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
@@ -35,11 +35,26 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
35
35
  mod
36
36
  ));
37
37
 
38
+ // scripts/lib/heyspex-home.mjs
39
+ import { homedir } from "node:os";
40
+ function heyspexHome(env = process.env) {
41
+ const override = env?.HEYSPEX_HOME;
42
+ if (typeof override === "string" && override.trim().length > 0) {
43
+ return override.trim();
44
+ }
45
+ return homedir();
46
+ }
47
+ var init_heyspex_home = __esm({
48
+ "scripts/lib/heyspex-home.mjs"() {
49
+ "use strict";
50
+ }
51
+ });
52
+
38
53
  // vendor/heyspex-mcp/credentials.mjs
39
54
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
40
- import os, { homedir } from "node:os";
55
+ import os, { homedir as homedir2 } from "node:os";
41
56
  import { dirname, join, resolve } from "node:path";
42
- function credentialsFilePath(homeDir = homedir()) {
57
+ function credentialsFilePath(homeDir = heyspexHome()) {
43
58
  return join(homeDir, ".heyspex", "credentials.yaml");
44
59
  }
45
60
  function parseMcpToken(raw) {
@@ -152,14 +167,14 @@ function readLegacyConfigAccountId(read, homeDir) {
152
167
  const value = raw?.match(CONFIG_ACCOUNT_ID_RE)?.[1]?.trim() ?? "";
153
168
  return value || null;
154
169
  }
155
- function readBearer({ env = process.env, homeDir = homedir(), readFile, accountId } = {}) {
170
+ function readBearer({ env = process.env, homeDir = heyspexHome(), readFile, accountId } = {}) {
156
171
  const read = readFile ?? ((p) => readFileSync(p, "utf8"));
157
172
  const credentialsText = readTextOrNull(read, credentialsFilePath(homeDir));
158
173
  const named = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null;
159
174
  const legacyAccountId = named && parseCredentialsStore(credentialsText).schemaVersion === 1 ? readLegacyConfigAccountId(read, homeDir) : null;
160
175
  return resolveBearer({ envToken: env?.[MCP_TOKEN_ENV], credentialsText, accountId: named, legacyAccountId });
161
176
  }
162
- function readCurrentAccountId({ homeDir = homedir(), readFile } = {}) {
177
+ function readCurrentAccountId({ homeDir = heyspexHome(), readFile } = {}) {
163
178
  const read = readFile ?? ((p) => readFileSync(p, "utf8"));
164
179
  const store = parseCredentialsStore(readTextOrNull(read, credentialsFilePath(homeDir)));
165
180
  if (store.schemaVersion === 2 && store.currentAccountId && store.currentAccountId !== LEGACY_ACCOUNT_ID) {
@@ -177,6 +192,7 @@ var MCP_TOKEN_ENV, MCP_TOKEN_LINE_RE, CREDENTIALS_SCHEMA_VERSION, LEGACY_ACCOUNT
177
192
  var init_credentials = __esm({
178
193
  "vendor/heyspex-mcp/credentials.mjs"() {
179
194
  "use strict";
195
+ init_heyspex_home();
180
196
  MCP_TOKEN_ENV = "HEYSPEX_MCP_TOKEN";
181
197
  MCP_TOKEN_LINE_RE = /^mcp_token:[ \t]*["']?([^"'\r\n#]+)/m;
182
198
  CREDENTIALS_SCHEMA_VERSION = 2;
@@ -189,9 +205,9 @@ var init_credentials = __esm({
189
205
 
190
206
  // scripts/lib/dev-mode.mjs
191
207
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
192
- import { homedir as homedir2 } from "node:os";
208
+ import { homedir as homedir3 } from "node:os";
193
209
  import { join as join2 } from "node:path";
194
- function devModeFilePath(homeDir = homedir2()) {
210
+ function devModeFilePath(homeDir = heyspexHome()) {
195
211
  return join2(homeDir, ".heyspex", DEV_MODE_FILENAME);
196
212
  }
197
213
  function normalizeMode(raw) {
@@ -216,7 +232,7 @@ function deriveSemantics(mode, reliance) {
216
232
  vaultWriteMode: local ? "local" : "cloud"
217
233
  };
218
234
  }
219
- function readLocalDevModeRecord({ homeDir = homedir2(), readFile = defaultReadFile } = {}) {
235
+ function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile = defaultReadFile } = {}) {
220
236
  let raw;
221
237
  try {
222
238
  raw = readFile(devModeFilePath(homeDir));
@@ -246,7 +262,7 @@ function readLocalDevModeRecord({ homeDir = homedir2(), readFile = defaultReadFi
246
262
  }
247
263
  function resolveDevMode({
248
264
  env = process.env,
249
- homeDir = homedir2(),
265
+ homeDir = heyspexHome(),
250
266
  readFile = defaultReadFile,
251
267
  cloudSetting = void 0
252
268
  } = {}) {
@@ -309,6 +325,7 @@ var CLOUD_CONSOLE_URL, LOCAL_CONSOLE_URL, DEV_MODE_FILENAME, defaultReadFile;
309
325
  var init_dev_mode = __esm({
310
326
  "scripts/lib/dev-mode.mjs"() {
311
327
  "use strict";
328
+ init_heyspex_home();
312
329
  init_credentials();
313
330
  CLOUD_CONSOLE_URL = "https://app.heyspex.com";
314
331
  LOCAL_CONSOLE_URL = "http://localhost:3000";
@@ -982,20 +999,21 @@ var require_gray_matter = __commonJS({
982
999
  import { spawnSync as spawnSync3 } from "node:child_process";
983
1000
  import {
984
1001
  copyFileSync,
985
- existsSync as existsSync5,
986
- mkdirSync as mkdirSync5,
987
- readdirSync,
988
- readFileSync as readFileSync10,
1002
+ existsSync as existsSync6,
1003
+ mkdirSync as mkdirSync6,
1004
+ readdirSync as readdirSync2,
1005
+ readFileSync as readFileSync11,
989
1006
  statSync,
990
- writeFileSync as writeFileSync5
1007
+ writeFileSync as writeFileSync6
991
1008
  } from "node:fs";
992
- import { join as join11, resolve as resolve5 } from "node:path";
1009
+ import { join as join12, resolve as resolve5 } from "node:path";
993
1010
  import process4 from "node:process";
994
1011
 
995
1012
  // scripts/lib/console-url-resolver.mjs
1013
+ init_heyspex_home();
996
1014
  init_dev_mode();
997
1015
  import { readFileSync as readFileSync3 } from "node:fs";
998
- import { homedir as homedir3 } from "node:os";
1016
+ import { homedir as homedir4 } from "node:os";
999
1017
  import { join as join3 } from "node:path";
1000
1018
  var DEFAULT_CONSOLE_URL = LOCAL_CONSOLE_URL;
1001
1019
  function trimTrailingSlash(url) {
@@ -1022,7 +1040,7 @@ function warnStaleLoopbackCredentialsOnce(credentialsUrl, derivedUrl, { log }) {
1022
1040
  } catch {
1023
1041
  }
1024
1042
  }
1025
- function readCredentialsConsoleUrl({ homeDir = homedir3(), readFile = defaultReadFile2 } = {}) {
1043
+ function readCredentialsConsoleUrl({ homeDir = heyspexHome(), readFile = defaultReadFile2 } = {}) {
1026
1044
  try {
1027
1045
  const raw = readFile(join3(homeDir, ".heyspex", "credentials.yaml"));
1028
1046
  const match = String(raw ?? "").match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
@@ -1066,7 +1084,7 @@ function resolveModeEnvelope({ env, homeDir }) {
1066
1084
  function resolveConsoleUrl({
1067
1085
  repoRoot,
1068
1086
  env = process.env,
1069
- homeDir = homedir3(),
1087
+ homeDir = heyspexHome(),
1070
1088
  strict = false,
1071
1089
  devMode,
1072
1090
  log = (line) => process.stderr.write(line),
@@ -1136,17 +1154,19 @@ function shellQuote(p) {
1136
1154
  var HS_DISPATCHER_PREFIX = HS_DISPATCHER_PATH ? `node ${shellQuote(HS_DISPATCHER_PATH)}` : HS_BIN_NAME;
1137
1155
 
1138
1156
  // vendor/heyspex-mcp/console-fetch.mjs
1157
+ init_heyspex_home();
1139
1158
  init_credentials();
1140
- import { readFileSync as readFileSync6 } from "node:fs";
1141
- import { homedir as homedir6 } from "node:os";
1142
- import { join as join7 } from "node:path";
1159
+ import { readFileSync as readFileSync7 } from "node:fs";
1160
+ import { homedir as homedir7 } from "node:os";
1161
+ import { join as join8 } from "node:path";
1143
1162
 
1144
1163
  // vendor/heyspex-mcp/session-owner.mjs
1164
+ init_heyspex_home();
1145
1165
  init_credentials();
1146
1166
  import { readFileSync as readFileSync4 } from "node:fs";
1147
- import { homedir as homedir4 } from "node:os";
1167
+ import { homedir as homedir5 } from "node:os";
1148
1168
  import { join as join5 } from "node:path";
1149
- function resolveSessionOwnerUncached({ env = process.env, homeDir = homedir4(), readFile } = {}) {
1169
+ function resolveSessionOwnerUncached({ env = process.env, homeDir = heyspexHome(), readFile } = {}) {
1150
1170
  const fromEnv = typeof env?.HS_ACCOUNT_ID === "string" ? env.HS_ACCOUNT_ID.trim() : "";
1151
1171
  if (fromEnv) return fromEnv;
1152
1172
  return readCurrentAccountId({ homeDir, readFile });
@@ -1176,10 +1196,23 @@ function isVaultLessEndpoint(method, path5) {
1176
1196
  });
1177
1197
  }
1178
1198
 
1199
+ // vendor/heyspex-mcp/write-outbox.mjs
1200
+ import {
1201
+ readdirSync,
1202
+ readFileSync as readFileSync5,
1203
+ writeFileSync as writeFileSync3,
1204
+ mkdirSync as mkdirSync3,
1205
+ existsSync as existsSync2,
1206
+ renameSync as renameSync2,
1207
+ rmSync
1208
+ } from "node:fs";
1209
+ import { basename, join as join6, isAbsolute as isAbsolute2 } from "node:path";
1210
+
1179
1211
  // vendor/heyspex-mcp/vault-resolve.mjs
1180
- import { existsSync as existsSync2, readFileSync as readFileSync5 } from "node:fs";
1181
- import { homedir as homedir5, tmpdir } from "node:os";
1182
- import { dirname as dirname3, join as join6, resolve as resolve3 } from "node:path";
1212
+ init_heyspex_home();
1213
+ import { existsSync as existsSync3, readFileSync as readFileSync6 } from "node:fs";
1214
+ import { homedir as homedir6, tmpdir } from "node:os";
1215
+ import { dirname as dirname3, join as join7, resolve as resolve3 } from "node:path";
1183
1216
 
1184
1217
  // vendor/heyspex-mcp/console-fetch.mjs
1185
1218
  var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMATCH", "VAULT_NOT_REGISTERED"]);
@@ -1188,9 +1221,18 @@ var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMAT
1188
1221
  init_credentials();
1189
1222
 
1190
1223
  // spex-console/src/lib/vault-registry/registry-file.ts
1191
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "node:fs";
1192
- import { homedir as homedir7 } from "node:os";
1193
- import { dirname as dirname4, join as join8, resolve as resolve4 } from "node:path";
1224
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync8, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
1225
+ import { dirname as dirname4, join as join9, resolve as resolve4 } from "node:path";
1226
+
1227
+ // spex-console/src/lib/heyspex-home.ts
1228
+ import { homedir as homedir8 } from "node:os";
1229
+ function heyspexHome2(env = process.env) {
1230
+ const override = env?.HEYSPEX_HOME;
1231
+ if (typeof override === "string" && override.trim().length > 0) {
1232
+ return override.trim();
1233
+ }
1234
+ return homedir8();
1235
+ }
1194
1236
 
1195
1237
  // spex-console/src/lib/vault-registry/types.ts
1196
1238
  var LEGACY_REGISTRY_ACCOUNT_ID = "legacy";
@@ -1207,10 +1249,10 @@ function emptyVaultRegistry() {
1207
1249
 
1208
1250
  // spex-console/src/lib/vault-registry/registry-file.ts
1209
1251
  function vaultRegistryDir(opts = {}) {
1210
- return opts.registryDir ?? join8(homedir7(), ".heyspex");
1252
+ return opts.registryDir ?? join9(heyspexHome2(), ".heyspex");
1211
1253
  }
1212
1254
  function vaultRegistryFilePath(opts = {}) {
1213
- return join8(vaultRegistryDir(opts), "registry.json");
1255
+ return join9(vaultRegistryDir(opts), "registry.json");
1214
1256
  }
1215
1257
  function isNonEmptyString(v) {
1216
1258
  return typeof v === "string" && v.trim().length > 0;
@@ -1311,9 +1353,9 @@ function parseVaultRegistryFile(json) {
1311
1353
  }
1312
1354
  function readRegistryJson(opts) {
1313
1355
  const filePath = vaultRegistryFilePath(opts);
1314
- if (!existsSync3(filePath)) return { kind: "missing" };
1356
+ if (!existsSync4(filePath)) return { kind: "missing" };
1315
1357
  try {
1316
- const raw = readFileSync7(filePath, "utf8");
1358
+ const raw = readFileSync8(filePath, "utf8");
1317
1359
  const json = JSON.parse(raw);
1318
1360
  if (registryFileSchemaOf(json) === null) return { kind: "unreadable" };
1319
1361
  return { kind: "ok", raw, json };
@@ -1374,7 +1416,7 @@ function samePathLoose(a, b) {
1374
1416
  }
1375
1417
  function assertNotRealHomeRegistryUnderTest(filePath) {
1376
1418
  if (!underTestRunner()) return;
1377
- if (samePathLoose(dirname4(filePath), join8(homedir7(), ".heyspex"))) {
1419
+ if (samePathLoose(dirname4(filePath), join9(heyspexHome2(), ".heyspex"))) {
1378
1420
  throw new Error(
1379
1421
  `registry-file: refusing to write ${filePath} under a test runner \u2014 that is the real ~/.heyspex registry. Pass opts.registryDir (a temp dir).`
1380
1422
  );
@@ -1383,10 +1425,10 @@ function assertNotRealHomeRegistryUnderTest(filePath) {
1383
1425
  function writeVaultRegistry(registry, opts = {}) {
1384
1426
  const filePath = vaultRegistryFilePath(opts);
1385
1427
  assertNotRealHomeRegistryUnderTest(filePath);
1386
- mkdirSync3(dirname4(filePath), { recursive: true });
1428
+ mkdirSync4(dirname4(filePath), { recursive: true });
1387
1429
  const tmpPath = `${filePath}.tmp`;
1388
- writeFileSync3(tmpPath, serializeVaultRegistry(registry), "utf8");
1389
- renameSync2(tmpPath, filePath);
1430
+ writeFileSync4(tmpPath, serializeVaultRegistry(registry), "utf8");
1431
+ renameSync3(tmpPath, filePath);
1390
1432
  }
1391
1433
  function isValidId(id) {
1392
1434
  if (!isNonEmptyString(id)) return false;
@@ -1585,7 +1627,7 @@ function withCloudScope(deps, accountId, vaultId) {
1585
1627
  }
1586
1628
 
1587
1629
  // spex-console/src/lib/cli/install.ts
1588
- import { homedir as homedir10 } from "node:os";
1630
+ import { isAbsolute as isAbsolute3, resolve as resolveNodePath2 } from "node:path";
1589
1631
 
1590
1632
  // scripts/install-cursor-hooks.mjs
1591
1633
  import fs2 from "node:fs";
@@ -1602,10 +1644,13 @@ var HOOKS = [
1602
1644
  { event: "sessionStart", hook: "wip-checkpoint", why: "Surface the previous session WIP handoff before work resumes." },
1603
1645
  { event: "sessionStart", hook: "inject-dev-server-awareness", why: "Once-per-session reminder that dev-server status is a tool call, not a guess." },
1604
1646
  { event: "sessionStart", hook: "inject-coo-seat", why: "Inject the dev-factory COO seat mandate when the COO window is open." },
1647
+ { event: "sessionStart", hook: "inject-doctrine", why: "Inject the baseline SpexCode Doctrine edition at session start." },
1605
1648
  { event: "sessionStart", hook: "coo-guard-check", why: "Warn at session start when a breakdown has not been delegated." },
1606
1649
  { event: "sessionStart", hook: "check-auto-memory-budget", why: "Self-heal and budget-check the auto-memory index." },
1607
1650
  { event: "sessionStart", hook: "check-claude-md-budget", why: "Advisory budget check on the always-loaded agent contract." },
1608
1651
  { event: "sessionStart", hook: "check-mcp-freshness-hook", why: "Warn when the running MCP server is stale versus vendor." },
1652
+ { event: "sessionStart", hook: "git-reflog-audit", why: "Session-start git audit (stash/reset/rebase witness)." },
1653
+ { event: "sessionStart", hook: "vault-mirror-catchup", why: "Cloud write mode: detached hs pull --apply closes a stale disk mirror (HS-XJFQ)." },
1609
1654
  // ── Compaction ──────────────────────────────────────────────────────────
1610
1655
  // Claude re-runs SessionStart(source: compact) after compaction; Cursor fires
1611
1656
  // preCompact BEFORE it, so the shim relays this one as SessionStart(compact)
@@ -1619,6 +1664,7 @@ var HOOKS = [
1619
1664
  { event: "beforeSubmitPrompt", hook: "preflight-reminder", why: "Name the required procedure before a filing/destructive/protocol turn." },
1620
1665
  { event: "beforeSubmitPrompt", hook: "intent-classifier-trigger", why: "Route capture / research / settings / defer intents to the right skill." },
1621
1666
  { event: "beforeSubmitPrompt", hook: "inject-communication-style", why: "Re-assert the plain-language communication rules each turn." },
1667
+ { event: "beforeSubmitPrompt", hook: "inject-vault-context", timeout: 10, why: "Lexical vault retrieval for the prompt (surfaced as a user message)." },
1622
1668
  // ── Before every tool call (any tool) ───────────────────────────────────
1623
1669
  { event: "preToolUse", matcher: ".*", hook: "emit-claim-status", args: ["--event=PreToolUse"], why: "Claim liveness heartbeat to the console." },
1624
1670
  { event: "preToolUse", matcher: ".*", hook: "coo-denylist-gate", why: "Hard deny the COO-window deny list." },
@@ -1629,21 +1675,27 @@ var HOOKS = [
1629
1675
  // the vault guard because removing a profile file is the loss it exists to
1630
1676
  // prevent, and Claude had no separate delete tool to list.
1631
1677
  { event: "preToolUse", matcher: "Write|Delete", hook: "env-vault-guard", why: "Block destructive writes to the encrypted env vault (ALWAYS ON)." },
1678
+ { event: "preToolUse", matcher: "Write", hook: "vault-write-tier-guard", why: "Deny disk writes to vault specs in cloud write mode (HS-FCNH)." },
1632
1679
  { event: "preToolUse", matcher: "Write", hook: "settings-write-gate", why: "Require approval before .heyspex/config.yaml changes." },
1633
1680
  { event: "preToolUse", matcher: "Write", hook: "enforce-vault-naming", why: "Block vault filenames that violate the naming registry." },
1634
1681
  { event: "preToolUse", matcher: "Write", hook: "guard-auto-memory-writes", why: "Block new topic files in the auto-memory directory." },
1635
1682
  { event: "preToolUse", matcher: "Write", hook: "preflight-reminder", why: "Preflight reminder on the edit path itself." },
1636
1683
  { event: "preToolUse", matcher: "Write", hook: "claim-before-code-gate", why: "Deny the first code edit after plan exit with no claim." },
1684
+ { event: "preToolUse", matcher: "Write", hook: "directive-obligation-gate", why: "Deny product-code writes while a capture_on_idea obligation is unmet, and (claim-before-build) with no claim/capture (HS-20SW)." },
1637
1685
  // ── Before a shell command ──────────────────────────────────────────────
1638
1686
  // beforeShellExecution, not preToolUse/Shell: it is the only event guaranteed
1639
1687
  // to carry the command string, and Cursor honors allow/deny/ask on it.
1640
1688
  { event: "beforeShellExecution", hook: "env-vault-guard", why: "Block shell writes/exfiltration targeting the env vault (ALWAYS ON)." },
1689
+ { event: "beforeShellExecution", hook: "vault-write-tier-guard", why: "Deny shell writes to vault specs in cloud write mode (HS-FCNH)." },
1641
1690
  { event: "beforeShellExecution", hook: "settings-write-gate", why: "Catch shell writes to .heyspex/config.yaml." },
1642
1691
  { event: "beforeShellExecution", hook: "enforce-vault-naming", why: "Catch vault files created via the shell." },
1643
1692
  { event: "beforeShellExecution", hook: "guard-auto-memory-writes", why: "Catch auto-memory topic files created via the shell." },
1644
1693
  { event: "beforeShellExecution", hook: "preflight-reminder", why: "Preflight reminder on rm -rf / force-push shaped commands." },
1645
1694
  { event: "beforeShellExecution", hook: "dev-server-task-reminder", timeout: 10, why: "Dev-server start/stop hard gate plus DB-safety reminder." },
1646
1695
  { event: "beforeShellExecution", hook: "git-stash-gate", timeout: 10, why: "Always ask before a mutating git stash." },
1696
+ { event: "beforeShellExecution", hook: "git-identity-guard", why: "Agents never write git identity (owner ruling 2026-09-19, ALWAYS ON)." },
1697
+ { event: "beforeShellExecution", hook: "capture-execution-gate", why: "Block dev-factory research/paid-model/routing shell commands past an ungrounded capture (HS-KNJ8, platform)." },
1698
+ { event: "beforeShellExecution", hook: "directive-obligation-gate", why: "Deny build commands and product-code shell writes while a capture obligation is unmet (HS-20SW)." },
1647
1699
  // ── Before specific MCP tools ───────────────────────────────────────────
1648
1700
  // Cursor fires preToolUse for MCP tools too (matcher `MCP:<name>`), so these
1649
1701
  // stay on preToolUse rather than beforeMCPExecution — registering both would
@@ -1653,15 +1705,20 @@ var HOOKS = [
1653
1705
  { event: "preToolUse", matcher: ".*", tool: "heyspex_file_spec$", hook: "capture-workflow-gate", timeout: 8, why: "Deny filing an implementation spec straight out of an open capture." },
1654
1706
  { event: "preToolUse", matcher: ".*", tool: "heyspex_settings_set$", hook: "settings-write-gate", why: "Require approval for settings changes made through MCP." },
1655
1707
  { event: "preToolUse", matcher: ".*", tool: "[Nn]eon__(prepare_database_migration|complete_database_migration|reset_from_parent|delete_branch)$", hook: "dev-server-task-reminder", timeout: 10, why: "DB-safety gate on Neon migration/reset/delete tools." },
1708
+ { event: "preToolUse", matcher: ".*", tool: "heyspex_file_spec$", hook: "diagnose-capture-reminder", why: "Warn when a diagnose is filed with no capture holding the owner words (HS-IK2D)." },
1709
+ { event: "preToolUse", matcher: ".*", tool: "^(Workflow|Skill)$", hook: "research-method-guard", why: "Research method guard on Skill/Workflow launches (HS-EN4F, ALWAYS ON)." },
1656
1710
  { event: "preToolUse", matcher: ".*", tool: "^(Workflow|Skill)$", hook: "deep-research-cost-gate", why: "Cost confirmation before a paid deep-research harness launch." },
1657
1711
  { event: "preToolUse", matcher: ".*", tool: "^(WebSearch|WebFetch|web_search|web_fetch)$|heyspex_save_research$", hook: "research-protocol-gate", why: "WebSearch/WebFetch/save_research refused until the research preflight ran (HS-K0ZP, HS-EN4F)." },
1658
1712
  // ── Before a subagent spawn ─────────────────────────────────────────────
1659
1713
  // subagentStart over preToolUse/Task: it carries subagent_type and the model,
1660
1714
  // which is exactly what the routing gate decides on, and it can deny.
1661
1715
  { event: "subagentStart", hook: "agent-spawn-delegation-gate", why: "Block a build/breakdown/critic subagent on an off-routing model." },
1716
+ { event: "subagentStart", hook: "capture-execution-gate", why: "Block a dev-factory subagent spawn past an ungrounded capture (HS-KNJ8, platform)." },
1717
+ { event: "subagentStart", hook: "directive-obligation-gate", why: "Deny a build subagent while a capture obligation is unmet or the session has no claim/capture (HS-20SW)." },
1662
1718
  // ── After every tool call ───────────────────────────────────────────────
1663
1719
  { event: "postToolUse", matcher: ".*", hook: "emit-claim-status", args: ["--event=PostToolUse"], why: "Claim liveness heartbeat after the call resolves." },
1664
1720
  { event: "postToolUse", matcher: ".*", hook: "log-session-activity", args: ["--event=PostToolUse"], why: "Append the tool result row to the session log." },
1721
+ { event: "postToolUse", matcher: ".*", hook: "directive-obligation-gate", why: "Discharge the capture obligation on a capture write; track claim acquire/release (HS-20SW)." },
1665
1722
  { event: "postToolUse", matcher: ".*", hook: "close-terminal-on-ship", why: "Close the Launch terminal once the spec ships." },
1666
1723
  { event: "postToolUse", matcher: "Write", hook: "notify-status-flip", why: "Sound on an in_review status flip." },
1667
1724
  { event: "postToolUse", matcher: "Write", hook: "scripts/stamp-provenance-on-edit.mjs", why: "Stamp assisted_by provenance on direct spec edits." },
@@ -1674,7 +1731,11 @@ var HOOKS = [
1674
1731
  // hook re-drives the agent — the same effect as Claude's exit-2 block.
1675
1732
  { event: "stop", hook: "emit-claim-status", args: ["--event=Stop"], why: "Mark the claim pending-on-response." },
1676
1733
  { event: "stop", hook: "naughty-list-gate", why: "Re-drive the turn when the final message uses banned jargon." },
1734
+ { event: "stop", hook: "id-display-format-gate", why: "Re-drive the turn when a reply breaks the Naming V3 display-id format." },
1735
+ { event: "stop", hook: "epic-milestone-phrasing-gate", why: "Re-drive the turn when a reply breaks the epic/milestone phrasing rules." },
1736
+ { event: "stop", hook: "git-reflog-audit", why: "Session-close git audit backstop (stash/reset/rebase witness)." },
1677
1737
  { event: "stop", hook: "enforce-session-sequence", why: "Re-drive the turn when work shipped without record_wip." },
1738
+ { event: "stop", hook: "directive-obligation-gate", why: "Re-drive the turn ONCE (followup_message) when a capture obligation is still unmet (HS-20SW)." },
1678
1739
  // ── Session end ─────────────────────────────────────────────────────────
1679
1740
  { event: "sessionEnd", hook: "session-end", why: "Release the claim and notify the console." },
1680
1741
  { event: "sessionEnd", hook: "close-terminal-on-ship", why: "Final terminal cleanup." }
@@ -1718,7 +1779,134 @@ function timeoutFor(entry) {
1718
1779
  import { createHash } from "node:crypto";
1719
1780
  import fs from "node:fs";
1720
1781
  import path2 from "node:path";
1782
+ import os2 from "node:os";
1721
1783
  import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
1784
+
1785
+ // scripts/lib/hook-dispositions.mjs
1786
+ var HOSTS = Object.freeze(["claude-code", "cursor", "copilot"]);
1787
+ var DISPOSITIONS = Object.freeze(["mapped", "advisory-degraded", "unportable", "not-shipped"]);
1788
+ var mapped = (note) => Object.freeze({ disposition: "mapped", ...note ? { note } : {} });
1789
+ var degraded = (note) => Object.freeze({ disposition: "advisory-degraded", note });
1790
+ var unportable = (control) => Object.freeze({ disposition: "unportable", control });
1791
+ var notShipped = Object.freeze({ disposition: "not-shipped" });
1792
+ var CC = mapped();
1793
+ var COPILOT_PROMPT_DROPPED = "Copilot drops command-hook output on userPromptSubmitted (no prompt-time context channel); the text moves to sessionStart / .github/copilot-instructions.md.";
1794
+ var CURSOR_PROMPT_USER_MESSAGE = "Cursor beforeSubmitPrompt honors user_message only (no additional_context): the text surfaces in the transcript instead of as invisible context.";
1795
+ var COPILOT_NO_PLAN_TOOL = "MCP preflight refusals (heyspex_claim_spec / heyspex_submit_for_review / heyspex_save_research) + .github/copilot-instructions.md plan rule (state the plan in chat, claim the spec before editing) + HS-20SW directive-obligation-gate claim-before-build deny";
1796
+ var HOOK_DISPOSITIONS = Object.freeze({
1797
+ // ── customer: session start / compaction ──────────────────────────────────
1798
+ "wip-checkpoint": {
1799
+ "claude-code": CC,
1800
+ cursor: mapped("preCompact is relayed as SessionStart(compact)."),
1801
+ copilot: mapped("sessionStart additionalContext is delivered; the preCompact leg writes WIP only (no output channel), re-injected at the next sessionStart.")
1802
+ },
1803
+ "inject-doctrine": { "claude-code": CC, cursor: mapped(), copilot: mapped("sessionStart additionalContext.") },
1804
+ "check-auto-memory-budget": { "claude-code": CC, cursor: mapped(), copilot: mapped("sessionStart additionalContext.") },
1805
+ "check-claude-md-budget": { "claude-code": CC, cursor: mapped(), copilot: mapped("sessionStart additionalContext.") },
1806
+ "git-reflog-audit": { "claude-code": CC, cursor: mapped(), copilot: mapped("sessionStart additionalContext and agentStop.") },
1807
+ "vault-mirror-catchup": { "claude-code": CC, cursor: mapped(), copilot: mapped("Detached side effect on sessionStart.") },
1808
+ // ── customer: every user turn ─────────────────────────────────────────────
1809
+ "preflight-reminder": {
1810
+ "claude-code": CC,
1811
+ cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
1812
+ copilot: degraded(`${COPILOT_PROMPT_DROPPED} Real enforcement is the MCP preflight refusal.`)
1813
+ },
1814
+ "intent-classifier-trigger": {
1815
+ "claude-code": CC,
1816
+ cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
1817
+ copilot: degraded(
1818
+ `${COPILOT_PROMPT_DROPPED} The hook still runs and arms obligation state; the HS-20SW directive-obligation-gate turns that state into a hard deny.`
1819
+ )
1820
+ },
1821
+ "inject-communication-style": {
1822
+ "claude-code": CC,
1823
+ cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
1824
+ copilot: degraded(COPILOT_PROMPT_DROPPED)
1825
+ },
1826
+ "inject-vault-context": {
1827
+ "claude-code": CC,
1828
+ cursor: degraded(CURSOR_PROMPT_USER_MESSAGE),
1829
+ copilot: degraded(`${COPILOT_PROMPT_DROPPED} Per-prompt vault retrieval has no Copilot channel at all.`)
1830
+ },
1831
+ // ── customer: before a tool call (hard gates) ─────────────────────────────
1832
+ "git-identity-guard": { "claude-code": CC, cursor: mapped("beforeShellExecution. Always on."), copilot: mapped("preToolUse on bash/powershell. Always on.") },
1833
+ "vault-write-tier-guard": { "claude-code": CC, cursor: mapped(), copilot: mapped("preToolUse on create/edit/bash/powershell.") },
1834
+ "settings-write-gate": { "claude-code": CC, cursor: mapped(), copilot: mapped("preToolUse on create/edit/bash/powershell and the MCP settings tool.") },
1835
+ "emit-claim-status": { "claude-code": CC, cursor: mapped(), copilot: mapped("Telemetry on preToolUse/postToolUse/agentStop; the cloud agent needs a network allow rule for the console.") },
1836
+ "enforce-vault-naming": { "claude-code": CC, cursor: mapped(), copilot: mapped("preToolUse on create/bash/powershell.") },
1837
+ "guard-auto-memory-writes": { "claude-code": CC, cursor: mapped(), copilot: mapped("preToolUse on create/bash/powershell.") },
1838
+ "directive-obligation-gate": {
1839
+ "claude-code": CC,
1840
+ cursor: mapped(),
1841
+ copilot: mapped("HS-20SW: hard deny on task/create/edit/write-shaped shell while a capture obligation is unmet; the only hard capture-on-idea guarantee on Copilot.")
1842
+ },
1843
+ "claim-before-code-gate": {
1844
+ "claude-code": CC,
1845
+ cursor: degraded("Armed only by plan-exit-ingest-gate, which has no Cursor trigger, so it stays dormant; covered by the HS-20SW claim-before-build rule."),
1846
+ copilot: degraded("Armed only by plan-exit-ingest-gate, which has no Copilot trigger (no ExitPlanMode tool), so it stays dormant; covered by the HS-20SW claim-before-build rule.")
1847
+ },
1848
+ "capture-workflow-gate": {
1849
+ "claude-code": CC,
1850
+ cursor: mapped(),
1851
+ copilot: mapped("preToolUse on the MCP heyspex_file_spec tool; MCP tool naming in hook payloads is unverified live (matched by suffix).")
1852
+ },
1853
+ "diagnose-capture-reminder": {
1854
+ "claude-code": CC,
1855
+ cursor: mapped("preToolUse agent_message delivers the reminder."),
1856
+ copilot: degraded("Copilot preToolUse has no context channel for an inject, so this advisory is silent (lost).")
1857
+ },
1858
+ "git-stash-gate": {
1859
+ "claude-code": CC,
1860
+ cursor: mapped(),
1861
+ copilot: mapped('The Claude "ask" becomes a deny where no user is present (cloud agent, CLI autopilot).')
1862
+ },
1863
+ "research-protocol-gate": { "claude-code": CC, cursor: mapped("Always on."), copilot: mapped("preToolUse on web_fetch/web_search and the MCP save_research tool. Always on; the MCP refusal duplicates it.") },
1864
+ "research-method-guard": {
1865
+ "claude-code": CC,
1866
+ cursor: mapped("preToolUse with a Skill|Workflow tool filter. Always on."),
1867
+ copilot: unportable("MCP preflight refusal on heyspex_save_research (hard floor, hook-independent) + .github/copilot-instructions.md research rule; Copilot has no Skill or Workflow tool to hook.")
1868
+ },
1869
+ "deep-research-cost-gate": {
1870
+ "claude-code": CC,
1871
+ cursor: mapped("preToolUse with a Workflow|Skill tool filter."),
1872
+ copilot: unportable("MCP preflight refusal on heyspex_save_research + .github/copilot-instructions.md cost-confirmation rule; Copilot has no Skill or Workflow tool to hook.")
1873
+ },
1874
+ "plan-review-gate": {
1875
+ "claude-code": CC,
1876
+ cursor: unportable(".cursor/rules/plan-review-required.mdc (always-on rule)"),
1877
+ copilot: unportable(COPILOT_NO_PLAN_TOOL)
1878
+ },
1879
+ "plan-exit-ingest-gate": {
1880
+ "claude-code": CC,
1881
+ cursor: unportable(".cursor/rules/plan-review-required.mdc + spec-creation-linkage.mdc"),
1882
+ copilot: unportable(COPILOT_NO_PLAN_TOOL)
1883
+ },
1884
+ // ── customer: end of turn / session ───────────────────────────────────────
1885
+ "naughty-list-gate": { "claude-code": CC, cursor: mapped("stop followup_message re-drive."), copilot: mapped('agentStop {decision:"block"} re-drive, capped at 8 consecutive blocks.') },
1886
+ "id-display-format-gate": { "claude-code": CC, cursor: mapped("stop followup_message re-drive."), copilot: mapped("agentStop re-drive; reads the transcript (format is not a stable API).") },
1887
+ "epic-milestone-phrasing-gate": { "claude-code": CC, cursor: mapped("stop followup_message re-drive."), copilot: mapped("agentStop re-drive; reads the transcript (format is not a stable API).") },
1888
+ "enforce-session-sequence": { "claude-code": CC, cursor: mapped("stop followup_message re-drive."), copilot: mapped("agentStop re-drive, capped at 8 consecutive blocks.") },
1889
+ "notify-status-flip": { "claude-code": CC, cursor: mapped(), copilot: mapped("Side effect on postToolUse.") },
1890
+ "close-terminal-on-ship": { "claude-code": CC, cursor: mapped(), copilot: mapped("Side effect on postToolUse / sessionEnd.") },
1891
+ "scripts/stamp-provenance-on-edit.mjs": { "claude-code": CC, cursor: mapped(), copilot: mapped("Side effect on postToolUse.") },
1892
+ "session-end": { "claude-code": CC, cursor: mapped(), copilot: mapped("sessionEnd.") },
1893
+ // ── platform: heyspex-app development machinery (never installed in a customer repo) ──
1894
+ "inject-dev-server-awareness": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1895
+ "inject-coo-seat": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1896
+ "coo-guard-check": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1897
+ "coo-denylist-gate": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1898
+ "agent-spawn-delegation-gate": { "claude-code": CC, cursor: mapped("subagentStart."), copilot: notShipped },
1899
+ "capture-execution-gate": { "claude-code": CC, cursor: mapped("beforeShellExecution + subagentStart."), copilot: notShipped },
1900
+ "check-mcp-freshness-hook": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1901
+ "dev-server-task-reminder": { "claude-code": CC, cursor: mapped(), copilot: notShipped },
1902
+ "env-vault-guard": { "claude-code": CC, cursor: mapped("Always on."), copilot: notShipped },
1903
+ "log-session-activity": { "claude-code": CC, cursor: mapped(), copilot: notShipped }
1904
+ });
1905
+ function dispositionFor(hook, host) {
1906
+ return HOOK_DISPOSITIONS[hook]?.[host] ?? null;
1907
+ }
1908
+
1909
+ // scripts/lib/customer-kit.mjs
1722
1910
  var HOOK_SCOPES = Object.freeze({
1723
1911
  // ── customer: vault / workflow / safety gates ─────────────────────────────
1724
1912
  "wip-checkpoint": { scope: "customer", why: "Previous-session WIP handoff, read from the active vault." },
@@ -1734,6 +1922,7 @@ var HOOK_SCOPES = Object.freeze({
1734
1922
  "inject-communication-style": { scope: "customer", why: "Plain-language communication rules each turn." },
1735
1923
  "inject-vault-context": { scope: "customer", why: "Lexical vault retrieval for the prompt." },
1736
1924
  "git-identity-guard": { scope: "customer", why: "Agents never write git identity (owner ruling 2026-09-19)." },
1925
+ "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)." },
1737
1926
  "settings-write-gate": { scope: "customer", why: "Approval before the vault's .heyspex/config.yaml changes." },
1738
1927
  "emit-claim-status": { scope: "customer", why: "Claim liveness heartbeat to the console." },
1739
1928
  "enforce-vault-naming": { scope: "customer", why: "Naming V3 filename guard (HS-RY2N)." },
@@ -1741,7 +1930,10 @@ var HOOK_SCOPES = Object.freeze({
1741
1930
  "plan-review-gate": { scope: "customer", why: "Plan review + QC markers before ExitPlanMode." },
1742
1931
  "plan-exit-ingest-gate": { scope: "customer", why: "Plan ingest on ExitPlanMode; arms claim-before-code." },
1743
1932
  "claim-before-code-gate": { scope: "customer", why: "First code edit after plan exit needs a claim." },
1933
+ "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)." },
1744
1934
  "capture-workflow-gate": { scope: "customer", why: "No implementation spec straight out of an open capture." },
1935
+ "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)." },
1936
+ "diagnose-capture-reminder": { scope: "customer", why: "Warn when a diagnose is filed with no capture holding the owner's words (HS-IK2D)." },
1745
1937
  "git-stash-gate": { scope: "customer", why: "Always ask before a mutating git stash." },
1746
1938
  "research-method-guard": { scope: "customer", why: "Research method guard (HS-EN4F)." },
1747
1939
  "deep-research-cost-gate": { scope: "customer", why: "Cost confirmation before a paid deep-research launch." },
@@ -1760,6 +1952,7 @@ var HOOK_SCOPES = Object.freeze({
1760
1952
  "coo-guard-check": { scope: "platform", why: "dev-factory breakdown-delegation guard." },
1761
1953
  "coo-denylist-gate": { scope: "platform", why: "dev-factory COO window deny list." },
1762
1954
  "agent-spawn-delegation-gate": { scope: "platform", why: "dev-factory seat routing for subagents." },
1955
+ "capture-execution-gate": { scope: "platform", why: "Blocks dev-factory research/paid-model/routing surfaces past an ungrounded capture (HS-KNJ8)." },
1763
1956
  "check-mcp-freshness-hook": { scope: "platform", why: "Freshness of the vendored MCP build \u2014 a platform-dev concern." },
1764
1957
  "dev-server-task-reminder": { scope: "platform", why: "spex-console dev server + Neon DB safety." },
1765
1958
  "env-vault-guard": { scope: "platform", why: "Guards heyspex-app's encrypted env vault paths." },
@@ -1778,6 +1971,7 @@ var CLAUDE_HOOK_ROWS = Object.freeze([
1778
1971
  { event: "SessionStart", hook: "check-claude-md-budget", timeout: 5 },
1779
1972
  { event: "SessionStart", hook: "check-mcp-freshness-hook", timeout: 5 },
1780
1973
  { event: "SessionStart", hook: "git-reflog-audit", timeout: 5 },
1974
+ { event: "SessionStart", hook: "vault-mirror-catchup", timeout: 5 },
1781
1975
  { event: "PreCompact", hook: "wip-checkpoint", timeout: 5 },
1782
1976
  { event: "UserPromptSubmit", hook: "preflight-reminder", timeout: 5 },
1783
1977
  { event: "UserPromptSubmit", hook: "intent-classifier-trigger", timeout: 5 },
@@ -1785,10 +1979,13 @@ var CLAUDE_HOOK_ROWS = Object.freeze([
1785
1979
  { event: "UserPromptSubmit", hook: "inject-vault-context", timeout: 10 },
1786
1980
  { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell", hook: "env-vault-guard", timeout: 5 },
1787
1981
  { event: "PreToolUse", matcher: "Bash|PowerShell", hook: "git-identity-guard", timeout: 5 },
1982
+ { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash|PowerShell", hook: "vault-write-tier-guard", timeout: 5 },
1788
1983
  { event: "PreToolUse", matcher: "Write|Edit|NotebookEdit|Bash|PowerShell|mcp__heyspex__heyspex_settings_set", hook: "settings-write-gate", timeout: 5 },
1789
1984
  { event: "PreToolUse", matcher: ".*", hook: "emit-claim-status", args: ["--event=PreToolUse"], timeout: 5 },
1790
1985
  { event: "PreToolUse", matcher: ".*", hook: "coo-denylist-gate", timeout: 5 },
1791
1986
  { event: "PreToolUse", matcher: "Agent|Task", hook: "agent-spawn-delegation-gate", timeout: 5 },
1987
+ { event: "PreToolUse", matcher: "Bash|PowerShell|Agent|Task", hook: "capture-execution-gate", timeout: 5 },
1988
+ { event: "PreToolUse", matcher: "Agent|Task|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell", hook: "directive-obligation-gate", timeout: 5 },
1792
1989
  { event: "PreToolUse", matcher: "mcp__heyspex__heyspex_claim_spec", hook: "coo-guard-check", timeout: 5 },
1793
1990
  { event: "PreToolUse", matcher: "Write|NotebookEdit|Bash|PowerShell", hook: "enforce-vault-naming", timeout: 5 },
1794
1991
  { event: "PreToolUse", matcher: "Write|NotebookEdit|Bash|PowerShell", hook: "guard-auto-memory-writes", timeout: 5 },
@@ -1797,6 +1994,7 @@ var CLAUDE_HOOK_ROWS = Object.freeze([
1797
1994
  { event: "PreToolUse", matcher: "ExitPlanMode", hook: "plan-exit-ingest-gate", timeout: 5 },
1798
1995
  { event: "PreToolUse", matcher: "Edit|MultiEdit|Write", hook: "claim-before-code-gate", timeout: 5 },
1799
1996
  { event: "PreToolUse", matcher: "mcp__heyspex__heyspex_file_spec", hook: "capture-workflow-gate", timeout: 8 },
1997
+ { event: "PreToolUse", matcher: "mcp__heyspex__heyspex_file_spec", hook: "diagnose-capture-reminder", timeout: 5 },
1800
1998
  { 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 },
1801
1999
  { event: "PreToolUse", matcher: "Bash|PowerShell", hook: "git-stash-gate", timeout: 10 },
1802
2000
  { event: "PreToolUse", matcher: "Skill|Workflow", hook: "research-method-guard", timeout: 5 },
@@ -1809,7 +2007,9 @@ var CLAUDE_HOOK_ROWS = Object.freeze([
1809
2007
  { event: "Stop", hook: "epic-milestone-phrasing-gate", timeout: 5 },
1810
2008
  { event: "Stop", hook: "git-reflog-audit", timeout: 5 },
1811
2009
  { event: "Stop", hook: "enforce-session-sequence", timeout: 5 },
2010
+ { event: "Stop", hook: "directive-obligation-gate", timeout: 5 },
1812
2011
  { event: "PostToolUse", matcher: ".*", hook: "emit-claim-status", args: ["--event=PostToolUse"], timeout: 5 },
2012
+ { 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 },
1813
2013
  { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", hook: "notify-status-flip", timeout: 5 },
1814
2014
  { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", hook: "close-terminal-on-ship", timeout: 5 },
1815
2015
  { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", hook: "scripts/stamp-provenance-on-edit.mjs", timeout: 5 },
@@ -1894,9 +2094,22 @@ var CUSTOMER_LAUNCHERS = Object.freeze({
1894
2094
  });
1895
2095
  function customerRunnerPrefix(_platformRoot, surface = "claude") {
1896
2096
  const l = CUSTOMER_LAUNCHERS[surface];
2097
+ if (surface === "claude") return CUSTOMER_RUNNER_PREFIX;
1897
2098
  return `node "$${l.projectDirEnv}/${l.rel.join("/")}"`;
1898
2099
  }
1899
- var PLUGIN_RUNNER_PREFIX = 'node "${CLAUDE_PLUGIN_ROOT}/bin/run-hook.mjs"';
2100
+ var BOOTSTRAP_WALK = "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}}";
2101
+ function buildBootstrap({ plugin = false } = {}) {
2102
+ const walk = BOOTSTRAP_WALK;
2103
+ const body = plugin ? "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;if(m&&f.existsSync(p.join(m,'bin','run-hook.mjs')))s=p.join(m,'bin','run-hook.mjs');if(!s){" + walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}}" : "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=['scripts','claude-hooks','_run.mjs'],s='';" + walk + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}";
2104
+ return body + "if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});" + (plugin ? "//bin/run-hook.mjs" : "//scripts/claude-hooks/_run.mjs");
2105
+ }
2106
+ var PLATFORM_RUNNER_PREFIX = 'node -e "' + buildBootstrap() + '"';
2107
+ var PLUGIN_RUNNER_PREFIX = 'node -e "' + buildBootstrap({ plugin: true }) + '"';
2108
+ function buildCustomerBootstrap() {
2109
+ const rel = CUSTOMER_LAUNCHERS.claude.rel.map((s) => "'" + s + "'").join(",");
2110
+ return "var f=require('fs'),p=require('path'),u=require('url'),e=process.env,n=[" + rel + "],s='';" + BOOTSTRAP_WALK + "if(r){e.CLAUDE_PROJECT_DIR=r;s=p.join(r,...n)}if(!s)process.exit(0);process.argv=[process.argv[0],s].concat(process.argv.slice(1));import(u.pathToFileURL(s).href).catch(function(){process.exit(0)});//" + CUSTOMER_LAUNCHERS.claude.rel.join("/");
2111
+ }
2112
+ var CUSTOMER_RUNNER_PREFIX = 'node -e "' + buildCustomerBootstrap() + '"';
1900
2113
  var LAUNCHER_TEMPLATE = [
1901
2114
  "#!/usr/bin/env node",
1902
2115
  "// HeySpex hook launcher -- GENERATED by hs install / hs connect. Commit it with this repo.",
@@ -1974,7 +2187,12 @@ var LAUNCHER_TEMPLATE = [
1974
2187
  "// (the Cursor one) runs exactly as if Cursor had invoked it directly.",
1975
2188
  "const shim = resolve(root, ...SHIM);",
1976
2189
  "process.argv[1] = shim;",
1977
- "await import(pathToFileURL(shim).href);",
2190
+ "try {",
2191
+ " await import(pathToFileURL(shim).href);",
2192
+ "} catch (err) {",
2193
+ " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
2194
+ " process.exit(0);",
2195
+ "}",
1978
2196
  ""
1979
2197
  ].join("\n");
1980
2198
  function renderCustomerLauncher(surface = "claude") {
@@ -2081,7 +2299,6 @@ function renderPluginHooksDocument() {
2081
2299
  });
2082
2300
  }
2083
2301
  return {
2084
- _comment: "GENERATED from scripts/lib/customer-kit.mjs (customer-scoped rows only) \u2014 do not edit by hand; run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`. Every command goes through bin/run-hook.mjs, which finds the hook runtime (HEYSPEX_PLATFORM_ROOT / HEYSPEX_MCP_ENTRY when set in the machine's own env, then the plugin's own location, node_modules/heyspex, $CLAUDE_PROJECT_DIR) \u2014 never $CLAUDE_PROJECT_DIR/scripts, which only exists in heyspex-app. WARNING: a repo whose .claude/settings.json already carries the hs install kit hooks runs every hook twice if this plugin is also enabled \u2014 use one or the other.",
2085
2302
  hooks
2086
2303
  };
2087
2304
  }
@@ -2089,6 +2306,75 @@ function renderPluginHooksText() {
2089
2306
  return `${JSON.stringify(renderPluginHooksDocument(), null, 2)}
2090
2307
  `;
2091
2308
  }
2309
+ var COPILOT_EVENT_NAMES = Object.freeze({
2310
+ SessionStart: "sessionStart",
2311
+ SessionEnd: "sessionEnd",
2312
+ UserPromptSubmit: "userPromptSubmitted",
2313
+ PreToolUse: "preToolUse",
2314
+ PostToolUse: "postToolUse",
2315
+ Stop: "agentStop",
2316
+ PreCompact: "preCompact"
2317
+ });
2318
+ var GITHUB_HOOKS_STATE_ARMING = Object.freeze(["intent-classifier-trigger"]);
2319
+ var COPILOT_TOOL_NAMES = Object.freeze({
2320
+ Write: "create",
2321
+ Edit: "edit",
2322
+ MultiEdit: "edit",
2323
+ NotebookEdit: "edit",
2324
+ Bash: "bash",
2325
+ PowerShell: "powershell",
2326
+ Agent: "task",
2327
+ Task: "task",
2328
+ WebFetch: "web_fetch",
2329
+ WebSearch: "web_search"
2330
+ });
2331
+ function toCopilotMatcher(matcher) {
2332
+ if (!matcher) return void 0;
2333
+ const out = [];
2334
+ for (const part of matcher.split("|")) {
2335
+ let t = null;
2336
+ if (part === ".*") t = ".*";
2337
+ else if (COPILOT_TOOL_NAMES[part]) t = COPILOT_TOOL_NAMES[part];
2338
+ else if (part.startsWith(MCP_PROJECT_PREFIX)) t = ".*" + part.slice(MCP_PROJECT_PREFIX.length);
2339
+ else if (part.startsWith(MCP_PLUGIN_PREFIX)) t = ".*" + part.slice(MCP_PLUGIN_PREFIX.length);
2340
+ if (t && !out.includes(t)) out.push(t);
2341
+ }
2342
+ if (out.includes(".*")) return ".*";
2343
+ return out.length ? out.join("|") : null;
2344
+ }
2345
+ function githubHookNames() {
2346
+ const names = /* @__PURE__ */ new Set();
2347
+ for (const r of customerHookRows()) {
2348
+ if (dispositionFor(r.hook, "copilot")?.disposition === "mapped" || GITHUB_HOOKS_STATE_ARMING.includes(r.hook)) names.add(r.hook);
2349
+ }
2350
+ return names;
2351
+ }
2352
+ function githubHookCommand(row) {
2353
+ const tail = hookTail(row);
2354
+ return `R=node_modules/heyspex/scripts/claude-hooks/_run.mjs; [ -f "$R" ] || R=./scripts/claude-hooks/_run.mjs; [ -f "$R" ] || exit 0; exec node "$R" ${tail}`;
2355
+ }
2356
+ function renderGithubHooksDocument() {
2357
+ const allowed = githubHookNames();
2358
+ const hooks = {};
2359
+ for (const row of customerHookRows()) {
2360
+ if (!allowed.has(row.hook)) continue;
2361
+ const event = COPILOT_EVENT_NAMES[row.event];
2362
+ if (!event) continue;
2363
+ const matcher = toCopilotMatcher(row.matcher);
2364
+ if (matcher === null) continue;
2365
+ (hooks[event] ??= []).push({
2366
+ type: "command",
2367
+ ...matcher ? { matcher } : {},
2368
+ bash: githubHookCommand(row),
2369
+ timeoutSec: row.timeout
2370
+ });
2371
+ }
2372
+ return { version: 1, hooks };
2373
+ }
2374
+ function renderGithubHooksText() {
2375
+ return `${JSON.stringify(renderGithubHooksDocument(), null, 2)}
2376
+ `;
2377
+ }
2092
2378
  var HARNESS_COMMAND_SURFACES = Object.freeze([
2093
2379
  {
2094
2380
  installedDir: Object.freeze([".claude", "commands"]),
@@ -2221,6 +2507,22 @@ if (invokedDirectly(import.meta.url, "customer-kit.mjs")) {
2221
2507
  process.exit(1);
2222
2508
  }
2223
2509
  console.log("customer-kit: plugins/heyspex/hooks/hooks.json up to date");
2510
+ } else if (process.argv.includes("--write-github-hooks") || process.argv.includes("--check-github-hooks")) {
2511
+ const ghTarget = path2.join(platformRoot, ".github", "hooks", "heyspex.json");
2512
+ const ghWant = renderGithubHooksText();
2513
+ const ghSame = (fsDeps.readFile(ghTarget) ?? "").replace(/\r\n/g, "\n") === ghWant;
2514
+ if (process.argv.includes("--write-github-hooks")) {
2515
+ if (!ghSame) {
2516
+ fs.mkdirSync(path2.dirname(ghTarget), { recursive: true });
2517
+ fs.writeFileSync(ghTarget, ghWant, "utf8");
2518
+ }
2519
+ console.log(`customer-kit: ${ghSame ? "unchanged" : "wrote"} ${path2.relative(platformRoot, ghTarget).replace(/\\/g, "/")}`);
2520
+ } else if (!ghSame) {
2521
+ console.error("customer-kit: .github/hooks/heyspex.json is stale - run `node scripts/lib/customer-kit.mjs --write-github-hooks`");
2522
+ process.exit(1);
2523
+ } else {
2524
+ console.log("customer-kit: .github/hooks/heyspex.json up to date");
2525
+ }
2224
2526
  } else {
2225
2527
  console.log("usage: node scripts/lib/customer-kit.mjs --write-plugin-hooks | --check-plugin-hooks");
2226
2528
  }
@@ -2541,15 +2843,13 @@ if (invokedDirectly(import.meta.url, "install-cursor-hooks.mjs")) {
2541
2843
  init_credentials();
2542
2844
 
2543
2845
  // spex-console/src/lib/cli/connect-checks.ts
2544
- import { homedir as homedir9 } from "node:os";
2545
2846
  import { dirname as dirname5 } from "node:path";
2546
2847
  import { pathToFileURL as pathToFileURL2 } from "node:url";
2547
2848
 
2548
2849
  // spex-console/src/lib/dev-mode.ts
2850
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
2851
+ import { join as join10 } from "node:path";
2549
2852
  init_credentials();
2550
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "node:fs";
2551
- import { homedir as homedir8 } from "node:os";
2552
- import { join as join9 } from "node:path";
2553
2853
 
2554
2854
  // spex-console/src/lib/cli/connect-checks.ts
2555
2855
  init_credentials();
@@ -2738,7 +3038,7 @@ function runInit(args, deps) {
2738
3038
  };
2739
3039
  }
2740
3040
  const vaultId = existingVaultId ?? args.adoptedVaultId ?? mintVaultId();
2741
- const accountId = args.accountId ?? (existingContent ? parseAccountId(existingContent) : null);
3041
+ const accountId = null;
2742
3042
  const content = renderHeyspexYaml({ ...args, vaultId, accountId });
2743
3043
  try {
2744
3044
  deps.writeFile(markerPath, content);
@@ -2835,6 +3135,11 @@ function normalizePathForCompare(p) {
2835
3135
  function samePath(a, b) {
2836
3136
  return normalizePathForCompare(a) === normalizePathForCompare(b);
2837
3137
  }
3138
+ function isAtOrUnder(child, parent) {
3139
+ const c = normalizePathForCompare(child);
3140
+ const p = normalizePathForCompare(parent);
3141
+ return c === p || c.startsWith(`${p}/`);
3142
+ }
2838
3143
  function isAbsolutePathForCompare(p) {
2839
3144
  const s = p.trim();
2840
3145
  return /^[\\/]/.test(s) || /^[a-zA-Z]:[\\/]/.test(s);
@@ -2843,6 +3148,62 @@ function defaultResolveCliPath(p) {
2843
3148
  const base = process2.env.HS_INVOKED_CWD?.trim() || process2.cwd();
2844
3149
  return resolveNodePath(base, p);
2845
3150
  }
3151
+ function expandPinPlaceholders(value, repoRoot) {
3152
+ return value.replace(/\$\{(?:CLAUDE_PROJECT_DIR|workspaceFolder)(?::-[^}]*)?\}/g, repoRoot);
3153
+ }
3154
+ function lexicalJoin(base, rel) {
3155
+ const parts = `${base}/${rel}`.replace(/\\/g, "/").split("/");
3156
+ const out = [];
3157
+ for (const seg of parts) {
3158
+ if (seg === "" && out.length > 0) continue;
3159
+ if (seg === ".") continue;
3160
+ if (seg === ".." && out.length > 1) {
3161
+ out.pop();
3162
+ continue;
3163
+ }
3164
+ out.push(seg);
3165
+ }
3166
+ return out.join("/");
3167
+ }
3168
+ function parseMarkerVaultPath(content) {
3169
+ const m = content.match(/^vault_path:[ \t]*(.+?)[ \t]*$/m);
3170
+ if (!m) return null;
3171
+ const v = m[1].trim().replace(/\s+#.*$/, "").replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
3172
+ if (!v || v.includes("${") || /^[\\/]/.test(v) || /^[a-zA-Z]:[\\/]/.test(v)) return null;
3173
+ return v;
3174
+ }
3175
+ function markerVaultIdOf(content) {
3176
+ const m = content.match(/^vault_id:\s*(.+?)\s*$/m);
3177
+ if (!m) return null;
3178
+ const v = m[1].trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
3179
+ return v.length > 0 ? v : null;
3180
+ }
3181
+ function detectMarkerAttachment(repoRoot, deps) {
3182
+ const markerPath = deps.joinPath(repoRoot, "heyspex.yaml");
3183
+ if (!deps.fileExists(markerPath)) return null;
3184
+ const raw = deps.readFile(markerPath);
3185
+ const vaultPath = raw ? parseMarkerVaultPath(raw) : null;
3186
+ if (!raw || !vaultPath) return null;
3187
+ const vaultRootAbs = lexicalJoin(repoRoot, vaultPath);
3188
+ const markerId = markerVaultIdOf(raw);
3189
+ let cloned = deps.fileExists(deps.joinPath(vaultRootAbs, "specs"));
3190
+ if (cloned && markerId) {
3191
+ const theirs = deps.readFile(deps.joinPath(vaultRootAbs, "heyspex.yaml"));
3192
+ const theirId = theirs ? markerVaultIdOf(theirs) : null;
3193
+ if (theirId && theirId !== markerId) cloned = false;
3194
+ }
3195
+ const registry = loadVaultRegistry(deps.registryOpts);
3196
+ const record = registry.vaults.find((v) => samePath(v.repo_root_abs, vaultRootAbs));
3197
+ return {
3198
+ vaultRootAbs,
3199
+ vaultId: markerId ?? record?.id ?? null,
3200
+ pins: [{ source: "heyspex.yaml", value: vaultRootAbs }],
3201
+ conflicting: false,
3202
+ viaMarker: true,
3203
+ cloned,
3204
+ vaultPath
3205
+ };
3206
+ }
2846
3207
  function readPinnedVaultRoot(repoRoot, deps) {
2847
3208
  const pins = [];
2848
3209
  for (const rel of SATELLITE_MANIFEST_RELS) {
@@ -2859,9 +3220,13 @@ function readPinnedVaultRoot(repoRoot, deps) {
2859
3220
  }
2860
3221
  if (typeof value !== "string") continue;
2861
3222
  const trimmed = value.trim();
2862
- if (!trimmed || trimmed.includes("${")) continue;
2863
- if (!isAbsolutePathForCompare(trimmed)) continue;
2864
- pins.push({ source: rel.join("/"), value: trimmed });
3223
+ if (!trimmed) continue;
3224
+ const expanded = expandPinPlaceholders(trimmed, repoRoot);
3225
+ if (expanded.includes("${")) continue;
3226
+ if (/^[a-zA-Z]:(?![\\/])/.test(expanded)) continue;
3227
+ const abs = isAbsolutePathForCompare(expanded) ? expanded.includes("..") ? lexicalJoin(expanded, ".") : expanded : lexicalJoin(repoRoot, expanded);
3228
+ if (expanded !== trimmed && isAtOrUnder(abs, repoRoot)) continue;
3229
+ pins.push({ source: rel.join("/"), value: abs });
2865
3230
  }
2866
3231
  return pins;
2867
3232
  }
@@ -3040,7 +3405,7 @@ function writeProjectContract(ctx, options2 = {}) {
3040
3405
  }
3041
3406
 
3042
3407
  // spex-console/src/lib/cli/init.customer-vault.ts
3043
- import { basename } from "node:path";
3408
+ import { basename as basename2 } from "node:path";
3044
3409
 
3045
3410
  // spex-console/src/lib/cli/template-manifest.sources.mjs
3046
3411
  var UNIVERSAL_TEMPLATE_ROWS = [
@@ -3551,7 +3916,7 @@ function findManagedSpecs(deps, targetDir, manifestDests) {
3551
3916
  return out;
3552
3917
  }
3553
3918
  function defaultVaultName(targetDir) {
3554
- return basename(targetDir) || targetDir;
3919
+ return basename2(targetDir) || targetDir;
3555
3920
  }
3556
3921
  function runCustomerVaultInit(args, deps, options2 = {}) {
3557
3922
  const { targetDir } = args;
@@ -3699,14 +4064,14 @@ function templatePinHook() {
3699
4064
  }
3700
4065
 
3701
4066
  // spex-console/src/lib/cli/principle-selection.ts
3702
- import { basename as basename2 } from "node:path";
4067
+ import { basename as basename3 } from "node:path";
3703
4068
  var PRINCIPLES_INDEX_DEST = "specs/principles-index.md";
3704
4069
  function principleKey(row) {
3705
- const base = basename2(row.dest, ".md");
4070
+ const base = basename3(row.dest, ".md");
3706
4071
  return base.startsWith("principle-") ? base.slice("principle-".length) : base;
3707
4072
  }
3708
4073
  function principleWikilink(row) {
3709
- return basename2(row.dest, ".md");
4074
+ return basename3(row.dest, ".md");
3710
4075
  }
3711
4076
  function parsePrinciplesFlag(value, rows) {
3712
4077
  const principleRows = rows.filter((row) => row.group === "principle");
@@ -4101,8 +4466,7 @@ function formatReceipt(receipt) {
4101
4466
  }
4102
4467
 
4103
4468
  // spex-console/src/lib/cli/vault-workspace-shared.ts
4104
- import { readFileSync as readFileSync9 } from "node:fs";
4105
- import os2 from "node:os";
4469
+ import { readFileSync as readFileSync10 } from "node:fs";
4106
4470
  var VAULT_OWNED_BY_OTHER_ACCOUNT = "vault_owned_by_other_account";
4107
4471
  var WORKSPACE_OWNED_BY_OTHER_ACCOUNT = "workspace_owned_by_other_account";
4108
4472
  function vaultOwnedByOtherAccountMessage(id) {
@@ -4981,6 +5345,9 @@ function parseInstallArgs(argv) {
4981
5345
  let plugin = false;
4982
5346
  let pluginApply = false;
4983
5347
  let pluginScope = "project";
5348
+ let copilotHooks = false;
5349
+ let copilotPlugin = false;
5350
+ let copilotPluginApply = false;
4984
5351
  let statusline = true;
4985
5352
  let commandCode = false;
4986
5353
  let force = false;
@@ -5040,6 +5407,11 @@ function parseInstallArgs(argv) {
5040
5407
  }
5041
5408
  pluginScope = v.trim();
5042
5409
  plugin = true;
5410
+ } else if (arg === "--copilot-hooks") copilotHooks = true;
5411
+ else if (arg === "--copilot-plugin") copilotPlugin = true;
5412
+ else if (arg === "--copilot-plugin-apply") {
5413
+ copilotPlugin = true;
5414
+ copilotPluginApply = true;
5043
5415
  } else if (arg === "--no-statusline") statusline = false;
5044
5416
  else if (arg === "--cc" || arg === "--command-code") commandCode = true;
5045
5417
  else if (arg === "--cursor-only") {
@@ -5087,6 +5459,9 @@ function parseInstallArgs(argv) {
5087
5459
  plugin,
5088
5460
  pluginApply,
5089
5461
  pluginScope,
5462
+ copilotHooks,
5463
+ copilotPlugin,
5464
+ copilotPluginApply,
5090
5465
  statusline,
5091
5466
  commandCode,
5092
5467
  force,
@@ -5104,8 +5479,15 @@ function parseInstallArgs(argv) {
5104
5479
  function mcpCliPath(platformRoot, joinPath) {
5105
5480
  return joinPath(platformRoot, ...MCP_REL);
5106
5481
  }
5482
+ function resolveCallerPath(p, env, fallbackCwd) {
5483
+ const trimmed = p?.trim() ?? "";
5484
+ if (!trimmed) return "";
5485
+ if (isAbsolute3(trimmed)) return trimmed;
5486
+ const base = env.HS_INVOKED_CWD?.trim() || fallbackCwd;
5487
+ return resolveNodePath2(base, trimmed);
5488
+ }
5107
5489
  function resolveInstallTargetDir(argsTargetDir, env, fallbackCwd, opts) {
5108
- const explicit = argsTargetDir && argsTargetDir.trim();
5490
+ const explicit = resolveCallerPath(argsTargetDir, env, fallbackCwd);
5109
5491
  if (opts?.satellite) {
5110
5492
  return explicit || env.HS_INVOKED_CWD?.trim() || fallbackCwd;
5111
5493
  }
@@ -5176,7 +5558,7 @@ function mergeAttachedRemoteMcpJson(existingJson, pinEntry, cloudEntry, preserve
5176
5558
  }
5177
5559
  function resolveInstallBearer(deps) {
5178
5560
  const env = deps.env ?? process.env;
5179
- const homeDir = deps.homeDir ?? homedir10();
5561
+ const homeDir = deps.homeDir ?? heyspexHome2();
5180
5562
  const { source } = resolveBearer({
5181
5563
  envToken: env[REMOTE_MCP_TOKEN_ENV],
5182
5564
  credentialsText: deps.readFile(deps.joinPath(homeDir, ".heyspex", "credentials.yaml"))
@@ -5283,6 +5665,16 @@ function buildClaudePluginInstallCommandPowerShell(targetDir, platformRoot, spec
5283
5665
  `claude plugin install ${spec} --scope ${scope}`
5284
5666
  ].join("\n");
5285
5667
  }
5668
+ var COPILOT_HOOKS_REL = [".github", "hooks", "heyspex.json"];
5669
+ function buildCopilotPluginInstallCommand(targetDir, platformRoot, spec = PLUGIN_INSTALL_SPEC) {
5670
+ const escapedTarget = targetDir.replace(/'/g, "''");
5671
+ const escapedPlatform = platformRoot.replace(/'/g, "''");
5672
+ return [
5673
+ `Set-Location -LiteralPath '${escapedTarget}'`,
5674
+ `copilot plugin marketplace add '${escapedPlatform}'`,
5675
+ `copilot plugin install ${spec}`
5676
+ ].join("\n");
5677
+ }
5286
5678
  var STATUSLINE_VENDOR_FILES = [
5287
5679
  ["scripts", "install-statusline.mjs"],
5288
5680
  ["scripts", "claude-statusline", "statusline-command.mjs"],
@@ -5647,11 +6039,11 @@ function slugFromPath(p) {
5647
6039
  return pathTail(p).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5648
6040
  }
5649
6041
  function registerInstalledVault(args, ctx) {
5650
- const { targetDir, readFile, joinPath, registryOpts, now, basename: basename3 } = ctx;
6042
+ const { targetDir, readFile, joinPath, registryOpts, now, basename: basename4 } = ctx;
5651
6043
  let vaultId;
5652
6044
  let schemaVersion = HEYSPEX_YAML_SCHEMA_VERSION;
5653
6045
  const yamlContent = readFile(joinPath(targetDir, "heyspex.yaml"));
5654
- const markerAccountId = yamlContent ? parseAccountId(yamlContent) : null;
6046
+ const markerAccountId = null;
5655
6047
  if (yamlContent) {
5656
6048
  const existing = parseVaultId(yamlContent);
5657
6049
  if (existing) vaultId = existing;
@@ -5659,7 +6051,7 @@ function registerInstalledVault(args, ctx) {
5659
6051
  if (versionMatch) schemaVersion = Number(versionMatch[1]);
5660
6052
  }
5661
6053
  if (!vaultId) vaultId = slugFromPath(targetDir);
5662
- const displayName = basename3(targetDir);
6054
+ const displayName = basename4(targetDir);
5663
6055
  const before = loadVaultRegistry(registryOpts);
5664
6056
  const existingVault = before.vaults.find(
5665
6057
  (v) => v.id === vaultId || v.repo_root_abs === targetDir
@@ -5747,7 +6139,7 @@ function guardWritesAgainstLiteralBearer(writeFile, bearerToken) {
5747
6139
  };
5748
6140
  }
5749
6141
  function runInstall(args, inputDeps) {
5750
- const guardHome = inputDeps.homeDir ? inputDeps.homeDir() : homedir10();
6142
+ const guardHome = inputDeps.homeDir ? inputDeps.homeDir() : heyspexHome2();
5751
6143
  const guardBearer = resolveBearer({
5752
6144
  envToken: (inputDeps.env ?? process.env)[REMOTE_MCP_TOKEN_ENV],
5753
6145
  credentialsText: inputDeps.readFile(
@@ -5784,16 +6176,27 @@ function runInstall(args, inputDeps) {
5784
6176
  };
5785
6177
  }
5786
6178
  let attached;
6179
+ let portableVaultPath = null;
5787
6180
  let satellitePins = [];
5788
6181
  let satelliteStalePins = [];
5789
- if (args.attachVault !== void 0) {
5790
- const satelliteDeps = {
5791
- fileExists: deps.fileExists,
5792
- readFile: deps.readFile,
5793
- joinPath: deps.joinPath,
5794
- ...deps.registryOpts ? { registryOpts: deps.registryOpts } : {}
5795
- };
5796
- const resolved = resolveAttachTarget(args.attachVault, satelliteDeps);
6182
+ const markerSatelliteDeps = {
6183
+ fileExists: deps.fileExists,
6184
+ readFile: deps.readFile,
6185
+ joinPath: deps.joinPath,
6186
+ ...deps.registryOpts ? { registryOpts: deps.registryOpts } : {}
6187
+ };
6188
+ const markerAttachment = args.attachVault === void 0 ? detectMarkerAttachment(targetDir, markerSatelliteDeps) : null;
6189
+ if (args.attachVault !== void 0 || markerAttachment && markerAttachment.vaultId) {
6190
+ const satelliteDeps = markerSatelliteDeps;
6191
+ const resolved = args.attachVault === void 0 && markerAttachment ? {
6192
+ ok: true,
6193
+ target: {
6194
+ vaultId: markerAttachment.vaultId ?? "",
6195
+ vaultRootAbs: markerAttachment.vaultRootAbs,
6196
+ workspaceId: "(from heyspex.yaml)",
6197
+ displayName: markerAttachment.vaultId ?? ""
6198
+ }
6199
+ } : resolveAttachTarget(args.attachVault, satelliteDeps);
5797
6200
  if (!resolved.ok) {
5798
6201
  return {
5799
6202
  code: EXIT.INVALID_ARG,
@@ -5803,6 +6206,12 @@ function runInstall(args, inputDeps) {
5803
6206
  };
5804
6207
  }
5805
6208
  attached = resolved.target;
6209
+ {
6210
+ const markerText = deps.readFile(deps.joinPath(targetDir, "heyspex.yaml"));
6211
+ const vp = markerText ? parseMarkerVaultPath(markerText) : null;
6212
+ const markerId = markerText ? parseVaultId(markerText) : null;
6213
+ if (vp && (!markerId || markerId === attached.vaultId)) portableVaultPath = vp;
6214
+ }
5806
6215
  if (deps.joinPath(attached.vaultRootAbs) === deps.joinPath(targetDir)) {
5807
6216
  const message2 = "--attach-vault: this repo IS that vault -- run a normal `hs install` instead of attaching it to itself.";
5808
6217
  return {
@@ -5993,7 +6402,7 @@ ${detail}
5993
6402
  if (deps.fileExists(gitkeepPath)) {
5994
6403
  staleLocalMarkers.push("specs/.gitkeep");
5995
6404
  }
5996
- if (deps.fileExists(deps.joinPath(targetDir, "heyspex.yaml"))) {
6405
+ if (deps.fileExists(deps.joinPath(targetDir, "heyspex.yaml")) && !portableVaultPath) {
5997
6406
  staleLocalMarkers.push("heyspex.yaml");
5998
6407
  }
5999
6408
  if (staleLocalMarkers.length > 0) {
@@ -6071,8 +6480,10 @@ ${detail}
6071
6480
  templateOutcome = outcome;
6072
6481
  }
6073
6482
  const attachedRoot = attached?.vaultRootAbs;
6483
+ const claudePin = portableVaultPath ? "${CLAUDE_PROJECT_DIR:-.}/" + portableVaultPath : attachedRoot;
6484
+ const cursorPin = portableVaultPath ? "${workspaceFolder}/" + portableVaultPath : attachedRoot;
6074
6485
  const satelliteUnchanged = (rel) => pinsUnchanged.find((p) => p.rel === rel) ?? null;
6075
- const cursorJson = `${JSON.stringify(renderCursorMcpJson(cursorCliArg, attachedRoot), null, 2)}
6486
+ const cursorJson = `${JSON.stringify(renderCursorMcpJson(cursorCliArg, cursorPin), null, 2)}
6076
6487
  `;
6077
6488
  if (args.cursor) {
6078
6489
  const cursorUnchanged = satelliteUnchanged(".cursor/mcp.json");
@@ -6093,7 +6504,7 @@ ${detail}
6093
6504
  }
6094
6505
  const remoteUrl = args.remote ? resolveRemoteMcpUrl(args.remoteUrl) : null;
6095
6506
  const claudeJson = remoteUrl ? `${JSON.stringify(renderRemoteClaudeMcpJson(remoteUrl), null, 2)}
6096
- ` : `${JSON.stringify(renderClaudeMcpJson(claudeCliArg, attachedRoot), null, 2)}
6507
+ ` : `${JSON.stringify(renderClaudeMcpJson(claudeCliArg, claudePin), null, 2)}
6097
6508
  `;
6098
6509
  const claudePs = remoteUrl ? buildClaudeMcpAddRemoteCommandPowerShell(targetDir, remoteUrl) : buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot);
6099
6510
  if (args.claude) {
@@ -6110,7 +6521,7 @@ ${detail}
6110
6521
  existingJson = null;
6111
6522
  }
6112
6523
  }
6113
- const pinEntry = renderClaudeMcpJson(claudeCliArg, attachedRoot).mcpServers.heyspex;
6524
+ const pinEntry = renderClaudeMcpJson(claudeCliArg, claudePin).mcpServers.heyspex;
6114
6525
  const cloudEntry = renderRemoteClaudeMcpJson(remoteUrl).mcpServers.heyspex;
6115
6526
  const merged = mergeAttachedRemoteMcpJson(
6116
6527
  existingJson,
@@ -6219,6 +6630,71 @@ ${detail}
6219
6630
  );
6220
6631
  }
6221
6632
  }
6633
+ const copilotPluginWanted = args.copilotPlugin === true || args.copilotPluginApply === true;
6634
+ const copilotPluginAvailable = deps.fileExists(marketplaceManifest);
6635
+ let copilotPluginActive = false;
6636
+ if (copilotPluginWanted) {
6637
+ if (!copilotPluginAvailable) {
6638
+ steps.push({
6639
+ kind: "copilot_plugin",
6640
+ path: marketplaceManifest,
6641
+ action: "skipped",
6642
+ detail: "platform root has no .claude-plugin/marketplace.json"
6643
+ });
6644
+ } else {
6645
+ copilotPluginActive = true;
6646
+ steps.push({
6647
+ kind: args.copilotPluginApply ? "copilot_plugin_apply" : "copilot_plugin",
6648
+ path: args.copilotPluginApply ? "copilot plugin install" : pluginSpec.spec,
6649
+ action: args.copilotPluginApply ? args.dryRun ? "would_create" : "created" : "skipped",
6650
+ detail: args.copilotPluginApply ? "needs `copilot` on PATH; see copilot_plugin_commands if apply fails" : "printed below -- run it, or re-run with --copilot-plugin-apply"
6651
+ });
6652
+ if (!args.copilotPluginApply) {
6653
+ nextSteps.push(
6654
+ "GitHub Copilot plugin: run the printed commands, or re-run with --copilot-plugin-apply if `copilot` is on PATH. Then /restart."
6655
+ );
6656
+ }
6657
+ }
6658
+ }
6659
+ if (args.copilotHooks) {
6660
+ const ghPath = deps.joinPath(targetDir, ...COPILOT_HOOKS_REL);
6661
+ if (copilotPluginWanted) {
6662
+ steps.push({
6663
+ kind: "copilot_hooks",
6664
+ path: ghPath,
6665
+ action: "skipped",
6666
+ detail: "WARNING: --copilot-hooks and the Copilot plugin both requested -- the plugin already carries the hooks, so .github/hooks/heyspex.json is not written (they would fire twice)"
6667
+ });
6668
+ nextSteps.push(
6669
+ "Warning: --copilot-hooks skipped because the Copilot plugin is requested (single source: plugin OR .github/hooks, never both)."
6670
+ );
6671
+ } else {
6672
+ const want = renderGithubHooksText();
6673
+ const existing = deps.fileExists(ghPath) ? deps.readFile(ghPath) : null;
6674
+ if (existing !== null && existing.replace(/\r\n/g, "\n") === want) {
6675
+ steps.push({
6676
+ kind: "copilot_hooks",
6677
+ path: ghPath,
6678
+ action: "skipped",
6679
+ detail: "already current"
6680
+ });
6681
+ } else {
6682
+ steps.push({
6683
+ kind: "copilot_hooks",
6684
+ path: ghPath,
6685
+ action: args.dryRun ? "would_create" : "created",
6686
+ detail: `${existing === null ? "new" : "updated"} -- commands resolve ${PORTABLE_PACKAGE_DIR}/scripts/claude-hooks/_run.mjs first, then ./scripts/claude-hooks/_run.mjs, else exit 0`
6687
+ });
6688
+ if (!args.dryRun) {
6689
+ deps.mkdir(deps.joinPath(targetDir, ...COPILOT_HOOKS_REL.slice(0, -1)));
6690
+ deps.writeFile(ghPath, want);
6691
+ }
6692
+ }
6693
+ nextSteps.push(
6694
+ "GitHub Copilot hooks: commit .github/hooks/heyspex.json so cloud agents and teammates get it."
6695
+ );
6696
+ }
6697
+ }
6222
6698
  let settingsWrittenText = null;
6223
6699
  if ((args.statusline ?? true) && args.claude) {
6224
6700
  const statuslineShipsInPlatform = deps.fileExists(
@@ -6455,6 +6931,8 @@ ${detail}
6455
6931
  claude_plugin_powershell: args.plugin && pluginAvailable ? pluginPs : void 0,
6456
6932
  claude_plugin_spec: args.plugin && pluginAvailable ? pluginSpec.spec : void 0,
6457
6933
  claude_plugin_scope: args.plugin && pluginAvailable ? pluginScope : void 0,
6934
+ copilot_plugin_commands: copilotPluginActive ? buildCopilotPluginInstallCommand(targetDir, platformRoot, pluginSpec.spec) : void 0,
6935
+ copilot_plugin_spec: copilotPluginActive ? pluginSpec.spec : void 0,
6458
6936
  command_code_powershell: args.commandCode ? commandCodePs : void 0,
6459
6937
  next_steps: nextSteps,
6460
6938
  dry_run: args.dryRun,
@@ -6548,7 +7026,7 @@ function formatInstallFiles(facts) {
6548
7026
  }
6549
7027
  function predictInstallVaultId(targetDir, deps) {
6550
7028
  const yaml2 = deps.readFile(deps.joinPath(targetDir, "heyspex.yaml"));
6551
- const accountId = yaml2 ? parseAccountId(yaml2) : null;
7029
+ const accountId = null;
6552
7030
  const fromMarker = yaml2 ? parseVaultId(yaml2) : null;
6553
7031
  if (fromMarker) return { vaultId: fromMarker, accountId };
6554
7032
  try {
@@ -6729,6 +7207,7 @@ var INSTALL_HELP = [
6729
7207
  " hs install [--target <dir>] [--platform <dir>] [--force] [--dry-run] [--json]",
6730
7208
  " [--no-cursor] [--no-claude] [--claude-apply]",
6731
7209
  " [--plugin | --plugin-apply] [--plugin-scope user|project|local]",
7210
+ " [--copilot-hooks | --copilot-plugin | --copilot-plugin-apply]",
6732
7211
  " [--no-statusline]",
6733
7212
  " [--cc | --command-code] [--register-vault [--workspace <id>]]",
6734
7213
  " [--remote[=<url>]]",
@@ -6787,6 +7266,15 @@ var INSTALL_HELP = [
6787
7266
  ` the platform's .claude-plugin/marketplace.json (fallback: ${PLUGIN_INSTALL_SPEC}).`,
6788
7267
  " --plugin-scope <s> Plugin install scope: user, project (default), or local.",
6789
7268
  " Implies --plugin.",
7269
+ " --copilot-hooks Write .github/hooks/heyspex.json (GitHub Copilot hooks) into the",
7270
+ " target. Commands resolve node_modules/heyspex first, so an npm-only",
7271
+ " install (no heyspex repo checkout) works. Pick this OR the plugin.",
7272
+ " --copilot-plugin Print `copilot plugin marketplace add '<platform>'` +",
7273
+ " `copilot plugin install <plugin>@<marketplace>` (the platform root is",
7274
+ " node_modules/heyspex for a published install).",
7275
+ " --copilot-plugin-apply Run those two commands when `copilot` is on PATH.",
7276
+ " Requesting the Copilot plugin AND --copilot-hooks warns and skips the",
7277
+ " hooks file (both would fire every hook twice).",
6790
7278
  " --remote[=<url>] Write .mcp.json for the REMOTE (cloud) MCP endpoint instead of",
6791
7279
  ` the local stdio server \u2014 the Streamable-HTTP form against`,
6792
7280
  ` ${DEFAULT_REMOTE_MCP_URL} (or <url>), authenticated by the`,
@@ -6835,15 +7323,15 @@ var INSTALL_HELP = [
6835
7323
 
6836
7324
  // spex-console/src/lib/cli/install-identity.ts
6837
7325
  import { spawnSync as spawnSync2 } from "node:child_process";
6838
- import { existsSync as existsSync4 } from "node:fs";
6839
- import { homedir as homedir12 } from "node:os";
6840
- import { join as join10 } from "node:path";
7326
+ import { existsSync as existsSync5 } from "node:fs";
7327
+ import { join as join11 } from "node:path";
6841
7328
  import { createInterface } from "node:readline/promises";
6842
7329
  init_credentials();
6843
7330
 
6844
7331
  // vendor/heyspex-mcp/whoami.mjs
7332
+ init_heyspex_home();
6845
7333
  init_credentials();
6846
- import { homedir as homedir11 } from "node:os";
7334
+ import { homedir as homedir9 } from "node:os";
6847
7335
  var SESSION_PATH = "/api/mcp/session";
6848
7336
  async function probeCredentialIdentity({ consoleUrl, token, fetchImpl = fetch } = {}) {
6849
7337
  if (!token) return { kind: "no_credential" };
@@ -7098,21 +7586,26 @@ async function runIdentityGate(io, flags, verb, wording = ATTACH_WORDING) {
7098
7586
  "identity_unconfirmed"
7099
7587
  );
7100
7588
  }
7101
- function readConfiguredAccountId(env = process.env, homeDir = homedir12()) {
7589
+ function readConfiguredAccountId(env = process.env, homeDir = heyspexHome2()) {
7102
7590
  return resolveSessionOwnerUncached({ env, homeDir });
7103
7591
  }
7104
- function parseMarkerAccount(raw) {
7105
- if (typeof raw !== "string" || raw.length === 0) return null;
7106
- return parseAccountId(raw);
7107
- }
7108
7592
  function accountMismatchRepair(repoAccount) {
7109
7593
  return `hs login --switch-account ${repoAccount}`;
7110
7594
  }
7111
7595
  function readRecordedRepoAccount(opts) {
7112
- const joinPath = opts.joinPath ?? join10;
7113
- const fromMarker = parseMarkerAccount(opts.readFile(joinPath(opts.repoRoot, "heyspex.yaml")));
7114
- if (fromMarker) return { accountId: fromMarker, source: "heyspex.yaml" };
7115
- const configured = readConfiguredAccountId(opts.env ?? process.env, opts.homeDir ?? homedir12());
7596
+ const homeDir = opts.homeDir ?? heyspexHome2();
7597
+ let fromRegistry = null;
7598
+ try {
7599
+ const row = loadVaultRegistry({ registryDir: join11(homeDir, ".heyspex") }).vaults.find(
7600
+ (v) => samePath(v.repo_root_abs, opts.repoRoot)
7601
+ );
7602
+ const id = row?.account_id;
7603
+ fromRegistry = id && id !== "legacy" ? id : null;
7604
+ } catch {
7605
+ fromRegistry = null;
7606
+ }
7607
+ if (fromRegistry) return { accountId: fromRegistry, source: "registry" };
7608
+ const configured = readConfiguredAccountId(opts.env ?? process.env, homeDir);
7116
7609
  return configured ? { accountId: configured, source: "config.yaml" } : null;
7117
7610
  }
7118
7611
  function decideRemoteAccount(input) {
@@ -7131,7 +7624,7 @@ function decideRemoteAccount(input) {
7131
7624
  const tokenLine = ` token: ${describeBearerSource(identity.tokenSource ?? null)}`;
7132
7625
  if (recorded && recorded.accountId !== identity.accountId) {
7133
7626
  const message2 = `${verb}: refusing -- this repo is recorded under account ${recorded.accountId} (${recorded.source}), but the token this install would wire belongs to ${formatShownIdentity(identity)}. Sign in as the repo's account with \`hs login --switch-account\`, then re-run with --account ${recorded.accountId}. Nothing was written.`;
7134
- const fromMarker = recorded.source === "heyspex.yaml";
7627
+ const fromMarker = recorded.source === "registry";
7135
7628
  return {
7136
7629
  ok: false,
7137
7630
  result: {
@@ -7152,7 +7645,7 @@ function decideRemoteAccount(input) {
7152
7645
  }
7153
7646
  };
7154
7647
  }
7155
- const repoLine = !recorded ? " repo: unrecorded (heyspex.yaml has no account:, no account_id in ~/.heyspex/config.yaml) -- using the signed-in account" : recorded.source === "heyspex.yaml" ? ` repo: ${recorded.accountId} (heyspex.yaml) -- matches the signed-in account` : ` repo: unrecorded in heyspex.yaml -- compared with this machine's configured account ${recorded.accountId} (~/.heyspex/config.yaml), which matches`;
7648
+ const repoLine = !recorded ? " repo: unrecorded (heyspex.yaml has no account:, no account_id in ~/.heyspex/config.yaml) -- using the signed-in account" : recorded.source === "registry" ? ` repo: ${recorded.accountId} (registry) -- matches the signed-in account` : ` repo: unrecorded in heyspex.yaml -- compared with this machine's configured account ${recorded.accountId} (~/.heyspex/config.yaml), which matches`;
7156
7649
  return {
7157
7650
  ok: true,
7158
7651
  identity,
@@ -7192,7 +7685,7 @@ function isInteractiveTerminal() {
7192
7685
  }
7193
7686
  function defaultIdentityGateIo(opts) {
7194
7687
  const consoleUrl = opts.consoleUrl.trim().replace(/\/+$/, "");
7195
- const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join10(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync4(p));
7688
+ const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join11(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync5(p));
7196
7689
  return {
7197
7690
  consoleUrl,
7198
7691
  interactive: opts.interactive ?? isInteractiveTerminal(),
@@ -7299,25 +7792,27 @@ async function main() {
7299
7792
  satellite: args.attachVault !== void 0
7300
7793
  })
7301
7794
  );
7302
- const platformRoot = resolve5(args.platformRoot || platformDefault || process4.cwd());
7795
+ const platformRoot = resolve5(
7796
+ resolveCallerPath(args.platformRoot, process4.env, process4.cwd()) || platformDefault || process4.cwd()
7797
+ );
7303
7798
  const deps = {
7304
7799
  repoRoot: targetDir,
7305
7800
  resolvedTarget: targetDir,
7306
7801
  resolvedPlatform: platformRoot,
7307
- fileExists: (p) => existsSync5(p),
7802
+ fileExists: (p) => existsSync6(p),
7308
7803
  readFile: (p) => {
7309
7804
  try {
7310
- return readFileSync10(p, "utf8");
7805
+ return readFileSync11(p, "utf8");
7311
7806
  } catch {
7312
7807
  return null;
7313
7808
  }
7314
7809
  },
7315
- writeFile: (p, c) => writeFileSync5(p, c, "utf8"),
7316
- mkdir: (p) => mkdirSync5(p, { recursive: true }),
7810
+ writeFile: (p, c) => writeFileSync6(p, c, "utf8"),
7811
+ mkdir: (p) => mkdirSync6(p, { recursive: true }),
7317
7812
  copyFile: (src, dest) => copyFileSync(src, dest),
7318
- joinPath: (...s) => join11(...s),
7813
+ joinPath: (...s) => join12(...s),
7319
7814
  // Customer-template step (--register-vault) walks specs/ before applying.
7320
- readdir: (p) => readdirSync(p),
7815
+ readdir: (p) => readdirSync2(p),
7321
7816
  isDirectory: (p) => {
7322
7817
  try {
7323
7818
  return statSync(p).isDirectory();
@@ -7328,7 +7823,7 @@ async function main() {
7328
7823
  // The /hs-* harness commands step lists the platform's canonical trees.
7329
7824
  listDir: (p) => {
7330
7825
  try {
7331
- return readdirSync(p);
7826
+ return readdirSync2(p);
7332
7827
  } catch {
7333
7828
  return [];
7334
7829
  }
@@ -7349,7 +7844,7 @@ async function main() {
7349
7844
  verb: "hs install",
7350
7845
  repoRoot: targetDir,
7351
7846
  readFile: deps.readFile,
7352
- joinPath: join11
7847
+ joinPath: join12
7353
7848
  }) : await runIdentityGate(io, identityArgv.flags, "hs install");
7354
7849
  if (!gate.ok) {
7355
7850
  process4.stderr.write(
@@ -7380,7 +7875,7 @@ async function main() {
7380
7875
  sink.write(`${args.json ? JSON.stringify(payload, null, 2) : result.message}
7381
7876
  `);
7382
7877
  if (!result.isError && args.claudeApply && !args.dryRun && args.claude) {
7383
- const cliPath = mcpCliPath(platformRoot, join11);
7878
+ const cliPath = mcpCliPath(platformRoot, join12);
7384
7879
  const attachedRoot = result.payload?.attached_vault?.repo_root_abs;
7385
7880
  const mcpVaultRoot = attachedRoot ?? targetDir;
7386
7881
  const claude = spawnSync3(
@@ -7413,17 +7908,17 @@ ${buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot)}
7413
7908
  process4.stdout.write("Claude Code: registered heyspex MCP (--scope project).\n");
7414
7909
  }
7415
7910
  }
7416
- if (!result.isError && args.pluginApply && !args.dryRun && existsSync5(pluginMarketplaceManifestPath(platformRoot, join11))) {
7911
+ if (!result.isError && args.pluginApply && !args.dryRun && existsSync6(pluginMarketplaceManifestPath(platformRoot, join12))) {
7417
7912
  const shell = process4.platform === "win32";
7418
7913
  const spec = resolvePluginSpec(platformRoot, {
7419
7914
  readFile: (p) => {
7420
7915
  try {
7421
- return readFileSync10(p, "utf8");
7916
+ return readFileSync11(p, "utf8");
7422
7917
  } catch {
7423
7918
  return null;
7424
7919
  }
7425
7920
  },
7426
- joinPath: join11
7921
+ joinPath: join12
7427
7922
  });
7428
7923
  const scope = args.pluginScope ?? "project";
7429
7924
  const manualBlock = buildClaudePluginInstallCommandPowerShell(
@@ -7458,6 +7953,33 @@ ${manualBlock}
7458
7953
  } else {
7459
7954
  process4.stdout.write(
7460
7955
  `Claude Code: installed plugin ${spec.spec} (--scope ${scope}). Restart the Claude Code session to load it.
7956
+ `
7957
+ );
7958
+ }
7959
+ }
7960
+ }
7961
+ const copilotCommands = result.payload?.copilot_plugin_commands;
7962
+ if (!result.isError && args.copilotPluginApply && !args.dryRun && copilotCommands) {
7963
+ const shell = process4.platform === "win32";
7964
+ const spec = result.payload.copilot_plugin_spec ?? "";
7965
+ const copilotRun = (cliArgs) => spawnSync3("copilot", cliArgs, { cwd: targetDir, encoding: "utf8", shell });
7966
+ const failed = (what, r) => process4.stderr.write(
7967
+ `hs install: copilot plugin ${what} failed (${r.error ? r.error.message : `exit ${r.status ?? "?"}`}).
7968
+ ${r.stderr || r.stdout || ""}
7969
+ Run manually:
7970
+ ${copilotCommands}
7971
+ `
7972
+ );
7973
+ const marketplace = copilotRun(["plugin", "marketplace", "add", platformRoot]);
7974
+ if (marketplace.status !== 0 && !/already/i.test(marketplace.stderr + marketplace.stdout)) {
7975
+ failed("marketplace add", marketplace);
7976
+ } else {
7977
+ const install2 = copilotRun(["plugin", "install", spec]);
7978
+ if (install2.status !== 0 && !/already/i.test(install2.stderr + install2.stdout)) {
7979
+ failed("install", install2);
7980
+ } else {
7981
+ process4.stdout.write(
7982
+ `GitHub Copilot: installed plugin ${spec}. Run /restart in the Copilot CLI to load it.
7461
7983
  `
7462
7984
  );
7463
7985
  }
@@ -7465,9 +7987,9 @@ ${manualBlock}
7465
7987
  }
7466
7988
  if (!result.isError && !args.dryRun && args.claude && (args.statusline ?? true)) {
7467
7989
  const glyphScript = [
7468
- join11(targetDir, "scripts", "install-statusline.mjs"),
7469
- join11(platformRoot, "scripts", "install-statusline.mjs")
7470
- ].find((p) => existsSync5(p));
7990
+ join12(targetDir, "scripts", "install-statusline.mjs"),
7991
+ join12(platformRoot, "scripts", "install-statusline.mjs")
7992
+ ].find((p) => existsSync6(p));
7471
7993
  const glyph = glyphScript ? spawnSync3(process4.execPath, [glyphScript, "--glyph-only"], {
7472
7994
  cwd: targetDir,
7473
7995
  encoding: "utf8"