heyspex 0.2.2 → 0.2.3

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 (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +81 -77
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -177
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -250
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -312
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +64 -34
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -1,279 +1,305 @@
1
- #!/usr/bin/env node
2
- // Claude Code hook → the HeySpex Intent Classifier's SINGLE UserPromptSubmit entry point.
3
- //
4
- // HS-5ISS ENGINE CUTOVER: this hook now classifies through the engine module
5
- // (spexharness/src/intent-classifier.mjs's classifyPrompt) instead of the old
6
- // per-prompt Layer 2 "self_check" prose block (_intent-classifier-router.mjs's
7
- // buildSelfCheckBlock — ~4,200 tokens / ~6.5 KB, pasted into EVERY prompt that missed
8
- // every lexical pattern, i.e. almost every ordinary work order). The engine module is
9
- // LEXICAL ONLY: a prompt that matches none of the five classifier rules
10
- // (capture-on-idea, research-before-decision, settings-intent, defer-intent,
11
- // spec-reference-lookup) now injects NOTHING — under 500 bytes, usually zero — instead
12
- // of the old self-check block (AC3, [[HS-5ISS]]).
13
- //
14
- // A rule the engine matches still has to clear the SAME gating the old lexical sweep
15
- // applied (env kill switch, agent_behaviors.<key>.enabled, the injected-content guard)
16
- // before it fires — the engine module itself knows only patterns, not config or env,
17
- // so isBehaviorEligible() re-applies that gating here. Once eligible, the directive is
18
- // built through the SAME per-behavior transform (capture's confirm-mode substitution +
19
- // hints, research's source hint, settings/defer/spec-reference's raw inject) and
20
- // wrapped in the same CONFIRMED MATCH marker as the old lexical path
21
- // (buildConfirmedDirective) — so a gate that fires emits a byte-identical directive to
22
- // before. The five gates' own matching patterns and inject text are untouched.
23
- //
24
- // CUTOVER FLAG: agent_behaviors.intent_classifier.engine (.heyspex/config.yaml, read
25
- // from the VAULT root via the loader's default config root — this hook never joins a
26
- // config path itself; [[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
27
- // Defaults ON (isConfigKeyEnabled semantics — absent/anything but explicit `false` is
28
- // enabled). Set to `false` to fall back to the pre-HS-5ISS path in full — the unified
29
- // lexical-sweep-plus-Layer-2-self-check hook, unchanged (_intent-classifier-router.mjs's
30
- // runClassifierHook()).
31
- //
32
- // UNGATED by CLAUDE_AGENT — interactive human typing is the point. Per-behavior mutes
33
- // still work: HEYSPEX_CAPTURE_TRIGGER_OFF / HEYSPEX_RESEARCH_TRIGGER_OFF /
34
- // HEYSPEX_SETTINGS_INTENT_OFF / HEYSPEX_DEFER_INTENT_OFF / HEYSPEX_SPEC_REFERENCE_LOOKUP_OFF
35
- // = 1. HEYSPEX_INTENT_ENGINE_OFF=1 is the cutover's own env kill switch — same
36
- // contract as every other behavior here (a config_key AND an env var, never just
37
- // one) — and forces the old fallback path without touching the git-tracked config
38
- // file, which is what tests use to exercise the fallback safely. Decisions audited
39
- // to .heyspex/intent-classifier-history.jsonl.
40
- //
41
- // FAIL-SAFE: exits 0 on every path. A missing/corrupt config, a missing rule table, or
42
- // any thrown error degrades to "no injection" (engine path) or defers to the untouched
43
- // fallback (runClassifierHook never throws either).
44
-
45
- import { classifyPrompt } from '../../spexharness/src/intent-classifier.mjs';
46
- import { isConfigKeyEnabled, startsWithInjectedContent } from './_guardrail-loader.mjs';
47
- import {
48
- appendClassifierEvent,
49
- classifyIntent,
50
- readIntentClassifierConfig,
51
- } from './_intent-classifier-semantic.mjs';
52
- import {
53
- runClassifierHook,
54
- readStdinJson,
55
- behaviorKeyForRuleId,
56
- isBehaviorEligible,
57
- buildConfirmedDirective,
58
- buildSelfCheckBlock,
59
- CLASSIFIER_BEHAVIORS,
60
- MIN_SELF_CHECK_CHARS,
61
- } from './_intent-classifier-router.mjs';
62
-
63
- const ENGINE_FLAG = 'agent_behaviors.intent_classifier.engine';
64
-
65
- // HS-KNJ8: deterministic verbatim capture. After the classifier has run, an idea-bearing prompt
66
- // (a capture_on_idea / research_before_decision match, or the semantic-miss rung firing) is appended
67
- // VERBATIM to the capture on deck for THIS session, and the model is told it was done. Also refreshes
68
- // the PreToolUse capture-execution-gate's state file. Returns the note to append to the emitted context.
69
- // Body moved to scripts/lib/capture-verbatim-append.mjs (runVerbatimStageForHook) so the legacy router shares it.
70
- // Dynamic + variable specifier on purpose: platform-only module (customer-kit scope 'platform'), NOT in the
71
- // published npm closure; a customer install simply has no verbatim stage (caught -> null).
72
- //
73
- // HS-20SW: BOTH UserPromptSubmit entry points (this engine path AND the legacy router) now pass the same
74
- // options, so engine:true and engine:false behave identically ([[HS-LES-a-safety-net-wired-into-only-one-of-two]]):
75
- // `directiveIfNoCapture` on the semantic-miss rung (the hard directive when nothing is on deck, exactly what the
76
- // legacy self_check rung does) and `armObligation` on every capture-bearing path, which records the outstanding
77
- // capture obligation the directive-obligation-gate enforces.
78
- async function verbatimStage(payload, prompt, append, options = {}) {
79
- try {
80
- const mod = '../lib/capture-verbatim-append.mjs';
81
- const { runVerbatimStageForHook } = await import(mod);
82
- return await runVerbatimStageForHook(payload, prompt, append, options);
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
- const NOTE_SEP = '\n\n';
89
- const IDEA_BEARING_BEHAVIORS =new Set(['capture_on_idea', 'research_before_decision']);
90
-
91
- // THE SEMANTIC NET ON THE ENGINE PATH ([[HS-3J57-7EDT-DIAG-intent-classifier-s-semantic-fallback]],
92
- // [[HS-8A3Y-LELB-DIAG-a-prose-feature-request-bypassed]]). The HS-5ISS cutover made a
93
- // lexical miss a silent no-op — the ~6.5 KB Layer 2 self-check block was dropped and
94
- // nothing took its place, so a paraphrased idea, or an owner's work-order-shaped
95
- // feature ask ("please also add this to heyspex-app … should we create our own …"),
96
- // reached the agent with no capture directive at all and was built straight from the
97
- // prompt. This is the replacement: on a miss, when the semantic layer is on
98
- // (agent_behaviors.intent_classifier.semantic_enabled, env HEYSPEX_INTENT_SEMANTIC_OFF=1
99
- // to mute), the hook's behavior depends on the resolved mode
100
- // (agent_behaviors.intent_classifier.mode, default self_check):
101
- // - mode:self_check (DEFAULT) — the SAME deterministic SELF-CHECK block the legacy
102
- // _intent-classifier-router.mjs path builds (buildSelfCheckBlock, reused verbatim
103
- // — not a second copy of its prose) over whichever behaviors are still eligible on
104
- // this prompt (env kill switch / config_key / injected-content guard, via
105
- // isBehaviorEligible). This is a REAL directive, not an advisory: the agent judges
106
- // the gates and, on a hit, fetches and follows the exact directive via
107
- // `--directive <key>`, byte-identical to Layer 1. A prompt with zero eligible
108
- // behaviors emits nothing (nothing to self-check).
109
- // - mode:model (OPT-IN) — the local classifier is consulted first and a confident hit
110
- // fires the full CONFIRMED MATCH directive, exactly as the legacy router's opt-in
111
- // path did. When the model is silent, low-confidence, or unreachable, the hook
112
- // falls back to the COMPACT_GATE_CHECK advisory below (under 400 bytes) rather than
113
- // going fully silent again.
114
- // A sub-12-char prompt ("yes", "proceed") and a harness envelope (task-notification /
115
- // system-reminder as the prompt) never get either treatment.
116
- export const COMPACT_GATE_CHECK =
117
- 'HEYSPEX GATE CHECK (system note): no lexical rule matched. If this is a raw idea or a ' +
118
- 'feature ask (please add/build X, should we build our own X), run `node ' +
119
- 'scripts/claude-hooks/_intent-classifier-router.mjs --directive capture_on_idea` and ' +
120
- 'follow it. Other keys: research_before_decision, settings_intent, defer_intent, ' +
121
- 'spec_reference_lookup. Otherwise proceed silently.';
122
-
123
- /**
124
- * The engine-miss fallback. Returns the additionalContext to emit, or null when the
125
- * miss must stay silent (semantic layer off, prompt too short, harness envelope).
126
- * Never throws.
127
- */
128
- export async function engineMissFallback(prompt) {
129
- try {
130
- if (typeof prompt !== 'string' || prompt.trim().length < MIN_SELF_CHECK_CHARS) return null;
131
- if (startsWithInjectedContent(prompt)) return null;
132
- const cfg = readIntentClassifierConfig();
133
- if (!cfg.semanticEnabled) return null;
134
-
135
- if (cfg.semanticMode === 'model') {
136
- const result = await classifyIntent(prompt);
137
- if (
138
- result?.behavior &&
139
- result.confidence >= cfg.minConfidence &&
140
- isBehaviorEligible(result.behavior, prompt)
141
- ) {
142
- const directive = buildConfirmedDirective(result.behavior, undefined, prompt);
143
- if (directive) {
144
- appendClassifierEvent({
145
- surface: 'userpromptsubmit',
146
- rule: result.behavior,
147
- layer: 'semantic',
148
- decision: 'fire',
149
- engine: true,
150
- confidence: result.confidence,
151
- latency_ms: result.latencyMs,
152
- prompt_chars: prompt.length,
153
- });
154
- return directive;
155
- }
156
- }
157
- // model silent/low-confidence/unreachable/ineligible → fall through to the
158
- // COMPACT_GATE_CHECK advisory below (unchanged from before this fix).
159
- } else if (cfg.semanticMode === 'self_check') {
160
- // THE FIX: reuse the legacy router's own self-check block builder
161
- // (_intent-classifier-router.mjs's buildSelfCheckBlock) rather than the weak
162
- // COMPACT_GATE_CHECK advisory — this is what closes the Layer 2 hole if
163
- // `engine` is ever flipped back to true while mode stays self_check (the
164
- // default and, on this machine, the only reachable mode: `model` needs an
165
- // Ollama endpoint that is off the network here). Byte-identical to what the
166
- // legacy path would emit for the SAME considered set, so a gate an agent
167
- // judges to fire resolves to the exact same directive either way.
168
- const considered = CLASSIFIER_BEHAVIORS.filter((behavior) =>
169
- isBehaviorEligible(behavior.key, prompt),
170
- ).map((behavior) => ({ behavior }));
171
- if (considered.length) {
172
- const additionalContext = buildSelfCheckBlock(considered, undefined, prompt);
173
- if (additionalContext) {
174
- appendClassifierEvent({
175
- surface: 'userpromptsubmit',
176
- rule: 'intent-classifier',
177
- layer: 'semantic',
178
- decision: 'self_check_injected',
179
- engine: true,
180
- offered: considered.map((entry) => entry.behavior.key),
181
- prompt_chars: prompt.length,
182
- });
183
- return additionalContext;
184
- }
185
- }
186
- // No behavior is currently eligible (every gate env/config-disabled) — mirror
187
- // the legacy router's routePrompt(), which returns null before ever building a
188
- // self-check block when its `eligible` set is empty. Nothing to self-check.
189
- return null;
190
- }
191
-
192
- appendClassifierEvent({
193
- surface: 'userpromptsubmit',
194
- rule: null,
195
- layer: 'semantic',
196
- decision: 'compact_gate_check',
197
- engine: true,
198
- mode: cfg.semanticMode,
199
- prompt_chars: prompt.length,
200
- });
201
- return COMPACT_GATE_CHECK;
202
- } catch {
203
- return null;
204
- }
205
- }
206
-
207
- function emit(additionalContext) {
208
- if (!additionalContext) return;
209
- process.stdout.write(
210
- JSON.stringify({
211
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
212
- }),
213
- );
214
- }
215
-
216
- /**
217
- * The engine-cutover hook body: read the payload, classify via the engine module, and
218
- * emit a directive ONLY when a gate both matches lexically AND is eligible (env/config/
219
- * guard). Every other path — a full lexical miss, an unknown rule id, an ineligible
220
- * behavior, a malformed payload — emits nothing at all, which is the point (AC3).
221
- * Never throws (mirrors runClassifierHook's own contract).
222
- */
223
- export async function runEngineClassifierHook() {
224
- try {
225
- const raw = await readStdinJson();
226
- if (!raw) return;
227
- let payload;
228
- try {
229
- payload = JSON.parse(raw);
230
- } catch {
231
- return;
232
- }
233
- if (payload?.hook_event_name !== 'UserPromptSubmit') return;
234
- const prompt = payload?.prompt;
235
- if (typeof prompt !== 'string' || !prompt.trim()) return;
236
-
237
- const hit = classifyPrompt(prompt);
238
- if (!hit) {
239
- // Lexical miss: the compact semantic net (see engineMissFallback) — never the
240
- // old multi-KB self-check block, and nothing at all when the layer is off.
241
- const missContext = await engineMissFallback(prompt);
242
- // The semantic-miss rung fired (substantive, non-envelope prompt, layer on) = idea-bearing.
243
- const missNote = await verbatimStage(payload, prompt, Boolean(missContext), { directiveIfNoCapture: Boolean(missContext), armObligation: Boolean(missContext) });
244
- emit([missContext, missNote].filter(Boolean).join(NOTE_SEP));
245
- return;
246
- }
247
-
248
- const behaviorKey = behaviorKeyForRuleId(hit.rule);
249
- if (!behaviorKey) return; // an engine rule id this router doesn't know as a behavior
250
-
251
- if (!isBehaviorEligible(behaviorKey, prompt)) return; // env-off / config-disabled / injected-content guard
252
-
253
- const directive = buildConfirmedDirective(behaviorKey, undefined, prompt);
254
- if (!directive) return;
255
-
256
- appendClassifierEvent({
257
- surface: 'userpromptsubmit',
258
- rule: hit.rule,
259
- layer: 'lexical',
260
- decision: 'fire',
261
- engine: true,
262
- prompt_chars: prompt.length,
263
- });
264
-
265
- const hitNote = await verbatimStage(payload, prompt, IDEA_BEARING_BEHAVIORS.has(behaviorKey), {
266
- armObligation: behaviorKey === 'capture_on_idea',
267
- });
268
- emit([directive, hitNote].filter(Boolean).join(NOTE_SEP));
269
- } catch {
270
- // Never break Claude Code on a hook failure.
271
- }
272
- }
273
-
274
- const engineForcedOff = process.env.HEYSPEX_INTENT_ENGINE_OFF === '1';
275
- if (!engineForcedOff && isConfigKeyEnabled(ENGINE_FLAG)) {
276
- runEngineClassifierHook();
277
- } else {
278
- runClassifierHook();
279
- }
1
+ #!/usr/bin/env node
2
+ // Claude Code hook → the HeySpex Intent Classifier's SINGLE UserPromptSubmit entry point.
3
+ //
4
+ // HS-5ISS ENGINE CUTOVER: this hook now classifies through the engine module
5
+ // (spexharness/src/intent-classifier.mjs's classifyPrompt) instead of the old
6
+ // per-prompt Layer 2 "self_check" prose block (_intent-classifier-router.mjs's
7
+ // buildSelfCheckBlock — ~4,200 tokens / ~6.5 KB, pasted into EVERY prompt that missed
8
+ // every lexical pattern, i.e. almost every ordinary work order). The engine module is
9
+ // LEXICAL ONLY: a prompt that matches none of the five classifier rules
10
+ // (capture-on-idea, research-before-decision, settings-intent, defer-intent,
11
+ // spec-reference-lookup) now injects NOTHING — under 500 bytes, usually zero — instead
12
+ // of the old self-check block (AC3, [[HS-5ISS]]).
13
+ //
14
+ // A rule the engine matches still has to clear the SAME gating the old lexical sweep
15
+ // applied (env kill switch, agent_behaviors.<key>.enabled, the injected-content guard)
16
+ // before it fires — the engine module itself knows only patterns, not config or env,
17
+ // so isBehaviorEligible() re-applies that gating here. Once eligible, the directive is
18
+ // built through the SAME per-behavior transform (capture's confirm-mode substitution +
19
+ // hints, research's source hint, settings/defer/spec-reference's raw inject) and
20
+ // wrapped in the same CONFIRMED MATCH marker as the old lexical path
21
+ // (buildConfirmedDirective) — so a gate that fires emits a byte-identical directive to
22
+ // before. The five gates' own matching patterns and inject text are untouched.
23
+ //
24
+ // CUTOVER FLAG: agent_behaviors.intent_classifier.engine (.heyspex/config.yaml, read
25
+ // from the VAULT root via the loader's default config root — this hook never joins a
26
+ // config path itself; [[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]).
27
+ // Defaults ON (isConfigKeyEnabled semantics — absent/anything but explicit `false` is
28
+ // enabled). Set to `false` to fall back to the pre-HS-5ISS path in full — the unified
29
+ // lexical-sweep-plus-Layer-2-self-check hook, unchanged (_intent-classifier-router.mjs's
30
+ // runClassifierHook()).
31
+ //
32
+ // UNGATED by CLAUDE_AGENT — interactive human typing is the point. Per-behavior mutes
33
+ // still work: HEYSPEX_CAPTURE_TRIGGER_OFF / HEYSPEX_RESEARCH_TRIGGER_OFF /
34
+ // HEYSPEX_SETTINGS_INTENT_OFF / HEYSPEX_DEFER_INTENT_OFF / HEYSPEX_SPEC_REFERENCE_LOOKUP_OFF
35
+ // = 1. HEYSPEX_INTENT_ENGINE_OFF=1 is the cutover's own env kill switch — same
36
+ // contract as every other behavior here (a config_key AND an env var, never just
37
+ // one) — and forces the old fallback path without touching the git-tracked config
38
+ // file, which is what tests use to exercise the fallback safely. Decisions audited
39
+ // to .heyspex/intent-classifier-history.jsonl.
40
+ //
41
+ // FAIL-SAFE: exits 0 on every path. A missing/corrupt config, a missing rule table, or
42
+ // any thrown error degrades to "no injection" (engine path) or defers to the untouched
43
+ // fallback (runClassifierHook never throws either).
44
+
45
+ import { classifyPrompt } from '../../spexharness/src/intent-classifier.mjs';
46
+ import { isConfigKeyEnabled, startsWithInjectedContent } from './_guardrail-loader.mjs';
47
+ import {
48
+ appendClassifierEvent,
49
+ classifyIntent,
50
+ readIntentClassifierConfig,
51
+ } from './_intent-classifier-semantic.mjs';
52
+ import {
53
+ runClassifierHook,
54
+ readStdinJson,
55
+ behaviorKeyForRuleId,
56
+ isBehaviorEligible,
57
+ buildConfirmedDirective,
58
+ buildSelfCheckBlock,
59
+ nonIdeaExclusion,
60
+ CLASSIFIER_BEHAVIORS,
61
+ MIN_SELF_CHECK_CHARS,
62
+ directiveCommand,
63
+ } from './_intent-classifier-router.mjs';
64
+
65
+ const ENGINE_FLAG = 'agent_behaviors.intent_classifier.engine';
66
+
67
+ // HS-KNJ8: deterministic verbatim capture. After the classifier has run, an idea-bearing prompt
68
+ // (a capture_on_idea / research_before_decision match, or the semantic-miss rung firing) is appended
69
+ // VERBATIM to the capture on deck for THIS session, and the model is told it was done. Also refreshes
70
+ // the PreToolUse capture-execution-gate's state file. Returns the note to append to the emitted context.
71
+ // Body moved to scripts/lib/capture-verbatim-append.mjs (runVerbatimStageForHook) so the legacy router shares it.
72
+ // Dynamic + variable specifier on purpose: platform-only module (customer-kit scope 'platform'), NOT in the
73
+ // published npm closure; a customer install simply has no verbatim stage (caught -> null).
74
+ //
75
+ // HS-20SW: BOTH UserPromptSubmit entry points (this engine path AND the legacy router) now pass the same
76
+ // options, so engine:true and engine:false behave identically ([[HS-LES-a-safety-net-wired-into-only-one-of-two]]):
77
+ // `directiveIfNoCapture` on the semantic-miss rung (the hard directive when nothing is on deck, exactly what the
78
+ // legacy self_check rung does) and `armObligation` on every capture-bearing path, which records the outstanding
79
+ // capture obligation the directive-obligation-gate enforces.
80
+ async function verbatimStage(payload, prompt, append, options = {}) {
81
+ try {
82
+ const mod = '../lib/capture-verbatim-append.mjs';
83
+ const { runVerbatimStageForHook } = await import(mod);
84
+ return await runVerbatimStageForHook(payload, prompt, append, options);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const NOTE_SEP = '\n\n';
91
+ const IDEA_BEARING_BEHAVIORS =new Set(['capture_on_idea', 'research_before_decision']);
92
+
93
+ // THE SEMANTIC NET ON THE ENGINE PATH ([[HS-3J57-7EDT-DIAG-intent-classifier-s-semantic-fallback]],
94
+ // [[HS-8A3Y-LELB-DIAG-a-prose-feature-request-bypassed]]). The HS-5ISS cutover made a
95
+ // lexical miss a silent no-op — the ~6.5 KB Layer 2 self-check block was dropped and
96
+ // nothing took its place, so a paraphrased idea, or an owner's work-order-shaped
97
+ // feature ask ("please also add this to heyspex-app … should we create our own …"),
98
+ // reached the agent with no capture directive at all and was built straight from the
99
+ // prompt. This is the replacement: on a miss, when the semantic layer is on
100
+ // (agent_behaviors.intent_classifier.semantic_enabled, env HEYSPEX_INTENT_SEMANTIC_OFF=1
101
+ // to mute), the hook's behavior depends on the resolved mode
102
+ // (agent_behaviors.intent_classifier.mode, default self_check):
103
+ // - mode:self_check (DEFAULT) — the SAME deterministic SELF-CHECK block the legacy
104
+ // _intent-classifier-router.mjs path builds (buildSelfCheckBlock, reused verbatim
105
+ // — not a second copy of its prose) over whichever behaviors are still eligible on
106
+ // this prompt (env kill switch / config_key / injected-content guard, via
107
+ // isBehaviorEligible). This is a REAL directive, not an advisory: the agent judges
108
+ // the gates and, on a hit, fetches and follows the exact directive via
109
+ // `--directive <key>`, byte-identical to Layer 1. A prompt with zero eligible
110
+ // behaviors emits nothing (nothing to self-check).
111
+ // - mode:model (OPT-IN) — the local classifier is consulted first and a confident hit
112
+ // fires the full CONFIRMED MATCH directive, exactly as the legacy router's opt-in
113
+ // path did. When the model is silent, low-confidence, or unreachable, the hook
114
+ // falls back to the COMPACT_GATE_CHECK advisory below (under 400 bytes) rather than
115
+ // going fully silent again.
116
+ // A sub-12-char prompt ("yes", "proceed") and a harness envelope (task-notification /
117
+ // system-reminder as the prompt) never get either treatment.
118
+ // The fetch command comes from directiveCommand() so it resolves in the session's project —
119
+ // the repo-relative form in the platform checkout (byte-identical to before), the committed
120
+ // hook launcher or the router's absolute path in a satellite repo (no scripts/ there).
121
+ export function compactGateCheck(opts = {}) {
122
+ return (
123
+ 'HEYSPEX GATE CHECK (system note): no lexical rule matched. If this is a raw idea or a ' +
124
+ `feature ask (please add/build X, should we build our own X), run \`${directiveCommand('capture_on_idea', opts)}\` and ` +
125
+ 'follow it. Other keys: research_before_decision, settings_intent, defer_intent, ' +
126
+ 'spec_reference_lookup. Otherwise proceed silently.'
127
+ );
128
+ }
129
+ export const COMPACT_GATE_CHECK = compactGateCheck();
130
+
131
+ /**
132
+ * The engine-miss fallback. Returns the additionalContext to emit, or null when the
133
+ * miss must stay silent (semantic layer off, prompt too short, harness envelope).
134
+ * Never throws.
135
+ */
136
+ export async function engineMissFallback(prompt) {
137
+ try {
138
+ if (typeof prompt !== 'string' || prompt.trim().length < MIN_SELF_CHECK_CHARS) return null;
139
+ if (startsWithInjectedContent(prompt)) return null;
140
+ const cfg = readIntentClassifierConfig();
141
+ if (!cfg.semanticEnabled) return null;
142
+
143
+ if (cfg.semanticMode === 'model') {
144
+ const result = await classifyIntent(prompt);
145
+ if (
146
+ result?.behavior &&
147
+ result.confidence >= cfg.minConfidence &&
148
+ isBehaviorEligible(result.behavior, prompt) &&
149
+ // HS-BCCN: a model vote for capture_on_idea on a non-idea shape is not a confirmed match.
150
+ !(result.behavior === 'capture_on_idea' && nonIdeaExclusion(prompt).excluded)
151
+ ) {
152
+ const directive = buildConfirmedDirective(result.behavior, undefined, prompt);
153
+ if (directive) {
154
+ appendClassifierEvent({
155
+ surface: 'userpromptsubmit',
156
+ rule: result.behavior,
157
+ layer: 'semantic',
158
+ decision: 'fire',
159
+ engine: true,
160
+ confidence: result.confidence,
161
+ latency_ms: result.latencyMs,
162
+ prompt_chars: prompt.length,
163
+ });
164
+ return directive;
165
+ }
166
+ }
167
+ // model silent/low-confidence/unreachable/ineligible → fall through to the
168
+ // COMPACT_GATE_CHECK advisory below (unchanged from before this fix).
169
+ } else if (cfg.semanticMode === 'self_check') {
170
+ // THE FIX: reuse the legacy router's own self-check block builder
171
+ // (_intent-classifier-router.mjs's buildSelfCheckBlock) rather than the weak
172
+ // COMPACT_GATE_CHECK advisory — this is what closes the Layer 2 hole if
173
+ // `engine` is ever flipped back to true while mode stays self_check (the
174
+ // default and, on this machine, the only reachable mode: `model` needs an
175
+ // Ollama endpoint that is off the network here). Byte-identical to what the
176
+ // legacy path would emit for the SAME considered set, so a gate an agent
177
+ // judges to fire resolves to the exact same directive either way.
178
+ const considered = CLASSIFIER_BEHAVIORS.filter((behavior) =>
179
+ isBehaviorEligible(behavior.key, prompt),
180
+ ).map((behavior) => ({ behavior }));
181
+ if (considered.length) {
182
+ const additionalContext = buildSelfCheckBlock(considered, undefined, prompt);
183
+ if (additionalContext) {
184
+ appendClassifierEvent({
185
+ surface: 'userpromptsubmit',
186
+ rule: 'intent-classifier',
187
+ layer: 'semantic',
188
+ decision: 'self_check_injected',
189
+ engine: true,
190
+ offered: considered.map((entry) => entry.behavior.key),
191
+ prompt_chars: prompt.length,
192
+ });
193
+ return additionalContext;
194
+ }
195
+ }
196
+ // No behavior is currently eligible (every gate env/config-disabled) — mirror
197
+ // the legacy router's routePrompt(), which returns null before ever building a
198
+ // self-check block when its `eligible` set is empty. Nothing to self-check.
199
+ return null;
200
+ }
201
+
202
+ appendClassifierEvent({
203
+ surface: 'userpromptsubmit',
204
+ rule: null,
205
+ layer: 'semantic',
206
+ decision: 'compact_gate_check',
207
+ engine: true,
208
+ mode: cfg.semanticMode,
209
+ prompt_chars: prompt.length,
210
+ });
211
+ return COMPACT_GATE_CHECK;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ function emit(additionalContext) {
218
+ if (!additionalContext) return;
219
+ process.stdout.write(
220
+ JSON.stringify({
221
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
222
+ }),
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The engine-cutover hook body: read the payload, classify via the engine module, and
228
+ * emit a directive ONLY when a gate both matches lexically AND is eligible (env/config/
229
+ * guard). Every other path — a full lexical miss, an unknown rule id, an ineligible
230
+ * behavior, a malformed payload — emits nothing at all, which is the point (AC3).
231
+ * Never throws (mirrors runClassifierHook's own contract).
232
+ */
233
+ export async function runEngineClassifierHook() {
234
+ try {
235
+ const raw = await readStdinJson();
236
+ if (!raw) return;
237
+ let payload;
238
+ try {
239
+ payload = JSON.parse(raw);
240
+ } catch {
241
+ return;
242
+ }
243
+ if (payload?.hook_event_name !== 'UserPromptSubmit') return;
244
+ const prompt = payload?.prompt;
245
+ if (typeof prompt !== 'string' || !prompt.trim()) return;
246
+
247
+ const hit = classifyPrompt(prompt);
248
+ if (!hit) {
249
+ // Lexical miss: the compact semantic net (see engineMissFallback) — never the
250
+ // old multi-KB self-check block, and nothing at all when the layer is off.
251
+ const missContext = await engineMissFallback(prompt);
252
+ // The semantic-miss rung fired (substantive, non-envelope prompt, layer on) = idea-bearing —
253
+ // unless HS-BCCN's exclusion class says the shape carries no idea (a shell paste, a mechanical
254
+ // git/npm/agent order, a plain question). Decided BEFORE the verbatim stage can issue the hard
255
+ // capture directive or arm an obligation; the self-check block itself still goes out.
256
+ const exclusion = missContext ? nonIdeaExclusion(prompt) : { excluded: false, reason: null };
257
+ if (exclusion.excluded) {
258
+ appendClassifierEvent({
259
+ surface: 'userpromptsubmit',
260
+ rule: 'capture-on-idea',
261
+ layer: 'semantic',
262
+ decision: 'non_idea_excluded',
263
+ engine: true,
264
+ non_idea: exclusion.reason,
265
+ prompt_chars: prompt.length,
266
+ });
267
+ }
268
+ const ideaBearing = Boolean(missContext) && !exclusion.excluded;
269
+ const missNote = await verbatimStage(payload, prompt, ideaBearing, { directiveIfNoCapture: ideaBearing, armObligation: ideaBearing });
270
+ emit([missContext, missNote].filter(Boolean).join(NOTE_SEP));
271
+ return;
272
+ }
273
+
274
+ const behaviorKey = behaviorKeyForRuleId(hit.rule);
275
+ if (!behaviorKey) return; // an engine rule id this router doesn't know as a behavior
276
+
277
+ if (!isBehaviorEligible(behaviorKey, prompt)) return; // env-off / config-disabled / injected-content guard
278
+
279
+ const directive = buildConfirmedDirective(behaviorKey, undefined, prompt);
280
+ if (!directive) return;
281
+
282
+ appendClassifierEvent({
283
+ surface: 'userpromptsubmit',
284
+ rule: hit.rule,
285
+ layer: 'lexical',
286
+ decision: 'fire',
287
+ engine: true,
288
+ prompt_chars: prompt.length,
289
+ });
290
+
291
+ const hitNote = await verbatimStage(payload, prompt, IDEA_BEARING_BEHAVIORS.has(behaviorKey), {
292
+ armObligation: behaviorKey === 'capture_on_idea',
293
+ });
294
+ emit([directive, hitNote].filter(Boolean).join(NOTE_SEP));
295
+ } catch {
296
+ // Never break Claude Code on a hook failure.
297
+ }
298
+ }
299
+
300
+ const engineForcedOff = process.env.HEYSPEX_INTENT_ENGINE_OFF === '1';
301
+ if (!engineForcedOff && isConfigKeyEnabled(ENGINE_FLAG)) {
302
+ runEngineClassifierHook();
303
+ } else {
304
+ runClassifierHook();
305
+ }
@@ -51,7 +51,7 @@
51
51
  //
52
52
  // Self-test: `node scripts/claude-hooks/research-method-guard.mjs --self-test`
53
53
 
54
- import { pathToFileURL } from "node:url";
54
+ import { isMainModule } from "../lib/shared-path-helpers.mjs";
55
55
 
56
56
  // Order matters for the scriptPath substring scan below (first match wins) —
57
57
  // the three specific profiles are listed before the bare "deep-research" so a
@@ -388,7 +388,7 @@ async function main() {
388
388
  // executed directly — mirrors env-vault-guard.mjs's guard so importing this
389
389
  // module (e.g. from a future regression test) never blocks on stdin.
390
390
  const isDirectExecution =
391
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
391
+ isMainModule(import.meta.url);
392
392
 
393
393
  if (isDirectExecution) {
394
394
  main().catch(() => {