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.
@@ -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", required: true },
96
- round: { type: "int", min: 1, required: true },
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,
@@ -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 */ }
@@ -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,
@@ -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
- * - UPHILL_SOLVED: unknowns 0, no T0-green yet
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}>} A report of all scopes processed, their derived phase, and whether the hill shard on disk was modified.
26
- * Side effects: writes to `shapeup/<slug>/hill/<scope-id>.yml` for each scope.
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
- const scopeUnknowns = {};
100
- if (existsSync(ledgerPath)) {
101
- const lines = readFileSync(ledgerPath, "utf8").split("\n");
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
- const unknowns = scopeUnknowns[id] || 0;
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
  }
@@ -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);
@@ -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 { runIdFromReceipt, readReceipt } from "../lib/paths.mjs";
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
  };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.7.1",
3
+ "version": "3.7.3",
4
4
  "description": "Shape Up for coding agents \u2014 with gates the agent can't talk its way past. Harness for Claude Code.",
5
5
  "bin": {
6
6
  "shapeup-sdlc": "bin/init.mjs"
@@ -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 currently exist on disk; a committed-only pitch has
52
- none (the local trace was cleaned up after shipping), so re-running it would silently regress a
53
- true historical `FINISHED` down to a fabricated `UPHILL_SOLVED` — reading absence of evidence as
54
- evidence of absence. Read `shapeup/<slug>/hill/*.yml` for those slugs exactly as committed, and
55
- render them with the archived state the template already implements (see below). Same reasoning
56
- applies to `reduce graph` — there is no local trace to append from.
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 hook warns if the board is not truly green (advisory) and requires explicit PO approval to proceed. Under `--unattended`, it automatically aborts on a red board or proceeds on a green one.
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