heyspex 0.2.2 → 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 +81 -77
  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 -177
  27. package/docs/driving-heyspex-from-copilot.md +174 -165
  28. package/docs/mcp-client-config.md +93 -25
  29. package/package.json +255 -250
  30. package/plugins/heyspex/.mcp.json +3 -4
  31. package/plugins/heyspex/README.md +323 -312
  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 +64 -34
  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, path7) {
157
+ function readTextOrNull(read, path9) {
158
158
  try {
159
- const raw = read(path7);
159
+ const raw = read(path9);
160
160
  return typeof raw === "string" ? raw : null;
161
161
  } catch {
162
162
  return null;
@@ -260,6 +260,11 @@ function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile: readFile2 =
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,19 +1003,19 @@ var require_gray_matter = __commonJS({
996
1003
  });
997
1004
 
998
1005
  // spex-console/bin/hs-connect.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 readdirSync3,
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 path6 from "node:path";
1016
+ import path8 from "node:path";
1010
1017
  import process4 from "node:process";
1011
- import { fileURLToPath as fileURLToPath4 } from "node:url";
1018
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
1012
1019
 
1013
1020
  // scripts/lib/console-url-resolver.mjs
1014
1021
  init_heyspex_home();
@@ -1116,7 +1123,9 @@ function resolveConsoleUrl({
1116
1123
  }
1117
1124
 
1118
1125
  // scripts/lib/shared-path-helpers.mjs
1126
+ import { realpathSync } from "node:fs";
1119
1127
  import { isAbsolute, resolve as resolve2, sep } from "node:path";
1128
+ import { fileURLToPath } from "node:url";
1120
1129
  function resolveCodeRoot({
1121
1130
  explicit,
1122
1131
  packageRoot: packageRoot2,
@@ -1239,10 +1248,10 @@ function classifyHttpFailureReason(res) {
1239
1248
  if (res.status === 401) return "unauthorized";
1240
1249
  return "server-error";
1241
1250
  }
1242
- async function cloudHttpRequest(http, method, path7, body) {
1243
- const scopeHeaders = http.scope ? http.scope.headersFor(method, path7) : {};
1251
+ async function cloudHttpRequest(http, method, path9, body) {
1252
+ const scopeHeaders = http.scope ? http.scope.headersFor(method, path9) : {};
1244
1253
  try {
1245
- const res = await http.fetchImpl(`${http.consoleUrl}${path7}`, {
1254
+ const res = await http.fetchImpl(`${http.consoleUrl}${path9}`, {
1246
1255
  method,
1247
1256
  headers: {
1248
1257
  "content-type": "application/json",
@@ -3014,12 +3023,12 @@ var SUSPENSION_ELAPSED_FACTOR = 3;
3014
3023
  var SUSPENSION_MIN_OVERSHOOT_MS = 5e3;
3015
3024
  var MAX_SUSPENSION_RETRIES = 2;
3016
3025
  function withTimeout(promise, ms, label) {
3017
- return new Promise((resolve5, reject) => {
3026
+ return new Promise((resolve6, reject) => {
3018
3027
  const timer = setTimeout(() => reject(new NeonProbeTimeoutError(label, ms)), ms);
3019
3028
  promise.then(
3020
3029
  (value) => {
3021
3030
  clearTimeout(timer);
3022
- resolve5(value);
3031
+ resolve6(value);
3023
3032
  },
3024
3033
  (err) => {
3025
3034
  clearTimeout(timer);
@@ -3038,7 +3047,7 @@ var NeonProbeTimeoutError = class _NeonProbeTimeoutError extends Error {
3038
3047
  }
3039
3048
  };
3040
3049
  function sleep(ms) {
3041
- return new Promise((resolve5) => setTimeout(resolve5, ms));
3050
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
3042
3051
  }
3043
3052
  function emitProbeFailure(options2, failure) {
3044
3053
  try {
@@ -4032,7 +4041,7 @@ import { createHash } from "node:crypto";
4032
4041
  import fs from "node:fs";
4033
4042
  import path2 from "node:path";
4034
4043
  import os2 from "node:os";
4035
- import { fileURLToPath, pathToFileURL } from "node:url";
4044
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
4036
4045
 
4037
4046
  // scripts/lib/hook-dispositions.mjs
4038
4047
  var HOSTS = Object.freeze(["claude-code", "cursor", "copilot"]);
@@ -4380,8 +4389,16 @@ var LAUNCHER_TEMPLATE = [
4380
4389
  "// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd",
4381
4390
  "// 5. <npm root -g>/heyspex a global install, last resort",
4382
4391
  "// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).",
4392
+ "//",
4393
+ "// The same resolution starts the package's other entrypoints, so no committed",
4394
+ "// file names node_modules/heyspex directly and a repo with no node_modules of",
4395
+ "// its own still works off a global install:",
4396
+ "// --mcp the stdio MCP server (.mcp.json, .cursor/mcp.json)",
4397
+ "// --statusline the Claude Code status line (statusLine.command)",
4398
+ "// --resolve print the package root, exit 0; exit 1 when none (hs doctor)",
4383
4399
  "import { spawnSync } from 'node:child_process';",
4384
- "import { existsSync } from 'node:fs';",
4400
+ "import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';",
4401
+ "import { homedir } from 'node:os';",
4385
4402
  "import { dirname, resolve } from 'node:path';",
4386
4403
  "import { fileURLToPath, pathToFileURL } from 'node:url';",
4387
4404
  "",
@@ -4391,6 +4408,7 @@ var LAUNCHER_TEMPLATE = [
4391
4408
  "const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);",
4392
4409
  "const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));",
4393
4410
  "",
4411
+ "__YIELD__",
4394
4412
  "function packageUpFrom(start) {",
4395
4413
  " let dir = resolve(start);",
4396
4414
  " for (;;) {",
@@ -4425,31 +4443,109 @@ var LAUNCHER_TEMPLATE = [
4425
4443
  " if (hasShim(global)) root = global;",
4426
4444
  "}",
4427
4445
  "",
4446
+ "const ENTRYPOINTS = {",
4447
+ " '--mcp': 'vendor/heyspex-mcp/cli.mjs',",
4448
+ " '--statusline': 'scripts/claude-statusline/statusline-command.mjs',",
4449
+ "};",
4450
+ "const mode = process.argv[2];",
4451
+ "const entry = Object.hasOwn(ENTRYPOINTS, mode) ? ENTRYPOINTS[mode] : null;",
4452
+ "",
4428
4453
  "if (!root) {",
4454
+ " const what = entry || mode === '--resolve' ? mode : 'hook ' + (mode ?? '?');",
4429
4455
  " process.stderr.write(",
4430
- " '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
4431
- ` '(run "npm install heyspex" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +`,
4432
- " 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
4456
+ " '[heyspex] ' + what + ' skipped: no heyspex package found ' +",
4457
+ ` '(run "npm install heyspex" or "npm link heyspex" in this repo, install it globally, or set HEYSPEX_PLATFORM_ROOT). ' +`,
4458
+ " 'Looked for __SHIM__ under HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
4433
4459
  " );",
4460
+ " // Hooks and the status line fail open; the MCP server and --resolve fail",
4461
+ " // loudly, so the client and hs doctor both see it.",
4462
+ " process.exit(mode === '--mcp' || mode === '--resolve' ? 1 : 0);",
4463
+ "}",
4464
+ "if (mode === '--resolve') {",
4465
+ " process.stdout.write(root + '\\n');",
4434
4466
  " process.exit(0);",
4435
4467
  "}",
4436
4468
  "",
4437
4469
  "// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim",
4438
- '// reads; the shim path replaces this file so a shim guarded by "am I main?"',
4439
- "// (the Cursor one) runs exactly as if Cursor had invoked it directly.",
4440
- "const shim = resolve(root, ...SHIM);",
4441
- "process.argv[1] = shim;",
4470
+ '// reads; the target replaces this file so a script guarded by "am I main?"',
4471
+ "// runs exactly as if it had been invoked directly. Real path, so that guard",
4472
+ "// still matches when node_modules/heyspex is an npm link or a junction (node",
4473
+ "// realpaths import.meta.url but leaves argv[1] alone).",
4474
+ "let target = resolve(root, ...(entry ? entry.split('/') : SHIM));",
4475
+ "try {",
4476
+ " target = realpathSync(target);",
4477
+ "} catch {",
4478
+ " // keep the resolved path",
4479
+ "}",
4480
+ "if (entry) process.argv.splice(2, 1);",
4481
+ "process.argv[1] = target;",
4442
4482
  "try {",
4443
- " await import(pathToFileURL(shim).href);",
4483
+ " await import(pathToFileURL(target).href);",
4444
4484
  "} catch (err) {",
4445
- " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
4485
+ " process.stderr.write('[heyspex] ' + (entry ? mode : 'hook ' + (process.argv[2] ?? '?')) + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
4486
+ " // Hooks and the status line fail open; the MCP server fails loudly.",
4487
+ " if (mode === '--mcp') process.exit(1);",
4446
4488
  " process.exit(0);",
4447
4489
  "}",
4448
4490
  ""
4449
4491
  ].join("\n");
4492
+ var LAUNCHER_YIELD_MARKER = "// yields-to-plugin: v1";
4493
+ var LAUNCHER_YIELD_BLOCK = [
4494
+ LAUNCHER_YIELD_MARKER + " -- one source of hooks: when the heyspex plugin is enabled on this machine",
4495
+ "// its own launcher (plugins/heyspex/bin/run-hook.mjs, which never calls this file) already runs every",
4496
+ "// hook, so this committed row exits 0 silently before resolving anything and each hook fires ONCE.",
4497
+ "// HEYSPEX_REPO_HOOKS=1 never yield (the repo rows are the chosen source);",
4498
+ `// enabledPlugins["heyspex@..."] === false in ~/.claude/settings.json or this repo's .claude/`,
4499
+ "// settings[.local].json: plugin off here, the row runs;",
4500
+ "// plugin evidence (what `hs doctor` hook-double-fire reads): a heyspex@... key in",
4501
+ "// ~/.claude/plugins/installed_plugins.json, enabledPlugins naming heyspex with a non-false",
4502
+ "// value, or a heyspex directory under ~/.copilot/installed-plugins | ~/.copilot/plugins.",
4503
+ "function readJson(file) {",
4504
+ " try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }",
4505
+ "}",
4506
+ "function listDir(dir) {",
4507
+ " try { return readdirSync(dir); } catch { return []; }",
4508
+ "}",
4509
+ "function pluginTakesThisHook() {",
4510
+ " const flag = text(env.HEYSPEX_REPO_HOOKS);",
4511
+ " if (flag && flag !== '0' && flag.toLowerCase() !== 'false') return false;",
4512
+ " const home = homedir();",
4513
+ " const repo = resolve(here, '..', '..');",
4514
+ " const isHeyspex = (k) => /^heyspex(@|$)/i.test(k);",
4515
+ " let enabled = false;",
4516
+ " for (const f of [resolve(home, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.local.json')]) {",
4517
+ " const settings = readJson(f);",
4518
+ " const ep = settings && typeof settings === 'object' ? settings.enabledPlugins : null;",
4519
+ " if (!ep || typeof ep !== 'object') continue;",
4520
+ " for (const [k, v] of Object.entries(ep)) {",
4521
+ " if (!isHeyspex(k)) continue;",
4522
+ " if (v === false) return false;",
4523
+ " enabled = true;",
4524
+ " }",
4525
+ " }",
4526
+ " if (enabled) return true;",
4527
+ " const installed = readJson(resolve(home, '.claude', 'plugins', 'installed_plugins.json'));",
4528
+ " if (installed && typeof installed === 'object' && Object.keys(installed.plugins && typeof installed.plugins === 'object' ? installed.plugins : installed).some(isHeyspex)) return true;",
4529
+ " for (const sub of ['installed-plugins', 'plugins']) {",
4530
+ " const dir = resolve(home, '.copilot', sub);",
4531
+ " const names = listDir(dir);",
4532
+ " if (names.some((n) => /heyspex/i.test(n))) return true;",
4533
+ " if (names.some((n) => listDir(resolve(dir, n)).some((m) => /heyspex/i.test(m)))) return true;",
4534
+ " }",
4535
+ " return false;",
4536
+ "}",
4537
+ "// Only a HOOK EVENT yields: --mcp / --statusline / --resolve and an agent-run",
4538
+ "// `<script> --directive <key>` fetch are not hook events the plugin could run in",
4539
+ "// our place, so they always go on to the package below.",
4540
+ "const yieldMode = process.argv[2];",
4541
+ "const notAHookEvent = ['--mcp', '--statusline', '--resolve'].includes(yieldMode) || process.argv.includes('--directive');",
4542
+ "if (!notAHookEvent && pluginTakesThisHook()) process.exit(0);",
4543
+ "",
4544
+ ""
4545
+ ].join("\n");
4450
4546
  function renderCustomerLauncher(surface = "claude") {
4451
4547
  const l = CUSTOMER_LAUNCHERS[surface];
4452
- return LAUNCHER_TEMPLATE.replace(
4548
+ return LAUNCHER_TEMPLATE.replace("__YIELD__\n", surface === "claude" ? LAUNCHER_YIELD_BLOCK : "").replace(
4453
4549
  /__(REGISTRY|ENV|LAUNCHER|SHIM)__/g,
4454
4550
  (_m, token) => token === "REGISTRY" ? l.registry : token === "ENV" ? l.projectDirEnv : token === "LAUNCHER" ? l.rel.join("/") : l.shim
4455
4551
  );
@@ -4675,25 +4771,33 @@ var MARKER_RE = /\n<!-- heyspex-kit: managed by hs install \(sha256:([0-9a-f]{16
4675
4771
  function shortHash(text2) {
4676
4772
  return createHash("sha256").update(text2, "utf8").digest("hex").slice(0, 16);
4677
4773
  }
4774
+ function normalizeEol(text2) {
4775
+ return text2 === null || text2 === void 0 ? text2 : String(text2).replace(/\r\n/g, "\n");
4776
+ }
4678
4777
  function renderKitCommand(content, platformRoot) {
4679
- const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, "")}
4778
+ const body = `${rewritePlatformPaths(normalizeEol(content), platformRoot).replace(/\s+$/, "")}
4680
4779
  `;
4681
4780
  return `${body}
4682
4781
  <!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). Edit freely: once edited, hs install leaves this file alone. -->
4683
4782
  `;
4684
4783
  }
4685
4784
  function kitFileState(content) {
4686
- const m = String(content).match(MARKER_RE);
4785
+ const text2 = normalizeEol(String(content));
4786
+ const m = text2.match(MARKER_RE);
4687
4787
  if (!m) return "unmanaged";
4688
- const body = String(content).slice(0, m.index).replace(/\n$/, "");
4689
- return shortHash(`${body}
4690
- `) === m[1] ? "managed" : "customized";
4788
+ const body = `${text2.slice(0, m.index).replace(/\n$/, "")}
4789
+ `;
4790
+ if (shortHash(body) === m[1]) return "managed";
4791
+ if (shortHash(`${body.slice(0, -1).replace(/\n/g, "\r\n")}
4792
+ `) === m[1]) return "managed";
4793
+ return "customized";
4691
4794
  }
4692
4795
  function planKitFileWrite({ existing, rendered, raw, force }) {
4693
4796
  if (existing === null || existing === void 0) return { write: true, reason: "missing" };
4694
- if (existing === rendered) return { write: false, reason: "unchanged" };
4695
- if (kitFileState(existing) === "managed") return { write: true, reason: "update" };
4696
- if (existing === raw) return { write: true, reason: "legacy-copy" };
4797
+ const have = normalizeEol(existing);
4798
+ if (have === normalizeEol(rendered)) return { write: false, reason: "unchanged" };
4799
+ if (kitFileState(have) === "managed") return { write: true, reason: "update" };
4800
+ if (have === normalizeEol(raw)) return { write: true, reason: "legacy-copy" };
4697
4801
  return force ? { write: true, reason: "forced" } : { write: false, reason: "customized" };
4698
4802
  }
4699
4803
  function listKitCommandFiles(platformRoot, deps, opts = {}) {
@@ -4737,14 +4841,14 @@ var fsDeps = {
4737
4841
  function invokedDirectly(importMetaUrl, selfBasename, argv1 = process.argv[1]) {
4738
4842
  try {
4739
4843
  if (!argv1) return false;
4740
- const self = fileURLToPath(importMetaUrl);
4844
+ const self = fileURLToPath2(importMetaUrl);
4741
4845
  if (path2.basename(self) !== selfBasename) return false;
4742
4846
  return pathToFileURL(path2.resolve(argv1)).href === pathToFileURL(self).href;
4743
4847
  } catch {
4744
4848
  return false;
4745
4849
  }
4746
4850
  }
4747
- var SELF = fileURLToPath(import.meta.url);
4851
+ var SELF = fileURLToPath2(import.meta.url);
4748
4852
  if (invokedDirectly(import.meta.url, "customer-kit.mjs")) {
4749
4853
  const platformRoot = path2.resolve(path2.dirname(SELF), "..", "..");
4750
4854
  const target = path2.join(platformRoot, "plugins", "heyspex", "hooks", "hooks.json");
@@ -4790,6 +4894,11 @@ import { join as join6 } from "node:path";
4790
4894
  init_credentials();
4791
4895
  var CLOUD_CONSOLE_URL2 = "https://app.heyspex.com";
4792
4896
  var LOCAL_CONSOLE_URL2 = "http://localhost:3000";
4897
+ function overrideCloudCheck2(source, cloudKnown, staleAgainstCloud) {
4898
+ if (source !== "local-override") return "not-applicable";
4899
+ if (!cloudKnown) return "unverified";
4900
+ return staleAgainstCloud ? "stale" : "in-sync";
4901
+ }
4793
4902
  function normalizeMode2(raw) {
4794
4903
  const v = String(raw ?? "").trim().toLowerCase();
4795
4904
  if (v === "prod" || v === "production") return { mode: "prod", reliance: "cloud" };
@@ -4897,6 +5006,7 @@ function resolveDevMode2({
4897
5006
  const semantics = deriveSemantics2(mode, reliance);
4898
5007
  const consoleUrl = source === "local-override" && local.record?.console_url ? local.record.console_url.replace(/\/+$/, "") : semantics.consoleUrl;
4899
5008
  const staleAgainstCloud = source === "local-override" && cloudValue !== null && (cloudValue.mode !== mode || mode === "dev" && cloudValue.reliance !== semantics.reliance);
5009
+ const cloudCheck = overrideCloudCheck2(source, cloudKnown, staleAgainstCloud);
4900
5010
  return {
4901
5011
  mode,
4902
5012
  reliance: semantics.reliance,
@@ -4914,7 +5024,8 @@ function resolveDevMode2({
4914
5024
  present: local.record !== null,
4915
5025
  record: local.record,
4916
5026
  parseError: local.parseError,
4917
- staleAgainstCloud
5027
+ staleAgainstCloud,
5028
+ cloudCheck
4918
5029
  },
4919
5030
  cloudSetting: { known: cloudKnown, value: cloudValue }
4920
5031
  };
@@ -4950,16 +5061,16 @@ init_credentials();
4950
5061
  import { randomUUID } from "node:crypto";
4951
5062
 
4952
5063
  // spex-console/src/lib/cli/control-shared.ts
4953
- import path3 from "node:path";
5064
+ import path4 from "node:path";
4954
5065
 
4955
5066
  // scripts/lib/hs-command.mjs
4956
5067
  import { existsSync as existsSync2 } from "node:fs";
4957
5068
  import { delimiter, dirname as dirname3, join as join7 } from "node:path";
4958
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5069
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
4959
5070
  var HS_BIN_NAME = "hs";
4960
5071
  var HS_DISPATCHER_PATH = (() => {
4961
5072
  try {
4962
- const here = dirname3(fileURLToPath2(import.meta.url));
5073
+ const here = dirname3(fileURLToPath3(import.meta.url));
4963
5074
  return join7(dirname3(dirname3(here)), "bin", "hs.mjs");
4964
5075
  } catch {
4965
5076
  return null;
@@ -4973,8 +5084,8 @@ var HS_DISPATCHER_PREFIX = HS_DISPATCHER_PATH ? `node ${shellQuote(HS_DISPATCHER
4973
5084
  // vendor/heyspex-mcp/console-fetch.mjs
4974
5085
  init_heyspex_home();
4975
5086
  init_credentials();
4976
- import { readFileSync as readFileSync10 } from "node:fs";
4977
- import { homedir as homedir8 } from "node:os";
5087
+ import { readFileSync as readFileSync11 } from "node:fs";
5088
+ import { homedir as homedir9 } from "node:os";
4978
5089
  import { join as join10 } from "node:path";
4979
5090
 
4980
5091
  // vendor/heyspex-mcp/vault-less-endpoints.mjs
@@ -4987,14 +5098,14 @@ var VAULT_LESS_ENDPOINTS = Object.freeze([
4987
5098
  "GET /api/workspaces",
4988
5099
  "* /api/admin/dev-mode"
4989
5100
  ]);
4990
- function pathnameOf(path7) {
4991
- const raw = String(path7 ?? "").split("?")[0].split("#")[0];
5101
+ function pathnameOf(path9) {
5102
+ const raw = String(path9 ?? "").split("?")[0].split("#")[0];
4992
5103
  const noTrailing = raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
4993
5104
  return noTrailing || "/";
4994
5105
  }
4995
- function isVaultLessEndpoint(method, path7) {
5106
+ function isVaultLessEndpoint(method, path9) {
4996
5107
  const m = String(method || "GET").toUpperCase();
4997
- const pathname = pathnameOf(path7);
5108
+ const pathname = pathnameOf(path9);
4998
5109
  return VAULT_LESS_ENDPOINTS.some((row2) => {
4999
5110
  const [rowMethod, rowPath] = row2.split(" ");
5000
5111
  return rowPath === pathname && (rowMethod === "*" || rowMethod === m);
@@ -5015,9 +5126,30 @@ import { basename, join as join8, isAbsolute as isAbsolute2 } from "node:path";
5015
5126
 
5016
5127
  // vendor/heyspex-mcp/vault-resolve.mjs
5017
5128
  init_heyspex_home();
5018
- import { existsSync as existsSync4, readFileSync as readFileSync9 } from "node:fs";
5019
- import { homedir as homedir7, tmpdir } from "node:os";
5020
- import { dirname as dirname4, join as join9, resolve as resolve4 } from "node:path";
5129
+ import { existsSync as existsSync5, readFileSync as readFileSync10, realpathSync as realpathSync3, statSync as statSync3 } from "node:fs";
5130
+ import { homedir as homedir8, tmpdir } from "node:os";
5131
+
5132
+ // vendor/heyspex-mcp/usable-dir-env.mjs
5133
+ import { statSync } from "node:fs";
5134
+ import { isAbsolute as isAbsolute3 } from "node:path";
5135
+
5136
+ // vendor/heyspex-mcp/vault-resolve.mjs
5137
+ import { basename as basename2, dirname as dirname4, join as join9, resolve as resolve4 } from "node:path";
5138
+
5139
+ // vendor/heyspex-mcp/vault-binding.mjs
5140
+ import {
5141
+ copyFileSync,
5142
+ existsSync as existsSync4,
5143
+ mkdirSync as mkdirSync6,
5144
+ readFileSync as readFileSync9,
5145
+ realpathSync as realpathSync2,
5146
+ renameSync as renameSync4,
5147
+ statSync as statSync2,
5148
+ unlinkSync,
5149
+ writeFileSync as writeFileSync6
5150
+ } from "node:fs";
5151
+ import { homedir as homedir7 } from "node:os";
5152
+ import path3 from "node:path";
5021
5153
 
5022
5154
  // vendor/heyspex-mcp/console-fetch.mjs
5023
5155
  var SCOPE_REFUSAL_CODES = Object.freeze(["VAULT_SCOPE_MISMATCH", "ACCOUNT_MISMATCH", "VAULT_NOT_REGISTERED"]);
@@ -5119,11 +5251,11 @@ function readPairedBearer(opts = {}) {
5119
5251
  function readPairedToken(opts = {}) {
5120
5252
  return readPairedBearer(opts).token ?? void 0;
5121
5253
  }
5122
- function scopeHeadersFor(scope, method, path7) {
5254
+ function scopeHeadersFor(scope, method, path9) {
5123
5255
  if (scope === "none") {
5124
- if (!isVaultLessEndpoint(method, path7)) {
5256
+ if (!isVaultLessEndpoint(method, path9)) {
5125
5257
  throw new Error(
5126
- `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 }.`
5258
+ `controlRequest: scope "none" is only allowed for the vault-less endpoints (${VAULT_LESS_ENDPOINTS.join(", ")}); ${method.toUpperCase()} ${path9} is vault-scoped and must name { accountId, vaultId }.`
5127
5259
  );
5128
5260
  }
5129
5261
  return {};
@@ -5134,7 +5266,7 @@ function cloudRequestScope(scope) {
5134
5266
  const named = { accountId: scope.accountId, vaultId: scope.vaultId };
5135
5267
  return {
5136
5268
  ...named,
5137
- headersFor: (method, path7) => isVaultLessEndpoint(method, path7) ? {} : scopeHeadersFor(named, method, path7)
5269
+ headersFor: (method, path9) => isVaultLessEndpoint(method, path9) ? {} : scopeHeadersFor(named, method, path9)
5138
5270
  };
5139
5271
  }
5140
5272
  function withCloudScope(deps, accountId, vaultId) {
@@ -5381,12 +5513,15 @@ var HELP = [
5381
5513
  "(spex-console/src/lib/cli/template-manifest.ts) \u2014 universal docs",
5382
5514
  "copied verbatim, template-shape docs written as author-prompt skeletons \u2014",
5383
5515
  "plus a generated CLAUDE.md, the kept-principles index, and the template_version",
5384
- "pin; with --git, one initial `git init` + commit. Rerunnable: a <dir> that already",
5385
- "holds heyspex.yaml and an empty/placeholder-only specs/ is accepted, existing",
5386
- "files are never overwritten (an existing CLAUDE.md gets a CLAUDE.md.template",
5387
- "beside it), and the result lists each manifest row as created or kept. Refuses a",
5388
- "non-empty <dir> without heyspex.yaml, or a specs/ holding managed specs.",
5389
- "See init.customer-vault.ts.",
5516
+ "pin; with --git, one initial `git init` + commit. On an EXISTING vault (<dir>",
5517
+ "holds heyspex.yaml with a vault_id) it is a REFRESH \u2014 the repair `hs doctor`'s",
5518
+ "vault-template row names: only missing shipped files are added, template_version",
5519
+ "is written/updated in heyspex.yaml (every other byte and the vault_id kept),",
5520
+ "nothing existing is ever overwritten (an existing CLAUDE.md gets a",
5521
+ "CLAUDE.md.template beside it), the result lists each manifest row as created or",
5522
+ "kept, and a second run writes nothing. <dir> may be any path (a satellite repo",
5523
+ "names its vault, e.g. `hs init --customer-vault ../my-specs`). Refuses only a",
5524
+ "non-empty <dir> that has no heyspex.yaml. See init.customer-vault.ts.",
5390
5525
  "",
5391
5526
  "Flags:",
5392
5527
  " --console-url <url> console_url value (default: the mode-aware console --",
@@ -5650,9 +5785,9 @@ function detectMarkerAttachment(repoRoot, deps) {
5650
5785
  function readPinnedVaultRoot(repoRoot, deps) {
5651
5786
  const pins = [];
5652
5787
  for (const rel of SATELLITE_MANIFEST_RELS) {
5653
- const path7 = deps.joinPath(repoRoot, ...rel);
5654
- if (!deps.fileExists(path7)) continue;
5655
- const raw = deps.readFile(path7);
5788
+ const path9 = deps.joinPath(repoRoot, ...rel);
5789
+ if (!deps.fileExists(path9)) continue;
5790
+ const raw = deps.readFile(path9);
5656
5791
  if (!raw) continue;
5657
5792
  let value;
5658
5793
  try {
@@ -5736,7 +5871,7 @@ function checkHarnessCommandDrift(repoRoot, deps) {
5736
5871
  const installedContent = deps.fileExists(installedPath) ? deps.readFile(installedPath) : null;
5737
5872
  if (installedContent === null) {
5738
5873
  out.missingFiles.push(`${label}/${f.file}`);
5739
- } else if (installedContent !== f.content) {
5874
+ } else if (normalizeEol(installedContent) !== normalizeEol(f.content)) {
5740
5875
  out.staleFiles.push(`${label}/${f.file}`);
5741
5876
  }
5742
5877
  }
@@ -5756,8 +5891,8 @@ function row(id, label, present, detail) {
5756
5891
  ...detail ? { detail } : {}
5757
5892
  };
5758
5893
  }
5759
- function readIfExists(deps, path7) {
5760
- return deps.fileExists(path7) ? deps.readFile(path7) : null;
5894
+ function readIfExists(deps, path9) {
5895
+ return deps.fileExists(path9) ? deps.readFile(path9) : null;
5761
5896
  }
5762
5897
  var PRE_COMMIT_HOOK_MARKER = "run-pre-commit-checks.mjs";
5763
5898
  function hasHeyspexMcpEntry(raw) {
@@ -6067,12 +6202,12 @@ function runConnectChecks(repoRoot, deps, opts = {}) {
6067
6202
  };
6068
6203
  }
6069
6204
  var CLOUD_READ_TIMEOUT_MS = 8e3;
6070
- async function cloudGet(http, token, path7) {
6205
+ async function cloudGet(http, token, path9) {
6071
6206
  const controller = new AbortController();
6072
6207
  const timer = setTimeout(() => controller.abort(), CLOUD_READ_TIMEOUT_MS);
6073
6208
  timer.unref?.();
6074
6209
  try {
6075
- const res = await http.fetch(`${http.consoleUrl.replace(/\/+$/, "")}${path7}`, {
6210
+ const res = await http.fetch(`${http.consoleUrl.replace(/\/+$/, "")}${path9}`, {
6076
6211
  method: "GET",
6077
6212
  headers: token ? { authorization: `Bearer ${token}` } : {},
6078
6213
  signal: controller.signal
@@ -6090,7 +6225,7 @@ async function cloudGet(http, token, path7) {
6090
6225
  clearTimeout(timer);
6091
6226
  }
6092
6227
  }
6093
- function readFailure(read, path7, http) {
6228
+ function readFailure(read, path9, http) {
6094
6229
  if (read === null) {
6095
6230
  return {
6096
6231
  cause: "no sign-in token on this machine, so the console was not asked",
@@ -6106,9 +6241,9 @@ function readFailure(read, path7, http) {
6106
6241
  };
6107
6242
  }
6108
6243
  if (read.status === 401) {
6109
- return { cause: `GET ${path7} answered HTTP 401 (the sign-in was refused)`, repair: "hs login" };
6244
+ return { cause: `GET ${path9} answered HTTP 401 (the sign-in was refused)`, repair: "hs login" };
6110
6245
  }
6111
- return { cause: `GET ${path7} answered HTTP ${read.status}`, repair: retry };
6246
+ return { cause: `GET ${path9} answered HTTP ${read.status}`, repair: retry };
6112
6247
  }
6113
6248
  var str2 = (v) => typeof v === "string" && v ? v : null;
6114
6249
  function cloudRow(id, fields) {
@@ -6296,7 +6431,13 @@ async function defaultCheckMcpBuild(deps, opts) {
6296
6431
  }
6297
6432
  }
6298
6433
  var shortFp = (fp) => fp ? fp.slice(0, 12) : null;
6299
- function hookScriptPath(command, repoRoot) {
6434
+ function hookScriptPath(command, repoRoot, joinPath) {
6435
+ const bootstrap = /^\s*node\s+-e\s+"[^"]*\/\/([^"\s]+)"/.exec(command);
6436
+ if (bootstrap) {
6437
+ const rel = bootstrap[1].replace(/^\/+/, "");
6438
+ if (rel === "bin/run-hook.mjs") return null;
6439
+ return joinPath(repoRoot, ...rel.split("/"));
6440
+ }
6300
6441
  const m = /^\s*node\s+(?:"([^"]+)"|(\S+))/.exec(command);
6301
6442
  const raw = m?.[1] ?? m?.[2];
6302
6443
  if (!raw) return null;
@@ -6330,7 +6471,7 @@ function hooksRow(repoRoot, deps) {
6330
6471
  const list = Array.isArray(group?.hooks) ? group.hooks : [];
6331
6472
  for (const h of list) {
6332
6473
  if (typeof h?.command !== "string" || ourHookTail(h.command) === null) continue;
6333
- const script = hookScriptPath(h.command, repoRoot);
6474
+ const script = hookScriptPath(h.command, repoRoot, deps.joinPath);
6334
6475
  if (script) scripts.add(script);
6335
6476
  }
6336
6477
  }
@@ -6370,7 +6511,7 @@ async function mcpBuildRow(vaultSubject, deps) {
6370
6511
  );
6371
6512
  }
6372
6513
  async function readCloudView(http, tokens) {
6373
- const get = (token, path7) => http && token ? cloudGet(http, token, path7) : Promise.resolve(null);
6514
+ const get = (token, path9) => http && token ? cloudGet(http, token, path9) : Promise.resolve(null);
6374
6515
  const [session, vaults2, workspaces2, devMode] = await Promise.all([
6375
6516
  get(tokens.current, "/api/mcp/session"),
6376
6517
  get(tokens.repo, "/api/vaults?include_archived=true"),
@@ -6381,9 +6522,9 @@ async function readCloudView(http, tokens) {
6381
6522
  cause: "no console URL configured for this machine",
6382
6523
  repair: "hs login"
6383
6524
  };
6384
- const fail = (read, path7) => http ? readFailure(read, path7, http) : noConsole;
6385
- const shapeFailure = (path7, what) => ({
6386
- cause: `GET ${path7} answered without ${what}`,
6525
+ const fail = (read, path9) => http ? readFailure(read, path9, http) : noConsole;
6526
+ const shapeFailure = (path9, what) => ({
6527
+ cause: `GET ${path9} answered without ${what}`,
6387
6528
  repair: `retry \`hs connect --check\` once ${http?.consoleUrl ?? "the console"} answers`
6388
6529
  });
6389
6530
  const sessionBody = session?.ok ? session.json : null;
@@ -6691,7 +6832,7 @@ function writeProjectContract(ctx, options2 = {}) {
6691
6832
  }
6692
6833
 
6693
6834
  // spex-console/src/lib/cli/init.customer-vault.ts
6694
- import { basename as basename2 } from "node:path";
6835
+ import { basename as basename3 } from "node:path";
6695
6836
 
6696
6837
  // spex-console/src/lib/cli/template-manifest.sources.mjs
6697
6838
  var UNIVERSAL_TEMPLATE_ROWS = [
@@ -7176,41 +7317,42 @@ var SCAFFOLD_FOLDERS = [
7176
7317
  "checks",
7177
7318
  "events"
7178
7319
  ];
7179
- var PLACEHOLDER_FILES = /* @__PURE__ */ new Set([".gitkeep"]);
7180
- function isDir(deps, path7, name) {
7181
- if (deps.isDirectory) return deps.isDirectory(path7);
7182
- try {
7183
- const kids = deps.readdir(path7);
7184
- return kids.length > 0 || !name.includes(".");
7185
- } catch {
7186
- return false;
7187
- }
7320
+ function defaultVaultName(targetDir) {
7321
+ return basename3(targetDir) || targetDir;
7188
7322
  }
7189
- function findManagedSpecs(deps, targetDir, manifestDests) {
7190
- const out = [];
7191
- const walk = (abs, rel) => {
7192
- for (const name of deps.readdir(abs)) {
7193
- const childAbs = deps.joinPath(abs, name);
7194
- const childRel = `${rel}/${name}`;
7195
- if (isDir(deps, childAbs, name)) walk(childAbs, childRel);
7196
- else if (!PLACEHOLDER_FILES.has(name) && name.endsWith(".md") && !manifestDests.has(childRel))
7197
- out.push(childRel);
7198
- }
7199
- };
7200
- const specsRoot = deps.joinPath(targetDir, "specs");
7201
- if (deps.exists(specsRoot)) walk(specsRoot, "specs");
7202
- return out;
7323
+ function readTemplateVersionPin(yamlText) {
7324
+ const m = yamlText.match(/^template_version:[ \t]*(.*?)[ \t]*$/m);
7325
+ if (!m) return null;
7326
+ const raw = m[1].trim();
7327
+ const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
7328
+ return unquoted.length > 0 ? unquoted : null;
7203
7329
  }
7204
- function defaultVaultName(targetDir) {
7205
- return basename2(targetDir) || targetDir;
7330
+ function stampTemplateVersion(content, version) {
7331
+ if (readTemplateVersionPin(content) === version) return content;
7332
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
7333
+ const line = `template_version: ${version}`;
7334
+ if (/^template_version:[^\r\n]*$/m.test(content)) {
7335
+ return content.replace(/^template_version:[^\r\n]*$/m, line);
7336
+ }
7337
+ const body = content.replace(/(?:\r?\n)+$/, "");
7338
+ return body.length === 0 ? `${line}${eol}` : `${body}${eol}${line}${eol}`;
7339
+ }
7340
+ function parentDirOf(deps, targetDir, dest) {
7341
+ const segments = dest.split("/");
7342
+ if (segments.length < 2) return null;
7343
+ return deps.joinPath(targetDir, ...segments.slice(0, -1));
7206
7344
  }
7207
7345
  function runCustomerVaultInit(args, deps, options2 = {}) {
7208
7346
  const { targetDir } = args;
7209
7347
  const written = [];
7210
7348
  const filter = options2.manifestFilter ?? (() => true);
7211
7349
  const rows = TEMPLATE_MANIFEST.filter(filter);
7350
+ const heyspexYamlPath = deps.joinPath(targetDir, "heyspex.yaml");
7351
+ const markerPresent = deps.exists(targetDir) && deps.exists(heyspexYamlPath);
7352
+ const existingYaml = markerPresent ? deps.readFile?.(heyspexYamlPath) ?? null : null;
7353
+ const existingVaultId = existingYaml ? parseVaultId(existingYaml) : null;
7354
+ const mode = markerPresent && existingVaultId ? "refresh" : "scaffold";
7212
7355
  if (deps.exists(targetDir)) {
7213
- const markerPresent = deps.exists(deps.joinPath(targetDir, "heyspex.yaml"));
7214
7356
  if (!markerPresent && deps.readdir(targetDir).length > 0) {
7215
7357
  return {
7216
7358
  code: EXIT.INVALID_ARG,
@@ -7220,43 +7362,43 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
7220
7362
  written: []
7221
7363
  };
7222
7364
  }
7223
- if (markerPresent) {
7224
- const manifestDests = new Set(TEMPLATE_MANIFEST.map((r) => r.dest));
7225
- const managed = findManagedSpecs(deps, targetDir, manifestDests);
7226
- if (managed.length > 0) {
7227
- return {
7228
- code: EXIT.INVALID_ARG,
7229
- 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.`,
7230
- payload: { ok: false, error: "specs_not_empty", path: targetDir, specs: managed },
7231
- isError: true,
7232
- written: []
7233
- };
7234
- }
7235
- }
7236
7365
  } else {
7237
7366
  deps.mkdirRecursive(targetDir);
7238
7367
  }
7239
- const specsRoot = deps.joinPath(targetDir, "specs");
7240
- deps.mkdirRecursive(specsRoot);
7241
- for (const folder of SCAFFOLD_FOLDERS) {
7242
- deps.mkdirRecursive(deps.joinPath(specsRoot, folder));
7368
+ if (mode === "scaffold") {
7369
+ const specsRoot = deps.joinPath(targetDir, "specs");
7370
+ deps.mkdirRecursive(specsRoot);
7371
+ for (const folder of SCAFFOLD_FOLDERS) {
7372
+ deps.mkdirRecursive(deps.joinPath(specsRoot, folder));
7373
+ }
7243
7374
  }
7244
- const heyspexYamlPath = deps.joinPath(targetDir, "heyspex.yaml");
7245
7375
  let vaultId;
7246
- if (deps.exists(heyspexYamlPath)) {
7247
- const existingYaml = deps.readFile?.(heyspexYamlPath) ?? null;
7248
- vaultId = (existingYaml ? parseVaultId(existingYaml) : null) ?? "";
7376
+ let pin;
7377
+ if (markerPresent) {
7378
+ vaultId = existingVaultId ?? "";
7379
+ const stamped = existingYaml === null ? null : stampTemplateVersion(existingYaml, TEMPLATE_VERSION);
7380
+ if (stamped !== null && stamped !== existingYaml) {
7381
+ deps.writeFile(heyspexYamlPath, stamped);
7382
+ written.push(heyspexYamlPath);
7383
+ pin = "written";
7384
+ } else {
7385
+ pin = "kept";
7386
+ }
7249
7387
  } else {
7250
7388
  vaultId = mintVaultId();
7251
7389
  deps.writeFile(
7252
7390
  heyspexYamlPath,
7253
- renderHeyspexYaml({
7254
- consoleUrl: options2.consoleUrl ?? DEFAULT_CONSOLE_URL2,
7255
- vaultRoot: "specs/",
7256
- vaultId
7257
- })
7391
+ stampTemplateVersion(
7392
+ renderHeyspexYaml({
7393
+ consoleUrl: options2.consoleUrl ?? DEFAULT_CONSOLE_URL2,
7394
+ vaultRoot: "specs/",
7395
+ vaultId
7396
+ }),
7397
+ TEMPLATE_VERSION
7398
+ )
7258
7399
  );
7259
7400
  written.push(heyspexYamlPath);
7401
+ pin = "written";
7260
7402
  }
7261
7403
  const readConfigSeed = deps.readConfigSeed;
7262
7404
  const configSeed = seedConfigYaml(
@@ -7272,6 +7414,10 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
7272
7414
  rowResults.push({ dest: row2.dest, action: "kept" });
7273
7415
  continue;
7274
7416
  }
7417
+ if (mode === "refresh") {
7418
+ const parent = parentDirOf(deps, targetDir, row2.dest);
7419
+ if (parent && !deps.exists(parent)) deps.mkdirRecursive(parent);
7420
+ }
7275
7421
  if (row2.classification === "universal-template") {
7276
7422
  const srcAbs = deps.joinPath(deps.platformRoot, ...row2.source.split("/"));
7277
7423
  deps.copyFile(srcAbs, destAbs);
@@ -7291,24 +7437,41 @@ function runCustomerVaultInit(args, deps, options2 = {}) {
7291
7437
  targetDir,
7292
7438
  vaultName: options2.vaultName ?? defaultVaultName(targetDir),
7293
7439
  templateVersion: TEMPLATE_VERSION,
7440
+ mode,
7294
7441
  manifestRows: rows,
7295
7442
  rows: rowResults,
7296
7443
  deps,
7297
- recordWrite: (path7) => {
7298
- written.push(path7);
7444
+ recordWrite: (path9) => {
7445
+ written.push(path9);
7299
7446
  }
7300
7447
  };
7301
7448
  for (const hook of options2.afterScaffold) hook(ctx);
7302
7449
  }
7303
7450
  const createdCount = rowResults.filter((r) => r.action === "created").length;
7304
7451
  const keptCount = rowResults.length - createdCount;
7452
+ const added = rowResults.filter((r) => r.action === "created").map((r) => r.dest);
7453
+ let message2;
7454
+ if (mode === "refresh") {
7455
+ const parts = [];
7456
+ if (added.length > 0)
7457
+ parts.push(`added ${added.length} missing template file(s): ${added.join(", ")}`);
7458
+ if (pin === "written")
7459
+ parts.push(`pinned template_version: ${TEMPLATE_VERSION} in heyspex.yaml`);
7460
+ const hookWrites = written.length - added.length - (pin === "written" ? 1 : 0);
7461
+ if (hookWrites > 0) parts.push(`${hookWrites} other file(s) written`);
7462
+ 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.`;
7463
+ } else {
7464
+ message2 = `Initialized customer vault at ${targetDir} (${written.length} files written, ${createdCount} manifest rows created, ${keptCount} kept, template v${TEMPLATE_VERSION}).`;
7465
+ }
7305
7466
  return {
7306
7467
  code: EXIT.OK,
7307
- message: `Initialized customer vault at ${targetDir} (${written.length} files written, ${createdCount} manifest rows created, ${keptCount} kept, template v${TEMPLATE_VERSION}).`,
7468
+ message: message2,
7308
7469
  payload: {
7309
7470
  ok: true,
7471
+ mode,
7310
7472
  targetDir,
7311
7473
  templateVersion: TEMPLATE_VERSION,
7474
+ templatePin: pin,
7312
7475
  vaultId,
7313
7476
  written,
7314
7477
  rows: rowResults,
@@ -7350,14 +7513,14 @@ function templatePinHook() {
7350
7513
  }
7351
7514
 
7352
7515
  // spex-console/src/lib/cli/principle-selection.ts
7353
- import { basename as basename3 } from "node:path";
7516
+ import { basename as basename4 } from "node:path";
7354
7517
  var PRINCIPLES_INDEX_DEST = "specs/principles-index.md";
7355
7518
  function principleKey(row2) {
7356
- const base = basename3(row2.dest, ".md");
7519
+ const base = basename4(row2.dest, ".md");
7357
7520
  return base.startsWith("principle-") ? base.slice("principle-".length) : base;
7358
7521
  }
7359
7522
  function principleWikilink(row2) {
7360
- return basename3(row2.dest, ".md");
7523
+ return basename4(row2.dest, ".md");
7361
7524
  }
7362
7525
  function parsePrinciplesFlag(value, rows) {
7363
7526
  const principleRows = rows.filter((row2) => row2.group === "principle");
@@ -7423,13 +7586,13 @@ function applyCustomerTemplate(args) {
7423
7586
  templateVersion: ctx.templateVersion
7424
7587
  });
7425
7588
  for (const file of contractFiles) {
7426
- const path7 = ctx.deps.joinPath(ctx.targetDir, file);
7427
- if (force && ctx.deps.exists(path7)) {
7428
- if (ctx.deps.readFile?.(path7) === content) {
7589
+ const path9 = ctx.deps.joinPath(ctx.targetDir, file);
7590
+ if (force && ctx.deps.exists(path9)) {
7591
+ if (ctx.deps.readFile?.(path9) === content) {
7429
7592
  contracts.push({ file, action: "unchanged" });
7430
7593
  } else {
7431
- ctx.deps.writeFile(path7, content);
7432
- ctx.recordWrite(path7);
7594
+ ctx.deps.writeFile(path9, content);
7595
+ ctx.recordWrite(path9);
7433
7596
  contracts.push({ file, action: "overwritten" });
7434
7597
  }
7435
7598
  continue;
@@ -7468,12 +7631,17 @@ function applyCustomerTemplate(args) {
7468
7631
  }
7469
7632
 
7470
7633
  // spex-console/src/lib/cli/install.ts
7471
- import { isAbsolute as isAbsolute3, resolve as resolveNodePath2 } from "node:path";
7634
+ import { isAbsolute as isAbsolute4, resolve as resolveNodePath2 } from "node:path";
7635
+
7636
+ // spex-console/src/lib/vault-resolve/index.ts
7637
+ import { existsSync as existsSync6, readFileSync as readFileSync12, realpathSync as realpathSync4, statSync as statSync4 } from "node:fs";
7638
+ import { tmpdir as tmpdir2 } from "node:os";
7639
+ import path5 from "node:path";
7472
7640
 
7473
7641
  // scripts/install-cursor-hooks.mjs
7474
7642
  import fs2 from "node:fs";
7475
- import path4 from "node:path";
7476
- import { fileURLToPath as fileURLToPath3 } from "node:url";
7643
+ import path6 from "node:path";
7644
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
7477
7645
 
7478
7646
  // scripts/cursor-hooks/manifest.mjs
7479
7647
  var SHIM = "scripts/cursor-hooks/_run.mjs";
@@ -7617,9 +7785,9 @@ function timeoutFor(entry) {
7617
7785
  }
7618
7786
 
7619
7787
  // scripts/install-cursor-hooks.mjs
7620
- var PLATFORM_ROOT = path4.resolve(path4.dirname(fileURLToPath3(import.meta.url)), "..");
7621
- var REPO_ROOT = process.env.HS_SETUP_TARGET?.trim() ? path4.resolve(process.env.HS_SETUP_TARGET.trim()) : PLATFORM_ROOT;
7622
- var SETTINGS_PATH = path4.join(REPO_ROOT, ".cursor", "hooks.json");
7788
+ var PLATFORM_ROOT = path6.resolve(path6.dirname(fileURLToPath4(import.meta.url)), "..");
7789
+ var REPO_ROOT = process.env.HS_SETUP_TARGET?.trim() ? path6.resolve(process.env.HS_SETUP_TARGET.trim()) : PLATFORM_ROOT;
7790
+ var SETTINGS_PATH = path6.join(REPO_ROOT, ".cursor", "hooks.json");
7623
7791
  function cursorKitFor(targetRoot, platformRoot = PLATFORM_ROOT) {
7624
7792
  const customer = !samePlatformPath(targetRoot, platformRoot);
7625
7793
  return {
@@ -7631,7 +7799,7 @@ function cursorKitFor(targetRoot, platformRoot = PLATFORM_ROOT) {
7631
7799
  }
7632
7800
  var KIT = cursorKitFor(REPO_ROOT);
7633
7801
  var ACTIVE_HOOKS = KIT.hooks;
7634
- var LAUNCHER_PATH = path4.join(REPO_ROOT, ...CUSTOMER_LAUNCHERS.cursor.rel);
7802
+ var LAUNCHER_PATH = path6.join(REPO_ROOT, ...CUSTOMER_LAUNCHERS.cursor.rel);
7635
7803
  var LEGACY_MARKER = "scripts/claude-hooks/";
7636
7804
  function readSettings() {
7637
7805
  try {
@@ -7650,7 +7818,7 @@ function readSettings() {
7650
7818
  return { version: 1, hooks: {} };
7651
7819
  }
7652
7820
  function writeSettings(settings) {
7653
- fs2.mkdirSync(path4.dirname(SETTINGS_PATH), { recursive: true });
7821
+ fs2.mkdirSync(path6.dirname(SETTINGS_PATH), { recursive: true });
7654
7822
  fs2.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}
7655
7823
  `, "utf8");
7656
7824
  }
@@ -7685,7 +7853,7 @@ function sameEntry(raw, entry, render = KIT.commandFor) {
7685
7853
  const want = desiredEntry(entry, render);
7686
7854
  return commandOf(raw) === want.command && raw.timeout === want.timeout && (raw.matcher ?? void 0) === (want.matcher ?? void 0);
7687
7855
  }
7688
- var PLUGIN_HOOKS_PATH = path4.join(REPO_ROOT, "plugins", "heyspex", "hooks", "cursor-hooks.json");
7856
+ var PLUGIN_HOOKS_PATH = path6.join(REPO_ROOT, "plugins", "heyspex", "hooks", "cursor-hooks.json");
7689
7857
  function pluginHooksDocument() {
7690
7858
  const hooks = {};
7691
7859
  for (const entry of HOOKS) {
@@ -7715,7 +7883,7 @@ function syncPluginHooks() {
7715
7883
  have = null;
7716
7884
  }
7717
7885
  if (have === want) return false;
7718
- fs2.mkdirSync(path4.dirname(PLUGIN_HOOKS_PATH), { recursive: true });
7886
+ fs2.mkdirSync(path6.dirname(PLUGIN_HOOKS_PATH), { recursive: true });
7719
7887
  fs2.writeFileSync(PLUGIN_HOOKS_PATH, want, "utf8");
7720
7888
  return true;
7721
7889
  }
@@ -7788,7 +7956,7 @@ function install({ json = false } = {}) {
7788
7956
  if (KIT.customer) {
7789
7957
  const plan = launcherPlan();
7790
7958
  if (plan.write) {
7791
- fs2.mkdirSync(path4.dirname(LAUNCHER_PATH), { recursive: true });
7959
+ fs2.mkdirSync(path6.dirname(LAUNCHER_PATH), { recursive: true });
7792
7960
  fs2.writeFileSync(LAUNCHER_PATH, plan.text, "utf8");
7793
7961
  launcherWritten = true;
7794
7962
  }
@@ -7815,10 +7983,10 @@ function install({ json = false } = {}) {
7815
7983
  return;
7816
7984
  }
7817
7985
  if (pluginChanged) {
7818
- console.log(`install-cursor-hooks: regenerated ${path4.relative(REPO_ROOT, PLUGIN_HOOKS_PATH)}`);
7986
+ console.log(`install-cursor-hooks: regenerated ${path6.relative(REPO_ROOT, PLUGIN_HOOKS_PATH)}`);
7819
7987
  }
7820
7988
  if (launcherWritten) {
7821
- console.log(`install-cursor-hooks: wrote ${path4.relative(REPO_ROOT, LAUNCHER_PATH)} (resolves node_modules/heyspex)`);
7989
+ console.log(`install-cursor-hooks: wrote ${path6.relative(REPO_ROOT, LAUNCHER_PATH)} (resolves node_modules/heyspex)`);
7822
7990
  }
7823
7991
  if (!settingsChanged) {
7824
7992
  console.log(`install-cursor-hooks: already installed (${ACTIVE_HOOKS.length} hooks, no changes).`);
@@ -7829,7 +7997,7 @@ function install({ json = false } = {}) {
7829
7997
  if (repaired.length) parts.push(`repaired ${repaired.length}`);
7830
7998
  if (removed.length) parts.push(`removed ${removed.length}`);
7831
7999
  console.log(
7832
- `install-cursor-hooks: ${parts.join("; ")} \u2192 ${path4.relative(REPO_ROOT, SETTINGS_PATH)} (${ACTIVE_HOOKS.length} hooks registered)`
8000
+ `install-cursor-hooks: ${parts.join("; ")} \u2192 ${path6.relative(REPO_ROOT, SETTINGS_PATH)} (${ACTIVE_HOOKS.length} hooks registered)`
7833
8001
  );
7834
8002
  for (const line of removed) console.log(` - ${line}`);
7835
8003
  console.log(
@@ -8078,8 +8246,8 @@ function formatReceipt(receipt) {
8078
8246
  // spex-console/src/lib/cli/receipt-readback.ts
8079
8247
  var RECEIPT_CLOUD_READS = 3;
8080
8248
  var RECEIPT_REREAD_DELAY_MS = 1e3;
8081
- var realSleep = (ms) => new Promise((resolve5) => {
8082
- setTimeout(resolve5, ms);
8249
+ var realSleep = (ms) => new Promise((resolve6) => {
8250
+ setTimeout(resolve6, ms);
8083
8251
  });
8084
8252
  function message(err) {
8085
8253
  return err instanceof Error ? err.message : String(err);
@@ -8117,11 +8285,11 @@ function readLocalReceiptSide(subject, registryOpts = {}) {
8117
8285
  return unreadSide(`registry.json could not be read: ${message(err)}`);
8118
8286
  }
8119
8287
  }
8120
- async function readList(http, path7, key) {
8121
- const res = await cloudHttpRequest(http, "GET", path7);
8288
+ async function readList(http, path9, key) {
8289
+ const res = await cloudHttpRequest(http, "GET", path9);
8122
8290
  const rows = res.json?.[key];
8123
8291
  if (!res.ok || !Array.isArray(rows)) {
8124
- return { reason: `GET ${path7.split("?")[0]} answered HTTP ${res.status}` };
8292
+ return { reason: `GET ${path9.split("?")[0]} answered HTTP ${res.status}` };
8125
8293
  }
8126
8294
  return { rows: rows.filter((r) => Boolean(r) && typeof r === "object") };
8127
8295
  }
@@ -8196,7 +8364,7 @@ async function readReceiptSides(subject, deps) {
8196
8364
  var import_gray_matter = __toESM(require_gray_matter(), 1);
8197
8365
  import { createHash as createHash2 } from "node:crypto";
8198
8366
  import { promises as fs3 } from "node:fs";
8199
- import path5 from "node:path";
8367
+ import path7 from "node:path";
8200
8368
 
8201
8369
  // spex-console/src/lib/vault-id.ts
8202
8370
  var FILE_KEY_RE = /^(?=[0-9]*[A-Z])[A-Z0-9]{4,}$/;
@@ -8778,8 +8946,8 @@ async function indexTemplateToCloud(args) {
8778
8946
  const res = await cloudHttpRequest(args.http, "GET", REINDEX_PATH);
8779
8947
  return { status: res.status, json: res.json };
8780
8948
  },
8781
- post: async (path7, body) => {
8782
- const res = await cloudHttpRequest(args.http, "POST", path7, body);
8949
+ post: async (path9, body) => {
8950
+ const res = await cloudHttpRequest(args.http, "POST", path9, body);
8783
8951
  return { status: res.status, json: res.json };
8784
8952
  }
8785
8953
  });
@@ -8825,39 +8993,58 @@ async function indexTemplateToCloud(args) {
8825
8993
  // spex-console/src/lib/cli/install.ts
8826
8994
  var MCP_REL = ["vendor", "heyspex-mcp", "cli.mjs"];
8827
8995
  var PORTABLE_MCP_CLI = Object.freeze({
8996
+ claude: `\${CLAUDE_PROJECT_DIR:-.}/${CUSTOMER_LAUNCHERS.claude.rel.join("/")}`,
8997
+ cursor: `\${workspaceFolder}/${CUSTOMER_LAUNCHERS.cursor.rel.join("/")}`
8998
+ });
8999
+ var LEGACY_PORTABLE_MCP_CLI = Object.freeze({
8828
9000
  claude: `\${CLAUDE_PROJECT_DIR:-.}/${PORTABLE_PACKAGE_DIR}/${MCP_REL.join("/")}`,
8829
9001
  cursor: `\${workspaceFolder}/${PORTABLE_PACKAGE_DIR}/${MCP_REL.join("/")}`
8830
9002
  });
9003
+ var LAUNCHER_MCP_FLAG = "--mcp";
9004
+ function mcpServerArgs(cliPath) {
9005
+ return /[\\/]heyspex[\\/]run-hook\.mjs$/.test(cliPath) ? [cliPath, LAUNCHER_MCP_FLAG] : [cliPath];
9006
+ }
9007
+ function repairLegacyMcpManifest(manifest, surface) {
9008
+ const servers = manifest?.mcpServers;
9009
+ const entry = servers && typeof servers === "object" ? servers.heyspex : void 0;
9010
+ if (!entry || !Array.isArray(entry.args) || entry.args[0] !== LEGACY_PORTABLE_MCP_CLI[surface]) {
9011
+ return false;
9012
+ }
9013
+ entry.args = mcpServerArgs(PORTABLE_MCP_CLI[surface]);
9014
+ return true;
9015
+ }
8831
9016
  function mcpCliArgFor(surface, platformRoot, targetDir, joinPath) {
8832
9017
  return samePlatformPath(targetDir, platformRoot) ? mcpCliPath(platformRoot, joinPath) : PORTABLE_MCP_CLI[surface];
8833
9018
  }
8834
9019
  function mcpCliPath(platformRoot, joinPath) {
8835
9020
  return joinPath(platformRoot, ...MCP_REL);
8836
9021
  }
8837
- function renderCursorMcpJson(cliAbsPath, attachedVaultRoot) {
9022
+ function renderCursorMcpJson(cliAbsPath, attachedVaultRoot, opts = {}) {
9023
+ const pin = vaultRootPin(attachedVaultRoot, "${workspaceFolder}", opts);
8838
9024
  return {
8839
9025
  mcpServers: {
8840
9026
  heyspex: {
8841
9027
  type: "stdio",
8842
9028
  command: "node",
8843
- args: [cliAbsPath],
8844
- env: {
8845
- HEYSPEX_VAULT_ROOT: attachedVaultRoot ?? "${workspaceFolder}"
8846
- }
9029
+ args: mcpServerArgs(cliAbsPath),
9030
+ ...pin ? { env: { HEYSPEX_VAULT_ROOT: pin } } : {}
8847
9031
  }
8848
9032
  }
8849
9033
  };
8850
9034
  }
8851
- function renderClaudeMcpJson(cliAbsPath, attachedVaultRoot) {
9035
+ function vaultRootPin(attachedVaultRoot, placeholder, opts = {}) {
9036
+ if (attachedVaultRoot) return attachedVaultRoot;
9037
+ return opts.targetIsVault === false ? null : placeholder;
9038
+ }
9039
+ function renderClaudeMcpJson(cliAbsPath, attachedVaultRoot, opts = {}) {
9040
+ const pin = vaultRootPin(attachedVaultRoot, "${CLAUDE_PROJECT_DIR:-.}", opts);
8852
9041
  return {
8853
9042
  mcpServers: {
8854
9043
  heyspex: {
8855
9044
  type: "stdio",
8856
9045
  command: "node",
8857
- args: [cliAbsPath],
8858
- env: {
8859
- HEYSPEX_VAULT_ROOT: attachedVaultRoot ?? "${CLAUDE_PROJECT_DIR:-.}"
8860
- }
9046
+ args: mcpServerArgs(cliAbsPath),
9047
+ ...pin ? { env: { HEYSPEX_VAULT_ROOT: pin } } : {}
8861
9048
  }
8862
9049
  }
8863
9050
  };
@@ -9135,7 +9322,6 @@ var WRITE_TARGET_KIND = {
9135
9322
  claudeMcp: "claude_mcp",
9136
9323
  claudeMd: "claude_md",
9137
9324
  agentsMd: "agents_md",
9138
- statuslineVendor: "statusline_vendor",
9139
9325
  gitignore: "gitignore",
9140
9326
  claudeMdTemplate: "claude_md_template",
9141
9327
  agentsMdTemplate: "agents_md_template"
@@ -9192,12 +9378,6 @@ var INSTALL_WRITE_TARGETS = [
9192
9378
  path: (targetDir, joinPath) => joinPath(targetDir, "AGENTS.md.template"),
9193
9379
  appliesTo: (args, opts) => Boolean(args.registerVault) && !opts.attached && opts.installContract
9194
9380
  },
9195
- {
9196
- kind: WRITE_TARGET_KIND.statuslineVendor,
9197
- rel: "scripts/claude-statusline",
9198
- path: (targetDir, joinPath) => joinPath(targetDir, "scripts", "claude-statusline"),
9199
- appliesTo: (args) => (args.statusline ?? true) && args.claude
9200
- },
9201
9381
  {
9202
9382
  kind: WRITE_TARGET_KIND.gitignore,
9203
9383
  rel: ".gitignore",
@@ -9330,7 +9510,7 @@ var INSTALL_HELP = [
9330
9510
 
9331
9511
  // spex-console/src/lib/cli/install-identity.ts
9332
9512
  import { spawnSync as spawnSync2 } from "node:child_process";
9333
- import { existsSync as existsSync5 } from "node:fs";
9513
+ import { existsSync as existsSync7 } from "node:fs";
9334
9514
  import { join as join11 } from "node:path";
9335
9515
  import { createInterface } from "node:readline/promises";
9336
9516
  init_credentials();
@@ -9338,7 +9518,7 @@ init_credentials();
9338
9518
  // vendor/heyspex-mcp/whoami.mjs
9339
9519
  init_heyspex_home();
9340
9520
  init_credentials();
9341
- import { homedir as homedir9 } from "node:os";
9521
+ import { homedir as homedir10 } from "node:os";
9342
9522
  var SESSION_PATH = "/api/mcp/session";
9343
9523
  async function probeCredentialIdentity({ consoleUrl, token, fetchImpl = fetch } = {}) {
9344
9524
  if (!token) return { kind: "no_credential" };
@@ -9622,7 +9802,7 @@ function isInteractiveTerminal() {
9622
9802
  }
9623
9803
  function defaultIdentityGateIo(opts) {
9624
9804
  const consoleUrl = opts.consoleUrl.trim().replace(/\/+$/, "");
9625
- const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join11(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync5(p));
9805
+ const loginCli = [opts.platformRoot, resolveCodeRoot({})].filter((r) => Boolean(r && r.trim())).map((r) => join11(r, "vendor", "heyspex-mcp", "cli.mjs")).find((p) => existsSync7(p));
9626
9806
  return {
9627
9807
  consoleUrl,
9628
9808
  interactive: opts.interactive ?? isInteractiveTerminal(),
@@ -10036,9 +10216,9 @@ function detachedSatelliteWarning(repoRoot, gitRemote, registryBefore, deps) {
10036
10216
  reasons.push(`${wiredManifests.join(" + ")} already declares the heyspex MCP server`);
10037
10217
  }
10038
10218
  const contractFiles = ["CLAUDE.md", "AGENTS.md"].filter((name) => {
10039
- const path7 = deps.joinPath(repoRoot, name);
10040
- if (!deps.fileExists(path7)) return false;
10041
- const content = deps.readFile(path7);
10219
+ const path9 = deps.joinPath(repoRoot, name);
10220
+ if (!deps.fileExists(path9)) return false;
10221
+ const content = deps.readFile(path9);
10042
10222
  return content !== null && HEYSPEX_CONTRACT_MARKERS.some((m) => content.includes(m));
10043
10223
  });
10044
10224
  if (contractFiles.length > 0) {
@@ -10857,7 +11037,32 @@ async function runConnectFullRun(args, deps) {
10857
11037
  const mcpConfirmedBy = "read .mcp.json + .cursor/mcp.json";
10858
11038
  const claudeMissing = !deps.fileExists(claudeMcpPath);
10859
11039
  const cursorMissing = !deps.fileExists(cursorMcpPath);
10860
- if (!claudeMissing && !cursorMissing) {
11040
+ const repairedLegacy = [];
11041
+ for (const [file, surface, missing] of [
11042
+ [claudeMcpPath, "claude", claudeMissing],
11043
+ [cursorMcpPath, "cursor", cursorMissing]
11044
+ ]) {
11045
+ if (missing || samePlatformPath(repoRoot, platformRoot)) continue;
11046
+ let parsed = null;
11047
+ try {
11048
+ parsed = JSON.parse(deps.readFile(file) ?? "");
11049
+ } catch {
11050
+ continue;
11051
+ }
11052
+ if (!repairLegacyMcpManifest(parsed, surface)) continue;
11053
+ if (!args.dryRun) deps.writeFile(file, `${JSON.stringify(parsed, null, 2)}
11054
+ `);
11055
+ repairedLegacy.push(file);
11056
+ }
11057
+ if (repairedLegacy.length > 0 && !claudeMissing && !cursorMissing) {
11058
+ steps.push({
11059
+ id: "mcp_entry",
11060
+ label,
11061
+ status: "run",
11062
+ detail: `${args.dryRun ? "would repoint" : "repointed"} ${repairedLegacy.join(", ")} at the committed launcher (${PORTABLE_MCP_CLI.claude})`,
11063
+ confirmed_by: mcpConfirmedBy
11064
+ });
11065
+ } else if (!claudeMissing && !cursorMissing) {
10861
11066
  steps.push({
10862
11067
  id: "mcp_entry",
10863
11068
  label,
@@ -10880,12 +11085,14 @@ async function runConnectFullRun(args, deps) {
10880
11085
  try {
10881
11086
  const claudeCli = mcpCliArgFor("claude", platformRoot, repoRoot, deps.joinPath);
10882
11087
  const cursorCli = mcpCliArgFor("cursor", platformRoot, repoRoot, deps.joinPath);
10883
- const pinnedRoot = satellite?.vaultRootAbs;
11088
+ const markerRel = satellite?.viaMarker ? satellite.vaultPath : void 0;
11089
+ const claudePin = markerRel ? "${CLAUDE_PROJECT_DIR:-.}/" + markerRel : satellite?.vaultRootAbs;
11090
+ const cursorPin = markerRel ? "${workspaceFolder}/" + markerRel : satellite?.vaultRootAbs;
10884
11091
  const written = [];
10885
11092
  if (claudeMissing) {
10886
11093
  deps.writeFile(
10887
11094
  claudeMcpPath,
10888
- `${JSON.stringify(renderClaudeMcpJson(claudeCli, pinnedRoot), null, 2)}
11095
+ `${JSON.stringify(renderClaudeMcpJson(claudeCli, claudePin), null, 2)}
10889
11096
  `
10890
11097
  );
10891
11098
  written.push(claudeMcpPath);
@@ -10894,7 +11101,7 @@ async function runConnectFullRun(args, deps) {
10894
11101
  deps.mkdir(deps.joinPath(repoRoot, ".cursor"));
10895
11102
  deps.writeFile(
10896
11103
  cursorMcpPath,
10897
- `${JSON.stringify(renderCursorMcpJson(cursorCli, pinnedRoot), null, 2)}
11104
+ `${JSON.stringify(renderCursorMcpJson(cursorCli, cursorPin), null, 2)}
10898
11105
  `
10899
11106
  );
10900
11107
  written.push(cursorMcpPath);
@@ -11279,11 +11486,11 @@ function resolveDoctorCommand(repoRoot, platformRoot, deps) {
11279
11486
  const hs = deps.joinPath(platformRoot, "bin", "hs.mjs");
11280
11487
  return deps.fileExists(hs) ? { args: [hs, "doctor", "--json"], confirmedBy: "hs doctor --json" } : null;
11281
11488
  }
11282
- function assertMarker(content, path7, vaultId, accountId) {
11283
- if (content === null) throw new Error(`${path7} could not be read back after the write`);
11489
+ function assertMarker(content, path9, vaultId, accountId) {
11490
+ if (content === null) throw new Error(`${path9} could not be read back after the write`);
11284
11491
  const back = parseVaultId(content);
11285
11492
  if (vaultId && back !== vaultId) {
11286
- throw new Error(`${path7} reads back vault_id ${back ?? "(none)"}, not ${vaultId}`);
11493
+ throw new Error(`${path9} reads back vault_id ${back ?? "(none)"}, not ${vaultId}`);
11287
11494
  }
11288
11495
  }
11289
11496
  var MIRRORED_SKILL_DIRS = [
@@ -11437,7 +11644,7 @@ async function runFixHarnessCommands(args, deps) {
11437
11644
  continue;
11438
11645
  }
11439
11646
  const existingContent = deps.fileExists(installedPath) ? deps.readFile(installedPath) : null;
11440
- if (existingContent === f.content) {
11647
+ if (existingContent !== null && normalizeEol(existingContent) === normalizeEol(f.content)) {
11441
11648
  unchangedCount += 1;
11442
11649
  continue;
11443
11650
  }
@@ -11624,6 +11831,104 @@ var HELP3 = [
11624
11831
  "Exit codes: 0 ok \xB7 1 usage \xB7 10 not-found \xB7 11 bad-arg \xB7 12 console-unreachable \xB7 13 write-failed"
11625
11832
  ].join("\n");
11626
11833
 
11834
+ // spex-console/src/lib/cli/generated-file-eol.ts
11835
+ import { spawnSync as spawnSync3 } from "node:child_process";
11836
+ import { existsSync as existsSync8, readFileSync as readFileSync13 } from "node:fs";
11837
+ import { dirname as dirname6, relative, resolve as resolve5 } from "node:path";
11838
+ function dominantEol(text2) {
11839
+ const crlf = (text2.match(/\r\n/g) ?? []).length;
11840
+ const lf = (text2.match(/\n/g) ?? []).length - crlf;
11841
+ if (crlf === 0 && lf === 0) return null;
11842
+ return crlf > lf ? "\r\n" : "\n";
11843
+ }
11844
+ function applyEol(text2, eol) {
11845
+ const lf = text2.replace(/\r\n/g, "\n");
11846
+ return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
11847
+ }
11848
+ function defaultRunGit(args, cwd) {
11849
+ try {
11850
+ const res = spawnSync3("git", args, {
11851
+ cwd,
11852
+ encoding: "utf8",
11853
+ windowsHide: true,
11854
+ timeout: 1e4
11855
+ });
11856
+ if (res.status !== 0) return null;
11857
+ return String(res.stdout ?? "").trim();
11858
+ } catch {
11859
+ return null;
11860
+ }
11861
+ }
11862
+ function defaultReadFile4(path9) {
11863
+ try {
11864
+ return readFileSync13(path9, "utf8");
11865
+ } catch {
11866
+ return null;
11867
+ }
11868
+ }
11869
+ function existingAncestor(path9) {
11870
+ let dir = dirname6(resolve5(path9));
11871
+ for (; ; ) {
11872
+ if (existsSync8(dir)) return dir;
11873
+ const parent = dirname6(dir);
11874
+ if (parent === dir) return dir;
11875
+ dir = parent;
11876
+ }
11877
+ }
11878
+ function createGeneratedFileEol(opts) {
11879
+ const readFile2 = opts.readFile ?? defaultReadFile4;
11880
+ const runGit = opts.runGit ?? defaultRunGit;
11881
+ const platform = opts.platform ?? process.platform;
11882
+ const attrCache = /* @__PURE__ */ new Map();
11883
+ let configEol;
11884
+ const configDefault = () => {
11885
+ if (configEol !== void 0) return configEol;
11886
+ if (runGit(["rev-parse", "--is-inside-work-tree"], opts.repoRoot) !== "true") {
11887
+ configEol = null;
11888
+ return configEol;
11889
+ }
11890
+ const get = (key) => runGit(["config", "--get", key], opts.repoRoot);
11891
+ const coreEol = (get("core.eol") ?? "").toLowerCase();
11892
+ if (coreEol === "crlf") configEol = "\r\n";
11893
+ else if (coreEol === "lf") configEol = "\n";
11894
+ else if (platform === "win32" && (get("core.autocrlf") ?? "").toLowerCase() === "true")
11895
+ configEol = "\r\n";
11896
+ else configEol = null;
11897
+ return configEol;
11898
+ };
11899
+ const attrEol = (path9) => {
11900
+ const abs = resolve5(path9);
11901
+ const cwd = existingAncestor(abs);
11902
+ const rel = relative(cwd, abs).replace(/\\/g, "/") || ".";
11903
+ const key = `${cwd}\0${rel}`;
11904
+ let out = attrCache.get(key);
11905
+ if (out === void 0) {
11906
+ out = runGit(["check-attr", "eol", "--", rel], cwd) ?? "";
11907
+ attrCache.set(key, out);
11908
+ }
11909
+ const m = out.match(/:\s*eol:\s*(\S+)\s*$/);
11910
+ const value = (m?.[1] ?? "").toLowerCase();
11911
+ if (value === "crlf") return "\r\n";
11912
+ if (value === "lf") return "\n";
11913
+ return null;
11914
+ };
11915
+ const eolFor = (path9, existing) => {
11916
+ const current = existing === void 0 ? readFile2(path9) : existing;
11917
+ if (current !== null) {
11918
+ const have = dominantEol(current);
11919
+ if (have) return have;
11920
+ }
11921
+ return attrEol(path9) ?? configDefault() ?? "\n";
11922
+ };
11923
+ return {
11924
+ eolFor,
11925
+ convert: (path9, content, existing) => {
11926
+ if (!content.includes("\n")) return content;
11927
+ return applyEol(content, eolFor(path9, existing));
11928
+ }
11929
+ };
11930
+ }
11931
+
11627
11932
  // spex-console/bin/run-entry.ts
11628
11933
  import process3 from "node:process";
11629
11934
  var BinExit = class extends Error {
@@ -11655,7 +11960,7 @@ function runEntry(name, main2, options2 = {}) {
11655
11960
  // spex-console/bin/hs-connect.ts
11656
11961
  function readFile(p) {
11657
11962
  try {
11658
- return readFileSync11(p, "utf8");
11963
+ return readFileSync14(p, "utf8");
11659
11964
  } catch {
11660
11965
  return null;
11661
11966
  }
@@ -11667,13 +11972,13 @@ function listDir(p) {
11667
11972
  return [];
11668
11973
  }
11669
11974
  }
11670
- var packageRoot = path6.resolve(path6.dirname(fileURLToPath4(import.meta.url)), "..", "..");
11975
+ var packageRoot = path8.resolve(path8.dirname(fileURLToPath5(import.meta.url)), "..", "..");
11671
11976
  var satelliteWalkDeps = {
11672
- fileExists: (p) => existsSync6(p),
11977
+ fileExists: (p) => existsSync9(p),
11673
11978
  readFile,
11674
- joinPath: (...segments) => path6.join(...segments),
11675
- parentDir: (p) => path6.dirname(p),
11676
- resolveDir: (p) => path6.resolve(p)
11979
+ joinPath: (...segments) => path8.join(...segments),
11980
+ parentDir: (p) => path8.dirname(p),
11981
+ resolveDir: (p) => path8.resolve(p)
11677
11982
  };
11678
11983
  async function main() {
11679
11984
  const identityArgv = extractIdentityFlags(process4.argv.slice(3));
@@ -11698,7 +12003,7 @@ async function main() {
11698
12003
  }
11699
12004
  const cwd = resolveConnectCwd(process4.env, process4.cwd());
11700
12005
  const satelliteRepoRoot = findSatelliteRepoRoot(cwd, satelliteWalkDeps);
11701
- const repoRoot = satelliteRepoRoot ?? (process4.env.HS_VAULT_ROOT ? path6.resolve(process4.env.HS_VAULT_ROOT) : cwd);
12006
+ const repoRoot = satelliteRepoRoot ?? (process4.env.HS_VAULT_ROOT ? path8.resolve(process4.env.HS_VAULT_ROOT) : cwd);
11702
12007
  const platformRoot = resolveCodeRoot({
11703
12008
  // The two install-specific overrides outrank HS_PKG_ROOT here, so they
11704
12009
  // ride in as `explicit`; the rest of the ladder (HS_PKG_ROOT, then the
@@ -11709,7 +12014,7 @@ async function main() {
11709
12014
  const consoleUrl = resolveSpexConsoleUrl(repoRoot, readFile);
11710
12015
  if (parsed.args.sub === "run" && !parsed.args.dryRun) {
11711
12016
  const gate = await runIdentityGate(
11712
- defaultIdentityGateIo({ consoleUrl, platformRoot: path6.resolve(platformRoot) }),
12017
+ defaultIdentityGateIo({ consoleUrl, platformRoot: path8.resolve(platformRoot) }),
11713
12018
  identityArgv.flags,
11714
12019
  "hs connect"
11715
12020
  );
@@ -11722,28 +12027,29 @@ async function main() {
11722
12027
  return;
11723
12028
  }
11724
12029
  }
12030
+ const eol = createGeneratedFileEol({ repoRoot });
11725
12031
  const result = await runConnect(parsed.args, {
11726
12032
  repoRoot,
11727
12033
  cwd,
11728
- platformRoot: path6.resolve(platformRoot),
11729
- fileExists: (p) => existsSync6(p),
12034
+ platformRoot: path8.resolve(platformRoot),
12035
+ fileExists: (p) => existsSync9(p),
11730
12036
  readFile,
11731
12037
  listDir,
11732
- writeFile: (p, c) => writeFileSync6(p, c, "utf8"),
11733
- mkdir: (p) => mkdirSync6(p, { recursive: true }),
11734
- copyFile: (src, dest) => copyFileSync(src, dest),
12038
+ writeFile: (p, c) => writeFileSync7(p, eol.convert(p, c), "utf8"),
12039
+ mkdir: (p) => mkdirSync7(p, { recursive: true }),
12040
+ copyFile: (src, dest) => copyFileSync2(src, dest),
11735
12041
  isDirectory: (p) => {
11736
12042
  try {
11737
- return statSync(p).isDirectory();
12043
+ return statSync5(p).isDirectory();
11738
12044
  } catch {
11739
12045
  return false;
11740
12046
  }
11741
12047
  },
11742
- joinPath: (...s) => path6.join(...s),
11743
- basename: (p) => path6.basename(p),
12048
+ joinPath: (...s) => path8.join(...s),
12049
+ basename: (p) => path8.basename(p),
11744
12050
  now: () => (/* @__PURE__ */ new Date()).toISOString(),
11745
12051
  spawn: (command, args, opts) => {
11746
- const res = spawnSync3(command, args, {
12052
+ const res = spawnSync4(command, args, {
11747
12053
  cwd: opts?.cwd,
11748
12054
  ...opts?.env ? { env: mergeChildEnv(process4.env, opts.env) } : {},
11749
12055
  encoding: "utf8",