shapeup-sdlc 3.1.2 → 3.3.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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/AGENTS.md +7 -6
  3. package/README.md +1 -1
  4. package/SECURITY.md +4 -1
  5. package/bin/init.mjs +3 -0
  6. package/commands/retro.md +19 -2
  7. package/commands/ship.md +5 -0
  8. package/hooks/dispatch-receipt.mjs +6 -3
  9. package/hooks/gate-intake.mjs +1 -1
  10. package/hooks/gate-zerowork.mjs +5 -2
  11. package/hooks/lib/decision.mjs +49 -6
  12. package/hooks/safety-spine.mjs +8 -5
  13. package/hooks/sandbox-guard.mjs +13 -6
  14. package/kernel/compile.mjs +112 -6
  15. package/kernel/harness.mjs +11 -5
  16. package/kernel/init/run.mjs +122 -6
  17. package/kernel/lib/breadboard.mjs +165 -0
  18. package/kernel/lib/paths.mjs +15 -3
  19. package/kernel/probe/owner.mjs +139 -0
  20. package/kernel/probe/resume.mjs +7 -1
  21. package/kernel/probe/stats.mjs +49 -2
  22. package/kernel/reduce/hill.mjs +12 -2
  23. package/kernel/verify/build.mjs +319 -0
  24. package/kernel/verify/spec.mjs +190 -2
  25. package/package.json +1 -1
  26. package/skills/ba-pitch-analyzer/SKILL.md +11 -5
  27. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +2 -1
  28. package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +12 -2
  29. package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -0
  30. package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +9 -0
  31. package/skills/coach/SKILL.md +232 -43
  32. package/skills/orient/SKILL.md +15 -5
  33. package/skills/qa-edge-hunter/SKILL.md +3 -2
  34. package/skills/scope-architect/SKILL.md +7 -0
  35. package/skills/scope-hammer/SKILL.md +13 -0
  36. package/skills/solution-architect/SKILL.md +7 -2
  37. package/skills/tech-lead/SKILL.md +7 -7
  38. package/skills/tech-lead/references/gates.md +69 -17
  39. package/skills/tech-lead/references/protocol.md +33 -8
  40. package/skills/tech-lead/schemas/domain.schema.json +180 -9
  41. package/skills/tech-lead/workflows/shapeup-run.js +54 -12
