session-orchestrator 4.1.0 → 5.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * post-bash-issue-budget-refund.mjs — PostToolUseFailure hook: gives a budget
4
+ * slot back when the `gh|glab issue create` it was booked for did NOT run (#1347).
5
+ *
6
+ * Compensating half of `hooks/pre-bash-issue-budget.mjs`. That hook charges in
7
+ * PreToolUse — BEFORE the command runs — which is the only place a QUANTITY gate
8
+ * can block. The cost is that a create which fails (network, rejected label,
9
+ * expired auth) has already spent its slot, so the retry spends a second one:
10
+ * the same issue consumed two of `issue-budget.max-per-session`, and under
11
+ * `mode: strict` that pushes a legitimate issue into overflow parking.
12
+ *
13
+ * Decision flow (mirrors the pre-hook's gate numbering where it applies):
14
+ * G1 tool filter — only Bash.
15
+ * G2 command is a non-empty string.
16
+ * G2b FAILURE filter — a failure FIELD is required: a non-zero `exit_code`, a
17
+ * non-empty `error`, or `is_error === true` (top level or inside
18
+ * `tool_response`). The EVENT NAME is deliberately NOT a signal — see
19
+ * {@link isFailure}.
20
+ * G3 matcher — the SAME `findIssueCreateStatements` the pre-hook charges with,
21
+ * so the two can never drift on what counts as an issue-create call. One
22
+ * refund per issue-create STATEMENT, which is exactly the unit G6 charged.
23
+ * G3b ATTRIBUTION — one exit code covers the whole Bash call, so it is evidence
24
+ * about the create only when the create statements ARE the whole command
25
+ * (`statementsCoverWholeCommand`). Measured 2026-09-13: `glab issue create
26
+ * --title X && false` files the issue, exits 1, and the first cut of this
27
+ * hook refunded a slot for it. Anything else → no-op,
28
+ * `chain-not-attributable`. EVALUATED AFTER G4 in the code: G4 is the
29
+ * cheaper and more absolute gate, and a repo with the budget off must not
30
+ * report a chain verdict it has no stake in.
31
+ * G4 config — `mode: off` → nothing (there was no charge to give back), and
32
+ * that includes the telemetry: EVERY branch that can emit reads the config
33
+ * first and returns on `off` (see {@link loadBudgetContext}).
34
+ * G5 refund — `refundBooking` per statement, honoured ONLY against a statement
35
+ * present in the session's `charged[]` ledger. A pre-execution failure
36
+ * (parked at the cap and denied, `exit_code: null`) therefore refunds
37
+ * nothing WITHOUT a heuristic here: those statements were never charged.
38
+ * Exemption unit and the never-below-zero / never-touch-`overflow[]`
39
+ * invariants live in the shared core (`scripts/lib/issue-budget.mjs`).
40
+ *
41
+ * Fail-safe posture: every path exits 0 and emits NOTHING on stdout. This hook
42
+ * runs after the tool has already run; it has no decision to make and must not
43
+ * alter the tool result. Internal errors are swallowed in main().catch.
44
+ *
45
+ * TELEMETRY (#1353). Every decision branch that concerns an issue-create command
46
+ * also emits ONE `orchestrator.issue_budget.refunded` record — see
47
+ * {@link emitRefundDecision}. Before that, a refund left only a stderr line,
48
+ * which under exit 0 reaches the debug log alone: refunds were uncountable, so
49
+ * "how often does this fire, and for which reason" was unfalsifiable
50
+ * (`.claude/rules/host-resources.md` HR-105). The no-op reasons are emitted too —
51
+ * without them the census has a numerator and no denominator. A repo with
52
+ * `issue-budget.mode: off` contributes NO record on any branch: it never charged,
53
+ * so its decisions are not part of the population the census describes, and
54
+ * counting them would invert exactly the numerator/denominator argument above.
55
+ */
56
+
57
+ import { readStdin } from '../scripts/lib/io.mjs';
58
+ import { resolveProjectDir } from '../scripts/lib/platform.mjs';
59
+ import { readJson } from '../scripts/lib/common.mjs';
60
+ import {
61
+ findIssueCreateStatements,
62
+ statementsCoverWholeCommand,
63
+ } from './_lib/vcs-create-matcher.mjs';
64
+ import {
65
+ loadIssueBudgetConfig,
66
+ resolveIssueBudgetSessionId,
67
+ refundBooking,
68
+ } from '../scripts/lib/issue-budget.mjs';
69
+
70
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
71
+ import { existsSync } from 'node:fs';
72
+ import path from 'node:path';
73
+
74
+ // Opt-out per session via SO_DISABLED_HOOKS=post-bash-issue-budget-refund; the
75
+ // "minimal"/"off" profiles disable it like every other non-core hook.
76
+ if (!shouldRunHook('post-bash-issue-budget-refund')) process.exit(0);
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Resolve the accounting session key exactly as the charging hook does — the
84
+ * refund has to land in the SAME per-session counter file the charge wrote, so
85
+ * this resolution order (stdin id > `CLAUDE_CODE_SESSION_ID`, then the
86
+ * `current-session.json` semantic-id bridge) is a contract, not a preference.
87
+ *
88
+ * @param {object|null} input
89
+ * @param {string|null} projectDir
90
+ * @returns {Promise<string|null>}
91
+ */
92
+ async function resolveSessionId(input, projectDir) {
93
+ const stdinRawId = input?.session_id ?? input?.sessionId ?? null;
94
+ const nativeRawId =
95
+ typeof stdinRawId === 'string' && stdinRawId.length > 0
96
+ ? stdinRawId
97
+ : (process.env.CLAUDE_CODE_SESSION_ID ?? null);
98
+ if (typeof nativeRawId !== 'string' || nativeRawId.length === 0) return null;
99
+
100
+ let currentSession = null;
101
+ if (projectDir) {
102
+ const persisted = path.join(projectDir, '.orchestrator', 'current-session.json');
103
+ if (existsSync(persisted)) {
104
+ try {
105
+ currentSession = await readJson(persisted);
106
+ } catch {
107
+ // Malformed or unreadable records conservatively retain the raw key.
108
+ }
109
+ }
110
+ }
111
+ return resolveIssueBudgetSessionId(nativeRawId, currentSession);
112
+ }
113
+
114
+ /**
115
+ * Did the tool call this payload describes FAIL?
116
+ *
117
+ * ONLY a failure FIELD counts. The event name does NOT: `PostToolUseFailure`
118
+ * also carries PRE-EXECUTION failures (measured: `corrective_context` entries
119
+ * with `exit_code: null` for calls that never ran), so "the event fired" is not
120
+ * evidence that the create ran and failed. The sibling
121
+ * `hooks/post-tool-failure-corrective-context.mjs` reads only `exit_code` /
122
+ * `error` for the same reason.
123
+ *
124
+ * `is_error` is read at BOTH levels. Top level is not speculation: the Cursor
125
+ * bridge builds its canonical payload with `is_error: eventObject.is_error ??
126
+ * eventObject.isError` at TOP level and forwards no `exit_code` and no `error`
127
+ * key at all (`scripts/lib/cursor-hook-bridge.mjs`
128
+ * `normalizeCursorHookPayload`) — so for a Cursor-bridged `postToolUseFailure`
129
+ * the top-level flag is the ONLY failure field that can arrive.
130
+ *
131
+ * A payload whose event name claims a failure while carrying no failure field is
132
+ * a deliberate but LOUD no-op (see the WARN in main()). Silence there is the
133
+ * "built but never switched on" class this hook was caught in.
134
+ *
135
+ * @param {object} input
136
+ * @returns {boolean}
137
+ */
138
+ function isFailure(input) {
139
+ if (typeof input?.exit_code === 'number' && input.exit_code !== 0) return true;
140
+ if (typeof input?.error === 'string' && input.error.trim().length > 0) return true;
141
+ if (input?.is_error === true) return true;
142
+ const response = input?.tool_response;
143
+ if (response && typeof response === 'object' && response.is_error === true) return true;
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Does this payload CLAIM a failure by event name? Used ONLY to decide whether a
149
+ * missing failure field deserves a WARN — an unclaimed success needs none.
150
+ *
151
+ * @param {object} input
152
+ * @returns {boolean}
153
+ */
154
+ function claimsFailure(input) {
155
+ const event = input?.hook_event_name ?? input?.hookEventName ?? null;
156
+ return event === 'PostToolUseFailure' || input?.cursor_event_name === 'postToolUseFailure';
157
+ }
158
+
159
+ /**
160
+ * The harness's id for THIS tool call, when it publishes one — the first half of
161
+ * a charge record's identity (`bookingId` in the shared core). Absent, the core
162
+ * matches the deterministic session+command+index key instead. Several spellings
163
+ * are accepted because the payload key is the harness's to choose; this resolver
164
+ * is kept identical to the pre-hook's copy, since the two must read the same id
165
+ * out of the same payload or the refund cannot find the charge.
166
+ *
167
+ * @param {object} input
168
+ * @returns {string|null}
169
+ */
170
+ function resolveToolCallId(input) {
171
+ for (const value of [input?.tool_use_id, input?.toolUseId, input?.tool_call_id, input?.tool_id]) {
172
+ if (typeof value === 'string' && value.length > 0) return value;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Resolve the repo root AND its issue-budget config together — the pair every
179
+ * emitting branch needs, and the reason G4 can be honoured before the FIRST emit
180
+ * rather than only before the refund (#1353 fix-pass).
181
+ *
182
+ * Deliberately NOT hoisted to the top of main(): the config read is a file read,
183
+ * and G1/G2 plus "failed command creates no issue" must stay allocation-free.
184
+ * Every caller sits behind a matcher hit, so an ordinary failing Bash call still
185
+ * pays nothing.
186
+ *
187
+ * @returns {{ projectDir: string, config: ReturnType<typeof loadIssueBudgetConfig> }}
188
+ */
189
+ function loadBudgetContext() {
190
+ const projectDir = resolveProjectDir() || process.cwd();
191
+ return { projectDir, config: loadIssueBudgetConfig(projectDir) };
192
+ }
193
+
194
+ /**
195
+ * Event name for the refund decision (#1353). One record per decision, so a
196
+ * census over N sessions can group by `reason` and see the whole population.
197
+ */
198
+ const ISSUE_BUDGET_REFUNDED_EVENT = 'orchestrator.issue_budget.refunded';
199
+
200
+ /**
201
+ * Emit ONE `orchestrator.issue_budget.refunded` record for this invocation.
202
+ *
203
+ * `reason` is a CLOSED enum, mapped onto the hook's branches:
204
+ * `no-signal` — G2b: the payload carries no failure FIELD, so
205
+ * there is no evidence the create ran and failed.
206
+ * `chain-not-attributable` — G3b: the failed command mixes the create with
207
+ * other statements, so the exit code judges neither.
208
+ * `refunded` — G5: at least one charge record was honoured.
209
+ * `not-charged` — G5: no charge record matched (parked at the cap,
210
+ * re-delivered failure, or an identity-less call
211
+ * that was never persisted → `no-session`).
212
+ * The fifth documented reason, `counter-at-zero`, has NO branch here: a matched
213
+ * record whose counter is already 0 is absorbed inside `refundBooking`'s
214
+ * never-below-zero guard and returns `refunded` like any other match, and an
215
+ * UNmatched record returns `noop`/`not-charged`. Surfacing it would need a new
216
+ * field on the shared core's verdict (`scripts/lib/issue-budget.mjs`), so it is
217
+ * reported rather than faked from a value this hook cannot observe.
218
+ *
219
+ * PRIVACY: the payload carries NO command text, issue title or path. This record
220
+ * travels verbatim over the optional Clank webhook with no redaction (same rule
221
+ * as `orchestrator.issue_budget.reconciled`), where a `glab issue create --title
222
+ * …` string or an absolute ledger path is owner data the receiver has no use for.
223
+ *
224
+ * Awaited AND caught: telemetry added to a hook silently disarms it unless both
225
+ * hold — an unawaited promise loses the write when the process exits, and a
226
+ * throwing emit (an unwritable ledger, a schema rejection) would otherwise reach
227
+ * `main().catch` and turn a completed refund into a reported internal error.
228
+ * `events.mjs` is imported LAZILY so the pass-through paths (G1/G2, the
229
+ * overwhelming majority of Bash calls) never pay its module-load cost.
230
+ *
231
+ * @param {string} repoRoot
232
+ * @param {'no-signal'|'chain-not-attributable'|'refunded'|'not-charged'} reason
233
+ * @param {number} statementCount — issue-create statements the matcher found.
234
+ * @param {'count'|'exempt'|null} unit — which counter was given back, read off
235
+ * the honoured charge records (`exempt` only when EVERY refund was an exempt
236
+ * one); `null` whenever nothing was refunded.
237
+ * @returns {Promise<void>}
238
+ */
239
+ async function emitRefundDecision(repoRoot, reason, statementCount, unit) {
240
+ try {
241
+ const { emitEvent, sessionAttribution } = await import('../scripts/lib/events.mjs');
242
+ await emitEvent(
243
+ ISSUE_BUDGET_REFUNDED_EVENT,
244
+ {
245
+ reason,
246
+ unit,
247
+ statement_count: statementCount,
248
+ ...sessionAttribution(repoRoot),
249
+ },
250
+ { repoRoot },
251
+ );
252
+ } catch {
253
+ // Best-effort telemetry — never the reason a refund reports failure.
254
+ }
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Main
259
+ // ---------------------------------------------------------------------------
260
+
261
+ async function main() {
262
+ const input = await readStdin();
263
+ if (!input) return;
264
+
265
+ // G1 — only Bash is gated (the pre-hook charges only Bash).
266
+ if (input.tool_name !== 'Bash') return;
267
+
268
+ // G2 — command must be a non-empty string.
269
+ const command = input?.tool_input?.command;
270
+ if (typeof command !== 'string' || command.length === 0) return;
271
+
272
+ // G2b — only a FAILED invocation is compensated, and only on the evidence of a
273
+ // failure FIELD. A payload claiming failure by event name while carrying none
274
+ // is reported rather than silently dropped.
275
+ if (!isFailure(input)) {
276
+ if (claimsFailure(input)) {
277
+ process.stderr.write(
278
+ '⚠ post-bash-issue-budget-refund: failure event carried no failure field ' +
279
+ '(exit_code / error / is_error) — nothing refunded. On the Cursor bridge only ' +
280
+ 'top-level `is_error` is forwarded; without it no refund is possible.\n',
281
+ );
282
+ // The matcher runs here ONLY to scope the record: a `no-signal` line for
283
+ // every failing Bash call would drown the census in commands the budget
284
+ // never charged. The hook is registered on PostToolUseFailure alone
285
+ // (hooks/hooks.json), so this is not a hot path for ordinary tool calls.
286
+ const claimed = findIssueCreateStatements(command);
287
+ if (claimed.length > 0) {
288
+ // G4 ahead of the emit: a repo with the budget OFF was never charged, so
289
+ // it is not part of the refund census (docs/events-schema.md).
290
+ const { projectDir, config } = loadBudgetContext();
291
+ if (config.mode !== 'off') {
292
+ await emitRefundDecision(projectDir, 'no-signal', claimed.length, null);
293
+ }
294
+ }
295
+ }
296
+ return;
297
+ }
298
+
299
+ // G3 — shared matcher, per statement: the charge unit is the refund unit.
300
+ const statements = findIssueCreateStatements(command);
301
+ if (statements.length === 0) return;
302
+
303
+ // G4 — config, read BEFORE the first emit below. `off` means nothing was ever
304
+ // charged, so there is neither a slot to give back nor a decision to count.
305
+ const { projectDir, config } = loadBudgetContext();
306
+ if (config.mode === 'off') return;
307
+
308
+ // G3b — one exit code judges the WHOLE call, so it is evidence about the
309
+ // create only when the creates ARE the whole call.
310
+ if (!statementsCoverWholeCommand(command)) {
311
+ process.stderr.write(
312
+ 'ℹ post-bash-issue-budget-refund: failed command mixes an issue-create with other ' +
313
+ 'statements — chain-not-attributable, no slot refunded.\n',
314
+ );
315
+ await emitRefundDecision(projectDir, 'chain-not-attributable', statements.length, null);
316
+ return;
317
+ }
318
+
319
+ const sessionId = await resolveSessionId(input, projectDir);
320
+ const toolCallId = resolveToolCallId(input);
321
+
322
+ // G5 — one refund per statement, each honoured ONLY against the charge record
323
+ // the pre-hook wrote for the same (tool-call id, statement index) pair. The
324
+ // record is REMOVED when honoured, so a re-delivered failure of the same call
325
+ // gives back nothing; a statement that was PARKED instead of charged has no
326
+ // record at all and is `not-charged`.
327
+ const verdicts = statements.map((s, i) =>
328
+ refundBooking({
329
+ repoRoot: projectDir,
330
+ sessionId,
331
+ command: s.text,
332
+ toolCallId,
333
+ statementIndex: i,
334
+ config,
335
+ }),
336
+ );
337
+
338
+ const refunded = verdicts.filter(
339
+ (v) => v.decision === 'refunded' || v.decision === 'refunded-exempt',
340
+ );
341
+ if (refunded.length > 0) {
342
+ const last = refunded[refunded.length - 1];
343
+ // stderr only: PostToolUse-family stdout would be an envelope Claude reads
344
+ // as a decision, and this hook has none to make.
345
+ process.stderr.write(
346
+ `ℹ post-bash-issue-budget-refund: failed issue-create — ${refunded.length} slot(s) ` +
347
+ `refunded (${last.count}/${last.max})\n`,
348
+ );
349
+ }
350
+
351
+ // The unit is read off the honoured records, never re-classified from the
352
+ // command text: `exempt` only when EVERY refund landed on the exempt counter,
353
+ // so a mixed chain (one capped + one exempt create) reports the capped unit —
354
+ // that is the counter the operator's cap is spent from.
355
+ const unit =
356
+ refunded.length === 0
357
+ ? null
358
+ : refunded.every((v) => v.decision === 'refunded-exempt')
359
+ ? 'exempt'
360
+ : 'count';
361
+ await emitRefundDecision(
362
+ projectDir,
363
+ refunded.length > 0 ? 'refunded' : 'not-charged',
364
+ statements.length,
365
+ unit,
366
+ );
367
+ }
368
+
369
+ // Top-level error handler — fail open, same posture as the sibling hooks.
370
+ main().catch((e) => {
371
+ process.stderr.write(
372
+ `⚠ post-bash-issue-budget-refund: internal error — ${e?.message || e}\n`,
373
+ );
374
+ process.exit(0);
375
+ });
@@ -2,10 +2,11 @@
2
2
  /**
3
3
  * pre-auq-clarity.mjs — PreToolUse hook on `AskUserQuestion`.
4
4
  *
5
- * Checks the questions this system is about to put in front of the operator,
6
- * at the moment they are asked, and blocks the two that the tool itself
7
- * mangles: a header longer than the tool renders (H1) and an option count /
8
- * recommendation placement the operator cannot weigh (H2).
5
+ * Checks the questions this system is about to put in front of the operator, at
6
+ * the moment they are asked. It BLOCKS exactly one thing an option count or
7
+ * recommendation placement the operator cannot weigh (H2, `BLOCKING_HURDLES`);
8
+ * everything else it finds, including an over-budget header (H1), it reports on
9
+ * stderr and in telemetry and lets through.
9
10
  *
10
11
  * ## Why this exists beside the template gate
11
12
  *
@@ -36,7 +37,7 @@
36
37
  *
37
38
  * | class | decision | why |
38
39
  * |----------------------------------------|----------|-----|
39
- * | H1 — header over 12 codepoints | **DENY** | the tool truncates it, INVISIBLY: the operator sees a mangled headline and cannot tell it was cut. Auto-shortening loses meaning the same invisible way a model can write a good short header, a regex cannot. |
40
+ * | H1 — header over 12 codepoints | ALLOW | reported, never blocked. The "the tool truncates it" premise is refuted: `rdr=12` occurs only in the schema's prose, and 125 over-12 headers were accepted and answered. See `BLOCKING_HURDLES`. |
40
41
  * | H2 — options per question / recommendation not first | **DENY** | dropping or reordering options is a meaning decision, never a normalisation |
41
42
  * | K1, K3, K4, K7, K8 (content criteria) | ALLOW | measured false-positive rates 14–25 %. A hook that blocks one correct question in four is switched off within a month — and takes the hard limits with it. Reported on stderr, never denied. |
42
43
  * | K2, and every `warn` finding | ALLOW | advisory by construction (`CRITERIA.K2.weight === 0`) |
@@ -65,10 +66,6 @@
65
66
  * because every repair the two hurdles admit costs meaning that the operator
66
67
  * cannot see going missing:
67
68
  *
68
- * - shortening a 23-character header — which 11 characters are the ones to
69
- * lose? The operator would read a truncated headline and have no way to know
70
- * it was truncated. That is the exact failure H1 exists to prevent, moved
71
- * one layer earlier.
72
69
  * - moving the recommended option to position 1 — the ORDER is content. A
73
70
  * description written for position 3 can refer to the options above it;
74
71
  * lifting it changes what the operator reads first, silently.
@@ -190,13 +187,52 @@ export const MAX_LINE_CHARS = 300;
190
187
  */
191
188
  export const MAX_FINDING_LINES = 16;
192
189
 
190
+ /**
191
+ * The hurdles this hook is allowed to DENY on. Everything else the scorer flags
192
+ * as a hurdle is reported and let through.
193
+ *
194
+ * H2 is here because its cap is REAL: the shipped Zod schema declares
195
+ * `options: T(e).min(2).max(4)`, so a five-option question is rejected by the
196
+ * tool itself — denying early only replaces one error with a better-worded one.
197
+ *
198
+ * H1 is deliberately NOT here, and the reason is a measurement that refutes the
199
+ * premise it was built on. `schema.mjs` justified the deny with "the tool
200
+ * truncates — the operator never sees the rest". In the shipped bundle
201
+ * (2.1.268) the 12 exists as `rdr=12` and is referenced in exactly ONE place:
202
+ * the schema's `describe()` PROSE. There is no `.max(12)`, and no render or
203
+ * truncate path reads it. Measured against the real transcripts
204
+ * (`~/.claude/projects/**\/*.jsonl`, since 2026-06-01), questions whose header
205
+ * exceeded 12 codepoints:
206
+ *
207
+ * accepted by the tool and answered by the operator : 125
208
+ * tool errors not originating from this hook : 0
209
+ * denied by this hook : 103
210
+ *
211
+ * So the only thing that ever destroyed one of those questions was this hook.
212
+ * Fleet-wide it denied 13.0 % of all calls (146 of 1121, 2026-08 → 2026-09),
213
+ * 72 % of them on H1 — above the ~10 % line at which
214
+ * `.claude/rules/host-resources.md` HR-101 calls a class a broken instrument.
215
+ * And the 12 is unit-mismatched (HR-103): it comes from English chip examples
216
+ * (`Auth method` = 11), while 77 % of the breaks in a German corpus miss it by
217
+ * one or two codepoints (`Sitzungsdauer` = 13).
218
+ *
219
+ * H1 stays a hurdle in `scripts/lib/auq/schema.mjs` on purpose: the TEMPLATE
220
+ * gate (`scripts/auq-audit.mjs`) acts on text in a file, where an author can
221
+ * pick a shorter header at no cost and nothing is destroyed by the refusal.
222
+ *
223
+ * REVISIT TRIGGER: a measurement showing the tool actually truncating a header
224
+ * the operator then answered wrongly. That would move H1 back — as a hurdle
225
+ * with evidence, which it has never had.
226
+ */
227
+ export const BLOCKING_HURDLES = Object.freeze(['H2']);
228
+
193
229
  /**
194
230
  * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993).
195
231
  */
196
232
  const GUARD_CONSEQUENCE = {
197
233
  inactive: [
198
234
  ' Consequence: runtime AskUserQuestion clarity checking is OFF — a question',
199
- ' with a truncated header or an unweighable option set CAN now reach the',
235
+ ' with an unweighable option set CAN now reach the',
200
236
  ' operator unchecked. Template-level checking is unaffected. This is a',
201
237
  ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
202
238
  ],
@@ -405,9 +441,9 @@ function renderFinding(f, questionNo) {
405
441
  /**
406
442
  * Decide whether this `AskUserQuestion` call may proceed.
407
443
  *
408
- * Denies if and only if at least one HARD HURDLE (H1/H2) is broken on at least
409
- * one question. Content criteria are collected into `notes` and never affect the
410
- * action.
444
+ * Denies if and only if at least one BLOCKING hurdle (`BLOCKING_HURDLES` = H2)
445
+ * is broken on at least one question. Content criteria, and every hurdle outside
446
+ * that set, are collected into `notes` and never affect the action.
411
447
  *
412
448
  * ## Known limitation, with its revisit trigger (BV-004)
413
449
  *
@@ -550,14 +586,30 @@ export function decide(input, lib) {
550
586
  // has ever measured it on live runtime questions. `soft` is what makes that
551
587
  // measurable: a criterion that fires on almost every real question is a broken
552
588
  // instrument by HR-101 and must be re-aimed, not promoted to a hurdle.
589
+ // Split BEFORE the telemetry is built, so both halves are recorded from the
590
+ // same maps the verdict is derived from (see BLOCKING_HURDLES).
591
+ const blocking = [...broken.entries()].filter(([id]) => BLOCKING_HURDLES.includes(id));
592
+ const advisory = [...broken.entries()].filter(([id]) => !BLOCKING_HURDLES.includes(id));
593
+
594
+ if (advisory.length > 0) {
595
+ notes.push(
596
+ `Beratende Grenze(n) ${advisory.map(([id]) => id).join(', ')} gerissen — NICHT blockiert. `
597
+ + 'Die Vorlagenprüfung (scripts/auq-audit.mjs) hält sie weiterhin hart.',
598
+ );
599
+ }
600
+
553
601
  const telemetry = {
554
602
  questions: toolInput.questions.length,
555
603
  skipped,
604
+ // EVERY broken hurdle, blocking or not — the field keeps its historical
605
+ // meaning, so a fleet tally over the whole store stays comparable across the
606
+ // change that made H1 advisory. `blocking` is the subset that decided.
556
607
  hurdles: [...broken.keys()],
608
+ blocking: blocking.map(([id]) => id),
557
609
  soft: Object.fromEntries([...softByCriterion.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
558
610
  };
559
611
 
560
- if (broken.size === 0) return allow(telemetry);
612
+ if (blocking.length === 0) return allow(telemetry);
561
613
 
562
614
  const sections = [];
563
615
  let used = 0;
@@ -577,7 +629,7 @@ export function decide(input, lib) {
577
629
  // Ablehnung, und der Operator sieht keine der beiden Fragen.
578
630
  //
579
631
  // Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
580
- const groups = [...broken.entries()];
632
+ const groups = blocking;
581
633
  // Reihum über FRAGEN, nicht der Reihe nach über Zeilen: erst bekommt jede
582
634
  // Frage jeder Gruppe ihre erste Zeile, dann die zweite, und so fort bis das
583
635
  // Budget alle ist. Damit ist garantiert, dass jede Frage mit gerissener Hürde
@@ -612,10 +664,10 @@ export function decide(input, lib) {
612
664
  // sentence that is identical for every deny in front of the human, and the
613
665
  // one thing they need — WHICH limit broke — past the 200-char clip.
614
666
  const reason =
615
- `AskUserQuestion blockiert: harte Grenze ${[...broken.keys()].join(' + ')} gerissen — ` +
667
+ `AskUserQuestion blockiert: harte Grenze ${groups.map(([id]) => id).join(' + ')} gerissen — ` +
616
668
  'so erreicht die Frage den Operator nicht.\n\n' +
617
- 'Das ist keine Stilfrage: das Tool schneidet eine zu lange Kopfzeile selbst ab, und ' +
618
- 'mehr als vier Optionen kann niemand gegeneinander abwägen.\n\n' +
669
+ 'Das ist keine Stilfrage: das Tool selbst nimmt nur zwei bis vier Optionen je Frage an, ' +
670
+ 'und mehr als vier kann niemand gegeneinander abwägen.\n\n' +
619
671
  `${sections.join('\n\n')}${tail}`;
620
672
 
621
673
  const suggestion =