heyspex 0.2.1 → 0.2.3

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 (70) hide show
  1. package/README.md +10 -0
  2. package/bin/hs-vault-binding.mjs +157 -0
  3. package/bin/hs.mjs +99 -81
  4. package/bin/spexcode.mjs +91 -0
  5. package/dev-factory/engine/worktree.mjs +36 -7
  6. package/dev-factory/lib/epic-detect.mjs +2 -2
  7. package/dev-factory/lib/fleet-readiness.mjs +3 -5
  8. package/dev-factory/lib/fleet-state.mjs +2 -2
  9. package/dev-factory/lib/sufficiency-score.mjs +3 -5
  10. package/dist/cli/hs-check.mjs +76 -46
  11. package/dist/cli/hs-claim.mjs +63 -33
  12. package/dist/cli/hs-connect.mjs +520 -214
  13. package/dist/cli/hs-defer.mjs +161 -19
  14. package/dist/cli/hs-dev.mjs +11 -5
  15. package/dist/cli/hs-env.mjs +187 -161
  16. package/dist/cli/hs-index-rebuild.mjs +123 -78
  17. package/dist/cli/hs-index-reconcile.mjs +203 -107
  18. package/dist/cli/hs-init.mjs +426 -179
  19. package/dist/cli/hs-install.mjs +759 -437
  20. package/dist/cli/hs-join.mjs +590 -383
  21. package/dist/cli/hs-vault-pull.mjs +359 -254
  22. package/dist/cli/hs-vault-push.mjs +151 -113
  23. package/dist/cli/hs-vault.mjs +271 -104
  24. package/dist/cli/hs-workspace.mjs +90 -57
  25. package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
  26. package/docs/copilot-plugin-install.md +189 -162
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -249
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -309
  32. package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
  33. package/plugins/heyspex/mcp.json +12 -13
  34. package/scripts/check-mcp-freshness.mjs +6 -4
  35. package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
  36. package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
  37. package/scripts/claude-hooks/research-method-guard.mjs +2 -2
  38. package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
  39. package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
  40. package/scripts/claude-statusline/statusline-command.mjs +1058 -0
  41. package/scripts/dev-server-status.mjs +3 -2
  42. package/scripts/dev-server-stop.mjs +3 -2
  43. package/scripts/hs-capture-cli.mjs +2 -2
  44. package/scripts/hs-settings.mjs +2 -2
  45. package/scripts/hs-view.mjs +3 -2
  46. package/scripts/install-copilot-hooks.mjs +318 -0
  47. package/scripts/lib/copilot-cli.mjs +108 -0
  48. package/scripts/lib/customer-kit.mjs +223 -30
  49. package/scripts/lib/dev-mode.mjs +28 -2
  50. package/scripts/lib/shared-path-helpers.mjs +32 -0
  51. package/scripts/lib/standalone-doctor.mjs +131 -32
  52. package/scripts/wip-ledger-health.mjs +3 -2
  53. package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
  54. package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
  55. package/spexharness/src/intent-directive.mjs +839 -626
  56. package/spexharness/src/rule-table.mjs +1 -1
  57. package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
  58. package/vendor/heyspex-mcp/cli.mjs +20 -4
  59. package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
  60. package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
  61. package/vendor/heyspex-mcp/events-tools.mjs +1 -1
  62. package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
  63. package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
  64. package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
  65. package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
  66. package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
  67. package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
  68. package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
  69. package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
  70. package/vendor/heyspex-mcp/vault-resolve.mjs +353 -245
@@ -154,9 +154,9 @@ function resolveBearer({ envToken, credentialsText, accountId, legacyAccountId }
154
154
  if (entry?.mcp_token) return { token: entry.mcp_token, source: "credentials-file" };
155
155
  return { token: null, source: null };
156
156
  }
