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
@@ -410,8 +410,16 @@ const LAUNCHER_TEMPLATE = [
410
410
  '// 4. node_modules/heyspex up from $__ENV__, $CLAUDE_PROJECT_DIR, $CURSOR_PROJECT_DIR, cwd',
411
411
  '// 5. <npm root -g>/heyspex a global install, last resort',
412
412
  '// Nothing found -> one stderr line and exit 0 (fail-open, like the shim itself).',
413
+ '//',
414
+ '// The same resolution starts the package\'s other entrypoints, so no committed',
415
+ '// file names node_modules/heyspex directly and a repo with no node_modules of',
416
+ '// its own still works off a global install:',
417
+ '// --mcp the stdio MCP server (.mcp.json, .cursor/mcp.json)',
418
+ '// --statusline the Claude Code status line (statusLine.command)',
419
+ '// --resolve print the package root, exit 0; exit 1 when none (hs doctor)',
413
420
  "import { spawnSync } from 'node:child_process';",
414
- "import { existsSync } from 'node:fs';",
421
+ "import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';",
422
+ "import { homedir } from 'node:os';",
415
423
  "import { dirname, resolve } from 'node:path';",
416
424
  "import { fileURLToPath, pathToFileURL } from 'node:url';",
417
425
  '',
@@ -421,6 +429,7 @@ const LAUNCHER_TEMPLATE = [
421
429
  'const text = (v) => (typeof v === \'string\' && v.trim() ? v.trim() : null);',
422
430
  'const hasShim = (root) => Boolean(root) && existsSync(resolve(root, ...SHIM));',
423
431
  '',
432
+ '__YIELD__',
424
433
  'function packageUpFrom(start) {',
425
434
  ' let dir = resolve(start);',
426
435
  ' for (;;) {',
@@ -455,39 +464,146 @@ const LAUNCHER_TEMPLATE = [
455
464
  ' if (hasShim(global)) root = global;',
456
465
  '}',
457
466
  '',
467
+ 'const ENTRYPOINTS = {',
468
+ " '--mcp': 'vendor/heyspex-mcp/cli.mjs',",
469
+ " '--statusline': 'scripts/claude-statusline/statusline-command.mjs',",
470
+ '};',
471
+ 'const mode = process.argv[2];',
472
+ 'const entry = Object.hasOwn(ENTRYPOINTS, mode) ? ENTRYPOINTS[mode] : null;',
473
+ '',
458
474
  'if (!root) {',
475
+ " const what = entry || mode === '--resolve' ? mode : 'hook ' + (mode ?? '?');",
459
476
  ' process.stderr.write(',
460
- " '[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: no heyspex package found ' +",
461
- " '(run \"npm install heyspex\" in this repo, or set HEYSPEX_PLATFORM_ROOT). Looked for __SHIM__ under ' +",
462
- " 'HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
477
+ " '[heyspex] ' + what + ' skipped: no heyspex package found ' +",
478
+ " '(run \"npm install heyspex\" or \"npm link heyspex\" in this repo, install it globally, or set HEYSPEX_PLATFORM_ROOT). ' +",
479
+ " 'Looked for __SHIM__ under HEYSPEX_PLATFORM_ROOT, HEYSPEX_MCP_ENTRY, node_modules/heyspex up from ' + starts.join(', ') + ', and npm root -g.\\n',",
463
480
  ' );',
481
+ ' // Hooks and the status line fail open; the MCP server and --resolve fail',
482
+ ' // loudly, so the client and hs doctor both see it.',
483
+ " process.exit(mode === '--mcp' || mode === '--resolve' ? 1 : 0);",
484
+ '}',
485
+ "if (mode === '--resolve') {",
486
+ " process.stdout.write(root + '\\n');",
464
487
  ' process.exit(0);',
465
488
  '}',
466
489
  '',
467
490
  '// argv is already [node, <this file>, <hook>, ...args] -- the shape the shim',
468
- '// reads; the shim path replaces this file so a shim guarded by "am I main?"',
469
- '// (the Cursor one) runs exactly as if Cursor had invoked it directly.',
470
- 'const shim = resolve(root, ...SHIM);',
471
- 'process.argv[1] = shim;',
491
+ '// reads; the target replaces this file so a script guarded by "am I main?"',
492
+ '// runs exactly as if it had been invoked directly. Real path, so that guard',
493
+ '// still matches when node_modules/heyspex is an npm link or a junction (node',
494
+ '// realpaths import.meta.url but leaves argv[1] alone).',
495
+ 'let target = resolve(root, ...(entry ? entry.split(\'/\') : SHIM));',
496
+ 'try {',
497
+ ' target = realpathSync(target);',
498
+ '} catch {',
499
+ ' // keep the resolved path',
500
+ '}',
501
+ 'if (entry) process.argv.splice(2, 1);',
502
+ 'process.argv[1] = target;',
472
503
  'try {',
473
- ' await import(pathToFileURL(shim).href);',
504
+ ' await import(pathToFileURL(target).href);',
474
505
  '} catch (err) {',
475
- " process.stderr.write('[heyspex] hook ' + (process.argv[2] ?? '?') + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
506
+ " process.stderr.write('[heyspex] ' + (entry ? mode : 'hook ' + (process.argv[2] ?? '?')) + ' skipped: ' + (err && err.message ? err.message : err) + '\\n');",
507
+ ' // Hooks and the status line fail open; the MCP server fails loudly.',
508
+ " if (mode === '--mcp') process.exit(1);",
476
509
  ' process.exit(0);',
477
510
  '}',
478
511
  '',
479
512
  ].join('\n');
480
513
 
514
+ /**
515
+ * Version marker the Claude launcher carries once it yields to the plugin.
516
+ * Bumping the version changes the rendered text, so
517
+ * {@link planCustomerLauncherWrite} rewrites every older launcher exactly
518
+ * once and is idempotent after that; {@link detectDoubleFire} reads it to
519
+ * tell a yielding launcher from a pre-yield one.
520
+ */
521
+ export const LAUNCHER_YIELD_MARKER = '// yields-to-plugin: v1';
522
+ const LAUNCHER_YIELD_MARKER_RE = /^\/\/ yields-to-plugin: v\d+/m;
523
+
524
+ /** Whether a launcher's text (any version) yields to an enabled heyspex plugin. */
525
+ export function launcherYieldsToPlugin(text) {
526
+ return typeof text === 'string' && LAUNCHER_YIELD_MARKER_RE.test(text.replace(/\r\n/g, '\n'));
527
+ }
528
+
529
+ // THE YIELD (Claude surface only; Cursor has no heyspex plugin). The design rule is ONE source of
530
+ // hooks per repo (plugins/heyspex/README.md "One source of hooks: plugin OR repo hooks, never
531
+ // both"), but `hs install` commits the repo rows for every teammate while the plugin is a
532
+ // per-MACHINE choice (`hs setup copilot`, `/plugin install`), so the committed file cannot decide
533
+ // per machine at install time -- it decides at run time instead: when the plugin is enabled here,
534
+ // the plugin's own launcher (plugins/heyspex/bin/run-hook.mjs, which resolves the platform shim
535
+ // itself and never imports this file -- no recursion) already runs the hook, and this row exits 0
536
+ // silently BEFORE resolving the package. Plugin evidence mirrors {@link detectDoubleFire}; an
537
+ // explicit `enabledPlugins["heyspex@..."] === false` (user or project Claude settings) means the
538
+ // plugin is off for this harness and the repo row runs; HEYSPEX_REPO_HOOKS=1 never yields.
539
+ // Dependency-free and sync (a handful of small reads) so the cost per hook event stays negligible.
540
+ const LAUNCHER_YIELD_BLOCK = [
541
+ LAUNCHER_YIELD_MARKER + ' -- one source of hooks: when the heyspex plugin is enabled on this machine',
542
+ '// its own launcher (plugins/heyspex/bin/run-hook.mjs, which never calls this file) already runs every',
543
+ '// hook, so this committed row exits 0 silently before resolving anything and each hook fires ONCE.',
544
+ '// HEYSPEX_REPO_HOOKS=1 never yield (the repo rows are the chosen source);',
545
+ '// enabledPlugins["heyspex@..."] === false in ~/.claude/settings.json or this repo\'s .claude/',
546
+ '// settings[.local].json: plugin off here, the row runs;',
547
+ '// plugin evidence (what `hs doctor` hook-double-fire reads): a heyspex@... key in',
548
+ '// ~/.claude/plugins/installed_plugins.json, enabledPlugins naming heyspex with a non-false',
549
+ '// value, or a heyspex directory under ~/.copilot/installed-plugins | ~/.copilot/plugins.',
550
+ 'function readJson(file) {',
551
+ " try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }",
552
+ '}',
553
+ 'function listDir(dir) {',
554
+ ' try { return readdirSync(dir); } catch { return []; }',
555
+ '}',
556
+ 'function pluginTakesThisHook() {',
557
+ ' const flag = text(env.HEYSPEX_REPO_HOOKS);',
558
+ " if (flag && flag !== '0' && flag.toLowerCase() !== 'false') return false;",
559
+ ' const home = homedir();',
560
+ " const repo = resolve(here, '..', '..');",
561
+ ' const isHeyspex = (k) => /^heyspex(@|$)/i.test(k);',
562
+ ' let enabled = false;',
563
+ " for (const f of [resolve(home, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.json'), resolve(repo, '.claude', 'settings.local.json')]) {",
564
+ ' const settings = readJson(f);',
565
+ " const ep = settings && typeof settings === 'object' ? settings.enabledPlugins : null;",
566
+ " if (!ep || typeof ep !== 'object') continue;",
567
+ ' for (const [k, v] of Object.entries(ep)) {',
568
+ ' if (!isHeyspex(k)) continue;',
569
+ ' if (v === false) return false;',
570
+ ' enabled = true;',
571
+ ' }',
572
+ ' }',
573
+ ' if (enabled) return true;',
574
+ " const installed = readJson(resolve(home, '.claude', 'plugins', 'installed_plugins.json'));",
575
+ " if (installed && typeof installed === 'object' && Object.keys(installed.plugins && typeof installed.plugins === 'object' ? installed.plugins : installed).some(isHeyspex)) return true;",
576
+ " for (const sub of ['installed-plugins', 'plugins']) {",
577
+ " const dir = resolve(home, '.copilot', sub);",
578
+ ' const names = listDir(dir);',
579
+ ' if (names.some((n) => /heyspex/i.test(n))) return true;',
580
+ ' if (names.some((n) => listDir(resolve(dir, n)).some((m) => /heyspex/i.test(m)))) return true;',
581
+ ' }',
582
+ ' return false;',
583
+ '}',
584
+ '// Only a HOOK EVENT yields: --mcp / --statusline / --resolve and an agent-run',
585
+ '// `<script> --directive <key>` fetch are not hook events the plugin could run in',
586
+ '// our place, so they always go on to the package below.',
587
+ "const yieldMode = process.argv[2];",
588
+ "const notAHookEvent = ['--mcp', '--statusline', '--resolve'].includes(yieldMode) || process.argv.includes('--directive');",
589
+ 'if (!notAHookEvent && pluginTakesThisHook()) process.exit(0);',
590
+ '',
591
+ '',
592
+ ].join('\n');
593
+
481
594
  /**
482
595
  * The launcher file `hs install` / `hs connect` write into a customer repo
483
596
  * (`.claude/heyspex/run-hook.mjs`; the Cursor copy at
484
597
  * `.cursor/heyspex/run-hook.mjs`). Pure: a string, no machine path in it.
598
+ * The Claude copy yields to an enabled heyspex plugin ({@link LAUNCHER_YIELD_MARKER}).
485
599
  * @param {'claude' | 'cursor'} [surface]
486
600
  */
487
601
  export function renderCustomerLauncher(surface = 'claude') {
488
602
  const l = CUSTOMER_LAUNCHERS[surface];
489
- return LAUNCHER_TEMPLATE.replace(/__(REGISTRY|ENV|LAUNCHER|SHIM)__/g, (_m, token) =>
490
- token === 'REGISTRY' ? l.registry : token === 'ENV' ? l.projectDirEnv : token === 'LAUNCHER' ? l.rel.join('/') : l.shim,
603
+ return LAUNCHER_TEMPLATE.replace('__YIELD__\n', surface === 'claude' ? LAUNCHER_YIELD_BLOCK : '').replace(
604
+ /__(REGISTRY|ENV|LAUNCHER|SHIM)__/g,
605
+ (_m, token) =>
606
+ token === 'REGISTRY' ? l.registry : token === 'ENV' ? l.projectDirEnv : token === 'LAUNCHER' ? l.rel.join('/') : l.shim,
491
607
  );
492
608
  }
493
609
 
@@ -798,18 +914,31 @@ const readJsonFile = (deps, file) => {
798
914
  }
799
915
  };
800
916
 
801
- /** @param {unknown} settings */
917
+ /** A command routed through the committed customer launcher (`.claude/heyspex/run-hook.mjs`). */
918
+ const CUSTOMER_LAUNCHER_COMMAND_RE = /\.claude\/heyspex\/run-hook\.mjs/;
919
+
920
+ /**
921
+ * HeySpex rows in a parsed hooks document: how many, and how many of them go
922
+ * through the committed customer launcher (the rows that can yield).
923
+ * @param {unknown} settings
924
+ * @returns {{ n: number, viaLauncher: number }}
925
+ */
802
926
  function countOurRows(settings) {
803
927
  let n = 0;
928
+ let viaLauncher = 0;
804
929
  const hooks = settings && typeof settings === 'object' && settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
805
930
  for (const groups of Object.values(hooks)) {
806
931
  for (const g of Array.isArray(groups) ? groups : []) {
807
932
  // Claude shape: { matcher, hooks: [{ command }] }; Copilot .github/hooks shape: { bash } / { command }.
808
933
  const cmds = Array.isArray(g?.hooks) ? g.hooks.map((h) => h?.command) : [g?.command, g?.bash];
809
- for (const c of cmds) if (typeof c === 'string' && (ourHookTail(c) !== null || /claude-hooks\/_run\.mjs/.test(c))) n++;
934
+ for (const c of cmds) {
935
+ if (typeof c !== 'string' || (ourHookTail(c) === null && !/claude-hooks\/_run\.mjs/.test(c))) continue;
936
+ n++;
937
+ if (CUSTOMER_LAUNCHER_COMMAND_RE.test(c.replace(/\\/g, '/'))) viaLauncher++;
938
+ }
810
939
  }
811
940
  }
812
- return n;
941
+ return { n, viaLauncher };
813
942
  }
814
943
 
815
944
  /**
@@ -824,22 +953,26 @@ function countOurRows(settings) {
824
953
  * @param {{ targetRoot: string, home?: string, deps?: { readFile: (p: string) => string | null, listDir?: (p: string) => string[] } }} opts
825
954
  * @returns {{ status: 'ok' | 'warn' | 'unknown', detail: string, repoHooks: number, plugin: boolean | null, evidence: string[] }}
826
955
  */
827
- export function detectDoubleFire({ targetRoot, home = os.homedir(), deps = fsDeps }) {
956
+ export function detectDoubleFire({ targetRoot, home = os.homedir(), copilotHome = path.join(home, '.copilot'), deps = fsDeps }) {
828
957
  const evidence = [];
829
958
  const repoFiles = ['.claude/settings.json', '.claude/settings.local.json'].map((f) => path.join(targetRoot, f));
830
959
  let repoHooks = 0;
960
+ let launcherRows = 0; // rows in .claude/settings*.json that go through the committed launcher
831
961
  for (const f of repoFiles) {
832
- const n = countOurRows(readJsonFile(deps, f));
833
- if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in ${f}`); }
962
+ const { n, viaLauncher } = countOurRows(readJsonFile(deps, f));
963
+ if (n) { repoHooks += n; launcherRows += viaLauncher; evidence.push(`${n} HeySpex hook row(s) in ${f}`); }
834
964
  }
835
965
  const ghDir = path.join(targetRoot, '.github', 'hooks');
836
966
  for (const name of (deps.listDir ? deps.listDir(ghDir) : []).filter((x) => x.endsWith('.json'))) {
837
- const n = countOurRows(readJsonFile(deps, path.join(ghDir, name)));
967
+ const { n } = countOurRows(readJsonFile(deps, path.join(ghDir, name)));
838
968
  if (n) { repoHooks += n; evidence.push(`${n} HeySpex hook row(s) in .github/hooks/${name}`); }
839
969
  }
840
970
 
841
971
  let readable = 0;
842
972
  let plugin = false;
973
+ /** `enabledPlugins["heyspex@..."] === false` somewhere: the plugin is switched off for this harness. */
974
+ let disabledIn = null;
975
+ let copilotPlugin = false;
843
976
  const installed = readJsonFile(deps, path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
844
977
  if (installed !== undefined) {
845
978
  readable++;
@@ -852,24 +985,29 @@ export function detectDoubleFire({ targetRoot, home = os.homedir(), deps = fsDep
852
985
  if (st === undefined) continue;
853
986
  if (f === userSettings) readable++; // a project file proves nothing about what is installed elsewhere
854
987
  const ep = st?.enabledPlugins;
855
- if (ep && typeof ep === 'object' && Object.entries(ep).some(([k, v]) => /^heyspex(@|$)/i.test(k) && v !== false)) {
988
+ if (!ep || typeof ep !== 'object') continue;
989
+ const ours = Object.entries(ep).filter(([k]) => /^heyspex(@|$)/i.test(k));
990
+ if (ours.some(([, v]) => v !== false)) {
856
991
  plugin = true;
857
992
  evidence.push(`heyspex in enabledPlugins of ${f}`);
858
993
  }
994
+ if (ours.some(([, v]) => v === false)) disabledIn ??= f;
859
995
  }
860
996
  if (deps.listDir) {
861
997
  for (const sub of ['installed-plugins', 'plugins']) {
862
- const dir = path.join(home, '.copilot', sub);
998
+ const dir = path.join(copilotHome, sub);
863
999
  const names = deps.listDir(dir);
864
1000
  if (names.length) readable++;
865
1001
  if (names.some((n) => /heyspex/i.test(n))) {
866
1002
  plugin = true;
1003
+ copilotPlugin = true;
867
1004
  evidence.push(`heyspex under ${dir}`);
868
1005
  } else {
869
1006
  // one level deeper: <marketplace>/<plugin> layouts
870
1007
  for (const n of names) {
871
1008
  if (deps.listDir(path.join(dir, n)).some((m) => /heyspex/i.test(m))) {
872
1009
  plugin = true;
1010
+ copilotPlugin = true;
873
1011
  evidence.push(`heyspex under ${path.join(dir, n)}`);
874
1012
  }
875
1013
  }
@@ -879,6 +1017,28 @@ export function detectDoubleFire({ targetRoot, home = os.homedir(), deps = fsDep
879
1017
 
880
1018
  if (repoHooks === 0) return { status: 'ok', detail: 'no repo-level HeySpex hooks to double up with a plugin', repoHooks, plugin: plugin || null, evidence };
881
1019
  if (plugin) {
1020
+ // The committed Claude launcher yields at run time when the plugin is enabled on this machine
1021
+ // ({@link LAUNCHER_YIELD_MARKER}): every .claude/settings*.json row that goes through it fires once.
1022
+ // Rows in .github/hooks/*.json never use the launcher, so any of those keeps the warning.
1023
+ const launcher = deps.readFile(path.join(targetRoot, ...CUSTOMER_LAUNCHERS.claude.rel));
1024
+ if (launcherRows === repoHooks && launcherYieldsToPlugin(launcher)) {
1025
+ return {
1026
+ status: 'ok',
1027
+ detail: `repo launcher yields to the heyspex plugin on this machine — hooks fire once (${evidence.join('; ')})`,
1028
+ repoHooks,
1029
+ plugin: true,
1030
+ evidence,
1031
+ };
1032
+ }
1033
+ if (disabledIn && !copilotPlugin) {
1034
+ return {
1035
+ status: 'ok',
1036
+ detail: `heyspex plugin installed but switched off (enabledPlugins false in ${disabledIn}) — the repo hooks are the one source`,
1037
+ repoHooks,
1038
+ plugin: true,
1039
+ evidence,
1040
+ };
1041
+ }
882
1042
  return {
883
1043
  status: 'warn',
884
1044
  detail: `HeySpex hooks are registered in the repo AND the heyspex plugin is installed, so every hook fires twice (${evidence.join('; ')})`,
@@ -990,14 +1150,30 @@ function shortHash(text) {
990
1150
  return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
991
1151
  }
992
1152
 
1153
+ /**
1154
+ * LINE ENDINGS. Every comparison and hash here is over LF text: a Windows
1155
+ * clone with `core.autocrlf=true` checks the committed copy out with CRLF,
1156
+ * and the canonical source itself may sit on such a checkout. The write
1157
+ * layer (`spex-console/src/lib/cli/generated-file-eol.ts`) picks the EOL a
1158
+ * file is written with; nothing in this module may let `\r\n` reach a hash
1159
+ * or an equality test, or a CRLF file reads as "customized"/"stale" forever
1160
+ * (the 0.2.2 kit did exactly that: `hs install` left 14 untouched commands
1161
+ * alone as "customized" while `hs connect --check` reported them stale).
1162
+ * @param {string | null | undefined} text
1163
+ */
1164
+ export function normalizeEol(text) {
1165
+ return text === null || text === undefined ? text : String(text).replace(/\r\n/g, '\n');
1166
+ }
1167
+
993
1168
  /**
994
1169
  * The customer copy of a canonical command: platform paths made portable,
995
1170
  * then a trailing marker carrying the hash of the body. The marker is how a
996
1171
  * re-run tells "ours, untouched, safe to update" from "the customer edited
997
- * it, leave it alone".
1172
+ * it, leave it alone". Always LF, whatever the canonical source's checkout
1173
+ * used, so the hash is the same on every machine.
998
1174
  */
999
1175
  export function renderKitCommand(content, platformRoot) {
1000
- const body = `${rewritePlatformPaths(content, platformRoot).replace(/\s+$/, '')}\n`;
1176
+ const body = `${rewritePlatformPaths(normalizeEol(content), platformRoot).replace(/\s+$/, '')}\n`;
1001
1177
  return (
1002
1178
  `${body}\n<!-- heyspex-kit: managed by hs install (sha256:${shortHash(body)}). ` +
1003
1179
  'Edit freely: once edited, hs install leaves this file alone. -->\n'
@@ -1005,27 +1181,44 @@ export function renderKitCommand(content, platformRoot) {
1005
1181
  }
1006
1182
 
1007
1183
  /**
1184
+ * `managed`: carries the marker and the body still hashes to it — ours,
1185
+ * untouched, from ANY kit version (the marker survives the render changing).
1186
+ * `customized`: marker present but the body no longer hashes to it (a human
1187
+ * edit). `unmanaged`: no marker at all.
1188
+ *
1189
+ * A marker is accepted over the LF body or over its CRLF form: the 0.2.2
1190
+ * package rendered on a CRLF checkout and hashed the body with `\r\n` in
1191
+ * it, then git normalised the committed copy to LF — those files are ours,
1192
+ * never a customer edit.
1008
1193
  * @param {string} content
1009
1194
  * @returns {'managed' | 'customized' | 'unmanaged'}
1010
1195
  */
1011
1196
  export function kitFileState(content) {
1012
- const m = String(content).match(MARKER_RE);
1197
+ const text = normalizeEol(String(content));
1198
+ const m = text.match(MARKER_RE);
1013
1199
  if (!m) return 'unmanaged';
1014
- const body = String(content).slice(0, m.index).replace(/\n$/, '');
1015
- return shortHash(`${body}\n`) === m[1] ? 'managed' : 'customized';
1200
+ const body = `${text.slice(0, m.index).replace(/\n$/, '')}\n`;
1201
+ if (shortHash(body) === m[1]) return 'managed';
1202
+ // Legacy CRLF-checkout render: the old renderer trimmed the trailing whitespace (the final
1203
+ // `\r\n` with it) and appended `\n`, so it hashed inner CRLFs plus one final LF.
1204
+ if (shortHash(`${body.slice(0, -1).replace(/\n/g, '\r\n')}\n`) === m[1]) return 'managed';
1205
+ return 'customized';
1016
1206
  }
1017
1207
 
1018
1208
  /**
1019
1209
  * Whether to write one kit file. `raw` is the canonical (unrendered) content:
1020
1210
  * an existing file equal to it is an old verbatim copy (`hs connect
1021
- * --fix-commands` before this change) and is safe to replace.
1211
+ * --fix-commands` before this change) and is safe to replace. EOL-insensitive
1212
+ * throughout ({@link normalizeEol}): a CRLF checkout of the rendered file is
1213
+ * `unchanged`, a managed file from an older render is `update`.
1022
1214
  * @returns {{ write: boolean, reason: 'missing' | 'unchanged' | 'update' | 'legacy-copy' | 'customized' | 'forced' }}
1023
1215
  */
1024
1216
  export function planKitFileWrite({ existing, rendered, raw, force }) {
1025
1217
  if (existing === null || existing === undefined) return { write: true, reason: 'missing' };
1026
- if (existing === rendered) return { write: false, reason: 'unchanged' };
1027
- if (kitFileState(existing) === 'managed') return { write: true, reason: 'update' };
1028
- if (existing === raw) return { write: true, reason: 'legacy-copy' };
1218
+ const have = normalizeEol(existing);
1219
+ if (have === normalizeEol(rendered)) return { write: false, reason: 'unchanged' };
1220
+ if (kitFileState(have) === 'managed') return { write: true, reason: 'update' };
1221
+ if (have === normalizeEol(raw)) return { write: true, reason: 'legacy-copy' };
1029
1222
  return force ? { write: true, reason: 'forced' } : { write: false, reason: 'customized' };
1030
1223
  }
1031
1224
 
@@ -127,6 +127,20 @@ export function readLocalDevModeRecord({ homeDir = heyspexHome(), readFile = def
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * How a local override stands against the cloud admin setting:
132
+ * 'not-applicable' — no local override won the resolution;
133
+ * 'unverified' — an override won, but the cloud setting could not be read;
134
+ * 'stale' — the cloud setting was read and disagrees;
135
+ * 'in-sync' — the cloud setting was read (or is known-unset) and agrees.
136
+ * Pure.
137
+ */
138
+ export function overrideCloudCheck(source, cloudKnown, staleAgainstCloud) {
139
+ if (source !== 'local-override') return 'not-applicable';
140
+ if (!cloudKnown) return 'unverified';
141
+ return staleAgainstCloud ? 'stale' : 'in-sync';
142
+ }
143
+
130
144
  /**
131
145
  * Resolve the effective mode. `cloudSetting` is injected by the caller:
132
146
  * { mode, reliance } when the account_settings.dev_mode value is known,
@@ -191,6 +205,11 @@ export function resolveDevMode({
191
205
  source === 'local-override' &&
192
206
  cloudValue !== null &&
193
207
  (cloudValue.mode !== mode || (mode === 'dev' && cloudValue.reliance !== semantics.reliance));
208
+ // `staleAgainstCloud: false` used to mean both "checked, agrees" and "never
209
+ // checked" — the cloud read failed (or went to localhost under a local
210
+ // override) and the override silently read as fresh. `cloudCheck` keeps the
211
+ // two apart: an override the cloud could not confirm is 'unverified'.
212
+ const cloudCheck = overrideCloudCheck(source, cloudKnown, staleAgainstCloud);
194
213
 
195
214
  return {
196
215
  mode,
@@ -211,6 +230,7 @@ export function resolveDevMode({
211
230
  record: local.record,
212
231
  parseError: local.parseError,
213
232
  staleAgainstCloud,
233
+ cloudCheck,
214
234
  },
215
235
  cloudSetting: { known: cloudKnown, value: cloudValue },
216
236
  };
@@ -429,9 +449,15 @@ async function discardResponseBody(res) {
429
449
  }
430
450
  }
431
451
 
432
- /** Best-effort GET of the cloud admin setting via the console API. */
452
+ /**
453
+ * Best-effort GET of the cloud admin setting via the console API. The setting
454
+ * lives in the CLOUD console's account_settings, so the URL defaults to
455
+ * {@link CLOUD_CONSOLE_URL} — callers must NOT pass the resolved console URL,
456
+ * which under a local override is localhost and can never answer for the
457
+ * cloud (the staleness check then silently read "not stale").
458
+ */
433
459
  export async function fetchCloudDevMode({
434
- consoleUrl,
460
+ consoleUrl = CLOUD_CONSOLE_URL,
435
461
  fetchImpl = fetch,
436
462
  homeDir = heyspexHome(),
437
463
  timeoutMs = 4000,
@@ -20,7 +20,9 @@
20
20
  * `scripts/verify-mcp-package.mjs`) because it is in the static import closure
21
21
  * of `vendor/heyspex-mcp/cli.mjs`.
22
22
  */
23
+ import { realpathSync } from "node:fs";
23
24
  import { isAbsolute, resolve, sep } from "node:path";
25
+ import { fileURLToPath } from "node:url";
24
26
 
25
27
  /**
26
28
  * Refuse to build a path under `root` when the root itself is not absolute, or
@@ -132,3 +134,33 @@ export function resolveCodeAndVaultRoots({ vaultRoot, explicit, packageRoot, env
132
134
  }
133
135
  return roots;
134
136
  }
137
+
138
+ /**
139
+ * Is the module at `metaUrl` (its `import.meta.url`) the script node was
140
+ * started with? The naive `import.meta.url === pathToFileURL(process.argv[1]).href`
141
+ * fails through an `npm link` / junction / symlinked `node_modules/heyspex`:
142
+ * node realpaths the module for `import.meta.url` but leaves `argv[1]` as the
143
+ * link path, so the guard came out false and the script silently printed
144
+ * nothing and exited 0 (the satellite status line, [[HS-561M]] for `hs`).
145
+ * Realpath both sides; case-insensitive on Windows, where drive-letter casing
146
+ * differs between a shim-built path and what realpath reports.
147
+ * @param {string} metaUrl
148
+ */
149
+ export function isMainModule(metaUrl) {
150
+ const argv1 = process.argv[1];
151
+ if (!argv1) return false;
152
+ const normalize = (p) => {
153
+ let real;
154
+ try {
155
+ real = realpathSync.native(p);
156
+ } catch {
157
+ real = resolve(p);
158
+ }
159
+ return process.platform === "win32" ? real.toLowerCase() : real;
160
+ };
161
+ try {
162
+ return normalize(argv1) === normalize(fileURLToPath(metaUrl));
163
+ } catch {
164
+ return false;
165
+ }
166
+ }