shapeup-sdlc 3.4.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.
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +4 -2
- package/README.md +6 -2
- package/SECURITY.md +1 -1
- package/hooks/sandbox-guard.mjs +69 -6
- package/kernel/compile.mjs +15 -6
- package/kernel/harness.mjs +7 -2
- package/kernel/lib/contract.mjs +68 -1
- package/kernel/probe/requirements.mjs +296 -0
- package/kernel/probe/resume.mjs +7 -1
- package/kernel/reduce/graph.mjs +5 -2
- package/kernel/reduce/ingest.mjs +16 -3
- package/kernel/reduce/ship.mjs +37 -1
- package/kernel/verify/spec.mjs +130 -4
- package/kernel/verify/trace.mjs +16 -7
- package/package.json +1 -1
- package/skills/ba-pitch-analyzer/SKILL.md +16 -1
- package/skills/scope-architect/SKILL.md +16 -1
- package/skills/scope-hammer/SKILL.md +10 -1
- package/skills/spec-evaluator/SKILL.md +12 -1
- package/skills/tech-lead/references/gates.md +22 -1
- package/skills/tech-lead/schemas/domain.schema.json +5 -0
- package/skills/tech-lead/workflows/shapeup-run.js +44 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapeup-sdlc-plugin",
|
|
3
3
|
"displayName": "ShapeUp SDLC Plugin",
|
|
4
|
-
"version": "3.
|
|
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
|
-
|
|
|
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
|
|
225
|
-
|
|
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
|
|
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
|
|
package/hooks/sandbox-guard.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
package/kernel/compile.mjs
CHANGED
|
@@ -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":
|
package/kernel/harness.mjs
CHANGED
|
@@ -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 ·
|
|
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" },
|
package/kernel/lib/contract.mjs
CHANGED
|
@@ -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
|
|
@@ -440,7 +443,34 @@ export function parseContract(md, spec = SCOPE_CONTRACT) {
|
|
|
440
443
|
const unreadable = [...(meta[UNREADABLE] || [])];
|
|
441
444
|
delete out[UNREADABLE];
|
|
442
445
|
for (const [field, heading] of Object.entries(spec.tables || {})) {
|
|
443
|
-
|
|
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
|
+
}
|
|
444
474
|
// The field is absent — but is it absent because nobody declared it, or because the
|
|
445
475
|
// author declared it somewhere this parser does not look? Those are opposite facts and the
|
|
446
476
|
// old code returned the same thing for both. A table carrying this field's signature columns,
|
|
@@ -534,6 +564,14 @@ export function unreadableReason(contract) {
|
|
|
534
564
|
return `\`${x.field}\` was written as a \`## ${x.field}\` markdown section, which this dialect reads as prose — ` +
|
|
535
565
|
`it must be a FRONTMATTER key (a \`- \` block list or an inline [a, b] list), so as written the field parsed as ABSENT`;
|
|
536
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
|
+
}
|
|
537
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`;
|
|
538
576
|
})
|
|
539
577
|
.join("; ");
|
|
@@ -652,6 +690,35 @@ export function ucId(ref) {
|
|
|
652
690
|
return String(ref ?? "").trim().replace(/^\[\[|\]\]$/g, "").replace(/^usecases\//, "").replace(/\.md$/, "").trim();
|
|
653
691
|
}
|
|
654
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
|
+
|
|
655
722
|
/**
|
|
656
723
|
* The tasks on a LOCAL board that belong to a scope, joined through the COMMITTED spec.
|
|
657
724
|
*
|