157
- function readTextOrNull(read, path5) {
157
+ function readTextOrNull(read, path7) {
158
158
  try {
159
- const raw = read(path5);
159
+ const raw = read(path7);
160
160
  return typeof raw === "string" ? raw : null;
161
161
  } catch {
162
162
  return null;
@@ -260,6 +260,11 @@ function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile = defaultRea
260
260
  return { record: null, parseError: `unparseable dev-mode.json: ${String(err?.message ?? err)}` };
261
261
  }
262
262
  }
263
+ function overrideCloudCheck(source, cloudKnown, staleAgainstCloud) {
264
+ if (source !== "local-override") return "not-applicable";
265
+ if (!cloudKnown) return "unverified";
266
+ return staleAgainstCloud ? "stale" : "in-sync";
267
+ }
263
268
  function resolveDevMode({
264
269
  env = process.env,
265
270
  homeDir = heyspexHome(),
@@ -299,6 +304,7 @@ function resolveDevMode({
299
304
  const semantics = deriveSemantics(mode, reliance);
300
305
  const consoleUrl = source === "local-override" && local.record?.console_url ? local.record.console_url.replace(/\/+$/, "") : semantics.consoleUrl;
301
306
  const staleAgainstCloud = source === "local-override" && cloudValue !== null && (cloudValue.mode !== mode || mode === "dev" && cloudValue.reliance !== semantics.reliance);
307
+ const cloudCheck = overrideCloudCheck(source, cloudKnown, staleAgainstCloud);
302
308
  return {
303
309
  mode,
304
310
  reliance: semantics.reliance,
@@ -316,7 +322,8 @@ function resolveDevMode({
316
322
  present: local.record !== null,
317
323
  record: local.record,
318
324
  parseError: local.parseError,
319
- staleAgainstCloud
325
+ staleAgainstCloud,
326
+ cloudCheck
320
327
  },
321
328
  cloudSetting: { known: cloudKnown, value: cloudValue }
322
329
  };
@@ -996,17 +1003,17 @@ var require_gray_matter = __commonJS({
996
1003
  });
997
1004
 
998
1005
  // spex-console/bin/hs-install.ts
999
- import { spawnSync as spawnSync3 } from "node:child_process";
1006
+ import { spawnSync as spawnSync4 } from "node:child_process";
1000
1007
  import {
1001
- copyFileSync,
1002
- existsSync as existsSync6,
1003
- mkdirSync as mkdirSync6,
1008
+ copyFileSync as copyFileSync2,
1009
+ existsSync as existsSync9,
1010
+ mkdirSync as mkdirSync7,
1004
1011
  readdirSync as readdirSync2,
1005
- readFileSync as readFileSync11,
1006
- statSync,
1007
- writeFileSync as writeFileSync6
1012
+ readFileSync as readFileSync14,
1013
+ statSync as statSync5,
1014
+ writeFileSync as writeFileSync7
1008
1015
  } from "node:fs";
1009
- import { join as join12, resolve as resolve5 } from "node:path";
1016
+ import { join as join12, resolve as resolve6 } from "node:path";
1010
1017
  import process4 from "node:process";
1011
1018
 
1012
1019
  // scripts/lib/console-url-resolver.mjs
@@ -1115,7 +1122,9 @@ function resolveConsoleUrl({
1115
1122
  }
1116
1123
 
1117
1124
  // scripts/lib/shared-path-helpers.mjs
1125
+ import { realpathSync } from "node:fs";
1118
1126
  import { isAbsolute, resolve as resolve2, sep } from "node:path";
1127
+ import { fileURLToPath } from "node:url";
1119
1128
  function resolveCodeRoot({
1120
1129
  explicit,
1121
1130
  packageRoot,
@@ -1133,16 +1142,16 @@ function resolveCodeRoot({
1133
1142
  }
1134
1143
 
1135
1144
  // spex-console/src/lib/cli/control-shared.ts
1136
- import path from "node:path";
1145
+ import path2 from "node:path";
1137
1146
 
1138
1147
  // scripts/lib/hs-command.mjs
1139
1148
  import { existsSync } from "node:fs";
1140
1149
  import { delimiter, dirname as dirname2, join as join4 } from "node:path";
1141
- import { fileURLToPath } from "node:url";
1150
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1142
1151
  var HS_BIN_NAME = "hs";
1143
1152
  var HS_DISPATCHER_PATH = (() => {
1144
1153
  try {
1145
- const here = dirname2(fileURLToPath(import.meta.url));
1154
+ const here = dirname2(fileURLToPath2(import.meta.url));
1146
1155
  return join4(dirname2(dirname2(here)), "bin", "hs.mjs");
1147
1156
  } catch {
1148
1157
  return null;
@@ -1156,8 +1165,8 @@ var HS_DISPATCHER_PREFIX = HS_DISPATCHER_PATH ? `node ${shellQuote(HS_DISPATCHER
1156
1165
  // vendor/heyspex-mcp/console-fetch.mjs
1157
1166
  init_heyspex_home();
1158
1167
  init_credentials();
1159
- import { readFileSync as readFileSync7 } from "node:fs";
1160
- import { homedir as homedir7 } from "node:os";
1168
+ import { readFileSync as readFileSync8 } from "node:fs";
1169
+ import { homedir as homedir8 } from "node:os";
1161
1170
  import { join as join8 } from "node:path";
1162
1171
 
1163
1172
  // vendor/heyspex-mcp/session-owner.mjs
@@ -1182,14 +1191,14 @@ var VAULT_LESS_ENDPOINTS = Object.freeze([
1182
1191
  "GET /api/workspaces",
1183
1192
  "* /api/admin/dev-mode"
1184
1193
  ]);
1185
- function pathnameOf(path5) {
1186
- const raw = String(path5 ?? "").split("?")[0].split("#")[0];
1194
+ function pathnameOf(path7) {
1195
+ const raw = String(path7 ?? "").split("?")[0].split("#")[0];
1187
1196
  const noTrailing = raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
1188
1197
  return noTrailing || "/";
1189
1198
  }
1190
- function isVaultLessEndpoint(method, path5) {
1199
+ function isVaultLessEndpoint(method, path7) {
1191
1200
  const m = String(method || "GET").toUpperCase();
1192
- const pathname = pathnameOf(path5);
1201
+ const pathname = pathnameOf(path7);
1193
1202
  return VAULT_LESS_ENDPOINTS.some((row) => {
1194
1203
  const [rowMethod, rowPath] = row.split(" ");
1195
1204
  return rowPath === pathname && (rowMethod === "*" || rowMethod === m);
@@ -1210,9 +1219,30 @@ import { basename, join as join6, isAbsolute as isAbsolute2 } from "node:path";
1210
1219
 
1211
1220
  // vendor/heyspex-mcp/vault-resolve.mjs
1212
1221
  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";
1222
+ import { existsSync as existsSync4, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync3 } from "node:fs";
1223
+ import { homedir as homedir7, tmpdir } from "node:os";
1224
+
1225
+ // vendor/heyspex-mcp/usable-dir-env.mjs
1226
+ import { statSync } from "node:fs";
1227
+ import { isAbsolute as isAbsolute3 } from "node:path";
1228
+
1229
+ // vendor/heyspex-mcp/vault-resolve.mjs
1230
+ import { basename as basename2, dirname as dirname3, join as join7, resolve as resolve3 } from "node:path";
1231
+
1232
+ // vendor/heyspex-mcp/vault-binding.mjs
1233
+ import {
1234
+ copyFileSync,
1235
+ existsSync as existsSync3,
1236
+ mkdirSync as mkdirSync4,
1237
+ readFileSync as readFileSync6,
1238
+ realpathSync as realpathSync2,
1239
+ renameSync as renameSync3,
1240
+ statSync as statSync2,
1241
+ unlinkSync,
1242
+ writeFileSync as writeFileSync4
1243
+ } from "node:fs";
1244
+ import { homedir as homedir6 } from "node:os";
1245
+ import path from "node:path";
1216
1246
 
1217
1247
  // vendor/heyspex-mcp/console-fetch.mjs
1218
1248
  var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMATCH", "VAULT_NOT_REGISTERED"]);
@@ -1221,17 +1251,17 @@ var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMAT
1221
1251
  init_credentials();
1222
1252
 
1223
1253
  // spex-console/src/lib/vault-registry/registry-file.ts
1224
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync8, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
1254
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "node:fs";
1225
1255
  import { dirname as dirname4, join as join9, resolve as resolve4 } from "node:path";
1226
1256
 
1227
1257
  // spex-console/src/lib/heyspex-home.ts
1228
- import { homedir as homedir8 } from "node:os";
1258
+ import { homedir as homedir9 } from "node:os";
1229
1259
  function heyspexHome2(env = process.env) {
1230
1260
  const override = env?.HEYSPEX_HOME;
1231
1261
  if (typeof override === "string" && override.trim().length > 0) {
1232
1262
  return override.trim();
1233
1263
  }
1234
- return homedir8();
1264
+ return homedir9();
1235
1265
  }
1236
1266
 
1237
1267
  // spex-console/src/lib/vault-registry/types.ts
@@ -1353,9 +1383,9 @@ function parseVaultRegistryFile(json) {
1353
1383
  }
1354
1384
  function readRegistryJson(opts) {
1355
1385
  const filePath = vaultRegistryFilePath(opts);
1356
- if (!existsSync4(filePath)) return { kind: "missing" };
1386
+ if (!existsSync5(filePath)) return { kind: "missing" };
1357
1387
  try {
1358
- const raw = readFileSync8(filePath, "utf8");
1388
+ const raw = readFileSync9(filePath, "utf8");
1359
1389
  const json = JSON.parse(raw);
1360
1390
  if (registryFileSchemaOf(json) === null) return { kind: "unreadable" };
1361
1391
  return { kind: "ok", raw, json };
@@ -1425,10 +1455,10 @@ function assertNotRealHomeRegistryUnderTest(filePath) {
1425
1455
  function writeVaultRegistry(registry, opts = {}) {
1426
1456
  const filePath = vaultRegistryFilePath(opts);
1427
1457
  assertNotRealHomeRegistryUnderTest(filePath);
1428
- mkdirSync4(dirname4(filePath), { recursive: true });
1458
+ mkdirSync5(dirname4(filePath), { recursive: true });
1429
1459
  const tmpPath = `${filePath}.tmp`;
1430
- writeFileSync4(tmpPath, serializeVaultRegistry(registry), "utf8");
1431
- renameSync3(tmpPath, filePath);
1460
+ writeFileSync5(tmpPath, serializeVaultRegistry(registry), "utf8");
1461
+ renameSync4(tmpPath, filePath);
1432
1462
  }
1433
1463
  function isValidId(id) {
1434
1464
  if (!isNonEmptyString(id)) return false;
@@ -1603,11 +1633,11 @@ function readPairedBearer(opts = {}) {
1603
1633
  function readPairedToken(opts = {}) {
1604
1634
  return readPairedBearer(opts).token ?? void 0;
1605
1635
  }
1606
- function scopeHeadersFor(scope, method, path5) {
1636
+ function scopeHeadersFor(scope, method, path7) {
1607
1637
  if (scope === "none") {
1608
- if (!isVaultLessEndpoint(method, path5)) {
1638
+ if (!isVaultLessEndpoint(method, path7)) {
1609
1639
  throw new Error(
1610
- `controlRequest: scope "none" is only allowed for the vault-less endpoints (${VAULT_LESS_ENDPOINTS.join(", ")}); ${method.toUpperCase()} ${path5} is vault-scoped and must name { accountId, vaultId }.`
1640
+ `controlRequest: scope "none" is only allowed for the vault-less endpoints (${VAULT_LESS_ENDPOINTS.join(", ")}); ${method.toUpperCase()} ${path7} is vault-scoped and must name { accountId, vaultId }.`
1611
1641
  );
1612
1642
  }
1613
1643
  return {};
@@ -1618,7 +1648,7 @@ function cloudRequestScope(scope) {
1618
1648
  const named = { accountId: scope.accountId, vaultId: scope.vaultId };
1619
1649
  return {
1620
1650
  ...named,
1621
- headersFor: (method, path5) => isVaultLessEndpoint(method, path5) ? {} : scopeHeadersFor(named, method, path5)
1651
+ headersFor: (method, path7) => isVaultLessEndpoint(method, path7) ? {} : scopeHeadersFor(named, method, path7)
1622
1652
  };
1623
1653
  }
1624
1654
  function withCloudScope(deps, accountId, vaultId) {
@@ -1626,13 +1656,297 @@ function withCloudScope(deps, accountId, vaultId) {
1626
1656
  return { ...deps, http: { ...deps.http, scope: cloudRequestScope({ accountId, vaultId }) } };
1627
1657
  }
1628
1658
 
1659
+ // spex-console/src/lib/cli/generated-file-eol.ts
1660
+ import { spawnSync } from "node:child_process";
1661
+ import { existsSync as existsSync6, readFileSync as readFileSync10 } from "node:fs";
1662
+ import { dirname as dirname5, relative, resolve as resolve5 } from "node:path";
1663
+ function dominantEol(text) {
1664
+ const crlf = (text.match(/\r\n/g) ?? []).length;
1665
+ const lf = (text.match(/\n/g) ?? []).length - crlf;
1666
+ if (crlf === 0 && lf === 0) return null;
1667
+ return crlf > lf ? "\r\n" : "\n";
1668
+ }
1669
+ function applyEol(text, eol) {
1670
+ const lf = text.replace(/\r\n/g, "\n");
1671
+ return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
1672
+ }
1673
+ function defaultRunGit(args, cwd) {
1674
+ try {
1675
+ const res = spawnSync("git", args, {
1676
+ cwd,
1677
+ encoding: "utf8",
1678
+ windowsHide: true,
1679
+ timeout: 1e4
1680
+ });
1681
+ if (res.status !== 0) return null;
1682
+ return String(res.stdout ?? "").trim();
1683
+ } catch {
1684
+ return null;
1685
+ }
1686
+ }
1687
+ function defaultReadFile3(path7) {
1688
+ try {
1689
+ return readFileSync10(path7, "utf8");
1690
+ } catch {
1691
+ return null;
1692
+ }
1693
+ }
1694
+ function existingAncestor(path7) {
1695
+ let dir = dirname5(resolve5(path7));
1696
+ for (; ; ) {
1697
+ if (existsSync6(dir)) return dir;
1698
+ const parent = dirname5(dir);
1699
+ if (parent === dir) return dir;
1700
+ dir = parent;
1701
+ }
1702
+ }
1703
+ function createGeneratedFileEol(opts) {
1704
+ const readFile = opts.readFile ?? defaultReadFile3;
1705
+ const runGit = opts.runGit ?? defaultRunGit;
1706
+ const platform = opts.platform ?? process.platform;
1707
+ const attrCache = /* @__PURE__ */ new Map();
1708
+ let configEol;
1709
+ const configDefault = () => {
1710
+ if (configEol !== void 0) return configEol;
1711
+ if (runGit(["rev-parse", "--is-inside-work-tree"], opts.repoRoot) !== "true") {
1712
+ configEol = null;
1713
+ return configEol;
1714
+ }
1715
+ const get = (key) => runGit(["config", "--get", key], opts.repoRoot);
1716
+ const coreEol = (get("core.eol") ?? "").toLowerCase();
1717
+ if (coreEol === "crlf") configEol = "\r\n";
1718
+ else if (coreEol === "lf") configEol = "\n";
1719
+ else if (platform === "win32" && (get("core.autocrlf") ?? "").toLowerCase() === "true")
1720
+ configEol = "\r\n";
1721
+ else configEol = null;
1722
+ return configEol;
1723
+ };
1724
+ const attrEol = (path7) => {
1725
+ const abs = resolve5(path7);
1726
+ const cwd = existingAncestor(abs);
1727
+ const rel = relative(cwd, abs).replace(/\\/g, "/") || ".";
1728
+ const key = `${cwd}\0${rel}`;
1729
+ let out = attrCache.get(key);
1730
+ if (out === void 0) {
1731
+ out = runGit(["check-attr", "eol", "--", rel], cwd) ?? "";
1732
+ attrCache.set(key, out);
1733
+ }
1734
+ const m = out.match(/:\s*eol:\s*(\S+)\s*$/);
1735
+ const value = (m?.[1] ?? "").toLowerCase();
1736
+ if (value === "crlf") return "\r\n";
1737
+ if (value === "lf") return "\n";
1738
+ return null;
1739
+ };
1740
+ const eolFor = (path7, existing) => {
1741
+ const current = existing === void 0 ? readFile(path7) : existing;
1742
+ if (current !== null) {
1743
+ const have = dominantEol(current);
1744
+ if (have) return have;
1745
+ }
1746
+ return attrEol(path7) ?? configDefault() ?? "\n";
1747
+ };
1748
+ return {
1749
+ eolFor,
1750
+ convert: (path7, content, existing) => {
1751
+ if (!content.includes("\n")) return content;
1752
+ return applyEol(content, eolFor(path7, existing));
1753
+ }
1754
+ };
1755
+ }
1756
+
1629
1757
  // spex-console/src/lib/cli/install.ts
1630
- import { isAbsolute as isAbsolute3, resolve as resolveNodePath2 } from "node:path";
1758
+ import { isAbsolute as isAbsolute4, resolve as resolveNodePath2 } from "node:path";
1759
+
1760
+ // spex-console/src/lib/vault-resolve/index.ts
1761
+ import { existsSync as existsSync7, readFileSync as readFileSync11, realpathSync as realpathSync4, statSync as statSync4 } from "node:fs";
1762
+ import { tmpdir as tmpdir2 } from "node:os";
1763
+ import path3 from "node:path";
1764
+
1765
+ // spex-console/src/lib/cli/satellite.ts
1766
+ import { resolve as resolveNodePath } from "node:path";
1767
+ import process2 from "node:process";
1768
+ var SATELLITE_VAULT_ENV = "HEYSPEX_VAULT_ROOT";
1769
+ var SATELLITE_MANIFEST_RELS = [
1770
+ [".mcp.json"],
1771
+ [".cursor", "mcp.json"]
1772
+ ];
1773
+ function normalizePathForCompare(p) {
1774
+ return p.trim().replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
1775
+ }
1776
+ function samePath(a, b) {
1777
+ return normalizePathForCompare(a) === normalizePathForCompare(b);
1778
+ }
1779
+ function isAtOrUnder(child, parent) {
1780
+ const c = normalizePathForCompare(child);
1781
+ const p = normalizePathForCompare(parent);
1782
+ return c === p || c.startsWith(`${p}/`);
1783
+ }
1784
+ function isAbsolutePathForCompare(p) {
1785
+ const s = p.trim();
1786
+ return /^[\\/]/.test(s) || /^[a-zA-Z]:[\\/]/.test(s);
1787
+ }
1788
+ function defaultResolveCliPath(p) {
1789
+ const base = process2.env.HS_INVOKED_CWD?.trim() || process2.cwd();
1790
+ return resolveNodePath(base, p);
1791
+ }
1792
+ function expandPinPlaceholders(value, repoRoot) {
1793
+ return value.replace(/\$\{(?:CLAUDE_PROJECT_DIR|workspaceFolder)(?::-[^}]*)?\}/g, repoRoot);
1794
+ }
1795
+ function lexicalJoin(base, rel) {
1796
+ const parts = `${base}/${rel}`.replace(/\\/g, "/").split("/");
1797
+ const out = [];
1798
+ for (const seg of parts) {
1799
+ if (seg === "" && out.length > 0) continue;
1800
+ if (seg === ".") continue;
1801
+ if (seg === ".." && out.length > 1) {
1802
+ out.pop();
1803
+ continue;
1804
+ }
1805
+ out.push(seg);
1806
+ }
1807
+ return out.join("/");
1808
+ }
1809
+ function parseMarkerVaultPath(content) {
1810
+ const m = content.match(/^vault_path:[ \t]*(.+?)[ \t]*$/m);
1811
+ if (!m) return null;
1812
+ const v = m[1].trim().replace(/\s+#.*$/, "").replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
1813
+ if (!v || v.includes("${") || /^[\\/]/.test(v) || /^[a-zA-Z]:[\\/]/.test(v)) return null;
1814
+ return v;
1815
+ }
1816
+ function markerVaultIdOf(content) {
1817
+ const m = content.match(/^vault_id:\s*(.+?)\s*$/m);
1818
+ if (!m) return null;
1819
+ const v = m[1].trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
1820
+ return v.length > 0 ? v : null;
1821
+ }
1822
+ function detectMarkerAttachment(repoRoot, deps) {
1823
+ const markerPath = deps.joinPath(repoRoot, "heyspex.yaml");
1824
+ if (!deps.fileExists(markerPath)) return null;
1825
+ const raw = deps.readFile(markerPath);
1826
+ const vaultPath = raw ? parseMarkerVaultPath(raw) : null;
1827
+ if (!raw || !vaultPath) return null;
1828
+ const vaultRootAbs = lexicalJoin(repoRoot, vaultPath);
1829
+ const markerId = markerVaultIdOf(raw);
1830
+ let cloned = deps.fileExists(deps.joinPath(vaultRootAbs, "specs"));
1831
+ if (cloned && markerId) {
1832
+ const theirs = deps.readFile(deps.joinPath(vaultRootAbs, "heyspex.yaml"));
1833
+ const theirId = theirs ? markerVaultIdOf(theirs) : null;
1834
+ if (theirId && theirId !== markerId) cloned = false;
1835
+ }
1836
+ const registry = loadVaultRegistry(deps.registryOpts);
1837
+ const record = registry.vaults.find((v) => samePath(v.repo_root_abs, vaultRootAbs));
1838
+ return {
1839
+ vaultRootAbs,
1840
+ vaultId: markerId ?? record?.id ?? null,
1841
+ pins: [{ source: "heyspex.yaml", value: vaultRootAbs }],
1842
+ conflicting: false,
1843
+ viaMarker: true,
1844
+ cloned,
1845
+ vaultPath
1846
+ };
1847
+ }
1848
+ function readPinnedVaultRoot(repoRoot, deps) {
1849
+ const pins = [];
1850
+ for (const rel of SATELLITE_MANIFEST_RELS) {
1851
+ const path7 = deps.joinPath(repoRoot, ...rel);
1852
+ if (!deps.fileExists(path7)) continue;
1853
+ const raw = deps.readFile(path7);
1854
+ if (!raw) continue;
1855
+ let value;
1856
+ try {
1857
+ const parsed = JSON.parse(raw);
1858
+ value = parsed?.mcpServers?.heyspex?.env?.[SATELLITE_VAULT_ENV];
1859
+ } catch {
1860
+ continue;
1861
+ }
1862
+ if (typeof value !== "string") continue;
1863
+ const trimmed = value.trim();
1864
+ if (!trimmed) continue;
1865
+ const expanded = expandPinPlaceholders(trimmed, repoRoot);
1866
+ if (expanded.includes("${")) continue;
1867
+ if (/^[a-zA-Z]:(?![\\/])/.test(expanded)) continue;
1868
+ const abs = isAbsolutePathForCompare(expanded) ? expanded.includes("..") ? lexicalJoin(expanded, ".") : expanded : lexicalJoin(repoRoot, expanded);
1869
+ if (expanded !== trimmed && isAtOrUnder(abs, repoRoot)) continue;
1870
+ pins.push({ source: rel.join("/"), value: abs });
1871
+ }
1872
+ return pins;
1873
+ }
1874
+ function isVaultRootOnDisk(dir, deps) {
1875
+ return deps.fileExists(deps.joinPath(dir, "heyspex.yaml")) || deps.fileExists(deps.joinPath(dir, "specs"));
1876
+ }
1877
+ function resolveAttachTarget(idOrPath, deps) {
1878
+ const raw = idOrPath.trim();
1879
+ if (!raw) return { ok: false, message: "--attach-vault requires a vault id or path" };
1880
+ const registry = loadVaultRegistry(deps.registryOpts);
1881
+ const looksLikePath = /[\\/]/.test(raw) || /^[a-zA-Z]:$/.test(raw.slice(0, 2)) || raw === "." || raw === "..";
1882
+ const asPath = looksLikePath && !isAbsolutePathForCompare(raw) ? (deps.resolvePath ?? defaultResolveCliPath)(raw) : raw;
1883
+ const shownPath = asPath === raw ? raw : `${raw} (${asPath})`;
1884
+ const record = registry.vaults.find((v) => v.id === raw) ?? (looksLikePath ? registry.vaults.find((v) => samePath(v.repo_root_abs, asPath)) : void 0);
1885
+ if (!record) {
1886
+ const known = registry.vaults.map((v) => v.id);
1887
+ const knownList = known.length ? `known vault ids: ${known.join(", ")}` : "no vaults are registered on this machine";
1888
+ return {
1889
+ ok: false,
1890
+ message: looksLikePath ? `--attach-vault: no registered vault at ${shownPath} \u2014 register it first with \`hs vault register --path <dir>\`, then attach by id (${knownList}).` : `--attach-vault: unknown vault id "${raw}" \u2014 run \`hs vault list\` to see registered vaults (${knownList}).`
1891
+ };
1892
+ }
1893
+ if (!deps.fileExists(record.repo_root_abs)) {
1894
+ return {
1895
+ ok: false,
1896
+ message: `--attach-vault: vault "${record.id}" is registered at ${record.repo_root_abs}, which does not exist on this machine \u2014 clone it there, or fix the row with \`hs vault move\`.`
1897
+ };
1898
+ }
1899
+ if (!isVaultRootOnDisk(record.repo_root_abs, deps)) {
1900
+ return {
1901
+ ok: false,
1902
+ message: `--attach-vault: ${record.repo_root_abs} (vault "${record.id}") has neither heyspex.yaml nor specs/ \u2014 it is not a vault root.`
1903
+ };
1904
+ }
1905
+ return {
1906
+ ok: true,
1907
+ target: {
1908
+ vaultId: record.id,
1909
+ vaultRootAbs: record.repo_root_abs,
1910
+ workspaceId: record.workspace_id,
1911
+ displayName: record.display_name
1912
+ }
1913
+ };
1914
+ }
1915
+
1916
+ // spex-console/src/lib/vault-resolve/index.ts
1917
+ function parseRepoBinding(content) {
1918
+ const src = String(content ?? "");
1919
+ if (/^(vault_id|vault_root):/m.test(src)) return null;
1920
+ const kind = src.match(/^kind:\s*(.+?)\s*$/m)?.[1]?.replace(/^["']|["']$/g, "").trim();
1921
+ if (kind && kind !== "repo-binding") return null;
1922
+ const lines = src.split(/\r?\n/);
1923
+ const start = lines.findIndex((l) => /^vault:(\s|$)/.test(l));
1924
+ if (start === -1) return null;
1925
+ const unq = (v) => v.trim().replace(/\s+#.*$/, "").replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
1926
+ const scalar = lines[start].slice("vault:".length).trim();
1927
+ if (scalar && !scalar.startsWith("#")) {
1928
+ const id = unq(scalar);
1929
+ return id ? { id, name: null, remote: null } : null;
1930
+ }
1931
+ const out = {
1932
+ id: null,
1933
+ name: null,
1934
+ remote: null
1935
+ };
1936
+ for (let i = start + 1; i < lines.length; i += 1) {
1937
+ const l = lines[i];
1938
+ if (!l.trim() || /^\s*#/.test(l)) continue;
1939
+ if (!/^\s+/.test(l)) break;
1940
+ const m = l.match(/^\s+([a-z_]+):\s*(.*)$/);
1941
+ if (m && (m[1] === "id" || m[1] === "name" || m[1] === "remote")) out[m[1]] = unq(m[2]) || null;
1942
+ }
1943
+ return out.id ? { id: out.id, name: out.name, remote: out.remote } : null;
1944
+ }
1631
1945
 
1632
1946
  // scripts/install-cursor-hooks.mjs
1633
1947
  import fs2 from "node:fs";
1634
- import path3 from "node:path";
1635
- import { fileURLToPath as fileURLToPath3 } from "node:url";
1948
+ import path5 from "node:path";
1949
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
1636
1950
 
1637
1951
  // scripts/cursor-hooks/manifest.mjs
1638
1952
  var SHIM = "scripts/cursor-hooks/_run.mjs";
@@ -1778,9 +2092,9 @@ function timeoutFor(entry) {
1778
2092
  // scripts/lib/customer-kit.mjs
1779
2093
  import { createHash } from "node:crypto";
1780
2094
  import fs from "node:fs";
1781
- import path2 from "node:path";
2095
+ import path4 from "node:path";
1782
2096
  import os2 from "node:os";
1783
- import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
2097
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
1784
2098
 
1785
2099
  // scripts/lib/hook-dispositions.mjs
1786
2100
  var HOSTS = Object.freeze(["claude-code", "cursor", "copilot"]);
@@ -2050,7 +2364,7 @@ function toPosixPath(p) {
2050
2364
  function samePlatformPath(a, b) {
2051
2365
  if (!a || !b) return false;
2052
2366
  const norm = (p) => {
2053
- const s = toPosixPath(path2.resolve(p));
2367
+ const s = toPosixPath(path4.resolve(p));
2054
2368
  return process.platform === "win32" ? s.toLowerCase() : s;
2055
2369
  };
2056
2370
  return norm(a) === norm(b);
@@ -2128,8 +2442,16 @@ var LAUNCHER_TEMPLATE = [
2128
2442
  "// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd",
2129
2443
  "// 5. <npm root -g>/heyspex a global install, last resort",
2130
2444
  "// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).",
2445
+ "//",
2446
+ "// The same resolution starts the package's other entrypoints, so no committed",
2447
+ "// file names node_modules/heyspex directly and a repo with no node_modules of",
2448
+ "// its own still works off a global install:",
2449
+ "// --mcp the stdio MCP server (.mcp.json, .cursor/mcp.json)",
2450
+ "// --statusline the Claude Code status line (statusLine.command)",
2451
+ "// --resolve print the package root, exit 0; exit 1 when none (hs doctor)",
2131
2452
  "import { spawnSync } from 'node:child_process';",
2132
- "import { existsSync } from 'node:fs';",
2453
+ "import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';",
2454
+ "import { homedir } from 'node:os';",
2133
2455
  "import { dirname, resolve } from 'node:path';",
2134
2456
  "import { fileURLToPath, pathToFileURL } from 'node:url';",
2135
2457
  "",
@@ -2139,6 +2461,7 @@ var LAUNCHER_TEMPLATE = [
2139
2461
  "const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);",
2140
2462
  "const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));",
2141
2463
  "",
2464
+ "__YIELD__",
2142
2465
  "function packageUpFrom(start) {",
2143
2466
  " let dir = resolve(start);",
2144
2467
  " for (;;) {",
@@ -2173,31 +2496,109 @@ var LAUNCHER_TEMPLATE = [
2173
2496
  " if (hasShim(global)) root = global;",
2174
2497
  "}",
2175
2498
  "",
2499
+ "const ENTRYPOINTS = {",
2500
+ " '--mcp': 'vendor/heyspex-mcp/cli.mjs',",
2501
+ " '--statusline': 'scripts/claude-statusline/statusline-command.mjs',",
2502
+ "};",
2503
+ "const mode = process.argv[2];",
2504
+ "const entry = Object.hasOwn(ENTRYPOINTS, mode) ? ENTRYPOINTS[mode] : null;",
2505
+ "",
2176
2506
  "if (!root) {",
2507
+ " const what = entry || mode === '--resolve' ? mode : 'hook ' + (mode ?? '?');",
2177
2508
  " process.stderr.write(",
2178
- " '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
2179
- ` '(run "npm install heyspex" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +`,
2180
- " 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
2509
+ " '[heyspex] ' + what + ' skipped: no heyspex package found ' +",
2510
+ ` '(run "npm install heyspex" or "npm link heyspex" in this repo, install it globally, or set HEYSPEX_PLATFORM_ROOT). ' +`,
2511
+ " 'Looked for __SHIM__ under HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
2181
2512
  " );",
2513
+ " // Hooks and the status line fail open; the MCP server and --resolve fail",
2514
+ " // loudly, so the client and hs doctor both see it.",
2515
+ " process.exit(mode === '--mcp' || mode === '--resolve' ? 1 : 0);",
2516
+ "}",
2517
+ "if (mode === '--resolve') {",
2518
+ " process.stdout.write(root + '\\n');",
2182
2519
  " process.exit(0);",
2183
2520
  "}",
2184
2521
  "",
2185
2522
  "// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim",
2186
- '// reads; the shim path replaces this file so a shim guarded by "am I main?"',
2187
- "// (the Cursor one) runs exactly as if Cursor had invoked it directly.",
2188
- "const shim = resolve(root, ...SHIM);",
2189
- "process.argv[1] = shim;",
2523
+ '// reads; the target replaces this file so a script guarded by "am I main?"',
2524
+ "// runs exactly as if it had been invoked directly. Real path, so that guard",
2525
+ "// still matches when node_modules/heyspex is an npm link or a junction (node",
2526
+ "// realpaths import.meta.url but leaves argv[1] alone).",
2527
+ "let target = resolve(root, ...(entry ? entry.split('/') : SHIM));",
2190
2528
  "try {",
2191
- " await import(pathToFileURL(shim).href);",
2529
+ " target = realpathSync(target);",
2530
+ "} catch {",
2531
+ " // keep the resolved path",
2532
+ "}",
2533
+ "if (entry) process.argv.splice(2, 1);",
2534
+ "process.argv[1] = target;",
2535
+ "try {",
2536
+ " await import(pathToFileURL(target).href);",
2192
2537
  "} catch (err) {",
2193
- " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
2538
+ " process.stderr.write('[heyspex] ' + (entry ? mode : 'hook ' + (process.argv[2] ?? '?')) + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
2539
+ " // Hooks and the status line fail open; the MCP server fails loudly.",
2540
+ " if (mode === '--mcp') process.exit(1);",
2194
2541
  " process.exit(0);",
2195
2542
  "}",
2196
2543
  ""
2197
2544
  ].join("\n");
2545
+ var LAUNCHER_YIELD_MARKER = "// yields-to-plugin: v1";
2546
+ var LAUNCHER_YIELD_BLOCK = [
2547
+ LAUNCHER_YIELD_MARKER + " -- one source of hooks: when the heyspex plugin is enabled on this machine",
2548
+ "// its own launcher (plugins/heyspex/bin/run-hook.mjs, which never calls this file) already runs every",
2549
+ "// hook, so this committed row exits 0 silently before resolving anything and each hook fires ONCE.",
2550
+ "// HEYSPEX_REPO_HOOKS=1 never yield (the repo rows are the chosen source);",
2551
+ `// enabledPlugins["heyspex@..."] === false in ~/.claude/settings.json or this repo's .claude/`,
2552
+ "// settings[.local].json: plugin off here, the row runs;",
2553
+ "// plugin evidence (what `hs doctor` hook-double-fire reads): a heyspex@... key in",
2554
+ "// ~/.claude/plugins/installed_plugins.json, enabledPlugins naming heyspex with a non-false",
2555
+ "// value, or a heyspex directory under ~/.copilot/installed-plugins | ~/.copilot/plugins.",
2556
+ "function readJson(file) {",
2557
+ " try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }",
2558
+ "}",
2559
+ "function listDir(dir) {",
2560
+ " try { return readdirSync(dir); } catch { return []; }",
2561
+ "}",
2562
+ "function pluginTakesThisHook() {",
2563
+ " const flag = text(env.HEYSPEX_REPO_HOOKS);",
2564
+ " if (flag && flag !== '0' && flag.toLowerCase() !== 'false') return false;",
2565
+ " const home = homedir();",
2566
+ " const repo = resolve(here, '..', '..');",
2567
+ " const isHeyspex = (k) => /^heyspex(@|$)/i.test(k);",
2568
+ " let enabled = false;",
2569
+ " for (const f of [resolve(home, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.local.json')]) {",
2570
+ " const settings = readJson(f);",
2571
+ " const ep = settings && typeof settings === 'object' ? settings.enabledPlugins : null;",
2572
+ " if (!ep || typeof ep !== 'object') continue;",
2573
+ " for (const [k, v] of Object.entries(ep)) {",
2574
+ " if (!isHeyspex(k)) continue;",
2575
+ " if (v === false) return false;",
2576
+ " enabled = true;",
2577
+ " }",
2578
+ " }",
2579
+ " if (enabled) return true;",
2580
+ " const installed = readJson(resolve(home, '.claude', 'plugins', 'installed_plugins.json'));",
2581
+ " if (installed && typeof installed === 'object' && Object.keys(installed.plugins && typeof installed.plugins === 'object' ? installed.plugins : installed).some(isHeyspex)) return true;",
2582
+ " for (const sub of ['installed-plugins', 'plugins']) {",
2583
+ " const dir = resolve(home, '.copilot', sub);",
2584
+ " const names = listDir(dir);",
2585
+ " if (names.some((n) => /heyspex/i.test(n))) return true;",
2586
+ " if (names.some((n) => listDir(resolve(dir, n)).some((m) => /heyspex/i.test(m)))) return true;",
2587
+ " }",
2588
+ " return false;",
2589
+ "}",
2590
+ "// Only a HOOK EVENT yields: --mcp / --statusline / --resolve and an agent-run",
2591
+ "// `<script> --directive <key>` fetch are not hook events the plugin could run in",
2592
+ "// our place, so they always go on to the package below.",
2593
+ "const yieldMode = process.argv[2];",
2594
+ "const notAHookEvent = ['--mcp', '--statusline', '--resolve'].includes(yieldMode) || process.argv.includes('--directive');",
2595
+ "if (!notAHookEvent && pluginTakesThisHook()) process.exit(0);",
2596
+ "",
2597
+ ""
2598
+ ].join("\n");
2198
2599
  function renderCustomerLauncher(surface = "claude") {
2199
2600
  const l = CUSTOMER_LAUNCHERS[surface];
2200
- return LAUNCHER_TEMPLATE.replace(
2601
+ return LAUNCHER_TEMPLATE.replace("__YIELD__\n", surface === "claude" ? LAUNCHER_YIELD_BLOCK : "").replace(
2201
2602
  /__(REGISTRY|ENV|LAUNCHER|SHIM)__/g,
2202
2603
  (_m, token) => token === "REGISTRY" ? l.registry : token === "ENV" ? l.projectDirEnv : token === "LAUNCHER" ? l.rel.join("/") : l.shim
2203
2604
  );
@@ -2423,25 +2824,33 @@ var MARKER_RE = /\n<!-- heyspex-kit: managed by hs install \(sha256:([0-9a-f]{16
2423
2824
  function shortHash(text) {
2424
2825
  return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
2425
2826
  }
2827
+ function normalizeEol(text) {
2828
+ return text === null || text === void 0 ? text : String(text).replace(/\r\n/g, "\n");
2829
+ }
2426
2830
  function renderKitCommand(content, platformRoot) {
2427
- const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, "")}
2831
+ const body = `${rewritePlatformPaths(normalizeEol(content), platformRoot).replace(/\s+$/, "")}
2428
2832
  `;
2429
2833
  return `${body}
2430
2834
  <!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). Edit freely: once edited, hs install leaves this file alone. -->
2431
2835
  `;
2432
2836
  }
2433
2837
  function kitFileState(content) {
2434
- const m = String(content).match(MARKER_RE);
2838
+ const text = normalizeEol(String(content));
2839
+ const m = text.match(MARKER_RE);
2435
2840
  if (!m) return "unmanaged";
2436
- const body = String(content).slice(0, m.index).replace(/\n$/, "");
2437
- return shortHash(`${body}
2438
- `) === m[1] ? "managed" : "customized";
2841
+ const body = `${text.slice(0, m.index).replace(/\n$/, "")}
2842
+ `;
2843
+ if (shortHash(body) === m[1]) return "managed";
2844
+ if (shortHash(`${body.slice(0, -1).replace(/\n/g, "\r\n")}
2845
+ `) === m[1]) return "managed";
2846
+ return "customized";
2439
2847
  }
2440
2848
  function planKitFileWrite({ existing, rendered, raw, force }) {
2441
2849
  if (existing === null || existing === void 0) return { write: true, reason: "missing" };
2442
- if (existing === rendered) return { write: false, reason: "unchanged" };
2443
- if (kitFileState(existing) === "managed") return { write: true, reason: "update" };
2444
- if (existing === raw) return { write: true, reason: "legacy-copy" };
2850
+ const have = normalizeEol(existing);
2851
+ if (have === normalizeEol(rendered)) return { write: false, reason: "unchanged" };
2852
+ if (kitFileState(have) === "managed") return { write: true, reason: "update" };
2853
+ if (have === normalizeEol(raw)) return { write: true, reason: "legacy-copy" };
2445
2854
  return force ? { write: true, reason: "forced" } : { write: false, reason: "customized" };
2446
2855
  }
2447
2856
  function listKitCommandFiles(platformRoot, deps, opts = {}) {
@@ -2466,7 +2875,7 @@ function listKitCommandFiles(platformRoot, deps, opts = {}) {
2466
2875
  return out;
2467
2876
  }
2468
2877
  var fsDeps = {
2469
- joinPath: (...s) => path2.join(...s),
2878
+ joinPath: (...s) => path4.join(...s),
2470
2879
  readFile: (p) => {
2471
2880
  try {
2472
2881
  return fs.readFileSync(p, "utf8");
@@ -2485,22 +2894,22 @@ var fsDeps = {
2485
2894
  function invokedDirectly(importMetaUrl, selfBasename, argv1 = process.argv[1]) {
2486
2895
  try {
2487
2896
  if (!argv1) return false;
2488
- const self = fileURLToPath2(importMetaUrl);
2489
- if (path2.basename(self) !== selfBasename) return false;
2490
- return pathToFileURL(path2.resolve(argv1)).href === pathToFileURL(self).href;
2897
+ const self = fileURLToPath3(importMetaUrl);
2898
+ if (path4.basename(self) !== selfBasename) return false;
2899
+ return pathToFileURL(path4.resolve(argv1)).href === pathToFileURL(self).href;
2491
2900
  } catch {
2492
2901
  return false;
2493
2902
  }
2494
2903
  }
2495
- var SELF = fileURLToPath2(import.meta.url);
2904
+ var SELF = fileURLToPath3(import.meta.url);
2496
2905
  if (invokedDirectly(import.meta.url, "customer-kit.mjs")) {
2497
- const platformRoot = path2.resolve(path2.dirname(SELF), "..", "..");
2498
- const target = path2.join(platformRoot, "plugins", "heyspex", "hooks", "hooks.json");
2906
+ const platformRoot = path4.resolve(path4.dirname(SELF), "..", "..");
2907
+ const target = path4.join(platformRoot, "plugins", "heyspex", "hooks", "hooks.json");
2499
2908
  const want = renderPluginHooksText();
2500
2909
  const same = (fsDeps.readFile(target) ?? "").replace(/\r\n/g, "\n") === want;
2501
2910
  if (process.argv.includes("--write-plugin-hooks")) {
2502
2911
  if (!same) fs.writeFileSync(target, want, "utf8");
2503
- console.log(`customer-kit: ${same ? "unchanged" : "wrote"} ${path2.relative(platformRoot, target)}`);
2912
+ console.log(`customer-kit: ${same ? "unchanged" : "wrote"} ${path4.relative(platformRoot, target)}`);
2504
2913
  } else if (process.argv.includes("--check-plugin-hooks")) {
2505
2914
  if (!same) {
2506
2915
  console.error("customer-kit: plugins/heyspex/hooks/hooks.json is stale \u2014 run `node scripts/lib/customer-kit.mjs --write-plugin-hooks`");
@@ -2508,15 +2917,15 @@ if (invokedDirectly(import.meta.url, "customer-kit.mjs")) {
2508
2917
  }
2509
2918
  console.log("customer-kit: plugins/heyspex/hooks/hooks.json up to date");
2510
2919
  } else if (process.argv.includes("--write-github-hooks") || process.argv.includes("--check-github-hooks")) {
2511
- const ghTarget = path2.join(platformRoot, ".github", "hooks", "heyspex.json");
2920
+ const ghTarget = path4.join(platformRoot, ".github", "hooks", "heyspex.json");
2512
2921
  const ghWant = renderGithubHooksText();
2513
2922
  const ghSame = (fsDeps.readFile(ghTarget) ?? "").replace(/\r\n/g, "\n") === ghWant;
2514
2923
  if (process.argv.includes("--write-github-hooks")) {
2515
2924
  if (!ghSame) {
2516
- fs.mkdirSync(path2.dirname(ghTarget), { recursive: true });
2925
+ fs.mkdirSync(path4.dirname(ghTarget), { recursive: true });
2517
2926
  fs.writeFileSync(ghTarget, ghWant, "utf8");
2518
2927
  }
2519
- console.log(`customer-kit: ${ghSame ? "unchanged" : "wrote"} ${path2.relative(platformRoot, ghTarget).replace(/\\/g, "/")}`);
2928
+ console.log(`customer-kit: ${ghSame ? "unchanged" : "wrote"} ${path4.relative(platformRoot, ghTarget).replace(/\\/g, "/")}`);
2520
2929
  } else if (!ghSame) {
2521
2930
  console.error("customer-kit: .github/hooks/heyspex.json is stale - run `node scripts/lib/customer-kit.mjs --write-github-hooks`");
2522
2931
  process.exit(1);
@@ -2529,9 +2938,9 @@ if (invokedDirectly(import.meta.url, "customer-kit.mjs")) {
2529
2938
  }
2530
2939
 
2531
2940
  // scripts/install-cursor-hooks.mjs
2532
- var PLATFORM_ROOT = path3.resolve(path3.dirname(fileURLToPath3(import.meta.url)), "..");
2533
- var REPO_ROOT = process.env.HS_SETUP_TARGET?.trim() ? path3.resolve(process.env.HS_SETUP_TARGET.trim()) : PLATFORM_ROOT;
2534
- var SETTINGS_PATH = path3.join(REPO_ROOT, ".cursor", "hooks.json");
2941
+ var PLATFORM_ROOT = path5.resolve(path5.dirname(fileURLToPath4(import.meta.url)), "..");
2942
+ var REPO_ROOT = process.env.HS_SETUP_TARGET?.trim() ? path5.resolve(process.env.HS_SETUP_TARGET.trim()) : PLATFORM_ROOT;
2943
+ var SETTINGS_PATH = path5.join(REPO_ROOT, ".cursor", "hooks.json");
2535
2944
  function cursorKitFor(targetRoot, platformRoot = PLATFORM_ROOT) {
2536
2945
  const customer = !samePlatformPath(targetRoot, platformRoot);
2537
2946
  return {
@@ -2543,7 +2952,7 @@ function cursorKitFor(targetRoot, platformRoot = PLATFORM_ROOT) {
2543
2952
  }
2544
2953
  var KIT = cursorKitFor(REPO_ROOT);
2545
2954
  var ACTIVE_HOOKS = KIT.hooks;
2546
- var LAUNCHER_PATH = path3.join(REPO_ROOT, ...CUSTOMER_LAUNCHERS.cursor.rel);
2955
+ var LAUNCHER_PATH = path5.join(REPO_ROOT, ...CUSTOMER_LAUNCHERS.cursor.rel);
2547
2956
  var LEGACY_MARKER = "scripts/claude-hooks/";
2548
2957
  function readSettings() {
2549
2958
  try {
@@ -2562,7 +2971,7 @@ function readSettings() {
2562
2971
  return { version: 1, hooks: {} };
2563
2972
  }
2564
2973
  function writeSettings(settings) {
2565
- fs2.mkdirSync(path3.dirname(SETTINGS_PATH), { recursive: true });
2974
+ fs2.mkdirSync(path5.dirname(SETTINGS_PATH), { recursive: true });
2566
2975
  fs2.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}
2567
2976
  `, "utf8");
2568
2977
  }
@@ -2597,7 +3006,7 @@ function sameEntry(raw, entry, render = KIT.commandFor) {
2597
3006
  const want = desiredEntry(entry, render);
2598
3007
  return commandOf(raw) === want.command && raw.timeout === want.timeout && (raw.matcher ?? void 0) === (want.matcher ?? void 0);
2599
3008
  }
2600
- var PLUGIN_HOOKS_PATH = path3.join(REPO_ROOT, "plugins", "heyspex", "hooks", "cursor-hooks.json");
3009
+ var PLUGIN_HOOKS_PATH = path5.join(REPO_ROOT, "plugins", "heyspex", "hooks", "cursor-hooks.json");
2601
3010
  function pluginHooksDocument() {
2602
3011
  const hooks = {};
2603
3012
  for (const entry of HOOKS) {
@@ -2627,7 +3036,7 @@ function syncPluginHooks() {
2627
3036
  have = null;
2628
3037
  }
2629
3038
  if (have === want) return false;
2630
- fs2.mkdirSync(path3.dirname(PLUGIN_HOOKS_PATH), { recursive: true });
3039
+ fs2.mkdirSync(path5.dirname(PLUGIN_HOOKS_PATH), { recursive: true });
2631
3040
  fs2.writeFileSync(PLUGIN_HOOKS_PATH, want, "utf8");
2632
3041
  return true;
2633
3042
  }
@@ -2700,7 +3109,7 @@ function install({ json = false } = {}) {
2700
3109
  if (KIT.customer) {
2701
3110
  const plan = launcherPlan();
2702
3111
  if (plan.write) {
2703
- fs2.mkdirSync(path3.dirname(LAUNCHER_PATH), { recursive: true });
3112
+ fs2.mkdirSync(path5.dirname(LAUNCHER_PATH), { recursive: true });
2704
3113
  fs2.writeFileSync(LAUNCHER_PATH, plan.text, "utf8");
2705
3114
  launcherWritten = true;
2706
3115
  }
@@ -2727,10 +3136,10 @@ function install({ json = false } = {}) {
2727
3136
  return;
2728
3137
  }
2729
3138
  if (pluginChanged) {
2730
- console.log(`install-cursor-hooks: regenerated ${path3.relative(REPO_ROOT, PLUGIN_HOOKS_PATH)}`);
3139
+ console.log(`install-cursor-hooks: regenerated ${path5.relative(REPO_ROOT, PLUGIN_HOOKS_PATH)}`);
2731
3140
  }
2732
3141
  if (launcherWritten) {
2733
- console.log(`install-cursor-hooks: wrote ${path3.relative(REPO_ROOT, LAUNCHER_PATH)} (resolves node_modules/heyspex)`);
3142
+ console.log(`install-cursor-hooks: wrote ${path5.relative(REPO_ROOT, LAUNCHER_PATH)} (resolves node_modules/heyspex)`);
2734
3143
  }
2735
3144
  if (!settingsChanged) {
2736
3145
  console.log(`install-cursor-hooks: already installed (${ACTIVE_HOOKS.length} hooks, no changes).`);
@@ -2741,7 +3150,7 @@ function install({ json = false } = {}) {
2741
3150
  if (repaired.length) parts.push(`repaired ${repaired.length}`);
2742
3151
  if (removed.length) parts.push(`removed ${removed.length}`);
2743
3152
  console.log(
2744
- `install-cursor-hooks: ${parts.join("; ")} \u2192 ${path3.relative(REPO_ROOT, SETTINGS_PATH)} (${ACTIVE_HOOKS.length} hooks registered)`
3153
+ `install-cursor-hooks: ${parts.join("; ")} \u2192 ${path5.relative(REPO_ROOT, SETTINGS_PATH)} (${ACTIVE_HOOKS.length} hooks registered)`
2745
3154
  );
2746
3155
  for (const line of removed) console.log(` - ${line}`);
2747
3156
  console.log(
@@ -2843,11 +3252,11 @@ if (invokedDirectly(import.meta.url, "install-cursor-hooks.mjs")) {
2843
3252
  init_credentials();
2844
3253
 
2845
3254
  // spex-console/src/lib/cli/connect-checks.ts
2846
- import { dirname as dirname5 } from "node:path";
3255
+ import { dirname as dirname6 } from "node:path";
2847
3256
  import { pathToFileURL as pathToFileURL2 } from "node:url";
2848
3257
 
2849
3258
  // spex-console/src/lib/dev-mode.ts
2850
- import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
3259
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync6 } from "node:fs";
2851
3260
  import { join as join10 } from "node:path";
2852
3261
  init_credentials();
2853
3262
 
@@ -3094,12 +3503,15 @@ var HELP = [
3094
3503
  "(spex-console/src/lib/cli/template-manifest.ts) \u2014 universal docs",
3095
3504
  "copied verbatim, template-shape docs written as author-prompt skeletons \u2014",
3096
3505
  "plus a generated CLAUDE.md, the kept-principles index, and the template_version",
3097
- "pin; with --git, one initial `git init` + commit. Rerunnable: a <dir> that already",
3098
- "holds heyspex.yaml and an empty/placeholder-only specs/ is accepted, existing",
3099
- "files are never overwritten (an existing CLAUDE.md gets a CLAUDE.md.template",
3100
- "beside it), and the result lists each manifest row as created or kept. Refuses a",
3101
- "non-empty <dir> without heyspex.yaml, or a specs/ holding managed specs.",
3102
- "See init.customer-vault.ts.",
3506
+ "pin; with --git, one initial `git init` + commit. On an EXISTING vault (<dir>",
3507
+ "holds heyspex.yaml with a vault_id) it is a REFRESH \u2014 the repair `hs doctor`'s",
3508
+ "vault-template row names: only missing shipped files are added, template_version",
3509
+ "is written/updated in heyspex.yaml (every other byte and the vault_id kept),",
3510
+ "nothing existing is ever overwritten (an existing CLAUDE.md gets a",
3511
+ "CLAUDE.md.template beside it), the result lists each manifest row as created or",
3512
+ "kept, and a second run writes nothing. <dir> may be any path (a satellite repo",
3513
+ "names its vault, e.g. `hs init --customer-vault ../my-specs`). Refuses only a",
3514
+ "non-empty <dir> that has no heyspex.yaml. See init.customer-vault.ts.",
3103
3515
  "",
3104
3516
  "Flags:",
3105
3517
  " --console-url <url> console_url value (default: the mode-aware console --",
@@ -3121,157 +3533,6 @@ var HELP = [
3121
3533
  "Exit codes: 0 ok \xB7 1 usage \xB7 11 bad-arg \xB7 13 write-failed"
3122
3534
  ].join("\n");
3123
3535
 
3124
- // spex-console/src/lib/cli/satellite.ts
3125
- import { resolve as resolveNodePath } from "node:path";
3126
- import process2 from "node:process";
3127
- var SATELLITE_VAULT_ENV = "HEYSPEX_VAULT_ROOT";
3128
- var SATELLITE_MANIFEST_RELS = [
3129
- [".mcp.json"],
3130
- [".cursor", "mcp.json"]
3131
- ];
3132
- function normalizePathForCompare(p) {
3133
- return p.trim().replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
3134
- }
3135
- function samePath(a, b) {
3136
- return normalizePathForCompare(a) === normalizePathForCompare(b);
3137
- }
3138
- function isAtOrUnder(child, parent) {
3139
- const c = normalizePathForCompare(child);
3140
- const p = normalizePathForCompare(parent);
3141
- return c === p || c.startsWith(`${p}/`);
3142
- }
3143
- function isAbsolutePathForCompare(p) {
3144
- const s = p.trim();
3145
- return /^[\\/]/.test(s) || /^[a-zA-Z]:[\\/]/.test(s);
3146
- }
3147
- function defaultResolveCliPath(p) {
3148
- const base = process2.env.HS_INVOKED_CWD?.trim() || process2.cwd();
3149
- return resolveNodePath(base, p);
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
- }
3207
- function readPinnedVaultRoot(repoRoot, deps) {
3208
- const pins = [];
3209
- for (const rel of SATELLITE_MANIFEST_RELS) {
3210
- const path5 = deps.joinPath(repoRoot, ...rel);
3211
- if (!deps.fileExists(path5)) continue;
3212
- const raw = deps.readFile(path5);
3213
- if (!raw) continue;
3214
- let value;
3215
- try {
3216
- const parsed = JSON.parse(raw);
3217
- value = parsed?.mcpServers?.heyspex?.env?.[SATELLITE_VAULT_ENV];
3218
- } catch {
3219
- continue;
3220
- }
3221
- if (typeof value !== "string") continue;
3222
- const trimmed = value.trim();
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 });
3230
- }
3231
- return pins;
3232
- }
3233
- function isVaultRootOnDisk(dir, deps) {
3234
- return deps.fileExists(deps.joinPath(dir, "heyspex.yaml")) || deps.fileExists(deps.joinPath(dir, "specs"));
3235
- }
3236
- function resolveAttachTarget(idOrPath, deps) {
3237
- const raw = idOrPath.trim();
3238
- if (!raw) return { ok: false, message: "--attach-vault requires a vault id or path" };
3239
- const registry = loadVaultRegistry(deps.registryOpts);
3240
- const looksLikePath = /[\\/]/.test(raw) || /^[a-zA-Z]:$/.test(raw.slice(0, 2)) || raw === "." || raw === "..";
3241
- const asPath = looksLikePath && !isAbsolutePathForCompare(raw) ? (deps.resolvePath ?? defaultResolveCliPath)(raw) : raw;
3242
- const shownPath = asPath === raw ? raw : `${raw} (${asPath})`;
3243
- const record = registry.vaults.find((v) => v.id === raw) ?? (looksLikePath ? registry.vaults.find((v) => samePath(v.repo_root_abs, asPath)) : void 0);
3244
- if (!record) {
3245
- const known = registry.vaults.map((v) => v.id);
3246
- const knownList = known.length ? `known vault ids: ${known.join(", ")}` : "no vaults are registered on this machine";
3247
- return {
3248
- ok: false,
3249
- message: looksLikePath ? `--attach-vault: no registered vault at ${shownPath} \u2014 register it first with \`hs vault register --path <dir>\`, then attach by id (${knownList}).` : `--attach-vault: unknown vault id "${raw}" \u2014 run \`hs vault list\` to see registered vaults (${knownList}).`
3250
- };
3251
- }
3252
- if (!deps.fileExists(record.repo_root_abs)) {
3253
- return {
3254
- ok: false,
3255
- message: `--attach-vault: vault "${record.id}" is registered at ${record.repo_root_abs}, which does not exist on this machine \u2014 clone it there, or fix the row with \`hs vault move\`.`
3256
- };
3257
- }
3258
- if (!isVaultRootOnDisk(record.repo_root_abs, deps)) {
3259
- return {
3260
- ok: false,
3261
- message: `--attach-vault: ${record.repo_root_abs} (vault "${record.id}") has neither heyspex.yaml nor specs/ \u2014 it is not a vault root.`
3262
- };
3263
- }
3264
- return {
3265
- ok: true,
3266
- target: {
3267
- vaultId: record.id,
3268
- vaultRootAbs: record.repo_root_abs,
3269
- workspaceId: record.workspace_id,
3270
- displayName: record.display_name
3271
- }
3272
- };
3273
- }
3274
-
3275
3536
  // spex-console/src/lib/cli/connect-checks.ts
3276
3537
  function listCanonicalHarnessCommandFiles(platformRoot, deps, opts = {}) {
3277
3538
  return listKitCommandFiles(
@@ -3405,7 +3666,7 @@ function writeProjectContract(ctx, options2 = {}) {
3405
3666
  }
3406
3667
 
3407
3668
  // spex-console/src/lib/cli/init.customer-vault.ts
3408
- import { basename as basename2 } from "node:path";
3669
+ import { basename as basename3 } from "node:path";
3409
3670
 
3410
3671
  // spex-console/src/lib/cli/template-manifest.sources.mjs
3411
3672
  var UNIVERSAL_TEMPLATE_ROWS = [
@@ -3890,41 +4151,42 @@ var SCAFFOLD_FOLDERS = [
3890
4151
  "checks",
3891
4152
  "events"
3892
4153
  ];
3893
- var PLACEHOLDER_FILES = /* @__PURE__ */ new Set([".gitkeep"]);
3894
- function isDir(deps, path5, name) {
3895
- if (deps.isDirectory) return deps.isDirectory(path5);
3896
- try {
3897
- const kids = deps.readdir(path5);
3898
- return kids.length > 0 || !name.includes(".");
3899
- } catch {
3900
- return false;
3901
- }
4154
+ function defaultVaultName(targetDir) {
4155
+ return basename3(targetDir) || targetDir;
3902
4156
  }
3903
- function findManagedSpecs(deps, targetDir, manifestDests) {
3904
- const out = [];
3905
- const walk = (abs, rel) => {
3906
- for (const name of deps.readdir(abs)) {
3907
- const childAbs = deps.joinPath(abs, name);
3908
- const childRel = `${rel}/${name}`;
3909
- if (isDir(deps, childAbs, name)) walk(childAbs, childRel);
3910
- else if (!PLACEHOLDER_FILES.has(name) && name.endsWith(".md") && !manifestDests.has(childRel))
3911
- out.push(childRel);
3912
- }
3913
- };
3914
- const specsRoot = deps.joinPath(targetDir, "specs");
3915
- if (deps.exists(specsRoot)) walk(specsRoot, "specs");
3916
- return out;
4157
+ function readTemplateVersionPin(yamlText) {
4158
+ const m = yamlText.match(/^template_version:[ \t]*(.*?)[ \t]*$/m);
4159
+ if (!m) return null;
4160
+ const raw = m[1].trim();
4161
+ const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
4162
+ return unquoted.length > 0 ? unquoted : null;
3917
4163
  }
3918
- function defaultVaultName(targetDir) {
3919
- return basename2(targetDir) || targetDir;
4164
+ function stampTemplateVersion(content, version) {
4165
+ if (readTemplateVersionPin(content) === version) return content;
4166
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
4167
+ const line = `template_version: ${version}`;
4168
+ if (/^template_version:[^\r\n]*$/m.test(content)) {
4169
+ return content.replace(/^template_version:[^\r\n]*$/m, line);
4170
+ }
4171
+ const body = content.replace(/(?:\r?\n)+$/, "");
4172
+ return body.length === 0 ? `${line}${eol}` : `${body}${eol}${line}${eol}`;
4173
+ }
4174
+ function parentDirOf(deps, targetDir, dest) {
4175
+ const segments = dest.split("/");
4176
+ if (segments.length < 2) return null;
4177
+ return deps.joinPath(targetDir, ...segments.slice(0, -1));
3920
4178
  }
3921
4179
  function runCustomerVaultInit(args, deps, options2 = {}) {
3922
4180
  const { targetDir } = args;
3923
4181
  const written = [];
3924
4182
  const filter = options2.manifestFilter ?? (() => true);
3925
4183
  const rows = TEMPLATE_MANIFEST.filter(filter);
4184
+ const heyspexYamlPath = deps.joinPath(targetDir, "heyspex.yaml");
4185
+ const markerPresent = deps.exists(targetDir) && deps.exists(heyspexYamlPath);
4186
+ const existingYaml = markerPresent ? deps.readFile?.(heyspexYamlPath) ?? null : null;
4187
+ const existingVaultId = existingYaml ? parseVaultId(existingYaml) : null;
4188
+ const mode = markerPresent && existingVaultId ? "refresh" : "scaffold";
3926
4189
  if (deps.exists(targetDir)) {
3927
- const markerPresent = deps.exists(deps.joinPath(targetDir, "heyspex.yaml"));
3928
4190
  if (!markerPresent && deps.readdir(targetDir).length > 0) {
3929
4191
  return {
3930
4192
  code: EXIT.INVALID_ARG,
@@ -3934,43 +4196,43 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
3934
4196
  written: []
3935
4197
  };
3936
4198
  }
3937
- if (markerPresent) {
3938
- const manifestDests = new Set(TEMPLATE_MANIFEST.map((r) => r.dest));
3939
- const managed = findManagedSpecs(deps, targetDir, manifestDests);
3940
- if (managed.length > 0) {
3941
- return {
3942
- code: EXIT.INVALID_ARG,
3943
- message: `${targetDir}/specs already holds managed spec(s) (${managed.slice(0, 3).join(", ")}${managed.length > 3 ? ", ..." : ""}) \u2014 customer-vault init refuses to write over an existing vault.`,
3944
- payload: { ok: false, error: "specs_not_empty", path: targetDir, specs: managed },
3945
- isError: true,
3946
- written: []
3947
- };
3948
- }
3949
- }
3950
4199
  } else {
3951
4200
  deps.mkdirRecursive(targetDir);
3952
4201
  }
3953
- const specsRoot = deps.joinPath(targetDir, "specs");
3954
- deps.mkdirRecursive(specsRoot);
3955
- for (const folder of SCAFFOLD_FOLDERS) {
3956
- deps.mkdirRecursive(deps.joinPath(specsRoot, folder));
4202
+ if (mode === "scaffold") {
4203
+ const specsRoot = deps.joinPath(targetDir, "specs");
4204
+ deps.mkdirRecursive(specsRoot);
4205
+ for (const folder of SCAFFOLD_FOLDERS) {
4206
+ deps.mkdirRecursive(deps.joinPath(specsRoot, folder));
4207
+ }
3957
4208
  }
3958
- const heyspexYamlPath = deps.joinPath(targetDir, "heyspex.yaml");
3959
4209
  let vaultId;
3960
- if (deps.exists(heyspexYamlPath)) {
3961
- const existingYaml = deps.readFile?.(heyspexYamlPath) ?? null;
3962
- vaultId = (existingYaml ? parseVaultId(existingYaml) : null) ?? "";
4210
+ let pin;
4211
+ if (markerPresent) {
4212
+ vaultId = existingVaultId ?? "";
4213
+ const stamped = existingYaml === null ? null : stampTemplateVersion(existingYaml, TEMPLATE_VERSION);
4214
+ if (stamped !== null && stamped !== existingYaml) {
4215
+ deps.writeFile(heyspexYamlPath, stamped);
4216
+ written.push(heyspexYamlPath);
4217
+ pin = "written";
4218
+ } else {
4219
+ pin = "kept";
4220
+ }
3963
4221
  } else {
3964
4222
  vaultId = mintVaultId();
3965
4223
  deps.writeFile(
3966
4224
  heyspexYamlPath,
3967
- renderHeyspexYaml({
3968
- consoleUrl: options2.consoleUrl ?? DEFAULT_CONSOLE_URL2,
3969
- vaultRoot: "specs/",
3970
- vaultId
3971
- })
4225
+ stampTemplateVersion(
4226
+ renderHeyspexYaml({
4227
+ consoleUrl: options2.consoleUrl ?? DEFAULT_CONSOLE_URL2,
4228
+ vaultRoot: "specs/",
4229
+ vaultId
4230
+ }),
4231
+ TEMPLATE_VERSION
4232
+ )
3972
4233
  );
3973
4234
  written.push(heyspexYamlPath);
4235
+ pin = "written";
3974
4236
  }
3975
4237
  const readConfigSeed = deps.readConfigSeed;
3976
4238
  const configSeed = seedConfigYaml(
@@ -3986,6 +4248,10 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
3986
4248
  rowResults.push({ dest: row.dest, action: "kept" });
3987
4249
  continue;
3988
4250
  }
4251
+ if (mode === "refresh") {
4252
+ const parent = parentDirOf(deps, targetDir, row.dest);
4253
+ if (parent && !deps.exists(parent)) deps.mkdirRecursive(parent);
4254
+ }
3989
4255
  if (row.classification === "universal-template") {
3990
4256
  const srcAbs = deps.joinPath(deps.platformRoot, ...row.source.split("/"));
3991
4257
  deps.copyFile(srcAbs, destAbs);
@@ -4005,24 +4271,41 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
4005
4271
  targetDir,
4006
4272
  vaultName: options2.vaultName ?? defaultVaultName(targetDir),
4007
4273
  templateVersion: TEMPLATE_VERSION,
4274
+ mode,
4008
4275
  manifestRows: rows,
4009
4276
  rows: rowResults,
4010
4277
  deps,
4011
- recordWrite: (path5) => {
4012
- written.push(path5);
4278
+ recordWrite: (path7) => {
4279
+ written.push(path7);
4013
4280
  }
4014
4281
  };
4015
4282
  for (const hook of options2.afterScaffold) hook(ctx);
4016
4283
  }
4017
4284
  const createdCount = rowResults.filter((r) => r.action === "created").length;
4018
4285
  const keptCount = rowResults.length - createdCount;
4286
+ const added = rowResults.filter((r) => r.action === "created").map((r) => r.dest);
4287
+ let message2;
4288
+ if (mode === "refresh") {
4289
+ const parts = [];
4290
+ if (added.length > 0)
4291
+ parts.push(`added ${added.length} missing template file(s): ${added.join(", ")}`);
4292
+ if (pin === "written")
4293
+ parts.push(`pinned template_version: ${TEMPLATE_VERSION} in heyspex.yaml`);
4294
+ const hookWrites = written.length - added.length - (pin === "written" ? 1 : 0);
4295
+ if (hookWrites > 0) parts.push(`${hookWrites} other file(s) written`);
4296
+ message2 = parts.length > 0 ? `Refreshed customer vault at ${targetDir}: ${parts.join("; ")} (${keptCount} existing file(s) kept untouched).` : `Customer vault at ${targetDir} is already current (template v${TEMPLATE_VERSION}, ${keptCount} file(s) kept) \u2014 nothing written.`;
4297
+ } else {
4298
+ message2 = `Initialized customer vault at ${targetDir} (${written.length} files written, ${createdCount} manifest rows created, ${keptCount} kept, template v${TEMPLATE_VERSION}).`;
4299
+ }
4019
4300
  return {
4020
4301
  code: EXIT.OK,
4021
- message: `Initialized customer vault at ${targetDir} (${written.length} files written, ${createdCount} manifest rows created, ${keptCount} kept, template v${TEMPLATE_VERSION}).`,
4302
+ message: message2,
4022
4303
  payload: {
4023
4304
  ok: true,
4305
+ mode,
4024
4306
  targetDir,
4025
4307
  templateVersion: TEMPLATE_VERSION,
4308
+ templatePin: pin,
4026
4309
  vaultId,
4027
4310
  written,
4028
4311
  rows: rowResults,
@@ -4035,7 +4318,7 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
4035
4318
  }
4036
4319
 
4037
4320
  // spex-console/src/lib/cli/init-git-finalize.ts
4038
- import { spawnSync } from "node:child_process";
4321
+ import { spawnSync as spawnSync2 } from "node:child_process";
4039
4322
  function readTemplateVersion(yamlText) {
4040
4323
  const m = yamlText.match(/^template_version:\s*(.+?)\s*$/m);
4041
4324
  if (!m) return null;
@@ -4064,14 +4347,14 @@ function templatePinHook() {
4064
4347
  }
4065
4348
 
4066
4349
  // spex-console/src/lib/cli/principle-selection.ts
4067
- import { basename as basename3 } from "node:path";
4350
+ import { basename as basename4 } from "node:path";
4068
4351
  var PRINCIPLES_INDEX_DEST = "specs/principles-index.md";
4069
4352
  function principleKey(row) {
4070
- const base = basename3(row.dest, ".md");
4353
+ const base = basename4(row.dest, ".md");
4071
4354
  return base.startsWith("principle-") ? base.slice("principle-".length) : base;
4072
4355
  }
4073
4356
  function principleWikilink(row) {
4074
- return basename3(row.dest, ".md");
4357
+ return basename4(row.dest, ".md");
4075
4358
  }
4076
4359
  function parsePrinciplesFlag(value, rows) {
4077
4360
  const principleRows = rows.filter((row) => row.group === "principle");
@@ -4137,13 +4420,13 @@ function applyCustomerTemplate(args) {
4137
4420
  templateVersion: ctx.templateVersion
4138
4421
  });
4139
4422
  for (const file of contractFiles) {
4140
- const path5 = ctx.deps.joinPath(ctx.targetDir, file);
4141
- if (force && ctx.deps.exists(path5)) {
4142
- if (ctx.deps.readFile?.(path5) === content) {
4423
+ const path7 = ctx.deps.joinPath(ctx.targetDir, file);
4424
+ if (force && ctx.deps.exists(path7)) {
4425
+ if (ctx.deps.readFile?.(path7) === content) {
4143
4426
  contracts.push({ file, action: "unchanged" });
4144
4427
  } else {
4145
- ctx.deps.writeFile(path5, content);
4146
- ctx.recordWrite(path5);
4428
+ ctx.deps.writeFile(path7, content);
4429
+ ctx.recordWrite(path7);
4147
4430
  contracts.push({ file, action: "overwritten" });
4148
4431
  }
4149
4432
  continue;
@@ -4466,7 +4749,7 @@ function formatReceipt(receipt) {
4466
4749
  }
4467
4750
 
4468
4751
  // spex-console/src/lib/cli/vault-workspace-shared.ts
4469
- import { readFileSync as readFileSync10 } from "node:fs";
4752
+ import { readFileSync as readFileSync13 } from "node:fs";
4470
4753
  var VAULT_OWNED_BY_OTHER_ACCOUNT = "vault_owned_by_other_account";
4471
4754
  var WORKSPACE_OWNED_BY_OTHER_ACCOUNT = "workspace_owned_by_other_account";
4472
4755
  function vaultOwnedByOtherAccountMessage(id) {
@@ -4480,10 +4763,10 @@ function classifyHttpFailureReason(res) {
4480
4763
  if (res.status === 401) return "unauthorized";
4481
4764
  return "server-error";
4482
4765
  }
4483
- async function cloudHttpRequest(http, method, path5, body) {
4484
- const scopeHeaders = http.scope ? http.scope.headersFor(method, path5) : {};
4766
+ async function cloudHttpRequest(http, method, path7, body) {
4767
+ const scopeHeaders = http.scope ? http.scope.headersFor(method, path7) : {};
4485
4768
  try {
4486
- const res = await http.fetchImpl(`${http.consoleUrl}${path5}`, {
4769
+ const res = await http.fetchImpl(`${http.consoleUrl}${path7}`, {
4487
4770
  method,
4488
4771
  headers: {
4489
4772
  "content-type": "application/json",
@@ -4542,8 +4825,8 @@ async function classifyWorkspaceConflictHttp(id, conflictJson, http) {
4542
4825
  // spex-console/src/lib/cli/receipt-readback.ts
4543
4826
  var RECEIPT_CLOUD_READS = 3;
4544
4827
  var RECEIPT_REREAD_DELAY_MS = 1e3;
4545
- var realSleep = (ms) => new Promise((resolve6) => {
4546
- setTimeout(resolve6, ms);
4828
+ var realSleep = (ms) => new Promise((resolve7) => {
4829
+ setTimeout(resolve7, ms);
4547
4830
  });
4548
4831
  function message(err) {
4549
4832
  return err instanceof Error ? err.message : String(err);
@@ -4581,11 +4864,11 @@ function readLocalReceiptSide(subject, registryOpts = {}) {
4581
4864
  return unreadSide(`registry.json could not be read: ${message(err)}`);
4582
4865
  }
4583
4866
  }
4584
- async function readList(http, path5, key) {
4585
- const res = await cloudHttpRequest(http, "GET", path5);
4867
+ async function readList(http, path7, key) {
4868
+ const res = await cloudHttpRequest(http, "GET", path7);
4586
4869
  const rows = res.json?.[key];
4587
4870
  if (!res.ok || !Array.isArray(rows)) {
4588
- return { reason: `GET ${path5.split("?")[0]} answered HTTP ${res.status}` };
4871
+ return { reason: `GET ${path7.split("?")[0]} answered HTTP ${res.status}` };
4589
4872
  }
4590
4873
  return { rows: rows.filter((r) => Boolean(r) && typeof r === "object") };
4591
4874
  }
@@ -4690,7 +4973,7 @@ async function readReceiptSides(subject, deps) {
4690
4973
  var import_gray_matter = __toESM(require_gray_matter(), 1);
4691
4974
  import { createHash as createHash2 } from "node:crypto";
4692
4975
  import { promises as fs3 } from "node:fs";
4693
- import path4 from "node:path";
4976
+ import path6 from "node:path";
4694
4977
 
4695
4978
  // spex-console/src/lib/vault-id.ts
4696
4979
  var FILE_KEY_RE = /^(?=[0-9]*[A-Z])[A-Z0-9]{4,}$/;
@@ -5272,8 +5555,8 @@ async function indexTemplateToCloud(args) {
5272
5555
  const res = await cloudHttpRequest(args.http, "GET", REINDEX_PATH);
5273
5556
  return { status: res.status, json: res.json };
5274
5557
  },
5275
- post: async (path5, body) => {
5276
- const res = await cloudHttpRequest(args.http, "POST", path5, body);
5558
+ post: async (path7, body) => {
5559
+ const res = await cloudHttpRequest(args.http, "POST", path7, body);
5277
5560
  return { status: res.status, json: res.json };
5278
5561
  }
5279
5562
  });
@@ -5319,9 +5602,17 @@ async function indexTemplateToCloud(args) {
5319
5602
  // spex-console/src/lib/cli/install.ts
5320
5603
  var MCP_REL = ["vendor", "heyspex-mcp", "cli.mjs"];
5321
5604
  var PORTABLE_MCP_CLI = Object.freeze({
5605
+ claude: `\${CLAUDE_PROJECT_DIR:-.}/${CUSTOMER_LAUNCHERS.claude.rel.join("/")}`,
5606
+ cursor: `\${workspaceFolder}/${CUSTOMER_LAUNCHERS.cursor.rel.join("/")}`
5607
+ });
5608
+ var LEGACY_PORTABLE_MCP_CLI = Object.freeze({
5322
5609
  claude: `\${CLAUDE_PROJECT_DIR:-.}/${PORTABLE_PACKAGE_DIR}/${MCP_REL.join("/")}`,
5323
5610
  cursor: `\${workspaceFolder}/${PORTABLE_PACKAGE_DIR}/${MCP_REL.join("/")}`
5324
5611
  });
5612
+ var LAUNCHER_MCP_FLAG = "--mcp";
5613
+ function mcpServerArgs(cliPath) {
5614
+ return /[\\/]heyspex[\\/]run-hook\.mjs$/.test(cliPath) ? [cliPath, LAUNCHER_MCP_FLAG] : [cliPath];
5615
+ }
5325
5616
  function mcpCliArgFor(surface, platformRoot, targetDir, joinPath) {
5326
5617
  return samePlatformPath(targetDir, platformRoot) ? mcpCliPath(platformRoot, joinPath) : PORTABLE_MCP_CLI[surface];
5327
5618
  }
@@ -5482,7 +5773,7 @@ function mcpCliPath(platformRoot, joinPath) {
5482
5773
  function resolveCallerPath(p, env, fallbackCwd) {
5483
5774
  const trimmed = p?.trim() ?? "";
5484
5775
  if (!trimmed) return "";
5485
- if (isAbsolute3(trimmed)) return trimmed;
5776
+ if (isAbsolute4(trimmed)) return trimmed;
5486
5777
  const base = env.HS_INVOKED_CWD?.trim() || fallbackCwd;
5487
5778
  return resolveNodePath2(base, trimmed);
5488
5779
  }
@@ -5493,30 +5784,37 @@ function resolveInstallTargetDir(argsTargetDir, env, fallbackCwd, opts) {
5493
5784
  }
5494
5785
  return explicit || env.HS_VAULT_ROOT?.trim() || env.HS_INVOKED_CWD?.trim() || fallbackCwd;
5495
5786
  }
5496
- function renderCursorMcpJson(cliAbsPath, attachedVaultRoot) {
5787
+ function renderCursorMcpJson(cliAbsPath, attachedVaultRoot, opts = {}) {
5788
+ const pin = vaultRootPin(attachedVaultRoot, "${workspaceFolder}", opts);
5497
5789
  return {
5498
5790
  mcpServers: {
5499
5791
  heyspex: {
5500
5792
  type: "stdio",
5501
5793
  command: "node",
5502
- args: [cliAbsPath],
5503
- env: {
5504
- HEYSPEX_VAULT_ROOT: attachedVaultRoot ?? "${workspaceFolder}"
5505
- }
5794
+ args: mcpServerArgs(cliAbsPath),
5795
+ ...pin ? { env: { HEYSPEX_VAULT_ROOT: pin } } : {}
5506
5796
  }
5507
5797
  }
5508
5798
  };
5509
5799
  }
5510
- function renderClaudeMcpJson(cliAbsPath, attachedVaultRoot) {
5800
+ function vaultRootPin(attachedVaultRoot, placeholder, opts = {}) {
5801
+ if (attachedVaultRoot) return attachedVaultRoot;
5802
+ return opts.targetIsVault === false ? null : placeholder;
5803
+ }
5804
+ function isVaultTarget(targetDir, deps) {
5805
+ if (deps.fileExists(deps.joinPath(targetDir, "specs"))) return true;
5806
+ const marker = deps.readFile(deps.joinPath(targetDir, "heyspex.yaml"));
5807
+ return !(marker && parseRepoBinding(marker));
5808
+ }
5809
+ function renderClaudeMcpJson(cliAbsPath, attachedVaultRoot, opts = {}) {
5810
+ const pin = vaultRootPin(attachedVaultRoot, "${CLAUDE_PROJECT_DIR:-.}", opts);
5511
5811
  return {
5512
5812
  mcpServers: {
5513
5813
  heyspex: {
5514
5814
  type: "stdio",
5515
5815
  command: "node",
5516
- args: [cliAbsPath],
5517
- env: {
5518
- HEYSPEX_VAULT_ROOT: attachedVaultRoot ?? "${CLAUDE_PROJECT_DIR:-.}"
5519
- }
5816
+ args: mcpServerArgs(cliAbsPath),
5817
+ ...pin ? { env: { HEYSPEX_VAULT_ROOT: pin } } : {}
5520
5818
  }
5521
5819
  }
5522
5820
  };
@@ -5587,20 +5885,34 @@ var SATELLITE_PIN_TARGETS = [
5587
5885
  function satellitePinnableManifests(args) {
5588
5886
  return SATELLITE_PIN_TARGETS.filter((t) => t.enabled(args)).map((t) => t.rel);
5589
5887
  }
5590
- function planSatelliteManifests(targetDir, attachedRoot, args, deps, satelliteDeps) {
5888
+ function manifestHasHeyspexEntry(raw) {
5889
+ if (!raw) return false;
5890
+ try {
5891
+ const parsed = JSON.parse(raw);
5892
+ return Boolean(parsed?.mcpServers && Object.hasOwn(parsed.mcpServers, "heyspex"));
5893
+ } catch {
5894
+ return false;
5895
+ }
5896
+ }
5897
+ function planSatelliteManifests(targetDir, attachedRoot, args, deps, satelliteDeps, markerBound = false) {
5591
5898
  const onDisk = new Map(readPinnedVaultRoot(targetDir, satelliteDeps).map((p) => [p.source, p]));
5592
5899
  const pinnable = [];
5593
5900
  const stale = [];
5594
5901
  for (const target of SATELLITE_PIN_TARGETS) {
5595
- const path5 = deps.joinPath(targetDir, ...target.segments);
5902
+ const path7 = deps.joinPath(targetDir, ...target.segments);
5596
5903
  const currentPin = onDisk.get(target.rel)?.value ?? null;
5597
- const exists = deps.fileExists(path5);
5598
- const matches = currentPin !== null && samePath(currentPin, attachedRoot);
5904
+ const exists = deps.fileExists(path7);
5905
+ const hasHeyspexEntry = exists && manifestHasHeyspexEntry(deps.readFile(path7));
5906
+ const matches = currentPin !== null ? samePath(currentPin, attachedRoot) : (
5907
+ // Pin-free + a heyspex entry is the portable shape a marker-bound repo commits.
5908
+ markerBound && hasHeyspexEntry
5909
+ );
5599
5910
  const plan = {
5600
5911
  rel: target.rel,
5601
- path: path5,
5912
+ path: path7,
5602
5913
  exists,
5603
5914
  currentPin,
5915
+ hasHeyspexEntry,
5604
5916
  matches,
5605
5917
  willWrite: !exists || Boolean(args.force)
5606
5918
  };
@@ -5623,13 +5935,15 @@ function buildClaudeMcpAddRemoteCommandPowerShell(targetDir, url = DEFAULT_REMOT
5623
5935
  `claude mcp add --transport http --scope project heyspex '${escapedUrl}' --header 'Authorization: Bearer \${${REMOTE_MCP_TOKEN_ENV}}'`
5624
5936
  ].join("\n");
5625
5937
  }
5626
- function buildClaudeMcpAddCommandPowerShell(targetDir, cliAbsPath, attachedVaultRoot) {
5627
- const escapedCli = cliAbsPath.replace(/'/g, "''");
5938
+ function buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedVaultRoot, opts = {}) {
5939
+ const escapedCli = cliPath.replace(/'/g, "''");
5628
5940
  const escapedTarget = targetDir.replace(/'/g, "''");
5629
- const vaultRootArg = attachedVaultRoot ? `HEYSPEX_VAULT_ROOT="${attachedVaultRoot}"` : 'HEYSPEX_VAULT_ROOT="$PWD"';
5941
+ const serverArgs = mcpServerArgs(cliPath).map((a, i) => i === 0 ? `'${escapedCli}'` : a).join(" ");
5942
+ const pin = attachedVaultRoot === null ? null : vaultRootPin(attachedVaultRoot, "$PWD", opts);
5943
+ const envArg = pin ? ` --env HEYSPEX_VAULT_ROOT="${pin}"` : "";
5630
5944
  return [
5631
5945
  `Set-Location -LiteralPath '${escapedTarget}'`,
5632
- `claude mcp add --transport stdio --scope project --env ${vaultRootArg} heyspex -- node '${escapedCli}'`
5946
+ `claude mcp add --transport stdio --scope project${envArg} heyspex -- node ${serverArgs}`
5633
5947
  ].join("\n");
5634
5948
  }
5635
5949
  var PLUGIN_MARKETPLACE_NAME = "heyspex-marketplace";
@@ -5675,21 +5989,17 @@ function buildCopilotPluginInstallCommand(targetDir, platformRoot, spec = PLUGIN
5675
5989
  `copilot plugin install ${spec}`
5676
5990
  ].join("\n");
5677
5991
  }
5678
- var STATUSLINE_VENDOR_FILES = [
5679
- ["scripts", "install-statusline.mjs"],
5680
- ["scripts", "claude-statusline", "statusline-command.mjs"],
5681
- ["scripts", "claude-statusline", "statusline-command.ps1"],
5682
- ["scripts", "claude-statusline", "fonts", "HeySpexIcons.ttf"],
5683
- ["scripts", "claude-statusline", "fonts", "README.md"]
5684
- ];
5685
- function renderStatusLineEntry() {
5992
+ function renderStatusLineEntry(customer = false) {
5686
5993
  return {
5687
5994
  type: "command",
5688
- command: 'node "$CLAUDE_PROJECT_DIR/scripts/claude-statusline/statusline-command.mjs"',
5995
+ command: customer ? `node "$CLAUDE_PROJECT_DIR/${CUSTOMER_LAUNCHERS.claude.rel.join("/")}" --statusline` : 'node "$CLAUDE_PROJECT_DIR/scripts/claude-statusline/statusline-command.mjs"',
5689
5996
  padding: 2,
5690
5997
  refreshInterval: 5
5691
5998
  };
5692
5999
  }
6000
+ function isHeyspexStatusLineCommand(command) {
6001
+ return typeof command === "string" && (command.includes("statusline-command.") || /heyspex\/run-hook\.mjs"?\s+--statusline/.test(command));
6002
+ }
5693
6003
  var MCP_ENTRY_ENV = "HEYSPEX_MCP_ENTRY";
5694
6004
  function isAbsoluteFsPath(value) {
5695
6005
  return typeof value === "string" && /^(?:[A-Za-z]:)?[\\/]/.test(value.trim());
@@ -5931,11 +6241,12 @@ function writeCustomerAgentKit(opts, deps) {
5931
6241
  }
5932
6242
  return { steps, settingsText };
5933
6243
  }
5934
- function buildCommandCodeMcpAddCommand(cliAbsPath) {
6244
+ function buildCommandCodeMcpAddCommand(cliAbsPath, opts = {}) {
5935
6245
  const escapedCli = cliAbsPath.replace(/'/g, "''");
6246
+ const pin = vaultRootPin(void 0, "$PWD", opts);
5936
6247
  return [
5937
6248
  `cmdc mcp add --transport stdio heyspex -- node '${escapedCli}'`,
5938
- `cmdc mcp env heyspex HEYSPEX_VAULT_ROOT "$PWD"`
6249
+ ...pin ? [`cmdc mcp env heyspex HEYSPEX_VAULT_ROOT "${pin}"`] : []
5939
6250
  ].join("\n");
5940
6251
  }
5941
6252
  function shouldWrite(exists, force) {
@@ -5948,7 +6259,6 @@ var WRITE_TARGET_KIND = {
5948
6259
  claudeMcp: "claude_mcp",
5949
6260
  claudeMd: "claude_md",
5950
6261
  agentsMd: "agents_md",
5951
- statuslineVendor: "statusline_vendor",
5952
6262
  gitignore: "gitignore",
5953
6263
  claudeMdTemplate: "claude_md_template",
5954
6264
  agentsMdTemplate: "agents_md_template"
@@ -6009,12 +6319,6 @@ var INSTALL_WRITE_TARGETS = [
6009
6319
  path: (targetDir, joinPath) => joinPath(targetDir, "AGENTS.md.template"),
6010
6320
  appliesTo: (args, opts) => Boolean(args.registerVault) && !opts.attached && opts.installContract
6011
6321
  },
6012
- {
6013
- kind: WRITE_TARGET_KIND.statuslineVendor,
6014
- rel: "scripts/claude-statusline",
6015
- path: (targetDir, joinPath) => joinPath(targetDir, "scripts", "claude-statusline"),
6016
- appliesTo: (args) => (args.statusline ?? true) && args.claude
6017
- },
6018
6322
  {
6019
6323
  kind: WRITE_TARGET_KIND.gitignore,
6020
6324
  rel: ".gitignore",
@@ -6039,7 +6343,7 @@ function slugFromPath(p) {
6039
6343
  return pathTail(p).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6040
6344
  }
6041
6345
  function registerInstalledVault(args, ctx) {
6042
- const { targetDir, readFile, joinPath, registryOpts, now, basename: basename4 } = ctx;
6346
+ const { targetDir, readFile, joinPath, registryOpts, now, basename: basename5 } = ctx;
6043
6347
  let vaultId;
6044
6348
  let schemaVersion = HEYSPEX_YAML_SCHEMA_VERSION;
6045
6349
  const yamlContent = readFile(joinPath(targetDir, "heyspex.yaml"));
@@ -6051,7 +6355,7 @@ function registerInstalledVault(args, ctx) {
6051
6355
  if (versionMatch) schemaVersion = Number(versionMatch[1]);
6052
6356
  }
6053
6357
  if (!vaultId) vaultId = slugFromPath(targetDir);
6054
- const displayName = basename4(targetDir);
6358
+ const displayName = basename5(targetDir);
6055
6359
  const before = loadVaultRegistry(registryOpts);
6056
6360
  const existingVault = before.vaults.find(
6057
6361
  (v) => v.id === vaultId || v.repo_root_abs === targetDir
@@ -6129,13 +6433,13 @@ function settingsInvalidResult(settingsPath, message2) {
6129
6433
  function guardWritesAgainstLiteralBearer(writeFile, bearerToken) {
6130
6434
  const token = typeof bearerToken === "string" ? bearerToken.trim() : "";
6131
6435
  if (!token) return writeFile;
6132
- return (path5, content) => {
6436
+ return (path7, content) => {
6133
6437
  if (content.includes(token)) {
6134
6438
  throw new Error(
6135
- `hs install: refusing to write ${path5} -- its content contains the literal bearer token (only the \${${REMOTE_MCP_TOKEN_ENV}} env reference may be written).`
6439
+ `hs install: refusing to write ${path7} -- its content contains the literal bearer token (only the \${${REMOTE_MCP_TOKEN_ENV}} env reference may be written).`
6136
6440
  );
6137
6441
  }
6138
- writeFile(path5, content);
6442
+ writeFile(path7, content);
6139
6443
  };
6140
6444
  }
6141
6445
  function runInstall(args, inputDeps) {
@@ -6221,16 +6525,20 @@ function runInstall(args, inputDeps) {
6221
6525
  isError: true
6222
6526
  };
6223
6527
  }
6528
+ const markerBound2 = portableVaultPath !== null;
6224
6529
  const planned = planSatelliteManifests(
6225
6530
  targetDir,
6226
6531
  attached.vaultRootAbs,
6227
6532
  args,
6228
6533
  deps,
6229
- satelliteDeps
6534
+ satelliteDeps,
6535
+ markerBound2
6230
6536
  );
6231
6537
  satellitePins = planned.pinnable;
6232
6538
  satelliteStalePins = planned.stale;
6233
- const blocked = satellitePins.filter((p) => p.exists && !p.matches && !args.force);
6539
+ const blocked = satellitePins.filter(
6540
+ (p) => p.exists && !p.matches && !args.force && (!markerBound2 || p.currentPin !== null)
6541
+ );
6234
6542
  if (blocked.length > 0) {
6235
6543
  const detail = blocked.map(
6236
6544
  (p) => ` ${p.rel} \u2014 ${p.currentPin ? `currently pins ${p.currentPin}` : "carries no vault pin"}`
@@ -6368,15 +6676,17 @@ ${detail}
6368
6676
  return !args.dryRun;
6369
6677
  };
6370
6678
  const pinsToWrite = satellitePins.filter((p) => p.willWrite);
6371
- const pinsUnchanged = satellitePins.filter((p) => !p.willWrite);
6679
+ const pinsUnchanged = satellitePins.filter((p) => !p.willWrite && p.matches);
6372
6680
  const satellitePinWritten = pinsToWrite.length > 0;
6373
6681
  const relList = (plans) => plans.map((p) => p.rel).join(", ");
6682
+ const markerBound = portableVaultPath !== null;
6374
6683
  if (attached) {
6684
+ const binding = markerBound ? `bound to vault "${attached.vaultId}" by the committed heyspex.yaml (vault_path ${portableVaultPath})` : `attached to vault "${attached.vaultId}" (workspace "${attached.workspaceId}")`;
6375
6685
  steps.push({
6376
6686
  kind: "satellite_attach",
6377
6687
  path: attached.vaultRootAbs,
6378
6688
  action: satellitePinWritten ? args.dryRun ? "would_create" : "created" : "skipped",
6379
- detail: satellitePinWritten ? `attached to vault "${attached.vaultId}" (workspace "${attached.workspaceId}") -- ${SATELLITE_VAULT_ENV} pin ${args.dryRun ? "would be written to" : "written to"} ` + relList(pinsToWrite) + (pinsUnchanged.length ? `; ${relList(pinsUnchanged)} already pinned here` : "") : `already attached to vault "${attached.vaultId}" (workspace "${attached.workspaceId}") -- ${relList(pinsUnchanged)} already pin this root, nothing to write`
6689
+ detail: satellitePinWritten ? `${binding} -- ${markerBound ? "portable manifest" : `${SATELLITE_VAULT_ENV} pin`} ${args.dryRun ? "would be written to" : "written to"} ` + relList(pinsToWrite) + (pinsUnchanged.length ? `; ${relList(pinsUnchanged)} already ${markerBound ? "current" : "pinned here"}` : "") : markerBound ? `already ${binding} -- ${relList(pinsUnchanged)} portable and current, nothing to write` : `already ${binding} -- ${relList(pinsUnchanged)} already pin this root, nothing to write`
6380
6690
  });
6381
6691
  for (const plan of satelliteStalePins) {
6382
6692
  steps.push({
@@ -6483,7 +6793,9 @@ ${detail}
6483
6793
  const claudePin = portableVaultPath ? "${CLAUDE_PROJECT_DIR:-.}/" + portableVaultPath : attachedRoot;
6484
6794
  const cursorPin = portableVaultPath ? "${workspaceFolder}/" + portableVaultPath : attachedRoot;
6485
6795
  const satelliteUnchanged = (rel) => pinsUnchanged.find((p) => p.rel === rel) ?? null;
6486
- const cursorJson = `${JSON.stringify(renderCursorMcpJson(cursorCliArg, cursorPin), null, 2)}
6796
+ const unchangedDetail = (plan) => plan.currentPin === null ? `portable manifest, bound by heyspex.yaml (vault "${attached?.vaultId}") -- unchanged` : `already pins vault "${attached?.vaultId}" (${attachedRoot}) -- unchanged`;
6797
+ const pinOpts = { targetIsVault: isVaultTarget(targetDir, deps) };
6798
+ const cursorJson = `${JSON.stringify(renderCursorMcpJson(cursorCliArg, cursorPin, pinOpts), null, 2)}
6487
6799
  `;
6488
6800
  if (args.cursor) {
6489
6801
  const cursorUnchanged = satelliteUnchanged(".cursor/mcp.json");
@@ -6492,7 +6804,7 @@ ${detail}
6492
6804
  kind: WRITE_TARGET_KIND.cursorMcp,
6493
6805
  path: cursorPath,
6494
6806
  action: "skipped",
6495
- detail: `already pins vault "${attached?.vaultId}" (${attachedRoot}) -- unchanged`
6807
+ detail: unchangedDetail(cursorUnchanged)
6496
6808
  });
6497
6809
  } else if (planWrite(WRITE_TARGET_KIND.cursorMcp, cursorPath, deps.fileExists(cursorPath))) {
6498
6810
  deps.mkdir(deps.joinPath(targetDir, ".cursor"));
@@ -6504,9 +6816,9 @@ ${detail}
6504
6816
  }
6505
6817
  const remoteUrl = args.remote ? resolveRemoteMcpUrl(args.remoteUrl) : null;
6506
6818
  const claudeJson = remoteUrl ? `${JSON.stringify(renderRemoteClaudeMcpJson(remoteUrl), null, 2)}
6507
- ` : `${JSON.stringify(renderClaudeMcpJson(claudeCliArg, claudePin), null, 2)}
6819
+ ` : `${JSON.stringify(renderClaudeMcpJson(claudeCliArg, claudePin, pinOpts), null, 2)}
6508
6820
  `;
6509
- const claudePs = remoteUrl ? buildClaudeMcpAddRemoteCommandPowerShell(targetDir, remoteUrl) : buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot);
6821
+ let claudeManifestRegisters = false;
6510
6822
  if (args.claude) {
6511
6823
  const claudeUnchanged = satelliteUnchanged(".mcp.json");
6512
6824
  if (attached && remoteUrl) {
@@ -6521,7 +6833,7 @@ ${detail}
6521
6833
  existingJson = null;
6522
6834
  }
6523
6835
  }
6524
- const pinEntry = renderClaudeMcpJson(claudeCliArg, claudePin).mcpServers.heyspex;
6836
+ const pinEntry = renderClaudeMcpJson(claudeCliArg, claudePin, pinOpts).mcpServers.heyspex;
6525
6837
  const cloudEntry = renderRemoteClaudeMcpJson(remoteUrl).mcpServers.heyspex;
6526
6838
  const merged = mergeAttachedRemoteMcpJson(
6527
6839
  existingJson,
@@ -6559,12 +6871,42 @@ ${detail}
6559
6871
  kind: WRITE_TARGET_KIND.claudeMcp,
6560
6872
  path: claudePath,
6561
6873
  action: "skipped",
6562
- detail: `already pins vault "${attached?.vaultId}" (${attachedRoot}) -- unchanged`
6874
+ detail: unchangedDetail(claudeUnchanged)
6563
6875
  });
6876
+ claudeManifestRegisters = true;
6564
6877
  } else if (planWrite(WRITE_TARGET_KIND.claudeMcp, claudePath, deps.fileExists(claudePath))) {
6565
6878
  deps.writeFile(claudePath, claudeJson);
6879
+ claudeManifestRegisters = true;
6566
6880
  }
6567
- if (args.claudeApply) {
6881
+ if (!claudeManifestRegisters) {
6882
+ claudeManifestRegisters = manifestHasHeyspexEntry(deps.readFile(claudePath));
6883
+ }
6884
+ }
6885
+ const claudePs = remoteUrl ? buildClaudeMcpAddRemoteCommandPowerShell(targetDir, remoteUrl) : !customerTarget ? buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot, pinOpts) : claudeManifestRegisters ? void 0 : (
6886
+ // Marker-bound: the committed heyspex.yaml binds the vault, so no
6887
+ // HEYSPEX_VAULT_ROOT pin is registered (`null`), matching the manifest.
6888
+ buildClaudeMcpAddCommandPowerShell(
6889
+ targetDir,
6890
+ claudeCliArg,
6891
+ markerBound ? null : attachedRoot,
6892
+ pinOpts
6893
+ )
6894
+ );
6895
+ const claudeMcpRegisteredByManifest = args.claude && !remoteUrl && claudePs === void 0;
6896
+ if (args.claude) {
6897
+ if (claudeMcpRegisteredByManifest) {
6898
+ if (args.claudeApply) {
6899
+ steps.push({
6900
+ kind: "claude_mcp_apply",
6901
+ path: "claude mcp add",
6902
+ action: "skipped",
6903
+ detail: ".mcp.json already registers the heyspex MCP server for Claude Code"
6904
+ });
6905
+ }
6906
+ nextSteps.push(
6907
+ "Claude Code: .mcp.json already registers the heyspex MCP server -- no `claude mcp add` needed; restart Claude Code (or `/mcp` reconnect) in this repo to load it."
6908
+ );
6909
+ } else if (args.claudeApply) {
6568
6910
  steps.push({
6569
6911
  kind: "claude_mcp_apply",
6570
6912
  path: "claude mcp add",
@@ -6699,7 +7041,7 @@ ${detail}
6699
7041
  if ((args.statusline ?? true) && args.claude) {
6700
7042
  const statuslineShipsInPlatform = deps.fileExists(
6701
7043
  deps.joinPath(platformRoot, "scripts", "claude-statusline", "statusline-command.mjs")
6702
- );
7044
+ ) && (!customerTarget || deps.fileExists(deps.joinPath(platformRoot, "scripts", "claude-hooks", "_run.mjs")));
6703
7045
  if (!statuslineShipsInPlatform) {
6704
7046
  steps.push({
6705
7047
  kind: "claude_statusline",
@@ -6708,41 +7050,8 @@ ${detail}
6708
7050
  detail: "not shipped in this package"
6709
7051
  });
6710
7052
  } else {
6711
- if (targetDir !== platformRoot) {
6712
- if (!deps.copyFile) {
6713
- steps.push({
6714
- kind: WRITE_TARGET_KIND.statuslineVendor,
6715
- path: deps.joinPath(targetDir, "scripts", "claude-statusline"),
6716
- action: "skipped",
6717
- detail: "no copyFile capability in this caller"
6718
- });
6719
- } else {
6720
- let copied = 0;
6721
- let missing = 0;
6722
- for (const rel of STATUSLINE_VENDOR_FILES) {
6723
- const src = deps.joinPath(platformRoot, ...rel);
6724
- const dest = deps.joinPath(targetDir, ...rel);
6725
- if (!deps.fileExists(src)) {
6726
- missing += 1;
6727
- continue;
6728
- }
6729
- if (deps.fileExists(dest) && !args.force) continue;
6730
- copied += 1;
6731
- if (!args.dryRun) {
6732
- deps.mkdir(deps.joinPath(targetDir, ...rel.slice(0, -1)));
6733
- deps.copyFile(src, dest);
6734
- }
6735
- }
6736
- steps.push({
6737
- kind: WRITE_TARGET_KIND.statuslineVendor,
6738
- path: deps.joinPath(targetDir, "scripts", "claude-statusline"),
6739
- action: copied === 0 ? "skipped" : args.dryRun ? "would_create" : "created",
6740
- detail: copied === 0 ? "already vendored" : `${copied} file${copied === 1 ? "" : "s"} (renderer + font + installer)` + (missing > 0 ? `; ${missing} missing from platform` : "")
6741
- });
6742
- }
6743
- }
6744
7053
  const settingsPath = deps.joinPath(targetDir, ".claude", "settings.json");
6745
- const entry = renderStatusLineEntry();
7054
+ const entry = renderStatusLineEntry(customerTarget);
6746
7055
  const raw = deps.readFile(settingsPath);
6747
7056
  let settings = raw === null ? {} : null;
6748
7057
  if (raw !== null) {
@@ -6753,7 +7062,7 @@ ${detail}
6753
7062
  }
6754
7063
  }
6755
7064
  const existing = settings?.statusLine;
6756
- const ours = typeof existing?.command === "string" && existing.command.includes("statusline-command.");
7065
+ const ours = isHeyspexStatusLineCommand(existing?.command);
6757
7066
  if (settings === null) {
6758
7067
  steps.push({
6759
7068
  kind: "claude_statusline",
@@ -6780,7 +7089,7 @@ ${detail}
6780
7089
  kind: "claude_statusline",
6781
7090
  path: settingsPath,
6782
7091
  action: args.dryRun ? "would_create" : "created",
6783
- detail: existing ? "repaired to canonical entry" : "branded statusline (vendored renderer)"
7092
+ detail: existing ? "repaired to canonical entry" : customerTarget ? "branded statusline (via .claude/heyspex/run-hook.mjs)" : "branded statusline"
6784
7093
  });
6785
7094
  if (!args.dryRun) {
6786
7095
  settings.statusLine = entry;
@@ -6839,7 +7148,10 @@ ${detail}
6839
7148
  "The plugin's hooks and the kit hooks hs install wrote into .claude/settings.json are the same set -- enable one or the other, or every hook runs twice."
6840
7149
  );
6841
7150
  }
6842
- const commandCodePs = buildCommandCodeMcpAddCommand(cliPath);
7151
+ const commandCodePs = buildCommandCodeMcpAddCommand(
7152
+ customerTarget ? deps.joinPath(targetDir, PORTABLE_PACKAGE_DIR, ...MCP_REL) : cliPath,
7153
+ pinOpts
7154
+ );
6843
7155
  if (args.commandCode) {
6844
7156
  steps.push({
6845
7157
  kind: "commandcode_mcp",
@@ -6927,6 +7239,7 @@ ${detail}
6927
7239
  steps,
6928
7240
  conflicts,
6929
7241
  claude_powershell: args.claude ? claudePs : void 0,
7242
+ ...claudeMcpRegisteredByManifest ? { claude_mcp_registered_by_manifest: true } : {},
6930
7243
  remote_mcp_url: remoteUrl ?? void 0,
6931
7244
  claude_plugin_powershell: args.plugin && pluginAvailable ? pluginPs : void 0,
6932
7245
  claude_plugin_spec: args.plugin && pluginAvailable ? pluginSpec.spec : void 0,
@@ -6952,6 +7265,7 @@ ${detail}
6952
7265
  repo_root_abs: attached.vaultRootAbs,
6953
7266
  workspace_id: attached.workspaceId,
6954
7267
  display_name: attached.displayName,
7268
+ ...markerBound ? { via_marker: true } : {},
6955
7269
  pinned_manifests: satellitePins.map((p) => p.rel),
6956
7270
  pin_written: satellitePinWritten
6957
7271
  }
@@ -6962,7 +7276,9 @@ ${detail}
6962
7276
  "",
6963
7277
  `Target: ${targetDir}`,
6964
7278
  `Platform: ${platformRoot}`,
6965
- `MCP: ${cliPath}`,
7279
+ // A customer repo's manifests name the portable node_modules form, so
7280
+ // the receipt names that too -- never a path into the platform checkout.
7281
+ `MCP: ${customerTarget ? claudeCliArg : cliPath}`,
6966
7282
  ...attached ? [`Vault: ${attached.vaultRootAbs} (attached, id: ${attached.vaultId})`] : [],
6967
7283
  ""
6968
7284
  ];
@@ -7322,8 +7638,8 @@ var INSTALL_HELP = [
7322
7638
  ].join("\n");
7323
7639
 
7324
7640
  // spex-console/src/lib/cli/install-identity.ts
7325
- import { spawnSync as spawnSync2 } from "node:child_process";
7326
- import { existsSync as existsSync5 } from "node:fs";
7641
+ import { spawnSync as spawnSync3 } from "node:child_process";
7642
+ import { existsSync as existsSync8 } from "node:fs";
7327
7643
  import { join as join11 } from "node:path";
7328
7644
  import { createInterface } from "node:readline/promises";
7329
7645
  init_credentials();
@@ -7331,7 +7647,7 @@ init_credentials();
7331
7647
  // vendor/heyspex-mcp/whoami.mjs
7332
7648
  init_heyspex_home();
7333
7649
  init_credentials();
7334
- import { homedir as homedir9 } from "node:os";
7650
+ import { homedir as homedir10 } from "node:os";
7335
7651
  var SESSION_PATH = "/api/mcp/session";
7336
7652
  async function probeCredentialIdentity({ consoleUrl, token, fetchImpl = fetch } = {}) {
7337
7653
  if (!token) return { kind: "no_credential" };
@@ -7685,7 +8001,7 @@ function isInteractiveTerminal() {
7685
8001
  }
7686
8002
  function defaultIdentityGateIo(opts) {
7687
8003
  const consoleUrl = opts.consoleUrl.trim().replace(/\/+$/, "");
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));
8004
+ const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join11(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync8(p));
7689
8005
  return {
7690
8006
  consoleUrl,
7691
8007
  interactive: opts.interactive ?? isInteractiveTerminal(),
@@ -7714,7 +8030,7 @@ function defaultIdentityGateIo(opts) {
7714
8030
  }
7715
8031
  return 0;
7716
8032
  }
7717
- const res = spawnSync2(
8033
+ const res = spawnSync3(
7718
8034
  process.execPath,
7719
8035
  [loginCli, "login", flag, "--console-url", consoleUrl],
7720
8036
  {
@@ -7787,35 +8103,36 @@ async function main() {
7787
8103
  return;
7788
8104
  }
7789
8105
  const args = parsed.args;
7790
- const targetDir = resolve5(
8106
+ const targetDir = resolve6(
7791
8107
  resolveInstallTargetDir(args.targetDir, process4.env, process4.cwd(), {
7792
8108
  satellite: args.attachVault !== void 0
7793
8109
  })
7794
8110
  );
7795
- const platformRoot = resolve5(
8111
+ const platformRoot = resolve6(
7796
8112
  resolveCallerPath(args.platformRoot, process4.env, process4.cwd()) || platformDefault || process4.cwd()
7797
8113
  );
8114
+ const eol = createGeneratedFileEol({ repoRoot: targetDir });
7798
8115
  const deps = {
7799
8116
  repoRoot: targetDir,
7800
8117
  resolvedTarget: targetDir,
7801
8118
  resolvedPlatform: platformRoot,
7802
- fileExists: (p) => existsSync6(p),
8119
+ fileExists: (p) => existsSync9(p),
7803
8120
  readFile: (p) => {
7804
8121
  try {
7805
- return readFileSync11(p, "utf8");
8122
+ return readFileSync14(p, "utf8");
7806
8123
  } catch {
7807
8124
  return null;
7808
8125
  }
7809
8126
  },
7810
- writeFile: (p, c) => writeFileSync6(p, c, "utf8"),
7811
- mkdir: (p) => mkdirSync6(p, { recursive: true }),
7812
- copyFile: (src, dest) => copyFileSync(src, dest),
8127
+ writeFile: (p, c) => writeFileSync7(p, eol.convert(p, c), "utf8"),
8128
+ mkdir: (p) => mkdirSync7(p, { recursive: true }),
8129
+ copyFile: (src, dest) => copyFileSync2(src, dest),
7813
8130
  joinPath: (...s) => join12(...s),
7814
8131
  // Customer-template step (--register-vault) walks specs/ before applying.
7815
8132
  readdir: (p) => readdirSync2(p),
7816
8133
  isDirectory: (p) => {
7817
8134
  try {
7818
- return statSync(p).isDirectory();
8135
+ return statSync5(p).isDirectory();
7819
8136
  } catch {
7820
8137
  return false;
7821
8138
  }
@@ -7874,11 +8191,17 @@ async function main() {
7874
8191
  const payload = remoteReceipt && !result.isError ? { ...result.payload, ...remoteReceipt } : result.payload;
7875
8192
  sink.write(`${args.json ? JSON.stringify(payload, null, 2) : result.message}
7876
8193
  `);
7877
- if (!result.isError && args.claudeApply && !args.dryRun && args.claude) {
7878
- const cliPath = mcpCliPath(platformRoot, join12);
7879
- const attachedRoot = result.payload?.attached_vault?.repo_root_abs;
8194
+ const applyPayload = result.payload;
8195
+ if (!result.isError && args.claudeApply && !args.dryRun && args.claude && applyPayload.claude_mcp_registered_by_manifest) {
8196
+ process4.stdout.write(
8197
+ "Claude Code: .mcp.json already registers the heyspex MCP server (nothing to apply).\n"
8198
+ );
8199
+ } else if (!result.isError && args.claudeApply && !args.dryRun && args.claude) {
8200
+ const cliPath = mcpCliArgFor("claude", platformRoot, targetDir, join12);
8201
+ const attachedRoot = applyPayload.attached_vault?.repo_root_abs;
8202
+ const viaMarker = applyPayload.attached_vault?.via_marker === true;
7880
8203
  const mcpVaultRoot = attachedRoot ?? targetDir;
7881
- const claude = spawnSync3(
8204
+ const claude = spawnSync4(
7882
8205
  "claude",
7883
8206
  [
7884
8207
  "mcp",
@@ -7887,8 +8210,7 @@ async function main() {
7887
8210
  "stdio",
7888
8211
  "--scope",
7889
8212
  "project",
7890
- "--env",
7891
- `HEYSPEX_VAULT_ROOT=${mcpVaultRoot}`,
8213
+ ...viaMarker ? [] : ["--env", `HEYSPEX_VAULT_ROOT=${mcpVaultRoot}`],
7892
8214
  "heyspex",
7893
8215
  "--",
7894
8216
  process4.execPath,
@@ -7901,19 +8223,19 @@ async function main() {
7901
8223
  `hs install: claude mcp add failed (exit ${claude.status ?? "?"}).
7902
8224
  ${claude.stderr || claude.stdout || ""}
7903
8225
  Run manually:
7904
- ${buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot)}
8226
+ ${buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, viaMarker ? null : attachedRoot)}
7905
8227
  `
7906
8228
  );
7907
8229
  } else {
7908
8230
  process4.stdout.write("Claude Code: registered heyspex MCP (--scope project).\n");
7909
8231
  }
7910
8232
  }
7911
- if (!result.isError && args.pluginApply && !args.dryRun && existsSync6(pluginMarketplaceManifestPath(platformRoot, join12))) {
8233
+ if (!result.isError && args.pluginApply && !args.dryRun && existsSync9(pluginMarketplaceManifestPath(platformRoot, join12))) {
7912
8234
  const shell = process4.platform === "win32";
7913
8235
  const spec = resolvePluginSpec(platformRoot, {
7914
8236
  readFile: (p) => {
7915
8237
  try {
7916
- return readFileSync11(p, "utf8");
8238
+ return readFileSync14(p, "utf8");
7917
8239
  } catch {
7918
8240
  return null;
7919
8241
  }
@@ -7927,7 +8249,7 @@ ${buildClaudeMcpAddCommandPowerShell(targetDir, cliPath, attachedRoot)}
7927
8249
  spec.spec,
7928
8250
  scope
7929
8251
  );
7930
- const claudeRun = (cliArgs) => spawnSync3("claude", cliArgs, { cwd: targetDir, encoding: "utf8", shell });
8252
+ const claudeRun = (cliArgs) => spawnSync4("claude", cliArgs, { cwd: targetDir, encoding: "utf8", shell });
7931
8253
  let marketplace = claudeRun(["plugin", "marketplace", "add", platformRoot]);
7932
8254
  if (marketplace.status !== 0 && /already/i.test(marketplace.stderr + marketplace.stdout)) {
7933
8255
  marketplace = claudeRun(["plugin", "marketplace", "update", spec.marketplaceName]);
@@ -7962,7 +8284,7 @@ ${manualBlock}
7962
8284
  if (!result.isError && args.copilotPluginApply && !args.dryRun && copilotCommands) {
7963
8285
  const shell = process4.platform === "win32";
7964
8286
  const spec = result.payload.copilot_plugin_spec ?? "";
7965
- const copilotRun = (cliArgs) => spawnSync3("copilot", cliArgs, { cwd: targetDir, encoding: "utf8", shell });
8287
+ const copilotRun = (cliArgs) => spawnSync4("copilot", cliArgs, { cwd: targetDir, encoding: "utf8", shell });
7966
8288
  const failed = (what, r) => process4.stderr.write(
7967
8289
  `hs install: copilot plugin ${what} failed (${r.error ? r.error.message : `exit ${r.status ?? "?"}`}).
7968
8290
  ${r.stderr || r.stdout || ""}
@@ -7989,8 +8311,8 @@ ${copilotCommands}
7989
8311
  const glyphScript = [
7990
8312
  join12(targetDir, "scripts", "install-statusline.mjs"),
7991
8313
  join12(platformRoot, "scripts", "install-statusline.mjs")
7992
- ].find((p) => existsSync6(p));
7993
- const glyph = glyphScript ? spawnSync3(process4.execPath, [glyphScript, "--glyph-only"], {
8314
+ ].find((p) => existsSync9(p));
8315
+ const glyph = glyphScript ? spawnSync4(process4.execPath, [glyphScript, "--glyph-only"], {
7994
8316
  cwd: targetDir,
7995
8317
  encoding: "utf8"
7996
8318
  }) : { status: 1, stdout: "", stderr: "install-statusline.mjs not found" };