create-cmp-cli 0.17.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/harness/src/lib/approvals.mjs +13 -4
- package/packages/harness/src/lib/feature-brief.mjs +88 -16
- package/packages/harness/src/lib/inputs-hash.mjs +8 -0
- package/packages/harness/src/lib/plan.mjs +200 -0
- package/packages/harness/src/lib/walk.mjs +212 -21
- package/packages/harness/src/plan.mjs +64 -0
- package/packages/harness/src/receipt-check.mjs +6 -1
- package/packages/harness/src/walk-status.mjs +37 -1
- package/packages/receipts/src/inputs-hash.mjs +8 -0
- package/src/commands/doctor.mjs +26 -0
- package/src/lib/project-doctor.mjs +37 -0
- package/template/CLAUDE.md +33 -7
- package/template/gitignore +7 -0
- package/template/qa/lib/approvals.mjs +13 -4
- package/template/qa/lib/feature-brief.mjs +88 -16
- package/template/qa/lib/inputs-hash.mjs +8 -0
- package/template/qa/lib/plan.mjs +200 -0
- package/template/qa/lib/walk.mjs +212 -21
- package/template/qa/plan.mjs +64 -0
- package/template/qa/receipt-check.mjs +6 -1
- package/template/qa/walk-status.mjs +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@create-cmp/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The create-cmp verify lane — the machine-owned harness code every stamped app carries byte-identical: evidence receipts, spec coverage, approvals, conformance reporting, golden trees, a11y, and the preview/inspector libs. Dependency-free ESM, vendored into each generated project so the lane runs offline with no install step, and content-hashed so a receipt can name the exact lane that issued it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/verify.mjs",
|
|
@@ -1063,7 +1063,10 @@ export function reopenFeature(root, name, options = {}) {
|
|
|
1063
1063
|
return { ok: false, reason: `unknown feature "${name}" — known briefs: ${briefs.join(", ") || "(none)"}` };
|
|
1064
1064
|
}
|
|
1065
1065
|
const derived = deriveAllFeatures(root).find((d) => d.name === name);
|
|
1066
|
-
|
|
1066
|
+
// The spec side of the family follows the brief's own pairing (a multi-spec
|
|
1067
|
+
// brief reopens every spec its promises live in), defaulting to the name.
|
|
1068
|
+
const specIds = (derived?.specNames ?? [name]).map((n) => `feature-spec:${n}`);
|
|
1069
|
+
const set = [briefId, ...specIds, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
|
|
1067
1070
|
const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
|
|
1068
1071
|
const reopened = [];
|
|
1069
1072
|
const skipped = [];
|
|
@@ -1274,7 +1277,12 @@ export function getFeatureBoard(root) {
|
|
|
1274
1277
|
// feature-spec:* that is still signed must be reopened and amended, and the
|
|
1275
1278
|
// step says so by name — that is what the human's signature set in motion.
|
|
1276
1279
|
const deriveNextStep = (d, phase) => {
|
|
1277
|
-
|
|
1280
|
+
// The brief's PAIRED specs (feature-brief.mjs pairedSpecNames — the one
|
|
1281
|
+
// pairing function): a multi-spec brief waits on ALL of them being
|
|
1282
|
+
// signed, and its contract step names each one still waiting.
|
|
1283
|
+
const specArtifacts = (d.specNames ?? [d.name])
|
|
1284
|
+
.map((n) => byId.get(`feature-spec:${n}`))
|
|
1285
|
+
.filter(Boolean);
|
|
1278
1286
|
const designArtifact = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
|
|
1279
1287
|
const declaredSpecAmendments = d.touches
|
|
1280
1288
|
.filter((id) => id.startsWith("feature-spec:") && byId.get(id)?.status === "approved")
|
|
@@ -1343,8 +1351,9 @@ export function getFeatureBoard(root) {
|
|
|
1343
1351
|
// phase === "approved": building — which part of the loop is open?
|
|
1344
1352
|
if (!d.specExists || d.total === 0)
|
|
1345
1353
|
return { key: "contract", owner: "agent drafts → human signs", label: `contract: write the clauses in ${d.specRel}${amendNote}` };
|
|
1346
|
-
|
|
1347
|
-
|
|
1354
|
+
const unsignedSpecs = specArtifacts.filter((a) => a.status !== "approved");
|
|
1355
|
+
if (unsignedSpecs.length > 0)
|
|
1356
|
+
return { key: "sign-spec", owner: "human", label: `sign the contract (${unsignedSpecs.map((a) => a.id).join(", ")})${amendNote}` };
|
|
1348
1357
|
if (d.covered < d.total)
|
|
1349
1358
|
return { key: "build", owner: "agent", label: `build & cite: ${d.total - d.covered} clause(s) have no citing test yet` };
|
|
1350
1359
|
return { key: "prove", owner: "agent", label: "prove: run node qa/verify.mjs so the receipt attests this tree" };
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// docs/features/ is a governed `feature-brief:<name>` artifact, hashed and
|
|
8
8
|
// signed like anything else, approved BEFORE the feature is built. (Harness
|
|
9
9
|
// design standards stay in docs/proposals/ — different directory, different
|
|
10
|
-
// meaning.) `<name>`
|
|
11
|
-
//
|
|
10
|
+
// meaning.) `<name>` pairs with the feature's spec — by default
|
|
11
|
+
// specs/<name>.spec.md, overridable by the brief itself when its promises
|
|
12
|
+
// genuinely live in several spec files (see pairedSpecNames).
|
|
12
13
|
//
|
|
13
14
|
// The brief carries at most ONE machine-read block, and it declares — it never
|
|
14
15
|
// gates:
|
|
@@ -163,30 +164,90 @@ export function briefSections(markdown) {
|
|
|
163
164
|
|
|
164
165
|
/**
|
|
165
166
|
* A brief's declarations: blast radius (`touches`), UI surface (`screens`),
|
|
166
|
-
*
|
|
167
|
-
* intentionally not wired into the navigation graph yet)
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
167
|
+
* the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
|
|
168
|
+
* intentionally not wired into the navigation graph yet), and the paired
|
|
169
|
+
* spec files (`specs` — walk-legibility L1: spec NAMES, no path/extension;
|
|
170
|
+
* `"specs": ["catalog", "entry-editing"]`). A missing block, or one without a
|
|
171
|
+
* field, declares nothing — legal and common. A block that IS present but
|
|
172
|
+
* malformed is surfaced as `error`: a doc that tried to declare and failed
|
|
173
|
+
* should say so, not read as "declares nothing".
|
|
171
174
|
* @param {string} markdown
|
|
172
|
-
* @returns {{touches: string[], screens: boolean, unrouted: boolean, error: (string|null)}}
|
|
175
|
+
* @returns {{touches: string[], screens: boolean, unrouted: boolean, specs: string[], error: (string|null)}}
|
|
173
176
|
*/
|
|
174
177
|
export function parseFeatureBlock(markdown) {
|
|
175
178
|
const m = typeof markdown === "string" ? markdown.match(FEATURE_FENCE_RE) : null;
|
|
176
|
-
if (!m) return { touches: [], screens: false, unrouted: false, error: null };
|
|
179
|
+
if (!m) return { touches: [], screens: false, unrouted: false, specs: [], error: null };
|
|
177
180
|
let parsed;
|
|
178
181
|
try {
|
|
179
182
|
parsed = JSON.parse(m[1]);
|
|
180
183
|
} catch (err) {
|
|
181
|
-
return { touches: [], screens: false, unrouted: false, error: `cmp:feature block is not valid JSON — ${err.message}` };
|
|
184
|
+
return { touches: [], screens: false, unrouted: false, specs: [], error: `cmp:feature block is not valid JSON — ${err.message}` };
|
|
182
185
|
}
|
|
183
186
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
184
|
-
return { touches: [], screens: false, unrouted: false, error: "cmp:feature must be a JSON object" };
|
|
187
|
+
return { touches: [], screens: false, unrouted: false, specs: [], error: "cmp:feature must be a JSON object" };
|
|
185
188
|
}
|
|
186
189
|
const touches = Array.isArray(parsed.touches)
|
|
187
190
|
? parsed.touches.filter((t) => typeof t === "string" && t.trim() !== "")
|
|
188
191
|
: [];
|
|
189
|
-
|
|
192
|
+
// `specs` entries are normalized to bare names ("specs/catalog.spec.md" and
|
|
193
|
+
// "catalog" both mean specs/catalog.spec.md) — declaring in either form is
|
|
194
|
+
// fine; storing one form keeps every consumer's arithmetic identical.
|
|
195
|
+
const specs = Array.isArray(parsed.specs)
|
|
196
|
+
? [
|
|
197
|
+
...new Set(
|
|
198
|
+
parsed.specs
|
|
199
|
+
.filter((s) => typeof s === "string" && s.trim() !== "")
|
|
200
|
+
.map((s) => s.trim().replace(/^specs\//, "").replace(/\.spec\.md$/, "")),
|
|
201
|
+
),
|
|
202
|
+
]
|
|
203
|
+
: [];
|
|
204
|
+
return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, specs, error: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The spec files a brief's promises live in — THE pairing function
|
|
209
|
+
* (walk-legibility L1). One definition, consumed by the board derivation, the
|
|
210
|
+
* walk, and (through them) the console, so no surface can pair differently.
|
|
211
|
+
* Precedence:
|
|
212
|
+
* 1. the cmp:feature block's `"specs": [...]` — the explicit declaration
|
|
213
|
+
* 2. the brief's `**Spec:**` paragraph — every `specs/<name>.spec.md`
|
|
214
|
+
* reference in it (the form briefs already carry for human readers)
|
|
215
|
+
* 3. the filename default: `specs/<name>.spec.md`
|
|
216
|
+
* Before this existed, a brief whose blast radius genuinely spans two specs
|
|
217
|
+
* (catalog-and-editing, showcase 2026-08-26) derived as "still awaiting a
|
|
218
|
+
* contract" forever — a standing false instruction on the primary surface
|
|
219
|
+
* that invites an agent to write a second definition of signed behavior.
|
|
220
|
+
* @param {string} markdown the brief's full text
|
|
221
|
+
* @param {string} name the brief's name (docs/features/<name>.md)
|
|
222
|
+
* @param {{specs?: string[]}} [block] a parseFeatureBlock result, if the
|
|
223
|
+
* caller already has one (avoids re-parsing; same answer either way)
|
|
224
|
+
* @returns {string[]} spec names, e.g. ["catalog", "entry-editing"]
|
|
225
|
+
*/
|
|
226
|
+
export function pairedSpecNames(markdown, name, block) {
|
|
227
|
+
const declared = (block ?? parseFeatureBlock(markdown)).specs ?? [];
|
|
228
|
+
if (declared.length > 0) return declared;
|
|
229
|
+
const fromHeader = specHeaderNames(markdown);
|
|
230
|
+
if (fromHeader.length > 0) return fromHeader;
|
|
231
|
+
return [name];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every `specs/<name>.spec.md` referenced in the brief's `**Spec:**`
|
|
236
|
+
* paragraph — the line starting `**Spec:**` through the next blank line, so
|
|
237
|
+
* later prose that merely MENTIONS a spec path never redirects the pairing.
|
|
238
|
+
*/
|
|
239
|
+
function specHeaderNames(markdown) {
|
|
240
|
+
if (typeof markdown !== "string") return [];
|
|
241
|
+
const lines = markdown.split("\n");
|
|
242
|
+
const start = lines.findIndex((l) => /^\*\*Spec:?\*\*/.test(l.trim()));
|
|
243
|
+
if (start === -1) return [];
|
|
244
|
+
const para = [];
|
|
245
|
+
for (let i = start; i < lines.length && lines[i].trim() !== ""; i++) para.push(lines[i]);
|
|
246
|
+
const out = [];
|
|
247
|
+
for (const m of para.join("\n").matchAll(/specs\/([A-Za-z0-9_-]+)\.spec\.md/g)) {
|
|
248
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
190
251
|
}
|
|
191
252
|
|
|
192
253
|
/**
|
|
@@ -262,12 +323,21 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
|
262
323
|
} catch {
|
|
263
324
|
readable = false;
|
|
264
325
|
}
|
|
265
|
-
const block = readable
|
|
326
|
+
const block = readable
|
|
327
|
+
? parseFeatureBlock(markdown)
|
|
328
|
+
: { touches: [], screens: false, specs: [], error: `${brief.rel} could not be read` };
|
|
266
329
|
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
// The paired specs (walk-legibility L1): usually one, by filename; a brief
|
|
331
|
+
// may name several. Clauses concatenate in declaration order — "done" means
|
|
332
|
+
// every live clause across ALL of them is cited.
|
|
333
|
+
const specNames = pairedSpecNames(markdown, brief.name, block);
|
|
334
|
+
const specRels = specNames.map((n) => `specs/${n}.spec.md`);
|
|
335
|
+
const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
|
|
336
|
+
const specRel = specRels.join(" + ");
|
|
269
337
|
const citedIds = new Set((pre.citations ?? scanCitations(root)).map((t) => t.id));
|
|
270
|
-
const clauses =
|
|
338
|
+
const clauses = specRels
|
|
339
|
+
.flatMap((rel) => clausesOfSpec(root, rel))
|
|
340
|
+
.map((c) => ({ ...c, cited: citedIds.has(c.id) }));
|
|
271
341
|
const live = clauses.filter((c) => !c.withdrawn);
|
|
272
342
|
const covered = live.filter((c) => c.cited).length;
|
|
273
343
|
|
|
@@ -286,6 +356,8 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
|
|
|
286
356
|
// rung's only mechanical signal (see countEdgeCases).
|
|
287
357
|
edgeCases: readable ? countEdgeCases(markdown) : 0,
|
|
288
358
|
specRel,
|
|
359
|
+
specNames,
|
|
360
|
+
specRels,
|
|
289
361
|
specExists,
|
|
290
362
|
clauses,
|
|
291
363
|
covered,
|
|
@@ -48,7 +48,15 @@ export const VERIFIED_SURFACE = [
|
|
|
48
48
|
// bookkeeping about a commit that already happened, so appending a record
|
|
49
49
|
// must never invalidate a receipt for a tree whose code did not change
|
|
50
50
|
// (approvals.log.jsonl's principle, applied to audits).
|
|
51
|
+
// qa/.request.json and qa/.plan.json are the live chain's EPHEMERAL state
|
|
52
|
+
// (studio-drive-mode): the request file is rewritten on EVERY user prompt by
|
|
53
|
+
// the UserPromptSubmit hook, so hashing either would invalidate the receipt
|
|
54
|
+
// the moment the human speaks. They are also gitignored on fresh scaffolds,
|
|
55
|
+
// but the exclusion here is the load-bearing one — upgraded apps keep their
|
|
56
|
+
// own .gitignore, which never learns new entries.
|
|
51
57
|
const EXCLUDED_PREFIXES = [
|
|
58
|
+
"qa/.plan.json",
|
|
59
|
+
"qa/.request.json",
|
|
52
60
|
"qa/evidence",
|
|
53
61
|
"qa-artifacts",
|
|
54
62
|
"qa/comments.json",
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
|
|
37
|
+
// A marker older than this is a crashed writer, not a live run — the same
|
|
38
|
+
// bound qa/watch.mjs and the preview daemon apply to the same files.
|
|
39
|
+
const MARKER_FRESH_MS = 5 * 60 * 1000;
|
|
40
|
+
const MAX_REQUEST_CHARS = 500;
|
|
41
|
+
const MAX_STEPS = 20;
|
|
42
|
+
const MAX_LABEL_CHARS = 120;
|
|
43
|
+
|
|
44
|
+
function readJson(p) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeJson(p, value) {
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
|
|
55
|
+
return { ok: true };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Record the human's latest prompt — tier 1, machinery-owned. Called by the
|
|
63
|
+
* UserPromptSubmit hook path with the hook's own `prompt` field; never by
|
|
64
|
+
* the agent with words of its own choosing.
|
|
65
|
+
*/
|
|
66
|
+
export function recordRequest(root, text) {
|
|
67
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
68
|
+
if (t === "") return { ok: false, reason: "empty prompt — nothing to record" };
|
|
69
|
+
return writeJson(path.join(root, REQUEST_REL), {
|
|
70
|
+
text: t.length > MAX_REQUEST_CHARS ? `${t.slice(0, MAX_REQUEST_CHARS - 1)}…` : t,
|
|
71
|
+
at: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {{text: string, at: string}|null} */
|
|
76
|
+
export function readRequest(root) {
|
|
77
|
+
const r = readJson(path.join(root, REQUEST_REL));
|
|
78
|
+
return r && typeof r.text === "string" ? r : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Declare the chain — tier 2, agent-declared, said so on every rendering.
|
|
83
|
+
* `title` is the agent's triage restatement of the ask (the contract already
|
|
84
|
+
* mandates one); `steps` are plain labels in order. Declaring replaces any
|
|
85
|
+
* previous chain: one request, one chain.
|
|
86
|
+
*/
|
|
87
|
+
export function setPlan(root, { title, feature, steps } = {}) {
|
|
88
|
+
const labels = (Array.isArray(steps) ? steps : [])
|
|
89
|
+
.map((s) => String(s ?? "").trim())
|
|
90
|
+
.filter((s) => s !== "")
|
|
91
|
+
.slice(0, MAX_STEPS)
|
|
92
|
+
.map((s) => (s.length > MAX_LABEL_CHARS ? `${s.slice(0, MAX_LABEL_CHARS - 1)}…` : s));
|
|
93
|
+
if (labels.length === 0) return { ok: false, reason: "a chain needs at least one step" };
|
|
94
|
+
return writeJson(path.join(root, PLAN_REL), {
|
|
95
|
+
title: typeof title === "string" && title.trim() !== "" ? title.trim() : null,
|
|
96
|
+
feature: typeof feature === "string" && feature.trim() !== "" ? feature.trim() : null,
|
|
97
|
+
steps: labels.map((label, i) => ({ n: i + 1, label, done: false })),
|
|
98
|
+
current: 1,
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Advance to step `n`: everything before it is done, `n` is current. `--done`
|
|
105
|
+
* (n past the end) closes the chain. Refuses without a declared chain —
|
|
106
|
+
* advancing nothing would fabricate a plan that was never stated.
|
|
107
|
+
*/
|
|
108
|
+
export function markStep(root, n) {
|
|
109
|
+
const plan = readJson(path.join(root, PLAN_REL));
|
|
110
|
+
if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0)
|
|
111
|
+
return { ok: false, reason: "no declared chain — declare one first: node qa/plan.mjs --set \"step | step | …\"" };
|
|
112
|
+
const step = Number(n);
|
|
113
|
+
if (!Number.isInteger(step) || step < 1 || step > plan.steps.length + 1)
|
|
114
|
+
return { ok: false, reason: `step must be 1..${plan.steps.length + 1} (=${plan.steps.length + 1} closes the chain), got ${n}` };
|
|
115
|
+
for (const s of plan.steps) s.done = s.n < step;
|
|
116
|
+
plan.current = step > plan.steps.length ? null : step;
|
|
117
|
+
plan.updatedAt = new Date().toISOString();
|
|
118
|
+
return writeJson(path.join(root, PLAN_REL), plan).ok ? { ok: true, plan } : { ok: false, reason: "could not write the chain" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @returns {object|null} the declared chain, or null. */
|
|
122
|
+
export function readPlan(root) {
|
|
123
|
+
const p = readJson(path.join(root, PLAN_REL));
|
|
124
|
+
return p && Array.isArray(p.steps) ? p : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Clear the chain (a landed request leaves no stale windshield behind). */
|
|
128
|
+
export function clearPlan(root) {
|
|
129
|
+
try {
|
|
130
|
+
fs.rmSync(path.join(root, PLAN_REL), { force: true });
|
|
131
|
+
return { ok: true };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function markerFresh(root, name) {
|
|
138
|
+
try {
|
|
139
|
+
const st = fs.statSync(path.join(root, "composeApp", "build", name));
|
|
140
|
+
return Date.now() - st.mtimeMs < MARKER_FRESH_MS;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Everything a chain-rendering surface needs, with provenance attached:
|
|
148
|
+
* 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).
|
|
150
|
+
* @returns {{request: (object|null), plan: (object|null), planAgeMs: (number|null),
|
|
151
|
+
* busy: {lane: boolean, render: boolean}}}
|
|
152
|
+
*/
|
|
153
|
+
export function deriveChain(root) {
|
|
154
|
+
const plan = readPlan(root);
|
|
155
|
+
const at = plan ? Date.parse(plan.updatedAt) : NaN;
|
|
156
|
+
return {
|
|
157
|
+
request: readRequest(root),
|
|
158
|
+
plan,
|
|
159
|
+
planAgeMs: Number.isNaN(at) ? null : Math.max(0, Date.now() - at),
|
|
160
|
+
busy: {
|
|
161
|
+
lane: markerFresh(root, ".cmp-lane-in-progress"),
|
|
162
|
+
render: markerFresh(root, ".cmp-render-in-progress"),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** "40s ago" / "12 min ago" — freshness a human can weigh at a glance. */
|
|
168
|
+
export function formatAge(ms) {
|
|
169
|
+
if (!(ms >= 0)) return "age unknown";
|
|
170
|
+
if (ms < 90000) return `${Math.round(ms / 1000)}s ago`;
|
|
171
|
+
if (ms < 90 * 60000) return `${Math.round(ms / 60000)} min ago`;
|
|
172
|
+
return `${Math.round(ms / 3600000)}h ago`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The chain as one text block — the CLI's and the inject's rendering.
|
|
177
|
+
* Numbered steps: done ✓, current ◉ (with tier-3 corroboration when a lane
|
|
178
|
+
* or render is genuinely in flight), pending ○. "" when nothing is declared
|
|
179
|
+
* AND no request is recorded (silence, never an empty frame).
|
|
180
|
+
*/
|
|
181
|
+
export function renderChain(chain) {
|
|
182
|
+
if (!chain || (!chain.plan && !chain.request)) return "";
|
|
183
|
+
const lines = [];
|
|
184
|
+
const title = chain.plan?.title ?? chain.request?.text ?? null;
|
|
185
|
+
if (title) lines.push(`Request: ${title}`);
|
|
186
|
+
if (chain.plan) {
|
|
187
|
+
const p = chain.plan;
|
|
188
|
+
const seq = p.steps
|
|
189
|
+
.map((s) => `${s.done ? "✓" : s.n === p.current ? "◉" : "○"} ${s.n}. ${s.label}`)
|
|
190
|
+
.join(" → ");
|
|
191
|
+
lines.push(seq);
|
|
192
|
+
const cur = p.steps.find((s) => s.n === p.current) ?? null;
|
|
193
|
+
const busy = chain.busy?.lane ? " · the full check is running NOW" : chain.busy?.render ? " · a preview render is in flight" : "";
|
|
194
|
+
const age = chain.planAgeMs !== null ? ` · declared by the agent, updated ${formatAge(chain.planAgeMs)}` : "";
|
|
195
|
+
lines.push(cur ? `now: step ${cur.n} of ${p.steps.length} — ${cur.label}${busy}${age}` : `chain complete${busy}${age}`);
|
|
196
|
+
} else {
|
|
197
|
+
lines.push("(no declared chain for this request yet — node qa/plan.mjs --set \"step | step | …\")");
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|