diff --git a/.codex/bo4_runs.jsonl b/.codex/bo4_runs.jsonl new file mode 100644 index 0000000..bfbf088 --- /dev/null +++ b/.codex/bo4_runs.jsonl @@ -0,0 +1,2 @@ +{"task_id":"task_e_68eb0ffa43d88320bad9f2faa2889687","env":"LexLattice/true-modules","base":"main","best_of":4,"prompt_file":"prompts/wave3/c6-implementer-pack.md","cards":["C6"],"timestamp":"2025-10-12T02:19:12Z"} +{"task_id":"task_e_68eb1001d8488320970fdaf8e6efc4eb","env":"LexLattice/true-modules","base":"main","best_of":4,"prompt_file":"prompts/wave3/c7-meta-pack.md","cards":["C7"],"timestamp":"2025-10-12T02:19:12Z"} diff --git a/.codex/prompts/amr_merge.md b/.codex/prompts/amr_merge.md new file mode 100644 index 0000000..a45f709 --- /dev/null +++ b/.codex/prompts/amr_merge.md @@ -0,0 +1,24 @@ +# SYSTEM — AMR Meta-Reviewer (merge slates into Canon) + +You are the **Meta-Reviewer**. Given 3–4 architecture slates (no code), MERGE them into a single Canon and justify the choice. + +## Inputs +- `rcm/rcm.json` — Requirement Coverage Matrix +- `amr/slates/var*/{architecture.json,schemas.json,acceptance.json,notes.md}` + +## Score each slate (0..1); weights: +- RCM coverage (0.35) — % of `must:true` linked by module+test +- SSD (0.25) — per-layer spec density (architecture/data_schemas/error_model/cli_surface) +- Complexity (0.15) — edges/modules; penalize fan-in/out > 3 +- Testability (0.15) — # acceptance cases + oracles +- Risk (0.10) — rollbacks, error taxonomy, timeouts + +## Produce (REQUIRED) +- `amr/architecture.json`, `amr/schemas.json`, `amr/acceptance.json` +- `amr/traceability.map.json` — REQ → {modules, interfaces, tests} +- `amr/amr_winner.md` — why chosen, why not others, residual risks +- Raise any layer with SSD < 0.75 by adding explicit contracts/schemas. + +## Rules +- Every `must:true` in RCM must have ≥1 module **and** ≥1 test in the Canon. +- Resolve ambiguity; keep fan-out ≤ 3; prefer simpler wiring. diff --git a/.codex/prompts/bo4A_architect.md b/.codex/prompts/bo4A_architect.md new file mode 100644 index 0000000..a9a5208 --- /dev/null +++ b/.codex/prompts/bo4A_architect.md @@ -0,0 +1,39 @@ +# SYSTEM — Bo4-A Architect (no code; canon slate) + +You are the **Architect**. Produce ONLY these files in your output: +- `architecture.json` — modules, interfaces, invariants, edges +- `schemas.json` — JSON Schemas for all interface inputs/outputs +- `acceptance.json` — REQ-* → test cases with oracle names +- `notes.md` — assumptions, risks, open choices + +## Constraints +- Use `rcm/rcm.json` as source of truth; each module/interface/test MUST reference ≥1 `REQ-*`. +- Interfaces MUST include: `name`, `input`, `output`, `errors[]`, `pre[]`, `post[]`. +- Keep modules cohesive, edges minimal; target fan-out ≤ 3. +- Declare invariants (e.g., idempotence, determinism); link each to tests. + +## Output format hints +- `architecture.json`: + ```json + { + "modules":[ + { + "id":"reporter", + "purpose":"Emit report.json", + "interfaces":[ + { + "name":"Reporter.write", + "input":{"$ref":"schema.RunSummary"}, + "output":{"path":"string"}, + "errors":["E_IO","E_VALIDATION"], + "pre":["RunSummary.valid == true"], + "post":["file.exists(output.path)"] + } + ], + "invariants":["idempotent(Reporter.write)"] + } + ], + "edges":[{"from":"cli","to":"reporter","contract":"Reporter.write"}], + "schemas_ref":"schemas.json" + } + ``` diff --git a/.codex/selfcheck.sh b/.codex/selfcheck.sh new file mode 100755 index 0000000..52e4504 --- /dev/null +++ b/.codex/selfcheck.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE=${1:-runs/current/meta/compose.json} +LOCK=${2:-amr/canon.lock.json} +MODROOT=${3:-modules} +ARTIFACTS_DIR=${4:-artifacts} + +echo ">>> Canon check ..." +if [[ ! -f "$COMPOSE" ]]; then + echo "Compose file not found: $COMPOSE" >&2 + exit 1 +fi +if [[ ! -f "$LOCK" ]]; then + echo "Canon lock not found: $LOCK" >&2 + exit 1 +fi +if [[ ! -d "$MODROOT" ]]; then + echo "Modules root not found: $MODROOT" >&2 + exit 1 +fi + +mkdir -p "$ARTIFACTS_DIR" + +EVENTS_FILE="${ARTIFACTS_DIR%/}/events.ndjson" + +node tm.mjs gates shipping \ + --compose "$COMPOSE" \ + --modules-root "$MODROOT" \ + --emit-events \ + --events-out "$EVENTS_FILE" \ + --events-truncate \ + --strict-events + +node scripts/canon-verify.mjs \ + --lock "$LOCK" \ + --modules-root "$MODROOT" \ + --compose "$COMPOSE" + +echo ">>> PASS: Canon stamp verified." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54477f1..f1562c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,7 @@ jobs: --npm-pack \ --emit-events \ --events-out artifacts/events.ndjson \ + --events-truncate \ --strict-events echo "::endgroup::" diff --git a/.gitignore b/.gitignore index b1f0514..7693888 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,8 @@ target .env artifacts/ +!artifacts/events.ndjson !runs/**/artifacts/ tmp/ cloud-prompt-*.md -.codex/ runs/ci-headless/ diff --git a/amr/canon.lock.json b/amr/canon.lock.json new file mode 100644 index 0000000..bac69ec --- /dev/null +++ b/amr/canon.lock.json @@ -0,0 +1,42 @@ +{ + "schema": "tm-canon-lock@1", + "modules": { + "reporter": { + "ports": { + "ReporterPort@1": { + "export": { + "file": "src/index.js", + "symbol": "reporterWrite" + } + } + }, + "invariants": { + "idempotent(Reporter.write)": { + "test": "tests/invariants/idempotent.json" + } + }, + "acceptance": { + "T-001": "tests/T-001.json", + "T-002": "tests/T-002.json" + } + }, + "cli": { + "ports": { + "CLIPort@1": { + "export": { + "file": "src/cli.js", + "symbol": "cliParse" + } + } + }, + "invariants": { + "deterministic(CLI.parse)": { + "test": "tests/invariants/deterministic.json" + } + }, + "acceptance": { + "T-010": "tests/T-010.json" + } + } + } +} diff --git a/artifacts/events.ndjson b/artifacts/events.ndjson new file mode 100644 index 0000000..4915b42 --- /dev/null +++ b/artifacts/events.ndjson @@ -0,0 +1,23 @@ +{"schema":"tm-events@1","event":"GATES_START","ts":"2025-10-19T02:35:59.889Z","seq":1,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"compose_path":"/workspace/true-modules/runs/canon-loop-demo/meta/compose.json","modules_total":2}} +{"schema":"tm-events@1","event":"LINT_START","ts":"2025-10-19T02:35:59.960Z","seq":2,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"lint_tool":"eslint"}} +{"schema":"tm-events@1","event":"LINT_PASS","ts":"2025-10-19T02:36:00.798Z","seq":3,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"lint_tool":"eslint","dur_ms":838}} +{"schema":"tm-events@1","event":"TEST_START","ts":"2025-10-19T02:36:00.800Z","seq":4,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/T-001.json"}} +{"schema":"tm-events@1","event":"TEST_PASS","ts":"2025-10-19T02:36:00.934Z","seq":5,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/T-001.json","dur_ms":134,"side_effects":{"declared":["FS:write"],"observed_operations":["FS:write"],"undeclared_operations":[],"fs_write":{"count":5,"outside_module_root":false,"sample_paths":[{"path":".tmp","inside_module_root":true},{"path":".tmp/case-t0cphL","inside_module_root":true},{"path":".tmp/case-t0cphL/reporter.log","inside_module_root":true},{"path":".tmp/case-t0cphL","inside_module_root":true},{"path":".tmp/case-t0cphL/reporter.log","inside_module_root":true}],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"TEST_START","ts":"2025-10-19T02:36:00.935Z","seq":6,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/T-002.json"}} +{"schema":"tm-events@1","event":"TEST_PASS","ts":"2025-10-19T02:36:01.088Z","seq":7,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/T-002.json","dur_ms":153,"side_effects":{"declared":["FS:write"],"observed_operations":["FS:write"],"undeclared_operations":[],"fs_write":{"count":7,"outside_module_root":false,"sample_paths":[{"path":".tmp","inside_module_root":true},{"path":".tmp/case-XI63ZD","inside_module_root":true},{"path":".tmp/case-XI63ZD/reporter.log","inside_module_root":true},{"path":".tmp/case-XI63ZD","inside_module_root":true},{"path":".tmp/case-XI63ZD/reporter.log","inside_module_root":true}],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"TEST_START","ts":"2025-10-19T02:36:01.089Z","seq":8,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/invariants/idempotent.json"}} +{"schema":"tm-events@1","event":"TEST_PASS","ts":"2025-10-19T02:36:01.226Z","seq":9,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","test":"tests/invariants/idempotent.json","dur_ms":137,"side_effects":{"declared":["FS:write"],"observed_operations":["FS:write"],"undeclared_operations":[],"fs_write":{"count":8,"outside_module_root":false,"sample_paths":[{"path":".tmp","inside_module_root":true},{"path":".tmp/case-dW6Efo","inside_module_root":true},{"path":".tmp/case-dW6Efo/reporter.log","inside_module_root":true},{"path":".tmp/case-dW6Efo","inside_module_root":true},{"path":".tmp/case-dW6Efo/reporter.log","inside_module_root":true}],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"TEST_START","ts":"2025-10-19T02:36:01.227Z","seq":10,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","test":"tests/T-010.json"}} +{"schema":"tm-events@1","event":"TEST_PASS","ts":"2025-10-19T02:36:01.358Z","seq":11,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","test":"tests/T-010.json","dur_ms":131,"side_effects":{"declared":[],"observed_operations":[],"undeclared_operations":[],"fs_write":{"count":0,"outside_module_root":false,"sample_paths":[],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"TEST_START","ts":"2025-10-19T02:36:01.359Z","seq":12,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","test":"tests/invariants/deterministic.json"}} +{"schema":"tm-events@1","event":"TEST_PASS","ts":"2025-10-19T02:36:01.488Z","seq":13,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","test":"tests/invariants/deterministic.json","dur_ms":129,"side_effects":{"declared":[],"observed_operations":[],"undeclared_operations":[],"fs_write":{"count":0,"outside_module_root":false,"sample_paths":[],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"PORT_CHECK_START","ts":"2025-10-19T02:36:01.492Z","seq":14,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","port":"ReporterPort@1"}} +{"schema":"tm-events@1","event":"PORT_CHECK_START","ts":"2025-10-19T02:36:01.495Z","seq":15,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","port":"CLIPort@1"}} +{"schema":"tm-events@1","event":"TSC_START","ts":"2025-10-19T02:36:01.500Z","seq":16,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"artifact":".tm/tsc.log"}} +{"schema":"tm-events@1","event":"TSC_PASS","ts":"2025-10-19T02:36:02.866Z","seq":17,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"dur_ms":1364,"artifact":".tm/tsc.log"}} +{"schema":"tm-events@1","event":"PORT_CHECK_PASS","ts":"2025-10-19T02:36:02.867Z","seq":18,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","port":"ReporterPort@1"}} +{"schema":"tm-events@1","event":"PORT_CHECK_PASS","ts":"2025-10-19T02:36:02.867Z","seq":19,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","port":"CLIPort@1"}} +{"schema":"tm-events@1","event":"SIDEEFFECTS_SUMMARY","ts":"2025-10-19T02:36:02.880Z","seq":20,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"cli","side_effects":{"declared":[],"observed_operations":[],"undeclared_operations":[],"fs_write":{"count":0,"outside_module_root":false,"sample_paths":[],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"SIDEEFFECTS_SUMMARY","ts":"2025-10-19T02:36:02.880Z","seq":21,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"module":"reporter","side_effects":{"declared":["FS:write"],"observed_operations":["FS:write"],"undeclared_operations":[],"fs_write":{"count":20,"outside_module_root":false,"sample_paths":[{"path":".tmp","inside_module_root":true},{"path":".tmp/case-t0cphL","inside_module_root":true},{"path":".tmp/case-t0cphL/reporter.log","inside_module_root":true},{"path":".tmp/case-XI63ZD","inside_module_root":true},{"path":".tmp/case-XI63ZD/reporter.log","inside_module_root":true}],"outside_samples":[]},"processes":{"total":0,"categories":{}}}}} +{"schema":"tm-events@1","event":"GATES_SUMMARY","ts":"2025-10-19T02:36:02.881Z","seq":22,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"phases":[{"name":"manifest_validation","dur_ms":62,"status":"pass"},{"name":"lint","dur_ms":840,"status":"pass"},{"name":"tests","dur_ms":689,"status":"pass"},{"name":"typecheck","dur_ms":1370,"status":"pass"},{"name":"oracles","dur_ms":0,"status":"skipped"},{"name":"npm_pack","dur_ms":0,"status":"skipped"}],"dur_ms":2992,"passed":5,"failed":0,"skipped":0}} +{"schema":"tm-events@1","event":"GATES_PASS","ts":"2025-10-19T02:36:02.881Z","seq":23,"source":{"cli":"tm","version":"0.1.0"},"context":{"run_id":"canon-demo","mode":"shipping","compose_sha256":"6809b66c0ea846ca9467f763b91e73b3cd9ccf87e462bf5e1beae3ccd2de1af0"},"detail":{"passed":5,"failed":0,"skipped":0,"dur_ms":2992}} diff --git a/docs/briefs.md b/docs/briefs.md index f228d5f..06cf134 100644 --- a/docs/briefs.md +++ b/docs/briefs.md @@ -1,3 +1,12 @@ +## Canon Loop Quick Reference + +- System prompt: copy the “Canon instructions excerpt” from `docs/implementation-briefs.md` into Codex Cloud before launching an implementation run. +- Self-check: `.codex/selfcheck.sh ` wraps shipping gates plus canon verification and emits `artifacts/events.ndjson`. +- Reports: after a PASS, duplicate `implementation_report.json.template`, fill the run metadata, and commit it with the new `artifacts/events.ndjson`. +- MUST block: every C/E/F card now inherits the shared requirements list (ports bound, tests present, selfcheck run, report completed, no failing gates). + +--- + ## C1 — Add Type-Safe Shipping (TS compile in gates) **Why:** catch broken types in the winner workspace, not just schema/test issues. diff --git a/docs/headless-bo4-runbook.md b/docs/headless-bo4-runbook.md index b7dbfc9..3a9ff28 100644 --- a/docs/headless-bo4-runbook.md +++ b/docs/headless-bo4-runbook.md @@ -102,6 +102,18 @@ cat <<'EOF' > runs/2024-07-01-demo/meta/followup.txt EOF ``` +## Canon failure remediation + +When `.codex/selfcheck.sh` or direct `tm gates shipping` runs fail, inspect the first error code in stderr and apply the matching fix before re-running. + +- **E_CANON_MISSING_MODULE**: generate the module scaffold with `node tm.mjs module --new ` (or create `modules//` manually), then implement the required files/tests from the canon lock. +- **E_CANON_MISSING_INTERFACE**: add the missing `"port_exports"` entry in `modules//module.json` and expose the correct symbol/file. +- **E_CANON_PORT_MISMATCH**: ensure the exported symbol matches the port contract (TypeScript/JS signature); update both the implementation and `port_exports`. +- **E_CANON_MISSING_INVARIANT_TEST**: create the JSON spec or script listed in the lock, register it in `module.json/tests`, and wire it into the runner. +- **E_CANON_MISSING_ACCEPTANCE**: scaffold `T-xxx` acceptance specs under the module’s `tests/` folder and make sure the runner executes them. +- **E_TSC / E_LINT**: resolve TypeScript or lint diagnostics (missing types, cross-imports) highlighted in the gate output. +- **Other errors** (`E_REQUIRE_UNSAT`, `E_HOOK`, etc.): follow the message guidance, fix the underlying issue, and rerun the self-check until PASS. + ## References - `docs/headless-cloud.md` — automation details for watch/harvest/meta/compose/gates. diff --git a/docs/headless-cloud.md b/docs/headless-cloud.md index 4728134..c273e82 100644 --- a/docs/headless-cloud.md +++ b/docs/headless-cloud.md @@ -156,6 +156,22 @@ The command prints grouped logs for each stage, updates `runs//run.json`, and leaves variant exports under `.codex-cloud/variants/...` ready for meta review or application. +## Canon preflight hook (optional) + +If the Codex Cloud environment supports post-run hooks, wire in the canon +verifier so failed stamps never surface as “ready”: + +```bash +node tm.mjs gates shipping \ + --compose runs//meta/compose.json \ + --modules-root modules \ + --canon-lock amr/canon.lock.json \ + --emit-events --events-out artifacts/events.ndjson --strict-events +``` + +The hook exits non-zero on canon or gating violations, keeping headless runs +blocked until the local self-check loop produces a PASS. + ## References - Codex Cloud CLI: run `codex cloud --help` for command trees and flags. diff --git a/docs/implementation-briefs.md b/docs/implementation-briefs.md index 03f488c..0c90f0f 100644 --- a/docs/implementation-briefs.md +++ b/docs/implementation-briefs.md @@ -1,3 +1,70 @@ +# Canon Workflow Guardrails + +Codex implementers must run a fixed “Canon loop” while working through these briefs. The loop keeps module manifests, tests, and evidence in sync with the canon lock before Codex Cloud files a patch. + +- **Canon sources** live under `amr/`: `architecture.json`, `acceptance.json`, and `traceability.map.json`. The lock file (`amr/canon.lock.json`) enumerates the exact modules, ports, invariants, and acceptance specs that must ship. +- **Helper script** `.codex/selfcheck.sh` wraps shipping gates (`tm gates shipping ...`) and a canon verifier so missing modules, port bindings, or tests are caught locally. The script emits `artifacts/events.ndjson` on every run. +- **Implementation report**: after a PASS, copy `implementation_report.json.template` to `implementation_report.json`, fill the placeholders with actual run metadata, and include it with the final patch alongside `artifacts/events.ndjson`. +- **Canon instructions excerpt**: the snippet below is the system prompt we paste into Codex Cloud for implementation tasks. It encodes the repeat-until-green loop. + +```text +You MUST NOT submit a patch until `.codex/selfcheck.sh` exits 0. + +Context +- Canon references live in: amr/architecture.json, amr/acceptance.json, amr/traceability.map.json. +- The lock is authoritative: amr/canon.lock.json. +- Implementation loop: docs/implementation-briefs.md (“Canon loop” section). + +Required loop (repeat until PASS) +1. Implement EXACTLY the Canon modules/interfaces, nothing extra. + - For every required interface, add `port_exports` in modules//module.json: + "port_exports": { "": { "file": "", "export": "" } } + - Add the invariant + acceptance specs listed in the lock (T-xxx) using repo conventions. +2. Run the self-check script locally: + - If a spec is missing, scaffold it and rerun. +3. Verify gates: + chmod +x .codex/selfcheck.sh # once per workspace + .codex/selfcheck.sh runs//meta/compose.json amr/canon.lock.json modules + - Do not fabricate stamp.json; rely on the command output. +4. After a PASS, fill implementation_report.json exactly like docs/implementation-briefs.md specifies. + - Every interface/invariant/acceptance must be “pass” (gates just proved it). +5. Submit ONLY: + - All code/test changes, + - implementation_report.json, + - artifacts/events.ndjson from the PASS run, + - Required module/winner reports (if any). +6. If gates fail (E_CANON_*, E_TSC, E_LINT, ...), fix the cause, rerun the loop, and stop when the script prints PASS. + +Never rename Canon IDs or interfaces, never remove failing tests, never declare PASS without a fresh green run. +``` + +MUST: +- Leave the canon intact: implement only the modules listed in `amr/canon.lock.json`, nothing extra. +- Bind all canon interfaces via `"port_exports"` in each module’s `module.json`. +- Add tests for every invariant and acceptance case noted in the lock (T-xxx); keep runner conventions. +- Run `.codex/selfcheck.sh` and include the resulting `artifacts/events.ndjson` in the submission. +- Create `implementation_report.json` from the template after a PASS run. +- Do not submit while any gate fails. + +--- + +## Canon Loop Test — Reporter + CLI Modules (T-001 · T-002 · T-010) + +- **Objective**: implement the two starter modules referenced by `amr/canon.lock.json` so we can validate the Canon loop end-to-end. +- **Key files**: `modules/reporter/module.json`, `modules/reporter/src/index.js`, `modules/reporter/tests/**`; `modules/cli/module.json`, `modules/cli/src/cli.js`, `modules/cli/tests/**`; `implementation_report.json`. +- **Implementation steps**: + 1. **Reporter port**: author `reporterWrite(message, options?)` that appends distinct, trimmed log entries to `artifacts/reporter.log` (idempotent on repeated writes). Surface `{ file, appended, lines }`. + 2. **CLI port**: expose `cliParse(argv)` that normalises argv, recognises `report|status`, parses `--key=value`/`--key value`/short flags, and returns `{ command, options, positionals, errors }`. + 3. **Modules**: bring both manifests in line with the lock (ports, invariants, tests, evidence). Invariant + acceptance specs live under each module’s `tests/` tree with a shared `tests/runner.mjs`. + 4. **Tests**: add JSON specs called out in the lock — reporter: `tests/T-001.json` (creates log), `tests/T-002.json` (preserves order), `tests/invariants/idempotent.json`; CLI: `tests/T-010.json` plus `tests/invariants/deterministic.json`. + 5. **Report**: after a PASS loop, copy `implementation_report.json.template` → `implementation_report.json`, compute lock/compose SHA256, and list `"pass"` for every port/invariant/acceptance. +- **Acceptance**: + - `.codex/selfcheck.sh runs/canon-loop-demo/meta/compose.json amr/canon.lock.json modules` completes with `PASS: Canon stamp verified.` and produces `artifacts/events.ndjson`. + - `node tm.mjs gates shipping --compose runs/canon-loop-demo/meta/compose.json --modules-root modules --emit-events --strict-events` matches the selfcheck results (no additional failures). + - `implementation_report.json` and `artifacts/events.ndjson` from the PASS run are staged for submission. + +--- + # Implementation Briefs — Wave 1 (C1–C3) This implementation layer translates the high-level briefs into concrete work items for Codex Cloud coders. Each card below captures scope, files, acceptance criteria, event expectations, and CI touchpoints. Wave 1 focuses on hardening shipping gates and static analysis. diff --git a/implementation_report.json b/implementation_report.json new file mode 100644 index 0000000..9b244b4 --- /dev/null +++ b/implementation_report.json @@ -0,0 +1,26 @@ +{ + "schema": "tm-implementation-report@1", + "run_id": "2025-10-19T02:36:02.881Z", + "canon_lock_sha256": "97b94ada2f0f6bb47477259c7d54a75108a71130ba717d108f93af148db56400", + "compose_sha256": "1bc197c0266354b6bef03c58b86156bde9674285f969659eeb95f26526d23013", + "results": { + "reporter": { + "interfaces": { "Reporter.write": "pass" }, + "invariants": { "idempotent(Reporter.write)": "pass" }, + "acceptance": { "T-001": "pass", "T-002": "pass" } + }, + "cli": { + "interfaces": { "CLI.parse": "pass" }, + "invariants": { "deterministic(CLI.parse)": "pass" }, + "acceptance": { "T-010": "pass" } + } + }, + "evidence": { + "events_file": "artifacts/events.ndjson", + "port_exports": { + "reporter": { "Reporter.write": "modules/reporter/src/index.js#reporterWrite" }, + "cli": { "CLI.parse": "modules/cli/src/cli.js#cliParse" } + } + }, + "notes": [] +} diff --git a/implementation_report.json.template b/implementation_report.json.template new file mode 100644 index 0000000..e8ed97d --- /dev/null +++ b/implementation_report.json.template @@ -0,0 +1,26 @@ +{ + "schema": "tm-implementation-report@1", + "run_id": "", + "canon_lock_sha256": "", + "compose_sha256": "", + "results": { + "reporter": { + "interfaces": { "Reporter.write": "pass" }, + "invariants": { "idempotent(Reporter.write)": "pass" }, + "acceptance": { "T-001": "pass", "T-002": "pass" } + }, + "cli": { + "interfaces": { "CLI.parse": "pass" }, + "invariants": { "deterministic(CLI.parse)": "pass" }, + "acceptance": { "T-010": "pass" } + } + }, + "evidence": { + "events_file": "artifacts/events.ndjson", + "port_exports": { + "reporter": { "Reporter.write": "modules/reporter/src/index.js#reporterWrite" }, + "cli": { "CLI.parse": "modules/cli/src/cli.js#cliParse" } + } + }, + "notes": [] +} diff --git a/modules/cli/module.json b/modules/cli/module.json new file mode 100644 index 0000000..40401e8 --- /dev/null +++ b/modules/cli/module.json @@ -0,0 +1,29 @@ +{ + "id": "cli", + "version": "0.1.0", + "summary": "Argument parser for the reporter tool.", + "provides": [ + "CLIPort@1" + ], + "requires": [], + "inputs": {}, + "outputs": {}, + "side_effects": [], + "invariants": [ + "deterministic(CLI.parse)" + ], + "tests": [ + "tests/T-010.json", + "tests/invariants/deterministic.json" + ], + "port_exports": { + "CLIPort@1": { + "file": "src/cli.js", + "export": "cliParse" + } + }, + "evidence": [ + { "kind": "test", "file": "tests/T-010.json" }, + { "kind": "test", "file": "tests/invariants/deterministic.json" } + ] +} diff --git a/modules/cli/src/cli.d.ts b/modules/cli/src/cli.d.ts new file mode 100644 index 0000000..131bd63 --- /dev/null +++ b/modules/cli/src/cli.d.ts @@ -0,0 +1,8 @@ +export interface CLIParseResult { + command: string | null; + options: Record; + positionals: string[]; + errors: string[]; +} + +export declare function cliParse(argv: string[]): CLIParseResult; diff --git a/modules/cli/src/cli.js b/modules/cli/src/cli.js new file mode 100644 index 0000000..13e69d4 --- /dev/null +++ b/modules/cli/src/cli.js @@ -0,0 +1,93 @@ +import path from 'path'; +import process from 'process'; + +const SCRIPT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); +const NODE_BINARIES = new Set(['node', 'node.exe']); + +function isNodeExecutable(value) { + if (typeof value !== 'string') return false; + if (value === process.execPath) return true; + const base = path.basename(value); + return NODE_BINARIES.has(base); +} + +function isScriptPath(value) { + if (typeof value !== 'string') return false; + const ext = path.extname(value); + return SCRIPT_EXTENSIONS.has(ext); +} + +export function cliParse(argv) { + if (!Array.isArray(argv)) { + throw new TypeError('CLI.parse expects an argv array.'); + } + + const tokens = argv.slice(); + if (tokens.length && isNodeExecutable(tokens[0])) { + tokens.shift(); + } + if (tokens.length && isScriptPath(tokens[0])) { + tokens.shift(); + } + + const result = { + command: null, + options: {}, + positionals: [], + errors: [] + }; + + if (tokens.length === 0) { + result.errors.push('No command provided.'); + return result; + } + + result.command = tokens.shift(); + const knownCommands = new Set(['report', 'status']); + if (!knownCommands.has(result.command)) { + result.errors.push(`Unknown command: ${result.command}`); + } + + while (tokens.length > 0) { + const token = tokens.shift(); + if (token.startsWith('--')) { + const [rawKey, rawValue] = token.slice(2).split('=', 2); + if (!rawKey) { + result.errors.push('Invalid flag syntax.'); + continue; + } + let value = rawValue; + if (value === undefined) { + const next = tokens[0]; + if (next && !next.startsWith('-')) { + value = tokens.shift(); + } else { + value = true; + } + } + result.options[rawKey] = value; + continue; + } + if (token.startsWith('-')) { + const flags = token.slice(1).split(''); + for (const flag of flags) { + result.options[flag] = true; + } + continue; + } + result.positionals.push(token); + } + + if ('format' in result.options) { + const allowedFormats = new Set(['json', 'text']); + if (!allowedFormats.has(result.options.format)) { + result.errors.push(`Unsupported format: ${result.options.format}`); + } + } + + if (result.command === 'report' && result.positionals.length === 0) { + result.errors.push('report command expects a message argument'); + } + + return result; +} diff --git a/modules/cli/tests/T-010.json b/modules/cli/tests/T-010.json new file mode 100644 index 0000000..34d8dbb --- /dev/null +++ b/modules/cli/tests/T-010.json @@ -0,0 +1,6 @@ +{ + "name": "T-010 - parses report command with format", + "case": "basic", + "argv": ["node", "cli.js", "report", "--format=json", "deployment ok"], + "message": "deployment ok" +} diff --git a/modules/cli/tests/invariants/deterministic.json b/modules/cli/tests/invariants/deterministic.json new file mode 100644 index 0000000..3f89978 --- /dev/null +++ b/modules/cli/tests/invariants/deterministic.json @@ -0,0 +1,5 @@ +{ + "name": "deterministic(CLI.parse)", + "case": "deterministic", + "argv": ["report", "--format", "json", "ping"] +} diff --git a/modules/cli/tests/runner.mjs b/modules/cli/tests/runner.mjs new file mode 100644 index 0000000..e2d844f --- /dev/null +++ b/modules/cli/tests/runner.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'fs/promises'; +import process from 'process'; + +import { cliParse } from '../src/cli.js'; + +function parseArgs(argv) { + const result = new Map(); + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith('--')) continue; + const key = token.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${key}`); + } + result.set(key, value); + i += 1; + } + return result; +} + +async function loadSpec(filePath) { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); +} + +function runCase(spec) { + switch (spec.case) { + case 'basic': { + const result = cliParse(spec.argv); + assert.equal(result.command, 'report'); + assert.equal(result.errors.length, 0); + assert.equal(result.options.format, 'json'); + assert.deepEqual(result.positionals, [spec.message]); + break; + } + case 'invalid-format': { + const result = cliParse(spec.argv); + assert.ok(result.errors.some((err) => /unsupported format/i.test(err))); + break; + } + case 'deterministic': { + const first = cliParse(spec.argv); + const second = cliParse(spec.argv); + assert.deepEqual(second, first); + break; + } + default: + throw new Error(`Unknown spec case: ${spec.case}`); + } +} + +async function main() { + const args = parseArgs(process.argv); + const specPath = args.get('spec'); + if (!specPath) { + throw new Error('Missing --spec argument'); + } + const spec = await loadSpec(specPath); + runCase(spec); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err.stack || err.message || err); + process.exit(1); + }); diff --git a/modules/reporter/module.json b/modules/reporter/module.json new file mode 100644 index 0000000..e24cd9f --- /dev/null +++ b/modules/reporter/module.json @@ -0,0 +1,33 @@ +{ + "id": "reporter", + "version": "0.1.0", + "summary": "Append-only log writer with idempotent entries for status reports.", + "provides": [ + "ReporterPort@1" + ], + "requires": [], + "inputs": {}, + "outputs": {}, + "side_effects": [ + "FS:write" + ], + "invariants": [ + "idempotent(Reporter.write)" + ], + "tests": [ + "tests/T-001.json", + "tests/T-002.json", + "tests/invariants/idempotent.json" + ], + "port_exports": { + "ReporterPort@1": { + "file": "src/index.js", + "export": "reporterWrite" + } + }, + "evidence": [ + { "kind": "test", "file": "tests/T-001.json" }, + { "kind": "test", "file": "tests/T-002.json" }, + { "kind": "test", "file": "tests/invariants/idempotent.json" } + ] +} diff --git a/modules/reporter/src/index.d.ts b/modules/reporter/src/index.d.ts new file mode 100644 index 0000000..e6b21cc --- /dev/null +++ b/modules/reporter/src/index.d.ts @@ -0,0 +1,16 @@ +export interface ReporterWriteOptions { + logDir?: string; + fileName?: string; + cwd?: string; +} + +export interface ReporterWriteResult { + file: string; + appended: boolean; + lines: string[]; +} + +export declare function reporterWrite( + message: string, + options?: ReporterWriteOptions +): Promise; diff --git a/modules/reporter/src/index.js b/modules/reporter/src/index.js new file mode 100644 index 0000000..3653fe8 --- /dev/null +++ b/modules/reporter/src/index.js @@ -0,0 +1,51 @@ +import fs from 'fs/promises'; +import path from 'path'; +import process from 'process'; + +function normalizeLines(contents) { + if (!contents) return []; + return contents + .split(/\r?\n/g) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); +} + +export async function reporterWrite(message, options = {}) { + if (typeof message !== 'string') { + throw new TypeError('Reporter.write expects a string message.'); + } + const trimmed = message.trim(); + if (!trimmed) { + throw new Error('Reporter.write requires a non-empty message.'); + } + + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const logDir = options.logDir + ? path.resolve(options.logDir) + : path.join(cwd, 'artifacts'); + const fileName = options.fileName || 'reporter.log'; + const logFile = path.join(logDir, fileName); + + await fs.mkdir(logDir, { recursive: true }); + + let prior = []; + try { + const existing = await fs.readFile(logFile, 'utf8'); + prior = normalizeLines(existing); + } catch (err) { + if (err && err.code !== 'ENOENT') throw err; + } + + const seen = new Set(prior); + const alreadyLogged = seen.has(trimmed); + if (!alreadyLogged) { + await fs.appendFile(logFile, `${trimmed}\n`, 'utf8'); + prior.push(trimmed); + } + + return { + file: logFile, + appended: !alreadyLogged, + lines: prior.slice() + }; +} diff --git a/modules/reporter/tests/T-001.json b/modules/reporter/tests/T-001.json new file mode 100644 index 0000000..7afd374 --- /dev/null +++ b/modules/reporter/tests/T-001.json @@ -0,0 +1,5 @@ +{ + "name": "T-001 - creates log file on first write", + "case": "create-log", + "message": "first entry" +} diff --git a/modules/reporter/tests/T-002.json b/modules/reporter/tests/T-002.json new file mode 100644 index 0000000..ebfd736 --- /dev/null +++ b/modules/reporter/tests/T-002.json @@ -0,0 +1,5 @@ +{ + "name": "T-002 - preserves write order", + "case": "preserve-order", + "messages": ["alpha", "beta"] +} diff --git a/modules/reporter/tests/invariants/idempotent.json b/modules/reporter/tests/invariants/idempotent.json new file mode 100644 index 0000000..99922b3 --- /dev/null +++ b/modules/reporter/tests/invariants/idempotent.json @@ -0,0 +1,6 @@ +{ + "name": "idempotent(Reporter.write)", + "case": "idempotent", + "message": "stable", + "interleave": ["something else"] +} diff --git a/modules/reporter/tests/runner.mjs b/modules/reporter/tests/runner.mjs new file mode 100644 index 0000000..e004be4 --- /dev/null +++ b/modules/reporter/tests/runner.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'fs/promises'; +import path from 'path'; +import process from 'process'; + +import { reporterWrite } from '../src/index.js'; + +function parseArgs(argv) { + const result = new Map(); + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith('--')) continue; + const key = token.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${key}`); + } + result.set(key, value); + i += 1; + } + return result; +} + +async function loadSpec(filePath) { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); +} + +async function allocateScratch(rootDir) { + const scratchRoot = path.join(rootDir, '.tmp'); + await fs.mkdir(scratchRoot, { recursive: true }); + return fs.mkdtemp(path.join(scratchRoot, 'case-')); +} + +async function runCase(spec, moduleRoot) { + const logDir = await allocateScratch(moduleRoot); + try { + switch (spec.case) { + case 'create-log': { + const res = await reporterWrite(spec.message, { logDir }); + assert.equal(res.appended, true); + const contents = await fs.readFile(res.file, 'utf8'); + assert.match(contents, new RegExp(`${spec.message}\\s*$`, 'm')); + break; + } + case 'preserve-order': { + const first = await reporterWrite(spec.messages[0], { logDir }); + const second = await reporterWrite(spec.messages[1], { logDir }); + assert.equal(first.appended, true); + assert.equal(second.appended, true); + const contents = await fs.readFile(second.file, 'utf8'); + const lines = contents.trim().split(/\r?\n/); + assert.deepEqual(lines, spec.messages); + break; + } + case 'idempotent': { + const first = await reporterWrite(spec.message, { logDir }); + assert.equal(first.appended, true); + + if (Array.isArray(spec.interleave)) { + for (const entry of spec.interleave) { + const res = await reporterWrite(entry, { logDir }); + assert.equal(res.appended, true); + } + } + + const second = await reporterWrite(spec.message, { logDir }); + assert.equal(second.appended, false); + + const contents = await fs.readFile(second.file, 'utf8'); + const lines = contents + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + const occurrences = lines.filter((line) => line === spec.message); + assert.equal(occurrences.length, 1); + break; + } + default: + throw new Error(`Unknown spec case: ${spec.case}`); + } +} finally { + await fs.rm(logDir, { recursive: true, force: true }); +} +} + +async function main() { + const args = parseArgs(process.argv); + const specPath = args.get('spec'); + const moduleRoot = args.get('moduleRoot'); + if (!specPath) { + throw new Error('Missing --spec argument'); + } + if (!moduleRoot) { + throw new Error('Missing --moduleRoot argument'); + } + const spec = await loadSpec(specPath); + await runCase(spec, moduleRoot); +} + +main() + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error(err.stack || err.message || err); + process.exit(1); + }); diff --git a/runs/canon-loop-demo/meta/compose.json b/runs/canon-loop-demo/meta/compose.json new file mode 100644 index 0000000..89d28d2 --- /dev/null +++ b/runs/canon-loop-demo/meta/compose.json @@ -0,0 +1,10 @@ +{ + "run_id": "canon-demo", + "modules": [ + { "id": "reporter", "version": "0.1.0" }, + { "id": "cli", "version": "0.1.0" } + ], + "wiring": [], + "glue": [], + "constraints": ["canon-demo"] +} diff --git a/runtimes/ts/ports/index.d.ts b/runtimes/ts/ports/index.d.ts index 49511fb..13d8915 100644 --- a/runtimes/ts/ports/index.d.ts +++ b/runtimes/ts/ports/index.d.ts @@ -23,9 +23,36 @@ export interface SafetyPort { isSafe(p: Path): Promise; } +export interface ReporterWriteOptions { + logDir?: Path; + fileName?: string; +} + +export interface ReporterWriteResult { + file: Path; + appended: boolean; + lines: string[]; +} + +export type ReporterPort = ( + message: string, + options?: ReporterWriteOptions +) => Promise; + +export interface CLIParseResult { + command: string | null; + options: Record; + positionals: string[]; + errors: string[]; +} + +export type CLIPort = (argv: string[]) => CLIParseResult; + export declare const PortsV1: { readonly DiffPort: 1; readonly IndexPort: 1; readonly WorktreePort: 1; readonly SafetyPort: 1; + readonly ReporterPort: 1; + readonly CLIPort: 1; }; diff --git a/runtimes/ts/ports/index.ts b/runtimes/ts/ports/index.ts index 44b223b..ac1e1a1 100644 --- a/runtimes/ts/ports/index.ts +++ b/runtimes/ts/ports/index.ts @@ -25,10 +25,37 @@ export interface SafetyPort { isSafe(p: Path): Promise; } +export interface ReporterWriteOptions { + logDir?: Path; + fileName?: string; +} + +export interface ReporterWriteResult { + file: Path; + appended: boolean; + lines: string[]; +} + +export type ReporterPort = ( + message: string, + options?: ReporterWriteOptions +) => Promise; + +export interface CLIParseResult { + command: string | null; + options: Record; + positionals: string[]; + errors: string[]; +} + +export type CLIPort = (argv: string[]) => CLIParseResult; + // Version fence for Ports@1 (documentation-only constant). export const PortsV1 = { DiffPort: 1, IndexPort: 1, WorktreePort: 1, SafetyPort: 1, + ReporterPort: 1, + CLIPort: 1, } as const; diff --git a/scripts/canon-verify.mjs b/scripts/canon-verify.mjs new file mode 100644 index 0000000..3733fdb --- /dev/null +++ b/scripts/canon-verify.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import fs from 'fs/promises'; +import path from 'path'; +import process from 'process'; + +class CanonError extends Error { + constructor(code, message, detail = null) { + super(message); + this.code = code; + if (detail) this.detail = detail; + } +} + +function usage() { + console.error('Usage: node scripts/canon-verify.mjs --lock --modules-root [--compose ]'); + process.exit(1); +} + +function parseArgs(argv) { + const args = {}; + for (let i = 2; i < argv.length; i += 1) { + const key = argv[i]; + if (!key.startsWith('--')) continue; + const name = key.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${name}`); + } + args[name] = value; + i += 1; + } + return args; +} + +async function readJson(filePath) { + try { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') { + throw new CanonError('E_CANON_MISSING_RESOURCE', `File not found: ${filePath}`); + } + throw new CanonError('E_CANON_PARSE', `Failed to parse JSON at ${filePath}: ${err.message}`); + } +} + +function ensureArray(value, code, message) { + if (!Array.isArray(value) || value.length === 0) { + throw new CanonError(code, message); + } + return value; +} + +async function ensureFileExists(filePath, code, message) { + try { + await fs.access(filePath); + } catch (err) { + if (err && err.code === 'ENOENT') { + throw new CanonError(code, message, { file: filePath }); + } + throw new CanonError(code, message, { file: filePath, cause: err?.message }); + } +} + +async function verifyModule(id, declaration, modulesRoot, composeModules) { + if (!id || typeof declaration !== 'object' || Array.isArray(declaration)) { + throw new CanonError('E_CANON_PARSE', `Invalid lock entry for module ${id}`); + } + if (composeModules && !composeModules.has(id)) { + throw new CanonError('E_CANON_MISSING_MODULE', `Compose plan is missing module ${id}`); + } + + const moduleRoot = path.join(modulesRoot, id); + const manifestPath = path.join(moduleRoot, 'module.json'); + const manifest = await readJson(manifestPath).catch((err) => { + if (err instanceof CanonError && err.code === 'E_CANON_MISSING_RESOURCE') { + throw new CanonError('E_CANON_MISSING_MODULE', `Module manifest missing for ${id}`, { manifest: manifestPath }); + } + throw err; + }); + + const manifestPorts = manifest.port_exports || {}; + const manifestTests = new Set(manifest.tests || []); + const manifestInvariants = new Set(manifest.invariants || []); + + if (!declaration.ports || typeof declaration.ports !== 'object') { + throw new CanonError('E_CANON_PARSE', `Lock entry for ${id} must include "ports" object`); + } + + for (const [portName, spec] of Object.entries(declaration.ports)) { + const manifestPort = manifestPorts[portName]; + if (!manifestPort) { + throw new CanonError('E_CANON_MISSING_INTERFACE', `Missing port_exports entry for ${portName} in ${id}`); + } + if (!spec || typeof spec !== 'object') { + throw new CanonError('E_CANON_PARSE', `Invalid port declaration for ${portName} in lock`); + } + if (spec.export?.file && manifestPort.file !== spec.export.file) { + throw new CanonError( + 'E_CANON_PORT_MISMATCH', + `Port ${portName} in ${id} must export file ${spec.export.file}`, + { expected: spec.export.file, found: manifestPort.file } + ); + } + if (spec.export?.symbol && manifestPort.export !== spec.export.symbol) { + throw new CanonError( + 'E_CANON_PORT_MISMATCH', + `Port ${portName} in ${id} must export symbol ${spec.export.symbol}`, + { expected: spec.export.symbol, found: manifestPort.export } + ); + } + const resolvedFile = path.join(moduleRoot, manifestPort.file); + await ensureFileExists(resolvedFile, 'E_CANON_PORT_MISSING_FILE', `Port export file missing for ${portName} in ${id}`); + } + + if (declaration.invariants) { + for (const [name, invariantSpec] of Object.entries(declaration.invariants)) { + if (!manifestInvariants.has(name)) { + throw new CanonError('E_CANON_MISSING_INVARIANT', `Invariant ${name} not declared in module.json for ${id}`); + } + const testPath = invariantSpec?.test; + if (!testPath || typeof testPath !== 'string') { + throw new CanonError('E_CANON_PARSE', `Invariant ${name} for ${id} must specify a test path`); + } + if (!manifestTests.has(testPath)) { + throw new CanonError( + 'E_CANON_MISSING_INVARIANT_TEST', + `Invariant test ${testPath} missing from module.json for ${id}` + ); + } + const resolvedTest = path.join(moduleRoot, testPath); + await ensureFileExists( + resolvedTest, + 'E_CANON_MISSING_INVARIANT_TEST', + `Invariant test file missing for ${name} in ${id}` + ); + } + } + + if (declaration.acceptance) { + for (const [label, testPath] of Object.entries(declaration.acceptance)) { + if (typeof testPath !== 'string') { + throw new CanonError('E_CANON_PARSE', `Acceptance ${label} for ${id} must be a string path`); + } + if (!manifestTests.has(testPath)) { + throw new CanonError( + 'E_CANON_MISSING_ACCEPTANCE', + `Acceptance ${label} missing from module.json for ${id}` + ); + } + const resolvedTest = path.join(moduleRoot, testPath); + await ensureFileExists( + resolvedTest, + 'E_CANON_MISSING_ACCEPTANCE', + `Acceptance spec file missing (${label}) for ${id}` + ); + } + } +} + +async function main() { + let args; + try { + args = parseArgs(process.argv); + } catch (err) { + console.error(err.message); + usage(); + return; + } + + const lockPath = args.lock; + const modulesRoot = args['modules-root']; + const composePath = args.compose; + if (!lockPath || !modulesRoot) usage(); + + const lock = await readJson(path.resolve(lockPath)); + if (!lock.modules || typeof lock.modules !== 'object') { + throw new CanonError('E_CANON_PARSE', 'Canon lock must provide a "modules" object'); + } + + let composeModules = null; + if (composePath) { + const compose = await readJson(path.resolve(composePath)); + const modList = ensureArray(compose.modules, 'E_CANON_PARSE', 'Compose file missing "modules" array'); + composeModules = new Set( + modList + .map((m) => (m && typeof m.id === 'string' ? m.id : null)) + .filter(Boolean) + ); + } + + for (const [moduleId, declaration] of Object.entries(lock.modules)) { + // eslint-disable-next-line no-await-in-loop + await verifyModule(moduleId, declaration, path.resolve(modulesRoot), composeModules); + } +} + +main() + .then(() => { + console.error('Canon verification completed.'); + }) + .catch((err) => { + if (err instanceof CanonError) { + const detail = err?.detail ? `\n${JSON.stringify(err.detail, null, 2)}` : ''; + console.error(`${err.code}: ${err.message}${detail}`); + process.exit(1); + } + console.error(err); + process.exit(1); + });