create-cmp-cli 0.15.0 → 0.17.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.
package/README.md CHANGED
@@ -25,7 +25,10 @@ npm create kmp@latest my-app
25
25
 
26
26
  Deterministic (stamps a frozen, CI-verified template), non-interactive with flags, exits non-zero
27
27
  on failure — and it **builds the app to prove it's green** before reporting success.
28
- Agent-readable: [llms.txt](./llms.txt) · [options.schema.json](./options.schema.json).
28
+ Agent-readable: [llms.txt](./llms.txt) · [AGENTS.md](./AGENTS.md) · [options.schema.json](./options.schema.json).
29
+
30
+ **Claude Code users:** `/plugin marketplace add kvdm-co-pilot/create-cmp` →
31
+ `/plugin install create-cmp` — [10 skills + the cmp-inspector MCP server](#the-claude-code-plugin-10-skills).
29
32
 
30
33
  ## What is this, in plain words
31
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@create-cmp/harness",
3
- "version": "0.14.1",
4
- "description": "The create-cmp verify lane \u2014 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.",
3
+ "version": "0.15.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
5
  "type": "module",
6
6
  "main": "src/verify.mjs",
7
7
  "exports": {
@@ -0,0 +1,277 @@
1
+ // walk.mjs — the walk: where every open change stands, whose turn it is, and
2
+ // what arrives unplanned. docs/features/walk-status.md is the brief of record.
3
+ //
4
+ // A PROJECTION, not new state (D1): everything here re-renders the derivation
5
+ // getFeatureBoard already computes (phase, nextStep with owner, clause
6
+ // coverage, provenDone). No new ledger, nothing agent-declared, nothing to
7
+ // forget to update — if a number here could disagree with the board, this
8
+ // module is wrong.
9
+ //
10
+ // Vocabulary (D2/D3): six stages — Decide · Design · Contract · Build ·
11
+ // Prove · Sign-off — and clauses rendered as PROMISES ("keeping promise 5 of
12
+ // 7"). Mechanically exact: a clause is a behavioral promise, a citing green
13
+ // test is that promise kept, provenDone is all of them kept with the receipt
14
+ // attesting this tree.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { getFeatureBoard, getApprovalStatuses, readJournal } from "./approvals.mjs";
20
+ import { CLAUSE_LINE_RE } from "./spec-coverage.mjs";
21
+
22
+ /** The six stages, in walk order. `label` is the only user-facing name (D2). */
23
+ export const STAGES = [
24
+ { key: "decide", label: "Decide" },
25
+ { key: "design", label: "Design" },
26
+ { key: "contract", label: "Contract" },
27
+ { key: "build", label: "Build" },
28
+ { key: "prove", label: "Prove" },
29
+ { key: "signoff", label: "Sign-off" },
30
+ ];
31
+
32
+ /** nextStep.key -> the stage that step belongs to. `closed` maps to none. */
33
+ const STEP_STAGE = {
34
+ "sign-brief": "decide",
35
+ "re-approve": "decide",
36
+ design: "design",
37
+ audit: "design",
38
+ "sign-design": "design",
39
+ contract: "contract",
40
+ "sign-spec": "contract",
41
+ build: "build",
42
+ // A sanctioned redesign is promises being re-kept — Build, not a seventh stage.
43
+ redesign: "build",
44
+ prove: "prove",
45
+ accept: "signoff",
46
+ };
47
+
48
+ /**
49
+ * A promise's human title: the clause line's own words after the id, first
50
+ * sentence-ish, capped. "" when the line carries nothing after the id — the
51
+ * card then falls back to the bare id rather than inventing prose.
52
+ */
53
+ function promiseTitle(rest) {
54
+ const cleaned = String(rest ?? "")
55
+ .replace(/^[\s—:-]+/, "")
56
+ .replace(/\*\*/g, "")
57
+ .trim();
58
+ if (cleaned === "") return "";
59
+ const cut = cleaned.search(/[.;]\s/);
60
+ const first = cut === -1 ? cleaned : cleaned.slice(0, cut);
61
+ return first.length > 110 ? `${first.slice(0, 107)}…` : first;
62
+ }
63
+
64
+ /**
65
+ * The spec's promises, in file order: id, title, withdrawn. Reads the spec
66
+ * directly (same CLAUSE_LINE_RE the coverage gate uses) because the board's
67
+ * clause list carries ids only — the words are the whole point here (D3).
68
+ * @returns {Array<{id: string, title: string, withdrawn: boolean}>}
69
+ */
70
+ export function listPromises(root, specRel) {
71
+ let text;
72
+ try {
73
+ text = fs.readFileSync(path.join(root, specRel), "utf8");
74
+ } catch {
75
+ return [];
76
+ }
77
+ const out = [];
78
+ for (const line of text.split("\n")) {
79
+ const m = line.match(CLAUSE_LINE_RE);
80
+ if (!m) continue;
81
+ const rest = line.slice(m[0].length).replace(/^~~/, "");
82
+ out.push({ id: m[2], title: promiseTitle(rest), withdrawn: Boolean(m[1]) });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** The human stops remaining from (and including) the current stage. */
88
+ function remainingStops(stages, currentIdx) {
89
+ const stops = [];
90
+ for (let i = Math.max(currentIdx, 0); i < stages.length; i++) {
91
+ const s = stages[i];
92
+ if (s.state === "done" || s.state === "skipped") continue;
93
+ if (s.key === "decide") stops.push("Decide");
94
+ if (s.key === "design") stops.push("Design sign-off");
95
+ if (s.key === "contract") stops.push("Contract");
96
+ if (s.key === "signoff") stops.push("Sign-off");
97
+ }
98
+ return stops;
99
+ }
100
+
101
+ /**
102
+ * One feature's walk. Stage states are POSITIONAL around the derived current
103
+ * step: the board's deriveNextStep already resolves every competing condition
104
+ * (drift outranks acceptance, design before contract, redesign splits by
105
+ * provenDone) to ONE next act — stages before it are done, after it pending.
106
+ * Design is `skipped` when the feature honestly has no UI surface (D2).
107
+ */
108
+ function walkOfFeature(root, f) {
109
+ const currentKey = STEP_STAGE[f.nextStep?.key] ?? null; // null => closed
110
+ const currentIdx = currentKey ? STAGES.findIndex((s) => s.key === currentKey) : STAGES.length;
111
+ const stages = STAGES.map((s, i) => {
112
+ if (s.key === "design" && f.design === null)
113
+ return { ...s, state: "skipped", note: "no UI surface" };
114
+ return { ...s, state: i < currentIdx ? "done" : i === currentIdx ? "current" : "pending" };
115
+ });
116
+
117
+ const promises = listPromises(root, f.specRel).filter((p) => !p.withdrawn);
118
+ // The promise being kept NOW: first live clause without a citing test —
119
+ // board clause order, titles from the spec's own words.
120
+ const citedIds = new Set(f.clauses.filter((c) => c.cited).map((c) => c.id));
121
+ const current = promises.find((p) => !citedIds.has(p.id)) ?? null;
122
+
123
+ // Whose turn (D4): from the board's own owner, never re-derived.
124
+ const owner = f.nextStep?.owner ?? null;
125
+ const you =
126
+ owner === null
127
+ ? { turn: "none", act: null }
128
+ : owner === "human"
129
+ ? { turn: "you", act: f.nextStep.label }
130
+ : owner.includes("human")
131
+ ? { turn: "agent", act: f.nextStep.label, then: "your signature" }
132
+ : { turn: "agent", act: f.nextStep.label };
133
+
134
+ return {
135
+ name: f.name,
136
+ phase: f.phase,
137
+ open: f.phase !== "accepted",
138
+ stages,
139
+ currentStage: currentKey,
140
+ promises: { total: f.total, kept: f.covered, current },
141
+ you,
142
+ stops: remainingStops(stages, currentIdx),
143
+ doneReason: f.doneReason,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
149
+ * — governed artifacts drifted or reopened that NO open walk accounts for
150
+ * (the board's undeclared set, widened to reopens, e.g. a harness upgrade's
151
+ * rule-change wave). Each arrival carries the journal's last reopen reason so
152
+ * the surface can say WHY it arrived, not only that it did.
153
+ * @param {string} root
154
+ * @returns {{available: boolean, reason?: string, walks: object[], arrivals: object[]}}
155
+ */
156
+ export function deriveWalks(root) {
157
+ let board, statuses;
158
+ try {
159
+ board = getFeatureBoard(root);
160
+ statuses = getApprovalStatuses(root);
161
+ } catch (err) {
162
+ return { available: false, reason: err?.message ?? String(err), walks: [], arrivals: [] };
163
+ }
164
+
165
+ // Open walks, MOST RECENTLY ACTIVE first (journal recency across the walk's
166
+ // family) — five features parked at Sign-off must not bury the one being
167
+ // worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
168
+ // the live navigation-ia walk in the statusline).
169
+ const journalForOrder = readJournal(root);
170
+ const lastActivity = (name) => {
171
+ const family = new Set([`feature-brief:${name}`, `feature-design:${name}`, `feature-spec:${name}`]);
172
+ for (let i = journalForOrder.length - 1; i >= 0; i--) {
173
+ if (family.has(journalForOrder[i].artifact)) return i;
174
+ }
175
+ return -1;
176
+ };
177
+ const walks = board.features
178
+ .map((f) => walkOfFeature(root, f))
179
+ .filter((w) => w.open)
180
+ .sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
181
+
182
+ // Ids an open walk accounts for: its own family (brief/design/spec) and its
183
+ // declared touches. A reopened design mid-walk is that walk's Design stage,
184
+ // never an arrival (brief, edge cases).
185
+ const owned = new Set();
186
+ for (const f of board.features) {
187
+ if (f.phase === "accepted") continue;
188
+ owned.add(`feature-brief:${f.name}`);
189
+ owned.add(`feature-design:${f.name}`);
190
+ owned.add(`feature-spec:${f.name}`);
191
+ for (const t of f.touches) owned.add(t.id);
192
+ }
193
+ const journal = journalForOrder;
194
+ const lastReopenReason = (id) => {
195
+ for (let i = journal.length - 1; i >= 0; i--) {
196
+ if (journal[i].artifact === id && journal[i].verb === "reopen") return journal[i].reason ?? null;
197
+ }
198
+ return null;
199
+ };
200
+ const arrivals = statuses
201
+ .filter((s) => (s.status === "changed-since-approval" || s.status === "reopened") && !owned.has(s.id))
202
+ .map((s) => ({
203
+ id: s.id,
204
+ label: s.label ?? s.id,
205
+ status: s.status,
206
+ reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
207
+ }));
208
+
209
+ return { available: true, walks, arrivals };
210
+ }
211
+
212
+ // ── Renderings — one grammar, four slots (D4) ────────────────────────────────
213
+
214
+ const bar = (stages) =>
215
+ stages.map((s) => (s.state === "done" ? "●" : s.state === "current" ? "◐" : s.state === "skipped" ? "·" : "○")).join("");
216
+
217
+ const stageLabel = (w) => STAGES.find((s) => s.key === w.currentStage)?.label ?? "Closed";
218
+
219
+ /** The walk whose state is loudest: YOUR TURN beats agent-working. */
220
+ function loudest(walks) {
221
+ return walks.find((w) => w.you.turn === "you") ?? walks[0] ?? null;
222
+ }
223
+
224
+ /**
225
+ * The always-on one-liner (statusline). "" when there is nothing to say — an
226
+ * ungoverned project's statusline stays silent, never fabricated.
227
+ */
228
+ export function renderStatusline({ available, walks, arrivals }) {
229
+ if (!available || walks.length === 0) return "";
230
+ const w = loudest(walks);
231
+ const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
232
+ const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
233
+ if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}`;
234
+ const now =
235
+ w.currentStage === "build" && w.promises.total > 0
236
+ ? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
237
+ : stageLabel(w);
238
+ return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
239
+ }
240
+
241
+ /** One walk's full card — the CLI default and the loud stop-card's body. */
242
+ export function renderCard(w) {
243
+ const line = w.stages
244
+ .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.state === "skipped" ? ` (${s.note})` : ""}`)
245
+ .join(" ");
246
+ const nowLine =
247
+ w.currentStage === "build" && w.promises.current
248
+ ? `Now: keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)} of ${w.promises.total} — “${w.promises.current.title || w.promises.current.id}”`
249
+ : `Now: ${w.you.act ?? w.doneReason}`;
250
+ const youLine =
251
+ w.you.turn === "you"
252
+ ? `■ YOUR TURN: ${w.you.act}`
253
+ : w.you.turn === "agent"
254
+ ? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
255
+ : "Closed.";
256
+ return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
257
+ }
258
+
259
+ /**
260
+ * The per-prompt context block (UserPromptSubmit --inject): position + the
261
+ * standing protocol reminders, re-delivered every turn so the narration rules
262
+ * are decay-proof — re-told, never remembered (D5/D6).
263
+ */
264
+ export function renderInject({ available, walks, arrivals }) {
265
+ if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
266
+ const parts = [];
267
+ if (walks.length > 0) {
268
+ parts.push("[walk-status — derived from the ledgers; render this state, never your own memory of it]");
269
+ for (const w of walks) parts.push(renderCard(w));
270
+ }
271
+ for (const a of arrivals)
272
+ parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
273
+ parts.push(
274
+ "Protocol: speak stages as Decide·Design·Contract·Build·Prove·Sign-off and clauses as promises. Quiet while working (one line per stage transition). At any human gate, render the full stop card (stage, what it is in plain words, exactly what to do, what comes after). Never open a second walk silently.",
275
+ );
276
+ return parts.join("\n\n");
277
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // walk-status.mjs — where every open change stands, whose turn it is, what
3
+ // arrived unplanned. The walk's CLI face (docs/features/walk-status.md D5).
4
+ //
5
+ // node qa/walk-status.mjs # full cards, human-readable
6
+ // node qa/walk-status.mjs --statusline # the always-on one-liner
7
+ // node qa/walk-status.mjs --inject # UserPromptSubmit hook JSON
8
+ // node qa/walk-status.mjs --json # the raw derivation
9
+ //
10
+ // FAIL-OPEN BY CONTRACT: this runs inside a statusline and a per-prompt hook.
11
+ // Any failure — broken ledger, non-cmp directory, anything — exits 0 with
12
+ // empty output. A status surface may never block or noise the work it reports
13
+ // on. (The console renders the same derivation with full error honesty; this
14
+ // surface's honesty is silence.)
15
+
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+ const args = process.argv.slice(2);
21
+
22
+ try {
23
+ const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
24
+ const data = deriveWalks(ROOT);
25
+
26
+ if (args.includes("--json")) {
27
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
28
+ } else if (args.includes("--statusline")) {
29
+ const line = renderStatusline(data);
30
+ if (line !== "") process.stdout.write(`${line}\n`);
31
+ } else if (args.includes("--inject")) {
32
+ const ctx = renderInject(data);
33
+ if (ctx !== "") {
34
+ process.stdout.write(
35
+ JSON.stringify({
36
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: ctx },
37
+ }),
38
+ );
39
+ }
40
+ } else {
41
+ if (!data.available) {
42
+ process.stdout.write(`walk-status: not derivable — ${data.reason}\n`);
43
+ } else if (data.walks.length === 0 && data.arrivals.length === 0) {
44
+ process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
45
+ } else {
46
+ for (const w of data.walks) process.stdout.write(`${renderCard(w)}\n\n`);
47
+ for (const a of data.arrivals)
48
+ process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
49
+ }
50
+ }
51
+ process.exit(0);
52
+ } catch {
53
+ process.exit(0); // fail-open: a status surface never blocks the work
54
+ }
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a committed receipt, approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
9
+ "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a committed receipt, approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes \u2014 if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
10
10
  }
11
11
  ]
12
12
  }
@@ -21,11 +21,11 @@
21
21
  },
