shapeup-sdlc 1.6.3 → 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 (118) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +54 -107
  4. package/README.md +165 -151
  5. package/SECURITY.md +49 -27
  6. package/bin/init.mjs +93 -108
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +20 -0
  9. package/commands/eval.md +5 -4
  10. package/commands/scopes.md +5 -4
  11. package/commands/shape.md +1 -1
  12. package/commands/ship.md +53 -7
  13. package/commands/wire.md +1 -1
  14. package/hooks/dispatch-receipt.mjs +195 -0
  15. package/hooks/gate-intake.mjs +16 -17
  16. package/hooks/gate-zerowork.mjs +107 -25
  17. package/hooks/hooks.json +9 -48
  18. package/hooks/lib/decision.mjs +38 -19
  19. package/hooks/safety-spine.mjs +4 -4
  20. package/hooks/sandbox-guard.mjs +130 -50
  21. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
  22. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
  23. package/kernel/harness.mjs +134 -0
  24. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
  25. package/kernel/init/run.mjs +489 -0
  26. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  27. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
  28. package/kernel/lib/paths.mjs +491 -0
  29. package/kernel/probe/concurrency.mjs +510 -0
  30. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
  31. package/kernel/probe/eval.mjs +77 -0
  32. package/kernel/probe/leg.mjs +125 -0
  33. package/kernel/probe/resume.mjs +528 -0
  34. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
  35. package/kernel/probe/t0.mjs +66 -0
  36. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
  37. package/kernel/reduce/graph.mjs +437 -0
  38. package/kernel/reduce/hill.mjs +152 -0
  39. package/kernel/reduce/ingest.mjs +633 -0
  40. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  41. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
  42. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
  43. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
  44. package/kernel/report/export.mjs +325 -0
  45. package/kernel/report/facts.mjs +347 -0
  46. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
  47. package/kernel/verify/dispatch.mjs +114 -0
  48. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  49. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  50. package/kernel/verify/skills.mjs +125 -0
  51. package/kernel/verify/spec.mjs +559 -0
  52. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
  53. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
  54. package/oracles/_shared.mjs +1 -1
  55. package/oracles/http-oracle.mjs +2 -2
  56. package/oracles/index.mjs +1 -1
  57. package/oracles/process-oracle.mjs +2 -2
  58. package/oracles/snapshot-oracle.mjs +2 -2
  59. package/oracles/test-oracle.mjs +2 -2
  60. package/package.json +11 -13
  61. package/skills/ba-pitch-analyzer/SKILL.md +24 -19
  62. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  63. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  64. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  65. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  66. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  67. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  68. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  69. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  70. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  71. package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
  72. package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
  73. package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
  74. package/skills/coach/SKILL.md +3 -3
  75. package/skills/orient/SKILL.md +2 -1
  76. package/skills/qa-edge-hunter/SKILL.md +15 -41
  77. package/skills/scope-architect/SKILL.md +57 -18
  78. package/skills/scope-hammer/SKILL.md +5 -5
  79. package/skills/shapeup/SKILL.md +3 -3
  80. package/skills/shapeup/resources/context-compaction.md +4 -3
  81. package/skills/solution-architect/SKILL.md +37 -15
  82. package/skills/spec-evaluator/SKILL.md +24 -7
  83. package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
  84. package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
  85. package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
  86. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
  87. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
  88. package/skills/spec-evaluator/references/probing.md +42 -23
  89. package/skills/spec-evaluator/references/report-schema.md +2 -2
  90. package/skills/task-executor/SKILL.md +32 -18
  91. package/skills/tech-lead/SKILL.md +127 -438
  92. package/skills/tech-lead/references/gates.md +140 -49
  93. package/skills/tech-lead/references/protocol.md +832 -0
  94. package/skills/tech-lead/schemas/domain.schema.json +645 -198
  95. package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
  96. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  97. package/skills/tech-lead/schemas/work-result.schema.json +56 -18
  98. package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
  99. package/skills/translator/SKILL.md +2 -2
  100. package/hooks/anti-rationalization.mjs +0 -244
  101. package/hooks/compact-snapshot.mjs +0 -47
  102. package/hooks/gate-deadline.mjs +0 -151
  103. package/hooks/gate-l2.mjs +0 -161
  104. package/hooks/session-rehydrate.mjs +0 -109
  105. package/skills/advisor-protocol/SKILL.md +0 -171
  106. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  107. package/skills/spec-evaluator/README.md +0 -93
  108. package/skills/tech-lead/README.md +0 -71
  109. package/skills/tech-lead/references/delegation.md +0 -254
  110. package/skills/tech-lead/references/invocation.md +0 -45
  111. package/skills/tech-lead/references/ledger-schema.md +0 -214
  112. package/skills/tech-lead/references/round-protocol.md +0 -184
  113. package/skills/tech-lead/references/state-model.md +0 -66
  114. package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
  115. package/skills/tech-lead/scripts/init-run.mjs +0 -326
  116. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
  117. package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
  118. package/skills/translator/README.md +0 -66
