shapeup-sdlc 3.18.2 → 4.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 (141) hide show
  1. package/.claude/settings.local.example.json +2 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +115 -56
  4. package/README.md +101 -143
  5. package/SECURITY.md +16 -25
  6. package/agents/orient.md +14 -0
  7. package/agents/qa-edge-hunter.md +13 -0
  8. package/agents/spec-evaluator.md +14 -0
  9. package/agents/task-executor.md +14 -0
  10. package/bin/init.mjs +13 -16
  11. package/bin/lib/grant.mjs +8 -19
  12. package/commands/build.md +9 -14
  13. package/commands/eval.md +3 -3
  14. package/commands/hammer.md +9 -5
  15. package/commands/retro.md +12 -11
  16. package/commands/scopes.md +1 -12
  17. package/commands/ship.md +45 -88
  18. package/commands/wire.md +1 -8
  19. package/hooks/dispatch-receipt.mjs +94 -8
  20. package/hooks/hooks.json +0 -41
  21. package/hooks/lib/decision.mjs +6 -9
  22. package/hooks/lib/tier.mjs +50 -0
  23. package/hooks/sandbox-guard.mjs +140 -64
  24. package/kernel/gate.mjs +203 -22
  25. package/kernel/harness.mjs +20 -67
  26. package/kernel/init/run.mjs +175 -523
  27. package/kernel/lib/contract.mjs +9 -93
  28. package/kernel/lib/floor.mjs +64 -0
  29. package/kernel/lib/paths.mjs +39 -112
  30. package/kernel/order.mjs +157 -0
  31. package/kernel/probe/digest.mjs +2 -38
  32. package/kernel/schemas/domain.schema.json +146 -1125
  33. package/kernel/schemas/work-order.schema.json +8 -4
  34. package/kernel/schemas/work-result.schema.json +1 -1
  35. package/kernel/verify/envelope.mjs +2 -2
  36. package/kernel/verify/skills.mjs +27 -8
  37. package/kernel/verify/t0.mjs +2 -2
  38. package/package.json +4 -2
  39. package/skills/building/SKILL.md +144 -0
  40. package/skills/building/resources/gates.md +76 -0
  41. package/skills/building/resources/guidance.md +23 -0
  42. package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
  43. package/skills/building/resources/intake.md +157 -0
  44. package/skills/building/resources/orders.md +69 -0
  45. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
  46. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
  47. package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
  48. package/skills/building/resources/plan-scopes.md +95 -0
  49. package/skills/building/resources/plan-spec.md +102 -0
  50. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
  51. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
  52. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
  53. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
  54. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
  55. package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
  56. package/skills/building/resources/plan-wire.md +82 -0
  57. package/skills/building/resources/plan.md +98 -0
  58. package/skills/building/resources/retro-research.md +60 -0
  59. package/skills/building/resources/retro-scan.md +49 -0
  60. package/skills/building/resources/retro.md +225 -0
  61. package/skills/building/resources/rounds.md +78 -0
  62. package/skills/building/resources/stop.md +127 -0
  63. package/skills/orient/SKILL.md +37 -37
  64. package/skills/qa-edge-hunter/SKILL.md +29 -14
  65. package/skills/shapeup/SKILL.md +5 -5
  66. package/skills/shapeup/resources/breadboarding.md +1 -1
  67. package/skills/shapeup/resources/context-compaction.md +5 -5
  68. package/skills/shapeup/resources/shaping.md +1 -1
  69. package/skills/spec-evaluator/SKILL.md +17 -21
  70. package/skills/spec-evaluator/references/dimension-contract.md +1 -1
  71. package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
  72. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
  73. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
  74. package/skills/task-executor/SKILL.md +11 -15
  75. package/commands/hill.md +0 -10
  76. package/hooks/gate-intake.mjs +0 -109
  77. package/hooks/gate-zerowork.mjs +0 -349
  78. package/hooks/tier-guard.mjs +0 -162
  79. package/kernel/compile.mjs +0 -1419
  80. package/kernel/init/fit.mjs +0 -199
  81. package/kernel/init/run-args.mjs +0 -206
  82. package/kernel/lib/breadboard.mjs +0 -165
  83. package/kernel/probe/attempts.mjs +0 -161
  84. package/kernel/probe/concurrency.mjs +0 -535
  85. package/kernel/probe/eval.mjs +0 -405
  86. package/kernel/probe/hunt.mjs +0 -104
  87. package/kernel/probe/leg.mjs +0 -229
  88. package/kernel/probe/owner.mjs +0 -142
  89. package/kernel/probe/requirements.mjs +0 -328
  90. package/kernel/probe/resume.mjs +0 -1110
  91. package/kernel/probe/rounds.mjs +0 -115
  92. package/kernel/probe/staged.mjs +0 -83
  93. package/kernel/probe/stats.mjs +0 -477
  94. package/kernel/probe/t0.mjs +0 -115
  95. package/kernel/reduce/board.mjs +0 -350
  96. package/kernel/reduce/graph.mjs +0 -482
  97. package/kernel/reduce/hill.mjs +0 -322
  98. package/kernel/reduce/ingest.mjs +0 -767
  99. package/kernel/reduce/leftovers.mjs +0 -159
  100. package/kernel/reduce/ship.mjs +0 -529
  101. package/kernel/reduce/snapshot.mjs +0 -291
  102. package/kernel/reduce/verdict.mjs +0 -172
  103. package/kernel/report/export.mjs +0 -384
  104. package/kernel/report/facts.mjs +0 -234
  105. package/kernel/verify/budget.mjs +0 -153
  106. package/kernel/verify/build.mjs +0 -428
  107. package/kernel/verify/dispatch.mjs +0 -114
  108. package/kernel/verify/spec.mjs +0 -975
  109. package/kernel/verify/trace.mjs +0 -604
  110. package/skills/ba-pitch-analyzer/SKILL.md +0 -204
  111. package/skills/coach/SKILL.md +0 -397
  112. package/skills/hill-chart/SKILL.md +0 -154
  113. package/skills/hill-chart/assets/dashboard.template.html +0 -1432
  114. package/skills/scope-architect/SKILL.md +0 -174
  115. package/skills/scope-hammer/SKILL.md +0 -231
  116. package/skills/solution-architect/SKILL.md +0 -158
  117. package/skills/tech-lead/SKILL.md +0 -151
  118. package/skills/tech-lead/references/gates.md +0 -605
  119. package/skills/tech-lead/references/protocol.md +0 -889
  120. package/skills/tech-lead/references/tiny-lane.md +0 -53
  121. package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
  122. package/skills/translator/SKILL.md +0 -258
  123. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
  124. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
  125. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
  126. /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
  127. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
  128. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
  129. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
  130. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
  131. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
  132. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
  133. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
  134. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
  135. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
  136. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
  137. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
  138. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
  139. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
  140. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
  141. /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/hooks/hooks.json CHANGED
