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,626 +1,839 @@
1
- // spexharness/src/intent-directive.mjs
2
- //
3
- // The intent classifier's DIRECTIVE ASSEMBLY — the per-behavior enrichment that turns a rule's
4
- // raw `inject` text into the exact directive Claude Code's plugin injects for the same prompt
5
- // ([[HS-N01T-8CMW-TASK-prompt-submit-injects-reach-the-model]], epic
6
- // [[HS-LS51-8CMW-EPIC-spexcode-plugin-parity-the-prompt]]).
7
- //
8
- // MOVED, not copied, from scripts/claude-hooks/_intent-classifier-router.mjs: the confirmed-
9
- // match marker, the classifier behavior catalog, the capture confirm-mode substitution, the
10
- // capture kind/routing hints, the research source hint, the eligibility check and the
11
- // confirmed-directive builder. The router now imports every one of these from here and
12
- // re-exports them, so its own Layer 1/Layer 2 paths and the Claude Code hook emit the SAME
13
- // bytes they always did — and ../intent-classifier.mjs's engine gate builds the SAME text for
14
- // a SpexCode seat. One implementation, two callers; neither can drift from the other.
15
- //
16
- // LAYERING: this is a harness module. It imports from ./rule-table.mjs and from
17
- // vendor/heyspex-mcp (the same vendor modules ./chat/chat-loop.mjs and ./gates/*.mjs already
18
- // import) and NEVER from scripts/claude-hooks — the plugin's hook scripts sit above the
19
- // engine and import downward, not the other way round. The two small helpers the router
20
- // used to take from scripts/claude-hooks/_guardrail-loader.mjs (the injected-content
21
- // sentinel guards and the config-leaf reads) are therefore implemented here against the same
22
- // contracts; ./intent-directive.test.mjs pins the sentinel list to the loader's copy so the
23
- // two lists cannot diverge silently.
24
- //
25
- // INJECTED READS (so a test never touches the real config): every function that consults
26
- // `.heyspex/config.yaml` takes a trailing `opts` carrying
27
- // opts.getSetting(repoRoot, key) — returns a leaf shaped like
28
- // vendor/heyspex-mcp/config-settings.mjs's `getConfigLeaf` (`{ found, raw, value }`) or
29
- // `getEffectiveSetting` (`{ found, value }`); a plain string is also accepted. Defaults to
30
- // `getConfigLeaf` against `repoRoot`.
31
- // repoRoot — the directory holding `.heyspex/config.yaml`. When absent it is the VAULT
32
- // root (./config-root.mjs `defaultConfigRoot()`), NOT DEFAULT_APP_ROOT: since the
33
- // 2026-08-31 repo split the settings file lives in the vault checkout
34
- // ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]). The rule table
35
- // (`.heyspex/guardrails.json`) is a code-repo artifact and still loads from
36
- // DEFAULT_APP_ROOT when `repoRoot` is absent.
37
- // opts.env — the environment the per-behavior kill switches are read from. Defaults to
38
- // `process.env`.
39
- // opts.ruleTable — a ./rule-table.mjs table (`{ rules }`) to resolve behaviors against
40
- // instead of loading the real one.
41
- // The value semantics mirror the loader's `readConfigLeafRaw` (the trimmed raw scalar after
42
- // the colon; `undefined` when the leaf is absent) so every substitution below stays
43
- // byte-identical to what the plugin hook produced before the move.
44
- //
45
- // Pure: no I/O of its own beyond loadRuleTable's / getConfigLeaf's fail-safe file reads. No
46
- // process.stdin/stdout/exit.
47
-
48
- import { loadRuleTable, DEFAULT_APP_ROOT } from './rule-table.mjs';
49
- import { defaultConfigRoot } from './config-root.mjs';
50
- import { getConfigLeaf } from '../../vendor/heyspex-mcp/config-settings.mjs';
51
- // HS-QOBQ: cheapest-possible-rung kind classification at capture time — the
52
- // same lexical guess promote-capture.mjs re-checks at promote time, so both
53
- // call sites the diagnose named agree on what "reads like a bug" means.
54
- import { guessCaptureKindFromText } from '../../vendor/heyspex-mcp/capture-kind-suggest.mjs';
55
-
56
- /** The registry surface every classifier behavior's rule is authored on. */
57
- export const SURFACE = 'userpromptsubmit';
58
- /** The severity the plugin router requires of a rule before it will fire it (its
59
- * `rulesForSurface(guardrails, SURFACE, 'prime')` filter) — the same filter applies here. */
60
- export const DIRECTIVE_SEVERITY = 'prime';
61
- /** The token capture-on-idea's authored inject carries where the confirm mode goes. */
62
- export const MODE_TOKEN = '{{CONFIRM_MODE}}';
63
-
64
- // [[HS-BIDR-DTOP-DIAG]] fix: the bare/unconditional directive (a Layer 1 lexical fire,
65
- // or a Layer 2 mode:'model' fire) and the Layer 2 self_check "still undecided" block are
66
- // textually distinct but nothing marked WHICH one an agent was reading — it had to infer
67
- // from wording alone that discretion applies to the self-check and never to a confirmed
68
- // fire. That inference is easy to drop under load, and an agent that drops it treats a
69
- // confirmed match as if it were a self-check and silently skips it (the production miss
70
- // this diagnose captures). This marker is prepended to every CONFIRMED-FIRE directive —
71
- // loud and explicit as the mirror image of the self-check wrapper's own "silently
72
- // judge... DEFAULT OUTCOME IS NONE" framing — so the two shapes are unmistakable at a
73
- // glance, not just by prose wording.
74
- export const CONFIRMED_MATCH_MARKER =
75
- 'HEYSPEX CONFIRMED MATCH — NOT A SELF-CHECK. A deterministic rule has ALREADY matched ' +
76
- "this message. There is no judgment step and nothing left to evaluate — do NOT silently " +
77
- 'second-guess it, and "this is also a work order / bug report" is explicitly NOT a valid ' +
78
- 'reason to skip it. Execute the directive below IMMEDIATELY, exactly as written.';
79
-
80
- /** Prepend the confirmed-match marker to a resolved directive. */
81
- export function withConfirmedMatchMarker(directive) {
82
- return `${CONFIRMED_MATCH_MARKER}\n\n${directive}`;
83
- }
84
-
85
- // ── [[HS-XDH8-9Y92-DIAG-defer-intent-hook-fires-on-negated]] Fix §2: per-behavior marker ──
86
- // The unconditional CONFIRMED MATCH marker is right for capture_on_idea (HS-BIDR: a false
87
- // positive only costs an extra capture, a skipped fire loses an idea). For behaviors whose
88
- // false positive CHANGES WHAT GETS DONE — defer_intent (schedules the work a week out instead
89
- // of doing it) and settings_intent (writes a setting) — the marker must not override the
90
- // inject's own "FIRST, gate for precision" step. On 2026-10-10 "No do not defer the vault
91
- // right to closeout. Do it, right, please." fired defer, and the "do NOT second-guess" marker
92
- // ordered the agent to invert the owner's explicit instruction. These behaviors get a softer
93
- // marker that still says a deterministic rule matched (so it is never confused with the
94
- // self-check block) but keeps the precision gate authoritative.
95
- export const PRECISION_GATED_MATCH_MARKER =
96
- 'HEYSPEX LEXICAL MATCH — PRECISION-GATED, NOT A SELF-CHECK. A deterministic rule matched a ' +
97
- 'phrase in this message, but a phrase match can misread what the user asked (for example a ' +
98
- 'negated request such as "do not defer this"). The directive\'s own FIRST precision gate is ' +
99
- 'AUTHORITATIVE: if the user\'s words do not actually ask for this, IGNORE the directive and do ' +
100
- 'what they asked. Never act against an explicit instruction in the message.';
101
-
102
- /** Behavior keys whose fire is precision-gated (soft marker), not unconditional. */
103
- export const PRECISION_GATED_BEHAVIORS = Object.freeze(new Set(['defer_intent', 'settings_intent']));
104
-
105
- /** The marker a confirmed lexical/model fire of this behavior carries. */
106
- export function matchMarkerFor(behaviorKey) {
107
- return PRECISION_GATED_BEHAVIORS.has(behaviorKey) ? PRECISION_GATED_MATCH_MARKER : CONFIRMED_MATCH_MARKER;
108
- }
109
-
110
- /** Prepend the behavior-appropriate match marker to a resolved directive. */
111
- export function withMatchMarker(behaviorKey, directive) {
112
- return `${matchMarkerFor(behaviorKey)}\n\n${directive}`;
113
- }
114
-
115
- // ── HS-XDH8 Fix §1: negation-aware lexical matching ─────────────────────────
116
- // A rule's patterns are a bag of positive regexes; "defer the vault" matched inside "do not
117
- // defer the vault". For a negation-guarded rule, a pattern hit only counts when its CLAUSE
118
- // does not negate it: the text before the match, back to the nearest clause boundary
119
- // (punctuation, a dash, or a joining conjunction such as "so"/"but"), has its last few words
120
- // scanned for a negation cue. Every occurrence of every pattern is checked; the rule fires if
121
- // ANY occurrence is un-negated ("don't defer the docs, but park this until Friday" fires).
122
- // Opt-in per rule: the ids below, or `negationGuard: true` on a rule row.
123
- export const NEGATION_GUARDED_RULE_IDS = Object.freeze(new Set(['defer-intent', 'capture-on-idea']));
124
- const NEGATION_WINDOW_WORDS = 5;
125
- const NEGATION_CUE_RE =
126
- /\b(?:no|not|never|dont|don't|doesn't|didn't|won't|wont|shouldn't|mustn't|can't|cannot|stop|quit|without|nor|neither|instead\s+of|rather\s+than)\b/i;
127
- const CLAUSE_BOUNDARY_RE = /[.!?;:,\n\r—–]|\s-\s|\b(?:so|but|and|then|because|though|although)\b/gi;
128
-
129
- /** Does the clause leading up to `index` in `text` negate what starts at `index`? */
130
- export function isNegatedAt(text, index) {
131
- let before = String(text).slice(0, index).replace(/’/g, "'");
132
- let cut = -1;
133
- for (const m of before.matchAll(CLAUSE_BOUNDARY_RE)) cut = m.index + m[0].length;
134
- if (cut >= 0) before = before.slice(cut);
135
- const words = before.match(/[a-z]+(?:'[a-z]+)?/gi) ?? [];
136
- return NEGATION_CUE_RE.test(words.slice(-NEGATION_WINDOW_WORDS).join(' '));
137
- }
138
-
139
- /** Is this rule row negation-guarded? */
140
- export function isNegationGuarded(rule) {
141
- return rule?.negationGuard === true || NEGATION_GUARDED_RULE_IDS.has(rule?.rule);
142
- }
143
-
144
- /**
145
- * Does `prompt` match `rule` through its compiled `patterns` (case-insensitive RegExps)?
146
- * Unguarded rules keep the plain `patterns.some(re.test)` semantics byte-for-byte; a
147
- * negation-guarded rule needs at least one un-negated occurrence.
148
- */
149
- export function ruleMatchesPrompt(rule, patterns, prompt) {
150
- if (typeof prompt !== 'string' || !Array.isArray(patterns)) return false;
151
- if (!isNegationGuarded(rule)) return patterns.some((re) => re.test(prompt));
152
- for (const re of patterns) {
153
- const global = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
154
- for (const m of prompt.matchAll(global)) {
155
- if (!isNegatedAt(prompt, m.index)) return true;
156
- }
157
- }
158
- return false;
159
- }
160
-
161
- // ── injected-content guards ──────────────────────────────────────────────────
162
- // Sentinels that mark harness-injected content arriving through the UserPromptSubmit
163
- // channel (background-task notifications, system reminders, slash-command envelopes). They
164
- // can contain a rule's trigger phrase verbatim but are NOT a genuine human prompt, so a rule
165
- // with `ignoreInjectedContent` must not fire on them. scripts/claude-hooks/_guardrail-loader.mjs
166
- // carries the plugin-side copy of this list; ./intent-directive.test.mjs asserts the two are
167
- // equal so an edit to one cannot silently leave the other behind.
168
- export const INJECTED_CONTENT_SENTINELS = [
169
- '<task-notification>',
170
- '<system-reminder>',
171
- '[SYSTEM NOTIFICATION',
172
- '<command-message>',
173
- '<command-name>',
174
- '<local-command-stdout>',
175
- ];
176
-
177
- /** Contains-anywhere guard — the research hook's original (broad) check. */
178
- export function containsInjectedContent(text) {
179
- if (typeof text !== 'string') return false;
180
- return INJECTED_CONTENT_SENTINELS.some((s) => text.includes(s));
181
- }
182
-
183
- /**
184
- * Stricter, false-positive-safe variant: is the message a genuine harness ENVELOPE (a
185
- * task-notification / system-reminder / slash-command envelope delivered AS the prompt),
186
- * rather than user text that merely QUOTES a sentinel? A real envelope leads with its tag;
187
- * a user pasting a transcript or terminal logs leads with their own words.
188
- */
189
- export function startsWithInjectedContent(text) {
190
- if (typeof text !== 'string') return false;
191
- const head = text.trimStart();
192
- return INJECTED_CONTENT_SENTINELS.some((s) => head.startsWith(s));
193
- }
194
-
195
- // ── config reads (injectable) ────────────────────────────────────────────────
196
-
197
- /**
198
- * The default settings reader: vendor/heyspex-mcp/config-settings.mjs's `getConfigLeaf`
199
- * against `<repoRoot>/.heyspex/config.yaml` — the same indent-scoped `key:` scan the plugin
200
- * loader's `readConfigLeafRaw` performs, returning `{ found, raw, value }`. With no
201
- * `repoRoot` the file is read from the vault root (`defaultConfigRoot()`).
202
- */
203
- export function defaultGetSetting(repoRoot, key) {
204
- return getConfigLeaf(repoRoot ?? defaultConfigRoot(), key);
205
- }
206
-
207
- /**
208
- * Read one config leaf as the loader's `readConfigLeafRaw` would: the trimmed raw scalar,
209
- * or `undefined` when absent/unreadable. Accepts every reader shape the header documents.
210
- * FAIL-SAFE: a throwing reader reads as "absent".
211
- */
212
- export function readConfigLeafRaw(configKey, repoRoot, opts = {}) {
213
- if (!configKey || typeof configKey !== 'string') return undefined;
214
- const getSetting = typeof opts.getSetting === 'function' ? opts.getSetting : defaultGetSetting;
215
- let leaf;
216
- try {
217
- leaf = getSetting(repoRoot ?? defaultConfigRoot(), configKey);
218
- } catch {
219
- return undefined;
220
- }
221
- if (leaf == null) return undefined;
222
- if (typeof leaf === 'string') return leaf.trim();
223
- if (typeof leaf === 'object') {
224
- if (leaf.found === false) return undefined;
225
- if (typeof leaf.raw === 'string') return leaf.raw.trim();
226
- if (leaf.value === undefined || leaf.value === null) return undefined;
227
- return String(leaf.value).trim();
228
- }
229
- return String(leaf).trim();
230
- }
231
-
232
- /**
233
- * Is a dotted `config_key` ON? Semantics match the loader's `isConfigKeyEnabled`: ON unless
234
- * the leaf is explicitly `false`; absent / unreadable / no key at all default to ON.
235
- */
236
- export function isConfigKeyEnabled(configKey, repoRoot, opts = {}) {
237
- if (!configKey || typeof configKey !== 'string') return true;
238
- const raw = readConfigLeafRaw(configKey, repoRoot, opts);
239
- if (raw === undefined) return true;
240
- return raw.toLowerCase() !== 'false';
241
- }
242
-
243
- /** Enabled-check straight off a rule row (its `configKey`). */
244
- export function isRuleEnabled(rule, repoRoot, opts = {}) {
245
- return isConfigKeyEnabled(rule && rule.configKey, repoRoot, opts);
246
- }
247
-
248
- /**
249
- * Resolve the CONFIRM MODE for an autonomous agent behavior — the two-layer permission
250
- * model the owner specified (2026-07-11):
251
- * 'auto' = act immediately, user can undo/discard afterward (forgiveness)
252
- * 'ask' = ask the user for confirmation before acting (permission)
253
- *
254
- * Resolution order (per-behavior override wins over the global default):
255
- * 1. agent_behaviors.<behaviorKey>.ask_first
256
- * true|yes|ask → 'ask' · false|no|auto → 'auto' · null/absent → inherit
257
- * 2. (inherit) global agent_behaviors.ask_forgiveness_not_permission
258
- * true|yes|absent → 'auto' (forgiveness — the owner's default)
259
- * false|no → 'ask'
260
- * FAIL-SAFE: any unreadable config → 'auto' (the stated default).
261
- */
262
- export function resolveConfirmMode(behaviorKey, repoRoot, opts = {}) {
263
- if (behaviorKey && typeof behaviorKey === 'string') {
264
- const per = readConfigLeafRaw(`agent_behaviors.${behaviorKey}.ask_first`, repoRoot, opts);
265
- if (per !== undefined) {
266
- const v = per.toLowerCase();
267
- if (v === 'true' || v === 'yes' || v === 'ask') return 'ask';
268
- if (v === 'false' || v === 'no' || v === 'auto') return 'auto';
269
- // 'null' / '' / anything else → fall through to the global default (inherit)
270
- }
271
- }
272
- const global = readConfigLeafRaw('agent_behaviors.ask_forgiveness_not_permission', repoRoot, opts);
273
- if (global !== undefined) {
274
- const g = global.toLowerCase();
275
- if (g === 'false' || g === 'no') return 'ask'; // forgiveness OFF → ask first
276
- }
277
- return 'auto'; // forgiveness ON / absent → act, don't ask
278
- }
279
-
280
- // ── capture-on-idea enrichment ───────────────────────────────────────────────
281
-
282
- // Capture's forgiveness/permission directives — carried over verbatim from the retired
283
- // capture-trigger.mjs shim (HS-IYU1, deleted after burn-in) so the emitted inject stays
284
- // byte-identical.
285
- export const CAPTURE_MODE_DIRECTIVES = {
286
- auto:
287
- "FORGIVENESS MODE (the owner's default — act, don't ask): START THE CAPTURE FLOW " +
288
- 'IMMEDIATELY. In ONE short line, tell the user you are capturing this as an idea ' +
289
- 'and they can say "discard" (or "no, just do it") to skip — then begin. Do NOT ask ' +
290
- 'permission first.',
291
- ask:
292
- 'PERMISSION MODE: ASK FIRST. Reply with ONE line — "Want me to capture this as an ' +
293
- 'idea? (yes / no, just do it)" — and run the capture steps below ONLY if the user ' +
294
- 'says yes. If they decline, just do the task.',
295
- };
296
-
297
- /**
298
- * capture-on-idea's full transform: substitute `{{CONFIRM_MODE}}` (or prepend the mode
299
- * directive when the token is absent), then append the routing and kind hints.
300
- */
301
- export function applyCaptureConfirmMode(inject, repoRoot, prompt, opts = {}) {
302
- const mode = resolveConfirmMode('capture_on_idea', repoRoot, opts);
303
- const directive = CAPTURE_MODE_DIRECTIVES[mode] || CAPTURE_MODE_DIRECTIVES.auto;
304
- const base = inject.includes(MODE_TOKEN) ? inject.split(MODE_TOKEN).join(directive) : `${directive}\n\n${inject}`;
305
- return appendCaptureKindHint(appendCaptureRoutingHint(base, repoRoot, opts), prompt);
306
- }
307
-
308
- // Capture kind hint ([[HS-QOBQ-S4JZ-DIAG-capture-kind-is-never-classified-every]]):
309
- // every capture materializes at `kind: idea` and nothing ever inspected the
310
- // triggering message to check whether that default is right. This is the
311
- // cheapest rung (lexical, no model call) — a strong bug-language match in the
312
- // SAME message the capture-on-idea rule already fired on. Suggest-only: the
313
- // agent still must offer it and get a yes before calling heyspex_update_capture
314
- // with kind ([[HS-5HYS]] elicitation — suggest, never auto-apply), matching the
315
- // existing heyspex_update_capture `kind` arg description verbatim.
316
- export function appendCaptureKindHint(inject, prompt) {
317
- const guess = guessCaptureKindFromText(prompt);
318
- if (!guess.kind) return inject;
319
- const hint =
320
- `CAPTURE KIND HINT (lexical, suggest-only): this message reads like kind: ${guess.kind} ` +
321
- `(matched "${guess.matched}"), not the default idea. After capturing, offer the ` +
322
- `reclassification in your reply (e.g. "this reads like a bug-fix capture — reclassify?") ` +
323
- `and call heyspex_update_capture with kind: "${guess.kind}" ONLY on the user's yes — never ` +
324
- `auto-apply it.`;
325
- return `${inject}\n\n${hint}`;
326
- }
327
-
328
- // Capture model-routing + thread-split hint ([[HS-6YOA-HPR6-RES-capture-to-research-auto-model-routing]],
329
- // owner ruling 2026-08-11). A best-effort HINT appended to the capture inject, same shape as
330
- // appendResearchSourceHint below — the hook cannot know the running session's actual current
331
- // model (that's not in the UserPromptSubmit payload), so it can only tell the agent what's
332
- // CONFIGURED and trust the agent (which does know its own model, per its own system prompt) to
333
- // judge whether a switch/delegate/ask actually applies. Neither mode is ever a silent, live
334
- // model switch — that is not possible from a hook or from the agent itself (research doc's
335
- // Platform capability check: `/model` is a user-issued local command; no MCP tool flips it).
336
- export function appendCaptureRoutingHint(inject, repoRoot, opts = {}) {
337
- const defaultModel = readConfigLeafRaw('agent_behaviors.capture_on_idea.default_model', repoRoot, opts);
338
- const switchMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.model_switch_mode', repoRoot, opts) || 'ask';
339
- const splitMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.thread_split_mode', repoRoot, opts) || 'ask';
340
-
341
- const parts = [inject];
342
-
343
- if (defaultModel && switchMode !== 'off') {
344
- const modeInstruction =
345
- switchMode === 'silent_delegate'
346
- ? 'CONFIGURED MODE: silent_delegate — do NOT interrupt the user with a model question. ' +
347
- `If this is the FIRST/ONLY capture turn in this thread (not a continuation of an ` +
348
- `already-open capture) and you are not already running as ${defaultModel}, hand the ` +
349
- 'mechanical capture steps (verbatim quote, core idea, summary, ground=expand) to a ' +
350
- `one-shot Agent(prompt: <the capture instructions>, model: "${defaultModel}") subagent ` +
351
- 'instead of running them on this session — then relay its result. Never switch this ' +
352
- "running session's own model; that is not possible and is not what this mode means."
353
- : 'CONFIGURED MODE: ask — if this is the FIRST/ONLY capture turn in this thread (not a ' +
354
- `continuation of an already-open capture) and you are not already running as ` +
355
- `${defaultModel}, add ONE short line to your capture-start message offering a switch — ` +
356
- `e.g. "(captures default to ${defaultModel} — say switch to run /model ${defaultModel} ` +
357
- 'first)" — then proceed with the capture flow regardless of the answer; never block on ' +
358
- 'it. A mid-thread continuation never gets this offer — it stays on the thread\'s model.';
359
- parts.push(
360
- `CAPTURE MODEL ROUTING (default_model=${defaultModel}, model_switch_mode=${switchMode}): ${modeInstruction}`,
361
- );
362
- }
363
-
364
- const splitInstruction =
365
- splitMode === 'auto'
366
- ? 'CONFIGURED thread_split_mode=auto: when new mid-thread prose is judged NOT a continuation ' +
367
- 'of the on-deck capture, decide on your own judgment — no AskUserQuestion — whether it ' +
368
- 'SPLITS (a new HS-CAP-* that still benefits from this thread\'s context: continue it via ' +
369
- 'Agent(subagent_type:"fork") so full context carries over) or SPAWNS (unrelated enough that ' +
370
- 'a fresh Agent() with no inherited context is cleaner). State which you picked and why in ' +
371
- 'one line.'
372
- : 'CONFIGURED thread_split_mode=ask: when new mid-thread prose is judged NOT a continuation of ' +
373
- 'the on-deck capture, and you are NOT confident whether it benefits from this thread\'s ' +
374
- 'existing context, use AskUserQuestion with exactly two options — split (new capture id, ' +
375
- 'keep this thread\'s context via Agent(subagent_type:"fork")) vs. spawn (new capture id, ' +
376
- 'fresh context via a plain Agent() call) — rather than guessing. Skip the ask only when ' +
377
- 'you ARE confident: a clearly-related new idea still splits, a clearly-unrelated one still ' +
378
- 'spawns, with no prompt.';
379
- parts.push(`CAPTURE THREAD SPLIT/SPAWN: ${splitInstruction}`);
380
-
381
- return parts.join('\n\n');
382
- }
383
-
384
- // Research-source hint — fix for [[HS-LFUH-DIAG-W1YE]] root-cause part 1 ("not injected").
385
- // The research inject above tells the agent WHICH RUNGS to climb and WHICH MODEL to spawn
386
- // (default_agent), but never named the configured search/scrape BACKEND
387
- // (agent_behaviors.research.default_source) — so a configured `firecrawl-self-hosted` default
388
- // was silently unreachable on the interactive path; the agent had no way to even know it existed.
389
- // This is deliberately a best-effort HINT appended to the existing inject text, not a
390
- // replacement: it is a snapshot taken at prompt-submit time, so it can go stale mid-session
391
- // (a lease can be granted/refused, a box can go up/down). The authoritative, can't-go-stale
392
- // source of truth is the on-demand `heyspex_resolve_research_plan` MCP tool this hint points the
393
- // agent at — per the research doc's recommendation, "hook injection is a best-effort hint; the
394
- // on-demand tool is the source of truth", not either/or.
395
- export function appendResearchSourceHint(inject, repoRoot, opts = {}) {
396
- const defaultSource = readConfigLeafRaw('agent_behaviors.research.default_source', repoRoot, opts);
397
- const defaultAgent = readConfigLeafRaw('agent_behaviors.research.default_agent', repoRoot, opts);
398
- if (!defaultSource && !defaultAgent) return inject; // nothing configured — nothing to hint at
399
- const hint =
400
- 'CONFIGURED RESEARCH RESOURCE (best-effort hint, a snapshot — re-confirm live via ' +
401
- 'heyspex_resolve_research_plan() before RUNG 0 `sequential` search/scrape, since availability ' +
402
- `can change mid-session): default_source=${defaultSource ?? '(unset)'}, ` +
403
- `default_agent=${defaultAgent ?? '(unset)'}. If default_source names a Firecrawl backend ` +
404
- '(firecrawl-self-hosted | firecrawl-hosted), call heyspex_firecrawl_research({ mode: "search"|"scrape", ' +
405
- 'query|url }) for rung 0 instead of native WebSearch/WebFetch — it wraps that SAME configured ' +
406
- 'backend and honors its existing availability gates (the Ollama-XOR-Firecrawl lease / hosted credit ' +
407
- 'gate). If its result reports downgraded:true, tell the user plainly that the configured backend was ' +
408
- 'unavailable and you fell back to web-native — NEVER substitute silently.';
409
- return `${inject}\n\n${hint}`;
410
- }
411
-
412
- // ── the behavior catalog ─────────────────────────────────────────────────────
413
-
414
- // The classifier behavior catalog. `ruleId` ties a behavior to its registry rule;
415
- // `description` is the semantic-classification instruction (the router's self-check block
416
- // and opt-in model mode read it from here — this catalog is the single source of behavior
417
- // descriptions); `envOff` is the per-behavior kill switch; `guard` is the injected-content
418
- // check that behavior used historically; `transform(inject, repoRoot, prompt, opts)` builds
419
- // the final inject from the rule's inject text (`opts` carries the injected reads — see the
420
- // header). BEHAVIOR_ORDER (array order) is the deterministic lexical tiebreak.
421
- export const CLASSIFIER_BEHAVIORS = [
422
- {
423
- key: 'capture_on_idea',
424
- ruleId: 'capture-on-idea',
425
- envOff: 'HEYSPEX_CAPTURE_TRIGGER_OFF',
426
- guard: startsWithInjectedContent,
427
- transform: (inject, repoRoot, prompt, opts) => applyCaptureConfirmMode(inject, repoRoot, prompt, opts),
428
- description:
429
- 'The user\'s message contains a raw idea, suggestion, observation, or improvement thought — something that could exist or be better. Fires even when the message is ALSO a work order or bug report, as long as it carries new idea/capability content worth remembering. Does NOT fire on purely mechanical commands with no new idea (e.g. "run the tests", "fix the typo"), plain questions about existing work, or bare continuations of work already in progress.',
430
- },
431
- {
432
- key: 'research_before_decision',
433
- ruleId: 'research-before-decision',
434
- envOff: 'HEYSPEX_RESEARCH_TRIGGER_OFF',
435
- guard: containsInjectedContent,
436
- transform: (inject, repoRoot, _prompt, opts) => appendResearchSourceHint(inject, repoRoot, opts),
437
- description:
438
- 'The user is declining to make a design or architecture decision themselves and is asking for research, best practices, or an expert recommendation instead (e.g. "not a decision I should have to make", "don\'t reinvent the wheel").',
439
- },
440
- {
441
- key: 'settings_intent',
442
- ruleId: 'settings-intent',
443
- envOff: 'HEYSPEX_SETTINGS_INTENT_OFF',
444
- guard: startsWithInjectedContent,
445
- transform: (inject) => inject,
446
- description:
447
- 'The user wants to READ or CHANGE a HeySpex workflow setting / agent behavior — enable or disable a behavior (auto-capture, the research trigger, the intent classifier), turn something on or off, set/lower/raise a threshold or value (confidence, timeout), or ask what a setting is currently. About the configuration itself, NOT a request to do the underlying work.',
448
- },
449
- {
450
- key: 'defer_intent',
451
- ruleId: 'defer-intent',
452
- envOff: 'HEYSPEX_DEFER_INTENT_OFF',
453
- guard: startsWithInjectedContent,
454
- transform: (inject) => inject,
455
- description:
456
- 'The user wants to DEFER, POSTPONE, or SCHEDULE a follow-up to a later time — "come back to this later", "remind me in a week", "park/table this until X is done", "schedule this for next week", "not now — later, after the migration". About WHEN to revisit something, not a request to do it now.',
457
- },
458
- {
459
- key: 'handoff_intent',
460
- ruleId: 'handoff-intent',
461
- envOff: 'HEYSPEX_HANDOFF_INTENT_OFF',
462
- guard: startsWithInjectedContent,
463
- transform: (inject) => inject,
464
- description:
465
- 'The user asks for a hand-off prompt, doc, notes or resume context for the NEXT agent or session ("give me a handoff prompt for the next agent", "hand this off", "what should the next session know"). About producing the hand-off artifact, NOT about loading one (a lane code like GQ5R or `hs handoff load`) and not ordinary work.',
466
- },
467
- {
468
- key: 'spec_reference_lookup',
469
- ruleId: 'spec-reference-lookup',
470
- envOff: 'HEYSPEX_SPEC_REFERENCE_LOOKUP_OFF',
471
- guard: startsWithInjectedContent,
472
- transform: (inject) => inject,
473
- description:
474
- 'The user\'s message is little more than a pointer at an existing spec by id (a bare "HS-XXXX-YYYY", or "Reference: HS-...", "re: HS-...", "see HS-..." with nothing else actionable). Fires whether or not the spec turns out to matter. Does NOT fire on an ordinary work order that happens to name a spec id alongside real instructions ("update HS-XXXX\'s tests and rerun them") — there the id is already load-bearing context, not an unresolved pointer.',
475
- },
476
- ];
477
-
478
- /**
479
- * Map an engine rule id (hyphenated, e.g. 'capture-on-idea') back to the router's
480
- * behavior key (underscored, e.g. 'capture_on_idea'). Returns null for an id no
481
- * behavior claims (fail-safe: the engine cutover treats that as no match).
482
- */
483
- export function behaviorKeyForRuleId(ruleId) {
484
- const found = CLASSIFIER_BEHAVIORS.find((b) => b.ruleId === ruleId);
485
- return found ? found.key : null;
486
- }
487
-
488
- /** The catalog entry for a behavior key, or null. */
489
- export function behaviorForKey(behaviorKey) {
490
- return CLASSIFIER_BEHAVIORS.find((b) => b.key === behaviorKey) ?? null;
491
- }
492
-
493
- /**
494
- * Resolve the compiled rule for each classifier behavior from the rule table. Returns
495
- * `[{ behavior, rule }]` for behaviors whose rule exists on the userpromptsubmit surface at
496
- * {@link DIRECTIVE_SEVERITY} and carries an inject — the same filter the plugin router's
497
- * `rulesForSurface(guardrails, SURFACE, 'prime')` applies. FAIL-SAFE: a missing rule
498
- * simply drops that behavior.
499
- *
500
- * @param {{ repoRoot?: string, ruleTable?: { rules?: object[] } }} [opts]
501
- */
502
- export function resolveBehaviorRules(opts = {}) {
503
- const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
504
- const rules = Array.isArray(table?.rules) ? table.rules : [];
505
- const byId = new Map();
506
- for (const r of rules) {
507
- if (!r || !Array.isArray(r.surfaces) || !r.surfaces.includes(SURFACE)) continue;
508
- if (r.severity !== DIRECTIVE_SEVERITY) continue;
509
- byId.set(r.rule, r);
510
- }
511
- const resolved = [];
512
- for (const behavior of CLASSIFIER_BEHAVIORS) {
513
- const rule = byId.get(behavior.ruleId);
514
- if (!rule || !rule.inject) continue;
515
- resolved.push({ behavior, rule });
516
- }
517
- return resolved;
518
- }
519
-
520
- /**
521
- * Is this resolved `{ behavior, rule }` entry eligible to fire on this prompt right now?
522
- * (env kill switch off, injected-content guard passes, config_key enabled.) Behaviors
523
- * that are ineligible fire on no path — matching the old per-hook early-returns exactly.
524
- *
525
- * @param {{ behavior: object, rule: object }} entry
526
- * @param {string} prompt
527
- * @param {string} [repoRoot]
528
- * @param {{ env?: object, getSetting?: Function }} [opts]
529
- */
530
- export function isEntryEligible(entry, prompt, repoRoot, opts = {}) {
531
- const { behavior, rule } = entry;
532
- const env = opts.env ?? process.env;
533
- if (String(env?.[behavior.envOff] ?? '') === '1') return false;
534
- if (rule.ignoreInjectedContent && behavior.guard(prompt)) return false;
535
- if (!isRuleEnabled(rule, repoRoot, opts)) return false;
536
- return true;
537
- }
538
-
539
- /**
540
- * Is this ONE behavior eligible to fire on this prompt right now? (env kill switch off,
541
- * config_key enabled, injected-content guard passes). The engine gate uses this to re-apply
542
- * the same gating the plugin's lexical sweep applied per-entry, since
543
- * ../intent-classifier.mjs's `classifyPrompt()` is lexical-pattern-only and knows nothing
544
- * about env switches, config toggles, or the injected-content guard. Returns false for an
545
- * unknown behavior key.
546
- *
547
- * @param {string} behaviorKey
548
- * @param {string} prompt
549
- * @param {string} [repoRoot]
550
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
551
- */
552
- export function isBehaviorEligible(behaviorKey, prompt, repoRoot, opts = {}) {
553
- const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
554
- if (!entry) return false;
555
- return isEntryEligible(entry, prompt, repoRoot, opts);
556
- }
557
-
558
- /**
559
- * Resolve ONE behavior's full directive (the rule's inject run through the behavior's
560
- * transform), WITHOUT the marker. Returns null when the key is unknown / its rule is gone.
561
- *
562
- * @param {string} behaviorKey
563
- * @param {string} [repoRoot]
564
- * @param {string} [prompt]
565
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
566
- */
567
- export function resolveDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
568
- const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
569
- if (!entry) return null;
570
- return entry.behavior.transform(entry.rule.inject, repoRoot, prompt, opts);
571
- }
572
-
573
- /**
574
- * The confirmed-fire seam (HS-5ISS): {@link resolveDirective} plus the SAME loud marker
575
- * the plugin's Layer 1 lexical fire prepends — so a behavior the engine module
576
- * (../intent-classifier.mjs) matched, once confirmed eligible, emits a byte-identical
577
- * directive to the plugin's lexical path. Returns null on an unknown/gone behavior
578
- * (fail-safe: no injection).
579
- *
580
- * @param {string} behaviorKey
581
- * @param {string} [repoRoot]
582
- * @param {string} [prompt]
583
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
584
- */
585
- export function buildConfirmedDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
586
- const directive = resolveDirective(behaviorKey, repoRoot, prompt, opts);
587
- if (!directive) return null;
588
- return withMatchMarker(behaviorKey, directive);
589
- }
590
-
591
- /**
592
- * The engine gate's one-shot: from a rule id the classifier matched, decide whether the
593
- * behavior may fire and, if so, build the plugin's confirmed directive for it.
594
- *
595
- * Outcomes:
596
- * - `null` — the behavior is known but NOT eligible right now (env kill switch, config
597
- * toggle, or the injected-content guard). The gate must inject nothing.
598
- * - `{ behavior: null, additionalContext: null }` — no catalog behavior claims this rule
599
- * id, or its rule is not a userpromptsubmit `prime` row the plugin router would have
600
- * fired. The gate falls back to the rule's raw inject (the pre-HS-N01T behavior).
601
- * - `{ behavior, additionalContext }` — marker + directive, byte-identical to the plugin.
602
- *
603
- * @param {string} ruleId
604
- * @param {string} prompt
605
- * @param {{ repoRoot?: string, env?: object, getSetting?: Function, ruleTable?: object }} [opts]
606
- * @returns {{ behavior: string|null, additionalContext: string|null }|null}
607
- */
608
- export function buildIntentDirective(ruleId, prompt, opts = {}) {
609
- const behaviorKey = behaviorKeyForRuleId(ruleId);
610
- if (!behaviorKey) return { behavior: null, additionalContext: null };
611
- const behavior = behaviorForKey(behaviorKey);
612
- const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
613
- const entry = resolveBehaviorRules({ ruleTable: table }).find((e) => e.behavior.key === behaviorKey);
614
- if (!entry) {
615
- // The classifier matched a row this catalog knows but the plugin router would never
616
- // have fired (not a userpromptsubmit `prime` row). The kill switches still apply to the
617
- // BEHAVIOR; the directive does not — the gate falls back to the row's raw inject.
618
- const row = (Array.isArray(table?.rules) ? table.rules : []).find((r) => r?.rule === ruleId);
619
- if (row && !isEntryEligible({ behavior, rule: row }, prompt, opts.repoRoot, opts)) return null;
620
- return { behavior: behaviorKey, additionalContext: null };
621
- }
622
- if (!isEntryEligible(entry, prompt, opts.repoRoot, opts)) return null;
623
- const directive = entry.behavior.transform(entry.rule.inject, opts.repoRoot, prompt, opts);
624
- if (!directive) return null;
625
- return { behavior: behaviorKey, additionalContext: withMatchMarker(behaviorKey, directive) };
626
- }
1
+ // spexharness/src/intent-directive.mjs
2
+ //
3
+ // The intent classifier's DIRECTIVE ASSEMBLY — the per-behavior enrichment that turns a rule's
4
+ // raw `inject` text into the exact directive Claude Code's plugin injects for the same prompt
5
+ // ([[HS-N01T-8CMW-TASK-prompt-submit-injects-reach-the-model]], epic
6
+ // [[HS-LS51-8CMW-EPIC-spexcode-plugin-parity-the-prompt]]).
7
+ //
8
+ // MOVED, not copied, from scripts/claude-hooks/_intent-classifier-router.mjs: the confirmed-
9
+ // match marker, the classifier behavior catalog, the capture confirm-mode substitution, the
10
+ // capture kind/routing hints, the research source hint, the eligibility check and the
11
+ // confirmed-directive builder. The router now imports every one of these from here and
12
+ // re-exports them, so its own Layer 1/Layer 2 paths and the Claude Code hook emit the SAME
13
+ // bytes they always did — and ../intent-classifier.mjs's engine gate builds the SAME text for
14
+ // a SpexCode seat. One implementation, two callers; neither can drift from the other.
15
+ //
16
+ // LAYERING: this is a harness module. It imports from ./rule-table.mjs and from
17
+ // vendor/heyspex-mcp (the same vendor modules ./chat/chat-loop.mjs and ./gates/*.mjs already
18
+ // import) and NEVER from scripts/claude-hooks — the plugin's hook scripts sit above the
19
+ // engine and import downward, not the other way round. The two small helpers the router
20
+ // used to take from scripts/claude-hooks/_guardrail-loader.mjs (the injected-content
21
+ // sentinel guards and the config-leaf reads) are therefore implemented here against the same
22
+ // contracts; ./intent-directive.test.mjs pins the sentinel list to the loader's copy so the
23
+ // two lists cannot diverge silently.
24
+ //
25
+ // INJECTED READS (so a test never touches the real config): every function that consults
26
+ // `.heyspex/config.yaml` takes a trailing `opts` carrying
27
+ // opts.getSetting(repoRoot, key) — returns a leaf shaped like
28
+ // vendor/heyspex-mcp/config-settings.mjs's `getConfigLeaf` (`{ found, raw, value }`) or
29
+ // `getEffectiveSetting` (`{ found, value }`); a plain string is also accepted. Defaults to
30
+ // `getConfigLeaf` against `repoRoot`.
31
+ // repoRoot — the directory holding `.heyspex/config.yaml`. When absent it is the VAULT
32
+ // root (./config-root.mjs `defaultConfigRoot()`), NOT DEFAULT_APP_ROOT: since the
33
+ // 2026-08-31 repo split the settings file lives in the vault checkout
34
+ // ([[HS-4C76-9W86-DIAG-heyspex-config-yaml-has-no-single-home]]). The rule table
35
+ // (`.heyspex/guardrails.json`) is a code-repo artifact and still loads from
36
+ // DEFAULT_APP_ROOT when `repoRoot` is absent.
37
+ // opts.env — the environment the per-behavior kill switches are read from. Defaults to
38
+ // `process.env`.
39
+ // opts.ruleTable — a ./rule-table.mjs table (`{ rules }`) to resolve behaviors against
40
+ // instead of loading the real one.
41
+ // The value semantics mirror the loader's `readConfigLeafRaw` (the trimmed raw scalar after
42
+ // the colon; `undefined` when the leaf is absent) so every substitution below stays
43
+ // byte-identical to what the plugin hook produced before the move.
44
+ //
45
+ // Pure: no I/O of its own beyond loadRuleTable's / getConfigLeaf's fail-safe file reads. No
46
+ // process.stdin/stdout/exit.
47
+
48
+ import { loadRuleTable, DEFAULT_APP_ROOT } from './rule-table.mjs';
49
+ import { defaultConfigRoot } from './config-root.mjs';
50
+ import { getConfigLeaf } from '../../vendor/heyspex-mcp/config-settings.mjs';
51
+ import { isResolvedVault, resolveActiveVault } from '../../vendor/heyspex-mcp/vault-resolve.mjs';
52
+ // HS-QOBQ: cheapest-possible-rung kind classification at capture time — the
53
+ // same lexical guess promote-capture.mjs re-checks at promote time, so both
54
+ // call sites the diagnose named agree on what "reads like a bug" means.
55
+ import { guessCaptureKindFromText } from '../../vendor/heyspex-mcp/capture-kind-suggest.mjs';
56
+
57
+ /** The registry surface every classifier behavior's rule is authored on. */
58
+ export const SURFACE = 'userpromptsubmit';
59
+ /** The severity the plugin router requires of a rule before it will fire it (its
60
+ * `rulesForSurface(guardrails, SURFACE, 'prime')` filter) — the same filter applies here. */
61
+ export const DIRECTIVE_SEVERITY = 'prime';
62
+ /** The token capture-on-idea's authored inject carries where the confirm mode goes. */
63
+ export const MODE_TOKEN = '{{CONFIRM_MODE}}';
64
+
65
+ // [[HS-BIDR-DTOP-DIAG]] fix: the bare/unconditional directive (a Layer 1 lexical fire,
66
+ // or a Layer 2 mode:'model' fire) and the Layer 2 self_check "still undecided" block are
67
+ // textually distinct but nothing marked WHICH one an agent was reading — it had to infer
68
+ // from wording alone that discretion applies to the self-check and never to a confirmed
69
+ // fire. That inference is easy to drop under load, and an agent that drops it treats a
70
+ // confirmed match as if it were a self-check and silently skips it (the production miss
71
+ // this diagnose captures). This marker is prepended to every CONFIRMED-FIRE directive —
72
+ // loud and explicit as the mirror image of the self-check wrapper's own "silently
73
+ // judge... DEFAULT OUTCOME IS NONE" framing — so the two shapes are unmistakable at a
74
+ // glance, not just by prose wording.
75
+ export const CONFIRMED_MATCH_MARKER =
76
+ 'HEYSPEX CONFIRMED MATCH — NOT A SELF-CHECK. A deterministic rule has ALREADY matched ' +
77
+ "this message. There is no judgment step and nothing left to evaluate — do NOT silently " +
78
+ 'second-guess it, and "this is also a work order / bug report" is explicitly NOT a valid ' +
79
+ 'reason to skip it. Execute the directive below IMMEDIATELY, exactly as written.';
80
+
81
+ /** Prepend the confirmed-match marker to a resolved directive. */
82
+ export function withConfirmedMatchMarker(directive) {
83
+ return `${CONFIRMED_MATCH_MARKER}\n\n${directive}`;
84
+ }
85
+
86
+ // ── [[HS-XDH8-9Y92-DIAG-defer-intent-hook-fires-on-negated]] Fix §2: per-behavior marker ──
87
+ // The unconditional CONFIRMED MATCH marker is right for capture_on_idea (HS-BIDR: a false
88
+ // positive only costs an extra capture, a skipped fire loses an idea). For behaviors whose
89
+ // false positive CHANGES WHAT GETS DONE — defer_intent (schedules the work a week out instead
90
+ // of doing it) and settings_intent (writes a setting) — the marker must not override the
91
+ // inject's own "FIRST, gate for precision" step. On 2026-10-10 "No do not defer the vault
92
+ // right to closeout. Do it, right, please." fired defer, and the "do NOT second-guess" marker
93
+ // ordered the agent to invert the owner's explicit instruction. These behaviors get a softer
94
+ // marker that still says a deterministic rule matched (so it is never confused with the
95
+ // self-check block) but keeps the precision gate authoritative.
96
+ export const PRECISION_GATED_MATCH_MARKER =
97
+ 'HEYSPEX LEXICAL MATCH — PRECISION-GATED, NOT A SELF-CHECK. A deterministic rule matched a ' +
98
+ 'phrase in this message, but a phrase match can misread what the user asked (for example a ' +
99
+ 'negated request such as "do not defer this"). The directive\'s own FIRST precision gate is ' +
100
+ 'AUTHORITATIVE: if the user\'s words do not actually ask for this, IGNORE the directive and do ' +
101
+ 'what they asked. Never act against an explicit instruction in the message.';
102
+
103
+ /** Behavior keys whose fire is precision-gated (soft marker), not unconditional. */
104
+ export const PRECISION_GATED_BEHAVIORS = Object.freeze(new Set(['defer_intent', 'settings_intent']));
105
+
106
+ /** The marker a confirmed lexical/model fire of this behavior carries. */
107
+ export function matchMarkerFor(behaviorKey) {
108
+ return PRECISION_GATED_BEHAVIORS.has(behaviorKey) ? PRECISION_GATED_MATCH_MARKER : CONFIRMED_MATCH_MARKER;
109
+ }
110
+
111
+ /** Prepend the behavior-appropriate match marker to a resolved directive. */
112
+ export function withMatchMarker(behaviorKey, directive) {
113
+ return `${matchMarkerFor(behaviorKey)}\n\n${directive}`;
114
+ }
115
+
116
+ // ── HS-XDH8 Fix §1: negation-aware lexical matching ─────────────────────────
117
+ // A rule's patterns are a bag of positive regexes; "defer the vault" matched inside "do not
118
+ // defer the vault". For a negation-guarded rule, a pattern hit only counts when its CLAUSE
119
+ // does not negate it: the text before the match, back to the nearest clause boundary
120
+ // (punctuation, a dash, or a joining conjunction such as "so"/"but"), has its last few words
121
+ // scanned for a negation cue. Every occurrence of every pattern is checked; the rule fires if
122
+ // ANY occurrence is un-negated ("don't defer the docs, but park this until Friday" fires).
123
+ // Opt-in per rule: the ids below, or `negationGuard: true` on a rule row.
124
+ export const NEGATION_GUARDED_RULE_IDS = Object.freeze(new Set(['defer-intent', 'capture-on-idea']));
125
+ const NEGATION_WINDOW_WORDS = 5;
126
+ const NEGATION_CUE_RE =
127
+ /\b(?:no|not|never|dont|don't|doesn't|didn't|won't|wont|shouldn't|mustn't|can't|cannot|stop|quit|without|nor|neither|instead\s+of|rather\s+than)\b/i;
128
+ const CLAUSE_BOUNDARY_RE = /[.!?;:,\n\r—–]|\s-\s|\b(?:so|but|and|then|because|though|although)\b/gi;
129
+
130
+ /** Does the clause leading up to `index` in `text` negate what starts at `index`? */
131
+ export function isNegatedAt(text, index) {
132
+ let before = String(text).slice(0, index).replace(/’/g, "'");
133
+ let cut = -1;
134
+ for (const m of before.matchAll(CLAUSE_BOUNDARY_RE)) cut = m.index + m[0].length;
135
+ if (cut >= 0) before = before.slice(cut);
136
+ const words = before.match(/[a-z]+(?:'[a-z]+)?/gi) ?? [];
137
+ return NEGATION_CUE_RE.test(words.slice(-NEGATION_WINDOW_WORDS).join(' '));
138
+ }
139
+
140
+ /** Is this rule row negation-guarded? */
141
+ export function isNegationGuarded(rule) {
142
+ return rule?.negationGuard === true || NEGATION_GUARDED_RULE_IDS.has(rule?.rule);
143
+ }
144
+
145
+ // ── [[HS-BCCN-1TMV-DIAG-capture-on-idea-classifier-arms-on]]: the NON-IDEA exclusion class ──
146
+ // capture_on_idea's directive text lists "purely mechanical commands" and "plain questions about
147
+ // existing work" as non-matches, but nothing deterministic ever checked for them: the lexical
148
+ // patterns were a bag of POSITIVE cues, and the semantic-miss rung (self_check with nothing on
149
+ // deck) manufactured a CONFIRMED MATCH out of "no lexical hit + long enough". With HS-20SW's
150
+ // obligation gate live, each false positive cost a capture write or a Stop re-drive. Five live
151
+ // shapes on 2026-10-17: a pasted shell prompt + command output, "Commit and push to <repos>", a
152
+ // question about an open red test, a lane-resume order (/hs-handoff <code> + a cd && claude
153
+ // command), and a verification request ("can you double check that this is all fixed now?").
154
+ //
155
+ // This is the exclusion class, evaluated BEFORE any confirmed-match verdict:
156
+ // (a) shell_paste — the message is predominantly a shell prompt plus command output, or a
157
+ // pasted agent-terminal log (✻ / ● / ⎿ status lines);
158
+ // pasted_content — a `<pasted_content>` / `[Pasted text #n]`-wrapped paste where the user's
159
+ // own words OUTSIDE the paste carry no idea marker (a sixth live false
160
+ // positive: "Here is some context from the last things printed:" + a log);
161
+ // (b) mechanical_order — an imperative made of git/npm/build/run/install/publish mechanics, an
162
+ // agent-spawn order, or a slash-command / lane-code resume;
163
+ // (c) plain_question — a question or a verification request about existing work.
164
+ // An IDEA MARKER anywhere in the message ("should", "could", "what if", "idea", "would be nice",
165
+ // "wish", "better", "instead", ...) always wins: the message is then NOT excluded, whatever its
166
+ // shape ("Commit and push, and we should also add a retry" still captures). On the LEXICAL path
167
+ // only (a) applies — a pattern hit is itself positive evidence, so a mechanical order or a
168
+ // question that matches "can we add ..." keeps firing exactly as before; (b) and (c) are the
169
+ // soft rung's business, where there is no lexical evidence at all. The HS-XDH8 negation guard is
170
+ // untouched: it runs first, on the pattern occurrence; this runs after, on the message shape.
171
+ export const NON_IDEA_EXCLUDED_RULE_IDS = Object.freeze(new Set(['capture-on-idea']));
172
+
173
+ /** Positive idea / suggestion cues: any one of these vetoes every exclusion below. */
174
+ export const IDEA_MARKER_RE =
175
+ /\b(?:(?:should|could)(?:n'?t)?(?!\s+you\b)|what\s+if|ideas?|thoughts?|(?:would|it'?d|that'?d|this'?d)\s+be\s+(?:nice|good|great|cool|neat|useful|handy|helpful|better|amazing)|wouldn'?t\s+it|i\s+wish|better|instead|improv(?:e|es|ed|ing|ement)s?|can\s+we|we\s+need|needs?\s+to|ought\s+to|i\s+(?:noticed|realized|realised|keep\s+thinking)|i\s+(?:want|'?d\s+like|would\s+like|wanna)\s+to|brain[\s-]?dump|why\s+not|features?|suggest(?:s|ed|ion|ions)?|propos(?:e|al|als)|capture\s+this|for\s+all\s+future\s+users)\b/i;
176
+
177
+ // (a) A shell prompt line: `PS C:\dir>`, `C:\dir>`, `~/dir $`, `user@host:~/dir$`, `$ cmd`, `> npm ...`.
178
+ const SHELL_PROMPT_LINE_RE =
179
+ /^\s*(?:PS\s+)?[A-Za-z]:[\\/][^>\n]*>|^\s*(?:PS\s+)?[~/][^\s>]*\s*[$>#]\s?|^\s*\$\s+\S|^\s*[\w.-]+@[\w.-]+[: ]\S*\s*[$#%]|^\s*>\s*(?:npm|npx|node|git|hs|pnpm|yarn|dotnet|cd|ls|dir|cat|type)\b/;
180
+ // Command OUTPUT, not prose: versions, paths, npm/git chatter, stack frames, table rules, status words.
181
+ const OUTPUT_LINE_RE =
182
+ /\bv?\d+\.\d+\.\d+\b|[\\/][\w.-]+[\\/]|\.(?:mjs|cjs|js|ts|tsx|json|md|ya?ml|log)\b|\bnpm\s+(?:warn|err|notice|info)\b|\b(?:err(?:or)?|warn(?:ing)?|fatal|exit\s+code|exited|passed|failed|fail|pass|ok|done|added|removed|up\s+to\s+date|audited|packages?|vulnerabilit(?:y|ies)|branch|commit|ahead|behind|modified|untracked)\b|^\s*at\s+\S|^[\s|+\-=*─│┌┐└┘├┤]+$|^\s*[-*]\s+\S/i;
183
+ const wordCount = (line) => (line.match(/[A-Za-z']+/g) ?? []).length;
184
+
185
+ // (a, terminal log) A Claude Code / agent-terminal status line: `✻ Churned for 19m`, `● Agent "x" failed`,
186
+ // `⎿ Done`, `❯ prompt`. Counted like a shell prompt line: it marks the text as a pasted transcript.
187
+ const TERMINAL_GLYPH_LINE_RE = /^\s*[✻✳✶✢✽●⎿❯⏺]\s*\S/u;
188
+ // (a, wrapped paste) Claude Code wraps a long paste as `<pasted_content id="..">…</pasted_content ..>` (closing tag
189
+ // sometimes carries the id, sometimes is missing when truncated) or collapses it to `[Pasted text #1 +12 lines]`.
190
+ const PASTED_BLOCK_RE = /<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content\b[^>]*>|$)|\[Pasted text #\d+[^\]\n]*\]/gi;
191
+
192
+ /**
193
+ * Split a message into the user's OWN words and the pasted blocks. `pasted` is true when at least
194
+ * one `<pasted_content>` / `[Pasted text #n]` block is present; `own` is the message with them removed.
195
+ */
196
+ export function splitPastedContent(prompt) {
197
+ const s = String(prompt ?? '');
198
+ const own = s.replace(PASTED_BLOCK_RE, ' ');
199
+ return { own, pasted: own !== s };
200
+ }
201
+
202
+ /** (a) Is the message predominantly a shell prompt plus command output? (shape only — no marker check) */
203
+ export function isShellPastePrompt(prompt) {
204
+ const lines = String(prompt ?? '').split(/\r?\n/).filter((l) => l.trim());
205
+ if (!lines.length) return false;
206
+ let promptLines = 0;
207
+ let terminalish = 0;
208
+ for (const line of lines) {
209
+ if (SHELL_PROMPT_LINE_RE.test(line) || TERMINAL_GLYPH_LINE_RE.test(line)) {
210
+ promptLines += 1;
211
+ terminalish += 1;
212
+ } else if (wordCount(line) < 4 || OUTPUT_LINE_RE.test(line)) {
213
+ terminalish += 1;
214
+ }
215
+ }
216
+ return promptLines >= 1 && terminalish * 2 >= lines.length;
217
+ }
218
+
219
+ // (b) Leads with a mechanics verb (after any politeness), an agent-spawn order, or a branch/tag/PR order...
220
+ const MECHANICAL_LEAD_RE = new RegExp(
221
+ '^\\s*(?:(?:please|pls|plz|now|also|then|and|next|first|just|ok(?:ay)?|go\\s+ahead\\s+and|can\\s+you|could\\s+you|would\\s+you|let\'?s)[,\\s]+)*' +
222
+ '(?:' +
223
+ [
224
+ 'commit', 'push', 'pull', 'sync', 'fetch', 'merge', 'rebase', 'stash', 'checkout', 'switch', 'publish', 'unpublish',
225
+ 'install', 'uninstall', 'reinstall', 'build', 'rebuild', 'test', 'retest', 'run', 'rerun', 're-run', 'deploy', 'redeploy',
226
+ 'tag', 'clone', 'lint', 'typecheck', 'start', 'stop', 'restart', 'kill', 'release', 'bump', 'bootstrap', 'compile',
227
+ 'npm', 'npx', 'node', 'git', 'hs', 'pnpm', 'yarn', 'dotnet', 'spawn', 'launch', 'resume', 'reload', 'claim', 'close\\s+out',
228
+ // "complete / finish / continue this agent's work" — a hand-off order about existing work.
229
+ "(?:complete|finish|continue|wrap\\s+up|pick\\s+up)\\s+(?:(?:this|that|the|their|its|his|her|my|your)\\s+)?(?:(?:other|previous|last|dead|sub-?)\\s*)?(?:agent'?s?|session'?s?|lane'?s?|work|task|job|spec|merge|handoff)",
230
+ '(?:create|make|cut|open|start|spawn|launch|add|fork|kick\\s+off|fire\\s+up|bring\\s+up)\\s+(?:another|an?|one\\s+more|the|two|three|\\d+)?\\s*(?:new\\s+|more\\s+|second\\s+)?(?:git\\s+)?' +
231
+ '(?:branch(?:es)?|tags?|worktrees?|prs?|pull\\s+requests?|sub-?agents?|agents?|workers?|sessions?|lanes?|terminals?)',
232
+ ].join('|') +
233
+ ')\\b',
234
+ 'i',
235
+ );
236
+ // ...or carries a slash-command / lane-code resume or a shell command line anywhere.
237
+ const RESUME_OR_COMMAND_RE =
238
+ /(?:^|\s)\/hs-[a-z][\w-]*\b|\bclaude\s+"?\/|\bhs\s+(?:handoff|next|pull|push|mcp|doctor|setup|login)\b|\bcd\s+\S+\s*&&|^\s*(?:npm|npx|node|git|pnpm|yarn|dotnet)\s+\S/im;
239
+
240
+ /** (b) Is the message a mechanical imperative (git/npm/build/run/agent-spawn) or a slash-command / lane resume? (shape only) */
241
+ export function isMechanicalOrderPrompt(prompt) {
242
+ const s = String(prompt ?? '').replace(/’/g, "'");
243
+ return MECHANICAL_LEAD_RE.test(s) || RESUME_OR_COMMAND_RE.test(s);
244
+ }
245
+
246
+ // (c) A question mark closing a sentence, an interrogative opener, or a verification phrase.
247
+ const QUESTION_MARK_RE = /\?(?:\s|$|[)"'\]])/;
248
+ const QUESTION_OPENER_RE =
249
+ /^\s*(?:please\s+)?(?:(?:can|could|would|will|did|do|does|is|are|was|were|has|have|should)\s+(?:you|it|this|that|we|there|the|they|these|those|i)\b|what|why|how|where|which|when|who)\b/i;
250
+ const VERIFICATION_RE =
251
+ /\b(?:double[\s-]?check|verify|confirm|check\s+(?:that|if|whether)|sanity[\s-]?check|is\s+(?:this|that|it|everything)\s+(?:all\s+)?(?:fixed|done|working|green|merged|pushed|live|resolved)|did\s+(?:that|this|it)\s+(?:work|land|pass|go\s+through))\b/i;
252
+
253
+ /** (c) Is the message a plain question or a verification request? (shape only) */
254
+ export function isPlainQuestionPrompt(prompt) {
255
+ const s = String(prompt ?? '');
256
+ return QUESTION_MARK_RE.test(s) || QUESTION_OPENER_RE.test(s) || VERIFICATION_RE.test(s);
257
+ }
258
+
259
+ /**
260
+ * The exclusion verdict for capture_on_idea. `{ excluded: false, reason: null }` whenever an idea
261
+ * marker is present; otherwise the first matching class in (a) → (b) → (c) order. With
262
+ * `lexicalHit: true` (a pattern already matched) only (a) can exclude.
263
+ *
264
+ * @param {string} prompt
265
+ * @param {{ lexicalHit?: boolean }} [opts]
266
+ * @returns {{ excluded: boolean, reason: 'pasted_content'|'shell_paste'|'mechanical_order'|'plain_question'|null }}
267
+ */
268
+ export function nonIdeaExclusion(prompt, { lexicalHit = false } = {}) {
269
+ const s = typeof prompt === 'string' ? prompt.replace(/’/g, "'") : '';
270
+ if (!s.trim()) return { excluded: false, reason: null };
271
+ // A wrapped paste (`<pasted_content>` / `[Pasted text #n]`) is not the user's words: only the text
272
+ // OUTSIDE it can carry an idea marker. With no marker there, the message is a paste → class (a).
273
+ const { own, pasted } = splitPastedContent(s);
274
+ if (IDEA_MARKER_RE.test(own)) return { excluded: false, reason: null };
275
+ if (pasted) return { excluded: true, reason: 'pasted_content' };
276
+ if (isShellPastePrompt(s)) return { excluded: true, reason: 'shell_paste' };
277
+ if (lexicalHit) return { excluded: false, reason: null };
278
+ if (isMechanicalOrderPrompt(s)) return { excluded: true, reason: 'mechanical_order' };
279
+ if (isPlainQuestionPrompt(s)) return { excluded: true, reason: 'plain_question' };
280
+ return { excluded: false, reason: null };
281
+ }
282
+
283
+ /** Is this rule row subject to the non-idea exclusion class? */
284
+ export function isNonIdeaExcluded(rule) {
285
+ return rule?.nonIdeaExclusion === true || NON_IDEA_EXCLUDED_RULE_IDS.has(rule?.rule);
286
+ }
287
+
288
+ /**
289
+ * Does `prompt` match `rule` through its compiled `patterns` (case-insensitive RegExps)?
290
+ * Unguarded rules keep the plain `patterns.some(re.test)` semantics byte-for-byte; a
291
+ * negation-guarded rule needs at least one un-negated occurrence. For a rule in
292
+ * {@link NON_IDEA_EXCLUDED_RULE_IDS} a hit inside a predominantly-terminal paste with no idea
293
+ * marker is then discarded (HS-BCCN class (a); see {@link nonIdeaExclusion}).
294
+ */
295
+ // HS-BCCN (seventh live false positive): a capture-on-idea pattern that is ITSELF a question shape —
296
+ // "why does this / why did the build ..." — is not positive idea evidence on its own. When every hit
297
+ // comes from such a pattern, the full exclusion class (incl. (b) mechanical order, (c) plain question)
298
+ // applies, exactly as on the soft rung; any idea marker in the message still keeps it firing.
299
+ const QUESTION_SHAPED_PATTERN_RE = /^\\bwhy\\s\+/; // matches the pattern SOURCE text `\bwhy\s+...`
300
+ export function isQuestionShapedPattern(re) {
301
+ return QUESTION_SHAPED_PATTERN_RE.test(re?.source ?? '');
302
+ }
303
+
304
+ export function ruleMatchesPrompt(rule, patterns, prompt) {
305
+ if (typeof prompt !== 'string' || !Array.isArray(patterns)) return false;
306
+ const hits = [];
307
+ if (!isNegationGuarded(rule)) {
308
+ for (const re of patterns) if (re.test(prompt)) hits.push(re);
309
+ } else {
310
+ for (const re of patterns) {
311
+ const global = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
312
+ for (const m of prompt.matchAll(global)) {
313
+ if (!isNegatedAt(prompt, m.index)) {
314
+ hits.push(re);
315
+ break;
316
+ }
317
+ }
318
+ }
319
+ }
320
+ if (!hits.length) return false;
321
+ if (isNonIdeaExcluded(rule)) {
322
+ const questionOnly = hits.every(isQuestionShapedPattern);
323
+ if (nonIdeaExclusion(prompt, { lexicalHit: !questionOnly }).excluded) return false;
324
+ }
325
+ return true;
326
+ }
327
+
328
+ // ── injected-content guards ──────────────────────────────────────────────────
329
+ // Sentinels that mark harness-injected content arriving through the UserPromptSubmit
330
+ // channel (background-task notifications, system reminders, slash-command envelopes). They
331
+ // can contain a rule's trigger phrase verbatim but are NOT a genuine human prompt, so a rule
332
+ // with `ignoreInjectedContent` must not fire on them. scripts/claude-hooks/_guardrail-loader.mjs
333
+ // carries the plugin-side copy of this list; ./intent-directive.test.mjs asserts the two are
334
+ // equal so an edit to one cannot silently leave the other behind.
335
+ export const INJECTED_CONTENT_SENTINELS = [
336
+ '<task-notification>',
337
+ '<system-reminder>',
338
+ '[SYSTEM NOTIFICATION',
339
+ '<command-message>',
340
+ '<command-name>',
341
+ '<local-command-stdout>',
342
+ ];
343
+
344
+ /** Contains-anywhere guard — the research hook's original (broad) check. */
345
+ export function containsInjectedContent(text) {
346
+ if (typeof text !== 'string') return false;
347
+ return INJECTED_CONTENT_SENTINELS.some((s) => text.includes(s));
348
+ }
349
+
350
+ /**
351
+ * Stricter, false-positive-safe variant: is the message a genuine harness ENVELOPE (a
352
+ * task-notification / system-reminder / slash-command envelope delivered AS the prompt),
353
+ * rather than user text that merely QUOTES a sentinel? A real envelope leads with its tag;
354
+ * a user pasting a transcript or terminal logs leads with their own words.
355
+ */
356
+ export function startsWithInjectedContent(text) {
357
+ if (typeof text !== 'string') return false;
358
+ const head = text.trimStart();
359
+ return INJECTED_CONTENT_SENTINELS.some((s) => head.startsWith(s));
360
+ }
361
+
362
+ // ── config reads (injectable) ────────────────────────────────────────────────
363
+
364
+ /**
365
+ * The default settings reader: vendor/heyspex-mcp/config-settings.mjs's `getConfigLeaf`
366
+ * against `<repoRoot>/.heyspex/config.yaml` — the same indent-scoped `key:` scan the plugin
367
+ * loader's `readConfigLeafRaw` performs, returning `{ found, raw, value }`. With no
368
+ * `repoRoot` the file is read from the vault root (`defaultConfigRoot()`).
369
+ */
370
+ export function defaultGetSetting(repoRoot, key) {
371
+ return getConfigLeaf(repoRoot ?? defaultConfigRoot(), key);
372
+ }
373
+
374
+ /**
375
+ * Read one config leaf as the loader's `readConfigLeafRaw` would: the trimmed raw scalar,
376
+ * or `undefined` when absent/unreadable. Accepts every reader shape the header documents.
377
+ * FAIL-SAFE: a throwing reader reads as "absent".
378
+ */
379
+ export function readConfigLeafRaw(configKey, repoRoot, opts = {}) {
380
+ if (!configKey || typeof configKey !== 'string') return undefined;
381
+ const getSetting = typeof opts.getSetting === 'function' ? opts.getSetting : defaultGetSetting;
382
+ let leaf;
383
+ try {
384
+ leaf = getSetting(repoRoot ?? defaultConfigRoot(), configKey);
385
+ } catch {
386
+ return undefined;
387
+ }
388
+ if (leaf == null) return undefined;
389
+ if (typeof leaf === 'string') return leaf.trim();
390
+ if (typeof leaf === 'object') {
391
+ if (leaf.found === false) return undefined;
392
+ if (typeof leaf.raw === 'string') return leaf.raw.trim();
393
+ if (leaf.value === undefined || leaf.value === null) return undefined;
394
+ return String(leaf.value).trim();
395
+ }
396
+ return String(leaf).trim();
397
+ }
398
+
399
+ /**
400
+ * Is a dotted `config_key` ON? Semantics match the loader's `isConfigKeyEnabled`: ON unless
401
+ * the leaf is explicitly `false`; absent / unreadable / no key at all default to ON.
402
+ */
403
+ export function isConfigKeyEnabled(configKey, repoRoot, opts = {}) {
404
+ if (!configKey || typeof configKey !== 'string') return true;
405
+ const raw = readConfigLeafRaw(configKey, repoRoot, opts);
406
+ if (raw === undefined) return true;
407
+ return raw.toLowerCase() !== 'false';
408
+ }
409
+
410
+ /** Enabled-check straight off a rule row (its `configKey`). */
411
+ export function isRuleEnabled(rule, repoRoot, opts = {}) {
412
+ return isConfigKeyEnabled(rule && rule.configKey, repoRoot, opts);
413
+ }
414
+
415
+ /**
416
+ * Resolve the CONFIRM MODE for an autonomous agent behavior — the two-layer permission
417
+ * model the owner specified (2026-07-11):
418
+ * 'auto' = act immediately, user can undo/discard afterward (forgiveness)
419
+ * 'ask' = ask the user for confirmation before acting (permission)
420
+ *
421
+ * Resolution order (per-behavior override wins over the global default):
422
+ * 1. agent_behaviors.<behaviorKey>.ask_first
423
+ * true|yes|ask → 'ask' · false|no|auto → 'auto' · null/absent → inherit
424
+ * 2. (inherit) global agent_behaviors.ask_forgiveness_not_permission
425
+ * true|yes|absent → 'auto' (forgiveness — the owner's default)
426
+ * false|no → 'ask'
427
+ * FAIL-SAFE: any unreadable config → 'auto' (the stated default).
428
+ */
429
+ export function resolveConfirmMode(behaviorKey, repoRoot, opts = {}) {
430
+ if (behaviorKey && typeof behaviorKey === 'string') {
431
+ const per = readConfigLeafRaw(`agent_behaviors.${behaviorKey}.ask_first`, repoRoot, opts);
432
+ if (per !== undefined) {
433
+ const v = per.toLowerCase();
434
+ if (v === 'true' || v === 'yes' || v === 'ask') return 'ask';
435
+ if (v === 'false' || v === 'no' || v === 'auto') return 'auto';
436
+ // 'null' / '' / anything else → fall through to the global default (inherit)
437
+ }
438
+ }
439
+ const global = readConfigLeafRaw('agent_behaviors.ask_forgiveness_not_permission', repoRoot, opts);
440
+ if (global !== undefined) {
441
+ const g = global.toLowerCase();
442
+ if (g === 'false' || g === 'no') return 'ask'; // forgiveness OFF → ask first
443
+ }
444
+ return 'auto'; // forgiveness ON / absent → act, don't ask
445
+ }
446
+
447
+ // ── capture-on-idea enrichment ───────────────────────────────────────────────
448
+
449
+ // Capture's forgiveness/permission directives — carried over verbatim from the retired
450
+ // capture-trigger.mjs shim (HS-IYU1, deleted after burn-in) so the emitted inject stays
451
+ // byte-identical.
452
+ export const CAPTURE_MODE_DIRECTIVES = {
453
+ auto:
454
+ "FORGIVENESS MODE (the owner's default — act, don't ask): START THE CAPTURE FLOW " +
455
+ 'IMMEDIATELY. In ONE short line, tell the user you are capturing this as an idea ' +
456
+ 'and they can say "discard" (or "no, just do it") to skip — then begin. Do NOT ask ' +
457
+ 'permission first.',
458
+ ask:
459
+ 'PERMISSION MODE: ASK FIRST. Reply with ONE line — "Want me to capture this as an ' +
460
+ 'idea? (yes / no, just do it)" — and run the capture steps below ONLY if the user ' +
461
+ 'says yes. If they decline, just do the task.',
462
+ };
463
+
464
+ /**
465
+ * capture-on-idea's full transform: substitute `{{CONFIRM_MODE}}` (or prepend the mode
466
+ * directive when the token is absent), then append the routing and kind hints.
467
+ */
468
+ export function applyCaptureConfirmMode(inject, repoRoot, prompt, opts = {}) {
469
+ const mode = resolveConfirmMode('capture_on_idea', repoRoot, opts);
470
+ const directive = CAPTURE_MODE_DIRECTIVES[mode] || CAPTURE_MODE_DIRECTIVES.auto;
471
+ const base = inject.includes(MODE_TOKEN) ? inject.split(MODE_TOKEN).join(directive) : `${directive}\n\n${inject}`;
472
+ return appendCaptureKindHint(appendCaptureRoutingHint(base, repoRoot, opts), prompt);
473
+ }
474
+
475
+ // Capture kind hint ([[HS-QOBQ-S4JZ-DIAG-capture-kind-is-never-classified-every]]):
476
+ // every capture materializes at `kind: idea` and nothing ever inspected the
477
+ // triggering message to check whether that default is right. This is the
478
+ // cheapest rung (lexical, no model call) — a strong bug-language match in the
479
+ // SAME message the capture-on-idea rule already fired on. Suggest-only: the
480
+ // agent still must offer it and get a yes before calling heyspex_update_capture
481
+ // with kind ([[HS-5HYS]] elicitation — suggest, never auto-apply), matching the
482
+ // existing heyspex_update_capture `kind` arg description verbatim.
483
+ export function appendCaptureKindHint(inject, prompt) {
484
+ const guess = guessCaptureKindFromText(prompt);
485
+ if (!guess.kind) return inject;
486
+ const hint =
487
+ `CAPTURE KIND HINT (lexical, suggest-only): this message reads like kind: ${guess.kind} ` +
488
+ `(matched "${guess.matched}"), not the default idea. After capturing, offer the ` +
489
+ `reclassification in your reply (e.g. "this reads like a bug-fix capture — reclassify?") ` +
490
+ `and call heyspex_update_capture with kind: "${guess.kind}" ONLY on the user's yes — never ` +
491
+ `auto-apply it.`;
492
+ return `${inject}\n\n${hint}`;
493
+ }
494
+
495
+ // Capture model-routing + thread-split hint ([[HS-6YOA-HPR6-RES-capture-to-research-auto-model-routing]],
496
+ // owner ruling 2026-08-11). A best-effort HINT appended to the capture inject, same shape as
497
+ // appendResearchSourceHint below — the hook cannot know the running session's actual current
498
+ // model (that's not in the UserPromptSubmit payload), so it can only tell the agent what's
499
+ // CONFIGURED and trust the agent (which does know its own model, per its own system prompt) to
500
+ // judge whether a switch/delegate/ask actually applies. Neither mode is ever a silent, live
501
+ // model switch — that is not possible from a hook or from the agent itself (research doc's
502
+ // Platform capability check: `/model` is a user-issued local command; no MCP tool flips it).
503
+ export function appendCaptureRoutingHint(inject, repoRoot, opts = {}) {
504
+ const defaultModel = readConfigLeafRaw('agent_behaviors.capture_on_idea.default_model', repoRoot, opts);
505
+ const switchMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.model_switch_mode', repoRoot, opts) || 'ask';
506
+ const splitMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.thread_split_mode', repoRoot, opts) || 'ask';
507
+
508
+ const parts = [inject];
509
+
510
+ if (defaultModel && switchMode !== 'off') {
511
+ const modeInstruction =
512
+ switchMode === 'silent_delegate'
513
+ ? 'CONFIGURED MODE: silent_delegate — do NOT interrupt the user with a model question. ' +
514
+ `If this is the FIRST/ONLY capture turn in this thread (not a continuation of an ` +
515
+ `already-open capture) and you are not already running as ${defaultModel}, hand the ` +
516
+ 'mechanical capture steps (verbatim quote, core idea, summary, ground=expand) to a ' +
517
+ `one-shot Agent(prompt: <the capture instructions>, model: "${defaultModel}") subagent ` +
518
+ 'instead of running them on this session — then relay its result. Never switch this ' +
519
+ "running session's own model; that is not possible and is not what this mode means."
520
+ : 'CONFIGURED MODE: ask — if this is the FIRST/ONLY capture turn in this thread (not a ' +
521
+ `continuation of an already-open capture) and you are not already running as ` +
522
+ `${defaultModel}, add ONE short line to your capture-start message offering a switch — ` +
523
+ `e.g. "(captures default to ${defaultModel} — say switch to run /model ${defaultModel} ` +
524
+ 'first)" — then proceed with the capture flow regardless of the answer; never block on ' +
525
+ 'it. A mid-thread continuation never gets this offer — it stays on the thread\'s model.';
526
+ parts.push(
527
+ `CAPTURE MODEL ROUTING (default_model=${defaultModel}, model_switch_mode=${switchMode}): ${modeInstruction}`,
528
+ );
529
+ }
530
+
531
+ const splitInstruction =
532
+ splitMode === 'auto'
533
+ ? 'CONFIGURED thread_split_mode=auto: when new mid-thread prose is judged NOT a continuation ' +
534
+ 'of the on-deck capture, decide on your own judgment — no AskUserQuestion — whether it ' +
535
+ 'SPLITS (a new HS-CAP-* that still benefits from this thread\'s context: continue it via ' +
536
+ 'Agent(subagent_type:"fork") so full context carries over) or SPAWNS (unrelated enough that ' +
537
+ 'a fresh Agent() with no inherited context is cleaner). State which you picked and why in ' +
538
+ 'one line.'
539
+ : 'CONFIGURED thread_split_mode=ask: when new mid-thread prose is judged NOT a continuation of ' +
540
+ 'the on-deck capture, and you are NOT confident whether it benefits from this thread\'s ' +
541
+ 'existing context, use AskUserQuestion with exactly two options — split (new capture id, ' +
542
+ 'keep this thread\'s context via Agent(subagent_type:"fork")) vs. spawn (new capture id, ' +
543
+ 'fresh context via a plain Agent() call) — rather than guessing. Skip the ask only when ' +
544
+ 'you ARE confident: a clearly-related new idea still splits, a clearly-unrelated one still ' +
545
+ 'spawns, with no prompt.';
546
+ parts.push(`CAPTURE THREAD SPLIT/SPAWN: ${splitInstruction}`);
547
+
548
+ return parts.join('\n\n');
549
+ }
550
+
551
+ // Research-source hint — fix for [[HS-LFUH-DIAG-W1YE]] root-cause part 1 ("not injected").
552
+ // The research inject above tells the agent WHICH RUNGS to climb and WHICH MODEL to spawn
553
+ // (default_agent), but never named the configured search/scrape BACKEND
554
+ // (agent_behaviors.research.default_source) — so a configured `firecrawl-self-hosted` default
555
+ // was silently unreachable on the interactive path; the agent had no way to even know it existed.
556
+ // This is deliberately a best-effort HINT appended to the existing inject text, not a
557
+ // replacement: it is a snapshot taken at prompt-submit time, so it can go stale mid-session
558
+ // (a lease can be granted/refused, a box can go up/down). The authoritative, can't-go-stale
559
+ // source of truth is the on-demand `heyspex_resolve_research_plan` MCP tool this hint points the
560
+ // agent at — per the research doc's recommendation, "hook injection is a best-effort hint; the
561
+ // on-demand tool is the source of truth", not either/or.
562
+ export function appendResearchSourceHint(inject, repoRoot, opts = {}) {
563
+ const defaultSource = readConfigLeafRaw('agent_behaviors.research.default_source', repoRoot, opts);
564
+ const defaultAgent = readConfigLeafRaw('agent_behaviors.research.default_agent', repoRoot, opts);
565
+ if (!defaultSource && !defaultAgent) return inject; // nothing configured — nothing to hint at
566
+ const hint =
567
+ 'CONFIGURED RESEARCH RESOURCE (best-effort hint, a snapshot — re-confirm live via ' +
568
+ 'heyspex_resolve_research_plan() before RUNG 0 `sequential` search/scrape, since availability ' +
569
+ `can change mid-session): default_source=${defaultSource ?? '(unset)'}, ` +
570
+ `default_agent=${defaultAgent ?? '(unset)'}. If default_source names a Firecrawl backend ` +
571
+ '(firecrawl-self-hosted | firecrawl-hosted), call heyspex_firecrawl_research({ mode: "search"|"scrape", ' +
572
+ 'query|url }) for rung 0 instead of native WebSearch/WebFetch — it wraps that SAME configured ' +
573
+ 'backend and honors its existing availability gates (the Ollama-XOR-Firecrawl lease / hosted credit ' +
574
+ 'gate). If its result reports downgraded:true, tell the user plainly that the configured backend was ' +
575
+ 'unavailable and you fell back to web-native — NEVER substitute silently.';
576
+ return `${inject}\n\n${hint}`;
577
+ }
578
+
579
+ // Hand-off vault path. The handoff-intent inject (authored in the vault's
580
+ // handoff-intent-protocol `enforcement:` block, compiled into rule-table.mjs) names the
581
+ // launch form as a literal `cd C:\dev-projects\heyspex-specs && claude "/hs-next …"` — the
582
+ // owner's own checkout. In any other repo (a satellite bound to its own vault, a teammate's
583
+ // machine) that sent the human into the WRONG vault. The path is localized here, at directive
584
+ // time, from the vault bound to the session's project (the shared resolver: explicit →
585
+ // HEYSPEX_VAULT_ROOT → walk up for a binding / vault checkout; never the registry's retired
586
+ // active pointer). Unresolvable → a placeholder, never somebody else's machine path.
587
+ export const VAULT_CHECKOUT_TOKEN = '{{VAULT_CHECKOUT}}';
588
+ export const VAULT_CHECKOUT_PLACEHOLDER = '<your vault checkout>';
589
+ const HANDOFF_CD_RE = /\bcd\s+(?:"[^"]*"|\S+)(\s+&&\s+claude\s+"\/hs-next\b)/g;
590
+
591
+ /**
592
+ * The vault checkout to name in the hand-off launch command, display-ready (quoted when it
593
+ * carries whitespace), or {@link VAULT_CHECKOUT_PLACEHOLDER} when nothing names a vault.
594
+ * `opts.vaultCheckout` (string | null) overrides resolution — tests use it. Never throws.
595
+ *
596
+ * @param {{ vaultCheckout?: string|null, env?: object }} [opts]
597
+ */
598
+ export function resolveHandoffVaultCheckout(opts = {}) {
599
+ let root = null;
600
+ if (Object.prototype.hasOwnProperty.call(opts, 'vaultCheckout')) {
601
+ root = typeof opts.vaultCheckout === 'string' && opts.vaultCheckout.trim() ? opts.vaultCheckout.trim() : null;
602
+ } else {
603
+ try {
604
+ const resolved = resolveActiveVault(opts.env ? { env: opts.env } : {});
605
+ if (isResolvedVault(resolved)) root = resolved.repoRootAbs;
606
+ } catch {
607
+ root = null;
608
+ }
609
+ }
610
+ if (!root) return VAULT_CHECKOUT_PLACEHOLDER;
611
+ return /\s/.test(root) ? `"${root}"` : root;
612
+ }
613
+
614
+ /** Point the hand-off launch command (`cd <vault> && claude "/hs-next …"`) at the bound vault. */
615
+ export function localizeHandoffVaultPath(inject, opts = {}) {
616
+ if (typeof inject !== 'string') return inject;
617
+ if (!inject.includes(VAULT_CHECKOUT_TOKEN) && !HANDOFF_CD_RE.test(inject)) return inject;
618
+ HANDOFF_CD_RE.lastIndex = 0;
619
+ const shown = resolveHandoffVaultCheckout(opts);
620
+ return inject
621
+ .split(VAULT_CHECKOUT_TOKEN).join(shown)
622
+ .replace(HANDOFF_CD_RE, (_m, tail) => `cd ${shown}${tail}`);
623
+ }
624
+
625
+ // ── the behavior catalog ─────────────────────────────────────────────────────
626
+
627
+ // The classifier behavior catalog. `ruleId` ties a behavior to its registry rule;
628
+ // `description` is the semantic-classification instruction (the router's self-check block
629
+ // and opt-in model mode read it from here — this catalog is the single source of behavior
630
+ // descriptions); `envOff` is the per-behavior kill switch; `guard` is the injected-content
631
+ // check that behavior used historically; `transform(inject, repoRoot, prompt, opts)` builds
632
+ // the final inject from the rule's inject text (`opts` carries the injected reads — see the
633
+ // header). BEHAVIOR_ORDER (array order) is the deterministic lexical tiebreak.
634
+ export const CLASSIFIER_BEHAVIORS = [
635
+ {
636
+ key: 'capture_on_idea',
637
+ ruleId: 'capture-on-idea',
638
+ envOff: 'HEYSPEX_CAPTURE_TRIGGER_OFF',
639
+ guard: startsWithInjectedContent,
640
+ transform: (inject, repoRoot, prompt, opts) => applyCaptureConfirmMode(inject, repoRoot, prompt, opts),
641
+ description:
642
+ 'The user\'s message contains a raw idea, suggestion, observation, or improvement thought — something that could exist or be better. Fires even when the message is ALSO a work order or bug report, as long as it carries new idea/capability content worth remembering. Does NOT fire on purely mechanical commands with no new idea (e.g. "run the tests", "fix the typo"), plain questions about existing work, or bare continuations of work already in progress.',
643
+ },
644
+ {
645
+ key: 'research_before_decision',
646
+ ruleId: 'research-before-decision',
647
+ envOff: 'HEYSPEX_RESEARCH_TRIGGER_OFF',
648
+ guard: containsInjectedContent,
649
+ transform: (inject, repoRoot, _prompt, opts) => appendResearchSourceHint(inject, repoRoot, opts),
650
+ description:
651
+ 'The user is declining to make a design or architecture decision themselves and is asking for research, best practices, or an expert recommendation instead (e.g. "not a decision I should have to make", "don\'t reinvent the wheel").',
652
+ },
653
+ {
654
+ key: 'settings_intent',
655
+ ruleId: 'settings-intent',
656
+ envOff: 'HEYSPEX_SETTINGS_INTENT_OFF',
657
+ guard: startsWithInjectedContent,
658
+ transform: (inject) => inject,
659
+ description:
660
+ 'The user wants to READ or CHANGE a HeySpex workflow setting / agent behavior — enable or disable a behavior (auto-capture, the research trigger, the intent classifier), turn something on or off, set/lower/raise a threshold or value (confidence, timeout), or ask what a setting is currently. About the configuration itself, NOT a request to do the underlying work.',
661
+ },
662
+ {
663
+ key: 'defer_intent',
664
+ ruleId: 'defer-intent',
665
+ envOff: 'HEYSPEX_DEFER_INTENT_OFF',
666
+ guard: startsWithInjectedContent,
667
+ transform: (inject) => inject,
668
+ description:
669
+ 'The user wants to DEFER, POSTPONE, or SCHEDULE a follow-up to a later time — "come back to this later", "remind me in a week", "park/table this until X is done", "schedule this for next week", "not now — later, after the migration". About WHEN to revisit something, not a request to do it now.',
670
+ },
671
+ {
672
+ key: 'handoff_intent',
673
+ ruleId: 'handoff-intent',
674
+ envOff: 'HEYSPEX_HANDOFF_INTENT_OFF',
675
+ guard: startsWithInjectedContent,
676
+ transform: (inject, _repoRoot, _prompt, opts) => localizeHandoffVaultPath(inject, opts),
677
+ description:
678
+ 'The user asks for a hand-off prompt, doc, notes or resume context for the NEXT agent or session ("give me a handoff prompt for the next agent", "hand this off", "what should the next session know"). About producing the hand-off artifact, NOT about loading one (a lane code like GQ5R or `hs handoff load`) and not ordinary work.',
679
+ },
680
+ {
681
+ key: 'spec_reference_lookup',
682
+ ruleId: 'spec-reference-lookup',
683
+ envOff: 'HEYSPEX_SPEC_REFERENCE_LOOKUP_OFF',
684
+ guard: startsWithInjectedContent,
685
+ transform: (inject) => inject,
686
+ description:
687
+ 'The user\'s message is little more than a pointer at an existing spec by id (a bare "HS-XXXX-YYYY", or "Reference: HS-...", "re: HS-...", "see HS-..." with nothing else actionable). Fires whether or not the spec turns out to matter. Does NOT fire on an ordinary work order that happens to name a spec id alongside real instructions ("update HS-XXXX\'s tests and rerun them") — there the id is already load-bearing context, not an unresolved pointer.',
688
+ },
689
+ ];
690
+
691
+ /**
692
+ * Map an engine rule id (hyphenated, e.g. 'capture-on-idea') back to the router's
693
+ * behavior key (underscored, e.g. 'capture_on_idea'). Returns null for an id no
694
+ * behavior claims (fail-safe: the engine cutover treats that as no match).
695
+ */
696
+ export function behaviorKeyForRuleId(ruleId) {
697
+ const found = CLASSIFIER_BEHAVIORS.find((b) => b.ruleId === ruleId);
698
+ return found ? found.key : null;
699
+ }
700
+
701
+ /** The catalog entry for a behavior key, or null. */
702
+ export function behaviorForKey(behaviorKey) {
703
+ return CLASSIFIER_BEHAVIORS.find((b) => b.key === behaviorKey) ?? null;
704
+ }
705
+
706
+ /**
707
+ * Resolve the compiled rule for each classifier behavior from the rule table. Returns
708
+ * `[{ behavior, rule }]` for behaviors whose rule exists on the userpromptsubmit surface at
709
+ * {@link DIRECTIVE_SEVERITY} and carries an inject — the same filter the plugin router's
710
+ * `rulesForSurface(guardrails, SURFACE, 'prime')` applies. FAIL-SAFE: a missing rule
711
+ * simply drops that behavior.
712
+ *
713
+ * @param {{ repoRoot?: string, ruleTable?: { rules?: object[] } }} [opts]
714
+ */
715
+ export function resolveBehaviorRules(opts = {}) {
716
+ const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
717
+ const rules = Array.isArray(table?.rules) ? table.rules : [];
718
+ const byId = new Map();
719
+ for (const r of rules) {
720
+ if (!r || !Array.isArray(r.surfaces) || !r.surfaces.includes(SURFACE)) continue;
721
+ if (r.severity !== DIRECTIVE_SEVERITY) continue;
722
+ byId.set(r.rule, r);
723
+ }
724
+ const resolved = [];
725
+ for (const behavior of CLASSIFIER_BEHAVIORS) {
726
+ const rule = byId.get(behavior.ruleId);
727
+ if (!rule || !rule.inject) continue;
728
+ resolved.push({ behavior, rule });
729
+ }
730
+ return resolved;
731
+ }
732
+
733
+ /**
734
+ * Is this resolved `{ behavior, rule }` entry eligible to fire on this prompt right now?
735
+ * (env kill switch off, injected-content guard passes, config_key enabled.) Behaviors
736
+ * that are ineligible fire on no path — matching the old per-hook early-returns exactly.
737
+ *
738
+ * @param {{ behavior: object, rule: object }} entry
739
+ * @param {string} prompt
740
+ * @param {string} [repoRoot]
741
+ * @param {{ env?: object, getSetting?: Function }} [opts]
742
+ */
743
+ export function isEntryEligible(entry, prompt, repoRoot, opts = {}) {
744
+ const { behavior, rule } = entry;
745
+ const env = opts.env ?? process.env;
746
+ if (String(env?.[behavior.envOff] ?? '') === '1') return false;
747
+ if (rule.ignoreInjectedContent && behavior.guard(prompt)) return false;
748
+ if (!isRuleEnabled(rule, repoRoot, opts)) return false;
749
+ return true;
750
+ }
751
+
752
+ /**
753
+ * Is this ONE behavior eligible to fire on this prompt right now? (env kill switch off,
754
+ * config_key enabled, injected-content guard passes). The engine gate uses this to re-apply
755
+ * the same gating the plugin's lexical sweep applied per-entry, since
756
+ * ../intent-classifier.mjs's `classifyPrompt()` is lexical-pattern-only and knows nothing
757
+ * about env switches, config toggles, or the injected-content guard. Returns false for an
758
+ * unknown behavior key.
759
+ *
760
+ * @param {string} behaviorKey
761
+ * @param {string} prompt
762
+ * @param {string} [repoRoot]
763
+ * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
764
+ */
765
+ export function isBehaviorEligible(behaviorKey, prompt, repoRoot, opts = {}) {
766
+ const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
767
+ if (!entry) return false;
768
+ return isEntryEligible(entry, prompt, repoRoot, opts);
769
+ }
770
+
771
+ /**
772
+ * Resolve ONE behavior's full directive (the rule's inject run through the behavior's
773
+ * transform), WITHOUT the marker. Returns null when the key is unknown / its rule is gone.
774
+ *
775
+ * @param {string} behaviorKey
776
+ * @param {string} [repoRoot]
777
+ * @param {string} [prompt]
778
+ * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
779
+ */
780
+ export function resolveDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
781
+ const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
782
+ if (!entry) return null;
783
+ return entry.behavior.transform(entry.rule.inject, repoRoot, prompt, opts);
784
+ }
785
+
786
+ /**
787
+ * The confirmed-fire seam (HS-5ISS): {@link resolveDirective} plus the SAME loud marker
788
+ * the plugin's Layer 1 lexical fire prepends — so a behavior the engine module
789
+ * (../intent-classifier.mjs) matched, once confirmed eligible, emits a byte-identical
790
+ * directive to the plugin's lexical path. Returns null on an unknown/gone behavior
791
+ * (fail-safe: no injection).
792
+ *
793
+ * @param {string} behaviorKey
794
+ * @param {string} [repoRoot]
795
+ * @param {string} [prompt]
796
+ * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
797
+ */
798
+ export function buildConfirmedDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
799
+ const directive = resolveDirective(behaviorKey, repoRoot, prompt, opts);
800
+ if (!directive) return null;
801
+ return withMatchMarker(behaviorKey, directive);
802
+ }
803
+
804
+ /**
805
+ * The engine gate's one-shot: from a rule id the classifier matched, decide whether the
806
+ * behavior may fire and, if so, build the plugin's confirmed directive for it.
807
+ *
808
+ * Outcomes:
809
+ * - `null` — the behavior is known but NOT eligible right now (env kill switch, config
810
+ * toggle, or the injected-content guard). The gate must inject nothing.
811
+ * - `{ behavior: null, additionalContext: null }` — no catalog behavior claims this rule
812
+ * id, or its rule is not a userpromptsubmit `prime` row the plugin router would have
813
+ * fired. The gate falls back to the rule's raw inject (the pre-HS-N01T behavior).
814
+ * - `{ behavior, additionalContext }` — marker + directive, byte-identical to the plugin.
815
+ *
816
+ * @param {string} ruleId
817
+ * @param {string} prompt
818
+ * @param {{ repoRoot?: string, env?: object, getSetting?: Function, ruleTable?: object }} [opts]
819
+ * @returns {{ behavior: string|null, additionalContext: string|null }|null}
820
+ */
821
+ export function buildIntentDirective(ruleId, prompt, opts = {}) {
822
+ const behaviorKey = behaviorKeyForRuleId(ruleId);
823
+ if (!behaviorKey) return { behavior: null, additionalContext: null };
824
+ const behavior = behaviorForKey(behaviorKey);
825
+ const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
826
+ const entry = resolveBehaviorRules({ ruleTable: table }).find((e) => e.behavior.key === behaviorKey);
827
+ if (!entry) {
828
+ // The classifier matched a row this catalog knows but the plugin router would never
829
+ // have fired (not a userpromptsubmit `prime` row). The kill switches still apply to the
830
+ // BEHAVIOR; the directive does not — the gate falls back to the row's raw inject.
831
+ const row = (Array.isArray(table?.rules) ? table.rules : []).find((r) => r?.rule === ruleId);
832
+ if (row && !isEntryEligible({ behavior, rule: row }, prompt, opts.repoRoot, opts)) return null;
833
+ return { behavior: behaviorKey, additionalContext: null };
834
+ }
835
+ if (!isEntryEligible(entry, prompt, opts.repoRoot, opts)) return null;
836
+ const directive = entry.behavior.transform(entry.rule.inject, opts.repoRoot, prompt, opts);
837
+ if (!directive) return null;
838
+ return { behavior: behaviorKey, additionalContext: withMatchMarker(behaviorKey, directive) };
839
+ }