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
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // Dispatch receipt — PostToolUse hook. The attestation that the SHIPPED skill ran.
3
+ //
4
+ // WHAT IT DEFENDS AGAINST, measured live rather than imagined. A worker dispatch that fails —
5
+ // plugin absent, disabled, or the wrong version loaded — comes back as `<tool_use_error>Unknown
6
+ // skill</tool_use_error>`, and the sub-agent then does the craft ITSELF from the prose in its own
7
+ // prompt. Everything downstream accepts the result: the artifacts are on disk, in exactly the right
8
+ // place, so the phase post-condition passes and the run advances. Both existing walls fire correctly
9
+ // and neither can help — the order gate validates the ORDER, the sandbox guard validates WHERE
10
+ // writes land, and an improvised write satisfies both. Nothing in the system attested WHICH SKILL
11
+ // produced an artifact, so a green run was consistent with zero shipped craft having been applied.
12
+ // That is not a failure, it is a FALSE GREEN, and it defeats "measured, not claimed" at the root:
13
+ // the measurement was "is the artifact on disk", which cannot distinguish skill-produced from
14
+ // improvised.
15
+ //
16
+ // WHY PostToolUse AND NOT PreToolUse. `PreToolUse` fires BEFORE the tool runs, so it cannot separate
17
+ // "the Skill returned" from "the Skill errored and the sub-agent improvised". It appears to catch the
18
+ // observed instance only by coupling — the plugin was not loaded, so these hooks were not registered
19
+ // either, so there was no receipt. The moment the environment is repaired, a dispatch that errors
20
+ // still gets a `PreToolUse` receipt written before it fails, and the fix expires exactly when the
21
+ // thing it guards starts working.
22
+ //
23
+ // WHAT WAS MEASURED, in a real session with the plugin loaded and a SUB-AGENT making the calls
24
+ // (every dispatch in this pipeline is a `Skill(...)` call made by a workflow leg, never by the main
25
+ // session, so a hook that is blind there is blind exactly where it is needed):
26
+ //
27
+ // skill resolves and completes → PostToolUse fires, tool_response = {success, commandName}
28
+ // skill name unknown → NO hook fires at all; the host rejects the name upstream
29
+ // order missing → gate denies → PreToolUse deny, then nothing; a denied call never runs
30
+ //
31
+ // So the discriminator is stronger than a status field: A FAILED DISPATCH LEAVES NO ROW. The mere
32
+ // existence of a matching receipt is the wall; `dispatch_ok` is corroboration on top of it, recorded
33
+ // because a future host that reports failures through this event must not be able to satisfy the
34
+ // wall by firing at all.
35
+ //
36
+ // WHAT IT WILL NOT ATTEST. A receipt is written only when the tool result NAMES the skill that ran
37
+ // (`tool_response.commandName`). An `Agent` dispatch whose prompt merely mentions `--order` gets no
38
+ // receipt: the prompt is a plan to dispatch, not evidence of one, and minting attestation from it
39
+ // would forge the exact fact this file exists to establish.
40
+ //
41
+ // EVERY WRITE IS INSIDE try/catch AND THIS HOOK NEVER DENIES (`lib/decision.mjs`). An unguarded
42
+ // `writeFileSync` in a hook body becomes `verdict:"error"` → exit 0 → the dispatch proceeds WITHOUT
43
+ // a receipt, and the phase then dies at ingest instead — a receipt channel that can break a run
44
+ // gets the whole layer disabled, which is the outcome this file exists to prevent.
45
+ //
46
+ // Contract: PostToolUse stdin JSON { tool_name, tool_input:{skill,args}, tool_response, cwd }.
47
+
48
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
49
+ import { resolve, dirname } from "node:path";
50
+ import { isMain } from "../kernel/lib/argv.mjs";
51
+ import { dispatchReceipts } from "../kernel/lib/paths.mjs";
52
+ import { runHook, readStdin, settle } from "./lib/decision.mjs";
53
+
54
+ /**
55
+ * The `--order` matcher, character-for-character the one the PreToolUse order gate uses
56
+ * (`kernel/verify/envelope.mjs`). Two dispatch hooks that disagree about what an order path IS
57
+ * would gate one set of calls and attest another.
58
+ */
59
+ export const ORDER_RE = /--order(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/;
60
+
61
+ /**
62
+ * Pull the order path out of a dispatch's tool input.
63
+ * @param {object} toolInput - The `tool_input` block from the hook payload.
64
+ * @returns {string|null} The path as written, or null when no `--order` was threaded.
65
+ */
66
+ export function orderPathFrom(toolInput) {
67
+ const haystack = [toolInput?.skill_args, toolInput?.args, toolInput?.prompt].filter(Boolean).join(" ");
68
+ const m = haystack.match(ORDER_RE);
69
+ return m ? (m[1] || m[2] || m[3]) : null;
70
+ }
71
+
72
+ /**
73
+ * Which skill actually ran, from the tool result.
74
+ *
75
+ * The host reports it NAMESPACED (`shapeup-sdlc-plugin:orient`) while a WorkOrder's `worker` field
76
+ * carries the bare skill name (`orient`), so the comparison the ingest gate performs is only
77
+ * meaningful against the segment after the last `:`.
78
+ *
79
+ * @param {object} toolResponse - The `tool_response` block from the hook payload.
80
+ * @returns {string|null} The bare skill name, or null when the result names no command — which is
81
+ * the case for every dispatch that did not resolve to a skill.
82
+ */
83
+ export function skillInvokedFrom(toolResponse) {
84
+ const name = toolResponse?.commandName;
85
+ if (typeof name !== "string" || !name) return null;
86
+ return name.slice(name.lastIndexOf(":") + 1) || null;
87
+ }
88
+
89
+ /**
90
+ * Build the receipt row for one dispatch.
91
+ *
92
+ * Exported so the suite can assert the row's SHAPE without a live session — the hook body is
93
+ * otherwise reachable only through stdin.
94
+ *
95
+ * @param {object} order - The parsed WorkOrder the dispatch was aimed by.
96
+ * @param {string} skillInvoked - The bare skill name the host reported running.
97
+ * @param {object} payload - The whole hook payload, for the sub-agent provenance fields.
98
+ * @returns {object} The row appended to the run's dispatch ledger.
99
+ */
100
+ export function receiptRow(order, skillInvoked, payload) {
101
+ return {
102
+ at: new Date().toISOString(),
103
+ order_id: order.order_id ?? null,
104
+ run_id: order.run_id ?? null,
105
+ worker_declared: order.worker ?? null,
106
+ skill_invoked: skillInvoked,
107
+ // Corroboration, not the wall — see the header. `success` is absent on hosts that do not
108
+ // report one, and an absent field must not read as a successful dispatch.
109
+ dispatch_ok: payload?.tool_response?.success === true,
110
+ tool: payload?.tool_name ?? null,
111
+ // Present when the dispatch came from a sub-agent, absent when the operator called it directly.
112
+ // That is the difference between an orchestrated leg and someone driving a skill by hand.
113
+ agent_id: payload?.agent_id ?? null,
114
+ agent_type: payload?.agent_type ?? null,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Append one receipt to the run's dispatch ledger. Never throws.
120
+ * @param {object} row - A {@link receiptRow}.
121
+ * @param {string} cwd - Project root the LOCAL root resolves against.
122
+ * @returns {string|null} The ledger path when the row reached disk, else null.
123
+ */
124
+ export function writeReceipt(row, cwd) {
125
+ try {
126
+ const slug = String(row.order_id).split("/")[0];
127
+ if (!slug) return null;
128
+ const path = dispatchReceipts(cwd, slug);
129
+ mkdirSync(dirname(path), { recursive: true });
130
+ appendFileSync(path, JSON.stringify(row) + "\n");
131
+ return path;
132
+ } catch { return null; }
133
+ }
134
+
135
+ /**
136
+ * The hook body — attest a completed dispatch, defer on everything else.
137
+ * @returns {Promise<void>} Settles through {@link runHook}, which always exits 0.
138
+ */
139
+ export async function main() {
140
+ await runHook("dispatch-receipt", async () => {
141
+ /**
142
+ * Permit with the reason on the record. This hook has no deny path at all.
143
+ *
144
+ * THE SKILL NAME TRAVELS ON EVERY ROW, including the defers, and that is load-bearing rather
145
+ * than decorative. A dispatch whose skill name does not resolve fires no hook at all — measured,
146
+ * not assumed — so a row naming a skill is itself proof that THIS SESSION resolved THAT skill.
147
+ * That is the one question a file-existence check cannot answer, and `verify dispatch` answers
148
+ * it by reading these rows. Without the name here, the evidence exists and is unattributable.
149
+ *
150
+ * @param {string} reason - Why no receipt was written.
151
+ * @param {string} [rule] - Which defer condition matched.
152
+ * @returns {never} Does not return — settles the hook.
153
+ */
154
+ const defer = (reason, rule) => settle({
155
+ verdict: "allow", event: "PostToolUse", tool: p?.tool_name ?? null, cwd: p?.cwd, reason, rule,
156
+ subject: p?.tool_input?.skill ?? null,
157
+ });
158
+ const raw = await readStdin();
159
+ let p;
160
+ try { p = JSON.parse(raw || "{}"); }
161
+ catch (e) { settle({ verdict: "error", event: "PostToolUse", reason: `unparseable payload: ${e.message}` }); }
162
+
163
+ if (p.tool_name !== "Skill" && p.tool_name !== "Agent") {
164
+ defer(`${p.tool_name ?? "no tool_name"} is not a dispatch tool — out of scope`, "not-a-dispatch");
165
+ }
166
+ const cited = orderPathFrom(p.tool_input);
167
+ if (!cited) defer("no --order threaded — not an orchestrated dispatch", "no-order");
168
+
169
+ const skillInvoked = skillInvokedFrom(p.tool_response);
170
+ if (!skillInvoked) {
171
+ // The `Agent` case, and any host result that does not name what ran. Attesting here would
172
+ // mint the fact rather than record it.
173
+ defer("dispatch result names no resolved skill — nothing to attest", "no-skill-named");
174
+ }
175
+
176
+ const cwd = p.cwd || process.cwd();
177
+ const orderPath = resolve(cwd, cited);
178
+ let order;
179
+ try { order = JSON.parse(readFileSync(orderPath, "utf8")); }
180
+ catch (e) { defer(`order not readable (${e.message}) — cannot attest a dispatch it cannot identify`, "order-unreadable"); }
181
+ if (!order?.order_id) defer("order carries no order_id — nothing to key a receipt by", "order-unkeyed");
182
+
183
+ const row = receiptRow(order, skillInvoked, p);
184
+ const written = writeReceipt(row, cwd);
185
+ return {
186
+ verdict: "allow", event: "PostToolUse", tool: p.tool_name, cwd, subject: row.order_id,
187
+ rule: written ? "receipt-written" : "receipt-write-failed",
188
+ reason: written
189
+ ? `dispatch receipt: ${row.order_id} ran ${skillInvoked} (declared ${row.worker_declared}, ok=${row.dispatch_ok})`
190
+ : `dispatch receipt could not be written for ${row.order_id} — ingest will refuse this result unless --no-receipt-check`,
191
+ };
192
+ });
193
+ }
194
+
195
+ if (isMain(import.meta.url)) main();
@@ -3,23 +3,21 @@
3
3
  //
4
4
  // Denies a `tech-lead` dispatch that carries no pitch, no spec folder, and no requirement text.
5
5
  //
6
- // WHY THIS EXISTS (measured, not theorized). On the SDD harness benchmark
7
- // (`sdd-harness-bench`, feature F2, Haiku 4.5, n=3, zero variance) the orchestrator was reached as
6
+ // WHY THIS EXISTS (reproduced, not theorized). The orchestrator was reached as
8
7
  //
9
8
  // Skill(tech-lead, args: "--unattended")
10
9
  //
11
10
  // — the flag survived the hand-off, the requirement text did not. With nothing to orchestrate,
12
- // the run printed eleven gate names, a confident plan, and wrote no code, scoring 29% against a
13
- // hidden acceptance suite while *looking* like a successful run. The same harness invoked with
14
- // --pitch/--spec scored 100%, n=3. A stronger model happened to inline the text and recover; a
15
- // cheaper one did not, three times out of three.
11
+ // the run printed the gate names, a confident plan, and wrote no code, while *looking* like a
12
+ // successful run. The same harness invoked with --pitch/--spec built the feature. Whether a model
13
+ // happens to inline the text and recover is a property of the model, not of the harness.
16
14
  //
17
15
  // That is the "agent claims done" failure this project exists to prevent, happening at the
18
16
  // project's own front door. Every other invariant that matters here lives in the runtime; this one
19
17
  // was living in a prompt, and a prompt is exactly what gets dropped on a hand-off. So it becomes a
20
18
  // hook, like GATE L2.
21
19
  //
22
- // Design mirrors gate-l2.mjs deliberately:
20
+ // Design mirrors the GATE L2 block deliberately:
23
21
  // • Scope — only `Skill` → tech-lead. Anything else defers instantly.
24
22
  // • Fail-CLOSED only on a provably empty intake, with an actionable re-invocation in the reason.
25
23
  // • Fail-OPEN on anything ambiguous (unparseable payload, unknown arg shape, an envelope
@@ -29,10 +27,10 @@
29
27
  // Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name|skill, skill_args|args}, ... }
30
28
  // Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
31
29
 
32
- // RECEIPTS (v1.5). This gate was scored `No effect` built, verified on 10 cases, and the
33
- // benchmark re-run still 4/14 — because the hook CORRECTLY never fired and the cause was elsewhere.
34
- // That distinction was unprovable from a hook that answers "inspected and permitted" and "never
35
- // ran" with the same silence. Every decision below is now recorded (hooks/lib/decision.mjs).
30
+ // RECEIPTS (v1.5). This gate has been scored `No effect` on a re-run where the acceptance rate did
31
+ // not move — because the hook CORRECTLY never fired and the cause was elsewhere. That distinction
32
+ // is unprovable from a hook that answers "inspected and permitted" and "never ran" with the same
33
+ // silence. Every decision below is now recorded (hooks/lib/decision.mjs).
36
34
 
37
35
  import { runHook, readStdin, settle } from "./lib/decision.mjs";
38
36
 
@@ -57,7 +55,7 @@ const args = String(p.tool_input?.skill_args ?? p.tool_input?.args ?? "");
57
55
  const skill = String(skillRaw).split(":").pop();
58
56
  if (skill !== "tech-lead") defer(`Skill(${skill}) is not the orchestrator — out of scope`);
59
57
 
60
- // The envelope port supplies its own intake; validate-envelope.mjs owns that path.
58
+ // The envelope port supplies its own intake; `harness verify envelope` owns that path.
61
59
  if (/--order\b/.test(args)) defer("envelope dispatch — validate-envelope owns this path", "--order");
62
60
 
63
61
  // Intake is satisfied by ANY of: a pitch path, a spec folder, or free requirement text.
@@ -68,13 +66,14 @@ const hasResume = /--from\s+\S/.test(args); // resuming an existing run has its
68
66
  // Free text = anything left once flags and their values are removed.
69
67
  // Every flag that TAKES A VALUE must be listed here. A flag whose value is not stripped reads as
70
68
  // free requirement text, and the gate then defers on an empty intake — the exact dispatch it
71
- // exists to deny. This is a live failure mode, not a hypothetical: adding `--gate-answers ci` and
72
- // `--wall-clock-budget 2400` silently blinded the gate, and the very next benchmark run reached
73
- // tech-lead as `args:"--unattended --gate-answers ci --wall-clock-budget 2400"` — no requirement
74
- // text at all — and was waved straight through, because "ci" and "2400" counted as the spec.
69
+ // exists to deny. This is a live failure mode, not a hypothetical: adding `--gate-answers` and
70
+ // `--wall-clock-budget` without listing them here silently blinded the gate, and the very next run
71
+ // reached tech-lead as `args:"--unattended --gate-answers ci --wall-clock-budget 2400"` — no
72
+ // requirement text at all — and was waved straight through, because "ci" and "2400" counted as
73
+ // the spec.
75
74
  // Adding a valued flag anywhere in the harness means adding it here, and structural test §39
76
75
  // enforces exactly that against commands/ship.md.
77
- const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order)\s+\S+/g;
76
+ const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|parallel-scopes|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order)\s+\S+/g;
78
77
  const BARE_FLAGS = /--[a-z0-9-]+/g;
79
78
  const freeText = args.replace(VALUED_FLAGS, " ").replace(BARE_FLAGS, " ").trim();
80
79
 
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // GATE Z — ZERO-WORK. Blocking Stop hook. The detector for "the harness described itself".
3
3
  //
4
- // WHY THIS EXISTS (measured, not theorized).
4
+ // WHY THIS EXISTS (reproduced, not theorized).
5
5
  //
6
- // SDD harness benchmark, F2, Haiku 4.5, n=5, zero variance. The orchestrator was dispatched
7
- // with a valid spec and returned this, in full:
6
+ // Dispatched with a valid spec, the orchestrator returned this, in full:
8
7
  //
9
8
  // TOOL Skill(tech-lead, "--unattended --rounds 3\n\n# F2 — category budgets…")
10
9
  // TEXT "The tech-lead skill is orchestrating the full Shape Up harness. It will: 1. …"
@@ -14,7 +13,7 @@
14
13
  // That is the "agent claims done" pathology this project exists to prevent, reproduced by the
15
14
  // project, at its own front door.
16
15
  //
17
- // THE TWO STRUCTURAL MISSES IT EXPOSED. `anti-rationalization.mjs` is the guard for exactly
16
+ // THE TWO STRUCTURAL MISSES IT EXPOSED. the ship report's census is the guard for exactly
18
17
  // this class of failure, and it could not see this one for two independent reasons:
19
18
  //
20
19
  // 1. SCOPE. It defers unless a run is active (`activeSlug()` → `.shapeup/<slug>/`).
@@ -30,10 +29,10 @@
30
29
  //
31
30
  // the session dispatched the orchestrator AND the run left no receipt
32
31
  //
33
- // `init-run.mjs` writes that receipt as the orchestrator's first tool call. Its absence is the
32
+ // ``harness init run`` writes that receipt as the orchestrator's first tool call. Its absence is the
34
33
  // fact. Nothing here parses intent, so nothing here can be talked past.
35
34
  //
36
- // WHY THIS ONE BLOCKS, WHEN anti-rationalization DOES NOT. The invariant is "QA is a level-up,
35
+ // WHY THIS ONE BLOCKS, WHEN the ship report's census DOES NOT. The invariant is "QA is a level-up,
37
36
  // not a gate" — no second judge behind `spec-evaluator`. That governs quality JUDGMENTS. This
38
37
  // hook makes no judgment: it reports that no work exists to judge. Blocking is also uniquely
39
38
  // safe here, because a session with no artifacts has nothing to lose by continuing, and a
@@ -50,25 +49,77 @@
50
49
  // SECOND CONDITION (v1.5). Since `hooks/lib/decision.mjs` gives every hook a receipt, this gate
51
50
  // gains a second, independent fact it can assert at `Stop`: the orchestrator was dispatched, and
52
51
  // `decisions.jsonl` holds ZERO rows for this pid — meaning the enforcement layer itself never ran.
53
- // That is F-16's class, not its instance: under a symlinked install every gate was inert while
54
- // every gate reported success. The detector for "the gates didn't run" now stops depending on the
55
- // gates running.
52
+ // That is a whole CLASS of failure, not one instance: under a symlinked install every gate can be
53
+ // inert while every gate reports success. The detector for "the gates didn't run" therefore stops
54
+ // depending on the gates running.
56
55
 
57
56
  import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
58
57
  import { join } from "node:path";
59
- import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
60
- import { localDir, globLocal } from "../skills/tech-lead/scripts/lib/paths.mjs";
58
+ import { isMain } from "../kernel/lib/argv.mjs";
59
+ import { localDir, globLocal } from "../kernel/lib/paths.mjs";
61
60
  import { runHook, readStdin, settle, decisionsPath } from "./lib/decision.mjs";
62
61
 
63
62
  const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024;
64
63
 
65
- /** Tool names that constitute doing something to the project, as opposed to looking at it. */
64
+ /**
65
+ * Tool names that constitute doing something to the project, as opposed to looking at it.
66
+ *
67
+ * The census these produce is now DESCRIPTIVE ONLY — it sharpens the block message and nothing
68
+ * decides on it. Until v1.7.1 a count above two was a fail-open ("the session did work by other
69
+ * means"), and it was retired for the reason spelled out at the `work-done` note below. `Workflow` stays absent because a launch is not work — it is the work this hook is
70
+ * asking about — and it is a DISPATCH signal instead (`dispatchedOrchestrator`).
71
+ */
66
72
  const WORK_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "Task", "Agent"]);