@@ -1,15 +1,5 @@
1
1
  {
2
2
  "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "echo \"shapeup-sdlc-plugin loaded from ${CLAUDE_PLUGIN_ROOT}\""
9
- }
10
- ]
11
- }
12
- ],
13
3
  "PreToolUse": [
14
4
  {
15
5
  "matcher": "Bash|Read|Write|Edit|MultiEdit",
@@ -21,16 +11,6 @@
21
11
  }
22
12
  ]
23
13
  },
24
- {
25
- "matcher": "Skill",
26
- "hooks": [
27
- {
28
- "type": "command",
29
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-intake.mjs\"",
30
- "timeout": 10
31
- }
32
- ]
33
- },
34
14
  {
35
15
  "matcher": "Skill|Agent",
36
16
  "hooks": [
@@ -50,16 +30,6 @@
50
30
  "timeout": 10
51
31
  }
52
32
  ]
53
- },
54
- {
55
- "matcher": "Edit|Write|MultiEdit",
56
- "hooks": [
57
- {
58
- "type": "command",
59
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tier-guard.mjs\"",
60
- "timeout": 10
61
- }
62
- ]
63
33
  }
64
34
  ],
65
35
  "PostToolUse": [
@@ -73,17 +43,6 @@
73
43
  }
74
44
  ]