22
22
  {
23
23
  "type": "command",
24
- "command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/device-lease.mjs) the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
24
+ "command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/device-lease.mjs) \u2014 the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
25
25
  },
26
26
  {
27
27
  "type": "command",
28
- "command": "in=$(cat); printf '%s' \"$in\" | grep -qE 'node qa/verify\\.mjs' && ! printf '%s' \"$in\" | grep -q -- '--fast' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: for inner-loop iteration, node qa/verify.mjs --fast skips the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke) and is much quicker. Run the full lane once, deliberately, before reporting work done never speculatively, and never to re-confirm a result you already have. A --fast receipt never satisfies the done-gate.\"}}' || true"
28
+ "command": "in=$(cat); printf '%s' \"$in\" | grep -qE 'node qa/verify\\.mjs' && ! printf '%s' \"$in\" | grep -q -- '--fast' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: for inner-loop iteration, node qa/verify.mjs --fast skips the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke) and is much quicker. Run the full lane once, deliberately, before reporting work done \u2014 never speculatively, and never to re-confirm a result you already have. A --fast receipt never satisfies the done-gate.\"}}' || true"
29
29
  }
30
30
  ]
31
31
  }
@@ -34,9 +34,27 @@
34
34
  {
35
35
  "matcher": "",
36
36
  "hooks": [
37
- { "type": "command", "command": "node qa/receipt-check.mjs --hook" }
37
+ {
38
+ "type": "command",
39
+ "command": "node qa/receipt-check.mjs --hook"
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "UserPromptSubmit": [
45
+ {
46
+ "matcher": "",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "test -f qa/walk-status.mjs && node qa/walk-status.mjs --inject || true"
51
+ }
38
52
  ]
39
53
  }
40
54
  ]
55
+ },
56
+ "statusLine": {
57
+ "type": "command",
58
+ "command": "test -f qa/walk-status.mjs && node qa/walk-status.mjs --statusline || true"
41
59
  }
