Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf3065fc5 | |||
| 296fe8a558 | |||
| 9d2b5b6fc9 | |||
| f52fa97327 |
@@ -6,6 +6,8 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
|
||||
**v0.3 additions (Credential Engines):** real credential engines replace v0.2 mock inputs — a sandbox fabric (isolated per-learner coding environments via Linux user/mount/pid/net namespaces), a live build-telemetry pipeline (WebSocket ingest + SQLite-ordered event log), a process-trace grading engine, seeded per-learner variant task generation, and a voice-based oral defense (STT/TTS via a new provider-agnostic voice layer). **First real persistence introduced: SQLite** (`ai_service/telemetry/`, grading, variant, defense stores). Lab/Assessor/Proctor agents are re-grounded onto real telemetry/traces. **Identity/age-gating (KYC) deferred per founder directive** — no security engineer persona; secrets-hygiene checklist only.
|
||||
|
||||
**v0.4 additions (Distribution & Bootstrap CLI, founder directive D-016):** a new `apps/cli` package — the `nextcraft` bootstrap CLI (`doctor`/`bootstrap`/`verify`/`dev`) compiled to a self-contained linux x64 binary via **Node SEA** (probe-verified: Go/Rust absent, node v24.15.0 SEA-capable), installed by a repo-served one-liner script that resolves the latest Gitea release, downloads binary + sha256 sidecar, verifies, and installs to `~/.local/bin`. Every release from v0.4 onward attaches the binary + checksum as release assets (the "ongoing binaries" requirement). The CLI is a thin wrapper: all orchestration logic stays in `apps/ai-service/scripts/` (bootstrap.sh/dev.sh) — the CLI composes them via subprocess (A-202), duplicating nothing. Previously-planned v0.4 seams (real STT/TTS, KYC, design/sim envs, seq-lease) move to v0.5.
|
||||
|
||||
### Confirmed Technology Stack (v0.2)
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
@@ -47,6 +49,14 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
7. **D-022 Monorepo integration** — zero-dependency shim `package.json` in apps/ai-service + `ai#*` turbo passthrough tasks (`cache:false, outputs:[]`) + root `ai:dev`/`ai:test` scripts + idempotent venv bootstrap.
|
||||
8. **D-023 Testing** — pytest-asyncio auto mode; TestClient `client.stream()` for SSE; httpx MockTransport for byte-exact provider parser tests; scripted mock provider incl. failure modes. Tests never call the cloud.
|
||||
|
||||
### v0.4 Architecture Decisions (from Research — Distribution & Bootstrap CLI)
|
||||
|
||||
18. **D-033 Binary toolchain = Node SEA (probe-verified)** — Go and Rust are absent from this box; node v24.15.0 ships SEA support (`--experimental-sea-config`, postject-free on linux via `cp node nextcraft && node sea-config` … blob injection with the system `dd`/`npx postject` if needed). CLI source lives in `apps/cli` (TypeScript, compiled to a single CJS bundle by esbuild, then SEA-injected into a copy of the node binary → `nextcraft-linux-x64`). Fallback if SEA breaks: python3 `zipapp` (3.11.2 available). No new toolchain deps beyond dev-scoped esbuild.
|
||||
19. **D-034 CLI = thin wrapper, orchestration stays in scripts/** — `nextcraft` composes `apps/ai-service/scripts/bootstrap.sh` and `scripts/dev.sh` equivalents via `spawn` with inherited stdio and timeout guards (A-202/A-209). doctor/bootstrap/verify implement only *checking* logic (prereqs, env template, health) — never re-implement installs. This keeps one source of truth for bootstrap semantics.
|
||||
20. **D-035 Install path = repo raw `install.sh` + Gitea latest-release API** — the one-liner `curl -fsSL <forge>/coreci/nextcraft/raw/main/scripts/install.sh | bash` resolves `GET /api/v1/repos/coreci/nextcraft/releases/latest`, downloads the `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` assets, verifies sha256 (`shasum -a 256`), installs to `~/.local/bin` (PATH hint), and degrades to printed source-bootstrap instructions when no binary asset exists or the platform mismatches (A-203/A-204/A-206).
|
||||
21. **D-036 Ongoing binaries = ship-workflow asset step** — the release pipeline (v0.3's `ShipWorkflow.createRelease` equivalent, executed as the ship step's asset stage) builds the binary + checksum and attaches both to every Gitea release from v0.4 onward (A-205). Token resolution stays `.env*`-only (D-006/D-014); binaries are linux x64 only for v0.4 (macOS arm64 deferred — unverifiable on this box).
|
||||
22. **D-037 CLI package layout** — `apps/cli` is a pnpm workspace package (`@nextcraft/cli`): `src/` (entry, commands/, checks/, lib/), `scripts/build-binary.mjs` (esbuild bundle → SEA inject), unit tests runnable via `pnpm --filter @nextcraft/cli test` (node:test, no new test framework). Root `package.json` gains `cli:*` passthrough scripts mirroring the `ai:*` pattern (D-022).
|
||||
|
||||
### v0.3 Architecture Decisions (from Research — Credential Engines)
|
||||
|
||||
9. **D-024 Sandbox isolation = Linux namespaces via `unshare`** — per-learner sandbox runs as a subprocess entered into fresh user+mount+pid+network namespaces (`unshare --user --map-root-user --mount --pid --fork --net`). Probe-verified on this box: in-namespace uid=0, **network fully isolated** (0 interfaces), learner writes land in a per-sandbox directory; proc-remount not permitted here but not required. Chosen because no container runtime (docker/podman/bwrap/firejail) exists on the box and there is no sudo. A `SandboxBackend` protocol abstracts the spawner so a future containerd/runc backend can replace namespace-spawning without touching callers.
|
||||
@@ -94,6 +104,20 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
|
||||
**Boundary additions:** `sandbox/`, `telemetry/`, `grading/`, `variants/`, `voice/` are engine modules — they never import `api/` (which composes them via DI) and never import `agents/` (agents call engines through narrow interfaces, not vice versa).
|
||||
|
||||
### apps/cli — Nextcraft Bootstrap CLI (v0.4 NEW)
|
||||
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
|-----------|-------------|------------|------------|
|
||||
| `src/index.ts` | Entry: arg parsing (no deps beyond node stdlib at runtime), command dispatch, `--help`/`--version`, exit-code contract (0 ok / 1 failure / 2 usage) | CLI surface only | commands/ |
|
||||
| `src/commands/` | `doctor.ts` (prereq checks + actionable errors), `bootstrap.ts` (pnpm install + scripts/bootstrap.sh wrapper + env template copy + key validation), `verify.ts` (health: venv imports, ports, env, build readiness), `dev.ts` (thin passthrough to scripts/dev.sh) | Compose checks/ + lib/; spawn scripts — never re-implement them | checks/, lib/ |
|
||||
| `src/checks/` | Pure check functions: `check-command.ts` (binary-on-PATH + version compare), `check-env.ts` (template diff, required/optional key classification) | Pure logic, unit-testable, no fs side effects at import | None |
|
||||
| `src/lib/` | `spawn.ts` (subprocess with timeout + inherited stdio), `log.ts` (✓/✗/warn output formatter) | Shared utilities | None |
|
||||
| `scripts/build-binary.mjs` | esbuild → CJS bundle → Node SEA injection → `dist/nextcraft-linux-x64` + sha256 sidecar | Build-time only | esbuild (dev dep) |
|
||||
| `scripts/install.sh` | The one-liner install script served from repo raw: Gitea latest-release resolve → download + checksum verify → ~/.local/bin; source-bootstrap fallback | Standalone POSIX sh | forge API |
|
||||
| `tests/` | node:test unit tests: command dispatch, check logic, env template diff, install-script shellcheck-style assertions | Fixtures only — never mutate repo state | src/ |
|
||||
|
||||
**Boundary rules:** the CLI never imports from `apps/web`, `packages/*`, or `ai_service` Python modules — it orchestrates them exclusively via subprocess/filesystem. Runtime deps: node stdlib only (no runtime npm deps; esbuild is dev-only). The binary embeds the bundle; `scripts/bootstrap.sh` remains the single source of bootstrap truth (D-034).
|
||||
|
||||
### apps/web — Next.js Application
|
||||
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
@@ -193,7 +217,13 @@ Composite/layout/theme components (navigation shell, tables, chat panels, graph
|
||||
|
||||
---
|
||||
|
||||
## Build Order (v0.3)
|
||||
## Build Order (v0.4)
|
||||
|
||||
1. **Bootstrap CLI core** — apps/cli package: doctor checks (node/pnpm/python3/git/unshare), bootstrap wrapper (pnpm install + scripts/bootstrap.sh + .env template + key validation), verify health check, dev passthrough; unit tests
|
||||
2. **Binary build + release pipeline** — esbuild bundle → Node SEA binary (`nextcraft-linux-x64`) + sha256 sidecar; install.sh one-liner (Gitea latest-release resolve + checksum verify + PATH install); release-asset upload wired into the ship flow (ongoing binaries from v0.4 onward)
|
||||
3. **Install docs + fresh-clone E2E** — README quickstart (one-liner → doctor → bootstrap → dev), CLI reference, fresh-clone end-to-end test proving a clean clone reaches a running stack
|
||||
|
||||
## Build Order (v0.3 — complete)
|
||||
|
||||
1. **Sandbox fabric** — SandboxBackend protocol + unshare namespace spawner + lifecycle manager (create/list/snapshot/destroy) + concurrency guard + per-sandbox workdir; isolation + resource-limit probes
|
||||
2. **Live build telemetry** — TelemetryEvent models + SQLite TraceStore + WebSocket ingest endpoint + seq gap detection + in-sandbox capture agent
|
||||
@@ -217,16 +247,19 @@ The v0.1 build order (monorepo → types → mock data → tokens → primitives
|
||||
|
||||
---
|
||||
|
||||
## Future Architecture (Post-v0.3, for reference)
|
||||
## Future Architecture (Post-v0.4, for reference)
|
||||
|
||||
v0.3 delivers the real credential engines; later milestones fill in the remaining platform:
|
||||
v0.4 delivers distribution (CLI + binary releases); later milestones fill in the remaining platform:
|
||||
|
||||
- **In-memory sessions → PostgreSQL + Drizzle/SQLModel** — SessionStore + v0.3 TraceStore/GradeStore/VariantStore/DefenseStore protocols swap SQLite→Postgres with no API changes
|
||||
- **userns subprocess sandboxes → containerd/runc backend** — D-024 `SandboxBackend` protocol swap; same lifecycle API
|
||||
- **Coding-IDE sandbox → design tool + simulation environments** — REQ-F-021 full scope (v0.4)
|
||||
- **Coding-IDE sandbox → design tool + simulation environments** — REQ-F-021 full scope (v0.5)
|
||||
- **Mock provider → per-agent model routing** — provider factory already selects by config; per-agent `AI_<AGENT>_MODEL` overrides
|
||||
- **No auth → real KYC + sessions** — **deferred per founder directive**; REQ-F-017 identity/age-gating lands post-v0.3 (v0.4+). Age-gating remains the v0.1 visual flow mockup
|
||||
- **No auth → real KYC + sessions** — **deferred per founder directive; moved to v0.5 with D-016**; REQ-F-017 identity/age-gating lands post-v0.4. Age-gating remains the v0.1 visual flow mockup
|
||||
- **Mock voice → real server STT/TTS (openai-audio provider)** — CUT-1/G-7 seam moved to v0.5 per D-016; VoiceProvider protocol is the drop-in point
|
||||
- **linux x64 binary → macOS arm64 + auto-update** — D-036 defers non-linux targets (unverifiable on this box); `nextcraft upgrade` (self-replace from latest release) is the natural v0.5+ follow-up
|
||||
- **Exec-telemetry seq-lease / replay-margin fix** — the P6-lesson one-line ACK gap moves to v0.5 per D-016
|
||||
- **No search → Semantic vector search (pgvector)** — Filter UI replaced with vector similarity search
|
||||
- **No payments → Payment processing** — Pricing page replaced with real subscription/payment flows
|
||||
|
||||
The monorepo structure (apps/web + apps/ai-service + packages/*) accommodates further apps without restructuring.
|
||||
The monorepo structure (apps/web + apps/ai-service + apps/cli + packages/*) accommodates further apps without restructuring.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.4",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-13T01:50:00Z"
|
||||
}
|
||||
@@ -46,3 +46,41 @@ The credential-pipeline architecture (telemetry → trace → grade → defense)
|
||||
## Outcome
|
||||
|
||||
**GO** — all six binding decisions and both scope cuts applied to PLAN.md / REQUIREMENTS.md / ROADMAP.md / PROJECT.md before Phase 1 execution. No axis requires escalation (all resolvable at confidence ≥ 0.85). The milestone no longer claims resource enforcement it cannot deliver, and the no-auth abuse vector is closed at MVP scale.
|
||||
|
||||
---
|
||||
# Nextcraft v0.4 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.83
|
||||
|
||||
## Summary
|
||||
|
||||
The distribution milestone is small, founder-directed (D-016, confidence 0.99), and additive (zero changes to the running credential pipeline). The plan's central risk: **Node SEA was probe-verified as a flag, not as a working build** — the v0.3 lesson (A-101: probe the mechanism, not the existence) applies. Second gap: a binary whose `--version` lies (stale package.json) would poison the "ongoing binaries" contract. Third: sed-based JSON parsing in install.sh is a fragility + integrity risk. Fourth: "ongoing binaries" has no enforcement mechanism beyond prose. All four closed by binding decisions G-101..G-104 below. No scope cuts required — the milestone is already minimal.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| Axis | Verdict | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Business case | PASS | Founder directive explicit + recorded (D-016). Evidence of need: live Gitea probe shows latest release v0.2.8 with ZERO assets; bootstrap requires repo archaeology (scripts found only via package.json spelunking). |
|
||||
| Scope | PASS | 5 REQs, 3 execution phases, one focused surface (apps/cli + scripts). Smallest milestone yet. macOS arm64 already cut (D-036, unverifiable here). |
|
||||
| Feasibility | CONCERN (fixed) | SEA flag exists on node v24.15.0, but no end-to-end SEA binary was built during RESEARCH. postject availability assumed (`npx postject` — needs npm registry reachability, unproven). Zipapp fallback requires python3 on target — an honest-degradation ladder, not a silent downgrade. → G-101. |
|
||||
| Honest versioning | CONCERN (fixed) | `--version` from package.json would print a stale hardcoded version inside a per-release binary — breaks upgrade detection + the one-liner's re-run-to-upgrade promise. → G-102. |
|
||||
| Install integrity | CONCERN (fixed) | sed/grep JSON parsing is brittle; a parse failure must never fall through to installing an unverified artifact. Exact asset-name matching + hard-degrade to source instructions. → G-103. |
|
||||
| Sequencing | PASS | P1 CLI (source-runnable) → P2 binary+pipeline → P3 docs+E2E matches dependency order; each phase ships independently. |
|
||||
| Cost/quota | PASS | Zero new paid infra; binaries built on-box; Gitea releases free. Dev-only esbuild dep. |
|
||||
| Risks | CONCERN (fixed) | Top 3: SEA end-to-end (→ G-101 live probe FIRST in P2), npm registry reachability for esbuild (→ proven by P1's pnpm install must-have), Gitea asset-upload token scope (→ live-proven at the v0.3.2 ship itself). |
|
||||
| Adoption/operability | PASS | Consumer = founder + future pilots; one command replaces README archaeology. Rollback trivial (rm ~/.local/bin/nextcraft). No server changes. |
|
||||
|
||||
## Binding Decisions (applied to PLAN.md)
|
||||
|
||||
- **G-101 (BINDING) — SEA live-build probe is the FIRST P2 action.** Task 2-1-01 builds a real binary before anything depends on it; the build script encodes the fallback ladder explicitly (SEA → zipapp with "requires python3" honesty). If SEA fails on this box, zipapp becomes primary with the docs stating the requirement — no silent claim of node-less operation.
|
||||
- **G-102 (BINDING) — Version stamping at build time.** `build-binary` accepts the shipping tag and stamps it into the bundle (`NEXTCRAFT_VERSION` replace); `--version` prints it; install E2E asserts the installed binary reports the tag it was downloaded from. A binary may never report a version it was not built as.
|
||||
- **G-103 (BINDING) — Install-script integrity hard-degrade.** install.sh matches assets by EXACT name (`nextcraft-linux-x64`, `nextcraft-linux-x64.sha256`); any parse/lookup/download failure degrades to source-bootstrap instructions (exit 0) — never installs unverified or name-approximate artifacts. Checksum mismatch = hard stop, exit 1, explicit do-not-run message. dash-safe POSIX sh, no jq.
|
||||
- **G-104 (BINDING) — Ongoing-binaries enforcement.** Every ship from v0.3.2 onward MUST run `scripts/release-assets.sh <tag>` after tag+merge (best-effort, non-blocking, `release_pending` escalation on failure — but attempted + logged every release). The final-phase audit gate includes "milestone release carries both assets" as a check. This makes the founder's "ongoing binaries" directive a pipeline property, not prose.
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All four concerns resolved at confidence ≥ 0.85. No axis requires founder escalation (directive already explicit).
|
||||
|
||||
## Outcome
|
||||
|
||||
**GO** — G-101..G-104 applied to PLAN.md before Phase 1 execution. The milestone claims only what its probes prove, and the ongoing-binaries contract has an enforcement mechanism.
|
||||
|
||||
+107
-136
@@ -2,19 +2,19 @@
|
||||
|
||||
## Persona Roster
|
||||
|
||||
> **v0.3 update (RESEARCH, lead-developer assessment):** backend-engineer territory extended to the new engine modules (telemetry/grading/variants persistence + APIs). New phase-relevant custom personas added: **sandbox-engineer** (Linux-namespace isolation infra) and **voice-engineer** (STT/TTS + Examiner agent audio pipeline). ai-engineer re-scoped to LLM/agents/prompts + grading/variant/voice *model-facing* logic. **security-auditor stays inactive** (KYC deferred per founder directive). frontend-engineer gains real-sandbox (read-only exec-output terminal frame, CUT-2/G-8 — interactive xterm relay is v0.4), live-telemetry, and live-defense surfaces.
|
||||
> **v0.4 update (RESEARCH, lead-developer assessment):** milestone pivoted to Distribution & Bootstrap CLI (founder directive D-016). New custom persona **cli-engineer** (domain `cli`) owns apps/cli end-to-end: doctor/bootstrap/verify/dev commands, checks, spawn wrappers, the SEA binary build, the one-liner install script, and the release-asset pipeline. backend-engineer retains the scripts/ + turbo/root-package integration surface. **sandbox-engineer and voice-engineer deactivated** (their v0.3 code is complete and untouched this milestone — reason fields below). ai-engineer light-touch (no model-facing work in v0.4). **security-auditor re-activated (phase-specific)** for the install pipeline: curl|bash attack surface, checksum trust, PATH writes, secrets handling in the release flow. frontend-engineer/design-system-engineer/data-engineer inactive (zero UI/data-scope tasks in v0.4 — retained below with reasons).
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates task decomposition across web, AI service, engine, and sandbox territories; resolves conflicts between frontend, backend, AI, sandbox, and voice personas
|
||||
reason: Coordinates task decomposition across CLI, scripts, release-pipeline, and docs territories; resolves cli-engineer/backend-engineer boundary (scripts vs CLI)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- next.js
|
||||
- turborepo
|
||||
- pnpm
|
||||
- fastapi
|
||||
- node
|
||||
constraints:
|
||||
- pragmatic
|
||||
- battle-tested defaults
|
||||
@@ -27,97 +27,83 @@ territory:
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
```
|
||||
|
||||
### frontend-engineer
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Phase 6 real-engine learner-surface integration — read-only exec-output terminal frame (CUT-2, no interactive shell), file-tree/run/test controls, live telemetry panels, live voice defense UI, live grading display. Owns all page components, layouts, surface-specific UI.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
- next.js
|
||||
- tailwindcss
|
||||
- lucide-react
|
||||
- recharts
|
||||
- react-flow
|
||||
constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- minimal-client-js
|
||||
- sse-client-buffering (buffer bytes, split frames on \n\n, join data: lines)
|
||||
- abortcontroller-cleanup (idempotent abort in effect cleanup)
|
||||
- fetch-lifecycle (typed engine-client calls, retry/teardown, cleanup)
|
||||
- mediarecorder-permission-ux (mic consent, graceful no-mic fallback)
|
||||
- responsive-all-breakpoints
|
||||
- dark-mode-support
|
||||
territory:
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/mock-data/**"
|
||||
- "packages/types/**"
|
||||
```
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns TS mock data layer schema and typed definitions + TS types for telemetry/trace/grade/variant/defense shapes the web surfaces consume. Does NOT own the Python corpus or engine stores — aligned by convention (D-021).
|
||||
domain: data
|
||||
reason: v0.4 custom persona (RESEARCH) — owns the distribution milestone core: nextcraft CLI (doctor/bootstrap/verify/dev), pure check logic, spawn wrappers with timeouts, Node SEA binary build (D-033), one-liner install.sh (D-035), checksum sidecar, and Gitea release-asset upload (D-036)
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
- typescript
|
||||
- node:test
|
||||
- esbuild
|
||||
- node-sea
|
||||
- posix-sh
|
||||
constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- migration-ready
|
||||
- mock-data-only
|
||||
- stdlib-only-runtime (no runtime npm deps; esbuild dev-only)
|
||||
- thin-wrapper (never re-implement scripts/bootstrap.sh or dev.sh — compose via spawn, A-202/A-209)
|
||||
- timeout-every-spawn (no unbounded subprocess)
|
||||
- actionable-errors (every failed check tells the user how to fix it)
|
||||
- graceful-degradation (install never hard-fails; source-bootstrap fallback, A-206)
|
||||
- checksum-before-install (sha256 verify before chmod+install, A-207)
|
||||
- secrets-never-in-cli (no key generation; .env.example -> .env copy only, A-210)
|
||||
- fail-loud-exit-codes (0 ok / 1 failure / 2 usage)
|
||||
territory:
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
- "apps/cli/**"
|
||||
- "scripts/install.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns apps/ai-service app shell, config, API endpoints (incl. WebSocket telemetry ingest), engine persistence (SQLite stores), scripts, and test harness. Extended for v0.3 engine modules.
|
||||
reason: Owns the script + monorepo integration surface the CLI composes: apps/ai-service/scripts/*, root package.json cli:* passthrough scripts, turbo task wiring (D-037/D-022). Python ai-service itself is untouched this milestone (v0.3 complete).
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- pydantic
|
||||
- pydantic-settings
|
||||
- httpx
|
||||
- pytest
|
||||
- sqlmodel
|
||||
- sqlalchemy
|
||||
- websockets
|
||||
- aiofiles
|
||||
- bash
|
||||
- turborepo
|
||||
- pnpm
|
||||
constraints:
|
||||
- provider-agnostic-boundaries (engine modules import nothing from agents/ or api/)
|
||||
- streaming-first
|
||||
- sqlite-first-persistence (protocol-wrapped stores, Postgres-ready, D-027)
|
||||
- secrets-via-env-only
|
||||
- mock-provider-in-tests
|
||||
- websocket-contract (typed envelopes, seq gap detection, D-026)
|
||||
- scripts-are-truth (bootstrap.sh/dev.sh stay the single source of bootstrap orchestration; CLI only wraps)
|
||||
- idempotent-scripts (re-runnable without side effects)
|
||||
- secrets-via-env-only (D-014; dev.sh exports from .ciagent/.env.secrets)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/ai_service/telemetry/store.py"
|
||||
- "apps/ai-service/ai_service/telemetry/ingest.py"
|
||||
- "apps/ai-service/ai_service/grading/store.py"
|
||||
- "apps/ai-service/ai_service/variants/store.py"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/package.json"
|
||||
- "apps/ai-service/tests/api/**"
|
||||
- "package.json"
|
||||
- "turbo.json"
|
||||
- ".gitignore"
|
||||
```
|
||||
|
||||
### security-auditor
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.4 re-activated (phase-specific) — the install pipeline is the first externally-consumed attack surface: curl|bash piping, latest-release resolution, checksum trust root, PATH writes to ~/.local/bin, download tempdir hygiene, release-asset upload token handling. No KYC/PII work (still v0.5).
|
||||
domain: security
|
||||
frameworks:
|
||||
- posix-sh
|
||||
- curl
|
||||
- sha256sum
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- no-pipe-to-shell-without-checksum (download -> verify -> install order)
|
||||
- tmpdir-safe (mktemp, no predictable paths, trap cleanup)
|
||||
- token-never-echoed (release upload resolves .env* only, never logs)
|
||||
territory:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
- "apps/cli/src/lib/spawn.ts"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns the LLM provider layer, agent framework, prompt library, structured outputs, and the model-facing logic of v0.3 engines — trace-digest→rubric grading prompts (grading/features.py+engine.py), variant instantiation (variants/templates.py+generator.py), and the Examiner agent. Owns the deterministic-mock corpora.
|
||||
reason: Light-touch v0.4 — no model-facing work in the distribution milestone; retained to guard the CLI against touching agent/engine boundaries and to keep territory mappings accurate for v0.5 (voice real-path, seq-lease).
|
||||
domain: ai
|
||||
frameworks:
|
||||
- pydantic
|
||||
@@ -125,116 +111,101 @@ frameworks:
|
||||
- pytest
|
||||
constraints:
|
||||
- provider-agnostic-protocol
|
||||
- prompts-are-code
|
||||
- json-defensive-parsing
|
||||
- never-call-cloud-in-tests
|
||||
- delta-passthrough
|
||||
- llm-sees-digest-not-raw-trace (D-028)
|
||||
- seeded-variant-reproducibility (D-029)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/llm/**"
|
||||
- "apps/ai-service/ai_service/agents/**"
|
||||
- "apps/ai-service/ai_service/prompts/**"
|
||||
- "apps/ai-service/ai_service/corpus/**"
|
||||
- "apps/ai-service/ai_service/grading/features.py"
|
||||
- "apps/ai-service/ai_service/grading/engine.py"
|
||||
- "apps/ai-service/ai_service/variants/templates.py"
|
||||
- "apps/ai-service/ai_service/variants/generator.py"
|
||||
- "apps/ai-service/tests/llm/**"
|
||||
- "apps/ai-service/tests/agents/**"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.3 custom persona (RESEARCH) — owns the sandbox fabric: SandboxBackend protocol, unshare-based Linux user/mount/pid/net namespace spawner, per-sandbox workdir, resource limits, lifecycle manager, concurrency guard, and the in-sandbox capture agent. Probe-verified isolation on this box (D-024).
|
||||
domain: infra
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.4 has zero UI-scope work (no web/pages/components changes planned in the distribution milestone); v0.3 surfaces are complete. Reactivated at v0.5 when deferred UX work resumes.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
- asyncio
|
||||
- pytest
|
||||
- react
|
||||
- next.js
|
||||
- tailwindcss
|
||||
constraints:
|
||||
- isolation-verified (probe must show in-ns uid=0, network isolated, writes to workdir only)
|
||||
- backend-protocol-swap (no containerd assumption; D-024)
|
||||
- resource-limits-enforced (cpu/mem/time quotas observable)
|
||||
- no-daemon (subprocess-only; no docker/containerd service)
|
||||
- capacity-guard (1-5 concurrent; 503 when full, D-032)
|
||||
- component-first
|
||||
- server-components-default
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
- "apps/ai-service/scripts/sandbox-agent.py"
|
||||
- "apps/ai-service/tests/sandbox/**"
|
||||
```
|
||||
|
||||
### voice-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.3 custom persona (RESEARCH) — owns the voice layer: VoiceProvider protocol, STT/TTS against a compatible endpoint, deterministic mock (tests never call a voice API), browser-native fallback, and the media-path wiring consumed by the Examiner agent and assessment UI.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- pydantic
|
||||
- httpx
|
||||
- pytest
|
||||
- web-mediarecorder
|
||||
constraints:
|
||||
- provider-agnostic-protocol (D-030)
|
||||
- never-call-voice-api-in-tests
|
||||
- browser-native-fallback (no-key path still functions)
|
||||
- bounded-turn-latency (conversational feel budget)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
- "apps/ai-service/tests/voice/**"
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
```
|
||||
|
||||
### design-system-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Owns the shared component library, design tokens, and visual consistency. v0.3 duty: new primitives for the real build/assessment surfaces (terminal frame, telemetry status indicator, mic/record control, grade badge, defense transcript viewer).
|
||||
reason: No design-token or primitive work in v0.4; roster retained for v0.5.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
- storybook
|
||||
- lucide-react
|
||||
constraints:
|
||||
- design-token-driven
|
||||
- wcag-aa-contrast
|
||||
- dark-mode-required
|
||||
- consistent-across-surfaces
|
||||
territory:
|
||||
- "packages/ui/**"
|
||||
```
|
||||
|
||||
### security-auditor
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Identity/age-gating (KYC) deferred beyond v0.3 per founder directive (A-110) — no real auth or PII backend lands this milestone. Security coverage remains: verifier's STRIDE layer + Phase 7 secrets-hygiene checklist (keys absent from code/logs/commits/errors, localhost-only CORS, no PII in prompts). Sandbox isolation safety is owned by sandbox-engineer's probe-verified constraint.
|
||||
domain: security
|
||||
reason: No schema/mock-data work in v0.4; types packages untouched. Reactivated if CLI surfaces need shared types (not planned — CLI is self-contained).
|
||||
domain: data
|
||||
frameworks:
|
||||
- typescript
|
||||
constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
territory:
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.3 persona — sandbox fabric shipped complete (v0.2.x series); v0.4 touches no sandbox code. doctor only *checks* unshare availability; no sandbox logic changes. Reactivated at v0.5 (design/sim environments).
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
constraints: []
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
```
|
||||
|
||||
### voice-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.3 persona — voice defense shipped complete (mock-first, CUT-1); real server STT/TTS moved to v0.5 per D-016. No v0.4 voice work.
|
||||
domain: ai-media
|
||||
frameworks: []
|
||||
constraints: []
|
||||
territory: []
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
```
|
||||
|
||||
## Phase-Specific Personas
|
||||
|
||||
| Persona | Phases | Removed After |
|
||||
|---------|--------|---------------|
|
||||
| sandbox-engineer | 1 (primary), 2, 6 | persists while sandbox fabric exists |
|
||||
| voice-engineer | 5 (primary), 6 | persists while voice defense exists |
|
||||
| security-auditor | 2 (primary: install pipeline), 3, 4 (final review) | milestone complete |
|
||||
|
||||
All other active personas span the entire milestone. data-engineer and design-system-engineer are light-touch outside their phases.
|
||||
All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
## Territory Conflict Resolution
|
||||
|
||||
| Conflict | Resolution |
|
||||
|----------|------------|
|
||||
| frontend-engineer vs data-engineer (packages/types, packages/mock-data) | data-engineer owns type definitions and mock data schema (incl. new telemetry/grade/variant/defense TS types); frontend-engineer consumes them. |
|
||||
| frontend-engineer vs design-system-engineer (packages/ui) | design-system-engineer owns design tokens and primitive components (terminal frame, mic control, grade badge); frontend-engineer owns composite components and page-level UI. |
|
||||
| ai-engineer vs backend-engineer (grading/variants) | ai-engineer owns the model-facing files (features/engine/templates/generator = LLM logic + prompts); backend-engineer owns the persistence stores + API endpoints. Boundary: stores are pure SQLite; engine logic is pure compute. |
|
||||
| sandbox-engineer vs backend-engineer (sandbox/) | sandbox-engineer owns `ai_service/sandbox/**` + capture agent; backend-engineer owns the API route that composes `sandbox/manager.py` via DI. manager.py has a narrow typed interface consumed by api/. |
|
||||
| voice-engineer vs ai-engineer (Examiner agent) | ai-engineer owns `agents/examiner.py` + its prompt; voice-engineer owns `voice/**` (audio in/out). Examiner calls `voice/` through the `VoiceProvider` protocol — never imports concrete providers. |
|
||||
| ai-engineer vs data-engineer (mock duplication) | ai-engineer owns `ai_service/corpus/` (Python); data-engineer owns `packages/mock-data` (TS). Shared IDs/shapes aligned by convention (D-021). |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files. |
|
||||
| cli-engineer vs backend-engineer (scripts/) | backend-engineer owns `apps/ai-service/scripts/**` + root `package.json`/`turbo.json` wiring; cli-engineer owns `apps/cli/**` + top-level `scripts/install.sh` + `scripts/release-assets.sh` and *consumes* backend scripts via spawn — never edits them |
|
||||
| cli-engineer vs security-auditor (install.sh) | cli-engineer implements; security-auditor reviews + may patch security defects directly in install.sh/spawn.ts (its territory) |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
|
||||
+194
-422
@@ -1,438 +1,210 @@
|
||||
# Nextcraft v0.3 — PLAN.md
|
||||
# Nextcraft v0.4 — 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.
|
||||
This plan covers execution phases 1–3 of milestone v0.4 (Distribution & Bootstrap CLI) plus the final phase (P4 review+ship). The milestone delivers the founder directive (D-016): a streamlined install for Nextcraft — a `nextcraft` bootstrap CLI shipped as a linux x64 binary, installed via a one-liner script, with binaries published on **every ongoing release** from v0.4 onward. Phases are strictly sequential (P1→P3); 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). 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; age-gating stays the v0.1 visual flow mockup. **GRILL scope decisions (this revision):** real server STT/TTS (`OpenAIAudioProvider`) deferred to v0.4 — voice defense is mock+browser-first (CUT-1/G-7); the interactive xterm.js shell relay is deferred to v0.4 — the build panel is run/test-buttons + read-only exec output (CUT-2/G-8), so `@xterm/*` is NOT a v0.3 dependency; sandbox abuse control (per-learner caps + allowlist, G-5) ships even though KYC is deferred; sandbox resource limits are partially enforced (memory/CPU/wall-clock + workdir-size sweep; per-sandbox pids and hard disk quota are accepted gaps, G-1/G-2).
|
||||
**Environment facts (probe-verified, apply throughout):** Go MISSING, Rust MISSING, gcc 12.2 present, **node v24.15.0 x64 linux (SEA-capable)**, python3 3.11.2, `shasum` 6.02, pnpm 12.3.4 via corepack, turborepo 2.3.3, tsx 4.23 in root devDeps path. Gitea API verified live at `https://git.coreci.dev/api/v1` (latest release v0.2.8, **zero assets** — the gap this milestone closes). Existing orchestration: `apps/ai-service/scripts/bootstrap.sh` (idempotent venv+pip incl. the no-ensurepip get-pip path), `apps/ai-service/scripts/dev.sh` (secrets export → uvicorn :8420), `apps/ai-service/.env.example` (full AI_* template). Root scripts: `ai:dev/ai:test/ai:bootstrap/ai:lint` turbo passthroughs (D-022 pattern to mirror as `cli:*`). Secrets live only in gitignored `.ciagent/.env.secrets` (GITEA_TOKEN, OLLAMA_API_KEY, OLLAMA_BASE_URL) — never in code, commits, or logs; tests never call the cloud or the forge (mocks/fixtures only).
|
||||
|
||||
**Milestone type:** feature. Tags: phase 0 → **v0.3.0**, P1 → v0.3.1, P2 → v0.3.2, P3 → v0.3.3, final phase P4 → **v0.3.4 = milestone release**. **GRILL binding decisions (this revision):** G-101 — SEA live-build probe is the FIRST P2 action (mechanism, not flag, must be proven); fallback ladder encoded honestly (zipapp requires python3 on target). G-102 — binary `--version` stamped from the shipping tag at build time (never a stale package.json version); install E2E asserts the installed binary reports its release tag. G-103 — install.sh matches assets by exact name; any parse/download failure degrades to source-bootstrap instructions (exit 0), never installs unverified artifacts; checksum mismatch = hard stop exit 1. G-104 — every ship from v0.3.2 onward runs `scripts/release-assets.sh <tag>` (best-effort, logged, non-blocking); the P4 audit gate checks the milestone release carries both assets.
|
||||
|
||||
| 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.py` — **re-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:test` — `tests/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 -u` → `0`; `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` which also performs a **workdir-size sweep**: any sandbox whose `workdir` exceeds `AI_SANDBOX_MAX_WORKDIR_MB` (new config, default 512) is snapshotted-then-destroyed and the event logged as an integrity signal (G-2 — soft disk cap, best-effort, not kernel-enforced); the sweep runs on the same timer as the timeout reaper. Enforce rlimits per spawner (Task 1-1-03: RLIMIT_AS + RLIMIT_CPU + RLIMIT_FSIZE=50MB as a cheap single-file disk guard (a-2); RLIMIT_NPROC noted as shared-per-host-uid, not relied on (G-1)) 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. Startup reaper (a-1): on lifespan boot, scan `AI_SANDBOX_DIR` for workdirs whose recorded pid is dead and reap them, logging a warning. 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) single huge file > `RLIMIT_FSIZE` → assert write failure (a-2 partial disk guard); (d) wall-clock: spawn `sleep 9999` with a small manager timeout → reaper destroys it; (e) **disk sweep (G-2)**: write > `AI_SANDBOX_MAX_WORKDIR_MB` across many files → assert the manager sweep destroys the sandbox and logs the integrity signal. Assert limits are observable (handle reports its limit set). NOTE (G-1): per-sandbox `RLIMIT_NPROC` is shared at the host uid — the fork-bomb probe is documented as shared-budget behavior, NOT asserted as per-sandbox isolation.
|
||||
- **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). **Abuse control (G-5, NOT KYC):** even in no-auth v0.3 the sandbox API enforces per-`learner_id` rate limiting (`AI_SANDBOX_MAX_PER_LEARNER`, default 1 active → 429) and a global create-rate cap (`AI_SANDBOX_CREATES_PER_MIN`, default 10 → 429); `learner_id` is validated against a server-side allowlist from config (`AI_LEARNER_ALLOWLIST`, default the single mock pilot id → unknown ids rejected 403). This ships in the no-auth milestone so a rogue local process can't exhaust shared NPROC/disk.
|
||||
- **Verify:** `pnpm ai:test` — test_sandboxes green (create→list→snapshot→delete roundtrip via TestClient; 6th create → 503; delete of unknown id → 404; abuse control (G-5): non-allowlisted learner_id → 403; >1 active sandbox for one learner → 429; burst of >10 creates/min → 429); 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 (RLIMIT_AS) + CPU (RLIMIT_CPU) rlimits kill violating processes; wall-clock reaper destroys stale sandboxes; disk usage capped by a periodic workdir-size sweep in the manager (soft cap, configurable `AI_SANDBOX_MAX_WORKDIR_MB`, default 512MB — NOT kernel-enforced); per-sandbox NPROC is shared across sandboxes at the host uid — documented, not relied on for isolation (G-1, G-2 — 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. Enable `PRAGMA journal_mode=WAL` + `synchronous=NORMAL` at engine creation (a-3) so concurrent ingest (writer) and trace reads (grader) don't hit `database is locked` under concurrent sandboxes.
|
||||
- **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); ping/pong keepalive. **Backpressure / flood control (G-3 — replaces silent drop):** bounded inbound queue; on overflow OR when a per-connection cap `AI_TELEMETRY_MAX_EVENTS_PER_TASK` (default 50000) is exceeded → **reject with a 1008 policy-violation close and mark the (learner,task) trace `INCOMPLETE_FLOODED`** (an integrity signal consumed by Proctor). Silent drop-oldest is FORBIDDEN because it corrupts grading input and is indistinguishable from trace-gaming. `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)
|
||||
- [ ] Flood control (G-3): burst past `AI_TELEMETRY_MAX_EVENTS_PER_TASK` → connection closed 1008 + trace marked `INCOMPLETE_FLOODED`; no silent event drop on overflow
|
||||
- [ ] `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`: **trace-completeness gate (G-4)** — first call `TraceStore.gaps()` + check the trace `INCOMPLETE_FLOODED` flag; if gaps are non-empty OR the trace is flagged incomplete → return `verdict=UNGRADABLE_TRACE_INCOMPLETE` (a first-class verdict, not an exception) surfacing the gap list; a credential is NEVER issued from a gapped/incomplete trace. Otherwise: load trace via `TraceStore` → `compute_digest` → render rubric prompt (`prompts/grading.py`: criteria + level anchors for process quality, correctness, debugging discipline, test usage; a-4: treat high edit/command churn with no test-progress as a process-quality negative) → 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, including the INCOMPLETE path.
|
||||
- **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
|
||||
- [ ] Incomplete-trace gate (G-4): gapped or `INCOMPLETE_FLOODED` trace → `verdict=UNGRADABLE_TRACE_INCOMPLETE` with the gap list; no credential issued from an incomplete trace (test 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; **fairness envelope (a-5):** two variants of one template compute digests within the template's expected feature envelope (comparable slot complexity/difficulty features) — "same bar" is testable, not asserted
|
||||
|
||||
### 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 + browser fallback (D-030) + seventh `Examiner` agent streaming over existing SSE + `DefenseStore` persisting transcript + integrity signals. **Real server STT/TTS (`OpenAIAudioProvider`) is DEFERRED to v0.4 (with KYC, when there's a real key + real users)** — voice is mock-first (D-030) and the `/audio/*` real path could never be exercised in CI, so v0.3 proves the full defense *dialogue* + integrity-signal pipeline over mock + browser-native fallback only; the protocol seam keeps the real provider a drop-in later.
|
||||
|
||||
### Wave 1: Voice provider layer (parallel — no shared files)
|
||||
|
||||
#### Task 5-1-01: VoiceProvider protocol + mock provider + browser fallback
|
||||
- **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/ai_service/voice/browser.py`, `apps/ai-service/ai_service/voice/factory.py`, `apps/ai-service/tests/voice/__init__.py`, `apps/ai-service/tests/voice/test_mock.py`, `apps/ai-service/tests/voice/test_factory.py`
|
||||
- **Action:** `VoiceProvider` protocol mirroring `LLMProvider` (D-030): `transcribe(audio: bytes, fmt) -> TranscriptSegment` + `synthesize(text, voice) -> AsyncIterator[bytes]`. `MockVoiceProvider`: deterministic canned transcript (scripted per test), canned 1kHz-tone WAV bytes, scripted failure modes. `browser.py`: fallback **descriptor** (`sr_available: true`, endpoint hints) the web client uses to select browser-native `SpeechRecognition`/`speechSynthesis` when no server provider. `factory.py`: `AI_VOICE_PROVIDER=browser | mock` (default mock when no key). **`OpenAIAudioProvider` (real server STT/TTS) intentionally NOT built in v0.3 — deferred to v0.4**; the protocol is its future seam. `voice/` never imports `agents/` or `api/`.
|
||||
- **Verify:** `pnpm ai:test` — test_mock + test_factory green (deterministic transcribe/synthesize; failure modes; factory selects mock with empty key, browser when provider=browser; 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: Examiner latency instrumentation (depends on Wave 3)
|
||||
|
||||
#### Task 5-4-01: Per-turn latency instrumentation (mock-based)
|
||||
- **Persona:** voice-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/tests/voice/test_latency.py`, `apps/ai-service/README.md` (update: conversational-budget doc + v0.4 voice note)
|
||||
- **Action:** Instrument per-turn latency (STT ms + LLM TTFT ms + TTS ms) recorded on each DefenseTurn; deterministic test over mock providers asserts instrumentation presence + `latency_ms` populated + budget constant defined (mock runs are near-instant — wall-clock asserted in v0.4 against a real endpoint). README documents the conversational-latency target as a v0.4 acceptance criterion (real STT/TTS deferred per CUT-1/G-7).
|
||||
- **Verify:** `pnpm ai:test` — test_latency green (latency_ms fields populated on every turn; budget constant defined); README documents the deferred real-voice acceptance probe
|
||||
|
||||
### 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)
|
||||
- [ ] Instrumented per-turn latency fields populated on every DefenseTurn (STT ms + LLM TTFT ms + TTS ms); conversational budget named (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 voice provider configured (mock/browser are first-class, D-030)
|
||||
- [ ] Real server STT/TTS (`OpenAIAudioProvider`) explicitly deferred to v0.4 (with real keys/users); the defense pipeline is fully proven over mock+browser — documented in README + release note
|
||||
- [ ] Optional future voice config noted for v0.4 (`AI_VOICE_BASE_URL` / `AI_VOICE_API_KEY`) 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
|
||||
|
||||
---
|
||||
|
||||
## 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 build/run (Run/Test buttons + read-only exec output, CUT-2 — no interactive shell), live telemetry panel, browser/typed voice 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: Sandbox build-panel engine client (run/test-only — no raw shell relay)
|
||||
- **Persona:** frontend-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `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 client for `/v1/sandboxes` (create/destroy — learner_id from the v0.3 mock session constant, allowlisted server-side per G-5), `/v1/sandboxes/{id}/files` (workspace CRUD), `/v1/sandboxes/{id}/exec` (run/test), `/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 → destroy on unmount (idempotent cleanup, AbortController pattern); 503 pool-full → user-facing "environment busy, retry" (D-032); 403/429 abuse-control surfaced honestly (G-5). **CUT-2 (G-8): NO raw interactive WS terminal relay (keystroke-level stdin/stdout) in v0.3** — the credential pipeline needs *process events* (from Run/Test + file edits), not a live shell; the interactive xterm relay is the most fragile real-time piece and is deferred to v0.4. The build panel is a **Run/Test output viewer** (exec results + telemetry pulse render), not an interactive shell. `@xterm/xterm` is therefore NOT a dependency in v0.3.
|
||||
- **Verify:** `pnpm install && pnpm typecheck` pass; hook unmount destroys the sandbox (manual probe: `curl localhost:8420/v1/sandboxes` shows count drop after navigation); RUN/TEST buttons produce streamed output + telemetry events in the trace
|
||||
|
||||
#### 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 (CUT-2: a read-only exec-output viewer chrome — streams Run/Test results, NOT an interactive shell), 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, CUT-2): file tree (HTTP CRUD into the sandbox workdir via `/v1/sandboxes/{id}/files` routes added to api/sandboxes — read/write/list workspace files), syntax-highlight editor (existing), **Run**/**Test** buttons (exec in sandbox; results stream to a read-only TerminalFrame output panel — no interactive shell), starter files from the P4 variant scaffold. Lab panel 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; 403/429 surfaced.
|
||||
- **Verify:** with ai-service up: open `/build/comp-01` → variant statement + starter files load → edit a file → **Run** executes the command in-sandbox and output renders in the panel → **Test** runs the test suite 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 (CUT-2): Run/Test buttons execute in a namespace sandbox and stream output to a read-only panel; file tree CRUD works; 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; abuse control (per-learner sandbox caps + server-side learner allowlist, G-5) ships in place of auth (A-110), (d) voice runs mock-first with browser-native fallback — real server STT/TTS deferred to v0.4 (CUT-1), (e) sandbox resource limits are partially enforced (memory/CPU/wall-clock kernel-enforced via rlimits; per-sandbox pids + hard disk quota are NOT — mitigated by a workdir-size sweep and per-learner caps; full enforcement requires cgroup delegation, deferred to the post-MVP containerd backend, D-024/G-1/G-2).
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
| 1 | Bootstrap CLI core | REQ-4-001, REQ-4-002 | 3 | cli-engineer, backend-engineer, security-auditor (W3 review) |
|
||||
| 2 | Binary build + release pipeline | REQ-4-003, REQ-4-004 | 3 | cli-engineer, backend-engineer, security-auditor |
|
||||
| 3 | Install docs + fresh-clone E2E | REQ-4-005 | 2 | cli-engineer, backend-engineer |
|
||||
| 4 | Final review + ship | — | 1 | all reviewers |
|
||||
|
||||
## 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 build environment**: file tree, syntax editor, **Run/Test buttons that execute in a namespace sandbox and stream output to a read-only panel** (CUT-2 — no interactive shell), 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.
|
||||
1. **One-liner install (README quickstart):** `curl -fsSL https://git.coreci.dev/coreci/nextcraft/raw/main/scripts/install.sh | bash` — downloads the latest release's `nextcraft-linux-x64` binary, verifies its sha256, installs to `~/.local/bin`, prints a PATH hint if needed.
|
||||
2. **CLI commands:** `nextcraft doctor` (prereq checks), `nextcraft bootstrap` (fresh clone → runnable stack), `nextcraft verify` (health check), `nextcraft dev` (dev server passthrough), plus `--help`/`--version`.
|
||||
3. **Release surface:** every Gitea release from v0.3.2 onward carries `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` assets.
|
||||
|
||||
## 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 clicks **Run**/**Test** → commands execute in the sandbox and real output renders in the build panel; the telemetry sidebar pulses as events stream to ai-service and persist in SQLite
|
||||
3. The Lab panel streams feedback derived from the **live trace digest** (real commands, real failures)
|
||||
4. Learner opens `/defend/comp-01` → **Start 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
|
||||
Before execution, the end-to-end scenario this milestone must make true:
|
||||
|
||||
1. A consumer on a linux x64 box runs the one-liner; `nextcraft` lands in `~/.local/bin`.
|
||||
2. They clone the repo (or the CLI detects the repo root), run `nextcraft doctor` — all prerequisites report ✓ with actionable messages for any gap.
|
||||
3. `nextcraft bootstrap` — pnpm install, ai-service venv via the existing bootstrap.sh, `.env` created from `.env.example`, optional-key warnings (not blockers), mock providers keep the stack runnable keyless.
|
||||
4. `nextcraft verify` — venv imports, ports, env presence, build readiness all ✓.
|
||||
5. `nextcraft dev` — the dev stack runs; Ctrl+C stops it (passthrough semantics).
|
||||
6. On every ship, the Gitea release shows the binary + checksum assets; re-running the one-liner upgrades to the latest binary.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. Run/Test output visibly reflects the real sandbox execution (command round-trip to the sandbox, real stdout/stderr), 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
|
||||
- `doctor` output lists every prerequisite with ✓/✗ and a **fix hint** on every ✗; exit code 1 if any ✗, 0 otherwise.
|
||||
- `bootstrap` is **idempotent** — running twice produces the same end state, second run fast (no reinstalls where avoidable).
|
||||
- `bootstrap` never writes secrets, never blocks on missing optional keys — warns with the exact key names and where to set them.
|
||||
- `verify` gives a single-glance green/red summary; every red item names the failing command it ran.
|
||||
- Every command supports `--help`; unknown command/flag exits 2 with usage.
|
||||
- The one-liner **never hard-fails silently**: any error path (no release, no binary asset, checksum mismatch, platform mismatch) prints a specific message + the source-bootstrap alternative.
|
||||
- Checksum mismatch = hard stop + explicit "do not run this binary" message.
|
||||
- PATH hint: if `~/.local/bin` is not on PATH, the installer prints the exact export line to add.
|
||||
- Binary runs standalone on a box with node NOT installed (SEA self-containment) — `./nextcraft-linux-x64 --version` works.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Bootstrap CLI Core
|
||||
|
||||
**Requirements:** REQ-4-001, REQ-4-002
|
||||
**Goal:** `apps/cli` package with doctor/bootstrap/verify/dev fully working from source (`node dist` + pnpm bin), unit-tested, wired into the monorepo (turbo + root scripts), composing — not duplicating — the existing scripts.
|
||||
|
||||
### Wave 1: Package foundation (parallel)
|
||||
|
||||
#### Task 1-1-01: CLI package scaffold + entry + dispatch
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-001
|
||||
- **Files:** `apps/cli/package.json`, `apps/cli/tsconfig.json`, `apps/cli/src/index.ts`, `apps/cli/src/commands/help.ts` (usage text), `apps/cli/tests/dispatch.test.ts`
|
||||
- **Action:** pnpm workspace package `@nextcraft/cli` (private, `"bin": {"nextcraft": "dist/index.js"}`). Entry: parse argv (hand-rolled, no runtime deps), dispatch to commands, `--help`/`-h`, `--version` (from package.json version), unknown → exit 2 with usage. Exit-code contract: 0 ok / 1 failure / 2 usage. shebang `#!/usr/bin/env node` on the built entry (esbuild banner in P2; for P1 `tsx` runs in dev via package script `"dev": "tsx src/index.ts"`).
|
||||
- **Verify:** `pnpm --filter @nextcraft/cli test` green (dispatch: routes doctor/bootstrap/verify/dev; unknown exits 2; --help exits 0; --version prints package version); `pnpm typecheck` green.
|
||||
|
||||
#### Task 1-1-02: Checks library (pure logic)
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-001, REQ-4-002
|
||||
- **Files:** `apps/cli/src/checks/check-command.ts`, `apps/cli/src/checks/check-env.ts`, `apps/cli/src/lib/log.ts`, `apps/cli/tests/checks.test.ts`
|
||||
- **Action:** `check-command`: given a name + optional `--version` probe + a min-version parser, resolve binary on PATH (`which`), semver-ish compare (major.minor tolerant), return `CheckResult {name, ok, found, version, hint}`. `check-env`: diff `.env.example` template keys vs an existing `.env` (missing keys → warn-classified; required-vs-optional classification table from the template's own comments + a static required list of zero keys — all optional per A-210), return per-key results. `log.ts`: `ok(msg)`, `fail(msg, hint)`, `warn(msg)`, `info(msg)` formatters with symbols and consistent alignment. Pure functions — no side effects at import; fs access injected as parameters for testability.
|
||||
- **Verify:** unit tests green: version compare (>= boundaries), missing binary → ok:false + hint, env diff missing/new/extra keys, required-optional classification.
|
||||
|
||||
#### Task 1-1-03: Root + turbo wiring
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-4-002
|
||||
- **Files:** root `package.json` (update), `turbo.json` (update), `pnpm-workspace.yaml` (verify apps/* already covered — no change expected)
|
||||
- **Action:** Add `cli:dev`, `cli:test`, `cli:build`, `cli:typecheck`, `cli:lint` root scripts mirroring the `ai:*` passthrough pattern (D-022/D-037). Turbo tasks for the CLI package: `build` (dependsOn `^build`, outputs `dist/**`), `test`, `typecheck`, `lint` (cache:false, outputs:[] for test — same shape as ai-service). No changes to existing ai:* tasks.
|
||||
- **Verify:** `pnpm cli:test` + `pnpm cli:typecheck` green from repo root; `pnpm build` still green for web+ai-service (turbo graph unaffected); `pnpm ai:test` still green.
|
||||
|
||||
### Wave 2: Commands (depends on Wave 1)
|
||||
|
||||
#### Task 1-2-01: doctor command
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-001
|
||||
- **Files:** `apps/cli/src/commands/doctor.ts`, `apps/cli/tests/doctor.test.ts`
|
||||
- **Action:** Checks (each with actionable hint): node ≥18 (`process.version`), pnpm ≥8 on PATH (`pnpm --version`), python3 ≥3.11 (`python3 --version` parse), git (`git --version`), corepack available-or-pnpm-present nuance folded into pnpm check, `unshare` binary on PATH (`which unshare` — sandbox fabric needs it; hint explains what breaks without it). Sequential execution with per-check timeout; summary line; exit 1 if any ✗. Runs from any cwd (no repo required — pure environment check).
|
||||
- **Verify:** unit tests with injected spawn results: all-pass → exit 0 + summary; missing pnpm → ✗ + hint + exit 1; missing unshare → ✗ with sandbox-specific hint.
|
||||
|
||||
#### Task 1-2-02: bootstrap command
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-002
|
||||
- **Files:** `apps/cli/src/commands/bootstrap.ts`, `apps/cli/src/lib/spawn.ts`, `apps/cli/tests/bootstrap.test.ts`
|
||||
- **Action:** `spawn.ts`: `run(cmd, args, {timeoutMs, cwd, env})` — promisified child_process.spawn, inherited stdio, timeout kill (SIGTERM→SIGKILL escalation), returns `{code}`; throws never (codes always returned). `bootstrap.ts` steps (each logged before/after): (1) locate repo root (walk up for pnpm-workspace.yaml; error with hint if not in a clone); (2) `pnpm install` at root; (3) delegate ai-service venv to `apps/ai-service/scripts/bootstrap.sh` via spawn with generous timeout (10 min) — **zero pip/venv logic in the CLI** (A-202); (4) copy `.env.example` → `.env` if absent (preserve existing; report created vs kept); (5) validate optional keys in `.env` vs template — warn-only (A-210); never touch `.ciagent/.env.secrets`; (6) print next-steps (`nextcraft verify`, `nextcraft dev`). Idempotent: every step safe to re-run.
|
||||
- **Verify:** unit tests with stub spawn: step order, env copy semantics (absent → create, present → keep), timeout path returns failure code, secrets file never written; `pnpm cli:test` green.
|
||||
|
||||
#### Task 1-2-03: verify + dev commands
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-002
|
||||
- **Files:** `apps/cli/src/commands/verify.ts`, `apps/cli/src/commands/dev.ts`, `apps/cli/tests/verify.test.ts`
|
||||
- **Action:** `verify.ts` health checks (each runnable + reported): ai-service venv python imports (`import ai_service` via venv python), uvicorn present in venv, ports 3000/8420 free (net stat via node), `.env` exists with AI_PORT parseable, `pnpm build` dry readiness (turbo graph parses — run `turbo build --dry=json` cheap check or typecheck-only default; choose the cheap one). Summary + exit code. `dev.ts`: locate repo root, exec passthrough to `apps/ai-service/scripts/dev.sh` with **inherited stdio and signals** (Ctrl+C semantics), no timeout (long-running); document that web dev server runs via `pnpm dev` separately (dev.sh owns ai-service only).
|
||||
- **Verify:** unit tests: verify aggregates check results → exit codes; dev spawns dev.sh with signal passthrough assertions (mock spawn).
|
||||
|
||||
### Wave 3: Integration review (depends on Wave 2)
|
||||
|
||||
#### Task 1-3-01: CLI security + integration review pass
|
||||
- **Persona:** security-auditor — **REQ:** REQ-4-001, REQ-4-002
|
||||
- **Files:** `apps/cli/src/lib/spawn.ts` (review; patch if defect), `apps/cli/src/commands/bootstrap.ts` (review), `apps/cli/tests/**` (add regression if defect found)
|
||||
- **Action:** STRIDE pass on the CLI surface: spawn injection (args never through shell string — array form only), timeout enforcement, secrets never logged, env template copy doesn't overwrite user edits, no shell=true anywhere, PATH resolution honest errors. Findings → P0 patches now with regression tests; P1+ noted for final-phase review.
|
||||
- **Verify:** `pnpm cli:test` green incl. any added regressions; `grep -rn "shell: *true" apps/cli/src` returns nothing.
|
||||
|
||||
### Must-Haves (Phase 1)
|
||||
- [ ] `pnpm --filter @nextcraft/cli test` green; `pnpm typecheck` green; `pnpm build` green
|
||||
- [ ] doctor: every prerequisite reported with ✓/✗ + actionable hint; exit 1 on any ✗; runs outside a repo clone
|
||||
- [ ] bootstrap: composes scripts/bootstrap.sh (no pip/venv logic in CLI); idempotent; .env created from template only when absent; optional-key warnings, never blocks; never writes secrets
|
||||
- [ ] verify: venv import + uvicorn + ports + env checks with single-glance summary and named failing commands
|
||||
- [ ] dev: passthrough with signal inheritance (Ctrl+C stops the stack)
|
||||
- [ ] Exit-code contract: 0/1/2; --help everywhere; unknown command → 2
|
||||
- [ ] No runtime npm dependencies in apps/cli (dev deps only)
|
||||
- [ ] Root `cli:*` scripts work from repo root; ai:* scripts unaffected
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Binary Build + Release Pipeline
|
||||
|
||||
**Requirements:** REQ-4-003, REQ-4-004
|
||||
**Goal:** `nextcraft-linux-x64` SEA binary + sha256 sidecar built reproducibly from the CLI package; one-liner `install.sh` verified end-to-end against a real release; release-asset upload wired so **every ship from now on carries binaries**.
|
||||
|
||||
### Wave 1: Binary build (parallel)
|
||||
|
||||
#### Task 2-1-01: SEA binary build script (G-101: live-build probe FIRST — mechanism must be proven before the pipeline depends on it)
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-004
|
||||
- **Files:** `apps/cli/scripts/build-binary.mjs`, `apps/cli/package.json` (add `build:binary` script), `apps/cli/.sea-config.json` (or generated in-script)
|
||||
- **Action:** **First action of this task: build one real SEA binary end-to-end and run it** (`--version` + `doctor` smoke) before writing the polished script.** Pipeline: esbuild bundle `src/index.ts` → `dist/bundle.cjs` (platform node, target node18, banner shebang, SEA config: `{main: "dist/bundle.cjs", output: "dist/sea-prep.blob", disableExperimentalSEAWarning: true}`) → `node --experimental-sea-config` → copy system node binary → inject blob (`npx postject` with sentinel `NODE_SEA_BLOB_FUSE` fuse, or `dd` fallback) → chmod +x → `dist/nextcraft-linux-x64` → **stamp version from the shipping tag argument** (`NEXTCRAFT_VERSION` injected via esbuild `define`, G-102 — `--version` prints it; absent arg → dev stamp `0.0.0-dev`) → `shasum -a 256` → `dist/nextcraft-linux-x64.sha256`. Fallback (documented, scripted, honest): if SEA injection fails, python3 zipapp builds `nextcraft-linux-x64.pyz` (requires python3 on target — install.sh handles both asset shapes and the docs say so; NO silent claim of node-less operation, G-101).
|
||||
- **Verify:** `pnpm --filter @nextcraft/cli build:binary` produces the binary; `./dist/nextcraft-linux-x64 --version` runs **with node absent from PATH** (test via `env -i /bin/sh -c 'PATH=/usr/bin:/bin ...'` sandbox or by temporarily stripping PATH in a subprocess test); sha256 file matches `shasum -c`.
|
||||
|
||||
#### Task 2-1-02: Release-asset upload helper
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-004
|
||||
- **Files:** `scripts/release-assets.sh`, `apps/cli/tests/release-assets.test.ts` (fixture-level)
|
||||
- **Action:** Given a tag: build binary (Task 2-1-01), resolve GITEA_TOKEN from `.env`/`.env.secrets`/`.env.*` **via the secrets loader only** (never shell env — v1.8 root cause), create/locate the Gitea release via API, upload both assets (`POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets?name=...` multipart). Bounded retry (3) per config.ship.max_release_retries; token never echoed; failure = non-blocking escalation message (release_pending semantics) — tag+merge already complete the ship.
|
||||
- **Verify:** fixture test: token resolution order (.env.secrets wins over .env; shell env NEVER consulted — assert with a poisoned env var fixture); dry-run mode prints the exact curl-multipart it would send (no net in tests).
|
||||
|
||||
#### Task 2-1-03: install.sh one-liner
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-003
|
||||
- **Files:** `scripts/install.sh`, `apps/cli/tests/install-script.test.ts`
|
||||
- **Action:** POSIX sh (no bashisms — dash-safe): `set -eu`; platform check (uname linux + x86_64; else print source-bootstrap path + exit 0 — a graceful no-op, not an error); resolve latest release via Gitea API (`curl -fsSL .../releases/latest`, parse `tag_name` + asset `browser_download_url`s with sed/grep — no jq dependency); **match assets by EXACT name** (`nextcraft-linux-x64`, `nextcraft-linux-x64.sha256` — any parse/lookup miss = degrade to source-bootstrap instructions, exit 0, G-103 — never a name-approximate install); handle the zipapp asset shape (`nextcraft-linux-x64.pyz` + sidecar) when the binary is absent, printing the python3 requirement honestly; download both assets to `mktemp -d` (trap cleanup EXIT); **verify sha256 before anything else** (`shasum -a 256 -c` or sha256sum); on mismatch → hard stop, explicit "do not run" message, exit 1; install to `~/.local/bin` (mkdir -p; `--dest` override); PATH hint when missing (print exact export line); print the binary's own `--version` output (G-102: must equal the resolved release tag — mismatch = install-time integrity stop) + `nextcraft doctor` next-step. No-binary-asset path: print the git-clone + scripts/bootstrap.sh instructions + exit 0. Zero secrets required (public release assets).
|
||||
- **Verify:** unit tests over the script's pure helpers extracted where feasible; **live E2E in Task 2-3-01**. `sh -n scripts/install.sh` syntax-clean; `dash scripts/install.sh --help` safe if dash present.
|
||||
|
||||
### Wave 2: Ship-flow integration (depends on Wave 1)
|
||||
|
||||
#### Task 2-2-01: Wire binaries into every ship (G-104: enforcement, not prose)
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-4-004
|
||||
- **Files:** `.ciagent/config.json` (no schema change needed — release section already configured), this repo's ship procedure notes (update `.ciagent/ARCHITECTURE.md` Build Order note if needed), `scripts/release-assets.sh` (finalize from 2-1-02)
|
||||
- **Action:** Establish the ship-time contract going forward: after every phase ship (tag + merge complete = ship gate per config.ship), run `scripts/release-assets.sh <tag>` to attach binary + checksum to the freshly created release. **G-104:** this run is MANDATORY-ATTEMPTED on every release from v0.3.2 onward — best-effort/non-blocking like release creation (release_pending escalation on exhaustion), logged in the ship commit, and the P4 final audit gate includes "milestone release carries both assets" as an explicit check. This makes "ongoing binaries" a property of the pipeline, not a one-off.
|
||||
- **Verify:** The P2 ship itself executes the step against tag v0.3.2 (live validation — see Ship).
|
||||
|
||||
### Wave 3: End-to-end validation (depends on Wave 2)
|
||||
|
||||
#### Task 2-3-01: Install E2E against the live release
|
||||
- **Persona:** security-auditor — **REQ:** REQ-4-003
|
||||
- **Files:** `apps/cli/tests/install-e2e.test.ts` (marked slow/e2e), `apps/cli/README.md` (install internals section)
|
||||
- **Action:** Live E2E after the v0.3.2 release exists (run post-ship, documented as the verify gate for this phase's asset path): fresh HOME tmpdir → run install.sh → assert binary at `$HOME/.local/bin/nextcraft`, `--version` output equals the release tag (G-102 integrity assertion), checksum verified path taken (tamper test: flip a byte in a local fixture download → script refuses + exits 1). Record the transcript in the phase verify commit. If the live release isn't reachable at verify time, run the full local equivalent (serve assets from a fixture dir via `python3 -m http.server` + FORGE_BASE override) and mark live re-check as a P1 follow-up.
|
||||
- **Verify:** E2E green locally (fixture server path mandatory in tests — no test depends on the live forge); tamper-rejection proven; transcript recorded.
|
||||
|
||||
### Must-Haves (Phase 2)
|
||||
- [ ] **G-101:** a real SEA binary built + smoke-run BEFORE the pipeline depends on it; if SEA fails, zipapp is primary and docs state the python3 requirement
|
||||
- [ ] **G-102:** binary `--version` reports the shipping tag (stamped at build); install E2E asserts version == release tag
|
||||
- [ ] `pnpm --filter @nextcraft/cli build:binary` produces `nextcraft-linux-x64` + `.sha256`; binary runs without node on PATH (`--version`, `doctor` smoke)
|
||||
- [ ] **G-103:** `sh -n scripts/install.sh` clean; dash-safe; exact-name asset matching; platform mismatch → graceful source-bootstrap path (exit 0)
|
||||
- [ ] Checksum verified before install; tamper → hard stop with explicit warning (E2E-proven)
|
||||
- [ ] install.sh resolves latest release + assets from the Gitea API with zero secrets and no jq
|
||||
- [ ] release-assets.sh resolves GITEA_TOKEN from .env* files only (never shell env — tested with poisoned env)
|
||||
- [ ] **G-104:** v0.3.2 release carries both assets (live validation at ship); upload failure is non-blocking escalation, attempted + logged every release
|
||||
- [ ] `pnpm build`, `pnpm typecheck`, `pnpm cli:test` all green
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Install Docs + Fresh-Clone E2E
|
||||
|
||||
**Requirements:** REQ-4-005
|
||||
**Goal:** README quickstart + CLI reference matching the tested reality exactly, plus a fresh-clone E2E test proving the happy path end-to-end.
|
||||
|
||||
### Wave 1: Fresh-clone E2E (drives doc accuracy)
|
||||
|
||||
#### Task 3-1-01: Fresh-clone bootstrap E2E
|
||||
- **Persona:** cli-engineer — **REQ:** REQ-4-005
|
||||
- **Files:** `apps/cli/tests/fresh-clone-e2e.test.ts` (slow/e2e-marked)
|
||||
- **Action:** In a `mktemp -d` sandbox: `git clone` the repo locally (file:// clone of HEAD — no network), run `pnpm --filter @nextcraft/cli dev -- doctor` (or the built binary from P2) → then `bootstrap` → then `verify`, asserting each step's exit codes and key output markers. Skips gracefully when network-dependent steps are unavailable (CI marker). Documents the exact happy path the README will state.
|
||||
- **Verify:** E2E green locally (clone of the working tree); output transcript matches README claims (cross-checked in 3-2-01).
|
||||
|
||||
### Wave 2: Documentation (depends on Wave 1 transcript)
|
||||
|
||||
#### Task 3-2-01: README quickstart + CLI reference
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-4-005
|
||||
- **Files:** root `README.md` (update quickstart section), `apps/cli/README.md` (CLI reference)
|
||||
- **Action:** Root README quickstart: the one-liner (exact tested URL), then doctor → bootstrap → verify → dev sequence with expected outputs; source-bootstrap alternative documented (clone + scripts). apps/cli README: every command, flags, exit codes, the env-template copy semantics, optional-key warning semantics, secrets policy (never generated/committed; .ciagent/.env.secrets location), binary install internals, troubleshooting table keyed to actual failure modes observed in E2E.
|
||||
- **Verify:** Every command line in both READMEs is copy-paste runnable — verified against the 3-1-01 transcript; doc drift check: no references to commands/flags that don't exist in `--help` output.
|
||||
|
||||
### Must-Haves (Phase 3)
|
||||
- [ ] Fresh-clone E2E green: doctor → bootstrap → verify sequence from a clean clone
|
||||
- [ ] README quickstart matches the E2E transcript exactly (no aspirational docs)
|
||||
- [ ] CLI reference covers all 4 commands + --help/--version + exit codes
|
||||
- [ ] `pnpm build`, `pnpm typecheck`, `pnpm test` (all suites) green
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Final Review + Ship (milestone release v0.3.4)
|
||||
|
||||
1. Branch gate → `phase/04-final-review-ship`.
|
||||
2. Multi-persona review across the milestone (correctness, testing, security, performance, maintainability, adversarial) — P0 auto-fixed, P1+ fixed in this phase.
|
||||
3. Audit: reconstruction test (.ciagent files ↔ git log), file discipline, branch hygiene, commit discipline, P0-review flags resolved, **G-104 gate: milestone release v0.3.4 carries `nextcraft-linux-x64` + `.sha256` assets**.
|
||||
4. Milestone ship: merge phase/04 → milestone/v0.4-distribution; merge milestone → main; tag **v0.3.4** (= milestone release); attach binary + checksum assets (the ongoing-binaries contract); release notes with full milestone summary (all phases, all REQ-4-001..005, the "ongoing binaries from now on" statement, v0.5 deferral list per D-016); delete all milestone/phase branches.
|
||||
5. Complete: REQUIREMENTS.md REQ-4-001..005 → complete; ROADMAP.md v0.4 → complete; checkpoint cleared.
|
||||
|
||||
## Must-Haves (Milestone)
|
||||
- [ ] One-liner installs a working binary from the live Gitea release (E2E-proven, tamper-tested)
|
||||
- [ ] Fresh clone → doctor → bootstrap → verify → dev: the full happy path green from a clean environment
|
||||
- [ ] Every release from v0.3.2 onward carries `nextcraft-linux-x64` + `.sha256` assets
|
||||
- [ ] Zero runtime npm deps in the CLI; secrets only ever from .env* files; never in code/logs/commits
|
||||
- [ ] All suites green: `pnpm build`, `pnpm typecheck`, `pnpm ai:test`, `pnpm cli:test`
|
||||
+30
-13
@@ -8,13 +8,17 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Current Milestone: v0.3 — Credential Engines
|
||||
## Current Milestone: v0.4 — Distribution & Bootstrap CLI
|
||||
|
||||
**Scope:** Replace v0.2's mock engine inputs with real credential engines. Build the sandbox fabric (sandboxed IDE / design tool / simulation), the live in-environment build-telemetry pipeline, the process-trace grading engine, per-learner variant task generation, and the oral/voice defense with AI examiner. Lab/Assessor/Proctor agents move from mock inputs to real engine inputs; the six tutor agents operate on authentic telemetry and artifacts.
|
||||
**Scope (founder directive, 2026-09-12):** Streamline installing Nextcraft. Ship a bootstrap CLI with a single-liner install script, and publish release binaries on an ongoing basis for every release going forward. The previously-named v0.4 seams (real server STT/TTS, KYC/identity, design/simulation sandbox environments, exec-telemetry seq-lease) are **re-scoped to v0.5**.
|
||||
|
||||
**Deliverables:** (1) `nextcraft` CLI — `doctor` (prerequisite checks), `bootstrap` (deps + venv + env from templates + key validation), `verify` (health check), `dev` (thin passthrough to scripts/dev.sh); (2) one-liner install script downloading the linux x64 binary from the latest Gitea release; (3) binary build + checksum + release-asset pipeline wired into every ship; (4) install/quickstart documentation.
|
||||
|
||||
**Status of v0.3:** Complete and shipped (v0.2.8). Credential engines live: namespace-isolated sandbox fabric, live build telemetry, process-trace grading, seeded variants, oral defense; real learner build/defense/grading surfaces.
|
||||
|
||||
**Status of v0.2:** Complete and shipped (v0.2.0). Six AI tutor agents live over mockengine inputs (D-015).
|
||||
|
||||
**Deferred from earlier plan:** REQ-F-017 (identity verification + 16+/18+ age-gating) is explicitly deferred to a later milestone per founder directive. Age-gating remains the v0.1-style visual flow mockup; no real KYC backend is built in v0.3.
|
||||
**Deferred from earlier plan:** REQ-F-017 (identity verification + age-gating KYC), real server STT/TTS, design/simulation sandbox environments, and the exec-telemetry seq-lease are all deferred to v0.5. Age-gating remains the v0.1-style visual flow mockup.
|
||||
|
||||
**Tech stack:** v0.1 TS monorepo (pnpm/turborepo, Next.js) + v0.2 Python FastAPI ai-service + new credential-engine services (sandbox fabric orchestrator, telemetry ingest, grading engine) in Python/TypeScript as determined at RESEARCH.
|
||||
|
||||
@@ -22,24 +26,36 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
## Requirements (Validated)
|
||||
|
||||
The following requirements have been validated during specification and are locked for milestone v0.3 (REQ-F-007..010 and REQ-F-021 activated from the deferred pool; REQ-F-017 deferred per founder directive):
|
||||
The following requirements are locked for milestone v0.4 (Distribution & Bootstrap CLI) per the founder directive of 2026-09-12:
|
||||
|
||||
1. Sandbox fabric — sandboxed IDE, design tool, and simulation environments with isolated execution and lifecycle management (REQ-F-021)
|
||||
2. Live build telemetry — in-environment capture of process events (keystrokes, commands, file diffs, run/test results) streamed to ai-service (REQ-F-010)
|
||||
3. Process-trace grading engine — grade artifacts from their process traces, not just final output (REQ-F-007); feeds the Assessor agent real inputs
|
||||
4. Variant task generation — per-learner task variants so no two learners receive identical prompts (REQ-F-008)
|
||||
5. Oral/voice defense — AI examiner conducts spoken defense of submitted work (REQ-F-009); feeds the Proctor/Mentor agents
|
||||
6. Agent re-grounding — Lab/Assessor/Proctor consume real engine inputs (telemetry, traces, defenses) instead of v0.2 mocks
|
||||
7. Learner surface integration — wire the v0.1 sandbox + assessment mockups to the real engines (build/run in-browser, live telemetry, live defense)
|
||||
1. Bootstrap CLI — `nextcraft` executable with `doctor` / `bootstrap` / `verify` / `dev` commands covering prerequisite checks, monorepo bootstrap, health verification, and dev-server orchestration (REQ-4-001, REQ-4-002)
|
||||
2. One-liner install — `curl | bash` style install script fetching the linux x64 binary from the latest Gitea release with checksum verification (REQ-4-003)
|
||||
3. Ongoing release binaries — every release from v0.4 onward ships a linux x64 CLI binary + checksum as release assets (REQ-4-004)
|
||||
4. Install documentation — README quickstart + CLI reference so a fresh clone reaches a running dev stack in one command (REQ-4-005)
|
||||
|
||||
## v0.2 Requirements (Complete)
|
||||
## v0.3 Requirements (Complete)
|
||||
|
||||
All 12 v0.2 requirements (REQ-2-001..012) are complete and shipped as v0.2.0. See REQUIREMENTS.md traceability matrix.
|
||||
All 8 v0.3 requirements (REQ-3-001..008) are complete and shipped as v0.2.8. See REQUIREMENTS.md traceability matrix.
|
||||
|
||||
## v0.1 Requirements (Complete)
|
||||
|
||||
All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See REQUIREMENTS.md traceability matrix.
|
||||
|
||||
## Clarified Assumptions (v0.4 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
|---|-----------|------------|-------------|
|
||||
| A-201 | CLI language/toolchain for the binary? | **Probe-driven at RESEARCH** — Go → Rust → Node SEA → Python zipapp fallback chain; spec stays toolchain-agnostic so PLAN locks the probe-verified toolchain | 0.70 |
|
||||
| A-202 | Does bootstrap replace scripts/bootstrap.sh? | **No — reuse it.** CLI wraps existing `scripts/bootstrap.sh` + `scripts/dev.sh` via subprocess; zero orchestration logic duplicated in the CLI (thin passthrough pattern) | 0.85 |
|
||||
| A-203 | Where does the one-liner fetch the binary? | **Gitea latest-release API** (`/repos/{owner}/{repo}/releases/latest`) → download `nextcraft-linux-x64` + `.sha256` asset; repo raw serves `install.sh` as the stable URL | 0.80 |
|
||||
| A-204 | Install target + PATH? | **~/.local/bin** (XDG-style, no sudo), PATH hint printed when missing; `--dest` override flag | 0.85 |
|
||||
| A-205 | Binary "ongoing releases" scope? | **Every ship from v0.4 onward** attaches `nextcraft-linux-x64` + sha256 sidecar to the Gitea release — the ship workflow gains an asset step; retroactive binaries for old releases NOT required | 0.90 |
|
||||
| A-206 | No binary available yet / non-linux? | **Graceful degradation**: install script prints source-bootstrap instructions (git clone + scripts/bootstrap.sh) — never a hard fail | 0.88 |
|
||||
| A-207 | Checksum trust root? | **sha256 sidecar shipped as a release asset next to the binary** (same release, same channel); script verifies download against it. Signature/PKI out of scope for v0.4 (single forge, TLS transport) | 0.75 |
|
||||
| A-208 | Which prerequisites does doctor check? | node ≥18, pnpm ≥8, python3 ≥3.11, git, `unshare` availability (sandbox fabric needs it) — versions from the existing bootstrap tooling, not invented | 0.85 |
|
||||
| A-209 | Does `dev` manage multiple processes? | **No.** Thin passthrough to scripts/dev.sh only — the CLI stays bootstrap-scoped (D-016); orchestration remains in dev.sh | 0.82 |
|
||||
| A-210 | `.env.secrets` handling by bootstrap? | **Template copy only for `.env.example` → `.env`; secrets NEVER generated, NEVER committed; bootstrap validates presence of optional keys and warns (not blocks) when missing — mock-first providers keep the stack runnable** | 0.90 |
|
||||
|
||||
## Clarified Assumptions (v0.3 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
@@ -133,6 +149,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
| D-013 | v0.1 prototype founder-agreed; D-001 business-logic gate unlocked | Founder approved starting v0.2 with AI Tutor Architecture, which constitutes agreement of the v0.1 prototype per D-001. Recorded at v0.2 SPECIFY. | Business logic authorized from v0.2 onward |
|
||||
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
|
||||
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
|
||||
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.4 Requirements (Distribution & Bootstrap CLI)
|
||||
|
||||
### Bootstrap CLI
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-4-001 | `nextcraft` CLI (linux x64 binary): `doctor` command checking prerequisites (node, pnpm, python3, git, unshare) with actionable error messages | critical | 1 | complete |
|
||||
| REQ-4-002 | `bootstrap` command: pnpm install, ai-service venv + pinned deps, .env from templates, key validation, .env.secrets handling; `verify` health check (ports, imports, builds); `dev` thin passthrough to scripts/dev.sh | critical | 1 | complete |
|
||||
|
||||
### Distribution
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-4-003 | One-liner install script (`curl -fsSL <url> \| bash`): detects linux x64, resolves latest release from Gitea API, downloads binary + checksum, verifies sha256, installs to ~/.local/bin (PATH hint), degrades to source-bootstrap instructions when no binary | critical | 2 | complete |
|
||||
| REQ-4-004 | Binary release pipeline: reproducible linux x64 build script, sha256 checksum sidecar, upload as release assets on every ship from v0.4 onward (ongoing binaries requirement) | critical | 2 | complete |
|
||||
| REQ-4-005 | Install + quickstart documentation: README one-liner quickstart, CLI command reference, fresh-clone-to-running-stack end-to-end verification | high | 3 | pending |
|
||||
|
||||
## v0.3 Requirements (Credential Engines)
|
||||
|
||||
### Sandbox & Telemetry
|
||||
@@ -170,7 +187,17 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.3 (current milestone)
|
||||
### v0.4 (current milestone)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-4-001 | 1 | complete |
|
||||
| REQ-4-002 | 1 | complete |
|
||||
| REQ-4-003 | 2 | complete |
|
||||
| REQ-4-004 | 2 | complete |
|
||||
| REQ-4-005 | 3 | pending |
|
||||
|
||||
### v0.3 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
|
||||
+55
-108
@@ -2,17 +2,17 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.3 — COMPLETE (shipped as v0.2.8, 2026-09-12).** Next milestone: v0.4 (real server STT/TTS + KYC/identity + design/simulation sandbox environments + exec-telemetry seq-lease — the deferred seams named in the v0.2.8 release note).
|
||||
**Milestone v0.4 — Distribution & Bootstrap CLI (founder directive, 2026-09-12).** Streamline installing Nextcraft: a `nextcraft` bootstrap CLI shipped as a linux x64 binary, installed via a one-liner script, with binaries published on every ongoing release. Next milestone: v0.5 (real server STT/TTS + KYC/identity + design/simulation sandbox environments + exec-telemetry seq-lease — the seams deferred out of v0.4 by the founder directive).
|
||||
|
||||
**Milestone v0.3** — Credential Engines: Replace v0.2's mock engine inputs with real credential engines. Build the sandbox fabric (sandboxed IDE / design tool / simulation), the live in-environment build-telemetry pipeline, the process-trace grading engine, per-learner variant task generation, and the oral/voice defense with AI examiner. Lab/Assessor/Proctor agents move from mock inputs to real engine inputs.
|
||||
**Milestone v0.3** — Credential Engines: complete, shipped as v0.2.8 (2026-09-12). Real sandbox fabric, live build telemetry, process-trace grading, per-learner variants, oral defense, real learner surfaces.
|
||||
|
||||
**Deferred per founder directive:** REQ-F-017 identity verification + age-gating (real KYC backend) is deferred beyond v0.3. Age-gating remains the v0.1 visual flow mockup.
|
||||
**Deferred per founder directive (D-016):** REQ-F-017 identity verification + age-gating (real KYC backend), real server STT/TTS, design/simulation sandbox environments, and the exec-telemetry seq-lease are deferred to v0.5. Age-gating remains the v0.1 visual flow mockup.
|
||||
|
||||
**Prior milestone:** v0.2 (ai-tutor-architecture) — complete, shipped as v0.2.0, six tutor agents live over mock engine inputs (D-015).
|
||||
|
||||
**Milestone type:** Feature (new credential-engine services + real agent inputs)
|
||||
**Tag line:** v0.2.x (patches on the v0.2 line; milestone release as the final v0.2.x patch)
|
||||
**Branch:** milestone/v0.3-credential-engines
|
||||
**Milestone type:** Feature (new CLI + distribution pipeline)
|
||||
**Tag line:** v0.3.x (patches on the v0.3 line; milestone release as the final v0.3.x patch)
|
||||
**Branch:** milestone/v0.4-distribution
|
||||
|
||||
---
|
||||
|
||||
@@ -20,14 +20,11 @@
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan complete; .ciagent/ files updated for v0.3 |
|
||||
| 1 | Sandbox fabric | complete | 0 | REQ-3-001, REQ-3-002 | Isolated per-learner sandbox environments provisioned (IDE / design / simulation); lifecycle API (create/destroy/snapshot); resource limits enforced; no cross-tenant access |
|
||||
| 2 | Live build telemetry | complete | 1 | REQ-3-003 | In-environment capture of process events (commands, file diffs, run/test results, keystroke-level activity) streamed to ai-service; reliable transport; per-learner trace persistence |
|
||||
| 3 | Process-trace grading engine | complete | 2 | REQ-3-004 | Grades artifacts from their full process traces (not just final output); emits structured rubric-aligned scores; feeds Assessor real inputs |
|
||||
| 4 | Variant task generation | complete | 1 | REQ-3-005 | Per-learner task variants generated so no two learners receive identical prompts; variant seed recorded for grading fairness |
|
||||
| 5 | Oral / voice defense | complete | 3 | REQ-3-006 | AI examiner conducts spoken defense of submitted work; STT → dialogue → TTS; transcript + integrity signals captured; feeds Proctor/Mentor |
|
||||
| 6 | Agent re-grounding + learner surface integration | complete | 2,3,4,5 | REQ-3-007, REQ-3-008 | Lab/Assessor/Proctor consume real engine inputs; v0.1 sandbox + assessment mockups wired to real engines (in-browser build/run, live telemetry, live defense) |
|
||||
| 7 | Final review + ship | complete | 6 | — | Code review clean; audit passes; milestone tagged (v0.2.x final patch); release created on Gitea |
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan complete; .ciagent/ files updated for v0.4 |
|
||||
| 1 | Bootstrap CLI core | complete | 0 | REQ-4-001, REQ-4-002 | `nextcraft doctor/bootstrap/verify/dev` work against a fresh clone; unit tests green |
|
||||
| 2 | Binary build + release pipeline | complete | 1 | REQ-4-003, REQ-4-004 | Reproducible linux x64 binary + sha256 checksum; one-liner install script; assets uploaded to the Gitea release |
|
||||
| 3 | Install docs + fresh-clone E2E | pending | 2 | REQ-4-005 | README quickstart verified end-to-end from a clean environment; fresh clone reaches running stack |
|
||||
| 4 | Final review + ship | pending | 3 | — | Code review clean; audit passes; milestone tagged (v0.3.x final patch); release with binary assets created on Gitea |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,148 +32,98 @@
|
||||
|
||||
### Phase 0: Pre-execution
|
||||
|
||||
**Goal:** Establish v0.3 specification, clarify ambiguities, research credential-engine architecture (sandbox isolation, telemetry transport, trace grading, variant generation, voice IO), create detailed plans.
|
||||
**Goal:** Establish v0.4 specification (founder directive D-016), clarify ambiguities, research the binary toolchain + Gitea release-asset API + existing bootstrap scripts, create detailed plans, grill adversarially.
|
||||
|
||||
**Stages:** SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → MVP/UX CHECK → SHIP
|
||||
|
||||
**Deliverables:**
|
||||
- Updated .ciagent/config.json, PROJECT.md, REQUIREMENTS.md, ROADMAP.md, ARCHITECTURE.md, PERSONAS.md, PLAN.md
|
||||
|
||||
**Success criteria:** All .ciagent/ files updated for v0.3; phase 0 shipped as first v0.2.x patch.
|
||||
**Success criteria:** All .ciagent/ files updated for v0.4; phase 0 shipped as v0.3.0.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Sandbox Fabric
|
||||
### Phase 1: Bootstrap CLI Core
|
||||
|
||||
**Goal:** Provision and manage isolated per-learner execution environments.
|
||||
**Goal:** A working `nextcraft` CLI with doctor/bootstrap/verify/dev commands, unit-tested against the real monorepo.
|
||||
|
||||
**Requirements:** REQ-3-001, REQ-3-002
|
||||
**Requirements:** REQ-4-001, REQ-4-002
|
||||
|
||||
**Key deliverables:**
|
||||
- Sandbox orchestrator service: create/list/destroy/snapshot sandbox instances (coding IDE — design tool and simulation environments deferred to v0.4 per D-025)
|
||||
- Isolation boundary: per-learner Linux user/mount/pid/net namespace subprocess isolation (`unshare`, D-024); no cross-tenant filesystem/network access
|
||||
- Resource limits: CPU/memory/single-file-size quotas (rlimits) + wall-clock timeout reaper + best-effort workdir-size sweep; per-sandbox pids + hard disk quota accepted as v0.3 gaps (G-1/G-2)
|
||||
- Sandbox lifecycle API consumed by ai-service and the web learner surface
|
||||
- `apps/cli` package: `nextcraft` executable (source-runnable in dev, binary-built in P2)
|
||||
- `doctor`: checks node ≥18, pnpm, python3 ≥3.11, git, unshare availability — actionable errors, exit codes
|
||||
- `bootstrap`: idempotent — pnpm install, ai-service venv + pinned deps (reuses scripts/bootstrap.sh logic), .env from .env.example templates, key validation (warnings not blockers for optional keys), .env.secrets handling
|
||||
- `verify`: health check — venv imports, pnpm build readiness, ports free, env vars present
|
||||
- `dev`: thin passthrough to scripts/dev.sh (no orchestration logic duplicated)
|
||||
- Unit tests: doctor/bootstrap parsing + command dispatch, against fixtures (never modifying the real repo state)
|
||||
|
||||
**Success criteria:**
|
||||
- A sandbox can be created, written to, snapshotted, and destroyed via API
|
||||
- Isolation verified: a sandbox cannot read another learner's data
|
||||
- Resource limits enforced and observable — enforcement mechanism: rlimits (memory/CPU) + wall-clock reaper + workdir-size sweep; per-sandbox pids and hard-disk-quota are accepted v0.3 gaps (no cgroup delegation/sudo on this box, G-1)
|
||||
- `nextcraft doctor` reports each prerequisite with actionable guidance
|
||||
- `nextcraft bootstrap` on a fresh clone reaches a state where `verify` passes
|
||||
- All commands have `--help`, exit non-zero on failure, no shell-out without timeout
|
||||
- `pnpm build`, `pnpm typecheck`, `pnpm ai:test` green
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Live Build Telemetry
|
||||
### Phase 2: Binary Build + Release Pipeline
|
||||
|
||||
**Goal:** Capture in-environment process events and stream them to ai-service reliably.
|
||||
**Goal:** Reproducible linux x64 binary + one-liner install + release-asset upload wired into the ship flow.
|
||||
|
||||
**Requirements:** REQ-3-003
|
||||
**Requirements:** REQ-4-003, REQ-4-004
|
||||
|
||||
**Key deliverables:**
|
||||
- Telemetry capture agent (in-sandbox): commands, file diffs, run/test results, keystroke-level/activity events
|
||||
- Telemetry transport: durable, ordered, resumable stream to ai-service ingestion endpoint
|
||||
- Trace persistence: per-learner, per-task process traces stored for grading and proctoring
|
||||
- Transport hardening: retries, backpressure, exactly-once-or-at-least-once semantics documented
|
||||
- Build script producing `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` (toolchain probe-verified at RESEARCH; embedded script assets)
|
||||
- One-liner install script `install.sh` served from the repo: detect linux x64, resolve latest release via Gitea API, download + verify checksum, install to `~/.local/bin`, PATH hint, source-bootstrap fallback when no binary/asset
|
||||
- Ship integration: every release from v0.4 onward attaches the binary + checksum as release assets (the "ongoing binaries" requirement)
|
||||
- Asset-upload helper using the Gitea token from `.env*` files only (never shell env)
|
||||
|
||||
**Success criteria:**
|
||||
- Sandbox activity produces a complete ordered process trace in ai-service
|
||||
- Stream survives transient network failure without trace loss
|
||||
- Trace retrievable by learner+task ID for grading
|
||||
- Binary runs on this box: `./nextcraft-linux-x64 doctor` green against the repo
|
||||
- Install script verified end-to-end against the real Gitea release (or local dry-run if release pending)
|
||||
- Checksum verification rejects a corrupted download (tested)
|
||||
- Release assets present on the phase ship
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Process-Trace Grading Engine
|
||||
### Phase 3: Install Docs + Fresh-Clone E2E
|
||||
|
||||
**Goal:** Grade learner artifacts from their full process traces.
|
||||
**Goal:** Documentation and end-to-end proof that a fresh consumer reaches a running stack via the one-liner.
|
||||
|
||||
**Requirements:** REQ-3-004
|
||||
**Requirements:** REQ-4-005
|
||||
|
||||
**Key deliverables:**
|
||||
- Trace analyzer: reconstructs build/decision timeline from a process trace
|
||||
- Grading engine: rubric-aligned scoring over the trace (process quality, not just final artifact)
|
||||
- Structured score output consumable by the Assessor agent
|
||||
- Calibration against v0.2 mock corpora to validate grading dimensions
|
||||
- README quickstart: one-liner → `nextcraft doctor` → `nextcraft bootstrap` → `nextcraft dev`
|
||||
- CLI command reference (all flags, exit codes)
|
||||
- Fresh-clone E2E test: clean temp clone → doctor → bootstrap → verify → build green (sandboxed; no network beyond package registries already used)
|
||||
- Install-script docs: prerequisites, offline/manual install, troubleshooting
|
||||
|
||||
**Success criteria:**
|
||||
- Engine emits structured rubric-aligned scores from a real process trace
|
||||
- Scores distinguish process quality (e.g., iterative debugging vs. paste-and-run)
|
||||
- Output feeds Assessor; replaces pre-baked artifact corpus inputs
|
||||
- A fresh clone bootstraps to a passing `verify` with one command sequence
|
||||
- README quickstart matches the actual tested flow exactly
|
||||
- E2E test green in CI-equivalent local run
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Variant Task Generation
|
||||
### Phase 4: Final Review + Ship
|
||||
|
||||
**Goal:** Generate per-learner task variants so no two learners receive identical prompts.
|
||||
|
||||
**Requirements:** REQ-3-005
|
||||
|
||||
**Key deliverables:**
|
||||
- Variant generator: parameterized task templates → unique per-learner instances
|
||||
- Variant seed registry: record variant parameters for grading fairness and proctoring
|
||||
- Difficulty normalization: variants calibrated to equivalent difficulty
|
||||
|
||||
**Success criteria:**
|
||||
- Two learners requesting the same competency receive distinct task variants
|
||||
- Variant parameters persisted and auditable
|
||||
- Grading engine scores variants equitably
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Oral / Voice Defense
|
||||
|
||||
**Goal:** AI examiner conducts a spoken defense of the learner's submitted work.
|
||||
|
||||
**Requirements:** REQ-3-006
|
||||
|
||||
**Key deliverables:**
|
||||
- Voice pipeline: STT → defense dialogue (LLM examiner) → TTS
|
||||
- Examiner agent: probes understanding, challenges process choices from the trace
|
||||
- Transcript + integrity signals captured for Proctor/Mentor
|
||||
- Latency budget: defense feels conversational (bounded turn latency)
|
||||
|
||||
**Success criteria:**
|
||||
- A spoken defense runs end-to-end (speak → examiner question → learner response → verdict)
|
||||
- Transcript + integrity signals persisted and consumable by Proctor
|
||||
- Turn latency within the documented budget
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Agent Re-grounding + Learner Surface Integration
|
||||
|
||||
**Goal:** Move Lab/Assessor/Proctor to real engine inputs; wire learner surface to the real engines.
|
||||
|
||||
**Requirements:** REQ-3-007, REQ-3-008
|
||||
|
||||
**Key deliverables:**
|
||||
- Lab agent consumes live sandbox telemetry (replaces v0.2 mock telemetry)
|
||||
- Assessor agent consumes grading-engine output (replaces pre-baked artifacts)
|
||||
- Proctor consumes telemetry + defense integrity signals (replaces mock telemetry)
|
||||
- Learner sandbox mockup → real in-browser build/run; assessment mockup → live defense + live grading
|
||||
|
||||
**Success criteria:**
|
||||
- Lab/Assessor/Proctor operate on real inputs with no mock fallback in the learner path
|
||||
- Learner can build in-browser and see live telemetry + live feedback
|
||||
- Assessment surface runs a live defense and shows live grading
|
||||
- `pnpm build` and `pnpm typecheck` pass
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Final Review + Ship
|
||||
|
||||
**Goal:** Code review, audit, milestone release.
|
||||
**Goal:** Code review, audit, milestone release with binary assets.
|
||||
|
||||
**Key deliverables:**
|
||||
- Multi-persona code review (correctness, testing, security, performance, maintainability)
|
||||
- Project health audit (reconstruction test, .ciagent/ file discipline, branch hygiene, commit discipline)
|
||||
- Milestone ship: merge milestone → main, tag final v0.2.x patch, create Gitea release
|
||||
- Milestone ship: merge milestone → main, tag final v0.3.x patch, create Gitea release WITH binary + checksum assets, verify assets downloadable
|
||||
|
||||
**Success criteria:**
|
||||
- Code review: P0 fixes applied, P1+ documented
|
||||
- Audit: all checks pass, project state reconstructable from git log
|
||||
- Ship: milestone tagged, branch merged to main, Gitea release created — release note states identity/age-gating (KYC) is deferred and age-gating remains a visual mockup
|
||||
- All v0.3 requirements marked complete
|
||||
- Ship: milestone tagged, branch merged to main, Gitea release created with `nextcraft-linux-x64` + `.sha256` assets attached — the first of the ongoing binary releases
|
||||
|
||||
---
|
||||
|
||||
## v0.4 (In Progress — Distribution & Bootstrap CLI)
|
||||
|
||||
Distribution & Bootstrap CLI: `nextcraft` CLI (doctor/bootstrap/verify/dev), one-liner install, linux x64 release binaries on every ongoing release, install/quickstart docs. 5 phases (P0–P4). Requirements REQ-4-001..005.
|
||||
|
||||
## v0.3 (Complete — Shipped as v0.2.8)
|
||||
|
||||
Credential Engines: real sandbox fabric (Linux namespaces), live build telemetry
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.3",
|
||||
"name": "credential-engines",
|
||||
"version": "v0.4",
|
||||
"name": "distribution",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.3-credential-engines"
|
||||
"branch": "milestone/v0.4-distribution"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@nextcraft/cli",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nextcraft": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:binary": "node scripts/build-binary.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^5.7.2",
|
||||
"esbuild": "0.28.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pkgDir = dirname(fileURLToPath(import.meta.url)) + "/..";
|
||||
const dist = join(pkgDir, "dist");
|
||||
const bundle = join(dist, "bundle.cjs");
|
||||
const blob = join(dist, "sea-prep.blob");
|
||||
const config = join(dist, "sea-config.json");
|
||||
const out = join(dist, "nextcraft-linux-x64");
|
||||
const checksum = out + ".sha256";
|
||||
const version = process.argv[2] ?? "0.0.0-dev";
|
||||
|
||||
if (version !== "0.0.0-dev" && !/^v?\d/.test(version)) {
|
||||
console.error(`refusing to stamp implausible version: ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
execFileSync(
|
||||
join(pkgDir, "node_modules/.bin/esbuild"),
|
||||
[
|
||||
join(pkgDir, "src/index.ts"),
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--format=cjs",
|
||||
"--target=node18",
|
||||
`--define:NEXTCRAFT_VERSION_STAMP=${JSON.stringify(version)}`,
|
||||
"--outfile=" + bundle,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
const seaConfig = {
|
||||
main: bundle,
|
||||
output: blob,
|
||||
disableExperimentalSEAWarning: true,
|
||||
};
|
||||
writeFileSync(config, JSON.stringify(seaConfig));
|
||||
execFileSync(process.execPath, ["--experimental-sea-config", config], { stdio: "inherit" });
|
||||
|
||||
const nodeBin = process.execPath;
|
||||
copyFileSync(nodeBin, out);
|
||||
execFileSync(
|
||||
"npx",
|
||||
["--yes", "postject", out, "NODE_SEA_BLOB", blob, "--sentinel-fuse", "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
execFileSync("chmod", ["+x", out]);
|
||||
|
||||
const size = statSync(out).size;
|
||||
const hash = createHash("sha256").update(readFileSync(out)).digest("hex");
|
||||
writeFileSync(checksum, `${hash} nextcraft-linux-x64\n`);
|
||||
|
||||
console.log(`built ${out} (${(size / 1024 / 1024).toFixed(1)} MB) stamped ${version}`);
|
||||
console.log(`checksum ${checksum}: ${hash}`);
|
||||
if (!existsSync(out) || !existsSync(checksum)) {
|
||||
console.error("expected artifacts missing");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = parse(a);
|
||||
const pb = parse(b);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (pa[i] > pb[i]) return 1;
|
||||
if (pa[i] < pb[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parse(v: string): [number, number] {
|
||||
const clean = v.trim().replace(/^v/i, "");
|
||||
const dotted = clean.match(/(\d+)\.(\d+)/);
|
||||
if (dotted) return [parseInt(dotted[1], 10), parseInt(dotted[2], 10)];
|
||||
const bare = clean.match(/^(\d+)(?:\.(\d+))?/);
|
||||
if (!bare) return [0, 0];
|
||||
return [parseInt(bare[1], 10), parseInt(bare[2] ?? "0", 10)];
|
||||
}
|
||||
|
||||
export interface CommandCheckResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
found: boolean;
|
||||
version?: string;
|
||||
hint?: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface EnvDiff {
|
||||
missing: string[];
|
||||
extra: string[];
|
||||
}
|
||||
|
||||
export function parseEnvKeys(content: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=/);
|
||||
if (m) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function diffEnvTemplate(templateContent: string, envContent: string): EnvDiff {
|
||||
const template = new Set(parseEnvKeys(templateContent));
|
||||
const env = new Set(parseEnvKeys(envContent));
|
||||
const missing = [...template].filter((k) => !env.has(k)).sort();
|
||||
const extra = [...env].filter((k) => !template.has(k)).sort();
|
||||
return { missing, extra };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { join } from "node:path";
|
||||
import type { Ctx } from "../ctx.js";
|
||||
import { findRepoRoot } from "../lib/repo.js";
|
||||
import { diffEnvTemplate } from "../checks/check-env.js";
|
||||
import { hr, info, warn } from "../lib/log.js";
|
||||
|
||||
const INSTALL_TIMEOUT_MS = 600_000;
|
||||
|
||||
export async function bootstrap(_args: string[], ctx: Ctx): Promise<number> {
|
||||
const root = findRepoRoot(ctx.cwd);
|
||||
if (!root) {
|
||||
ctx.stderr.write("\u2717 not inside a nextcraft clone (no pnpm-workspace.yaml found upwards)\n");
|
||||
ctx.stderr.write(" hint: run from the repo, or clone first:\n");
|
||||
ctx.stderr.write(" git clone https://git.coreci.dev/coreci/nextcraft.git && cd nextcraft\n");
|
||||
return 1;
|
||||
}
|
||||
const aiDir = join(root, "apps/ai-service");
|
||||
|
||||
hr("nextcraft bootstrap — monorepo setup", ctx);
|
||||
|
||||
info("installing workspace dependencies (pnpm install)...", ctx);
|
||||
const install = await ctx.spawn("pnpm", ["install"], { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS });
|
||||
if (install.code !== 0) {
|
||||
ctx.stderr.write(`\u2717 pnpm install failed (exit ${install.code})\n`);
|
||||
return 1;
|
||||
}
|
||||
info("workspace dependencies installed", ctx);
|
||||
|
||||
info("bootstrapping ai-service venv (scripts/bootstrap.sh)...", ctx);
|
||||
const boot = await ctx.spawn("bash", ["scripts/bootstrap.sh"], {
|
||||
cwd: aiDir,
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
if (boot.code !== 0) {
|
||||
ctx.stderr.write(`\u2717 ai-service bootstrap failed (exit ${boot.code})\n`);
|
||||
return 1;
|
||||
}
|
||||
info("ai-service venv ready", ctx);
|
||||
|
||||
const examplePath = join(aiDir, ".env.example");
|
||||
const envPath = join(aiDir, ".env");
|
||||
if (!ctx.exists(envPath) && ctx.exists(examplePath)) {
|
||||
ctx.writeFile(envPath, ctx.readFile(examplePath) ?? "");
|
||||
info("created apps/ai-service/.env from .env.example", ctx);
|
||||
} else if (ctx.exists(envPath)) {
|
||||
info("apps/ai-service/.env already present — kept as-is", ctx);
|
||||
} else {
|
||||
warn("no .env.example found — skipping env setup (pydantic-settings defaults apply)", ctx);
|
||||
}
|
||||
|
||||
if (ctx.exists(examplePath) && ctx.exists(envPath)) {
|
||||
const diff = diffEnvTemplate(ctx.readFile(examplePath) ?? "", ctx.readFile(envPath) ?? "");
|
||||
if (diff.missing.length > 0) {
|
||||
warn(
|
||||
`${diff.missing.length} optional key(s) unset in .env: ${diff.missing.join(", ")}`,
|
||||
ctx,
|
||||
);
|
||||
info("optional keys warn only — mock providers keep the stack runnable without them", ctx);
|
||||
}
|
||||
if (diff.extra.length > 0) {
|
||||
info(`extra keys in .env (kept): ${diff.extra.join(", ")}`, ctx);
|
||||
}
|
||||
if (diff.missing.length === 0 && diff.extra.length === 0) {
|
||||
info(".env covers all template keys", ctx);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stdout.write("\n\u2713 bootstrap complete\n\nNext steps:\n nextcraft verify\n nextcraft dev\n\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import type { Ctx } from "../ctx.js";
|
||||
import { findRepoRoot } from "../lib/repo.js";
|
||||
|
||||
export async function dev(_args: string[], ctx: Ctx): Promise<number> {
|
||||
const root = findRepoRoot(ctx.cwd);
|
||||
if (!root) {
|
||||
ctx.stderr.write("\u2717 not inside a nextcraft clone — run from the repo root or a subdirectory\n");
|
||||
return 1;
|
||||
}
|
||||
ctx.stdout.write("nextcraft dev — ai-service dev server (web dev server: run `pnpm dev` separately)\n\n");
|
||||
const child = spawn("bash", ["scripts/dev.sh"], {
|
||||
cwd: join(root, "apps/ai-service"),
|
||||
stdio: "inherit",
|
||||
});
|
||||
const forward = (sig: NodeJS.Signals) => () => child.kill(sig);
|
||||
process.on("SIGINT", forward("SIGINT"));
|
||||
process.on("SIGTERM", forward("SIGTERM"));
|
||||
return await new Promise<number>((resolve) => {
|
||||
child.on("close", (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Ctx } from "../ctx.js";
|
||||
import { compareVersions } from "../checks/check-command.js";
|
||||
import { ok, fail, hr, summary } from "../lib/log.js";
|
||||
|
||||
interface CheckOutcome {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export async function doctor(_args: string[], ctx: Ctx): Promise<number> {
|
||||
hr("nextcraft doctor — environment prerequisites", ctx);
|
||||
const results: CheckOutcome[] = [];
|
||||
|
||||
results.push(await checkNode(ctx));
|
||||
results.push(await checkProgram(ctx, "pnpm", "8", "install pnpm via corepack: corepack enable pnpm (or: npm i -g pnpm)"));
|
||||
results.push(await checkProgram(ctx, "python3", "3.11", "install python3 >= 3.11 (e.g. apt install python3 python3-venv)"));
|
||||
results.push(await checkProgram(ctx, "git", undefined, "install git: https://git-scm.com/download/linux"));
|
||||
results.push(await checkUnshare(ctx));
|
||||
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.length - passed;
|
||||
summary(passed, failed, ctx);
|
||||
return failed === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
async function checkNode(ctx: Ctx): Promise<CheckOutcome> {
|
||||
const version = process.version;
|
||||
if (compareVersions(version, "18") >= 0) {
|
||||
ok(`node ${version} (>= 18)`, ctx);
|
||||
return { name: "node", passed: true };
|
||||
}
|
||||
fail(`node ${version} is older than 18`, "install node >= 18 (https://nodejs.org)", ctx);
|
||||
return { name: "node", passed: false };
|
||||
}
|
||||
|
||||
async function checkProgram(
|
||||
ctx: Ctx,
|
||||
name: string,
|
||||
minVersion: string | undefined,
|
||||
hint: string,
|
||||
): Promise<CheckOutcome> {
|
||||
const which = await ctx.spawn("which", [name], { capture: true, timeoutMs: 3000 });
|
||||
if (which.code !== 0) {
|
||||
fail(`${name} not found on PATH`, hint, ctx);
|
||||
return { name, passed: false };
|
||||
}
|
||||
let version: string | undefined;
|
||||
if (minVersion) {
|
||||
const probe = await ctx.spawn(name, ["--version"], { capture: true, timeoutMs: 10000 });
|
||||
version = probe.stdout.trim().split("\n")[0]?.trim();
|
||||
if (probe.code !== 0 || !version || compareVersions(version, minVersion) < 0) {
|
||||
fail(
|
||||
`${name} ${version ?? "(unknown version)"} is older than required ${minVersion}`,
|
||||
hint,
|
||||
ctx,
|
||||
);
|
||||
return { name, passed: false };
|
||||
}
|
||||
ok(`${name} ${version} (>= ${minVersion})`, ctx);
|
||||
return { name, passed: true };
|
||||
}
|
||||
const probe = await ctx.spawn(name, ["--version"], { capture: true, timeoutMs: 10000 });
|
||||
version = probe.stdout.trim().split("\n")[0]?.trim();
|
||||
ok(`${name} ${version ?? ""}`.trim(), ctx);
|
||||
return { name, passed: true };
|
||||
}
|
||||
|
||||
async function checkUnshare(ctx: Ctx): Promise<CheckOutcome> {
|
||||
const which = await ctx.spawn("which", ["unshare"], { capture: true, timeoutMs: 3000 });
|
||||
if (which.code === 0) {
|
||||
ok("unshare available (sandbox fabric ready)", ctx);
|
||||
return { name: "unshare", passed: true };
|
||||
}
|
||||
fail(
|
||||
"unshare not found on PATH",
|
||||
"sandbox fabric needs unshare (util-linux) — credential builds degrade without it: apt install util-linux",
|
||||
ctx,
|
||||
);
|
||||
return { name: "unshare", passed: false };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
declare const NEXTCRAFT_VERSION_STAMP: string | undefined;
|
||||
|
||||
export function version(env?: Record<string, string | undefined>): string {
|
||||
if (typeof NEXTCRAFT_VERSION_STAMP !== "undefined") {
|
||||
return NEXTCRAFT_VERSION_STAMP;
|
||||
}
|
||||
return env?.NEXTCRAFT_VERSION ?? "0.0.0-dev";
|
||||
}
|
||||
|
||||
export function helpText(env?: Record<string, string | undefined>): string {
|
||||
return `nextcraft ${version(env)} — bootstrap CLI for the Nextcraft monorepo
|
||||
|
||||
Usage:
|
||||
nextcraft <command> [flags]
|
||||
|
||||
Commands:
|
||||
doctor check environment prerequisites (node, pnpm, python3, git, unshare)
|
||||
bootstrap set up a fresh clone: pnpm install, ai-service venv, .env from template
|
||||
verify health-check the bootstrapped stack (venv, uvicorn, ports, env)
|
||||
dev run the ai-service dev server (thin passthrough to scripts/dev.sh)
|
||||
|
||||
Flags:
|
||||
--help, -h show this help
|
||||
--version print the CLI version
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 a check or step failed (see the printed hint)
|
||||
2 usage error (unknown command or flag)
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { join } from "node:path";
|
||||
import type { Ctx } from "../ctx.js";
|
||||
import { findRepoRoot } from "../lib/repo.js";
|
||||
import { hr, ok, fail, warn, summary } from "../lib/log.js";
|
||||
|
||||
export async function verify(_args: string[], ctx: Ctx): Promise<number> {
|
||||
const root = findRepoRoot(ctx.cwd);
|
||||
if (!root) {
|
||||
ctx.stderr.write("\u2717 not inside a nextcraft clone — run from the repo root or a subdirectory\n");
|
||||
return 1;
|
||||
}
|
||||
const aiDir = join(root, "apps/ai-service");
|
||||
hr("nextcraft verify — stack health check", ctx);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const venvPython = join(aiDir, ".venv/bin/python3");
|
||||
if (ctx.exists(venvPython)) {
|
||||
const imp = await ctx.spawn(venvPython, ["-c", "import ai_service"], {
|
||||
capture: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (imp.code === 0) {
|
||||
ok("ai-service venv — import ai_service", ctx);
|
||||
passed++;
|
||||
} else {
|
||||
fail("ai_service no longer imports in the venv", "re-run nextcraft bootstrap", ctx);
|
||||
failed++;
|
||||
}
|
||||
const uv = await ctx.spawn(venvPython, ["-c", "import uvicorn"], {
|
||||
capture: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (uv.code === 0) {
|
||||
ok("uvicorn importable in venv", ctx);
|
||||
passed++;
|
||||
} else {
|
||||
fail("uvicorn missing in venv", "re-run nextcraft bootstrap", ctx);
|
||||
failed++;
|
||||
}
|
||||
} else {
|
||||
fail("ai-service venv not found", "run nextcraft bootstrap", ctx);
|
||||
failed += 2;
|
||||
}
|
||||
|
||||
const envPath = join(aiDir, ".env");
|
||||
if (ctx.exists(envPath)) {
|
||||
ok(".env present", ctx);
|
||||
passed++;
|
||||
} else {
|
||||
warn(".env absent — pydantic-settings defaults apply (copy apps/ai-service/.env.example to customize)", ctx);
|
||||
}
|
||||
|
||||
const port = parsePort(ctx.readFile(envPath) ?? "") ?? 8420;
|
||||
if (await ctx.portFree(port, "127.0.0.1")) {
|
||||
ok(`port ${port} free (ai-service will bind it)`, ctx);
|
||||
passed++;
|
||||
} else {
|
||||
fail(`port ${port} is busy`, `stop the process listening on ${port} (ai-service default)`, ctx);
|
||||
failed++;
|
||||
}
|
||||
|
||||
if (ctx.exists(join(root, "node_modules/.bin/turbo"))) {
|
||||
ok("workspace dependencies installed (node_modules present)", ctx);
|
||||
passed++;
|
||||
} else {
|
||||
fail("node_modules missing at repo root", "run nextcraft bootstrap", ctx);
|
||||
failed++;
|
||||
}
|
||||
|
||||
summary(passed, failed, ctx);
|
||||
return failed === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
function parsePort(envContent: string): number | undefined {
|
||||
for (const line of envContent.split("\n")) {
|
||||
const m = line.match(/^\s*AI_PORT\s*=\s*(\d+)\s*$/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SpawnResult } from "./lib/spawn.js";
|
||||
|
||||
export interface Ctx {
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
stdout: { write(s: string): void };
|
||||
stderr: { write(s: string): void };
|
||||
spawn: (cmd: string, args: string[], opts: SpawnOpts) => Promise<SpawnResult>;
|
||||
exists: (p: string) => boolean;
|
||||
readFile: (p: string) => string | undefined;
|
||||
writeFile: (p: string, content: string) => void;
|
||||
portFree: (port: number, host: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SpawnOpts {
|
||||
timeoutMs?: number;
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
stdio?: "inherit" | "pipe";
|
||||
capture?: boolean;
|
||||
}
|
||||
|
||||
export type Command = (args: string[], ctx: Ctx) => Promise<number>;
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Ctx, Command } from "./ctx.js";
|
||||
import { helpText, version } from "./commands/help.js";
|
||||
import { doctor } from "./commands/doctor.js";
|
||||
import { bootstrap } from "./commands/bootstrap.js";
|
||||
import { verify } from "./commands/verify.js";
|
||||
import { dev } from "./commands/dev.js";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
|
||||
const commands: Record<string, Command> = { doctor, bootstrap, verify, dev };
|
||||
|
||||
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
||||
const flags = argv.filter((a) => a.startsWith("--") || a === "-h");
|
||||
const positional = argv.filter((a) => !(a.startsWith("--") || a === "-h"));
|
||||
|
||||
for (const flag of flags) {
|
||||
if (flag === "--help" || flag === "-h") {
|
||||
ctx.stdout.write(helpText(ctx.env));
|
||||
return 0;
|
||||
}
|
||||
if (flag === "--version") {
|
||||
ctx.stdout.write(`${version(ctx.env)}\n`);
|
||||
return 0;
|
||||
}
|
||||
ctx.stderr.write(`unknown flag: ${flag}\n\n${helpText(ctx.env)}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const name = positional[0];
|
||||
const command = name ? commands[name] : undefined;
|
||||
if (!command) {
|
||||
const text = helpText(ctx.env);
|
||||
ctx.stderr.write(name ? `unknown command: ${name}\n\n${text}` : text);
|
||||
return 2;
|
||||
}
|
||||
return await command(positional.slice(1), ctx);
|
||||
}
|
||||
|
||||
function isDirectRun(): boolean {
|
||||
if (process.env.NODE_TEST_CONTEXT) return false;
|
||||
const [argv0, argv1] = process.argv;
|
||||
if (argv0 && argv1 && argv0 === argv1) return true;
|
||||
if (argv1?.endsWith("dist/index.js")) return true;
|
||||
if (argv1?.endsWith("src/index.ts")) return true;
|
||||
return false;
|
||||
}
|
||||
if (isDirectRun()) {
|
||||
void (async () => {
|
||||
const realCtx: Ctx = {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
spawn: async (cmd, args, opts) => {
|
||||
const { run: spawnRun } = await import("./lib/spawn.js");
|
||||
return spawnRun(cmd, args, opts);
|
||||
},
|
||||
exists: existsSync,
|
||||
readFile: (p) => (existsSync(p) ? readFileSync(p, "utf8") : undefined),
|
||||
writeFile: (p, c) => writeFileSync(p, c),
|
||||
portFree: async (port, host) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.once("error", () => resolve(false));
|
||||
srv.once("listening", () => srv.close(() => resolve(true)));
|
||||
srv.listen(port, host);
|
||||
}),
|
||||
};
|
||||
process.exitCode = await run(process.argv.slice(2), realCtx);
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Ctx } from "../ctx.js";
|
||||
|
||||
export function ok(msg: string, ctx: Ctx): void {
|
||||
ctx.stdout.write(` \u2713 ${msg}\n`);
|
||||
}
|
||||
|
||||
export function fail(msg: string, hint: string | undefined, ctx: Ctx): void {
|
||||
ctx.stderr.write(` \u2717 ${msg}\n`);
|
||||
if (hint) ctx.stderr.write(` hint: ${hint}\n`);
|
||||
}
|
||||
|
||||
export function warn(msg: string, ctx: Ctx): void {
|
||||
ctx.stdout.write(` \u26a0 ${msg}\n`);
|
||||
}
|
||||
|
||||
export function info(msg: string, ctx: Ctx): void {
|
||||
ctx.stdout.write(` - ${msg}\n`);
|
||||
}
|
||||
|
||||
export function hr(title: string, ctx: Ctx): void {
|
||||
ctx.stdout.write(`\n${title}\n\n`);
|
||||
}
|
||||
|
||||
export function summary(passed: number, failed: number, ctx: Ctx): void {
|
||||
const line = failed === 0 ? "All checks passed" : `${failed} check(s) failed, ${passed} passed`;
|
||||
ctx.stdout.write(`\n${failed === 0 ? "\u2713" : "\u2717"} ${line}\n`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { SpawnResult } from "../lib/spawn.js";
|
||||
|
||||
export function findRepoRoot(start: string): string | undefined {
|
||||
let dir = resolve(start);
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export type Spawn = (cmd: string, args: string[], opts: Record<string, unknown>) => Promise<SpawnResult>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { SpawnOpts } from "../ctx.js";
|
||||
|
||||
export interface SpawnResult {
|
||||
code: number;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: SpawnOpts = {},
|
||||
): Promise<SpawnResult> {
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env as NodeJS.ProcessEnv | undefined,
|
||||
stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (child.stdout) child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
|
||||
if (child.stderr) child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
||||
let timedOut = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
if (opts.timeoutMs) {
|
||||
killTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => child.kill("SIGKILL"), 1000);
|
||||
}, opts.timeoutMs);
|
||||
}
|
||||
child.on("error", () => {
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve({ code: 127, signal: null, timedOut: false, stdout, stderr });
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve({
|
||||
code: code ?? 127,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { compareVersions } from "../src/checks/check-command.ts";
|
||||
import { parseEnvKeys, diffEnvTemplate } from "../src/checks/check-env.ts";
|
||||
|
||||
test("compareVersions: equal across patch omission", () => {
|
||||
assert.equal(compareVersions("18.0.0", "18"), 0);
|
||||
assert.equal(compareVersions("18", "18.0.0"), 0);
|
||||
});
|
||||
|
||||
test("compareVersions: minor boundary", () => {
|
||||
assert.equal(compareVersions("3.11", "3.11.2"), 0);
|
||||
assert.equal(compareVersions("3.10", "3.11"), -1);
|
||||
assert.equal(compareVersions("3.12", "3.11"), 1);
|
||||
});
|
||||
|
||||
test("compareVersions: v-prefix and embedded version strings", () => {
|
||||
assert.ok(compareVersions("v18.2.0", "18") >= 0);
|
||||
assert.equal(compareVersions("Python 3.11.2", "3.11"), 0);
|
||||
assert.ok(compareVersions("python3 (3.9)", "3.11") < 0);
|
||||
});
|
||||
|
||||
test("compareVersions: major win beats minor", () => {
|
||||
assert.equal(compareVersions("24.0.0", "18.99"), 1);
|
||||
assert.equal(compareVersions("2.99", "18.0"), -1);
|
||||
});
|
||||
|
||||
test("parseEnvKeys: skips comments and blanks", () => {
|
||||
const content = [
|
||||
"# comment",
|
||||
"",
|
||||
"AI_PORT=8420",
|
||||
" AI_MODEL=gemma4:31b",
|
||||
"#AI_SKIP=1",
|
||||
"AI_OLLAMA_CLOUD_API_KEY=",
|
||||
].join("\n");
|
||||
assert.deepEqual(parseEnvKeys(content), ["AI_PORT", "AI_MODEL", "AI_OLLAMA_CLOUD_API_KEY"]);
|
||||
});
|
||||
|
||||
test("diffEnvTemplate: missing + extra classification", () => {
|
||||
const template = "A=1\nB=2\nC=3\n";
|
||||
const env = "B=2\nD=4\n";
|
||||
const diff = diffEnvTemplate(template, env);
|
||||
assert.deepEqual(diff.missing, ["A", "C"]);
|
||||
assert.deepEqual(diff.extra, ["D"]);
|
||||
});
|
||||
|
||||
test("diffEnvTemplate: full coverage yields empty diff", () => {
|
||||
const template = "A=1\nB=2\n";
|
||||
const env = "A=x\nB=y\n";
|
||||
const diff = diffEnvTemplate(template, env);
|
||||
assert.deepEqual(diff.missing, []);
|
||||
assert.deepEqual(diff.extra, []);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { doctor } from "../src/commands/doctor.ts";
|
||||
import { bootstrap } from "../src/commands/bootstrap.ts";
|
||||
import { testCtx } from "./helpers.ts";
|
||||
import type { SpawnResult } from "../src/lib/spawn.ts";
|
||||
|
||||
const r0 = (stdout = ""): SpawnResult => ({ code: 0, signal: null, timedOut: false, stdout, stderr: "" });
|
||||
const r1 = (): SpawnResult => ({ code: 1, signal: null, timedOut: false, stdout: "", stderr: "" });
|
||||
|
||||
test("doctor: all prerequisites present exits 0", async () => {
|
||||
const ctx = testCtx({
|
||||
spawn: async (cmd, args) => {
|
||||
if (cmd === "which") return r0();
|
||||
if (cmd === "pnpm" && args[0] === "--version") return r0("10.0.0\n");
|
||||
if (cmd === "python3" && args[0] === "--version") return r0("Python 3.11.2\n");
|
||||
if (cmd === "git" && args[0] === "--version") return r0("git version 2.39.2\n");
|
||||
return r0();
|
||||
},
|
||||
});
|
||||
const code = await doctor([], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ctx.out().includes("All checks passed"));
|
||||
});
|
||||
|
||||
test("doctor: missing pnpm fails with hint, exit 1", async () => {
|
||||
const ctx = testCtx({
|
||||
spawn: async (cmd, args) => {
|
||||
if (cmd === "which" && args[0] === "pnpm") return { ...r1(), code: 1 };
|
||||
if (cmd === "which") return r0();
|
||||
if (cmd === "python3") return r0("Python 3.11.2\n");
|
||||
if (cmd === "git") return r0("git version 2.39.2\n");
|
||||
return r0();
|
||||
},
|
||||
});
|
||||
const code = await doctor([], ctx);
|
||||
assert.equal(code, 1);
|
||||
assert.ok(ctx.err().includes("pnpm not found"));
|
||||
assert.ok(ctx.err().includes("corepack"));
|
||||
});
|
||||
|
||||
test("doctor: outdated python3 fails with version comparison", async () => {
|
||||
const ctx = testCtx({
|
||||
spawn: async (cmd, args) => {
|
||||
if (cmd === "which") return r0();
|
||||
if (cmd === "python3" && args[0] === "--version") return r0("Python 3.9.0\n");
|
||||
if (cmd === "pnpm") return r0("10.0.0\n");
|
||||
if (cmd === "git") return r0("git version 2.39.2\n");
|
||||
return r0();
|
||||
},
|
||||
});
|
||||
const code = await doctor([], ctx);
|
||||
assert.equal(code, 1);
|
||||
assert.ok(ctx.err().includes("3.9"));
|
||||
});
|
||||
|
||||
test("bootstrap: outside a repo fails with clone hint", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nc-norepo-"));
|
||||
const ctx = testCtx({ cwd: dir });
|
||||
const code = await bootstrap([], ctx);
|
||||
assert.equal(code, 1);
|
||||
assert.ok(ctx.err().includes("git clone"));
|
||||
});
|
||||
|
||||
test("bootstrap: step order — pnpm install before venv bootstrap before env copy", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nc-repo-"));
|
||||
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n");
|
||||
mkdirSync(join(dir, "apps", "ai-service"), { recursive: true });
|
||||
writeFileSync(join(dir, "apps", "ai-service", ".env.example"), "AI_PORT=8420\nAI_KEY=\n");
|
||||
|
||||
const calls: string[] = [];
|
||||
const ctx = testCtx({
|
||||
cwd: dir,
|
||||
spawn: async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(" ")}`);
|
||||
return r0();
|
||||
},
|
||||
});
|
||||
const code = await bootstrap([], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(calls[0].startsWith("pnpm install"));
|
||||
assert.ok(calls[1].startsWith("bash scripts/bootstrap.sh"));
|
||||
assert.ok(ctx.exists(join(dir, "apps", "ai-service", ".env")));
|
||||
assert.ok(ctx.out().includes("bootstrap complete"));
|
||||
});
|
||||
|
||||
test("bootstrap: existing .env kept, not overwritten", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nc-keep-"));
|
||||
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n");
|
||||
const aiDir = join(dir, "apps", "ai-service");
|
||||
mkdirSync(aiDir, { recursive: true });
|
||||
writeFileSync(join(aiDir, ".env.example"), "AI_PORT=8420\n");
|
||||
writeFileSync(join(aiDir, ".env"), "AI_PORT=9999\n");
|
||||
|
||||
const ctx = testCtx({ cwd: dir, spawn: async () => r0() });
|
||||
const code = await bootstrap([], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ctx.out().includes("kept as-is"));
|
||||
assert.equal(ctx.readFile(join(aiDir, ".env")), "AI_PORT=9999\n");
|
||||
});
|
||||
|
||||
test("bootstrap: failing pnpm install aborts before venv step", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nc-fail-"));
|
||||
writeFileSync(join(dir, "pnpm-workspace.yaml"), "packages:\n - apps/*\n");
|
||||
const calls: string[] = [];
|
||||
const ctx = testCtx({
|
||||
cwd: dir,
|
||||
spawn: async (cmd, args) => {
|
||||
calls.push(cmd);
|
||||
return r1();
|
||||
},
|
||||
});
|
||||
const code = await bootstrap([], ctx);
|
||||
assert.equal(code, 1);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(ctx.err().includes("pnpm install failed"));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { run } from "../src/index.ts";
|
||||
import { testCtx } from "./helpers.ts";
|
||||
|
||||
|
||||
|
||||
test("dispatch: --help exits 0 and prints usage", async () => {
|
||||
const ctx = testCtx();
|
||||
const code = await run(["--help"], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ctx.out().includes("Usage:"));
|
||||
assert.ok(ctx.out().includes("doctor"));
|
||||
});
|
||||
|
||||
test("dispatch: -h behaves like --help", async () => {
|
||||
const ctx = testCtx();
|
||||
assert.equal(await run(["-h"], ctx), 0);
|
||||
});
|
||||
|
||||
test("dispatch: --version prints NEXTCRAFT_VERSION override", async () => {
|
||||
const env = { ...process.env, NEXTCRAFT_VERSION: "v9.9.9-test" };
|
||||
const ctx = testCtx({ env });
|
||||
const code = await run(["--version"], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ctx.out().includes("v9.9.9-test"));
|
||||
});
|
||||
|
||||
test("dispatch: no command exits 2 with usage on stderr", async () => {
|
||||
const ctx = testCtx();
|
||||
assert.equal(await run([], ctx), 2);
|
||||
assert.ok(ctx.err().includes("Usage:") || ctx.out().includes("Usage:") || ctx.err().includes("nextcraft"));
|
||||
});
|
||||
|
||||
test("dispatch: unknown command exits 2", async () => {
|
||||
const ctx = testCtx();
|
||||
const code = await run(["frobnicate"], ctx);
|
||||
assert.equal(code, 2);
|
||||
assert.ok(ctx.err().includes("unknown command: frobnicate"));
|
||||
});
|
||||
|
||||
test("dispatch: unknown flag exits 2", async () => {
|
||||
const ctx = testCtx();
|
||||
assert.equal(await run(["doctor", "--bogus"], ctx), 2);
|
||||
});
|
||||
|
||||
test("dispatch: doctor routes to the doctor command", async () => {
|
||||
const ctx = testCtx({
|
||||
spawn: async (cmd, args) => {
|
||||
if (cmd === "which") return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
||||
if (cmd === "pnpm" && args[0] === "--version") return r("10.0.0\n");
|
||||
if (cmd === "python3" && args[0] === "--version") return r("Python 3.11.2\n");
|
||||
if (cmd === "git" && args[0] === "--version") return r("git version 2.39.2\n");
|
||||
return { code: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
const code = await run(["doctor"], ctx);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ctx.out().includes("All checks passed"));
|
||||
});
|
||||
|
||||
const r = (stdout: string) => ({ code: 0, signal: null, timedOut: false, stdout, stderr: "" });
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { doctor } from "../src/commands/doctor.ts";
|
||||
import { testCtx } from "./helpers.ts";
|
||||
|
||||
test("doctor integration: real box — all prerequisites present", async () => {
|
||||
const ctx = testCtx();
|
||||
const code = await doctor([], ctx);
|
||||
const output = ctx.out() + ctx.err();
|
||||
if (output.includes("unshare not found")) {
|
||||
console.warn("unshare missing on this box — tolerating its single failure");
|
||||
assert.equal(code, 1);
|
||||
return;
|
||||
}
|
||||
assert.equal(code, 0);
|
||||
assert.ok(output.includes("node"));
|
||||
assert.ok(output.includes("pnpm"));
|
||||
assert.ok(output.includes("python3"));
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Ctx } from "../src/ctx.ts";
|
||||
import { run } from "../src/index.ts";
|
||||
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import { run as spawnRun } from "../src/lib/spawn.ts";
|
||||
import type { SpawnResult } from "../src/lib/spawn.ts";
|
||||
|
||||
export interface TestCtx extends Ctx {
|
||||
out(): string;
|
||||
err(): string;
|
||||
writes: Record<string, string>;
|
||||
}
|
||||
|
||||
export function testCtx(overrides: Partial<Ctx> = {}): TestCtx {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const writes: Record<string, string> = {};
|
||||
const base = {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env } as Record<string, string | undefined>,
|
||||
stdout: { write: (s: string) => void out.push(s) },
|
||||
stderr: { write: (s: string) => void err.push(s) },
|
||||
spawn: async (cmd: string, args: string[], opts: Parameters<Ctx["spawn"]>[2]) =>
|
||||
spawnRun(cmd, args, opts),
|
||||
exists: (p: string) => existsSync(p),
|
||||
readFile: (p: string) => (existsSync(p) ? readFileSync(p, "utf8") : undefined),
|
||||
writeFile: (p: string, c: string) => {
|
||||
writes[p] = c;
|
||||
writeFileSync(p, c);
|
||||
},
|
||||
portFree: async (port: number, host: string) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.once("error", () => resolve(false));
|
||||
srv.once("listening", () => srv.close(() => resolve(true)));
|
||||
srv.listen(port, host);
|
||||
}),
|
||||
out: () => out.join(""),
|
||||
err: () => err.join(""),
|
||||
};
|
||||
const ctx = { ...base, ...overrides } as unknown as TestCtx;
|
||||
(ctx as unknown as { writes: Record<string, string> }).writes = writes;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function fakeSpawn(
|
||||
sequence: Array<{ match: (cmd: string, args: string[]) => boolean; result: SpawnResult }>,
|
||||
): Ctx["spawn"] {
|
||||
return async (cmd, args) => {
|
||||
const hit = sequence.find((s) => s.match(cmd, args));
|
||||
if (!hit) throw new Error(`unexpected spawn: ${cmd} ${args.join(" ")}`);
|
||||
return hit.result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const installSh = join(repoRoot, "scripts", "install.sh");
|
||||
|
||||
function sha256(p: string): string {
|
||||
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
||||
}
|
||||
|
||||
async function serve(
|
||||
setup: (srvDir: string, base: () => string) => void,
|
||||
): Promise<{ url: string; stop: () => void }> {
|
||||
const srvDir = mkdtempSync(join(tmpdir(), "nc-srv-"));
|
||||
const port = 30000 + Math.floor(Math.random() * 20000);
|
||||
const base = () => `http://127.0.0.1:${port}`;
|
||||
setup(srvDir, base);
|
||||
const child = spawn("python3", ["-m", "http.server", String(port), "--directory", srvDir], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const probe = spawnSync("curl", ["-fsS", `${base()}/api/v1/repos/coreci/nextcraft/releases/latest`], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (probe.status === 0) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { url: base(), stop: () => child.kill("SIGTERM") };
|
||||
}
|
||||
|
||||
function apiManifestDir(srvDir: string, manifest: object): string {
|
||||
const dir = join(srvDir, "api/v1/repos/coreci/nextcraft/releases");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "latest"), JSON.stringify(manifest));
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function runInstall(home: string, srvUrl: string) {
|
||||
return spawnSync("sh", [installSh], {
|
||||
env: { ...process.env, HOME: home, NEXTCRAFT_FORGE_BASE: srvUrl, DEST: join(home, "bin") },
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
test("install.sh: checksum mismatch = hard stop, no install", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "nc-home-"));
|
||||
const srv = await serve((srvDir, base) => {
|
||||
writeFileSync(join(srvDir, "ping"), "pong");
|
||||
writeFileSync(join(srvDir, "nextcraft-linux-x64"), "#!/bin/sh\necho fake\n");
|
||||
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), "deadbeef nextcraft-linux-x64\n");
|
||||
apiManifestDir(srvDir, {
|
||||
tag_name: "v9.9.9",
|
||||
assets: [
|
||||
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
|
||||
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
|
||||
],
|
||||
});
|
||||
});
|
||||
try {
|
||||
const res = await runInstall(home, srv.url);
|
||||
assert.notEqual(res.status, 0);
|
||||
assert.ok(res.stderr.includes("CHECKSUM MISMATCH"));
|
||||
assert.ok(!existsSync(join(home, "bin", "nextcraft")));
|
||||
} finally {
|
||||
srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("install.sh: valid checksum installs binary and reports version", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "nc-home-ok-"));
|
||||
const srv = await serve((srvDir, base) => {
|
||||
const bin = join(srvDir, "nextcraft-linux-x64");
|
||||
writeFileSync(join(srvDir, "ping"), "pong");
|
||||
writeFileSync(bin, "#!/bin/sh\necho v9.9.9-installed\n");
|
||||
writeFileSync(join(srvDir, "nextcraft-linux-x64.sha256"), `${sha256(bin)} nextcraft-linux-x64\n`);
|
||||
apiManifestDir(srvDir, {
|
||||
tag_name: "v9.9.9",
|
||||
assets: [
|
||||
{ name: "nextcraft-linux-x64", browser_download_url: `${base()}/nextcraft-linux-x64` },
|
||||
{ name: "nextcraft-linux-x64.sha256", browser_download_url: `${base()}/nextcraft-linux-x64.sha256` },
|
||||
],
|
||||
});
|
||||
});
|
||||
try {
|
||||
const res = await runInstall(home, srv.url);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.ok(existsSync(join(home, "bin", "nextcraft")));
|
||||
const run = spawnSync(join(home, "bin", "nextcraft"), [], { encoding: "utf8" });
|
||||
assert.ok(run.stdout.includes("v9.9.9-installed"));
|
||||
} finally {
|
||||
srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("install.sh: release without binary assets degrades to source instructions, exit 0", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "nc-home-nb-"));
|
||||
const srv = await serve((srvDir) => {
|
||||
writeFileSync(join(srvDir, "ping"), "pong");
|
||||
apiManifestDir(srvDir, { tag_name: "v0.2.8", assets: [] });
|
||||
});
|
||||
try {
|
||||
const res = await runInstall(home, srv.url);
|
||||
assert.equal(res.status, 0);
|
||||
assert.ok(res.stdout.includes("git clone"));
|
||||
assert.ok(!existsSync(join(home, "bin", "nextcraft")));
|
||||
} finally {
|
||||
srv.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("release-assets.sh: token resolution reads .env* files only — poisoned shell env is never used", async () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), "nc-home-token-"));
|
||||
mkdirSync(join(tmpHome, ".ciagent"), { recursive: true });
|
||||
writeFileSync(join(tmpHome, ".ciagent", ".env.secrets"), "GITEA_TOKEN=real-token-from-file\n");
|
||||
const fakeBin = join(tmpHome, "apps/cli/dist");
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
writeFileSync(join(fakeBin, "nextcraft-linux-x64"), "fake-binary-bytes\n");
|
||||
writeFileSync(join(fakeBin, "nextcraft-linux-x64.sha256"), "abc123 nextcraft-linux-x64\n");
|
||||
|
||||
const srv = await serve((srvDir) => {
|
||||
const tagsDir = join(srvDir, "api/v1/repos/coreci/nextcraft/releases/tags");
|
||||
mkdirSync(tagsDir, { recursive: true });
|
||||
writeFileSync(join(tagsDir, "v0.3.2"), JSON.stringify({ id: 42, tag_name: "v0.3.2" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const env: Record<string, string> = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: tmpHome,
|
||||
GITEA_TOKEN: "poisoned-shell-token",
|
||||
NEXTCRAFT_FORGE_BASE: srv.url,
|
||||
};
|
||||
const res = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`NEXTCRAFT_FORGE_BASE='${srv.url}' GITEA_TOKEN=poisoned-shell-token bash '${join(repoRoot, "scripts", "release-assets.sh")}' v0.3.2 --dry-run`,
|
||||
],
|
||||
{ cwd: tmpHome, env, encoding: "utf8", timeout: 20000 },
|
||||
);
|
||||
|
||||
assert.equal(res.stdout.includes("poisoned-shell-token"), false, "poisoned token never in stdout");
|
||||
assert.equal(res.stderr.includes("poisoned-shell-token"), false, "poisoned token never in stderr");
|
||||
assert.equal(res.status, 0, `dry-run should succeed against fixture, stderr: ${res.stderr}`);
|
||||
assert.ok(res.stdout.includes("DRY-RUN would upload"), `expected dry-run upload lines, got: ${res.stdout}`);
|
||||
assert.ok(res.stdout.includes("nextcraft-linux-x64"));
|
||||
} finally {
|
||||
srv.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { run } from "../src/lib/spawn.ts";
|
||||
|
||||
test("spawn: returns exit code of short process", async () => {
|
||||
const res = await run("node", ["-e", "process.exit(3)"], { capture: true, timeoutMs: 5000 });
|
||||
assert.equal(res.code, 3);
|
||||
assert.equal(res.timedOut, false);
|
||||
});
|
||||
|
||||
test("spawn: timeout kills and reports timedOut", async () => {
|
||||
const res = await run("node", ["-e", "setTimeout(() => {}, 10000)"], {
|
||||
capture: true,
|
||||
timeoutMs: 300,
|
||||
});
|
||||
assert.equal(res.timedOut, true);
|
||||
assert.ok(res.code !== 0);
|
||||
});
|
||||
|
||||
test("spawn: missing binary resolves code 127, never rejects", async () => {
|
||||
const res = await run("definitely-not-a-real-binary-xyz", [], { capture: true, timeoutMs: 1000 });
|
||||
assert.equal(res.code, 127);
|
||||
});
|
||||
|
||||
test("spawn: capture collects stdout", async () => {
|
||||
const res = await run("node", ["-e", "process.stdout.write('hello')"], {
|
||||
capture: true,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
assert.equal(res.stdout, "hello");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"incremental": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["tests", "node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
+5
-1
@@ -13,7 +13,11 @@
|
||||
"ai:dev": "turbo run dev --filter=@nextcraft/ai-service",
|
||||
"ai:test": "turbo run test --filter=@nextcraft/ai-service",
|
||||
"ai:bootstrap": "turbo run bootstrap --filter=@nextcraft/ai-service",
|
||||
"ai:lint": "turbo run lint --filter=@nextcraft/ai-service"
|
||||
"ai:lint": "turbo run lint --filter=@nextcraft/ai-service",
|
||||
"cli:dev": "pnpm --filter @nextcraft/cli dev",
|
||||
"cli:test": "pnpm --filter @nextcraft/cli test",
|
||||
"cli:typecheck": "pnpm --filter @nextcraft/cli typecheck",
|
||||
"cli:build": "pnpm --filter @nextcraft/cli build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.3",
|
||||
|
||||
Generated
+46
@@ -118,6 +118,21 @@ importers:
|
||||
|
||||
apps/ai-service: {}
|
||||
|
||||
apps/cli:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.4
|
||||
esbuild:
|
||||
specifier: 0.28.2
|
||||
version: 0.28.2
|
||||
tsx:
|
||||
specifier: ^4.23.0
|
||||
version: 4.23.13
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@nextcraft/mock-data':
|
||||
@@ -1904,6 +1919,9 @@ packages:
|
||||
'@types/node@20.17.6':
|
||||
resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==}
|
||||
|
||||
'@types/node@24.13.4':
|
||||
resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
|
||||
|
||||
'@types/react-dom@19.3.0':
|
||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||
peerDependencies:
|
||||
@@ -2731,6 +2749,11 @@ packages:
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -3927,6 +3950,11 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.23.13:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tty-browserify@0.0.1:
|
||||
resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==}
|
||||
|
||||
@@ -3950,6 +3978,9 @@ packages:
|
||||
undici-types@6.19.8:
|
||||
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1:
|
||||
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5841,6 +5872,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/node@24.13.4':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
@@ -6781,6 +6816,9 @@ snapshots:
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
@@ -7998,6 +8036,12 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
turbo@2.10.12:
|
||||
@@ -8021,6 +8065,8 @@ snapshots:
|
||||
|
||||
undici-types@6.19.8: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
unicode-match-property-ecmascript@2.0.0:
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/sh
|
||||
# Nextcraft CLI installer — the one-liner:
|
||||
# curl -fsSL https://git.coreci.dev/coreci/nextcraft/raw/main/scripts/install.sh | sh
|
||||
# Resolves the latest release from the Gitea API, downloads the linux x64 binary
|
||||
# + sha256 sidecar, verifies the checksum, installs to ~/.local/bin.
|
||||
# Any failure degrades to printed source-bootstrap instructions (G-103) — never
|
||||
# installs an unverified artifact. Checksum mismatch = hard stop (exit 1).
|
||||
set -eu
|
||||
|
||||
FORGE_BASE="${NEXTCRAFT_FORGE_BASE:-https://git.coreci.dev}"
|
||||
OWNER="coreci"
|
||||
REPO="nextcraft"
|
||||
DEST="${DEST:-$HOME/.local/bin}"
|
||||
API="$FORGE_BASE/api/v1/repos/$OWNER/$REPO/releases/latest"
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "x86_64" ] || {
|
||||
say "nextcraft: no binary for $(uname -s)/$(uname -m) (linux x64 only for now)."
|
||||
say "Source bootstrap instead:"
|
||||
say " git clone $FORGE_BASE/$OWNER/$REPO.git && cd $REPO"
|
||||
say " pnpm --filter @nextcraft/cli dev -- doctor # or scripts/bootstrap.sh path"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v curl >/dev/null 2>&1 || {
|
||||
say "nextcraft: curl is required. Install curl, or source bootstrap:"
|
||||
say " git clone $FORGE_BASE/$OWNER/$REPO.git"
|
||||
exit 0
|
||||
}
|
||||
|
||||
RELEASE_JSON="$(curl -fsSL "$API" 2>/dev/null)" || {
|
||||
say "nextcraft: could not reach $API."
|
||||
say "Source bootstrap instead: git clone $FORGE_BASE/$OWNER/$REPO.git"
|
||||
exit 0
|
||||
}
|
||||
|
||||
TAG="$(printf '%s' "$RELEASE_JSON" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p' | head -1)"
|
||||
BIN_URL="$(printf '%s' "$RELEASE_JSON" | sed -n 's/.*"browser_download_url":"\([^"]*nextcraft-linux-x64\)".*/\1/p' | head -1)"
|
||||
SUM_URL="$(printf '%s' "$RELEASE_JSON" | sed -n 's/.*"browser_download_url":"\([^"]*nextcraft-linux-x64\.sha256\)".*/\1/p' | head -1)"
|
||||
|
||||
if [ -z "$TAG" ] || [ -z "$BIN_URL" ] || [ -z "$SUM_URL" ]; then
|
||||
say "nextcraft: latest release ($TAG) has no linux x64 binary assets yet."
|
||||
say "Source bootstrap instead:"
|
||||
say " git clone $FORGE_BASE/$OWNER/$REPO.git && cd $REPO"
|
||||
say " pnpm install && pnpm --filter @nextcraft/cli dev -- doctor"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
say "nextcraft: downloading $TAG ..."
|
||||
curl -fsSL "$BIN_URL" -o "$TMP/nextcraft-linux-x64" || {
|
||||
say "nextcraft: download failed. Source bootstrap: git clone $FORGE_BASE/$OWNER/$REPO.git"
|
||||
exit 0
|
||||
}
|
||||
curl -fsSL "$SUM_URL" -o "$TMP/nextcraft-linux-x64.sha256" || {
|
||||
say "nextcraft: checksum download failed — refusing to install unverified binary."
|
||||
say "Source bootstrap instead: git clone $FORGE_BASE/$OWNER/$REPO.git"
|
||||
exit 0
|
||||
}
|
||||
|
||||
EXPECTED="$(sed 's/ .*$//' "$TMP/nextcraft-linux-x64.sha256" | tr -d '[:space:]')"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
ACTUAL="$(sha256sum "$TMP/nextcraft-linux-x64" | sed 's/ .*$//')"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL="$(shasum -a 256 "$TMP/nextcraft-linux-x64" | sed 's/ .*$//')"
|
||||
else
|
||||
die "nextcraft: no sha256 tool available — cannot verify. DO NOT install an unverified binary. Install sha256sum (coreutils) and retry."
|
||||
fi
|
||||
[ "$ACTUAL" = "$EXPECTED" ] || die "nextcraft: CHECKSUM MISMATCH for $TAG — do not run this binary. Aborting."
|
||||
|
||||
mkdir -p "$DEST"
|
||||
mv "$TMP/nextcraft-linux-x64" "$DEST/nextcraft"
|
||||
chmod +x "$DEST/nextcraft"
|
||||
|
||||
say "nextcraft: installed $DEST/nextcraft ($TAG)"
|
||||
case ":$PATH:" in
|
||||
*":$DEST:"*) ;;
|
||||
*)
|
||||
say ""
|
||||
say "$DEST is not on your PATH. Add it:"
|
||||
say " echo 'export PATH=\"$DEST:\$PATH\"' >> ~/.profile && source ~/.profile"
|
||||
;;
|
||||
esac
|
||||
say ""
|
||||
say "Next steps:"
|
||||
say " nextcraft doctor # check prerequisites"
|
||||
say " git clone $FORGE_BASE/$OWNER/$REPO.git && cd nextcraft"
|
||||
say " nextcraft bootstrap # set up the monorepo"
|
||||
say " nextcraft verify && nextcraft dev"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Attach nextcraft-linux-x64 + .sha256 to a Gitea release.
|
||||
# Usage: scripts/release-assets.sh <tag> [--dry-run]
|
||||
# Token resolution: .env files ONLY (never shell env — v1.8 root cause).
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?usage: release-assets.sh <tag> [--dry-run]}"
|
||||
DRY_RUN="${2:-}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
FORGE_BASE="${NEXTCRAFT_FORGE_BASE:-https://git.coreci.dev}"
|
||||
OWNER="coreci"
|
||||
REPO="nextcraft"
|
||||
|
||||
resolve_token() {
|
||||
for f in "$ROOT/.ciagent/.env.secrets" "$ROOT/.env.secrets" "$ROOT/.env" "$ROOT/.ciagent/.env"; do
|
||||
if [ -f "$f" ] && grep -q '^GITEA_TOKEN=' "$f"; then
|
||||
grep '^GITEA_TOKEN=' "$f" | head -1 | cut -d= -f2
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
TOKEN="$(resolve_token)" || { echo "no GITEA_TOKEN in .env* files — cannot upload assets"; exit 1; }
|
||||
|
||||
BIN="$ROOT/apps/cli/dist/nextcraft-linux-x64"
|
||||
SUM="$ROOT/apps/cli/dist/nextcraft-linux-x64.sha256"
|
||||
[ -f "$BIN" ] || { echo "binary missing at $BIN — run pnpm --filter @nextcraft/cli build:binary $TAG first"; exit 1; }
|
||||
[ -f "$SUM" ] || { echo "checksum missing at $SUM"; exit 1; }
|
||||
|
||||
RELEASE_ID="$(curl -fsSL -H "Authorization: token $TOKEN" \
|
||||
"$FORGE_BASE/api/v1/repos/$OWNER/$REPO/releases/tags/$TAG" | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
print(d["id"])
|
||||
' 2>/dev/null)" || true
|
||||
[ -n "${RELEASE_ID:-}" ] || { echo "no release found for tag $TAG"; exit 1; }
|
||||
|
||||
upload() {
|
||||
local file="$1" name="$2"
|
||||
if [ "$DRY_RUN" = "--dry-run" ]; then
|
||||
echo "DRY-RUN would upload $name -> release $RELEASE_ID ($(stat -c%s "$file") bytes)"
|
||||
return 0
|
||||
fi
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$file" \
|
||||
"$FORGE_BASE/api/v1/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$name" >/dev/null
|
||||
echo "uploaded $name"
|
||||
}
|
||||
|
||||
upload "$BIN" "nextcraft-linux-x64"
|
||||
upload "$SUM" "nextcraft-linux-x64.sha256"
|
||||
echo "release $TAG now carries nextcraft-linux-x64 + .sha256"
|
||||
+12
@@ -34,6 +34,18 @@
|
||||
"@nextcraft/ai-service#lint": {
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"@nextcraft/cli#test": {
|
||||
"cache": false,
|
||||
"outputs": []
|
||||
},
|
||||
"@nextcraft/cli#dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"@nextcraft/cli#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user