session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { writeFile, rename, mkdir } from 'node:fs/promises';
20
- import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from 'node:fs';
20
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, writeSync } from 'node:fs';
21
21
  import path, { dirname } from 'node:path';
22
22
  import { randomBytes, randomUUID } from 'node:crypto';
23
23
 
@@ -27,6 +27,12 @@ import { randomBytes, randomUUID } from 'node:crypto';
27
27
 
28
28
  /**
29
29
  * Build the deny reason string, optionally appending a suggestion.
30
+ *
31
+ * Multi-line `reason` values are passed through verbatim — callers that need a
32
+ * several-line rationale (e.g. hooks/pre-bash-destructive-guard.mjs) rely on
33
+ * this. `JSON.stringify` later escapes the newlines, so the emitted payload
34
+ * still occupies exactly one stdout line.
35
+ *
30
36
  * @param {string} reason
31
37
  * @param {string|undefined} suggestion
32
38
  * @returns {string}
@@ -35,6 +41,161 @@ function _formatReason(reason, suggestion) {
35
41
  return suggestion ? `${reason} — ${suggestion}` : reason;
36
42
  }
37
43
 
44
+ /** Max length of the operator-facing `systemMessage` headline. */
45
+ const DENY_HEADLINE_MAX = 200;
46
+
47
+ /**
48
+ * Hard ceiling (in characters) for `permissionDecisionReason`.
49
+ *
50
+ * Rationale is empirical, not a round number pulled from the air. Measured
51
+ * worst-case reason lengths across all 12 live `emitDeny` call sites:
52
+ *
53
+ * | call site | measured chars |
54
+ * |----------------------------------------------|----------------|
55
+ * | pre-bash-destructive-guard (worst of 14 rules) | 435 |
56
+ * | pre-bash-templates-first (fixed part) | 348 + command |
57
+ * | pre-bash-issue-budget (formatBlockReason) | 652 |
58
+ * | config-protection (all 6 reasons at once) | 432 |
59
+ * | enforce-scope (LIVE wave-scope, 18 paths) | 1 322 |
60
+ * | enforce-scope (deep wave, 144-path union) | ~9 068 |
61
+ *
62
+ * `enforce-scope` joins `allowedPaths` into BOTH the reason and the suggestion,
63
+ * so its length grows at ~2× the union. A deep session (18 agents) with a wide
64
+ * union is the binding constraint at ~9 k — which is why the ceiling is NOT the
65
+ * 8 000 originally proposed: that would clip a legitimate deep-wave scope
66
+ * violation exactly when the operator most needs the path list. 16 000 sits
67
+ * ~1.8× above that worst case and ~12× above the largest measured live reason,
68
+ * while staying ~4× below the 65 536-byte kernel pipe buffer — so even the clamp
69
+ * ALONE keeps a typical-ASCII envelope inside one buffer.
70
+ */
71
+ const DENY_REASON_MAX = 16_000;
72
+
73
+ /**
74
+ * Hard ceiling (in characters) for the operator-facing `systemMessage` that
75
+ * {@link emitWarn} emits.
76
+ *
77
+ * Same ceiling as {@link DENY_REASON_MAX}, and for the same measured reason:
78
+ * the warn path shares its call sites with the deny path — `enforce-scope` calls
79
+ * `emitDeny(reason, suggestion)` under `strict` and `emitWarn(reason — suggestion)`
80
+ * under `warn`, on the identical text. The binding case is therefore identical
81
+ * too (a deep wave's 144-path allowedPaths union, ~9 068 chars, interpolated into
82
+ * both halves), so a tighter warn ceiling would clip exactly the path list the
83
+ * operator needs. 16 000 stays ~4× below the 65 536-byte kernel pipe buffer.
84
+ *
85
+ * NOT reused as a shared alias by accident: `emitDeny` splits its text across a
86
+ * clipped 200-char headline plus the full `permissionDecisionReason`, whereas
87
+ * `systemMessage` is the warn path's ONLY carrier — this constant is what makes
88
+ * that difference explicit rather than incidental.
89
+ */
90
+ const WARN_MESSAGE_MAX = 16_000;
91
+
92
+ /**
93
+ * Reason substituted when a caller denies without supplying one.
94
+ *
95
+ * A guard must never fail on its own bookkeeping: throwing here used to land in
96
+ * the fail-open `main().catch(() => emitAllow())` of four hooks
97
+ * (pre-bash-destructive-guard, pre-bash-issue-budget, pre-bash-templates-first,
98
+ * config-protection) and turn a deny into an ALLOW. The programmer-error signal
99
+ * is preserved as a stderr diagnostic instead of a throw.
100
+ */
101
+ const EMPTY_REASON_FALLBACK =
102
+ 'Denied by a session-orchestrator guard that did not supply a reason (guard bug — see stderr). '
103
+ + 'Blocking rather than allowing: a guard must fail closed.';
104
+
105
+ /** stdout file descriptor. */
106
+ const STDOUT_FD = 1;
107
+
108
+ /** Backoff between EAGAIN retries in the synchronous stdout writer. */
109
+ const STDOUT_EAGAIN_BACKOFF_MS = 1;
110
+
111
+ /**
112
+ * Clip `text` to `max` characters, marking the cut with an ellipsis.
113
+ *
114
+ * @param {string} text
115
+ * @param {number} max
116
+ * @returns {string}
117
+ */
118
+ function _clip(text, max) {
119
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
120
+ }
121
+
122
+ /**
123
+ * Derive the short operator-facing headline from a (possibly multi-line) reason.
124
+ * First line only, clipped — the full text stays in `permissionDecisionReason`.
125
+ *
126
+ * @param {string} reason
127
+ * @returns {string}
128
+ */
129
+ function _denyHeadline(reason) {
130
+ return `⛔ ${_clip(reason.split('\n')[0].trim(), DENY_HEADLINE_MAX)}`;
131
+ }
132
+
133
+ /**
134
+ * Clamp a reason/message to `max` characters, appending a visible marker that
135
+ * names how much was dropped. The marker is budgeted INSIDE the ceiling, so the
136
+ * returned string never exceeds it.
137
+ *
138
+ * @param {string} reason
139
+ * @param {number} [max=DENY_REASON_MAX]
140
+ * @returns {string}
141
+ */
142
+ function _clampReason(reason, max = DENY_REASON_MAX) {
143
+ if (reason.length <= max) return reason;
144
+ const marker = `\n… [truncated: showing ${max} of ${reason.length} characters]`;
145
+ return reason.slice(0, max - marker.length) + marker;
146
+ }
147
+
148
+ /**
149
+ * Synchronously write one line (a trailing `\n` is appended) to stdout,
150
+ * looping until every byte is handed to the kernel.
151
+ *
152
+ * ## Why this exists instead of `console.log`
153
+ *
154
+ * `process.stdout` is **asynchronous when it is a pipe on macOS** (Node docs,
155
+ * "process I/O": "Pipes and sockets: … asynchronous on macOS") — exactly the
156
+ * configuration every Claude Code hook runs under. `console.log` therefore only
157
+ * QUEUES the write: whatever does not fit into the 65 536-byte kernel pipe
158
+ * buffer stays in libuv's write queue, and `process.exit()` discards it. The
159
+ * observable result is a truncated, unparseable JSON envelope delivered with
160
+ * exit 0.
161
+ *
162
+ * Before #906 that truncation was harmless: `emitDeny` exited **2**, which
163
+ * blocks regardless of stdout. Once the helper moved to `exit 0` + structured
164
+ * JSON, the same truncation flipped the guard layer from fail-CLOSED to
165
+ * fail-OPEN — a >64 KB reason meant the harness saw no structured output and
166
+ * ALLOWED the tool call. `writeSync` bypasses the libuv queue entirely, so the
167
+ * bytes are in the kernel before `process.exit` runs.
168
+ *
169
+ * EAGAIN is expected when fd 1 is non-blocking and the pipe is momentarily
170
+ * full; the loop backs off and retries rather than dropping the tail. Any other
171
+ * error (EPIPE — reader gone) is reported to the caller, which must then decide
172
+ * how to signal WITHOUT stdout. Never throws.
173
+ *
174
+ * @param {string} line Payload without trailing newline.
175
+ * @returns {{ ok: true, bytesWritten: number } | { ok: false, bytesWritten: number, error: string }}
176
+ */
177
+ export function writeStdoutLineSync(line) {
178
+ const buf = Buffer.from(`${line}\n`, 'utf8');
179
+ let offset = 0;
180
+ while (offset < buf.length) {
181
+ let written;
182
+ try {
183
+ written = writeSync(STDOUT_FD, buf, offset, buf.length - offset);
184
+ } catch (err) {
185
+ const code = err?.code;
186
+ if (code === 'EAGAIN' || code === 'EINTR') {
187
+ // Synchronous backoff: Atomics.wait is the only event-loop-free sleep,
188
+ // and the event loop is precisely what we cannot yield to here.
189
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STDOUT_EAGAIN_BACKOFF_MS);
190
+ continue;
191
+ }
192
+ return { ok: false, bytesWritten: offset, error: code ?? String(err) };
193
+ }
194
+ offset += written;
195
+ }
196
+ return { ok: true, bytesWritten: offset };
197
+ }
198
+
38
199
  // ---------------------------------------------------------------------------
