Files
nextcraft/.ciagent/PLAN.md
T
CIAgent 044e4b8cc8 docs(P00): create phase plans — v0.3 credential engines
Vertical-slice plan: 6 execution phases, 20 waves, 39 tasks.
P1 sandbox fabric (unshare namespaces, D-024/032) · P2 telemetry (WS+SQLite, D-026/027)
P3 trace grading (digest+LLM, D-028) · P4 variants (seeded templates, D-029)
P5 voice defense (VoiceProvider, Examiner, D-030) · P6 re-grounding + learner integration.
MVP/UX sections included (User-Facing Surface, Happy Path, UX Acceptance Criteria).
Must-haves per phase; KYC deferred (A-110); mock-first voice (no blocking key).

---ci---
phase: 0
milestone: v0.3
status: plan
---/ci---
2026-09-12 00:47:45 +00:00

53 KiB

Nextcraft v0.3 — PLAN.md

Overview

This plan covers execution phases 1-6 of milestone v0.3 (Credential Engines): the real engines that replace v0.2's mock inputs — a namespace-isolated sandbox fabric, live build telemetry over WebSocket + SQLite, a process-trace grading engine, seeded per-learner variant task generation, and an oral/voice defense with a seventh Examiner agent — plus re-grounding the Lab/Assessor/Proctor agents onto real engine inputs and wiring the v0.1 learner surfaces to the real build/defense/grading paths. Phases are strictly sequential (P1→P6); within each phase, Wave 1 tasks are parallelizable (no cross-file dependencies) and later waves depend on earlier ones.

Environment facts (apply throughout): Python 3.11.2 via python3 -m venv (no uv, no system pip); pnpm 12.3.4 via corepack; turborepo; ai-service port 8420; default model gemma4:31b (config via AI_TUTOR_MODEL); ollama-cloud base https://ollama.com/v1 (OpenAI-compatible, Bearer auth); keys live only in gitignored .ciagent/.env.secrets (exported by scripts/dev.sh) — never in code, commits, or logs; all automated tests use the deterministic mock LLM and mock voice providers and never call cloud or voice APIs. New ai-service deps this milestone: sqlmodel, sqlalchemy, websockets, aiofiles (all PyPI-verified; added in Task 1-1-02). New web deps: @xterm/xterm, @xterm/addon-fit (Task 6-1-01). SQLite data (apps/ai-service/ai_service/data/) and sandbox dirs (apps/ai-service/sandboxes/) are already gitignored. KYC/age-gating is deferred per founder directive (A-110) — no identity work appears anywhere in this plan; age-gating stays the v0.1 visual flow mockup.

Phase Name Requirements Waves Personas
1 Sandbox fabric REQ-3-001, 002 3 sandbox-engineer, backend-engineer, ai-engineer (W1 lint only)
2 Live build telemetry REQ-3-003 4 sandbox-engineer, backend-engineer, data-engineer
3 Process-trace grading engine REQ-3-004 3 ai-engineer, backend-engineer
4 Variant task generation REQ-3-005 3 ai-engineer, backend-engineer, data-engineer
5 Oral / voice defense REQ-3-006 4 voice-engineer, ai-engineer, backend-engineer
6 Agent re-grounding + learner surface integration REQ-3-007, 008 5 ai-engineer, frontend-engineer, design-system-engineer, data-engineer, backend-engineer, lead-developer

Phase 1: Sandbox Fabric

Requirements: REQ-3-001, REQ-3-002 Goal: SandboxBackend protocol + unshare-based namespace spawner (D-024) + lifecycle manager with concurrency guard (D-032) + per-sandbox workdir; isolation and resources probe-verified on this box; /v1/sandboxes API live; deps + gitignore landed

Wave 1: Foundations (parallel — no shared files)

Task 1-1-01: SandboxBackend protocol + unshare spawner + probe test

  • Persona: sandbox-engineer — REQ: REQ-3-001, REQ-3-002
  • Files: apps/ai-service/ai_service/sandbox/__init__.py, apps/ai-service/ai_service/sandbox/backend.py, apps/ai-service/ai_service/sandbox/workdir.py, apps/ai-service/ai_service/sandbox/unshare_backend.py, apps/ai-service/tests/sandbox/__init__.py, apps/ai-service/tests/sandbox/test_isolation.py
  • Action: backend.py: SandboxBackend protocol + SandboxSpec (sandbox_id, learner_id, workdir, resource limits) + SandboxHandle (id, pid, workdir, created_at); spawn(spec), exec(handle, cmd), snapshot(handle) -> Path, destroy(handle). workdir.py: per-sandbox layout under apps/ai-service/sandboxes/<id>/ (workspace/ writable, snapshot() = recursive copy to snapshots/<ts>/) — no symlinks as the snapshot mechanism. unshare_backend.py: subprocess spawner — unshare --user --map-root-user --mount --pid --fork --net with the per-sandbox dir bind-mounted (--bind <dir> /work) and chdir /work (D-024); pipes for stdout/stderr; async wrappers. test_isolation.pyre-verify box isolation properties (runs on this box, guarded by probe skip): (a) id -u inside namespace prints 0; (b) ip link inside namespace shows 0 usable interfaces (loopback-only/no carrier) — network isolated; (c) file written to /work/inside.txt lands at sandboxes/<id>/workspace/inside.txt on the host; (d) attempt to write outside the mount (e.g. host tmp path via bind) does not escape the per-sandbox dir; (e) /proc visibility degraded (proc-remount not permitted per A-101 — assert the probe documents this, not that it fails).
  • Verify: pnpm ai:testtests/sandbox/test_isolation.py green on this box (probe-gated: skips with an explicit reason if userns unavailable); lint clean

Task 1-1-02: v0.3 dependencies + gitignore + config additions

  • Persona: backend-engineer — REQ: REQ-3-001
  • Files: apps/ai-service/pyproject.toml (update), apps/ai-service/ai_service/config.py (update), apps/ai-service/.env.example (update), apps/ai-service/scripts/bootstrap.sh (update if needed), root package.json (no change), turbo.json (no change)
  • Action: Add pinned deps: sqlmodel, sqlalchemy, websockets, aiofiles to pyproject. config.py additions (env_prefix AI_): AI_SANDBOX_DIR (default apps/ai-service/sandboxes), AI_SANDBOX_MAX_CONCURRENT (default 5, D-032), AI_SANDBOX_CPU_LIMIT (default 1 core / cpu.max), AI_SANDBOX_MEM_LIMIT_MB (default 512), AI_SANDBOX_PIDS_LIMIT (default 256), AI_SANDBOX_TIMEOUT_S (default 1800), AI_DB_PATH (default ai_service/data/nextcraft.db), AI_VOICE_BASE_URL / AI_VOICE_API_KEY / AI_VOICE_STT_MODEL / AI_VOICE_TTS_MODEL (all optional, default empty — mock-first, D-030; documented in .env.example and README). Confirm .gitignore already covers ai_service/data/ + sandboxes/ (it does — v0.3 block present). Re-run bootstrap idempotently to install new deps.
  • Verify: pnpm ai:bootstrap re-installs cleanly (no-op venv, new wheels land); python -c "import sqlmodel, sqlalchemy, websockets, aiofiles" succeeds in the venv; settings parse with new keys unset

