create-cmp-cli 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -71,6 +71,7 @@ function printHelp() {
71
71
  ` npx create-cmp create [target-dir] same, explicit\n` +
72
72
  ` npx create-cmp doctor toolchain doctor + project diagnosis (any KMP project)\n` +
73
73
  ` npx create-cmp upgrade migrate to the next proven-green version set\n` +
74
+ ` npx create-cmp upgrade --harness refresh engine-owned files of a stamped app (3-way merge)\n` +
74
75
  ` npx create-cmp clean ~/.konan + Gradle build-output hygiene (consent-gated)\n` +
75
76
  ` npx create-cmp verify run the green-build gate on an existing project\n\n` +
76
77
  `create (scaffold) flags:\n` +
@@ -83,6 +84,8 @@ function printHelp() {
83
84
  ` --target-dir --verify/--no-verify --yes --force --dry-run-verify\n\n` +
84
85
  `doctor flags: --yes --dry-run --no-ios --no-install --target-dir <dir> --fix\n` +
85
86
  `upgrade flags: --target-dir <dir> --set <id> --dry-run --yes --verify\n` +
87
+ ` --harness mode flags: --target-dir <dir> --base-dir <extracted-template> --dry-run --yes\n` +
88
+ ` (--harness dry-runs by default; conflicts never clobber — they land as *.cmp-new sidecars)\n` +
86
89
  `clean flags: --target-dir <dir> --dry-run --yes\n` +
87
90
  `verify flags: --target-dir <dir> --no-ios --dry-run\n`
88
91
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.12.0",
4
- "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
3
+ "version": "0.14.0",
4
+ "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI \u2014 the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-cmp": "bin/create-cmp.mjs",
@@ -14,6 +14,10 @@
14
14
  "bin",
15
15
  "src",
16
16
  "template",
17
+ "packages/harness/package.json",
18
+ "packages/harness/src",
19
+ "packages/receipts/package.json",
20
+ "packages/receipts/src",
17
21
  "options.schema.json",
18
22
  "llms.txt"
19
23
  ],
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "create-cmp-harness",
3
+ "version": "0.14.0",
4
+ "description": "The create-cmp verify lane — the machine-owned harness code every stamped app carries byte-identical: evidence receipts, spec coverage, approvals, conformance reporting, golden trees, a11y, and the preview/inspector libs. Dependency-free ESM, vendored into each generated project so the lane runs offline with no install step, and content-hashed so a receipt can name the exact lane that issued it.",
5
+ "type": "module",
6
+ "main": "src/verify.mjs",
7
+ "exports": {
8
+ ".": "./src/verify.mjs",
9
+ "./harness-region": "./src/lib/harness-region.mjs",
10
+ "./lib/*": "./src/lib/*.mjs"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "create-cmp",
24
+ "verify",
25
+ "evidence",
26
+ "receipt",
27
+ "harness",
28
+ "compose-multiplatform",
29
+ "kotlin-multiplatform"
30
+ ],
31
+ "license": "MIT",
32
+ "author": "Karel van der Merwe",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/kvdm-co-pilot/create-cmp.git",
36
+ "directory": "packages/harness"
37
+ }
38
+ }
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // The approvals CLI — thin shell over qa/lib/approvals.mjs.
3
+ //
4
+ // node qa/approve.mjs <artifact> records approval (recomputes the artifact's
5
+ // hash now, stamps the time, writes qa/approvals.json)
6
+ // node qa/approve.mjs --status lists every governed artifact + live state
7
+ // (unreviewed / approved / changed-since-approval /
8
+ // reopened, + mode when set) + short hash
9
+ // node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
10
+ // every currently-resolvable, not-yet-approved
11
+ // artifact, each stamped "defaults-accepted"
12
+ // node qa/approve.mjs --reopen <artifact> --reason "…"
13
+ // moves an approved artifact back to "reopened" for
14
+ // redesign (refuses anything not currently approved).
15
+ // --reason is REQUIRED — a reopen walks back a
16
+ // signature, and the signer must be able to read why
17
+ // from the ledger itself (2026-07-28 flow audit)
18
+ // node qa/approve.mjs --reopen-feature <name> --reason "…"
19
+ // one recorded change, not N reopen commands: reopens
20
+ // the brief + its spec + its design + every artifact
21
+ // the brief declares in `touches` (each only if
22
+ // currently approved), all under one reason
23
+ // node qa/approve.mjs --log the journal — every approve/reopen/accept with
24
+ // when, which surface, and why (newest last)
25
+ // node qa/approve.mjs --accept <name> the HUMAN's bookend on a feature brief
26
+ // (feature-brief:<name>) — refused until the feature
27
+ // is provenDone (every live clause cited + receipt
28
+ // PASS + receipt attests this tree). There is no
29
+ // --deliver: doneness is DERIVED, never claimed
30
+ // (CHANGE-FLOW-DESIGN.md §2).
31
+ //
32
+ // This file has NO logic of its own — every decision (the registry, hashing,
33
+ // state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
34
+ // console (VERIFICATION-LAYER-DESIGN.md §4, `POST /api/approve`; GENESIS-FLOW-DESIGN.md
35
+ // §2, `POST /api/reopen`) calls the SAME library this CLI calls, so this file is the
36
+ // API surface, kept intentionally thin and easy to keep in lockstep.
37
+
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ import {
42
+ acceptFeature,
43
+ approveAllDefaults,
44
+ approveArtifact,
45
+ getApprovalStatuses,
46
+ getFeatureBoard,
47
+ isPackageResolvable,
48
+ listGovernedArtifacts,
49
+ readJournal,
50
+ reopenArtifact,
51
+ reopenFeature,
52
+ } from "./lib/approvals.mjs";
53
+
54
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
55
+ const args = process.argv.slice(2);
56
+
57
+ function shortHash(hash) {
58
+ return hash ? hash.slice(0, 8) : "none";
59
+ }
60
+
61
+ function printStatus() {
62
+ const statuses = getApprovalStatuses(ROOT);
63
+ if (statuses.length === 0) {
64
+ console.log("No governed artifacts resolved in this project (no specs/, or the package could not be resolved).");
65
+ return;
66
+ }
67
+ console.log("Approval status:\n");
68
+ for (const s of statuses) {
69
+ const mark =
70
+ s.status === "approved" ? "✓" : s.status === "changed-since-approval" ? "✗" : s.status === "reopened" ? "↺" : "→";
71
+ // An unresolvable artifact (0 files, or a partial kotlin set in a
72
+ // pre-stamp tree) must never display its degraded hash as if it were
73
+ // approvable — approval over an unresolved file set is refused.
74
+ const hashInfo =
75
+ s.status === "reopened"
76
+ ? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})${s.reason ? ` — ${s.reason}` : ""}`
77
+ : s.status === "changed-since-approval"
78
+ ? s.resolvable
79
+ ? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
80
+ : `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
81
+ : s.status === "approved"
82
+ ? // storedHash is the signature; s.hash is the live recompute. They
83
+ // agree except on a legacy raw-bytes row, where printing the live
84
+ // value would name a hash nobody signed.
85
+ `${shortHash(s.storedHash) || shortHash(s.hash)}${
86
+ s.hashBasis === "raw-bytes" ? ", signed pre-strip — bytes unchanged since" : ""
87
+ }`
88
+ : s.resolvable
89
+ ? `would approve at ${shortHash(s.hash)}`
90
+ : `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
91
+ const modeInfo = s.mode ? ` [${s.mode}]` : "";
92
+ // A feature brief's acceptance lives on the ledger row, never in the doc
93
+ // (the signed bytes must not move when the human accepts).
94
+ const lifecycle = s.accepted ? ` · accepted ${s.acceptedAt}` : "";
95
+ console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo}${lifecycle} — ${s.label}`);
96
+ if (s.missing.length > 0) {
97
+ console.log(` missing: ${s.missing.join(", ")}`);
98
+ }
99
+ }
100
+
101
+ // Per-feature DERIVED doneness + next step — the same getFeatureBoard the
102
+ // console renders, so --status and the console never tell different
103
+ // stories. The next step names its owner: a signature HANDS OFF, it never
104
+ // commands — the agent drafts/builds/proves, the human signs/accepts.
105
+ const { features } = getFeatureBoard(ROOT);
106
+ if (features.length > 0) {
107
+ console.log("\nFeatures (doneness is derived, never claimed):");
108
+ for (const f of features) {
109
+ const mark = f.provenDone ? "✓" : "…";
110
+ console.log(` ${mark} ${f.name}: ${f.doneReason}${f.blockError ? ` — BLOCK ERROR: ${f.blockError}` : ""}`);
111
+ console.log(` next → ${f.nextStep.label}${f.nextStep.owner ? ` · ${f.nextStep.owner}` : ""}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ if (args.includes("--status")) {
117
+ printStatus();
118
+ process.exit(0);
119
+ }
120
+
121
+ // Write guard: refuse to RECORD approvals in a tree whose package is not
122
+ // resolvable (the raw template / a pre-stamp tree). Approvals belong to a
123
+ // generated project; writing qa/approvals.json into the template pollutes the
124
+ // template itself. Read-only --status (above) stays available anywhere. Applies
125
+ // to every write operation below (single approve, express lane, reopen).
126
+ function refuseIfUnresolvable() {
127
+ if (isPackageResolvable(ROOT)) return;
128
+ console.error(
129
+ "error: this tree's package is not resolvable (composeApp/build.gradle.kts namespace is missing or still a placeholder) — " +
130
+ "this looks like the raw template or a pre-stamp tree. Approvals are recorded in a generated project; refusing to write qa/approvals.json here.",
131
+ );
132
+ process.exit(1);
133
+ }
134
+
135
+ if (args.includes("--accept-defaults")) {
136
+ refuseIfUnresolvable();
137
+ const { approved, skipped } = approveAllDefaults(ROOT);
138
+ for (const id of approved) {
139
+ console.log(`✓ approved ${id} [defaults-accepted]`);
140
+ }
141
+ for (const s of skipped) {
142
+ console.log(`→ skipped ${s.id}: ${s.reason}`);
143
+ }
144
+ console.log(`\n${approved.length} approved (defaults-accepted), ${skipped.length} skipped (unresolvable).`);
145
+ process.exit(0);
146
+ }
147
+
148
+ // --reason is shared by --reopen and --reopen-feature: one plain sentence for
149
+ // the human who signed, recorded on the ledger row AND the journal. The library
150
+ // refuses without it; parsing it here just makes the usage line honest.
151
+ const reasonFlagIdx = args.indexOf("--reason");
152
+ const reason = reasonFlagIdx !== -1 ? args[reasonFlagIdx + 1] : undefined;
153
+
154
+ if (args.includes("--log")) {
155
+ const events = readJournal(ROOT);
156
+ if (events.length === 0) {
157
+ console.log("No journal yet (qa/approvals.log.jsonl) — it starts recording with the next approve/reopen/accept.");
158
+ process.exit(0);
159
+ }
160
+ console.log("Governance journal (oldest first):\n");
161
+ for (const e of events) {
162
+ const mark = e.verb === "approve" ? "✓" : e.verb === "reopen" ? "↺" : e.verb === "accept" ? "◆" : "·";
163
+ const bits = [e.via ? `via ${e.via}` : null, e.mode ? `[${e.mode}]` : null, e.feature ? `feature ${e.feature}` : null]
164
+ .filter(Boolean)
165
+ .join(" · ");
166
+ console.log(`${mark} ${e.at} ${e.verb} ${e.artifact}${bits ? ` (${bits})` : ""}${e.reason ? ` — ${e.reason}` : ""}`);
167
+ }
168
+ process.exit(0);
169
+ }
170
+
171
+ const reopenFlagIdx = args.indexOf("--reopen");
172
+ if (reopenFlagIdx !== -1) {
173
+ refuseIfUnresolvable();
174
+ const artifactId = args[reopenFlagIdx + 1];
175
+ if (!artifactId || artifactId === "--reason") {
176
+ console.error('usage: node qa/approve.mjs --reopen <artifact> --reason "why, in one sentence"');
177
+ process.exit(1);
178
+ }
179
+ const result = reopenArtifact(ROOT, artifactId, { reason, via: "cli" });
180
+ if (!result.ok) {
181
+ console.error(`error: ${result.reason}`);
182
+ process.exit(1);
183
+ }
184
+ console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}\n reason: ${reason.trim()}`);
185
+ process.exit(0);
186
+ }
187
+
188
+ // One recorded change, not N reopen commands (2026-07-28 flow audit, fix 4):
189
+ // the brief is the container the human thinks in — reopen its whole declared
190
+ // set under one reason, each journal event grouped by the feature's name.
191
+ const reopenFeatureFlagIdx = args.indexOf("--reopen-feature");
192
+ if (reopenFeatureFlagIdx !== -1) {
193
+ refuseIfUnresolvable();
194
+ const name = args[reopenFeatureFlagIdx + 1];
195
+ if (!name || name === "--reason") {
196
+ console.error('usage: node qa/approve.mjs --reopen-feature <name> --reason "why, in one sentence"');
197
+ process.exit(1);
198
+ }
199
+ const result = reopenFeature(ROOT, name, { reason, via: "cli" });
200
+ if (!result.ok) {
201
+ console.error(`error: ${result.reason}`);
202
+ process.exit(1);
203
+ }
204
+ console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
205
+ for (const id of result.reopened) console.log(` ↺ ${id}`);
206
+ for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
207
+ process.exit(0);
208
+ }
209
+
210
+ // The human's bookend: --accept takes the brief NAME, not the full artifact
211
+ // id — the docs/features/<name>.md filename is what a human knows. The library
212
+ // refuses until the feature is provenDone; there is no agent claim in between.
213
+ const acceptFlagIdx = args.indexOf("--accept");
214
+ if (acceptFlagIdx !== -1) {
215
+ refuseIfUnresolvable();
216
+ const name = args[acceptFlagIdx + 1];
217
+ if (!name) {
218
+ console.error("usage: node qa/approve.mjs --accept <name> (the brief's name — docs/features/<name>.md)");
219
+ process.exit(1);
220
+ }
221
+ const result = acceptFeature(ROOT, name, { via: "cli" });
222
+ if (!result.ok) {
223
+ console.error(`error: ${result.reason}`);
224
+ process.exit(1);
225
+ }
226
+ console.log(`✓ accepted ${result.artifact} at ${result.acceptedAt} — the feature's card closes; the brief is its doc-of-record.`);
227
+ process.exit(0);
228
+ }
229
+
230
+ if (args.length === 0) {
231
+ const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
232
+ console.error(
233
+ 'usage: node qa/approve.mjs <artifact> | --status | --log | --accept-defaults | --reopen <artifact> --reason "…" | --reopen-feature <name> --reason "…" | --accept <name>\n' +
234
+ ` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
235
+ );
236
+ process.exit(1);
237
+ }
238
+
239
+ refuseIfUnresolvable();
240
+
241
+ const artifactId = args[0];
242
+ const result = approveArtifact(ROOT, artifactId, { via: "cli" });
243
+ if (!result.ok) {
244
+ console.error(`error: ${result.reason}`);
245
+ process.exit(1);
246
+ }
247
+ console.log(`✓ approved ${result.artifact} — hash ${shortHash(result.hash)}, at ${result.approvedAt}`);
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // The architecture-doc generator/checker — thin shell over qa/lib/arch-doc.mjs,
3
+ // mirroring qa/approve.mjs's CLI-over-library split.
4
+ //
5
+ // node qa/arch-doc.mjs regenerate docs/ARCHITECTURE.md's
6
+ // `cmp:generated` sections in place from a
7
+ // real tree walk — touches NOTHING outside
8
+ // the markers
9
+ // node qa/arch-doc.mjs --check exit nonzero (naming every stale/missing
10
+ // section) if regenerating would change the
11
+ // file; never writes
12
+ //
13
+ // This file has no logic of its own — every decision (what each section
14
+ // derives from, the marker grammar) lives in qa/lib/arch-doc.mjs. The verify
15
+ // lane's `archDoc` step (qa/verify.mjs) calls the SAME library in --check mode.
16
+
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { ARCH_DOC_REL_PATH, regenerateArchDoc, writeArchDoc } from "./lib/arch-doc.mjs";
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ const checkOnly = process.argv.includes("--check");
24
+
25
+ function reportUnknown(result) {
26
+ if (result.unknownSections.length === 0) return false;
27
+ console.error(
28
+ `error: ${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")}`,
29
+ );
30
+ return true;
31
+ }
32
+
33
+ if (checkOnly) {
34
+ const result = regenerateArchDoc(ROOT);
35
+ if (!result.ok) {
36
+ console.error(`error: ${result.reason}`);
37
+ process.exit(1);
38
+ }
39
+ if (reportUnknown(result)) process.exit(1);
40
+
41
+ const stale = result.changed || result.missingSections.length > 0;
42
+ if (!stale) {
43
+ console.log(`✓ ${ARCH_DOC_REL_PATH} is fresh — every generated section matches the tree.`);
44
+ process.exit(0);
45
+ }
46
+
47
+ console.error(`✗ ${ARCH_DOC_REL_PATH} is stale:`);
48
+ for (const id of result.changedSections) {
49
+ console.error(` [${id}] regenerating would change this section — the tree no longer matches the doc`);
50
+ }
51
+ for (const id of result.missingSections) {
52
+ console.error(` [${id}] marker missing from the doc entirely — never generated`);
53
+ }
54
+ console.error("Run: node qa/arch-doc.mjs");
55
+ process.exit(1);
56
+ }
57
+
58
+ const result = writeArchDoc(ROOT);
59
+ if (!result.ok) {
60
+ console.error(`error: ${result.reason}`);
61
+ process.exit(1);
62
+ }
63
+ if (reportUnknown(result)) process.exit(1);
64
+
65
+ if (!result.wrote) {
66
+ console.log(`✓ ${ARCH_DOC_REL_PATH} already fresh — nothing to regenerate.`);
67
+ process.exit(0);
68
+ }
69
+ console.log(`✓ regenerated ${ARCH_DOC_REL_PATH} — updated section(s): ${result.changedSections.join(", ")}`);
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // The comments CLI — thin shell over qa/lib/comments.mjs (mirrors qa/approve.mjs).
3
+ //
4
+ // node qa/comment.mjs --list [--open] readable table of the ledger,
5
+ // including resolution notes
6
+ // node qa/comment.mjs --resolve <id> --note "..." resolves a comment as "agent-cli",
7
+ // recording the note explaining
8
+ // what changed as a result
9
+ //
10
+ // This file has NO logic of its own — every decision (validation, ids, the ledger)
11
+ // lives in qa/lib/comments.mjs. Adding a comment is a console/human action (the
12
+ // console's `POST /api/comment` calls the same library through a bridge); this CLI
13
+ // covers the agent's side of the loop of record: observe, act, resolve.
14
+
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import { listComments, resolveComment } from "./lib/comments.mjs";
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
+ const args = process.argv.slice(2);
22
+
23
+ function argValue(flag) {
24
+ const idx = args.indexOf(flag);
25
+ if (idx === -1 || idx === args.length - 1) return undefined;
26
+ return args[idx + 1];
27
+ }
28
+
29
+ function printList(openOnly) {
30
+ let result;
31
+ try {
32
+ result = listComments(ROOT, openOnly ? { status: "open" } : {});
33
+ } catch (err) {
34
+ console.error(`error: ${err.message}`);
35
+ process.exit(1);
36
+ }
37
+ if (result.comments.length === 0) {
38
+ console.log(openOnly ? "No open comments." : "No comments recorded yet.");
39
+ return;
40
+ }
41
+ console.log(`Comments${openOnly ? " (open only)" : ""}:\n`);
42
+ for (const c of result.comments) {
43
+ const mark = c.status === "resolved" ? "✓" : "→";
44
+ console.log(`${mark} ${c.id} [${c.target?.type ?? "unknown"}] ${c.status} — by ${c.author} at ${c.createdAt}`);
45
+ console.log(` ${c.text}`);
46
+ if (c.status === "resolved") {
47
+ const note = c.resolutionNote ? `: ${c.resolutionNote}` : " (no note)";
48
+ console.log(` resolved by ${c.resolvedBy} at ${c.resolvedAt}${note}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ if (args.includes("--list")) {
54
+ printList(args.includes("--open"));
55
+ process.exit(0);
56
+ }
57
+
58
+ if (args.includes("--resolve")) {
59
+ const id = argValue("--resolve");
60
+ if (!id) {
61
+ console.error('usage: node qa/comment.mjs --resolve <id> --note "..."');
62
+ process.exit(1);
63
+ }
64
+ const note = argValue("--note");
65
+ const result = resolveComment(ROOT, id, { note, author: "agent-cli" });
66
+ if (!result.ok) {
67
+ console.error(`error: ${result.reason}`);
68
+ process.exit(1);
69
+ }
70
+ const noteSuffix = result.comment.resolutionNote ? ` — ${result.comment.resolutionNote}` : "";
71
+ console.log(`✓ resolved ${result.comment.id}${noteSuffix}`);
72
+ process.exit(0);
73
+ }
74
+
75
+ console.error('usage: node qa/comment.mjs --list [--open] | --resolve <id> --note "..."');
76
+ process.exit(1);
@@ -0,0 +1,113 @@
1
+ // a11y.mjs — accessibility audit over the tree contract.
2
+ // Pure logic only — no fs, no MCP imports; unit-testable.
3
+ //
4
+ // Rules (violations):
5
+ // touch-target-too-small — a clickable node whose width or height is below the
6
+ // minimum touch target (default 48px; the harness dumps
7
+ // at density 1 so px == dp there — pass a different
8
+ // minTouchTargetPx for device-density trees). Judged on the
9
+ // FULL composed size (the tree's additive `size` field) when
10
+ // present: `bounds` is the visible slice after ancestor
11
+ // clipping, so a list row bisected by a scroll fold reports
12
+ // e.g. 371x36 while measuring 371x88 — a scroll-position
13
+ // artifact, never an a11y defect. Trees without `size`
14
+ // (older dumps) are judged on bounds, as before.
15
+ // missing-label — a clickable node with no text, no contentDescription,
16
+ // and no descendant text: nothing for a screen reader.
17
+ // Rules (warnings):
18
+ // empty-content-description — contentDescription === "" (redundant/empty; either
19
+ // label it or drop the attribute).
20
+ //
21
+ // Trees produced before the role/clickable/disabled contract extension are handled
22
+ // gracefully: nodes without `clickable` are simply skipped, never crashed on.
23
+
24
+ import { walk } from "./tree.mjs";
25
+
26
+ /**
27
+ * Audit a tree for accessibility faults.
28
+ *
29
+ * @param {object} tree a full tree ({root}) or bare node.
30
+ * @param {{minTouchTargetPx?: number}} [opts]
31
+ * @returns {{
32
+ * violations: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
33
+ * warnings: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
34
+ * warningCount: number,
35
+ * passCount: number
36
+ * }} passCount = clickable nodes that passed every check.
37
+ */
38
+ export function auditA11y(tree, opts = {}) {
39
+ const minTouchTargetPx =
40
+ typeof opts.minTouchTargetPx === "number" && opts.minTouchTargetPx > 0
41
+ ? opts.minTouchTargetPx
42
+ : 48;
43
+
44
+ const violations = [];
45
+ const warnings = [];
46
+ let passCount = 0;
47
+
48
+ for (const { node, path } of walk(tree)) {
49
+ const entryBase = {
50
+ path,
51
+ testTag: node.testTag ?? null,
52
+ bounds: node.bounds ?? null,
53
+ };
54
+
55
+ // Warn on redundant/empty contentDescription regardless of clickability.
56
+ if (node.contentDescription === "") {
57
+ warnings.push({
58
+ ...entryBase,
59
+ rule: "empty-content-description",
60
+ detail: 'contentDescription is an empty string ("") — either label the node or drop the attribute',
61
+ });
62
+ }
63
+
64
+ // Interactive checks only apply to nodes that self-report clickable:true.
65
+ // Old trees without the optional field are skipped gracefully.
66
+ if (node.clickable !== true) continue;
67
+
68
+ let violated = false;
69
+
70
+ // Judge the FULL composed size when the dump carries it (`size` is unclipped; `bounds`
71
+ // is the visible slice after ancestor clipping — see the rule doc above). max() keeps
72
+ // the check honest either way: a genuinely small target is small in both.
73
+ const b = node.bounds;
74
+ const s = node.size;
75
+ const dim = (bv, sv) =>
76
+ Math.max(typeof bv === "number" ? bv : -1, typeof sv === "number" ? sv : -1);
77
+ const width = dim(b && b.width, s && s.width);
78
+ const height = dim(b && b.height, s && s.height);
79
+ if (width >= 0 && height >= 0 && (width < minTouchTargetPx || height < minTouchTargetPx)) {
80
+ violations.push({
81
+ ...entryBase,
82
+ rule: "touch-target-too-small",
83
+ detail: `clickable node is ${width}x${height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
84
+ });
85
+ violated = true;
86
+ }
87
+
88
+ const hasOwnLabel =
89
+ (node.text != null && node.text !== "") ||
90
+ (node.contentDescription != null && node.contentDescription !== "");
91
+ if (!hasOwnLabel && !hasDescendantText(node)) {
92
+ violations.push({
93
+ ...entryBase,
94
+ rule: "missing-label",
95
+ detail: "clickable node has no text, no contentDescription, and no descendant text — invisible to screen readers",
96
+ });
97
+ violated = true;
98
+ }
99
+
100
+ if (!violated) passCount++;
101
+ }
102
+
103
+ return { violations, warnings, warningCount: warnings.length, passCount };
104
+ }
105
+
106
+ function hasDescendantText(node) {
107
+ for (const child of node.children || []) {
108
+ if (child.text != null && child.text !== "") return true;
109
+ if (child.contentDescription != null && child.contentDescription !== "") return true;
110
+ if (hasDescendantText(child)) return true;
111
+ }
112
+ return false;
113
+ }