shapeup-sdlc 3.7.9 → 3.7.12

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.7.9",
4
+ "version": "3.7.12",
5
5
  "description": "Shape Up SDLC harness for Claude Code: shaping, intake, orient, scope-mapping, building (T0-verified, sandboxed, scope-contracted), evaluation and QA skills orchestrated by a tech-lead.",
6
6
  "author": {
7
7
  "name": "Liberty Nguyen",
@@ -329,6 +329,11 @@ async function main() {
329
329
  allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
330
330
  appendOnly: o.substrate.append_only || [],
331
331
  frozen: o.substrate.frozen || [],
332
+ // The one exception to "frozen outranks everything", and it is the compiler's to grant, never
333
+ // the worker's to request: the paths THIS order authors, named from its own identity. A build
334
+ // leg's substrate freezes the whole run trace, so without this its own WorkResult — its
335
+ // documented last step — would be denied along with every channel it must not touch.
336
+ own: o.substrate.own || [],
332
337
  })).filter((c) => c.allowed.length || c.appendOnly.length || c.frozen.length);
333
338
 
334
339
  if (contracts.length === 0) defer("no live order declares write/append/frozen boundaries", "no-whitelist");
@@ -363,6 +368,10 @@ async function main() {
363
368
  // a planner is graded against — so the compiler emitted a declaration with no enforcer, which is
364
369
  // the exact state this hook exists to end. A path a live contract freezes is a violation
365
370
  // wherever it lives.
371
+ // `own` first, and only against the contract that declared it: another live order's exception
372
+ // never licenses this write. A path no contract claims as its own falls through to the freeze.
373
+ if (contracts.some((c) => matchesAny(rel, c.own, fold))) continue;
374
+
366
375
  const freezer = contracts.find((c) => matchesAny(rel, c.frozen, fold));
367
376
  if (freezer) {
368
377
  violations.push(rel);
@@ -211,13 +211,37 @@ export const OP_OWNER = {
211
211
  * operation, so mode/flag differences are enforced by the sandbox hook reading the order's substrate, not trusted to prose.
212
212
  * @param {string} operation - The order's operation (execute|fix|spike|analyze|reconcile|
213
213
  * retrofit-surface|coverage|map-scopes|wire|evaluate|orient|hunt|translate|hammer|coach|scan|research).
214
- * @param {{slug?:string, specDir?:string, scope?:object}} [ctx] - slug (names LOCAL/SHARED roots),
215
- * specDir (overrides the default spec path), scope (contract supplying allowed/shared substrates).
216
- * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[]}} The
217
- * substrate contract: globs the worker may write (`allowed`), shared-write globs, read-only
218
- * `frozen` globs, and `append_only` globs. An unknown operation returns a LOCAL-only default.
214
+ * @param {{slug?:string, specDir?:string, scope?:object, ownStem?:string}} [ctx] - slug (names
215
+ * LOCAL/SHARED roots), specDir (overrides the default spec path), scope (contract supplying
216
+ * allowed/shared substrates), ownStem (this order's own file stem, so a build leg may write its
217
+ * own WorkResult and no one else's).
218
+ * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
219
+ * The substrate contract: globs the worker may write (`allowed`), shared-write globs, read-only
220
+ * `frozen` globs, `append_only` globs, and `own` — the paths this order may write DESPITE a
221
+ * broader freeze, derived by the compiler from the order's own identity and never requested.
222
+ * An unknown operation returns a LOCAL-only default.
219
223
  */
220
- export function substrateFor(operation, { slug, specDir, scope } = {}) {
224
+ export function substrateFor(operation, ctx = {}) {
225
+ // EVERY ORDER NAMES THE RESULT IT ANSWERS WITH. A worker used to infer that path from its order's
226
+ // own filename — the one thing in the envelope that was convention rather than contract — so the
227
+ // one file every dispatch must write was the one the order did not mention. It is `own` for every
228
+ // operation now: the compiler derives it from the order's identity, which is also what keeps a leg
229
+ // from writing somebody else's.
230
+ const base = substrateTemplate(operation, ctx);
231
+ if (!ctx.ownStem) return base;
232
+ const ownResult = `${globLocal(ctx.slug)}/results/${ctx.ownStem}.json`;
233
+ return { ...base, own: [...new Set([...(base.own || []), ownResult])] };
234
+ }
235
+
236
+ /**
237
+ * The per-operation template {@link substrateFor} builds on.
238
+ *
239
+ * @param {string} operation - The order's operation.
240
+ * @param {object} [ctx] - As {@link substrateFor}: slug, specDir, scope, ownStem.
241
+ * @returns {{allowed:string[], shared?:string[], frozen?:string[], append_only?:string[], own?:string[]}}
242
+ * The operation's write contract before the result path every order names is merged in.
243
+ */
244
+ function substrateTemplate(operation, { slug, specDir, scope, ownStem = null } = {}) {
221
245
  const local = globLocal(slug);
222
246
  const spec = specDir || globShared(slug, "spec");
223
247
  const scopesDir = globShared(slug, "scopes");
@@ -266,15 +290,30 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
266
290
  const FROZEN_ATTESTATION = [`${local}/receipts/**`, `${local}/legs.jsonl`, `${local}/t0/verdicts/**`, `${local}/tasks/_index.md`];
267
291
  switch (operation) {
268
292
  case "execute": case "fix": case "spike":
269
- // Build legs are the widest window on FROZEN_INTAKE, not an exemption from it: they are the
270
- // most numerous and longest-lived dispatches in a run, so a doer that can rewrite the staged
271
- // pitch can rewrite the run's own input truth mid-build. `init run` stages these before any
272
- // order is live (no live contract yet — nothing to violate) and `translate` writes the
273
- // COMMITTED copy, not this one, so neither legitimate write is touched by this line.
293
+ // THE RUN TRACE IS THE KERNEL'S, EXCEPT WHAT THIS LEG AUTHORS. The freeze used to be a list of
294
+ // channels a defect had named — the staged pitch, the receipts, the leg ledger, the T0
295
+ // verdicts, the board index — and each review found more of the same class: the trial ledger,
296
+ // the gate ledger, the round build gates, the graph, the run args, the run ledger itself. A
297
+ // list that grows one defect at a time is not a boundary. So the boundary is inverted here:
298
+ // everything under the run trace is frozen for a build leg, and `own` carries the short,
299
+ // derived list of what such a leg actually writes. Everything else under there is written by
300
+ // the kernel in its own process or by the hook layer, and neither goes through this guard —
301
+ // so freezing it costs no legitimate write. `${local}/**` subsumes FROZEN_INTAKE and
302
+ // FROZEN_ATTESTATION for this operation, and a check asserts that rather than restating them.
274
303
  return {
275
304
  allowed: [...(scope?.allowed_file_substrate || []), `${local}/spikes/**`],
276
305
  shared: scope?.shared_substrate || [],
277
- frozen: [...FROZEN_INTAKE, ...FROZEN_ATTESTATION],
306
+ frozen: [`${local}/**`],
307
+ own: [
308
+ // The result this order answers with, and no sibling's: a leg that can write another
309
+ // leg's WorkResult can report work nobody did, and ingest would have no way to tell.
310
+ // With no stem the caller is asking about the operation in general, not about one order,
311
+ // and the honest answer is the whole directory rather than a guess at which file.
312
+ ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`]),
313
+ `${local}/tasks/TASK-*.md`,
314
+ `${local}/discovery/**`,
315
+ `${local}/spikes/**`,
316
+ ],
278
317
  };
279
318
  case "analyze":
280
319
  return { allowed: [`${spec}/**`, `${local}/**`], frozen: [...FROZEN_INTAKE] };
@@ -315,9 +354,22 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
315
354
  frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${scopesDir}/**`, globShared(slug, "project-profile.md")],
316
355
  };
317
356
  case "evaluate":
318
- return { allowed: [`${local}/evaluation/**`], frozen: [`${spec}/**`, ...FROZEN_INTAKE, `${local}/tasks/**`] };
357
+ // THE JUDGE MAY NOT WRITE THE EVIDENCE IT CITES. Its substrate froze the spec, the pitch and
358
+ // the board — and not `t0/verdicts/**`, so a judge could write a green verdict artifact into
359
+ // the canonical directory under the run-trace carve-out and then cite it, correctly hashed.
360
+ // Same inversion as a build leg: the run trace is the kernel's, and `own` names what the
361
+ // judge authors — its report and the envelope that answers its order.
362
+ return {
363
+ allowed: [],
364
+ frozen: [`${spec}/**`, `${local}/**`],
365
+ own: [`${local}/evaluation/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
366
+ };
319
367
  case "hunt":
320
- return { allowed: [`${local}/qa/**`], frozen: [`${spec}/**`, ...FROZEN_INTAKE, `${local}/tasks/**`] };
368
+ return {
369
+ allowed: [],
370
+ frozen: [`${spec}/**`, `${local}/**`],
371
+ own: [`${local}/qa/**`, ...(ownStem ? [`${local}/results/${ownStem}.json`] : [`${local}/results/**`])],
372
+ };
321
373
  case "orient":
322
374
  return { allowed: [`${local}/orient/**`], frozen: [`${spec}/**`] };
323
375
  case "translate":
@@ -782,7 +834,7 @@ export function compileOrder({
782
834
  mode,
783
835
  ...(operation ? { operation } : {}),
784
836
  ...(interaction ? { interaction } : {}),
785
- substrate: substrateFor(operation, { slug, specDir, scope }),
837
+ substrate: substrateFor(operation, { slug, specDir, scope, ownStem: suffix }),
786
838
  payload: {
787
839
  ...(scope ? { scope_contract: scope } : {}),
788
840
  ...(tasks?.length ? { tasks } : {}),
@@ -31,11 +31,11 @@
31
31
  // cause as its first deviation. A bare `ok: false` reached the operator as a sub-agent that died
32
32
  // after retries, while the one sentence naming the actual cause sat in a file nobody was pointed at.
33
33
 
34
- import { existsSync, readFileSync, readdirSync } from "node:fs";
35
- import { join, resolve } from "node:path";
34
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
35
+ import { join, resolve, resolve as resolvePath, sep } from "node:path";
36
36
  import { createHash } from "node:crypto";
37
37
  import { runArgs } from "../lib/argv.mjs";
38
- import { resultsDir, scopesDir, readRunId } from "../lib/paths.mjs";
38
+ import { resultsDir, scopesDir, readRunId, verdictsDir } from "../lib/paths.mjs";
39
39
 
40
40
  /** Longest `reason` reported. A deviation is prose written by a worker and can run to paragraphs. */
41
41
  const REASON_MAX = 400;
@@ -89,7 +89,7 @@ export function isScoped(cwd, slug) {
89
89
  * @returns {(string|null)} A reason phrased for an operator, or null when the file at `path` exists,
90
90
  * hashes to the cited `sha256`, and its own `overall` reads "green".
91
91
  */
92
- function unresolvedCitation(cwd, citation, { round = null, runId = null } = {}) {
92
+ function unresolvedCitation(cwd, citation, { round = null, runId = null, verdicts = null } = {}) {
93
93
  const rel = typeof citation?.path === "string" ? citation.path : "";
94
94
  if (!rel) return "names no artifact path";
95
95
  let text;
@@ -109,6 +109,19 @@ function unresolvedCitation(cwd, citation, { round = null, runId = null } = {})
109
109
  try { body = JSON.parse(text); }
110
110
  catch { return `cites ${rel}, whose bytes match the hash but do not read as a T0 verdict`; }
111
111
  if (body?.overall !== "green") return `cites ${rel}, whose own verdict is "${body?.overall ?? "unknown"}", not green`;
112
+ // INSIDE THIS RUN'S OWN VERDICTS DIRECTORY, resolved — asked of a file that exists, so "does not
113
+ // exist" and "resolves somewhere else" stay different answers. Accepted before this check: a
114
+ // green artifact in the source tree, one outside the project reached by `../`, one by absolute
115
+ // path, and a symlink in the verdicts directory pointing at a forged file. Every one hashed
116
+ // correctly, because a digest says the bytes are the file's and nothing about which file it
117
+ // should have been.
118
+ if (verdicts) {
119
+ const real = (() => { try { return realpathSync.native(resolvePath(cwd, rel)); } catch { return resolvePath(cwd, rel); } })();
120
+ const home = (() => { try { return realpathSync.native(verdicts); } catch { return verdicts; } })();
121
+ if (!real.startsWith(home.endsWith(sep) ? home : home + sep)) {
122
+ return `cites ${rel}, which resolves outside this run's own verdicts directory (${home}) — a verdict cites what this run's own verifier wrote, not a file the judge can reach`;
123
+ }
124
+ }
112
125
  // THE ARTIFACT HAS TO BE THE ONE THE CITATION SAYS IT IS. A re-hash proves the bytes are the
113
126
  // file's; it says nothing about whose verdict the file holds. A PASS citing scope alpha's green
114
127
  // artifact while declaring scope beta, or a prior round's, or a prior run's over the same slug,
@@ -198,8 +211,9 @@ export function citationProblem(cwd, slug, verdict, { round = null } = {}) {
198
211
  "cite the T0 verdict it re-hashed (the order lists them under payload.t0_artifacts)";
199
212
  }
200
213
  const runId = readRunId(cwd, slug);
214
+ const verdicts = verdictsDir(cwd, slug);
201
215
  for (const citation of verdict.t0_citations) {
202
- const reason = unresolvedCitation(cwd, citation, { round, runId });
216
+ const reason = unresolvedCitation(cwd, citation, { round, runId, verdicts });
203
217
  if (reason) return `the ${verdict.overall} verdict ${reason} — a T0 citation is re-hashed from disk, never taken on the handed word`;
204
218
  }
205
219
  return null;
@@ -542,7 +542,14 @@
542
542
  "items": {
543
543
  "type": "string"
544
544
  },
545
- "description": "Explicitly untouchable paths (spec core: domain-model, UC Steps, contracts, ux-behavior)."
545
+ "description": "Explicitly untouchable paths (spec core: domain-model, UC Steps, contracts, ux-behavior). A build leg's order freezes the whole run trace here and carves out what it authors through `own`: a freeze that lists the channels a defect happened to name grows one defect at a time and is not a boundary."
546
+ },
547
+ "own": {
548
+ "type": "array",
549
+ "items": {
550
+ "type": "string"
551
+ },
552
+ "description": "Paths this order may write DESPITE a broader freeze — the compiler's grant, derived from the order's own identity and never requested by a worker. For a build leg: its own WorkResult (no sibling's — a leg that can write another's can report work nobody did), its task files, the discovery ledger, its spikes. Checked BEFORE `frozen`, and only against the contract that declared it, so another live order's exception never licenses this write."
546
553
  }
547
554
  }
548
555
  },
@@ -52,6 +52,16 @@ const UNSET = "<unset>";
52
52
  * @param {string} cmd - A shell command line.
53
53
  * @returns {string[]} Invoked tokens, in order, without duplicates.
54
54
  */
55
+ export function cdTargets(cmd) {
56
+ const out = [];
57
+ for (const seg of String(cmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)) {
58
+ const m = seg.match(/^cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))/);
59
+ const dir = m && (m[1] || m[2] || m[3]);
60
+ if (dir && !dir.startsWith("-") && !out.includes(dir)) out.push(dir);
61
+ }
62
+ return out;
63
+ }
64
+
55
65
  export function invokedTokens(cmd) {
56
66
  const out = [];
57
67
  for (const seg of String(cmd || "").split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)) {
@@ -162,12 +172,19 @@ export function environmentFingerprint(rawCwd, { commands = [], profilePath = nu
162
172
  cwd,
163
173
  tree: treeState(cwd),
164
174
  toolchain: tokens.map((bin) => ({ bin, path: resolveBin(bin, cwd) })),
165
- lockfiles: LOCKFILES
166
- .filter((f) => existsSync(join(cwd, f)))
167
- .map((f) => {
168
- try { return { file: f, sha256: sha256(readFileSync(join(cwd, f))) }; }
169
- catch { return { file: f, sha256: null }; }
170
- }),
175
+ // The root AND wherever the commands actually run. Measured on a consumer whose fixtures are
176
+ // `cd app && …`: the lockfile that decides what the build resolves lives in `app/`, and a scan
177
+ // of the project root alone recorded an empty list beside a build whose dependencies were the
178
+ // whole question.
179
+ lockfiles: [...new Set(["", ...commands.flatMap((c) => cdTargets(c))])]
180
+ .flatMap((sub) => LOCKFILES
181
+ .map((f) => (sub ? `${sub.replace(/\/+$/, "")}/${f}` : f))
182
+ .filter((rel) => existsSync(join(cwd, rel)))
183
+ .map((rel) => {
184
+ try { return { file: rel, sha256: sha256(readFileSync(join(cwd, rel))) }; }
185
+ catch { return { file: rel, sha256: null }; }
186
+ }))
187
+ .filter((l, i, all) => all.findIndex((x) => x.file === l.file) === i),
171
188
  caches: declaredCaches(profilePath),
172
189
  env: {
173
190
  allowlist: ENV_ALLOWLIST,
@@ -422,6 +422,9 @@ export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tas
422
422
  return findings;
423
423
  }
424
424
 
425
+ /** Registry sources that name the pitch's own out-of-scope section, in the spellings pitches use. */
426
+ const NOGO_SOURCE = /\bno[-\s]?gos?\b|\bnon[-\s]?goals?\b|\bout[-\s]of[-\s]scope\b|\bwill not build\b/i;
427
+
425
428
  /**
426
429
  * REQ-UNCOVERED — a live requirement that nothing in the plan reaches.
427
430
  *
@@ -451,6 +454,57 @@ export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tas
451
454
  * @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} One red per
452
455
  * uncovered live requirement; [] when every one is graded, claimed or cut.
453
456
  */
457
+ /**
458
+ * REQ-NARRATED — a committed spec file stating the requirement-coverage verdict as fact.
459
+ *
460
+ * The Health Dashboard's `Coverage` row is about USE CASES and tasks, derived by inverting each
461
+ * task's `use_case_refs` over the local board. Measured on a consumer, a worker filled its Signal
462
+ * cell with a different claim entirely — *"every registered non-CUT REQ-id (REQ-1 … REQ-7) reaches
463
+ * an AC carrying `(covers: REQ-…)`"*, with a 🟢 beside it — while a grep for `covers:` across the
464
+ * whole spec folder returned that sentence and nothing else. Not one acceptance criterion carried
465
+ * the clause, and the run's own derived report said `0/11 PASS`.
466
+ *
467
+ * `AGENTS.md` names the invariant this breaks: the requirements matrix is a projection, never a
468
+ * verdict, derived from files for one named run and never narrated. The rule is the narrow,
469
+ * checkable form of it — a dashboard Coverage row in a committed file may not name a REQ id — and
470
+ * it cannot fire on the legitimate signal, which counts use cases and tasks.
471
+ *
472
+ * @param {{cwd:string, slug:string}} opts - Working root and feature slug.
473
+ * @returns {object[]} Findings, one per offending line.
474
+ */
475
+ export function lintNarratedCoverage({ cwd, slug }) {
476
+ const findings = [];
477
+ const dir = join(sharedRoot(cwd, slug), "spec");
478
+ let files;
479
+ try { files = readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { return findings; }
480
+ for (const f of files) {
481
+ let lines;
482
+ try { lines = readFileSync(join(dir, f), "utf8").split(/\r?\n/); } catch { continue; }
483
+ lines.forEach((line, i) => {
484
+ if (!/^\|\s*Coverage\s*\|/i.test(line.trim())) return;
485
+ const named = [...line.matchAll(/\bREQ-\d+/g)].map((m) => m[0]);
486
+ if (!named.length) return;
487
+ findings.push({ rule: "REQ-NARRATED", level: "red", scope: `${f}:${i + 1}`, detail:
488
+ `${f}:${i + 1} states the requirement-coverage verdict in a committed file, naming ${named.slice(0, 3).join(", ")}` +
489
+ `${named.length > 3 ? ` (+${named.length - 3})` : ""}. That row is the UC × Task indicator; the ` +
490
+ "REQ → AC → criterion → verdict state is a projection derived per run (probe requirements), " +
491
+ "never a claim a committed artifact may make — a reader who checks the file finds corroboration " +
492
+ "for something no run measured. Say what the use cases and tasks show, and leave the requirement " +
493
+ "matrix to the run that derives it." });
494
+ });
495
+ }
496
+ return findings;
497
+ }
498
+
499
+ /**
500
+ * REQ-UNCOVERED and REQ-NOGO — the registry's two ways of being wrong about what ships.
501
+ *
502
+ * @param {{clauses:object[], board:object[], scopes:object[]}} opts - The parsed registry, the
503
+ * board `readBoard` produced (its acceptance criteria carry the covers clauses), and the scope
504
+ * contracts.
505
+ * @returns {object[]} Findings, most specific first: a no-go registered as covered is reported as
506
+ * itself rather than as the coverage gap it inevitably becomes.
507
+ */
454
508
  export function lintRequirementCoverage({ clauses = [], board = [], scopes = [] }) {
455
509
  const findings = [];
456
510
  const graded = coveredReqIds(board);
@@ -459,6 +513,21 @@ export function lintRequirementCoverage({ clauses = [], board = [], scopes = []
459
513
  const claimed = new Set();
460
514
  for (const s of scopes) for (const r of s.covers || []) claimed.add(reqId(r).toUpperCase());
461
515
  for (const c of clauses) {
516
+ // A NO-GO IS A CONSTRAINT, NOT A DELIVERABLE, and marking one `covered` asserts something that
517
+ // cannot be true: nothing grades "do not build a settings screen". Measured on a consumer — a
518
+ // coverage dispatch lifted seven clauses out of the pitch's No-gos section, registered each as
519
+ // covered, and L1b then refused the run with seven REQ-UNCOVERED findings, correctly and
520
+ // unavoidably. Reported here as itself, so the operator reads one cause instead of seven
521
+ // symptoms, and named before REQ-UNCOVERED can fire on the same row.
522
+ if (c.status === "covered" && NOGO_SOURCE.test(c.source || "")) {
523
+ findings.push({ rule: "REQ-NOGO", level: "red", scope: c.id, detail:
524
+ `${c.id} ← ${c.source} registers a NO-GO as a covered requirement — "${(c.clause || "").slice(0, 60)}". ` +
525
+ "A no-go is a constraint the shape deliberately does not build, so no acceptance criterion can " +
526
+ "grade it and nothing downstream can ever turn it green. Mark it CUT (PO-approved) in " +
527
+ "requirements.md — the family that already means deliberately-not-built — or drop the row and give " +
528
+ "the breach a Test Surface row (TS-NOGO-NN) instead, which is the channel that does grade one." });
529
+ continue;
530
+ }
462
531
  if (c.status !== "covered") continue; // CUT (PO-approved) — an answer on the record, not a gap
463
532
  const id = c.id.toUpperCase();
464
533
  if (graded.has(c.id) || claimed.has(id)) continue;
@@ -851,6 +920,7 @@ export function lint({ cwd, slug }) {
851
920
  // `readBoard`, not the `tasks` above: only the compile-order parser carries acceptance_criteria.
852
921
  ...lintRequirementCoverage({ clauses: reqClauses, board: readBoard(cwd, slug), scopes }),
853
922
  ...lintCommittedTier({ cwd, slug }),
923
+ ...lintNarratedCoverage({ cwd, slug }),
854
924
  ...lintStructure({ specDir: specRoot, tasks, intakeContent }),
855
925
  ...(() => {
856
926
  const bbText = runBreadboard(cwd, slug, intakeContent);
@@ -194,13 +194,17 @@ export function runDbProbe(dbProbeCmd, cwd) {
194
194
  */
195
195
  export function seesawCheck(registryPath, cwd) {
196
196
  if (!registryPath || !existsSync(registryPath)) {
197
- return { ran: false, pass: true, scopes_checked: [], failing: [] };
197
+ // `pass: null`, not `pass: true`: a check that did not run has no result, and recording one as
198
+ // clean is how "not asked" came to read as "nothing regressed". The verdict below still treats
199
+ // an absent seesaw as non-blocking — that part is deliberate while the arm is unwired — but the
200
+ // artifact now says which of the two it was.
201
+ return { ran: false, pass: null, scopes_checked: [], failing: [] };
198
202
  }
199
203
  let registry;
200
204
  try {
201
205
  registry = JSON.parse(readFileSync(registryPath, "utf8"));
202
206
  } catch {
203
- return { ran: false, pass: true, scopes_checked: [], failing: [], error: "registry unparsable" };
207
+ return { ran: false, pass: null, scopes_checked: [], failing: [], error: "registry unparsable" };
204
208
  }
205
209
  const scopes = registry.scopes || [];
206
210
  const failing = [];
@@ -222,7 +226,12 @@ export function seesawCheck(registryPath, cwd) {
222
226
  export function computeVerdict({ fixtures, dbProbe, seesaw }) {
223
227
  const fixturesGreen = fixtures.pass;
224
228
  const dbGreen = dbProbe === null || dbProbe.pass;
225
- const seesawGreen = !seesaw.ran || seesaw.pass;
229
+ // A seesaw that did not run does not hold the verdict red — the arm is declared and unwired, and
230
+ // blocking every build on it would be a different defect. It does not make it green either: the
231
+ // hill requires `ran && pass` before a scope may reach FINISHED, and `seesaw_green` here means
232
+ // "nothing this check found is wrong", which is true of a check that found nothing because it
233
+ // never looked.
234
+ const seesawGreen = seesaw.ran ? seesaw.pass === true : true;
226
235
  return {
227
236
  fixtures_green: fixturesGreen,
228
237
  db_probe_green: dbGreen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.7.9",
3
+ "version": "3.7.12",
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"
@@ -26,6 +26,11 @@ depends_on:
26
26
 
27
27
  | Indicator | Status | Signal |
28
28
  |-----------|--------|--------|
29
+ <!-- Coverage here is USE CASES × TASKS, derived by inverting each task's use_case_refs over the
30
+ local board. It is NOT requirement coverage: whether every REQ-id reaches an acceptance
31
+ criterion that a judge graded is a projection derived per run (`probe requirements`), and a
32
+ committed file that states it is corroborating something no run measured. Name REQ ids in this
33
+ row and spec-lint reds it (REQ-NARRATED). Count use cases and tasks; say nothing about REQ. -->
29
34
  | Coverage | COVERAGE_STATUS | COVERAGE_SIGNAL |
30
35
  | Risk | RISK_STATUS | RISK_SIGNAL |
31
36
  | Dependency | DEPENDENCY_STATUS | DEPENDENCY_SIGNAL |