create-cmp-cli 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- 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/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/README.md
CHANGED
|
@@ -25,7 +25,10 @@ npm create kmp@latest my-app
|
|
|
25
25
|
|
|
26
26
|
Deterministic (stamps a frozen, CI-verified template), non-interactive with flags, exits non-zero
|
|
27
27
|
on failure — and it **builds the app to prove it's green** before reporting success.
|
|
28
|
-
Agent-readable: [llms.txt](./llms.txt) · [options.schema.json](./options.schema.json).
|
|
28
|
+
Agent-readable: [llms.txt](./llms.txt) · [AGENTS.md](./AGENTS.md) · [options.schema.json](./options.schema.json).
|
|
29
|
+
|
|
30
|
+
**Claude Code users:** `/plugin marketplace add kvdm-co-pilot/create-cmp` →
|
|
31
|
+
`/plugin install create-cmp` — [10 skills + the cmp-inspector MCP server](#the-claude-code-plugin-10-skills).
|
|
29
32
|
|
|
30
33
|
## What is this, in plain words
|
|
31
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@create-cmp/harness",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|