Task 1-1-03: Resource-limit probe documentation + harness ruff pass

  • Persona: ai-engineer — REQ: REQ-3-001
  • Files: apps/ai-service/README.md (update: sandbox section + probe transcript), apps/ai-service/pyproject.toml (no change), apps/ai-service/tests/sandbox/test_isolation.py (no change)
  • Action: Record the A-101 probe transcript in README (verbatim commands + observed output from this box: unshare --user --map-root-user --mount --pid --fork --net id -u0; ip link → loopback only; write containment). Document the v0.3 resource-limit mechanism choice: cgroup-v2 delegation via per-sandbox scope files is not available on this box without sudo → enforcement = subprocess-level (ulimit-equivalent via preexec_fn: RLIMIT_AS for memory, RLIMIT_CPU for CPU-seconds, RLIMIT_NPROC for pids) + hard wall-clock timeout kill in the manager. This is the locked v0.3 mechanism (D-024 + no-sudo constraint). Run ruff over the new tree; fix all findings.
  • Verify: pnpm ai:lint exits 0; README shows the probe transcript and the rlimit mechanism note

Wave 2: Lifecycle manager (depends on Wave 1)

Task 1-2-01: Sandbox manager + concurrency guard + snapshots

  • Persona: sandbox-engineer — REQ: REQ-3-001, REQ-3-002
  • Files: apps/ai-service/ai_service/sandbox/manager.py, apps/ai-service/tests/sandbox/test_manager.py
  • Action: SandboxManager: create(learner_id) -> SandboxHandle (guard: active count ≥ AI_SANDBOX_MAX_CONCURRENT → raise PoolFullError → API maps to 503, D-032; no queue); list() -> list[SandboxHandle]; get(id); snapshot(id) -> Path (delegates to workdir); destroy(id) (kill process tree, keep or purge workdir per flag); reap_expired() background hook for AI_SANDBOX_TIMEOUT_S. Enforce rlimits per spawner (Task 1-1-03) at exec time. Handle registry persisted in-memory (v0.3, single process; not a store — see D-019 precedent) with a clear note that handles are process-local. Narrow typed interface only — manager never imports api/ (boundary rule).
  • Verify: pnpm ai:test — test_manager covers create/list/destroy/snapshot, 6th create raises PoolFullError (503 path), destroy kills the namespace process (pid gone), snapshot dir exists with workspace contents, timeout reaper removes a stale handle

Task 1-2-02: Resource-limit enforcement test

  • Persona: sandbox-engineer — REQ: REQ-3-002
  • Files: apps/ai-service/tests/sandbox/test_resource_limits.py
  • Action: Concrete enforcement probes (guarded like isolation tests): (a) spawn a process that allocates > RLIMIT_AS → assert it dies with MemoryError/killed within a bound; (b) spawn a CPU spinner past RLIMIT_CPU → assert SIGXCPU/kill; (c) fork-bomb attempt beyond RLIMIT_NPROC → assert fork failures; (d) wall-clock: spawn sleep 9999 with a small manager timeout → reaper destroys it. Assert limits are observable (handle reports its limit set).
  • Verify: pnpm ai:test — test_resource_limits green; limits proven enforced and observable

Wave 3: API exposure (depends on Wave 2)

Task 1-3-01: Sandboxes API module

  • Persona: backend-engineer — REQ: REQ-3-001, REQ-3-002
  • Files: apps/ai-service/ai_service/api/sandboxes.py, apps/ai-service/ai_service/main.py (update: include router + lifespan manager), apps/ai-service/ai_service/api/deps.py (update), apps/ai-service/tests/api/test_sandboxes.py
  • Action: DI exposes a singleton SandboxManager. Endpoints: POST /v1/sandboxes {learner_id} → 201 handle (503 when pool full); GET /v1/sandboxes → list; GET /v1/sandboxes/{id} → handle; POST /v1/sandboxes/{id}/snapshot → snapshot path; DELETE /v1/sandboxes/{id} → 204. All behind localhost CORS (A-008). Lifespan creates/destroys the manager; on shutdown destroys any live sandboxes (no orphans).
  • Verify: pnpm ai:test — test_sandboxes green (create→list→snapshot→delete roundtrip via TestClient; 6th create → 503; delete of unknown id → 404); manual probe: curl -X POST localhost:8420/v1/sandboxes -d '{"learner_id":"l1"}' returns a handle id

Must-Haves (Phase 1)

  • Isolation probe test green on this box: in-namespace uid=0, network isolated (0 usable interfaces), host writes confined to the per-sandbox bind dir (A-101 re-verified as an automated test, not just research notes)
  • Resource limits enforced + observable: memory/CPU/pids rlimits kill violating processes; wall-clock reaper destroys stale sandboxes (test_resource_limits green)
  • No cross-tenant access: sandbox A cannot read sandbox B's workdir (isolation test asserts containment)
  • Lifecycle API works end-to-end: create/list/snapshot/destroy via TestClient; pool full → 503 (D-032, no queue)
  • Snapshot produces a restorable directory copy under the sandbox's own snapshots/ dir
  • pnpm ai:test and pnpm ai:lint green; new deps (sqlmodel, sqlalchemy, websockets, aiofiles) installed via idempotent bootstrap
  • Boundary rules hold: sandbox/ imports nothing from api/ or agents/; only api/sandboxes.py composes the manager via DI
  • No docker/podman/sudo anywhere in the spawner path (D-024); SandboxBackend protocol is the only coupling to the spawner (containerd swap possible later)

Phase 2: Live Build Telemetry

Requirements: REQ-3-003 Goal: First real persistence (D-027: SQLite via SQLModel) with a TraceStore protocol; TelemetryEvent model with per-(learner,task) monotonic seq; WebSocket ingest endpoint (D-026) with gap detection; stdlib-only in-sandbox capture agent streams real sandbox activity into ai-service; trace retrievable by learner+task

Wave 1: Models + stores + TS types (parallel — no shared files)