67
73
 
68
- /** Did this session dispatch the orchestrator? Skill(tech-lead) in any of its surface spellings. */
74
+ /**
75
+ * Is this block a launch of the orchestrator's own workflow script, by either surface?
76
+ *
77
+ * The shipped front door is `Workflow({scriptPath})`; `npx shapeup-sdlc init` writes the grant it
78
+ * needs. The Bash arm below is kept because a project that declined that grant, or one still on a
79
+ * v1 install, launches the same script through a Bash runtime — a gate that knew only one surface
80
+ * would go blind on the lane those users actually run, which is the same "the emptier the failure,
81
+ * the less of it there is to detect" hole the banner above describes.
82
+ *
83
+ * Matched on the BASENAME, anchored. `CLAUDE_PLUGIN_ROOT` itself ends in `shapeup-sdlc-plugin/` on
84
+ * a normal install — so a substring match on the whole path would count EVERY workflow script that
85
+ * happens to live under the plugin root, including one a user wrote for something else. The
86
+ * basename is the part the skill controls and the part `SKILL.md` names.
87
+ *
88
+ * @param {object} block - A `tool_use` content block.
89
+ * @returns {boolean} True when the block launches a `shapeup-*` workflow.
90
+ */
91
+ function launchedShapeupWorkflow(block) {
92
+ if (block.name === "Bash") {
93
+ const cmd = String(block.input?.command ?? "");
94
+ // Both halves required: a node launcher AND an orchestrator script. A launcher carrying
95
+ // somebody else's workflow is not a harness dispatch, and neither is a bare mention of the
96
+ // script in an unrelated command (`ls`, `cat`).
97
+ return /\bnode\b/.test(cmd) && /[\\/]shapeup-[\w.-]*\.[cm]?js\b/.test(cmd);
98
+ }
99
+ if (block.name !== "Workflow") return false;
100
+ const scriptPath = String(block.input?.scriptPath ?? "");
101
+ const base = scriptPath.split(/[\\/]/).pop().replace(/\.[cm]?js$/, "");
102
+ const named = String(block.input?.name ?? "");
103
+ return /^shapeup-/.test(base) || /^shapeup-/.test(named);
104
+ }
105
+
106
+ /**
107
+ * Did this session dispatch the orchestrator? Three surface spellings, all equivalent:
108
+ * `Skill(tech-lead)`, a leading `/ship` slash command, and — since the workflow cutover (v1.7) —
109
+ * a `Workflow` tool_use launching one of the orchestrator's own `shapeup-*` scripts.
110
+ *
111
+ * The third arm is a correctness repair, not a new detector. `SKILL.md`'s Step 2 makes the
112
+ * Workflow launch the scoped lane's front door, so a session can now reach the orchestrator
113
+ * without ever emitting `Skill(tech-lead)` — and before this arm existed such a session was
114
+ * invisible to the gate, exactly the "the emptier the failure, the less of it there is to detect"
115
+ * hole the banner above describes. It is a TRIGGER, never an escape: a launch that left no
116
+ * receipt is still a blocked stop, because a `Workflow` call that returned without starting a run
117
+ * is precisely the narration case wearing a tool call.
118
+ */
69
119
  export function dispatchedOrchestrator(events) {
70
120
  for (const ev of events) {
71
121
  for (const block of toolUses(ev)) {
122
+ if (launchedShapeupWorkflow(block)) return true;
72
123
  if (block.name !== "Skill") continue;
73
124
  const skill = String(block.input?.skill ?? block.input?.skill_name ?? "");
74
125
  if (skill.split(":").pop() === "tech-lead") return true;
@@ -110,7 +161,7 @@ export function workCensus(events) {
110
161
  return census;
111
162
  }
112
163
 
113
- /** Any run receipt on disk, from any run. Written by init-run.mjs as the run's first act. */
164
+ /** Any run receipt on disk, from any run. Written by `harness init run` as the run's first act. */
114
165
  export function findReceipts(cwd) {
115
166
  const root = localDir(cwd);
116
167
  if (!existsSync(root)) return [];
@@ -178,26 +229,39 @@ export function buildReason({ narration, census, enforcement }) {
178
229
  `Mechanical facts: ${census.tool_calls} tool call(s), ${census.work_calls} of them work calls, ` +
179
230
  `${census.writes} file write(s), and no \`${globLocal("<slug>", "receipt.json")}\`.`,
180
231
  narration ? `The final message reads as a plan, not a result (matched: "${narration}").` : null,
232
+ census.work_calls > 2
233
+ ? `Those ${census.work_calls} work calls are why this block exists, not a reason to waive it: a busy ` +
234
+ "session used to switch this gate off. Work done AROUND the harness has no board, no T0 verdict and " +
235
+ "no receipt — a hand-built feature can pass its own oracle while the pipeline never ran."
236
+ : null,
181
237
  enforcement && enforcement.readable && enforcement.rows === 0
182
238
  ? "AND the enforcement layer left zero decision rows — the gates did not merely permit this run, they never ran. " +
183
- "Check the plugin install (a symlinked or spaced path was the measured cause; see lib/is-main.mjs)."
239
+ "Check the plugin install a symlinked path or a path with a space is the usual cause."
184
240
  : null,
185
241
  "",
186
242
  "A run that describes its own pipeline and stops is the exact failure this harness exists to",
187
- "prevent measured at 29% acceptance with 10 escaped defects while looking like a clean run.",
243
+ "prevent: it reads like a clean run and leaves escaped defects behind it.",
188
244
  "Loading the instructions is not running them.",
189
245
  "",
190
246
  "Do the work now, starting with the first step of the runbook:",
191
247
  "",
192
248
  " # write the requirement to a file first — inlining multi-line text into a shell",
193
249
  " # argument is where this step goes wrong",
194
- " node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/init-run.mjs\" \\",
250
+ " node \"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs\" init run \\",
195
251
  " --slug <slug> --intake-file <path/to/requirement.md> \\",
196
252
  " --auto-level <interactive|auto|unattended> [--gate-answers <preset|path>]",
197
253
  "",
198
- "Then proceed through the gates, resolving each one with:",
254
+ "Then launch the lane itself with the Workflow tool (`npx shapeup-sdlc init` writes the grant",
255
+ "it needs, unless --no-native-workflow was given, in which case approve the launch once):",
256
+ "",
257
+ " Workflow({",
258
+ " scriptPath: \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/workflows/shapeup-run.js\",",
259
+ " args: <the RunArgs object>",
260
+ " })",
199
261
  "",
200
- " node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/gate-answers.mjs\" --resolve <gate-id> ",
262
+ "Resolve each gate the run pauses at with:",
263
+ "",
264
+ " node \"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs\" gate --resolve <gate-id> …",
201
265
  "",
202
266
  "If the command comes back \"requires approval\", say so and stop — the harness's scripts ship with",
203
267
  "the plugin and need a one-time permission grant (`npx shapeup-sdlc init` writes it). Do NOT route",
@@ -231,18 +295,36 @@ async function main() {
231
295
  // not a harness session → not our business
232
296
  if (!dispatchedOrchestrator(events)) defer("session never dispatched the orchestrator — not a harness run", "no-dispatch");
233
297
 
234
- // A receipt means the run started. What happens after that is anti-rationalization's job and
298
+ // A receipt means the run started. What happens after that is the ship report's census's job and
235
299
  // the evaluator's; this hook only asks whether anything started at all.
236
300
  const receipts = findReceipts(cwd);
237
301
  if (receipts.length > 0) defer(`${receipts.length} run receipt(s) on disk — the run started`, "receipt-present");
238
302
 
239
303
  const census = workCensus(events);
240
304
 
241
- // Fail open when the session clearly did work by other means. A user may have run the harness
242
- // steps by hand, or be on a pre-receipt version of the plugin. Real narration has ~zero work
243
- // calls; this threshold keeps the hook off everything else.
244
- if (census.work_calls > 2) defer(`${census.work_calls} work calls the session did work by other means`, "work-done");
245
-
305
+ // ── The `work-done` fail-open is GONE, and its removal is the point.
306
+ //
307
+ // WHAT IT USED TO DO: `if (census.work_calls > 2) defer("the session did work by other means")`.
308
+ // The stated rationale was that a user may have run the harness steps by hand, or be on a
309
+ // pre-receipt version of the plugin.
310
+ //
311
+ // WHAT IT ACTUALLY DID, observed. `Skill(tech-lead)` was dispatched, the lane could not start,
312
+ // the agent hand-built the whole feature, and the hook ALLOWED the Stop on `"37 work calls — the
313
+ // session did work by other means"`. That session passed its own oracle and read like a clean
314
+ // run, with no board, no T0, no verdict and no receipt. A busy session was the one thing that
315
+ // could switch this gate off — so the harder the agent worked around the harness, the safer it
316
+ // was from the gate watching for exactly that.
317
+ //
318
+ // BOTH HALVES OF THE RATIONALE FAIL ON INSPECTION. Running the harness steps by hand starts with
319
+ // ``harness init run``, which writes the receipt — such a session already deferred at `receipt-present`
320
+ // two lines up and never reached here. And a pre-receipt plugin cannot be the one executing this
321
+ // hook: ``harness init run`` ships in the same install, beside the `lib/` this file imports from.
322
+ // Neither case needs an escape, so no replacement escape is added — an escape that cannot fire
323
+ // is the "row that cannot fail" this project keeps catching.
324
+ //
325
+ // WHAT KEEPS THIS SAFE is not a threshold, it is the loop guard: `stop_hook_active` defers
326
+ // unconditionally, so a session that genuinely did the work outside the harness costs one extra
327
+ // turn and then stops. One nudge, never a hang. That is the trade this gate is worth.
246
328
  const message = typeof p.last_assistant_message === "string" ? p.last_assistant_message : "";
247
329
  const narration = detectNarration(message);
248
330
  const enforcement = enforcementCensus(cwd);
package/hooks/hooks.json CHANGED
@@ -8,27 +8,6 @@
8
8
  "command": "echo \"shapeup-sdlc-plugin loaded from ${CLAUDE_PLUGIN_ROOT}\""
9
9
  }
10
10
  ]
11
- },
12
- {
13
- "matcher": "startup|compact|resume|clear",
14
- "hooks": [
15
- {
16
- "type": "command",
17
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-rehydrate.mjs\"",
18
- "timeout": 10
19
- }
20
- ]
21
- }
22
- ],
23
- "PreCompact": [
24
- {
25
- "hooks": [
26
- {
27
- "type": "command",
28
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/compact-snapshot.mjs\"",
29
- "timeout": 10
30
- }
31
- ]
32
11
  }
33
12
  ],
34
13
  "PreToolUse": [
@@ -42,16 +21,6 @@
42
21
  }
43
22
  ]
44
23
  },