39
200
  // Exports
40
201
  // ---------------------------------------------------------------------------
@@ -124,24 +285,198 @@ export function emitAllow() {
124
285
  }
125
286
 
126
287
  /**
127
- * Deny the current hook invocation with a structured JSON reason on stdout, then exit 2.
128
- * @param {string} reason Non-empty human-readable denial reason.
288
+ * Deny the current **PreToolUse** hook invocation: emit exactly one JSON object
289
+ * on stdout, then exit **0**.
290
+ *
291
+ * ## The contract (code.claude.com/docs/en/hooks), verbatim
292
+ *
293
+ * > "**Exit 2** means a blocking error. Claude Code **ignores stdout and any
294
+ * > JSON in it**. Instead, stderr text is fed back to Claude as an error
295
+ * > message."
296
+ *
297
+ * > "You must choose one approach per hook, **not both**: either use exit codes
298
+ * > alone for signaling, or exit 0 and print JSON for structured control."
299
+ *
300
+ * > "Unlike other hooks that use a top-level `decision` field, **PreToolUse
301
+ * > returns its decision inside a `hookSpecificOutput` object**" — which
302
+ * > "requires a `hookEventName` field set to the event name."
303
+ *
304
+ * > PreToolUse top-level `decision` / `reason` are deprecated: "Use
305
+ * > `hookSpecificOutput.permissionDecision` and
306
+ * > `hookSpecificOutput.permissionDecisionReason` instead."
307
+ *
308
+ * Until #906 this function emitted stdout JSON **and** `exit 2` — the mixed
309
+ * form the second quote forbids. The block still bit, but the reason was
310
+ * discarded wholesale and the operator saw only `hook error: … No stderr
311
+ * output`, i.e. what looks like a crash. Do not reintroduce either half of that
312
+ * mixed form: `exit 2` here, or a flat top-level `{permissionDecision, reason}`.
313
+ *
314
+ * ## Emitted payload (single stdout line, nothing else on stdout)
315
+ *
316
+ * ```json
317
+ * {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"<reason — suggestion>"},"systemMessage":"⛔ <first line of reason>"}
318
+ * ```
319
+ *
320
+ * `permissionDecisionReason` is fed to **Claude**; the universal `systemMessage`
321
+ * field is what the **operator** sees. Both are emitted on purpose: operator
322
+ * visibility is the entire point of the #906 repair, and a `deny` whose cause is
323
+ * invisible to the human is what pushed wave agents into circumventing the
324
+ * enforcement layer. The headline is deliberately short (first line, clipped) —
325
+ * the long text belongs in `permissionDecisionReason`.
326
+ *
327
+ * Multi-line reasons survive intact: `JSON.stringify` escapes the newlines, so
328
+ * the payload stays exactly one line and consumers that parse stdout line-wise
329
+ * (e.g. `scripts/lib/pi-hook-bridge.mjs`) keep working.
330
+ *
331
+ * ## PRECONDITION — PreToolUse hooks only
332
+ *
333
+ * `hookEventName` is hardcoded to `PreToolUse`. PostToolUse / Stop /
334
+ * SubagentStop signal through a **top-level** `decision` / `reason` pair and
335
+ * MUST NOT be routed through this helper.
336
+ *
337
+ * ## Delivery is part of the contract, not an implementation detail
338
+ *
339
+ * The envelope goes out through {@link writeStdoutLineSync}, never
340
+ * `console.log`. On macOS a piped stdout is asynchronous, so `console.log` +
341
+ * `process.exit(0)` silently drops everything past the 65 536-byte kernel pipe
342
+ * buffer — a truncated envelope reads as "no structured output" and the tool
343
+ * call is ALLOWED. Two independent bounds keep that from happening:
344
+ *
345
+ * 1. `permissionDecisionReason` is clamped to {@link DENY_REASON_MAX}, and the
346
+ * `opts.systemMessage` override to {@link DENY_HEADLINE_MAX}, so no caller
347
+ * — including one that funnels attacker-controlled tool input into the
348
+ * reason, as pre-bash-templates-first does with the raw bash command — can
349
+ * inflate the envelope past the buffer.
350
+ * 2. The write itself is synchronous and loops to completion, so even an
351
+ * envelope that somehow exceeds the buffer still lands in full.
352
+ *
353
+ * Both are load-bearing: the clamp alone would not survive a caller that
354
+ * bypasses it, and the synchronous write alone would ship 200 KB envelopes to
355
+ * the harness. If stdout cannot be written at all (EPIPE — the reader is gone),
356
+ * the helper exits **2**: with no structured channel left, the exit code is the
357
+ * only remaining way to block, and the "never both" rule is not violated
358
+ * because no parseable JSON was delivered.
359
+ *
360
+ * @param {string} reason Human-readable denial reason. May be multi-line. When
361
+ * blank/absent the call still DENIES (with a generic reason plus a stderr
362
+ * diagnostic) — see {@link EMPTY_REASON_FALLBACK} for why this does not
363
+ * throw.
129
364
  * @param {string} [suggestion] Optional remediation hint appended after " — ".
365
+ * @param {object} [opts]
366
+ * @param {string} [opts.systemMessage] Override the derived operator headline.
367
+ * Ignored when blank; clipped to one short line.
130
368
  * @returns {never}
131
369
  */
