create-cmp-cli 0.18.0 → 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/src/approve.mjs +7 -0
- package/packages/harness/src/lib/approvals.mjs +32 -6
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +1 -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 +286 -20
- 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 +66 -16
- package/packages/harness/src/receipt-check.mjs +56 -3
- package/packages/harness/src/verify.mjs +115 -1197
- package/packages/receipts/src/inputs-hash.mjs +1 -0
- package/packages/receipts/src/receipt-validate.mjs +4 -1
- package/template/CLAUDE.md +44 -6
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +7 -0
- package/template/qa/lib/approvals.mjs +32 -6
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +1 -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 +286 -20
- 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 +66 -16
- package/template/qa/receipt-check.mjs +56 -3
- package/template/qa/verify.mjs +115 -1197
- package/template/specs/README.md +26 -0
package/template/qa/lib/plan.mjs
CHANGED
|
@@ -33,6 +33,13 @@ import path from "node:path";
|
|
|
33
33
|
|
|
34
34
|
export const PLAN_REL = "qa/.plan.json";
|
|
35
35
|
export const REQUEST_REL = "qa/.request.json";
|
|
36
|
+
// N5 (docs/features/drive-narration.md): closed chains leave a LOCAL trail —
|
|
37
|
+
// request, steps, wall time, receipt state at close. Gitignored and excluded
|
|
38
|
+
// from the hashed input surface like its siblings above, and deliberately NOT
|
|
39
|
+
// a committed journal: it carries raw human prompts. Lane history that
|
|
40
|
+
// belongs in the repo stays the flight recorder's.
|
|
41
|
+
export const PLAN_HISTORY_REL = "qa/.plan-history.jsonl";
|
|
42
|
+
const MAX_HISTORY_LINES = 50;
|
|
36
43
|
|
|
37
44
|
// A marker older than this is a crashed writer, not a live run — the same
|
|
38
45
|
// bound qa/watch.mjs and the preview daemon apply to the same files.
|
|
@@ -91,12 +98,17 @@ export function setPlan(root, { title, feature, steps } = {}) {
|
|
|
91
98
|
.slice(0, MAX_STEPS)
|
|
92
99
|
.map((s) => (s.length > MAX_LABEL_CHARS ? `${s.slice(0, MAX_LABEL_CHARS - 1)}…` : s));
|
|
93
100
|
if (labels.length === 0) return { ok: false, reason: "a chain needs at least one step" };
|
|
101
|
+
// N1: the declaration's own write times ARE the timing data — createdAt for
|
|
102
|
+
// the whole chain, startedAt on step 1. No new claims, just timestamps the
|
|
103
|
+
// writes already imply; renderers derive durations from them.
|
|
104
|
+
const now = new Date().toISOString();
|
|
94
105
|
return writeJson(path.join(root, PLAN_REL), {
|
|
95
106
|
title: typeof title === "string" && title.trim() !== "" ? title.trim() : null,
|
|
96
107
|
feature: typeof feature === "string" && feature.trim() !== "" ? feature.trim() : null,
|
|
97
|
-
steps: labels.map((label, i) => ({ n: i + 1, label, done: false })),
|
|
108
|
+
steps: labels.map((label, i) => ({ n: i + 1, label, done: false, ...(i === 0 ? { startedAt: now } : {}) })),
|
|
98
109
|
current: 1,
|
|
99
|
-
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
100
112
|
});
|
|
101
113
|
}
|
|
102
114
|
|
|
@@ -112,10 +124,90 @@ export function markStep(root, n) {
|
|
|
112
124
|
const step = Number(n);
|
|
113
125
|
if (!Number.isInteger(step) || step < 1 || step > plan.steps.length + 1)
|
|
114
126
|
return { ok: false, reason: `step must be 1..${plan.steps.length + 1} (=${plan.steps.length + 1} closes the chain), got ${n}` };
|
|
115
|
-
|
|
127
|
+
const now = new Date().toISOString();
|
|
128
|
+
for (const s of plan.steps) {
|
|
129
|
+
const willBeDone = s.n < step;
|
|
130
|
+
// N1: stamp doneAt the first time a step closes and startedAt the first
|
|
131
|
+
// time it becomes current — first-write-wins, so re-marking never
|
|
132
|
+
// rewrites history.
|
|
133
|
+
if (willBeDone && !s.done && !s.doneAt) s.doneAt = now;
|
|
134
|
+
s.done = willBeDone;
|
|
135
|
+
if (s.n === step && !s.startedAt) s.startedAt = now;
|
|
136
|
+
}
|
|
137
|
+
const closing = step > plan.steps.length && !plan.closedAt;
|
|
116
138
|
plan.current = step > plan.steps.length ? null : step;
|
|
117
|
-
plan.
|
|
118
|
-
|
|
139
|
+
if (closing) plan.closedAt = now;
|
|
140
|
+
plan.updatedAt = now;
|
|
141
|
+
if (!writeJson(path.join(root, PLAN_REL), plan).ok) return { ok: false, reason: "could not write the chain" };
|
|
142
|
+
// N5: the FIRST close leaves the trail entry; a re-close of an already
|
|
143
|
+
// closed chain never double-writes. Fail-soft — a trail that cannot be
|
|
144
|
+
// written must not fail the advance that was asked for.
|
|
145
|
+
if (closing) appendPlanHistory(root, plan);
|
|
146
|
+
return { ok: true, plan };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The receipt's verdict + rung right now, for the trail — fail-soft glance. */
|
|
150
|
+
function receiptGlance(root) {
|
|
151
|
+
try {
|
|
152
|
+
const r = JSON.parse(fs.readFileSync(path.join(root, "qa/evidence/latest.json"), "utf8"));
|
|
153
|
+
return { verdict: r?.verdict ?? null, rung: r?.evidenceLevel?.rung ?? null };
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function appendPlanHistory(root, plan) {
|
|
160
|
+
try {
|
|
161
|
+
const started = Date.parse(plan.createdAt ?? "");
|
|
162
|
+
const closed = Date.parse(plan.closedAt ?? "");
|
|
163
|
+
const entry = {
|
|
164
|
+
schema: "cmp-plan-history/1",
|
|
165
|
+
at: plan.closedAt ?? new Date().toISOString(),
|
|
166
|
+
request: readRequest(root)?.text ?? null,
|
|
167
|
+
title: plan.title ?? null,
|
|
168
|
+
feature: plan.feature ?? null,
|
|
169
|
+
steps: plan.steps.map((s) => s.label),
|
|
170
|
+
durationMs: Number.isNaN(started) || Number.isNaN(closed) ? null : Math.max(0, closed - started),
|
|
171
|
+
receipt: receiptGlance(root),
|
|
172
|
+
};
|
|
173
|
+
const p = path.join(root, PLAN_HISTORY_REL);
|
|
174
|
+
let lines = [];
|
|
175
|
+
try {
|
|
176
|
+
lines = fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
177
|
+
} catch {
|
|
178
|
+
/* first entry */
|
|
179
|
+
}
|
|
180
|
+
lines.push(JSON.stringify(entry));
|
|
181
|
+
fs.writeFileSync(p, `${lines.slice(-MAX_HISTORY_LINES).join("\n")}\n`);
|
|
182
|
+
return { ok: true };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The last `limit` closed chains, NEWEST FIRST — Drive's "Recent requests"
|
|
190
|
+
* fold. Absent trail or unparsable lines read as an empty/shorter list,
|
|
191
|
+
* never an error.
|
|
192
|
+
* @returns {object[]}
|
|
193
|
+
*/
|
|
194
|
+
export function readPlanHistory(root, limit = 5) {
|
|
195
|
+
try {
|
|
196
|
+
const raw = fs.readFileSync(path.join(root, PLAN_HISTORY_REL), "utf8");
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const line of raw.split("\n")) {
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
try {
|
|
201
|
+
const e = JSON.parse(line);
|
|
202
|
+
if (e && typeof e === "object") out.push(e);
|
|
203
|
+
} catch {
|
|
204
|
+
/* skip the line, keep the trail */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out.slice(-Math.max(0, limit)).reverse();
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
119
211
|
}
|
|
120
212
|
|
|
121
213
|
/** @returns {object|null} the declared chain, or null. */
|
|
@@ -134,33 +226,130 @@ export function clearPlan(root) {
|
|
|
134
226
|
}
|
|
135
227
|
}
|
|
136
228
|
|
|
137
|
-
|
|
229
|
+
/**
|
|
230
|
+
* A marker read WITH its content (N2, docs/features/drive-narration.md):
|
|
231
|
+
* every other marker consumer is mtime-only, so the content is free to carry
|
|
232
|
+
* the lane's own narration — verify.mjs rewrites the lane marker at each
|
|
233
|
+
* step start with {step, index, total, stepStartedAt, expectedStepMs,
|
|
234
|
+
* expectedLaneMs}. Legacy "pid iso" content (older lanes, the render marker)
|
|
235
|
+
* reads as a bare truthy {} — busy, no narration. Stale/absent -> false.
|
|
236
|
+
* @returns {object|false}
|
|
237
|
+
*/
|
|
238
|
+
function markerInfo(root, name) {
|
|
239
|
+
const p = path.join(root, "composeApp", "build", name);
|
|
138
240
|
try {
|
|
139
|
-
const st = fs.statSync(
|
|
140
|
-
|
|
241
|
+
const st = fs.statSync(p);
|
|
242
|
+
if (Date.now() - st.mtimeMs >= MARKER_FRESH_MS) return false;
|
|
243
|
+
const raw = fs.readFileSync(p, "utf8").trim();
|
|
244
|
+
if (raw.startsWith("{")) {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(raw);
|
|
247
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
248
|
+
} catch {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {};
|
|
141
253
|
} catch {
|
|
142
254
|
return false;
|
|
143
255
|
}
|
|
144
256
|
}
|
|
145
257
|
|
|
258
|
+
// The build stage's observed tier (evidence-economics S3). Between the prompt
|
|
259
|
+
// and the lane — most of the working time — the chain moved only if the agent
|
|
260
|
+
// volunteered `plan.mjs --step`, so an undeclared chain was a still photo until
|
|
261
|
+
// the lane landed. The lane marker already corroborates the lane mechanically;
|
|
262
|
+
// this corroborates the build stage the same way: writes in the working tree
|
|
263
|
+
// since the current request began. No agent cooperation required — which is
|
|
264
|
+
// the point.
|
|
265
|
+
const ACTIVITY_ROOTS = ["composeApp/src", "specs", "qa", "docs"];
|
|
266
|
+
const ACTIVITY_SKIP_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules", "evidence"]);
|
|
267
|
+
// Machinery, not work: the chain's own files and the lane's outputs must not
|
|
268
|
+
// count as "the agent wrote something", or the pulse would corroborate itself.
|
|
269
|
+
const ACTIVITY_SKIP_FILES = new Set([".plan.json", ".request.json", ".plan-history.jsonl", "flight-recorder.jsonl", "approvals.log.jsonl", ".DS_Store"]);
|
|
270
|
+
// Nothing written for this long, with no lane or render running, is a stall
|
|
271
|
+
// worth naming — the human is watching a strip that has stopped moving.
|
|
272
|
+
export const ACTIVITY_STALL_MS = 10 * 60 * 1000;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Files written under the working roots since `sinceIso` — the request stamp.
|
|
276
|
+
* Pure filesystem, no git: a scaffold before `git init` still answers, and a
|
|
277
|
+
* mtime is a fact regardless of what is staged.
|
|
278
|
+
* @param {string} root
|
|
279
|
+
* @param {string|null|undefined} sinceIso the request's `at`
|
|
280
|
+
* @param {{now?: number}} [opts]
|
|
281
|
+
* @returns {{filesChanged: number, lastWriteAgoMs: (number|null), since: string}|null}
|
|
282
|
+
* null when there is no request to measure from
|
|
283
|
+
*/
|
|
284
|
+
export function observeActivity(root, sinceIso, { now = Date.now() } = {}) {
|
|
285
|
+
const since = Date.parse(sinceIso ?? "");
|
|
286
|
+
if (Number.isNaN(since)) return null;
|
|
287
|
+
let filesChanged = 0;
|
|
288
|
+
let newest = -Infinity;
|
|
289
|
+
const walk = (dir) => {
|
|
290
|
+
let entries;
|
|
291
|
+
try {
|
|
292
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
293
|
+
} catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const e of entries) {
|
|
297
|
+
if (e.isDirectory()) {
|
|
298
|
+
if (!ACTIVITY_SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name));
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!e.isFile() || ACTIVITY_SKIP_FILES.has(e.name)) continue;
|
|
302
|
+
let m;
|
|
303
|
+
try {
|
|
304
|
+
m = fs.statSync(path.join(dir, e.name)).mtimeMs;
|
|
305
|
+
} catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (m > since) {
|
|
309
|
+
filesChanged += 1;
|
|
310
|
+
if (m > newest) newest = m;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
for (const rel of ACTIVITY_ROOTS) walk(path.join(root, rel));
|
|
315
|
+
return {
|
|
316
|
+
filesChanged,
|
|
317
|
+
lastWriteAgoMs: filesChanged > 0 ? Math.max(0, now - newest) : null,
|
|
318
|
+
since: new Date(since).toISOString(),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
146
322
|
/**
|
|
147
323
|
* Everything a chain-rendering surface needs, with provenance attached:
|
|
148
324
|
* request (tier 1) + plan with its age (tier 2) + what is ACTUALLY running
|
|
149
|
-
* (tier 3 — the markers the lane and preview daemon already stamp
|
|
325
|
+
* (tier 3 — the markers the lane and preview daemon already stamp, the lane's
|
|
326
|
+
* now carrying its own step narration) + the local trail of closed chains
|
|
327
|
+
* (N5). `busy.lane`/`busy.render` are truthy objects while fresh — existing
|
|
328
|
+
* truthiness consumers keep working unchanged.
|
|
150
329
|
* @returns {{request: (object|null), plan: (object|null), planAgeMs: (number|null),
|
|
151
|
-
* busy: {lane:
|
|
330
|
+
* busy: {lane: (object|false), render: (object|false)}, history: object[]}}
|
|
152
331
|
*/
|
|
153
332
|
export function deriveChain(root) {
|
|
154
333
|
const plan = readPlan(root);
|
|
155
334
|
const at = plan ? Date.parse(plan.updatedAt) : NaN;
|
|
335
|
+
const busy = {
|
|
336
|
+
lane: markerInfo(root, ".cmp-lane-in-progress"),
|
|
337
|
+
render: markerInfo(root, ".cmp-render-in-progress"),
|
|
338
|
+
};
|
|
339
|
+
const request = readRequest(root);
|
|
340
|
+
// S3: the build stage's observed tier — writes since the request began.
|
|
341
|
+
const activity = observeActivity(root, request ? request.at : null);
|
|
156
342
|
return {
|
|
157
|
-
request
|
|
343
|
+
request,
|
|
158
344
|
plan,
|
|
159
345
|
planAgeMs: Number.isNaN(at) ? null : Math.max(0, Date.now() - at),
|
|
160
|
-
busy
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
346
|
+
busy,
|
|
347
|
+
activity,
|
|
348
|
+
// Pre-rendered so every surface (chat, CLI, studio) speaks the observed
|
|
349
|
+
// tier in identical words — the console renders this string, never its
|
|
350
|
+
// own paraphrase of the marker.
|
|
351
|
+
busyText: describeBusy(busy, Date.now(), activity),
|
|
352
|
+
history: readPlanHistory(root, 5),
|
|
164
353
|
};
|
|
165
354
|
}
|
|
166
355
|
|
|
@@ -172,29 +361,106 @@ export function formatAge(ms) {
|
|
|
172
361
|
return `${Math.round(ms / 3600000)}h ago`;
|
|
173
362
|
}
|
|
174
363
|
|
|
364
|
+
/** "12s" / "~3 min" — a plain duration (formatAge's sibling, no "ago"). */
|
|
365
|
+
export function formatDuration(ms) {
|
|
366
|
+
if (!(ms >= 0)) return "";
|
|
367
|
+
if (ms < 120000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
368
|
+
return `~${Math.round(ms / 60000)} min`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A done step's wall time from its own N1 stamps, or null pre-N1. */
|
|
372
|
+
function stepDurationMs(s) {
|
|
373
|
+
const a = Date.parse(s.startedAt ?? "");
|
|
374
|
+
const b = Date.parse(s.doneAt ?? "");
|
|
375
|
+
return Number.isNaN(a) || Number.isNaN(b) ? null : Math.max(0, b - a);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The tier-3 corroboration as one phrase (N2): the lane's own narration when
|
|
380
|
+
* the marker carries it ("full check — unitTests (10/16) · 12s of ~3s,
|
|
381
|
+
* usually ~52s total"), the legacy phrase when it does not. "" when nothing
|
|
382
|
+
* is running. Shared by the text and HTML renderers so the observed tier
|
|
383
|
+
* speaks identically everywhere.
|
|
384
|
+
*/
|
|
385
|
+
export function describeBusy(busy, now = Date.now(), activity = null) {
|
|
386
|
+
if (!busy) return describeActivity(activity);
|
|
387
|
+
const lane = busy.lane;
|
|
388
|
+
if (lane) {
|
|
389
|
+
if (typeof lane === "object" && typeof lane.step === "string" && lane.step !== "") {
|
|
390
|
+
const pos = Number.isInteger(lane.index) && Number.isInteger(lane.total) ? ` (${lane.index}/${lane.total})` : "";
|
|
391
|
+
const started = Date.parse(lane.stepStartedAt ?? "");
|
|
392
|
+
const elapsed = Number.isNaN(started) ? null : Math.max(0, now - started);
|
|
393
|
+
const stepExpect = typeof lane.expectedStepMs === "number" && lane.expectedStepMs > 0 ? ` of ~${formatDuration(lane.expectedStepMs)}` : "";
|
|
394
|
+
const laneExpect =
|
|
395
|
+
typeof lane.expectedLaneMs === "number" && lane.expectedLaneMs > 0 ? `, usually ${formatDuration(lane.expectedLaneMs)} total` : "";
|
|
396
|
+
return `full check — ${lane.step}${pos}${elapsed !== null ? ` · ${formatDuration(elapsed)}${stepExpect}` : ""}${laneExpect}`;
|
|
397
|
+
}
|
|
398
|
+
return "the full check is running NOW";
|
|
399
|
+
}
|
|
400
|
+
if (busy.render) return "a preview render is in flight";
|
|
401
|
+
// Nothing mechanical is running — but the working tree may still be moving.
|
|
402
|
+
return describeActivity(activity);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The build stage's phrase (S3). Files written since the request, and how
|
|
407
|
+
* long ago the last one landed; a stall when the tree has stopped moving.
|
|
408
|
+
* "" when there is no request to measure from, or nothing has happened yet.
|
|
409
|
+
*/
|
|
410
|
+
export function describeActivity(activity) {
|
|
411
|
+
if (!activity) return "";
|
|
412
|
+
if (activity.filesChanged === 0) return "";
|
|
413
|
+
const n = activity.filesChanged;
|
|
414
|
+
const ago = activity.lastWriteAgoMs;
|
|
415
|
+
const files = `${n} file${n === 1 ? "" : "s"} written since the request`;
|
|
416
|
+
if (typeof ago === "number" && ago >= ACTIVITY_STALL_MS) return `${files} · stalled — nothing written for ${formatDuration(ago)}`;
|
|
417
|
+
return typeof ago === "number" ? `${files} · last ${formatDuration(ago)} ago` : files;
|
|
418
|
+
}
|
|
419
|
+
|
|
175
420
|
/**
|
|
176
421
|
* The chain as one text block — the CLI's and the inject's rendering.
|
|
177
|
-
* Numbered steps: done
|
|
178
|
-
*
|
|
179
|
-
*
|
|
422
|
+
* Numbered steps: done ✓ with wall time, current ◉ with elapsed, pending ○
|
|
423
|
+
* (N1); the tier-3 corroboration is prefixed "observed:" so the machine's
|
|
424
|
+
* word is visibly distinct from the agent's declaration (N3). "" when
|
|
425
|
+
* nothing is declared AND no request is recorded (silence, never an empty
|
|
426
|
+
* frame).
|
|
180
427
|
*/
|
|
181
428
|
export function renderChain(chain) {
|
|
182
429
|
if (!chain || (!chain.plan && !chain.request)) return "";
|
|
430
|
+
const now = Date.now();
|
|
183
431
|
const lines = [];
|
|
184
432
|
const title = chain.plan?.title ?? chain.request?.text ?? null;
|
|
185
433
|
if (title) lines.push(`Request: ${title}`);
|
|
186
434
|
if (chain.plan) {
|
|
187
435
|
const p = chain.plan;
|
|
188
436
|
const seq = p.steps
|
|
189
|
-
.map((s) =>
|
|
437
|
+
.map((s) => {
|
|
438
|
+
if (s.done) {
|
|
439
|
+
const d = stepDurationMs(s);
|
|
440
|
+
return `✓ ${s.n}. ${s.label}${d !== null ? ` (${formatDuration(d)})` : ""}`;
|
|
441
|
+
}
|
|
442
|
+
if (s.n === p.current) {
|
|
443
|
+
const a = Date.parse(s.startedAt ?? "");
|
|
444
|
+
return `◉ ${s.n}. ${s.label}${Number.isNaN(a) ? "" : ` · ${formatDuration(Math.max(0, now - a))} in`}`;
|
|
445
|
+
}
|
|
446
|
+
return `○ ${s.n}. ${s.label}`;
|
|
447
|
+
})
|
|
190
448
|
.join(" → ");
|
|
191
449
|
lines.push(seq);
|
|
192
450
|
const cur = p.steps.find((s) => s.n === p.current) ?? null;
|
|
193
|
-
const
|
|
451
|
+
const busyText = typeof chain.busyText === "string" ? chain.busyText : describeBusy(chain.busy, now, chain.activity ?? null);
|
|
452
|
+
const busy = busyText !== "" ? ` · observed: ${busyText}` : "";
|
|
194
453
|
const age = chain.planAgeMs !== null ? ` · declared by the agent, updated ${formatAge(chain.planAgeMs)}` : "";
|
|
195
454
|
lines.push(cur ? `now: step ${cur.n} of ${p.steps.length} — ${cur.label}${busy}${age}` : `chain complete${busy}${age}`);
|
|
196
455
|
} else {
|
|
197
456
|
lines.push("(no declared chain for this request yet — node qa/plan.mjs --set \"step | step | …\")");
|
|
457
|
+
// S3: an undeclared chain is no longer a still photo. The observed tier —
|
|
458
|
+
// a running lane, a render, or writes since the request — is printed even
|
|
459
|
+
// when the agent declared nothing, because it is the machine's word and
|
|
460
|
+
// needs no declaration to exist. This is the case that used to show nothing
|
|
461
|
+
// for forty minutes.
|
|
462
|
+
const observed = typeof chain.busyText === "string" ? chain.busyText : describeBusy(chain.busy, Date.now(), chain.activity ?? null);
|
|
463
|
+
if (observed !== "") lines.push(`observed: ${observed}`);
|
|
198
464
|
}
|
|
199
465
|
return lines.join("\n");
|
|
200
466
|
}
|
|
@@ -134,7 +134,10 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
|
|
|
134
134
|
if (!steps || steps.length === 0) {
|
|
135
135
|
return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
|
|
136
136
|
}
|
|
137
|
-
|
|
137
|
+
// Executed = produced a verdict about the tree. SKIP did not try; ERROR
|
|
138
|
+
// tried and could not (a deadline, zero tests, a throw) — neither measured
|
|
139
|
+
// anything, so neither counts toward "this lane verified something".
|
|
140
|
+
const executed = steps.filter((s) => s && s.verdict !== "SKIP" && s.verdict !== "ERROR");
|
|
138
141
|
if (executed.length === 0) {
|
|
139
142
|
return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
|
|
140
143
|
}
|
|
@@ -14,6 +14,20 @@ import path from "node:path";
|
|
|
14
14
|
|
|
15
15
|
/** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
|
|
16
16
|
export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
|
|
17
|
+
// An OPTIONAL tier requirement on the clause line itself:
|
|
18
|
+
//
|
|
19
|
+
// - **MOTION-13** [tier: device] — Given a cold start, When … Then …
|
|
20
|
+
//
|
|
21
|
+
// The clause declares what it takes to OBSERVE it, which is a property of the
|
|
22
|
+
// promise, not of whatever test happened to cite it. Note this attaches to the
|
|
23
|
+
// clause line, not to `[enforced: …]` — that tags docs/ARCHITECTURE.md prose and
|
|
24
|
+
// is a different grammar entirely.
|
|
25
|
+
const CLAUSE_TIER_RE = /\[tier:\s*(device|e2e)\]/i;
|
|
26
|
+
/** Which citing tiers satisfy a declared requirement. */
|
|
27
|
+
export const TIERS_SATISFYING = Object.freeze({
|
|
28
|
+
device: ["androidInstrumentedTest", "e2e"],
|
|
29
|
+
e2e: ["e2e"],
|
|
30
|
+
});
|
|
17
31
|
|
|
18
32
|
const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
|
|
19
33
|
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
@@ -45,7 +59,12 @@ export function scanSpecClauses(root) {
|
|
|
45
59
|
for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
|
|
46
60
|
const m = line.match(CLAUSE_LINE_RE);
|
|
47
61
|
if (!m) continue;
|
|
48
|
-
|
|
62
|
+
const tierMatch = line.match(CLAUSE_TIER_RE);
|
|
63
|
+
clauses.set(m[2], {
|
|
64
|
+
file: path.relative(root, abs),
|
|
65
|
+
withdrawn: Boolean(m[1]),
|
|
66
|
+
requiredTier: tierMatch ? tierMatch[1].toLowerCase() : null,
|
|
67
|
+
});
|
|
49
68
|
}
|
|
50
69
|
}
|
|
51
70
|
return clauses;
|
|
@@ -108,6 +127,9 @@ export function scanCitations(root) {
|
|
|
108
127
|
* (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
|
|
109
128
|
* `summaryLine` is the one line the lane's specCoverage step (and any other
|
|
110
129
|
* consumer) can print verbatim; null when nothing is desktop-only.
|
|
130
|
+
* `unmetTier` is the PRESCRIPTIVE half — clauses that declared `[tier: …]` and
|
|
131
|
+
* have no citation from a tier that could observe them. specCoverage FAILS on it:
|
|
132
|
+
* "instrument before you police" was the right first move, and this is the second.
|
|
111
133
|
* @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
|
|
112
134
|
* @param {Array<{id: string, tier: string}>} tags from scanCitations
|
|
113
135
|
* @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
|
|
@@ -117,6 +139,17 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
117
139
|
for (const t of tags) {
|
|
118
140
|
(tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
|
|
119
141
|
}
|
|
142
|
+
// The gate input. A clause that DECLARED the tier it needs and has no citation
|
|
143
|
+
// from that tier is not covered — it is cited by tests structurally incapable
|
|
144
|
+
// of observing it, which is the exact hole `desktopOnly` below could only ever
|
|
145
|
+
// describe. MOTION-13 promised an animation "plays once per process start" and
|
|
146
|
+
// was cited by a desktop Compose test, a tier with no process lifecycle at all:
|
|
147
|
+
// the citation existed, the gate went green, and nothing ever observed the
|
|
148
|
+
// promise. Declared requirements are checked; undeclared clauses are unchanged.
|
|
149
|
+
const unmetTier = [...clauses.entries()]
|
|
150
|
+
.filter(([, c]) => !c.withdrawn && c.requiredTier)
|
|
151
|
+
.map(([id, c]) => ({ id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file }))
|
|
152
|
+
.filter((u) => !(TIERS_SATISFYING[u.requiredTier] ?? []).some((t) => u.tiers.includes(t)));
|
|
120
153
|
const desktopOnly = [...clauses.entries()]
|
|
121
154
|
.filter(([, c]) => !c.withdrawn)
|
|
122
155
|
.map(([id]) => id)
|
|
@@ -127,5 +160,5 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
127
160
|
const summaryLine = desktopOnly.length
|
|
128
161
|
? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
|
|
129
162
|
: null;
|
|
130
|
-
return { tiersByClause, desktopOnly, summaryLine };
|
|
163
|
+
return { tiersByClause, desktopOnly, unmetTier, summaryLine };
|
|
131
164
|
}
|
|
@@ -138,7 +138,7 @@ export function loadStepCache(root) {
|
|
|
138
138
|
export function lookupCachedPass(root, stepName, inputsHash) {
|
|
139
139
|
const entry = loadStepCache(root).steps[stepName];
|
|
140
140
|
if (!entry || typeof entry !== "object") return null;
|
|
141
|
-
if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
|
|
141
|
+
if (entry.verdict !== "PASS") return null; // FAIL/SKIP/ERROR are never reused
|
|
142
142
|
if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
|
|
143
143
|
if (typeof entry.at !== "string") return null;
|
|
144
144
|
return entry;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// step-outcomes.mjs — a step's VERDICT, separated from its INVOCATION.
|
|
2
|
+
//
|
|
3
|
+
// A step that ran zero tests knows nothing about behaviour and must not speak
|
|
4
|
+
// as though it does. Observed 2026-09-02 (create-cmp-showcase): a concurrent
|
|
5
|
+
// adb session collided with androidChecks, Gradle exited non-zero having
|
|
6
|
+
// executed no tests, and the step reported "an on-device behavior claim is
|
|
7
|
+
// broken. Fix the behavior, not the test." The identical task passed 8 tests
|
|
8
|
+
// moments later. Believed, that sends the reader hunting a defect that does not
|
|
9
|
+
// exist; disbelieved once, it teaches them to discount every future red from
|
|
10
|
+
// the step — a gate that misattributes its own failures corrodes the gates that
|
|
11
|
+
// are right.
|
|
12
|
+
//
|
|
13
|
+
// Pure, so the wording and the rule are testable without Gradle or a device.
|
|
14
|
+
// (docs/proposals/evidence-economics.md C3, S4.)
|
|
15
|
+
//
|
|
16
|
+
// FOUR VERDICTS. PASS / FAIL / SKIP had no way to say "I could not run": a
|
|
17
|
+
// step whose infrastructure broke reported a behaviour failure. ERROR is that
|
|
18
|
+
// fourth word — zero tests executed, a deadline passed, a tool vanished, a
|
|
19
|
+
// step threw. An ERROR never accuses the change, never counts as evidence
|
|
20
|
+
// (evidence-level derives no rung over it; the plausibility check does not
|
|
21
|
+
// count it as executed), is visibly distinct from FAIL (⊘, not ✗), and is
|
|
22
|
+
// never silently retried. It still makes the lane FAIL — "could not check" is
|
|
23
|
+
// not green. This is JUnit's error-vs-failure, Bazel's FAILED_TO_BUILD /
|
|
24
|
+
// TIMEOUT vs FAILED, pytest's error vs failed — the distinction every mature
|
|
25
|
+
// runner makes and this one did not.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The androidChecks outcome from Gradle's exit and the JUnit summary.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ok: boolean, out: string}} res the Gradle invocation
|
|
31
|
+
* @param {{tests: number, failures: number, errors: number}|null} summary parsed JUnit
|
|
32
|
+
* results, or null when none were written
|
|
33
|
+
* @param {{gradlew?: string}} [opts]
|
|
34
|
+
* @returns {{verdict: "PASS"|"FAIL"|"ERROR", executed: boolean, reason?: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function androidChecksOutcome(res, summary, { gradlew = "./gradlew" } = {}) {
|
|
37
|
+
const executed = Boolean(summary && summary.tests > 0);
|
|
38
|
+
if (res.ok) return { verdict: "PASS", executed };
|
|
39
|
+
const tail = String(res.out ?? "")
|
|
40
|
+
.split("\n")
|
|
41
|
+
.filter((l) => /FAILED|error:|failed/i.test(l))
|
|
42
|
+
.slice(0, 12)
|
|
43
|
+
.join("\n");
|
|
44
|
+
if (executed) {
|
|
45
|
+
return {
|
|
46
|
+
verdict: "FAIL",
|
|
47
|
+
executed,
|
|
48
|
+
reason:
|
|
49
|
+
`connectedDebugAndroidTest failed (${summary.failures + summary.errors} of ${summary.tests} tests) — ` +
|
|
50
|
+
`an on-device behavior claim is broken. Fix the behavior, not the test:\n${tail}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// ERROR, not FAIL: the step could not execute. A device tier that could not
|
|
54
|
+
// run is not evidence (the lane still FAILs), and going green would be the
|
|
55
|
+
// worse lie — but "your behaviour is broken" is withdrawn, and the receipt
|
|
56
|
+
// can tell a red that measured something from a red that measured nothing.
|
|
57
|
+
return {
|
|
58
|
+
verdict: "ERROR",
|
|
59
|
+
executed,
|
|
60
|
+
reason:
|
|
61
|
+
"connectedDebugAndroidTest DID NOT EXECUTE — the run reported no tests at all, so this step has observed " +
|
|
62
|
+
"nothing about your change and is not accusing it. Usual cause: another adb/Gradle session touching the same " +
|
|
63
|
+
"device (a manual `adb` command, a second lane, a running preview), or an install that never landed. " +
|
|
64
|
+
`Re-run this step alone with nothing else on the device before suspecting the code:\n ${gradlew} :composeApp:connectedDebugAndroidTest --rerun\n${tail}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Thrown by the lane's subprocess helper when a step's deadline passes. */
|
|
69
|
+
export class StepTimeout extends Error {
|
|
70
|
+
constructor(cmd, deadlineMs) {
|
|
71
|
+
super(`deadline of ${Math.round(deadlineMs / 60000)} min passed: ${cmd}`);
|
|
72
|
+
this.name = "StepTimeout";
|
|
73
|
+
this.cmd = cmd;
|
|
74
|
+
this.deadlineMs = deadlineMs;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Did a spawnSync result hit its deadline? Node reports ETIMEDOUT on
|
|
80
|
+
* `error.code` and the kill signal on `signal`; either alone is enough — an
|
|
81
|
+
* older Node sets only one of them.
|
|
82
|
+
* @param {{error?: {code?: string}, signal?: string|null}} res
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function spawnTimedOut(res) {
|
|
86
|
+
if (!res) return false;
|
|
87
|
+
if (res.error && res.error.code === "ETIMEDOUT") return true;
|
|
88
|
+
return res.signal === "SIGTERM" && (res.status === null || res.status === undefined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A step's own deadline, from the journal's last measured duration for it:
|
|
93
|
+
* three times what it usually takes, never under five minutes (a cold Gradle
|
|
94
|
+
* daemon is slow, not wedged), never over thirty (past that it IS wedged).
|
|
95
|
+
* Unknown steps get the ceiling — a first run is never cut short.
|
|
96
|
+
* @param {number|null|undefined} expectedMs
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
export function stepDeadlineMs(expectedMs, { floorMs = 5 * 60_000, ceilingMs = 30 * 60_000 } = {}) {
|
|
100
|
+
if (!(expectedMs > 0)) return ceilingMs;
|
|
101
|
+
return Math.min(ceilingMs, Math.max(floorMs, Math.round(expectedMs * 3)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The step result for a step that could not run — a deadline, or any throw
|
|
106
|
+
* out of the step's own body (which used to crash the whole lane; now it is
|
|
107
|
+
* one ERROR row and the lane keeps going, because the other steps' verdicts
|
|
108
|
+
* are still worth having).
|
|
109
|
+
* @param {string} name the step's display name
|
|
110
|
+
* @param {unknown} err
|
|
111
|
+
* @param {number} durationMs
|
|
112
|
+
* @returns {{name: string, verdict: "ERROR", reason: string, durationMs: number, details: {executed: false, kind: string}}}
|
|
113
|
+
*/
|
|
114
|
+
export function stepErrorResult(name, err, durationMs) {
|
|
115
|
+
const timeout = err instanceof StepTimeout;
|
|
116
|
+
const reason = timeout
|
|
117
|
+
? `DID NOT COMPLETE — no result within its deadline (${Math.round(err.deadlineMs / 60000)} min). This step has observed nothing about your change and is not accusing it. ` +
|
|
118
|
+
`A wedged Gradle daemon or a device that stopped answering are the usual causes; check \`./gradlew --status\` and \`adb devices\`, then re-run the step alone.
|
|
119
|
+
${err.cmd}`
|
|
120
|
+
: `DID NOT RUN — the step threw before producing a verdict: ${err && err.message ? err.message : String(err)}. ` +
|
|
121
|
+
`Nothing here is a claim about your change.`;
|
|
122
|
+
return { name, verdict: "ERROR", reason, durationMs, details: { executed: false, kind: timeout ? "deadline" : "threw" } };
|
|
123
|
+
}
|