shapeup-sdlc 3.3.0 → 3.5.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.3.0",
4
+ "version": "3.5.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
@@ -28,9 +28,10 @@ Betting Table: PO decides; rejected pitches loop back to raw idea.
28
28
  |------|------|--------|
29
29
  | Kick-off | ⏸ **L0** — Intake & Config (L0.8 model/budget matrix) + worker roster ✧ | `/translator` if non-English |
30
30
  | Orient (Scout) | ⏸ **L1a** — Orient Review | `/orient` |
31
- | Analyze | — (reviewed at L1b) | `/ba-pitch-analyzer` (`analyze`): spec tree + board (UC + Invariants + Test Surface ★); before Wire (needs its use cases) |
31
+ | Requirements | — (reviewed at L1b) | `/ba-pitch-analyzer` (`coverage`): the pitch's clauses → committed `requirements.md`, one atomic clause per `REQ-<n>` row naming the pitch clause it came from, ids assigned once and frozen; dispatched once, ahead of Analyze because the acceptance criteria are what cite its ids. A registry already on disk is not re-dispatched |
32
+ | Analyze | — (reviewed at L1b) | `/ba-pitch-analyzer` (`analyze`): spec tree + board (UC + Invariants + Test Surface ★); before Wire (needs its use cases). An acceptance criterion that grades a requirement carries `(covers: REQ-…)` — that clause is the edge the verdict travels back along |
32
33
  | Wire | ⏸ **L1a.5** — Wiring Review ✚ | `/solution-architect` (`wire`): sole writer of committed `wiring-map.md` — per-UC engine → seam → entry-point call site → affordance, per `project-profile.md` |
33
- | Map Scopes | ⏸ **L1b** — Board Review (+ substrate disjointness lint) | `/scope-architect` (scope contracts ✦ — sole writer); traceability oracle advisory ✚ |
34
+ | Map Scopes | ⏸ **L1b** — Board Review (+ substrate disjointness lint) | `/scope-architect` (scope contracts ✦ — sole writer); traceability oracle advisory ✚. A registered requirement that no acceptance criterion grades and no scope claims is **red** here, and L1b prints the `REQ → AC` table: the two ways out are an AC carrying `(covers: REQ-…)` or the PO marking the clause `CUT (PO-approved)`. Red only where the plan is still cheap to change — after L1b nobody re-reads the pitch |
34
35
  | Build Vertically | ⏸ **L2** — Board 100% ✅ + T0-green ✦ | per dispatch: compile order → `/task-executor` (--order) → ingest result; T0-verified per attempt (fixtures + DB probe + seesaw ✦), substrate-sandboxed ✦. Scopes build **concurrently** ✦ — `--parallel-scopes N` caps it (default 4), a scope is released the moment its own dependencies are green, and a scope green in this round is skipped rather than rebuilt. Then the **round build gate** ⚙: the ledger's run command, then the profile's `build_probe` and `launch_probe`, run once per round before EVAL — a red gate ends the round with no verdict and its failing step is compiled into the next round's orders as bugs; a `mobile` profile with no `launch_probe` is warned about every round, so the install/launch risk has an owner |
35
36
  | EVAL (once per round) | ⏸ **L3** — Verdict | `/spec-evaluator` (--order), only over a round whose build gate ⚙ is not red: spec- + test-surface-conformance ★, T0 citation ✦; refuted boxes/verdict applied by ingest |
36
37
  | FAIL → round r+1 | — | regression rule ★: bugs + full Test Surface of touched UC |
