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.
- package/.claude/settings.local.example.json +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +17 -3
- package/README.md +124 -103
- package/SECURITY.md +37 -30
- package/bin/init.mjs +77 -47
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +7 -6
- package/commands/ship.md +29 -16
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +3 -3
- package/hooks/gate-zerowork.mjs +25 -24
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +19 -8
- package/hooks/safety-spine.mjs +3 -3
- package/hooks/sandbox-guard.mjs +84 -40
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
- package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
- package/kernel/reduce/graph.mjs +437 -0
- package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
- package/oracles/http-oracle.mjs +1 -1
- package/oracles/process-oracle.mjs +1 -1
- package/oracles/snapshot-oracle.mjs +1 -1
- package/oracles/test-oracle.mjs +1 -1
- package/package.json +11 -9
- package/skills/ba-pitch-analyzer/SKILL.md +5 -5
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
- package/skills/qa-edge-hunter/SKILL.md +7 -3
- package/skills/scope-architect/SKILL.md +40 -6
- package/skills/solution-architect/SKILL.md +19 -4
- package/skills/spec-evaluator/SKILL.md +12 -2
- package/skills/task-executor/SKILL.md +7 -6
- package/skills/tech-lead/SKILL.md +50 -38
- package/skills/tech-lead/references/gates.md +69 -20
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +341 -66
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +1 -1
- package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
- package/hooks/anti-rationalization.mjs +0 -238
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -147
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -108
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -261
- package/skills/tech-lead/references/hard-rules.md +0 -34
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -213
- package/skills/tech-lead/references/round-protocol.md +0 -234
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
- package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
- package/skills/translator/README.md +0 -66
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// contract
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
363
|
-
|
|
364
|
-
|
|
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.
|