45
- {
46
- "matcher": "Skill",
47
- "hooks": [
48
- {
49
- "type": "command",
50
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-l2.mjs\"",
51
- "timeout": 10
52
- }
53
- ]
54
- },
55
24
  {
56
25
  "matcher": "Skill",
57
26
  "hooks": [
@@ -63,31 +32,33 @@
63
32
  ]
64
33
  },
65
34
  {
66
- "matcher": "Skill",
35
+ "matcher": "Skill|Agent",
67
36
  "hooks": [
68
37
  {
69
38
  "type": "command",
70
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-deadline.mjs\"",
39
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/kernel/harness.mjs\" verify envelope",
71
40
  "timeout": 10
72
41
  }
73
42
  ]
74
43
  },
75
44
  {
76
- "matcher": "Skill|Agent",
45
+ "matcher": "Edit|Write|MultiEdit",
77
46
  "hooks": [
78
47
  {
79
48
  "type": "command",
80
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/validate-envelope.mjs\"",
49
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-guard.mjs\"",
81
50
  "timeout": 10
82
51
  }
83
52
  ]
84
- },
53
+ }
54
+ ],
55
+ "PostToolUse": [
85
56
  {
86
- "matcher": "Edit|Write|MultiEdit",
57
+ "matcher": "Skill|Agent",
87
58
  "hooks": [
88
59
  {
89
60
  "type": "command",
90
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-guard.mjs\"",
61
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-receipt.mjs\"",
91
62
  "timeout": 10
92
63
  }
93
64
  ]
@@ -100,16 +71,6 @@
100
71
  "type": "command",
101
72
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-zerowork.mjs\"",
102
73
  "timeout": 10
103
- },
104
- {
105
- "type": "command",
106
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/anti-rationalization.mjs\"",
107
- "timeout": 10
108
- },
109
- {
110
- "type": "command",
111
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/slop-cleaner.mjs\"",
112
- "timeout": 15
113
74
  }
114
75
  ]
115
76
  }