shapeup-sdlc 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  // probe eval — "what did round N's EVAL WorkResult actually say?"
2
2
  //
3
3
  // CONTRACT. A bounded, read-only query over the evaluate WorkResult ingest already wrote. Prints
4
- // `{ok, overall, bug_count, report_path}` on stdout; exits 0 when found and readable, 1 when the
5
- // result is missing (nothing ran, or ingest hasn't landed yet), 2 on a bad argv. Writes nothing.
4
+ // `{ok, overall, bug_count, report_path, round, status, reason}` on stdout; exits 0 when the round
5
+ // holds a verdict the run may act on, 1 when it does not — nothing ran, ingest hasn't landed, the
6
+ // evaluator refused the round, or the verdict is structurally invalid; `reason` says which — and 2
7
+ // on a bad argv. Writes nothing.
6
8
  //
7
9
  // WHY THIS EXISTS. `shapeup-run.js` cannot read a file itself (a Workflow script has no filesystem
8
10
  // of its own — see this repo's own note on why it may not call `Date.now()`), so every fact it
@@ -23,11 +25,65 @@
23
25
  // shared state; the `.md` report is prose for a human. Reading the prose to re-derive a verdict a
24
26
  // schema already carries structurally is the paraphrase channel this repo's hooks exist to close
25
27
  // everywhere else.
28
+ //
29
+ // WHY "NO VERDICT" CARRIES A REASON. An evaluator that refuses a round — a structural precondition
30
+ // it cannot meet — still writes `evaluate-r<N>.json`, with `status: failed`, no verdict, and the
31
+ // cause as its first deviation. A bare `ok: false` reached the operator as a sub-agent that died
32
+ // after retries, while the one sentence naming the actual cause sat in a file nobody was pointed at.
26
33
 
27
- import { existsSync, readFileSync } from "node:fs";
34
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
28
35
  import { join, resolve } from "node:path";
29
36
  import { runArgs } from "../lib/argv.mjs";
30
- import { resultsDir } from "../lib/paths.mjs";
37
+ import { resultsDir, scopesDir } from "../lib/paths.mjs";
38
+
39
+ /** Longest `reason` reported. A deviation is prose written by a worker and can run to paragraphs. */
40
+ const REASON_MAX = 400;
41
+
42
+ /**
43
+ * Bound a reason to {@link REASON_MAX} characters.
44
+ * @param {string} s - The reason.
45
+ * @returns {string} `s`, or its first REASON_MAX − 1 characters and an ellipsis.
46
+ */
47
+ const clip = (s) => (s.length > REASON_MAX ? `${s.slice(0, REASON_MAX - 1)}…` : s);
48
+
49
+ /**
50
+ * Whether a feature's spec is SCOPED — has scope contracts, the case in which every verdict must
51
+ * cite the T0 artifacts it re-hashed.
52
+ *
53
+ * @param {string} cwd - Project root.
54
+ * @param {string} slug - Feature slug.
55
+ * @returns {boolean} True when `scopes/` holds at least one contract (`.md`, or a legacy `.json`).
56
+ */
57
+ export function isScoped(cwd, slug) {
58
+ try { return readdirSync(scopesDir(cwd, slug)).some((f) => /\.(md|json)$/.test(f)); }
59
+ catch { return false; }
60
+ }
61
+
62
+ /**
63
+ * Why a verdict cannot stand as its round's judgement on T0 grounds, or null when it can.
64
+ *
65
+ * A PASS or FAIL on a scoped spec that cites no T0 artifact is structurally invalid — the
66
+ * evaluator's own contract says so, because T0 is the machine fact a generator cannot fabricate.
67
+ * That rule used to live only in the contract, so a verdict citing nothing was ingested, ledgered
68
+ * and branched on like any other. It is checked here so the round loop, the resume derivation, the
69
+ * hill and ingest all refuse the same verdict for the same reason.
70
+ *
71
+ * PRESENCE, NOT HASHES. The evaluator re-hashes what it cites; a slip transcribing a digest is not
72
+ * evidence the verdict is wrong, and refusing a round over one would cost a whole re-evaluation.
73
+ *
74
+ * @param {string} cwd - Project root.
75
+ * @param {string} slug - Feature slug.
76
+ * @param {object} verdict - The WorkResult's `verdict` block.
77
+ * @returns {(string|null)} The problem, phrased for an operator; null for a cited verdict, an
78
+ * unscoped spec, or a block with no PASS/FAIL in it (there is no judgement to invalidate).
79
+ */
80
+ export function citationProblem(cwd, slug, verdict) {
81
+ if (verdict?.overall !== "PASS" && verdict?.overall !== "FAIL") return null;
82
+ if (Array.isArray(verdict.t0_citations) && verdict.t0_citations.length) return null;
83
+ if (!isScoped(cwd, slug)) return null;
84
+ return `the ${verdict.overall} verdict cites no T0 artifact, and a verdict on a scoped spec must ` +
85
+ "cite the T0 verdict it re-hashed (the order lists them under payload.t0_artifacts)";
86
+ }
31
87
 
