create-cmp-cli 0.16.0 → 0.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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
+ }
@@ -7,11 +7,13 @@
7
7
  // [--target-dir <dir>] [--fix]
8
8
  //
9
9
  // --fix applies only SAFE heals (write local.properties from ANDROID_HOME, add
10
- // ksp.useKSP2=true); everything else prints the exact manual step.
10
+ // ksp.useKSP2=true, wire the walk into .claude/settings.json); everything else
11
+ // prints the exact manual step.
11
12
 
12
13
  import fs from "node:fs";
13
14
  import os from "node:os";
14
15
  import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
15
17
 
16
18
  import { flagBool } from "../lib/args.mjs";
17
19
  import { colors, ok } from "../lib/log.mjs";
@@ -71,6 +73,65 @@ function freeDiskBytes() {
71
73
  }
72
74
  }
73
75
 
76
+ /** This engine checkout root — the template it ships is the wiring of record. */
77
+ const ENGINE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
78
+
79
+ /**
80
+ * The walk wiring the CURRENT engine template declares: the statusLine object
81
+ * and the UserPromptSubmit hook groups that invoke qa/walk-status.mjs. Read from
82
+ * template/.claude/settings.json rather than duplicated here, so the heal cannot
83
+ * drift from what a fresh scaffold gets.
84
+ * @returns {{statusLine: object|null, promptSubmit: Array|null}}
85
+ */
86
+ export function templateWalkWiring() {
87
+ const raw = readIfExists(path.join(ENGINE_ROOT, "template", ".claude", "settings.json"));
88
+ if (raw === null) return { statusLine: null, promptSubmit: null };
89
+ try {
90
+ const t = JSON.parse(raw);
91
+ const statusLine = invokesWalk(t.statusLine) ? t.statusLine : null;
92
+ const groups = (t.hooks?.UserPromptSubmit ?? []).filter((g) =>
93
+ (g?.hooks ?? []).some(invokesWalk)
94
+ );
95
+ return { statusLine, promptSubmit: groups.length > 0 ? groups : null };
96
+ } catch {
97
+ return { statusLine: null, promptSubmit: null };
98
+ }
99
+ }
100
+
101
+ /** Does this settings entry ({type, command}) actually run the walk? */
102
+ function invokesWalk(entry) {
103
+ return String(entry?.command ?? "").includes("walk-status.mjs");
104
+ }
105
+
106
+ /**
107
+ * Is the walk installed, and does .claude/settings.json invoke it? The machinery
108
+ * and the wiring live in separately-owned files (lane vs app config), so they can
109
+ * and do come apart — see the walk-wiring finding in project-doctor.mjs.
110
+ */
111
+ export function gatherWalkInputs(projectDir) {
112
+ const scriptPresent = fs.existsSync(path.join(projectDir, "qa", "walk-status.mjs"));
113
+ if (!scriptPresent) return null; // not a walk-carrying lane — nothing to say
114
+ const raw = readIfExists(path.join(projectDir, ".claude", "settings.json"));
115
+ if (raw === null) {
116
+ return { scriptPresent, settingsPresent: false, statusLine: false, promptHook: false };
117
+ }
118
+ let settings;
119
+ try {
120
+ settings = JSON.parse(raw);
121
+ } catch {
122
+ // Unparseable settings invoke nothing, which is exactly what we report.
123
+ return { scriptPresent, settingsPresent: true, statusLine: false, promptHook: false };
124
+ }
125
+ return {
126
+ scriptPresent,
127
+ settingsPresent: true,
128
+ statusLine: invokesWalk(settings.statusLine),
129
+ promptHook: (settings.hooks?.UserPromptSubmit ?? []).some((g) =>
130
+ (g?.hooks ?? []).some(invokesWalk)
131
+ ),
132
+ };
133
+ }
134
+
74
135
  /** Gather filesystem/env inputs for the pure diagnosis. */
75
136
  export function gatherProjectInputs(projectDir) {
76
137
  const toml = readIfExists(path.join(projectDir, "gradle", "libs.versions.toml"));
@@ -112,6 +173,7 @@ export function gatherProjectInputs(projectDir) {
112
173
  freeDiskBytes: freeDiskBytes(),
113
174
  inspectorHits,
114
175
  inspectorCatalog,
176
+ walk: gatherWalkInputs(projectDir),
115
177
  };
116
178
  }
