shapeup-sdlc 3.2.0 → 3.3.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/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +6 -5
- package/README.md +1 -1
- package/SECURITY.md +4 -1
- package/bin/init.mjs +3 -0
- package/commands/retro.md +19 -2
- package/hooks/dispatch-receipt.mjs +6 -3
- package/hooks/gate-zerowork.mjs +5 -2
- package/hooks/lib/decision.mjs +49 -6
- package/hooks/safety-spine.mjs +8 -5
- package/hooks/sandbox-guard.mjs +13 -6
- package/kernel/compile.mjs +112 -6
- package/kernel/harness.mjs +11 -5
- package/kernel/lib/paths.mjs +12 -2
- package/kernel/probe/owner.mjs +139 -0
- package/kernel/probe/stats.mjs +49 -2
- package/kernel/reduce/hill.mjs +12 -2
- package/kernel/verify/build.mjs +319 -0
- package/package.json +1 -1
- package/skills/coach/SKILL.md +232 -43
- package/skills/orient/SKILL.md +8 -1
- package/skills/qa-edge-hunter/SKILL.md +3 -2
- package/skills/scope-architect/SKILL.md +3 -0
- package/skills/scope-hammer/SKILL.md +13 -0
- package/skills/solution-architect/SKILL.md +3 -1
- package/skills/tech-lead/SKILL.md +1 -1
- package/skills/tech-lead/references/gates.md +43 -5
- package/skills/tech-lead/references/protocol.md +25 -1
- package/skills/tech-lead/schemas/domain.schema.json +154 -9
- package/skills/tech-lead/workflows/shapeup-run.js +41 -4
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// probe owner — which scope owns a path, derived from the contracts and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A QUERY AND NOT A SENTENCE. GATE H's census stated ownership facts without reading
|
|
5
|
+
// the contracts: a ship report said no scope owned the app's page files, while the composition
|
|
6
|
+
// root's committed substrate listed `pages/**` in plain sight. The real gap was elsewhere (a route
|
|
7
|
+
// table naming files nobody had written), and the report pointed the PO at a scope cut that was
|
|
8
|
+
// not the problem. A census that narrates ownership from memory is a census that can be wrong in
|
|
9
|
+
// exactly the way that looks most authoritative.
|
|
10
|
+
//
|
|
11
|
+
// Ownership already has ONE mechanical definition in this plugin — the substrate the sandbox hook
|
|
12
|
+
// enforces, and the election `harness compile` runs to address a cited bug to the scope that may
|
|
13
|
+
// write the file (`electOwner`). This probe exposes that same answer on the command line, so a
|
|
14
|
+
// worker that needs to say "no scope owns X" can cite the query instead of asserting it.
|
|
15
|
+
//
|
|
16
|
+
// With no `--path`, the input set is what a census would ask about anyway: every engine and
|
|
17
|
+
// entry-point call site the wiring map names, plus the profile's entry point. A row with no
|
|
18
|
+
// writers is an UNOWNED SEAM — the one kind of gap the disjointness lint cannot see, since DISJOINT
|
|
19
|
+
// fails two owners and nothing fails zero.
|
|
20
|
+
//
|
|
21
|
+
// Usage:
|
|
22
|
+
// node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" probe owner --slug <slug> [--path <p>]... [--format json|table] [--cwd <dir>]
|
|
23
|
+
//
|
|
24
|
+
// Exit code: 0 = answered, 2 = bad argv or no readable scope contracts.
|
|
25
|
+
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { resolve, join } from "node:path";
|
|
28
|
+
import { runArgs, isMain } from "../lib/argv.mjs";
|
|
29
|
+
import { wiringMap, projectProfile } from "../lib/paths.mjs";
|
|
30
|
+
import { readContract, WIRING_MAP, PROJECT_PROFILE } from "../lib/contract.mjs";
|
|
31
|
+
import { scopeSubstrates, electOwner, bugLocations } from "../compile.mjs";
|
|
32
|
+
import { matchesAny } from "../../hooks/sandbox-guard.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ownership of one repo-relative path under a set of scope substrates.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} path - Repo-relative POSIX path.
|
|
38
|
+
* @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - From
|
|
39
|
+
* {@link scopeSubstrates}.
|
|
40
|
+
* @param {string} [cwd] - Project root; when given, `exists` says whether the path is on disk.
|
|
41
|
+
* @returns {{path:string, owner:(string|null), writers:string[], shared_with:string[], exists:(boolean|null)}}
|
|
42
|
+
* `owner` is the elected fixer (exclusive first, lowest id among equals); `writers` every scope
|
|
43
|
+
* whose substrate admits the path; `shared_with` the writers that declare it shared. Empty
|
|
44
|
+
* `writers` means no scope may write the file — an unowned path. `exists` is the other half a
|
|
45
|
+
* census needs: a seam that is owned but not on disk is a route to a file nobody wrote.
|
|
46
|
+
*/
|
|
47
|
+
export function ownership(path, scopes, cwd = null) {
|
|
48
|
+
const rel = String(path).replace(/^\.\//, "");
|
|
49
|
+
const writers = (scopes || []).filter((s) => matchesAny(rel, s.allowed)).map((s) => s.scope_id).sort();
|
|
50
|
+
const shared = (scopes || []).filter((s) => matchesAny(rel, s.shared || [])).map((s) => s.scope_id).sort();
|
|
51
|
+
return { path: rel, owner: electOwner(rel, scopes), writers, shared_with: shared, exists: cwd ? existsSync(join(cwd, rel)) : null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The paths a census asks about when none are given: the wiring map's engines and entry call
|
|
56
|
+
* sites, and the profile's entry point. Only tokens that look like files are kept — an entry call
|
|
57
|
+
* site is prose around a path (`src/server.ts — POST /checkout route`), and the path is what the
|
|
58
|
+
* substrate can answer for.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} cwd - Project root.
|
|
61
|
+
* @param {string} slug - Feature slug.
|
|
62
|
+
* @returns {Array<{path:string, cited_by:string}>} Distinct paths with where each was named.
|
|
63
|
+
*/
|
|
64
|
+
export function seamPaths(cwd, slug) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const add = (p, by) => { if (p && !seen.has(p)) { seen.add(p); out.push({ path: p, cited_by: by }); } };
|
|
68
|
+
const wm = wiringMap(cwd, slug);
|
|
69
|
+
if (existsSync(wm)) {
|
|
70
|
+
const entries = readContract(wm, WIRING_MAP)?.contract?.entries || [];
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
for (const p of bugLocations({ location: e.engine })) add(p, `${e.use_case || "?"} engine`);
|
|
73
|
+
for (const p of bugLocations({ location: e.entry_call_site })) add(p, `${e.use_case || "?"} entry_call_site`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const pp = projectProfile(cwd, slug);
|
|
77
|
+
if (existsSync(pp)) {
|
|
78
|
+
const ep = readContract(pp, PROJECT_PROFILE)?.contract?.entry_point;
|
|
79
|
+
for (const p of bugLocations({ location: ep })) add(p, "project-profile entry_point");
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render the report as a fixed-width table for a human reading a gate block.
|
|
86
|
+
* @param {object} r - The report {@link cli} prints.
|
|
87
|
+
* @returns {string} One header line and one row per path.
|
|
88
|
+
*/
|
|
89
|
+
export function renderTable(r) {
|
|
90
|
+
const rows = r.paths.map((p) => [p.path, p.owner || "—", p.writers.join(",") || "UNOWNED", p.exists === null ? "?" : p.exists ? "yes" : "MISSING", p.cited_by || ""]);
|
|
91
|
+
const head = ["path", "owner", "writers", "on disk", "cited by"];
|
|
92
|
+
const w = head.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
|
|
93
|
+
const line = (row) => row.map((c, i) => String(c).padEnd(w[i])).join(" ").trimEnd();
|
|
94
|
+
return [line(head), line(w.map((n) => "-".repeat(n))), ...rows.map(line),
|
|
95
|
+
"", `${r.scopes} scope(s) read · ${r.unowned.length} unowned path(s)${r.unowned.length ? `: ${r.unowned.join(", ")}` : ""}` +
|
|
96
|
+
` · ${r.missing.length} not on disk${r.missing.length ? `: ${r.missing.join(", ")}` : ""}`].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const ARGV_SPEC = {
|
|
100
|
+
usage: "harness.mjs probe owner --slug <slug> [--path <p>]... [--format json|table] [--cwd <dir>]",
|
|
101
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
102
|
+
slug: { type: "str", required: true },
|
|
103
|
+
path: { type: "str", multiple: true },
|
|
104
|
+
format: { type: "enum", values: ["json", "table"], default: "json" },
|
|
105
|
+
cwd: { type: "path" },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Answer "which scope owns this path" from the committed contracts.
|
|
110
|
+
*
|
|
111
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
112
|
+
* @returns {Promise<void>} Exits 0 with the report, 2 when no scope contract can be read.
|
|
113
|
+
*/
|
|
114
|
+
export async function cli(rawArgv) {
|
|
115
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
116
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
117
|
+
const scopes = scopeSubstrates(cwd, args.slug);
|
|
118
|
+
if (!scopes.length) {
|
|
119
|
+
console.error(`probe owner: no readable scope contract for ${args.slug} — ownership is a property of the contracts, and there are none to read`);
|
|
120
|
+
process.exit(2);
|
|
121
|
+
}
|
|
122
|
+
const asked = (args.path || []).map((p) => ({ path: p, cited_by: "--path" }));
|
|
123
|
+
const inputs = asked.length ? asked : seamPaths(cwd, args.slug);
|
|
124
|
+
const paths = inputs.map((i) => ({ ...ownership(i.path, scopes, cwd), cited_by: i.cited_by }));
|
|
125
|
+
const report = {
|
|
126
|
+
slug: args.slug,
|
|
127
|
+
scopes: scopes.length,
|
|
128
|
+
source: asked.length ? "--path" : "wiring-map + project-profile",
|
|
129
|
+
paths,
|
|
130
|
+
unowned: paths.filter((p) => p.writers.length === 0).map((p) => p.path),
|
|
131
|
+
// Owned but absent: the wiring names a file nobody wrote — the gap a census reads as
|
|
132
|
+
// "unowned" when it does not check the disk.
|
|
133
|
+
missing: paths.filter((p) => p.exists === false).map((p) => p.path),
|
|
134
|
+
};
|
|
135
|
+
console.log(args.format === "table" ? renderTable(report) : JSON.stringify(report, null, 2));
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isMain(import.meta.url)) cli(process.argv.slice(2));
|
package/kernel/probe/stats.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Usage: node `harness probe stats` [--cwd <dir>] [--metrics-dir <dir>] [--slug <slug>] [--format json|table]
|
|
21
21
|
|
|
22
22
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
23
|
-
import { resolve, join } from "node:path";
|
|
23
|
+
import { resolve, join, relative, sep } from "node:path";
|
|
24
24
|
import { validate } from "../verify/envelope.mjs";
|
|
25
25
|
import { runArgs } from "../lib/argv.mjs";
|
|
26
26
|
import { localDir, decisions as decisionsPath, metricsDir as metricsDirPath, SHARED } from "../lib/paths.mjs";
|
|
@@ -283,6 +283,49 @@ export function readDecisions(cwd) {
|
|
|
283
283
|
} catch { return []; }
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Decision ledgers that landed OUTSIDE the project root — the trace a hook left when it filed
|
|
288
|
+
* under the shell's working directory instead of the project's.
|
|
289
|
+
*
|
|
290
|
+
* The hooks now resolve the root themselves, so a stray is either history from before that fix or
|
|
291
|
+
* evidence the resolver missed a layout; either way the rows in it are missing from the ledger
|
|
292
|
+
* `--hooks` reads, and a count that silently omits them is the inert-layer signature this report
|
|
293
|
+
* exists to expose. Bounded walk: depth-limited, skipping dependency and VCS directories.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} cwd - Project root.
|
|
296
|
+
* @param {number} [maxDepth=8] - How deep to look.
|
|
297
|
+
* @returns {Array<{path:string, rows:number}>} Each stray ledger and its row count.
|
|
298
|
+
*/
|
|
299
|
+
export function strayLedgers(cwd, maxDepth = 8) {
|
|
300
|
+
const SKIP = new Set(["node_modules", ".git", ".hg", ".svn", ".hvigor", ".idea", "dist", "build", "target", ".next", ".cache"]);
|
|
301
|
+
const out = [];
|
|
302
|
+
const home = decisionsPath(cwd);
|
|
303
|
+
/**
|
|
304
|
+
* Visit one directory level.
|
|
305
|
+
* @param {string} dir - Directory to scan.
|
|
306
|
+
* @param {number} depth - Its depth below `cwd`.
|
|
307
|
+
* @returns {void}
|
|
308
|
+
*/
|
|
309
|
+
const walk = (dir, depth) => {
|
|
310
|
+
if (depth > maxDepth) return;
|
|
311
|
+
let entries;
|
|
312
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
313
|
+
for (const e of entries) {
|
|
314
|
+
if (!e.isDirectory() || SKIP.has(e.name)) continue;
|
|
315
|
+
const sub = join(dir, e.name);
|
|
316
|
+
const candidate = decisionsPath(sub);
|
|
317
|
+
if (candidate !== home && existsSync(candidate)) {
|
|
318
|
+
let rows = 0;
|
|
319
|
+
try { rows = readFileSync(candidate, "utf8").split("\n").filter((l) => l.trim()).length; } catch { /* unreadable */ }
|
|
320
|
+
out.push({ path: relative(cwd, candidate).split(sep).join("/"), rows });
|
|
321
|
+
}
|
|
322
|
+
walk(sub, depth + 1);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
walk(cwd, 1);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
286
329
|
/**
|
|
287
330
|
* Render a StatsReport as a human-readable fixed-width table.
|
|
288
331
|
* @param {object} report - A validated StatsReport (see {@link aggregate}).
|
|
@@ -382,6 +425,10 @@ function renderHooks(r) {
|
|
|
382
425
|
lines.push("", "(zero rows: either no hook has run in this checkout, or the enforcement layer is inert —");
|
|
383
426
|
lines.push(" and that distinction is exactly what this ledger exists to make.)");
|
|
384
427
|
}
|
|
428
|
+
if (Array.isArray(r.stray_ledgers) && r.stray_ledgers.length) {
|
|
429
|
+
lines.push("", `stray ledgers: ${r.stray_ledgers.length} decisions.jsonl outside the project root — rows not counted above:`);
|
|
430
|
+
for (const s of r.stray_ledgers) lines.push(` ${s.path} (${s.rows} row(s))`);
|
|
431
|
+
}
|
|
385
432
|
return lines.join("\n");
|
|
386
433
|
}
|
|
387
434
|
|
|
@@ -403,7 +450,7 @@ export async function cli(rawArgv) {
|
|
|
403
450
|
if (args.ratchet || args.hooks) {
|
|
404
451
|
const out = {};
|
|
405
452
|
if (args.ratchet) out.ratchet = ratchetReport(readAllTrials(cwd, args.slug ?? null));
|
|
406
|
-
if (args.hooks) out.hooks = hooksReport(readDecisions(cwd));
|
|
453
|
+
if (args.hooks) out.hooks = { ...hooksReport(readDecisions(cwd)), stray_ledgers: strayLedgers(cwd) };
|
|
407
454
|
if (format === "table") {
|
|
408
455
|
const parts = [];
|
|
409
456
|
if (out.ratchet) parts.push(renderRatchet(out.ratchet));
|
package/kernel/reduce/hill.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { runArgs } from "../lib/argv.mjs";
|
|
|
9
9
|
import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger } from "../lib/paths.mjs";
|
|
10
10
|
import { readAllContracts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
11
11
|
import { evalVerdict } from "../probe/eval.mjs";
|
|
12
|
+
import { redBuildRounds } from "../verify/build.mjs";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Derive and write the hill phase for all scopes mechanically based on T0, T1, and ledger facts.
|
|
@@ -16,7 +17,7 @@ import { evalVerdict } from "../probe/eval.mjs";
|
|
|
16
17
|
* The derived phase follows these progression rules (facts move dots, not authors):
|
|
17
18
|
* - UPHILL_UNKNOWN: open unknowns > 0 in the ledger for this scope
|
|
18
19
|
* - UPHILL_SOLVED: unknowns 0, no T0-green yet
|
|
19
|
-
* - DOWNHILL_EXECUTION: ≥1 T0-green; T1/seesaw pending
|
|
20
|
+
* - DOWNHILL_EXECUTION: ≥1 T0-green in a round whose build gate is not red; T1/seesaw pending
|
|
20
21
|
* - FINISHED: T1 PASS ∧ seesaw green
|
|
21
22
|
*
|
|
22
23
|
* @param {string} cwd - The project root directory.
|
|
@@ -52,6 +53,15 @@ export function deriveHill(cwd, slug) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// 2. T0 facts per scope: has it achieved a green overall verdict? was seesaw also green?
|
|
56
|
+
//
|
|
57
|
+
// MINUS THE ROUNDS WHOSE BUILD GATE IS RED. A T0 verdict is one scope's fixtures inside its own
|
|
58
|
+
// substrate; the round build gate (`verify build`) is the feature's build and launch. Measured on
|
|
59
|
+
// a live run, all fourteen committed shards read DOWNHILL_EXECUTION off T0 verdicts from rounds in
|
|
60
|
+
// which the app never compiled and never launched — the dashboard showed a feature going downhill
|
|
61
|
+
// that had not started. A green fixture in a round the gate failed is not evidence the scope
|
|
62
|
+
// works; it is evidence the fixture does not test the build. No gate artifact at all leaves every
|
|
63
|
+
// verdict counting exactly as before.
|
|
64
|
+
const redRounds = redBuildRounds(cwd, slug);
|
|
55
65
|
const t0Facts = {};
|
|
56
66
|
if (existsSync(vDir)) {
|
|
57
67
|
for (const f of readdirSync(vDir)) {
|
|
@@ -59,7 +69,7 @@ export function deriveHill(cwd, slug) {
|
|
|
59
69
|
try {
|
|
60
70
|
const b = JSON.parse(readFileSync(join(vDir, f), "utf8"));
|
|
61
71
|
if (!t0Facts[b.scope_id]) t0Facts[b.scope_id] = { hasGreen: false, seesawGreen: false };
|
|
62
|
-
if (b.overall === "green") {
|
|
72
|
+
if (b.overall === "green" && !redRounds.has(Number(b.round))) {
|
|
63
73
|
t0Facts[b.scope_id].hasGreen = true;
|
|
64
74
|
// Read the REAL seesaw result off the verdict artifact (`t0.mjs`'s `writeArtifact()`
|
|
65
75
|
// already persists the full `{ran, pass, scopes_checked, failing}` object), rather than
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The round build gate — does the FEATURE build and launch, before the judge is asked about it.
|
|
3
|
+
//
|
|
4
|
+
// WHY A GATE ABOVE T0. A T0 verdict is one scope's fixtures, run inside that scope's substrate, and
|
|
5
|
+
// the fixtures are the scope-architect's to write. Nothing in that layer proves the feature as a
|
|
6
|
+
// whole compiles or starts. Measured on a live mobile run: every one of 30 T0 trials went green on
|
|
7
|
+
// its first try — the fixtures were TypeScript stand-ins, structural greps and a test-suite wrapper
|
|
8
|
+
// that never compiled its sources — while the ledger's own `run_cmd` failed, and once the compiler
|
|
9
|
+
// could actually reach the new files it reported 58 errors. Three rounds of EVAL then graded a
|
|
10
|
+
// blank screen: nothing in the loop had ever installed or launched the app. The hill shards, derived
|
|
11
|
+
// from those T0 verdicts, all read DOWNHILL_EXECUTION.
|
|
12
|
+
//
|
|
13
|
+
// Two lessons, and this gate is both:
|
|
14
|
+
//
|
|
15
|
+
// 1. A green exit code from the build is not proof the feature compiled. Some toolchains compile
|
|
16
|
+
// only what is reachable from an entry point, so a scope's files can sit outside the compiled
|
|
17
|
+
// set and the build stays green. The exit code is the necessary half; `build_probe` is where a
|
|
18
|
+
// project asserts the other half (the artifact covers what the run wrote), in whatever way its
|
|
19
|
+
// toolchain makes possible.
|
|
20
|
+
// 2. Nothing else in the loop launches the app. `launch_probe` is where a project installs, starts
|
|
21
|
+
// and asserts a first screen. For the mobile archetype its absence is called out every round,
|
|
22
|
+
// because "on-device install unverified" was an L0 risk with no owner for three rounds.
|
|
23
|
+
//
|
|
24
|
+
// WHAT RUNS, in order, each stopping the rest on failure: the ledger's `run_cmd` (the build),
|
|
25
|
+
// then the profile's `build_probe`, then its `launch_probe`. All three are commands the tech lead
|
|
26
|
+
// pinned at GATE L0 — this script invents none of them, and a run that declares none of them gets
|
|
27
|
+
// exit 3 and no artifact, which every reader treats as "no gate declared" rather than as green.
|
|
28
|
+
//
|
|
29
|
+
// THE RED GATE IS THE NEXT ROUND'S BUG LIST. `harness compile` reads the latest gate artifact for
|
|
30
|
+
// the previous round and addresses each failing step to the scopes whose substrate contains the
|
|
31
|
+
// files the output names (unowned → every scope, marked), exactly as it does for an EVAL verdict.
|
|
32
|
+
// `reduce hill` reads the same artifact: a T0-green verdict from a round whose gate is red moves no
|
|
33
|
+
// scope downhill. Both are the same file, written once here — zero LLM tokens, same as T0.
|
|
34
|
+
//
|
|
35
|
+
// Usage:
|
|
36
|
+
// node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify build --slug <slug> --round N [--cwd <dir>]
|
|
37
|
+
//
|
|
38
|
+
// Exit code: 0 = green, 1 = red, 2 = bad argv, 3 = nothing declared (no run_cmd, no probes).
|
|
39
|
+
|
|
40
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
42
|
+
import { spawnSync } from "node:child_process";
|
|
43
|
+
import { createHash } from "node:crypto";
|
|
44
|
+
import { runArgs, isMain } from "../lib/argv.mjs";
|
|
45
|
+
import { harnessRun, projectProfile, roundBuildDir, localRoot, runIdFromRoot } from "../lib/paths.mjs";
|
|
46
|
+
import { readContract, readAllContracts, PROJECT_PROFILE, SCOPE_CONTRACT, splitFrontmatter } from "../lib/contract.mjs";
|
|
47
|
+
import { scopesDir } from "../lib/paths.mjs";
|
|
48
|
+
import { digest } from "../probe/digest.mjs";
|
|
49
|
+
|
|
50
|
+
/** The three steps, in the order they run. */
|
|
51
|
+
export const STEPS = ["run_cmd", "build_probe", "launch_probe"];
|
|
52
|
+
|
|
53
|
+
/** Archetypes whose product cannot be judged without being launched on a device or simulator. */
|
|
54
|
+
export const LAUNCH_REQUIRED_ARCHETYPES = new Set(["mobile"]);
|
|
55
|
+
|
|
56
|
+
const TAIL_BYTES = 8 * 1024;
|
|
57
|
+
const tail = (s) => (s || "").length > TAIL_BYTES ? (s || "").slice(-TAIL_BYTES) : (s || "");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The commands this gate runs for a feature, read from the artifacts that declare them.
|
|
61
|
+
*
|
|
62
|
+
* `run_cmd` comes from the run ledger's frontmatter (`harness-run.md`, written at GATE L0 and
|
|
63
|
+
* already what every EVAL order carries); the two probes come from the committed project profile.
|
|
64
|
+
* Nothing is inferred: an absent field is an absent step.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} cwd - Project root.
|
|
67
|
+
* @param {string} slug - Feature slug.
|
|
68
|
+
* @returns {{archetype:(string|null), steps:Array<{kind:string, cmd:string}>, warnings:string[]}}
|
|
69
|
+
* The declared steps in run order, the profile's archetype, and the warnings a reader should
|
|
70
|
+
* see — currently one: a launch-required archetype with no `launch_probe`.
|
|
71
|
+
*/
|
|
72
|
+
export function declaredSteps(cwd, slug) {
|
|
73
|
+
const warnings = [];
|
|
74
|
+
let runCmd = null;
|
|
75
|
+
try {
|
|
76
|
+
const hr = splitFrontmatter(readFileSync(harnessRun(cwd, slug), "utf8")).meta || {};
|
|
77
|
+
runCmd = typeof hr.run_cmd === "string" && hr.run_cmd.trim() ? hr.run_cmd.trim() : null;
|
|
78
|
+
} catch { /* no ledger — no run_cmd */ }
|
|
79
|
+
|
|
80
|
+
let profile = null;
|
|
81
|
+
const pp = projectProfile(cwd, slug);
|
|
82
|
+
if (existsSync(pp)) profile = readContract(pp, PROJECT_PROFILE)?.contract || null;
|
|
83
|
+
const archetype = typeof profile?.archetype === "string" ? profile.archetype : null;
|
|
84
|
+
const pick = (k) => (typeof profile?.[k] === "string" && profile[k].trim() ? profile[k].trim() : null);
|
|
85
|
+
|
|
86
|
+
const steps = [];
|
|
87
|
+
if (runCmd) { steps.push({ kind: "run_cmd", cmd: runCmd }); warnings.push(...fixtureCoverageWarnings(cwd, slug, runCmd)); }
|
|
88
|
+
else warnings.push("the run ledger declares no run_cmd — the build itself is not part of this gate");
|
|
89
|
+
const buildProbe = pick("build_probe");
|
|
90
|
+
if (buildProbe) steps.push({ kind: "build_probe", cmd: buildProbe });
|
|
91
|
+
const launchProbe = pick("launch_probe");
|
|
92
|
+
if (launchProbe) steps.push({ kind: "launch_probe", cmd: launchProbe });
|
|
93
|
+
else if (archetype && LAUNCH_REQUIRED_ARCHETYPES.has(archetype)) {
|
|
94
|
+
warnings.push(`archetype ${archetype} declares no launch_probe in project-profile.md — nothing in the loop ` +
|
|
95
|
+
`launches the app, so a blank first screen survives every round. Give the install/launch risk an owner: ` +
|
|
96
|
+
`a command that installs the built artifact, starts it, asserts the first screen and fails on fatal logs.`);
|
|
97
|
+
}
|
|
98
|
+
return { archetype, steps, warnings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The executable a run command actually invokes — its basename, past any `cd …&&`, `env`, or
|
|
103
|
+
* `VAR=value` prefix. `cd app && DEVECO_SDK_HOME=/x /tools/hvigor/bin/hvigorw assembleHap` → `hvigorw`.
|
|
104
|
+
* @param {string} runCmd - The ledger's run command.
|
|
105
|
+
* @returns {(string|null)} The tool's basename, or null when none can be read.
|
|
106
|
+
*/
|
|
107
|
+
export function buildTool(runCmd) {
|
|
108
|
+
const segments = String(runCmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean);
|
|
109
|
+
for (const seg of segments) {
|
|
110
|
+
const tokens = seg.split(/\s+/).filter((t) => t && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) && t !== "env" && t !== "cd");
|
|
111
|
+
if (seg.startsWith("cd ")) continue;
|
|
112
|
+
if (!tokens.length) continue;
|
|
113
|
+
const base = tokens[0].split(/[\\/]/).pop();
|
|
114
|
+
if (base) return base;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Scopes whose fixtures never invoke the build tool `run_cmd` names — ADVISORY.
|
|
121
|
+
*
|
|
122
|
+
* A T0 layer can be entirely green on a feature that does not compile when every fixture is a
|
|
123
|
+
* grep or a stand-in compiler (measured: 13 of 15 fixtures on one run). The harness cannot know
|
|
124
|
+
* what a project's fixtures should run, but it can see when none of a scope's fixture commands
|
|
125
|
+
* mention the tool the ledger builds with, and say so beside the gate's verdict. A warning, not a
|
|
126
|
+
* failure: a library scope with no compiled surface is a legitimate reason for the mismatch.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} cwd - Project root.
|
|
129
|
+
* @param {string} slug - Feature slug.
|
|
130
|
+
* @param {(string|null)} runCmd - The ledger's run command.
|
|
131
|
+
* @returns {string[]} One warning per scope whose fixtures name the tool nowhere.
|
|
132
|
+
*/
|
|
133
|
+
export function fixtureCoverageWarnings(cwd, slug, runCmd) {
|
|
134
|
+
const tool = buildTool(runCmd);
|
|
135
|
+
if (!tool) return [];
|
|
136
|
+
const out = [];
|
|
137
|
+
let contracts = [];
|
|
138
|
+
try { contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map((x) => x.contract); } catch { return []; }
|
|
139
|
+
for (const c of contracts) {
|
|
140
|
+
const fixtures = Array.isArray(c.e2e_verification_fixtures) ? c.e2e_verification_fixtures : [];
|
|
141
|
+
if (!fixtures.length || fixtures.some((f) => String(f).includes(tool))) continue;
|
|
142
|
+
out.push(`scope ${c.scope_id}: none of its ${fixtures.length} fixture(s) invoke ${tool} (the ledger's build tool) — ` +
|
|
143
|
+
`its T0 green is not evidence the scope compiles; only this gate's run_cmd is.`);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run one gate command and capture its outcome (10-minute timeout), output tails kept for the digest.
|
|
150
|
+
* @param {{kind:string, cmd:string}} step - The step to run.
|
|
151
|
+
* @param {string} cwd - Working directory.
|
|
152
|
+
* @returns {object} `{kind, cmd, exit, pass, stdout_tail, stderr_tail, error?}`.
|
|
153
|
+
*/
|
|
154
|
+
export function runStep(step, cwd) {
|
|
155
|
+
const r = spawnSync(step.cmd, { shell: true, cwd, encoding: "utf8", timeout: 10 * 60 * 1000 });
|
|
156
|
+
const error = r.error ? String(r.error.message || r.error) : null;
|
|
157
|
+
return {
|
|
158
|
+
kind: step.kind, cmd: step.cmd,
|
|
159
|
+
exit: r.status ?? 1, pass: r.status === 0,
|
|
160
|
+
stdout_tail: tail(r.stdout), stderr_tail: tail(r.stderr),
|
|
161
|
+
...(error ? { error } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run the declared steps in order, stopping at the first failure — a probe over a build that did
|
|
167
|
+
* not complete answers nothing, and would only bury the real error under a second one.
|
|
168
|
+
*
|
|
169
|
+
* @param {Array<{kind:string, cmd:string}>} steps - From {@link declaredSteps}.
|
|
170
|
+
* @param {string} cwd - Working directory.
|
|
171
|
+
* @returns {{overall:("green"|"red"), steps:Array<object>}} Every declared step, the ones not
|
|
172
|
+
* reached marked `skipped: true`.
|
|
173
|
+
*/
|
|
174
|
+
export function runGate(steps, cwd) {
|
|
175
|
+
const out = [];
|
|
176
|
+
let failed = false;
|
|
177
|
+
for (const step of steps) {
|
|
178
|
+
if (failed) { out.push({ kind: step.kind, cmd: step.cmd, skipped: true }); continue; }
|
|
179
|
+
const r = runStep(step, cwd);
|
|
180
|
+
out.push(r);
|
|
181
|
+
if (!r.pass) failed = true;
|
|
182
|
+
}
|
|
183
|
+
return { overall: failed ? "red" : "green", steps: out };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every gate artifact for a round, oldest first.
|
|
188
|
+
* @param {string} cwd - Project root.
|
|
189
|
+
* @param {string} slug - Feature slug.
|
|
190
|
+
* @param {number} round - Round number.
|
|
191
|
+
* @returns {Array<{path:string, trial:number, body:object}>} Parsed artifacts; unreadable ones skipped.
|
|
192
|
+
*/
|
|
193
|
+
export function roundBuildArtifacts(cwd, slug, round) {
|
|
194
|
+
const dir = roundBuildDir(cwd, slug);
|
|
195
|
+
let files;
|
|
196
|
+
try { files = readdirSync(dir); } catch { return []; }
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const f of files) {
|
|
199
|
+
const m = f.match(/^r(\d+)-t(\d+)\.json$/);
|
|
200
|
+
if (!m || Number(m[1]) !== Number(round)) continue;
|
|
201
|
+
try { out.push({ path: join(dir, f), trial: Number(m[2]), body: JSON.parse(readFileSync(join(dir, f), "utf8")) }); }
|
|
202
|
+
catch { /* skip */ }
|
|
203
|
+
}
|
|
204
|
+
return out.sort((a, b) => a.trial - b.trial);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The gate's latest word on a round — the artifact readers branch on.
|
|
209
|
+
*
|
|
210
|
+
* Latest, because the gate may run again after a hand fix between launches, and the run must act on
|
|
211
|
+
* the current state of the build, not the first one recorded. Every earlier artifact stays on disk.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} cwd - Project root.
|
|
214
|
+
* @param {string} slug - Feature slug.
|
|
215
|
+
* @param {number} round - Round number.
|
|
216
|
+
* @returns {(object|null)} The latest artifact body, or null when the gate never ran for this round.
|
|
217
|
+
*/
|
|
218
|
+
export function latestRoundBuild(cwd, slug, round) {
|
|
219
|
+
const all = roundBuildArtifacts(cwd, slug, round);
|
|
220
|
+
return all.length ? all[all.length - 1].body : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The rounds whose latest gate artifact is red — the set `reduce hill` subtracts from.
|
|
225
|
+
* @param {string} cwd - Project root.
|
|
226
|
+
* @param {string} slug - Feature slug.
|
|
227
|
+
* @returns {Set<number>} Round numbers; empty when the gate never ran (non-regression: every
|
|
228
|
+
* T0 verdict then counts exactly as it did before the gate existed).
|
|
229
|
+
*/
|
|
230
|
+
export function redBuildRounds(cwd, slug) {
|
|
231
|
+
const latest = new Map();
|
|
232
|
+
let files;
|
|
233
|
+
try { files = readdirSync(roundBuildDir(cwd, slug)); } catch { return new Set(); }
|
|
234
|
+
for (const f of files) {
|
|
235
|
+
const m = f.match(/^r(\d+)-t(\d+)\.json$/);
|
|
236
|
+
if (!m) continue;
|
|
237
|
+
const round = Number(m[1]), trial = Number(m[2]);
|
|
238
|
+
if (!latest.has(round) || latest.get(round).trial < trial) latest.set(round, { trial, file: f });
|
|
239
|
+
}
|
|
240
|
+
const red = new Set();
|
|
241
|
+
for (const [round, { file }] of latest) {
|
|
242
|
+
try {
|
|
243
|
+
if (JSON.parse(readFileSync(join(roundBuildDir(cwd, slug), file), "utf8")).overall === "red") red.add(round);
|
|
244
|
+
} catch { /* unreadable → not proven red */ }
|
|
245
|
+
}
|
|
246
|
+
return red;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Write the gate artifact immutably (`wx`, next ordinal on collision — the T0 convention).
|
|
251
|
+
* @param {string} cwd - Project root.
|
|
252
|
+
* @param {string} slug - Feature slug.
|
|
253
|
+
* @param {number} round - Round number.
|
|
254
|
+
* @param {object} body - Artifact fields.
|
|
255
|
+
* @returns {{path:string, sha256:string, trial:number}} Where it landed and its digest.
|
|
256
|
+
*/
|
|
257
|
+
export function writeRoundBuild(cwd, slug, round, body) {
|
|
258
|
+
const dir = roundBuildDir(cwd, slug);
|
|
259
|
+
mkdirSync(dir, { recursive: true });
|
|
260
|
+
const existing = roundBuildArtifacts(cwd, slug, round);
|
|
261
|
+
for (let trial = (existing.length ? existing[existing.length - 1].trial : 0) + 1; ; trial++) {
|
|
262
|
+
const path = join(dir, `r${round}-t${trial}.json`);
|
|
263
|
+
const text = JSON.stringify({ schema_version: 1, round, trial, at: new Date().toISOString(), ...body }, null, 2);
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(path, text, { flag: "wx" });
|
|
266
|
+
return { path, sha256: createHash("sha256").update(text).digest("hex"), trial };
|
|
267
|
+
} catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const ARGV_SPEC = {
|
|
272
|
+
usage: "harness.mjs verify build --slug <slug> --round <N> [--cwd <dir>]",
|
|
273
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
274
|
+
slug: { type: "str", required: true },
|
|
275
|
+
round: { type: "int", min: 1, required: true },
|
|
276
|
+
cwd: { type: "path" },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Run the round build gate and write its artifact.
|
|
281
|
+
*
|
|
282
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
283
|
+
* @returns {Promise<void>} Exits 0 green, 1 red, 3 nothing declared, 2 bad argv.
|
|
284
|
+
*/
|
|
285
|
+
export async function cli(rawArgv) {
|
|
286
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
287
|
+
const cwd = resolve(args.cwd || process.cwd());
|
|
288
|
+
const { slug, round } = args;
|
|
289
|
+
const { archetype, steps, warnings } = declaredSteps(cwd, slug);
|
|
290
|
+
for (const w of warnings) console.error(`build-gate: ${w}`);
|
|
291
|
+
|
|
292
|
+
if (steps.length === 0) {
|
|
293
|
+
console.log(JSON.stringify({ round, overall: "skipped", steps: [], warnings,
|
|
294
|
+
reason: "nothing declared — no run_cmd in the run ledger, no build_probe or launch_probe in project-profile.md" }, null, 2));
|
|
295
|
+
process.exit(3);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const gate = runGate(steps, cwd);
|
|
299
|
+
const failing = gate.steps.filter((s) => !s.skipped && !s.pass);
|
|
300
|
+
const discovered = failing.flatMap((s) => digest(`${s.stdout_tail}\n${s.stderr_tail}`));
|
|
301
|
+
const runId = runIdFromRoot(localRoot(cwd, slug));
|
|
302
|
+
const { path, sha256, trial } = writeRoundBuild(cwd, slug, round, {
|
|
303
|
+
...(runId ? { run_id: runId } : {}),
|
|
304
|
+
archetype,
|
|
305
|
+
overall: gate.overall,
|
|
306
|
+
steps: gate.steps,
|
|
307
|
+
warnings,
|
|
308
|
+
discovered_tasks: discovered.slice(0, 16),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
console.log(JSON.stringify({
|
|
312
|
+
path, sha256, trial, round, overall: gate.overall, warnings,
|
|
313
|
+
steps: gate.steps.map((s) => (s.skipped ? { kind: s.kind, skipped: true } : { kind: s.kind, exit: s.exit, pass: s.pass })),
|
|
314
|
+
...(failing.length ? { failed_step: failing[0].kind, stderr_tail: failing[0].stderr_tail.slice(-1200) } : {}),
|
|
315
|
+
}, null, 2));
|
|
316
|
+
process.exit(gate.overall === "green" ? 0 : 1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (isMain(import.meta.url)) cli(process.argv.slice(2));
|
package/package.json
CHANGED