Task 2-1-01: Telemetry event models

  • Persona: backend-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/ai_service/telemetry/__init__.py, apps/ai-service/ai_service/telemetry/models.py, apps/ai-service/tests/telemetry/__init__.py, apps/ai-service/tests/telemetry/test_models.py
  • Action: Pydantic/SQLModel TelemetryEvent: learner_id, task_id, seq (int, monotonic per (learner,task)), kind (command | file_diff | run_result | test_result | activity | stdin | stdout), payload (JSON), ts (datetime, monotonic-envelope), sandbox_id. TraceSpan derived view (ordered events for one (learner,task)). Validation: seq ≥ 0, kind enum, non-empty ids.
  • Verify: pnpm ai:test — test_models green (validation rules enforced, JSON payload roundtrip)

Task 2-1-02: TraceStore protocol + SQLite implementation

  • Persona: backend-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/ai_service/telemetry/store.py, apps/ai-service/tests/telemetry/test_store.py, apps/ai-service/ai_service/data/.gitkeep
  • Action: TraceStore protocol (D-027, Postgres-migration-ready): append(event) -> None (idempotent on (learner,task,seq) — at-least-once dedup), get_trace(learner_id, task_id) -> list[TelemetryEvent] (ordered by seq), gaps(learner_id, task_id) -> list[int] (missing seqs), latest_seq(learner_id, task_id) -> int, list_tasks(learner_id) -> list[str], close(). SQLiteTraceStore(SQLModel): single telemetry_event table, composite PK ((learner_id, task_id, seq)), indexes on (learner_id, task_id). Engine creation from AI_DB_PATH; SQLModel.metadata.create_all at app lifespan.
  • Verify: pnpm ai:test — test_store green (append/ordered-get/dedup-on-retry/gap detection/latest_seq; tmp-path SQLite per test)

Task 2-1-03: TS types for telemetry/traces

  • Persona: data-engineer — REQ: REQ-3-003
  • Files: packages/types/telemetry.ts (new), packages/types/index.ts (update)
  • Action: TS TelemetryEvent, TraceSpan, TelemetryKind mirroring the Python model field-for-field (cross-referencing header, same string enums). Consumed by Phase 6 web surfaces; no runtime code.
  • Verify: pnpm typecheck passes; TS type keys match Python model keys exactly

Wave 2: Capture agent + ingest (depends on Wave 1)

Task 2-2-01: In-sandbox capture agent (stdlib-only)

  • Persona: sandbox-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/scripts/sandbox-agent.py, apps/ai-service/tests/sandbox/test_sandbox_agent.py
  • Action: Tiny standalone process (D-031, stdlib only — no deps shipped into the namespace): wraps a shell inside the sandbox; captures commands, file diffs (mtime/content polling of workspace/ at 250ms), run/test results, activity; assigns per-(learner,task) seq; buffers to a local spool file on disconnect (at-least-once, D-026); reconnects with exponential backoff and flushes spool in order; small WebSocket client implemented over raw socket (RFC6455 client handshake + frames — stdlib only, no websockets in-namespace). Configured via env baked at spawn (NC_LEARNER_ID, NC_TASK_ID, NC_INGEST_URL).
  • Verify: pnpm ai:test — unit tests with a loopback fake WS server: ordered seq emission, spool-on-disconnect, reconnect flush preserves order (no loss, dupes deduped server-side), no third-party imports in the file (asserted by AST scan)

Task 2-2-02: WebSocket ingest endpoint

  • Persona: backend-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/ai_service/telemetry/ingest.py, apps/ai-service/ai_service/api/sandboxes.py (update: register WS route), apps/ai-service/ai_service/main.py (update), apps/ai-service/tests/api/test_telemetry_ingest.py
  • Action: WS /v1/telemetry/ingest (D-026): accepts connections carrying learner_id/task_id/sandbox_id; validates + appends events to TraceStore (idempotent — server-side dedup on (learner,task,seq)); emits gap warnings when seq skips (logged + surfaced in a per-connection status); backpressure = bounded inbound queue with drop-oldest only on unbounded growth (documented); ping/pong keepalive. GET /v1/telemetry/traces/{learner_id}/{task_id} returns the ordered trace; GET /v1/telemetry/gaps/{learner_id}/{task_id} returns missing seqs.
  • Verify: pnpm ai:test — test_telemetry_ingest green (TestClient websocket: connect → send 3 events → trace retrievable ordered; resend event 2 → deduped; skip seq 5 → gap reported; unknown sandbox tolerated in v0.3 no-auth mode)

Wave 3: Sandbox telemetry wiring (depends on Wave 2)

Task 2-3-01: Spawn sandboxes with the capture agent

  • Persona: sandbox-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/ai_service/sandbox/manager.py (update), apps/ai-service/ai_service/sandbox/unshare_backend.py (update), apps/ai-service/tests/sandbox/test_telemetry_wiring.py
  • Action: create() gains optional task_id; when set the spawner copies scripts/sandbox-agent.py into the sandbox workdir, injects NC_* env, and launches the agent as a child of the namespace process (agent lifecycle tied to sandbox lifecycle; destroy kills the agent). No capture when task_id absent (pure shell sandbox).
  • Verify: pnpm ai:test — end-to-end on this box: create sandbox with task_id → run 2 commands via exec → events arrive at the ingest endpoint and land in SQLite in order

Wave 4: Reliability probe (depends on Wave 3)

Task 2-4-01: Dropped-connection durability probe

  • Persona: backend-engineer — REQ: REQ-3-003
  • Files: apps/ai-service/tests/telemetry/test_durability.py
  • Action: Integration probe: run a capture agent against ingest, kill the WS connection mid-stream (simulate network failure), keep generating events, reconnect, assert the SQLite trace contains every event exactly once in order (spool + dedup). Document at-least-once semantics + replay path in README.
  • Verify: pnpm ai:test — test_durability green; README documents semantics

Must-Haves (Phase 2)

  • Real telemetry from a live sandbox arrives at ai-service: shell commands, file diffs, run/test results appear as ordered events in SQLite (end-to-end, no mocks)
  • Per-(learner,task) monotonic seq; gap detection reports missing seqs; replay yields the complete ordered trace
  • At-least-once proven: transient disconnect + reconnect loses no events; duplicates deduped server-side (durability probe green)
  • Capture agent is stdlib-only (AST-verified) and its lifecycle is tied to the sandbox (destroy kills it)
  • Trace retrievable by learner+task via GET /v1/telemetry/traces/...; unknown trace → 404
  • TraceStore protocol respected: no api/ code touches SQLite directly; telemetry/ never imports agents/ (D-027)
  • pnpm ai:test green; packages/types telemetry TS types compile (pnpm typecheck)

Phase 3: Process-Trace Grading Engine

Requirements: REQ-3-004 Goal: Deterministic feature computation over traces (D-028) → compact digest → LLM rubric scoring via existing D-020 JSON defense → validated structured scores stored in GradeStore; LLM never sees the raw trace; engine calibrated against v0.2 mock corpora so process quality separates paste-and-run from iterative debugging

