heyspex 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +181 -15
  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 +177 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +21 -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 +312 -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/install-copilot-hooks.mjs +288 -0
  72. package/scripts/lib/console-url-resolver.mjs +3 -2
  73. package/scripts/lib/customer-kit.mjs +327 -10
  74. package/scripts/lib/dev-mode.mjs +7 -6
  75. package/scripts/lib/directive-obligations.mjs +349 -0
  76. package/scripts/lib/hook-dispositions.mjs +210 -0
  77. package/scripts/lib/hook-vault-header.mjs +30 -0
  78. package/scripts/lib/hs-source-branch.mjs +196 -0
  79. package/scripts/lib/hs-source-log.mjs +2 -1
  80. package/scripts/lib/hs-source-preflight.mjs +18 -4
  81. package/scripts/lib/hs-source-push.mjs +386 -356
  82. package/scripts/lib/window-placement.mjs +109 -0
  83. package/spexharness/src/adapters/claude-code.mjs +11 -3
  84. package/spexharness/src/adapters/copilot.mjs +236 -0
  85. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  86. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  87. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  88. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  90. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  91. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  93. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  94. package/spexharness/src/intent-classifier.mjs +4 -2
  95. package/spexharness/src/intent-directive.mjs +626 -541
  96. package/spexharness/src/rule-table.mjs +31 -1
  97. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  98. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  99. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  100. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  101. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  102. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  103. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  104. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  105. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  106. package/vendor/heyspex-mcp/cli.mjs +7 -1
  107. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  108. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  109. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  110. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  111. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  112. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  113. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  114. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  115. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  116. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  117. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  118. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  119. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  120. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  121. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  122. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  123. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  124. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  125. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  126. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  127. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  128. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  129. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  130. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  131. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  132. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  133. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  134. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  135. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  136. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  137. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  138. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  139. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  140. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  141. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  142. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  143. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  144. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  145. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  146. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -50,8 +50,11 @@
50
50
  import { createHash } from 'node:crypto';
51
51
  import fs from 'node:fs';
52
52
  import path from 'node:path';
53
+ import os from 'node:os';
53
54
  import { fileURLToPath, pathToFileURL } from 'node:url';
54
55
 
56
+ import { dispositionFor } from './hook-dispositions.mjs';
57
+
55
58
  /** @typedef {'customer' | 'platform'} KitScope */
56
59
 