132
- export function emitDeny(reason, suggestion) {
133
- if (!reason) throw new TypeError('io.mjs: emitDeny requires a non-empty reason string');
134
- console.log(JSON.stringify({ permissionDecision: 'deny', reason: _formatReason(reason, suggestion) }));
135
- process.exit(2);
370
+ export function emitDeny(reason, suggestion, opts = {}) {
371
+ // Coerce defensively and never throw: a throw inside a deny path unwinds into
372
+ // the fail-open `main().catch(() => emitAllow())` that four hooks install,
373
+ // turning the deny into an ALLOW. A guard must never fail open on its own
374
+ // bookkeeping — so a missing reason degrades to a generic deny plus a loud
375
+ // stderr diagnostic, which keeps the programmer-error signal without the
376
+ // fail-open blast radius.
377
+ const raw = typeof reason === 'string'
378
+ ? reason
379
+ : (reason === null || reason === undefined ? '' : String(reason));
380
+ const missingReason = raw.trim() === '';
381
+ if (missingReason) {
382
+ try {
383
+ process.stderr.write(
384
+ '⚠ io.mjs: emitDeny called without a reason — denying with a generic message (guard bug)\n',
385
+ );
386
+ } catch { /* stderr may be closed; the deny below is what matters */ }
387
+ }
388
+ const permissionDecisionReason = _clampReason(
389
+ String(_formatReason(missingReason ? EMPTY_REASON_FALLBACK : raw, suggestion)),
390
+ );
391
+ const override = typeof opts?.systemMessage === 'string' ? opts.systemMessage.trim() : '';
392
+ const line = JSON.stringify({
393
+ hookSpecificOutput: {
394
+ hookEventName: 'PreToolUse',
395
+ permissionDecision: 'deny',
396
+ permissionDecisionReason,
397
+ },
398
+ systemMessage: override !== ''
399
+ ? _clip(override, DENY_HEADLINE_MAX)
400
+ : _denyHeadline(permissionDecisionReason),
401
+ });
402
+ const result = writeStdoutLineSync(line);
403
+ // stdout delivered → exit 0 (structured channel). stdout unwritable → exit 2,
404
+ // the only blocking signal left. Never exit 0 on a failed write: that is the
405
+ // fail-open case this whole helper exists to prevent.
406
+ process.exit(result.ok ? 0 : 2);
136
407
  }
137
408
 
138
409
  /**
139
- * Emit a warning message to stderr and exit 0 (allow with notice).
140
- * @param {string} message Warning text written to stderr prefixed with "⚠ ".
410
+ * Emit an operator-visible warning and exit **0** — "allow, with a notice".
411
+ *
412
+ * ## Why this is not stderr-only (#916)
413
+ *
414
+ * Until #916 this helper wrote only to stderr. Under the exit-0 branch of the
415
+ * hook contract that channel goes nowhere: `docs/plugin-architecture-v3.md`
416
+ * states "stderr | Only for debugging. Not surfaced to the user.", and
417
+ * `skills/hook-development/SKILL.md` puts it plainly — "When the hook exits 0
418
+ * here it is **silent** — no stdout, no stderr". So every `enforcement: warn`
419
+ * scope/command violation was announced to a debug log and to nobody else. A
420
+ * warning that reaches neither the operator nor the model is not a warning.
421
+ *
422
+ * The visible channel is the universal top-level `systemMessage` field — the
423
+ * same one {@link emitDeny} already rides for its ⛔ headline, and the one
424
+ * {@link emitSystemMessage} documents. It is emitted here WITHOUT any
425
+ * `hookSpecificOutput` / `permissionDecision`, which is precisely what keeps
426
+ * warn non-blocking: the harness sees a message but no decision.
427
+ *
428
+ * stderr is retained unchanged for debug-log and CI-capture parity.
429
+ *
430
+ * ## Consumer contract — stdout in the warn path is no longer empty
431
+ *
432
+ * `scripts/lib/pi-hook-bridge.mjs#readHookDecision` scans stdout line-wise and
433
+ * keeps the first line that actually carries a `permissionDecision`; a
434
+ * decision-less JSON line (this one) is skipped, so the Pi lane reports
435
+ * `{decision: null, deny: false, malformed: false}` ⇒ not blocked. Verified
436
+ * empirically, not assumed. Codex does not wire these hooks at all
437
+ * (`hooks/hooks-codex.json` has `"PreToolUse": []`), and `hooks/hooks-cursor.json`
438
+ * is a documentation-only mapping reference with no in-repo executor.
439
+ *
440
+ * ## Delivery: the same two bounds {@link emitDeny} uses, for a sharper reason
441
+ *
442
+ * The write goes through {@link writeStdoutLineSync}, never `console.log`, and
443
+ * the message is clamped to {@link WARN_MESSAGE_MAX}. On macOS a piped stdout is
444
+ * asynchronous, so `console.log` + `process.exit(0)` drops everything past the
445
+ * 65 536-byte kernel pipe buffer — and `enforce-scope` interpolates the whole
446
+ * `allowedPaths` union into its warn text TWICE, so this path genuinely reaches
447
+ * five figures. A truncated line here is worse than a lost warning: the Pi
448
+ * bridge classifies an unparseable `{`-prefixed line as `malformed`, which for
449
+ * PreToolUse fails CLOSED — i.e. pipe truncation would silently convert
450
+ * `enforcement: warn` into a hard block. Both bounds are load-bearing.
451
+ *
452
+ * ## Deliberate asymmetry to emitDeny: an unwritable stdout still exits 0
453
+ *
454
+ * {@link emitDeny} exits 2 when stdout cannot be written, because with no
455
+ * structured channel left the exit code is its only way to block. The warn path
456
+ * inverts that: its decision is "allow", so a failed write must cost the
457
+ * NOTICE, never the permission. Exiting non-zero here would turn a dropped
458
+ * warning into a blocked tool call.
459
+ *
460
+ * @param {string} message Warning text. Emitted on stderr and as the
461
+ * `systemMessage` payload, both prefixed with "⚠ ".
141
462
  * @returns {never}
142
463
  */
143
464
  export function emitWarn(message) {
144
- console.error(`⚠ ${message}`);
465
+ // Coerce defensively: a throw here would unwind into the callers' top-level
466
+ // `main().catch(() => emitDeny(...))`, turning an allow-with-notice into a DENY.
467
+ const raw = typeof message === 'string'
468
+ ? message
469
+ : (message === null || message === undefined ? '' : String(message));
470
+ const text = `⚠ ${_clampReason(raw, WARN_MESSAGE_MAX)}`;
471
+
472
+ // Debug channel — unchanged. Invisible under exit 0, kept for log/CI capture.
473
+ try {
474
+ console.error(text);
475
+ } catch { /* stderr may be closed; the visible channel below is what matters */ }
476
+
477
+ // Visible channel. Return value is deliberately ignored — see the asymmetry
478
+ // note above: a warning that cannot be delivered must not become a block.
479
+ writeStdoutLineSync(JSON.stringify({ systemMessage: text }));
145
480
  process.exit(0);
146
481
  }
147
482
 
@@ -0,0 +1,269 @@
1
+ /**
2
+ * issue-budget.mjs — per-session issue-creation cap: state, exemptions, verdict.
3
+ *
4
+ * WHY: measured on the private instance over four weeks, sessions created 1784
5
+ * issues against 1285 closed (net +499), with a median inter-creation gap of
6
+ * 2.6 s and 62 % of creations inside bursts of >= 5 per minute. The pre-existing
7
+ * `discovery-severity-threshold` / `discovery-confidence-threshold` keys cannot
8
+ * bound that: they filter individual FINDINGS by quality (and the `low` default
9
+ * filters nothing), they are only consulted in skill prose, and the biggest
10
+ * producers — session-end carryover filing, plan issue creation,
11
+ * `scripts/lib/spiral-carryover.mjs` — never read them at all.
12
+ *
13
+ * This module is the single decision point for BOTH producer paths:
14
+ * - shell path: `hooks/pre-bash-issue-budget.mjs` (PreToolUse/Bash)
15
+ * - programmatic path: `scripts/lib/spiral-carryover.mjs` `runCli()`
16
+ *
17
+ * EXEMPTIONS ARE LOAD-BEARING. `skills/session-end/SKILL.md` carries two
18
+ * standing promises the cap must not break:
19
+ * - `:319` — "SPIRAL / FAILED agent carryover → auto-carry candidate …
20
+ * (non-deselectable)"
21
+ * - `:1113` — "ALWAYS create issues for unfinished PLANNED work … nothing
22
+ * planned-but-unfinished is 'remembered' without one."
23
+ * A cap that silently swallowed a SPIRAL carryover would turn a hard promise
24
+ * into a lie, so the carryover class plus `priority::critical` bypass the cap
25
+ * entirely (they are counted for observability, but never blocked).
26
+ *
27
+ * Stdlib only — the hook path must stay cheap enough to run on every Bash call.
28
+ */
29
+
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ import { writeJsonAtomicSync } from './io.mjs';
34
+ import { resolveInstructionFile } from './common.mjs';
35
+ import { _parseIssueBudget } from './config/issue-budget.mjs';
36
+
37
+ /** Runtime counter file, relative to the repo root. */
38
+ export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
39
+
40
+ /**
41
+ * Commands whose issue creation MUST NOT be blocked, with the reason recorded
42
+ * for the stderr trace and for the overflow bookkeeping.
43
+ *
44
+ * Each entry is `[regex, reason]`. Both the scoped (`priority::critical`) and
45
+ * the legacy unscoped (`priority:critical`) spellings are accepted: the label
46
+ * migration to scoped `priority::` runs alongside this change and a
47
+ * half-migrated producer must not lose its exemption mid-flight.
48
+ */
49
+ const EXEMPT_RULES = [
50
+ [/priority::?critical\b/i, 'priority::critical'],
51
+ // `[Carryover] [SPIRAL] …` / `[Carryover] [FAILED] …` — the exact title
52
+ // template `createSpiralCarryoverIssue` emits, plus the `type::carryover`
53
+ // and bare `carryover` labels used by the vault/drift/docs strict fallbacks.
54
+ [/\[(SPIRAL|FAILED)\]/, 'spiral-failed-auto-carry'],
55
+ [/\[Carryover\]/i, 'carryover-class'],
56
+ [/\btype::?carryover\b/i, 'carryover-class'],
57
+ [/(^|[\s,"'=])carryover([\s,"']|$)/i, 'carryover-class'],
58
+ [/(^|[\s,"'=])broken-window([\s,"']|$)/i, 'broken-window-closure'],
59
+ [/\[Backlog-Sammel\]/i, 'overflow-collector'],
60
+ ];
61
+
62
+ /**
63
+ * Decide whether an issue-create command bypasses the cap.
64
+ *
65
+ * @param {string} command — the full shell command (or a reconstructed argv join)
66
+ * @returns {{ exempt: boolean, reason: string|null }}
67
+ */
68
+ export function classifyExemption(command) {
69
+ if (typeof command !== 'string' || command.length === 0) {
70
+ return { exempt: false, reason: null };
71
+ }
72
+ for (const [re, reason] of EXEMPT_RULES) {
73
+ if (re.test(command)) return { exempt: true, reason };
74
+ }
75
+ return { exempt: false, reason: null };
76
+ }
77
+
78
+ /**
79
+ * Load the `issue-budget` config from the repo's instruction file
80
+ * (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
81
+ * `parseSessionConfig()` is deliberately NOT used here so the hook path does
82
+ * not pay for host-path resolution and owner.yaml I/O on every Bash call.
83
+ *
84
+ * @param {string} repoRoot
85
+ * @returns {{ "max-per-session": number, mode: string, overflow: string }}
86
+ */
87
+ export function loadIssueBudgetConfig(repoRoot) {
88
+ const defaults = { 'max-per-session': 12, mode: 'strict', overflow: 'collect-issue' };
89
+ try {
90
+ const resolved = resolveInstructionFile(repoRoot);
91
+ if (!resolved) return defaults;
92
+ return _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
93
+ } catch {
94
+ return defaults;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Absolute path of the runtime counter file for a repo.
100
+ * @param {string} repoRoot
101
+ * @returns {string}
102
+ */
103
+ export function budgetStatePath(repoRoot) {
104
+ return path.join(repoRoot, BUDGET_STATE_REL);
105
+ }
106
+
107
+ /**
108
+ * Read the counter file. A missing, malformed, or foreign-session file yields
109
+ * a fresh zeroed state for `sessionId` — the counter is per session by
110
+ * construction, so a new session never inherits the previous session's spend.
111
+ *
112
+ * @param {string} repoRoot
113
+ * @param {string|null} sessionId
114
+ * @returns {{ sessionId: string|null, count: number, exempt: number, overflow: object[] }}
115
+ */
116
+ export function readBudgetState(repoRoot, sessionId) {
117
+ const fresh = { sessionId: sessionId ?? null, count: 0, exempt: 0, overflow: [] };
118
+ const file = budgetStatePath(repoRoot);
119
+ if (!existsSync(file)) return fresh;
120
+ try {
121
+ const data = JSON.parse(readFileSync(file, 'utf8'));
122
+ if (!data || typeof data !== 'object') return fresh;
123
+ if (sessionId && data.sessionId && data.sessionId !== sessionId) return fresh;
124
+ return {
125
+ sessionId: data.sessionId ?? sessionId ?? null,
126
+ count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
127
+ exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
128
+ overflow: Array.isArray(data.overflow) ? data.overflow : [],
129
+ };
130
+ } catch {
131
+ return fresh;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Persist the counter file. Best-effort: a write failure never blocks a
137
+ * creation (fail-open), it only means the count is under-reported.
138
+ *
139
+ * @param {string} repoRoot
140
+ * @param {object} state
141
+ * @returns {boolean} true on success
142
+ */
143
+ export function writeBudgetState(repoRoot, state) {
144
+ const res = writeJsonAtomicSync(budgetStatePath(repoRoot), state, {
145
+ tmpPrefix: '.issue-budget',
146
+ });
147
+ return res.ok === true;
148
+ }
149
+
150
+ /**
151
+ * Charge one issue creation against the session budget and return the verdict.
152
+ *
153
+ * Decision table:
154
+ * mode `off` → `{ decision: 'off' }`, no state written
155
+ * exempt command → `{ decision: 'exempt' }`, `exempt` counter++
156
+ * count < max → `{ decision: 'allow' }`, `count`++
157
+ * count >= max, mode `warn` → `{ decision: 'warn' }`, `count`++ (creation proceeds)
158
+ * count >= max, mode `strict` → `{ decision: 'block' }`, overflow record appended
159
+ *
160
+ * In `strict` the blocked creation is NOT counted (it never happened); the
161
+ * request is parked in `overflow[]` so session-end can file exactly one
162
+ * collector issue instead of losing the item.
163
+ *
164
+ * @param {{
165
+ * repoRoot: string,
166
+ * sessionId?: string|null,
167
+ * command: string,
168
+ * title?: string|null,
169
+ * config?: { "max-per-session": number, mode: string, overflow: string },
170
+ * now?: string,
171
+ * }} opts
172
+ * @returns {{
173
+ * decision: 'off'|'exempt'|'allow'|'warn'|'block',
174
+ * count: number,
175
+ * max: number,
176
+ * mode: string,
177
+ * overflowSink: string,
178
+ * overflowPath: string,
179
+ * overflowCount: number,
180
+ * reason?: string|null,
181
+ * }}
182
+ */
183
+ export function chargeIssueBudget({
184
+ repoRoot,
185
+ sessionId = null,
186
+ command,
187
+ title = null,
188
+ config,
189
+ now = new Date().toISOString(),
190
+ }) {
191
+ const cfg = config ?? loadIssueBudgetConfig(repoRoot);
192
+ const max = cfg['max-per-session'];
193
+ const mode = cfg.mode;
194
+ const overflowSink = cfg.overflow;
195
+ const overflowPath = budgetStatePath(repoRoot);
196
+
197
+ const base = { max, mode, overflowSink, overflowPath };
198
+
199
+ if (mode === 'off') {
200
+ return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
201
+ }
202
+
203
+ const state = readBudgetState(repoRoot, sessionId);
204
+ state.sessionId = sessionId ?? state.sessionId;
205
+
206
+ const { exempt, reason } = classifyExemption(command);
207
+ if (exempt) {
208
+ state.exempt += 1;
209
+ writeBudgetState(repoRoot, state);
210
+ return {
211
+ ...base,
212
+ decision: 'exempt',
213
+ count: state.count,
214
+ overflowCount: state.overflow.length,
215
+ reason,
216
+ };
217
+ }
218
+
219
+ if (state.count < max) {
220
+ state.count += 1;
221
+ writeBudgetState(repoRoot, state);
222
+ return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
223
+ }
224
+
225
+ if (mode === 'warn') {
226
+ state.count += 1;
227
+ writeBudgetState(repoRoot, state);
228
+ return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
229
+ }
230
+
231
+ // strict — park the request, do not count it.
232
+ state.overflow.push({
233
+ title: title ?? null,
234
+ command: String(command).slice(0, 500),
235
+ at: now,
236
+ });
237
+ writeBudgetState(repoRoot, state);
238
+ return {
239
+ ...base,
240
+ decision: 'block',
241
+ count: state.count,
242
+ overflowCount: state.overflow.length,
243
+ reason: null,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Human-readable block message. Shared by the hook (stderr + deny JSON) and the
249
+ * programmatic path so an agent sees the same instruction either way.
250
+ *
251
+ * @param {{ count: number, max: number, overflowPath: string, overflowSink: string, overflowCount: number }} v
252
+ * @returns {string}
253
+ */
254
+ export function formatBlockReason(v) {
255
+ const sink =
256
+ v.overflowSink === 'vault-note'
257
+ ? 'a note under `vault/00-inbox/`'
258
+ : 'ONE collector issue `[Backlog-Sammel] <session-id>, N zurückgestellte Punkte`';
259
+ return [
260
+ `issue-budget: session cap reached — ${v.count}/${v.max} issues already created.`,
261
+ `This request was NOT created. It is parked as overflow entry #${v.overflowCount} in:`,
262
+ ` ${v.overflowPath}`,
263
+ `session-end Phase 5 will fold all overflow entries into ${sink}. Nothing is lost.`,
264
+ `Exempt from the cap: priority::critical, the carryover class (SPIRAL/FAILED, [Carryover]),`,
265
+ `and broken-window closure issues — those are never deferred.`,
266
+ `To raise the cap for this repo, edit \`issue-budget.max-per-session\` in the Session Config;`,
267
+ `\`mode: warn\` reports without blocking, \`mode: off\` disables the gate.`,
268
+ ].join('\n');
269
+ }