32
88
  /**
33
89
  * Read one round's EVAL verdict straight from the WorkResult `reduce ingest` wrote.
@@ -35,20 +91,35 @@ import { resultsDir } from "../lib/paths.mjs";
35
91
  * @param {string} cwd - Project root.
36
92
  * @param {string} slug - Feature slug.
37
93
  * @param {number} round - The EVAL round (`evaluate-r<N>.json`).
38
- * @returns {{found: boolean, overall: (string|null), bug_count: (number|null),
39
- * report_path: (string|null)}} `found: false` when no result exists yet — a fact, not a guess.
94
+ * @returns {{found: boolean, overall: (string|null), status: (string|null), reason: (string|null),
95
+ * bug_count: (number|null), report_path: (string|null)}} `found` is true only for a PASS/FAIL the
96
+ * round may act on; otherwise `reason` says why not — a fact, not a guess.
40
97
  */
41
98
  export function evalVerdict(cwd, slug, round) {
42
99
  const path = join(resultsDir(cwd, slug), `evaluate-r${round}.json`);
43
- if (!existsSync(path)) return { found: false, overall: null, bug_count: null, report_path: null };
100
+ const unfit = (reason, status = null, overall = null) =>
101
+ ({ found: false, overall, status, reason, bug_count: null, report_path: null });
102
+ if (!existsSync(path)) return unfit("no evaluate result for this round yet");
44
103
  let doc;
45
104
  try { doc = JSON.parse(readFileSync(path, "utf8")); }
46
- catch { return { found: false, overall: null, bug_count: null, report_path: null }; }
105
+ catch { return unfit("the evaluate result is not readable JSON"); }
106
+ const status = typeof doc?.status === "string" ? doc.status : null;
47
107
  const v = doc?.verdict || {};
48
108
  const overall = v.overall === "PASS" || v.overall === "FAIL" ? v.overall : null;
109
+ if (!overall) {
110
+ // A worker that refused to grade says why in its FIRST deviation — the only channel it has.
111
+ const first = Array.isArray(doc?.deviations) && typeof doc.deviations[0] === "string" ? doc.deviations[0] : "";
112
+ return unfit(clip(first
113
+ ? `the evaluator returned ${status || "no status"}: ${first}`
114
+ : `status ${status || "unknown"} with no PASS/FAIL verdict`), status);
115
+ }
116
+ const problem = citationProblem(cwd, slug, v);
117
+ if (problem) return unfit(problem, status, overall);
49
118
  return {
50
- found: overall !== null,
119
+ found: true,
51
120
  overall,
121
+ status,
122
+ reason: null,
52
123
  bug_count: Array.isArray(v.bugs) ? v.bugs.length : null,
53
124
  report_path: typeof v.report_path === "string" ? v.report_path : null,
54
125
  };
@@ -66,12 +137,12 @@ export const ARGV_SPEC = {
66
137
  * Report round N's EVAL verdict, mechanically, from the WorkResult on disk.
67
138
  *
68
139
  * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
69
- * @returns {void} Exits 0 when a verdict was found, 1 when none exists yet for this round.
140
+ * @returns {void} Exits 0 when the round holds a verdict the run may act on, 1 when it does not.
70
141
  */
71
142
  export function cli(rawArgv) {
72
143
  const args = runArgs(ARGV_SPEC, rawArgv);
73
144
  const cwd = resolve(args.cwd || process.cwd());
74
- const { found, overall, bug_count, report_path } = evalVerdict(cwd, args.slug, args.round);
75
- console.log(JSON.stringify({ ok: found, overall, bug_count, report_path, round: args.round }));
145
+ const { found, overall, status, reason, bug_count, report_path } = evalVerdict(cwd, args.slug, args.round);
146
+ console.log(JSON.stringify({ ok: found, overall, bug_count, report_path, round: args.round, status, reason }));
76
147
  process.exit(found ? 0 : 1);
77
148
  }
@@ -63,8 +63,9 @@ import { splitFrontmatter } from "../lib/contract.mjs";
63
63
  import { globToRegExp } from "../verify/spec.mjs";
64
64
  import {
65
65
  intake, harnessRun, wiringMap, projectProfile, scopesDir, resultsDir, ordersDir,
66
- orientDir, activeOrder, usecasesDir,
66
+ orientDir, activeOrder, usecasesDir, breadboard, receipt, readReceipt,
67
67
  } from "../lib/paths.mjs";
68
+ import { evalVerdict } from "./eval.mjs";
68
69
 
69
70
  /** The run-state values `references/protocol.md` (Part 4 — State) defines. A typo'd status is a rejection,
70
71
  * not a write — the whole point of this file is that a write nobody validates is a write nobody
@@ -377,6 +378,12 @@ export function deriveResumeState(cwd, slug) {
377
378
 
378
379
  const facts = {
379
380
  intake_path: intake(cwd, slug),
381
+ // The pitch's other half, staged by `init run` beside the intake. Null when the pitch had no
382
+ // separate breadboard — the planning dispatches then carry no `breadboard` key at all.
383
+ breadboard_path: existsSync(breadboard(cwd, slug)) ? breadboard(cwd, slug) : null,
384
+ // How it was found (flag | sibling | shaping-dir | shared-root | embedded), from the receipt;
385
+ // null when there was none or the receipt cannot be read.
386
+ breadboard_source: readReceipt(receipt(cwd, slug))?.breadboard?.source ?? null,
380
387
  spec_folder: hr.spec_folder || null,
381
388
  status: hr.status || null,
382
389
  lens: hr.lens || null,
@@ -405,9 +412,15 @@ export function deriveResumeState(cwd, slug) {
405
412
  // that permits the overlap is the same one that makes it invisible to the disjointness lint.
406
413
  scope_exclusions: scopeExclusions(cwd, slug, scope_files),
407
414
  pending_orders: orderFiles.filter((f) => f.endsWith(".json") && !resultFiles.includes(f)),
415
+ // A round is DONE when it was graded, not when its result file exists. An evaluator that
416
+ // refused the round — no PASS/FAIL, or a scoped verdict citing no T0 artifact — still writes
417
+ // `evaluate-r<N>.json`; counted, the relaunch opened round N+1 over a round nobody judged, with
418
+ // no bugs to route, and rebuilt every scope. Left open, it re-enters round N, skips the scopes
419
+ // already green there, and evaluates again.
408
420
  eval_rounds_done: resultFiles
409
421
  .filter((f) => /^evaluate-r\d+\.json$/.test(f))
410
- .map((f) => Number(f.match(/\d+/)[0])),
422
+ .map((f) => Number(f.match(/\d+/)[0]))
423
+ .filter((n) => evalVerdict(cwd, slug, n).found),
411
424
  };
412
425
  return { ...facts, next_phase: nextPhase(facts) };
413
426
  }
@@ -18,13 +18,36 @@ import { join, resolve } from "node:path";
18
18
  import { runArgs } from "../lib/argv.mjs";
19
19
  import { verdictsDir } from "../lib/paths.mjs";
20
20
 
21
+ /**
22
+ * Verdict filenames, newest first by their NUMERIC address.
23
+ *
24
+ * A string sort files `r1-a1-t10.json` before `r1-a1-t9.json`, and the trial ordinal is shared by
25
+ * every scope verified at one (round, attempt) — ten scopes on their first attempt are enough to
26
+ * make an older verdict read as the newest. Names that carry no address sort last.
27
+ *
28
+ * @param {string[]} names - Filenames from the verdicts directory.
29
+ * @returns {string[]} A new array, newest first: round, then attempt, then trial, descending.
30
+ */
31
+ export function newestFirst(names) {
32
+ const key = (f) => {
33
+ const m = f.match(/^r(\d+)-a(\d+)(?:-t(\d+))?\.json$/);
34
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : [-1, -1, -1];
35
+ };
36
+ return [...names].sort((a, b) => {
37
+ const ka = key(a), kb = key(b);
38
+ for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i];
39
+ return b.localeCompare(a);
40
+ });
41
+ }
42
+
21
43
  /**
22
44
  * The newest green T0 verdict for one scope in one round.
23
45
  *
24
46
  * @param {string} cwd - Project root.
25
47
  * @param {string} slug - Feature slug.
26
48
  * @param {string} scopeId - Scope contract id.
27
- * @param {number} round - Build round.
49
+ * @param {number} [round] - Build round. Omitted, the newest green verdict of ANY round — what an
50
+ * evaluation with no round (a standalone single pass) has to cite.
28
51
  * @returns {{green: boolean, path: (string|null)}} `path` is the artifact a later EVAL can cite.
29
52
  */
30
53
  export function greenVerdict(cwd, slug, scopeId, round) {
@@ -32,11 +55,11 @@ export function greenVerdict(cwd, slug, scopeId, round) {
32
55
  if (!existsSync(dir)) return { green: false, path: null };
33
56
  // Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
34
57
  // (round, attempt) address, and the LAST verdict is the one that stands.
35
- for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort().reverse()) {
58
+ for (const f of newestFirst(readdirSync(dir).filter((x) => x.endsWith(".json")))) {
36
59
  const p = join(dir, f);
37
60
  try {
38
61
  const b = JSON.parse(readFileSync(p, "utf8"));
39
- if (b.scope_id === scopeId && b.round === round && b.overall === "green") return { green: true, path: p };
62
+ if (b.scope_id === scopeId && (round == null || b.round === round) && b.overall === "green") return { green: true, path: p };
40
63
  } catch { /* a torn artifact proves nothing; keep looking */ }
41
64
  }
42
65
  return { green: false, path: null };
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
28
28
  import { validate } from "../verify/envelope.mjs";
29
29
  import { runArgs } from "../lib/argv.mjs";
30
30
  import { tasksDir, localRoot, dispatchReceipts, legLedger, readRunId } from "../lib/paths.mjs";
31
+ import { citationProblem } from "../probe/eval.mjs";
31
32
 
32
33
  const HERE = dirname(fileURLToPath(import.meta.url));
33
34
  const RESULT_SCHEMA = JSON.parse(readFileSync(resolve(HERE, "../../skills/tech-lead/schemas/work-result.schema.json"), "utf8"));
@@ -596,6 +597,20 @@ export async function cli(rawArgv) {
596
597
  process.exit(1);
597
598
  }
598
599
 
600
+ // --- T0 citation gate -----------------------------------------------------------------------
601
+ // A PASS or FAIL on a scoped spec that cites no T0 artifact is not a judgement this run may act
602
+ // on (see `citationProblem`). `probe eval` refuses it to the round loop; refusing it here as well
603
+ // keeps the verdict ledger from recording a verdict the loop will never branch on.
604
+ if (result.verdict) {
605
+ const problem = citationProblem(cwd, String(result.order_id).split("/")[0], result.verdict);
606
+ if (problem) {
607
+ console.error(`ingest-result: result refused — ${problem}.`);
608
+ console.error(` The round stays open: re-dispatch the evaluator against its order, which lists`);
609
+ console.error(` the T0 artifacts to cite. Nothing was written.`);
610
+ process.exit(1);
611
+ }
612
+ }
613
+
599
614
  // Resolved for EVERY order, not only the gated ones: the attesting receipt is this leg's start,
600
615
  // and a standalone or `--no-receipt-check` ingest still deserves a truthful timing row rather
601
616
  // than one silently falling back to the order's re-writable `compiled_at`.
@@ -42,6 +42,16 @@
42
42
  // Edge-cases heading with real content under it) but no usecases/UC-*.md declares
43
43
  // a single [INV-NN] anywhere — a criteria-count check can't tell a healthy small
44
44
  // tree from one that silently derived nothing from the pitch
45
+ // BREADBOARD-PLACE (red) a breadboard Place that owns UI affordances has no ux-behavior.md
46
+ // `## Screen: … (P#)` section and is not under `## Deferred Places`
47
+ // BREADBOARD-UI (red) a UI affordance (U#) not cited inside the screen section of any Place the
48
+ // breadboard puts it in. CITING IS NOT PLACING: a U# specified under another Place's
49
+ // screen passes a presence check and is exactly the defect this rule exists for
50
+ // BREADBOARD-TRACE (warn) N#/S# cited nowhere in the spec, V# slices no scope board records,
51
+ // U# the spec places that no manifest entry names as its `source`
52
+ // BREADBOARD-UNPARSED (warn) a staged breadboard this reader finds no ids in — a layout it
53
+ // cannot read must never become a hard stop
54
+ // All four are silent when the run has no breadboard (staged, or inline in the intake).
45
55
  //
46
56
  // Zero dependencies (glob matcher inlined from hooks/sandbox-guard.mjs). Judgment stays in the skill
47
57
  // (gap severity, lens choice); this script only reports facts.
@@ -56,6 +66,8 @@ import { runArgs } from "../lib/argv.mjs";
56
66
  import { LOCAL } from "../lib/paths.mjs";
57
67
  import { specDir, scopesDir, tasksDir, intake, sharedRoot, requirements } from "../lib/paths.mjs";
58
68
  import { readAllContracts, unreadableReason, ucId, scopePartitionConflicts, SCOPE_CONTRACT } from "../lib/contract.mjs";
69
+ import { breadboard as stagedBreadboard } from "../lib/paths.mjs";
70
+ import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
59
71
 
60
72
  // Inlined from hooks/sandbox-guard.mjs so this skill ships self-contained (a skill's scripts
61
73
  // must not reach outside its own folder — channels that copy only skills/ would dangle).
@@ -494,12 +506,169 @@ export function lintStructure({ specDir, tasks, intakeContent = "" }) {
494
506
  return findings;
495
507
  }
496
508
 
509
+ /** Every Place id inside a heading's parentheses — `## Screen: Sheet (P2)`, `(P1, P3)`. */
510
+ const PLACE_IN_PARENS = /\(([^)]*)\)/g;
511
+ const PLACE_ID = /\bP\d+(?:\.\d+)*\b/g;
512
+
513
+ /**
514
+ * Cut ux-behavior.md into the screen sections a breadboard's Places are checked against.
515
+ *
516
+ * A section runs from its `Screen:` heading to the next heading at the same level or higher, so
517
+ * a screen's `### States` table and behavior rules belong to it. Its Places are the P# ids in the
518
+ * heading's parentheses; a screen with none is kept (its citations are still "somewhere") but can
519
+ * place nothing.
520
+ *
521
+ * @param {string} uxText - ux-behavior.md, verbatim ("" when absent).
522
+ * @returns {{screens: {heading: string, places: string[], body: string}[], deferred: Set<string>}}
523
+ * The screen sections, and the Place ids listed first-cell under `## Deferred Places`.
524
+ */
525
+ export function uxScreens(uxText) {
526
+ const lines = String(uxText ?? "").split(/\r?\n/);
527
+ const screens = [];
528
+ const deferred = new Set();
529
+ let cur = null; // { level, heading, places, body[] } for a screen, or { level, deferred: true }
530
+ for (const line of lines) {
531
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
532
+ if (h) {
533
+ const level = h[1].length;
534
+ if (cur && level <= cur.level) cur = null;
535
+ if (!cur) {
536
+ const text = h[2].trim();
537
+ if (/^screen\s*:/i.test(text)) {
538
+ const places = [...text.matchAll(PLACE_IN_PARENS)].flatMap((m) => m[1].match(PLACE_ID) ?? []);
539
+ cur = { level, heading: text, places, body: [] };
540
+ screens.push(cur);
541
+ continue;
542
+ }
543
+ if (/^deferred places\b/i.test(text)) { cur = { level, deferred: true }; continue; }
544
+ }
545
+ }
546
+ if (!cur) continue;
547
+ if (cur.deferred) {
548
+ const row = line.match(/^\s*\|\s*([^|]*)\|/);
549
+ const id = row ? row[1].replace(/[*`\[\]]/g, "").match(/^\s*(P\d+(?:\.\d+)*)\b/) : null;
550
+ if (id) deferred.add(id[1]);
551
+ } else cur.body.push(line);
552
+ }
553
+ return {
554
+ screens: screens.map((s) => ({ heading: s.heading, places: s.places, body: s.body.join("\n") })),
555
+ deferred,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Lint the spec against the pitch's breadboard: every Place with UI affordances has a screen, and
561
+ * every UI affordance is specified on a screen of a Place the breadboard puts it in.
562
+ *
563
+ * PLACEMENT, NOT CITATION. The loss this exists for did not drop a new sheet's affordances — they
564
+ * were all in the spec, inside the composer's state table. What was lost was the Place. A rule that
565
+ * only asks "is U2 cited?" passes that spec; this one asks "is U2 cited under P2?". It checks WHICH
566
+ * screen, never where on the screen: layout inside a Place stays the designer's.
567
+ *
568
+ * Absent breadboard ⇒ zero findings, the same "absent artifact ⇒ arm skipped" rule INV-FLOOR and
569
+ * SCOPE-COVERS follow — every pre-breadboard spec and every run without one is untouched.
570
+ *
571
+ * @param {object} input - What to lint (destructured).
572
+ * @param {string} input.uxText - ux-behavior.md, verbatim ("" when absent).
573
+ * @param {string} input.specText - Every markdown file under the spec tree, concatenated.
574
+ * @param {Array<object>} [input.scopes] - Parsed scope contracts ([] before MAP SCOPES).
575
+ * @param {(string|null)} [input.scopeSummaryText] - scope-summary.md, or null when absent.
576
+ * @param {(string|null)} [input.scopeBoardText] - scope-board.md, or null when absent — the scope
577
+ * architect's own write surface, where it records which scopes deliver each slice.
578
+ * @param {(string|null)} input.bbText - The breadboard, or null when the run has none.
579
+ * @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean or
580
+ * when there is no breadboard.
581
+ */
582
+ export function lintBreadboard({ uxText = "", specText = "", scopes = [], scopeSummaryText = null, scopeBoardText = null, bbText = null }) {
583
+ if (!bbText) return [];
584
+ const findings = [];
585
+ const bb = parseBreadboard(bbText);
586
+ const counts = idCounts(bb);
587
+ if (Object.values(counts).every((n) => n === 0)) {
588
+ findings.push({ rule: "BREADBOARD-UNPARSED", level: "warn", detail: "the run has a breadboard but no P#/U#/N#/S#/V# ids could be read from its tables — placement was not checked. A `#` or `ID` first column holding the id, and a `Place` column on affordance rows, is the layout this reads" });
589
+ return findings;
590
+ }
591
+
592
+ const { screens, deferred } = uxScreens(uxText);
593
+ /**
594
+ * A Place as a finding names it — id and breadboard name.
595
+ * @param {string} p - Place id.
596
+ * @returns {string} e.g. `P2 Payment Sheet`.
597
+ */
598
+ const name = (p) => {
599
+ const n = bb.places.find((x) => x.id === p)?.name;
600
+ return n ? `${p} ${n}` : p;
601
+ };
602
+ /**
603
+ * Does the text cite this exact id — `U2` but not `U21` or `U2a`?
604
+ * @param {string} text - Markdown to search.
605
+ * @param {string} id - A breadboard id.
606
+ * @returns {boolean} True when the id appears as a whole word.
607
+ */
608
+ const cites = (text, id) => new RegExp(`\\b${id.replace(/\./g, "\\.")}\\b`).test(text);
609
+
610
+ // BREADBOARD-PLACE — a Place with something to place needs a screen of its own.
611
+ const owners = new Map(); // Place → the U# it owns
612
+ for (const u of bb.ui) for (const p of u.places) (owners.get(p) ?? owners.set(p, []).get(p)).push(u.id);
613
+ for (const [p, us] of owners) {
614
+ if (deferred.has(p)) continue;
615
+ if (screens.some((s) => s.places.includes(p))) continue;
616
+ findings.push({ rule: "BREADBOARD-PLACE", level: "red", detail: `${name(p)} owns ${us.join(", ")} but ux-behavior.md has no "## Screen: … (${p})" section — add the screen or defer the Place under "## Deferred Places"; never fold it into another screen` });
617
+ }
618
+
619
+ // BREADBOARD-UI — each U# is specified on a screen of a Place the breadboard puts it in.
620
+ const unplaceable = [];
621
+ for (const u of bb.ui) {
622
+ if (!u.places.length) { unplaceable.push(u.id); continue; }
623
+ const live = u.places.filter((p) => !deferred.has(p));
624
+ if (!live.length) continue;
625
+ if (screens.some((s) => s.places.some((p) => live.includes(p)) && cites(s.body, u.id))) continue;
626
+ const elsewhere = [...new Set(screens.filter((s) => cites(s.body, u.id)).flatMap((s) => s.places.length ? s.places : [`"${s.heading}"`]))];
627
+ const where = elsewhere.length
628
+ ? `is cited only under ${elsewhere.join(", ")}`
629
+ : cites(uxText, u.id) ? "is cited in ux-behavior.md but on no screen" : "is cited on no screen";
630
+ findings.push({ rule: "BREADBOARD-UI", level: "red", detail: `${u.id} (${live.map(name).join(" / ")}) ${where} — specify it in the screen section of its own Place` });
631
+ }
632
+
633
+ // BREADBOARD-TRACE — the rest of the breadboard, reported and never blocking.
634
+ const trace = [];
635
+ const lost = [...bb.code, ...bb.stores].map((x) => x.id).filter((id) => !cites(specText, id));
636
+ if (lost.length) trace.push(`N#/S# cited nowhere in the spec: ${lost.join(", ")}`);
637
+ const slicesText = [scopeSummaryText, scopeBoardText].filter((t) => t !== null && t !== undefined).join("\n");
638
+ if (scopeSummaryText !== null || scopeBoardText !== null) {
639
+ const unsliced = bb.slices.map((v) => v.id).filter((id) => !cites(slicesText, id));
640
+ if (unsliced.length) trace.push(`V# slices no scope board or scope summary records: ${unsliced.join(", ")}`);
641
+ }
642
+ if (scopes.length) {
643
+ const sourced = new Set(scopes.flatMap((s) => (s.affordance_manifest ?? []).map((a) => String(a?.source ?? "").trim())));
644
+ const unsourced = bb.ui.map((u) => u.id).filter((id) => cites(uxText, id) && !sourced.has(id));
645
+ if (unsourced.length) trace.push(`U# the spec places but no manifest entry names as its source: ${unsourced.join(", ")}`);
646
+ }
647
+ if (unplaceable.length) trace.push(`U# with no Place column to check placement against: ${unplaceable.join(", ")}`);
648
+ if (trace.length) findings.push({ rule: "BREADBOARD-TRACE", level: "warn", detail: trace.join("; ") });
649
+ return findings;
650
+ }
651
+
652
+ /**
653
+ * The breadboard a run's spec is linted against: the staged copy, else the intake when it carries
654
+ * one inline, else null — and null switches every BREADBOARD-* rule off.
655
+ * @param {string} cwd - Project root.
656
+ * @param {string} slug - Feature slug.
657
+ * @param {string} intakeContent - The run's intake, verbatim ("" when absent).
658
+ * @returns {(string|null)} The breadboard text, or null when the run has none.
659
+ */
660
+ export function runBreadboard(cwd, slug, intakeContent) {
661
+ const p = stagedBreadboard(cwd, slug);
662
+ if (existsSync(p)) return readFileSync(p, "utf8");
663
+ return hasBreadboardTables(intakeContent) ? intakeContent : null;
664
+ }
665
+
497
666
  /**
498
667
  * Run the full spec lint (scopes + structure) for a slug.
499
668
  * @param {{cwd:string, slug:string}} opts - Working root and feature slug.
500
669
  * @returns {{slug:string, scopes:number, tasks:number, red:number, warn:number,
501
- * findings:Array<object>}} Counts and the combined findings from {@link lintScopes} and
502
- * {@link lintStructure}.
670
+ * findings:Array<object>}} Counts and the combined findings from {@link lintScopes},
671
+ * {@link lintStructure} and, when the run has a breadboard, {@link lintBreadboard}.
503
672
  */
504
673
  export function lint({ cwd, slug }) {
505
674
  const specRoot = specDir(cwd, slug);
@@ -525,6 +694,25 @@ export function lint({ cwd, slug }) {
525
694
  ...lintScopeAnchors({ scopes, specDir: specRoot, reqIds, tasks }),
526
695
  ...lintCommittedTier({ cwd, slug }),
527
696
  ...lintStructure({ specDir: specRoot, tasks, intakeContent }),
697
+ ...(() => {
698
+ const bbText = runBreadboard(cwd, slug, intakeContent);
699
+ if (!bbText) return [];
700
+ /**
701
+ * A file's text, or null when it does not exist.
702
+ * @param {string} p - Absolute path.
703
+ * @returns {(string|null)} The contents, or null.
704
+ */
705
+ const readOr = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
706
+ const specFiles = existsSync(specRoot) ? walkFiles(specRoot).filter((f) => f.endsWith(".md")) : [];
707
+ return lintBreadboard({
708
+ uxText: readOr(join(specRoot, "ux-behavior.md")) ?? "",
709
+ specText: specFiles.map((f) => readFileSync(join(specRoot, f), "utf8")).join("\n"),
710
+ scopes,
711
+ scopeSummaryText: readOr(join(specRoot, "scope-summary.md")),
712
+ scopeBoardText: readOr(join(sharedRoot(cwd, slug), "scope-board.md")),
713
+ bbText,
714
+ });
715
+ })(),
528
716
  ];
529
717
  return {
530
718
  slug,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shapeup-sdlc",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
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,7 @@ Invoked as `--order <path>`. Fields you may rely on (absent = unknown; surface i
26
26
  |---|---|
27
27
  | `operation` | `analyze` (pitch → full spec tree + board) · `reconcile` (fold discovered-ledger items into the board + UC invariants) · `retrofit-surface` (append `## Test Surface` to a pre-surface spec) · `coverage` (extract atomic requirement clauses → the SHARED `requirements.md` registry) |
28
28
  | `payload.pitch` | The pitch/PRD path (analyze) |
29
+ | `payload.breadboard` | The breadboard (analyze): its Places are your screens; its U# and N# are the affordances you place and cite. Absent = none separate; never inferred |
29
30
  | `payload.requirements` | (coverage) the REQ source to extract atomic clauses from — pitch / a customer-requirements doc / the use-case bodies. Absent → default to the pitch and record the choice in `assumptions[]` |
30
31
  | `payload.lens` | `lite` \| `standard` \| `cross-context`. Absent → judge it: LITE for ≤2-week appetite, no third-party, ≤3 user-facing actions; STANDARD for multi-team, third-party, or bigger appetite; genuinely unclear → one binary question, or `status: "escalated"` with the question in `deviations[]` |
31
32
  | `payload.orient_dir` | The Scout's artifacts — `code-surface.md` IS your codebase map (do not re-scan), `discovered-seed.md` seeds task gen, `spike-*.md` feeds feasibility |
@@ -43,8 +44,11 @@ Phases, each with a checkpoint (pause only per `interaction`). Read the referenc
43
44
  its phase; templates live in `assets/templates/`.
44
45
 
45
46
  ```
46
- 1 INGEST pitch + orient artifacts + KB. Extract slug, appetite, in/out boundaries,
47
- rabbit holes, third-party mentions. No files written yet.
47
+ 1 INGEST pitch + breadboard (`payload.breadboard`, or tables inline in the pitch) +
48
+ orient artifacts + KB. Extract slug, appetite, in/out boundaries, rabbit
49
+ holes, third-party mentions. With a breadboard, list every Place (P#) and UI
50
+ affordance (U#) first — they are the screens and interactive elements Phase 3
51
+ must place. No files written yet.
48
52
  1b FEASIBILITY (third-party/API/SDK/webhook mentioned) verification questions + fallback
49
53
  scope per API-NN → api-feasibility.md
50
54
  2 DDD bounded contexts, aggregates (new vs extended), value objects, domain events,
@@ -52,8 +56,9 @@ its phase; templates live in `assets/templates/`.
52
56
  2b CONTRACTS (standard lens) typed Request/Response/Error per repository; two-pass rule:
53
57
  unresolvable at spec time → `⏳ TBD — verify in the [UC-x] spike`, resolved
54
58
  post-SPIKE with citation → contracts/ [references/contract-patterns.md]
55
- 3 UX per screen: state table (idle→loading→error→success), error cases with
56
- message+action, ASCII flows → ux-behavior.md [references/ux-behavior-patterns.md]
59
+ 3 UX per screen — with a breadboard, one screen per Place that owns UI affordances:
60
+ state table (idle→loading→error→success), error cases with message+action,
61
+ ASCII flows → ux-behavior.md [references/ux-behavior-patterns.md]
57
62
  4 USE CASES one file per actor+action: typed Input/Output, numbered Steps, all error
58
63
  cases with codes, ## System Flow (UI→API→UC→Repo→DB), ## Test Surface
59
64
  (DERIVED ONLY from D1 Invariants · D2 Error Cases · D3 Contract shape ·
@@ -69,7 +74,8 @@ its phase; templates live in `assets/templates/`.
69
74
  overflow is a fact you REPORT for the caller's HAMMER gate, never resolve)
70
75
  node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" verify spec --slug <slug>
71
76
  (structure, wikilinks, edge symmetry — fix reds, then re-run; you never
72
- self-grade with a hand-walked checklist)
77
+ self-grade with a hand-walked checklist. BREADBOARD-PLACE / BREADBOARD-UI:
78
+ add the screen or defer the Place; never fold it into another screen)
73
79
  → scope-summary.md + synthesis.md (traceability matrix, risk register,
74
80
  dependency graph — the JUDGMENT layers over board-derive's numbers)
75
81
  8 INDEX _index.md (pitch digest + document map) + feedback.md template
@@ -29,7 +29,8 @@ audit_rules_version: "2.5"
29
29
  ## Solution Elements
30
30
 
31
31
  ### Breadboarding
32
- <!-- Text-based flow showing the key interaction path, no images needed -->
32
+ <!-- Text-based flow showing the key interaction path, no images needed. With a breadboard,
33
+ name Places and affordances by id: P1 Cart ──U1──► P2 Payment Sheet -->
33
34
  ```
34
35
  [Screen A] ──action──► [Screen B] ──action──► [Outcome]
35
36
  │
@@ -29,7 +29,7 @@ status: draft
29
29
 
30
30
  ---
31
31
 
32
- ## Screen: [ScreenName]
32
+ ## Screen: [ScreenName] ([P#] — omit without a breadboard)
33
33
 
34
34
  ### States
35
35
 
@@ -54,7 +54,7 @@ status: draft
54
54
 
55
55
  ---
56
56
 
57
- <!-- Repeat "Screen: [Name]" section for each screen -->
57
+ <!-- Repeat "Screen: [Name]" section for each screen — one per breadboard Place with UI affordances -->
58
58
 
59
59
  ---
60
60
 
@@ -63,3 +63,13 @@ status: draft
63
63
  | Behavior | Mobile | Web |
64
64
  |---|---|---|
65
65
  | [behavior] | [mobile treatment] | [web treatment] |
66
+
67
+ ---
68
+
69
+ ## Deferred Places
70
+
71
+ <!-- Breadboard Places with UI affordances this shape will not build. Each needs the PO's yes at GATE L1b. Omit the section when there are none. -->
72
+
73
+ | Place | Reason |
74
+ |---|---|
75
+ | [P#] [Place name] | [why this shape does not build it] |
@@ -126,6 +126,9 @@ Required sections: Screen Flow (ASCII diagram), one section per Screen with:
126
126
  - **Visual & Layout Specs**: Flex/Grid structure, spacing, alignment rules, and desktop/mobile responsiveness
127
127
  - **Design Tokens**: Specific CSS variables or Tailwind classes used for background, borders, fonts, and actions
128
128
  - **States table**, **Behavior Rules list**, **Error States table**
129
+ - Screen headings carry the breadboard Place id when a breadboard exists — `## Screen: Payment Sheet (P2)`, one screen per Place that owns UI affordances, each U# cited inside its own Place's section
130
+
131
+ Plus **Deferred Places**, when any: a `Place | Reason` table of breadboard Places this shape will not build.
129
132
 
130
133
 
131
134
  ---
@@ -15,6 +15,15 @@ From the pitch breadboarding and fat marker sketches, identify:
15
15
 
16
16
  Each decision point is typically a screen boundary.
17
17
 
18
+ > **With a breadboard, the screens are its Places.** Write one `## Screen:` section per Place that
19
+ > owns at least one UI affordance, with the Place id in the heading — `## Screen: Payment Sheet (P2)`.
20
+ > Cite each UI affordance's id (`U3`) in the state-table row or behavior rule that specifies it,
21
+ > inside the section of the Place the breadboard puts it in. A Place is a screen boundary: a sheet or
22
+ > modal the breadboard names as its own Place is never folded into its parent's state table, even
23
+ > when the pitch's prose describes it as part of the parent. A Place with no UI affordances (a
24
+ > backend, a store) needs no screen. A Place this shape will not build goes in `## Deferred Places`
25
+ > with the reason; it surfaces at GATE L1b, where the PO accepts or rejects the deferral.
26
+
18
27
  ---
19
28
 
20
29
  ## State Machine per Screen