57
60
  /**
@@ -75,6 +78,7 @@ export const HOOK_SCOPES = Object.freeze({
75
78
  'inject-communication-style': { scope: 'customer', why: 'Plain-language communication rules each turn.' },
76
79
  'inject-vault-context': { scope: 'customer', why: 'Lexical vault retrieval for the prompt.' },
77
80
  'git-identity-guard': { scope: 'customer', why: 'Agents never write git identity (owner ruling 2026-09-19).' },
81
+ 'vault-write-tier-guard': { scope: 'customer', why: 'Cloud write mode: disk edits to vault specs are a mirror the console never reads back (HS-FCNH).' },
78
82
  'settings-write-gate': { scope: 'customer', why: 'Approval before the vault\'s .heyspex/config.yaml changes.' },
79
83
  'emit-claim-status': { scope: 'customer', why: 'Claim liveness heartbeat to the console.' },
80
84
  'enforce-vault-naming': { scope: 'customer', why: 'Naming V3 filename guard (HS-RY2N).' },
@@ -82,7 +86,10 @@ export const HOOK_SCOPES = Object.freeze({
82
86
  'plan-review-gate': { scope: 'customer', why: 'Plan review + QC markers before ExitPlanMode.' },
83
87
  'plan-exit-ingest-gate': { scope: 'customer', why: 'Plan ingest on ExitPlanMode; arms claim-before-code.' },
84
88
  'claim-before-code-gate': { scope: 'customer', why: 'First code edit after plan exit needs a claim.' },
89
+ 'directive-obligation-gate': { scope: 'customer', why: 'A capture_on_idea directive leaves a durable obligation; an unmet one denies sub-agent spawns, product-code writes and builds, and re-drives Stop once. Also claim-before-build (HS-20SW).' },
85
90
  'capture-workflow-gate': { scope: 'customer', why: 'No implementation spec straight out of an open capture.' },
91
+ 'vault-mirror-catchup': { scope: 'customer', why: 'Cloud write mode: a SessionStart detached `hs pull --apply` closes a stale disk mirror before the first tool call (HS-XJFQ).' },
92
+ 'diagnose-capture-reminder': { scope: 'customer', why: 'Warn when a diagnose is filed with no capture holding the owner\'s words (HS-IK2D).' },
86
93
  'git-stash-gate': { scope: 'customer', why: 'Always ask before a mutating git stash.' },
87
94
  'research-method-guard': { scope: 'customer', why: 'Research method guard (HS-EN4F).' },
88
95
  'deep-research-cost-gate': { scope: 'customer', why: 'Cost confirmation before a paid deep-research launch.' },
@@ -102,6 +109,7 @@ export const HOOK_SCOPES = Object.freeze({
102
109
  'coo-guard-check': { scope: 'platform', why: 'dev-factory breakdown-delegation guard.' },
103
110
  'coo-denylist-gate': { scope: 'platform', why: 'dev-factory COO window deny list.' },
104
111
  'agent-spawn-delegation-gate': { scope: 'platform', why: 'dev-factory seat routing for subagents.' },
112
+ 'capture-execution-gate': { scope: 'platform', why: 'Blocks dev-factory research/paid-model/routing surfaces past an ungrounded capture (HS-KNJ8).' },
105
113
  'check-mcp-freshness-hook': { scope: 'platform', why: 'Freshness of the vendored MCP build — a platform-dev concern.' },
106
114
  'dev-server-task-reminder': { scope: 'platform', why: 'spex-console dev server + Neon DB safety.' },
107
115
  'env-vault-guard': { scope: 'platform', why: 'Guards heyspex-app\'s encrypted env vault paths.' },
@@ -138,6 +146,7 @@ export const CLAUDE_HOOK_ROWS = Object.freeze([
138
146
  { event: 'SessionStart', hook: 'check-claude-md-budget', timeout: 5 },
139
147
  { event: 'SessionStart', hook: 'check-mcp-freshness-hook', timeout: 5 },
140
148
  { event: 'SessionStart', hook: 'git-reflog-audit', timeout: 5 },
149
+ { event: 'SessionStart', hook: 'vault-mirror-catchup', timeout: 5 },
141
150
  { event: 'PreCompact', hook: 'wip-checkpoint', timeout: 5 },
142
151
  { event: 'UserPromptSubmit', hook: 'preflight-reminder', timeout: 5 },
143
152
  { event: 'UserPromptSubmit', hook: 'intent-classifier-trigger', timeout: 5 },
@@ -145,10 +154,13 @@ export const CLAUDE_HOOK_ROWS = Object.freeze([
145
154
  { event: 'UserPromptSubmit', hook: 'inject-vault-context', timeout: 10 },
146
155
  { event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'env-vault-guard', timeout: 5 },
147
156
  { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-identity-guard', timeout: 5 },
157
+ { event: 'PreToolUse', matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'vault-write-tier-guard', timeout: 5 },
148
158
  { event: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit|Bash|PowerShell|mcp__heyspex__heyspex_settings_set', hook: 'settings-write-gate', timeout: 5 },
149
159
  { event: 'PreToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PreToolUse'], timeout: 5 },
150
160
  { event: 'PreToolUse', matcher: '.*', hook: 'coo-denylist-gate', timeout: 5 },
151
161
  { event: 'PreToolUse', matcher: 'Agent|Task', hook: 'agent-spawn-delegation-gate', timeout: 5 },
162
+ { event: 'PreToolUse', matcher: 'Bash|PowerShell|Agent|Task', hook: 'capture-execution-gate', timeout: 5 },
163
+ { event: 'PreToolUse', matcher: 'Agent|Task|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell', hook: 'directive-obligation-gate', timeout: 5 },
152
164
  { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_claim_spec', hook: 'coo-guard-check', timeout: 5 },
153
165
  { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'enforce-vault-naming', timeout: 5 },
154
166
  { event: 'PreToolUse', matcher: 'Write|NotebookEdit|Bash|PowerShell', hook: 'guard-auto-memory-writes', timeout: 5 },
@@ -157,6 +169,7 @@ export const CLAUDE_HOOK_ROWS = Object.freeze([
157
169
  { event: 'PreToolUse', matcher: 'ExitPlanMode', hook: 'plan-exit-ingest-gate', timeout: 5 },
158
170
  { event: 'PreToolUse', matcher: 'Edit|MultiEdit|Write', hook: 'claim-before-code-gate', timeout: 5 },
159
171
  { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'capture-workflow-gate', timeout: 8 },
172
+ { event: 'PreToolUse', matcher: 'mcp__heyspex__heyspex_file_spec', hook: 'diagnose-capture-reminder', timeout: 5 },
160
173
  { event: 'PreToolUse', matcher: 'Bash|PowerShell|mcp__neon__prepare_database_migration|mcp__neon__complete_database_migration|mcp__neon__reset_from_parent|mcp__neon__delete_branch|mcp__Neon__prepare_database_migration|mcp__Neon__complete_database_migration|mcp__Neon__reset_from_parent|mcp__Neon__delete_branch', hook: 'dev-server-task-reminder', timeout: 10 },
161
174
  { event: 'PreToolUse', matcher: 'Bash|PowerShell', hook: 'git-stash-gate', timeout: 10 },
162
175
  { event: 'PreToolUse', matcher: 'Skill|Workflow', hook: 'research-method-guard', timeout: 5 },
@@ -169,7 +182,9 @@ export const CLAUDE_HOOK_ROWS = Object.freeze([
169
182
  { event: 'Stop', hook: 'epic-milestone-phrasing-gate', timeout: 5 },
170
183
  { event: 'Stop', hook: 'git-reflog-audit', timeout: 5 },
171
184
  { event: 'Stop', hook: 'enforce-session-sequence', timeout: 5 },
185
+ { event: 'Stop', hook: 'directive-obligation-gate', timeout: 5 },
172
186
  { event: 'PostToolUse', matcher: '.*', hook: 'emit-claim-status', args: ['--event=PostToolUse'], timeout: 5 },
187
+ { event: 'PostToolUse', matcher: 'Skill|mcp__heyspex__heyspex_update_capture|mcp__heyspex__heyspex_promote_capture.*|mcp__heyspex__heyspex_file_spec|mcp__heyspex__heyspex_claim_spec|mcp__heyspex__heyspex_release_spec|mcp__heyspex__heyspex_defer_spec', hook: 'directive-obligation-gate', timeout: 5 },
173
188
  { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'notify-status-flip', timeout: 5 },
174
189
  { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'close-terminal-on-ship', timeout: 5 },
175
190
  { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', hook: 'scripts/stamp-provenance-on-edit.mjs', timeout: 5 },
@@ -305,11 +320,73 @@ export const CUSTOMER_LAUNCHERS = Object.freeze({
305
320
  */