Wave 1: Features + grades store (parallel — no shared files)

Task 3-1-01: Deterministic trace digest (features)

  • Persona: ai-engineer — REQ: REQ-3-004
  • Files: apps/ai-service/ai_service/grading/__init__.py, apps/ai-service/ai_service/grading/features.py, apps/ai-service/tests/grading/__init__.py, apps/ai-service/tests/grading/test_features.py
  • Action: Pure compute module (D-028): compute_digest(trace: list[TelemetryEvent]) -> TraceDigest. Deterministic features: test pass/fail counts + final status; edit count; error/fix cycle count + mean fix latency; idle gaps (>Ns, count + total); command category histogram (build/test/file/nav/debug/other); session duration; first-test-pass offset. TraceDigest pydantic model — compact (bounded size, no raw commands), LLM-safe.
  • Verify: pnpm ai:test — test_features green over synthetic traces: paste-and-run trace (0 error/fix cycles, single test pass at end) vs iterative trace (many cycles) produce observably different digests

Task 3-1-02: GradeStore protocol + SQLite implementation

  • Persona: backend-engineer — REQ: REQ-3-004
  • Files: apps/ai-service/ai_service/grading/store.py, apps/ai-service/tests/grading/test_store.py
  • Action: GradeStore protocol (D-027): save(grade), get(learner_id, task_id), list_for_learner(learner_id), close(). SQLModel GradeRecord: learner_id, task_id, variant_seed (null until P4), digest (JSON), scores (JSON), verdict, model, created_at. PK (learner_id, task_id). Postgres-migration-ready.
  • Verify: pnpm ai:test — test_store green (save/get/list roundtrip, overwrite-on-regrade documented)

Wave 2: Grading engine + calibration (depends on Wave 1)

Task 3-2-01: Rubric scoring engine

  • Persona: ai-engineer — REQ: REQ-3-004
  • Files: apps/ai-service/ai_service/grading/engine.py, apps/ai-service/ai_service/prompts/grading.py (new), apps/ai-service/tests/grading/test_engine.py
  • Action: GradingEngine.grade(learner_id, task_id) -> GradeRecord: load trace via TraceStorecompute_digest → render rubric prompt (prompts/grading.py: criteria + level anchors for process quality, correctness, debugging discipline, test usage) → LLM structured output through the existing D-020 4-layer defense (agents/structured.py reused — engine composes it, never duplicates it) → validate RubricScore model (per-criterion 0-4 + strengths + gaps + verdict) → persist via GradeStore. Grading depends on llm/ + telemetry/ + prompts/ only (boundary). Mock provider scripts deterministic rubric JSON for tests.
  • Verify: pnpm ai:test — test_engine green (mock provider: digest-only prompt asserted — raw trace string absent from prompt; validated scores returned; malformed JSON exercises D-020 retry; unknown trace → error)

Task 3-2-02: Calibration against v0.2 mock corpora

  • Persona: ai-engineer — REQ: REQ-3-004
  • Files: apps/ai-service/ai_service/corpus/trace_fixtures.py (new), apps/ai-service/tests/grading/test_calibration.py
  • Action: Synthetic trace fixtures aligned with v0.2 corpus/telemetry.py + corpus/artifacts.py scenario IDs (strong/lazy/struggling builder archetypes). Assert grading separates them: strong archetype scores ≥ lazy archetype on process-quality criterion (mock provider maps digest shape → scripted scores; test asserts the ordering contract + that fixture IDs align with existing corpus IDs, D-021).
  • Verify: pnpm ai:test — test_calibration enforces the ordering contract

Wave 3: Grading endpoint (depends on Wave 2)

Task 3-3-01: Assessment grade endpoint (real traces)

  • Persona: backend-engineer — REQ: REQ-3-004
  • Files: apps/ai-service/ai_service/api/assessment.py (update), apps/ai-service/tests/api/test_grading.py
  • Action: POST /v1/assessment/grade {learner_id, task_id}GradingEngine → validated RubricScore JSON; GET /v1/assessment/grade/{learner_id}/{task_id} → stored grade; unknown trace → 404. Composed via DI (api/ owns wiring; engine knows nothing of FastAPI).
  • Verify: pnpm ai:test — test_grading green (grade roundtrip via TestClient with mock provider; 404 on unknown; GET after POST returns same scores)

Must-Haves (Phase 3)

  • Engine emits structured rubric-aligned scores from a real process trace (not pre-baked input) — TestClient roundtrip green
  • Deterministic features computed in code (test pass/fail, edit count, error/fix cycles, idle gaps, command categories); LLM receives the digest only — test asserts the raw trace never reaches the prompt (D-028)
  • Scores distinguish process quality: iterative-debugging archetype out-scores paste-and-run on the process criterion (calibration contract test)
  • Grades persisted + retrievable by learner+task via GradeStore (SQLite, protocol-wrapped, D-027)
  • Boundary rules hold: grading/ imports no api//agents/ internals except the shared D-020 structured defense; pnpm ai:test + pnpm ai:lint green

Phase 4: Variant Task Generation

Requirements: REQ-3-005 Goal: Template library with typed parameter slots (D-029) + seeded LLM instantiation + per-learner variant registry (SQLite) with difficulty-normalization anchors; two learners on the same competency get provably distinct, reproducible, auditable tasks

Wave 1: Templates + store (parallel — no shared files)

Task 4-1-01: Task template library

  • Persona: ai-engineer — REQ: REQ-3-005
  • Files: apps/ai-service/ai_service/variants/__init__.py, apps/ai-service/ai_service/variants/templates.py, apps/ai-service/tests/variants/__init__.py, apps/ai-service/tests/variants/test_templates.py
  • Action: ≥3 initial task templates bound to existing competency IDs (D-021 alignment). TaskTemplate: id, competency_id, statement skeleton with {slot} placeholders, ParameterSlot[] (name, type: enum/int-range/string-set, allowed values), rubric anchors (difficulty normalization: expected feature envelope — e.g. expected edit-count band — used by grading context), starter-file scaffolds served to the sandbox. Seeded slot sampler is pure code (random.Random(seed)), fully reproducible.
  • Verify: pnpm ai:test — test_templates green (slot validation: bad value rejected; seeded sampling reproducible across runs; all templates bind to real competency IDs)

