heyspex 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +163 -11
- 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 +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +20 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +309 -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/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
|
@@ -105,6 +105,11 @@ export const KNOWN_BEHAVIORS = Object.freeze([
|
|
|
105
105
|
description:
|
|
106
106
|
'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.',
|
|
107
107
|
},
|
|
108
|
+
{
|
|
109
|
+
key: 'handoff_intent',
|
|
110
|
+
description:
|
|
111
|
+
'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.',
|
|
112
|
+
},
|
|
108
113
|
]);
|
|
109
114
|
|
|
110
115
|
function num(raw, fallback) {
|
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
// stops firing — so an unresolvable mode reads as ON, and a genuinely broken
|
|
22
22
|
// hook path reports on stderr and exits 0.
|
|
23
23
|
|
|
24
|
+
import crypto from 'node:crypto';
|
|
24
25
|
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
25
27
|
import path from 'node:path';
|
|
26
28
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
27
29
|
|
|
30
|
+
const STATE_DIR = '.' + 'heyspex';
|
|
28
31
|
const HOOK_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
29
32
|
const REPO_ROOT = path.resolve(HOOK_DIR, '..', '..');
|
|
30
33
|
|
|
@@ -57,21 +60,8 @@ function resolveHook(name) {
|
|
|
57
60
|
return abs;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
// --- the off-switch
|
|
61
|
-
//
|
|
62
|
-
// every tool call in every session, and a resolve+parse of another module is
|
|
63
|
-
// real latency for a question answered by one small file read. The two must
|
|
64
|
-
// agree — see the resolution order documented in heyspex-mode.mjs.
|
|
65
|
-
function heyspexIsOff() {
|
|
66
|
-
try {
|
|
67
|
-
const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
|
|
68
|
-
if (raw) return ['off', '0', 'false', 'disabled', 'disable', 'no'].includes(raw);
|
|
69
|
-
const state = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, '.heyspex', 'mode.json'), 'utf8'));
|
|
70
|
-
return state?.mode === 'off';
|
|
71
|
-
} catch {
|
|
72
|
-
return false; // fail-open: HeySpex is on unless something says otherwise
|
|
73
|
-
}
|
|
74
|
-
}
|
|
63
|
+
// --- the off-switch: see modeOffFromProject() below (project-scoped; must agree with heyspex-mode.mjs resolution order:
|
|
64
|
+
// HEYSPEX_MODE env first, then <project>/.heyspex/mode.json).
|
|
75
65
|
|
|
76
66
|
// --- hooks the off-switch may NOT silence ---------------------------------
|
|
77
67
|
// The productivity hooks (reminders, injections, checkpoints) exist to help,
|
|
@@ -101,20 +91,323 @@ function isAlwaysOn(name) {
|
|
|
101
91
|
return ALWAYS_ON.has(base);
|
|
102
92
|
}
|
|
103
93
|
|
|
104
|
-
|
|
94
|
+
// ── project roots the mode file may live in ─────────────────────────────────
|
|
95
|
+
// `hs mode off` writes <project>/.heyspex/mode.json. In a customer repo whose hooks come from the plugin /
|
|
96
|
+
// node_modules, REPO_ROOT is the package, not the project, so the dev's switch was ignored. Look in the PROJECT
|
|
97
|
+
// first: CLAUDE_PROJECT_DIR, then the hook payload's cwd, then walk up from process.cwd(); REPO_ROOT stays the
|
|
98
|
+
// last resort. HEYSPEX_MODE (env) keeps precedence over every file.
|
|
99
|
+
function projectRoots(payload) {
|
|
100
|
+
const roots = [];
|
|
101
|
+
const add = (r) => {
|
|
102
|
+
if (typeof r === 'string' && r.trim() && !roots.includes(r.trim())) roots.push(r.trim());
|
|
103
|
+
};
|
|
104
|
+
add(process.env.CLAUDE_PROJECT_DIR);
|
|
105
|
+
add(payload?.cwd);
|
|
106
|
+
try {
|
|
107
|
+
let d = process.cwd();
|
|
108
|
+
for (;;) {
|
|
109
|
+
if (fs.existsSync(path.join(d, '.heyspex', 'mode.json'))) {
|
|
110
|
+
add(d);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
const up = path.dirname(d);
|
|
114
|
+
if (up === d) break;
|
|
115
|
+
d = up;
|
|
116
|
+
}
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
add(REPO_ROOT);
|
|
119
|
+
return roots;
|
|
120
|
+
}
|
|
105
121
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
function modeOffFromProject(payload) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = String(process.env.HEYSPEX_MODE ?? '').trim().toLowerCase();
|
|
125
|
+
if (raw) return ['off', '0', 'false', 'disabled', 'disable', 'no'].includes(raw);
|
|
126
|
+
for (const root of projectRoots(payload)) {
|
|
127
|
+
const file = path.join(root, '.heyspex', 'mode.json');
|
|
128
|
+
if (!fs.existsSync(file)) continue;
|
|
129
|
+
// ANY root saying off wins: the dev's `hs mode off` lives in the project, an older switch in the platform root.
|
|
130
|
+
if (JSON.parse(fs.readFileSync(file, 'utf8'))?.mode === 'off') return true;
|
|
131
|
+
}
|
|
132
|
+
} catch { /* fail-open: on */ }
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── fail-open, outermost ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]) ─────────────
|
|
137
|
+
// GitHub Copilot turns ANY non-zero hook exit into a deny (and 2 is always a deny with the reason lost), so a
|
|
138
|
+
// crash anywhere - resolution, import, a hook's own async throw - must exit 0. Exit 2 survives only where a hook
|
|
139
|
+
// truly decided "deny" (Claude Code: stderr reason; Copilot: translated to the JSON deny below).
|
|
140
|
+
process.on('uncaughtException', (err) => {
|
|
141
|
+
try { process.stderr.write(`[heyspex] hook ${target} crashed (failing open): ${err?.message ?? err}\n`); } catch { /* ignore */ }
|
|
142
|
+
process.exit(0);
|
|
143
|
+
});
|
|
144
|
+
process.on('unhandledRejection', (err) => {
|
|
145
|
+
try { process.stderr.write(`[heyspex] hook ${target} rejected (failing open): ${err?.message ?? err}\n`); } catch { /* ignore */ }
|
|
109
146
|
process.exit(0);
|
|
147
|
+
});
|
|
148
|
+
process.on('exit', (code) => {
|
|
149
|
+
if (code !== 0 && code !== 2) process.exitCode = 0;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function readAll(stream, ms) {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
let buf = '';
|
|
155
|
+
const t = setTimeout(() => resolve(buf), ms);
|
|
156
|
+
t.unref?.();
|
|
157
|
+
stream.setEncoding('utf8');
|
|
158
|
+
stream.on('data', (c) => { buf += c; });
|
|
159
|
+
stream.on('end', () => { clearTimeout(t); resolve(buf); });
|
|
160
|
+
stream.on('error', () => resolve(buf));
|
|
161
|
+
});
|
|
110
162
|
}
|
|
111
163
|
|
|
112
|
-
//
|
|
113
|
-
|
|
164
|
+
// ── Copilot helpers (all best-effort; every failure means "run the hook normally") ─────────────────────────────
|
|
165
|
+
function readJson(file) {
|
|
166
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The command text after the shim: `node -e "<bootstrap>" hook [args]` or `node ".../run-hook.mjs" hook [args]`.
|
|
170
|
+
function rowTokens(command) {
|
|
171
|
+
const c = String(command ?? '');
|
|
172
|
+
const m = c.match(/^node -e "[^"]*"\s+([\s\S]*)$/) || c.match(/\.mjs"?\s+([\s\S]*)$/);
|
|
173
|
+
return m ? m[1].trim().split(/\s+/) : [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The matcher(s) registered for this hook + event, read from the project's Claude settings file and the plugin's
|
|
178
|
+
* hooks.json. A hook may sit under several matchers; ALL are returned (an unset matcher is ''). null = the hook
|
|
179
|
+
* is in no row we can read, so no filtering.
|
|
180
|
+
*/
|
|
181
|
+
function matchersForHook(eventName, payload) {
|
|
182
|
+
const files = [];
|
|
183
|
+
for (const r of projectRoots(payload)) files.push(path.join(r, '.claude', 'settings.json'));
|
|
184
|
+
for (const r of [process.env.CLAUDE_PLUGIN_ROOT, process.env.COPILOT_PLUGIN_ROOT, process.env.PLUGIN_ROOT]) {
|
|
185
|
+
if (r && r.trim()) files.push(path.join(r.trim(), 'hooks', 'hooks.json'));
|
|
186
|
+
}
|
|
187
|
+
files.push(path.join(REPO_ROOT, 'plugins', 'heyspex', 'hooks', 'hooks.json'));
|
|
188
|
+
const want = [target, ...rest].join(' ');
|
|
189
|
+
const matchers = [];
|
|
190
|
+
let found = false;
|
|
191
|
+
for (const file of [...new Set(files)]) {
|
|
192
|
+
const groups = readJson(file)?.hooks?.[eventName];
|
|
193
|
+
if (!Array.isArray(groups)) continue;
|
|
194
|
+
for (const g of groups) {
|
|
195
|
+
for (const h of g?.hooks ?? []) {
|
|
196
|
+
const t = rowTokens(h?.command);
|
|
197
|
+
if (t.length && t.join(' ') === want) { found = true; matchers.push(typeof g.matcher === 'string' ? g.matcher : ''); }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return found ? matchers : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const DEDUPE_WINDOW_MS = 2000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Double-firing guard. The first run of a key atomically creates a stamp (flag 'wx'); a second run inside 2 s is
|
|
208
|
+
* a duplicate. The first run stores its final stdout in the stamp so a duplicate replays a DENY instead of
|
|
209
|
+
* silently allowing. Keyed by (session, event, hook+args, tool, timestamp | input hash, project).
|
|
210
|
+
*/
|
|
211
|
+
function claimDedupe(payload, eventName) {
|
|
212
|
+
const none = { duplicate: false };
|
|
213
|
+
try {
|
|
214
|
+
if (String(process.env.HEYSPEX_COPILOT_DEDUPE_OFF ?? '').trim() === '1') return none;
|
|
215
|
+
const session = payload.session_id ?? payload.sessionId;
|
|
216
|
+
if (typeof session !== 'string' || !session) return none;
|
|
217
|
+
const ident = payload.timestamp !== undefined
|
|
218
|
+
? String(payload.timestamp)
|
|
219
|
+
: crypto.createHash('sha1').update(JSON.stringify([payload.tool_input ?? null, payload.prompt ?? null])).digest('hex');
|
|
220
|
+
const key = crypto.createHash('sha1')
|
|
221
|
+
.update(JSON.stringify([session, eventName, target, rest, payload.tool_name ?? '', ident, payload.cwd ?? '']))
|
|
222
|
+
.digest('hex');
|
|
223
|
+
const dir = process.env.HEYSPEX_COPILOT_DEDUPE_DIR?.trim() || path.join(os.tmpdir(), 'heyspex-copilot-dedupe');
|
|
224
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
225
|
+
const file = path.join(dir, `${key}.json`);
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
try {
|
|
228
|
+
fs.writeFileSync(file, JSON.stringify({ t: now, out: '' }), { flag: 'wx' });
|
|
229
|
+
} catch (e) {
|
|
230
|
+
if (e?.code !== 'EEXIST') return none;
|
|
231
|
+
const prev = readJson(file);
|
|
232
|
+
if (prev && typeof prev.t === 'number' && now - prev.t >= 0 && now - prev.t < DEDUPE_WINDOW_MS) {
|
|
233
|
+
return { duplicate: true, replay: typeof prev.out === 'string' ? prev.out : '' };
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(file, JSON.stringify({ t: now, out: '' })); // stale stamp: this run owns it
|
|
236
|
+
}
|
|
237
|
+
try { // opportunistic cleanup of old stamps
|
|
238
|
+
for (const f of fs.readdirSync(dir)) {
|
|
239
|
+
const fp = path.join(dir, f);
|
|
240
|
+
if (now - fs.statSync(fp).mtimeMs > 60000) fs.rmSync(fp, { force: true });
|
|
241
|
+
}
|
|
242
|
+
} catch { /* ignore */ }
|
|
243
|
+
return { duplicate: false, store: (final) => { if (final) fs.writeFileSync(file, JSON.stringify({ t: now, out: final })); } };
|
|
244
|
+
} catch {
|
|
245
|
+
return none;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True exactly once per (project, session): creates <project>/<state dir>/copilot-style-<session>.stamp. */
|
|
250
|
+
function claimSessionStyleStamp(payload) {
|
|
251
|
+
try {
|
|
252
|
+
const session = String(payload.session_id ?? payload.sessionId ?? '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
253
|
+
if (!session) return false;
|
|
254
|
+
const root = projectRoots(payload).find((r) => { try { return fs.statSync(r).isDirectory(); } catch { return false; } });
|
|
255
|
+
if (!root) return false;
|
|
256
|
+
const dir = path.join(root, STATE_DIR);
|
|
257
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
258
|
+
try { fs.writeFileSync(path.join(dir, `copilot-style-${session}.stamp`), new Date().toISOString(), { flag: 'wx' }); } catch { return false; }
|
|
259
|
+
try { // drop stamps older than a week
|
|
260
|
+
for (const f of fs.readdirSync(dir)) {
|
|
261
|
+
if (f.startsWith('copilot-style-') && f.endsWith('.stamp') && Date.now() - fs.statSync(path.join(dir, f)).mtimeMs > 7 * 86400000) fs.rmSync(path.join(dir, f), { force: true });
|
|
262
|
+
}
|
|
263
|
+
} catch { /* ignore */ }
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
// stdin is read ONCE here (harness detection and the mode file's project root both need the payload) and
|
|
272
|
+
// handed on to the hook as a fresh stream, so every hook still reads process.stdin exactly as before.
|
|
273
|
+
let text = '';
|
|
274
|
+
try {
|
|
275
|
+
if (!process.stdin.isTTY) text = await readAll(process.stdin, 4000);
|
|
276
|
+
} catch { /* ignore */ }
|
|
277
|
+
let payload = null;
|
|
278
|
+
try { payload = text.trim() ? JSON.parse(text) : null; } catch { payload = null; }
|
|
279
|
+
|
|
280
|
+
if (!isAlwaysOn(target) && modeOffFromProject(payload)) process.exit(0);
|
|
281
|
+
|
|
282
|
+
// ── harness detection + the Copilot dialect ──────────────────────────────
|
|
283
|
+
let harness = 'unknown';
|
|
284
|
+
let copilot = null;
|
|
285
|
+
try {
|
|
286
|
+
const { detectHarness } = await import(pathToFileURL(path.join(REPO_ROOT, 'spexharness', 'src', 'adapters', 'detect-harness.mjs')).href);
|
|
287
|
+
harness = detectHarness(payload, process.env);
|
|
288
|
+
} catch { /* no adapter in this install: behave exactly as Claude Code */ }
|
|
289
|
+
|
|
290
|
+
if (harness === 'copilot') {
|
|
291
|
+
try {
|
|
292
|
+
copilot = await import(pathToFileURL(path.join(REPO_ROOT, 'spexharness', 'src', 'adapters', 'copilot.mjs')).href);
|
|
293
|
+
} catch {
|
|
294
|
+
// Copilot denies on exit 2 and on any crash, and without the adapter we cannot speak its dialect: fail OPEN.
|
|
295
|
+
process.stderr.write(`[heyspex] hook ${target}: Copilot adapter unavailable (failing open)\n`);
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (copilot) await copilotBranch(copilot);
|
|
300
|
+
|
|
301
|
+
// ── the Copilot branch ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]) ──────────────────────
|
|
302
|
+
// Everything below runs ONLY when the harness is GitHub Copilot. Guarantees for Copilot, whatever fails:
|
|
303
|
+
// * the process exits 0 (Copilot turns ANY non-zero exit into a deny, and 2 loses the reason);
|
|
304
|
+
// * stdout is empty (allow) or ONE valid JSON object (a gate's own decision) - never partial / malformed text;
|
|
305
|
+
// * a crash, a throwing hook, an unhandled rejection or a bad import is an ALLOW (the _run.mjs handlers above
|
|
306
|
+
// exit 0; the exit hook below translates the buffered output).
|
|
307
|
+
// Behaviors, in order:
|
|
308
|
+
// 1. opt-out: HEYSPEX_COPILOT_HOOKS_OFF=1 / config agent_behaviors.harness_hooks.copilot.enabled: false
|
|
309
|
+
// silences every hook except the ALWAYS_ON security gates.
|
|
310
|
+
// 2. normalize the payload (camelCase or PascalCase, MCP tool-name spellings -> mcp__heyspex__*).
|
|
311
|
+
// 3. script-side tool filter: the VS Code Local harness ignores matchers and fires every hook on every tool,
|
|
312
|
+
// so the matcher of the registered row (.claude/settings.json or the plugin hooks.json) is re-applied here.
|
|
313
|
+
// 4. double-firing guard: Copilot CLI loads hooks from the plugin AND .claude/settings.json; a duplicate
|
|
314
|
+
// (same session, event, hook, tool, timestamp) inside 2 s exits 0 (replaying a stored decision, if any).
|
|
315
|
+
// 5. prompt-time context: Copilot DROPS UserPromptSubmit output, so a context-only result prints nothing
|
|
316
|
+
// there. SessionStart accepts additionalContext, so ONCE PER SESSION (stamp file under the project's
|
|
317
|
+
// state dir) the SessionStart run also emits inject-communication-style's static text as additionalContext.
|
|
318
|
+
async function copilotBranch(copilot) {
|
|
319
|
+
if (!isAlwaysOn(target)) {
|
|
320
|
+
let off = String(process.env.HEYSPEX_COPILOT_HOOKS_OFF ?? '').trim() === '1';
|
|
321
|
+
if (!off) {
|
|
322
|
+
try {
|
|
323
|
+
const { isConfigKeyEnabled } = await import(pathToFileURL(path.join(HOOK_DIR, '_guardrail-loader.mjs')).href);
|
|
324
|
+
off = !isConfigKeyEnabled('agent_behaviors.harness_hooks.copilot.enabled');
|
|
325
|
+
} catch { /* config unreadable: stay on */ }
|
|
326
|
+
}
|
|
327
|
+
if (off) process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
payload = copilot.normalizeCopilotPayload(payload);
|
|
330
|
+
text = JSON.stringify(payload);
|
|
331
|
+
const eventName = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
332
|
+
|
|
333
|
+
// (3) script-side tool filter
|
|
334
|
+
let skip = false;
|
|
335
|
+
try {
|
|
336
|
+
if (eventName && typeof payload.tool_name === 'string') {
|
|
337
|
+
const matchers = matchersForHook(eventName, payload);
|
|
338
|
+
skip = !!matchers && !copilot.toolMatchesAnyMatcher(matchers, copilot.toolNameCandidates(payload));
|
|
339
|
+
}
|
|
340
|
+
} catch { /* lookup failure: run the hook */ }
|
|
341
|
+
if (skip) process.exit(0);
|
|
342
|
+
|
|
343
|
+
// (4) double-firing guard
|
|
344
|
+
const dedupe = claimDedupe(payload, eventName);
|
|
345
|
+
if (dedupe.duplicate) {
|
|
346
|
+
if (dedupe.replay) { try { fs.writeSync(1, dedupe.replay); } catch { /* ignore */ } }
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// (5) once-per-session static advisory at SessionStart
|
|
351
|
+
let extraContext = '';
|
|
352
|
+
if (eventName === 'SessionStart' && claimSessionStyleStamp(payload)) {
|
|
353
|
+
try {
|
|
354
|
+
const mod = await import(pathToFileURL(path.join(HOOK_DIR, 'inject-communication-style.mjs')).href);
|
|
355
|
+
extraContext = typeof mod.copilotSessionStartText === 'function' ? await mod.copilotSessionStartText() : '';
|
|
356
|
+
} catch { /* advisory only */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Copilot reads ONE top-level {permissionDecision, permissionDecisionReason}; buffer stdout/stderr and
|
|
360
|
+
// translate at exit: Claude-dialect JSON -> top-level, and a legacy `exit 2 + stderr` deny -> JSON deny, exit 0.
|
|
361
|
+
let out = '';
|
|
362
|
+
let err = '';
|
|
363
|
+
const realStderr = process.stderr.write.bind(process.stderr);
|
|
364
|
+
process.stdout.write = (chunk, enc, cb) => { out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); if (typeof enc === 'function') enc(); else if (typeof cb === 'function') cb(); return true; };
|
|
365
|
+
process.stderr.write = (chunk, enc, cb) => { err += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); if (typeof enc === 'function') enc(); else if (typeof cb === 'function') cb(); return true; };
|
|
366
|
+
process.on('exit', (code) => {
|
|
367
|
+
process.exitCode = 0; // Copilot: never non-zero, whatever happened
|
|
368
|
+
let final = '';
|
|
369
|
+
try {
|
|
370
|
+
final = out ? copilot.finalizeCopilotStdout(out, eventName) : '';
|
|
371
|
+
if (code === 2 && !final) final = copilot.denyFromLegacyExit(err);
|
|
372
|
+
else if (err && code !== 2) realStderr(err);
|
|
373
|
+
if (extraContext) final = copilot.mergeAdditionalContext(final, extraContext);
|
|
374
|
+
} catch { final = ''; }
|
|
375
|
+
try { dedupe.store?.(final); } catch { /* best effort */ }
|
|
376
|
+
try { if (final) fs.writeSync(1, final); } catch { /* never let the translation break the hook */ }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// hand the (already read) payload on as a fresh stdin
|
|
381
|
+
if (!process.stdin.isTTY) {
|
|
382
|
+
try {
|
|
383
|
+
const { Readable } = await import('node:stream');
|
|
384
|
+
const fresh = new Readable({ read() {} });
|
|
385
|
+
if (text) fresh.push(Buffer.from(text, 'utf8'));
|
|
386
|
+
fresh.push(null);
|
|
387
|
+
Object.defineProperty(process, 'stdin', { value: fresh, configurable: true });
|
|
388
|
+
} catch { /* hooks will see an empty stdin and fail open themselves */ }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const hookPath = resolveHook(target);
|
|
392
|
+
if (!hookPath || !fs.existsSync(hookPath)) {
|
|
393
|
+
process.stderr.write(`[heyspex] _run.mjs: hook not found: ${target}\n`);
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Make the hook believe it was invoked directly.
|
|
398
|
+
process.argv = [process.argv[0], hookPath, ...rest];
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
await import(pathToFileURL(hookPath).href);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
process.stderr.write(`[heyspex] hook ${target} failed: ${err?.message ?? err}\n`);
|
|
404
|
+
process.exit(0);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
114
407
|
|
|
115
408
|
try {
|
|
116
|
-
await
|
|
409
|
+
await main();
|
|
117
410
|
} catch (err) {
|
|
118
|
-
process.stderr.write(`[heyspex]
|
|
411
|
+
process.stderr.write(`[heyspex] _run.mjs failed (failing open): ${err?.message ?? err}\n`);
|
|
119
412
|
process.exit(0);
|
|
120
413
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code PreToolUse hook -> blocking gate: no research gather / paid-model synthesis /
|
|
3
|
+
// routing-edit sub-agent while THIS session's on-deck capture is unexpanded or ungrounded.
|
|
4
|
+
// [[HS-KNJ8-BQZC-DIAG-an-agent-session-executed-past-capture]]
|
|
5
|
+
//
|
|
6
|
+
// Decision logic + state contract live in scripts/lib/capture-execution-guard.mjs (pure, unit
|
|
7
|
+
// tested). This file is only the stdin/stdout wrapper. Registered in .claude/settings.json on
|
|
8
|
+
// matcher "Bash|PowerShell|Agent|Task" via _run.mjs. Never gates read-only tools, capture writes,
|
|
9
|
+
// /hs-ground, git, or test runs (see the guard's READ_ONLY_HEAD / OFFLINE_FLAG_RE).
|
|
10
|
+
//
|
|
11
|
+
// Kill switches: env HEYSPEX_CAPTURE_EXECUTION_GATE_OFF=1, or config
|
|
12
|
+
// agent_behaviors.capture_execution_gate.enabled: false. FAIL OPEN on every internal error and
|
|
13
|
+
// whenever capture state is unknowable (no session id / no fresh this-session state file).
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { decideCaptureExecution, GATE_CONFIG_KEY } from '../lib/capture-execution-guard.mjs';
|
|
18
|
+
import { isConfigKeyEnabled } from './_guardrail-loader.mjs';
|
|
19
|
+
|
|
20
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
21
|
+
|
|
22
|
+
async function readStdin() {
|
|
23
|
+
if (process.stdin.isTTY) return '';
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let buf = '';
|
|
26
|
+
const timer = setTimeout(() => resolve(buf), 200);
|
|
27
|
+
process.stdin.setEncoding('utf8');
|
|
28
|
+
process.stdin.on('data', (c) => (buf += c));
|
|
29
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(buf); });
|
|
30
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(buf); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const raw = await readStdin();
|
|
36
|
+
const payload = raw ? JSON.parse(raw) : null;
|
|
37
|
+
if (payload && (payload.hook_event_name === undefined || payload.hook_event_name === 'PreToolUse')) {
|
|
38
|
+
const root = process.env.CLAUDE_PROJECT_DIR || payload.cwd || REPO_ROOT;
|
|
39
|
+
const verdict = decideCaptureExecution(
|
|
40
|
+
{ toolName: payload.tool_name, toolInput: payload.tool_input ?? {}, sessionId: payload.session_id },
|
|
41
|
+
{ root, configEnabled: isConfigKeyEnabled(GATE_CONFIG_KEY) },
|
|
42
|
+
);
|
|
43
|
+
if (verdict.decision === 'deny') {
|
|
44
|
+
process.stdout.write(JSON.stringify({
|
|
45
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: verdict.reason },
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// fail open
|
|
51
|
+
}
|
|
@@ -12,6 +12,7 @@ import { resolveConsoleUrl as resolveConsoleUrlShared } from '../lib/console-url
|
|
|
12
12
|
// Bearer for the cloud-gated heartbeat PATCH — same reader every other consumer uses, never
|
|
13
13
|
// re-implemented and never printed.
|
|
14
14
|
import { readCredentialsMcpToken } from '../lib/dev-mode.mjs';
|
|
15
|
+
import { hookVaultHeaders } from '../lib/hook-vault-header.mjs';
|
|
15
16
|
// Read-only reuse of the resource governor's OWN liveness rule + config resolver — never a
|
|
16
17
|
// second, hand-invented rule at this layer. See the "Lease liveness" section below.
|
|
17
18
|
import { describeLivenessRule, resolveGovernorConfig } from '../../dev-factory/lib/fleet-queue-file.mjs';
|
|
@@ -340,6 +341,7 @@ export function patchHeartbeat(consoleUrl, sessionId, body, token) {
|
|
|
340
341
|
'content-length': Buffer.byteLength(payload),
|
|
341
342
|
connection: 'close',
|
|
342
343
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
344
|
+
...hookVaultHeaders(),
|
|
343
345
|
},
|
|
344
346
|
timeout: REQUEST_TIMEOUT_MS,
|
|
345
347
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/diagnose-capture-reminder.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import gate from '../../spexharness/src/gates/diagnose-capture-reminder.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by scripts/generate-hook-shells.mjs (HS-IQBF) — do not hand-edit.
|
|
3
|
+
// This file only wires Claude Code's hook stdin/stdout contract to the
|
|
4
|
+
// spexharness engine port of this gate. To change what this gate DECIDES,
|
|
5
|
+
// edit spexharness/src/gates/directive-obligation-gate.mjs. To change the SHAPE of this shell,
|
|
6
|
+
// edit the generator, then rerun: node scripts/generate-hook-shells.mjs
|
|
7
|
+
|
|
8
|
+
import { runGateAsHook } from '../../spexharness/src/adapters/claude-code.mjs';
|
|
9
|
+
import { hookGate as gate } from '../../spexharness/src/gates/directive-obligation-gate.mjs';
|
|
10
|
+
|
|
11
|
+
await runGateAsHook(gate);
|
|
@@ -51,6 +51,26 @@ export function shouldInject() {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The static communication-style prose, for the ONE surface that can still carry it: GitHub Copilot's
|
|
56
|
+
* `sessionStart` additionalContext (Copilot drops UserPromptSubmit output, and per-prompt injection stays
|
|
57
|
+
* retired on Claude Code). Called ONLY by scripts/claude-hooks/_run.mjs's Copilot branch, once per session.
|
|
58
|
+
* Honors the historical kill switch and the `agent_behaviors.communication_style.enabled` config key; returns
|
|
59
|
+
* '' on any failure (never throws).
|
|
60
|
+
* @returns {Promise<string>}
|
|
61
|
+
*/
|
|
62
|
+
export async function copilotSessionStartText() {
|
|
63
|
+
try {
|
|
64
|
+
if (process.env.HEYSPEX_COMMUNICATION_STYLE_OFF === '1') return '';
|
|
65
|
+
const { loadGuardrails, rulesForSurface, isRuleEnabled } = await import('./_guardrail-loader.mjs');
|
|
66
|
+
const rule = rulesForSurface(loadGuardrails(), 'userpromptsubmit', 'prime').find((r) => r && r.rule === 'communication-style-prime');
|
|
67
|
+
if (!rule || !isRuleEnabled(rule)) return '';
|
|
68
|
+
return typeof rule.inject === 'string' ? rule.inject.trim() : '';
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* The hook body. Reads nothing and writes nothing — retirement means there is no longer
|
|
56
76
|
* anything for it to read the payload FOR. Exported so a test can call it directly and
|