306
321
  export function customerRunnerPrefix(_platformRoot, surface = 'claude') {
307
322
  const l = CUSTOMER_LAUNCHERS[surface];
323
+ // Claude rows are self-locating (Copilot CLI loads .claude/settings.json and does not document CLAUDE_PROJECT_DIR).
324
+ if (surface === 'claude') return CUSTOMER_RUNNER_PREFIX;
308
325
  return `node "$${l.projectDirEnv}/${l.rel.join('/')}"`;
309
326
  }
310
327
 
311
328
  /** Runner prefix inside the plugin: its own launcher, which finds the platform. */
312
- export const PLUGIN_RUNNER_PREFIX = 'node "${CLAUDE_PLUGIN_ROOT}/bin/run-hook.mjs"';
329
+ const BOOTSTRAP_WALK =
330
+ "var d=process.cwd(),r=e.CLAUDE_PROJECT_DIR;if(!r||!f.existsSync(p.join(r,...n))){r='';for(;;){if(f.existsSync(p.join(d,...n))){r=d;break}var q=p.dirname(d);if(q===d)break;d=q}}";
331
+
332
+ /**
333
+ * SELF-LOCATING BOOTSTRAP ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]], Copilot compatibility).
334
+ *
335
+ * GitHub Copilot CLI runs the hooks in .claude/settings.json too, but it is not documented to set
336
+ * CLAUDE_PROJECT_DIR / CLAUDE_PLUGIN_ROOT, and a PowerShell-run command expands an unset $VAR to
337
+ * nothing - so \`node "$CLAUDE_PROJECT_DIR/scripts/claude-hooks/_run.mjs"\` became \`node /scripts/...\`,
338
+ * MODULE_NOT_FOUND, exit 1, and Copilot turns ANY non-zero exit into a DENY (on a \`.*\` matcher: every tool
339
+ * call). A registered command must therefore (a) work with no env var at all, under bash AND PowerShell, and
340
+ * (b) FAIL OPEN when it cannot find the runtime. It is an inline \`node -e\` script, single-quoted JS only
341
+ * (no double quote, backslash, dollar or backtick), so it survives JSON and every shell unchanged:
342
+ * root = CLAUDE_PROJECT_DIR if it holds the shim, else walk up from cwd looking for scripts/claude-hooks/_run.mjs;
343
+ * not found -> exit 0; found -> import the shim in-process with the same argv and stdin (the shim owns both).
344
+ * The plugin variant first tries <CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT>/bin/run-hook.mjs. The trailing //comment is
345
+ * deliberate: it keeps the recognisable ...scripts/claude-hooks/_run.mjs" <hook> tail every drift check,
346
+ * ourHookTail and the doctor already parse.
347
+ * @param {{ plugin?: boolean }} [opts]
348
+ */
349
+ export function buildBootstrap({ plugin = false } = {}) {
350
+ const walk = BOOTSTRAP_WALK;
351
+ const body = plugin
352
+ ? "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=['scripts','claude-hooks','_run.mjs'],s='',m=e.CLAUDE_PLUGIN_ROOT||e.COPILOT_PLUGIN_ROOT||e.PLUGIN_ROOT;" +
353
+ "if(m&&f.existsSync(p.join(m,'bin','run-hook.mjs')))s=p.join(m,'bin','run-hook.mjs');" +
354
+ "if(!s){" + walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}}"
355
+ : "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=['scripts','claude-hooks','_run.mjs'],s='';" +
356
+ walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}";
357
+ return (
358
+ body +
359
+ "if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));" +
360
+ "import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});" +
361
+ (plugin ? '//bin/run-hook.mjs' : '//scripts/claude-hooks/_run.mjs')
362
+ );
363
+ }
364
+
365
+ /** Platform checkout runner (.claude/settings.json): self-locating, env-independent, fail-open. */
366
+ export const PLATFORM_RUNNER_PREFIX = 'node -e "' + buildBootstrap() + '"';
367
+
368
+ /** Runner prefix inside the plugin: self-locating too (plugin root env, then cwd walk-up), fail-open. */
369
+ export const PLUGIN_RUNNER_PREFIX = 'node -e "' + buildBootstrap({ plugin: true }) + '"';
370
+
371
+ /**
372
+ * Customer-repo bootstrap (same defect, same fix as {@link buildBootstrap}): the committed launcher
373
+ * `.claude/heyspex/run-hook.mjs` is resolved from CLAUDE_PROJECT_DIR if it holds the launcher, else by walking up
374
+ * from cwd, else exit 0 (fail open). Sets CLAUDE_PROJECT_DIR to the found root so the shim reads
375
+ * <project>/.heyspex/mode.json (`hs mode off`). Single-quoted JS only; survives JSON, bash and PowerShell.
376
+ */
377
+ export function buildCustomerBootstrap() {
378
+ const rel = CUSTOMER_LAUNCHERS.claude.rel.map((s) => "'" + s + "'").join(',');
379
+ return (
380
+ "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=[" + rel + "],s='';" +
381
+ BOOTSTRAP_WALK + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}" +
382
+ "if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));" +
383
+ "import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});" +
384
+ '//' + CUSTOMER_LAUNCHERS.claude.rel.join('/')
385
+ );
386
+ }
387
+
388
+ /** Customer repo runner (.claude/settings.json): self-locating, env-independent, fail-open. */
389
+ export const CUSTOMER_RUNNER_PREFIX = 'node -e "' + buildCustomerBootstrap() + '"';
313
390
 