Task 4-1-02: VariantStore protocol + SQLite implementation

  • Persona: backend-engineer — REQ: REQ-3-005
  • Files: apps/ai-service/ai_service/variants/store.py, apps/ai-service/tests/variants/test_store.py
  • Action: VariantStore protocol (D-027): save(variant), get(learner_id, template_id_or_task_id), list_for_learner(learner_id), list_by_template(template_id), close(). SQLModel VariantRecord: learner_id, task_id (the grading/telemetry task key), template_id, seed, params (JSON), statement (rendered), created_at. Unique (learner_id, template_id).
  • Verify: pnpm ai:test — test_store green (roundtrip, unique constraint, audit listing)

Wave 2: Generator (depends on Wave 1)

Task 4-2-01: Seeded LLM variant generator

  • Persona: ai-engineer — REQ: REQ-3-005
  • Files: apps/ai-service/ai_service/variants/generator.py, apps/ai-service/ai_service/prompts/variant.py (new), apps/ai-service/tests/variants/test_generator.py
  • Action: VariantGenerator.generate(learner_id, template_id) -> VariantRecord: derive seed (sha256(template_id|learner_id|milestone) — reproducible, D-029); sample typed slots in code; render a fill prompt (statement skeleton + concrete slot values) → LLM via D-020 structured defense → unique task statement + starter files → validate → persist (seed + params + statement) via VariantStore. Cache: existing (learner,template) returns the stored variant (no duplicate work). Mock provider scripts deterministic statements per seed for tests.
  • Verify: pnpm ai:test — test_generator green: two different learner_ids → distinct statements for the same template; same learner twice → identical stored variant (reproducible); params JSON contains only schema-valid slot values

Wave 3: Variant endpoint + TS types (depends on Wave 2)

Task 4-3-01: Variant task endpoint

  • Persona: backend-engineer — REQ: REQ-3-005
  • Files: apps/ai-service/ai_service/api/variants.py (new), apps/ai-service/ai_service/main.py (update), apps/ai-service/tests/api/test_variants.py
  • Action: POST /v1/variants {learner_id, template_id or competency_id} → generated (or cached) variant: statement, starter files, task_id, seed; GET /v1/variants/{task_id} → stored variant; GET /v1/variants?learner_id= → learner's variants. DI wiring in api/ only.
  • Verify: pnpm ai:test — test_variants green (generate→get roundtrip; cache hit on regenerate; distinct learners → distinct statements asserted at the API layer)

Task 4-3-02: TS types for variants (+ grades)

  • Persona: data-engineer — REQ: REQ-3-005
  • Files: packages/types/variants.ts (new), packages/types/grading.ts (new), packages/types/index.ts (update)
  • Action: TS TaskVariant, VariantParams, RubricScore, GradeRecord matching Python models (cross-referencing headers; same field names). Consumed by Phase 6 surfaces.
  • Verify: pnpm typecheck passes

Must-Haves (Phase 4)

  • Two learners requesting the same competency receive provably distinct task variants (API-level test)
  • Seed derivation reproducible: same (template, learner) → same variant, served from cache without a second LLM call (D-029)
  • Variant seed + typed params persisted + auditable (VariantStore listing; proctoring cross-check path exists)
  • Difficulty normalization anchors present per template and shipped to the grader prompt context
  • Starter-file scaffolds defined per template (P6 wires them into the sandbox workdir)
  • pnpm ai:test + pnpm typecheck green; variants/ imports no api/ (boundary)

Phase 5: Oral / Voice Defense

Requirements: REQ-3-006 Goal: VoiceProvider protocol with mock-first STT/TTS (D-030) + seventh Examiner agent streaming over existing SSE + HTTP audio endpoints + DefenseStore persisting transcript + integrity signals — nothing blocks on a real voice key (mock + browser fallback are first-class)

Wave 1: Voice provider layer (parallel — no shared files)

Task 5-1-01: VoiceProvider protocol + mock provider

  • Persona: voice-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/ai_service/voice/__init__.py, apps/ai-service/ai_service/voice/base.py, apps/ai-service/ai_service/voice/mock.py, apps/ai-service/tests/voice/__init__.py, apps/ai-service/tests/voice/test_mock.py
  • Action: VoiceProvider protocol mirroring LLMProvider (D-030): transcribe(audio: bytes, fmt) -> TranscriptSegment + synthesize(text, voice) -> AsyncIterator[bytes] (audio chunks). MockVoiceProvider: deterministic canned transcript (scripted per test), canned 1kHz-tone WAV bytes; scripted failure modes (provider down, empty audio). voice/ never imports agents/ or api/.
  • Verify: pnpm ai:test — test_mock green (deterministic: two identical transcribe calls → identical segments; synthesize yields non-empty bytes; failure modes trigger)

Task 5-1-02: OpenAI-compatible audio provider + browser fallback descriptor + factory

  • Persona: voice-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/ai_service/voice/openai_audio.py, apps/ai-service/ai_service/voice/browser.py, apps/ai-service/ai_service/voice/factory.py, apps/ai-service/tests/voice/test_openai_audio.py, apps/ai-service/tests/voice/test_factory.py
  • Action: OpenAIAudioProvider: STT = POST multipart to {AI_VOICE_BASE_URL}/audio/transcriptions (Whisper-compatible, Bearer AI_VOICE_API_KEY); TTS = POST to /audio/speech streaming mp3/pcm chunks; reuses lifespan httpx client. browser.py: fallback descriptor only (sr_available: true, endpoint hints) consumed by the web client to select browser-native SpeechRecognition/speechSynthesis when no server key configured. factory.py: AI_VOICE_PROVIDER=openai-audio | browser | mock (default: mock when no key — mock-first, nothing blocks on a real voice key).
  • Verify: pnpm ai:test — httpx MockTransport tests: transcription multipart parse, TTS byte stream, 4xx/5xx error mapping, factory selects mock with empty key; zero network calls

Task 5-1-03: DefenseStore protocol + SQLite implementation

  • Persona: backend-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/ai_service/voice/defense_store.py, apps/ai-service/tests/voice/test_defense_store.py
  • Action: DefenseStore protocol (D-027): start(defense), append_turn(defense_id, turn), finalize(defense_id, integrity_signals), get(defense_id), list_for_learner(learner_id), close(). SQLModel DefenseRecord (id, learner_id, task_id, status, created/finished_at) + DefenseTurn (defense_id FK, turn seq, role examiner|learner, text, ts, latency_ms) + integrity signals JSON on the record (long pauses, off-scope cadence markers — A-109).
  • Verify: pnpm ai:test — test_defense_store green (start→append turns→finalize→get roundtrip; ordered turns by seq)

Wave 2: Examiner agent (depends on Wave 1)

