heyspex 0.2.0 → 0.2.1

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +163 -11
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +162 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +20 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +309 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/lib/console-url-resolver.mjs +3 -2
  72. package/scripts/lib/customer-kit.mjs +327 -10
  73. package/scripts/lib/dev-mode.mjs +7 -6
  74. package/scripts/lib/directive-obligations.mjs +349 -0
  75. package/scripts/lib/hook-dispositions.mjs +210 -0
  76. package/scripts/lib/hook-vault-header.mjs +30 -0
  77. package/scripts/lib/hs-source-branch.mjs +196 -0
  78. package/scripts/lib/hs-source-log.mjs +2 -1
  79. package/scripts/lib/hs-source-preflight.mjs +18 -4
  80. package/scripts/lib/hs-source-push.mjs +386 -356
  81. package/scripts/lib/window-placement.mjs +109 -0
  82. package/spexharness/src/adapters/claude-code.mjs +11 -3
  83. package/spexharness/src/adapters/copilot.mjs +236 -0
  84. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  85. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  86. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  87. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  88. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  89. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  90. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  91. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  93. package/spexharness/src/intent-classifier.mjs +4 -2
  94. package/spexharness/src/intent-directive.mjs +626 -541
  95. package/spexharness/src/rule-table.mjs +31 -1
  96. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  97. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  98. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  99. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  100. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  101. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  102. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  103. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  104. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  105. package/vendor/heyspex-mcp/cli.mjs +7 -1
  106. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  107. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  108. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  109. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  110. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  111. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  112. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  113. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  114. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  115. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  116. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  117. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  118. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  119. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  120. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  121. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  122. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  123. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  124. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  125. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  126. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  127. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  128. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  129. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  130. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  131. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  132. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  133. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  134. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  135. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  136. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  137. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  138. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  139. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  140. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  141. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  142. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  143. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  144. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  145. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -1,541 +1,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