@@ -58,6 +59,7 @@ Everything discovered funnels into `.shapeup/<slug>/discovery/ledger.md` (Orient
58
59
  - **Ledger = single source of truth** — every discovery flow writes only its own section.
59
60
  - **QA is a level-up, not a gate** — `--no-qa` skips it; circuit breaker outranks the Hunter.
60
61
  - **Role separation** — Evaluator grades, task-executor fixes, QA discovers.
62
+ - **The requirements matrix is a projection, never a verdict** — `REQ → AC → criterion → verdict` is derived from files for one named run (the registry, the board's `covers:` clauses, the run's verdict rows, the T0 citations), never narrated and never passed in. `covers:` is the authoritative join: a criterion anchored to a requirement no AC covers is printed for reconciliation and counted as nothing. L4 reads one line off it, GATE H's census takes the clauses with no evidence, `REPORT.md` freezes the table — and none of that blocks a ship. A clause with no PASS evidence is a fact the baseline comparison weighs, not a veto.
61
63
  - **Hill phase is mechanical ✦** — derived only from T0/T1/seesaw artifacts, never self-reported, and a T0-green from a round whose build gate ⚙ is red moves no dot (a green fixture in a round the feature did not build is evidence about the fixture); 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
64
  - **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
65
 
package/README.md CHANGED
@@ -221,8 +221,12 @@ the layer that carries it, and the three layers here fail differently:
221
221
  - `PreToolUse` (`Edit|Write|MultiEdit`) — **`hooks/sandbox-guard.mjs` blocks a write that no LIVE
222
222
  order's substrate permits.** It reads every order that is compiled and not yet answered — a result
223
223
  at least as new as the order itself — rather than a pointer to one, so scopes building
224
- concurrently are each held to their own contract and a finished run fences nothing; `frozen`
225
- outranks everything, across all of them.
224
+ concurrently are each held to their own contract and a finished run fences nothing. A phase
225
+ dispatch the run has already moved past stops fencing too: once a later phase compiles its order,
226
+ an evaluation or a QA leg that never returned a result no longer holds the board. `frozen` is
227
+ checked first and outranks everything, across every live contract — including the carve-out that
228
+ otherwise keeps the active feature's own run trace writable, so a path a live order froze stays
229
+ frozen wherever it lives.
226
230
  - `PreToolUse` (`Bash|Read|Write|Edit|MultiEdit`) — **`hooks/safety-spine.mjs` denies destructive
227
231
  commands** (`rm -rf` on unrecoverable targets, force-push/push-to-main, `git reset --hard`,
228
232
  `DROP TABLE`) and secret-file reads. A machine guard, not a pipeline guard; the escape hatch is
package/SECURITY.md CHANGED
@@ -72,7 +72,7 @@ sitting, and reading them is the recommended review.
72
72
  | [`safety-spine.mjs`](hooks/safety-spine.mjs) | PreToolUse (`Bash\|Read\|Write\|Edit\|MultiEdit`) | The proposed command/path; `.shapeup/safety-overrides.json` | Yes — provably destructive ops only: `rm -rf` on unrecoverable targets, `git push --force` / push to main, `git reset --hard`, `git clean -fdx`, `DROP TABLE`/`TRUNCATE`, reads of `.env`/keys/cloud credentials, and any write to its own overrides file | Never blocks an unmatched command; `--force-with-lease` stays allowed |
73
73
  | [`gate-intake.mjs`](hooks/gate-intake.mjs) | PreToolUse (`Skill`) | The `tech-lead` dispatch's own arguments | Yes — an orchestrator dispatch carrying no resolvable intake (no pitch, spec, resume or requirement text) | Fails open on `--order` and on any ambiguous arg shape |
74
74
  | [`harness verify envelope`](kernel/verify/envelope.mjs) | PreToolUse (`Skill\|Agent`) | The `--order` file named in the dispatch; the JSON schemas | Yes — a worker dispatch whose order file is missing or schema-invalid | Never gates a dispatch that carries no `--order` (standalone skill use stays free) |
75
- | [`sandbox-guard.mjs`](hooks/sandbox-guard.mjs) | PreToolUse (`Edit\|Write\|MultiEdit`) | The target path; the `substrate` block of every LIVE order — compiled, with no result at least as new as the order's own `compiled_at` | Yes — any write no live order permits: outside every `allowed`/`shared`, inside any `frozen`, or a `Write` to an `append_only` path | No-op unless an order is live, which a finished run no longer is: the pointer names the run, never a dispatch. The active feature's own `.shapeup/<slug>/` run-trace is always writable. Appends denials to the local pathology log |
75
+ | [`sandbox-guard.mjs`](hooks/sandbox-guard.mjs) | PreToolUse (`Edit\|Write\|MultiEdit`) | The target path; the `substrate` block of every LIVE order — compiled, with no result at least as new as the order's own `compiled_at`, and, for a run-level phase dispatch, not yet superseded by a later phase's order | Yes — any write no live order permits: inside any `frozen`, outside every `allowed`/`shared`, or a `Write` to an `append_only` path. `frozen` is checked FIRST, so it also outranks the run-trace carve-out | No-op unless an order is live, which a finished run no longer is: the pointer names the run, never a dispatch. The active feature's own `.shapeup/<slug>/` run trace is writable EXCEPT where a live order freezes a path inside it. Appends denials to the local pathology log |
76
76
  | [`dispatch-receipt.mjs`](hooks/dispatch-receipt.mjs) | PostToolUse (`Skill\|Agent`) | The `--order` file named in the dispatch; the tool result's own report of which skill ran | **No — it has no deny path at all.** It records that the shipped skill ran, so `harness reduce ingest` can refuse a result no dispatch produced | Never writes an attestation for a result that does not name a resolved skill; never fails the call it observes (every write is inside `try`/`catch`) |
77
77
  | [`gate-zerowork.mjs`](hooks/gate-zerowork.mjs) | Stop | Run receipts on disk; the session transcript; the decision ledger | **Yes — the one blocking hook.** Returns `decision:"block"` when the session dispatched the orchestrator and produced no run receipt | Defers the moment any receipt exists; `stop_hook_active` caps it at one block per stop chain |
78
78
 
@@ -42,6 +42,21 @@
42
42
  // the order's own `compiled_at`, the stamp the compiler writes INTO the order, which a copy or a
43
43
  // touch cannot perturb. An order carrying no stamp falls back to presence, which is all it ever had.
44
44
  //
45
+ // A RUN-LEVEL ORDER RETIRES AT ITS PHASE BOUNDARY, which is the other half of that same question.
46
+ // Liveness is "compiled, no result yet", so a PHASE dispatch whose worker never returned — a killed
47
+ // evaluation, a QA leg that escalated without a result, a failed scope mapping — would stay live for
48
+ // the rest of the run, and everything its `frozen` list names (the board, the spec tree) would be
49
+ // fenced from then on. The board is the one that bites: the next round's doer cannot tick its own
50
+ // acceptance criteria, and the run wedges with no dispatch actually in flight. The orchestrator has
51
+ // long since moved on by the time that matters, and the move itself is the signal: the next phase
52
+ // compiles its own order. So an order for a RUN-LEVEL operation stops being live once an order for a
53
+ // DIFFERENT operation has been compiled after it — the window the committed tier already has, a
54
+ // phase boundary rather than the middle of somebody else's dispatch. Build legs are exempt: they run
55
+ // concurrently, finish out of order, and an abandoned one is resolved by ``harness init run --force``
56
+ // rather than by a sibling's compile stamp. Two concurrent legs of the SAME operation never retire
57
+ // each other, for that same reason. An order carrying no operation or no `compiled_at` keeps
58
+ // fencing — an unreadable claim is not a retired one.
59
+ //
45
60
  // That is the same question as "the writer's own contract" because scope substrates are disjoint by
46
61
  // construction — `harness verify spec`'s DISJOINT rule fails a spec where two scopes claim the same
47
62
  // path, and it runs at GATE L1b before any build starts. `frozen` is checked across all of them, so
@@ -133,7 +148,37 @@ function answered(resultPath, order) {
133
148
  }
134
149
 
135
150
  /**
136
- * Every order for this run that has been compiled and not yet answered.
151
+ * Operations whose order is a BUILD leg — one scope, one attempt, dispatched alongside its siblings.
152
+ *
153
+ * Everything else the compiler emits is a RUN-LEVEL phase dispatch, and only those retire at a phase
154
+ * boundary (see the banner). The distinction is by operation rather than by "does it carry a scope",
155
+ * because the single-task lane compiles an `execute` order with no scope contract on it and that leg
156
+ * is still a build.
157
+ */
158
+ const BUILD_OPERATIONS = new Set(["execute", "fix", "spike"]);
159
+
160
+ /**
161
+ * Has the run moved past this order's phase — i.e. did a LATER order for a different operation get
162
+ * compiled while this one was still unanswered?
163
+ *
164
+ * Conservative in both directions it cannot read: an order with no `operation`, a build leg, or an
165
+ * order with no parseable `compiled_at` keeps fencing.
166
+ *
167
+ * @param {object} order - The parsed, unanswered order.
168
+ * @param {Array<{operation:(string|undefined), at:number}>} stamps - Every compiled order's
169
+ * operation and parsed `compiled_at`, unparseable stamps excluded.
170
+ * @returns {boolean} True when this order's phase is over and it should stop being enforced.
171
+ */
172
+ function pastItsPhase(order, stamps) {
173
+ const op = order?.operation;
174
+ if (!op || BUILD_OPERATIONS.has(op)) return false;
175
+ const at = Date.parse(order?.compiled_at ?? "");
176
+ if (Number.isNaN(at)) return false;
177
+ return stamps.some((s) => s.at > at && s.operation !== op);
178
+ }
179
+
180
+ /**
181
+ * Every order for this run that has been compiled, not yet answered, and whose phase is still open.
137
182
  *
138
183
  * @param {string} cwd - Project root.
139
184
  * @param {string} slug - The run named by the pointer.
@@ -143,14 +188,17 @@ function liveOrders(cwd, slug) {
143
188
  const dir = ordersDir(cwd, slug);
144
189
  if (!existsSync(dir)) return [];
145
190
  const rDir = resultsDir(cwd, slug);
146
- const live = [];
191
+ const unanswered = [];
192
+ const stamps = [];
147
193
  for (const f of readdirSync(dir)) {
148
194
  if (!f.endsWith(".json")) continue;
149
195
  const order = readJSON(join(dir, f));
150
196
  if (!order) continue;
151
- if (!answered(join(rDir, f), order)) live.push(order);
197
+ const at = Date.parse(order.compiled_at ?? "");
198
+ if (!Number.isNaN(at)) stamps.push({ operation: order.operation, at });
199
+ if (!answered(join(rDir, f), order)) unanswered.push(order);
152
200
  }
153
- return live;
201
+ return unanswered.filter((o) => !pastItsPhase(o, stamps));
154
202
  }
155
203
 
156
204
  function extractPaths(toolInput) {
@@ -231,22 +279,33 @@ async function main() {
231
279
  const runTracePrefix = join(LOCAL, active.slug) + sep;
232
280
  const violations = [];
233
281
  const blockReasons = [];
282
+ let frozenHits = 0;
234
283
 
235
284
  for (const raw of targetPaths) {
236
285
  const abs = resolve(cwd, raw);
237
286
  const rel = relative(root, abs);
238
- if (rel.startsWith(runTracePrefix)) continue;
239
287
 
240
288
  // Frozen takes absolute precedence, and it is checked across EVERY live contract: a path one
241
289
  // scope froze stays frozen while another scope is in flight, which is the whole point of
242
290
  // declaring it.
291
+ //
292
+ // IT IS CHECKED BEFORE THE RUN-TRACE CARVE-OUT, and that order is load-bearing. The carve-out
293
+ // below exists so the doer can keep its own bookkeeping current; it was never a licence to
294
+ // overwrite a file a live contract declared read-only. Checked after it, every `frozen` glob
295
+ // naming a path inside the run trace was inert — the board an evaluation froze, the staged pitch
296
+ // a planner is graded against — so the compiler emitted a declaration with no enforcer, which is
297
+ // the exact state this hook exists to end. A path a live contract freezes is a violation
298
+ // wherever it lives.
243
299
  const freezer = contracts.find((c) => matchesAny(rel, c.frozen));
244
300
  if (freezer) {
245
301
  violations.push(rel);
302
+ frozenHits++;
246
303
  blockReasons.push(`${rel} is frozen by ${freezer.order_id}`);
247
304
  continue;
248
305
  }
249
306
 
307
+ if (rel.startsWith(runTracePrefix)) continue;
308
+
250
309
  if (contracts.some((c) => matchesAny(rel, c.allowed))) continue; // inside a live contract
251
310
 
252
311
  if (contracts.some((c) => matchesAny(rel, c.appendOnly))) {
@@ -291,7 +350,11 @@ async function main() {
291
350
 
292
351
  return {
293
352
  verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.order_path, cwd: root,
294
- rule: "outside-substrate",
353
+ // THE LEDGER NAMES THE CAUSE, not just the verdict. A write refused because a live contract
354
+ // FROZE the path and a write refused because no contract covers it are different facts with
355
+ // different remedies, and a single rule string cannot tell the reader which one happened —
356
+ // which is how a frozen declaration can stop being enforced without a single row moving.
357
+ rule: frozenHits === violations.length ? "frozen" : "outside-substrate",
295
358
  reason: `${violations.length} write(s) rejected by substrate boundaries: ${blockReasons.join("; ")}`,
296
359
  payload: {
297
360
  hookSpecificOutput: {
@@ -207,6 +207,15 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
207
207
  // `feedback.md`, `api-feasibility.md` and `integration.md` are analysis, not contract.
208
208
  const working = `${local}/working`;
209
209
  const FROZEN_SPEC_CORE = [`${spec}/domain-model.md`, `${spec}/usecases/*.md#Steps`, `${spec}/contracts/**`, `${spec}/ux-behavior.md`];
210
+ // THE STAGED PITCH IS THE RUN'S OWN INPUT TRUTH, and it is a separate constant from the spec core
211
+ // on purpose: the spec core is what a planner PRODUCES and a judge grades against, while these two
212
+ // are what the run was asked for. `init run` stages them beside the receipt that digests them, and
213
+ // every planning and evaluating operation reads them — so a worker that can rewrite either can
214
+ // rewrite the question it is about to be measured on, and the receipt's digest stops describing
215
+ // the file next to it. Frozen, not merely unlisted: `analyze` already carries the whole run trace
216
+ // in its `allowed` globs, so only a `frozen` entry denies the write. `translate` is the one
217
+ // operation that legitimately rewrites a pitch, and it writes the COMMITTED copy, not this one.
218
+ const FROZEN_INTAKE = [`${local}/intake.md`, `${local}/breadboard.md`];
210
219
  switch (operation) {
211
220
  case "execute": case "fix": case "spike":
212
221
  return {
@@ -214,7 +223,7 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
214
223
  shared: scope?.shared_substrate || [],
215
224
  };
216
225
  case "analyze":
217
- return { allowed: [`${spec}/**`, `${local}/**`], frozen: [] };
226
+ return { allowed: [`${spec}/**`, `${local}/**`], frozen: [...FROZEN_INTAKE] };
218
227
 
219
228
  case "reconcile":
220
229
  return {
@@ -229,24 +238,24 @@ export function substrateFor(operation, { slug, specDir, scope } = {}) {
229
238
  // The covers-closure input truth. Writes ONLY the derived registry: the REQ source it
230
239
  // extracts from is frozen alongside the spec core, because a planner that may edit the
231
240
  // requirements it is being measured against is not measuring anything.
232
- return { allowed: [globShared(slug, "requirements.md")], frozen: FROZEN_SPEC_CORE };
241
+ return { allowed: [globShared(slug, "requirements.md")], frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE] };
233
242
 
234
243
  case "map-scopes":
235
244
  return {
236
245
  allowed: [`${scopesDir}/*.md`, globShared(slug, "scope-board.md")],
237
- frozen: [...FROZEN_SPEC_CORE, `${local}/tasks/**`],
246
+ frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${local}/tasks/**`],
238
247
  };
239
248
  case "wire":
240
249
  // solution-architect writes the SHARED wiring map DIRECTLY (precedent: scope-architect
241
250
  // writes scopes/*.md). The spec core, the scopes, and the profile stay frozen.
242
251
  return {
243
252
  allowed: [globShared(slug, "wiring-map.md")],
244
- frozen: [...FROZEN_SPEC_CORE, `${scopesDir}/**`, globShared(slug, "project-profile.md")],
253
+ frozen: [...FROZEN_SPEC_CORE, ...FROZEN_INTAKE, `${scopesDir}/**`, globShared(slug, "project-profile.md")],
245
254
  };
246
255
  case "evaluate":
247
- return { allowed: [`${local}/evaluation/**`], frozen: [`${spec}/**`, `${local}/tasks/**`] };
256
+ return { allowed: [`${local}/evaluation/**`], frozen: [`${spec}/**`, ...FROZEN_INTAKE, `${local}/tasks/**`] };
248
257
  case "hunt":
249
- return { allowed: [`${local}/qa/**`], frozen: [`${spec}/**`, `${local}/tasks/**`] };
258
+ return { allowed: [`${local}/qa/**`], frozen: [`${spec}/**`, ...FROZEN_INTAKE, `${local}/tasks/**`] };
250
259
  case "orient":
251
260
  return { allowed: [`${local}/orient/**`], frozen: [`${spec}/**`] };
252
261
  case "translate":
@@ -31,7 +31,8 @@
31
31
  // ship · board · verdict · graph
32
32
  // gate An answer file with a source, not a vibe.
33
33
  // probe resume · t0 · stats · digest · Read-only queries over run state. `concurrency`
34
- // concurrency · leg · eval · owner answers how many legs ran at once and what the
34
+ // concurrency · leg · eval · answers how many legs ran at once and what the
35
+ // owner · requirements
35
36
  // fan-out bought, and refuses a figure the record set
36
37
  // cannot support rather than printing a plausible one.
37
38
  // `leg` answers whether a scope's work reached the
@@ -45,7 +46,10 @@
45
46
  // own end-of-turn summary of its own verdict. `owner`
46
47
  // answers which scope may write a path, elected from
47
48
  // the contracts — so a census cites it instead of
48
- // asserting ownership from memory.
49
+ // asserting ownership from memory. `requirements`
50
+ // answers which pitch clause a verdict reached, joined
51
+ // through the plan's own covers: edge — the L4 line and
52
+ // GATE H's census cite it for the same reason.
49
53
  // init run · fit Opens a run, or refuses it (exit 3).
50
54
  // report export Projects the run's records as fact tables.
51
55
  // compile The WorkOrder: schema-valid or nothing is dispatched.
@@ -81,6 +85,7 @@ export const ROUTES = {
81
85
  resume: "./probe/resume.mjs", t0: "./probe/t0.mjs", stats: "./probe/stats.mjs",
82
86
  digest: "./probe/digest.mjs", concurrency: "./probe/concurrency.mjs",
83
87
  leg: "./probe/leg.mjs", eval: "./probe/eval.mjs", owner: "./probe/owner.mjs",
88
+ requirements: "./probe/requirements.mjs",
84
89
  },
85
90
  init: { run: "./init/run.mjs", fit: "./init/fit.mjs" },
86
91
  report: { export: "./report/export.mjs", _default: "export" },
@@ -94,6 +94,9 @@ export const PROJECT_PROFILE = { tables: {} };
94
94
  */
95
95
  export const UNREADABLE = "$unreadable_tables";
96
96
 
97
+ /** `found_under` marker: a table field that was also declared in the frontmatter, where nothing reads it. */
98
+ export const FRONTMATTER_COPY = "the frontmatter, where it is silently discarded";
99
+
97
100
  /**
98
101
  * Marks a contract that parsed only via a MIGRATION reader — readable, but not in the canonical
99
102
  * shape. Without it the fallback is permanent by silence: the file works, nothing says it is the
@@ -172,7 +175,16 @@ export function coerce(raw) {
172
175
  // A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
173
176
  // would turn it into a list and change its type on a round-trip.
174
177
  if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
178
+ // A QUOTED SCALAR IS A STRING, VERBATIM — the same rule the list test above already applies, one
179
+ // step further in. Unquoting FIRST and then testing the bareword literals meant the quotes bought
180
+ // nothing: `"false"` unwrapped to `false` and then matched the boolean test, so a cell whose value
181
+ // is genuinely the word "false" re-read as the boolean. Same for `"true"`, `"123"`, `"~"` and `""`
182
+ // — a string changed TYPE on a round trip, silently, the first time its contract was rewritten.
183
+ // Quoting is how an author says "this is text"; honouring that is what makes the round trip total.
184
+ const quoted = trimmed.length >= 2 && (trimmed[0] === '"' || trimmed[0] === "'")
185
+ && trimmed[trimmed.length - 1] === trimmed[0];
175
186
  const v = unquote(trimmed);
187
+ if (quoted) return v;
176
188
  if (v === "true") return true;
177
189
  if (v === "false") return false;
178
190
  if (v === "~" || v === "null" || v === "") return null;
@@ -192,10 +204,17 @@ export function uncoerce(v) {
192
204
  // side instead of the reader's.
193
205
  if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
194
206
  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);
207
+ // THE READER IS THE ORACLE. Anything whose plain text would come back as a DIFFERENT VALUE goes
208
+ // out quoted — and the only honest test of that is to ask `coerce` itself.
209
+ //
210
+ // This subsumes the older rule (a scalar that begins AND ends with a quote) and closes the family
211
+ // it missed: a string whose text is a bareword literal changed TYPE on a round trip. The string
212
+ // "false" re-read as the boolean false, "123" as the number 123, "[a, b]" as a two-member list,
213
+ // "~" as null. Each is a value an author can legitimately write in a cell, and each silently
214
+ // became something else the first time the contract was rewritten. `coerce(s) !== v` catches all
215
+ // of them at once and leaves every value that already round-trips untouched — a path, a sentence,
216
+ // a real boolean, a real number.
217
+ if (coerce(s) !== v) return JSON.stringify(s);
199
218
  return s;
200
219
  }
201
220
 
@@ -424,7 +443,34 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
424
443
  const unreadable = [...(meta[UNREADABLE] || [])];
425
444
  delete out[UNREADABLE];
426
445
  for (const [field, heading] of Object.entries(spec.tables || {})) {
427
- if (tables[heading]) { out[field] = tables[heading]; continue; }
446
+ // A TABLE FIELD DECLARED IN THE FRONTMATTER TOO IS A DISCARDED DECLARATION, and it has to be
447
+ // loud. `renderContract` writes table fields ONLY as a table — it filters them out of the
448
+ // frontmatter it emits — so the table is the source by construction and the line below is right
449
+ // to prefer it. What the old code did not say is that the frontmatter copy was then dropped
450
+ // without a word, and `splitFrontmatter` cannot even represent this shape: a block sequence of
451
+ // MAPPINGS flattens to a list of strings, so the copy is usually garbage as well as ignored.
452
+ //
453
+ // Measured: a planner wrote `affordance_manifest` in both places, the frontmatter copy carrying
454
+ // the correct `required_states: [idle]` and the table cell a bare `idle`. The parser took the
455
+ // table, the value was a string where the schema wants an array, `compile` refused the order,
456
+ // and the scope was NEVER DISPATCHED — spec-lint passing the contract, the build leg reporting
457
+ // `state: "done", error: null`, four of nine scopes gone every round, until EVAL refused to
458
+ // grade a round whose scopes had never run. The contract even captioned its own table "rendered
459
+ // here for reviewers": the author's model was the exact inverse of the parser's. That
460
+ // disagreement is the defect; which side wins is not.
461
+ //
462
+ // ONLY WHEN A TABLE ACTUALLY WINS. With no rows under the heading the frontmatter value is what
463
+ // the field becomes, and `affordance_manifest: []` meaning "none" is legal and current — five of
464
+ // those nine contracts do exactly that and are correct. An earlier cut of this rule fired on all
465
+ // nine and turned a working run red, which is the opposite of the fix.
466
+ if (tables[heading]) {
467
+ if (field in meta) {
468
+ unreadable.push({ field, expected_heading: heading, found_under: FRONTMATTER_COPY,
469
+ rows: Array.isArray(meta[field]) ? meta[field].length : 1 });
470
+ }
471
+ out[field] = tables[heading];
472
+ continue;
473
+ }
428
474
  // The field is absent — but is it absent because nobody declared it, or because the
429
475
  // author declared it somewhere this parser does not look? Those are opposite facts and the
430
476
  // old code returned the same thing for both. A table carrying this field's signature columns,
@@ -518,6 +564,14 @@ export function unreadableReason(contract) {
518
564
  return `\`${x.field}\` was written as a \`## ${x.field}\` markdown section, which this dialect reads as prose — ` +
519
565
  `it must be a FRONTMATTER key (a \`- \` block list or an inline [a, b] list), so as written the field parsed as ABSENT`;
520
566
  }
567
+ // A table field ALSO declared in the frontmatter. Its own case because the fix is "delete the
568
+ // frontmatter copy", not "move the table" — and because the copy is not merely redundant: the
569
+ // parser takes the table, so whatever the copy said was discarded without a word.
570
+ if (x.found_under === FRONTMATTER_COPY) {
571
+ return `\`${x.field}\` is a TABLE field and was also declared in the frontmatter, ` +
572
+ `where it is read by nothing and silently discarded — the \`## ${x.expected_heading}\` table is the only source. ` +
573
+ `Delete the frontmatter copy, and check the table says what it said: a list cell is written \`[a, b]\``;
574
+ }
521
575
  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
576
  })
523
577
  .join("; ");
@@ -636,6 +690,35 @@ export function ucId(ref) {
636
690
  return String(ref ?? "").trim().replace(/^\[\[|\]\]$/g, "").replace(/^usecases\//, "").replace(/\.md$/, "").trim();
637
691
  }
638
692
 
693
+ /**
694
+ * Normalise one requirement reference to the registry's key space (`REQ-<n>`).
695
+ *
696
+ * TWO KEY SPACES FOR ONE THING, and the measurement is what decided this. A pitch numbers its
697
+ * requirements `R1…R21`; the registry, every AC's `covers:` clause and every `traces_to[]` key off
698
+ * `REQ-<n>`. Measured across two runs of one pitch, the scope contracts cited `R<n>` — 20 of 21
699
+ * requirements had a scope claiming them, and every one of those links resolved to nothing,
700
+ * reported 23 times a run as a shape warning nobody could act on. So the edge WAS produced; it was
701
+ * severed by spelling alone.
702
+ *
703
+ * Normalising here rather than teaching the planner to emit `REQ-<n>` is deliberate: `covers[]` is
704
+ * an OPTIONAL contract field, so a fix that depends on a planner choosing to comply converts
705
+ * whatever links the next run happens to write, while this converts the links on contracts already
706
+ * committed, with no worker behaviour change. The craft still asks for `REQ-<n>` going forward.
707
+ *
708
+ * THIS IS A MAPPING PERFORMED BEFORE THE PATTERN, NOT A LOOSENING OF IT. `^REQ-[0-9]+$` is
709
+ * unchanged everywhere it appears; a reference this function does not recognise is returned
710
+ * verbatim, so it still fails that pattern and is still reported.
711
+ *
712
+ * @param {string} ref - A requirement reference (`REQ-12`, `R12`, `R-12`, `[[REQ-12]]`, any case).
713
+ * @returns {string} The canonical `REQ-<n>` id, or the trimmed input unchanged when it is not a
714
+ * numbered requirement reference at all.
715
+ */
716
+ export function reqId(ref) {
717
+ const s = String(ref ?? "").trim().replace(/^\[\[|\]\]$/g, "").trim();
718
+ const m = s.match(/^(?:REQ|R)-?([0-9]+)$/i);
719
+ return m ? `REQ-${m[1]}` : s;
720
+ }
721
+
639
722
  /**
640
723
  * The tasks on a LOCAL board that belong to a scope, joined through the COMMITTED spec.
641
724
  *