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,7 +1,7 @@
1
1
  {
2
2
  "name": "shapeup-sdlc-plugin",
3
3
  "displayName": "ShapeUp SDLC Plugin",
4
- "version": "3.1.1",
4
+ "version": "3.2.0",
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",
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ Skills and commands are named short throughout this file; every one of them reso
18
18
  1. Set Boundaries → `/shapeup shaping`
19
19
  2. Find the Elements → `/shapeup breadboarding`
20
20
  3. Risks & Rabbit Holes → `/shapeup spike`
21
- (The completed pitch is formed by `shaping.md` + `breadboard.md`)
21
+ (The completed pitch is formed by `shaping.md` + `breadboard.md`. A run takes both: `/ship` finds the breadboard beside the pitch or in `shaping/`, takes one named with `--breadboard`, or reads it inline in a single pitch file, and hands it to every planning worker. Each breadboard Place with UI affordances becomes its own screen in the spec — spec-lint stops the run at L1b when one is missing or folded into another screen — and a Place the shape will not build is deferred there, with the PO's yes.)
22
22
 
23
23
  ### Phase 2 — Betting (PO governance, no skill)
24
24
  Betting Table: PO decides; rejected pitches loop back to raw idea.
@@ -58,7 +58,7 @@ Everything discovered funnels into `.shapeup/<slug>/discovery/ledger.md` (Orient
58
58
  - **Ledger = single source of truth** — every discovery flow writes only its own section.
59
59
  - **QA is a level-up, not a gate** — `--no-qa` skips it; circuit breaker outranks the Hunter.
60
60
  - **Role separation** — Evaluator grades, task-executor fixes, QA discovers.
61
- - **Hill phase is mechanical ✦** — derived only from T0/T1/seesaw artifacts, never self-reported; the evaluator cites a T0 artifact it re-hashes itself.
61
+ - **Hill phase is mechanical ✦** — derived only from T0/T1/seesaw artifacts, never self-reported; the evaluator cites a T0 artifact it re-hashes itself, from the list its order carries. A scoped verdict citing none is refused: its round stays open and is evaluated again, never advanced.
62
62
  - **Envelope port (v1.0)** — every dispatch is WorkOrder in / WorkResult out; shared state has exactly one writer (the ingest step); malformed envelopes are hook-denied. Workers: stateless, craft-only, pipeline-blind.
63
63
 
64
64
  ## Setup & Execution
package/commands/ship.md CHANGED
@@ -71,6 +71,11 @@ Only run headless/auto if the user explicitly asks for it in their message:
71
71
  tiny, it will say so and recommend the full lane.
72
72
 
73
73
  Additional flags, pass through to `tech-lead` only when the user names them:
74
+ - `--breadboard <path>` → the pitch's breadboard, when it is not beside the pitch. A pitch shaped
75
+ with `/shapeup` is `shaping.md` + `breadboard.md`; the run finds a `breadboard.md` in the same
76
+ folder (or in `shaping/`) on its own, and reads one written inline in a single `pitch.md`. Name
77
+ it only when it lives somewhere else. Its Places become the spec's screens, and spec-lint stops
78
+ the run at GATE L1b when one is missing or folded into another screen.
74
79
  - `--gate-answers <ci|guarded|interactive|path.json>` → the pre-recorded PO decisions this run
75
80
  crosses its gates with. Gates still emit their blocks and still record a decision; the
76
81
  decision's **source** becomes the answer set instead of a live human, and the ledger says so.
@@ -73,7 +73,7 @@ const hasResume = /--from\s+\S/.test(args); // resuming an existing run has its
73
73
  // the spec.
74
74
  // Adding a valued flag anywhere in the harness means adding it here, and structural test §39
75
75
  // enforces exactly that against commands/ship.md.
76
- const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|parallel-scopes|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order)\s+\S+/g;
76
+ const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|parallel-scopes|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order|breadboard)\s+\S+/g;
77
77
  const BARE_FLAGS = /--[a-z0-9-]+/g;
78
78
  const freeText = args.replace(VALUED_FLAGS, " ").replace(BARE_FLAGS, " ").trim();
79
79
 
@@ -36,10 +36,11 @@ import { readRunId } from "./lib/paths.mjs";
36
36
  // --spec-overridden directory, and the import is the convention-derived default.
37
37
  import {
38
38
  tasksDir, specDir as defaultSpecDir, roundLedger, trials, verdictsDir, ordersDir,
39
- relShared, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
39
+ relShared, relLocal, globLocal, globShared, relKnowledgeBase, resultsDir, scopesDir,
40
40
  } from "./lib/paths.mjs";
41
- import { readContract, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
41
+ import { readContract, readAllContracts, tasksForScope, SCOPE_CONTRACT } from "./lib/contract.mjs";
42
42
  import { writeActiveOrder } from "./probe/resume.mjs";
43
+ import { greenVerdict } from "./probe/t0.mjs";
43
44
  // The SAME matcher the sandbox hook enforces with. "Is this cited file inside this scope's
44
45
  // substrate" has to mean exactly what the guard means, or a bug is addressed to a scope that is
45
46
  // then denied the write that fixes it.
@@ -482,6 +483,43 @@ export function scopeSubstrates(cwd, slug) {
482
483
  return out;
483
484
  }
484
485
 
486
+ // --- the T0 artifacts the judge must cite ----------------------------------------------------
487
+ //
488
+ // WHY THE KERNEL DERIVES THEM. spec-evaluator treats a scoped spec whose order lists no T0 artifact
489
+ // as NOT gradeable and returns `failed` without grading a criterion. The precondition is right —
490
+ // its verdict must cite a T0 artifact it re-hashed itself — and nothing met it: the list was once
491
+ // assembled by an orchestrator courier from the paths each scope reported, and was lost when the
492
+ // orchestrator became a workflow script that passes only `{dimensions, run_cmd, round}`. Evaluators
493
+ // that went looking on disk graded anyway; one that followed its contract refused, and the run
494
+ // aborted at L3 over a round whose every scope was green.
495
+ //
496
+ // Derived here for the reason `bugs` is: this is the one line every lane compiles through, and the
497
+ // evidence is already on disk. A caller could not rebuild the list from filenames in any case —
498
+ // verdict files are addressed by round, attempt and trial, never by scope, so the scope lives only
499
+ // inside each body, which is what `probe t0` reads.
500
+
501
+ /**
502
+ * The green T0 verdict each scope contract holds for a round — an evaluate order's `t0_artifacts`.
503
+ *
504
+ * @param {string} cwd - Project root.
505
+ * @param {string} slug - Feature slug.
506
+ * @param {number} [round] - The round being evaluated. Omitted, each scope's newest green verdict
507
+ * of any round — a standalone evaluation has no round.
508
+ * @returns {{artifacts: string[], missing: string[]}} Repo-relative verdict paths in scope-id
509
+ * order, one per scope that has one; and the scopes that have none. Both empty on an unscoped spec.
510
+ */
511
+ export function t0ArtifactsFor(cwd, slug, round) {
512
+ const artifacts = [];
513
+ const missing = [];
514
+ for (const { contract, id } of readAllContracts(scopesDir(cwd, slug))) {
515
+ const scopeId = contract?.scope_id || id;
516
+ const { green, path } = greenVerdict(cwd, slug, scopeId, round);
517
+ if (green) artifacts.push(relLocal(slug, "t0", "verdicts", basename(path)));
518
+ else missing.push(scopeId);
519
+ }
520
+ return { artifacts, missing };
521
+ }
522
+
485
523
  /**
486
524
  * Assemble a WorkOrder envelope. Pure given its inputs — the CLI wrapper does the disk reads.
487
525
  * @param {object} opts - The order inputs (destructured):
@@ -743,6 +781,17 @@ export async function cli(rawArgv) {
743
781
  let payloadExtra = flag("payload") || {};
744
782
  if (specDir && !payloadExtra.spec_folder) payloadExtra.spec_folder = specDir;
745
783
  if (!payloadExtra.feature) payloadExtra.feature = slug;
784
+ // The judge's citations, for every lane (see t0ArtifactsFor). An explicit `--payload` list still
785
+ // wins, as it does for `bugs`: an operator naming the evidence outranks the derivation.
786
+ if (operation === "evaluate" && payloadExtra.t0_artifacts === undefined) {
787
+ const { artifacts, missing } = t0ArtifactsFor(cwd, slug, round);
788
+ if (artifacts.length) payloadExtra.t0_artifacts = artifacts;
789
+ // On stderr, never stdout: stdout is the order path the caller consumes.
790
+ if (missing.length) {
791
+ console.error(`compile-order: warning — no green T0 verdict${round ? ` in round ${round}` : ""} for ` +
792
+ `${missing.join(", ")}; the evaluator has nothing to cite for ${missing.length === 1 ? "that scope" : "those scopes"}`);
793
+ }
794
+ }
746
795
 
747
796
  const order = compileOrder({
748
797
  slug, worker, operation, round, attempt, scope, tasks, decisions, digestedErrors, trialHistory, bugs,
@@ -54,6 +54,7 @@
54
54
  // --spec-folder SHARED spec deliverable path (default: shapeup/<slug>/spec/)
55
55
  // --dimensions comma-separated eval dimensions (default: spec-conformance)
56
56
  // --gate-answers path | preset name (see `harness gate`; recorded, not read)
57
+ // --breadboard path to the pitch's breadboard (default: found — see resolveBreadboard())
57
58
  // --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
58
59
  // --cwd project root (default: process.cwd())
59
60
  // --force re-init over an existing run receipt
@@ -68,8 +69,8 @@
68
69
  // that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
69
70
  // one moment it mattered named a mechanism that does not parse.
70
71
 
71
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
72
- import { join, dirname, resolve } from "node:path";
72
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync, statSync } from "node:fs";
73
+ import { join, dirname, resolve, relative, sep } from "node:path";
73
74
  import { createHash } from "node:crypto";
74
75
  import { decideLane, treeSize } from "./fit.mjs";
75
76
  import { runArgs } from "../lib/argv.mjs";
@@ -78,8 +79,9 @@ import { deriveSnapshot } from "../reduce/snapshot.mjs";
78
79
  import { mintRunId } from "../lib/paths.mjs";
79
80
  import {
80
81
  localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir,
81
- workflowsStage, globWorkflowsStage,
82
+ workflowsStage, globWorkflowsStage, sharedRoot, shapingDir, breadboard as stagedBreadboard,
82
83
  } from "../lib/paths.mjs";
84
+ import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
83
85
  import { resolveWorkers } from "../verify/skills.mjs";
84
86
 
85
87
  export const RECEIPT_VERSION = 1;
@@ -132,8 +134,19 @@ export function digest(text) {
132
134
  /**
133
135
  * Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
134
136
  * Kept separate from I/O so the structural tests can assert its shape without a filesystem.
137
+ *
138
+ * @param {object} o - Resolved inputs (destructured).
139
+ * @param {string} o.slug - Feature slug.
140
+ * @param {string} o.intake - The intake text, verbatim.
141
+ * @param {object} o.config - The pinned run config.
142
+ * @param {string} o.startedAt - ISO start time.
143
+ * @param {(object|null)} [o.plugin] - The plugin copy that answered.
144
+ * @param {(string|null)} [o.intakeSource] - Repo-relative `--intake-file` path, or "text" / "stdin".
145
+ * @param {({source: string, path: (string|null), text: string, translated?: boolean}|null)} [o.breadboard] -
146
+ * The resolved breadboard, `path` repo-relative (null when embedded in the intake), or null.
147
+ * @returns {object} The receipt.
135
148
  */
136
- export function buildReceipt({ slug, intake, config, startedAt, plugin = null }) {
149
+ export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null, breadboard = null }) {
137
150
  const intakeText = String(intake ?? "");
138
151
  const intakeSha256 = digest(intakeText);
139
152
  return {
@@ -156,6 +169,23 @@ export function buildReceipt({ slug, intake, config, startedAt, plugin = null })
156
169
  intake_sha256: intakeSha256,
157
170
  intake_chars: intakeText.length,
158
171
  intake_lines: intakeText ? intakeText.split("\n").length : 0,
172
+ // WHERE THE INTAKE CAME FROM. The copy above is all the run reads, so without its origin nothing
173
+ // on disk says which file the pitch was — or that it had a second half beside it.
174
+ intake_source: intakeSource,
175
+ // THE PITCH'S OTHER HALF. A `/shapeup` pitch is shaping.md + breadboard.md, and a Place that only
176
+ // the breadboard names reaches no planning worker unless the run carries it. Recorded here, at
177
+ // t=0, rather than left to a worker to note: the kernel knows whether a breadboard exists before
178
+ // anything is dispatched, and a worker's prose rule for exactly this case did not hold.
179
+ breadboard: breadboard
180
+ ? {
181
+ source: breadboard.source,
182
+ path: breadboard.path ?? null,
183
+ sha256: digest(breadboard.text),
184
+ chars: String(breadboard.text ?? "").length,
185
+ ids: idCounts(parseBreadboard(breadboard.text)),
186
+ ...(typeof breadboard.translated === "boolean" ? { translated: breadboard.translated } : {}),
187
+ }
188
+ : null,
159
189
  // The single fact that separates "the harness ran" from "the harness described itself".
160
190
  // Written before any gate, so its ABSENCE at Stop is unambiguous.
161
191
  started: true,
@@ -312,6 +342,70 @@ export function stageWorkflows(cwd, pluginRoot, { refresh = true } = {}) {
312
342
  return { ok: true, dir, staged: names };
313
343
  }
314
344
 
345
+ /**
346
+ * Find the breadboard that belongs to this run's pitch. First hit wins:
347
+ *
348
+ * 1. `flag` — `--breadboard <path>`, resolved against cwd; a missing file is an error.
349
+ * 2. `sibling` — `breadboard.md` in the folder of `--intake-file` (not for text or stdin).
350
+ * 3. `shaping-dir` — `shapeup/<slug>/shaping/breadboard.md`, the documented location.
351
+ * 4. `shared-root` — `shapeup/<slug>/breadboard.md`, the flat layout real projects use.
352
+ * 5. `embedded` — the intake itself, when it carries Places and UI tables; nothing is staged.
353
+ *
354
+ * THE INTAKE'S OWN FOLDER COMES FIRST, not the documented location, because the only consumer that
355
+ * hit this keeps its pitch flat in `shapeup/<slug>/`: a lookup that reads only the shaping folder
356
+ * passes every fixture built to the documented layout and misses the real one.
357
+ *
358
+ * A discovered candidate that is the intake file itself, or the run's own staged copy, is skipped:
359
+ * the staged copy is this function's OUTPUT, and re-opening a run from its staged intake must not
360
+ * inherit the breadboard of the run it replaces.
361
+ *
362
+ * A TRANSLATED PITCH GETS ITS TRANSLATED BREADBOARD. When the intake is a translator output
363
+ * (`<name>.en.md`), each folder is tried for `breadboard.en.md` before `breadboard.md`, and the
364
+ * result says whether it is translated — an English spec planned from a source-language breadboard
365
+ * is a mismatch the caller reports, not one it may silently stage.
366
+ *
367
+ * @param {object} o - Inputs (destructured).
368
+ * @param {string} o.cwd - Project root.
369
+ * @param {string} o.slug - Feature slug.
370
+ * @param {(string|null)} [o.intakeFile] - The `--intake-file` value as given ("-" = stdin).
371
+ * @param {(string|null)} [o.flag] - The `--breadboard` value as given.
372
+ * @param {string} [o.intake] - The intake text, for the embedded case.
373
+ * @returns {({source: string, path: (string|null), text: string, translated?: boolean}|null)} The
374
+ * breadboard, its absolute path (null when embedded), and its text — plus `translated` when the
375
+ * intake is a `.en.md` translation; or null when the pitch has none.
376
+ * @throws {Error} If `--breadboard` names a file that does not exist.
377
+ */
378
+ export function resolveBreadboard({ cwd, slug, intakeFile = null, flag = null, intake = "" }) {
379
+ const self = intakeFile && intakeFile !== "-" ? resolve(cwd, intakeFile) : null;
380
+ const english = Boolean(self && self.endsWith(".en.md"));
381
+ /** Tag a hit with whether it is translated — only meaningful when the intake is. */
382
+ const hit = (source, p, text) => ({ source, path: p, text, ...(english ? { translated: p === null || p.endsWith(".en.md") } : {}) });
383
+ if (flag) {
384
+ const p = resolve(cwd, flag);
385
+ if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`--breadboard not found: ${p}`);
386
+ return hit("flag", p, readFileSync(p, "utf8"));
387
+ }
388
+ const staged = stagedBreadboard(cwd, slug);
389
+ const names = english ? ["breadboard.en.md", "breadboard.md"] : ["breadboard.md"];
390
+ const folders = [
391
+ ...(self ? [["sibling", dirname(self)]] : []),
392
+ ["shaping-dir", shapingDir(cwd, slug)],
393
+ ["shared-root", sharedRoot(cwd, slug)],
394
+ ];
395
+ for (const [source, dir] of folders) {
396
+ for (const n of names) {
397
+ const p = join(dir, n);
398
+ if (p === self || p === staged) continue;
399
+ if (existsSync(p) && statSync(p).isFile()) return hit(source, p, readFileSync(p, "utf8"));
400
+ }
401
+ }
402
+ if (hasBreadboardTables(intake)) return hit("embedded", null, String(intake));
403
+ return null;
404
+ }
405
+
406
+ /** A path relative to the project root, `/`-joined on every platform — the form a receipt records. */
407
+ const repoRel = (cwd, p) => relative(cwd, p).split(sep).join("/");
408
+
315
409
  // ---- CLI -------------------------------------------------------------------
316
410
 
317
411
  /** The typed argv contract (see `./lib/argv.mjs`). */
@@ -319,7 +413,7 @@ export const ARGV_SPEC = {
319
413
  usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
320
414
  "[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
321
415
  "[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
322
- "[--gate-answers <preset|path>] " +
416
+ "[--gate-answers <preset|path>] [--breadboard <path>] " +
323
417
  "[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
324
418
  "[--plugin-root <dir>] [--force]",
325
419
  _: { arity: 0, max: 0, name: "(no positional operands)" },
@@ -335,6 +429,7 @@ export const ARGV_SPEC = {
335
429
  "spec-folder": { type: "path" },
336
430
  dimensions: { type: "str" },
337
431
  "gate-answers": { type: "str" },
432
+ breadboard: { type: "path" },
338
433
  lane: { type: "str" },
339
434
  tiny: { type: "flag" },
340
435
  "wall-clock-budget": { type: "int", min: 1 },
@@ -423,6 +518,16 @@ export function cli(rawArgv) {
423
518
  let eval_dimensions;
424
519
  try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
425
520
  catch (e) { fail(2, e.message); }
521
+ // The pitch's second half, resolved before anything is written so a bad `--breadboard` is a usage
522
+ // error that ran nothing. Read-only here; staged below, once the run is actually opened.
523
+ let bb = null;
524
+ try { bb = resolveBreadboard({ cwd, slug, intakeFile, flag: args.breadboard ?? null, intake }); }
525
+ catch (e) { fail(2, e.message); }
526
+ if (bb?.translated === false) {
527
+ console.error(`⚠ init-run: the intake is a translation but its breadboard is not — staging ${repoRel(cwd, bb.path)} as found.`);
528
+ console.error(" Translate the breadboard too (translator), and name the .en.md with --breadboard, or the spec is planned from two languages.");
529
+ }
530
+ const intakeSource = args.intakeStdin || intakeFile === "-" ? "stdin" : intakeFile ? repoRel(cwd, resolve(cwd, intakeFile)) : "text";
426
531
 
427
532
  const config = {
428
533
  auto_level,
@@ -532,7 +637,10 @@ export function cli(rawArgv) {
532
637
  }
533
638
 
534
639
  const startedAt = new Date().toISOString();
535
- const receipt = buildReceipt({ slug, intake, config, startedAt, plugin });
640
+ const receipt = buildReceipt({
641
+ slug, intake, config, startedAt, plugin, intakeSource,
642
+ breadboard: bb ? { ...bb, path: bb.path ? repoRel(cwd, bb.path) : null } : null,
643
+ });
536
644
 
537
645
  mkdirSync(runRoot, { recursive: true });
538
646
  mkdirSync(join(runRoot, "orders"), { recursive: true });
@@ -541,6 +649,10 @@ export function cli(rawArgv) {
541
649
 
542
650
  // The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
543
651
  writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
652
+ // The breadboard, verbatim — byte for byte, so the receipt's digest is the file's. A copy left by a
653
+ // run this one replaces goes first: a re-open without a breadboard must not inherit the last one.
654
+ rmSync(stagedBreadboard(cwd, slug), { force: true });
655
+ if (bb?.path) writeFileSync(stagedBreadboard(cwd, slug), bb.text, "utf8");
544
656
  writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
545
657
  writeFileSync(join(runRoot, "harness-run.md"), runFrontmatter({ slug, config, startedAt }), "utf8");
546
658
 
@@ -558,6 +670,10 @@ export function cli(rawArgv) {
558
670
  receipt: globLocal(slug, "receipt.json"),
559
671
  intake_sha256: receipt.intake_sha256,
560
672
  intake_chars: receipt.intake_chars,
673
+ // The staged breadboard the planning dispatches are handed, or null when the pitch has none
674
+ // separate (`breadboard_source` then says "embedded" or null).
675
+ breadboard: bb?.path ? globLocal(slug, "breadboard.md") : null,
676
+ breadboard_source: bb?.source ?? null,
561
677
  config,
562
678
  // What the launch names. Project-local by necessity, not by preference — see stageWorkflows().
563
679
  workflow_script: staged.ok ? globWorkflowsStage("shapeup-run.js") : null,
@@ -0,0 +1,165 @@
1
+ // breadboard — read a `/shapeup` breadboard's element ids out of its markdown tables.
2
+ //
3
+ // WHY THIS EXISTS. A pitch is two files: `shaping.md` (problem, requirements, parts) and
4
+ // `breadboard.md` (Places, affordances, slices). The run used to take only the first, so a Place
5
+ // that existed only in the breadboard — a new sheet, a new modal — reached no planning worker, and
6
+ // the spec folded it into whichever screen the prose happened to mention. The ids read here are what
7
+ // the run pins at open (the receipt's counts) and what spec-lint checks placement against.
8
+ //
9
+ // WHAT IT READS, and nothing more. An element id is the FIRST cell of a markdown table row —
10
+ // `P1`, `P2.1`, `U3`, `N1b`, `S2`, `V4` — after stripping `**` and backticks, so both table layouts
11
+ // the breadboarding guide ships parse the same way: a `#` first column and an `ID` first column. A
12
+ // row's Places come from its `Place` column, every `P#` token in the cell (`P1 / P3`, `P2/P1`); a
13
+ // table with no Place column gives its rows no Places. Places may also be listed as prose under a
14
+ // `## Places` heading (`P1: Composer — …`), which is the guide's own output template.
15
+ //
16
+ // It skips fenced code blocks: a table inside a fence is an example of a breadboard, not this one.
17
+ //
18
+ // Tolerant by design. A layout this reader cannot parse yields zero ids, and every caller treats
19
+ // zero ids as "nothing to check" plus a warning — never as a hard stop. Zero dependencies.
20
+
21
+ /** A first-cell element id: a Place (`P1`, `P2.1`), or a UI/code affordance, store or slice. */
22
+ export const ID_PATTERN = /^(P\d+(?:\.\d+)*|[UNSV]\d+[a-z]?)$/;
23
+
24
+ /** Every Place reference inside a cell. Global — use with `match`, never `test`. */
25
+ const PLACE_REF = /\bP\d+(?:\.\d+)*\b/g;
26
+
27
+ /** A prose Place line under `## Places`: `P1: …`, `- P1 — …`, `**P2** – …`. */
28
+ const PROSE_PLACE = /^\s*(?:[-*+]\s+)?\**(P\d+(?:\.\d+)*)\**\s*[:—–-]\s*(.*)$/;
29
+
30
+ /**
31
+ * Split one markdown table row into trimmed cells.
32
+ * @param {string} line - A line that starts with `|`.
33
+ * @returns {string[]} The cells between the outer pipes.
34
+ */
35
+ function cells(line) {
36
+ const t = line.trim().replace(/^\|/, "").replace(/\|$/, "");
37
+ return t.split("|").map((c) => c.trim());
38
+ }
39
+
40
+ /**
41
+ * Strip the emphasis and code marks a breadboard author puts around an id.
42
+ * @param {string} cell - A raw table cell.
43
+ * @returns {string} The cell's text without `**` and backticks.
44
+ */
45
+ function bare(cell) {
46
+ return String(cell ?? "").replace(/\*\*/g, "").replace(/`/g, "").trim();
47
+ }
48
+
49
+ /**
50
+ * Is this line a table's header separator (`|---|:--:|`)?
51
+ * @param {string} line - A table line.
52
+ * @returns {boolean} True for a separator row.
53
+ */
54
+ function isSeparator(line) {
55
+ return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
56
+ }
57
+
58
+ /**
59
+ * Parse a breadboard's element ids.
60
+ *
61
+ * @param {string} text - The breadboard markdown (or a pitch that carries one inline).
62
+ * @returns {{places: {id: string, name: string}[], ui: {id: string, places: string[]}[],
63
+ * code: {id: string, places: string[]}[], stores: {id: string, places: string[]}[],
64
+ * slices: {id: string}[]}} Every id found, de-duplicated by id; a repeated row merges its Places.
65
+ */
66
+ export function parseBreadboard(text) {
67
+ const lines = String(text ?? "").split(/\r?\n/);
68
+ const places = new Map();
69
+ const byKind = { U: new Map(), N: new Map(), S: new Map(), V: new Map() };
70
+
71
+ const addPlace = (id, name) => {
72
+ if (!places.has(id)) places.set(id, { id, name: name || "" });
73
+ else if (!places.get(id).name && name) places.get(id).name = name;
74
+ };
75
+ const addElement = (id, refs) => {
76
+ const m = byKind[id[0]];
77
+ if (!m.has(id)) m.set(id, new Set());
78
+ for (const p of refs) m.get(id).add(p);
79
+ };
80
+
81
+ let inFence = false;
82
+ let inPlacesSection = false;
83
+ let header = null; // the current table's header cells, lower-cased
84
+ let prevWasTable = false;
85
+
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i];
88
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; prevWasTable = false; header = null; continue; }
89
+ if (inFence) continue;
90
+
91
+ const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
92
+ if (heading) {
93
+ inPlacesSection = /^places\b/i.test(bare(heading[1]));
94
+ prevWasTable = false; header = null;
95
+ continue;
96
+ }
97
+
98
+ if (/^\s*\|/.test(line)) {
99
+ if (!prevWasTable) {
100
+ // A table starts here. Its first line is the header when the next line is a separator.
101
+ header = isSeparator(lines[i + 1] ?? "") ? cells(line).map((c) => bare(c).toLowerCase()) : null;
102
+ prevWasTable = true;
103
+ if (header) continue;
104
+ }
105
+ if (isSeparator(line)) continue;
106
+ const row = cells(line);
107
+ const id = bare(row[0]);
108
+ if (!ID_PATTERN.test(id)) continue;
109
+ const placeCol = header ? header.indexOf("place") : -1;
110
+ if (id[0] === "P") {
111
+ const nameCol = placeCol > 0 ? placeCol : (header ? header.findIndex((h) => h === "name") : -1);
112
+ addPlace(id, bare(row[nameCol > 0 ? nameCol : 1] ?? ""));
113
+ } else {
114
+ const cell = placeCol > 0 ? bare(row[placeCol] ?? "") : "";
115
+ addElement(id, cell.match(PLACE_REF) ?? []);
116
+ }
117
+ continue;
118
+ }
119
+ prevWasTable = false; header = null;
120
+
121
+ if (inPlacesSection) {
122
+ const m = line.match(PROSE_PLACE);
123
+ if (m) addPlace(m[1], bare(m[2]).split(/\s+[—–-]\s+/)[0]);
124
+ }
125
+ }
126
+
127
+ const list = (m) => [...m].map(([id, refs]) => ({ id, places: [...refs] }));
128
+ return {
129
+ places: [...places.values()],
130
+ ui: list(byKind.U),
131
+ code: list(byKind.N),
132
+ stores: list(byKind.S),
133
+ slices: [...byKind.V.keys()].map((id) => ({ id })),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Does this text carry a breadboard of its own — at least one Place and one UI affordance?
139
+ *
140
+ * The test for a pitch that embeds its breadboard inline rather than as a second file. Both are
141
+ * required: a shaping doc routinely has tables, and a Place with nothing to place is not a
142
+ * breadboard anything downstream can check.
143
+ *
144
+ * @param {string} text - Markdown to inspect.
145
+ * @returns {boolean} True when `parseBreadboard` finds a P# and a U#.
146
+ */
147
+ export function hasBreadboardTables(text) {
148
+ const bb = parseBreadboard(text);
149
+ return bb.places.length > 0 && bb.ui.length > 0;
150
+ }
151
+
152
+ /**
153
+ * Count a parsed breadboard's ids by kind, the shape the run receipt records.
154
+ * @param {ReturnType<typeof parseBreadboard>} parsed - A `parseBreadboard` result.
155
+ * @returns {{P: number, U: number, N: number, S: number, V: number}} Ids per kind.
156
+ */
157
+ export function idCounts(parsed) {
158
+ return {
159
+ P: parsed.places.length,
160
+ U: parsed.ui.length,
161
+ N: parsed.code.length,
162
+ S: parsed.stores.length,
163
+ V: parsed.slices.length,
164
+ };
165
+ }
@@ -91,7 +91,7 @@ export const localRoot = (cwd, slug) => join(cwd, LOCAL, slug);
91
91
  export const specDir = (cwd, slug) => join(sharedRoot(cwd, slug), "spec");
92
92
  /** Use-case directory inside the spec tree. */
93
93
  export const usecasesDir = (cwd, slug) => join(specDir(cwd, slug), "usecases");
94
- /** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. */
94
+ /** Shaping artifacts — pitch, framing, breadboard, baseline, glossary. `init run` looks here for a breadboard. */
95
95
  export const shapingDir = (cwd, slug) => join(sharedRoot(cwd, slug), "shaping");
96
96
  // The three contracts are markdown on disk and JSON on the wire (ADR-0001) — see
97
97
  // `lib/contract.mjs`. `readContract()` accepts either extension, so a project mid-migration
@@ -138,6 +138,8 @@ export const RECEIPT_FILE = "receipt.json";
138
138
  export const receipt = (cwd, slug) => join(localRoot(cwd, slug), RECEIPT_FILE);
139
139
  /** The intake, verbatim, next to its digest in the receipt. */
140
140
  export const intake = (cwd, slug) => join(localRoot(cwd, slug), "intake.md");
141
+ /** The breadboard the pitch was shaped with, verbatim, next to its digest in the receipt. */
142
+ export const breadboard = (cwd, slug) => join(localRoot(cwd, slug), "breadboard.md");
141
143
  /** The run ledger — rounds, decisions, status frontmatter. */
142
144
  export const harnessRun = (cwd, slug) => join(localRoot(cwd, slug), "harness-run.md");
143
145
  /** File-derived mid-run digest, frozen by `reduce snapshot --write` as an audit anchor. */