42
60
  }
@@ -37,6 +37,7 @@ prerequisite.
37
37
  <!-- >>> cmp:feature harness -->
38
38
  | Adding a feature / screen / repository | `node qa/scaffold-feature.mjs <Name>` — clones the tested exemplar through every layer; never freehand the pattern (skills: `add-feature`, `add-screen`, `add-repository`) |
39
39
  | A gate failed and looks arbitrary | `node qa/refusal-demo.mjs` — stages canonical violations so each gate names the clause it protects |
40
+ | Where are we / whose turn is it | `node qa/walk-status.mjs` — every open walk's stage card (Decide·Design·Contract·Build·Prove·Sign-off), whose turn, what arrived unplanned; `--statusline` is the one-liner |
40
41
  <!-- <<< cmp:feature harness -->
41
42
  | Build broken, toolchain suspect | `npx create-cmp-cli doctor --fix` — diagnoses machine AND project (kotlin↔ksp lockstep, catalog drift); asks before any repair |
42
43
  | Dependency versions stale or mismatched | `npx create-cmp-cli upgrade --dry-run` — diff against the next proven-green set before touching anything |
@@ -304,6 +304,42 @@ stamper clones your pattern in your domain language, and `home` demotes to an or
304
304
  feature spec. If the configured exemplar has grown files beyond the canonical 11-file