75
45
  }
76
- ],
77
- "Stop": [
78
- {
79
- "hooks": [
80
- {
81
- "type": "command",
82
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-zerowork.mjs\"",
83
- "timeout": 10
84
- }
85
- ]
86
- }
87
46
  ]
88
47
  }
89
48
  }
@@ -6,10 +6,8 @@
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-zerowork exit=0 stdout_len=0
10
9
  // sandbox-guard exit=0 stdout_len=0
11
10
  // safety-spine exit=0 stdout_len=0
12
- // gate-intake exit=0 stdout_len=0
13
11
  // verify envelope exit=0 stdout_len=0
14
12
  //
15
13
  // exit 0 + silence = allow. But that is ALSO what "inspected the board and deferred" looks like,
@@ -32,13 +30,12 @@
32
30
  // all four become distinguishable facts in the decisions ledger.
33
31
  //
34
32
  // TIER: LOCAL, and checkout-wide rather than per-slug — hooks fire outside any run, so there is
35
- // frequently no `<slug>` to file under. Pure run-trace; the durable cross-machine record is the
36
- // committed metrics shard, which `stats --hooks` aggregates into.
33
+ // frequently no `<slug>` to file under. Pure run-trace: one JSON object per line, read directly.
37
34
  //
38
35
  // 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
36
+ // every reader goes through. This file used to hardcode the earlier root, so every
40
37
  // 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
38
+ // the ledger showed zero hook activity on every project, which is indistinguishable from
42
39
  // the inert-enforcement-layer failure this file exists to make visible. A telemetry channel with
43
40
  // a hardcoded root is a telemetry channel with a silent disconnect in it.
44
41
  //