117
179
 
@@ -146,7 +208,7 @@ function scanInspectorSources(projectDir) {
146
208
  }
147
209
 
148
210
  /** Apply the SAFE auto-heals for --fix. Returns ids of findings it fixed. */
149
- function applySafeFixes(projectDir, findings, inputs) {
211
+ export function applySafeFixes(projectDir, findings, inputs) {
150
212
  const fixed = [];
151
213
  for (const f of findings) {
152
214
  if (!f.fix || !f.fix.auto || f.level === "ok") continue;
@@ -164,6 +226,43 @@ function applySafeFixes(projectDir, findings, inputs) {
164
226
  }
165
227
  }
166
228
 
229
+ if (f.id === "walk-wiring") {
230
+ const { statusLine, promptSubmit } = templateWalkWiring();
231
+ const target = path.join(projectDir, ".claude", "settings.json");
232
+ const raw = readIfExists(target);
233
+ let settings = {};
234
+ if (raw !== null) {
235
+ try {
236
+ settings = JSON.parse(raw);
237
+ } catch {
238
+ // Never overwrite settings we could not read — that is the app's file.
239
+ continue;
240
+ }
241
+ }
242
+ let changed = false;
243
+ if (statusLine && !invokesWalk(settings.statusLine)) {
244
+ // Only claim an unclaimed slot: an app that set its OWN status line keeps it.
245
+ if (!settings.statusLine) {
246
+ settings.statusLine = statusLine;
247
+ changed = true;
248
+ }
249
+ }
250
+ if (promptSubmit) {
251
+ settings.hooks = settings.hooks ?? {};
252
+ const existing = settings.hooks.UserPromptSubmit ?? [];
253
+ if (!existing.some((g) => (g?.hooks ?? []).some(invokesWalk))) {
254
+ settings.hooks.UserPromptSubmit = [...existing, ...promptSubmit];
255
+ changed = true;
256
+ }
257
+ }
258
+ if (changed) {
259
+ fs.mkdirSync(path.dirname(target), { recursive: true });
260
+ fs.writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`);
261
+ ok("--fix: wired the walk into .claude/settings.json (statusLine + UserPromptSubmit)");
262
+ fixed.push(f.id);
263
+ }
264
+ }
265
+
167
266
  if (f.id === "ksp2-flag") {
168
267
  const target = path.join(projectDir, "gradle.properties");
169
268
  const existing = inputs.gradleProperties ?? "";
package/src/lib/hooks.mjs CHANGED
@@ -42,7 +42,10 @@ export function isEnforcementEvent(event) {
42
42
  return ENFORCEMENT_EVENTS.has(event);
43
43
  }
44
44
 
45
- /** Does this hook's command presuppose the verify lane (`qa/`)? */
45
+ /**
46
+ * Does this entry's command presuppose the verify lane (`qa/`)? Takes anything
47
+ * settings.json can hold a command in — a hook, or the top-level `statusLine`.
48
+ */
46
49
  export function referencesLane(hook) {
47
50
  return String(hook?.command ?? "").includes("qa/");
48
51
  }
@@ -118,12 +121,20 @@ export function sessionStartCommand(context) {
118
121
 
119
122
  /**
120
123
  * The minimal-mode hook set, DERIVED from the full one rather than kept as a
121
- * second file to hold in sync (light is a filter, not a fork). Three edits:
124
+ * second file to hold in sync (light is a filter, not a fork). Four edits:
122
125
  *
123
126
  * (a) enforcement goes — the Stop hook is Act 3;
124
127
  * (b) lane-advisory goes — a nudge naming qa/ presupposes the lane;
125
128
  * (c) SessionStart says what is true HERE — `sessionContext` describes what
126
- * this scaffold carries and the one command that adds the rest.
129
+ * this scaffold carries and the one command that adds the rest;
130
+ * (d) a lane-referencing `statusLine` goes. It is not a hook, so the
131
+ * classifier above never saw it, and the first cut of the walk shipped a
132
+ * minimal scaffold a status line reading `qa/walk-status.mjs` — a file
133
+ * minimal deletes. The command is guarded (`test -f … || true`) so it
134
+ * printed nothing rather than erroring, which is exactly what made it
135
+ * survive review: dead config that fails silently. The lane-reference
136
+ * rule is the same one that drops lane-advisory hooks; it just has to be
137
+ * applied to every surface that carries a command, not only to `hooks`.
127
138
  *
128
139
  * @param {object} settings parsed .claude/settings.json content
129
140
  * @param {object} opts
@@ -131,7 +142,9 @@ export function sessionStartCommand(context) {
131
142
  */
132
143
  export function minimalHookSettings(settings, { sessionContext }) {
133
144
  const out = filterHooks(settings, (event, hook) => classifyHook(event, hook) !== "advisory");
134
- if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
145
+ if (!out || typeof out !== "object") return out;
146
+ if (referencesLane(out.statusLine)) delete out.statusLine;
147
+ if (typeof out.hooks !== "object" || out.hooks === null) return out;
135
148
  for (const group of out.hooks.SessionStart ?? []) {
136
149
  if (!Array.isArray(group?.hooks)) continue;
137
150
  for (const hook of group.hooks) hook.command = sessionStartCommand(sessionContext);
@@ -36,6 +36,10 @@ export const DISK_WARN_BYTES = 3 * GIB;
36
36
  * @param {string[]|null} [input.inspectorHits] relative (posix) paths of Kotlin sources that
37
37
  * reference the live-inspector endpoint (`/inspect/` or `InspectorHttpServer`);
38
38
  * null = scan skipped (no composeApp sources), [] = project has no inspector code.
39
+ * @param {{scriptPresent:boolean, settingsPresent:boolean, statusLine:boolean,
40
+ * promptHook:boolean}|null} [input.walk] the walk's wiring: is
41
+ * qa/walk-status.mjs installed, and does .claude/settings.json actually
42
+ * INVOKE it (statusLine + UserPromptSubmit)? null = skip the check.
39
43
  * @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
40
44
  * InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
41
45
  * the declared-token drift tripwire; null = skip.
@@ -55,6 +59,7 @@ export function diagnoseProject(input) {
55
59
  freeDiskBytes,
56
60
  inspectorHits = null,
57
61
  inspectorCatalog = null,
62
+ walk = null,
58
63
  } = input;
59
64
 
60
65
  // --- version catalog ------------------------------------------------------
@@ -313,6 +318,49 @@ export function diagnoseProject(input) {
313
318
  }
314
319
  }
315
320
 
321
+ // --- the walk: installed but unwired ----------------------------------------
322
+ // qa/walk-status.mjs is inert on its own. What renders it is .claude/settings.json:
323
+ // a statusLine (the ambient "where are we") and a UserPromptSubmit hook (the
324
+ // per-turn position injected into the agent). Both are APP-OWNED config, so an app
325
+ // that hand-edited settings.json can take the machinery on upgrade and lose the
326
+ // wiring — and the failure mode is silence, which is precisely the problem the walk
327
+ // exists to fix. Nothing else in the system can notice, so doctor does.
328
+ if (walk !== null && walk.scriptPresent) {
329
+ const missing = [
330
+ !walk.statusLine ? "no statusLine" : null,
331
+ !walk.promptHook ? "no UserPromptSubmit hook" : null,
332
+ ].filter(Boolean);
333
+ if (missing.length === 0) {
334
+ findings.push({
335
+ id: "walk-wiring",
336
+ level: "ok",
337
+ title: "The walk is wired",
338
+ detail:
339
+ "qa/walk-status.mjs is installed and .claude/settings.json invokes it from both the " +
340
+ "status line and UserPromptSubmit.",
341
+ });
342
+ } else {
343
+ findings.push({
344
+ id: "walk-wiring",
345
+ level: "warn",
346
+ title: "The walk is installed but not wired up",
347
+ detail:
348
+ "qa/walk-status.mjs is present, but " +
349
+ (walk.settingsPresent
350
+ ? `.claude/settings.json does not invoke it (${missing.join(", ")}).`
351
+ : "there is no .claude/settings.json to invoke it from.") +
352
+ " Nothing will show which stage a feature is at, or tell the agent where it is — " +
353
+ "the walk runs nowhere. Running node qa/walk-status.mjs by hand still works.",
354
+ fix: {
355
+ auto: true,
356
+ description:
357
+ "Add the statusLine and UserPromptSubmit entries to .claude/settings.json " +
358
+ "(copied from the engine template; existing hooks are left untouched).",
359
+ },
360
+ });
361
+ }
362
+ }
363
+
316
364
  return findings;
317
365
  }
318
366
 
@@ -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
+ }