shapeup-sdlc 3.2.0 → 3.4.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/contract.mjs +20 -4
- package/kernel/lib/paths.mjs +12 -2
- package/kernel/probe/owner.mjs +139 -0
- package/kernel/probe/stats.mjs +49 -2
- package/kernel/reduce/board.mjs +26 -2
- package/kernel/reduce/graph.mjs +26 -12
- 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 +85 -13
package/kernel/lib/contract.mjs
CHANGED
|
@@ -172,7 +172,16 @@ export function coerce(raw) {
|
|
|
172
172
|
// A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
|
|
173
173
|
// would turn it into a list and change its type on a round-trip.
|
|
174
174
|
if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
|
|
175
|
+
// A QUOTED SCALAR IS A STRING, VERBATIM — the same rule the list test above already applies, one
|
|
176
|
+
// step further in. Unquoting FIRST and then testing the bareword literals meant the quotes bought
|
|
177
|
+
// nothing: `"false"` unwrapped to `false` and then matched the boolean test, so a cell whose value
|
|
178
|
+
// is genuinely the word "false" re-read as the boolean. Same for `"true"`, `"123"`, `"~"` and `""`
|
|
179
|
+
// — a string changed TYPE on a round trip, silently, the first time its contract was rewritten.
|
|
180
|
+
// Quoting is how an author says "this is text"; honouring that is what makes the round trip total.
|
|
181
|
+
const quoted = trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'")
|
|
182
|
+
&& trimmed[trimmed.length - 1] === trimmed[0];
|
|
175
183
|
const v = unquote(trimmed);
|
|
184
|
+
if (quoted) return v;
|
|
176
185
|
if (v === "true") return true;
|
|
177
186
|
if (v === "false") return false;
|
|
178
187
|
if (v === "~" || v === "null" || v === "") return null;
|
|
@@ -192,10 +201,17 @@ export function uncoerce(v) {
|
|
|
192
201
|
// side instead of the reader's.
|
|
193
202
|
if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
|
|
194
203
|
const s = String(v);
|
|
195
|
-
//
|
|
196
|
-
// quoted
|
|
197
|
-
//
|
|
198
|
-
|
|
204
|
+
// THE READER IS THE ORACLE. Anything whose plain text would come back as a DIFFERENT VALUE goes
|
|
205
|
+
// out quoted — and the only honest test of that is to ask `coerce` itself.
|
|
206
|
+
//
|
|
207
|
+
// This subsumes the older rule (a scalar that begins AND ends with a quote) and closes the family
|
|
208
|
+
// it missed: a string whose text is a bareword literal changed TYPE on a round trip. The string
|
|
209
|
+
// "false" re-read as the boolean false, "123" as the number 123, "[a, b]" as a two-member list,
|
|
210
|
+
// "~" as null. Each is a value an author can legitimately write in a cell, and each silently
|
|
211
|
+
// became something else the first time the contract was rewritten. `coerce(s) !== v` catches all
|
|
212
|
+
// of them at once and leaves every value that already round-trips untouched — a path, a sentence,
|
|
213
|
+
// a real boolean, a real number.
|
|
214
|
+
if (coerce(s) !== v) return JSON.stringify(s);
|
|
199
215
|
return s;
|
|
200
216
|
}
|
|
201
217
|
|
package/kernel/lib/paths.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export const requirements = (cwd, slug) => join(sharedRoot(cwd, slug), "requirem
|
|
|
111
111
|
export const hillDir = (cwd, slug) => join(sharedRoot(cwd, slug), "hill");
|
|
112
112
|
/** The frozen ship report, written once at GATE L4. */
|
|
113
113
|
export const report = (cwd, slug) => join(sharedRoot(cwd, slug), "REPORT.md");
|
|
114
|
-
/** Team-shared coaching rules, read back by the
|
|
114
|
+
/** Team-shared coaching rules, read back by the coachable workers and by the tech lead at GATE L0. */
|
|
115
115
|
export const knowledgeBaseDir = (cwd) => join(sharedDir(cwd), "knowledge-base");
|
|
116
116
|
/** One worker's coaching file. */
|
|
117
117
|
export const knowledgeBase = (cwd, skill) => join(knowledgeBaseDir(cwd), `${skill}.md`);
|
|
@@ -208,6 +208,16 @@ export const trials = (cwd, slug) => join(t0Dir(cwd, slug), "trials.jsonl");
|
|
|
208
208
|
export const gates = (cwd, slug) => join(localRoot(cwd, slug), "gates.jsonl");
|
|
209
209
|
/** Finished-scope fixture registry for the seesaw regression check. */
|
|
210
210
|
export const seesawRegistry = (cwd, slug) => join(localRoot(cwd, slug), "seesaw", "registry.json");
|
|
211
|
+
/**
|
|
212
|
+
* The round build gate's verdicts — one immutable artifact per gate run, `r<N>-t<T>.json`.
|
|
213
|
+
*
|
|
214
|
+
* A separate directory from `t0/`, on purpose. A T0 verdict is one scope's fixtures inside that
|
|
215
|
+
* scope's substrate; this is the FEATURE's build, launched the way the ledger's `run_cmd` and the
|
|
216
|
+
* profile's probes say a user would, and it belongs to no scope. Written by `harness verify build`,
|
|
217
|
+
* read by `reduce hill` (a green T0 in a round whose build is red moves no dot) and by
|
|
218
|
+
* `harness compile` (a red gate is the next round's bug list).
|
|
219
|
+
*/
|
|
220
|
+
export const roundBuildDir = (cwd, slug) => join(localRoot(cwd, slug), "build");
|
|
211
221
|
/** Evaluator output — report, evidence, verdict ledger. */
|
|
212
222
|
export const evaluationDir = (cwd, slug) => join(localRoot(cwd, slug), "evaluation");
|
|
213
223
|
/** The workflow launcher's run directory — `journal.jsonl` and the launch `result.json`. */
|
|
@@ -385,7 +395,7 @@ export const globShared = (slug, ...parts) => [SHARED, slug, ...parts].join("/")
|
|
|
385
395
|
|
|
386
396
|
/**
|
|
387
397
|
* The coaching file a coachable worker reads, as a repo-relative path for the WorkOrder payload.
|
|
388
|
-
* @param {string} skill -
|
|
398
|
+
* @param {string} skill - A coachable worker name (`COACHABLE` in compile.mjs), or `tech-lead`.
|
|
389
399
|
* @returns {string} e.g. `shapeup/knowledge-base/task-executor.md`.
|
|
390
400
|
*/
|
|
391
401
|
export const relKnowledgeBase = (skill) => [SHARED, "knowledge-base", `${skill}.md`].join("/");
|
|
@@ -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/board.mjs
CHANGED
|
@@ -68,7 +68,11 @@ const listField = (fm, key) => {
|
|
|
68
68
|
*/
|
|
69
69
|
export function parseBoard(tasksDir) {
|
|
70
70
|
if (!existsSync(tasksDir)) return [];
|
|
71
|
-
|
|
71
|
+
// SORTED, because `criticalPath` breaks ties on strict `>` and therefore keeps the FIRST chain it
|
|
72
|
+
// meets among equal-hours chains. Directory order is a filesystem detail (APFS happens to return
|
|
73
|
+
// sorted; ext4's hash order does not, and neither does a rename), so an unsorted read made a
|
|
74
|
+
// derived value depend on which machine ran it. Every sibling reader in the kernel already sorts.
|
|
75
|
+
return readdirSync(tasksDir).sort()
|
|
72
76
|
.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))
|
|
73
77
|
.map((f) => {
|
|
74
78
|
const body = readFileSync(join(tasksDir, f), "utf8");
|
|
@@ -130,10 +134,30 @@ export function criticalPath(tasks) {
|
|
|
130
134
|
}
|
|
131
135
|
return (memo[id] = { hours: best.hours + t.hours, chain: [...best.chain, id] });
|
|
132
136
|
};
|
|
137
|
+
// TIES BREAK ON CONTENT, NOT ON ARRIVAL. `>` alone keeps whichever equal-hours chain is MET
|
|
138
|
+
// FIRST, which is input order — so this function returned a different critical path for the same
|
|
139
|
+
// board depending only on how its task list happened to be ordered. Measured: two disjoint
|
|
140
|
+
// 5-hour chains, five permutations of one list, two different answers.
|
|
141
|
+
//
|
|
142
|
+
// Sorting the reader that feeds it (`parseBoard`) makes the input stable and therefore hides this
|
|
143
|
+
// on any one machine, but it leaves the ORDER-DEPENDENCE in place one call up — a caller with its
|
|
144
|
+
// own ordering, or a future second reader, re-opens it. A derived value has to be a function of
|
|
145
|
+
// the board, not of the walk that produced it, so the tie is resolved here: same hours, then the
|
|
146
|
+
// lexicographically smaller chain. Deterministic on every machine and for every caller.
|
|
147
|
+
/**
|
|
148
|
+
* Is chain `c` a better critical path than the incumbent `b`?
|
|
149
|
+
* @param {{hours:number, chain:string[]}} c - The candidate chain.
|
|
150
|
+
* @param {{hours:number, chain:string[]}} b - The incumbent best.
|
|
151
|
+
* @returns {boolean} True when `c` has more hours, or ties on hours and sorts first by chain
|
|
152
|
+
* content — so the answer is a function of the board rather than of the iteration order.
|
|
153
|
+
*/
|
|
154
|
+
const better = (c, b) => c.hours > b.hours
|
|
155
|
+
|| (c.hours === b.hours && c.chain.length > 0
|
|
156
|
+
&& (b.chain.length === 0 || c.chain.join("\u0000") < b.chain.join("\u0000")));
|
|
133
157
|
let best = { hours: 0, chain: [] };
|
|
134
158
|
for (const t of tasks) {
|
|
135
159
|
const c = longest(t.id);
|
|
136
|
-
if (c
|
|
160
|
+
if (better(c, best)) best = c;
|
|
137
161
|
}
|
|
138
162
|
return best;
|
|
139
163
|
}
|
package/kernel/reduce/graph.mjs
CHANGED
|
@@ -43,7 +43,7 @@ export const WORK_NODES = ["Run", "Order", "Result", "Verdict", "Trial", "GateDe
|
|
|
43
43
|
export const DOMAIN_NODES = ["Scope", "UseCase", "Requirement", "Seam"];
|
|
44
44
|
|
|
45
45
|
/** Edge types. Each names a direction that is meaningful to read backwards. */
|
|
46
|
-
export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM"];
|
|
46
|
+
export const EDGES = ["PRODUCED", "EVALUATES", "SUPERSEDES", "COVERS", "DEPENDS_ON", "DERIVED_FROM", "IMPLEMENTS"];
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Read the graph as a log and fold it into nodes and edges.
|
|
@@ -63,7 +63,10 @@ export function readGraph(cwd, slug) {
|
|
|
63
63
|
let row;
|
|
64
64
|
try { row = JSON.parse(line); } catch { continue; } // a torn line proves nothing; skip it
|
|
65
65
|
lines++;
|
|
66
|
-
|
|
66
|
+
// LAST LINE WINS, as the banner says — a REPLACE, not a merge. Merging kept attributes from
|
|
67
|
+
// superseded lines alive forever, so an attribute removed from an artifact survived in the
|
|
68
|
+
// projection and a rebuilt graph stopped matching the maintained one.
|
|
69
|
+
if (row.k === "node" && row.id) nodes.set(row.id, row);
|
|
67
70
|
else if (row.k === "edge" && row.from && row.to && row.t) edges.set(`${row.from}|${row.t}|${row.to}`, row);
|
|
68
71
|
}
|
|
69
72
|
return { nodes, edges, lines };
|
|
@@ -194,16 +197,19 @@ export function project(cwd, slug) {
|
|
|
194
197
|
source: g.source ?? null, note: g.note ?? null, round: g.round ?? null,
|
|
195
198
|
run_id: g.run_id ?? runId ?? null,
|
|
196
199
|
});
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// `
|
|
200
|
+
// EVERY gate depends on the Run, UNCONDITIONALLY — and a round-scoped one ALSO depends on that
|
|
201
|
+
// round's T0 verdict(s), the evidence it was decided against.
|
|
202
|
+
//
|
|
203
|
+
// The Run edge used to be an `else` FALLBACK, and a conditional edge TARGET cannot survive an
|
|
204
|
+
// append-only log. Gates are crossed BEFORE the round's verdict artifact lands, so the first
|
|
205
|
+
// projection minted `DEPENDS_ON run` and a later one added the verdict edges beside it —
|
|
206
|
+
// and `appendGraph` has no tombstone, so the fallback became permanent. The incremental graph
|
|
207
|
+
// then carried an edge a rebuild does not imply, breaking the one property this file exists to
|
|
208
|
+
// promise: that it can be deleted and rebuilt identically. Unconditional is also truer — a gate
|
|
209
|
+
// does depend on its run. The fix is not a guard; it is removing the condition.
|
|
210
|
+
if (runNode) edge(id, "DEPENDS_ON", runNode);
|
|
201
211
|
const roundVerdicts = (g.gate === "L2" || g.gate === "L3") ? verdictIdsByRound.get(g.round) : null;
|
|
202
|
-
|
|
203
|
-
for (const vid of roundVerdicts) edge(id, "DEPENDS_ON", vid);
|
|
204
|
-
} else if (runNode) {
|
|
205
|
-
edge(id, "DEPENDS_ON", runNode);
|
|
206
|
-
}
|
|
212
|
+
for (const vid of roundVerdicts || []) edge(id, "DEPENDS_ON", vid);
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
|
|
@@ -221,7 +227,15 @@ export function project(cwd, slug) {
|
|
|
221
227
|
// projected to two nodes. `--trace` is supposed to reach "the execution record"; it reached
|
|
222
228
|
// whichever scope happened to be written last. `baseline_trial` is chosen from the same
|
|
223
229
|
// scope's prior rows, so the SUPERSEDES edge resolves inside the same partition.
|
|
224
|
-
|
|
230
|
+
//
|
|
231
|
+
// AND THE RUN IS PART OF THE KEY TOO, for the same reason one step out. A trial ordinal
|
|
232
|
+
// restarts at 1 in the next run while `trials.jsonl` is APPEND-ONLY, so both runs' rows live
|
|
233
|
+
// on disk together and `trial:<slug>:<scope>:1` named two of them — the second run silently
|
|
234
|
+
// overwrote the first run's execution record, which is the identical defect the paragraph
|
|
235
|
+
// above records fixing once already, one key component short. `baseline_trial` is chosen from
|
|
236
|
+
// the same run's rows, so SUPERSEDES still resolves inside the partition.
|
|
237
|
+
const trialRun = t.run_id ?? runId ?? "norun";
|
|
238
|
+
const trialKey = (n) => `trial:${slug}:${trialRun}:${t.scope_id ? `${t.scope_id}:` : ""}${n}`;
|
|
225
239
|
const id = trialKey(t.trial);
|
|
226
240
|
node(id, "Trial", {
|
|
227
241
|
trial: t.trial, round: t.round ?? null, attempt: t.attempt ?? null,
|
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
|