shapeup-sdlc 1.6.2

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 (135) hide show
  1. package/.claude/settings.local.example.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +18 -0
  4. package/.env.shapeup.example +14 -0
  5. package/AGENTS.md +133 -0
  6. package/LICENSE +21 -0
  7. package/README.md +362 -0
  8. package/SECURITY.md +72 -0
  9. package/bin/init.mjs +329 -0
  10. package/commands/build.md +14 -0
  11. package/commands/eval.md +15 -0
  12. package/commands/hammer.md +12 -0
  13. package/commands/orient.md +11 -0
  14. package/commands/qa.md +12 -0
  15. package/commands/retro.md +13 -0
  16. package/commands/scopes.md +14 -0
  17. package/commands/shape.md +12 -0
  18. package/commands/ship.md +53 -0
  19. package/commands/wire.md +11 -0
  20. package/hooks/anti-rationalization.mjs +244 -0
  21. package/hooks/compact-snapshot.mjs +47 -0
  22. package/hooks/gate-deadline.mjs +151 -0
  23. package/hooks/gate-intake.mjs +110 -0
  24. package/hooks/gate-l2.mjs +161 -0
  25. package/hooks/gate-zerowork.mjs +264 -0
  26. package/hooks/hooks.json +118 -0
  27. package/hooks/lib/decision.mjs +183 -0
  28. package/hooks/safety-spine.mjs +296 -0
  29. package/hooks/sandbox-guard.mjs +172 -0
  30. package/hooks/session-rehydrate.mjs +109 -0
  31. package/hooks/slop-cleaner.mjs +176 -0
  32. package/oracles/_shared.mjs +46 -0
  33. package/oracles/http-oracle.mjs +155 -0
  34. package/oracles/index.mjs +36 -0
  35. package/oracles/process-oracle.mjs +146 -0
  36. package/oracles/snapshot-oracle.mjs +119 -0
  37. package/oracles/test-oracle.mjs +138 -0
  38. package/package.json +49 -0
  39. package/skills/advisor-protocol/SKILL.md +171 -0
  40. package/skills/ba-pitch-analyzer/SKILL.md +175 -0
  41. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
  42. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
  43. package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
  44. package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
  45. package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
  46. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
  47. package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
  48. package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
  49. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
  50. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
  51. package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
  52. package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
  53. package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
  54. package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
  55. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
  56. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
  57. package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
  58. package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
  59. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
  60. package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
  61. package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
  62. package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
  63. package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
  64. package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
  65. package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
  66. package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
  67. package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
  68. package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
  69. package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
  70. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
  71. package/skills/coach/SKILL.md +202 -0
  72. package/skills/orient/SKILL.md +262 -0
  73. package/skills/qa-edge-hunter/SKILL.md +369 -0
  74. package/skills/scope-architect/SKILL.md +111 -0
  75. package/skills/scope-hammer/SKILL.md +186 -0
  76. package/skills/shapeup/SKILL.md +403 -0
  77. package/skills/shapeup/resources/breadboard-reflection.md +88 -0
  78. package/skills/shapeup/resources/breadboarding.md +334 -0
  79. package/skills/shapeup/resources/context-compaction.md +240 -0
  80. package/skills/shapeup/resources/framing-doc.md +92 -0
  81. package/skills/shapeup/resources/kickoff-doc.md +95 -0
  82. package/skills/shapeup/resources/shaping.md +194 -0
  83. package/skills/shapeup/resources/spike.md +86 -0
  84. package/skills/solution-architect/SKILL.md +129 -0
  85. package/skills/spec-evaluator/README.md +93 -0
  86. package/skills/spec-evaluator/SKILL.md +213 -0
  87. package/skills/spec-evaluator/references/anti-leniency.md +50 -0
  88. package/skills/spec-evaluator/references/dimension-contract.md +126 -0
  89. package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
  90. package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
  91. package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
  92. package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
  93. package/skills/spec-evaluator/references/dimensions/security.md +60 -0
  94. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
  95. package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
  96. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
  97. package/skills/spec-evaluator/references/probing.md +177 -0
  98. package/skills/spec-evaluator/references/report-schema.md +101 -0
  99. package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
  100. package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
  101. package/skills/task-executor/SKILL.md +194 -0
  102. package/skills/tech-lead/README.md +71 -0
  103. package/skills/tech-lead/SKILL.md +459 -0
  104. package/skills/tech-lead/references/delegation.md +254 -0
  105. package/skills/tech-lead/references/gates.md +379 -0
  106. package/skills/tech-lead/references/invocation.md +45 -0
  107. package/skills/tech-lead/references/ledger-schema.md +214 -0
  108. package/skills/tech-lead/references/round-protocol.md +184 -0
  109. package/skills/tech-lead/references/state-model.md +66 -0
  110. package/skills/tech-lead/references/tiny-lane.md +52 -0
  111. package/skills/tech-lead/schemas/domain.schema.json +2294 -0
  112. package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
  113. package/skills/tech-lead/schemas/work-order.schema.json +21 -0
  114. package/skills/tech-lead/schemas/work-result.schema.json +40 -0
  115. package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
  116. package/skills/tech-lead/scripts/budget-check.mjs +156 -0
  117. package/skills/tech-lead/scripts/compile-order.mjs +518 -0
  118. package/skills/tech-lead/scripts/fit-check.mjs +196 -0
  119. package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
  120. package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
  121. package/skills/tech-lead/scripts/init-run.mjs +326 -0
  122. package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
  123. package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
  124. package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
  125. package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
  126. package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
  127. package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
  128. package/skills/tech-lead/scripts/ship-report.mjs +275 -0
  129. package/skills/tech-lead/scripts/stats.mjs +423 -0
  130. package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
  131. package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
  132. package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
  133. package/skills/translator/README.md +66 -0
  134. package/skills/translator/SKILL.md +258 -0
  135. package/skills/translator/references/preservation-rules.md +102 -0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // GATE L2 — board-green check on the once-per-round EVAL. PreToolUse hook (audit Stage E1).
