heyspex 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +181 -15
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +177 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +21 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +312 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/install-copilot-hooks.mjs +288 -0
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
|
@@ -1,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
|
-
// ──
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
-
'
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
export
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// (
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
return
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* @
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
*
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
+
}
|