shapeup-sdlc 1.7.0 → 3.0.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 (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -1,4 +1,4 @@
1
- // contract-md — read and write the committed contracts as markdown.
1
+ // contract — read and write the committed contracts as markdown.
2
2
  //
3
3
  // WHY (ADR-0001). Scope contracts, the wiring map and the project profile are *low-level design*:
4
4
  // which files a slice may touch, which seam each use case attaches to, where the app starts. A
@@ -56,10 +56,30 @@ export const SCOPE_CONTRACT = {
56
56
  signatures: { affordance_manifest: ["test_id", "role"] },
57
57
  };
58
58
 
59
- /** `WiringMap` — per use case: engine → seam → entry-point call site → affordance. */
59
+ /**
60
+ * `WiringMap` — per use case: engine → seam → entry-point call site → affordance.
61
+ *
62
+ * `perSection` is a MIGRATION READER, and it exists because the canonical shape was never written.
63
+ * `solution-architect` was told to produce a `{schema_version, feature, entry_point, entries[]}`
64
+ * OBJECT and never told the markdown layout, so every run authored its own: a `## Entries` section
65
+ * with one `### UC-xx — title` subsection per use case, each holding a vertical `| Field | Value |`
66
+ * table. Under this spec that parses as no table at all — and the vertical layout carries no
67
+ * `use_case` header cell, so the `signatures` detector below cannot see it either and the contract
68
+ * reports as perfectly readable while yielding nothing.
69
+ *
70
+ * Measured across every completed run in the corpus: 9 of 9 committed wiring maps parsed to ZERO
71
+ * entries with `unreadableReason() === null`, and `trace-lint` certified `🟢 green · 0/0 engines
72
+ * reach <entry point>` against a deliverable whose engines were on disk — the exact failure quoted
73
+ * in the banner above, recurred in a shape its own fix could not detect.
74
+ *
75
+ * The horizontal `## Wiring` table stays canonical: it is what `renderContract` emits, so a map
76
+ * regenerated by any later `wire` converges on it without a migration step. This reader only keeps
77
+ * the maps already committed from resolving to nothing.
78
+ */
60
79
  export const WIRING_MAP = {
61
80
  tables: { entries: "Wiring" },
62
81
  signatures: { entries: ["use_case", "engine"] },
82
+ perSection: { field: "entries", key: "use_case", idPattern: /^(UC-[A-Za-z0-9_.-]+)/ },
63
83
  };
64
84
 
65
85
  /** `ProjectProfile` — archetype + entry point. All scalars; no tables. */
@@ -74,10 +94,52 @@ export const PROJECT_PROFILE = { tables: {} };
74
94
  */
75
95
  export const UNREADABLE = "$unreadable_tables";
76
96
 
97
+ /**
98
+ * Marks a contract that parsed only via a MIGRATION reader — readable, but not in the canonical
99
+ * shape. Without it the fallback is permanent by silence: the file works, nothing says it is the
100
+ * old form, and one artifact quietly keeps two formats forever. Readers surface it as a warn so a
101
+ * regeneration converges the file instead of the reader carrying it indefinitely.
102
+ */
103
+ export const LEGACY_LAYOUT = "$legacy_layout";
104
+
77
105
  // ---------------------------------------------------------------------------
78
106
  // Scalars
79
107
  // ---------------------------------------------------------------------------
80
108
 
109
+ /**
110
+ * Remove ONE matching pair of surrounding quotes, and unescape what was inside them.
111
+ *
112
+ * The pairing check is the whole point. Stripping a leading or trailing quote INDEPENDENTLY eats a
113
+ * character from any value that merely ends in one — a shell fixture like
114
+ * `export STORE="$T/s.json"` came back missing its final quote and was handed to `bash` as a
115
+ * syntax error. Nothing reports that as a parse failure: the verification step simply scores the
116
+ * implementation red, attempt after attempt, against code that was correct the whole time. A lossy
117
+ * reader is indistinguishable from a builder that cannot make progress, which is what makes this
118
+ * expensive rather than merely wrong.
119
+ *
120
+ * A double-quoted scalar that parses as JSON is read as JSON, because that is the escaping
121
+ * convention {@link uncoerce} emits — the two are exact inverses, and the round trip is asserted.
122
+ *
123
+ * EVERYTHING ELSE IS UNWRAPPED BUT NOT UNESCAPED, deliberately. A backslash inside a scalar that
124
+ * is not valid JSON belongs to whatever will consume the value, not to this dialect: shell
125
+ * verification fixtures are the common case, and there `echo "[{\"text\": \"ok\"}]"` needs its
126
+ * backslashes delivered intact. Unescaping on this path would corrupt them exactly as surely as
127
+ * the missing pair check corrupted the values that end in a quote — the same defect, from the
128
+ * other side. So the escaping convention applies only where it is unambiguous.
129
+ *
130
+ * @param {string} s - The trimmed raw text.
131
+ * @returns {string} The value with its surrounding quotes removed, or `s` unchanged.
132
+ */
133
+ function unquote(s) {
134
+ if (s.length < 2) return s;
135
+ const q = s[0];
136
+ if ((q !== '"' && q !== "'") || s[s.length - 1] !== q) return s;
137
+ if (q === '"') {
138
+ try { return JSON.parse(s); } catch { /* not JSON — unwrap only, see above */ }
139
+ }
140
+ return s.slice(1, -1);
141
+ }
142
+
81
143
  /**
82
144
  * Coerce one frontmatter or table-cell value.
83
145
  *
@@ -110,7 +172,7 @@ export function coerce(raw) {
110
172
  // A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
111
173
  // would turn it into a list and change its type on a round-trip.
112
174
  if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
113
- const v = trimmed.replace(/^["']|["']$/g, "");
175
+ const v = unquote(trimmed);
114
176
  if (v === "true") return true;
115
177
  if (v === "false") return false;
116
178
  if (v === "~" || v === "null" || v === "") return null;
@@ -129,7 +191,12 @@ export function uncoerce(v) {
129
191
  // trip that wrote it re-reads as several members — the same shredding, arriving from the writer's
130
192
  // side instead of the reader's.
131
193
  if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
132
- return String(v);
194
+ const s = String(v);
195
+ // A scalar that itself begins AND ends with a quote is indistinguishable, once written, from a
196
+ // quoted scalar — so emit it in the JSON form the reader unwraps exactly. Without this the round
197
+ // trip loses the value's own outer quotes, which is the same shredding as the reader's half.
198
+ if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) return JSON.stringify(s);
199
+ return s;
133
200
  }
134
201
 
135
202
  /**
@@ -287,6 +354,36 @@ export function parseTables(body) {
287
354
  return out;
288
355
  }
289
356
 
357
+ /**
358
+ * Fold per-entity `| Field | Value |` sections into the row array a horizontal table would give.
359
+ *
360
+ * The legacy wiring-map layout (see {@link WIRING_MAP}): one `### UC-xx — title` subsection per use
361
+ * case, each holding a vertical two-column table. The entity id comes from the HEADING, the fields
362
+ * from the rows. Field names are unwrapped from backticks and snake-cased so they land on the same
363
+ * keys the canonical horizontal table produces — the two layouts must yield identical objects or
364
+ * this is a second format rather than a second spelling of one.
365
+ *
366
+ * @param {Object<string, Array<Object>>} tables - `parseTables` output, keyed by heading.
367
+ * @param {{field:string, key:string, idPattern:RegExp}} desc - The `perSection` descriptor.
368
+ * @returns {Array<Object>} One row per matching section, in document order; [] when none match.
369
+ */
370
+ export function perSectionRows(tables, desc) {
371
+ const out = [];
372
+ for (const [heading, rows] of Object.entries(tables)) {
373
+ const id = heading.match(desc.idPattern)?.[1];
374
+ if (!id || !rows.length) continue;
375
+ const cols = new Set(Object.keys(rows[0]));
376
+ if (!cols.has("field") || !cols.has("value")) continue; // not a Field/Value table — prose, skip
377
+ const entry = { [desc.key]: id };
378
+ for (const r of rows) {
379
+ const k = String(r.field ?? "").replace(/`/g, "").trim().toLowerCase().replace(/\s+/g, "_");
380
+ if (k && r.value !== undefined) entry[k] = r.value;
381
+ }
382
+ out.push(entry);
383
+ }
384
+ return out;
385
+ }
386
+
290
387
  /**
291
388
  * Render rows as a markdown table.
292
389
  * @param {Array<Object>} rows - Row objects.
@@ -343,6 +440,57 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
343
440
  }
344
441
  }
345
442
  }
443
+ // THE PER-SECTION LAYOUT, read only when the canonical table is genuinely absent.
444
+ //
445
+ // Ordering matters: the canonical `## Wiring` table always wins, so a map carrying both shapes
446
+ // never has the migration reader override the current one. If neither yields rows but the body
447
+ // DOES carry sections this descriptor recognises, that is a parse failure and not an empty
448
+ // field — the whole point of the banner above — so it is reported rather than returned as
449
+ // silence. That case is what let 9 of 9 committed maps read as clean while holding nothing.
450
+ const ps = spec.perSection;
451
+ if (ps && !(ps.field in out)) {
452
+ const rows = perSectionRows(tables, ps);
453
+ if (rows.length) { out[ps.field] = rows; out[LEGACY_LAYOUT] = true; }
454
+ // The headings come from the BODY, not from `tables` — `parseTables` only records a heading
455
+ // that has a table under it, so a section whose table is missing or malformed leaves no trace
456
+ // there. Looking for the evidence in the parsed output is looking where the failure already
457
+ // erased it, which is the same mistake this whole diagnostic exists to correct.
458
+ else if ([...String(body).matchAll(/^#{1,6}\s+(.*)$/gm)].some((m) => ps.idPattern.test(m[1].trim()))) {
459
+ unreadable.push({
460
+ field: ps.field, expected_heading: Object.entries(spec.tables || {}).find(([f]) => f === ps.field)?.[1] || ps.field,
461
+ found_under: "per-entity sections whose tables this dialect could not read", rows: 0,
462
+ });
463
+ }
464
+ }
465
+
466
+ // A NON-TABLE FIELD WRITTEN AS A `## SECTION` VANISHES, and the loop above cannot see it.
467
+ //
468
+ // Everything above answers "was a declared TABLE written under the wrong heading". It says
469
+ // nothing about the other half of the contract — the scalars and string lists that live in
470
+ // frontmatter — because the parser has no heading to expect for them. So an author who writes
471
+ //
472
+ // ## e2e_verification_fixtures
473
+ // - `node --test test/store.test.js` — round-trips load()/save()
474
+ //
475
+ // instead of a frontmatter key produces a contract where that field is simply `undefined`, and
476
+ // every reader downstream treats it as "not declared". Measured: an architect did exactly this,
477
+ // the fixtures reached `verify t0` as `undefined`, and six scopes were certified T0-green having
478
+ // executed nothing — while the substrate list beside it, written as a frontmatter block list,
479
+ // parsed perfectly. One field silently vanished between the writer and the reader.
480
+ //
481
+ // The detector is deliberately narrow: a heading whose text is a bare snake_case identifier is
482
+ // an author naming a FIELD, not writing prose — `## Why this slice` and `## Affordances` cannot
483
+ // match. Reported through the same channel, so `unreadableReason()` covers both halves and a
484
+ // reader still asks once.
485
+ for (const m of String(body).matchAll(/^##\s+([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\s*$/gm)) {
486
+ const field = m[1];
487
+ if (field in out) continue; // also present in frontmatter — fine
488
+ if (Object.prototype.hasOwnProperty.call(spec.tables || {}, field)) continue; // handled above
489
+ unreadable.push({
490
+ field, expected_heading: field, rows: 0,
491
+ found_under: "a `## " + field + "` markdown section, which this dialect reads as prose",
492
+ });
493
+ }
346
494
  if (unreadable.length) out[UNREADABLE] = unreadable;
347
495
  return out;
348
496
  }
@@ -359,9 +507,19 @@ export function unreadableReason(contract) {
359
507
  const u = contract && contract[UNREADABLE];
360
508
  if (!u || !u.length) return null;
361
509
  return u
362
- .map((x) => (x.found_under === "an indented block this dialect cannot read" || x.found_under === "both"
363
- ? `\`${x.field}\` was written as \`${x.expected_heading}\` but ${x.rows} indented line(s) beneath it could not be read, so the value parsed as ABSENT`
364
- : `\`${x.field}\` must be a table under a \`## ${x.expected_heading}\` heading; found ${x.rows} matching row(s) under "${x.found_under}" instead, so the field parsed as ABSENT`))
510
+ .map((x) => {
511
+ if (x.found_under === "an indented block this dialect cannot read" || x.found_under === "both") {
512
+ return `\`${x.field}\` was written as \`${x.expected_heading}\` but ${x.rows} indented line(s) beneath it could not be read, so the value parsed as ABSENT`;
513
+ }
514
+ // A frontmatter field written as a prose section. Phrased as its own case because telling an
515
+ // author to "use a table heading" when the fix is "put it in frontmatter" sends them the
516
+ // wrong way — and this is the field whose silent absence certified six scopes on no evidence.
517
+ if (x.rows === 0 && String(x.found_under).includes("markdown section")) {
518
+ return `\`${x.field}\` was written as a \`## ${x.field}\` markdown section, which this dialect reads as prose — ` +
519
+ `it must be a FRONTMATTER key (a \`- \` block list or an inline [a, b] list), so as written the field parsed as ABSENT`;
520
+ }
521
+ return `\`${x.field}\` must be a table under a \`## ${x.expected_heading}\` heading; found ${x.rows} matching row(s) under "${x.found_under}" instead, so the field parsed as ABSENT`;
522
+ })
365
523
  .join("; ");
366
524
  }
367
525
 
@@ -460,6 +618,92 @@ export function readAllContracts(dir, spec = SCOPE_CONTRACT) {
460
618
  return out;
461
619
  }
462
620
 
621
+ // ---------------------------------------------------------------------------
622
+ // The scope ↔ spec join
623
+ // ---------------------------------------------------------------------------
624
+
625
+ /**
626
+ * Normalise one use-case reference to a bare `UC-*` id.
627
+ *
628
+ * The same reference is written three ways across the tree — `UC-AddTodo`, `[[UC-AddTodo]]` and
629
+ * `[[usecases/UC-AddTodo]]` all name one file — so every reader must strip identically or the two
630
+ * sides of a join disagree about a UC they both hold.
631
+ *
632
+ * @param {string} ref - A use-case reference in any of the accepted forms.
633
+ * @returns {string} The bare id (`UC-AddTodo`), trimmed.
634
+ */
635
+ export function ucId(ref) {
636
+ return String(ref ?? "").trim().replace(/^\[\[|\]\]$/g, "").replace(/^usecases\//, "").replace(/\.md$/, "").trim();
637
+ }
638
+
639
+ /**
640
+ * The tasks on a LOCAL board that belong to a scope, joined through the COMMITTED spec.
641
+ *
642
+ * WHY THE JOIN GOES THROUGH THE USE CASE. A scope contract is committed and a board is not, so a
643
+ * contract that named its task ids directly held a pointer into a gitignored, per-machine tier:
644
+ * on a fresh clone the ids resolved to nothing and every reader silently read "no tasks" rather
645
+ * than "the board is missing". Measured on a committed contract naming `TASK-004` with no board on
646
+ * disk: `compile` exited 0 and wrote a build order carrying no work at all. `use_cases[]` is the
647
+ * same LOCAL→SHARED direction the board's own `use_case_refs` already flows in, so the join is
648
+ * re-derived from two artifacts that are each valid on their own.
649
+ *
650
+ * @param {Array<{use_case_refs?:string[]}>} tasks - The parsed board.
651
+ * @param {{use_cases?:string[]}} contract - The scope contract.
652
+ * @returns {Array<object>} The board tasks anchored to ≥1 of this scope's use cases, in board
653
+ * order; [] when the scope names no use cases (the caller owns that fallback, because "this
654
+ * scope has no anchor" and "this scope has no tasks" are different questions).
655
+ */
656
+ export function tasksForScope(tasks, contract) {
657
+ const id = contract?.scope_id;
658
+ // AN EXPLICIT ASSIGNMENT WINS, and it is the only thing that makes this a PARTITION.
659
+ //
660
+ // The UC join alone is not one. A use case is routinely implemented by several scopes — that is
661
+ // what a vertical slice IS — so on a cut like the corpus's four scopes over a single UC, every
662
+ // scope matched every task. The previous `tasks[]` field partitioned correctly and paid for it by
663
+ // putting a machine-local id in a committed file; this restores the partition in the sanctioned
664
+ // direction instead, with the LOCAL task naming the committed scope. Absent, the UC join stands —
665
+ // which is right whenever the cut gives each scope its own use cases, and spec-lint's
666
+ // SCOPE-PARTITION reds the case where it does not.
667
+ const claimed = (tasks || []).filter((t) => t.scope_id);
668
+ if (id && claimed.length) {
669
+ const mine = claimed.filter((t) => String(t.scope_id).trim() === id);
670
+ // A board only partly stamped still resolves: assigned tasks go to their scope, and anything
671
+ // unassigned falls back to the UC join rather than vanishing mid-migration.
672
+ const rest = (tasks || []).filter((t) => !t.scope_id);
673
+ return [...mine, ...byUseCase(rest, contract)];
674
+ }
675
+ return byUseCase(tasks, contract);
676
+ }
677
+
678
+ /** The UC-overlap half of {@link tasksForScope}. @param {Array<object>} tasks Board. @param {object} contract Scope. @returns {Array<object>} Matches. */
679
+ function byUseCase(tasks, contract) {
680
+ const mine = new Set((contract?.use_cases || []).map(ucId).filter(Boolean));
681
+ if (!mine.size) return [];
682
+ return (tasks || []).filter((t) => (t.use_case_refs || []).some((r) => mine.has(ucId(r))));
683
+ }
684
+
685
+ /**
686
+ * Tasks that more than one scope would build — the ambiguity {@link tasksForScope} cannot resolve.
687
+ *
688
+ * Dispatch has to be a partition: a task built by two scopes is built twice, and the second builder
689
+ * is denied by the sandbox for writing outside its own substrate, so the cost lands as burnt
690
+ * attempts and escalations rather than as corruption. Measured on the corpus's four-scope,
691
+ * one-use-case cut: every task claimed by every scope.
692
+ *
693
+ * @param {Array<{id:string, scope_id?:string, use_case_refs?:string[]}>} tasks - The parsed board.
694
+ * @param {Array<{scope_id:string, use_cases?:string[]}>} contracts - Every scope in the run.
695
+ * @returns {Array<{task_id:string, scopes:string[]}>} One entry per contested task; [] when the
696
+ * assignment is already a partition.
697
+ */
698
+ export function scopePartitionConflicts(tasks, contracts) {
699
+ const out = [];
700
+ for (const t of tasks || []) {
701
+ const owners = (contracts || []).filter((c) => tasksForScope([t], c).length).map((c) => c.scope_id);
702
+ if (owners.length > 1) out.push({ task_id: t.id, scopes: owners });
703
+ }
704
+ return out;
705
+ }
706
+
463
707
  /**
464
708
  * Extract the prose a regeneration must not destroy — everything in the body except the sections
465
709
  * this spec owns.