create-cmp-cli 0.17.1 → 0.19.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 +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/harness/src/approve.mjs +7 -0
- package/packages/harness/src/lib/approvals.mjs +44 -9
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/feature-brief.mjs +88 -16
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +9 -0
- package/packages/harness/src/lib/lane-narrator.mjs +97 -0
- package/packages/harness/src/lib/lane-runner.mjs +173 -0
- package/packages/harness/src/lib/plan.mjs +466 -0
- package/packages/harness/src/lib/receipt-validate.mjs +4 -1
- package/packages/harness/src/lib/spec-coverage.mjs +35 -2
- package/packages/harness/src/lib/step-cache.mjs +1 -1
- package/packages/harness/src/lib/step-outcomes.mjs +123 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1275 -0
- package/packages/harness/src/lib/walk.mjs +262 -21
- package/packages/harness/src/plan.mjs +64 -0
- package/packages/harness/src/receipt-check.mjs +59 -1
- package/packages/harness/src/verify.mjs +115 -1197
- package/packages/harness/src/walk-status.mjs +37 -1
- package/packages/receipts/src/inputs-hash.mjs +9 -0
- package/packages/receipts/src/receipt-validate.mjs +4 -1
- package/src/commands/doctor.mjs +26 -0
- package/src/lib/project-doctor.mjs +37 -0
- package/template/CLAUDE.md +73 -9
- package/template/gitignore +10 -0
- package/template/qa/approve.mjs +7 -0
- package/template/qa/lib/approvals.mjs +44 -9
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/feature-brief.mjs +88 -16
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +9 -0
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +466 -0
- package/template/qa/lib/receipt-validate.mjs +4 -1
- package/template/qa/lib/spec-coverage.mjs +35 -2
- package/template/qa/lib/step-cache.mjs +1 -1
- package/template/qa/lib/step-outcomes.mjs +123 -0
- package/template/qa/lib/steps-cmp.mjs +1275 -0
- package/template/qa/lib/walk.mjs +262 -21
- package/template/qa/plan.mjs +64 -0
- package/template/qa/receipt-check.mjs +59 -1
- package/template/qa/verify.mjs +115 -1197
- package/template/qa/walk-status.mjs +37 -1
- package/template/specs/README.md +26 -0
|
@@ -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: laneCostPhrase(lane) };
|
|
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,127 @@ 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
|
+
// EVERY recorded full run, not just the last one. A single "last run" figure
|
|
228
|
+
// is dominated by Gradle's cache state: a run that changed nothing reports the
|
|
229
|
+
// no-op cost (a 2s releaseBuild cache hit), and the operator plans a real
|
|
230
|
+
// change around it. Observed spread on one project: last 25s, median 140s,
|
|
231
|
+
// worst 558s. The distribution is the honest answer to "what will this cost";
|
|
232
|
+
// `durationMs` stays the last run so existing callers keep their meaning.
|
|
233
|
+
const full = [];
|
|
234
|
+
for (const line of text.split("\n")) {
|
|
235
|
+
if (line.trim() === "") continue;
|
|
236
|
+
let e;
|
|
237
|
+
try {
|
|
238
|
+
e = JSON.parse(line);
|
|
239
|
+
} catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0) full.push(e);
|
|
243
|
+
}
|
|
244
|
+
if (full.length === 0) return null;
|
|
245
|
+
const sorted = full.map((e) => e.durationMs).sort((a, b) => a - b);
|
|
246
|
+
const last = full[full.length - 1];
|
|
247
|
+
return {
|
|
248
|
+
durationMs: last.durationMs,
|
|
249
|
+
verdict: last.verdict ?? null,
|
|
250
|
+
runs: sorted.length,
|
|
251
|
+
// Median over an even count takes the lower of the two middles — a measured
|
|
252
|
+
// run rather than an average of two, so every figure quoted is one that
|
|
253
|
+
// actually happened.
|
|
254
|
+
medianMs: sorted[Math.floor((sorted.length - 1) / 2)],
|
|
255
|
+
maxMs: sorted[sorted.length - 1],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What a full check costs, said honestly: the last run when that is all there
|
|
261
|
+
* is, and last + typical + worst once the journal can support a spread. Never
|
|
262
|
+
* an estimate — every number here is a run that happened (walk-legibility L4).
|
|
263
|
+
* @param {{durationMs: number, medianMs?: number, maxMs?: number, runs?: number}|null} lane
|
|
264
|
+
* @returns {string|null} null when nothing was ever recorded
|
|
265
|
+
*/
|
|
266
|
+
export function laneCostPhrase(lane) {
|
|
267
|
+
if (!lane || !(lane.durationMs > 0)) return null;
|
|
268
|
+
const last = `${humanDuration(lane.durationMs)} last full run`;
|
|
269
|
+
// One or two runs is not a distribution; saying "typical" over them would be
|
|
270
|
+
// the same overclaim in a new costume.
|
|
271
|
+
if (!(lane.runs > 2) || !(lane.medianMs > 0)) return `${last} (measured)`;
|
|
272
|
+
const spread =
|
|
273
|
+
lane.maxMs > lane.medianMs ? `, typically ${humanDuration(lane.medianMs)}, worst ${humanDuration(lane.maxMs)}` : `, typically ${humanDuration(lane.medianMs)}`;
|
|
274
|
+
return `${last}${spread} (measured over ${lane.runs} full runs)`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** "98s" under two minutes, "~5 min" above — for humans deciding whether to wait. */
|
|
278
|
+
export function humanDuration(ms) {
|
|
279
|
+
if (!(ms > 0)) return "unknown";
|
|
280
|
+
return ms < 120000 ? `${Math.round(ms / 1000)}s` : `~${Math.round(ms / 60000)} min`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The studio console currently registered for `root`, from the same tmp-dir
|
|
285
|
+
* record the console itself writes — a CROSS-PACKAGE CONTRACT with the
|
|
286
|
+
* inspector's preview-service.mjs (consoleRegistryPath): sha1(resolved
|
|
287
|
+
* root).slice(0,12), `cmp-console-<key>.json` in os.tmpdir(), fields
|
|
288
|
+
* {pid, port, url, buildStale}. pid-liveness only, no HTTP — this runs inside a
|
|
289
|
+
* statusline with a <300ms budget.
|
|
290
|
+
*
|
|
291
|
+
* `buildStale` is the console's OWN verdict on itself (studio-self-renewal R5):
|
|
292
|
+
* a console serving code that is no longer the code on disk. It is carried on
|
|
293
|
+
* the record rather than recomputed here on purpose — the harness cannot hash
|
|
294
|
+
* the inspector's sources (in a scaffolded app the inspector is an npm package
|
|
295
|
+
* elsewhere), and a per-prompt hook must not make an HTTP call. The process
|
|
296
|
+
* that owns the fact publishes it; every consumer stays dumb.
|
|
297
|
+
* @returns {{url: string, buildStale: boolean} | {stale: true} | null} null = no
|
|
298
|
+
* record at all (never started, or stopped cleanly — silence, not an alarm)
|
|
299
|
+
*/
|
|
300
|
+
export function consoleState(root) {
|
|
301
|
+
try {
|
|
302
|
+
const key = crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12);
|
|
303
|
+
const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
|
|
304
|
+
if (!rec || typeof rec.pid !== "number") return null;
|
|
305
|
+
try {
|
|
306
|
+
process.kill(rec.pid, 0); // signal 0: existence probe, touches nothing
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (!(err && err.code === "EPERM")) return { stale: true }; // record left by a crashed console
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/`,
|
|
312
|
+
buildStale: rec.buildStale === true,
|
|
313
|
+
};
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
147
319
|
/**
|
|
148
320
|
* Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
|
|
149
321
|
* — governed artifacts drifted or reopened that NO open walk accounts for
|
|
@@ -167,15 +339,22 @@ export function deriveWalks(root) {
|
|
|
167
339
|
// worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
|
|
168
340
|
// the live navigation-ia walk in the statusline).
|
|
169
341
|
const journalForOrder = readJournal(root);
|
|
342
|
+
const byName = new Map(board.features.map((f) => [f.name, f]));
|
|
170
343
|
const lastActivity = (name) => {
|
|
171
|
-
const
|
|
344
|
+
const specNames = byName.get(name)?.specNames ?? [name];
|
|
345
|
+
const family = new Set([
|
|
346
|
+
`feature-brief:${name}`,
|
|
347
|
+
`feature-design:${name}`,
|
|
348
|
+
...specNames.map((n) => `feature-spec:${n}`),
|
|
349
|
+
]);
|
|
172
350
|
for (let i = journalForOrder.length - 1; i >= 0; i--) {
|
|
173
351
|
if (family.has(journalForOrder[i].artifact)) return i;
|
|
174
352
|
}
|
|
175
353
|
return -1;
|
|
176
354
|
};
|
|
355
|
+
const lane = laneTiming(root);
|
|
177
356
|
const walks = board.features
|
|
178
|
-
.map((f) => walkOfFeature(root, f))
|
|
357
|
+
.map((f) => walkOfFeature(root, f, lane))
|
|
179
358
|
.filter((w) => w.open)
|
|
180
359
|
.sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
|
|
181
360
|
|
|
@@ -187,7 +366,9 @@ export function deriveWalks(root) {
|
|
|
187
366
|
if (f.phase === "accepted") continue;
|
|
188
367
|
owned.add(`feature-brief:${f.name}`);
|
|
189
368
|
owned.add(`feature-design:${f.name}`);
|
|
190
|
-
|
|
369
|
+
// The spec side follows the brief's own pairing (L1) — a reopened spec a
|
|
370
|
+
// multi-spec walk owns is that walk's Contract stage, never an arrival.
|
|
371
|
+
for (const n of f.specNames ?? [f.name]) owned.add(`feature-spec:${n}`);
|
|
191
372
|
for (const t of f.touches) owned.add(t.id);
|
|
192
373
|
}
|
|
193
374
|
const journal = journalForOrder;
|
|
@@ -206,7 +387,13 @@ export function deriveWalks(root) {
|
|
|
206
387
|
reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
|
|
207
388
|
}));
|
|
208
389
|
|
|
209
|
-
|
|
390
|
+
// `lane` (L4) and `console` (L6) ride along so every rendering can say
|
|
391
|
+
// what a check costs and where the buttons are — both derived, never
|
|
392
|
+
// remembered, and both null-safe for projects that have neither yet.
|
|
393
|
+
// The live CHAIN (studio-drive-mode) rides along too: request + declared
|
|
394
|
+
// step plan + what is actually running. Declared state, labeled as such by
|
|
395
|
+
// every renderer; it gates nothing and the walk stays the truth.
|
|
396
|
+
return { available: true, walks, arrivals, lane, console: consoleState(root), chain: deriveChain(root) };
|
|
210
397
|
}
|
|
211
398
|
|
|
212
399
|
// ── Renderings — one grammar, four slots (D4) ────────────────────────────────
|
|
@@ -225,35 +412,51 @@ function loudest(walks) {
|
|
|
225
412
|
* The always-on one-liner (statusline). "" when there is nothing to say — an
|
|
226
413
|
* ungoverned project's statusline stays silent, never fabricated.
|
|
227
414
|
*/
|
|
228
|
-
export function renderStatusline({ available, walks, arrivals }) {
|
|
415
|
+
export function renderStatusline({ available, walks, arrivals, console: consoleRec }) {
|
|
229
416
|
if (!available || walks.length === 0) return "";
|
|
230
417
|
const w = loudest(walks);
|
|
231
418
|
const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
|
|
232
419
|
const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
|
|
233
|
-
|
|
420
|
+
// L6: the always-visible surface reports the other surface's death. A stale
|
|
421
|
+
// record means the console CRASHED (a clean stop removes it) — the failure
|
|
422
|
+
// mode was silence, and silence is the one thing this line never does.
|
|
423
|
+
const down = consoleRec && consoleRec.stale ? " · console down" : consoleRec && consoleRec.buildStale ? " · console stale" : "";
|
|
424
|
+
if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}${down}`;
|
|
234
425
|
const now =
|
|
235
426
|
w.currentStage === "build" && w.promises.total > 0
|
|
236
427
|
? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
|
|
237
428
|
: stageLabel(w);
|
|
238
|
-
return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
|
|
429
|
+
return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}${down}`;
|
|
239
430
|
}
|
|
240
431
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
432
|
+
/**
|
|
433
|
+
* One walk's full card — the CLI default and the loud stop-card's body.
|
|
434
|
+
* `ctx` carries the derivation's ride-alongs ({console, lane} from
|
|
435
|
+
* deriveWalks): with a live console, a human gate leads with the console
|
|
436
|
+
* (L5 — the product ships buttons; the CLI stays as the fallback beneath).
|
|
437
|
+
*/
|
|
438
|
+
export function renderCard(w, ctx = {}) {
|
|
243
439
|
const line = w.stages
|
|
244
|
-
.map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.
|
|
440
|
+
.map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.note ? ` (${s.note})` : ""}`)
|
|
245
441
|
.join(" ");
|
|
442
|
+
const gloss = STAGE_GLOSS[w.currentStage] ? ` — ${STAGE_GLOSS[w.currentStage]}` : "";
|
|
443
|
+
const laneNote =
|
|
444
|
+
w.currentStage === "prove" && ctx.lane ? ` (${laneCostPhrase(ctx.lane)} here)` : "";
|
|
246
445
|
const nowLine =
|
|
247
446
|
w.currentStage === "build" && w.promises.current
|
|
248
447
|
? `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}`;
|
|
448
|
+
: `Now: ${w.you.act ?? w.doneReason}${laneNote}`;
|
|
449
|
+
const consoleLine =
|
|
450
|
+
w.you.turn === "you" && ctx.console && ctx.console.url
|
|
451
|
+
? `\n→ Easiest: the studio console at ${ctx.console.url} — the row carries the button. CLI fallback below.`
|
|
452
|
+
: "";
|
|
250
453
|
const youLine =
|
|
251
454
|
w.you.turn === "you"
|
|
252
|
-
? `■ YOUR TURN: ${w.you.act}`
|
|
455
|
+
? `■ YOUR TURN: ${w.you.act}${consoleLine}`
|
|
253
456
|
: w.you.turn === "agent"
|
|
254
457
|
? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
|
|
255
458
|
: "Closed.";
|
|
256
|
-
return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
|
|
459
|
+
return `${w.name} — stage: ${stageLabel(w)}${gloss}\n${line}\n${nowLine}\n${youLine}`;
|
|
257
460
|
}
|
|
258
461
|
|
|
259
462
|
/**
|
|
@@ -261,17 +464,55 @@ export function renderCard(w) {
|
|
|
261
464
|
* standing protocol reminders, re-delivered every turn so the narration rules
|
|
262
465
|
* are decay-proof — re-told, never remembered (D5/D6).
|
|
263
466
|
*/
|
|
264
|
-
|
|
467
|
+
/**
|
|
468
|
+
* The studio's status, said plainly every prompt (studio-drive-mode: the
|
|
469
|
+
* agent ALWAYS knows whether the human's window exists, and healing it is a
|
|
470
|
+
* standing instruction, not a discovery).
|
|
471
|
+
*/
|
|
472
|
+
function studioLine(consoleRec) {
|
|
473
|
+
// Order matters: a console that is UP but drawing from old code is not
|
|
474
|
+
// healthy, and reporting it as "running" is the clean bill of health that
|
|
475
|
+
// let this go unnoticed for a whole session. It heals itself (the worker
|
|
476
|
+
// renews on quiescence), so say what is true and why it may be waiting.
|
|
477
|
+
if (consoleRec && consoleRec.url && consoleRec.buildStale)
|
|
478
|
+
return `[studio: running at ${consoleRec.url} but STALE — it is serving code older than the tree, and everything it shows was drawn by that older code. It renews itself once no render or lane is in flight; if it stays stale, say so rather than citing what it shows.]`;
|
|
479
|
+
if (consoleRec && consoleRec.url) return `[studio: running at ${consoleRec.url}]`;
|
|
480
|
+
if (consoleRec && consoleRec.stale)
|
|
481
|
+
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.]";
|
|
482
|
+
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.]";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function renderInject(data) {
|
|
486
|
+
const { available, walks, arrivals, chain } = data;
|
|
265
487
|
if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
|
|
266
488
|
const parts = [];
|
|
489
|
+
// L2 — chat is a walk surface: the reply opens with the derivation's OWN
|
|
490
|
+
// one-liner, pasted verbatim. Machinery-authored so it cannot drift, and
|
|
491
|
+
// transcript-persistent, which the statusline never is.
|
|
492
|
+
const header = renderStatusline(data);
|
|
493
|
+
if (header !== "") {
|
|
494
|
+
parts.push(`[chat header — open your reply with this exact line (verbatim, then a blank line):]\n${header}`);
|
|
495
|
+
}
|
|
496
|
+
// The studio's status is stated EVERY prompt — its absence was silent once
|
|
497
|
+
// (the walk-wiring lesson) and never gets to be silent again.
|
|
498
|
+
parts.push(studioLine(data.console));
|
|
499
|
+
// The live chain (studio-drive-mode): the current request and the declared
|
|
500
|
+
// step plan, with its age. Declared by the agent — which is exactly why it
|
|
501
|
+
// is re-shown every turn: keeping it current is part of the contract.
|
|
502
|
+
const chainText = renderChain(chain);
|
|
503
|
+
parts.push(
|
|
504
|
+
chainText !== ""
|
|
505
|
+
? `[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. If the human redirects, re-declare (\`--set\` again) — the chain is an offer they can reshape, not an announcement. A stale chain misleads the human watching the studio.]\n${chainText}`
|
|
506
|
+
: '[no chain declared. At kickoff, declare the request\'s steps and show them in your first reply AS AN OFFER — the human can reorder or redirect, and you re-declare without ceremony; work starts immediately either way: `node qa/plan.mjs --set "step | step | …" --title "<the ask, restated>"`.]',
|
|
507
|
+
);
|
|
267
508
|
if (walks.length > 0) {
|
|
268
509
|
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));
|
|
510
|
+
for (const w of walks) parts.push(renderCard(w, data));
|
|
270
511
|
}
|
|
271
512
|
for (const a of arrivals)
|
|
272
513
|
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
514
|
parts.push(
|
|
274
|
-
"Protocol:
|
|
515
|
+
"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
516
|
);
|
|
276
517
|
return parts.join("\n\n");
|
|
277
518
|
}
|
|
@@ -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
|
+
}
|
|
@@ -28,6 +28,39 @@ const args = process.argv.slice(2);
|
|
|
28
28
|
const asHook = args.includes("--hook");
|
|
29
29
|
const asJson = args.includes("--json");
|
|
30
30
|
|
|
31
|
+
// The lane's own in-flight marker (verify.mjs stamps it, rewriting it at each
|
|
32
|
+
// step start with the step name and index). Mirrors qa/watch.mjs's bound.
|
|
33
|
+
const LANE_MARKER_REL = ["composeApp", "build", ".cmp-lane-in-progress"];
|
|
34
|
+
const LANE_MARKER_STALE_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The full check running RIGHT NOW, or null. The gate must still refuse — a
|
|
38
|
+
* lane in flight has not produced a receipt yet — but it must not tell the
|
|
39
|
+
* agent to start one. Repeating "run the lane" at a session whose lane is
|
|
40
|
+
* already ten minutes into its release build is an instruction to do the wrong
|
|
41
|
+
* thing, and it fired ~8 times in one observed session.
|
|
42
|
+
* @returns {{step: string|null, index: number|null, total: number|null}|null}
|
|
43
|
+
*/
|
|
44
|
+
function laneInFlight() {
|
|
45
|
+
try {
|
|
46
|
+
const p = path.join(ROOT, ...LANE_MARKER_REL);
|
|
47
|
+
const st = fs.statSync(p);
|
|
48
|
+
if (Date.now() - st.mtimeMs >= LANE_MARKER_STALE_MS) return null;
|
|
49
|
+
// Content is a bonus, never a requirement: legacy markers hold "pid iso".
|
|
50
|
+
try {
|
|
51
|
+
const n = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
52
|
+
if (n && typeof n === "object") {
|
|
53
|
+
return { step: n.step ?? null, index: n.index ?? null, total: n.total ?? null };
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* legacy marker — its EXISTENCE is the fact that matters */
|
|
57
|
+
}
|
|
58
|
+
return { step: null, index: null, total: null };
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
function readStdinJson() {
|
|
32
65
|
try {
|
|
33
66
|
const raw = fs.readFileSync(0, "utf8");
|
|
@@ -56,6 +89,16 @@ function evaluate() {
|
|
|
56
89
|
profile: receipt.profile,
|
|
57
90
|
};
|
|
58
91
|
}
|
|
92
|
+
// A nightly receipt proves the HARNESS and the tree's invariants under a
|
|
93
|
+
// forced double-run — never a change. Refused as done-evidence for the same
|
|
94
|
+
// reason --fast is: the receipt's own stage says what it is allowed to mean.
|
|
95
|
+
if (receipt.stage === "nightly" || receipt.profile === "nightly") {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
reason: "the last verify run was the nightly stage (it proves the harness, not this change); run the change-stage lane (`node qa/verify.mjs`) before finishing",
|
|
99
|
+
profile: receipt.profile,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
59
102
|
const result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
|
|
60
103
|
// Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
|
|
61
104
|
// alongside the verdict: the rung is the receipt's own derived field, read
|
|
@@ -75,8 +118,23 @@ if (asHook) {
|
|
|
75
118
|
process.exit(0);
|
|
76
119
|
}
|
|
77
120
|
if (!result.valid) {
|
|
121
|
+
// The walk's vocabulary (walk-legibility L3): this gate IS the Prove
|
|
122
|
+
// stage refusing to close — same fact, same enforcement, words that match
|
|
123
|
+
// every other surface. The precise reason stays verbatim beneath.
|
|
124
|
+
//
|
|
125
|
+
// The REFUSAL never changes with a lane in flight — no receipt yet means
|
|
126
|
+
// not done, and that is the whole point of the gate. What changes is the
|
|
127
|
+
// INSTRUCTION: "run the lane" is wrong advice when one is already running,
|
|
128
|
+
// and a gate that tells you to do the thing you are doing trains you to
|
|
129
|
+
// stop reading it.
|
|
130
|
+
const flight = laneInFlight();
|
|
131
|
+
const act = flight
|
|
132
|
+
? `A full check is ALREADY RUNNING${flight.step ? ` (${flight.step}${flight.index && flight.total ? `, step ${flight.index} of ${flight.total}` : ""})` : ""} — ` +
|
|
133
|
+
`wait for it to finish and commit its receipt. Do NOT start a second one; two lanes fight over the same build directory.`
|
|
134
|
+
: "Run `node qa/verify.mjs` (it checks every promise and writes the receipt), " +
|
|
135
|
+
"commit the receipt, or see README §Verification enforcement to bypass.";
|
|
78
136
|
process.stderr.write(
|
|
79
|
-
|
|
137
|
+
`■ Prove — not done: the promises are not yet checked against this tree. ${result.reason}. ${act}\n`,
|
|
80
138
|
);
|
|
81
139
|
process.exit(2);
|
|
82
140
|
}
|