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
|
@@ -19,8 +19,41 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
20
|
const args = process.argv.slice(2);
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The hook's stdin, parsed — UserPromptSubmit delivers {prompt, ...} as JSON.
|
|
24
|
+
* Bounded read, fail-soft: no stdin / non-JSON / no prompt -> null. Only the
|
|
25
|
+
* --inject path consumes this (the statusline gets no stdin and must not wait
|
|
26
|
+
* on one).
|
|
27
|
+
*/
|
|
28
|
+
async function readHookStdin() {
|
|
29
|
+
if (process.stdin.isTTY) return null;
|
|
30
|
+
try {
|
|
31
|
+
let raw = "";
|
|
32
|
+
for await (const chunk of process.stdin) {
|
|
33
|
+
raw += chunk;
|
|
34
|
+
if (raw.length > 1_000_000) break; // a prompt is never this — stop reading, keep what we have
|
|
35
|
+
}
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
try {
|
|
23
44
|
const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
|
|
45
|
+
|
|
46
|
+
// Tier 1 of the chain (studio-drive-mode): record the human's own prompt
|
|
47
|
+
// BEFORE deriving, so this very inject already reflects the new request.
|
|
48
|
+
// Machinery-owned — the words are the hook's, never the agent's.
|
|
49
|
+
if (args.includes("--inject")) {
|
|
50
|
+
const hook = await readHookStdin();
|
|
51
|
+
if (hook && typeof hook.prompt === "string") {
|
|
52
|
+
const { recordRequest } = await import("./lib/plan.mjs");
|
|
53
|
+
recordRequest(ROOT, hook.prompt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
const data = deriveWalks(ROOT);
|
|
25
58
|
|
|
26
59
|
if (args.includes("--json")) {
|
|
@@ -43,7 +76,10 @@ try {
|
|
|
43
76
|
} else if (data.walks.length === 0 && data.arrivals.length === 0) {
|
|
44
77
|
process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
|
|
45
78
|
} else {
|
|
46
|
-
|
|
79
|
+
const { renderChain } = await import("./lib/plan.mjs");
|
|
80
|
+
const chainText = renderChain(data.chain);
|
|
81
|
+
if (chainText !== "") process.stdout.write(`${chainText}\n\n`);
|
|
82
|
+
for (const w of data.walks) process.stdout.write(`${renderCard(w, data)}\n\n`);
|
|
47
83
|
for (const a of data.arrivals)
|
|
48
84
|
process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
|
|
49
85
|
}
|
|
@@ -48,7 +48,16 @@ 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",
|
|
60
|
+
"qa/.plan-history.jsonl",
|
|
52
61
|
"qa/evidence",
|
|
53
62
|
"qa-artifacts",
|
|
54
63
|
"qa/comments.json",
|
|
@@ -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
|
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// ksp.useKSP2=true, wire the walk into .claude/settings.json); everything else
|
|
11
11
|
// prints the exact manual step.
|
|
12
12
|
|
|
13
|
+
import crypto from "node:crypto";
|
|
13
14
|
import fs from "node:fs";
|
|
14
15
|
import os from "node:os";
|
|
15
16
|
import path from "node:path";
|
|
@@ -132,6 +133,30 @@ export function gatherWalkInputs(projectDir) {
|
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The studio console's registry record for this app, when one exists — a
|
|
138
|
+
* CROSS-PACKAGE CONTRACT with the inspector's preview-service.mjs
|
|
139
|
+
* (consoleRegistryPath) and the harness's walk.mjs (consoleState):
|
|
140
|
+
* sha1(resolved projectDir).slice(0,12) keys `cmp-console-<key>.json` in
|
|
141
|
+
* os.tmpdir(), fields {pid, url}. pid-liveness only — no HTTP.
|
|
142
|
+
*/
|
|
143
|
+
export function gatherConsoleInputs(projectDir) {
|
|
144
|
+
try {
|
|
145
|
+
const key = crypto.createHash("sha1").update(path.resolve(projectDir)).digest("hex").slice(0, 12);
|
|
146
|
+
const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
|
|
147
|
+
if (!rec || typeof rec.pid !== "number") return null;
|
|
148
|
+
let pidAlive = true;
|
|
149
|
+
try {
|
|
150
|
+
process.kill(rec.pid, 0);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
pidAlive = Boolean(err && err.code === "EPERM");
|
|
153
|
+
}
|
|
154
|
+
return { pidAlive, url: typeof rec.url === "string" ? rec.url : null };
|
|
155
|
+
} catch {
|
|
156
|
+
return null; // no record — never started, or stopped cleanly
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
/** Gather filesystem/env inputs for the pure diagnosis. */
|
|
136
161
|
export function gatherProjectInputs(projectDir) {
|
|
137
162
|
const toml = readIfExists(path.join(projectDir, "gradle", "libs.versions.toml"));
|
|
@@ -174,6 +199,7 @@ export function gatherProjectInputs(projectDir) {
|
|
|
174
199
|
inspectorHits,
|
|
175
200
|
inspectorCatalog,
|
|
176
201
|
walk: gatherWalkInputs(projectDir),
|
|
202
|
+
consoleRecord: gatherConsoleInputs(projectDir),
|
|
177
203
|
};
|
|
178
204
|
}
|
|
179
205
|
|
|
@@ -40,6 +40,10 @@ export const DISK_WARN_BYTES = 3 * GIB;
|
|
|
40
40
|
* promptHook:boolean}|null} [input.walk] the walk's wiring: is
|
|
41
41
|
* qa/walk-status.mjs installed, and does .claude/settings.json actually
|
|
42
42
|
* INVOKE it (statusLine + UserPromptSubmit)? null = skip the check.
|
|
43
|
+
* @param {{pidAlive:boolean, url:(string|null)}|null} [input.consoleRecord] the studio
|
|
44
|
+
* console's tmp-dir registry record for this app, when one exists: is its
|
|
45
|
+
* process still alive, and at what URL? null = no record (never started, or
|
|
46
|
+
* stopped cleanly — nothing to say).
|
|
43
47
|
* @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
|
|
44
48
|
* InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
|
|
45
49
|
* the declared-token drift tripwire; null = skip.
|
|
@@ -60,6 +64,7 @@ export function diagnoseProject(input) {
|
|
|
60
64
|
inspectorHits = null,
|
|
61
65
|
inspectorCatalog = null,
|
|
62
66
|
walk = null,
|
|
67
|
+
consoleRecord = null,
|
|
63
68
|
} = input;
|
|
64
69
|
|
|
65
70
|
// --- version catalog ------------------------------------------------------
|
|
@@ -361,6 +366,38 @@ export function diagnoseProject(input) {
|
|
|
361
366
|
}
|
|
362
367
|
}
|
|
363
368
|
|
|
369
|
+
// The studio console's liveness (walk-legibility L6c). A registry record with
|
|
370
|
+
// a dead pid means the console CRASHED — a clean stop removes the record — and
|
|
371
|
+
// the human's window silently disappeared. The statusline appends "console
|
|
372
|
+
// down" live; doctor is the once-over that says the same thing with the fix.
|
|
373
|
+
if (consoleRecord !== null) {
|
|
374
|
+
if (consoleRecord.pidAlive) {
|
|
375
|
+
findings.push({
|
|
376
|
+
id: "console-liveness",
|
|
377
|
+
level: "ok",
|
|
378
|
+
title: "The studio console is running",
|
|
379
|
+
detail: `A console is registered for this app${consoleRecord.url ? ` at ${consoleRecord.url}` : ""} and its process is alive.`,
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
findings.push({
|
|
383
|
+
id: "console-liveness",
|
|
384
|
+
level: "warn",
|
|
385
|
+
title: "The studio console crashed",
|
|
386
|
+
detail:
|
|
387
|
+
"A console registry record exists for this app but its process is gone — the " +
|
|
388
|
+
"window died without a clean stop, and every surface that leads with the console " +
|
|
389
|
+
"is now pointing at nothing.",
|
|
390
|
+
fix: {
|
|
391
|
+
auto: false,
|
|
392
|
+
description:
|
|
393
|
+
"Reconnect the cmp-inspector MCP (it ensures a resident console at session " +
|
|
394
|
+
"start), call the preview tool, or start one by hand: " +
|
|
395
|
+
"node inspector/mcp/bin/console.mjs <projectDir> (detached: nohup … &).",
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
364
401
|
return findings;
|
|
365
402
|
}
|
|
366
403
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -38,6 +38,13 @@ it; CI still enforces it).
|
|
|
38
38
|
New behavior begins as a spec clause in `specs/<feature>.spec.md`: Given/When/Then with a
|
|
39
39
|
stable id (see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
|
|
40
40
|
then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
|
|
41
|
+
|
|
42
|
+
**A clause about device behavior must say so.** A citation proves a test *exists*; it cannot
|
|
43
|
+
prove that test could ever *observe* the promise. Add `[tier: device]` (or `[tier: e2e]`)
|
|
44
|
+
after the id when the claim is about OS facts a host JVM cannot see — lifecycle, alarms,
|
|
45
|
+
notifications, permissions, real navigation. `specCoverage` then requires a citation from
|
|
46
|
+
`androidInstrumentedTest` or `qa/e2e` and FAILS without one, rather than accepting a
|
|
47
|
+
desktop test that is structurally blind to the claim.
|
|
41
48
|
[`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
|
|
42
49
|
invariants the conformance gates enforce.
|
|
43
50
|
|
|
@@ -90,8 +97,15 @@ the tree. The governed `architecture` artifact (below) hashes the document along
|
|
|
90
97
|
if the test itself is wrong, say so in your summary and justify the change.
|
|
91
98
|
|
|
92
99
|
**Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
|
|
93
|
-
feature touches alarms, notifications, lock-screen intents,
|
|
94
|
-
|
|
100
|
+
feature touches alarms, notifications, lock-screen intents, audio routing, **or app/process
|
|
101
|
+
lifecycle** (cold start vs warm resume, "once per process start", process death and
|
|
102
|
+
restore, `ON_STOP`/`ON_START`), its behavior test goes there, because no desktop tier can
|
|
103
|
+
see those OS facts. A desktop Compose test has no process lifecycle *at all*, so a claim
|
|
104
|
+
about one is unobservable there by construction — and `ProcessControl` below is the organ
|
|
105
|
+
that puts the device into the state such a claim is about. **Declare it on the clause**:
|
|
106
|
+
`- **MOTION-13** [tier: device] — Given a cold start, …`. The lane's `specCoverage` then
|
|
107
|
+
FAILS unless a test from a tier that can actually see it cites the clause, instead of
|
|
108
|
+
accepting a citation from a tier that cannot. Assertion helpers:
|
|
95
109
|
`NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
|
|
96
110
|
device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
|
|
97
111
|
timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
|
|
@@ -198,6 +212,20 @@ understood the change to be, which lane it takes, and why, before any tool runs.
|
|
|
198
212
|
can overrule the lane in a word; a silent route is a routing error even when the lane was
|
|
199
213
|
right.
|
|
200
214
|
|
|
215
|
+
**Grill before the brief** (the `grill-me` plugin skill; the rule holds without the plugin):
|
|
216
|
+
on the brief lane, after the triage restatement and before a word of the brief is drafted,
|
|
217
|
+
settle the load-bearing questions. Read what the repo already answers first — a signed brief
|
|
218
|
+
or spec is a CLOSED decision: cite it, never re-ask it. Then ask the frontier of unsettled
|
|
219
|
+
decisions as a numbered list, at most five per round, each with why it matters and a
|
|
220
|
+
recommended answer — and WAIT for the answers before anything else. Stop when no remaining
|
|
221
|
+
question would change the work; three rounds is the ceiling (more means the request needs
|
|
222
|
+
splitting). Answers land in the brief — settled calls become **Decisions** with their why,
|
|
223
|
+
the human's own calls the **Open decisions** section; the brief's signature closes them. The
|
|
224
|
+
direct lane is not grilled (one inline question at most, only when the restatement cannot
|
|
225
|
+
be made unambiguous); a bug fix or an emergency fix, never. While the grill is open, the
|
|
226
|
+
chain's first step reads `settle the open questions` (declare it before the first round;
|
|
227
|
+
re-declare when the answers reshape the steps).
|
|
228
|
+
|
|
201
229
|
**Brief lane** — when the change carries **decisions a future contributor could plausibly
|
|
202
230
|
"simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
|
|
203
231
|
**blast radius into other governed artifacts**. After naming the lane:
|
|
@@ -317,21 +345,56 @@ evidence attached"). `node qa/walk-status.mjs` derives the live position; a
|
|
|
317
345
|
UserPromptSubmit hook injects it every prompt. **Render the injected state — never
|
|
318
346
|
your memory of it.**
|
|
319
347
|
|
|
320
|
-
**At kickoff** (with the triage restatement): print the itinerary —
|
|
348
|
+
**At kickoff** (with the triage restatement): print the itinerary — and DECLARE it as
|
|
349
|
+
the live chain, so the studio's Drive strip and the statusline's readers see the same
|
|
350
|
+
steps you just printed:
|
|
321
351
|
|
|
322
352
|
Navigation redesign — the journey (brief lane)
|
|
323
353
|
Decide → Design → Contract → Build → Prove → Sign-off
|
|
324
354
|
Stops for you: 3 (Decide — now · Contract · Sign-off). Build and Prove never stop for you.
|
|
325
355
|
First stop is now: 2 open decisions below.
|
|
326
356
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
**At every human gate — loud:** a full stop card, never a bare question:
|
|
357
|
+
```bash
|
|
358
|
+
node qa/plan.mjs --set "sign the brief | draft screens | agree the promises | build | full check | your sign-off" --title "navigation redesign"
|
|
359
|
+
```
|
|
331
360
|
|
|
332
|
-
|
|
361
|
+
**The chain is an offer, not an announcement** (drive-narration N6): show the declared
|
|
362
|
+
steps in your first reply and invite the reshape in one breath — "say the word and I'll
|
|
363
|
+
reorder" — then start work immediately; the chain gates nothing, so the offer never
|
|
364
|
+
blocks. If the human redirects, re-declare (`--set` again) without ceremony: their
|
|
365
|
+
reshape IS the new chain.
|
|
366
|
+
|
|
367
|
+
**The chain stays current** — this is part of the contract, not a nicety: advance it
|
|
368
|
+
with `node qa/plan.mjs --step N` as each step lands and `--done` when the request
|
|
369
|
+
lands (closing writes the request's line into the local trail the studio's Recent
|
|
370
|
+
requests fold shows). The current request itself is recorded mechanically (the
|
|
371
|
+
per-prompt hook), the steps are yours to declare, and every surface shows the
|
|
372
|
+
declaration's age — a stale chain reads as stale to the human watching the studio,
|
|
373
|
+
which is worse than no chain. While the full check runs, the chain's observed line
|
|
374
|
+
narrates the lane's own position (step, elapsed, usual cost) — quote THAT, never an
|
|
375
|
+
estimate. The chain gates nothing; the walk stays the truth for doneness.
|
|
376
|
+
|
|
377
|
+
**The studio is a standing check:** every injected context opens with a `[studio: …]`
|
|
378
|
+
line. If it says DOWN or not running, restore it before proceeding — call the
|
|
379
|
+
cmp-inspector `preview { projectDir }` tool (it starts a detached resident console
|
|
380
|
+
that survives the session) — or, if the tools are absent, tell the human once. A
|
|
381
|
+
missing window is a fault to heal, never something to work silently past.
|
|
382
|
+
|
|
383
|
+
**While working — the header, then quiet:** open EVERY reply with the walk's one-line
|
|
384
|
+
header — the exact `[chat header]` line the per-prompt inject delivers. Paste it
|
|
385
|
+
verbatim, never compose it: it is the derivation's own string, so it cannot drift, and
|
|
386
|
+
it persists in the transcript, which the statusline beneath the input box never does.
|
|
387
|
+
After the header: one line per stage transition, nothing per-file. Stages carry their
|
|
388
|
+
plain-words gloss on first mention ("Contract — agreeing what it promises"); quote the
|
|
389
|
+
lane's cost only from the measured figure in the injected card, never an estimate.
|
|
390
|
+
|
|
391
|
+
**At every human gate — loud:** a full stop card, never a bare question — and the
|
|
392
|
+
easiest act leads:
|
|
393
|
+
|
|
394
|
+
■ YOUR TURN — <feature> · stage 3 of 6: Contract — agreeing what it promises
|
|
333
395
|
<what it is, in plain words — two lines maximum>
|
|
334
|
-
→
|
|
396
|
+
→ Easiest: the studio console at <url from the injected card> — the row carries the button.
|
|
397
|
+
→ CLI fallback: <the command> (or "reply approve" when no console is up)
|
|
335
398
|
After this: <the remaining stages, and which ones stop for the human>
|
|
336
399
|
|
|
337
400
|
**Arrivals:** work that belongs to no open walk (undeclared drift, a harness
|
|
@@ -455,6 +518,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
455
518
|
| `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
|
|
456
519
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
457
520
|
| `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
|
|
521
|
+
| `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
|
|
458
522
|
| `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
|
|
459
523
|
| `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
|
|
460
524
|
| `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
|
package/template/gitignore
CHANGED
|
@@ -34,3 +34,13 @@ xcuserdata/
|
|
|
34
34
|
# ignored by git. Delete them once you have reviewed the upgrade's diff.
|
|
35
35
|
*.bak-upgrade
|
|
36
36
|
*.cmp-new
|
|
37
|
+
|
|
38
|
+
# The live chain's ephemeral state (studio-drive-mode): the current request
|
|
39
|
+
# (rewritten by the UserPromptSubmit hook on every prompt) and the agent's
|
|
40
|
+
# declared step plan. Per-session windshield, not project history — and also
|
|
41
|
+
# hard-excluded from the receipt's hashed input surface (qa/lib/inputs-hash.mjs).
|
|
42
|
+
qa/.request.json
|
|
43
|
+
qa/.plan.json
|
|
44
|
+
# The closed-chain trail (drive-narration N5): local because it carries raw
|
|
45
|
+
# human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
|
|
46
|
+
qa/.plan-history.jsonl
|
package/template/qa/approve.mjs
CHANGED
|
@@ -201,9 +201,16 @@ if (reopenFeatureFlagIdx !== -1) {
|
|
|
201
201
|
console.error(`error: ${result.reason}`);
|
|
202
202
|
process.exit(1);
|
|
203
203
|
}
|
|
204
|
+
const inScope = result.reopened.length + result.skipped.length + (result.stillSigned ?? []).length;
|
|
204
205
|
console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
|
|
206
|
+
console.log(` ${inScope} in scope · ${result.reopened.length} reopened · ${(result.stillSigned ?? []).length} still signed`);
|
|
205
207
|
for (const id of result.reopened) console.log(` ↺ ${id}`);
|
|
206
208
|
for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
|
|
209
|
+
// The declared blast radius is reported, not walked back: a signature is
|
|
210
|
+
// demanded again only if the change actually moves the bytes it covers.
|
|
211
|
+
for (const t of result.stillSigned ?? []) {
|
|
212
|
+
console.log(` ✓ ${t.id} still signed (${t.status}${t.hash ? ` @${t.hash}` : ""}) — re-signature demanded only if it changes; the hash enforces that`);
|
|
213
|
+
}
|
|
207
214
|
process.exit(0);
|
|
208
215
|
}
|
|
209
216
|
|
|
@@ -1063,13 +1063,36 @@ 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
|
+
// WHAT A FEATURE REOPEN WALKS BACK (evidence-economics S5, aligning this
|
|
1070
|
+
// function with CHANGE-FLOW-DESIGN.md §"touches": "hashes enforce,
|
|
1071
|
+
// declaration lets the console tell as-planned from undeclared blast").
|
|
1072
|
+
//
|
|
1073
|
+
// reopened the brief, its declared spec(s), and its design when the
|
|
1074
|
+
// brief declares a UI surface — the documents the change
|
|
1075
|
+
// will AMEND. Their signatures are walked back on purpose.
|
|
1076
|
+
// stillSigned the declared `touches`. Before this, every one of them was
|
|
1077
|
+
// reopened too, and every one came back byte-identical:
|
|
1078
|
+
// twelve signatures for zero changes (design-system
|
|
1079
|
+
// d8fbdce8 → d8fbdce8). An `approved` artifact is, by
|
|
1080
|
+
// definition, one whose bytes still match what was signed —
|
|
1081
|
+
// so reopening it re-asks a question the hash has already
|
|
1082
|
+
// answered. Worse than wasted: it trains the signer to
|
|
1083
|
+
// approve without reading, the exact habit approvals exist
|
|
1084
|
+
// to prevent. They stay signed. If the change DOES move one,
|
|
1085
|
+
// its hash flips it to `changed` and demands a fresh
|
|
1086
|
+
// signature — the enforcement the doc always assigned to the
|
|
1087
|
+
// hash, not to this verb.
|
|
1088
|
+
const amendSet = [briefId, ...specIds, ...(derived?.screens ? [`${FEATURE_DESIGN_PREFIX}${name}`] : [])];
|
|
1089
|
+
const touchSet = (derived ? derived.touches : []).filter((id) => !amendSet.includes(id));
|
|
1067
1090
|
const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
|
|
1068
1091
|
const reopened = [];
|
|
1069
1092
|
const skipped = [];
|
|
1070
|
-
for (const id of [...new Set(
|
|
1093
|
+
for (const id of [...new Set(amendSet)]) {
|
|
1071
1094
|
const live = byId.get(id);
|
|
1072
|
-
if (!live) continue; //
|
|
1095
|
+
if (!live) continue; // resolves to no governed artifact — nothing to reopen
|
|
1073
1096
|
if (live.status !== "approved") {
|
|
1074
1097
|
skipped.push({ id, status: live.status });
|
|
1075
1098
|
continue;
|
|
@@ -1078,15 +1101,21 @@ export function reopenFeature(root, name, options = {}) {
|
|
|
1078
1101
|
if (result.ok) reopened.push(id);
|
|
1079
1102
|
else skipped.push({ id, status: `refused: ${result.reason}` });
|
|
1080
1103
|
}
|
|
1104
|
+
const stillSigned = [];
|
|
1105
|
+
for (const id of [...new Set(touchSet)]) {
|
|
1106
|
+
const live = byId.get(id);
|
|
1107
|
+
if (!live) continue;
|
|
1108
|
+
stillSigned.push({ id, status: live.status, hash: typeof live.hash === "string" ? live.hash.slice(0, 8) : null });
|
|
1109
|
+
}
|
|
1081
1110
|
if (reopened.length === 0) {
|
|
1082
1111
|
return {
|
|
1083
1112
|
ok: false,
|
|
1084
1113
|
reason:
|
|
1085
|
-
`nothing in "${name}"'s set is currently approved — there is no signature to walk back. ` +
|
|
1086
|
-
`Set: ${[...new Set(
|
|
1114
|
+
`nothing in "${name}"'s amend set is currently approved — there is no signature to walk back. ` +
|
|
1115
|
+
`Set: ${[...new Set(amendSet)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
|
|
1087
1116
|
};
|
|
1088
1117
|
}
|
|
1089
|
-
return { ok: true, feature: name, reopened, skipped };
|
|
1118
|
+
return { ok: true, feature: name, reopened, skipped, stillSigned };
|
|
1090
1119
|
}
|
|
1091
1120
|
|
|
1092
1121
|
// ── The verify-lane gate ─────────────────────────────────────────────────────
|
|
@@ -1274,7 +1303,12 @@ export function getFeatureBoard(root) {
|
|
|
1274
1303
|
// feature-spec:* that is still signed must be reopened and amended, and the
|
|
1275
1304
|
// step says so by name — that is what the human's signature set in motion.
|
|
1276
1305
|
const deriveNextStep = (d, phase) => {
|
|
1277
|
-
|
|
1306
|
+
// The brief's PAIRED specs (feature-brief.mjs pairedSpecNames — the one
|
|
1307
|
+
// pairing function): a multi-spec brief waits on ALL of them being
|
|
1308
|
+
// signed, and its contract step names each one still waiting.
|
|
1309
|
+
const specArtifacts = (d.specNames ?? [d.name])
|
|
1310
|
+
.map((n) => byId.get(`feature-spec:${n}`))
|
|
1311
|
+
.filter(Boolean);
|
|
1278
1312
|
const designArtifact = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
|
|
1279
1313
|
const declaredSpecAmendments = d.touches
|
|
1280
1314
|
.filter((id) => id.startsWith("feature-spec:") && byId.get(id)?.status === "approved")
|
|
@@ -1343,8 +1377,9 @@ export function getFeatureBoard(root) {
|
|
|
1343
1377
|
// phase === "approved": building — which part of the loop is open?
|
|
1344
1378
|
if (!d.specExists || d.total === 0)
|
|
1345
1379
|
return { key: "contract", owner: "agent drafts → human signs", label: `contract: write the clauses in ${d.specRel}${amendNote}` };
|
|
1346
|
-
|
|
1347
|
-
|
|
1380
|
+
const unsignedSpecs = specArtifacts.filter((a) => a.status !== "approved");
|
|
1381
|
+
if (unsignedSpecs.length > 0)
|
|
1382
|
+
return { key: "sign-spec", owner: "human", label: `sign the contract (${unsignedSpecs.map((a) => a.id).join(", ")})${amendNote}` };
|
|
1348
1383
|
if (d.covered < d.total)
|
|
1349
1384
|
return { key: "build", owner: "agent", label: `build & cite: ${d.total - d.covered} clause(s) have no citing test yet` };
|
|
1350
1385
|
return { key: "prove", owner: "agent", label: "prove: run node qa/verify.mjs so the receipt attests this tree" };
|
|
@@ -84,7 +84,9 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
|
|
|
84
84
|
export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
|
|
85
85
|
if (mode === "fast") return null; // the inner loop derives no rung — ever
|
|
86
86
|
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
87
|
-
|
|
87
|
+
// A failed lane has no rung — and a lane with a step that could not run
|
|
88
|
+
// (ERROR) has none either: a rung is evidence, and "could not check" is not.
|
|
89
|
+
if (steps.some((s) => s.verdict === "FAIL" || s.verdict === "ERROR")) return null;
|
|
88
90
|
const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
|
|
89
91
|
|
|
90
92
|
if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
|
|
@@ -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,
|