create-cmp-cli 0.17.1 → 0.18.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/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/harness/src/lib/approvals.mjs +13 -4
- package/packages/harness/src/lib/feature-brief.mjs +88 -16
- package/packages/harness/src/lib/inputs-hash.mjs +8 -0
- package/packages/harness/src/lib/plan.mjs +200 -0
- package/packages/harness/src/lib/walk.mjs +212 -21
- package/packages/harness/src/plan.mjs +64 -0
- package/packages/harness/src/receipt-check.mjs +6 -1
- package/packages/harness/src/walk-status.mjs +37 -1
- package/packages/receipts/src/inputs-hash.mjs +8 -0
- package/src/commands/doctor.mjs +26 -0
- package/src/lib/project-doctor.mjs +37 -0
- package/template/CLAUDE.md +33 -7
- package/template/gitignore +7 -0
- package/template/qa/lib/approvals.mjs +13 -4
- package/template/qa/lib/feature-brief.mjs +88 -16
- package/template/qa/lib/inputs-hash.mjs +8 -0
- package/template/qa/lib/plan.mjs +200 -0
- package/template/qa/lib/walk.mjs +212 -21
- package/template/qa/plan.mjs +64 -0
- package/template/qa/receipt-check.mjs +6 -1
- package/template/qa/walk-status.mjs +37 -1
package/template/qa/lib/walk.mjs
CHANGED
|
@@ -13,11 +13,14 @@
|
|
|
13
13
|
// test is that promise kept, provenDone is all of them kept with the receipt
|
|
14
14
|
// attesting this tree.
|
|
15
15
|
|
|
16
|
+
import crypto from "node:crypto";
|
|
16
17
|
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
17
19
|
import path from "node:path";
|
|
18
20
|
|
|
19
21
|
import { getFeatureBoard, getApprovalStatuses, readJournal } from "./approvals.mjs";
|
|
20
22
|
import { CLAUSE_LINE_RE } from "./spec-coverage.mjs";
|
|
23
|
+
import { deriveChain, renderChain } from "./plan.mjs";
|
|
21
24
|
|
|
22
25
|
/** The six stages, in walk order. `label` is the only user-facing name (D2). */
|
|
23
26
|
export const STAGES = [
|
|
@@ -29,6 +32,32 @@ export const STAGES = [
|
|
|
29
32
|
{ key: "signoff", label: "Sign-off" },
|
|
30
33
|
];
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Plain-language glosses (walk-legibility L3) — what each stage IS, for a
|
|
37
|
+
* reader who did not build the harness. Rendered beside the stage name
|
|
38
|
+
* wherever a human reads; the keys and ids underneath never change.
|
|
39
|
+
*/
|
|
40
|
+
export const STAGE_GLOSS = {
|
|
41
|
+
decide: "choosing what to build, and why",
|
|
42
|
+
design: "how it looks — judged on rendered screens",
|
|
43
|
+
contract: "agreeing what it promises",
|
|
44
|
+
build: "keeping the promises",
|
|
45
|
+
prove: "checking every promise",
|
|
46
|
+
signoff: "your sign-off",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A governed artifact id in plain words (L3): `feature-spec:meal` reads as
|
|
51
|
+
* "the promises for meal". Ledger ids stay ids; only the human rendering
|
|
52
|
+
* translates. Unknown shapes pass through untouched — never invented prose.
|
|
53
|
+
*/
|
|
54
|
+
export function humanArtifact(id) {
|
|
55
|
+
const m = /^feature-(brief|design|spec):(.+)$/.exec(String(id));
|
|
56
|
+
if (!m) return String(id);
|
|
57
|
+
const what = m[1] === "brief" ? "the decisions for" : m[1] === "design" ? "the design of" : "the promises for";
|
|
58
|
+
return `${what} ${m[2]}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/** nextStep.key -> the stage that step belongs to. `closed` maps to none. */
|
|
33
62
|
const STEP_STAGE = {
|
|
34
63
|
"sign-brief": "decide",
|
|
@@ -105,21 +134,50 @@ function remainingStops(stages, currentIdx) {
|
|
|
105
134
|
* provenDone) to ONE next act — stages before it are done, after it pending.
|
|
106
135
|
* Design is `skipped` when the feature honestly has no UI surface (D2).
|
|
107
136
|
*/
|
|
108
|
-
function walkOfFeature(root, f) {
|
|
137
|
+
function walkOfFeature(root, f, lane = null) {
|
|
109
138
|
const currentKey = STEP_STAGE[f.nextStep?.key] ?? null; // null => closed
|
|
110
139
|
const currentIdx = currentKey ? STAGES.findIndex((s) => s.key === currentKey) : STAGES.length;
|
|
111
140
|
const stages = STAGES.map((s, i) => {
|
|
112
141
|
if (s.key === "design" && f.design === null)
|
|
113
142
|
return { ...s, state: "skipped", note: "no UI surface" };
|
|
114
|
-
|
|
143
|
+
const state = i < currentIdx ? "done" : i === currentIdx ? "current" : "pending";
|
|
144
|
+
// Time is part of position (L4): Prove is the one stage with its own
|
|
145
|
+
// recorded history (the lane journals every run), so it says what it
|
|
146
|
+
// costs — measured, never the agent's memory of it.
|
|
147
|
+
if (s.key === "prove" && lane)
|
|
148
|
+
return { ...s, state, note: `${humanDuration(lane.durationMs)} last full run` };
|
|
149
|
+
return { ...s, state };
|
|
115
150
|
});
|
|
116
151
|
|
|
117
|
-
|
|
152
|
+
// The paired specs (L1): promises concatenate across every spec the brief
|
|
153
|
+
// names, in declaration order — same pairing the board derivation used.
|
|
154
|
+
const specRels = f.specRels ?? [f.specRel];
|
|
155
|
+
const promises = specRels.flatMap((rel) => listPromises(root, rel)).filter((p) => !p.withdrawn);
|
|
118
156
|
// The promise being kept NOW: first live clause without a citing test —
|
|
119
157
|
// board clause order, titles from the spec's own words.
|
|
120
158
|
const citedIds = new Set(f.clauses.filter((c) => c.cited).map((c) => c.id));
|
|
121
159
|
const current = promises.find((p) => !citedIds.has(p.id)) ?? null;
|
|
122
160
|
|
|
161
|
+
// The signature the current step is waiting for, when it IS a signature —
|
|
162
|
+
// so a surface with a signing control (the console) can put the button on
|
|
163
|
+
// the walk card itself instead of pointing at a CLI incantation (L5).
|
|
164
|
+
// Derived from the board's own step key, never a fourth approve path.
|
|
165
|
+
const signable = (() => {
|
|
166
|
+
switch (f.nextStep?.key) {
|
|
167
|
+
case "sign-brief":
|
|
168
|
+
case "re-approve":
|
|
169
|
+
return [{ verb: "approve", artifact: `feature-brief:${f.name}` }];
|
|
170
|
+
case "sign-design":
|
|
171
|
+
return [{ verb: "approve", artifact: `feature-design:${f.name}` }];
|
|
172
|
+
case "sign-spec":
|
|
173
|
+
return (f.specNames ?? [f.name]).map((n) => ({ verb: "approve", artifact: `feature-spec:${n}` }));
|
|
174
|
+
case "accept":
|
|
175
|
+
return [{ verb: "accept", artifact: f.name }];
|
|
176
|
+
default:
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
|
|
123
181
|
// Whose turn (D4): from the board's own owner, never re-derived.
|
|
124
182
|
const owner = f.nextStep?.owner ?? null;
|
|
125
183
|
const you =
|
|
@@ -137,13 +195,83 @@ function walkOfFeature(root, f) {
|
|
|
137
195
|
open: f.phase !== "accepted",
|
|
138
196
|
stages,
|
|
139
197
|
currentStage: currentKey,
|
|
140
|
-
|
|
141
|
-
|
|
198
|
+
// `all` is the full promise list with per-promise kept state (L5) — the
|
|
199
|
+
// console renders the promises themselves, not only the tally.
|
|
200
|
+
promises: {
|
|
201
|
+
total: f.total,
|
|
202
|
+
kept: f.covered,
|
|
203
|
+
current,
|
|
204
|
+
all: promises.map((p) => ({ ...p, kept: citedIds.has(p.id) })),
|
|
205
|
+
},
|
|
206
|
+
you: { ...you, signable },
|
|
142
207
|
stops: remainingStops(stages, currentIdx),
|
|
143
208
|
doneReason: f.doneReason,
|
|
144
209
|
};
|
|
145
210
|
}
|
|
146
211
|
|
|
212
|
+
/**
|
|
213
|
+
* What a full verify-lane run costs HERE, from the lane's own journal
|
|
214
|
+
* (qa/flight-recorder.jsonl) — the most recent non-fast run's wall time and
|
|
215
|
+
* verdict. Walk-legibility L4: any surface quoting the lane quotes this,
|
|
216
|
+
* never a memory of it (the observed failure: a 3× overestimate, spoken
|
|
217
|
+
* while the human was deciding whether a run was worth it, lost the run).
|
|
218
|
+
* @returns {{durationMs: number, verdict: (string|null)} | null}
|
|
219
|
+
*/
|
|
220
|
+
export function laneTiming(root) {
|
|
221
|
+
let text;
|
|
222
|
+
try {
|
|
223
|
+
text = fs.readFileSync(path.join(root, "qa/flight-recorder.jsonl"), "utf8");
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const lines = text.split("\n");
|
|
228
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
229
|
+
const line = lines[i].trim();
|
|
230
|
+
if (line === "") continue;
|
|
231
|
+
let e;
|
|
232
|
+
try {
|
|
233
|
+
e = JSON.parse(line);
|
|
234
|
+
} catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0)
|
|
238
|
+
return { durationMs: e.durationMs, verdict: e.verdict ?? null };
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** "98s" under two minutes, "~5 min" above — for humans deciding whether to wait. */
|
|
244
|
+
export function humanDuration(ms) {
|
|
245
|
+
if (!(ms > 0)) return "unknown";
|
|
246
|
+
return ms < 120000 ? `${Math.round(ms / 1000)}s` : `~${Math.round(ms / 60000)} min`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The studio console currently registered for `root`, from the same tmp-dir
|
|
251
|
+
* record the console itself writes — a CROSS-PACKAGE CONTRACT with the
|
|
252
|
+
* inspector's preview-service.mjs (consoleRegistryPath): sha1(resolved
|
|
253
|
+
* root).slice(0,12), `cmp-console-<key>.json` in os.tmpdir(), fields
|
|
254
|
+
* {pid, port, url}. pid-liveness only, no HTTP — this runs inside a
|
|
255
|
+
* statusline with a <300ms budget.
|
|
256
|
+
* @returns {{url: string} | {stale: true} | null} null = no record at all
|
|
257
|
+
* (never started, or stopped cleanly — silence, not an alarm)
|
|
258
|
+
*/
|
|
259
|
+
export function consoleState(root) {
|
|
260
|
+
try {
|
|
261
|
+
const key = crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12);
|
|
262
|
+
const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
|
|
263
|
+
if (!rec || typeof rec.pid !== "number") return null;
|
|
264
|
+
try {
|
|
265
|
+
process.kill(rec.pid, 0); // signal 0: existence probe, touches nothing
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (!(err && err.code === "EPERM")) return { stale: true }; // record left by a crashed console
|
|
268
|
+
}
|
|
269
|
+
return { url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/` };
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
147
275
|
/**
|
|
148
276
|
* Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
|
|
149
277
|
* — governed artifacts drifted or reopened that NO open walk accounts for
|
|
@@ -167,15 +295,22 @@ export function deriveWalks(root) {
|
|
|
167
295
|
// worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
|
|
168
296
|
// the live navigation-ia walk in the statusline).
|
|
169
297
|
const journalForOrder = readJournal(root);
|
|
298
|
+
const byName = new Map(board.features.map((f) => [f.name, f]));
|
|
170
299
|
const lastActivity = (name) => {
|
|
171
|
-
const
|
|
300
|
+
const specNames = byName.get(name)?.specNames ?? [name];
|
|
301
|
+
const family = new Set([
|
|
302
|
+
`feature-brief:${name}`,
|
|
303
|
+
`feature-design:${name}`,
|
|
304
|
+
...specNames.map((n) => `feature-spec:${n}`),
|
|
305
|
+
]);
|
|
172
306
|
for (let i = journalForOrder.length - 1; i >= 0; i--) {
|
|
173
307
|
if (family.has(journalForOrder[i].artifact)) return i;
|
|
174
308
|
}
|
|
175
309
|
return -1;
|
|
176
310
|
};
|
|
311
|
+
const lane = laneTiming(root);
|
|
177
312
|
const walks = board.features
|
|
178
|
-
.map((f) => walkOfFeature(root, f))
|
|
313
|
+
.map((f) => walkOfFeature(root, f, lane))
|
|
179
314
|
.filter((w) => w.open)
|
|
180
315
|
.sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
|
|
181
316
|
|
|
@@ -187,7 +322,9 @@ export function deriveWalks(root) {
|
|
|
187
322
|
if (f.phase === "accepted") continue;
|
|
188
323
|
owned.add(`feature-brief:${f.name}`);
|
|
189
324
|
owned.add(`feature-design:${f.name}`);
|
|
190
|
-
|
|
325
|
+
// The spec side follows the brief's own pairing (L1) — a reopened spec a
|
|
326
|
+
// multi-spec walk owns is that walk's Contract stage, never an arrival.
|
|
327
|
+
for (const n of f.specNames ?? [f.name]) owned.add(`feature-spec:${n}`);
|
|
191
328
|
for (const t of f.touches) owned.add(t.id);
|
|
192
329
|
}
|
|
193
330
|
const journal = journalForOrder;
|
|
@@ -206,7 +343,13 @@ export function deriveWalks(root) {
|
|
|
206
343
|
reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
|
|
207
344
|
}));
|
|
208
345
|
|
|
209
|
-
|
|
346
|
+
// `lane` (L4) and `console` (L6) ride along so every rendering can say
|
|
347
|
+
// what a check costs and where the buttons are — both derived, never
|
|
348
|
+
// remembered, and both null-safe for projects that have neither yet.
|
|
349
|
+
// The live CHAIN (studio-drive-mode) rides along too: request + declared
|
|
350
|
+
// step plan + what is actually running. Declared state, labeled as such by
|
|
351
|
+
// every renderer; it gates nothing and the walk stays the truth.
|
|
352
|
+
return { available: true, walks, arrivals, lane, console: consoleState(root), chain: deriveChain(root) };
|
|
210
353
|
}
|
|
211
354
|
|
|
212
355
|
// ── Renderings — one grammar, four slots (D4) ────────────────────────────────
|
|
@@ -225,35 +368,51 @@ function loudest(walks) {
|
|
|
225
368
|
* The always-on one-liner (statusline). "" when there is nothing to say — an
|
|
226
369
|
* ungoverned project's statusline stays silent, never fabricated.
|
|
227
370
|
*/
|
|
228
|
-
export function renderStatusline({ available, walks, arrivals }) {
|
|
371
|
+
export function renderStatusline({ available, walks, arrivals, console: consoleRec }) {
|
|
229
372
|
if (!available || walks.length === 0) return "";
|
|
230
373
|
const w = loudest(walks);
|
|
231
374
|
const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
|
|
232
375
|
const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
|
|
233
|
-
|
|
376
|
+
// L6: the always-visible surface reports the other surface's death. A stale
|
|
377
|
+
// record means the console CRASHED (a clean stop removes it) — the failure
|
|
378
|
+
// mode was silence, and silence is the one thing this line never does.
|
|
379
|
+
const down = consoleRec && consoleRec.stale ? " · console down" : "";
|
|
380
|
+
if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}${down}`;
|
|
234
381
|
const now =
|
|
235
382
|
w.currentStage === "build" && w.promises.total > 0
|
|
236
383
|
? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
|
|
237
384
|
: stageLabel(w);
|
|
238
|
-
return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
|
|
385
|
+
return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}${down}`;
|
|
239
386
|
}
|
|
240
387
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
388
|
+
/**
|
|
389
|
+
* One walk's full card — the CLI default and the loud stop-card's body.
|
|
390
|
+
* `ctx` carries the derivation's ride-alongs ({console, lane} from
|
|
391
|
+
* deriveWalks): with a live console, a human gate leads with the console
|
|
392
|
+
* (L5 — the product ships buttons; the CLI stays as the fallback beneath).
|
|
393
|
+
*/
|
|
394
|
+
export function renderCard(w, ctx = {}) {
|
|
243
395
|
const line = w.stages
|
|
244
|
-
.map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.
|
|
396
|
+
.map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.note ? ` (${s.note})` : ""}`)
|
|
245
397
|
.join(" ");
|
|
398
|
+
const gloss = STAGE_GLOSS[w.currentStage] ? ` — ${STAGE_GLOSS[w.currentStage]}` : "";
|
|
399
|
+
const laneNote =
|
|
400
|
+
w.currentStage === "prove" && ctx.lane ? ` (takes ${humanDuration(ctx.lane.durationMs)} here, measured)` : "";
|
|
246
401
|
const nowLine =
|
|
247
402
|
w.currentStage === "build" && w.promises.current
|
|
248
403
|
? `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}`;
|
|
404
|
+
: `Now: ${w.you.act ?? w.doneReason}${laneNote}`;
|
|
405
|
+
const consoleLine =
|
|
406
|
+
w.you.turn === "you" && ctx.console && ctx.console.url
|
|
407
|
+
? `\n→ Easiest: the studio console at ${ctx.console.url} — the row carries the button. CLI fallback below.`
|
|
408
|
+
: "";
|
|
250
409
|
const youLine =
|
|
251
410
|
w.you.turn === "you"
|
|
252
|
-
? `■ YOUR TURN: ${w.you.act}`
|
|
411
|
+
? `■ YOUR TURN: ${w.you.act}${consoleLine}`
|
|
253
412
|
: w.you.turn === "agent"
|
|
254
413
|
? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
|
|
255
414
|
: "Closed.";
|
|
256
|
-
return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
|
|
415
|
+
return `${w.name} — stage: ${stageLabel(w)}${gloss}\n${line}\n${nowLine}\n${youLine}`;
|
|
257
416
|
}
|
|
258
417
|
|
|
259
418
|
/**
|
|
@@ -261,17 +420,49 @@ export function renderCard(w) {
|
|
|
261
420
|
* standing protocol reminders, re-delivered every turn so the narration rules
|
|
262
421
|
* are decay-proof — re-told, never remembered (D5/D6).
|
|
263
422
|
*/
|
|
264
|
-
|
|
423
|
+
/**
|
|
424
|
+
* The studio's status, said plainly every prompt (studio-drive-mode: the
|
|
425
|
+
* agent ALWAYS knows whether the human's window exists, and healing it is a
|
|
426
|
+
* standing instruction, not a discovery).
|
|
427
|
+
*/
|
|
428
|
+
function studioLine(consoleRec) {
|
|
429
|
+
if (consoleRec && consoleRec.url) return `[studio: running at ${consoleRec.url}]`;
|
|
430
|
+
if (consoleRec && consoleRec.stale)
|
|
431
|
+
return "[studio: DOWN — it crashed (stale registry record). Restore it now: call the cmp-inspector `preview { projectDir }` tool (it starts a detached resident console), or tell the human it is down. Do not proceed silently.]";
|
|
432
|
+
return "[studio: not running. If the cmp-inspector tools are available, start it now with `preview { projectDir }` — the human's window should exist whenever work is happening. If they are absent, say so once.]";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function renderInject(data) {
|
|
436
|
+
const { available, walks, arrivals, chain } = data;
|
|
265
437
|
if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
|
|
266
438
|
const parts = [];
|
|
439
|
+
// L2 — chat is a walk surface: the reply opens with the derivation's OWN
|
|
440
|
+
// one-liner, pasted verbatim. Machinery-authored so it cannot drift, and
|
|
441
|
+
// transcript-persistent, which the statusline never is.
|
|
442
|
+
const header = renderStatusline(data);
|
|
443
|
+
if (header !== "") {
|
|
444
|
+
parts.push(`[chat header — open your reply with this exact line (verbatim, then a blank line):]\n${header}`);
|
|
445
|
+
}
|
|
446
|
+
// The studio's status is stated EVERY prompt — its absence was silent once
|
|
447
|
+
// (the walk-wiring lesson) and never gets to be silent again.
|
|
448
|
+
parts.push(studioLine(data.console));
|
|
449
|
+
// The live chain (studio-drive-mode): the current request and the declared
|
|
450
|
+
// step plan, with its age. Declared by the agent — which is exactly why it
|
|
451
|
+
// is re-shown every turn: keeping it current is part of the contract.
|
|
452
|
+
const chainText = renderChain(chain);
|
|
453
|
+
parts.push(
|
|
454
|
+
chainText !== ""
|
|
455
|
+
? `[the chain — the current request's steps. Keep it CURRENT: advance with \`node qa/plan.mjs --step N\` as steps land, \`--done\` when the request lands. A stale chain misleads the human watching the studio.]\n${chainText}`
|
|
456
|
+
: '[no chain declared. At kickoff, declare the request\'s steps so the human can watch position live: `node qa/plan.mjs --set "step | step | …" --title "<the ask, restated>"` — mirror the itinerary you print in chat.]',
|
|
457
|
+
);
|
|
267
458
|
if (walks.length > 0) {
|
|
268
459
|
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));
|
|
460
|
+
for (const w of walks) parts.push(renderCard(w, data));
|
|
270
461
|
}
|
|
271
462
|
for (const a of arrivals)
|
|
272
463
|
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
464
|
parts.push(
|
|
274
|
-
"Protocol:
|
|
465
|
+
"Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently.",
|
|
275
466
|
);
|
|
276
467
|
return parts.join("\n\n");
|
|
277
468
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plan.mjs — the live chain's CLI (docs/features/studio-drive-mode.md).
|
|
3
|
+
// The agent declares the current request's step chain at kickoff and advances
|
|
4
|
+
// it as work lands; the statusline stays the walk's, but the studio's Drive
|
|
5
|
+
// strip and the per-prompt inject both render this chain with its age.
|
|
6
|
+
//
|
|
7
|
+
// node qa/plan.mjs # show the chain
|
|
8
|
+
// node qa/plan.mjs --set "a | b | c" [--title "…"] [--feature <name>]
|
|
9
|
+
// node qa/plan.mjs --step 3 # steps 1..2 done, 3 current
|
|
10
|
+
// node qa/plan.mjs --done # close the chain
|
|
11
|
+
// node qa/plan.mjs --clear # a landed request leaves no stale windshield
|
|
12
|
+
//
|
|
13
|
+
// Unlike walk-status (a fail-open status surface), this CLI is a WRITER the
|
|
14
|
+
// agent invokes deliberately: bad input gets a refusal and exit 1, because a
|
|
15
|
+
// silently-dropped declaration would leave the surfaces lying about position.
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { deriveChain, markStep, readPlan, renderChain, setPlan, clearPlan } from "./lib/plan.mjs";
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
|
|
25
|
+
const valueOf = (flag) => {
|
|
26
|
+
const i = args.indexOf(flag);
|
|
27
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function out(result) {
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
process.stderr.write(`plan: ${result.reason}\n`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const rendered = renderChain(deriveChain(ROOT));
|
|
36
|
+
process.stdout.write(`${rendered === "" ? "No chain declared." : rendered}\n`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (args.includes("--set")) {
|
|
41
|
+
const spec = valueOf("--set");
|
|
42
|
+
if (spec === null) {
|
|
43
|
+
process.stderr.write('plan: --set needs a value: --set "step | step | …"\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
out(
|
|
47
|
+
setPlan(ROOT, {
|
|
48
|
+
title: valueOf("--title") ?? undefined,
|
|
49
|
+
feature: valueOf("--feature") ?? undefined,
|
|
50
|
+
steps: spec.split("|"),
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
} else if (args.includes("--step")) {
|
|
54
|
+
out(markStep(ROOT, valueOf("--step")));
|
|
55
|
+
} else if (args.includes("--done")) {
|
|
56
|
+
const plan = readPlan(ROOT);
|
|
57
|
+
out(plan ? markStep(ROOT, plan.steps.length + 1) : { ok: false, reason: "no declared chain to close" });
|
|
58
|
+
} else if (args.includes("--clear")) {
|
|
59
|
+
out(clearPlan(ROOT));
|
|
60
|
+
} else {
|
|
61
|
+
const rendered = renderChain(deriveChain(ROOT));
|
|
62
|
+
process.stdout.write(`${rendered === "" ? "No chain declared. Declare one: node qa/plan.mjs --set \"step | step | …\"" : rendered}\n`);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
@@ -75,8 +75,13 @@ if (asHook) {
|
|
|
75
75
|
process.exit(0);
|
|
76
76
|
}
|
|
77
77
|
if (!result.valid) {
|
|
78
|
+
// The walk's vocabulary (walk-legibility L3): this gate IS the Prove
|
|
79
|
+
// stage refusing to close — same fact, same enforcement, words that match
|
|
80
|
+
// every other surface. The precise reason stays verbatim beneath.
|
|
78
81
|
process.stderr.write(
|
|
79
|
-
|
|
82
|
+
`■ Prove — not done: the promises are not yet checked against this tree. ` +
|
|
83
|
+
`${result.reason}. Run \`node qa/verify.mjs\` (it checks every promise and writes the receipt), ` +
|
|
84
|
+
`commit the receipt, or see README §Verification enforcement to bypass.\n`,
|
|
80
85
|
);
|
|
81
86
|
process.exit(2);
|
|
82
87
|
}
|
|
@@ -19,8 +19,41 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
20
|
const args = process.argv.slice(2);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The hook's stdin, parsed — UserPromptSubmit delivers {prompt, ...} as JSON.
|
|
24
|
+
* Bounded read, fail-soft: no stdin / non-JSON / no prompt -> null. Only the
|
|
25
|
+
* --inject path consumes this (the statusline gets no stdin and must not wait
|
|
26
|
+
* on one).
|
|
27
|
+
*/
|
|
28
|
+
async function readHookStdin() {
|
|
29
|
+
if (process.stdin.isTTY) return null;
|
|
30
|
+
try {
|
|
31
|
+
let raw = "";
|
|
32
|
+
for await (const chunk of process.stdin) {
|
|
33
|
+
raw += chunk;
|
|
34
|
+
if (raw.length > 1_000_000) break; // a prompt is never this — stop reading, keep what we have
|
|
35
|
+
}
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
try {
|
|
23
44
|
const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
|
|
45
|
+
|
|
46
|
+
// Tier 1 of the chain (studio-drive-mode): record the human's own prompt
|
|
47
|
+
// BEFORE deriving, so this very inject already reflects the new request.
|
|
48
|
+
// Machinery-owned — the words are the hook's, never the agent's.
|
|
49
|
+
if (args.includes("--inject")) {
|
|
50
|
+
const hook = await readHookStdin();
|
|
51
|
+
if (hook && typeof hook.prompt === "string") {
|
|
52
|
+
const { recordRequest } = await import("./lib/plan.mjs");
|
|
53
|
+
recordRequest(ROOT, hook.prompt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
const data = deriveWalks(ROOT);
|
|
25
58
|
|
|
26
59
|
if (args.includes("--json")) {
|
|
@@ -43,7 +76,10 @@ try {
|
|
|
43
76
|
} else if (data.walks.length === 0 && data.arrivals.length === 0) {
|
|
44
77
|
process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
|
|
45
78
|
} else {
|
|
46
|
-
|
|
79
|
+
const { renderChain } = await import("./lib/plan.mjs");
|
|
80
|
+
const chainText = renderChain(data.chain);
|
|
81
|
+
if (chainText !== "") process.stdout.write(`${chainText}\n\n`);
|
|
82
|
+
for (const w of data.walks) process.stdout.write(`${renderCard(w, data)}\n\n`);
|
|
47
83
|
for (const a of data.arrivals)
|
|
48
84
|
process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
|
|
49
85
|
}
|