314
391
  // The launcher is kept as ONE template string (not a sibling file) so the
315
392
  // bundled CLI (`dist/cli/*.mjs`, built from install.ts) carries it by
@@ -392,7 +469,12 @@ const LAUNCHER_TEMPLATE = [
392
469
  '// (the Cursor one) runs exactly as if Cursor had invoked it directly.',
393
470
  'const shim = resolve(root, ...SHIM);',
394
471
  'process.argv[1] = shim;',
395
- 'await import(pathToFileURL(shim).href);',
472
+ 'try {',
473
+ ' await import(pathToFileURL(shim).href);',
474
+ '} catch (err) {',
475
+ " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
476
+ ' process.exit(0);',
477
+ '}',
396
478
  '',
397
479
  ].join('\n');
398
480
 
@@ -576,15 +658,10 @@ export function renderPluginHooksDocument() {
576
658
  hooks: [{ type: 'command', command: d.command, timeout: d.timeout }],
577
659
  });
578
660
  }
661
+ // No top-level `_comment`: GitHub Copilot loads this file too and a strict reader may reject unknown keys. The
662
+ // provenance note lives in plugins/heyspex/README.md (GENERATED from this file's customer-scoped rows; edit
663
+ // scripts/lib/customer-kit.mjs and run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`).
579
664
  return {
580
- _comment:
581
- 'GENERATED from scripts/lib/customer-kit.mjs (customer-scoped rows only) — do not edit by hand; ' +
582
- 'run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`. Every command goes through ' +
583
- 'bin/run-hook.mjs, which finds the hook runtime (HEYSPEX_PLATFORM_ROOT / HEYSPEX_MCP_ENTRY when set ' +
584
- 'in the machine\'s own env, then the plugin\'s own location, node_modules/heyspex, ' +
585
- '$CLAUDE_PROJECT_DIR) — never $CLAUDE_PROJECT_DIR/scripts, which only exists in heyspex-app. ' +
586
- 'WARNING: a repo whose .claude/settings.json already carries the hs install kit hooks runs every ' +
587
- 'hook twice if this plugin is also enabled — use one or the other.',
588
665
  hooks,
589
666
  };