@@ -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
@@ -111,7 +111,7 @@ export const requirements = (cwd, slug) => join(sharedRoot(cwd, slug), "requirem
111
111
  export const hillDir = (cwd, slug) => join(sharedRoot(cwd, slug), "hill");
112
112
  /** The frozen ship report, written once at GATE L4. */
113
113
  export const report = (cwd, slug) => join(sharedRoot(cwd, slug), "REPORT.md");
114
- /** Team-shared coaching rules, read back by the three coachable workers. */
114
+ /** Team-shared coaching rules, read back by the coachable workers and by the tech lead at GATE L0. */
115
115
  export const knowledgeBaseDir = (cwd) => join(sharedDir(cwd), "knowledge-base");
116
116
  /** One worker's coaching file. */
117
117
  export const knowledgeBase = (cwd, skill) => join(knowledgeBaseDir(cwd), `${skill}.md`);
@@ -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. */
@@ -206,6 +208,16 @@ export const trials = (cwd, slug) => join(t0Dir(cwd, slug), "trials.jsonl");
206
208
  export const gates = (cwd, slug) => join(localRoot(cwd, slug), "gates.jsonl");
207
209
  /** Finished-scope fixture registry for the seesaw regression check. */
208
210
  export const seesawRegistry = (cwd, slug) => join(localRoot(cwd, slug), "seesaw", "registry.json");
211
+ /**
212
+ * The round build gate's verdicts — one immutable artifact per gate run, `r<N>-t<T>.json`.
213
+ *
214
+ * A separate directory from `t0/`, on purpose. A T0 verdict is one scope's fixtures inside that
215
+ * scope's substrate; this is the FEATURE's build, launched the way the ledger's `run_cmd` and the
216
+ * profile's probes say a user would, and it belongs to no scope. Written by `harness verify build`,
217
+ * read by `reduce hill` (a green T0 in a round whose build is red moves no dot) and by
218
+ * `harness compile` (a red gate is the next round's bug list).
219
+ */
220
+ export const roundBuildDir = (cwd, slug) => join(localRoot(cwd, slug), "build");
209
221
  /** Evaluator output — report, evidence, verdict ledger. */
210
222
  export const evaluationDir = (cwd, slug) => join(localRoot(cwd, slug), "evaluation");
211
223
  /** The workflow launcher's run directory — `journal.jsonl` and the launch `result.json`. */
@@ -383,7 +395,7 @@ export const globShared = (slug, ...parts) => [SHARED, slug, ...parts].join("/")
383
395
 
384
396
  /**
385
397
  * The coaching file a coachable worker reads, as a repo-relative path for the WorkOrder payload.
386
- * @param {string} skill - Worker name (task-executor | ba-pitch-analyzer | qa-edge-hunter).
398
+ * @param {string} skill - A coachable worker name (`COACHABLE` in compile.mjs), or `tech-lead`.
387
399
  * @returns {string} e.g. `shapeup/knowledge-base/task-executor.md`.
388
400
  */
389
401
  export const relKnowledgeBase = (skill) => [SHARED, "knowledge-base", `${skill}.md`].join("/");
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // probe owner — which scope owns a path, derived from the contracts and nothing else.
3
+ //
4
+ // WHY THIS IS A QUERY AND NOT A SENTENCE. GATE H's census stated ownership facts without reading
5
+ // the contracts: a ship report said no scope owned the app's page files, while the composition
6
+ // root's committed substrate listed `pages/**` in plain sight. The real gap was elsewhere (a route
7
+ // table naming files nobody had written), and the report pointed the PO at a scope cut that was
8
+ // not the problem. A census that narrates ownership from memory is a census that can be wrong in
9
+ // exactly the way that looks most authoritative.
10
+ //
11
+ // Ownership already has ONE mechanical definition in this plugin — the substrate the sandbox hook
12
+ // enforces, and the election `harness compile` runs to address a cited bug to the scope that may
13
+ // write the file (`electOwner`). This probe exposes that same answer on the command line, so a
14
+ // worker that needs to say "no scope owns X" can cite the query instead of asserting it.
15
+ //
16
+ // With no `--path`, the input set is what a census would ask about anyway: every engine and
17
+ // entry-point call site the wiring map names, plus the profile's entry point. A row with no
18
+ // writers is an UNOWNED SEAM — the one kind of gap the disjointness lint cannot see, since DISJOINT
19
+ // fails two owners and nothing fails zero.
20
+ //
21
+ // Usage:
22
+ // node "${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs" probe owner --slug <slug> [--path <p>]... [--format json|table] [--cwd <dir>]
23
+ //
24
+ // Exit code: 0 = answered, 2 = bad argv or no readable scope contracts.
25
+
26
+ import { existsSync } from "node:fs";
27
+ import { resolve, join } from "node:path";
28
+ import { runArgs, isMain } from "../lib/argv.mjs";
29
+ import { wiringMap, projectProfile } from "../lib/paths.mjs";
30
+ import { readContract, WIRING_MAP, PROJECT_PROFILE } from "../lib/contract.mjs";
31
+ import { scopeSubstrates, electOwner, bugLocations } from "../compile.mjs";
32
+ import { matchesAny } from "../../hooks/sandbox-guard.mjs";
33
+
34
+ /**
35
+ * Ownership of one repo-relative path under a set of scope substrates.
36
+ *
37
+ * @param {string} path - Repo-relative POSIX path.
38
+ * @param {Array<{scope_id:string, allowed:string[], shared:string[]}>} scopes - From
39
+ * {@link scopeSubstrates}.
40
+ * @param {string} [cwd] - Project root; when given, `exists` says whether the path is on disk.
41
+ * @returns {{path:string, owner:(string|null), writers:string[], shared_with:string[], exists:(boolean|null)}}
42
+ * `owner` is the elected fixer (exclusive first, lowest id among equals); `writers` every scope
43
+ * whose substrate admits the path; `shared_with` the writers that declare it shared. Empty
44
+ * `writers` means no scope may write the file — an unowned path. `exists` is the other half a
45
+ * census needs: a seam that is owned but not on disk is a route to a file nobody wrote.
46
+ */
47
+ export function ownership(path, scopes, cwd = null) {
48
+ const rel = String(path).replace(/^\.\//, "");
49
+ const writers = (scopes || []).filter((s) => matchesAny(rel, s.allowed)).map((s) => s.scope_id).sort();
50
+ const shared = (scopes || []).filter((s) => matchesAny(rel, s.shared || [])).map((s) => s.scope_id).sort();
51
+ return { path: rel, owner: electOwner(rel, scopes), writers, shared_with: shared, exists: cwd ? existsSync(join(cwd, rel)) : null };
52
+ }
53
+
54
+ /**
55
+ * The paths a census asks about when none are given: the wiring map's engines and entry call
56
+ * sites, and the profile's entry point. Only tokens that look like files are kept — an entry call
57
+ * site is prose around a path (`src/server.ts — POST /checkout route`), and the path is what the
58
+ * substrate can answer for.
59
+ *
60
+ * @param {string} cwd - Project root.
61
+ * @param {string} slug - Feature slug.
62
+ * @returns {Array<{path:string, cited_by:string}>} Distinct paths with where each was named.
63
+ */
64
+ export function seamPaths(cwd, slug) {
65
+ const out = [];
66
+ const seen = new Set();
67
+ const add = (p, by) => { if (p && !seen.has(p)) { seen.add(p); out.push({ path: p, cited_by: by }); } };
68
+ const wm = wiringMap(cwd, slug);
69
+ if (existsSync(wm)) {
70
+ const entries = readContract(wm, WIRING_MAP)?.contract?.entries || [];
71
+ for (const e of entries) {
72
+ for (const p of bugLocations({ location: e.engine })) add(p, `${e.use_case || "?"} engine`);
73
+ for (const p of bugLocations({ location: e.entry_call_site })) add(p, `${e.use_case || "?"} entry_call_site`);
74
+ }
75
+ }
76
+ const pp = projectProfile(cwd, slug);
77
+ if (existsSync(pp)) {
78
+ const ep = readContract(pp, PROJECT_PROFILE)?.contract?.entry_point;
79
+ for (const p of bugLocations({ location: ep })) add(p, "project-profile entry_point");
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Render the report as a fixed-width table for a human reading a gate block.
86
+ * @param {object} r - The report {@link cli} prints.
87
+ * @returns {string} One header line and one row per path.
88
+ */
89
+ export function renderTable(r) {
90
+ const rows = r.paths.map((p) => [p.path, p.owner || "—", p.writers.join(",") || "UNOWNED", p.exists === null ? "?" : p.exists ? "yes" : "MISSING", p.cited_by || ""]);
91
+ const head = ["path", "owner", "writers", "on disk", "cited by"];
92
+ const w = head.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
93
+ const line = (row) => row.map((c, i) => String(c).padEnd(w[i])).join(" ").trimEnd();
94
+ return [line(head), line(w.map((n) => "-".repeat(n))), ...rows.map(line),
95
+ "", `${r.scopes} scope(s) read · ${r.unowned.length} unowned path(s)${r.unowned.length ? `: ${r.unowned.join(", ")}` : ""}` +
96
+ ` · ${r.missing.length} not on disk${r.missing.length ? `: ${r.missing.join(", ")}` : ""}`].join("\n");
97
+ }
98
+
99
+ export const ARGV_SPEC = {
100
+ usage: "harness.mjs probe owner --slug <slug> [--path <p>]... [--format json|table] [--cwd <dir>]",
101
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
102
+ slug: { type: "str", required: true },
103
+ path: { type: "str", multiple: true },
104
+ format: { type: "enum", values: ["json", "table"], default: "json" },
105
+ cwd: { type: "path" },
106
+ };
107
+
108
+ /**
109
+ * Answer "which scope owns this path" from the committed contracts.
110
+ *
111
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
112
+ * @returns {Promise<void>} Exits 0 with the report, 2 when no scope contract can be read.
113
+ */
114
+ export async function cli(rawArgv) {
115
+ const args = runArgs(ARGV_SPEC, rawArgv);
116
+ const cwd = resolve(args.cwd || process.cwd());
117
+ const scopes = scopeSubstrates(cwd, args.slug);
118
+ if (!scopes.length) {
119
+ console.error(`probe owner: no readable scope contract for ${args.slug} — ownership is a property of the contracts, and there are none to read`);
120
+ process.exit(2);
121
+ }
122
+ const asked = (args.path || []).map((p) => ({ path: p, cited_by: "--path" }));
123
+ const inputs = asked.length ? asked : seamPaths(cwd, args.slug);
124
+ const paths = inputs.map((i) => ({ ...ownership(i.path, scopes, cwd), cited_by: i.cited_by }));
125
+ const report = {
126
+ slug: args.slug,
127
+ scopes: scopes.length,
128
+ source: asked.length ? "--path" : "wiring-map + project-profile",
129
+ paths,
130
+ unowned: paths.filter((p) => p.writers.length === 0).map((p) => p.path),
131
+ // Owned but absent: the wiring names a file nobody wrote — the gap a census reads as
132
+ // "unowned" when it does not check the disk.
133
+ missing: paths.filter((p) => p.exists === false).map((p) => p.path),
134
+ };
135
+ console.log(args.format === "table" ? renderTable(report) : JSON.stringify(report, null, 2));
136
+ process.exit(0);
137
+ }
138
+
139
+ if (isMain(import.meta.url)) cli(process.argv.slice(2));
@@ -63,7 +63,7 @@ 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
68
  import { evalVerdict } from "./eval.mjs";
69
69
 
@@ -378,6 +378,12 @@ export function deriveResumeState(cwd, slug) {
378
378
 
379
379
  const facts = {
380
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,
381
387
  spec_folder: hr.spec_folder || null,
382
388
  status: hr.status || null,
383
389
  lens: hr.lens || null,