shapeup-sdlc 3.7.1 → 3.7.3
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 +7 -6
- package/README.md +18 -8
- package/SECURITY.md +2 -2
- package/hooks/lib/decision.mjs +10 -2
- package/hooks/sandbox-guard.mjs +78 -11
- package/kernel/compile.mjs +27 -1
- package/kernel/gate.mjs +1 -1
- package/kernel/lib/paths.mjs +38 -3
- package/kernel/probe/eval.mjs +79 -7
- package/kernel/probe/leg.mjs +80 -3
- package/kernel/probe/resume.mjs +10 -5
- package/kernel/reduce/graph.mjs +23 -4
- package/kernel/reduce/hill.mjs +200 -25
- package/kernel/reduce/ship.mjs +43 -1
- package/kernel/report/export.mjs +6 -1
- package/kernel/report/facts.mjs +3 -0
- package/kernel/schemas/domain.schema.json +7 -2
- package/package.json +1 -1
- package/skills/hill-chart/SKILL.md +10 -6
- package/skills/tech-lead/references/gates.md +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +133 -19
package/kernel/probe/leg.mjs
CHANGED
|
@@ -59,6 +59,65 @@ export function readLegs(path) {
|
|
|
59
59
|
* result exists that was never ingested; a scope with no results at all reports `closed: false`
|
|
60
60
|
* with an empty `orders` list, so a caller can tell "nothing ran" from "nothing was applied".
|
|
61
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* Every order the run compiled, with whether its result landed and whether the single writer
|
|
64
|
+
* applied it — planning orders (`analyze.json`) and build orders (`alpha-r1-a1.json`) alike.
|
|
65
|
+
*
|
|
66
|
+
* Why the generic form exists: the per-scope reading below was the only reader of the leg ledger
|
|
67
|
+
* in the run loop, and it was asked in one place, behind the checks that decide a scope is green.
|
|
68
|
+
* Measured on a live run: five dispatches, five results, three leg rows — and the planning phase
|
|
69
|
+
* whose result nobody applied walked on, because its post-condition checked the artifact the
|
|
70
|
+
* worker wrote directly and never asked whether the writer ran. This is the question, asked of any
|
|
71
|
+
* order by name.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} cwd - Project root.
|
|
74
|
+
* @param {string} slug - Feature slug.
|
|
75
|
+
* @returns {{order:string, name:string, order_id:(string|null), has_result:boolean, applied:boolean}[]}
|
|
76
|
+
*/
|
|
77
|
+
export function legsOf(cwd, slug) {
|
|
78
|
+
const oDir = ordersDir(cwd, slug);
|
|
79
|
+
const rDir = resultsDir(cwd, slug);
|
|
80
|
+
const ingested = new Set(readLegs(legLedger(cwd, slug))
|
|
81
|
+
.filter((r) => r.ingested_at)
|
|
82
|
+
.map((r) => String(r.order_id)));
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const f of (existsSync(oDir) ? readdirSync(oDir) : []).filter((x) => x.endsWith(".json")).sort()) {
|
|
85
|
+
let orderId = null;
|
|
86
|
+
try { orderId = JSON.parse(readFileSync(join(oDir, f), "utf8")).order_id ?? null; } catch { /* unreadable — reported as unapplied */ }
|
|
87
|
+
out.push({
|
|
88
|
+
order: join(oDir, f),
|
|
89
|
+
name: f.replace(/\.json$/, ""),
|
|
90
|
+
order_id: orderId,
|
|
91
|
+
has_result: existsSync(join(rDir, f)),
|
|
92
|
+
applied: orderId !== null && ingested.has(orderId),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One order by its file stem — `analyze`, `wire`, `alpha-r1-a1` — and whether its leg closed.
|
|
100
|
+
* @param {string} cwd - Project root.
|
|
101
|
+
* @param {string} slug - Feature slug.
|
|
102
|
+
* @param {string} name - The order file's stem.
|
|
103
|
+
* @returns {{closed:boolean, found:boolean, order:(string|null), order_id:(string|null), has_result:boolean, applied:boolean}}
|
|
104
|
+
*/
|
|
105
|
+
export function orderLegState(cwd, slug, name) {
|
|
106
|
+
const o = legsOf(cwd, slug).find((x) => x.name === name);
|
|
107
|
+
if (!o) return { closed: false, found: false, order: null, order_id: null, has_result: false, applied: false };
|
|
108
|
+
return { closed: o.applied, found: true, ...o };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The results on disk that no leg row applied — finished work the board never saw.
|
|
113
|
+
* @param {string} cwd - Project root.
|
|
114
|
+
* @param {string} slug - Feature slug.
|
|
115
|
+
* @returns {{order:string, name:string, order_id:(string|null)}[]}
|
|
116
|
+
*/
|
|
117
|
+
export function openLegs(cwd, slug) {
|
|
118
|
+
return legsOf(cwd, slug).filter((o) => o.has_result && !o.applied);
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
export function legState(cwd, slug, scopeId, round) {
|
|
63
122
|
const oDir = ordersDir(cwd, slug);
|
|
64
123
|
const rDir = resultsDir(cwd, slug);
|
|
@@ -89,11 +148,13 @@ export function legState(cwd, slug, scopeId, round) {
|
|
|
89
148
|
}
|
|
90
149
|
|
|
91
150
|
export const ARGV_SPEC = {
|
|
92
|
-
usage: "harness.mjs probe leg --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
|
|
151
|
+
usage: "harness.mjs probe leg --slug <slug> (--scope <scope-id> --round N | --order <stem> | --open) [--cwd <dir>]",
|
|
93
152
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
94
153
|
slug: { type: "str", required: true },
|
|
95
|
-
scope: { type: "str"
|
|
96
|
-
round: { type: "int", min: 1
|
|
154
|
+
scope: { type: "str" },
|
|
155
|
+
round: { type: "int", min: 1 },
|
|
156
|
+
order: { type: "str" },
|
|
157
|
+
open: { type: "flag" },
|
|
97
158
|
cwd: { type: "path" },
|
|
98
159
|
};
|
|
99
160
|
|
|
@@ -107,6 +168,22 @@ export const ARGV_SPEC = {
|
|
|
107
168
|
export function cli(rawArgv) {
|
|
108
169
|
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
109
170
|
const cwd = resolve(args.cwd || process.cwd());
|
|
171
|
+
// `--open`: every result nothing applied, across the whole run, any phase. Exit 0 when none.
|
|
172
|
+
if (args.open) {
|
|
173
|
+
const open = openLegs(cwd, args.slug);
|
|
174
|
+
console.log(JSON.stringify({ closed: open.length === 0, open_total: open.length, open: open.map((o) => o.order), open_ids: open.map((o) => o.order_id) }));
|
|
175
|
+
process.exit(open.length === 0 ? 0 : 1);
|
|
176
|
+
}
|
|
177
|
+
// `--order <stem>`: one order by file stem (`analyze`, `alpha-r1-a1`). Exit 0 when its leg closed.
|
|
178
|
+
if (args.order) {
|
|
179
|
+
const s = orderLegState(cwd, args.slug, args.order);
|
|
180
|
+
console.log(JSON.stringify(s));
|
|
181
|
+
process.exit(s.closed ? 0 : 1);
|
|
182
|
+
}
|
|
183
|
+
if (!args.scope || !args.round) {
|
|
184
|
+
console.error("probe leg: pass --scope <id> --round N, or --order <stem>, or --open");
|
|
185
|
+
process.exit(2);
|
|
186
|
+
}
|
|
110
187
|
const s = legState(cwd, args.slug, args.scope, args.round);
|
|
111
188
|
console.log(JSON.stringify({
|
|
112
189
|
closed: s.closed,
|
package/kernel/probe/resume.mjs
CHANGED
|
@@ -61,11 +61,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
61
61
|
import { runArgs } from "../lib/argv.mjs";
|
|
62
62
|
import { splitFrontmatter, uncoerce } from "../lib/contract.mjs";
|
|
63
63
|
import { globToRegExp } from "../verify/spec.mjs";
|
|
64
|
-
import {
|
|
65
|
-
intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
|
|
66
|
-
orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements,
|
|
67
|
-
exportRunDir,
|
|
68
|
-
} from "../lib/paths.mjs";
|
|
64
|
+
import { intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir, orientDir, activeOrder, activeScope, usecasesDir, breadboard, receipt, readReceipt, requirements, exportRunDir, lastRun, readRunId } from "../lib/paths.mjs";
|
|
69
65
|
import { evalVerdict } from "./eval.mjs";
|
|
70
66
|
import { collectRun, writeRun } from "../report/export.mjs";
|
|
71
67
|
|
|
@@ -709,6 +705,15 @@ export function closeRun(cwd, slug, { status, cause = null, withExport = true }
|
|
|
709
705
|
*/
|
|
710
706
|
const finishClose = (result) => {
|
|
711
707
|
const warning = shouldExport ? exportOnClose(cwd, slug) : null;
|
|
708
|
+
// The breadcrumb goes down before the pointers come up: the hooks that fire next — the
|
|
709
|
+
// scope-hammer census, the ship phase — resolve their run through whichever of the two exists,
|
|
710
|
+
// and there must be no instant in which neither does. Best-effort like the rest of this close.
|
|
711
|
+
try {
|
|
712
|
+
mkdirSync(dirname(lastRun(cwd)), { recursive: true });
|
|
713
|
+
writeFileSync(lastRun(cwd), JSON.stringify({
|
|
714
|
+
slug, run_id: readRunId(cwd, slug), closed_status: status, closed_at: new Date().toISOString(),
|
|
715
|
+
}) + "\n");
|
|
716
|
+
} catch { /* a missing breadcrumb costs the post-close rows their key, never the close */ }
|
|
712
717
|
const stuck = [];
|
|
713
718
|
for (const pointer of [activeOrder(cwd), activeScope(cwd)]) {
|
|
714
719
|
try { rmSync(pointer, { force: true }); } catch { /* fall through to the check below */ }
|
package/kernel/reduce/graph.mjs
CHANGED
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
// appended again and the LAST line wins on read, so the file is a log and the projection is a fold.
|
|
27
27
|
|
|
28
28
|
import { existsSync, readdirSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
29
|
+
import { readLegs } from "../probe/leg.mjs";
|
|
29
30
|
import { join, dirname, basename, resolve } from "node:path";
|
|
30
31
|
import { runArgs } from "../lib/argv.mjs";
|
|
31
32
|
import {
|
|
32
|
-
localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath,
|
|
33
|
-
gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath,
|
|
33
|
+
localRoot, receipt as receiptPath, ordersDir, resultsDir, verdictsDir, trials as trialsPath, gates as gatesPath, scopesDir, usecasesDir, requirements as requirementsPath, wiringMap as wiringMapPath, legLedger,
|
|
34
34
|
} from "../lib/paths.mjs";
|
|
35
35
|
import { readAllContracts, readContract, ucId, reqId, SCOPE_CONTRACT, WIRING_MAP } from "../lib/contract.mjs";
|
|
36
36
|
import { runIdFromReceipt } from "../lib/paths.mjs";
|
|
@@ -39,11 +39,11 @@ import { runIdFromReceipt } from "../lib/paths.mjs";
|
|
|
39
39
|
export const graphPath = (cwd, slug) => join(localRoot(cwd, slug), "graph.jsonl");
|
|
40
40
|
|
|
41
41
|
/** Node types, by family. A type outside these sets is a bug, not an extension point. */
|
|
42
|
-
export const WORK_NODES = ["Run", "Order", "Result", "Verdict", "Trial", "GateDecision"];
|
|
42
|
+
export const WORK_NODES = ["Run", "Order", "Result", "Leg", "Verdict", "Trial", "GateDecision"];
|
|
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", "IMPLEMENTS"];
|
|
46
|
+
export const EDGES = ["PRODUCED", "INGESTED", "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.
|
|
@@ -148,6 +148,22 @@ export function project(cwd, slug) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// Leg-completion rows — the record that separates "the result landed" from "the single writer
|
|
152
|
+
// applied it". It was in neither this graph nor the export, so a run could show five orders
|
|
153
|
+
// producing five results and nobody could see that only three were ever read. A Result with no
|
|
154
|
+
// INGESTED edge leaving it is finished work the board never saw, and `--subgraph run` names it.
|
|
155
|
+
for (const l of readLegs(legLedger(cwd, slug))) {
|
|
156
|
+
if (!l?.order_id) continue;
|
|
157
|
+
const id = `leg:${l.order_id}`;
|
|
158
|
+
node(id, "Leg", {
|
|
159
|
+
order_id: l.order_id, worker: l.worker ?? null, operation: l.operation ?? null,
|
|
160
|
+
scope_id: l.scope_id ?? null, round: l.round ?? null, attempt: l.attempt ?? null,
|
|
161
|
+
dispatched_at: l.dispatched_at ?? null, ingested_at: l.ingested_at ?? null,
|
|
162
|
+
attested: l.attested ?? null, run_id: l.run_id ?? runId ?? null,
|
|
163
|
+
});
|
|
164
|
+
edge(`result:${l.order_id}`, "INGESTED", id);
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
// T0 verdicts — the artifact the evaluator is required to cite, and the reason the lineage half
|
|
152
168
|
// of this graph is worth having: a verdict node is the anchor of every "show me the evidence".
|
|
153
169
|
const vDir = verdictsDir(cwd, slug);
|
|
@@ -370,6 +386,7 @@ export function runSubgraph(cwd, slug) {
|
|
|
370
386
|
const of = (t) => [...nodes.values()].filter((n) => n.t === t);
|
|
371
387
|
const orders = of("Order"), results = of("Result"), verdicts = of("Verdict");
|
|
372
388
|
const resultIds = new Set(results.map((r) => r.order_id));
|
|
389
|
+
const ingestedFrom = new Set([...edges.values()].filter((e) => e.t === "INGESTED").map((e) => e.from));
|
|
373
390
|
const greenByRound = {};
|
|
374
391
|
for (const v of verdicts) {
|
|
375
392
|
if (v.overall !== "green" || v.round == null || !v.scope_id) continue;
|
|
@@ -384,6 +401,8 @@ export function runSubgraph(cwd, slug) {
|
|
|
384
401
|
seams: of("Seam").map((s) => s.seam).sort(),
|
|
385
402
|
orders: orders.length,
|
|
386
403
|
pending_orders: orders.filter((o) => !resultIds.has(o.order_id)).map((o) => o.order_id).sort(),
|
|
404
|
+
// Results the single writer never applied — a leg that came back and was not read.
|
|
405
|
+
unapplied_results: results.filter((r) => !ingestedFrom.has(`result:${r.order_id}`)).map((r) => r.order_id).sort(),
|
|
387
406
|
rounds_with_green: Object.keys(greenByRound).map(Number).sort((a, b) => a - b),
|
|
388
407
|
green_scopes_by_round: Object.fromEntries(Object.entries(greenByRound).map(([r, s]) => [r, [...s].sort()])),
|
|
389
408
|
trials: of("Trial").length,
|
package/kernel/reduce/hill.mjs
CHANGED
|
@@ -6,24 +6,158 @@
|
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { resolve, join } from "node:path";
|
|
8
8
|
import { runArgs } from "../lib/argv.mjs";
|
|
9
|
-
import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger } from "../lib/paths.mjs";
|
|
9
|
+
import { scopesDir, hillDir, verdictsDir, resultsDir, discoveryLedger, localRoot } from "../lib/paths.mjs";
|
|
10
10
|
import { readAllContracts, SCOPE_CONTRACT } from "../lib/contract.mjs";
|
|
11
11
|
import { evalVerdict } from "../probe/eval.mjs";
|
|
12
12
|
import { redBuildRounds } from "../verify/build.mjs";
|
|
13
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------------------------
|
|
15
|
+
// THE DISCOVERY LEDGER, AND WHY ITS ABSENCE IS NOT A ZERO.
|
|
16
|
+
//
|
|
17
|
+
// The ledger arm is the only thing that promotes a scope from UPHILL_UNKNOWN to UPHILL_SOLVED —
|
|
18
|
+
// "the open questions are closed". It used to be read as `scopeUnknowns[id] || 0`, which made
|
|
19
|
+
// "nobody has filed a ledger yet" and "every unknown is closed" the same value, and that value
|
|
20
|
+
// selects the FLATTERING phase. An absent value and a real one must not share a signature; when
|
|
21
|
+
// they do, the run reports progress it has no evidence for. So the count is `null` — not `0` —
|
|
22
|
+
// whenever the ledger could not actually be read and understood, and `null` promotes nothing.
|
|
23
|
+
//
|
|
24
|
+
// THE HEADING IS THE ATTRIBUTION KEY, and it has to match what the ingest step really writes:
|
|
25
|
+
// `## Discovered — <order_id> (<date>)`, where `order_id` is `<slug>/<suffix>`. Two ways that
|
|
26
|
+
// parse used to fail silently, both ending in the same false zero:
|
|
27
|
+
// * It required a COLON between the slug and the suffix. The order-envelope schema pins
|
|
28
|
+
// `order_id` to a slug, a SLASH, and a suffix drawn from `[a-z0-9.-]` — a colon cannot appear
|
|
29
|
+
// in a schema-valid order id at all, so the scope was never captured from any real ledger and
|
|
30
|
+
// every scope on every project read zero unknowns regardless of what was open.
|
|
31
|
+
// * It matched the em dash only, so a heading typed with a plain hyphen contributed nothing.
|
|
32
|
+
// Both are fixed by matching the dash as a class and splitting the order id on its "/", and by
|
|
33
|
+
// resolving the scope against the CONTRACTS ON DISK rather than guessing at the suffix's shape: a
|
|
34
|
+
// build suffix is `<scope>-r<N>-a<M>` and a scoped operation's is `<operation>-<scope>[-r<N>]`, so
|
|
35
|
+
// a pattern that tried to carve the scope out positionally captured the round suffix along with it.
|
|
36
|
+
// ---------------------------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** A ledger block heading, naming the order it files: `## Discovered — <slug>/<suffix> (<date>)`. */
|
|
39
|
+
const LEDGER_HEADING = /^##\s+Discovered\s+[—–-]\s+(\S+)/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Index the scopes by the filename-safe form `harness compile` puts in an order suffix, so a
|
|
43
|
+
* heading is matched against ids that actually exist rather than parsed speculatively.
|
|
44
|
+
*
|
|
45
|
+
* @param {Array<object>} scopes - Parsed scope contracts.
|
|
46
|
+
* @returns {Map<string,string>} Compile's suffix form → the contract's own `scope_id`.
|
|
47
|
+
*/
|
|
48
|
+
function scopeSuffixIndex(scopes) {
|
|
49
|
+
const ix = new Map();
|
|
50
|
+
for (const s of scopes) {
|
|
51
|
+
const id = String(s?.scope_id || "");
|
|
52
|
+
if (!id) continue;
|
|
53
|
+
// Mirrors `harness compile`'s own normalisation of a scope id into an order suffix.
|
|
54
|
+
const key = id.toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/^[^a-z0-9]+/, "");
|
|
55
|
+
if (key) ix.set(key, id);
|
|
56
|
+
}
|
|
57
|
+
return ix;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The scope a ledger heading's order belongs to, or `null` when it belongs to none.
|
|
62
|
+
*
|
|
63
|
+
* Operation-level dispatches (orient, analyze, wire, evaluate, hunt, hammer) carry no scope in
|
|
64
|
+
* their order id by construction, so "no scope" is a real and common answer here, not a parse
|
|
65
|
+
* failure — their rows are feature-wide and are deliberately credited to nobody.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} orderId - The order id as the heading names it (`<slug>/<suffix>`).
|
|
68
|
+
* @param {Map<string,string>} ix - The index from `scopeSuffixIndex`.
|
|
69
|
+
* @returns {string|null} The owning contract's `scope_id`, or null.
|
|
70
|
+
*/
|
|
71
|
+
function scopeOfHeading(orderId, ix) {
|
|
72
|
+
const slash = orderId.indexOf("/");
|
|
73
|
+
const suffix = slash === -1 ? orderId : orderId.slice(slash + 1);
|
|
74
|
+
const core = suffix.replace(/-r\d+(?:-a\d+)?$/, "");
|
|
75
|
+
// Longest match wins, so a project holding both `pages` and `pages-admin` attributes each
|
|
76
|
+
// heading to the scope it actually names rather than to whichever was indexed first.
|
|
77
|
+
let best = null;
|
|
78
|
+
let bestLen = -1;
|
|
79
|
+
for (const [key, id] of ix) {
|
|
80
|
+
if ((core === key || core.endsWith(`-${key}`)) && key.length > bestLen) { best = id; bestLen = key.length; }
|
|
81
|
+
}
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open unknowns (`~` rows) per scope, read from the discovery ledger.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} ledgerPath - Path to the run's discovery ledger.
|
|
89
|
+
* @param {Array<object>} scopes - Parsed scope contracts, used to attribute each block.
|
|
90
|
+
* @returns {Object<string,number>|null} Counts per `scope_id` — a scope with a block and no open
|
|
91
|
+
* row is a genuine `0`. `null` means the ledger was NOT read: it is absent, unreadable, or
|
|
92
|
+
* nothing in it could be attributed to a scope. `null` is never treated as zero, because "we
|
|
93
|
+
* understood none of this file" is not evidence that nothing is open.
|
|
94
|
+
*/
|
|
95
|
+
function ledgerUnknowns(ledgerPath, scopes) {
|
|
96
|
+
if (!existsSync(ledgerPath)) return null;
|
|
97
|
+
let text;
|
|
98
|
+
try { text = readFileSync(ledgerPath, "utf8"); } catch { return null; }
|
|
99
|
+
|
|
100
|
+
const ix = scopeSuffixIndex(scopes);
|
|
101
|
+
const counts = {};
|
|
102
|
+
let headings = 0;
|
|
103
|
+
let attributed = 0;
|
|
104
|
+
let current = null;
|
|
105
|
+
|
|
106
|
+
for (const line of text.split("\n")) {
|
|
107
|
+
if (line.startsWith("## ")) {
|
|
108
|
+
// Reset on EVERY heading, matched or not. Carrying the previous block's scope across an
|
|
109
|
+
// unrecognised heading would credit one scope's open rows to another.
|
|
110
|
+
current = null;
|
|
111
|
+
const m = line.match(LEDGER_HEADING);
|
|
112
|
+
if (!m) continue;
|
|
113
|
+
headings++;
|
|
114
|
+
const id = scopeOfHeading(m[1], ix);
|
|
115
|
+
if (id) { attributed++; current = id; counts[id] = counts[id] || 0; }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (current && line.startsWith("~ ")) counts[current]++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A ledger whose blocks we could not attribute to a single scope tells us nothing per scope.
|
|
122
|
+
// Reporting that as all-zero is the same false signature the colon-vs-slash bug produced.
|
|
123
|
+
if (headings === 0 || attributed === 0) return null;
|
|
124
|
+
return counts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The phase currently recorded in a committed hill shard.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} hDir - The committed hill directory.
|
|
131
|
+
* @param {string} id - Scope id.
|
|
132
|
+
* @returns {string|null} The recorded phase, or null when no shard exists for that scope.
|
|
133
|
+
*/
|
|
134
|
+
function committedPhase(hDir, id) {
|
|
135
|
+
const p = join(hDir, `${id}.yml`);
|
|
136
|
+
if (!existsSync(p)) return null;
|
|
137
|
+
try { return readFileSync(p, "utf8").match(/^phase:\s*(\S+)/m)?.[1] ?? null; } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
14
140
|
/**
|
|
15
141
|
* Derive and write the hill phase for all scopes mechanically based on T0, T1, and ledger facts.
|
|
16
142
|
*
|
|
17
143
|
* The derived phase follows these progression rules (facts move dots, not authors):
|
|
18
|
-
* - UPHILL_UNKNOWN: open unknowns > 0 in the ledger for this scope
|
|
19
|
-
*
|
|
144
|
+
* - UPHILL_UNKNOWN: open unknowns > 0 in the ledger for this scope — and the floor the scope sits
|
|
145
|
+
* at whenever the ledger has not answered at all, which is where every run legitimately begins
|
|
146
|
+
* - UPHILL_SOLVED: the ledger was read and reports zero open unknowns, no T0-green yet
|
|
20
147
|
* - DOWNHILL_EXECUTION: ≥1 T0-green in a round whose build gate is not red; T1/seesaw pending
|
|
21
148
|
* - FINISHED: T1 PASS ∧ seesaw green
|
|
22
149
|
*
|
|
23
150
|
* @param {string} cwd - The project root directory.
|
|
24
151
|
* @param {string} slug - The feature slug being built.
|
|
25
|
-
* @returns {Array<{scope_id: string, phase: string, changed: boolean
|
|
26
|
-
*
|
|
152
|
+
* @returns {Array<{scope_id: string, phase: (string|null), changed: boolean, derived: boolean,
|
|
153
|
+
* unknowns: (number|null), reason?: string}>} A report of all scopes processed. `derived` says
|
|
154
|
+
* whether the phase was computed from run evidence at all: when it is false the phase is
|
|
155
|
+
* whatever the committed shard already records (or null when there is none), nothing was
|
|
156
|
+
* written, and `reason` names why. `unknowns` is the ledger count behind the phase, or null
|
|
157
|
+
* when the ledger could not be read — the two are deliberately distinguishable in the output as
|
|
158
|
+
* well as in the derivation.
|
|
159
|
+
* Side effects: writes to `shapeup/<slug>/hill/<scope-id>.yml` for each scope, EXCEPT on the
|
|
160
|
+
* refusal path below, which writes nothing at all.
|
|
27
161
|
*/
|
|
28
162
|
export function deriveHill(cwd, slug) {
|
|
29
163
|
const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map((x) => x.contract);
|
|
@@ -31,6 +165,44 @@ export function deriveHill(cwd, slug) {
|
|
|
31
165
|
const ledgerPath = discoveryLedger(cwd, slug);
|
|
32
166
|
const hDir = hillDir(cwd, slug);
|
|
33
167
|
|
|
168
|
+
// -------------------------------------------------------------------------------------------
|
|
169
|
+
// A DERIVATION THAT CANNOT SEE THE RUN TRACE MUST NOT WRITE THE DELIVERABLE.
|
|
170
|
+
//
|
|
171
|
+
// This function reads one tier and writes the other. Every input below — T0 verdicts, the EVAL
|
|
172
|
+
// results, the round build gates, the discovery ledger — lives in the gitignored run trace,
|
|
173
|
+
// while the shards it writes are committed and outlive it: after a ship the run trace is cleaned
|
|
174
|
+
// up and the shards are the ONLY surviving record of where the work got to. So when the run
|
|
175
|
+
// trace for this slug is not on disk, every input is provably absent, and anything derived from
|
|
176
|
+
// that is derived from nothing.
|
|
177
|
+
//
|
|
178
|
+
// This is not a hypothetical. Pulling a branch mid-run is a supported state: the puller gets the
|
|
179
|
+
// committed spec, scopes and shards and no run trace of their own. Their first launch used to
|
|
180
|
+
// re-derive every scope from the empty set and overwrite the shards with the result — losing
|
|
181
|
+
// committed history rather than misreporting it.
|
|
182
|
+
//
|
|
183
|
+
// WHY REFUSE RATHER THAN WRITE AN "UNKNOWN" PHASE. Writing anything here destroys the record
|
|
184
|
+
// just as thoroughly; a shard that says "I could not look" has still replaced the one that said
|
|
185
|
+
// FINISHED, and the phase enum is a committed data format that a reader parses as current
|
|
186
|
+
// status. Not writing already expresses "no opinion" exactly, and it needs no new enum value.
|
|
187
|
+
//
|
|
188
|
+
// WHY THE CONDITION IS THE TIER'S EXISTENCE AND NOT "the phase would go down". Moving a dot
|
|
189
|
+
// backwards is correct and must stay possible — this is a pure function of the artifacts present
|
|
190
|
+
// and reports what they currently support, in both directions. A guard phrased as "never lower a
|
|
191
|
+
// phase" would quietly turn a derived value into a high-water mark, which is a different defect
|
|
192
|
+
// wearing this one's clothes. The condition is the narrowest one that is positively provable:
|
|
193
|
+
// the tier holding every input is not there.
|
|
194
|
+
// -------------------------------------------------------------------------------------------
|
|
195
|
+
if (!existsSync(localRoot(cwd, slug))) {
|
|
196
|
+
return scopes.map((s) => ({
|
|
197
|
+
scope_id: s.scope_id,
|
|
198
|
+
phase: committedPhase(hDir, s.scope_id),
|
|
199
|
+
changed: false,
|
|
200
|
+
derived: false,
|
|
201
|
+
unknowns: null,
|
|
202
|
+
reason: "local-run-trace-absent",
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
34
206
|
if (!existsSync(hDir)) mkdirSync(hDir, { recursive: true });
|
|
35
207
|
|
|
36
208
|
// 1. Check if T1 Evaluation passed — the LATEST evaluate round's verdict, read the same way
|
|
@@ -95,28 +267,20 @@ export function deriveHill(cwd, slug) {
|
|
|
95
267
|
}
|
|
96
268
|
}
|
|
97
269
|
|
|
98
|
-
// 3. Ledger unknowns per scope
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
let currentScope = null;
|
|
103
|
-
for (const line of lines) {
|
|
104
|
-
const m = line.match(/^## Discovered — .*?:([\w.-]+)-a\d+/);
|
|
105
|
-
if (m) {
|
|
106
|
-
currentScope = m[1];
|
|
107
|
-
}
|
|
108
|
-
if (currentScope && line.startsWith("~ ")) {
|
|
109
|
-
scopeUnknowns[currentScope] = (scopeUnknowns[currentScope] || 0) + 1;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
270
|
+
// 3. Ledger unknowns per scope — `null` for every scope when the ledger itself was not readable
|
|
271
|
+
// or nothing in it named a scope (see `ledgerUnknowns`). Only a real count can promote.
|
|
272
|
+
const scopeUnknowns = ledgerUnknowns(ledgerPath, scopes);
|
|
273
|
+
|
|
114
274
|
const report = [];
|
|
115
275
|
for (const s of scopes) {
|
|
116
276
|
const id = s.scope_id;
|
|
117
277
|
const t0 = t0Facts[id] || { hasGreen: false, seesawGreen: false };
|
|
118
|
-
|
|
119
|
-
|
|
278
|
+
// `null` = the ledger did not answer; a number = it did. `|| 0` collapsed the two.
|
|
279
|
+
const unknowns = scopeUnknowns === null ? null : (scopeUnknowns[id] || 0);
|
|
280
|
+
|
|
281
|
+
// UPHILL_UNKNOWN is the floor, and the honest answer whenever nothing has promoted a scope off
|
|
282
|
+
// it — including before Orient has filed anything, which is where every run legitimately
|
|
283
|
+
// starts. Only an ANSWERED count of zero promotes to UPHILL_SOLVED; `null` never does.
|
|
120
284
|
let phase = "UPHILL_UNKNOWN";
|
|
121
285
|
if (t1Pass && t0.hasGreen && t0.seesawGreen) {
|
|
122
286
|
phase = "FINISHED";
|
|
@@ -125,7 +289,7 @@ export function deriveHill(cwd, slug) {
|
|
|
125
289
|
} else if (unknowns === 0) {
|
|
126
290
|
phase = "UPHILL_SOLVED";
|
|
127
291
|
}
|
|
128
|
-
|
|
292
|
+
|
|
129
293
|
const yaml = `scope_id: ${id}\nphase: ${phase}\n`;
|
|
130
294
|
const out = join(hDir, `${id}.yml`);
|
|
131
295
|
let changed = false;
|
|
@@ -133,7 +297,7 @@ export function deriveHill(cwd, slug) {
|
|
|
133
297
|
writeFileSync(out, yaml);
|
|
134
298
|
changed = true;
|
|
135
299
|
}
|
|
136
|
-
report.push({ scope_id: id, phase, changed });
|
|
300
|
+
report.push({ scope_id: id, phase, changed, derived: true, unknowns });
|
|
137
301
|
}
|
|
138
302
|
return report;
|
|
139
303
|
}
|
|
@@ -156,5 +320,16 @@ export async function cli(rawArgv) {
|
|
|
156
320
|
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
157
321
|
const cwd = resolve(args.cwd || process.cwd());
|
|
158
322
|
const report = deriveHill(cwd, args.slug);
|
|
323
|
+
// A refusal that is visible only as a missing write reads exactly like a derivation that
|
|
324
|
+
// happened to agree with what was already on disk, so say it out loud. It is not an error —
|
|
325
|
+
// the caller runs this advisorily several times a run, and declining to derive from nothing is
|
|
326
|
+
// the correct outcome, not a failure — so the exit code stays 0 and the warning goes to stderr.
|
|
327
|
+
const underived = report.filter((r) => r.derived === false);
|
|
328
|
+
if (underived.length) {
|
|
329
|
+
console.error(
|
|
330
|
+
`hill: derived nothing for ${underived.length} scope(s) (${underived[0].reason}) — ` +
|
|
331
|
+
`the run trace this phase is derived from is not on disk, so the committed shards were left as they are.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
159
334
|
console.log(JSON.stringify(report, null, 2));
|
|
160
335
|
}
|
package/kernel/reduce/ship.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { runArgs } from "../lib/argv.mjs";
|
|
|
32
32
|
import {
|
|
33
33
|
report as reportPath, tasksDir, verdictsDir, trials, evaluationDir, qaDir,
|
|
34
34
|
roundLedger, discoveryLedger, receipt as receiptPath, harnessRun, relShared,
|
|
35
|
-
activeOrder,
|
|
35
|
+
activeOrder, runArgsPath, readReceipt, runIdFromReceipt,
|
|
36
36
|
} from "../lib/paths.mjs";
|
|
37
37
|
import { readTrials } from "../verify/t0.mjs";
|
|
38
38
|
import { ratchetReport } from "../probe/stats.mjs";
|
|
@@ -398,9 +398,51 @@ export const ARGV_SPEC = {
|
|
|
398
398
|
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
399
399
|
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
400
400
|
*/
|
|
401
|
+
/**
|
|
402
|
+
* Did THIS run skip evaluation? Read from the run's own recorded arguments, and believed only when
|
|
403
|
+
* the record can be shown to belong to this run.
|
|
404
|
+
*
|
|
405
|
+
* WHY THE KERNEL ASKS AT ALL. A run launched with `--no-eval` verified nothing, and the protocol
|
|
406
|
+
* has always said such a run ships as `not-evaluated`, "recorded plainly — never silently
|
|
407
|
+
* upgraded". That promise lived entirely inside the orchestrator's control flow, where one
|
|
408
|
+
* assignment downstream of the branch restores the old behaviour with every check still green —
|
|
409
|
+
* measured, not supposed. The gate block tells the human the truth either way; the committed report
|
|
410
|
+
* a teammate inherits on `git pull` is the artifact that was lying, so the refusal belongs at the
|
|
411
|
+
* writer of that artifact, where a future orchestrator edit cannot reach it.
|
|
412
|
+
*
|
|
413
|
+
* POSITIVELY PROVEN OR NOT AT ALL. The record is written at launch, later than the receipt that
|
|
414
|
+
* mints the run key, so a freshly opened run can still find the PREVIOUS run's record on disk. A
|
|
415
|
+
* stale flag would refuse a ship that verified everything — a worse failure than the one this
|
|
416
|
+
* closes. The flag therefore counts only when the record names the same run the receipt does;
|
|
417
|
+
* a missing, unreadable or differently-keyed record proves nothing and permits.
|
|
418
|
+
*
|
|
419
|
+
* @param {string} cwd - Project root.
|
|
420
|
+
* @param {string} slug - Feature slug.
|
|
421
|
+
* @returns {boolean} True only when this run's own record says evaluation was skipped.
|
|
422
|
+
*/
|
|
423
|
+
function evalWasSkipped(cwd, slug) {
|
|
424
|
+
try {
|
|
425
|
+
const record = JSON.parse(readFileSync(runArgsPath(cwd, slug), "utf8"));
|
|
426
|
+
if (record?.noEval !== true) return false;
|
|
427
|
+
const mine = runIdFromReceipt(readReceipt(receiptPath(cwd, slug)));
|
|
428
|
+
return Boolean(mine) && record.runId === mine;
|
|
429
|
+
} catch { return false; }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The verdict values that assert the feature was graded and passed. */
|
|
433
|
+
const PASSING = new Set(["PASS", "pass"]);
|
|
434
|
+
|
|
401
435
|
export async function cli(rawArgv) {
|
|
402
436
|
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
403
437
|
const cwd = args.cwd || process.cwd();
|
|
438
|
+
if (PASSING.has(String(args.verdict ?? "")) && evalWasSkipped(cwd, args.slug)) {
|
|
439
|
+
console.error(
|
|
440
|
+
"✋ reduce ship: this run was launched with --no-eval, so nothing graded it — refusing to freeze a report " +
|
|
441
|
+
`that says ${args.verdict}. Ship it as --verdict not-evaluated, which the report, the ledger and the ` +
|
|
442
|
+
"sign-off block all carry.",
|
|
443
|
+
);
|
|
444
|
+
process.exit(3);
|
|
445
|
+
}
|
|
404
446
|
const { markdown, path } = generate({ cwd, slug: args.slug, verdict: args.verdict, qa: args.qa });
|
|
405
447
|
if (args.stdout) {
|
|
406
448
|
process.stdout.write(markdown);
|
package/kernel/report/export.mjs
CHANGED
|
@@ -45,7 +45,9 @@ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, statSy
|
|
|
45
45
|
import { join, resolve } from "node:path";
|
|
46
46
|
import { runArgs } from "../lib/argv.mjs";
|
|
47
47
|
import { splitFrontmatter } from "../lib/contract.mjs";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
runIdFromReceipt, readReceipt, legLedger,
|
|
50
|
+
} from "../lib/paths.mjs";
|
|
49
51
|
import { TABLES, runRow, dispatchFacts } from "./facts.mjs";
|
|
50
52
|
import { deriveRounds } from "../probe/rounds.mjs";
|
|
51
53
|
import {
|
|
@@ -260,6 +262,9 @@ export function collectRun(cwd, slug) {
|
|
|
260
262
|
// The decision that crossed each gate, and the round build gate's own artifact.
|
|
261
263
|
gate_decision: readJsonl(gatesPath(cwd, slug), t).map((g) => gateDecisionRow(g, runId)),
|
|
262
264
|
build_gate: readJsonDir(roundBuildDir(cwd, slug), t).map((a) => buildGateRow(a, runId)),
|
|
265
|
+
leg: readJsonl(legLedger(cwd, slug), t)
|
|
266
|
+
.filter((r) => !runId || !r?.run_id || r.run_id === runId)
|
|
267
|
+
.map((r) => ({ ...r, run_id: r.run_id ?? runId ?? null })),
|
|
263
268
|
},
|
|
264
269
|
defects: { records_skipped: t.skipped },
|
|
265
270
|
};
|
package/kernel/report/facts.mjs
CHANGED
|
@@ -32,6 +32,9 @@ export const TABLES = [
|
|
|
32
32
|
// has to. `build_gate` is the round build gate's own artifact (kernel/verify/build.mjs), on the
|
|
33
33
|
// same terms: it ends a round exactly as EVAL does, and had no table either.
|
|
34
34
|
"gate_decision", "build_gate",
|
|
35
|
+
// The leg-completion ledger — one row per order the single writer applied. Without it the
|
|
36
|
+
// warehouse could join an order to its result and never say whether anyone read the result.
|
|
37
|
+
"leg",
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
/** Coerce anything to a finite number, or null. Keeps `0` and rejects `NaN`/`""`/undefined. */
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"type": null,
|
|
34
34
|
"gap": "a gate decision row — emitted by reduce graph, never typed here"
|
|
35
35
|
},
|
|
36
|
+
"Leg": {
|
|
37
|
+
"type": null,
|
|
38
|
+
"gap": "a leg-completion row (reduce ingest's record that a result was applied) — emitted by reduce graph, never typed here"
|
|
39
|
+
},
|
|
36
40
|
"Scope": {
|
|
37
41
|
"type": "ScopeContract"
|
|
38
42
|
},
|
|
@@ -2846,9 +2850,10 @@
|
|
|
2846
2850
|
"type": "string",
|
|
2847
2851
|
"enum": [
|
|
2848
2852
|
"pass",
|
|
2849
|
-
"fail"
|
|
2853
|
+
"fail",
|
|
2854
|
+
"not-evaluated"
|
|
2850
2855
|
],
|
|
2851
|
-
"description": "shipped | ok: the round or run's EVAL verdict, lowercased from spec-evaluator's PASS|FAIL."
|
|
2856
|
+
"description": "shipped | ok: the round or run's EVAL verdict, lowercased from spec-evaluator's PASS|FAIL — or `not-evaluated` when a `shipped` return closed a `--no-eval` run, which reaches SHIP without ever dispatching the judge. Recorded plainly, never upgraded to `pass` (references/protocol.md)."
|
|
2852
2857
|
},
|
|
2853
2858
|
"rounds_used": {
|
|
2854
2859
|
"type": "integer"
|
package/package.json
CHANGED
|
@@ -48,12 +48,16 @@ node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" reduce graph --slug <slug>
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
**For a committed-only slug (`hasCommitted && !hasLocal`), NEVER call `reduce hill`.**
|
|
51
|
-
`deriveHill()` folds whatever T0 verdicts
|
|
52
|
-
none
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
`deriveHill()` folds whatever T0 verdicts, evaluation results and ledger rows exist in the LOCAL
|
|
52
|
+
run trace; a committed-only pitch has none, because that trace was cleaned up after shipping.
|
|
53
|
+
|
|
54
|
+
The runtime no longer takes that silence for an answer: a derivation whose run trace is absent
|
|
55
|
+
declines to write anything and reports every scope as underived (`derived: false`), so the
|
|
56
|
+
committed shards survive a call made in error. Treat that as a backstop, not a licence — an
|
|
57
|
+
underived report is not a refresh, and rendering it as one would show a pitch's true `FINISHED`
|
|
58
|
+
as freshly confirmed when nothing confirmed it. Read `shapeup/<slug>/hill/*.yml` for those slugs
|
|
59
|
+
exactly as committed, and render them with the archived state the template already implements
|
|
60
|
+
(see below). Same reasoning applies to `reduce graph` — there is no local trace to append from.
|
|
57
61
|
|
|
58
62
|
## Reading the hill shards
|
|
59
63
|
|
|
@@ -389,7 +389,7 @@ feature) and the failing step is compiled into round r+1's orders as `payload.bu
|
|
|
389
389
|
means L0 pinned no run command and the profile names no probe — the round proceeds over an
|
|
390
390
|
unproven build, and the block says so.
|
|
391
391
|
|
|
392
|
-
Under `--interactive` / `--auto`, the
|
|
392
|
+
Under `--interactive` / `--auto`, the gate block carries the board facts — `green_scopes`, `hammer_proposals` — and requires explicit PO approval to proceed. Under `--unattended` the answer set resolves it. **There is no hook behind this gate**: the `PreToolUse` warning it used to carry was retired into the block in v2.0, so what an unfinished board costs you here is a human reading the numbers, not a machine refusing the call.
|
|
393
393
|
|
|
394
394
|
---
|
|
395
395
|
|