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.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
'
|
|
131
|
-
"
|
|
132
|
-
'
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
'
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* @param {
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
if (cfg.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
}
|