heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * HeySpex fleet-queue MCP tools — agent-facing FACE 1 of the fleet-queue
3
+ * Neon port ([[HS-60Z1-EPIC]] Phase 5). Thin wrappers around the bearer-authed
4
+ * queue API landing in Phase 3:
5
+ *
6
+ * POST /api/fleet/queue { lane, kind, payload?, requestedBy? } -> { id, position }
7
+ * GET /api/fleet/queue?lane=&status=&limit= -> { items }
8
+ * GET /api/fleet/queue/peek?lane= -> { item }
9
+ * POST /api/fleet/queue/:taskId/claim { machineKey } -> { ok, reclaimed } | 409/404 { ok:false, reason }
10
+ * POST /api/fleet/queue/:taskId/complete { machineKey, result? } -> { accepted }
11
+ * POST /api/fleet/queue/:taskId/fail { machineKey, reason? } -> { accepted }
12
+ *
13
+ * Pure-logic module: each `executeFleetQueueXxx` takes an injected
14
+ * `consoleFetch` (the shared factory in console-fetch.mjs; idiom mirrored from
15
+ * procedure-tools.mjs / submit-for-review.mjs) so tests never touch a real
16
+ * server. cli.mjs wires the real consoleFetch + `resolveFleetToken()` and
17
+ * mounts the four tools below as heyspex_fleet_queue_{submit,claim,complete,list}.
18
+ *
19
+ * Auth: the queue API requires `Authorization: Bearer <fleet token>` — the
20
+ * same session token a companion presents on `/api/fleet/join` pull calls
21
+ * (see spex-console/src/lib/fleet/fleet-bearer-auth.ts). `resolveFleetToken`
22
+ * mirrors that file's resolution order (env -> ~/.heyspex/credentials.yaml ->
23
+ * Windows registry) so an agent already holding a joined machine's token can
24
+ * call these tools with no extra setup.
25
+ */
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+ import { readFileSync } from 'node:fs';
29
+ import { homedir, platform } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ /**
33
+ * Resolve the caller's fleet bearer token. Mirror of
34
+ * `resolveExpectedFleetToken` in spex-console/src/lib/fleet/fleet-bearer-auth.ts
35
+ * (kept in sync deliberately — this is the token that route checks).
36
+ * Returns null when no token can be resolved; callers still issue the
37
+ * request (the API 401s, which surfaces as a clean error, not a throw).
38
+ */
39
+ export function resolveFleetToken() {
40
+ const env = process.env.HEYSPEX_FLEET_TOKEN;
41
+ if (env && env.trim()) return env.trim();
42
+ try {
43
+ const yaml = readFileSync(join(homedir(), '.heyspex', 'credentials.yaml'), 'utf8');
44
+ const clerk = yaml.match(/^clerk:\s*$([\s\S]*?)(?=^\S|\s*$(?![\s\S]))/m)?.[1] ?? yaml;
45
+ const m = clerk.match(/^\s*companion_token:\s*["']?([^"'\r\n#]+)/m);
46
+ if (m?.[1]?.trim()) return m[1].trim();
47
+ } catch {
48
+ // fall through to the registry fallback
49
+ }
50
+ if (platform() === 'win32') {
51
+ try {
52
+ const out = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'HEYSPEX_FLEET_TOKEN'], {
53
+ encoding: 'utf8',
54
+ });
55
+ const m = out.match(/HEYSPEX_FLEET_TOKEN\s+REG_[A-Z_]+\s+(.+)/);
56
+ return m?.[1]?.trim() || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function bearerHeaders(fleetToken) {
65
+ return fleetToken ? { Authorization: `Bearer ${fleetToken}` } : {};
66
+ }
67
+
68
+ /**
69
+ * HS-FS7X (interim only — full fix is the unstarted [[HS-XOCK-60Z1-EPIC-fleet-work-queue-in-heyspex]]
70
+ * child tasks). This route writes to the Neon-backed `/api/fleet/queue`, but
71
+ * `dev-factory/lib/fleet-queue-runner.mjs` — the only thing that actually
72
+ * claims and executes queued work — still reads exclusively from the legacy
73
+ * file-backed queue (`dev-factory/lib/fleet-queue.mjs`). A submit through this
74
+ * tool therefore succeeds and is durably recorded, but nothing will ever pick
75
+ * it up. Every success return carries this warning so a caller cannot mistake
76
+ * "accepted" for "will run" until the runner is repointed at Neon.
77
+ */
78
+ export const NEON_QUEUE_NO_RUNNER_WARNING =
79
+ 'Neon fleet queue has NO runner reading it yet — dev-factory/lib/fleet-queue-runner.mjs ' +
80
+ 'still only reads the legacy JSONL file queue. This item is durably recorded in Neon but ' +
81
+ 'will NOT execute until HS-XOCK-60Z1 (fleet-queue Neon port) lands. Track: ' +
82
+ '[[HS-XOCK-60Z1-EPIC-fleet-work-queue-in-heyspex]].';
83
+
84
+ /**
85
+ * `heyspex_fleet_queue_submit` — POST /api/fleet/queue. `kind` defaults to
86
+ * "task" when omitted (the API requires a kind string).
87
+ *
88
+ * @param {{ consoleFetch: Function, args?: Record<string, unknown>, fleetToken?: string|null }} ctx
89
+ */
90
+ export async function executeFleetQueueSubmit({ consoleFetch, args = {}, fleetToken = null }) {
91
+ const lane = typeof args.lane === 'string' ? args.lane.trim() : '';
92
+ if (!lane) {
93
+ return { error: 'INVALID_ARGS', message: 'lane is required' };
94
+ }
95
+ const kind = typeof args.kind === 'string' && args.kind.trim() ? args.kind.trim() : 'task';
96
+
97
+ const body = {
98
+ lane,
99
+ kind,
100
+ payload: args.payload ?? {},
101
+ requestedBy: args.requested_by ?? null,
102
+ };
103
+
104
+ const result = await consoleFetch('/api/fleet/queue', {
105
+ method: 'POST',
106
+ headers: bearerHeaders(fleetToken),
107
+ body,
108
+ });
109
+
110
+ if (!result.ok) {
111
+ return {
112
+ error: 'SUBMIT_FAILED',
113
+ message: `POST /api/fleet/queue returned ${result.status}`,
114
+ status: result.status,
115
+ console_response: result.body,
116
+ };
117
+ }
118
+
119
+ return {
120
+ id: result.body?.id ?? null,
121
+ position: result.body?.position ?? null,
122
+ lane,
123
+ warning: NEON_QUEUE_NO_RUNNER_WARNING,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * `heyspex_fleet_queue_claim` — POST /api/fleet/queue/:taskId/claim. A 404
129
+ * (unknown id) or 409 (double-claim / terminal state) is a normal outcome for
130
+ * a busy machine-gated resource, so it is surfaced as `{ ok:false, reason }`
131
+ * rather than an `error` (which would read as an unexpected failure).
132
+ *
133
+ * @param {{ consoleFetch: Function, args?: Record<string, unknown>, fleetToken?: string|null }} ctx
134
+ */
135
+ export async function executeFleetQueueClaim({ consoleFetch, args = {}, fleetToken = null }) {
136
+ const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : '';
137
+ const machineKey = typeof args.machine_key === 'string' ? args.machine_key.trim() : '';
138
+ if (!taskId) {
139
+ return { error: 'INVALID_ARGS', message: 'task_id is required' };
140
+ }
141
+ if (!machineKey) {
142
+ return { error: 'INVALID_ARGS', message: 'machine_key is required' };
143
+ }
144
+
145
+ const result = await consoleFetch(`/api/fleet/queue/${encodeURIComponent(taskId)}/claim`, {
146
+ method: 'POST',
147
+ headers: bearerHeaders(fleetToken),
148
+ body: { machineKey },
149
+ });
150
+
151
+ if ((result.status === 404 || result.status === 409) && result.body && typeof result.body === 'object') {
152
+ return {
153
+ ok: false,
154
+ reason: result.body.reason ?? (result.status === 404 ? 'unknown id' : 'claim_conflict'),
155
+ task_id: taskId,
156
+ status: result.status,
157
+ };
158
+ }
159
+
160
+ if (!result.ok) {
161
+ return {
162
+ error: 'CLAIM_FAILED',
163
+ message: `POST /api/fleet/queue/${taskId}/claim returned ${result.status}`,
164
+ status: result.status,
165
+ console_response: result.body,
166
+ };
167
+ }
168
+
169
+ return {
170
+ ok: true,
171
+ reclaimed: result.body?.reclaimed ?? false,
172
+ task_id: taskId,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * `heyspex_fleet_queue_complete` — POST /api/fleet/queue/:taskId/complete.
178
+ *
179
+ * @param {{ consoleFetch: Function, args?: Record<string, unknown>, fleetToken?: string|null }} ctx
180
+ */
181
+ export async function executeFleetQueueComplete({ consoleFetch, args = {}, fleetToken = null }) {
182
+ const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : '';
183
+ const machineKey = typeof args.machine_key === 'string' ? args.machine_key.trim() : '';
184
+ if (!taskId) {
185
+ return { error: 'INVALID_ARGS', message: 'task_id is required' };
186
+ }
187
+ if (!machineKey) {
188
+ return { error: 'INVALID_ARGS', message: 'machine_key is required' };
189
+ }
190
+
191
+ const body = { machineKey };
192
+ if (args.result !== undefined) body.result = args.result;
193
+
194
+ const result = await consoleFetch(`/api/fleet/queue/${encodeURIComponent(taskId)}/complete`, {
195
+ method: 'POST',
196
+ headers: bearerHeaders(fleetToken),
197
+ body,
198
+ });
199
+
200
+ if (!result.ok) {
201
+ return {
202
+ error: 'COMPLETE_FAILED',
203
+ message: `POST /api/fleet/queue/${taskId}/complete returned ${result.status}`,
204
+ status: result.status,
205
+ console_response: result.body,
206
+ };
207
+ }
208
+
209
+ return { accepted: result.body?.accepted ?? true, task_id: taskId };
210
+ }
211
+
212
+ /**
213
+ * `heyspex_fleet_queue_list` — GET /api/fleet/queue?lane=&status=&limit=. The
214
+ * visibility read: queue depth, in-flight items, and history.
215
+ *
216
+ * @param {{ consoleFetch: Function, args?: Record<string, unknown>, fleetToken?: string|null }} ctx
217
+ */
218
+ export async function executeFleetQueueList({ consoleFetch, args = {}, fleetToken = null }) {
219
+ const params = new URLSearchParams();
220
+ if (typeof args.lane === 'string' && args.lane.trim()) params.set('lane', args.lane.trim());
221
+ if (typeof args.status === 'string' && args.status.trim()) params.set('status', args.status.trim());
222
+ if (args.limit !== undefined && args.limit !== null && args.limit !== '') {
223
+ params.set('limit', String(args.limit));
224
+ }
225
+ const qs = params.toString();
226
+ const path = `/api/fleet/queue${qs ? `?${qs}` : ''}`;
227
+
228
+ const result = await consoleFetch(path, { headers: bearerHeaders(fleetToken) });
229
+
230
+ if (!result.ok) {
231
+ return {
232
+ error: 'LIST_FAILED',
233
+ message: `GET ${path} returned ${result.status}`,
234
+ status: result.status,
235
+ console_response: result.body,
236
+ };
237
+ }
238
+
239
+ const items = Array.isArray(result.body?.items) ? result.body.items : [];
240
+ return { items, count: items.length };
241
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * HeySpex MCP Hook Engine
3
+ *
4
+ * Loads hook config from .commandcode/hooks.json (or .heyspex/hooks.json)
5
+ * and runs hook scripts before/after MCP tool handlers — matching the Claude
6
+ * Code hook lifecycle (UserPromptSubmit, PreToolUse, PostToolUse, SessionEnd)
7
+ * inside the MCP server process.
8
+ *
9
+ * Fail-silent contract: any hook failure silently passes through. A hook that
10
+ * breaks the agent is worse than a hook that silently does nothing.
11
+ *
12
+ * Zero dependencies — Node.js stdlib only.
13
+ */
14
+
15
+ import { spawnSync } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ import { getActiveConfig } from './vault-resolve.mjs';
20
+
21
+ const HOOK_CONFIG_SEARCH = [
22
+ '.commandcode/hooks.json',
23
+ '.heyspex/hooks.json',
24
+ ];
25
+
26
+ // ─────────────────────────────────────────────────────────────
27
+ // Config loading
28
+ // ─────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Read hook config from disk. Returns empty config on any failure.
32
+ * @param {string} rootDir — vault root
33
+ * @returns {{ userPromptSubmit: HookGroup[], preToolUse: HookGroup[], postToolUse: HookGroup[], sessionEnd: HookGroup[] }}
34
+ */
35
+ export function loadHooksConfig(rootDir) {
36
+ for (const relPath of HOOK_CONFIG_SEARCH) {
37
+ const filePath = join(rootDir, relPath);
38
+ if (!existsSync(filePath)) continue;
39
+ try {
40
+ const raw = readFileSync(filePath, 'utf8');
41
+ const parsed = JSON.parse(raw);
42
+ const hooks = parsed?.hooks ?? parsed;
43
+ return {
44
+ userPromptSubmit: hooks?.UserPromptSubmit ?? [],
45
+ preToolUse: hooks?.PreToolUse ?? [],
46
+ postToolUse: hooks?.PostToolUse ?? [],
47
+ sessionEnd: hooks?.SessionEnd ?? [],
48
+ _configPath: filePath,
49
+ };
50
+ } catch {
51
+ /* malformed JSON — skip silently */
52
+ return { userPromptSubmit: [], preToolUse: [], postToolUse: [], sessionEnd: [] };
53
+ }
54
+ }
55
+ return { userPromptSubmit: [], preToolUse: [], postToolUse: [], sessionEnd: [] };
56
+ }
57
+
58
+ /**
59
+ * Full parsed config for introspection (heyspex_hooks_list tool).
60
+ * @param {string} rootDir
61
+ * @returns {object}
62
+ */
63
+ export function listHookConfig(rootDir) {
64
+ const cfg = loadHooksConfig(rootDir);
65
+ return {
66
+ userPromptSubmit: cfg.userPromptSubmit,
67
+ preToolUse: cfg.preToolUse,
68
+ postToolUse: cfg.postToolUse,
69
+ sessionEnd: cfg.sessionEnd,
70
+ configPath: cfg._configPath ?? null,
71
+ };
72
+ }
73
+
74
+ // ─────────────────────────────────────────────────────────────
75
+ // Matcher
76
+ // ─────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Does a matcher string match a tool name?
80
+ *
81
+ * - `*`, `""`, or omitted — matches all
82
+ * - `tool1|tool2` — exact match against any pipe-delimited entry
83
+ * - anything else — exact match
84
+ *
85
+ * @param {string|undefined} matcher
86
+ * @param {string} toolName
87
+ * @returns {boolean}
88
+ */
89
+ function matcherMatches(matcher, toolName) {
90
+ if (!matcher || matcher === '*') return true;
91
+ // Pipe-delimited list
92
+ if (matcher.includes('|')) {
93
+ const parts = matcher.split('|').map((p) => p.trim());
94
+ return parts.includes(toolName);
95
+ }
96
+ return matcher === toolName;
97
+ }
98
+
99
+ /**
100
+ * Return all hook groups matching an event + tool name.
101
+ * @param {'UserPromptSubmit'|'PreToolUse'|'PostToolUse'|'SessionEnd'} event
102
+ * @param {string} toolName
103
+ * @param {object} config — from loadHooksConfig
104
+ * @returns {HookGroup[]}
105
+ */
106
+ export function matchHooks(event, toolName, config) {
107
+ let groups;
108
+ switch (event) {
109
+ case 'UserPromptSubmit': groups = config.userPromptSubmit ?? []; break;
110
+ case 'PreToolUse': groups = config.preToolUse ?? []; break;
111
+ case 'PostToolUse': groups = config.postToolUse ?? []; break;
112
+ case 'SessionEnd': groups = config.sessionEnd ?? []; break;
113
+ default: return [];
114
+ }
115
+ if (event === 'UserPromptSubmit' || event === 'SessionEnd') {
116
+ return groups;
117
+ }
118
+ return groups.filter((g) => matcherMatches(g.matcher, toolName));
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────
122
+ // Placeholder substitution
123
+ // ─────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Substitute ${HEYSPEX_VAULT_ROOT} in strings.
127
+ * @param {string|string[]} val
128
+ * @param {string} vaultRoot
129
+ * @returns {string|string[]}
130
+ */
131
+ function resolvePlaceholders(val, vaultRoot) {
132
+ if (Array.isArray(val)) {
133
+ return val.map((v) => resolvePlaceholders(v, vaultRoot));
134
+ }
135
+ if (typeof val === 'string') {
136
+ return val.replace(/\$\{HEYSPEX_VAULT_ROOT\}/g, vaultRoot);
137
+ }
138
+ return val;
139
+ }
140
+
141
+ // ─────────────────────────────────────────────────────────────
142
+ // Hook execution
143
+ // ─────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Execute a single hook command.
147
+ *
148
+ * @param {object} hookDef — { type, command, args, timeout? }
149
+ * @param {object} payload — JSON payload to write to stdin
150
+ * @param {object} options
151
+ * @param {string} options.vaultRoot — vault root (for placeholders + env)
152
+ * @returns {{ ok: boolean, blocked: boolean, reason?: string, modifiedInput?: object, output?: string }}
153
+ */
154
+ export function executeHook(hookDef, payload, { vaultRoot } = {}) {
155
+ if (hookDef.type !== 'command') {
156
+ // Only command hooks supported for now
157
+ return { ok: true, blocked: false };
158
+ }
159
+
160
+ const command = resolvePlaceholders(hookDef.command, vaultRoot);
161
+ const rawArgs = hookDef.args ?? [];
162
+ const args = resolvePlaceholders(rawArgs, vaultRoot);
163
+ const timeout = (hookDef.timeout ?? 5) * 1000; // seconds → ms, default 5s
164
+
165
+ const input = JSON.stringify(payload);
166
+
167
+ const result = spawnSync(command, args, {
168
+ input,
169
+ encoding: 'utf8',
170
+ timeout,
171
+ maxBuffer: 1024 * 1024, // 1 MB
172
+ env: {
173
+ ...process.env,
174
+ CLAUDE_PROJECT_DIR: vaultRoot,
175
+ HEYSPEX_VAULT_ROOT: vaultRoot,
176
+ // MCP hook path is always agent context (Command Code lacks CLAUDE_AGENT env).
177
+ CLAUDE_AGENT: 'true',
178
+ // Prevent infinite recursion — child hooks don't re-enter the MCP server
179
+ HEYSPEX_MCP_INLINE_CALLS: '1',
180
+ HEYSPEX_MCP_HOOKS: '1',
181
+ },
182
+ windowsHide: true,
183
+ });
184
+
185
+ const stdout = (result.stdout ?? '').trim();
186
+ const stderr = (result.stderr ?? '').trim();
187
+ const status = result.status;
188
+ const error = result.error;
189
+
190
+ // Timeout or spawn error — fail silent
191
+ if (error) {
192
+ return { ok: true, blocked: false };
193
+ }
194
+
195
+ // Exit code 2 = hard block
196
+ if (status === 2) {
197
+ const reason = stderr || 'Blocked by hook';
198
+ return { ok: false, blocked: true, reason, output: stdout };
199
+ }
200
+
201
+ // Non-zero exit that isn't 2 = non-blocking error
202
+ if (status !== 0 && status !== null) {
203
+ return { ok: true, blocked: false };
204
+ }
205
+
206
+ // Exit 0 — parse stdout for JSON decision
207
+ if (stdout) {
208
+ try {
209
+ const json = JSON.parse(stdout);
210
+
211
+ // Top-level decision: block
212
+ if (json.decision === 'block') {
213
+ return {
214
+ ok: false,
215
+ blocked: true,
216
+ reason: json.reason ?? 'Blocked by hook',
217
+ output: stdout,
218
+ };
219
+ }
220
+
221
+ // hookSpecificOutput: PreToolUse with permissionDecision or modifiedInput
222
+ if (json.hookSpecificOutput) {
223
+ const hso = json.hookSpecificOutput;
224
+ if (hso.permissionDecision === 'deny') {
225
+ return {
226
+ ok: false,
227
+ blocked: true,
228
+ reason: hso.permissionDecisionReason ?? 'Denied by hook',
229
+ output: stdout,
230
+ };
231
+ }
232
+ if (hso.modifiedInput) {
233
+ return {
234
+ ok: true,
235
+ blocked: false,
236
+ modifiedInput: hso.modifiedInput,
237
+ output: stdout,
238
+ };
239
+ }
240
+ // additionalContext is passed through in output
241
+ if (hso.additionalContext) {
242
+ return {
243
+ ok: true,
244
+ blocked: false,
245
+ additionalContext: hso.additionalContext,
246
+ output: stdout,
247
+ };
248
+ }
249
+ }
250
+ } catch {
251
+ /* stdout not JSON — treat as pass-through */
252
+ }
253
+ }
254
+
255
+ return { ok: true, blocked: false, output: stdout };
256
+ }
257
+
258
+ // ─────────────────────────────────────────────────────────────
259
+ // Orchestration
260
+ // ─────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Run all matching hooks for an event.
264
+ *
265
+ * @param {'UserPromptSubmit'|'PreToolUse'|'PostToolUse'|'SessionEnd'} event
266
+ * @param {string} toolName
267
+ * @param {object} toolArgs
268
+ * @param {object|null} toolResult — null for PreToolUse/UserPromptSubmit/SessionEnd
269
+ * @param {object} config — from loadHooksConfig
270
+ * @param {object} [options]
271
+ * @param {function} [options.onBlock] — called with (reason) on block
272
+ * @returns {{ blocked: boolean, reason?: string, modifiedInput?: object, additionalContexts?: string[] }}
273
+ */
274
+ export function runHooks(event, toolName, toolArgs, toolResult, config, options = {}) {
275
+ const rootDir = config._configPath
276
+ ? join(config._configPath, '..', '..')
277
+ : getActiveConfig().repoRootAbs;
278
+
279
+ const groups = matchHooks(event, toolName, config);
280
+ if (groups.length === 0) return { blocked: false };
281
+
282
+ let modifiedInput = null;
283
+ const additionalContexts = [];
284
+
285
+ for (const group of groups) {
286
+ const handlers = group.hooks ?? [];
287
+ for (const hookDef of handlers) {
288
+ // Build stdin payload matching Claude Code hook input shape
289
+ const payload = {
290
+ hook_event_name: event,
291
+ tool_name: toolName,
292
+ tool_input: modifiedInput ?? toolArgs,
293
+ cwd: rootDir,
294
+ };
295
+
296
+ if (event === 'UserPromptSubmit') {
297
+ payload.prompt = toolArgs?.prompt ?? toolArgs?.text ?? '';
298
+ }
299
+
300
+ // For PostToolUse, attach the tool result
301
+ if (event === 'PostToolUse' && toolResult) {
302
+ payload.tool_response = toolResult;
303
+ }
304
+
305
+ try {
306
+ const result = executeHook(hookDef, payload, { vaultRoot: rootDir });
307
+
308
+ if (result.blocked) {
309
+ if (options.onBlock) options.onBlock(result.reason ?? '');
310
+ return {
311
+ blocked: true,
312
+ reason: result.reason ?? 'Blocked by hook',
313
+ };
314
+ }
315
+
316
+ if (result.modifiedInput) {
317
+ modifiedInput = {
318
+ ...(modifiedInput ?? toolArgs),
319
+ ...result.modifiedInput,
320
+ };
321
+ }
322
+
323
+ if (result.additionalContext) {
324
+ additionalContexts.push(result.additionalContext);
325
+ }
326
+ } catch {
327
+ /* fail-silent — hook error never breaks the tool */
328
+ }
329
+ }
330
+ }
331
+
332
+ return {
333
+ blocked: false,
334
+ ...(modifiedInput ? { modifiedInput } : {}),
335
+ ...(additionalContexts.length > 0 ? { additionalContexts } : {}),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Prepend hook additionalContext blocks onto an MCP tool result (text content).
341
+ * @param {object} toolResult
342
+ * @param {string[]} contexts
343
+ * @returns {object}
344
+ */
345
+ export function attachHookContexts(toolResult, contexts) {
346
+ if (!toolResult || !contexts?.length) return toolResult;
347
+
348
+ const banner = contexts
349
+ .map((c) => `[HeySpex hook context]\n${c}`)
350
+ .join('\n\n');
351
+
352
+ const content = Array.isArray(toolResult.content) ? [...toolResult.content] : [];
353
+ const first = content[0];
354
+
355
+ if (first?.type === 'text' && typeof first.text === 'string') {
356
+ content[0] = { ...first, text: `${banner}\n\n${first.text}` };
357
+ } else if (first?.type === 'text') {
358
+ content[0] = { ...first, text: banner };
359
+ } else {
360
+ content.unshift({ type: 'text', text: banner });
361
+ }
362
+
363
+ return { ...toolResult, content };
364
+ }
365
+
366
+ /**
367
+ * Run PostToolUse hooks and merge Pre/Post additionalContext onto a tool result.
368
+ * @param {string} toolName
369
+ * @param {object} toolArgs
370
+ * @param {object} toolResult
371
+ * @param {object} hooksCfg
372
+ * @param {{ additionalContexts?: string[] }} [preHookResult]
373
+ * @returns {object}
374
+ */
375
+ export function finalizeToolResultWithHooks(toolName, toolArgs, toolResult, hooksCfg, preHookResult) {
376
+ if (!toolResult || toolResult.isError) return toolResult;
377
+
378
+ const contexts = [...(preHookResult?.additionalContexts ?? [])];
379
+ const postResult = runHooks('PostToolUse', toolName, toolArgs, toolResult, hooksCfg);
380
+
381
+ if (postResult.blocked) {
382
+ return {
383
+ isError: true,
384
+ content: [{
385
+ type: 'text',
386
+ text: JSON.stringify({
387
+ error: 'HOOK_BLOCKED',
388
+ message: postResult.reason ?? 'Blocked by PostToolUse hook',
389
+ }),
390
+ }],
391
+ };
392
+ }
393
+
394
+ if (postResult.additionalContexts?.length) {
395
+ contexts.push(...postResult.additionalContexts);
396
+ }
397
+
398
+ return attachHookContexts(toolResult, contexts);
399
+ }