Task 5-2-01: Examiner agent (seventh agent)

  • Persona: ai-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/ai_service/agents/examiner.py, apps/ai-service/ai_service/prompts/examiner.py (new), apps/ai-service/ai_service/agents/registry.py (update: central registration, G-4 pattern), apps/ai-service/tests/agents/test_examiner.py
  • Action: ExaminerAgent(BaseAgent) (D-030/A-109): builds questions from learner transcript + trace digest + (P4) variant statement; probes understanding + challenges process choices ("why did you choose X at step N?"); streams questions over the existing SSE pipeline; structured verdict mode returns verdict + per-answer integrity signal list (long pause flags, off-scope answers) computed from turn metadata; calls voice only through the VoiceProvider protocol (PERSONAS conflict rule — never concrete providers). Session-scoped history reused from v0.2.
  • Verify: pnpm ai:test — test_examiner green (question stream references trace-digest facts; verdict structured output validates via D-020 defense; registry resolves all seven agents; mock-VoiceProvider wiring through protocol only — asserted by import scan in test)

Wave 3: Defense endpoints (depends on Wave 2)

Task 5-3-01: Defense session + audio endpoints

  • Persona: backend-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/ai_service/api/defense.py (new), apps/ai-service/ai_service/main.py (update), apps/ai-service/tests/api/test_defense.py
  • Action: POST /v1/defense/start {learner_id, task_id} → creates DefenseRecord + streams the first examiner question (SSE, agent=examiner); POST /v1/defense/{id}/answer (multipart audio from browser MediaRecorder, or {text} for typed fallback) → STT via VoiceProvider → append learner turn → stream examiner follow-up (SSE) → TTS audio chunks over GET /v1/defense/{id}/audio/{turn_id}; POST /v1/defense/{id}/finish → verdict + integrity signals persisted; GET /v1/defense/{id} → full transcript + signals. Browser-fallback mode: when provider=browser, start returns the fallback descriptor instead of server audio.
  • Verify: pnpm ai:test — test_defense green (full loop with mock voice + mock LLM: start → answer(text) → answer(audio bytes) → finish → transcript retrievable with per-turn latency; unknown id → 404; GET signals present after finish)

Wave 4: Turn-latency probe (depends on Wave 3)

Task 5-4-01: Latency budget probe + README budget doc

  • Persona: voice-engineer — REQ: REQ-3-006
  • Files: apps/ai-service/tests/voice/test_latency.py, apps/ai-service/README.md (update: latency budget section + voice config docs)
  • Action: Instrument per-turn latency (STT ms + LLM TTFT ms + TTS ms) recorded on each DefenseTurn. Deterministic timing probe over mock providers: total turn budget < target (documented; mock runs are near-instant, so the test asserts instrumentation presence + budget field populated rather than wall-clock). README documents the conversational-budget target and how to run a manual live probe with a real voice endpoint (AI_VOICE_PROVIDER=openai-audio + key in .ciagent/.env.secrets — explicitly optional, never in tests/commits).
  • Verify: pnpm ai:test — test_latency green (latency_ms fields populated on every turn; budget constant defined); README documents the manual live probe as out-of-band

Must-Haves (Phase 5)

  • Spoken defense runs end-to-end over HTTP with mock providers: start → answer (audio + typed fallback) → examiner follow-up streams → verdict + transcript persisted (automated)
  • Examiner is the seventh registered agent; streams over the existing SSE envelope (meta agent=examiner)
  • Transcript + integrity signals persisted via DefenseStore and retrievable; per-turn latency instrumented (A-109)
  • VoiceProvider protocol respected: examiner + api touch voice only via the protocol; mock-first — no task requires a real voice key to pass
  • Browser-native SR/TTS fallback descriptor returned when no server key configured (mock/no-key path first-class, D-030)
  • Optional voice config documented: AI_VOICE_BASE_URL / AI_VOICE_API_KEY / models in .env.example + README; keys only in gitignored .ciagent/.env.secrets; tests never call a voice API
  • Boundary rules hold: voice/ imports no agents//api/; pnpm ai:test + pnpm ai:lint green
  • Manual live-voice turn-latency probe documented in README (out-of-band, optional)

Phase 6: Agent Re-grounding + Learner Surface Integration

Requirements: REQ-3-007, REQ-3-008 Goal: Lab/Assessor/Proctor consume real engine inputs (live telemetry, grading output, defense signals) with no mock fallback in the learner path; the v0.1 sandbox + assessment mockups become real — in-browser xterm.js build/run, live telemetry panel, live voice/typed defense, live grading; pnpm build + pnpm typecheck + full pnpm ai:test green Note: E2E verification runs against the real engines over HTTP with AI_PROVIDER=mock + AI_VOICE_PROVIDER=mock permitted (G-2 precedent) — the requirement is real engine plumbing (sandbox/telemetry/grading/defense over real endpoints, no corpus mocks in the learner path); a cloud outage must not block P6.

Wave 1: Agent re-grounding (parallel — no shared files)

Task 6-1-01: Lab agent on live telemetry

  • Persona: ai-engineer — REQ: REQ-3-007
  • Files: apps/ai-service/ai_service/agents/lab.py (update), apps/ai-service/ai_service/prompts/lab.py (update: render real trace digest), apps/ai-service/tests/agents/test_lab_live.py (new)
  • Action: Lab consumes a live trace digest (grading/features compute_digest over TraceStore events) instead of corpus/telemetry.py. build_messages renders digest facts (recent commands, failing tests, idle). Mock-provider scripts assert digest-derived content. v0.2 corpus path removed from the agent (dormant corpus retained until Task 6-1-04 check).
  • Verify: pnpm ai:test — test_lab_live green: feedback references events actually present in a seeded SQLite trace (not corpus fixtures); no corpus.telemetry import in agents/lab.py (AST-asserted)

Task 6-1-02: Assessor agent on grading output

  • Persona: ai-engineer — REQ: REQ-3-007
  • Files: apps/ai-service/ai_service/agents/assessor.py (update), apps/ai-service/ai_service/prompts/assessor.py (update), apps/ai-service/tests/agents/test_assessor_live.py (new)
  • Action: Assessor consumes GradeStore output (validated RubricScore + digest) for learner+task instead of pre-baked artifacts/transcripts; renders strengths/gaps/verdict with rubric-anchored coaching framing. Structured output unchanged (D-020).
  • Verify: pnpm ai:test — test_assessor_live green: given a real stored grade, Assessor output reflects its scores; corpus artifact path gone from the agent (AST-asserted)

Task 6-1-03: Proctor agent on telemetry + defense signals

  • Persona: ai-engineer — REQ: REQ-3-007
  • Files: apps/ai-service/ai_service/agents/proctor.py (update), apps/ai-service/ai_service/prompts/proctor.py (update), apps/ai-service/tests/agents/test_proctor_live.py (new)
  • Action: Proctor consumes real integrity inputs: idle gaps + command cadence from the trace digest + defense integrity signals from DefenseStore → classified signals + coaching interventions (supportive tone retained). Cross-checks variant seed params (P4) for off-template work.
  • Verify: pnpm ai:test — test_proctor_live green: signals derived from seeded real trace + defense records; corpus proctor scenarios no longer imported (AST-asserted)

