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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|