305
305
  shape, the stamper clones the canonical set and warns, listing exactly what it skipped.
306
306
 
307
+
308
+ ## The walk — the user always knows where we are and whose turn it is
309
+
310
+ Every governed change is a **walk** through six stages, spoken ONLY in this vocabulary
311
+ wherever the human reads (chat, cards, commit prose): **Decide · Design · Contract ·
312
+ Build · Prove · Sign-off**. The mapping is mechanical — Decide=the brief, Design=the
313
+ rendered screens, Contract=the spec, Build=code+citing tests, Prove=the lane's receipt,
314
+ Sign-off=acceptance — and spec clauses are spoken as **promises** ("Contract: 7
315
+ promises agreed" · "Build: keeping promise 5 of 7" · "Prove: all promises kept,
316
+ evidence attached"). `node qa/walk-status.mjs` derives the live position; a
317
+ UserPromptSubmit hook injects it every prompt. **Render the injected state — never
318
+ your memory of it.**
319
+
320
+ **At kickoff** (with the triage restatement): print the itinerary —
321
+
322
+ Navigation redesign — the journey (brief lane)
323
+ Decide → Design → Contract → Build → Prove → Sign-off
324
+ Stops for you: 3 (Decide — now · Contract · Sign-off). Build and Prove never stop for you.
325
+ First stop is now: 2 open decisions below.
326
+
327
+ **While working — quiet:** one line per stage transition, nothing per-file. The
328
+ statusline carries the position continuously; do not repeat it in prose.
329
+
330
+ **At every human gate — loud:** a full stop card, never a bare question:
331
+
332
+ ■ YOUR TURN — <feature> · stage 3 of 6: Contract
333
+ <what it is, in plain words — two lines maximum>
334
+ → <exactly what to do: the command, or the studio tab, or "reply approve">
335
+ After this: <the remaining stages, and which ones stop for the human>
336
+
337
+ **Arrivals:** work that belongs to no open walk (undeclared drift, a harness
338
+ upgrade's rule-change reopens) is NEVER silently interleaved. Render
339
+ `▲ ARRIVED, UNPLANNED — <what> · now, or after <current walk> lands?` and default to
340
+ after. One walk at a time unless the human chooses otherwise — three unframed
341
+ interleaved walks is precisely the session this rule exists to prevent.
342
+
307
343
  ## Comments — review feedback flows back through the agent
308
344
 
309
345
  Approvals are binding (they gate the verify lane); **comments are advisory** — a human's
@@ -0,0 +1,277 @@
1
+ // walk.mjs — the walk: where every open change stands, whose turn it is, and
2
+ // what arrives unplanned. docs/features/walk-status.md is the brief of record.
3
+ //
4
+ // A PROJECTION, not new state (D1): everything here re-renders the derivation
5
+ // getFeatureBoard already computes (phase, nextStep with owner, clause
6
+ // coverage, provenDone). No new ledger, nothing agent-declared, nothing to
7
+ // forget to update — if a number here could disagree with the board, this
8
+ // module is wrong.
9
+ //
10
+ // Vocabulary (D2/D3): six stages — Decide · Design · Contract · Build ·
11
+ // Prove · Sign-off — and clauses rendered as PROMISES ("keeping promise 5 of
12
+ // 7"). Mechanically exact: a clause is a behavioral promise, a citing green
13
+ // test is that promise kept, provenDone is all of them kept with the receipt
14
+ // attesting this tree.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { getFeatureBoard, getApprovalStatuses, readJournal } from "./approvals.mjs";
20
+ import { CLAUSE_LINE_RE } from "./spec-coverage.mjs";
21
+
22
+ /** The six stages, in walk order. `label` is the only user-facing name (D2). */
23
+ export const STAGES = [
24
+ { key: "decide", label: "Decide" },
25
+ { key: "design", label: "Design" },
26
+ { key: "contract", label: "Contract" },
27
+ { key: "build", label: "Build" },
28
+ { key: "prove", label: "Prove" },
29
+ { key: "signoff", label: "Sign-off" },
30
+ ];
31
+
32
+ /** nextStep.key -> the stage that step belongs to. `closed` maps to none. */
33
+ const STEP_STAGE = {
34
+ "sign-brief": "decide",
35
+ "re-approve": "decide",
36
+ design: "design",
37
+ audit: "design",
38
+ "sign-design": "design",
39
+ contract: "contract",
40
+ "sign-spec": "contract",
41
+ build: "build",
42
+ // A sanctioned redesign is promises being re-kept — Build, not a seventh stage.
43
+ redesign: "build",
44
+ prove: "prove",
45
+ accept: "signoff",
46
+ };
47
+
48
+ /**
49
+ * A promise's human title: the clause line's own words after the id, first
50
+ * sentence-ish, capped. "" when the line carries nothing after the id — the
51
+ * card then falls back to the bare id rather than inventing prose.
52
+ */
53
+ function promiseTitle(rest) {
54
+ const cleaned = String(rest ?? "")
55
+ .replace(/^[\s—:-]+/, "")
56
+ .replace(/\*\*/g, "")
57
+ .trim();
58
+ if (cleaned === "") return "";
59
+ const cut = cleaned.search(/[.;]\s/);
60
+ const first = cut === -1 ? cleaned : cleaned.slice(0, cut);
61
+ return first.length > 110 ? `${first.slice(0, 107)}…` : first;
62
+ }
63
+
64
+ /**
65
+ * The spec's promises, in file order: id, title, withdrawn. Reads the spec
66
+ * directly (same CLAUSE_LINE_RE the coverage gate uses) because the board's
67
+ * clause list carries ids only — the words are the whole point here (D3).
68
+ * @returns {Array<{id: string, title: string, withdrawn: boolean}>}
69
+ */
70
+ export function listPromises(root, specRel) {
71
+ let text;
72
+ try {
73
+ text = fs.readFileSync(path.join(root, specRel), "utf8");
74
+ } catch {
75
+ return [];
76
+ }
77
+ const out = [];
78
+ for (const line of text.split("\n")) {
79
+ const m = line.match(CLAUSE_LINE_RE);
80
+ if (!m) continue;
81
+ const rest = line.slice(m[0].length).replace(/^~~/, "");
82
+ out.push({ id: m[2], title: promiseTitle(rest), withdrawn: Boolean(m[1]) });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** The human stops remaining from (and including) the current stage. */
88
+ function remainingStops(stages, currentIdx) {
89
+ const stops = [];
90
+ for (let i = Math.max(currentIdx, 0); i < stages.length; i++) {
91
+ const s = stages[i];
92
+ if (s.state === "done" || s.state === "skipped") continue;
93
+ if (s.key === "decide") stops.push("Decide");
94
+ if (s.key === "design") stops.push("Design sign-off");
95
+ if (s.key === "contract") stops.push("Contract");
96
+ if (s.key === "signoff") stops.push("Sign-off");
97
+ }
98
+ return stops;
99
+ }
100
+
101
+ /**
102
+ * One feature's walk. Stage states are POSITIONAL around the derived current
103
+ * step: the board's deriveNextStep already resolves every competing condition
104
+ * (drift outranks acceptance, design before contract, redesign splits by
105
+ * provenDone) to ONE next act — stages before it are done, after it pending.
106
+ * Design is `skipped` when the feature honestly has no UI surface (D2).
107
+ */
108
+ function walkOfFeature(root, f) {
109
+ const currentKey = STEP_STAGE[f.nextStep?.key] ?? null; // null => closed
110
+ const currentIdx = currentKey ? STAGES.findIndex((s) => s.key === currentKey) : STAGES.length;
111
+ const stages = STAGES.map((s, i) => {
112
+ if (s.key === "design" && f.design === null)
113
+ return { ...s, state: "skipped", note: "no UI surface" };
114
+ return { ...s, state: i < currentIdx ? "done" : i === currentIdx ? "current" : "pending" };
115
+ });
116
+
117
+ const promises = listPromises(root, f.specRel).filter((p) => !p.withdrawn);
118
+ // The promise being kept NOW: first live clause without a citing test —
119
+ // board clause order, titles from the spec's own words.
120
+ const citedIds = new Set(f.clauses.filter((c) => c.cited).map((c) => c.id));
121
+ const current = promises.find((p) => !citedIds.has(p.id)) ?? null;
122
+
123
+ // Whose turn (D4): from the board's own owner, never re-derived.
124
+ const owner = f.nextStep?.owner ?? null;
125
+ const you =
126
+ owner === null
127
+ ? { turn: "none", act: null }
128
+ : owner === "human"
129
+ ? { turn: "you", act: f.nextStep.label }
130
+ : owner.includes("human")
131
+ ? { turn: "agent", act: f.nextStep.label, then: "your signature" }
132
+ : { turn: "agent", act: f.nextStep.label };
133
+
134
+ return {
135
+ name: f.name,
136
+ phase: f.phase,
137
+ open: f.phase !== "accepted",
138
+ stages,
139
+ currentStage: currentKey,
140
+ promises: { total: f.total, kept: f.covered, current },
141
+ you,
142
+ stops: remainingStops(stages, currentIdx),
143
+ doneReason: f.doneReason,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
149
+ * — governed artifacts drifted or reopened that NO open walk accounts for
150
+ * (the board's undeclared set, widened to reopens, e.g. a harness upgrade's
151
+ * rule-change wave). Each arrival carries the journal's last reopen reason so
152
+ * the surface can say WHY it arrived, not only that it did.
153
+ * @param {string} root
154
+ * @returns {{available: boolean, reason?: string, walks: object[], arrivals: object[]}}
155
+ */
156
+ export function deriveWalks(root) {
157
+ let board, statuses;
158
+ try {
159
+ board = getFeatureBoard(root);
160
+ statuses = getApprovalStatuses(root);
161
+ } catch (err) {
162
+ return { available: false, reason: err?.message ?? String(err), walks: [], arrivals: [] };
163
+ }
164
+
165
+ // Open walks, MOST RECENTLY ACTIVE first (journal recency across the walk's
166
+ // family) — five features parked at Sign-off must not bury the one being
167
+ // worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
168
+ // the live navigation-ia walk in the statusline).
169
+ const journalForOrder = readJournal(root);
170
+ const lastActivity = (name) => {
171
+ const family = new Set([`feature-brief:${name}`, `feature-design:${name}`, `feature-spec:${name}`]);
172
+ for (let i = journalForOrder.length - 1; i >= 0; i--) {
173
+ if (family.has(journalForOrder[i].artifact)) return i;
174
+ }
175
+ return -1;
176
+ };
177
+ const walks = board.features
178
+ .map((f) => walkOfFeature(root, f))
179
+ .filter((w) => w.open)
180
+ .sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
181
+
182
+ // Ids an open walk accounts for: its own family (brief/design/spec) and its
183
+ // declared touches. A reopened design mid-walk is that walk's Design stage,
184
+ // never an arrival (brief, edge cases).
185
+ const owned = new Set();
186
+ for (const f of board.features) {
187
+ if (f.phase === "accepted") continue;
188
+ owned.add(`feature-brief:${f.name}`);
189
+ owned.add(`feature-design:${f.name}`);
190
+ owned.add(`feature-spec:${f.name}`);
191
+ for (const t of f.touches) owned.add(t.id);
192
+ }
193
+ const journal = journalForOrder;
194
+ const lastReopenReason = (id) => {
195
+ for (let i = journal.length - 1; i >= 0; i--) {
196
+ if (journal[i].artifact === id && journal[i].verb === "reopen") return journal[i].reason ?? null;
197
+ }
198
+ return null;
199
+ };
200
+ const arrivals = statuses
201
+ .filter((s) => (s.status === "changed-since-approval" || s.status === "reopened") && !owned.has(s.id))
202
+ .map((s) => ({
203
+ id: s.id,
204
+ label: s.label ?? s.id,
205
+ status: s.status,
206
+ reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
207
+ }));
208
+
209
+ return { available: true, walks, arrivals };
210
+ }
211
+
212
+ // ── Renderings — one grammar, four slots (D4) ────────────────────────────────
213
+
214
+ const bar = (stages) =>
215
+ stages.map((s) => (s.state === "done" ? "●" : s.state === "current" ? "◐" : s.state === "skipped" ? "·" : "○")).join("");
216
+
217
+ const stageLabel = (w) => STAGES.find((s) => s.key === w.currentStage)?.label ?? "Closed";
218
+
219
+ /** The walk whose state is loudest: YOUR TURN beats agent-working. */
220
+ function loudest(walks) {
221
+ return walks.find((w) => w.you.turn === "you") ?? walks[0] ?? null;
222
+ }
223
+
224
+ /**
225
+ * The always-on one-liner (statusline). "" when there is nothing to say — an
226
+ * ungoverned project's statusline stays silent, never fabricated.
227
+ */
228
+ export function renderStatusline({ available, walks, arrivals }) {
229
+ if (!available || walks.length === 0) return "";
230
+ const w = loudest(walks);
231
+ const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
232
+ const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
233
+ if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}`;
234
+ const now =
235
+ w.currentStage === "build" && w.promises.total > 0
236
+ ? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
237
+ : stageLabel(w);
238
+ return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
239
+ }
240
+
241
+ /** One walk's full card — the CLI default and the loud stop-card's body. */
242
+ export function renderCard(w) {
243
+ const line = w.stages
244
+ .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.state === "skipped" ? ` (${s.note})` : ""}`)
245
+ .join(" ");
246
+ const nowLine =
247
+ w.currentStage === "build" && w.promises.current
248
+ ? `Now: keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)} of ${w.promises.total} — “${w.promises.current.title || w.promises.current.id}”`
249
+ : `Now: ${w.you.act ?? w.doneReason}`;
250
+ const youLine =
251
+ w.you.turn === "you"
252
+ ? `■ YOUR TURN: ${w.you.act}`
253
+ : w.you.turn === "agent"
254
+ ? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
255
+ : "Closed.";
256
+ return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
257
+ }
258
+
259
+ /**
260
+ * The per-prompt context block (UserPromptSubmit --inject): position + the
261
+ * standing protocol reminders, re-delivered every turn so the narration rules
262
+ * are decay-proof — re-told, never remembered (D5/D6).
263
+ */
264
+ export function renderInject({ available, walks, arrivals }) {
265
+ if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
266
+ const parts = [];
267
+ if (walks.length > 0) {
268
+ parts.push("[walk-status — derived from the ledgers; render this state, never your own memory of it]");
269
+ for (const w of walks) parts.push(renderCard(w));
270
+ }
271
+ for (const a of arrivals)
272
+ parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
273
+ parts.push(
274
+ "Protocol: speak stages as Decide·Design·Contract·Build·Prove·Sign-off and clauses as promises. Quiet while working (one line per stage transition). At any human gate, render the full stop card (stage, what it is in plain words, exactly what to do, what comes after). Never open a second walk silently.",
275
+ );
276
+ return parts.join("\n\n");
277
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // walk-status.mjs — where every open change stands, whose turn it is, what
3
+ // arrived unplanned. The walk's CLI face (docs/features/walk-status.md D5).
4
+ //
5
+ // node qa/walk-status.mjs # full cards, human-readable
6
+ // node qa/walk-status.mjs --statusline # the always-on one-liner
7
+ // node qa/walk-status.mjs --inject # UserPromptSubmit hook JSON
8
+ // node qa/walk-status.mjs --json # the raw derivation
9
+ //
10
+ // FAIL-OPEN BY CONTRACT: this runs inside a statusline and a per-prompt hook.
11
+ // Any failure — broken ledger, non-cmp directory, anything — exits 0 with
12
+ // empty output. A status surface may never block or noise the work it reports
13
+ // on. (The console renders the same derivation with full error honesty; this
14
+ // surface's honesty is silence.)
15
+
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+ const args = process.argv.slice(2);
21
+
22
+ try {
23
+ const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
24
+ const data = deriveWalks(ROOT);
25
+
26
+ if (args.includes("--json")) {
27
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
28
+ } else if (args.includes("--statusline")) {
29
+ const line = renderStatusline(data);
30
+ if (line !== "") process.stdout.write(`${line}\n`);
31
+ } else if (args.includes("--inject")) {
32
+ const ctx = renderInject(data);
33
+ if (ctx !== "") {
34
+ process.stdout.write(
35
+ JSON.stringify({
36
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: ctx },
37
+ }),
38
+ );
39
+ }
40
+ } else {
41
+ if (!data.available) {
42
+ process.stdout.write(`walk-status: not derivable — ${data.reason}\n`);
43
+ } else if (data.walks.length === 0 && data.arrivals.length === 0) {
44
+ process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
45
+ } else {
46
+ for (const w of data.walks) process.stdout.write(`${renderCard(w)}\n\n`);
47
+ for (const a of data.arrivals)
48
+ process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
49
+ }
50
+ }
51
+ process.exit(0);
52
+ } catch {
53
+ process.exit(0); // fail-open: a status surface never blocks the work
54
+ }