590
667
  }
@@ -593,6 +670,230 @@ export function renderPluginHooksText() {
593
670
  return `${JSON.stringify(renderPluginHooksDocument(), null, 2)}\n`;
594
671
  }
595
672
 
673
+ // ---------------------------------------------------------------------------
674
+ // GitHub Copilot cloud agent: .github/hooks/heyspex.json
675
+ // ---------------------------------------------------------------------------
676
+ // The cloud agent loads NO plugins and no user-level hook files; its only hook source is
677
+ // `.github/hooks/*.json` on the default branch. This is the one place a generated Copilot
678
+ // manifest is justified ([[HS-0GBS-CTF8-RES-copilot-plugin-parity-with-the-spexcode]], option 3).
679
+ // Generated from the SAME customer rows as hooks.json, restricted to the hooks whose Copilot
680
+ // disposition (scripts/lib/hook-dispositions.mjs) is 'mapped'. The cloud agent is Linux bash only,
681
+ // so every entry is `type: "command"` with a `bash` command; no PowerShell, no node -e bootstrap.
682
+
683
+ /** Claude hook event -> Copilot hooks event (camelCase, as documented for the CLI and the cloud agent). */
684
+ export const COPILOT_EVENT_NAMES = Object.freeze({
685
+ SessionStart: 'sessionStart',
686
+ SessionEnd: 'sessionEnd',
687
+ UserPromptSubmit: 'userPromptSubmitted',
688
+ PreToolUse: 'preToolUse',
689
+ PostToolUse: 'postToolUse',
690
+ Stop: 'agentStop',
691
+ PreCompact: 'preCompact',
692
+ });
693
+
694
+ /**
695
+ * Hooks shipped to the cloud agent although their Copilot disposition is 'advisory-degraded': their
696
+ * prompt-time OUTPUT is dropped, but they still run and ARM state (the HS-20SW capture obligation), and
697
+ * the 'mapped' directive-obligation-gate is inert without that arming.
698
+ */
699
+ export const GITHUB_HOOKS_STATE_ARMING = Object.freeze(['intent-classifier-trigger']);
700
+
701
+ /** Claude tool name -> Copilot tool name (documented: ask_user bash create edit glob grep powershell task view web_fetch). */
702
+ const COPILOT_TOOL_NAMES = Object.freeze({
703
+ Write: 'create',
704
+ Edit: 'edit',
705
+ MultiEdit: 'edit',
706
+ NotebookEdit: 'edit',
707
+ Bash: 'bash',
708
+ PowerShell: 'powershell',
709
+ Agent: 'task',
710
+ Task: 'task',
711
+ WebFetch: 'web_fetch',
712
+ WebSearch: 'web_search',
713
+ });
714
+
715
+ /**
716
+ * Translate a Claude matcher to Copilot tool names. MCP tools become a suffix regex because the MCP
717
+ * tool-name spelling inside hook payloads is not documented (unverified live); tools with no Copilot
718
+ * counterpart (Skill, Workflow, ExitPlanMode) are dropped.
719
+ * @param {string | undefined} matcher
720
+ * @returns {string | null | undefined} undefined = no matcher, null = nothing translatable
721
+ */
722
+ export function toCopilotMatcher(matcher) {
723
+ if (!matcher) return undefined;
724
+ const out = [];
725
+ for (const part of matcher.split('|')) {
726
+ let t = null;
727
+ if (part === '.*') t = '.*';
728
+ else if (COPILOT_TOOL_NAMES[part]) t = COPILOT_TOOL_NAMES[part];
729
+ else if (part.startsWith(MCP_PROJECT_PREFIX)) t = '.*' + part.slice(MCP_PROJECT_PREFIX.length);
730
+ else if (part.startsWith(MCP_PLUGIN_PREFIX)) t = '.*' + part.slice(MCP_PLUGIN_PREFIX.length);
731
+ if (t && !out.includes(t)) out.push(t);
732
+ }
733
+ if (out.includes('.*')) return '.*';
734
+ return out.length ? out.join('|') : null;
735
+ }
736
+
737
+ /** Hooks the cloud-agent manifest carries (see the module comment above). */
738
+ export function githubHookNames() {
739
+ const names = new Set();
740
+ for (const r of customerHookRows()) {
741
+ if (dispositionFor(r.hook, 'copilot')?.disposition === 'mapped' || GITHUB_HOOKS_STATE_ARMING.includes(r.hook)) names.add(r.hook);
742
+ }
743
+ return names;
744
+ }
745
+
746
+ /**
747
+ * The bash command for one hook: resolve the runtime from node_modules/heyspex first, then the checkout's own
748
+ * scripts/, else exit 0 (fail open; Copilot turns any non-zero exit into a deny).
749
+ * @param {{ hook: string, args?: string[] }} row
750
+ */
751
+ export function githubHookCommand(row) {
752
+ const tail = hookTail(row);
753
+ return (
754
+ 'R=node_modules/heyspex/scripts/claude-hooks/_run.mjs; ' +
755
+ '[ -f "$R" ] || R=./scripts/claude-hooks/_run.mjs; ' +
756
+ '[ -f "$R" ] || exit 0; ' +
757
+ `exec node "$R" ${tail}`
758
+ );
759
+ }
760
+
761
+ /** The cloud agent's .github/hooks/heyspex.json document, generated from the customer rows. */
762
+ export function renderGithubHooksDocument() {
763
+ const allowed = githubHookNames();
764
+ const hooks = {};
765
+ for (const row of customerHookRows()) {
766
+ if (!allowed.has(row.hook)) continue;
767
+ const event = COPILOT_EVENT_NAMES[row.event];
768
+ if (!event) continue;
769
+ const matcher = toCopilotMatcher(row.matcher);
770
+ if (matcher === null) continue; // a tool-scoped row with no Copilot tool to match
771
+ (hooks[event] ??= []).push({
772
+ type: 'command',
773
+ ...(matcher ? { matcher } : {}),
774
+ bash: githubHookCommand(row),
775
+ timeoutSec: row.timeout,
776
+ });
777
+ }
778
+ return { version: 1, hooks };
779
+ }
780
+
781
+ export function renderGithubHooksText() {
782
+ return `${JSON.stringify(renderGithubHooksDocument(), null, 2)}\n`;
783
+ }
784
+
785
+ // ---------------------------------------------------------------------------
786
+ // Double-firing detector (plugin AND repo hooks)
787
+ // ---------------------------------------------------------------------------
788
+ // Copilot CLI reads hooks from the plugin AND from .claude/settings.json / .github/hooks in the repo, and
789
+ // Claude Code does the same for plugin + .claude/settings.json: with both present every hook runs twice.
790
+ // Rule: pick one of plugin OR repo hooks. This detector is pure (injected fs), so the doctor and a test share it.
791
+
792
+ const readJsonFile = (deps, file) => {
793
+ try {
794
+ const text = deps.readFile(file);
795
+ return text == null ? undefined : JSON.parse(text);
796
+ } catch {
797
+ return null; // present but unparseable
798
+ }
799
+ };
800
+
801
+ /** @param {unknown} settings */
802
+ function countOurRows(settings) {
803
+ let n = 0;
804
+ const hooks = settings && typeof settings === 'object' && settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
805
+ for (const groups of Object.values(hooks)) {
806
+ for (const g of Array.isArray(groups) ? groups : []) {
807
+ // Claude shape: { matcher, hooks: [{ command }] }; Copilot .github/hooks shape: { bash } / { command }.
808
+ const cmds = Array.isArray(g?.hooks) ? g.hooks.map((h) => h?.command) : [g?.command, g?.bash];
809
+ for (const c of cmds) if (typeof c === 'string' && (ourHookTail(c) !== null || /claude-hooks\/_run\.mjs/.test(c))) n++;
810
+ }
811
+ }
812
+ return n;
813
+ }
814
+
815
+ /**
816
+ * Whether BOTH repo-level HeySpex hooks AND an installed heyspex plugin are present for a project.
817
+ *
818
+ * Repo hooks: HeySpex rows in <project>/.claude/settings.json, .claude/settings.local.json or
819
+ * <project>/.github/hooks/*.json. Plugin evidence (any of): ~/.claude/plugins/installed_plugins.json with a
820
+ * "heyspex@..." key; `enabledPlugins` naming heyspex in ~/.claude/settings.json or the project's Claude settings;
821
+ * a heyspex directory under ~/.copilot/installed-plugins or ~/.copilot/plugins (best effort: the Copilot plugin
822
+ * list file is not documented, `copilot plugin list` is the authority). If no plugin registry can be read at all
823
+ * and repo hooks exist, the answer is 'unknown' (never a failure).
824
+ * @param {{ targetRoot: string, home?: string, deps?: { readFile: (p: string) => string | null, listDir?: (p: string) => string[] } }} opts
825
+ * @returns {{ status: 'ok' | 'warn' | 'unknown', detail: string, repoHooks: number, plugin: boolean | null, evidence: string[] }}
826
+ */
827
+ export function detectDoubleFire({ targetRoot, home = os.homedir(), deps = fsDeps }) {
828
+ const evidence = [];
829
+ const repoFiles = ['.claude/settings.json', '.claude/settings.local.json'].map((f) => path.join(targetRoot, f));
830
+ let repoHooks = 0;
831
+ for (const f of repoFiles) {
832
+ const n = countOurRows(readJsonFile(deps, f));
833
+ if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in ${f}`); }
834
+ }
835
+ const ghDir = path.join(targetRoot, '.github', 'hooks');
836
+ for (const name of (deps.listDir ? deps.listDir(ghDir) : []).filter((x) => x.endsWith('.json'))) {
837
+ const n = countOurRows(readJsonFile(deps, path.join(ghDir, name)));
838
+ if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in .github/hooks/${name}`); }
839
+ }
840
+
841
+ let readable = 0;
842
+ let plugin = false;
843
+ const installed = readJsonFile(deps, path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
844
+ if (installed !== undefined) {
845
+ readable++;
846
+ const keys = Object.keys(installed?.plugins ?? installed ?? {});
847
+ if (keys.some((k) => /^heyspex(@|$)/i.test(k))) { plugin = true; evidence.push('heyspex in ~/.claude/plugins/installed_plugins.json'); }
848
+ }
849
+ const userSettings = path.join(home, '.claude', 'settings.json');
850
+ for (const f of [userSettings, ...repoFiles]) {
851
+ const st = readJsonFile(deps, f);
852
+ if (st === undefined) continue;
853
+ if (f === userSettings) readable++; // a project file proves nothing about what is installed elsewhere
854
+ const ep = st?.enabledPlugins;
855
+ if (ep && typeof ep === 'object' && Object.entries(ep).some(([k, v]) => /^heyspex(@|$)/i.test(k) && v !== false)) {
856
+ plugin = true;
857
+ evidence.push(`heyspex in enabledPlugins of ${f}`);
858
+ }
859
+ }
860
+ if (deps.listDir) {
861
+ for (const sub of ['installed-plugins', 'plugins']) {
862
+ const dir = path.join(home, '.copilot', sub);
863
+ const names = deps.listDir(dir);
864
+ if (names.length) readable++;
865
+ if (names.some((n) => /heyspex/i.test(n))) {
866
+ plugin = true;
867
+ evidence.push(`heyspex under ${dir}`);
868
+ } else {
869
+ // one level deeper: <marketplace>/<plugin> layouts
870
+ for (const n of names) {
871
+ if (deps.listDir(path.join(dir, n)).some((m) => /heyspex/i.test(m))) {
872
+ plugin = true;
873
+ evidence.push(`heyspex under ${path.join(dir, n)}`);
874
+ }
875
+ }
876
+ }
877
+ }
878
+ }
879
+
880
+ if (repoHooks === 0) return { status: 'ok', detail: 'no repo-level HeySpex hooks to double up with a plugin', repoHooks, plugin: plugin || null, evidence };
881
+ if (plugin) {
882
+ return {
883
+ status: 'warn',
884
+ detail: `HeySpex hooks are registered in the repo AND the heyspex plugin is installed, so every hook fires twice (${evidence.join('; ')})`,
885
+ repoHooks,
886
+ plugin: true,
887
+ evidence,
888
+ };
889
+ }
890
+ if (readable === 0) {
891
+ return { status: 'unknown', detail: 'repo-level HeySpex hooks present, but no plugin registry could be read to rule out a second copy', repoHooks, plugin: null, evidence };
892
+ }
893
+ return { status: 'ok', detail: 'repo-level hooks only; no heyspex plugin found', repoHooks, plugin: false, evidence };
894
+ }
895
+
896
+
596
897
  // ---------------------------------------------------------------------------