- // 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
- // ── injected-content guards ──────────────────────────────────────────────────
86
- // Sentinels that mark harness-injected content arriving through the UserPromptSubmit
87
- // channel (background-task notifications, system reminders, slash-command envelopes). They
88
- // can contain a rule's trigger phrase verbatim but are NOT a genuine human prompt, so a rule
89
- // with `ignoreInjectedContent` must not fire on them. scripts/claude-hooks/_guardrail-loader.mjs
90
- // carries the plugin-side copy of this list; ./intent-directive.test.mjs asserts the two are
91
- // equal so an edit to one cannot silently leave the other behind.
92
- export const INJECTED_CONTENT_SENTINELS = [
93
- '<task-notification>',
94
- '<system-reminder>',
95
- '[SYSTEM NOTIFICATION',
96
- '<command-message>',
97
- '<command-name>',
98
- '<local-command-stdout>',
99
- ];
100
-
101
- /** Contains-anywhere guard — the research hook's original (broad) check. */
102
- export function containsInjectedContent(text) {
103
- if (typeof text !== 'string') return false;
104
- return INJECTED_CONTENT_SENTINELS.some((s) => text.includes(s));
105
- }
106
-
107
- /**
108
- * Stricter, false-positive-safe variant: is the message a genuine harness ENVELOPE (a
109
- * task-notification / system-reminder / slash-command envelope delivered AS the prompt),
110
- * rather than user text that merely QUOTES a sentinel? A real envelope leads with its tag;
111
- * a user pasting a transcript or terminal logs leads with their own words.
112
- */
113
- export function startsWithInjectedContent(text) {
114
- if (typeof text !== 'string') return false;
115
- const head = text.trimStart();
116
- return INJECTED_CONTENT_SENTINELS.some((s) => head.startsWith(s));
117
- }
118
-
119
- // ── config reads (injectable) ────────────────────────────────────────────────
120
-
121
- /**
122
- * The default settings reader: vendor/heyspex-mcp/config-settings.mjs's `getConfigLeaf`
123
- * against `<repoRoot>/.heyspex/config.yaml` — the same indent-scoped `key:` scan the plugin
124
- * loader's `readConfigLeafRaw` performs, returning `{ found, raw, value }`. With no
125
- * `repoRoot` the file is read from the vault root (`defaultConfigRoot()`).
126
- */
127
- export function defaultGetSetting(repoRoot, key) {
128
- return getConfigLeaf(repoRoot ?? defaultConfigRoot(), key);
129
- }
130
-
131
- /**
132
- * Read one config leaf as the loader's `readConfigLeafRaw` would: the trimmed raw scalar,
133
- * or `undefined` when absent/unreadable. Accepts every reader shape the header documents.
134
- * FAIL-SAFE: a throwing reader reads as "absent".
135
- */
136
- export function readConfigLeafRaw(configKey, repoRoot, opts = {}) {
137
- if (!configKey || typeof configKey !== 'string') return undefined;
138
- const getSetting = typeof opts.getSetting === 'function' ? opts.getSetting : defaultGetSetting;
139
- let leaf;
140
- try {
141
- leaf = getSetting(repoRoot ?? defaultConfigRoot(), configKey);
142
- } catch {
143
- return undefined;
144
- }
145
- if (leaf == null) return undefined;
146
- if (typeof leaf === 'string') return leaf.trim();
147
- if (typeof leaf === 'object') {
148
- if (leaf.found === false) return undefined;
149
- if (typeof leaf.raw === 'string') return leaf.raw.trim();
150
- if (leaf.value === undefined || leaf.value === null) return undefined;
151
- return String(leaf.value).trim();
152
- }
153
- return String(leaf).trim();
154
- }
155
-
156
- /**
157
- * Is a dotted `config_key` ON? Semantics match the loader's `isConfigKeyEnabled`: ON unless
158
- * the leaf is explicitly `false`; absent / unreadable / no key at all default to ON.
159
- */
160
- export function isConfigKeyEnabled(configKey, repoRoot, opts = {}) {
161
- if (!configKey || typeof configKey !== 'string') return true;
162
- const raw = readConfigLeafRaw(configKey, repoRoot, opts);
163
- if (raw === undefined) return true;
164
- return raw.toLowerCase() !== 'false';
165
- }
166
-
167
- /** Enabled-check straight off a rule row (its `configKey`). */
168
- export function isRuleEnabled(rule, repoRoot, opts = {}) {
169
- return isConfigKeyEnabled(rule && rule.configKey, repoRoot, opts);
170
- }
171
-
172
- /**
173
- * Resolve the CONFIRM MODE for an autonomous agent behavior — the two-layer permission
174
- * model the owner specified (2026-07-11):
175
- * 'auto' = act immediately, user can undo/discard afterward (forgiveness)
176
- * 'ask' = ask the user for confirmation before acting (permission)
177
- *
178
- * Resolution order (per-behavior override wins over the global default):
179
- * 1. agent_behaviors.<behaviorKey>.ask_first
180
- * true|yes|ask → 'ask' · false|no|auto → 'auto' · null/absent → inherit
181
- * 2. (inherit) global agent_behaviors.ask_forgiveness_not_permission
182
- * true|yes|absent → 'auto' (forgiveness — the owner's default)
183
- * false|no → 'ask'
184
- * FAIL-SAFE: any unreadable config → 'auto' (the stated default).
185
- */
186
- export function resolveConfirmMode(behaviorKey, repoRoot, opts = {}) {
187
- if (behaviorKey && typeof behaviorKey === 'string') {
188
- const per = readConfigLeafRaw(`agent_behaviors.${behaviorKey}.ask_first`, repoRoot, opts);
189
- if (per !== undefined) {
190
- const v = per.toLowerCase();
191
- if (v === 'true' || v === 'yes' || v === 'ask') return 'ask';
192
- if (v === 'false' || v === 'no' || v === 'auto') return 'auto';
193
- // 'null' / '' / anything else → fall through to the global default (inherit)
194
- }
195
- }
196
- const global = readConfigLeafRaw('agent_behaviors.ask_forgiveness_not_permission', repoRoot, opts);
197
- if (global !== undefined) {
198
- const g = global.toLowerCase();
199
- if (g === 'false' || g === 'no') return 'ask'; // forgiveness OFF → ask first
200
- }
201
- return 'auto'; // forgiveness ON / absent → act, don't ask
202
- }
203
-
204
- // ── capture-on-idea enrichment ───────────────────────────────────────────────
205
-
206
- // Capture's forgiveness/permission directives — carried over verbatim from the retired
207
- // capture-trigger.mjs shim (HS-IYU1, deleted after burn-in) so the emitted inject stays
208
- // byte-identical.
209
- export const CAPTURE_MODE_DIRECTIVES = {
210
- auto:
211
- "FORGIVENESS MODE (the owner's default — act, don't ask): START THE CAPTURE FLOW " +
212
- 'IMMEDIATELY. In ONE short line, tell the user you are capturing this as an idea ' +
213
- 'and they can say "discard" (or "no, just do it") to skip — then begin. Do NOT ask ' +
214
- 'permission first.',
215
- ask:
216
- 'PERMISSION MODE: ASK FIRST. Reply with ONE line — "Want me to capture this as an ' +
217
- 'idea? (yes / no, just do it)" — and run the capture steps below ONLY if the user ' +
218
- 'says yes. If they decline, just do the task.',
219
- };
220
-
221
- /**
222
- * capture-on-idea's full transform: substitute `{{CONFIRM_MODE}}` (or prepend the mode
223
- * directive when the token is absent), then append the routing and kind hints.
224
- */
225
- export function applyCaptureConfirmMode(inject, repoRoot, prompt, opts = {}) {
226
- const mode = resolveConfirmMode('capture_on_idea', repoRoot, opts);
227
- const directive = CAPTURE_MODE_DIRECTIVES[mode] || CAPTURE_MODE_DIRECTIVES.auto;
228
- const base = inject.includes(MODE_TOKEN) ? inject.split(MODE_TOKEN).join(directive) : `${directive}\n\n${inject}`;
229
- return appendCaptureKindHint(appendCaptureRoutingHint(base, repoRoot, opts), prompt);
230
- }
231
-
232
- // Capture kind hint ([[HS-QOBQ-S4JZ-DIAG-capture-kind-is-never-classified-every]]):
233
- // every capture materializes at `kind: idea` and nothing ever inspected the
234
- // triggering message to check whether that default is right. This is the
235
- // cheapest rung (lexical, no model call) — a strong bug-language match in the
236
- // SAME message the capture-on-idea rule already fired on. Suggest-only: the
237
- // agent still must offer it and get a yes before calling heyspex_update_capture
238
- // with kind ([[HS-5HYS]] elicitation — suggest, never auto-apply), matching the
239
- // existing heyspex_update_capture `kind` arg description verbatim.
240
- export function appendCaptureKindHint(inject, prompt) {
241
- const guess = guessCaptureKindFromText(prompt);
242
- if (!guess.kind) return inject;
243
- const hint =
244
- `CAPTURE KIND HINT (lexical, suggest-only): this message reads like kind: ${guess.kind} ` +
245
- `(matched "${guess.matched}"), not the default idea. After capturing, offer the ` +
246
- `reclassification in your reply (e.g. "this reads like a bug-fix capture — reclassify?") ` +
247
- `and call heyspex_update_capture with kind: "${guess.kind}" ONLY on the user's yes — never ` +
248
- `auto-apply it.`;
249
- return `${inject}\n\n${hint}`;
250
- }
251
-
252
- // Capture model-routing + thread-split hint ([[HS-6YOA-HPR6-RES-capture-to-research-auto-model-routing]],
253
- // owner ruling 2026-08-11). A best-effort HINT appended to the capture inject, same shape as
254
- // appendResearchSourceHint below — the hook cannot know the running session's actual current
255
- // model (that's not in the UserPromptSubmit payload), so it can only tell the agent what's
256
- // CONFIGURED and trust the agent (which does know its own model, per its own system prompt) to
257
- // judge whether a switch/delegate/ask actually applies. Neither mode is ever a silent, live
258
- // model switch — that is not possible from a hook or from the agent itself (research doc's
259
- // Platform capability check: `/model` is a user-issued local command; no MCP tool flips it).
260
- export function appendCaptureRoutingHint(inject, repoRoot, opts = {}) {
261
- const defaultModel = readConfigLeafRaw('agent_behaviors.capture_on_idea.default_model', repoRoot, opts);
262
- const switchMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.model_switch_mode', repoRoot, opts) || 'ask';
263
- const splitMode = readConfigLeafRaw('agent_behaviors.capture_on_idea.thread_split_mode', repoRoot, opts) || 'ask';
264
-
265
- const parts = [inject];
266
-
267
- if (defaultModel && switchMode !== 'off') {
268
- const modeInstruction =
269
- switchMode === 'silent_delegate'
270
- ? 'CONFIGURED MODE: silent_delegate — do NOT interrupt the user with a model question. ' +
271
- `If this is the FIRST/ONLY capture turn in this thread (not a continuation of an ` +
272
- `already-open capture) and you are not already running as ${defaultModel}, hand the ` +
273
- 'mechanical capture steps (verbatim quote, core idea, summary, ground=expand) to a ' +
274
- `one-shot Agent(prompt: <the capture instructions>, model: "${defaultModel}") subagent ` +
275
- 'instead of running them on this session — then relay its result. Never switch this ' +
276
- "running session's own model; that is not possible and is not what this mode means."
277
- : 'CONFIGURED MODE: ask — if this is the FIRST/ONLY capture turn in this thread (not a ' +
278
- `continuation of an already-open capture) and you are not already running as ` +
279
- `${defaultModel}, add ONE short line to your capture-start message offering a switch — ` +
280
- `e.g. "(captures default to ${defaultModel} — say switch to run /model ${defaultModel} ` +
281
- 'first)" — then proceed with the capture flow regardless of the answer; never block on ' +
282
- 'it. A mid-thread continuation never gets this offer — it stays on the thread\'s model.';
283
- parts.push(
284
- `CAPTURE MODEL ROUTING (default_model=${defaultModel}, model_switch_mode=${switchMode}): ${modeInstruction}`,
285
- );
286
- }
287
-
288
- const splitInstruction =
289
- splitMode === 'auto'
290
- ? 'CONFIGURED thread_split_mode=auto: when new mid-thread prose is judged NOT a continuation ' +
291
- 'of the on-deck capture, decide on your own judgment — no AskUserQuestion — whether it ' +
292
- 'SPLITS (a new HS-CAP-* that still benefits from this thread\'s context: continue it via ' +
293
- 'Agent(subagent_type:"fork") so full context carries over) or SPAWNS (unrelated enough that ' +
294
- 'a fresh Agent() with no inherited context is cleaner). State which you picked and why in ' +
295
- 'one line.'
296
- : 'CONFIGURED thread_split_mode=ask: when new mid-thread prose is judged NOT a continuation of ' +
297
- 'the on-deck capture, and you are NOT confident whether it benefits from this thread\'s ' +
298
- 'existing context, use AskUserQuestion with exactly two options — split (new capture id, ' +
299
- 'keep this thread\'s context via Agent(subagent_type:"fork")) vs. spawn (new capture id, ' +
300
- 'fresh context via a plain Agent() call) — rather than guessing. Skip the ask only when ' +
301
- 'you ARE confident: a clearly-related new idea still splits, a clearly-unrelated one still ' +
302
- 'spawns, with no prompt.';
303
- parts.push(`CAPTURE THREAD SPLIT/SPAWN: ${splitInstruction}`);
304
-
305
- return parts.join('\n\n');
306
- }
307
-
308
- // Research-source hint — fix for [[HS-LFUH-DIAG-W1YE]] root-cause part 1 ("not injected").
309
- // The research inject above tells the agent WHICH RUNGS to climb and WHICH MODEL to spawn
310
- // (default_agent), but never named the configured search/scrape BACKEND
311
- // (agent_behaviors.research.default_source) — so a configured `firecrawl-self-hosted` default
312
- // was silently unreachable on the interactive path; the agent had no way to even know it existed.
313
- // This is deliberately a best-effort HINT appended to the existing inject text, not a
314
- // replacement: it is a snapshot taken at prompt-submit time, so it can go stale mid-session
315
- // (a lease can be granted/refused, a box can go up/down). The authoritative, can't-go-stale
316
- // source of truth is the on-demand `heyspex_resolve_research_plan` MCP tool this hint points the
317
- // agent at — per the research doc's recommendation, "hook injection is a best-effort hint; the
318
- // on-demand tool is the source of truth", not either/or.
319
- export function appendResearchSourceHint(inject, repoRoot, opts = {}) {
320
- const defaultSource = readConfigLeafRaw('agent_behaviors.research.default_source', repoRoot, opts);
321
- const defaultAgent = readConfigLeafRaw('agent_behaviors.research.default_agent', repoRoot, opts);
322
- if (!defaultSource && !defaultAgent) return inject; // nothing configured — nothing to hint at
323
- const hint =
324
- 'CONFIGURED RESEARCH RESOURCE (best-effort hint, a snapshot — re-confirm live via ' +
325
- 'heyspex_resolve_research_plan() before RUNG 0 `sequential` search/scrape, since availability ' +
326
- `can change mid-session): default_source=${defaultSource ?? '(unset)'}, ` +
327
- `default_agent=${defaultAgent ?? '(unset)'}. If default_source names a Firecrawl backend ` +
328
- '(firecrawl-self-hosted | firecrawl-hosted), call heyspex_firecrawl_research({ mode: "search"|"scrape", ' +
329
- 'query|url }) for rung 0 instead of native WebSearch/WebFetch — it wraps that SAME configured ' +
330
- 'backend and honors its existing availability gates (the Ollama-XOR-Firecrawl lease / hosted credit ' +
331
- 'gate). If its result reports downgraded:true, tell the user plainly that the configured backend was ' +
332
- 'unavailable and you fell back to web-native — NEVER substitute silently.';
333
- return `${inject}\n\n${hint}`;
334
- }
335
-
336
- // ── the behavior catalog ─────────────────────────────────────────────────────
337
-
338
- // The classifier behavior catalog. `ruleId` ties a behavior to its registry rule;
339
- // `description` is the semantic-classification instruction (the router's self-check block
340
- // and opt-in model mode read it from here — this catalog is the single source of behavior
341
- // descriptions); `envOff` is the per-behavior kill switch; `guard` is the injected-content
342
- // check that behavior used historically; `transform(inject, repoRoot, prompt, opts)` builds
343
- // the final inject from the rule's inject text (`opts` carries the injected reads — see the
344
- // header). BEHAVIOR_ORDER (array order) is the deterministic lexical tiebreak.
345
- export const CLASSIFIER_BEHAVIORS = [
346
- {
347
- key: 'capture_on_idea',
348
- ruleId: 'capture-on-idea',
349
- envOff: 'HEYSPEX_CAPTURE_TRIGGER_OFF',
350
- guard: startsWithInjectedContent,
351
- transform: (inject, repoRoot, prompt, opts) => applyCaptureConfirmMode(inject, repoRoot, prompt, opts),
352
- description:
353
- '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.',
354
- },
355
- {
356
- key: 'research_before_decision',
357
- ruleId: 'research-before-decision',
358
- envOff: 'HEYSPEX_RESEARCH_TRIGGER_OFF',
359
- guard: containsInjectedContent,
360
- transform: (inject, repoRoot, _prompt, opts) => appendResearchSourceHint(inject, repoRoot, opts),
361
- description:
362
- '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").',
363
- },
364
- {
365
- key: 'settings_intent',
366
- ruleId: 'settings-intent',
367
- envOff: 'HEYSPEX_SETTINGS_INTENT_OFF',
368
- guard: startsWithInjectedContent,
369
- transform: (inject) => inject,
370
- description:
371
- '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.',
372
- },
373
- {
374
- key: 'defer_intent',
375
- ruleId: 'defer-intent',
376
- envOff: 'HEYSPEX_DEFER_INTENT_OFF',
377
- guard: startsWithInjectedContent,
378
- transform: (inject) => inject,
379
- description:
380
- '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.',
381
- },
382
- {
383
- key: 'spec_reference_lookup',
384
- ruleId: 'spec-reference-lookup',
385
- envOff: 'HEYSPEX_SPEC_REFERENCE_LOOKUP_OFF',
386
- guard: startsWithInjectedContent,
387
- transform: (inject) => inject,
388
- description:
389
- '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.',
390
- },
391
- ];
392
-
393
- /**
394
- * Map an engine rule id (hyphenated, e.g. 'capture-on-idea') back to the router's
395
- * behavior key (underscored, e.g. 'capture_on_idea'). Returns null for an id no
396
- * behavior claims (fail-safe: the engine cutover treats that as no match).
397
- */
398
- export function behaviorKeyForRuleId(ruleId) {
399
- const found = CLASSIFIER_BEHAVIORS.find((b) => b.ruleId === ruleId);
400
- return found ? found.key : null;
401
- }
402
-
403
- /** The catalog entry for a behavior key, or null. */
404
- export function behaviorForKey(behaviorKey) {
405
- return CLASSIFIER_BEHAVIORS.find((b) => b.key === behaviorKey) ?? null;
406
- }
407
-
408
- /**
409
- * Resolve the compiled rule for each classifier behavior from the rule table. Returns
410
- * `[{ behavior, rule }]` for behaviors whose rule exists on the userpromptsubmit surface at
411
- * {@link DIRECTIVE_SEVERITY} and carries an inject — the same filter the plugin router's
412
- * `rulesForSurface(guardrails, SURFACE, 'prime')` applies. FAIL-SAFE: a missing rule
413
- * simply drops that behavior.
414
- *
415
- * @param {{ repoRoot?: string, ruleTable?: { rules?: object[] } }} [opts]
416
- */
417
- export function resolveBehaviorRules(opts = {}) {
418
- const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
419
- const rules = Array.isArray(table?.rules) ? table.rules : [];
420
- const byId = new Map();
421
- for (const r of rules) {
422
- if (!r || !Array.isArray(r.surfaces) || !r.surfaces.includes(SURFACE)) continue;
423
- if (r.severity !== DIRECTIVE_SEVERITY) continue;
424
- byId.set(r.rule, r);
425
- }
426
- const resolved = [];
427
- for (const behavior of CLASSIFIER_BEHAVIORS) {
428
- const rule = byId.get(behavior.ruleId);
429
- if (!rule || !rule.inject) continue;
430
- resolved.push({ behavior, rule });
431
- }
432
- return resolved;
433
- }
434
-
435
- /**
436
- * Is this resolved `{ behavior, rule }` entry eligible to fire on this prompt right now?
437
- * (env kill switch off, injected-content guard passes, config_key enabled.) Behaviors
438
- * that are ineligible fire on no path — matching the old per-hook early-returns exactly.
439
- *
440
- * @param {{ behavior: object, rule: object }} entry
441
- * @param {string} prompt
442
- * @param {string} [repoRoot]
443
- * @param {{ env?: object, getSetting?: Function }} [opts]
444
- */
445
- export function isEntryEligible(entry, prompt, repoRoot, opts = {}) {
446
- const { behavior, rule } = entry;
447
- const env = opts.env ?? process.env;
448
- if (String(env?.[behavior.envOff] ?? '') === '1') return false;
449
- if (rule.ignoreInjectedContent && behavior.guard(prompt)) return false;
450
- if (!isRuleEnabled(rule, repoRoot, opts)) return false;
451
- return true;
452
- }
453
-
454
- /**
455
- * Is this ONE behavior eligible to fire on this prompt right now? (env kill switch off,
456
- * config_key enabled, injected-content guard passes). The engine gate uses this to re-apply
457
- * the same gating the plugin's lexical sweep applied per-entry, since
458
- * ../intent-classifier.mjs's `classifyPrompt()` is lexical-pattern-only and knows nothing
459
- * about env switches, config toggles, or the injected-content guard. Returns false for an
460
- * unknown behavior key.
461
- *
462
- * @param {string} behaviorKey
463
- * @param {string} prompt
464
- * @param {string} [repoRoot]
465
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
466
- */
467
- export function isBehaviorEligible(behaviorKey, prompt, repoRoot, opts = {}) {
468
- const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
469
- if (!entry) return false;
470
- return isEntryEligible(entry, prompt, repoRoot, opts);
471
- }
472
-
473
- /**
474
- * Resolve ONE behavior's full directive (the rule's inject run through the behavior's
475
- * transform), WITHOUT the marker. Returns null when the key is unknown / its rule is gone.
476
- *
477
- * @param {string} behaviorKey
478
- * @param {string} [repoRoot]
479
- * @param {string} [prompt]
480
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
481
- */
482
- export function resolveDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
483
- const entry = resolveBehaviorRules({ repoRoot, ruleTable: opts.ruleTable }).find((e) => e.behavior.key === behaviorKey);
484
- if (!entry) return null;
485
- return entry.behavior.transform(entry.rule.inject, repoRoot, prompt, opts);
486
- }
487
-
488
- /**
489
- * The confirmed-fire seam (HS-5ISS): {@link resolveDirective} plus the SAME loud marker
490
- * the plugin's Layer 1 lexical fire prepends — so a behavior the engine module
491
- * (../intent-classifier.mjs) matched, once confirmed eligible, emits a byte-identical
492
- * directive to the plugin's lexical path. Returns null on an unknown/gone behavior
493
- * (fail-safe: no injection).
494
- *
495
- * @param {string} behaviorKey
496
- * @param {string} [repoRoot]
497
- * @param {string} [prompt]
498
- * @param {{ env?: object, getSetting?: Function, ruleTable?: object }} [opts]
499
- */
500
- export function buildConfirmedDirective(behaviorKey, repoRoot, prompt = '', opts = {}) {
501
- const directive = resolveDirective(behaviorKey, repoRoot, prompt, opts);
502
- if (!directive) return null;
503
- return withConfirmedMatchMarker(directive);
504
- }
505
-
506
- /**
507
- * The engine gate's one-shot: from a rule id the classifier matched, decide whether the
508
- * behavior may fire and, if so, build the plugin's confirmed directive for it.
509
- *
510
- * Outcomes:
511
- * - `null` — the behavior is known but NOT eligible right now (env kill switch, config
512
- * toggle, or the injected-content guard). The gate must inject nothing.
513
- * - `{ behavior: null, additionalContext: null }` — no catalog behavior claims this rule
514
- * id, or its rule is not a userpromptsubmit `prime` row the plugin router would have
515
- * fired. The gate falls back to the rule's raw inject (the pre-HS-N01T behavior).
516
- * - `{ behavior, additionalContext }` — marker + directive, byte-identical to the plugin.
517
- *
518
- * @param {string} ruleId
519
- * @param {string} prompt
520
- * @param {{ repoRoot?: string, env?: object, getSetting?: Function, ruleTable?: object }} [opts]
521
- * @returns {{ behavior: string|null, additionalContext: string|null }|null}
522
- */
523
- export function buildIntentDirective(ruleId, prompt, opts = {}) {
524
- const behaviorKey = behaviorKeyForRuleId(ruleId);
525
- if (!behaviorKey) return { behavior: null, additionalContext: null };
526
- const behavior = behaviorForKey(behaviorKey);
527
- const table = opts.ruleTable ?? loadRuleTable({ vaultRoot: opts.repoRoot ?? DEFAULT_APP_ROOT });
528
- const entry = resolveBehaviorRules({ ruleTable: table }).find((e) => e.behavior.key === behaviorKey);
529
- if (!entry) {
530
- // The classifier matched a row this catalog knows but the plugin router would never
531
- // have fired (not a userpromptsubmit `prime` row). The kill switches still apply to the
532
- // BEHAVIOR; the directive does not — the gate falls back to the row's raw inject.
533
- const row = (Array.isArray(table?.rules) ? table.rules : []).find((r) => r?.rule === ruleId);
534
- if (row && !isEntryEligible({ behavior, rule: row }, prompt, opts.repoRoot, opts)) return null;
535
- return { behavior: behaviorKey, additionalContext: null };
536
- }
537
- if (!isEntryEligible(entry, prompt, opts.repoRoot, opts)) return null;
538
- const directive = entry.behavior.transform(entry.rule.inject, opts.repoRoot, prompt, opts);
539
- if (!directive) return null;
540
- return { behavior: behaviorKey, additionalContext: withConfirmedMatchMarker(directive) };
541
- }
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
+ }