@@ -97,7 +94,7 @@ export function projectRoot(cwd) {
97
94
  *
98
95
  * `SHAPEUP_DECISIONS_PATH` redirects the ledger. It exists because this project's OWN test suite
99
96
  * executes the real hooks, and without a redirect every `npm test` appended ~21 rows to the
100
- * developer's live `decisions.jsonl` — which would then be read back by `stats --hooks` as if
97
+ * developer's live `decisions.jsonl` — which would then be read back as if
101
98
  * they were evaluations from a real run. A measurement instrument that its own test suite
102
99
  * contaminates is not an instrument.
103
100
  *
@@ -241,11 +238,11 @@ export async function runHook(name, fn) {
241
238
  // job is to SAY something on an allow — an injected context hint, for instance — so
242
239
  // a permitting hook can still write to stdout without pretending to be a denial.
243
240
  //
244
- // WHY `warn` IS ITS OWN VERDICT (ADR-0001). An advisory gate permits the call, so the obvious
241
+ // WHY `warn` IS ITS OWN VERDICT. An advisory gate permits the call, so the obvious
245
242
  // encoding is `verdict: "allow"`. That would make "permitted because the rule was satisfied"
246
243
  // byte-identical to "permitted DESPITE the rule being broken" — the exact indistinguishability
247
244
  // this file exists to eliminate, reintroduced one level up. A gate that downgrades from deny to
248
- // advisory must stay countable, or `stats --hooks` silently loses the measurement.
245
+ // advisory must stay countable, or the ledger silently loses the measurement.
249
246
  if (d.payload && (d.verdict === "deny" || d.verdict === "block" || d.verdict === "warn" || d.emit)) {
250
247
  process.stdout.write(JSON.stringify(d.payload));
251
248
  }
@@ -0,0 +1,50 @@
1
+ // The tier-direction rule, on a string: a committed file cannot carry a reference that only resolves
2
+ // on the machine that wrote it. Two forms — a board id (`TASK-NNN`, renumbered per machine) and a path
3
+ // into the gitignored run tier. The substrate fence calls this at the moment a tool writes the text,
4
+ // while the writer still holds the context to rephrase it.
5
+
6
+ import { LOCAL } from "../../kernel/lib/paths.mjs";
7
+
8
+ /** Text forms worth scanning; anything else in a committed tree is not a reference carrier. */
9
+ export const SCANNED = /\.(md|markdown|yml|yaml|json|txt)$/i;
10
+
11
+ /** A machine-local board id. Strict on purpose: a committed tree has no reason to carry one at all. */
12
+ const TASK_ID = /\bTASK-[A-Za-z0-9][\w.-]*/;
13
+
14
+ /**
15
+ * The tier-direction violations one piece of text carries — the whole rule, on a string.
16
+ *
17
+ * The detail strings are the message the writer reads, so they carry the remedy, not just the
18
+ * verdict: a board id resolves on the machine that wrote it and nowhere else, and a path into the
19
+ * gitignored tier dangles on every clone.
20
+ *
21
+ * @param {string} text - The file body, or the fragment a tool is about to write.
22
+ * @returns {Array<{kind:("board-id"|"local-path"), token:string, line:number, detail:string}>}
23
+ * One entry per offending line and form, in file order; [] when clean.
24
+ */
25
+ export function tierLeaks(text) {
26
+ // Built from the LOCAL constant, never a literal — the storage roots have exactly one home.
27
+ const esc = LOCAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ // `\S+` where the walk's own rule is `\S`: the same LINES match either way (a path with one
29
+ // non-space character after the slash has at least one), and the longer form yields the token to
30
+ // quote back at the writer. Trailing punctuation the prose wrapped it in is trimmed off the
31
+ // quote only — never off the test.
32
+ const localPath = new RegExp(`${esc}/\\S+`);
33
+ const leaks = [];
34
+ String(text ?? "").split(/\r?\n/).forEach((line, i) => {
35
+ const task = line.match(TASK_ID);
36
+ if (task) {
37
+ leaks.push({ kind: "board-id", token: task[0], line: i + 1, detail:
38
+ `names ${task[0]} — a committed file cannot carry a board id. Boards live in ${LOCAL}/ ` +
39
+ "(gitignored) and renumber on every regeneration, so this resolves on the machine that wrote it " +
40
+ "and nowhere else. Cite the use case or the scope_id, which are stable." });
41
+ }
42
+ const path = line.match(localPath);
43
+ if (path) {
44
+ leaks.push({ kind: "local-path", token: path[0].replace(/[`)\]},.;:'"]+$/, ""), line: i + 1, detail:
45
+ `points into ${LOCAL}/ — a committed file cannot reference the gitignored tier; the path ` +
46
+ "dangles on every other clone. Name the committed artifact, or describe the tier without a path." });
47
+ }
48
+ });
49
+ return leaks;
50
+ }
@@ -1,68 +1,50 @@
1
1
  #!/usr/bin/env node
2
- // Sandbox guard — PreToolUse hook (the PA3 countermeasure: writes outside the order's substrate).
2
+ // Sandbox guard — PreToolUse hook: writes outside the live order's substrate are refused.
3
3
  //
4
4
  // Blocks Edit/Write/MultiEdit calls that no LIVE ORDER's `substrate` block permits. Turns "a worker
5
5
  // only writes what the run authorised" from prose into a precondition the model cannot talk past.
6
+ // It also refuses, at the write, a committed-tier file whose text names a run-trace path or a board id
7
+ // (`committedTierLeaks` below) — the tier-direction rule, asked where the writer can still rephrase.
6
8
  //
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.
9
+ // IT ENFORCES THE ORDER, NOT THE SCOPE CONTRACT. The loop stamps a write contract onto every order
10
+ // it writes — allowed, shared, append_only, frozen, own — and reading the order makes that contract
11
+ // and the one this hook enforces the same object, for every operation, with no per-operation code
12
+ // here. Resolving the scope contract instead would cover only the build, because only build orders
13
+ // carry a scope.
15
14
  //
16
- // IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` names the run and
17
- // nothing more — `harness compile` publishes it as it writes each order, which is what fences the
18
- // lanes that never reach the workflow (`--tiny`, the prose round loop, a standalone `/build`).
19
- // A single pointer cannot survive concurrency: with scopes building side by side the last compile
20
- // wins the pointer, and a write from scope A would be judged against scope B's contract — a false
21
- // block or a false permit depending on which way the race fell. So the candidate set is every
22
- // order under `orders/` that is not yet ANSWERED, and a write is permitted when SOME live contract
23
- // covers it.
15
+ // IT READS EVERY LIVE ORDER, NOT A POINTER TO ONE. `.shapeup/active-order` names the run and nothing
16
+ // more; the loop sets it with `harness order open` and `harness order close` retires it. A single
17
+ // pointer cannot survive concurrency: with scopes building side by side the last order opened wins
18
+ // it, and a write from scope A would be judged against scope B's contract — a false block or a false
19
+ // permit depending on which way the race fell. So the candidate set is every order under `orders/`
20
+ // that is not yet ANSWERED, and a write is permitted when SOME live contract covers it.
24
21
  //
25
- // THE POINTER IS NOT A LIVENESS SIGNAL, and it used to be one: the order it names was counted live
26
- // unconditionally, "so the single-order lane behaves as it did before concurrency existed". The arm
27
- // bought nothing — an order that is genuinely in flight has no result yet and is already live by
28
- // the rule below — and it cost the checkout permanently. The pointer has one writer and no eraser,
29
- // so the LAST dispatch of a FINISHED run stayed live for good and fenced everything to that one
30
- // substrate: after a ship, an ordinary edit anywhere in the repo was denied, and the next feature
31
- // could not write even its own run trace, because the carve-out below is keyed to the slug the
32
- // stale pointer names. The documented fail-open state ("no pointer — not inside a dispatch") became
33
- // unreachable after the first run, and the only way out was to delete a file nothing documents.
34
- // The arm is gone; `reduce ship` and ``harness init run --force`` retire the pointer as well, so a
35
- // leftover one is untidy rather than load-bearing.
22
+ // THE POINTER IS NOT A LIVENESS SIGNAL. It arms the fence — with no pointer every write is allowed —
23
+ // but liveness is "written, no result yet", read off the order set, so a run that ended and retired
24
+ // its pointer fences nothing and a leftover pointer is untidy rather than load-bearing.
36
25
  //
37
- // ANSWERED IS A COMPARISON, NOT A PRESENCE TEST, and the difference is a hole the removal above
38
- // would otherwise open. Order filenames for the run-level operations carry no round (`hammer.json`,
39
- // `wire.json`, `analyze.json`), so re-dispatching one inside the same run rewrites the order beside
40
- // the PREVIOUS dispatch's result — and a presence test reads that as finished and runs the new
41
- // dispatch unfenced. An order counts as answered only when its result file is at least as new as
42
- // the order's own `compiled_at`, the stamp the compiler writes INTO the order, which a copy or a
43
- // touch cannot perturb. An order carrying no stamp falls back to presence, which is all it ever had.
26
+ // ANSWERED IS A COMPARISON, NOT A PRESENCE TEST. Order filenames for the run-level operations carry
27
+ // no round, so re-dispatching one inside the same run rewrites the order beside the PREVIOUS
28
+ // dispatch's result — and a presence test would read that as finished and run the new dispatch
29
+ // unfenced. An order counts as answered only when its result file is at least as new as the order's
30
+ // own `compiled_at`, the stamp the loop writes INTO the order, which a copy or a touch cannot
31
+ // perturb. An order carrying no stamp falls back to presence.
44
32
  //
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
33
+ // A RUN-LEVEL ORDER RETIRES AT ITS PHASE BOUNDARY. A phase dispatch whose worker never returned — a
34
+ // killed evaluation, a QA leg that escalated without a result — would otherwise stay live for the rest
35
+ // of the run and fence everything its `frozen` list names. The next phase writing its own order is
36
+ // the signal: an order for a RUN-LEVEL operation stops being live once an order for a DIFFERENT
37
+ // operation has been written after it. Build legs are exempt: they run concurrently, finish out of
38
+ // order, and an abandoned one is resolved by `harness init run --force`. Two concurrent legs of the
39
+ // SAME operation never retire each other. An order carrying no operation or no `compiled_at` keeps
58
40
  // fencing — an unreadable claim is not a retired one.
59
41
  //
60
- // That is the same question as "the writer's own contract" because scope substrates are disjoint by
61
- // construction — `harness verify spec`'s DISJOINT rule fails a spec where two scopes claim the same
62
- // path, and it runs at GATE L1b before any build starts. `frozen` is checked across all of them, so
63
- // a path one scope froze stays frozen while another scope is in flight.
42
+ // "The writer's own contract" and "some live contract" are the same question because scope
43
+ // substrates are disjoint by construction: the loop reads the contracts for overlap before it
44
+ // releases scopes together. `frozen` is checked across all of them, so a path one scope froze stays
45
+ // frozen while another scope is in flight.
64
46
  //
65
- // Design (deliberately conservative, mirrors the GATE L2 block):
47
+ // Design (deliberately conservative):
66
48
  // • Fail-OPEN whenever there is nothing to enforce: no active-order pointer (not running inside
67
49
  // a harness dispatch), pointer names an order that doesn't exist or is unparsable, the order
68
50
  // declares no boundaries at all, or the tool call carries no resolvable file path. A guard
@@ -72,14 +54,11 @@
72
54
  // an `allowed` glob that would otherwise match; `append_only` permits Edit and denies Write,
73
55
  // because Write overwrites what the append was supposed to preserve.
74
56
  // • Run-trace carve-out — writes under the ACTIVE feature's LOCAL gitignored root
75
- // (`.shapeup/<slug>/`) are always allowed: that root is harness bookkeeping the doer
76
- // is REQUIRED to write (task-executor P3 status/AC ticks + tasks/_index.md, run-state,
77
- // execution logs, the P3.7 discovery ledger). Substrate globs whitelist product code and
78
- // never list the run-trace, so without the carve-out a scoped round leaves its own task files
79
- // stale. Deliberately narrow: only the active slug's root. The pointers at the `.shapeup/`
80
- // root — `active-order` (this guard's own) and `active-scope` (the run pointer) — sit OUTSIDE
81
- // the carve-out by construction, so a worker cannot widen its own sandbox by rewriting the
82
- // thing that defines it.
57
+ // (`.shapeup/<slug>/`) are allowed unless a live order freezes them: that root is bookkeeping
58
+ // the doer is REQUIRED to write. Deliberately narrow: only the active slug's root. The pointers
59
+ // at the `.shapeup/` root — `active-order` (this guard's own) and `active-scope` (the run
60
+ // pointer) — sit OUTSIDE the carve-out by construction, so a worker cannot widen its own
61
+ // sandbox by rewriting the thing that defines it.
83
62
  // • Every denial is also appended to the metrics pathology log (telemetry, not just defense).
84
63
  //
85
64
  // Contract: PreToolUse stdin JSON { tool_name, tool_input:{file_path | edits[].file_path}, cwd }.
@@ -90,6 +69,7 @@ import { resolve, join, relative, dirname, basename, sep } from "node:path";
90
69
  import { isMain } from "../kernel/lib/argv.mjs";
91
70
  import { LOCAL, SHARED, activeOrder, ordersDir, resultsDir, metricsShard, dispatchReceipts } from "../kernel/lib/paths.mjs";
92
71
  import { runHook, readStdin, settle, projectRoot } from "./lib/decision.mjs";
72
+ import { SCANNED, tierLeaks } from "./lib/tier.mjs";
93
73
 
94
74
  // --- tiny glob matcher: supports *, **, ? — enough for substrate globs, zero dependencies ---
95
75
  export function globToRegExp(glob) {
@@ -313,6 +293,77 @@ export function logPathology(metricsPath, event) {
313
293
  } catch { /* telemetry is best-effort; never block on a logging failure */ }
314
294
  }
315
295
 
296
+ /** Committed-tier trees that hold instructions rather than references — outside the lint's walk. */
297
+ const NOT_A_SLUG = new Set(["knowledge-base"]);
298
+
299
+ /**
300
+ * The (path, content) pairs one write tool call is about to commit to disk.
301
+ *
302
+ * THE THREE WRITE TOOLS SPELL CONTENT THREE WAYS, and a check that reads only `content` is blind to
303
+ * the Edit that adds the same line — which is the form the measured recurrence took, since the file
304
+ * already existed by then.
305
+ *
306
+ * @param {object} toolInput - The PreToolUse `tool_input` block.
307
+ * @returns {Array<{path:string, content:string}>} Pairs with a usable path and string content.
308
+ */
309
+ export function extractWrites(toolInput) {
310
+ const out = [];
311
+ const base = toolInput?.file_path;
312
+ if (typeof toolInput?.content === "string" && base) out.push({ path: base, content: toolInput.content });
313
+ if (typeof toolInput?.new_string === "string" && base) out.push({ path: base, content: toolInput.new_string });
314
+ if (Array.isArray(toolInput?.edits)) {
315
+ for (const e of toolInput.edits) {
316
+ const p = e?.file_path || base;
317
+ if (p && typeof e?.new_string === "string") out.push({ path: p, content: e.new_string });
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * Is this path inside the committed tier the lint walks — `shapeup/<slug>/…`, scanned form?
325
+ *
326
+ * @param {string} rel - Path relative to the project root, as the lint would name it.
327
+ * @returns {boolean} True when a leak here is a leak the lint would red.
328
+ */
329
+ export function inCommittedTier(rel) {
330
+ if (!rel || rel.startsWith("..") || rel.startsWith(sep)) return false;
331
+ const parts = rel.split(/[\\/]/);
332
+ // `shapeup/<slug>/<file>` — the lint walks a slug's tree, so a file at the tier root belongs to
333
+ // no run and is left alone, and `knowledge-base` is a sibling of the slugs, not one of them.
334
+ if (parts[0] !== SHARED || parts.length < 3 || NOT_A_SLUG.has(parts[1])) return false;
335
+ return SCANNED.test(rel);
336
+ }
337
+
338
+ /**
339
+ * The committed tier's write boundary: the tier-direction leaks this call would commit.
340
+ *
341
+ * It is the tier-direction rule, asked at the write — the one
342
+ * moment the writer still knows what it meant to say, because a worker carries no lesson across a
343
+ * dispatch and the lint reds the same file a phase later. The predicate is the lint's own scanner
344
+ * (`tierLeaks`), imported and never restated, so this cannot become narrower or wider than the rule.
345
+ * `shapeup/knowledge-base/` is outside, exactly as it is outside the lint's walk. Fail-OPEN on
346
+ * everything it cannot positively prove: no path, no content, a path outside `shapeup/<slug>/`, a
347
+ * file form the lint does not scan.
348
+ *
349
+ * @param {object} toolInput - The PreToolUse `tool_input` block.
350
+ * @param {string} cwd - The shell's working directory, against which a raw tool path resolves.
351
+ * @param {string} root - The project root the committed tier lives under.
352
+ * @returns {{rel:string, blocked:string[]}} The first in-tier path and one line per leak; `blocked`
353
+ * is empty when the write carries none.
354
+ */
355
+ export function committedTierLeaks(toolInput, cwd, root) {
356
+ const inTier = extractWrites(toolInput)
357
+ .map((w) => ({ ...w, rel: relative(root, resolve(cwd, w.path)) }))
358
+ .filter((w) => inCommittedTier(w.rel));
359
+ const blocked = [];
360
+ // THE TOKEN IS QUOTED BACK, which the lint's own message does not need to do — it reports against a
361
+ // file on disk the reader can open at that line. Here the line does not exist yet, so naming the
362
+ // exact string is the difference between a denial that is acted on and one that is retried verbatim.
363
+ for (const w of inTier) for (const leak of tierLeaks(w.content)) blocked.push(`${w.rel}:${leak.line} — \`${leak.token}\` ${leak.detail}`);
364
+ return { rel: inTier[0]?.rel ?? "", blocked };
365
+ }
366
+
316
367
  async function main() {
317
368
  await runHook("sandbox-guard", async () => {
318
369
  const raw = await readStdin();
@@ -336,6 +387,31 @@ async function main() {
336
387
  // the shell's own cwd, because that is what a relative path in the tool input means.
337
388
  const cwd = p.cwd || process.cwd();
338
389
  const root = projectRoot(cwd);
390
+
391
+ // THE COMMITTED TIER FIRST, and before every "no live order" exit below: the tier-direction rule
392
+ // holds with no run in flight, and a write that is clean here falls through to the substrate fence.
393
+ const tier = committedTierLeaks(p.tool_input, cwd, root);
394
+ if (tier.blocked.length) {
395
+ return {
396
+ verdict: "deny", event: "PreToolUse", tool: p.tool_name, subject: tier.rel, cwd: root,
397
+ rule: "tier-direction",
398
+ reason: `${tier.blocked.length} tier-direction leak(s) refused at the write boundary: ${tier.blocked.join("; ")}`,
399
+ payload: {
400
+ hookSpecificOutput: {
401
+ hookEventName: "PreToolUse",
402
+ permissionDecision: "deny",
403
+ permissionDecisionReason:
404
+ "Tier guard (TIER-DIRECTION) — this write would put a reference into the committed tier that " +
405
+ "cannot survive the trip to another machine:\n" +
406
+ `${tier.blocked.join("\n")}\n` +
407
+ "Refused here, while the writer still knows what it meant to say. " +
408
+ "Rephrase the line now: cite the committed artifact, the use case or the scope_id, or describe " +
409
+ "the tier without naming a path.",
410
+ },
411
+ },
412
+ };
413
+ }
414
+
339
415
  const activeOrderPath = activeOrder(root);
340
416
  if (!existsSync(activeOrderPath)) defer("no active-order pointer — no tracked task running", "no-round");
341
417
 
@@ -343,9 +419,9 @@ async function main() {
343
419
  if (!active?.slug || !active?.order_path) defer("active-order pointer is unreadable or incomplete", "bad-pointer");
344
420
 
345
421
  // EVERY LIVE ORDER, not just the pointer's. The pointer names one order, and with scopes building
346
- // CONCURRENTLY the last compile wins it — so a write from scope A would be judged against scope
422
+ // CONCURRENTLY the last order opened wins it — so a write from scope A would be judged against scope
347
423
  // B's contract, which is either a false block or a false permit depending on which way the race
348
- // fell. Scope substrates are disjoint by construction (spec-lint's DISJOINT rule fails a spec
424
+ // fell. Scope substrates are disjoint by construction (the loop's planning read-back fails a spec
349
425
  // where they are not), so "is this write inside SOME live contract" and "is it inside the
350
426
  // writer's own contract" are the same question — and only the first can be asked without a
351
427
  // shared mutable pointer.
@@ -364,7 +440,7 @@ async function main() {
364
440
  allowed: [...(o.substrate.allowed || []), ...(o.substrate.shared || [])],
365
441
  appendOnly: o.substrate.append_only || [],
366
442
  frozen: o.substrate.frozen || [],
367
- // The one exception to "frozen outranks everything", and it is the compiler's to grant, never
443
+ // The one exception to "frozen outranks everything", and it is the loop's to grant, never
368
444
  // the worker's to request: the paths THIS order authors, named from its own identity. A build
369
445
  // leg's substrate freezes the whole run trace, so without this its own WorkResult — its
370
446
  // documented last step — would be denied along with every channel it must not touch.