heyspex 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +312 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -1,410 +1,456 @@
1
- #!/usr/bin/env node
2
- // HeySpex Intent Classifier — the UNIFIED registry-driven router.
3
- //
4
- // One place that reads every `userpromptsubmit` / `prime` CLASSIFIER rule from the
5
- // compiled guardrail registry, runs ONE lexical sweep, and on a full lexical miss
6
- // resolves the harder-to-pattern-match cases WITHOUT a second, separate model.
7
- // Adding a future skill = adding a rule (+ a handler entry only if its inject needs
8
- // special logic).
9
- // Design home: [[HS-UJVA-EPIC-KKMO-autonomous-skill-routing-context]] "generalize the router".
10
- //
11
- // SEMANTIC LAYER (Layer 2) — TWO MODES ([[HS-M4PN-TASK]]):
12
- // - self_check (DEFAULT): on a full lexical miss, build ONE deterministic SELF-CHECK
13
- // block — for each eligible behavior, its classification gate paired with the exact
14
- // resolved inject Layer 1 would have emitted — and hand it to the agent already
15
- // running THIS session to judge as part of its own turn. No network, no fleet
16
- // machine, no timeout/fail-safe path; works identically for the owner and any
17
- // future customer, and needs zero HeySpex inference budget. The injection is
18
- // DETERMINISTIC (same input → same block); the JUDGMENT is the agent's.
19
- // - model (OPT-IN): the original mechanism — AT MOST ONE local-model call classifies
20
- // the prompt and the winner fires above min_confidence. Demoted to opt-in; never
21
- // on the default path, so no reachable endpoint is required.
22
- // The mode is resolved by readIntentClassifierConfig() (config `mode:` / env
23
- // HEYSPEX_INTENT_SEMANTIC_MODE). HEYSPEX_INTENT_SEMANTIC_OFF=1 disables Layer 2 wholly.
24
- //
25
- // PER-BEHAVIOR FIDELITY (byte-identical to the old separate hooks):
26
- // - capture_on_idea: inject runs through applyConfirmMode() — the {{CONFIRM_MODE}}
27
- // token is replaced with the forgiveness (auto) or permission (ask) directive,
28
- // resolved by resolveConfirmMode('capture_on_idea'). Injected-content guard:
29
- // startsWithInjectedContent (strict envelope-leads-with).
30
- // - research_before_decision: rule.inject + appendResearchSourceHint() — a best-effort
31
- // hint naming agent_behaviors.research.default_source/default_agent, appended (not
32
- // replacing) so the original inject text stays byte-identical up to that point
33
- // ([[HS-LFUH-DIAG-W1YE]] fix). Injected-content guard: containsInjectedContent
34
- // (contains-anywhere — the research hook's original guard).
35
- // - settings_intent: raw rule.inject. Injected-content guard: startsWithInjectedContent.
36
- // Each behavior keeps its own config_key enable/disable and env kill switch. In the
37
- // self_check mode the SAME per-behavior transform builds each directive inside the
38
- // combined block, so the directive the agent follows stays byte-identical to Layer 1.
39
- //
40
- // PRECEDENCE: a lexical hit always wins over the semantic layer. Among lexical hits,
41
- // the FIRST behavior in BEHAVIOR_ORDER wins (deterministic). The behaviors are
42
- // mutually exclusive on their pattern sets today (verified by the cross-fire matrix in
43
- // the smoke suites) — the order only matters as a tiebreak that must never be reached.
44
- //
45
- // HARD FAIL-SAFE: every function degrades to "no injection". A corrupt/missing
46
- // registry, a disabled rule, an unreachable/slow model, or any thrown error → the
47
- // caller emits nothing and exits 0. Dependency-free (Node stdlib + global fetch).
48
-
49
- import {
50
- loadGuardrails,
51
- rulesForSurface,
52
- compilePatterns,
53
- isRuleEnabled,
54
- } from './_guardrail-loader.mjs';
55
- import {
56
- classifyIntent,
57
- readIntentClassifierConfig,
58
- appendClassifierEvent,
59
- } from './_intent-classifier-semantic.mjs';
60
- // HS-N01T: the directive assembly MOVED to the engine (spexharness/src/intent-directive.mjs)
61
- // so the Claude Code hook and a SpexCode seat build the SAME text for the same prompt. This
62
- // router imports it from there and RE-EXPORTS every moved symbol under its old name, so the
63
- // trigger hook, the smokes and the `--directive` CLI keep their imports and their bytes.
64
- // Layering: the plugin scripts import DOWN into the engine, never the reverse.
65
- import {
66
- CONFIRMED_MATCH_MARKER,
67
- withConfirmedMatchMarker,
68
- CAPTURE_MODE_DIRECTIVES,
69
- CLASSIFIER_BEHAVIORS,
70
- applyCaptureConfirmMode,
71
- appendCaptureKindHint,
72
- appendCaptureRoutingHint,
73
- appendResearchSourceHint,
74
- buildConfirmedDirective,
75
- behaviorKeyForRuleId,
76
- isBehaviorEligible,
77
- } from '../../spexharness/src/intent-directive.mjs';
78
-
79
- export {
80
- CONFIRMED_MATCH_MARKER,
81
- CAPTURE_MODE_DIRECTIVES,
82
- CLASSIFIER_BEHAVIORS,
83
- applyCaptureConfirmMode,
84
- appendCaptureKindHint,
85
- appendCaptureRoutingHint,
86
- appendResearchSourceHint,
87
- buildConfirmedDirective,
88
- behaviorKeyForRuleId,
89
- isBehaviorEligible,
90
- };
91
-
92
- const SURFACE = 'userpromptsubmit';
93
-
94
- // Human-readable heading per behavior for the self-check block. Falls back to the key.
95
- const SELF_CHECK_LABELS = {
96
- capture_on_idea: 'Capture-on-idea',
97
- research_before_decision: 'Research-before-decision',
98
- settings_intent: 'Settings intent',
99
- defer_intent: 'Defer / schedule intent',
100
- spec_reference_lookup: 'Spec-reference lookup',
101
- };
102
-
103
- // Cheap deterministic emission floor (mirrors the old classifyIntent text.length<12
104
- // short-prompt skip): a sub-12-char message ("yes", "ok", "proceed") carries no
105
- // classifiable intent, so never spend the agent's attention self-checking it.
106
- export const MIN_SELF_CHECK_CHARS = 12;
107
-
108
- /**
109
- * Build the ONE deterministic self-check block emitted on a lexical miss (mode:
110
- * self_check). For each considered behavior it pairs the classification GATE (the same
111
- * description the model was given) with the exact resolved inject Layer 1 would have
112
- * emitted — so whichever ONE the agent judges to apply, it follows the byte-identical
113
- * directive. The wrapper's whole job is precision: it states loudly that the common
114
- * outcome is "none" and that the check must never leak into the visible response, so a
115
- * block injected on every substantive prompt does not turn into noise. Deterministic:
116
- * same considered set + repoRoot → same block.
117
- */
118
- export function buildSelfCheckBlock(considered, repoRoot, prompt) {
119
- const sections = considered.map((entry) => {
120
- const label = SELF_CHECK_LABELS[entry.behavior.key] || entry.behavior.key;
121
- return (
122
- `### ${label} (${entry.behavior.key})\n` +
123
- `Applies ONLY when: ${entry.behavior.description}`
124
- );
125
- });
126
- return (
127
- 'HEYSPEX SILENT SELF-CHECK — a deterministic system note (NOT written by the user). ' +
128
- "Before you respond, silently judge the user's latest message against the gate(s) below.\n" +
129
- 'DEFAULT OUTCOME IS "NONE": ordinary work orders, questions about existing work, direct answers, ' +
130
- 'and small talk match NONE of these. When none clearly applies — the common case — do NOT mention ' +
131
- "this note, do NOT change what you would otherwise do, and just carry out the user's request normally.\n" +
132
- 'If EXACTLY ONE gate clearly applies, run this to get its exact directive and follow that verbatim:\n' +
133
- ` node scripts/claude-hooks/_intent-classifier-router.mjs --directive <key>\n` +
134
- '(<key> is the parenthesised key on the heading, e.g. capture_on_idea. If two gates seem to apply, ' +
135
- 'pick the single best fit and fetch only that one.) Never announce, quote, or acknowledge that you ' +
136
- 'ran this check — the user must not see it.\n\n' +
137
- sections.join('\n\n')
138
- );
139
- }
140
-
141
- /**
142
- * Resolve ONE behavior's full directive on demand — the other half of the
143
- * gate-plus-pointer split above.
144
- *
145
- * The block used to paste all four directives into every substantive turn, about
146
- * 4,200 tokens, so the agent could judge which one applied. It usually applied to
147
- * none of them, which meant paying for four directives to use zero. Now the block
148
- * carries only the gates (what you need to JUDGE) and this resolves the directive
149
- * (what you need to ACT) for the one gate that fired.
150
- *
151
- * Returns the directive string, or null when the key is unknown / its rule is gone.
152
- */
153
- export function resolveDirective(behaviorKey, repoRoot, prompt = '') {
154
- const resolved = resolveBehaviorRules();
155
- const entry = resolved.find((e) => e.behavior.key === behaviorKey);
156
- if (!entry) return null;
157
- return entry.behavior.transform(entry.rule.inject, repoRoot, prompt);
158
- }
159
-
160
- /**
161
- * Resolve the compiled rule for each classifier behavior from the registry. Returns a
162
- * map behaviorKey → { behavior, rule, patterns } for behaviors whose rule exists and
163
- * carries an inject. FAIL-SAFE: a missing rule simply drops that behavior.
164
- */
165
- function resolveBehaviorRules() {
166
- const guardrails = loadGuardrails();
167
- const primeRules = rulesForSurface(guardrails, SURFACE, 'prime');
168
- const byId = new Map(primeRules.map((r) => [r.rule, r]));
169
- const resolved = [];
170
- for (const behavior of CLASSIFIER_BEHAVIORS) {
171
- const rule = byId.get(behavior.ruleId);
172
- if (!rule || !rule.inject) continue;
173
- resolved.push({ behavior, rule, patterns: compilePatterns(rule) });
174
- }
175
- return resolved;
176
- }
177
-
178
- /**
179
- * Is this behavior eligible to fire on this prompt at all? (env kill switch off,
180
- * config_key enabled, and the injected-content envelope guard passes). Behaviors that
181
- * are ineligible participate in neither the lexical sweep nor the semantic call —
182
- * matching the old per-hook early-returns exactly.
183
- */
184
- function isEligible(entry, prompt, repoRoot) {
185
- const { behavior, rule } = entry;
186
- if (process.env[behavior.envOff] === '1') return false;
187
- if (rule.ignoreInjectedContent && behavior.guard(prompt)) return false;
188
- if (!isRuleEnabled(rule, repoRoot)) return false;
189
- return true;
190
- }
191
-
192
- /**
193
- * Classify a prompt and return the single inject to emit, or null.
194
- * `deps.classify` is injectable for tests (defaults to the real semantic classifier).
195
- * Guarantees AT MOST ONE call to `deps.classify` per invocation.
196
- *
197
- * @param {object} [opts]
198
- * @param {string} [opts.repoRoot]
199
- * @param {object} [opts.deps]
200
- * @param {string|null} [opts.onlyBehavior] Restrict firing to this one behavior key —
201
- * originally used by the three thin per-behavior shim hooks (capture-trigger /
202
- * research-trigger / settings-intent-trigger, retired HS-IYU1 after burn-in) so
203
- * each stayed byte-identical to its old single-behavior self: it lexically tests
204
- * ONLY its own patterns, and on a miss its self-check block offers ONLY its own
205
- * behavior (mode: self_check) / classifies against all eligible but fires only its
206
- * own winner (mode: model). Kept as a general single-behavior mode for callers that
207
- * still need it (e.g. tests). Omit for the unified hook (all behaviors compete /
208
- * are offered together).
209
- * @returns {Promise<{ additionalContext: string, behavior: string, layer: 'lexical'|'semantic', confidence?: number, offered?: string[] } | null>}
210
- * In mode self_check the semantic result has behavior:'self_check' and `offered` lists
211
- * the behavior keys the block presented; in mode model it is the single winning behavior.
212
- */
213
- export async function routePrompt(prompt, { repoRoot, deps = {}, onlyBehavior = null } = {}) {
214
- try {
215
- if (typeof prompt !== 'string' || !prompt.trim()) return null;
216
- const entries = resolveBehaviorRules();
217
- if (!entries.length) return null;
218
-
219
- // Eligibility is computed once and reused for BOTH layers.
220
- const eligible = entries.filter((e) => isEligible(e, prompt, repoRoot));
221
- if (!eligible.length) return null;
222
-
223
- // In onlyBehavior (single-behavior) mode, the lexical sweep and the final fire are
224
- // restricted to that one behavior; if it is ineligible, do nothing at all (matching
225
- // the retired per-hook shims' early-return). The semantic classification still runs
226
- // against all eligible behaviors so the model picks among the same options as before.
227
- const considered = onlyBehavior ? eligible.filter((e) => e.behavior.key === onlyBehavior) : eligible;
228
- if (!considered.length) return null;
229
-
230
- // ── Layer 1: lexical (fast path). First match in BEHAVIOR_ORDER wins. ──
231
- for (const entry of considered) {
232
- if (entry.patterns.some((re) => re.test(prompt))) {
233
- const additionalContext = withConfirmedMatchMarker(entry.behavior.transform(entry.rule.inject, repoRoot, prompt));
234
- appendClassifierEvent(
235
- { surface: SURFACE, rule: entry.behavior.ruleId, layer: 'lexical', decision: 'fire', prompt_chars: prompt.length },
236
- repoRoot,
237
- );
238
- return { additionalContext, behavior: entry.behavior.key, layer: 'lexical' };
239
- }
240
- }
241
-
242
- // ── Layer 2: semantic (regex missed). ──
243
- const cfg = readIntentClassifierConfig(repoRoot);
244
- if (!cfg.semanticEnabled) return null;
245
-
246
- // DEFAULT (mode: self_check): deterministically inject ONE self-check block and let
247
- // THIS session's own agent judge intent — no second model, no network call.
248
- if (cfg.semanticMode !== 'model') {
249
- // Cheap deterministic floor: skip trivially short prompts (no classifiable
250
- // intent). Anything longer gets the block; PRECISION is the agent's job (the
251
- // wrapper tells it the default is "none, do nothing"), not a hook-side pre-verdict.
252
- if (prompt.trim().length < MIN_SELF_CHECK_CHARS) return null;
253
-
254
- const additionalContext = buildSelfCheckBlock(considered, repoRoot, prompt);
255
- if (!additionalContext) return null;
256
- appendClassifierEvent(
257
- {
258
- surface: SURFACE,
259
- // Unified hook offers all eligible behaviors; a shim offers just its one.
260
- rule: onlyBehavior ? considered[0].behavior.ruleId : 'intent-classifier',
261
- layer: 'semantic',
262
- decision: 'self_check_injected',
263
- offered: considered.map((e) => e.behavior.ruleId),
264
- prompt_chars: prompt.length,
265
- },
266
- repoRoot,
267
- );
268
- return {
269
- additionalContext,
270
- behavior: 'self_check',
271
- layer: 'semantic',
272
- offered: considered.map((e) => e.behavior.key),
273
- };
274
- }
275
-
276
- // OPT-IN (mode: model): the original mechanism — AT MOST ONE local-model call.
277
- const behaviorsForModel = eligible.map((e) => ({
278
- key: e.behavior.key,
279
- description: e.behavior.description,
280
- }));
281
- const classify = deps.classify || classifyIntent;
282
- const result = await classify(prompt, behaviorsForModel, { ...cfg, repoRoot });
283
-
284
- if (result.error) {
285
- appendClassifierEvent(
286
- { surface: SURFACE, rule: 'intent-classifier', layer: 'semantic', decision: 'error', error: result.error, prompt_chars: prompt.length },
287
- repoRoot,
288
- );
289
- return null;
290
- }
291
-
292
- // In onlyBehavior mode the winner must be that behavior; otherwise any eligible.
293
- const winner = considered.find((e) => e.behavior.key === result.behavior);
294
- if (!winner) return null; // 'none', another behavior, or an unrecognized/ineligible key
295
-
296
- const fire = result.confidence >= cfg.minConfidence;
297
- appendClassifierEvent(
298
- {
299
- surface: SURFACE,
300
- rule: winner.behavior.ruleId,
301
- layer: 'semantic',
302
- decision: fire ? 'fire' : 'below_threshold',
303
- confidence: result.confidence,
304
- min_confidence: cfg.minConfidence,
305
- model: cfg.model,
306
- latency_ms: result.latencyMs,
307
- prompt_chars: prompt.length,
308
- },
309
- repoRoot,
310
- );
311
- if (!fire) return null;
312
-
313
- const additionalContext = withConfirmedMatchMarker(winner.behavior.transform(winner.rule.inject, repoRoot, prompt));
314
- return { additionalContext, behavior: winner.behavior.key, layer: 'semantic', confidence: result.confidence };
315
- } catch {
316
- return null; // any unexpected error → no injection (fail-safe)
317
- }
318
- }
319
-
320
- // ── shared hook entry (stdin → route → emit) ────────────────────────────────
321
- // The unified hook calls this with `onlyBehavior` unset; the three per-behavior
322
- // shims (capture-trigger / research-trigger / settings-intent-trigger) called it
323
- // with `onlyBehavior` set until they were retired after burn-in (HS-IYU1). Fail-safe:
324
- // reads stdin defensively, never throws.
325
-
326
- export function readStdinJson() {
327
- if (process.stdin.isTTY) return Promise.resolve(null);
328
- return new Promise((resolve) => {
329
- let buf = '';
330
- let settled = false;
331
- const finish = (value) => {
332
- if (settled) return;
333
- settled = true;
334
- resolve(value);
335
- };
336
- const timer = setTimeout(() => finish(buf || ''), 200);
337
- process.stdin.setEncoding('utf8');
338
- process.stdin.on('data', (chunk) => {
339
- buf += chunk;
340
- });
341
- process.stdin.on('end', () => {
342
- clearTimeout(timer);
343
- finish(buf);
344
- });
345
- process.stdin.on('error', () => {
346
- clearTimeout(timer);
347
- finish(buf);
348
- });
349
- });
350
- }
351
-
352
- function emit(additionalContext) {
353
- if (!additionalContext) return;
354
- process.stdout.write(
355
- JSON.stringify({
356
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
357
- }),
358
- );
359
- }
360
-
361
- /**
362
- * Full UserPromptSubmit hook body: read the payload, route the prompt, emit the winner.
363
- * @param {{ onlyBehavior?: string|null }} [options]
364
- */
365
- export async function runClassifierHook({ onlyBehavior = null } = {}) {
366
- try {
367
- const raw = await readStdinJson();
368
- if (!raw) return;
369
- let payload;
370
- try {
371
- payload = JSON.parse(raw);
372
- } catch {
373
- return;
374
- }
375
- if (payload?.hook_event_name !== 'UserPromptSubmit') return;
376
- const prompt = payload?.prompt;
377
- if (typeof prompt !== 'string' || !prompt.trim()) return;
378
- const routed = await routePrompt(prompt, { onlyBehavior });
379
- if (routed) emit(routed.additionalContext);
380
- } catch {
381
- // Never break Claude Code on a hook failure.
382
- }
383
- }
384
-
385
- // ── CLI: resolve one directive on demand ────────────────────────────────────
386
- // The self-check block emits gates plus a pointer here, rather than pasting all
387
- // four directives into every turn. Invoked as:
388
- // node scripts/claude-hooks/_intent-classifier-router.mjs --directive capture_on_idea
389
- // Prints the directive to stdout and exits 0, or an error to stderr and exits 1.
390
- if (process.argv[2] === '--directive') {
391
- const key = process.argv[3];
392
- if (!key) {
393
- process.stderr.write(
394
- 'usage: _intent-classifier-router.mjs --directive <key>\n' +
395
- `known keys: ${CLASSIFIER_BEHAVIORS.map((b) => b.key).join(', ')}\n`,
396
- );
397
- process.exit(1);
398
- }
399
- // repoRoot is optional throughout — the loader helpers fall back to their own
400
- // env/cwd detection, the same as the hook path does.
401
- const directive = resolveDirective(key, undefined, process.argv[4] ?? '');
402
- if (!directive) {
403
- process.stderr.write(
404
- `unknown or unavailable behavior key: ${key}\n` +
405
- `known keys: ${CLASSIFIER_BEHAVIORS.map((b) => b.key).join(', ')}\n`,
406
- );
407
- process.exit(1);
408
- }
409
- process.stdout.write(directive + '\n');
410
- }
1
+ #!/usr/bin/env node
2
+ // HeySpex Intent Classifier — the UNIFIED registry-driven router.
3
+ //
4
+ // One place that reads every `userpromptsubmit` / `prime` CLASSIFIER rule from the
5
+ // compiled guardrail registry, runs ONE lexical sweep, and on a full lexical miss
6
+ // resolves the harder-to-pattern-match cases WITHOUT a second, separate model.
7
+ // Adding a future skill = adding a rule (+ a handler entry only if its inject needs
8
+ // special logic).
9
+ // Design home: [[HS-UJVA-EPIC-KKMO-autonomous-skill-routing-context]] "generalize the router".
10
+ //
11
+ // SEMANTIC LAYER (Layer 2) — TWO MODES ([[HS-M4PN-TASK]]):
12
+ // - self_check (DEFAULT): on a full lexical miss, build ONE deterministic SELF-CHECK
13
+ // block — for each eligible behavior, its classification gate paired with the exact
14
+ // resolved inject Layer 1 would have emitted — and hand it to the agent already
15
+ // running THIS session to judge as part of its own turn. No network, no fleet
16
+ // machine, no timeout/fail-safe path; works identically for the owner and any
17
+ // future customer, and needs zero HeySpex inference budget. The injection is
18
+ // DETERMINISTIC (same input → same block); the JUDGMENT is the agent's.
19
+ // - model (OPT-IN): the original mechanism — AT MOST ONE local-model call classifies
20
+ // the prompt and the winner fires above min_confidence. Demoted to opt-in; never
21
+ // on the default path, so no reachable endpoint is required.
22
+ // The mode is resolved by readIntentClassifierConfig() (config `mode:` / env
23
+ // HEYSPEX_INTENT_SEMANTIC_MODE). HEYSPEX_INTENT_SEMANTIC_OFF=1 disables Layer 2 wholly.
24
+ //
25
+ // PER-BEHAVIOR FIDELITY (byte-identical to the old separate hooks):
26
+ // - capture_on_idea: inject runs through applyConfirmMode() — the {{CONFIRM_MODE}}
27
+ // token is replaced with the forgiveness (auto) or permission (ask) directive,
28
+ // resolved by resolveConfirmMode('capture_on_idea'). Injected-content guard:
29
+ // startsWithInjectedContent (strict envelope-leads-with).
30
+ // - research_before_decision: rule.inject + appendResearchSourceHint() — a best-effort
31
+ // hint naming agent_behaviors.research.default_source/default_agent, appended (not
32
+ // replacing) so the original inject text stays byte-identical up to that point
33
+ // ([[HS-LFUH-DIAG-W1YE]] fix). Injected-content guard: containsInjectedContent
34
+ // (contains-anywhere — the research hook's original guard).
35
+ // - settings_intent: raw rule.inject. Injected-content guard: startsWithInjectedContent.
36
+ // Each behavior keeps its own config_key enable/disable and env kill switch. In the
37
+ // self_check mode the SAME per-behavior transform builds each directive inside the
38
+ // combined block, so the directive the agent follows stays byte-identical to Layer 1.
39
+ //
40
+ // PRECEDENCE: a lexical hit always wins over the semantic layer. Among lexical hits,
41
+ // the FIRST behavior in BEHAVIOR_ORDER wins (deterministic). The behaviors are
42
+ // mutually exclusive on their pattern sets today (verified by the cross-fire matrix in
43
+ // the smoke suites) — the order only matters as a tiebreak that must never be reached.
44
+ //
45
+ // HARD FAIL-SAFE: every function degrades to "no injection". A corrupt/missing
46
+ // registry, a disabled rule, an unreachable/slow model, or any thrown error → the
47
+ // caller emits nothing and exits 0. Dependency-free (Node stdlib + global fetch).
48
+
49
+ import {
50
+ loadGuardrails,
51
+ rulesForSurface,
52
+ compilePatterns,
53
+ isRuleEnabled,
54
+ } from './_guardrail-loader.mjs';
55
+ import {
56
+ classifyIntent,
57
+ readIntentClassifierConfig,
58
+ appendClassifierEvent,
59
+ } from './_intent-classifier-semantic.mjs';
60
+ // HS-N01T: the directive assembly MOVED to the engine (spexharness/src/intent-directive.mjs)
61
+ // so the Claude Code hook and a SpexCode seat build the SAME text for the same prompt. This
62
+ // router imports it from there and RE-EXPORTS every moved symbol under its old name, so the
63
+ // trigger hook, the smokes and the `--directive` CLI keep their imports and their bytes.
64
+ // Layering: the plugin scripts import DOWN into the engine, never the reverse.
65
+ import {
66
+ CONFIRMED_MATCH_MARKER,
67
+ withConfirmedMatchMarker,
68
+ withMatchMarker,
69
+ ruleMatchesPrompt,
70
+ CAPTURE_MODE_DIRECTIVES,
71
+ CLASSIFIER_BEHAVIORS,
72
+ applyCaptureConfirmMode,
73
+ appendCaptureKindHint,
74
+ appendCaptureRoutingHint,
75
+ appendResearchSourceHint,
76
+ buildConfirmedDirective,
77
+ behaviorKeyForRuleId,
78
+ isBehaviorEligible,
79
+ } from '../../spexharness/src/intent-directive.mjs';
80
+
81
+ export {
82
+ CONFIRMED_MATCH_MARKER,
83
+ CAPTURE_MODE_DIRECTIVES,
84
+ CLASSIFIER_BEHAVIORS,
85
+ applyCaptureConfirmMode,
86
+ appendCaptureKindHint,
87
+ appendCaptureRoutingHint,
88
+ appendResearchSourceHint,
89
+ buildConfirmedDirective,
90
+ behaviorKeyForRuleId,
91
+ isBehaviorEligible,
92
+ };
93
+
94
+ const SURFACE = 'userpromptsubmit';
95
+
96
+ // Human-readable heading per behavior for the self-check block. Falls back to the key.
97
+ const SELF_CHECK_LABELS = {
98
+ capture_on_idea: 'Capture-on-idea',
99
+ research_before_decision: 'Research-before-decision',
100
+ settings_intent: 'Settings intent',
101
+ defer_intent: 'Defer / schedule intent',
102
+ handoff_intent: 'Hand-off request',
103
+ spec_reference_lookup: 'Spec-reference lookup',
104
+ };
105
+
106
+ // Cheap deterministic emission floor (mirrors the old classifyIntent text.length<12
107
+ // short-prompt skip): a sub-12-char message ("yes", "ok", "proceed") carries no
108
+ // classifiable intent, so never spend the agent's attention self-checking it.
109
+ export const MIN_SELF_CHECK_CHARS = 12;
110
+
111
+ /**
112
+ * Build the ONE deterministic self-check block emitted on a lexical miss (mode:
113
+ * self_check). For each considered behavior it pairs the classification GATE (the same
114
+ * description the model was given) with the exact resolved inject Layer 1 would have
115
+ * emitted — so whichever ONE the agent judges to apply, it follows the byte-identical
116
+ * directive. The wrapper's whole job is precision: it states loudly that the common
117
+ * outcome is "none" and that the check must never leak into the visible response, so a
118
+ * block injected on every substantive prompt does not turn into noise. Deterministic:
119
+ * same considered set + repoRoot → same block.
120
+ */
121
+ export function buildSelfCheckBlock(considered, repoRoot, prompt) {
122
+ const sections = considered.map((entry) => {
123
+ const label = SELF_CHECK_LABELS[entry.behavior.key] || entry.behavior.key;
124
+ return (
125
+ `### ${label} (${entry.behavior.key})\n` +
126
+ `Applies ONLY when: ${entry.behavior.description}`
127
+ );
128
+ });
129
+ return (
130
+ 'HEYSPEX SILENT SELF-CHECK — a deterministic system note (NOT written by the user). ' +
131
+ "Before you respond, silently judge the user's latest message against the gate(s) below.\n" +
132
+ 'DEFAULT OUTCOME IS "NONE": ordinary work orders, questions about existing work, direct answers, ' +
133
+ 'and small talk match NONE of these. When none clearly applies — the common case — do NOT mention ' +
134
+ "this note, do NOT change what you would otherwise do, and just carry out the user's request normally.\n" +
135
+ 'If EXACTLY ONE gate clearly applies, run this to get its exact directive and follow that verbatim:\n' +
136
+ ` node scripts/claude-hooks/_intent-classifier-router.mjs --directive <key>\n` +
137
+ '(<key> is the parenthesised key on the heading, e.g. capture_on_idea. If two gates seem to apply, ' +
138
+ 'pick the single best fit and fetch only that one.) Never announce, quote, or acknowledge that you ' +
139
+ 'ran this check — the user must not see it.\n\n' +
140
+ sections.join('\n\n')
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Resolve ONE behavior's full directive on demand — the other half of the
146
+ * gate-plus-pointer split above.
147
+ *
148
+ * The block used to paste all four directives into every substantive turn, about
149
+ * 4,200 tokens, so the agent could judge which one applied. It usually applied to
150
+ * none of them, which meant paying for four directives to use zero. Now the block
151
+ * carries only the gates (what you need to JUDGE) and this resolves the directive
152
+ * (what you need to ACT) for the one gate that fired.
153
+ *
154
+ * Returns the directive string, or null when the key is unknown / its rule is gone.
155
+ */
156
+ export function resolveDirective(behaviorKey, repoRoot, prompt = '') {
157
+ const resolved = resolveBehaviorRules();
158
+ const entry = resolved.find((e) => e.behavior.key === behaviorKey);
159
+ if (!entry) return null;
160
+ return entry.behavior.transform(entry.rule.inject, repoRoot, prompt);
161
+ }
162
+
163
+ /**
164
+ * Resolve the compiled rule for each classifier behavior from the registry. Returns a
165
+ * map behaviorKey → { behavior, rule, patterns } for behaviors whose rule exists and
166
+ * carries an inject. FAIL-SAFE: a missing rule simply drops that behavior.
167
+ */
168
+ function resolveBehaviorRules() {
169
+ const guardrails = loadGuardrails();
170
+ const primeRules = rulesForSurface(guardrails, SURFACE, 'prime');
171
+ const byId = new Map(primeRules.map((r) => [r.rule, r]));
172
+ const resolved = [];
173
+ for (const behavior of CLASSIFIER_BEHAVIORS) {
174
+ const rule = byId.get(behavior.ruleId);
175
+ if (!rule || !rule.inject) continue;
176
+ resolved.push({ behavior, rule, patterns: compilePatterns(rule) });
177
+ }
178
+ return resolved;
179
+ }
180
+
181
+ /**
182
+ * Is this behavior eligible to fire on this prompt at all? (env kill switch off,
183
+ * config_key enabled, and the injected-content envelope guard passes). Behaviors that
184
+ * are ineligible participate in neither the lexical sweep nor the semantic call —
185
+ * matching the old per-hook early-returns exactly.
186
+ */
187
+ function isEligible(entry, prompt, repoRoot) {
188
+ const { behavior, rule } = entry;
189
+ if (process.env[behavior.envOff] === '1') return false;
190
+ if (rule.ignoreInjectedContent && behavior.guard(prompt)) return false;
191
+ if (!isRuleEnabled(rule, repoRoot)) return false;
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * Classify a prompt and return the single inject to emit, or null.
197
+ * `deps.classify` is injectable for tests (defaults to the real semantic classifier).
198
+ * Guarantees AT MOST ONE call to `deps.classify` per invocation.
199
+ *
200
+ * @param {object} [opts]
201
+ * @param {string} [opts.repoRoot]
202
+ * @param {object} [opts.deps]
203
+ * @param {string|null} [opts.onlyBehavior] Restrict firing to this one behavior key —
204
+ * originally used by the three thin per-behavior shim hooks (capture-trigger /
205
+ * research-trigger / settings-intent-trigger, retired HS-IYU1 after burn-in) so
206
+ * each stayed byte-identical to its old single-behavior self: it lexically tests
207
+ * ONLY its own patterns, and on a miss its self-check block offers ONLY its own
208
+ * behavior (mode: self_check) / classifies against all eligible but fires only its
209
+ * own winner (mode: model). Kept as a general single-behavior mode for callers that
210
+ * still need it (e.g. tests). Omit for the unified hook (all behaviors compete /
211
+ * are offered together).
212
+ * @returns {Promise<{ additionalContext: string, behavior: string, layer: 'lexical'|'semantic', confidence?: number, offered?: string[] } | null>}
213
+ * In mode self_check the semantic result has behavior:'self_check' and `offered` lists
214
+ * the behavior keys the block presented; in mode model it is the single winning behavior.
215
+ */
216
+ export async function routePrompt(prompt, { repoRoot, deps = {}, onlyBehavior = null } = {}) {
217
+ try {
218
+ if (typeof prompt !== 'string' || !prompt.trim()) return null;
219
+ const entries = resolveBehaviorRules();
220
+ if (!entries.length) return null;
221
+
222
+ // Eligibility is computed once and reused for BOTH layers.
223
+ const eligible = entries.filter((e) => isEligible(e, prompt, repoRoot));
224
+ if (!eligible.length) return null;
225
+
226
+ // In onlyBehavior (single-behavior) mode, the lexical sweep and the final fire are
227
+ // restricted to that one behavior; if it is ineligible, do nothing at all (matching
228
+ // the retired per-hook shims' early-return). The semantic classification still runs
229
+ // against all eligible behaviors so the model picks among the same options as before.
230
+ const considered = onlyBehavior ? eligible.filter((e) => e.behavior.key === onlyBehavior) : eligible;
231
+ if (!considered.length) return null;
232
+
233
+ // ── Layer 1: lexical (fast path). First match in BEHAVIOR_ORDER wins. ──
234
+ for (const entry of considered) {
235
+ // HS-XDH8: negation-aware for guarded rules; per-behavior marker (defer/settings are precision-gated).
236
+ if (ruleMatchesPrompt(entry.rule, entry.patterns, prompt)) {
237
+ const additionalContext = withMatchMarker(entry.behavior.key, entry.behavior.transform(entry.rule.inject, repoRoot, prompt));
238
+ appendClassifierEvent(
239
+ { surface: SURFACE, rule: entry.behavior.ruleId, layer: 'lexical', decision: 'fire', prompt_chars: prompt.length },
240
+ repoRoot,
241
+ );
242
+ return { additionalContext, behavior: entry.behavior.key, layer: 'lexical' };
243
+ }
244
+ }
245
+
246
+ // ── Layer 2: semantic (regex missed). ──
247
+ const cfg = readIntentClassifierConfig(repoRoot);
248
+ if (!cfg.semanticEnabled) return null;
249
+
250
+ // DEFAULT (mode: self_check): deterministically inject ONE self-check block and let
251
+ // THIS session's own agent judge intent — no second model, no network call.
252
+ if (cfg.semanticMode !== 'model') {
253
+ // Cheap deterministic floor: skip trivially short prompts (no classifiable
254
+ // intent). Anything longer gets the block; PRECISION is the agent's job (the
255
+ // wrapper tells it the default is "none, do nothing"), not a hook-side pre-verdict.
256
+ if (prompt.trim().length < MIN_SELF_CHECK_CHARS) return null;
257
+
258
+ const additionalContext = buildSelfCheckBlock(considered, repoRoot, prompt);
259
+ if (!additionalContext) return null;
260
+ appendClassifierEvent(
261
+ {
262
+ surface: SURFACE,
263
+ // Unified hook offers all eligible behaviors; a shim offers just its one.
264
+ rule: onlyBehavior ? considered[0].behavior.ruleId : 'intent-classifier',
265
+ layer: 'semantic',
266
+ decision: 'self_check_injected',
267
+ offered: considered.map((e) => e.behavior.ruleId),
268
+ prompt_chars: prompt.length,
269
+ },
270
+ repoRoot,
271
+ );
272
+ return {
273
+ additionalContext,
274
+ behavior: 'self_check',
275
+ layer: 'semantic',
276
+ offered: considered.map((e) => e.behavior.key),
277
+ };
278
+ }
279
+
280
+ // OPT-IN (mode: model): the original mechanism — AT MOST ONE local-model call.
281
+ const behaviorsForModel = eligible.map((e) => ({
282
+ key: e.behavior.key,
283
+ description: e.behavior.description,
284
+ }));
285
+ const classify = deps.classify || classifyIntent;
286
+ const result = await classify(prompt, behaviorsForModel, { ...cfg, repoRoot });
287
+
288
+ // Model rung unreachable / errored / unsure: fall through to the deterministic self-check block
289
+ // instead of going silent (2026-10-10, diagnose HS-IK2D: a silent null here let ideas pass uncaptured).
290
+ const selfCheckFallback = (decision, extra = {}) => {
291
+ if (prompt.trim().length < MIN_SELF_CHECK_CHARS) return null;
292
+ const additionalContext = buildSelfCheckBlock(considered, repoRoot, prompt);
293
+ if (!additionalContext) return null;
294
+ appendClassifierEvent(
295
+ {
296
+ surface: SURFACE,
297
+ rule: onlyBehavior ? considered[0].behavior.ruleId : 'intent-classifier',
298
+ layer: 'semantic',
299
+ decision,
300
+ ...extra,
301
+ offered: considered.map((e) => e.behavior.ruleId),
302
+ prompt_chars: prompt.length,
303
+ },
304
+ repoRoot,
305
+ );
306
+ return { additionalContext, behavior: 'self_check', layer: 'semantic', offered: considered.map((e) => e.behavior.key) };
307
+ };
308
+
309
+ if (result.error) {
310
+ appendClassifierEvent(
311
+ { surface: SURFACE, rule: 'intent-classifier', layer: 'semantic', decision: 'error', error: result.error, prompt_chars: prompt.length },
312
+ repoRoot,
313
+ );
314
+ return selfCheckFallback('self_check_injected_after_model_error');
315
+ }
316
+
317
+ // In onlyBehavior mode the winner must be that behavior; otherwise any eligible.
318
+ const winner = considered.find((e) => e.behavior.key === result.behavior);
319
+ if (!winner) return null; // 'none', another behavior, or an unrecognized/ineligible key
320
+
321
+ const fire = result.confidence >= cfg.minConfidence;
322
+ appendClassifierEvent(
323
+ {
324
+ surface: SURFACE,
325
+ rule: winner.behavior.ruleId,
326
+ layer: 'semantic',
327
+ decision: fire ? 'fire' : 'below_threshold',
328
+ confidence: result.confidence,
329
+ min_confidence: cfg.minConfidence,
330
+ model: cfg.model,
331
+ latency_ms: result.latencyMs,
332
+ prompt_chars: prompt.length,
333
+ },
334
+ repoRoot,
335
+ );
336
+ if (!fire) return selfCheckFallback('self_check_injected_after_low_confidence', { confidence: result.confidence });
337
+
338
+ const additionalContext = withMatchMarker(winner.behavior.key, winner.behavior.transform(winner.rule.inject, repoRoot, prompt));
339
+ return { additionalContext, behavior: winner.behavior.key, layer: 'semantic', confidence: result.confidence };
340
+ } catch {
341
+ return null; // any unexpected error → no injection (fail-safe)
342
+ }
343
+ }
344
+
345
+ // ── shared hook entry (stdin → route → emit) ────────────────────────────────
346
+ // The unified hook calls this with `onlyBehavior` unset; the three per-behavior
347
+ // shims (capture-trigger / research-trigger / settings-intent-trigger) called it
348
+ // with `onlyBehavior` set until they were retired after burn-in (HS-IYU1). Fail-safe:
349
+ // reads stdin defensively, never throws.
350
+
351
+ export function readStdinJson() {
352
+ if (process.stdin.isTTY) return Promise.resolve(null);
353
+ return new Promise((resolve) => {
354
+ let buf = '';
355
+ let settled = false;
356
+ const finish = (value) => {
357
+ if (settled) return;
358
+ settled = true;
359
+ resolve(value);
360
+ };
361
+ const timer = setTimeout(() => finish(buf || ''), 200);
362
+ process.stdin.setEncoding('utf8');
363
+ process.stdin.on('data', (chunk) => {
364
+ buf += chunk;
365
+ });
366
+ process.stdin.on('end', () => {
367
+ clearTimeout(timer);
368
+ finish(buf);
369
+ });
370
+ process.stdin.on('error', () => {
371
+ clearTimeout(timer);
372
+ finish(buf);
373
+ });
374
+ });
375
+ }
376
+
377
+ function emit(additionalContext) {
378
+ if (!additionalContext) return;
379
+ process.stdout.write(
380
+ JSON.stringify({
381
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
382
+ }),
383
+ );
384
+ }
385
+
386
+ /**
387
+ * Full UserPromptSubmit hook body: read the payload, route the prompt, emit the winner.
388
+ * @param {{ onlyBehavior?: string|null }} [options]
389
+ */
390
+ export async function runClassifierHook({ onlyBehavior = null } = {}) {
391
+ try {
392
+ const raw = await readStdinJson();
393
+ if (!raw) return;
394
+ let payload;
395
+ try {
396
+ payload = JSON.parse(raw);
397
+ } catch {
398
+ return;
399
+ }
400
+ if (payload?.hook_event_name !== 'UserPromptSubmit') return;
401
+ const prompt = payload?.prompt;
402
+ if (typeof prompt !== 'string' || !prompt.trim()) return;
403
+ const routed = await routePrompt(prompt, { onlyBehavior });
404
+ // HS-KNJ8 deterministic verbatim stage, also on the legacy path (engine: false): an idea-bearing
405
+ // prompt (capture/research lexical hit, or the self-check rung) is appended VERBATIM to the capture on
406
+ // deck for this session. A self-check prompt with NOTHING on deck gets the hard capture directive.
407
+ const append =
408
+ routed?.behavior === 'capture_on_idea' ||
409
+ routed?.behavior === 'research_before_decision' ||
410
+ routed?.behavior === 'self_check';
411
+ let note = null;
412
+ if (append) {
413
+ try {
414
+ // Variable specifier on purpose: platform-only module, absent from the published npm closure.
415
+ const mod = '../lib/capture-verbatim-append.mjs';
416
+ const { runVerbatimStageForHook } = await import(mod);
417
+ note = await runVerbatimStageForHook(payload, prompt, append, {
418
+ directiveIfNoCapture: routed.behavior === 'self_check',
419
+ armObligation: routed.behavior === 'self_check' || routed.behavior === 'capture_on_idea',
420
+ });
421
+ } catch {
422
+ note = null;
423
+ }
424
+ }
425
+ if (routed) emit([routed.additionalContext, note].filter(Boolean).join(String.fromCharCode(10,10)));
426
+ } catch {
427
+ // Never break Claude Code on a hook failure.
428
+ }
429
+ }
430
+
431
+ // ── CLI: resolve one directive on demand ────────────────────────────────────
432
+ // The self-check block emits gates plus a pointer here, rather than pasting all
433
+ // four directives into every turn. Invoked as:
434
+ // node scripts/claude-hooks/_intent-classifier-router.mjs --directive capture_on_idea
435
+ // Prints the directive to stdout and exits 0, or an error to stderr and exits 1.
436
+ if (process.argv[2] === '--directive') {
437
+ const key = process.argv[3];
438
+ if (!key) {
439
+ process.stderr.write(
440
+ 'usage: _intent-classifier-router.mjs --directive <key>\n' +
441
+ `known keys: ${CLASSIFIER_BEHAVIORS.map((b) => b.key).join(', ')}\n`,
442
+ );
443
+ process.exit(1);
444
+ }
445
+ // repoRoot is optional throughout — the loader helpers fall back to their own
446
+ // env/cwd detection, the same as the hook path does.
447
+ const directive = resolveDirective(key, undefined, process.argv[4] ?? '');
448
+ if (!directive) {
449
+ process.stderr.write(
450
+ `unknown or unavailable behavior key: ${key}\n` +
451
+ `known keys: ${CLASSIFIER_BEHAVIORS.map((b) => b.key).join(', ')}\n`,
452
+ );
453
+ process.exit(1);
454
+ }
455
+ process.stdout.write(directive + '\n');
456
+ }