@@ -6,43 +6,50 @@
6
6
  // input, every gate in this repo answered the same way:
7
7
  //
8
8
  // $ echo 'NOT JSON AT ALL {{{' | node hooks/<gate>.mjs
9
- // gate-l2 exit=0 stdout_len=0
10
9
  // gate-zerowork exit=0 stdout_len=0
11
10
  // sandbox-guard exit=0 stdout_len=0
12
11
  // safety-spine exit=0 stdout_len=0
13
- // gate-deadline exit=0 stdout_len=0
14
12
  // gate-intake exit=0 stdout_len=0
15
- // validate-envelope exit=0 stdout_len=0
13
+ // verify envelope exit=0 stdout_len=0
16
14
  //
17
15
  // exit 0 + silence = allow. But that is ALSO what "inspected the board and deferred" looks like,
18
- // and what "no rule matched" looks like, and what a thrown exception looks like, and what F-16
19
- // looked like — a hook whose entire body silently never ran. Four states, one signature. No test,
20
- // orchestrator or auditor could tell them apart, which is how 26 enforcement points sat inert
21
- // behind 610 green checks while every one of them reported success.
16
+ // and what "no rule matched" looks like, and what a thrown exception looks like, and what an inert
17
+ // hook looks like — one whose entire body silently never ran. Four states, one signature. No test,
18
+ // orchestrator or auditor can tell them apart, which is how a whole enforcement layer can sit
19
+ // inert while every one of its checks reports success.
22
20
  //
23
- // FAIL-OPEN IS RETAINED, DELIBERATELY. `gate-l2.mjs` argues for it correctly in its own header: a
24
- // gate that breaks legitimate or standalone runs just gets disabled, and a disabled gate enforces
21
+ // FAIL-OPEN IS RETAINED, DELIBERATELY, and every hook here argues for it in its own header: a gate
22
+ // that breaks legitimate or standalone runs just gets disabled, and a disabled gate enforces
25
23
  // nothing. The defect was never the direction. It is that `allow` carried NO EVIDENCE.
26
24
  //
27
- // THE PREDICATE IS ALREADY INVENTED IN THIS REPO. `tests/structural/11-is-main.mjs` calls its
28
- // helper `spoke()` — did the script produce output? That is exactly the right question. It existed
29
- // only in the test harness, applied to entry points. This promotes it to runtime and applies it to
30
- // hooks, which closes F-16's whole CLASS rather than its instance:
25
+ // THE PREDICATE IS ALREADY INVENTED IN THIS REPO. The structural suite calls its helper `spoke()`
26
+ // — did the script produce output? That is exactly the right question. It existed only in the test
27
+ // harness, applied to entry points. This promotes it to runtime and applies it to
28
+ // hooks, which closes the whole CLASS rather than one instance of it:
31
29
  //
32
30
  // inspected-and-permitted · no-rule-matched · threw · never ran
33
31
  //
34
- // all four become distinguishable facts in `.shapeup-sdlc/decisions.jsonl`.
32
+ // all four become distinguishable facts in the decisions ledger.
35
33
  //
36
34
  // TIER: LOCAL, and checkout-wide rather than per-slug — hooks fire outside any run, so there is
37
35
  // frequently no `<slug>` to file under. Pure run-trace; the durable cross-machine record is the
38
36
  // committed metrics shard, which `stats --hooks` aggregates into.
39
37
  //
