heyspex 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/bin/hs-vault-binding.mjs +157 -0
- package/bin/hs.mjs +99 -81
- package/bin/spexcode.mjs +91 -0
- package/dev-factory/engine/worktree.mjs +36 -7
- package/dev-factory/lib/epic-detect.mjs +2 -2
- package/dev-factory/lib/fleet-readiness.mjs +3 -5
- package/dev-factory/lib/fleet-state.mjs +2 -2
- package/dev-factory/lib/sufficiency-score.mjs +3 -5
- package/dist/cli/hs-check.mjs +76 -46
- package/dist/cli/hs-claim.mjs +63 -33
- package/dist/cli/hs-connect.mjs +520 -214
- package/dist/cli/hs-defer.mjs +161 -19
- package/dist/cli/hs-dev.mjs +11 -5
- package/dist/cli/hs-env.mjs +187 -161
- package/dist/cli/hs-index-rebuild.mjs +123 -78
- package/dist/cli/hs-index-reconcile.mjs +203 -107
- package/dist/cli/hs-init.mjs +426 -179
- package/dist/cli/hs-install.mjs +759 -437
- package/dist/cli/hs-join.mjs +590 -383
- package/dist/cli/hs-vault-pull.mjs +359 -254
- package/dist/cli/hs-vault-push.mjs +151 -113
- package/dist/cli/hs-vault.mjs +271 -104
- package/dist/cli/hs-workspace.mjs +90 -57
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +178 -91
- package/docs/copilot-plugin-install.md +189 -162
- package/docs/driving-heyspex-from-copilot.md +174 -165
- package/docs/mcp-client-config.md +93 -25
- package/package.json +255 -249
- package/plugins/heyspex/.mcp.json +3 -4
- package/plugins/heyspex/README.md +323 -309
- package/plugins/heyspex/bin/launch-mcp.mjs +21 -7
- package/plugins/heyspex/mcp.json +12 -13
- package/scripts/check-mcp-freshness.mjs +6 -4
- package/scripts/claude-hooks/_intent-classifier-router.mjs +518 -456
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +305 -279
- package/scripts/claude-hooks/research-method-guard.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +33 -9
- package/scripts/claude-statusline/fonts/HeySpexIcons.ttf +0 -0
- package/scripts/claude-statusline/statusline-command.mjs +1058 -0
- package/scripts/dev-server-status.mjs +3 -2
- package/scripts/dev-server-stop.mjs +3 -2
- package/scripts/hs-capture-cli.mjs +2 -2
- package/scripts/hs-settings.mjs +2 -2
- package/scripts/hs-view.mjs +3 -2
- package/scripts/install-copilot-hooks.mjs +318 -0
- package/scripts/lib/copilot-cli.mjs +108 -0
- package/scripts/lib/customer-kit.mjs +223 -30
- package/scripts/lib/dev-mode.mjs +28 -2
- package/scripts/lib/shared-path-helpers.mjs +32 -0
- package/scripts/lib/standalone-doctor.mjs +131 -32
- package/scripts/wip-ledger-health.mjs +3 -2
- package/spexharness/src/gates/vault-mirror-catchup.mjs +113 -24
- package/spexharness/src/gates/vault-mirror-catchup.runner.mjs +52 -0
- package/spexharness/src/intent-directive.mjs +839 -626
- package/spexharness/src/rule-table.mjs +1 -1
- package/vendor/heyspex-mcp/cli-verbs.mjs +16 -2
- package/vendor/heyspex-mcp/cli.mjs +20 -4
- package/vendor/heyspex-mcp/console-fetch.mjs +15 -3
- package/vendor/heyspex-mcp/dev-tools.mjs +11 -4
- package/vendor/heyspex-mcp/events-tools.mjs +1 -1
- package/vendor/heyspex-mcp/factory-run-status.mjs +42 -0
- package/vendor/heyspex-mcp/mcp-config.mjs +4 -2
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +26 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +5 -2
- package/vendor/heyspex-mcp/tool-catalog.mjs +12 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +59 -23
- package/vendor/heyspex-mcp/usable-dir-env.mjs +82 -0
- package/vendor/heyspex-mcp/vault-binding.mjs +839 -0
- 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]
|
|
461
|
-
" '(run \"npm install heyspex\" in this repo, or set HEYSPEX_PLATFORM_ROOT).
|
|
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
|
|
469
|
-
'//
|
|
470
|
-
'
|
|
471
|
-
'
|
|
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(
|
|
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(
|
|
490
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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
|
|
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(
|
|
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
|
|
1197
|
+
const text = normalizeEol(String(content));
|
|
1198
|
+
const m = text.match(MARKER_RE);
|
|
1013
1199
|
if (!m) return 'unmanaged';
|
|
1014
|
-
const body =
|
|
1015
|
-
|
|
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
|
-
|
|
1027
|
-
if (
|
|
1028
|
-
if (
|
|
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
|
|
package/scripts/lib/dev-mode.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|