597
898
  // Harness commands (.claude/commands, .cursor/commands)
598
899
  // ---------------------------------------------------------------------------
@@ -880,6 +1181,22 @@ if (invokedDirectly(import.meta.url, 'customer-kit.mjs')) {
880
1181
  process.exit(1);
881
1182
  }
882
1183
  console.log('customer-kit: plugins/heyspex/hooks/hooks.json up to date');
1184
+ } else if (process.argv.includes('--write-github-hooks') || process.argv.includes('--check-github-hooks')) {
1185
+ const ghTarget = path.join(platformRoot, '.github', 'hooks', 'heyspex.json');
1186
+ const ghWant = renderGithubHooksText();
1187
+ const ghSame = (fsDeps.readFile(ghTarget) ?? '').replace(/\r\n/g, '\n') === ghWant;
1188
+ if (process.argv.includes('--write-github-hooks')) {
1189
+ if (!ghSame) {
1190
+ fs.mkdirSync(path.dirname(ghTarget), { recursive: true });
1191
+ fs.writeFileSync(ghTarget, ghWant, 'utf8');
1192
+ }
1193
+ console.log(`customer-kit: ${ghSame ? 'unchanged' : 'wrote'} ${path.relative(platformRoot, ghTarget).replace(/\\/g, '/')}`);
1194
+ } else if (!ghSame) {
1195
+ console.error('customer-kit: .github/hooks/heyspex.json is stale - run `node scripts/lib/customer-kit.mjs --write-github-hooks`');
1196
+ process.exit(1);
1197
+ } else {
1198
+ console.log('customer-kit: .github/hooks/heyspex.json up to date');
1199
+ }
883
1200
  } else {
884
1201
  console.log('usage: node scripts/lib/customer-kit.mjs --write-plugin-hooks | --check-plugin-hooks');
885
1202
  }