38
+ // THE PATH IS RESOLVED, NEVER SPELLED. It comes from `lib/paths.mjs` — the same resolver
39
+ // ``harness probe stats` --hooks` reads through. This file used to hardcode the pre-ADR-0001 root, so every
40
+ // hook wrote its receipts to `.shapeup-sdlc/` while the only reader looked in `.shapeup/`:
41
+ // `stats --hooks` reported zero hook activity on every project, which is indistinguishable from
42
+ // the inert-enforcement-layer failure this file exists to make visible. A telemetry channel with
43
+ // a hardcoded root is a telemetry channel with a silent disconnect in it.
44
+ //
40
45
  // THE RECEIPT IS BEST-EFFORT BY DESIGN. An unwritable `decisions.jsonl` must never turn into a
41
46
  // failed tool call: a receipt that can break a run would get the whole layer disabled, which is
42
47
  // the exact outcome this file exists to prevent. Every write here is inside a try/catch.
43
48
 
44
49
  import { appendFileSync, mkdirSync } from "node:fs";
45
- import { join, dirname } from "node:path";
50
+ import { dirname } from "node:path";
51
+ import { decisions } from "../../kernel/lib/paths.mjs";
52
+ import { resolveRunId } from "../../kernel/lib/paths.mjs";
46
53
 
47
54
  /**
48
55
  * Where the receipts land.
@@ -54,11 +61,11 @@ import { join, dirname } from "node:path";
54
61
  * contaminates is not an instrument.
55
62
  *
56
63
  * @param {string} [cwd] - Project root; defaults to the process cwd.
57
- * @returns {string} The ledger path — `SHAPEUP_DECISIONS_PATH` when set, else
58
- * `<cwd>/.shapeup-sdlc/decisions.jsonl`.
64
+ * @returns {string} The ledger path — `SHAPEUP_DECISIONS_PATH` when set, else the LOCAL root's
65
+ * `decisions.jsonl`, resolved through `lib/paths.mjs`.
59
66
  */
60
67
  export function decisionsPath(cwd) {
61
- return process.env.SHAPEUP_DECISIONS_PATH || join(cwd || process.cwd(), ".shapeup-sdlc", "decisions.jsonl");
68
+ return process.env.SHAPEUP_DECISIONS_PATH || decisions(cwd || process.cwd());
62
69
  }
63
70
 