3
+ //
4
+ // ADVISORY SINCE ADR-0001, and the downgrade was a deliberate product decision rather than a
5
+ // retreat. This hook used to hard-DENY the EVAL delegation (tech-lead → spec-evaluator) while the
6
+ // task board was not green. It now permits the dispatch and says what it found.
7
+ //
8
+ // WHY. The board is LOCAL and per-machine (`.shapeup/<slug>/tasks/`), and the harness runs only on
9
+ // the machine that invoked it — a teammate reads the committed design, they never resume someone
10
+ // else's run. So this gate never protected a team boundary; it protected the operator from their
11
+ // own agent, at the cost of denying a call the operator had asked for. The project chose the
12
+ // signal over the denial.
13
+ //
14
+ // WHAT IS LOST, stated plainly: nothing now mechanically prevents an EVAL on a half-green board.
15
+ // That is a defect this project has measured before (a run reached EVAL with 16/20 task files
16
+ // still `status: ready`). The warning names the offending tasks; it cannot stop the call.
17
+ //
18
+ // WHAT IS RETAINED: the detection is unchanged. Both independent reads still run — per-task
19
+ // frontmatter AND the board table — and the verdict is recorded as `warn`, which is its own row
20
+ // in `decisions.jsonl` precisely so "permitted because green" and "permitted despite not green"
21
+ // never collapse into the same fact (see hooks/lib/decision.mjs).
22
+ //
23
+ // Design (deliberate, conservative):
24
+ // • Scope — only ever looks at `Skill` → `spec-evaluator` in ROUND mode (`--single-pass`/
25
+ // `--feature`, no `--task`). A per-task eval (`--task TASK-NNN`) grades one task and is not
26
+ // in scope: the board-green rule is about the round. Anything else defers instantly.
27
+ // • Fail-OPEN whenever there is nothing to verify (no --spec, no board file, unparseable input,
28
+ // zero discoverable tasks) — it only speaks when it can prove the board is partial.
29
+ //
30
+ // Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name, skill_args}, cwd, ... }.
31
+ // Advises via { systemMessage } — never { permissionDecision: "deny" }.
32
+
33
+ //
34
+ // RECEIPTS (v1.5). Every `defer()` below now names WHICH of its fail-open conditions was met, and
35
+ // records it (hooks/lib/decision.mjs). The fail-open direction is unchanged and defended above;
36
+ // what changes is that "read the board and deferred" is no longer byte-identical to "did not run".
37
+
38
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
39
+ import { resolve, join, basename, dirname } from "node:path";
40
+ import { runHook, readStdin, settle } from "./lib/decision.mjs";
41
+ // Aliased: the body binds a local `tasksDir` to whichever candidate actually holds a board.
42
+ import { tasksDir as localTasksDir } from "../skills/tech-lead/scripts/lib/paths.mjs";
43
+
44
+ await runHook("gate-l2", async () => {
45
+ /** Allow normal permission flow — with the reason on the record. */
46
+ const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
47
+
48
+ // 1. Read & parse the PreToolUse payload.
49
+ const raw = await readStdin();
50
+ let p;
51
+ try { p = JSON.parse(raw || "{}"); }
52
+ catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
53
+
54
+ // 2. Only Skill → spec-evaluator is in scope.
55
+ if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
56
+ const skill = p.tool_input?.skill_name || "";
57
+ const args = p.tool_input?.skill_args || "";
58
+ if (skill !== "spec-evaluator") defer(`Skill(${skill || "?"}) is not the judge — out of scope`);
59
+
60
+ // 3. Round mode only. Two shapes qualify:
61
+ // (a) legacy flags: --single-pass / --feature, without --task (per-task eval is not gated);
62
+ // (b) pure-skill envelope (v1.0): --order <WorkOrder> whose operation is "evaluate" — the
63
+ // round dispatch tech-lead compiles. Slug/spec come from the order itself.
64
+ let orderSlug = null, orderSpec = null;
65
+ const om = args.match(/--order(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
66
+ if (om) {
67
+ try {
68
+ const order = JSON.parse(readFileSync(resolve(p.cwd || process.cwd(), om[1] || om[2] || om[3]), "utf8"));
69
+ if (order.worker === "spec-evaluator" && (order.operation || "evaluate") === "evaluate") {
70
+ orderSlug = String(order.order_id || "").split("/")[0] || order.payload?.feature || null;
71
+ orderSpec = order.payload?.spec_folder || null;
72
+ } else defer("order is for another job — not the round EVAL", "--order"); // not the round EVAL
73
+ } catch (e) {
74
+ if (e?.name === "HookDecision") throw e;
75
+ /* unreadable order → validate-envelope denies it; nothing to gate here */
76
+ defer(`order unreadable (${e.message}) — validate-envelope owns that denial`, "--order");
77
+ }
78
+ }
79
+ const hasTask = /--task(?:\s|=)/.test(args);
80
+ const roundMode = orderSlug !== null || (!hasTask && (/--single-pass\b/.test(args) || /--feature(?:\s|=)/.test(args)));
81
+ if (!roundMode) defer(hasTask ? "per-task eval — the board-green rule is about the round" : "not a round dispatch", "round-mode");
82
+
83
+ // 4. Locate the board. Since v0.4.0 (Local Tasks Architecture) it lives under the LOCAL
84
+ // gitignored root `.shapeup/<slug>/tasks/`, NOT the committed spec dir — resolving only
85
+ // `<spec>/tasks/` made this hook silently fail-open on every v0.4.0+ run (the island-escape
86
+ // hole: EVAL proceeded with 16/20 task files still `status: ready`).
87
+ // <slug> comes from --feature (the round invocation always carries it: tech-lead's eval plan
88
+ // is `--spec <path> --feature <slug> --single-pass`), falling back to the spec-path
89
+ // convention shapeup/<slug>/spec → parent dir name. `<spec>/tasks/` is kept as the
90
+ // legacy fallback so pre-v0.4.0 boards stay gated. A missing board on every candidate is a
91
+ // legitimate state — spec-evaluator v0.9 grades from the committed spec on machines that
92
+ // never generated a local board — so it stays fail-open.
93
+ const m = args.match(/--spec(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
94
+ const specPath = orderSpec || (m ? (m[1] || m[2] || m[3]) : null);
95
+ if (!specPath && !orderSlug) defer("no --spec and no order slug — nothing to locate a board from", "no-spec");
96
+ const cwd = p.cwd || process.cwd();
97
+ const specDir = specPath ? resolve(cwd, specPath) : null;
98
+ const fm = args.match(/--feature(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
99
+ const slug = orderSlug || (fm && (fm[1] || fm[2] || fm[3])) ||
100
+ (basename(specDir) === "spec" ? basename(dirname(specDir)) : basename(specDir));
101
+ const tasksDir = [localTasksDir(cwd, slug), ...(specDir ? [join(specDir, "tasks")] : [])]
102
+ .find((d) => existsSync(join(d, "_index.md")));
103
+ // no board on this machine → nothing to verify, don't break the run
104
+ if (!tasksDir) defer(`no board for "${slug}" on this machine — nothing to verify`, "no-board");
105
+ const board = join(tasksDir, "_index.md");
106
+
107
+ // 5. Assert the board is green, from two independent reads; fail-closed if EITHER shows unfinished
108
+ // work. (a) per-task frontmatter `status:` (the authoritative field); (b) the board table's
109
+ // status cell (what GATE L2 literally reads). Done = `status: done` / a ✅ in the row.
110
+ const DONE_FRONTMATTER = /^status:\s*done\s*$/im;
111
+ const NOT_DONE_MARK = /⬜|🔄|🚫|\b(ready|in-progress|blocked)\b/i;
112
+ const unfinished = new Set();
113
+
114
+ // (a) task files
115
+ let sawTaskFile = false;
116
+ try {
117
+ for (const f of readdirSync(tasksDir)) {
118
+ if (!/^TASK-[\w.-]+\.md$/i.test(f)) continue; // skip _index.md and non-task files
119
+ sawTaskFile = true;
120
+ const body = readFileSync(join(tasksDir, f), "utf8");
121
+ const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
122
+ const id = (body.match(/^id:\s*(TASK-[\w.-]+)/im) || [])[1] || f.replace(/\.md$/, "");
123
+ if (!fm || !DONE_FRONTMATTER.test(fm[1])) unfinished.add(id);
124
+ }
125
+ } catch { /* unreadable tasks dir → fall back to board table below */ }
126
+
127
+ // (b) board table rows — any row naming a task whose status cell is not ✅/done.
128
+ let sawBoardRow = false;
129
+ for (const line of readFileSync(board, "utf8").split(/\r?\n/)) {
130
+ const idm = line.match(/\bTASK-[\w.-]+/);
131
+ if (!idm || !line.includes("|")) continue;
132
+ sawBoardRow = true;
133
+ const done = line.includes("✅") || /\bdone\b/i.test(line);
134
+ if (!done || NOT_DONE_MARK.test(line)) unfinished.add(idm[0]);
135
+ }
136
+
137
+ // 6. If neither source yielded a single task, there's nothing to assert → defer.
138
+ if (!sawTaskFile && !sawBoardRow) defer("board directory holds zero tasks — nothing to assert", "empty-board");
139
+
140
+ // 7. Verdict.
141
+ // Board fully green → allow the EVAL. THIS is the row that used to be indistinguishable from the
142
+ // gate never having run: same exit code, same empty stdout, opposite meaning.
143
+ if (unfinished.size === 0) defer(`board green — ${sawTaskFile ? "task files" : "board rows"} all done, EVAL permitted`, "board-green");
144
+
145
+ // Board is partial. Advise, and record it as its own verdict — a `warn` row is what keeps
146
+ // "evaluated a non-green board" countable after the denial was removed (ADR-0001).
147
+ const list = [...unfinished].sort().join(", ");
148
+ return {
149
+ verdict: "warn", event: "PreToolUse", tool: "Skill", subject: slug, rule: "board-not-green",
150
+ reason: `${unfinished.size} unfinished task(s): ${list}`,
151
+ payload: {
152
+ systemMessage:
153
+ `⚠ GATE L2 — the board is NOT green and the EVAL is proceeding anyway.\n` +
154
+ `Unfinished (${unfinished.size}): ${list}\n` +
155
+ `EVAL is designed to run once per round, after every task is done. A verdict taken now grades ` +
156
+ `a partial board, so a PASS does not mean the feature is complete — it means the finished part ` +
157
+ `passed. Route back to BUILD (task-executor) to close these, or use --task for a deliberate ` +
158
+ `single-task check.`,
159
+ },
160
+ };
161
+ });
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ // GATE Z — ZERO-WORK. Blocking Stop hook. The detector for "the harness described itself".
3
+ //
4
+ // WHY THIS EXISTS (measured, not theorized).
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:
8
+ //
9
+ // TOOL Skill(tech-lead, "--unattended --rounds 3\n\n# F2 — category budgets…")
10
+ // TEXT "The tech-lead skill is orchestrating the full Shape Up harness. It will: 1. …"
11
+ // FINAL (same text — session ends)
12
+ //
13
+ // No code. No board. No gate artifacts. Prose that reads exactly like a successful run.
14
+ // That is the "agent claims done" pathology this project exists to prevent, reproduced by the
15
+ // project, at its own front door.
16
+ //
17
+ // THE TWO STRUCTURAL MISSES IT EXPOSED. `anti-rationalization.mjs` is the guard for exactly
18
+ // this class of failure, and it could not see this one for two independent reasons:
19
+ //
20
+ // 1. SCOPE. It defers unless a run is active (`activeSlug()` → `.shapeup/<slug>/`).
21
+ // A run that never started produces none of the files it reads. It catches "claimed done
22
+ // on a half-green board" and misses "claimed done with no board at all". The emptier the
23
+ // failure, the less of it there is to detect.
24
+ // 2. CLAIM SHAPE. Its detector matches PAST-tense completion — done, finished, shipped, all
25
+ // tests pass. Narration is FUTURE-tense: "it will". Fix the scope and this transcript
26
+ // still slips through, because narration never claims completion. It promises it.
27
+ //
28
+ // So this hook does not look at what the message says. It looks at whether anything happened.
29
+ // The predicate is mechanical and model-independent:
30
+ //
31
+ // the session dispatched the orchestrator AND the run left no receipt
32
+ //
33
+ // `init-run.mjs` writes that receipt as the orchestrator's first tool call. Its absence is the
34
+ // fact. Nothing here parses intent, so nothing here can be talked past.
35
+ //
36
+ // WHY THIS ONE BLOCKS, WHEN anti-rationalization DOES NOT. The invariant is "QA is a level-up,
37
+ // not a gate" — no second judge behind `spec-evaluator`. That governs quality JUDGMENTS. This
38
+ // hook makes no judgment: it reports that no work exists to judge. Blocking is also uniquely
39
+ // safe here, because a session with no artifacts has nothing to lose by continuing, and a
40
+ // blocking Stop is the only mechanism that turns narration back into execution — an advisory
41
+ // note at the end of a narrated run just gets narrated too.
42
+ //
43
+ // Loop safety: `stop_hook_active` defers unconditionally, so this fires at most once per stop
44
+ // chain. Everything ambiguous fails open — a hook that blocks legitimate sessions gets disabled,
45
+ // and a disabled hook enforces nothing.
46
+ //
47
+ // Contract: Stop stdin JSON { cwd, stop_hook_active, last_assistant_message, transcript_path }.
48
+ // Blocks via { decision: "block", reason }. Always exits 0.
49
+
50
+ // SECOND CONDITION (v1.5). Since `hooks/lib/decision.mjs` gives every hook a receipt, this gate
51
+ // gains a second, independent fact it can assert at `Stop`: the orchestrator was dispatched, and
52
+ // `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.
56
+
57
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
58
+ 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";
61
+ import { runHook, readStdin, settle, decisionsPath } from "./lib/decision.mjs";
62
+
63
+ const MAX_TRANSCRIPT_BYTES = 20 * 1024 * 1024;
64
+
65
+ /** Tool names that constitute doing something to the project, as opposed to looking at it. */
66
+ const WORK_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "Task", "Agent"]);
67
+
68
+ /** Did this session dispatch the orchestrator? Skill(tech-lead) in any of its surface spellings. */
69
+ export function dispatchedOrchestrator(events) {
70
+ for (const ev of events) {
71
+ for (const block of toolUses(ev)) {
72
+ if (block.name !== "Skill") continue;
73
+ const skill = String(block.input?.skill ?? block.input?.skill_name ?? "");
74
+ if (skill.split(":").pop() === "tech-lead") return true;
75
+ }
76
+ // The slash command is the other front door: `/shapeup-sdlc-plugin:ship …` arrives as user
77
+ // text, and reaches tech-lead through commands/ship.md.
78
+ //
79
+ // ANCHORED TO THE START OF THE MESSAGE, deliberately. A slash command IS the message — the CLI
80
+ // only dispatches one when it leads. Matching `/ship` anywhere in the text meant "how does
81
+ // /ship decide the lane?" counted as a dispatch, and in a repo with no run that answer is a
82
+ // Stop block: the session refuses to end and the model is told to bootstrap a feature nobody
83
+ // asked for. A gate that fires on a session merely TALKING about the harness is a gate users
84
+ // turn off, and it takes the real zero-work block down with it.
85
+ if (ev?.type === "user") {
86
+ const c = ev?.message?.content ?? ev?.content;
87
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => b?.text || "").join("\n") : "";
88
+ if (/^\s*\/(?:[\w-]+:)?ship\b/.test(text)) return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function toolUses(ev) {
95
+ const c = ev?.message?.content ?? ev?.content;
96
+ return Array.isArray(c) ? c.filter((b) => b?.type === "tool_use") : [];
97
+ }
98
+
99
+ /** How much actual work the session did, by tool class. */
100
+ export function workCensus(events) {
101
+ const census = { tool_calls: 0, work_calls: 0, writes: 0, by_tool: {} };
102
+ for (const ev of events) {
103
+ for (const block of toolUses(ev)) {
104
+ census.tool_calls++;
105
+ census.by_tool[block.name] = (census.by_tool[block.name] || 0) + 1;
106
+ if (WORK_TOOLS.has(block.name)) census.work_calls++;
107
+ if (block.name === "Write" || block.name === "Edit" || block.name === "MultiEdit") census.writes++;
108
+ }
109
+ }
110
+ return census;
111
+ }
112
+
113
+ /** Any run receipt on disk, from any run. Written by init-run.mjs as the run's first act. */
114
+ export function findReceipts(cwd) {
115
+ const root = localDir(cwd);
116
+ if (!existsSync(root)) return [];
117
+ const out = [];
118
+ let entries;
119
+ try { entries = readdirSync(root); } catch { return []; }
120
+ for (const entry of entries) {
121
+ const p = join(root, entry, "receipt.json");
122
+ if (!existsSync(p)) continue;
123
+ try {
124
+ const r = JSON.parse(readFileSync(p, "utf8"));
125
+ if (r?.started) out.push({ slug: entry, receipt: r, mtime: statSync(p).mtimeMs });
126
+ } catch { /* unreadable receipt is not a receipt */ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * Future-tense narration: the signature of describing a pipeline instead of running it.
133
+ * Advisory only — it sharpens the message, it never decides the block. The block is decided
134
+ * by the absence of a receipt, which no phrasing can change.
135
+ */
136
+ export function detectNarration(text) {
137
+ if (!text || typeof text !== "string") return null;
138
+ const m = /\b(will (?:now )?(?:run|orchestrate|execute|proceed|begin|start)|is orchestrating|I'?ll (?:now )?(?:run|start|begin|orchestrate)|going to (?:run|orchestrate|execute)|here'?s (?:what|how) (?:it|the harness) will)\b/i.exec(text);
139
+ return m ? m[0] : null;
140
+ }
141
+
142
+ function readEvents(transcriptPath) {
143
+ try {
144
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
145
+ if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
146
+ return readFileSync(transcriptPath, "utf8")
147
+ .split("\n")
148
+ .filter(Boolean)
149
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
150
+ .filter(Boolean);
151
+ } catch { return null; }
152
+ }
153
+
154
+ /**
155
+ * Did the enforcement layer leave any evidence of itself in this checkout?
156
+ *
157
+ * A hook that never runs and a hook that inspected-and-permitted used to produce identical
158
+ * evidence (exit 0, empty stdout). With `hooks/lib/decision.mjs` every evaluation appends a row,
159
+ * so ZERO rows across the whole file is now a positive fact about the layer rather than an absence
160
+ * of information about it.
161
+ *
162
+ * @param {string} cwd - Project root.
163
+ * @returns {{rows:number, readable:boolean}} How many decision rows exist and whether the ledger
164
+ * could be read at all (an unreadable ledger proves nothing and must not sharpen the message).
165
+ */
166
+ export function enforcementCensus(cwd) {
167
+ const p = decisionsPath(cwd);
168
+ if (!existsSync(p)) return { rows: 0, readable: false };
169
+ try {
170
+ return { rows: readFileSync(p, "utf8").split("\n").filter((l) => l.trim()).length, readable: true };
171
+ } catch { return { rows: 0, readable: false }; }
172
+ }
173
+
174
+ export function buildReason({ narration, census, enforcement }) {
175
+ return [
176
+ "✋ GATE Z — ZERO WORK. This session dispatched the tech-lead orchestrator and produced no run receipt.",
177
+ "",
178
+ `Mechanical facts: ${census.tool_calls} tool call(s), ${census.work_calls} of them work calls, ` +
179
+ `${census.writes} file write(s), and no \`${globLocal("<slug>", "receipt.json")}\`.`,
180
+ narration ? `The final message reads as a plan, not a result (matched: "${narration}").` : null,
181
+ enforcement && enforcement.readable && enforcement.rows === 0
182
+ ? "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)."
184
+ : null,
185
+ "",
186
+ "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.",
188
+ "Loading the instructions is not running them.",
189
+ "",
190
+ "Do the work now, starting with the first step of the runbook:",
191
+ "",
192
+ " # write the requirement to a file first — inlining multi-line text into a shell",
193
+ " # argument is where this step goes wrong",
194
+ " node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/init-run.mjs\" \\",
195
+ " --slug <slug> --intake-file <path/to/requirement.md> \\",
196
+ " --auto-level <interactive|auto|unattended> [--gate-answers <preset|path>]",
197
+ "",
198
+ "Then proceed through the gates, resolving each one with:",
199
+ "",
200
+ " node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/gate-answers.mjs\" --resolve <gate-id> …",
201
+ "",
202
+ "If the command comes back \"requires approval\", say so and stop — the harness's scripts ship with",
203
+ "the plugin and need a one-time permission grant (`npx shapeup-sdlc init` writes it). Do NOT route",
204
+ "around it with wrapper scripts, and do NOT hand-build the feature instead: a feature built outside",
205
+ "the harness has no board, no T0 and no verdict, which is the un-evidenced \"done\" this exists to stop.",
206
+ "",
207
+ "If the requirement genuinely cannot be built, say why in one sentence — an explicit refusal is",
208
+ "a real answer. A future-tense summary of what the harness would have done is not.",
209
+ ].filter((l) => l !== null).join("\n");
210
+ }
211
+
212
+ async function main() {
213
+ await runHook("gate-zerowork", async () => {
214
+ const raw = await readStdin();
215
+
216
+ let p;
217
+ /** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
218
+ const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
219
+ try { p = JSON.parse(raw || "{}"); }
220
+ catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
221
+
222
+ // At most one block per stop chain. Without this a session that cannot run the script at all
223
+ // (read-only cwd, missing node) would be held open forever.
224
+ if (p.stop_hook_active) defer("stop_hook_active — at most one block per stop chain", "loop-guard");
225
+
226
+ const cwd = p.cwd || process.cwd();
227
+ const events = readEvents(p.transcript_path);
228
+ // no transcript → no facts → fail open
229
+ if (!events || events.length === 0) defer("no readable transcript — no facts to assert", "no-transcript");
230
+
231
+ // not a harness session → not our business
232
+ if (!dispatchedOrchestrator(events)) defer("session never dispatched the orchestrator — not a harness run", "no-dispatch");
233
+
234
+ // A receipt means the run started. What happens after that is anti-rationalization's job and
235
+ // the evaluator's; this hook only asks whether anything started at all.
236
+ const receipts = findReceipts(cwd);
237
+ if (receipts.length > 0) defer(`${receipts.length} run receipt(s) on disk — the run started`, "receipt-present");
238
+
239
+ const census = workCensus(events);
240
+
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
+
246
+ const message = typeof p.last_assistant_message === "string" ? p.last_assistant_message : "";
247
+ const narration = detectNarration(message);
248
+ const enforcement = enforcementCensus(cwd);
249
+
250
+ return {
251
+ verdict: "block", event: "Stop", cwd, rule: "no-receipt",
252
+ reason: `orchestrator dispatched, ${census.work_calls} work call(s), no run receipt` +
253
+ (enforcement.readable && enforcement.rows === 0 ? "; enforcement layer left zero decision rows" : ""),
254
+ payload: {
255
+ decision: "block",
256
+ reason: buildReason({ narration, census, enforcement }),
257
+ },
258
+ };
259
+ });
260
+ }
261
+
262
+ if (isMain(import.meta.url)) {
263
+ main();
264
+ }
@@ -0,0 +1,118 @@
1
+ {
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
+ "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
+ }
33
+ ],
34
+ "PreToolUse": [
35
+ {
36
+ "matcher": "Bash|Read|Write|Edit|MultiEdit",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/safety-spine.mjs\"",
41
+ "timeout": 10
42
+ }
43
+ ]
44
+ },
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
+ {
56
+ "matcher": "Skill",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-intake.mjs\"",
61
+ "timeout": 10
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ "matcher": "Skill",
67
+ "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-deadline.mjs\"",
71
+ "timeout": 10
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "matcher": "Skill|Agent",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/tech-lead/scripts/validate-envelope.mjs\"",
81
+ "timeout": 10
82
+ }
83
+ ]
84
+ },
85
+ {
86
+ "matcher": "Edit|Write|MultiEdit",
87
+ "hooks": [
88
+ {
89
+ "type": "command",
90
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-guard.mjs\"",
91
+ "timeout": 10
92
+ }
93
+ ]
94
+ }
95
+ ],
96
+ "Stop": [
97
+ {
98
+ "hooks": [
99
+ {
100
+ "type": "command",
101
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gate-zerowork.mjs\"",
102
+ "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
+ }
114
+ ]
115
+ }
116
+ ]
117
+ }
118
+ }