@@ -47,6 +47,7 @@
47
47
  *
48
48
  * Zero deps beyond node builtins (same rule as console-url-resolver.mjs).
49
49
  */
50
+ import { heyspexHome } from './heyspex-home.mjs';
50
51
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
51
52
  import { homedir } from 'node:os';
52
53
  import { join } from 'node:path';
@@ -58,7 +59,7 @@ export const LOCAL_CONSOLE_URL = 'http://localhost:3000';
58
59
  export const DEV_MODE_FILENAME = 'dev-mode.json';
59
60
 
60
61
  /** Absolute path of the machine-local record. */
61
- export function devModeFilePath(homeDir = homedir()) {
62
+ export function devModeFilePath(homeDir = heyspexHome()) {
62
63
  return join(homeDir, '.heyspex', DEV_MODE_FILENAME);
63
64
  }
64
65
 
@@ -97,7 +98,7 @@ const defaultReadFile = (p) => readFileSync(p, 'utf8');
97
98
  * `readFile` is an injectable seam (same shape as the TS twin's) so the
98
99
  * parity test can feed both implementations the SAME in-memory fixture.
99
100
  */
100
- export function readLocalDevModeRecord({ homeDir = homedir(), readFile = defaultReadFile } = {}) {
101
+ export function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile = defaultReadFile } = {}) {
101
102
  let raw;
102
103
  try {
103
104
  raw = readFile(devModeFilePath(homeDir));
@@ -135,7 +136,7 @@ export function readLocalDevModeRecord({ homeDir = homedir(), readFile = default
135
136
  */
136
137
  export function resolveDevMode({
137
138
  env = process.env,
138
- homeDir = homedir(),
139
+ homeDir = heyspexHome(),
139
140
  readFile = defaultReadFile,
140
141
  cloudSetting = undefined,
141
142
  } = {}) {
@@ -382,7 +383,7 @@ export function upsertCredentialsYamlKeys(homeDir, updates) {
382
383
  * its historical name for the hook scripts that import it; the precedence is
383
384
  * the shared module's, never re-implemented here.
384
385
  */
385
- export function readCredentialsMcpToken({ env = process.env, homeDir = homedir() } = {}) {
386
+ export function readCredentialsMcpToken({ env = process.env, homeDir = heyspexHome() } = {}) {
386
387
  return readBearer({ env, homeDir }).token;
387
388
  }
388
389
 
@@ -432,7 +433,7 @@ async function discardResponseBody(res) {
432
433
  export async function fetchCloudDevMode({
433
434
  consoleUrl,
434
435
  fetchImpl = fetch,
435
- homeDir = homedir(),
436
+ homeDir = heyspexHome(),
436
437
  timeoutMs = 4000,
437
438
  } = {}) {
438
439
  if (!consoleUrl) return { ok: false, setting: undefined, error: 'no console url' };
@@ -488,7 +489,7 @@ export class DevModeNotAdminError extends Error {
488
489
  export async function applyDevMode(
489
490
  target,
490
491
  {
491
- homeDir = homedir(),
492
+ homeDir = heyspexHome(),
492
493
  fetchImpl = fetch,
493
494
  source = 'cli',
494
495
  now = () => new Date().toISOString(),