64
71
  /**
@@ -160,6 +167,18 @@ export async function runHook(name, fn) {
160
167
  at: new Date().toISOString(),
161
168
  hook: name,
162
169
  pid: process.pid,
170
+ // WHICH RUN THIS DECISION BELONGS TO — resolved from the active-scope pointer, best-effort.
171
+ //
172
+ // The ledger is checkout-wide by design (a hook frequently fires with no `<slug>` to file
173
+ // under), which is exactly why the row needs the key: without it, "the enforcement layer denied
174
+ // 4 writes" cannot be attributed to a run, so a denial rate cannot be compared between runs and
175
+ // an inert layer in ONE run is invisible inside a healthy checkout-wide total.
176
+ //
177
+ // `null` is a real answer, not a failure: a hook firing outside any run genuinely belongs to no
178
+ // run, and recording that is what lets the export tier partition ambient decisions from run
179
+ // ones. Resolution reads two small files and swallows every error — a receipt must never be
180
+ // able to fail a tool call.
181
+ run_id: (() => { try { return resolveRunId(d.cwd || process.cwd()); } catch { return null; } })(),
163
182
  event: d.event ?? null,
164
183
  tool: d.tool ?? null,
165
184
  subject: d.subject ?? null,
@@ -168,7 +187,7 @@ export async function runHook(name, fn) {
168
187
  rule: d.rule ?? null,
169
188
  }, d.cwd);
170
189
  // Deny/block/warn payloads are emitted by definition. `emit: true` covers the hooks whose whole
171
- // job is to SAY something on an allow — session-rehydrate's additionalContext, for instance — so
190
+ // job is to SAY something on an allow — an injected context hint, for instance — so
172
191
  // a permitting hook can still write to stdout without pretending to be a denial.
173
192
  //
174
193
  // WHY `warn` IS ITS OWN VERDICT (ADR-0001). An advisory gate permits the call, so the obvious
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Safety spine — PreToolUse hook (v1.2, absorb-audit P1).
2
+ // Safety spine — PreToolUse hook (v1.2).
3
3
  //
4
4
  // The three older hooks guard the PIPELINE (gate order, envelopes, substrates); this one
5
5
  // guards the MACHINE and the git remote — the substrate everything else sits on. It denies
@@ -17,7 +17,7 @@
17
17
  // the model must never widen its own safety envelope (same principle
18
18
  // as sandbox-guard's active-scope pointer rule)
19
19
  //
20
- // Deliberately conservative, mirrors gate-l2/sandbox-guard: fail-OPEN on unparseable input
20
+ // Deliberately conservative, mirrors the GATE L2 block/sandbox-guard: fail-OPEN on unparseable input
21
21
  // or an unmatched command (precision over reach — a spine that broke `rm -rf ./build` would
22
22
  // just get disabled), fail-CLOSED the moment a rule provably matches.
23
23
  //
@@ -33,8 +33,8 @@
33
33
  import { readFileSync, existsSync } from "node:fs";
34
34
  import { resolve, join, basename } from "node:path";
35
35
  import { globToRegExp, logPathology } from "./sandbox-guard.mjs";
36
- import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
37
- import { LOCAL, safetyOverrides, metricsShard } from "../skills/tech-lead/scripts/lib/paths.mjs";
36
+ import { isMain } from "../kernel/lib/argv.mjs";
37
+ import { LOCAL, safetyOverrides, metricsShard } from "../kernel/lib/paths.mjs";
38
38
 
39
39
  import { runHook, readStdin, settle } from "./lib/decision.mjs";
40
40
 
@@ -1,36 +1,59 @@
1
1
  #!/usr/bin/env node
2
- // Sandbox guard — PreToolUse hook (design spec v1.1 §4.5/Blueprint E, PA3 countermeasure).
2
+ // Sandbox guard — PreToolUse hook (the PA3 countermeasure: writes outside the order's substrate).
3
3
  //
4
- // Blocks Edit/Write/MultiEdit calls that touch a file outside the active scope's
5
- // `allowed_file_substrate` (+ declared `shared_substrate`). Turns "generator only edits its
6
- // own scope" from prose into a precondition the model cannot talk past — the same pattern as
7
- // hooks/gate-l2.mjs for GATE L2.
4
+ // Blocks Edit/Write/MultiEdit calls that no LIVE ORDER's `substrate` block permits. Turns "a worker
5
+ // only writes what the run authorised" from prose into a precondition the model cannot talk past.
8
6
  //
9
- // Design (deliberately conservative, mirrors gate-l2.mjs):
10
- // Fail-OPEN whenever there is nothing to enforce: no active-scope pointer (not running
11
- // inside a scoped harness round), pointer names a scope contract that doesn't exist or is
12
- // unparsable, or the tool call carries no resolvable file path. A guard that breaks
13
- // legitimate non-harness edits would just get disabled.
14
- // Fail-CLOSED the moment an active scope IS declared and the target path matches none of
15
- // its globs deny, naming the substrate so the model can self-correct.
7
+ // IT ENFORCES THE ORDER, NOT THE SCOPE CONTRACT, and that is the whole design. ``harness compile``
8
+ // already stamps a write contract onto every order from `substrateFor(operation)` — allowed,
9
+ // shared, append_only, frozen. Resolving the scope contract instead covered exactly one operation,
10
+ // the build, because only build orders carry a scope; every other dispatch (`analyze`, `wire`,
11
+ // `evaluate`, `hunt`, `coach` …) ran unfenced, and the `frozen`/`append_only` surfaces the
12
+ // compiler emits had no enforcer at all. Reading the order makes the contract the compiler writes
13
+ // and the contract the hook enforces the same object, for every operation, with no per-operation
14
+ // code here.
15
+ //
16
+ // IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` still names the run and
17
+ // seeds the search — `harness compile` publishes it as it writes each order, which is what fences
18
+ // the lanes that never reach the workflow (`--tiny`, the prose round loop, a standalone `/build`).
19
+ // But a single pointer cannot survive concurrency: with scopes building side by side the last
20
+ // compile wins the pointer, and a write from scope A is then judged against scope B's contract —
21
+ // a false block or a false permit depending on which way the race fell. So the candidate set is
22
+ // every order under `orders/` with no matching file under `results/` (compiled, not yet ingested),
23
+ // and a write is permitted when SOME live contract covers it.
24
+ //
25
+ // That is the same question as "the writer's own contract" because scope substrates are disjoint by
26
+ // construction — `harness verify spec`'s DISJOINT rule fails a spec where two scopes claim the same
27
+ // path, and it runs at GATE L1b before any build starts. `frozen` is checked across all of them, so
28
+ // a path one scope froze stays frozen while another scope is in flight.
29
+ //
30
+ // Design (deliberately conservative, mirrors the GATE L2 block):
31
+ // • Fail-OPEN whenever there is nothing to enforce: no active-order pointer (not running inside
32
+ // a harness dispatch), pointer names an order that doesn't exist or is unparsable, the order
33
+ // declares no boundaries at all, or the tool call carries no resolvable file path. A guard
34
+ // that breaks legitimate non-harness edits would just get disabled.
35
+ // • Fail-CLOSED the moment an order IS live and the target is outside what it permits — deny,
36
+ // naming the reason so the model can self-correct. `frozen` outranks everything, including
37
+ // an `allowed` glob that would otherwise match; `append_only` permits Edit and denies Write,
38
+ // because Write overwrites what the append was supposed to preserve.
16
39
  // • Run-trace carve-out — writes under the ACTIVE feature's LOCAL gitignored root
17
40
  // (`.shapeup/<slug>/`) are always allowed: that root is harness bookkeeping the doer
18
41
  // is REQUIRED to write (task-executor P3 status/AC ticks + tasks/_index.md, run-state,
19
42
  // execution logs, the P3.7 discovery ledger). Substrate globs whitelist product code and
20
- // never list the run-trace, so without the carve-out every scoped round strands its own
21
- // board (island-escape shipped 16/20 task files stale this way). Deliberately narrow:
22
- // only the active slug's root `.shapeup/active-scope` (this guard's own pointer)
23
- // and other features' roots remain subject to the substrate whitelist.
43
+ // never list the run-trace, so without the carve-out a scoped round leaves its own task files
44
+ // stale. Deliberately narrow: only the active slug's root. The pointers at the `.shapeup/`
45
+ // root `active-order` (this guard's own) and `active-scope` (the run pointer) — sit OUTSIDE
46
+ // the carve-out by construction, so a worker cannot widen its own sandbox by rewriting the
47
+ // thing that defines it.
24
48
  // • Every denial is also appended to the metrics pathology log (telemetry, not just defense).
25
49
  //
26
50
  // Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
27
51
  // Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
28
52
 
29
- import { readFileSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
53
+ import { readFileSync, existsSync, appendFileSync, mkdirSync, readdirSync } from "node:fs";
30
54
  import { resolve, join, relative, dirname, sep } from "node:path";
31
- import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
32
- import { LOCAL, activeScope, scopeContract, metricsShard } from "../skills/tech-lead/scripts/lib/paths.mjs";
33
- import { readContract, SCOPE_CONTRACT } from "../skills/tech-lead/scripts/lib/contract-md.mjs";
55
+ import { isMain } from "../kernel/lib/argv.mjs";
56
+ import { LOCAL, activeOrder, ordersDir, resultsDir, metricsShard } from "../kernel/lib/paths.mjs";
34
57
  import { runHook, readStdin, settle } from "./lib/decision.mjs";
35
58
 
36
59
  // --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
@@ -65,6 +88,31 @@ function readJSON(p) {
65
88
  try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
66
89
  }
67
90
 
91
+ /**
92
+ * Every order for this run that has been compiled and not yet ingested.
93
+ *
94
+ * "Not yet ingested" is read off the filesystem — an order with a same-named file under `results/`
95
+ * has finished — because that is the only signal that survives a killed session. The pointer's own
96
+ * order is always included, even when its result has landed, so the single-order lane behaves
97
+ * exactly as it did before concurrency existed.
98
+ *
99
+ * @param {string} cwd - Project root.
100
+ * @param {string} slug - The active run's slug.
101
+ * @param {string} pointerOrder - Absolute path of the order the pointer names.
102
+ * @returns {object[]} Parsed orders; unreadable files are skipped, never treated as permissive.
103
+ */
104
+ function liveOrders(cwd, slug, pointerOrder) {
105
+ const dir = ordersDir(cwd, slug);
106
+ const done = new Set(existsSync(resultsDir(cwd, slug)) ? readdirSync(resultsDir(cwd, slug)) : []);
107
+ const paths = new Set(existsSync(pointerOrder) ? [pointerOrder] : []);
108
+ if (existsSync(dir)) {
109
+ for (const f of readdirSync(dir)) {
110
+ if (f.endsWith(".json") && !done.has(f)) paths.add(join(dir, f));
111
+ }
112
+ }
113
+ return [...paths].map(readJSON).filter(Boolean);
114
+ }
115
+
68
116
  function extractPaths(toolInput) {
69
117
  const paths = [];
70
118
  if (toolInput?.file_path) paths.push(toolInput.file_path);
@@ -97,46 +145,78 @@ async function main() {
97
145
  }
98
146
 
99
147
  const cwd = p.cwd || process.cwd();
100
- const activeScopePath = activeScope(cwd);
101
- // no harness round in progress don't enforce
102
- if (!existsSync(activeScopePath)) defer("no active-scope pointer — no harness round in progress", "no-round");
103
-
104
- const active = readJSON(activeScopePath);
105
- if (!active?.slug || !active?.scope_id) defer("active-scope pointer is unreadable or incomplete", "bad-pointer");
106
-
107
- // Markdown first, legacy JSON second (ADR-0001) — a project mid-migration must stay sandboxed.
108
- let found = null;
109
- try { found = readContract(scopeContract(cwd, active.slug, active.scope_id), SCOPE_CONTRACT); }
110
- catch (e) { defer(`scope contract is unparseable (${e.message})`, "bad-contract"); }
111
- // pointer stale / contract not committed yet don't break the run
112
- if (!found) defer(`no contract for ${active.scope_id} — pointer stale or not committed yet`, "no-contract");
113
- const contract = found.contract;
114
- if (!contract) defer("scope contract is unparseable", "bad-contract");
115
-
116
- const allowed = [...(contract.allowed_file_substrate || []), ...(contract.shared_substrate || [])];
117
- // no whitelist declared nothing to enforce
118
- if (allowed.length === 0) defer(`scope ${active.scope_id} declares no write whitelist`, "no-whitelist");
148
+ const activeOrderPath = activeOrder(cwd);
149
+ if (!existsSync(activeOrderPath)) defer("no active-order pointer no tracked task running", "no-round");
150
+
151
+ const active = readJSON(activeOrderPath);
152
+ if (!active?.slug || !active?.order_path) defer("active-order pointer is unreadable or incomplete", "bad-pointer");
153
+
154
+ // EVERY LIVE ORDER, not just the pointer's. The pointer names one order, and with scopes building
155
+ // CONCURRENTLY the last compile wins itso a write from scope A would be judged against scope
156
+ // B's contract, which is either a false block or a false permit depending on which way the race
157
+ // fell. Scope substrates are disjoint by construction (spec-lint's DISJOINT rule fails a spec
158
+ // where they are not), so "is this write inside SOME live contract" and "is it inside the
159
+ // writer's own contract" are the same question and only the first can be asked without a
160
+ // shared mutable pointer.
161
+ //
162
+ // Live = compiled and not yet ingested. An order whose result is on disk has finished; leaving it
163
+ // in the candidate set would keep a finished scope's substrate open for the rest of the run.
164
+ const orders = liveOrders(cwd, active.slug, resolve(cwd, active.order_path));
165
+ if (orders.length === 0) defer(`no live order for ${active.slug}`, "no-order");
166
+
167
+ const withSubstrate = orders.filter((o) => o.substrate);
168
+ if (withSubstrate.length === 0) defer("no live order declares a substrate block", "no-substrate");
169
+
170
+ const contracts = withSubstrate.map((o) => ({
171
+ order_id: o.order_id,
172
+ allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
173
+ appendOnly: o.substrate.append_only || [],
174
+ frozen: o.substrate.frozen || [],
175
+ })).filter((c) => c.allowed.length || c.appendOnly.length || c.frozen.length);
176
+
177
+ if (contracts.length === 0) defer("no live order declares write/append/frozen boundaries", "no-whitelist");
119
178
 
120
179
  const targetPaths = extractPaths(p.tool_input);
121
180
  if (targetPaths.length === 0) defer("no writable path in the tool input", "no-target");
122
181
 
123
182
  const metricsPath = metricsShard(cwd);
124
- // Run-trace carve-out (see header): the active feature's LOCAL root only. The prefix ends
125
- // with a separator so a sibling `<local>/<slug>-other/` can't ride along, and the active-scope
126
- // pointer sits outside it by construction.
127
183
  const runTracePrefix = join(LOCAL, active.slug) + sep;
128
184
  const violations = [];
185
+ const blockReasons = [];
186
+
129
187
  for (const raw of targetPaths) {
130
188
  const abs = resolve(cwd, raw);
131
189
  const rel = relative(cwd, abs);
132
190
  if (rel.startsWith(runTracePrefix)) continue;
133
- if (!matchesAny(rel, allowed)) violations.push(rel);
191
+
192
+ // Frozen takes absolute precedence, and it is checked across EVERY live contract: a path one
193
+ // scope froze stays frozen while another scope is in flight, which is the whole point of
194
+ // declaring it.
195
+ const freezer = contracts.find((c) => matchesAny(rel, c.frozen));
196
+ if (freezer) {
197
+ violations.push(rel);
198
+ blockReasons.push(`${rel} is frozen by ${freezer.order_id}`);
199
+ continue;
200
+ }
201
+
202
+ if (contracts.some((c) => matchesAny(rel, c.allowed))) continue; // inside a live contract
203
+
204
+ if (contracts.some((c) => matchesAny(rel, c.appendOnly))) {
205
+ if (p.tool_name === "Write") {
206
+ violations.push(rel);
207
+ blockReasons.push(`${rel} is append-only (Write overwrites, use Edit)`);
208
+ }
209
+ continue;
210
+ }
211
+
212
+ violations.push(rel);
213
+ blockReasons.push(`${rel} is outside every live order's allowed scopes`);
134
214
  }
135
215
 
136
216
  // Inside the substrate — the "inspected and permitted" row. Previously byte-identical to
137
217
  // "this hook never ran", which is how 26 enforcement points sat inert behind 610 green checks.
138
218
  if (violations.length === 0) {
139
- defer(`${targetPaths.length} path(s) inside scope ${active.scope_id} substrate — permitted`, "in-substrate");
219
+ defer(`${targetPaths.length} path(s) inside a live order's substrate (${contracts.length} live) — permitted`, "in-substrate");
140
220
  }
141
221
 
142
222
  logPathology(metricsPath, {
@@ -144,23 +224,23 @@ async function main() {
144
224
  at: new Date().toISOString(),
145
225
  kind: "pathology",
146
226
  pathology: "PA3",
147
- scope_id: active.scope_id,
227
+ order: active.order_path,
148
228
  slug: active.slug,
149
229
  blocked_paths: violations,
150
230
  });
151
231
 
152
232
  return {
153
- verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.scope_id, cwd,
233
+ verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: active.order_path, cwd,
154
234
  rule: "outside-substrate",
155
- reason: `${violations.length} write(s) outside the scope substrate: ${violations.join(", ")}`,
235
+ reason: `${violations.length} write(s) rejected by substrate boundaries: ${blockReasons.join("; ")}`,
156
236
  payload: {
157
237
  hookSpecificOutput: {
158
238
  hookEventName: "PreToolUse",
159
239
  permissionDecision: "deny",
160
240
  permissionDecisionReason:
161
- `Sandbox guard (PA3) — scope "${active.scope_id}" may only write ${JSON.stringify(allowed)}. ` +
162
- `Blocked: ${violations.join(", ")}. If this write legitimately crosses scopes, add the path to ` +
163
- `the contract's shared_substrate (via ba --remap) rather than editing outside the substrate.`,
241
+ `Sandbox guard (PA3) — no live order's substrate covers these writes:\n` +
242
+ `${blockReasons.join("\n")}\n` +
243
+ `If this write legitimately crosses scopes, the order's substrate needs to be expanded (e.g. via ba --remap).`,
164
244
  },
165
245
  },
166
246
  };