session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-auq-clarity.mjs — PreToolUse hook on `AskUserQuestion`.
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).
9
+ *
10
+ * ## Why this exists beside the template gate
11
+ *
12
+ * Wave 2 built the deterministic scorer (`scripts/lib/auq/`), wave 3 brought all
13
+ * 72 TEMPLATES in the repo to 100 %, and a sibling gate now stops a NEW template
14
+ * landing with a broken hurdle. All three act on text that sits in a file.
15
+ *
16
+ * The other half never does: the coordinator formulates questions freshly in
17
+ * every session, from prose, and no template gate can see them. This hook is the
18
+ * only point at which a runtime-composed question can be measured before the
19
+ * operator reads it.
20
+ *
21
+ * ## EVIDENCE STATUS — code-evidence, not runtime-evidence
22
+ *
23
+ * That `AskUserQuestion` reaches `PreToolUse` at all was read out of the shipped
24
+ * Claude Code bundle (2.1.239): exactly one tool is exempted from the hook path
25
+ * (`EndConversation`), and the stdin envelope carries `tool_name` +
26
+ * `tool_input`, with the whole `questions` array inside `tool_input`.
27
+ *
28
+ * **No hook with a matcher of `AskUserQuestion` has ever run in this repo.** The
29
+ * end-to-end proof needs a session restart and is OUTSTANDING. What IS proven
30
+ * without one is everything downstream of stdin — the hook was driven with real
31
+ * envelopes on stdin and its allow/deny envelopes read back (see the test file).
32
+ * Treat the delivery of the envelope as expected-but-unconfirmed and the
33
+ * decision logic as measured.
34
+ *
35
+ * ## What blocks, and what deliberately does not
36
+ *
37
+ * | class | decision | why |
38
+ * |----------------------------------------|----------|-----|
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
+ * | H2 — options per question / recommendation not first | **DENY** | dropping or reordering options is a meaning decision, never a normalisation |
41
+ * | 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
+ * | K2, and every `warn` finding | ALLOW | advisory by construction (`CRITERIA.K2.weight === 0`) |
43
+ * | anything this hook cannot fully parse | ALLOW | see § Fail direction |
44
+ *
45
+ * ## Fail direction — this guard fails OPEN, on purpose
46
+ *
47
+ * A false positive here destroys the operator's question: `emitDeny` blocks the
48
+ * tool call, so the card is never rendered and the operator is never asked. A
49
+ * false negative is a badly-worded question the operator can still answer. The
50
+ * blast radius is asymmetric, so every doubt resolves to ALLOW: an unrecognised
51
+ * payload shape, a malformed option, a scorer throw, a module that fails to
52
+ * load, an unreadable stdin. None of them denies.
53
+ *
54
+ * That extends to this hook's own adapter. A question is analysed only when its
55
+ * shape is FULLY recognised — `questions` an array, `options` an array, every
56
+ * option an object with a string label. Dropping one malformed option out of two
57
+ * would turn a legal question into an H2 break ("fewer than 2 options"), i.e. a
58
+ * deny manufactured by the adapter rather than found in the question. So a
59
+ * partially-recognised question is skipped whole.
60
+ *
61
+ * ## Why `emitRewrite` is NOT wired here
62
+ *
63
+ * `emitRewrite` (scripts/lib/io.mjs) can replace the tool input outright, which
64
+ * would let this hook repair instead of refuse. It is deliberately unused,
65
+ * because every repair the two hurdles admit costs meaning that the operator
66
+ * cannot see going missing:
67
+ *
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
+ * - moving the recommended option to position 1 — the ORDER is content. A
73
+ * description written for position 3 can refer to the options above it;
74
+ * lifting it changes what the operator reads first, silently.
75
+ * - deleting the 5th option, or one of two `(Recommended)` markers — which
76
+ * one was meant? Only the author knows.
77
+ *
78
+ * NAMED TRIGGER for switching it on: a finding whose repair has exactly ONE
79
+ * admissible target form AND removes, reorders or rewrites no model-authored
80
+ * text — i.e. a pure character normalisation inside a single field. None of the
81
+ * eight criteria currently produces one. Should `scripts/lib/auq/` ever gain such
82
+ * a finding class (a literal `\n` escape where a line break was meant is the
83
+ * shape to watch for), route exactly that class through `emitRewrite` and leave
84
+ * everything else on `emitDeny` — because for every other finding the MODEL can
85
+ * make the judgement call and a transform cannot.
86
+ *
87
+ * ## stdout discipline
88
+ *
89
+ * Under the exit-0 PreToolUse protocol (#906, ADR-0011) allow and deny share
90
+ * exit code 0; the decision lives only in the stdout JSON. `emitAllow` writes
91
+ * nothing, `emitDeny` writes through `writeStdoutLineSync` and clamps at
92
+ * 16 000 chars. This module never calls `console.log`. Diagnostics go to stderr,
93
+ * which under exit 0 is a DEBUG LOG ONLY — invisible to the operator (see
94
+ * `docs/plugin-architecture-v3.md`). Nothing here may rely on being read.
95
+ *
96
+ * ## Cost
97
+ *
98
+ * The protocol caps a call at 4 questions × 4 options
99
+ * (`.claude/rules/ask-via-tool.md` § AUQ-003); scoring that is microseconds. The
100
+ * only real cost is process start plus parsing the two `auq/` modules. No file
101
+ * is read, no subprocess is spawned, no git command runs — `hooks.json` allows
102
+ * 5 s and this path does not approach it.
103
+ *
104
+ * ## PSA
105
+ *
106
+ * No git command, read-only or otherwise (PSA-007). The only filesystem write is
107
+ * the append-only telemetry line to `.orchestrator/metrics/events.jsonl` (see
108
+ * § Telemetry) — never a repo file, and never anything a sibling session could
109
+ * be mid-edit on.
110
+ *
111
+ * ## Telemetry
112
+ *
113
+ * Every SCORED call emits exactly one `orchestrator.auq_clarity.*` event; the two
114
+ * fail-open paths emit one too, so an unchecked question is distinguishable from
115
+ * a checked-and-clean one after the fact. Without this the hook is unfalsifiable:
116
+ * allow and deny share exit code 0 under ADR-0011, so from outside the process
117
+ * "never fired" and "fired and allowed" are the SAME observation.
118
+ *
119
+ * The payload carries SHAPES ONLY — counts, hurdle ids, criterion tallies. No
120
+ * header, label, description or question text ever leaves this process. That is
121
+ * a privacy boundary, not a size optimisation: the operator's question text is
122
+ * the one thing a clarity guard necessarily sees in full, and `events.jsonl` is
123
+ * read by fleet-wide audits that were never scoped to hold it.
124
+ *
125
+ * Telemetry is best-effort at every call site and can never alter the verdict —
126
+ * awaited BEFORE the terminal emit, because `emitAllow`/`emitDeny` call
127
+ * `process.exit()` and never return, which would discard a pending async write.
128
+ *
129
+ * hooks.json registration is NOT part of this file's change set — see the
130
+ * sibling agent's wiring change.
131
+ */
132
+
133
+ import path from 'node:path';
134
+ import { pathToFileURL, fileURLToPath } from 'node:url';
135
+ import { realpathSync } from 'node:fs';
136
+
137
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Late-bound repo dependencies (#993)
141
+ //
142
+ // Static imports fail at ESM LINK time: node exits 1 with 0 bytes on stdout, and
143
+ // under the exit-0 protocol a 0-byte stdout is indistinguishable from an explicit
144
+ // allow — the guard would fail open AND silently. Binding late turns the
145
+ // link-time crash into a catchable runtime error, which is what makes the GUARD
146
+ // INACTIVE banner reachable at all.
147
+ // ---------------------------------------------------------------------------
148
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
149
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
150
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
151
+ /** @type {typeof import('../scripts/lib/events.mjs').emitEvent} */ let emitEvent;
152
+ /** @type {typeof import('../scripts/lib/auq/schema.mjs')} */ let schemaMod;
153
+ /** @type {typeof import('../scripts/lib/auq/clarity.mjs')} */ let clarityMod;
154
+
155
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
156
+
157
+ /** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
158
+ const HOOK_NAME = 'pre-auq-clarity';
159
+
160
+ /** The only tool this hook acts on. Everything else allows immediately. */
161
+ const AUQ_TOOL = 'AskUserQuestion';
162
+
163
+ /**
164
+ * `file` recorded on the synthesised `AuqQuestion`. The scorer's schema requires
165
+ * one; a runtime question has no source file, and a fake path would read as a
166
+ * real location in the finding text the model gets back.
167
+ */
168
+ export const RUNTIME_ORIGIN = '<AskUserQuestion>';
169
+
170
+ /**
171
+ * Per-line ceiling for a rendered finding, in characters.
172
+ *
173
+ * The outer clamp in `emitDeny` (16 000) already keeps the ENVELOPE inside the
174
+ * kernel pipe buffer, so this bound is not about delivery — it is about
175
+ * ORDERING. A finding message interpolates model-authored text (`Die Kopfzeile
176
+ * "…" hat N Zeichen`), so one pathological header could otherwise consume the
177
+ * whole budget and push every other hurdle's line past the outer clamp. 300 is
178
+ * ~1.5× the longest message this scorer produces at the protocol's own field
179
+ * caps, so no legitimate line is ever clipped.
180
+ */
181
+ export const MAX_LINE_CHARS = 300;
182
+
183
+ /**
184
+ * Ceiling on rendered finding lines across the whole call.
185
+ *
186
+ * Worst case at the protocol caps (4 questions, 4 options each) is roughly
187
+ * 4 × (1 header + 4 description + 1 payload + 1 count + 1 recommendation) = 32.
188
+ * 16 keeps the reason readable while still naming more than any single question
189
+ * can break; the overflow is COUNTED in the text, never silently dropped.
190
+ */
191
+ export const MAX_FINDING_LINES = 16;
192
+
193
+ /**
194
+ * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993).
195
+ */
196
+ const GUARD_CONSEQUENCE = {
197
+ inactive: [
198
+ ' Consequence: runtime AskUserQuestion clarity checking is OFF — a question',
199
+ ' with a truncated header or an unweighable option set CAN now reach the',
200
+ ' operator unchecked. Template-level checking is unaffected. This is a',
201
+ ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
202
+ ],
203
+ };
204
+
205
+ /**
206
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
207
+ * is one of the ones that may have failed to load.
208
+ *
209
+ * @returns {string}
210
+ */
211
+ function bannerProjectDir() {
212
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
213
+ }
214
+
215
+ /**
216
+ * Bind every repo dependency late, making a load failure VISIBLE instead of a
217
+ * silent exit-1 disarm. Throws on any load failure; the entry-point catch
218
+ * banners. Banner-only: no module opts into the `git show HEAD:` fallback,
219
+ * because failing open here is cheap (§ Fail direction) and a HEAD copy of a
220
+ * scorer is not obviously better than no scorer.
221
+ *
222
+ * `requires` lists FUNCTION exports only — `assertShape` tests `typeof ===
223
+ * 'function'`, so naming a frozen constant there would fail every load.
224
+ *
225
+ * @returns {Promise<void>}
226
+ */
227
+ async function bootstrap() {
228
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
229
+
230
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
231
+ const { modules } = await armGuard(
232
+ {
233
+ io: { specifier: lib('io.mjs'), requires: ['readStdin', 'emitAllow', 'emitDeny'] },
234
+ // Telemetry is in the ARMING manifest, not loaded opportunistically at the
235
+ // call site, and that is deliberate. A guard whose firing rate nothing
236
+ // records is unfalsifiable (`.claude/rules/host-resources.md` § HR-105:
237
+ // one such verdict stayed wrong for four months because it reached the
238
+ // metrics store for 0.9 % of sessions). Under `armGuard` a broken
239
+ // events.mjs disarms this hook LOUDLY via the GUARD INACTIVE banner;
240
+ // loaded lazily it would instead disarm the measurement SILENTLY, and a
241
+ // silent measurement gap looks exactly like a clean run. Same placement as
242
+ // the sibling deny-capable guard, `hooks/pre-bash-destructive-guard.mjs`.
243
+ events: { specifier: lib('events.mjs'), requires: ['emitEvent'] },
244
+ schema: {
245
+ specifier: lib('auq', 'schema.mjs'),
246
+ requires: ['makeQuestion', 'makeOption'],
247
+ },
248
+ clarity: { specifier: lib('auq', 'clarity.mjs'), requires: ['scoreQuestion'] },
249
+ },
250
+ {
251
+ hookName: HOOK_NAME,
252
+ repoRoot: PLUGIN_ROOT,
253
+ projectDir: bannerProjectDir(),
254
+ consequence: GUARD_CONSEQUENCE,
255
+ },
256
+ );
257
+
258
+ ({ readStdin, emitAllow, emitDeny } = modules.io);
259
+ ({ emitEvent } = modules.events);
260
+ schemaMod = modules.schema;
261
+ clarityMod = modules.clarity;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Adapter — runtime payload → the scorer's AuqQuestion
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Convert ONE runtime question from `tool_input.questions` into the frozen
270
+ * `AuqQuestion` the scorer takes, or `null` when its shape is not fully
271
+ * recognised.
272
+ *
273
+ * `null` means ALLOW, never "empty question" — see § Fail direction for why a
274
+ * partially-recognised question is skipped whole rather than repaired.
275
+ *
276
+ * `isRecommended` is derived from the label because that is where the marker
277
+ * lives at runtime; the marker list comes from the schema registry
278
+ * (`RECOMMENDED_MARKERS`), so this adapter adds no second spelling of it. When
279
+ * the registry is unavailable the derivation degrades to "no recommendation
280
+ * detected", which can only REMOVE an H2 break, never invent one.
281
+ *
282
+ * @param {unknown} raw one entry of `tool_input.questions`
283
+ * @param {number} index its position, recorded as the `line`
284
+ * @param {object} schema the `auq/schema.mjs` module namespace
285
+ * @returns {object|null}
286
+ */
287
+ export function toAuqQuestion(raw, index, schema) {
288
+ if (raw === null || typeof raw !== 'object') return null;
289
+ if (typeof raw.question !== 'string') return null;
290
+ if (!Array.isArray(raw.options)) return null;
291
+
292
+ const options = [];
293
+ for (let i = 0; i < raw.options.length; i += 1) {
294
+ const o = raw.options[i];
295
+ // One unrecognised option disqualifies the whole question: dropping it would
296
+ // lower the option COUNT, which is itself an H2 hurdle — a deny invented by
297
+ // this adapter rather than found in the question.
298
+ if (o === null || typeof o !== 'object' || typeof o.label !== 'string') return null;
299
+ options.push(schema.makeOption({
300
+ label: o.label,
301
+ description: typeof o.description === 'string' ? o.description : '',
302
+ preview: typeof o.preview === 'string' ? o.preview : null,
303
+ // Geteiltes Prädikat aus schema.mjs — NICHT `markers.some(m => label.includes(m))`.
304
+ // Diese Zeile prüfte nur das Label und liess damit eine Frage durch, die der
305
+ // Datei-Validator als H2-Bruch meldete: der Marker steht in manchen Vorlagen in
306
+ // der Beschreibung. Zwei Ableitungen derselben Regel, gemessen divergent (W4-Q7).
307
+ isRecommended: schema.isRecommendedOption(o),
308
+ index: i,
309
+ }));
310
+ }
311
+
312
+ return schema.makeQuestion({
313
+ question: raw.question,
314
+ header: typeof raw.header === 'string' ? raw.header : null,
315
+ multiSelect: raw.multiSelect === true,
316
+ options,
317
+ file: RUNTIME_ORIGIN,
318
+ // `line` is the schema's location field and must be an integer; the question's
319
+ // 1-based position is the only location a runtime call has.
320
+ line: index + 1,
321
+ // The corpus populations describe where a question was FOUND. 'A' — an
322
+ // executed tool call — is the one a runtime call actually is.
323
+ population: 'A',
324
+ kind: 'template',
325
+ // 'prose' and not 'backtick': the text here is the real text, not a source
326
+ // literal, so the escape-sequence lower-bound note must NOT fire.
327
+ quoting: 'prose',
328
+ });
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Rendering
333
+ // ---------------------------------------------------------------------------
334
+
335
+ /**
336
+ * Name the exact place a finding sits, so the second attempt lands on the same
337
+ * field rather than somewhere adjacent.
338
+ *
339
+ * @param {object} f an `AuqFinding`
340
+ * @param {number} questionNo 1-based question position
341
+ * @returns {string}
342
+ */
343
+ function locationOf(f, questionNo) {
344
+ // The JSON path INTO `tool_input`, not a prose location: the reader of this
345
+ // text is the model that has to emit a corrected payload, and a path it can
346
+ // address is what makes the second attempt land on the same field.
347
+ const base = `questions[${questionNo - 1}]`;
348
+ if (f.target === 'header') return `Frage ${questionNo} · ${base}.header`;
349
+ if (f.target === 'option') return `Frage ${questionNo} · ${base}.options[${f.optionIndex}]`;
350
+ return `Frage ${questionNo} · ${base}`;
351
+ }
352
+
353
+ /**
354
+ * Render one finding as a single deny line.
355
+ *
356
+ * The measured value and the limit are appended MECHANICALLY from the finding's
357
+ * own `measured`/`threshold` fields rather than trusted to appear in the prose.
358
+ * The requirement is that a deny names the field, the actual value and the
359
+ * limit; leaning on the message wording alone would make that requirement
360
+ * dependent on a sentence a future edit could rephrase.
361
+ *
362
+ * @param {object} f
363
+ * @param {number} questionNo
364
+ * @returns {string}
365
+ */
366
+ function renderFinding(f, questionNo) {
367
+ const measured = f.measured === null ? '?' : String(f.measured);
368
+ const threshold = f.threshold === null ? '?' : String(f.threshold);
369
+ const head = ` • ${locationOf(f, questionNo)}: `;
370
+ const tail = ` [gemessen: ${measured}, Grenze: ${threshold}]`;
371
+
372
+ // Nur `f.message` wird geklippt — Ort und Messwerte sind reserviert.
373
+ //
374
+ // Vorher klippte diese Funktion die FERTIGE Zeile, und `clipLine` schneidet
375
+ // von hinten. Der Messwert-Suffix steht am Ende, also fiel genau er als
376
+ // erstes weg — und zwar UMSO SICHERER, je gravierender der Verstoß war.
377
+ // Gemessen 2026-08-22 am echten Hook: Kopfzeile 100 und 150 Zeichen → Grund
378
+ // trägt `[gemessen/Grenze]`; Kopfzeile 400 Zeichen → trägt es NICHT MEHR.
379
+ // 400 ist der Fall, für den H1 überhaupt existiert.
380
+ //
381
+ // Der Schaden ist nicht Kosmetik: der Leser dieses Textes ist das Modell, das
382
+ // eine korrigierte Nutzlast schicken muss. Ohne Ist-Wert und Grenze weiß es
383
+ // nicht, WIE VIEL zu kürzen ist, rät, trifft dieselbe Wand — und der Operator
384
+ // erfährt von keinem der beiden Anläufe.
385
+ //
386
+ // Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
387
+ const room = Math.max(24, MAX_LINE_CHARS - [...head].length - [...tail].length);
388
+ const msg = [...String(f.message ?? '')];
389
+ const body = msg.length <= room ? msg.join('') : `${msg.slice(0, room - 1).join('')}…`;
390
+ return `${head}${body}${tail}`;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Decision — PURE. Returns a verdict; emits nothing, exits nothing.
395
+ //
396
+ // The purity is load-bearing: `emitDeny` and `emitAllow` both call
397
+ // `process.exit()` and never return, so an emit reached from inside the checking
398
+ // flow would terminate before a later question could be judged.
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /**
402
+ * @typedef {{action: 'allow'|'deny', reason?: string, suggestion?: string, notes: string[]}} Verdict
403
+ */
404
+
405
+ /**
406
+ * Decide whether this `AskUserQuestion` call may proceed.
407
+ *
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.
411
+ *
412
+ * ## Known limitation, with its revisit trigger (BV-004)
413
+ *
414
+ * The registry maps a hurdle to a CRITERION (`CRITERIA.K5.hurdle === 'H1'`,
415
+ * `CRITERIA.K6.hurdle === 'H2'`), not to an individual finding — so when H2 is
416
+ * broken, the K6 `fail` findings listed alongside it may include two that carry
417
+ * no hurdle of their own (a description over 150 characters, a question payload
418
+ * over 650). They are listed, never decisive: without a broken hurdle they
419
+ * cannot produce a deny at all. Every such line still names its field, value and
420
+ * limit, so the reason stays actionable rather than merely longer.
421
+ *
422
+ * REVISIT TRIGGER: if `scripts/lib/auq/clarity.mjs` ever tags a finding with the
423
+ * hurdle it breaks, switch the selection below from criterion to that tag and
424
+ * delete this note.
425
+ *
426
+ * @param {object} input the parsed PreToolUse payload
427
+ * @param {{schema: object, clarity: object}} lib the two `auq/` module namespaces
428
+ * @returns {Verdict}
429
+ */
430
+ export function decide(input, lib) {
431
+ const notes = [];
432
+ // `telemetry: null` means NOTHING WAS SCORED, which is a distinct outcome to
433
+ // scored-and-clean; the two must not collapse into one event. The early
434
+ // returns below all take that branch; only the terminal allow/deny carry real
435
+ // counts.
436
+ //
437
+ // (Phrased without the words `from` + a quoted string on one line: the
438
+ // hooks static-import guard in tests/hooks/on-stop.test.mjs scans source
439
+ // textually and reads that shape inside a COMMENT as a third-party import.)
440
+ const allow = (telemetry = null) => ({ action: /** @type {'allow'} */ ('allow'), notes, telemetry });
441
+
442
+ if (input?.tool_name !== AUQ_TOOL) return allow();
443
+
444
+ const schema = lib?.schema;
445
+ const clarity = lib?.clarity;
446
+ if (typeof schema?.makeQuestion !== 'function' || typeof clarity?.scoreQuestion !== 'function') {
447
+ notes.push('Bewerter nicht verfügbar — Frage unverändert durchgelassen.');
448
+ return allow();
449
+ }
450
+
451
+ const toolInput = input?.tool_input;
452
+ if (toolInput === null || typeof toolInput !== 'object') return allow();
453
+ if (!Array.isArray(toolInput.questions) || toolInput.questions.length === 0) return allow();
454
+
455
+ const hurdles = schema.HURDLES ?? {};
456
+ const criteria = schema.CRITERIA ?? {};
457
+
458
+ // Grouped by hurdle, not by question: two questions breaking H1 are ONE
459
+ // problem with two witnesses, and a reason that repeats the H1 heading twice
460
+ // reads as two unrelated rules. Insertion order is the registry's (H1 then H2)
461
+ // because that is the order the first breaking question hits them in.
462
+ /** @type {Map<string, {title: string, lines: string[]}>} */
463
+ const broken = new Map();
464
+ /** @type {Map<string, number>} */
465
+ const softByCriterion = new Map();
466
+ let skipped = 0;
467
+
468
+ toolInput.questions.forEach((raw, index) => {
469
+ let score;
470
+ try {
471
+ const question = toAuqQuestion(raw, index, schema);
472
+ if (question === null) { skipped += 1; return; }
473
+ score = clarity.scoreQuestion(question, index);
474
+ } catch {
475
+ // A scorer throw is not a violation. Fail open, count it, move on.
476
+ skipped += 1;
477
+ return;
478
+ }
479
+
480
+ const findings = Array.isArray(score?.findings) ? score.findings : [];
481
+ const questionNo = index + 1;
482
+
483
+ for (const hurdleId of Array.isArray(score?.hurdlesBroken) ? score.hurdlesBroken : []) {
484
+ const hurdle = hurdles[hurdleId];
485
+ // Auf dem TAG des Befunds filtern, nicht auf dem Kriterium der Hürde.
486
+ //
487
+ // Vorher stand hier `f.criterion === hurdle.criterion`. Das griff über:
488
+ // K6 erzeugt vier Befundklassen (Beschreibungslänge, Labellänge,
489
+ // Nutzlast, Optionszahl) und nur die letzte reißt H2. Gemessen mit
490
+ // 4 Fragen × 5 Optionen: unter der H2-Überschrift standen zehn
491
+ // Beschreibungslängen-Zeilen und EINE, die den echten Bruch benannte —
492
+ // die Brüche der Fragen 2 bis 4 fielen komplett aus dem Zeilenbudget.
493
+ // Das Modell erfuhr von einem Bruch und musste dreimal nachliefern.
494
+ //
495
+ // `f.hurdle` wird seit dieser Session in `clarity.mjs` gesetzt, wo die
496
+ // Zuordnung ohnehin entsteht. Der Rückfallpfad auf das Kriterium bleibt
497
+ // für Befunde ohne Tag erhalten, damit ein älterer Bewerter nicht
498
+ // stillschweigend leere Abschnitte erzeugt.
499
+ const criterion = hurdle?.criterion;
500
+ const tagged = findings.filter((f) => f.severity === 'fail' && f.hurdle === hurdleId);
501
+ const pool = tagged.length > 0
502
+ ? tagged
503
+ : findings.filter((f) => f.severity === 'fail' && f.criterion === criterion);
504
+ const lines = pool.map((f) => renderFinding(f, questionNo));
505
+ // Nach FRAGE gebündelt, nicht flach angehängt. Das Budget wird unten
506
+ // reihum vergeben, und dafür muss sichtbar bleiben, welche Zeile zu
507
+ // welcher Frage gehört — sonst verdrängt eine geschwätzige Frage die
508
+ // Brüche aller anderen (siehe die Begründung an der Vergabe unten).
509
+ const group = broken.get(hurdleId);
510
+ if (group === undefined) {
511
+ broken.set(hurdleId, {
512
+ title: typeof hurdle?.title === 'string' ? hurdle.title : hurdleId,
513
+ perQuestion: [{ questionNo, lines: [...lines] }],
514
+ });
515
+ } else {
516
+ group.perQuestion.push({ questionNo, lines: [...lines] });
517
+ }
518
+ }
519
+
520
+ // Content criteria: counted for the stderr note, never decisive. A criterion
521
+ // "carries a hurdle" per the registry (K5/K6); everything else is content.
522
+ for (const f of findings) {
523
+ if (f.severity !== 'fail') continue;
524
+ if (criteria[f.criterion]?.hurdle) continue;
525
+ softByCriterion.set(f.criterion, (softByCriterion.get(f.criterion) ?? 0) + 1);
526
+ }
527
+ });
528
+
529
+ if (skipped > 0) {
530
+ notes.push(`${skipped} Frage(n) nicht auswertbar — unverändert durchgelassen.`);
531
+ }
532
+ if (softByCriterion.size > 0) {
533
+ const summary = [...softByCriterion.entries()]
534
+ .sort((a, b) => a[0].localeCompare(b[0]))
535
+ .map(([id, n]) => `${id}×${n}`)
536
+ .join(', ');
537
+ notes.push(
538
+ `Inhaltliche Befunde (${summary}) — NICHT blockiert. Gemessene Falsch-Positiv-Rate ` +
539
+ '14–25 %; nur die harten Grenzen H1/H2 blockieren.',
540
+ );
541
+ }
542
+
543
+ // Built ONCE, here, from the same maps the verdict itself is derived from.
544
+ // Re-deriving these counts at the emit site would be a second implementation
545
+ // of the judgement, free to drift from the one that actually decided.
546
+ //
547
+ // `soft` is the load-bearing field, not `hurdles`. The eight content criteria
548
+ // K1-K8 have a measured false-positive rate of 14-25 % and therefore do NOT
549
+ // block (module head) — but that rate was measured on TEMPLATES, and nothing
550
+ // has ever measured it on live runtime questions. `soft` is what makes that
551
+ // measurable: a criterion that fires on almost every real question is a broken
552
+ // instrument by HR-101 and must be re-aimed, not promoted to a hurdle.
553
+ const telemetry = {
554
+ questions: toolInput.questions.length,
555
+ skipped,
556
+ hurdles: [...broken.keys()],
557
+ soft: Object.fromEntries([...softByCriterion.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
558
+ };
559
+
560
+ if (broken.size === 0) return allow(telemetry);
561
+
562
+ const sections = [];
563
+ let used = 0;
564
+ let omitted = 0;
565
+ // ZWEI Durchgänge: erst jede Gruppe mit mindestens einer Zeile bedienen, dann
566
+ // den Rest auffüllen.
567
+ //
568
+ // Vorher lief das in EINEM Durchgang, und die erste Gruppe durfte das ganze
569
+ // Budget verbrauchen. Gemessen 2026-08-22 mit 4 Fragen × 5 Optionen (alles
570
+ // innerhalb der Protokollgrenzen): von 12 gezeigten H2-Zeilen benannten 10 die
571
+ // Beschreibungslänge — ein Befund, der laut Registry gar keine eigene Hürde
572
+ // trägt — und genau EINE den echten Optionszahl-Bruch, den von Frage 1. Die
573
+ // Brüche der Fragen 2, 3 und 4 landeten vollständig im verworfenen Rest.
574
+ //
575
+ // Das Modell erfuhr also, dass Frage 1 zu viele Optionen hat, und nichts
576
+ // darüber, dass drei weitere denselben Bruch tragen: zweiter Anlauf, zweite
577
+ // Ablehnung, und der Operator sieht keine der beiden Fragen.
578
+ //
579
+ // Gefunden vom QA-Review dieser Session (W4-Q6), koordinator-verifiziert.
580
+ const groups = [...broken.entries()];
581
+ // Reihum über FRAGEN, nicht der Reihe nach über Zeilen: erst bekommt jede
582
+ // Frage jeder Gruppe ihre erste Zeile, dann die zweite, und so fort bis das
583
+ // Budget alle ist. Damit ist garantiert, dass jede Frage mit gerissener Hürde
584
+ // benannt wird, solange überhaupt Platz für sie da ist.
585
+ const taken = new Map();
586
+ for (const [id, group] of groups) taken.set(id, group.perQuestion.map(() => 0));
587
+ const depth = Math.max(0, ...groups.map(([, g]) => Math.max(0, ...g.perQuestion.map((q) => q.lines.length))));
588
+ outer: for (let round = 0; round < depth; round += 1) {
589
+ for (const [id, group] of groups) {
590
+ const counts = taken.get(id);
591
+ for (let qi = 0; qi < group.perQuestion.length; qi += 1) {
592
+ if (used >= MAX_FINDING_LINES) break outer;
593
+ if (group.perQuestion[qi].lines.length <= round) continue;
594
+ counts[qi] += 1;
595
+ used += 1;
596
+ }
597
+ }
598
+ }
599
+ for (const [hurdleId, group] of groups) {
600
+ const counts = taken.get(hurdleId);
601
+ const shown = [];
602
+ group.perQuestion.forEach((q, qi) => {
603
+ shown.push(...q.lines.slice(0, counts[qi]));
604
+ omitted += q.lines.length - counts[qi];
605
+ });
606
+ sections.push(`${hurdleId} — ${group.title}\n${shown.join('\n')}`);
607
+ }
608
+ const tail = omitted > 0 ? `\n(+${omitted} weitere Befund(e) nicht gezeigt)` : '';
609
+
610
+ // The FIRST LINE names the concrete break, because `emitDeny` derives the
611
+ // operator-visible headline from it. A general preamble first would put a
612
+ // sentence that is identical for every deny in front of the human, and the
613
+ // one thing they need — WHICH limit broke — past the 200-char clip.
614
+ const reason =
615
+ `AskUserQuestion blockiert: harte Grenze ${[...broken.keys()].join(' + ')} gerissen — ` +
616
+ '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' +
619
+ `${sections.join('\n\n')}${tail}`;
620
+
621
+ const suggestion =
622
+ 'Formuliere die Frage neu und stell sie noch einmal — dieser Hook kürzt nicht selbst, ' +
623
+ 'weil jedes automatische Kürzen oder Umsortieren Bedeutung verlöre, ohne dass der ' +
624
+ 'Operator es sehen könnte. Inhaltliche Kriterien (Grund/Preis/Folge je Option, Jargon, ' +
625
+ '„geh selbst nachsehen") werden hier NICHT geprüft und sind kein Grund für diese Sperre.';
626
+
627
+ return { action: 'deny', reason, suggestion, notes, telemetry };
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Entry point — exactly ONE terminal emit
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /**
635
+ * Write diagnostics to stderr. Under exit 0 this channel is a DEBUG LOG ONLY —
636
+ * it never reaches the operator, so nothing may depend on it being read.
637
+ * Wrapped because stderr can be closed, and a throw here would unwind into the
638
+ * fail-open catch below and cost the diagnostic AND the reason for it.
639
+ *
640
+ * @param {string[]} notes
641
+ * @returns {void}
642
+ */
643
+ function writeNotes(notes) {
644
+ if (!Array.isArray(notes) || notes.length === 0) return;
645
+ try {
646
+ process.stderr.write(`⚠ ${HOOK_NAME}: ${notes.join(' ')}\n`);
647
+ } catch { /* stderr may be closed; the decision below is what matters */ }
648
+ }
649
+
650
+ /**
651
+ * Append one telemetry line, then get out of the way.
652
+ *
653
+ * Awaited by every caller BEFORE the terminal emit — `emitAllow`/`emitDeny` call
654
+ * `process.exit()` and never return, and node discards a pending async write at
655
+ * exit (the same class as `.claude/rules/` § "console.log + process.exit() drops
656
+ * stdout above the pipe buffer"). Awaiting is what makes the record real.
657
+ *
658
+ * Swallows EVERYTHING, including a missing `emitEvent` binding: a telemetry
659
+ * failure that turned a deny into an allow would be strictly worse than no
660
+ * telemetry at all — the guard's whole purpose would be traded for its
661
+ * measurement.
662
+ *
663
+ * @param {string} verb terminal segment of `orchestrator.auq_clarity.<verb>`
664
+ * @param {object} payload shapes and counts only — never question text
665
+ * @param {object|null} input the PreToolUse payload, for session attribution
666
+ * @returns {Promise<void>}
667
+ */
668
+ async function logDecision(verb, payload, input) {
669
+ try {
670
+ const sessionId = typeof input?.session_id === 'string' && input.session_id
671
+ ? input.session_id
672
+ : (typeof input?.parent_session_id === 'string' && input.parent_session_id
673
+ ? input.parent_session_id
674
+ : null);
675
+ await emitEvent(`orchestrator.auq_clarity.${verb}`, {
676
+ ...(sessionId ? { session_id: sessionId } : {}),
677
+ ...payload,
678
+ });
679
+ } catch {
680
+ // Best-effort — telemetry must never block or alter the guard decision.
681
+ }
682
+ }
683
+
684
+ async function main() {
685
+ let input;
686
+ try {
687
+ input = await readStdin();
688
+ } catch (err) {
689
+ // Malformed / oversized / slow stdin is a harness quirk, not a bad question.
690
+ // Denying here would destroy every question on a parse error.
691
+ writeNotes([`stdin unlesbar (${String(err?.message ?? err).split('\n')[0]}) — durchgelassen.`]);
692
+ // Logged, because this is a question that reached the operator UNCHECKED.
693
+ // Silent fail-open is the state this whole guard exists to make visible.
694
+ await logDecision('fail_open', { cause: 'stdin-unreadable' }, null);
695
+ return emitAllow();
696
+ }
697
+ if (!input) return emitAllow();
698
+
699
+ // Cheap pre-check: the matcher in hooks.json is not the only line of defence.
700
+ // Deliberately UNLOGGED: on a correct `AskUserQuestion` matcher this branch is
701
+ // unreachable, and logging every foreign tool call would bury the AUQ signal
702
+ // under traffic this hook has no opinion about.
703
+ if (input.tool_name !== AUQ_TOOL) return emitAllow();
704
+
705
+ const verdict = decide(input, { schema: schemaMod, clarity: clarityMod });
706
+ writeNotes(verdict.notes);
707
+
708
+ // The allow event is not bookkeeping — it is the ONLY proof the hook fires at
709
+ // all. Under ADR-0011 allow and deny share exit code 0 and an allow writes no
710
+ // stdout, so "the matcher never delivered" and "delivered, question was clean"
711
+ // are indistinguishable from outside this process without it.
712
+ if (verdict.telemetry) {
713
+ await logDecision(verdict.action === 'deny' ? 'denied' : 'allowed', verdict.telemetry, input);
714
+ }
715
+
716
+ if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
717
+ return emitAllow();
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // Self-execution guard (§ Import safety).
722
+ //
723
+ // Without it, an `import` of this module runs `main()`, blocks 5 s on stdin and
724
+ // terminates the IMPORTING process with `exit 0` — which under ADR-0011 is
725
+ // itself an ALLOW. `process.argv[1]` carries the path as passed (symlink-bearing
726
+ // under a symlinked plugin install) while `import.meta.url` is realpath-resolved
727
+ // by node's default loader, so BOTH sides are realpath'd.
728
+ // ---------------------------------------------------------------------------
729
+ function invokedAsScript() {
730
+ const entry = process.argv[1];
731
+ if (!entry) return false;
732
+ const self = fileURLToPath(import.meta.url);
733
+ try {
734
+ return realpathSync(entry) === realpathSync(self);
735
+ } catch {
736
+ return entry === self;
737
+ }
738
+ }
739
+
740
+ if (invokedAsScript()) {
741
+ // Silent no-op when disabled via profile/env (#211).
742
+ if (!shouldRunHook(HOOK_NAME)) process.exit(0);
743
+
744
+ // -------------------------------------------------------------------------
745
+ // TWO distinct failure classes, two distinct handlers — do NOT merge them:
746
+ //
747
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed. Exit 0
748
+ // (fail-OPEN — a broken module must not cost the operator's question, and
749
+ // `emitAllow` itself may be the symbol that failed to load) but SAY SO:
750
+ // GUARD INACTIVE.
751
+ // 2. RUNTIME failure inside `main()`: the guard armed and then tripped. Also
752
+ // fail-open, for the asymmetry in § Fail direction — a wrongly-denied
753
+ // question is never asked, while an unchecked one is merely worse worded.
754
+ // -------------------------------------------------------------------------
755
+ try {
756
+ await bootstrap();
757
+ } catch (loadError) {
758
+ try {
759
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
760
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
761
+ } catch {
762
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
763
+ // repeated noise beats a silent disarm.
764
+ process.stderr.write(
765
+ `🚨 ${HOOK_NAME}: GUARD INACTIVE — module load failed ` +
766
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
767
+ 'Runtime AskUserQuestion clarity checking is OFF. See issue #992.\n',
768
+ );
769
+ }
770
+ process.exit(0); // fail-open, but no longer fail-silent
771
+ }
772
+
773
+ main().catch(async (e) => {
774
+ try {
775
+ process.stderr.write(
776
+ `⚠ ${HOOK_NAME}: internal hook error — question ALLOWED unchecked ` +
777
+ `(${String(e?.message ?? e).split('\n')[0]})\n`,
778
+ );
779
+ } catch { /* stderr may be closed; the allow below is the decision */ }
780
+ // stderr is a debug channel nothing is obliged to read (§ writeNotes), so the
781
+ // durable record of "a question got through unchecked" has to go somewhere a
782
+ // later audit can find it. `logDecision` swallows its own failures, so this
783
+ // await cannot keep the allow below from happening.
784
+ await logDecision('fail_open', { cause: 'internal-error' }, null);
785
+ emitAllow();
786
+ });
787
+ }