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 +1 -1
- package/packages/harness/package.json +2 -2
- package/packages/harness/src/lib/walk.mjs +277 -0
- package/packages/harness/src/walk-status.mjs +54 -0
- package/src/commands/doctor.mjs +101 -2
- package/src/lib/hooks.mjs +17 -4
- package/src/lib/project-doctor.mjs +48 -0
- package/template/.claude/settings.json +22 -4
- package/template/AGENTS.md +1 -0
- package/template/CLAUDE.md +36 -0
- package/template/qa/lib/walk.mjs +277 -0
- package/template/qa/walk-status.mjs +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "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.
|
|
4
|
-
"description": "The create-cmp verify lane
|
|
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
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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).
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
{
|
|
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
|
}
|
package/template/AGENTS.md
CHANGED
|
@@ -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 |
|
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
+
}
|