Task 6-1-04: Corpus dormancy + mockup removal verification

  • Persona: lead-developer — REQ: REQ-3-007
  • Files: apps/ai-service/ai_service/corpus/telemetry.py, apps/ai-service/ai_service/corpus/artifacts.py, apps/ai-service/ai_service/corpus/ (README note), apps/ai-service/tests/test_corpus_dormancy.py (new)
  • Action: Verify no production code path imports corpus/telemetry.py or corpus/artifacts.py anymore (test scans imports across agents/, api/, engines). Retain files as Phase-3 calibration history with a header note marking them dormant — v0.2 mocks, not used at runtime; learner-context corpus stays (agents still need learner context). Disposes v0.2's G-5-class dead-code risk deliberately.
  • Verify: pnpm ai:test — test_corpus_dormancy green (zero runtime importers); suite otherwise unchanged

Wave 2: Client plumbing + design primitives (parallel — no shared files)

Task 6-2-01: Terminal + sandbox engine client

  • Persona: frontend-engineer — REQ: REQ-3-008
  • Files: apps/web/package.json (update: @xterm/xterm, @xterm/addon-fit), pnpm-lock.yaml (update), apps/web/hooks/use-sandbox-session.ts (new), apps/web/lib/engine-client.ts (new), apps/web/.env.example (update)
  • Action: engine-client.ts: typed fetch/WS client for /v1/sandboxes (create/destroy — learner_id from the v0.3 mock session constant), /v1/variants, /v1/assessment/grade, /v1/telemetry/traces, /v1/defense/*; base NEXT_PUBLIC_AI_SERVICE_URL. use-sandbox-session.ts: create sandbox+variant on task open → wire a WebSocket terminal channel (xterm attach: keystrokes → exec stdin in sandbox, stdout → terminal render; implemented as a WS relay endpoint consumed from api/sandboxes — reused ingest WS pattern) → destroy on unmount (idempotent cleanup, AbortController pattern). 503 pool-full → user-facing "environment busy, retry" state (D-032 surfaced honestly).
  • Verify: pnpm install && pnpm typecheck pass; hook unmount destroys the sandbox (manual probe: curl localhost:8420/v1/sandboxes shows count drop after navigation)

Task 6-2-02: New design primitives

  • Persona: design-system-engineer — REQ: REQ-3-008
  • Files: packages/ui/src/primitives/terminal-frame.tsx (new), packages/ui/src/primitives/mic-control.tsx (new), packages/ui/src/primitives/grade-badge.tsx (new), packages/ui/src/primitives/telemetry-status.tsx (new), packages/ui/src/primitives/transcript-viewer.tsx (new), packages/ui/src/primitives/index.ts (update), packages/ui/src/index.ts (update)
  • Action: Token-driven primitives: TerminalFrame (chrome around xterm with connection status), MicControl (record/stop with consent state + no-mic fallback state, MediaRecorder permission UX), GradeBadge (verdict rendering), TelemetryStatus (live event pulse / disconnected indicator), TranscriptViewer (examiner/learner turn list). Dark mode + WCAG AA; stories for each.
  • Verify: primitives import from @nextcraft/ui; Storybook stories render dark + light; pnpm build (ui package) passes

Task 6-2-03: Defense TS types

  • Persona: data-engineer — REQ: REQ-3-008
  • Files: packages/types/defense.ts (new), packages/types/index.ts (update)
  • Action: TS DefenseSession, DefenseTurn, IntegritySignal, Verdict mirroring P5 Python models (cross-referencing header).
  • Verify: pnpm typecheck passes

Wave 3: Real build surface (depends on Wave 2)

Task 6-3-01: Sandbox mockup → real in-browser IDE

  • Persona: frontend-engineer — REQ: REQ-3-008
  • Files: apps/web/app/(learner)/build/[competencyId]/page.tsx (rewrite), apps/web/components/learner/sandbox-terminal.tsx (new), apps/web/components/learner/file-tree.tsx (new), apps/web/components/learner/run-controls.tsx (new), apps/web/components/learner/lab-feedback-panel.tsx (update: live trace)
  • Action: Replace the mockup with the real build environment (A-103): file tree (HTTP CRUD into the sandbox workdir via /v1/sandboxes/{id}/files routes added to api/sandboxes — read/write/list workspace files; reused by run/test), syntax-highlight editor (existing), xterm.js terminal on use-sandbox-session, Run/Test buttons (exec in sandbox via relay; results stream to terminal), starter files from the P4 variant scaffold. Lab panel now posts learner_id+task_id → streams Lab feedback over the live trace (no scenario IDs). Telemetry sidebar shows live TelemetryStatus. Pool-full 503 → busy state with retry.
  • Verify: with ai-service up: open /build/comp-01 → variant statement + starter files load → type echo hello in terminal → real output renders; Run executes tests in-sandbox; Lab panel streams digest-derived feedback; telemetry status shows live events. Manual probe documented; pnpm typecheck green

Wave 4: Live defense + grading surfaces (depends on Waves 2-3)

Task 6-4-01: Assessment mockup → live defense + live grading

  • Persona: frontend-engineer — REQ: REQ-3-008
  • Files: apps/web/app/(learner)/defend/[competencyId]/page.tsx (rewrite), apps/web/components/learner/defense-session.tsx (new), apps/web/components/learner/assessor-results-panel.tsx (update), apps/web/components/learner/proctor-banner.tsx (update), apps/web/components/learner/oral-defense-interface.tsx (rewrite or remove)
  • Action: Real assessment flow: Start Defense → POST /v1/defense/start → examiner question streams → learner answers via MicControl (MediaRecorder webm/opus → multipart POST) with typed fallback when mic denied or provider=browser (native SpeechRecognition/speechSynthesis path per fallback descriptor) → follow-ups stream → Finish → verdict + integrity signals panel (TranscriptViewer, GradeBadge) + Grade My Work → POST /v1/assessment/grade → structured rubric bars render. Proctor banner reads signals for task_id. Loading + error states throughout; no mock defense data remains in the learner path.
  • Verify: with ai-service up: full defense loop runs in-browser (typed fallback acceptable in CI-less manual probe; mic path exercised manually with permission granted); grade panel renders real rubric scores; pnpm typecheck green

Wave 5: End-to-end verification (depends on Waves 3-4)

Task 6-5-01: Full learner-path E2E probe + green builds

  • Persona: backend-engineer — REQ: REQ-3-007, REQ-3-008
  • Files: apps/ai-service/tests/api/test_e2e_credential_flow.py (new), apps/ai-service/README.md (update: E2E probe doc)
  • Action: Endpoint-level E2E (mock LLM/voice providers, real engines): create variant → create sandbox with task_id → ingest trace events via real sandbox exec → POST grade → start defense (typed answers) → finish → assert: trace persisted, grade stored + digest-linked, defense transcript + signals stored, Proctor/Assessor endpoints serve them. No corpus fixture anywhere in the flow (AST-asserted). Run pnpm build + pnpm typecheck + full pnpm ai:test at repo root; fix all failures before phase ship.
  • Verify: pnpm ai:test green incl. test_e2e_credential_flow; pnpm build + pnpm typecheck green; README E2E probe section documents the manual browser pass

Must-Haves (Phase 6)

  • Lab/Assessor/Proctor operate on real inputs with no mock fallback in the learner path (AST-verified: no corpus telemetry/artifact/proctor imports in production paths)
  • Learner builds in-browser for real: xterm.js terminal executes in a namespace sandbox; file tree CRUD + Run/Test work; starter files come from the learner's variant scaffold
  • Live telemetry: build activity streams to ai-service and the sidebar shows live status (TelemetryStatus); trace persisted in SQLite
  • Live defense: start → answer (mic or typed fallback) → examiner follow-ups → finish → transcript + integrity signals + verdict rendered; browser-native path works with no server voice key
  • Live grading: grade request returns structured rubric scores computed from the real trace digest; grade panel renders them
  • 503 pool-full surfaced honestly in UI; navigating away destroys the sandbox (no leaked sandboxes — GET /v1/sandboxes manual probe)
  • Examiner remains protocol-clean (voice only via VoiceProvider); module boundary rules hold across all new code
  • pnpm build and pnpm typecheck pass; full pnpm ai:test green; no cloud/voice calls in any automated test
  • Release-note input (for P7): v0.3 ships real engines; identity/age-gating (KYC) remains deferred — age-gating is still a visual mockup (A-110); sandbox scope is coding-IDE only (design tool/simulation deferred to v0.4, D-025)

Phase 7: Final Review + Ship (no planned tasks)

Orchestrated by the SHIP stage, not this plan: multi-persona code review (correctness, testing, module boundaries, secrets hygiene — keys absent from code/logs/commits/errors, localhost-only CORS, no PII in prompts), project health audit (reconstruction, .ciagent/ discipline, branch/commit hygiene), then merge milestone → main, tag the final v0.2.x patch, create the Gitea release, mark all 8 v0.3 requirements complete.

Release-note honesty: the release note must state (a) Lab/Assessor/Proctor now run on real engine inputs (v0.2 mock-input caveat retired), (b) sandbox scope = coding IDE only — design tool + simulation environments deferred to v0.4 (D-025), (c) identity/age-gating (KYC) deferred per founder directive — age-gating remains the v0.1 visual flow mockup (A-110), (d) voice runs mock-first with browser-native fallback — a server STT/TTS key is optional and configured only via .ciagent/.env.secrets.

Secrets-hygiene checklist (P7): .ciagent/.env.secrets gitignored and never committed; AI_VOICE_API_KEY / AI_TUTOR_API_KEY referenced only via env; SQLite DBs + sandbox dirs gitignored; no keys in logs, error messages, or test fixtures.

Disposal checks (G-5 class): v0.2 dormant corpus files carry the dormant-header note (Task 6-1-04); any now-unused mock-data exports for the old sandbox/defense mockups (e.g. aiTutorResponses-class leftovers) must be removed or deprecated by review.


User-Facing Surface

The primary user-facing surface is the learner build + defend flow at http://localhost:3000, backed by the real engines in ai-service at http://localhost:8420:

  • /dashboard — AI tutor chat (Coach/Tutor, streaming) + Mentor panel (unchanged from v0.2)
  • /learn/[competencyId] — byte viewer with streaming Tutor explanations (unchanged)
  • /build/[competencyId]real in-browser IDE: xterm.js terminal into a namespace sandbox, file tree, Run/Test, live telemetry status, live Lab feedback, per-learner variant task statement
  • /defend/[competencyId]live oral defense + live grading: Examiner voice/typed dialogue, transcript + integrity signals, real rubric scores from the process trace

The marketplace, employer, and admin surfaces are unchanged from v0.1/v0.2.

Happy Path

  1. Learner opens /build/comp-01 → a per-learner variant statement and starter files load; a namespace sandbox is created for the session
  2. Learner edits files in the tree and types real shell commands in the xterm.js terminal → output renders live; the telemetry sidebar pulses as events stream to ai-service and persist in SQLite
  3. Run/Test executes in-sandbox; results stream into the terminal; the Lab panel streams feedback derived from the live trace digest (real commands, real failures)
  4. Learner opens /defend/comp-01Start Defense: the Examiner streams an opening question ("Walk me through your build — why did you structure it this way?")
  5. Learner answers by voice (mic consent → MediaRecorder → STT) or typed fallback → examiner follow-ups probe the trace ("You hit three test failures before passing — what changed?"); TTS plays examiner audio (or browser speech in fallback)
  6. Learner finishes the defense → transcript + integrity signals appear; verdict renders in a GradeBadge
  7. Learner clicks Grade My Work → the grading engine computes the digest from the real trace, rubric-scores it, and the panel renders per-criterion bars + strengths/gaps/verdict
  8. Proctor banner shows integrity signals from the live trace + defense in coaching tone; Mentor panel on /dashboard can narrate the real outcome
  9. Navigating away destroys the sandbox (pool slot freed); killing ai-service shows inline error + retry states on every panel, with no crashes

UX Acceptance Criteria

  1. Terminal output is visibly live (per keystroke/command round-trip), not a replay animation
  2. The learner path contains no mock engine data — scenarios, canned artifacts, and scripted defense transcripts from v0.2 are gone from runtime
  3. Variant statements visibly differ between two learner sessions on the same competency
  4. Mic permission flow is graceful: consent prompt, recording indicator, no-mic/typed fallback, and browser-native speech path when no server voice key is configured
  5. Defense transcript renders turn-by-turn with latency shown; integrity signals render in coaching (supportive) tone
  6. Grade results render as structured per-criterion bars with verdict, from the real trace — not from final-output-only heuristics
  7. Pool-full (503) shows an honest "environment busy — retry" state; navigation/unmount destroys sandboxes with no leaks
  8. When ai-service is unreachable: inline error + retry on every panel — no crashes, no console errors, no blank UI
  9. All new UI uses design tokens, supports dark mode, meets WCAG AA contrast, responsive at 375px / 768px / 1280px
  10. pnpm build and pnpm typecheck pass with zero errors; pnpm ai:test green cloud-free and voice-key-free