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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// lane-runner.mjs — the lane's step loop, as a function (evidence-economics S8a).
|
|
2
|
+
//
|
|
3
|
+
// The SPINE, separated from the STEPS. Everything a lane does around its steps —
|
|
4
|
+
// stamp the in-flight marker with the step's own narration, set the step's
|
|
5
|
+
// deadline from its measured history, keep a pulse alive while a synchronous
|
|
6
|
+
// step blocks, turn a throw or a timeout into one ERROR row and keep going,
|
|
7
|
+
// print the mark, derive the verdict — is the same for a Compose app and for
|
|
8
|
+
// a Kotlin backend. Only the steps differ. payment-blueprint re-implemented
|
|
9
|
+
// all of this by hand (2,769 lines) because it lived inside verify.mjs next
|
|
10
|
+
// to Gradle calls it could not use; this file is what it should have been
|
|
11
|
+
// able to import.
|
|
12
|
+
//
|
|
13
|
+
// PURE OF PROJECT KNOWLEDGE. No ROOT, no Gradle, no composeApp path: every
|
|
14
|
+
// project-specific fact arrives through `ctx`. The runner never reads argv.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
import { stepDeadlineMs, stepErrorResult } from "./step-outcomes.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-step expected durations from the journal's LAST FULL run — the source
|
|
24
|
+
* of the marker's "usually ~Ns" and of each step's deadline. Measured, never
|
|
25
|
+
* estimated; empty until one new-format full run exists.
|
|
26
|
+
* @param {object[]} entries parsed flight-journal entries, oldest first
|
|
27
|
+
* @returns {{byName: Map<string, number>, laneMs: (number|null)}}
|
|
28
|
+
*/
|
|
29
|
+
export function expectedDurations(entries) {
|
|
30
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
31
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
32
|
+
const e = list[i];
|
|
33
|
+
if (!e || e.mode === "fast" || !Array.isArray(e.steps)) continue;
|
|
34
|
+
const byName = new Map();
|
|
35
|
+
for (const s of e.steps) {
|
|
36
|
+
if (s && typeof s.name === "string" && typeof s.durationMs === "number" && s.durationMs > 0) byName.set(s.name, s.durationMs);
|
|
37
|
+
}
|
|
38
|
+
return { byName, laneMs: typeof e.durationMs === "number" && e.durationMs > 0 ? e.durationMs : null };
|
|
39
|
+
}
|
|
40
|
+
return { byName: new Map(), laneMs: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* "stepUnitTests" → "unitTests", "stepSpecCoverageMemo" → "specCoverage" — the
|
|
45
|
+
* name the result will carry. An anonymous step narrates as null rather than
|
|
46
|
+
* guessing. Exported so a step pack can name its steps the same way.
|
|
47
|
+
* @param {Function} fn
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
export function stepDisplayName(fn) {
|
|
51
|
+
const raw = typeof fn?.name === "string" ? fn.name.replace(/^step/, "").replace(/Memo$/, "") : "";
|
|
52
|
+
return raw === "" ? null : raw.charAt(0).toLowerCase() + raw.slice(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The lane's verdict over its rows: FAIL on any FAIL or ERROR, else PASS. CACHED counts as PASS (it IS a prior PASS). */
|
|
56
|
+
export function laneVerdict(steps) {
|
|
57
|
+
return steps.some((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR")) ? "FAIL" : "PASS";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The mark a row wears on the console: ✓ PASS · ⚡ CACHED · → SKIP · ⊘ ERROR (could not run) · ✗ FAIL. */
|
|
61
|
+
export function verdictMark(verdict) {
|
|
62
|
+
return verdict === "PASS" ? "✓" : verdict === "CACHED" ? "⚡" : verdict === "SKIP" ? "→" : verdict === "ERROR" ? "⊘" : "✗";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the steps, in order, under the lane's own discipline.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} ctx
|
|
69
|
+
* @param {Function[]} ctx.steps the step functions, each returning a result row
|
|
70
|
+
* @param {string} ctx.markerPath the in-flight marker (.cmp-lane-in-progress) — stamped
|
|
71
|
+
* before every step with {pid, at, step, index, total, stepStartedAt, expectedStepMs,
|
|
72
|
+
* expectedLaneMs}, removed when the loop ends however it ends
|
|
73
|
+
* @param {{byName: Map<string, number>, laneMs: (number|null)}} [ctx.expected] from expectedDurations
|
|
74
|
+
* @param {(ms: number) => void} [ctx.setDeadline] receives each step's deadline before it
|
|
75
|
+
* runs — the project's subprocess helper reads it (verify.mjs's sh())
|
|
76
|
+
* @param {(line: string) => void|null} [ctx.print] one line per finished step; null = silent
|
|
77
|
+
* (--json). Also gates the narrator: no print, no pulse.
|
|
78
|
+
* @param {{entry: string, root: string}|null} [ctx.narrator] the pulse process to spawn
|
|
79
|
+
* beside the loop (lane-narrator.mjs) — a separate process because the steps are
|
|
80
|
+
* synchronous and no timer in this process can fire while one runs
|
|
81
|
+
* @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate; default:
|
|
82
|
+
* stop after a FAILed "build" — nothing downstream is meaningful
|
|
83
|
+
* @param {() => void} [ctx.onFinally] runs in the finally (the project releases its device lease here)
|
|
84
|
+
* @param {number} [ctx.startedAt] the lane's start, for the marker's `at`
|
|
85
|
+
* @returns {{steps: object[], verdict: "PASS"|"FAIL", durationMs: number}}
|
|
86
|
+
*/
|
|
87
|
+
export function runLane(ctx) {
|
|
88
|
+
const {
|
|
89
|
+
steps: stepFns,
|
|
90
|
+
markerPath,
|
|
91
|
+
expected = { byName: new Map(), laneMs: null },
|
|
92
|
+
setDeadline = () => {},
|
|
93
|
+
print = null,
|
|
94
|
+
narrator = null,
|
|
95
|
+
stopAfter = (r) => r.name === "build" && r.verdict === "FAIL",
|
|
96
|
+
onFinally = () => {},
|
|
97
|
+
startedAt = Date.now(),
|
|
98
|
+
} = ctx;
|
|
99
|
+
|
|
100
|
+
const stamp = (stepFn, index, total) => {
|
|
101
|
+
try {
|
|
102
|
+
const name = stepFn ? stepDisplayName(stepFn) : null;
|
|
103
|
+
const narration = {
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
at: new Date(startedAt).toISOString(),
|
|
106
|
+
step: name,
|
|
107
|
+
index,
|
|
108
|
+
total,
|
|
109
|
+
stepStartedAt: new Date().toISOString(),
|
|
110
|
+
expectedStepMs: name !== null ? (expected.byName.get(name) ?? null) : null,
|
|
111
|
+
expectedLaneMs: expected.laneMs,
|
|
112
|
+
};
|
|
113
|
+
fs.writeFileSync(markerPath, `${JSON.stringify(narration)}\n`);
|
|
114
|
+
} catch {
|
|
115
|
+
/* the narration must never break the lane it narrates */
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
120
|
+
stamp(null, 0, stepFns.length);
|
|
121
|
+
|
|
122
|
+
let pulse = null;
|
|
123
|
+
if (print && narrator) {
|
|
124
|
+
try {
|
|
125
|
+
pulse = spawn(process.execPath, [narrator.entry, narrator.root], { stdio: ["ignore", "ignore", "inherit"] });
|
|
126
|
+
pulse.on("error", () => {});
|
|
127
|
+
} catch {
|
|
128
|
+
/* a missing pulse is a quieter lane, never a failed one */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const results = [];
|
|
133
|
+
try {
|
|
134
|
+
for (const [i, step] of stepFns.entries()) {
|
|
135
|
+
stamp(step, i + 1, stepFns.length);
|
|
136
|
+
const name = stepDisplayName(step) ?? `step${i + 1}`;
|
|
137
|
+
// S4: every step under a deadline from its own history (×3, floor 5 min,
|
|
138
|
+
// ceiling 30). A deadline or a throw is ONE ERROR row — the lane keeps
|
|
139
|
+
// going, because the other verdicts are still worth having.
|
|
140
|
+
setDeadline(stepDeadlineMs(expected.byName.get(name)));
|
|
141
|
+
const stepStarted = Date.now();
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = step();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
result = stepErrorResult(name, err, Date.now() - stepStarted);
|
|
147
|
+
}
|
|
148
|
+
results.push(result);
|
|
149
|
+
if (print) {
|
|
150
|
+
print(
|
|
151
|
+
`${verdictMark(result.verdict)} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${String(result.reason).split("\n")[0]}` : ""}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (stopAfter(result)) break;
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
if (pulse) {
|
|
158
|
+
try {
|
|
159
|
+
pulse.kill();
|
|
160
|
+
} catch {
|
|
161
|
+
/* the narrator holds nothing; a failed kill must not fail the lane */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
fs.rmSync(markerPath, { force: true });
|
|
165
|
+
try {
|
|
166
|
+
onFinally();
|
|
167
|
+
} catch {
|
|
168
|
+
/* a finalizer that throws must not hide the rows already earned */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { steps: results, verdict: laneVerdict(results), durationMs: Date.now() - startedAt };
|
|
173
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// plan.mjs — the live chain: what the CURRENT REQUEST is, which step the
|
|
2
|
+
// agent is on, and what comes next. docs/features/studio-drive-mode.md is the
|
|
3
|
+
// brief of record; this is D8's itinerary (walk-status.md) promoted from
|
|
4
|
+
// kickoff prose to a tracked object.
|
|
5
|
+
//
|
|
6
|
+
// PROVENANCE TIERS, each rendered as what it is — this is the one surface in
|
|
7
|
+
// the harness that is not purely derived, and the design is honest about it:
|
|
8
|
+
//
|
|
9
|
+
// 1. The REQUEST is machinery-owned: the UserPromptSubmit hook records the
|
|
10
|
+
// human's own prompt verbatim (walk-status --inject reads it from the
|
|
11
|
+
// hook's stdin). No agent claim involved.
|
|
12
|
+
// 2. The STEPS are agent-declared: written once at kickoff (`node
|
|
13
|
+
// qa/plan.mjs --set`), advanced as work lands (`--step N`). Every
|
|
14
|
+
// rendering carries the declaration's age — a stale plan reads as
|
|
15
|
+
// stale, never as true.
|
|
16
|
+
// 3. The CORROBORATION is derived and overrides: the lane/render markers
|
|
17
|
+
// (composeApp/build/.cmp-lane-in-progress / .cmp-render-in-progress,
|
|
18
|
+
// mtime-bounded like every other consumer) say what is ACTUALLY running
|
|
19
|
+
// right now, regardless of what was declared.
|
|
20
|
+
//
|
|
21
|
+
// THE PLAN GATES NOTHING. The walk (walk.mjs — a pure projection) stays the
|
|
22
|
+
// load-bearing truth for doneness; the chain is a windshield, not an
|
|
23
|
+
// instrument. Both live in EPHEMERAL dot-files that are excluded from the
|
|
24
|
+
// receipt's hashed input surface (qa/lib/inputs-hash.mjs EXCLUDED_PREFIXES —
|
|
25
|
+
// a request recorded on every prompt must never invalidate a receipt) and
|
|
26
|
+
// gitignored on fresh scaffolds.
|
|
27
|
+
//
|
|
28
|
+
// FAIL-SOFT EVERYWHERE: readers return null, writers return {ok:false} — a
|
|
29
|
+
// status surface never breaks the work it reports on.
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
export const PLAN_REL = "qa/.plan.json";
|
|
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;
|
|
43
|
+
|
|
44
|
+
// A marker older than this is a crashed writer, not a live run — the same
|
|
45
|
+
// bound qa/watch.mjs and the preview daemon apply to the same files.
|
|
46
|
+
const MARKER_FRESH_MS = 5 * 60 * 1000;
|
|
47
|
+
const MAX_REQUEST_CHARS = 500;
|
|
48
|
+
const MAX_STEPS = 20;
|
|
49
|
+
const MAX_LABEL_CHARS = 120;
|
|
50
|
+
|
|
51
|
+
function readJson(p) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeJson(p, value) {
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
|
|
62
|
+
return { ok: true };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record the human's latest prompt — tier 1, machinery-owned. Called by the
|
|
70
|
+
* UserPromptSubmit hook path with the hook's own `prompt` field; never by
|
|
71
|
+
* the agent with words of its own choosing.
|
|
72
|
+
*/
|
|
73
|
+
export function recordRequest(root, text) {
|
|
74
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
75
|
+
if (t === "") return { ok: false, reason: "empty prompt — nothing to record" };
|
|
76
|
+
return writeJson(path.join(root, REQUEST_REL), {
|
|
77
|
+
text: t.length > MAX_REQUEST_CHARS ? `${t.slice(0, MAX_REQUEST_CHARS - 1)}…` : t,
|
|
78
|
+
at: new Date().toISOString(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @returns {{text: string, at: string}|null} */
|
|
83
|
+
export function readRequest(root) {
|
|
84
|
+
const r = readJson(path.join(root, REQUEST_REL));
|
|
85
|
+
return r && typeof r.text === "string" ? r : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Declare the chain — tier 2, agent-declared, said so on every rendering.
|
|
90
|
+
* `title` is the agent's triage restatement of the ask (the contract already
|
|
91
|
+
* mandates one); `steps` are plain labels in order. Declaring replaces any
|
|
92
|
+
* previous chain: one request, one chain.
|
|
93
|
+
*/
|
|
94
|
+
export function setPlan(root, { title, feature, steps } = {}) {
|
|
95
|
+
const labels = (Array.isArray(steps) ? steps : [])
|
|
96
|
+
.map((s) => String(s ?? "").trim())
|
|
97
|
+
.filter((s) => s !== "")
|
|
98
|
+
.slice(0, MAX_STEPS)
|
|
99
|
+
.map((s) => (s.length > MAX_LABEL_CHARS ? `${s.slice(0, MAX_LABEL_CHARS - 1)}…` : s));
|
|
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();
|
|
105
|
+
return writeJson(path.join(root, PLAN_REL), {
|
|
106
|
+
title: typeof title === "string" && title.trim() !== "" ? title.trim() : null,
|
|
107
|
+
feature: typeof feature === "string" && feature.trim() !== "" ? feature.trim() : null,
|
|
108
|
+
steps: labels.map((label, i) => ({ n: i + 1, label, done: false, ...(i === 0 ? { startedAt: now } : {}) })),
|
|
109
|
+
current: 1,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Advance to step `n`: everything before it is done, `n` is current. `--done`
|
|
117
|
+
* (n past the end) closes the chain. Refuses without a declared chain —
|
|
118
|
+
* advancing nothing would fabricate a plan that was never stated.
|
|
119
|
+
*/
|
|
120
|
+
export function markStep(root, n) {
|
|
121
|
+
const plan = readJson(path.join(root, PLAN_REL));
|
|
122
|
+
if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0)
|
|
123
|
+
return { ok: false, reason: "no declared chain — declare one first: node qa/plan.mjs --set \"step | step | …\"" };
|
|
124
|
+
const step = Number(n);
|
|
125
|
+
if (!Number.isInteger(step) || step < 1 || step > plan.steps.length + 1)
|
|
126
|
+
return { ok: false, reason: `step must be 1..${plan.steps.length + 1} (=${plan.steps.length + 1} closes the chain), got ${n}` };
|
|
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;
|
|
138
|
+
plan.current = step > plan.steps.length ? null : step;
|
|
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
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @returns {object|null} the declared chain, or null. */
|
|
214
|
+
export function readPlan(root) {
|
|
215
|
+
const p = readJson(path.join(root, PLAN_REL));
|
|
216
|
+
return p && Array.isArray(p.steps) ? p : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Clear the chain (a landed request leaves no stale windshield behind). */
|
|
220
|
+
export function clearPlan(root) {
|
|
221
|
+
try {
|
|
222
|
+
fs.rmSync(path.join(root, PLAN_REL), { force: true });
|
|
223
|
+
return { ok: true };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
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);
|
|
240
|
+
try {
|
|
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 {};
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
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
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Everything a chain-rendering surface needs, with provenance attached:
|
|
324
|
+
* request (tier 1) + plan with its age (tier 2) + what is ACTUALLY running
|
|
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.
|
|
329
|
+
* @returns {{request: (object|null), plan: (object|null), planAgeMs: (number|null),
|
|
330
|
+
* busy: {lane: (object|false), render: (object|false)}, history: object[]}}
|
|
331
|
+
*/
|
|
332
|
+
export function deriveChain(root) {
|
|
333
|
+
const plan = readPlan(root);
|
|
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);
|
|
342
|
+
return {
|
|
343
|
+
request,
|
|
344
|
+
plan,
|
|
345
|
+
planAgeMs: Number.isNaN(at) ? null : Math.max(0, Date.now() - at),
|
|
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),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** "40s ago" / "12 min ago" — freshness a human can weigh at a glance. */
|
|
357
|
+
export function formatAge(ms) {
|
|
358
|
+
if (!(ms >= 0)) return "age unknown";
|
|
359
|
+
if (ms < 90000) return `${Math.round(ms / 1000)}s ago`;
|
|
360
|
+
if (ms < 90 * 60000) return `${Math.round(ms / 60000)} min ago`;
|
|
361
|
+
return `${Math.round(ms / 3600000)}h ago`;
|
|
362
|
+
}
|
|
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
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The chain as one text block — the CLI's and the inject's rendering.
|
|
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).
|
|
427
|
+
*/
|
|
428
|
+
export function renderChain(chain) {
|
|
429
|
+
if (!chain || (!chain.plan && !chain.request)) return "";
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const lines = [];
|
|
432
|
+
const title = chain.plan?.title ?? chain.request?.text ?? null;
|
|
433
|
+
if (title) lines.push(`Request: ${title}`);
|
|
434
|
+
if (chain.plan) {
|
|
435
|
+
const p = chain.plan;
|
|
436
|
+
const seq = p.steps
|
|
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
|
+
})
|
|
448
|
+
.join(" → ");
|
|
449
|
+
lines.push(seq);
|
|
450
|
+
const cur = p.steps.find((s) => s.n === p.current) ?? null;
|
|
451
|
+
const busyText = typeof chain.busyText === "string" ? chain.busyText : describeBusy(chain.busy, now, chain.activity ?? null);
|
|
452
|
+
const busy = busyText !== "" ? ` · observed: ${busyText}` : "";
|
|
453
|
+
const age = chain.planAgeMs !== null ? ` · declared by the agent, updated ${formatAge(chain.planAgeMs)}` : "";
|
|
454
|
+
lines.push(cur ? `now: step ${cur.n} of ${p.steps.length} — ${cur.label}${busy}${age}` : `chain complete${busy}${age}`);
|
|
455
|
+
} else {
|
|
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}`);
|
|
464
|
+
}
|
|
465
|
+
return lines.join("\n");
|
|
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
|
}
|