heyspex 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +163 -11
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +162 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +20 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +309 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/lib/console-url-resolver.mjs +3 -2
  72. package/scripts/lib/customer-kit.mjs +327 -10
  73. package/scripts/lib/dev-mode.mjs +7 -6
  74. package/scripts/lib/directive-obligations.mjs +349 -0
  75. package/scripts/lib/hook-dispositions.mjs +210 -0
  76. package/scripts/lib/hook-vault-header.mjs +30 -0
  77. package/scripts/lib/hs-source-branch.mjs +196 -0
  78. package/scripts/lib/hs-source-log.mjs +2 -1
  79. package/scripts/lib/hs-source-preflight.mjs +18 -4
  80. package/scripts/lib/hs-source-push.mjs +386 -356
  81. package/scripts/lib/window-placement.mjs +109 -0
  82. package/spexharness/src/adapters/claude-code.mjs +11 -3
  83. package/spexharness/src/adapters/copilot.mjs +236 -0
  84. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  85. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  86. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  87. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  88. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  89. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  90. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  91. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  93. package/spexharness/src/intent-classifier.mjs +4 -2
  94. package/spexharness/src/intent-directive.mjs +626 -541
  95. package/spexharness/src/rule-table.mjs +31 -1
  96. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  97. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  98. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  99. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  100. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  101. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  102. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  103. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  104. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  105. package/vendor/heyspex-mcp/cli.mjs +7 -1
  106. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  107. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  108. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  109. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  110. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  111. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  112. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  113. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  114. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  115. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  116. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  117. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  118. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  119. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  120. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  121. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  122. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  123. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  124. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  125. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  126. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  127. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  128. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  129. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  130. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  131. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  132. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  133. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  134. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  135. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  136. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  137. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  138. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  139. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  140. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  141. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  142. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  143. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  144. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  145. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -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
- // Inlined rather than imported from scripts/heyspex-mode.mjs: this runs on
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
- if (!isAlwaysOn(target) && heyspexIsOff()) process.exit(0);
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
- const hookPath = resolveHook(target);
107
- if (!hookPath || !fs.existsSync(hookPath)) {
108
- process.stderr.write(`[heyspex] _run.mjs: hook not found: ${target}\n`);
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
- // Make the hook believe it was invoked directly.
113
- process.argv = [process.argv[0], hookPath, ...rest];
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 import(pathToFileURL(hookPath).href);
409
+ await main();
117
410
  } catch (err) {
118
- process.stderr.write(`[heyspex] hook ${target} failed: ${err?.message ?? err}\n`);
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