heyspex 0.2.4 → 0.2.6

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +281 -30
  3. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  4. package/dev-factory/lib/runner-path.mjs +51 -0
  5. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  6. package/dist/cli/hs-connect.mjs +20 -1
  7. package/dist/cli/hs-defer.mjs +3802 -3744
  8. package/dist/cli/hs-events.mjs +1296 -0
  9. package/dist/cli/hs-export-events.mjs +1252 -0
  10. package/dist/cli/hs-handoff.mjs +2576 -0
  11. package/dist/cli/hs-index-rebuild.mjs +209 -36
  12. package/dist/cli/hs-index-reconcile.mjs +212 -39
  13. package/dist/cli/hs-init.mjs +20 -1
  14. package/dist/cli/hs-install.mjs +20 -1
  15. package/dist/cli/hs-join.mjs +20 -1
  16. package/dist/cli/hs-next-launch.mjs +2698 -0
  17. package/dist/cli/hs-pin.mjs +1336 -0
  18. package/dist/cli/hs-prereq.mjs +1380 -0
  19. package/dist/cli/hs-revalidate.mjs +3257 -0
  20. package/dist/cli/hs-spawn.mjs +12377 -0
  21. package/dist/cli/hs-test.mjs +1046 -0
  22. package/dist/cli/hs-trash.mjs +362 -0
  23. package/dist/cli/hs-trust.mjs +197 -0
  24. package/dist/cli/hs-vault-pull.mjs +214 -41
  25. package/dist/cli/hs-vault-push.mjs +212 -39
  26. package/dist/cli-templates/harness-commands/claude/hs-log.md +1 -1
  27. package/dist/cli-templates/harness-commands/claude/hs-res.md +71 -63
  28. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  29. package/dist/cli-templates/harness-commands/cursor/hs-log.md +1 -1
  30. package/dist/cli-templates/harness-commands/cursor/hs-res.md +66 -58
  31. package/dist/cli-templates/harness-commands/cursor/hs-review.md +18 -18
  32. package/docs/copilot-plugin-install.md +2 -2
  33. package/package.json +9 -5
  34. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  35. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  36. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  37. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  38. package/scripts/hs-companion.mjs +8 -0
  39. package/scripts/install-claude-code-hooks.mjs +104 -3
  40. package/scripts/install-copilot-hooks.mjs +21 -1
  41. package/scripts/install-cursor-hooks.mjs +4 -3
  42. package/scripts/lib/capture-execution-guard.mjs +221 -0
  43. package/scripts/lib/customer-kit.mjs +48 -0
  44. package/scripts/lib/hs-companion-settings.mjs +25 -0
  45. package/scripts/record-grounding.mjs +38 -0
  46. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  47. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  48. package/bin/postinstall.mjs +0 -88
@@ -32,11 +32,14 @@
32
32
  // write the current pin to `.heyspex/statusline-focus-cache.json` on every
33
33
  // change, so the file reflects the live pin within ms of a change.
34
34
  // - PULL: within FOCUS_CACHE_TTL_MS we read that file with no HTTP at all
35
- // (including a negative-cached null when the console is down); only past the
36
- // TTL do we fall back to one GET to reconcile (console restarted, cache
37
- // deleted, first run before any pin change), then rewrite the file.
35
+ // (including a negative-cached null when the console is down); past the TTL
36
+ // we still render the cached value immediately and spawn ONE detached,
37
+ // authenticated background refresh per window (maybeRefreshStatuslineCaches,
38
+ // HS-17A1 / HS-5EPV) that rewrites the file for the next repaint. The line
39
+ // itself never makes a network call.
38
40
  // Because push keeps the value fresh, the TTL is just a reconciliation interval —
39
- // default 60s, overridable via HEYSPEX_STATUSLINE_FOCUS_TTL_MS (0 = always fetch).
41
+ // default 60s, overridable via HEYSPEX_STATUSLINE_FOCUS_TTL_MS (0 = refresh every
42
+ // window, min 5 s).
40
43
  // .heyspex/active-claims.json — claims[*].sessionId / specId / status (still
41
44
  // file-backed pending its own Neon migration, [[HS-TASK-ACLM]]).
42
45
  // .git (via `git branch --show-current`) — current branch
@@ -297,7 +300,8 @@ function formatTelemetry(telemetry, usedPct) {
297
300
  // #28); we already hold the value, so a failed write is harmless — clean up and
298
301
  // move on rather than retry or throw.
299
302
  function writeFocusCache(file, specId) {
300
- if (FOCUS_CACHE_TTL_MS === 0) return;
303
+ // Written even when FOCUS_CACHE_TTL_MS is 0: the cache is the line's only pin
304
+ // source now (TTL 0 just means "always stale -> refresh every window").
301
305
  const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
302
306
  // Carry the console-pushed `telemetry` block forward. This writer owns only the
303
307
  // pin; without the read-back a pull-path refresh (every repaint on a cache miss)
@@ -332,11 +336,14 @@ function writeFocusCache(file, specId) {
332
336
  // Live focus-pin fetch from the console. Short timeout so a slow/absent console
333
337
  // never makes the statusline sluggish. Returns the spec id, or null when the
334
338
  // console is unreachable / has no pin.
335
- async function fetchPinnedSpecId(base) {
339
+ async function fetchPinnedSpecId(base, { headers, timeoutMs = CONSOLE_TIMEOUT_MS } = {}) {
336
340
  const ctrl = new AbortController();
337
- const timer = setTimeout(() => ctrl.abort(), CONSOLE_TIMEOUT_MS);
341
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
338
342
  try {
339
- const res = await fetch(`${base}/api/control/pin`, { signal: ctrl.signal });
343
+ const res = await fetch(`${base}/api/control/pin`, {
344
+ signal: ctrl.signal,
345
+ ...(headers ? { headers } : {}),
346
+ });
340
347
  if (!res.ok) return null;
341
348
  const body = await res.json();
342
349
  const id = body?.pin?.specId;
@@ -353,27 +360,264 @@ async function fetchPinnedSpecId(base) {
353
360
  // running this returns null and the segment is simply dropped. A short-TTL file
354
361
  // cache (see header) collapses the per-message / per-refresh burst of statusline
355
362
  // invocations into at most one backend call per TTL window.
356
- async function readPinnedSpecId(root, opts = {}) {
357
- const base = consoleUrl(root);
358
- if (!base) return null;
363
+ async function readPinnedSpecId(root) {
364
+ // Every host (Claude Code and Copilot) never blocks on the network: serve the
365
+ // last cached pin at ANY age (stale-while-revalidate — build() kicks off the
366
+ // detached, authenticated refresh, maybeRefreshStatuslineCaches below), or drop
367
+ // the segment. Not gated on consoleUrl(): that only knows the env var / local
368
+ // discovery file (empty in prod mode), while the refresh child resolves the
369
+ // console the cloud-aware way. HS-5EPV: the Claude path used to do an inline,
370
+ // UNAUTHENTICATED GET here that prod refuses, so its pin went stale forever.
371
+ return readAnyAgePinnedSpecId(focusCacheFile(root));
372
+ }
373
+
374
+ function readAnyAgePinnedSpecId(cacheFile) {
375
+ try {
376
+ const parsed = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
377
+ return typeof parsed?.specId === 'string' && parsed.specId ? parsed.specId : null;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Background cache refresh, every host — HS-17A1 (Copilot), HS-5EPV (Claude Code).
385
+ //
386
+ // The status line must never wait on the network, and in prod mode only an
387
+ // authenticated read reaches the console (the old Claude-path inline GET was
388
+ // unauthenticated and refused, so its pin froze). Stale-while-revalidate:
389
+ // build() serves whatever is cached now and, when the pin cache (or, for
390
+ // Copilot, the claims snapshot) is missing/past its TTL, spawns ONE detached,
391
+ // unref'd child (`--refresh-caches <root>`) that does the authenticated console
392
+ // reads and rewrites both caches for the NEXT repaint.
393
+ //
394
+ // At most one refresh per window: the window is the focus TTL (min 5 s) and
395
+ // the guard is an exclusive-create lock file named after the window's bucket
396
+ // index (`.heyspex/statusline-refresh-<bucket>.lock`, `wx`), so concurrent
397
+ // repaints race on an atomic create and exactly one wins per bucket. Older
398
+ // bucket files are swept by the winner. HEYSPEX_STATUSLINE_NO_REFRESH=1
399
+ // disables spawning (tests / offline).
400
+ // ---------------------------------------------------------------------------
401
+ const COPILOT_REFRESH_MIN_WINDOW_MS = 5000;
402
+ const COPILOT_REFRESH_TIMEOUT_MS = 8000;
403
+ const REFRESH_LOCK_RE = /^statusline-refresh-\d+\.lock$/;
404
+
405
+ // A claim counts as live for the Copilot repo fallback only if its newest
406
+ // liveness stamp (lastActivityAt / heartbeatAt / claimedAt) is within this
407
+ // window — mirrors the console's DEFAULT_CLAIM_STALE_MS (claim-lease.ts, 15 min).
408
+ const CLAIM_FRESH_MS = (() => {
409
+ const raw = Number(process.env.HEYSPEX_STATUSLINE_CLAIM_FRESH_MS);
410
+ return Number.isFinite(raw) && raw > 0 ? raw : 15 * 60_000;
411
+ })();
412
+
413
+ function claimsCacheFile(root) {
414
+ return path.join(root, '.heyspex', 'statusline-claims-cache.json');
415
+ }
416
+
417
+ /** Console claims snapshot written by the refresh child: `{ at, claims[] }` or null. */
418
+ function readClaimsCache(root) {
419
+ try {
420
+ const parsed = JSON.parse(fs.readFileSync(claimsCacheFile(root), 'utf8'));
421
+ if (!parsed || typeof parsed.at !== 'number' || !Array.isArray(parsed.claims)) return null;
422
+ return parsed;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function copilotRefreshWindowMs() {
429
+ return Math.max(FOCUS_CACHE_TTL_MS, COPILOT_REFRESH_MIN_WINDOW_MS);
430
+ }
359
431
 
360
- const cacheFile = focusCacheFile(root);
361
- const cached = readFocusCache(cacheFile);
362
- if (cached) return cached.specId;
363
- // Non-Claude hosts (Copilot) never block on the network: serve the last pushed
364
- // pin even when past its TTL, or drop the segment.
365
- if (opts.cacheOnly) {
432
+ /**
433
+ * Stale-while-revalidate trigger, every host. Never blocks, never throws.
434
+ * Returns 'disabled' | 'fresh' | 'locked' | 'spawned' | 'error' (for tests).
435
+ * `needClaims` (Copilot) also treats a missing/old claims snapshot as stale; the
436
+ * Claude host renders only session-keyed claims, so only the pin gates it. The
437
+ * child always refreshes both, and the per-window lock is shared across hosts.
438
+ * @param {string} root
439
+ * @param {{ spawnImpl?: typeof spawn, now?: number, env?: Record<string, string|undefined>, needClaims?: boolean }} [deps]
440
+ */
441
+ export function maybeRefreshStatuslineCaches(
442
+ root,
443
+ { spawnImpl = spawn, now = Date.now(), env = process.env, needClaims = true } = {},
444
+ ) {
445
+ try {
446
+ if (env.HEYSPEX_STATUSLINE_NO_REFRESH === '1') return 'disabled';
447
+ const windowMs = copilotRefreshWindowMs();
448
+ const pinFresh = readFocusCache(focusCacheFile(root)) !== undefined;
449
+ const claims = needClaims ? readClaimsCache(root) : null;
450
+ const claimsFresh = !needClaims || (!!claims && now - claims.at < windowMs);
451
+ if (pinFresh && claimsFresh) return 'fresh';
452
+
453
+ const dir = path.join(root, '.heyspex');
454
+ fs.mkdirSync(dir, { recursive: true });
455
+ const lockName = `statusline-refresh-${Math.floor(now / windowMs)}.lock`;
456
+ let fd;
457
+ try {
458
+ fd = fs.openSync(path.join(dir, lockName), 'wx');
459
+ } catch (err) {
460
+ return err?.code === 'EEXIST' ? 'locked' : 'error';
461
+ }
462
+ try {
463
+ fs.writeSync(fd, String(now));
464
+ } finally {
465
+ fs.closeSync(fd);
466
+ }
366
467
  try {
367
- const parsed = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
368
- return typeof parsed?.specId === 'string' && parsed.specId ? parsed.specId : null;
468
+ for (const f of fs.readdirSync(dir)) {
469
+ if (f !== lockName && REFRESH_LOCK_RE.test(f)) fs.rmSync(path.join(dir, f), { force: true });
470
+ }
369
471
  } catch {
370
- return null;
472
+ /* sweep is best-effort */
371
473
  }
474
+ const child = spawnImpl(process.execPath, [fileURLToPath(import.meta.url), '--refresh-caches', root], {
475
+ cwd: root,
476
+ detached: true,
477
+ stdio: 'ignore',
478
+ windowsHide: true,
479
+ });
480
+ child?.unref?.();
481
+ return 'spawned';
482
+ } catch {
483
+ return 'error';
372
484
  }
485
+ }
486
+
487
+ /** Copilot-host name kept for existing callers/tests (HS-17A1). */
488
+ export const maybeRefreshCopilotCaches = maybeRefreshStatuslineCaches;
373
489
 
374
- const specId = await fetchPinnedSpecId(base);
375
- writeFocusCache(cacheFile, specId);
376
- return specId;
490
+ /**
491
+ * The detached child: authenticated console reads (same console-URL resolver,
492
+ * bearer, vault and account headers the MCP uses; cwd is the repo, so the vault
493
+ * header is the repo-bound one), then rewrite the focus cache and the claims
494
+ * snapshot. Heavy imports are dynamic so the foreground line never pays for them.
495
+ * A failed read still stamps the cache so the next window, not the next repaint,
496
+ * retries.
497
+ */
498
+ async function refreshStatuslineCaches(root) {
499
+ if (!root) return;
500
+ let base = null;
501
+ let headers = {};
502
+ try {
503
+ const { resolveConsoleUrl } = await import('../lib/console-url-resolver.mjs');
504
+ base = resolveConsoleUrl({ repoRoot: root, log: () => {} });
505
+ } catch {
506
+ base = consoleUrl(root);
507
+ }
508
+ if (!base) return;
509
+ try {
510
+ const cf = await import('../../vendor/heyspex-mcp/console-fetch.mjs');
511
+ for (const fn of [cf.getAuthHeader, cf.getVaultIdHeader, cf.getAccountIdHeader]) {
512
+ try {
513
+ Object.assign(headers, fn() || {});
514
+ } catch {
515
+ /* missing token / unbound vault / no account: send what we have */
516
+ }
517
+ }
518
+ } catch {
519
+ headers = {};
520
+ }
521
+ try {
522
+ fs.mkdirSync(path.join(root, '.heyspex'), { recursive: true });
523
+ } catch {
524
+ /* ignore */
525
+ }
526
+
527
+ const specId = await fetchPinnedSpecId(base, { headers, timeoutMs: COPILOT_REFRESH_TIMEOUT_MS });
528
+ writeFocusCache(focusCacheFile(root), specId);
529
+
530
+ let claims = [];
531
+ let error;
532
+ const ctrl = new AbortController();
533
+ const timer = setTimeout(() => ctrl.abort(), COPILOT_REFRESH_TIMEOUT_MS);
534
+ try {
535
+ const res = await fetch(`${base}/api/claims`, { headers, signal: ctrl.signal });
536
+ if (res.ok) {
537
+ const body = await res.json();
538
+ claims = (Array.isArray(body?.claims) ? body.claims : [])
539
+ .filter((c) => c && typeof c.specId === 'string')
540
+ .map((c) => ({
541
+ specId: c.specId,
542
+ sessionId: c.sessionId ?? null,
543
+ agent: c.agent ?? null,
544
+ status: c.status ?? null,
545
+ effectiveStatus: c.effectiveStatus ?? null,
546
+ claimedAt: c.claimedAt ?? null,
547
+ lastActivityAt: c.lastActivityAt ?? null,
548
+ heartbeatAt: c.heartbeatAt ?? null,
549
+ staleAt: c.staleAt ?? null,
550
+ expiresAt: c.expiresAt ?? null,
551
+ }));
552
+ } else {
553
+ error = `http ${res.status}`;
554
+ }
555
+ } catch {
556
+ error = 'unreachable';
557
+ } finally {
558
+ clearTimeout(timer);
559
+ }
560
+ const file = claimsCacheFile(root);
561
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
562
+ try {
563
+ fs.writeFileSync(tmp, JSON.stringify({ at: Date.now(), claims, ...(error ? { error } : {}) }));
564
+ fs.renameSync(tmp, file);
565
+ } catch {
566
+ try {
567
+ fs.unlinkSync(tmp);
568
+ } catch {
569
+ /* ignore */
570
+ }
571
+ }
572
+ }
573
+
574
+ // Copilot-only repo fallback for the worked-spec segment, used ONLY when the
575
+ // session-id lookup misses (a Copilot session id never matches a HeySpex claim,
576
+ // which is keyed by the MCP/agent session). Deliberately NOT used on the Claude
577
+ // path — see readActiveClaim's comment for why a cross-session fallback was
578
+ // removed there; Copilot has no transcript signal to replace it, so it gets this
579
+ // narrower rule instead:
580
+ // Candidates: `.heyspex/active-claims.json` (free tier, file-backed) plus the
581
+ // console claims snapshot the refresh child wrote (paid tier; the console
582
+ // scopes it to THIS repo's bound vault). The snapshot is ignored when older
583
+ // than CLAIM_FRESH_MS.
584
+ // A candidate is live only if: not released, effectiveStatus is not 'stale',
585
+ // expiresAt/staleAt (when present) are in the future, and its newest
586
+ // liveness stamp (max of lastActivityAt, heartbeatAt, claimedAt) is within
587
+ // CLAIM_FRESH_MS of now.
588
+ // Winner: the live candidate with the newest liveness stamp.
589
+ export function readMostRecentRepoClaim(root, now = Date.now()) {
590
+ const candidates = [];
591
+ try {
592
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, '.heyspex', 'active-claims.json'), 'utf8'));
593
+ const claims = parsed && typeof parsed === 'object' && parsed.claims ? parsed.claims : {};
594
+ candidates.push(...Object.values(claims));
595
+ } catch {
596
+ /* no file-backed claims */
597
+ }
598
+ const snap = readClaimsCache(root);
599
+ if (snap && now - snap.at < CLAIM_FRESH_MS) candidates.push(...snap.claims);
600
+
601
+ const ts = (v) => {
602
+ const n = typeof v === 'string' ? Date.parse(v) : NaN;
603
+ return Number.isFinite(n) ? n : null;
604
+ };
605
+ let best = null;
606
+ let bestAt = -Infinity;
607
+ for (const c of candidates) {
608
+ if (!c || typeof c.specId !== 'string' || !c.specId) continue;
609
+ if (c.status === 'released' || c.effectiveStatus === 'stale') continue;
610
+ const expires = ts(c.expiresAt);
611
+ const stale = ts(c.staleAt);
612
+ if ((expires !== null && expires <= now) || (stale !== null && stale <= now)) continue;
613
+ const live = Math.max(...[c.lastActivityAt, c.heartbeatAt, c.claimedAt].map(ts).filter((n) => n !== null));
614
+ if (!Number.isFinite(live) || now - live > CLAIM_FRESH_MS) continue;
615
+ if (live > bestAt) {
616
+ best = c;
617
+ bestAt = live;
618
+ }
619
+ }
620
+ return best;
377
621
  }
378
622
 
379
623
  // Session-matched only — deliberately no "most-recently-active claim"
@@ -503,7 +747,11 @@ function relevantSpecFromTranscript(session) {
503
747
  // only (name kept for backward compat with the original "suppress fallback"
504
748
  // meaning — the fallback it originally guarded no longer exists).
505
749
  function pickRelevantSpec(root, session, opts = {}) {
506
- const claim = readActiveClaim(root, session?.session_id);
750
+ // `repoClaimFallback` (Copilot only): on a session-id miss, fall back to the
751
+ // repo's most recent live claim (readMostRecentRepoClaim). Claude passes none.
752
+ const claim =
753
+ readActiveClaim(root, session?.session_id) ??
754
+ (opts.repoClaimFallback ? readMostRecentRepoClaim(root) : null);
507
755
  const claimAt = claim ? Date.parse(claim.lastActivityAt ?? claim.claimedAt ?? '') : NaN;
508
756
  const ref = process.env.HEYSPEX_STATUSLINE_NO_CLAIM === '1' || opts.noTranscript
509
757
  ? null
@@ -996,7 +1244,10 @@ function threadLabel(session) {
996
1244
  // `opts` (all optional; the Claude Code path passes none and is unchanged):
997
1245
  // host: 'copilot' - no Claude glyph, no Haiku thread label, no transcript scan
998
1246
  // (Copilot's transcript_path is a directory, and there is no `claude` CLI), and
999
- // a cache-only focus-pin read so the line never waits on the console.
1247
+ // and a repo-level claim fallback for the worked-spec segment (readMostRecentRepoClaim).
1248
+ // Both hosts read the focus pin cache-only (never wait on the console); it is
1249
+ // kept fresh by a detached background refresh, maybeRefreshStatuslineCaches.
1250
+ // refreshCaches: test seam replacing maybeRefreshStatuslineCaches.
1000
1251
  export async function build(root, session, opts = {}) {
1001
1252
  const foreign = opts.host === 'copilot';
1002
1253
  const segments = [];
@@ -1029,7 +1280,11 @@ export async function build(root, session, opts = {}) {
1029
1280
  const githubCluster = [dir, branch].filter(Boolean).join(' > ');
1030
1281
  if (githubCluster) segments.push(`${mark === GLYPH ? GITHUB_GLYPH : foreign ? '' : '⎇'} ${githubCluster}`.trim());
1031
1282
 
1032
- const pinned = await readPinnedSpecId(root, { cacheOnly: foreign });
1283
+ // Stale-while-revalidate (both hosts) — kick the detached refresh (no-op when
1284
+ // the caches are fresh or a refresh already ran this window), then read the
1285
+ // cache. Copilot also needs the claims snapshot fresh (repo claim fallback).
1286
+ (opts.refreshCaches ?? maybeRefreshStatuslineCaches)(root, { needClaims: foreign });
1287
+ const pinned = await readPinnedSpecId(root);
1033
1288
  if (pinned) segments.push(`${mark} ${pinned}`);
1034
1289
 
1035
1290
  // HeySpex cluster, far right: "<glyph> <pillar> > <fileID> > <summary>"
@@ -1040,7 +1295,7 @@ export async function build(root, session, opts = {}) {
1040
1295
  // recent (pickRelevantSpec, above) — so it updates as the conversation's
1041
1296
  // focus moves, and never shows another session's claim.
1042
1297
  // Summary: what this session is about, in 2-6 words (sticky, pivot-only).
1043
- const worked = workedDoc(root, pickRelevantSpec(root, session, { noTranscript: foreign }));
1298
+ const worked = workedDoc(root, pickRelevantSpec(root, session, { noTranscript: foreign, repoClaimFallback: foreign }));
1044
1299
  const label = foreign ? null : threadLabel(session);
1045
1300
  const heyspexCluster = [worked?.pillar, worked?.display, label].filter(Boolean).join(' > ');
1046
1301
  if (heyspexCluster) segments.push(`${mark} ${heyspexCluster}`);
@@ -1067,6 +1322,11 @@ async function main() {
1067
1322
  if (isDirectRun) {
1068
1323
  if (process.argv[2] === '--refresh-label') {
1069
1324
  refreshLabel(process.argv[3], process.argv[4], process.argv[5]);
1325
+ } else if (process.argv[2] === '--refresh-caches' || process.argv[2] === '--refresh-copilot') {
1326
+ // '--refresh-copilot' is the HS-17A1 spelling, accepted for compatibility.
1327
+ refreshStatuslineCaches(process.argv[3])
1328
+ .catch(() => {})
1329
+ .finally(() => process.exit(0));
1070
1330
  } else {
1071
1331
  main();
1072
1332
  }
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import {
23
23
  makeCompanionConsoleFetch,
24
24
  parseCompanionArgs,
25
+ renderCompanionNotDistributed,
25
26
  renderHelp,
26
27
  runCompanionSettings,
27
28
  } from "./lib/hs-companion-settings.mjs";
@@ -35,6 +36,13 @@ function resolvePwsh() {
35
36
 
36
37
  function runHost(sub, rest) {
37
38
  if (!existsSync(START_PS1)) {
39
+ // No companion/ source beside this script: an installed npm package, not a
40
+ // repo checkout (the tarball never ships companion/). Say so instead of
41
+ // naming a path the customer can do nothing about.
42
+ if (!existsSync(path.join(PKG_ROOT, "companion"))) {
43
+ process.stderr.write(`${renderCompanionNotDistributed(sub)}\n`);
44
+ process.exit(1);
45
+ }
38
46
  process.stderr.write(`hs companion: start script not found at ${START_PS1}\n`);
39
47
  process.exit(1);
40
48
  }
@@ -25,8 +25,18 @@ import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
- const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const PLATFORM_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ // HS_SETUP_TARGET (what `hs setup claude` passes), else this checkout -- or,
30
+ // from an installed `heyspex` package, the customer project found from cwd.
31
+ // Never the package's own folder under node_modules/.
32
+ const REPO_ROOT = resolveSetupTarget({ platformRoot: PLATFORM_ROOT });
29
33
  const SETTINGS_PATH = path.join(REPO_ROOT, '.claude', 'settings.json');
34
+ // CUSTOMER TARGET: a repo other than this checkout gets the customer hook kit
35
+ // (scripts/lib/customer-kit.mjs) through the committed, self-locating launcher
36
+ // `.claude/heyspex/run-hook.mjs` -- the same rows `hs install` / `hs connect`
37
+ // write -- never the platform-only rows, whose shim path exists only here.
38
+ const CUSTOMER = !samePlatformPath(REPO_ROOT, PLATFORM_ROOT);
39
+ const LAUNCHER_PATH = path.join(REPO_ROOT, ...CUSTOMER_LAUNCHERS.claude.rel);
30
40
  const HOOK_TIMEOUT_SECONDS = 5;
31
41
 
32
42
  const CLAIM_STATUS_MARKER = 'scripts/claude-hooks/emit-claim-status.mjs';
@@ -43,7 +53,16 @@ const SESSION_ACTIVITY_MARKER = 'scripts/claude-hooks/log-session-activity.mjs';
43
53
  // scripts/claude-hooks/_run.mjs and docs/claude-code-setup.md.
44
54
  const SHIM = 'scripts/claude-hooks/_run.mjs';
45
55
  // Self-locating, env-independent, fail-open command (Copilot compatibility): scripts/lib/customer-kit.mjs buildBootstrap().
46
- import { PLATFORM_RUNNER_PREFIX } from './lib/customer-kit.mjs';
56
+ import {
57
+ CUSTOMER_LAUNCHERS,
58
+ PLATFORM_RUNNER_PREFIX,
59
+ customerRunnerPrefix,
60
+ mergeCustomerKitHooks,
61
+ ourHookTail,
62
+ planCustomerLauncherWrite,
63
+ resolveSetupTarget,
64
+ samePlatformPath,
65
+ } from './lib/customer-kit.mjs';
47
66
 
48
67
  /** Canonical command string for a hook: `node "$CLAUDE_PROJECT_DIR/_run.mjs" <name> [args]`. */
49
68
  function shimCommand(scriptMarker, args = '') {
@@ -262,11 +281,93 @@ function check() {
262
281
  process.exit(1);
263
282
  }
264
283
 
284
+ // ---------------------------------------------------------------------------
285
+ // Customer target (a repo other than this checkout)
286
+ // ---------------------------------------------------------------------------
287
+
288
+ function readLauncher() {
289
+ try {
290
+ return fs.readFileSync(LAUNCHER_PATH, 'utf8');
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ function customerInstall() {
297
+ const settings = readSettings();
298
+ const merge = mergeCustomerKitHooks(settings, customerRunnerPrefix());
299
+ const plan = planCustomerLauncherWrite(readLauncher(), 'claude');
300
+ if (plan.write) {
301
+ fs.mkdirSync(path.dirname(LAUNCHER_PATH), { recursive: true });
302
+ fs.writeFileSync(LAUNCHER_PATH, plan.text, 'utf8');
303
+ }
304
+ if (merge.changed) writeSettings(settings);
305
+ if (!merge.changed && !plan.write) {
306
+ console.log(`install-claude-code-hooks: already installed (${merge.total} hooks, no changes) → ${SETTINGS_PATH}`);
307
+ return;
308
+ }
309
+ const parts = [];
310
+ if (merge.added.length) parts.push(`added ${merge.added.length}`);
311
+ if (merge.repaired.length) parts.push(`repaired ${merge.repaired.length}`);
312
+ if (merge.removed.length) parts.push(`retired ${merge.removed.length}`);
313
+ if (parts.length) console.log(`install-claude-code-hooks: ${parts.join('; ')} → ${SETTINGS_PATH} (${merge.total} hooks)`);
314
+ if (plan.write) console.log(`install-claude-code-hooks: wrote ${LAUNCHER_PATH} (resolves node_modules/heyspex)`);
315
+ }
316
+
317
+ function customerCheck() {
318
+ const settings = readSettings();
319
+ const merge = mergeCustomerKitHooks(JSON.parse(JSON.stringify(settings)), customerRunnerPrefix());
320
+ const plan = planCustomerLauncherWrite(readLauncher(), 'claude');
321
+ const issues = [
322
+ ...merge.added.map((x) => `missing ${x}`),
323
+ ...merge.repaired.map((x) => `drift ${x}`),
324
+ ...merge.removed.map((x) => `stale ${x}`),
325
+ ...(plan.write ? [`launcher ${CUSTOMER_LAUNCHERS.claude.rel.join('/')} ${plan.reason}`] : []),
326
+ ];
327
+ if (issues.length === 0) {
328
+ console.log('install-claude-code-hooks --check: OK');
329
+ process.exit(0);
330
+ }
331
+ console.error('install-claude-code-hooks --check: drift detected');
332
+ for (const issue of issues) console.error(` - ${issue}`);
333
+ console.error(' Run `npx hs setup claude` to repair.');
334
+ process.exit(1);
335
+ }
336
+
337
+ function customerUninstall() {
338
+ const settings = readSettings();
339
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
340
+ console.log('install-claude-code-hooks: nothing to uninstall (no hooks block).');
341
+ return;
342
+ }
343
+ let removed = 0;
344
+ for (const [event, groups] of Object.entries(settings.hooks)) {
345
+ if (!Array.isArray(groups)) continue;
346
+ const kept = groups
347
+ .map((g) => {
348
+ if (!g || !Array.isArray(g.hooks)) return g;
349
+ const hooks = g.hooks.filter((h) => ourHookTail(h?.command) === null);
350
+ removed += g.hooks.length - hooks.length;
351
+ return { ...g, hooks };
352
+ })
353
+ .filter((g) => !Array.isArray(g?.hooks) || g.hooks.length > 0);
354
+ if (kept.length === 0) delete settings.hooks[event];
355
+ else settings.hooks[event] = kept;
356
+ }
357
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
358
+ writeSettings(settings);
359
+ console.log(`install-claude-code-hooks: uninstalled ${removed} hook entr${removed === 1 ? 'y' : 'ies'} from ${SETTINGS_PATH}.`);
360
+ }
361
+
265
362
  const mode = process.argv.includes('--uninstall')
266
363
  ? 'uninstall'
267
364
  : process.argv.includes('--check')
268
365
  ? 'check'
269
366
  : 'install';
270
- if (mode === 'uninstall') uninstall();
367
+ if (CUSTOMER) {
368
+ if (mode === 'uninstall') customerUninstall();
369
+ else if (mode === 'check') customerCheck();
370
+ else customerInstall();
371
+ } else if (mode === 'uninstall') uninstall();
271
372
  else if (mode === 'check') check();
272
373
  else install();
@@ -86,6 +86,7 @@ function parseArgs(argv) {
86
86
  else if (x === '--check') a.check = true;
87
87
  else if (x === '--uninstall') a.uninstall = true;
88
88
  else if (x === '--print') a.print = true;
89
+ else if (x === '--help' || x === '-h') a.help = true;
89
90
  else if (x === '--json') a.json = true;
90
91
  else if (x === '--target') {
91
92
  a.target = argv[++i] ?? null;
@@ -238,10 +239,25 @@ function manualCommands(spec) {
238
239
  ];
239
240
  }
240
241
 
242
+ /** This package's own version (package.json), or null when unreadable. */
243
+ function packageVersion() {
244
+ try {
245
+ const v = JSON.parse(fsDeps.readFile(path.join(PACKAGE_ROOT, 'package.json')) ?? 'null')?.version;
246
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /** The install doc as printed: its `heyspex-<version>.tgz` placeholder names this package's real version. */
253
+ export function renderInstallDoc(doc, version) {
254
+ return version ? doc.replace(/heyspex-<version>\.tgz/g, `heyspex-${version}.tgz`) : doc;
255
+ }
256
+
241
257
  function doPrint(out) {
242
258
  out(`Next: run \`${nextCommand()}\` (plugin) or \`${nextCommand()} --hooks\` (cloud agent) in this repo, then /restart.`);
243
259
  const doc = fsDeps.readFile(path.join(PACKAGE_ROOT, 'docs', 'copilot-plugin-install.md'));
244
- if (doc !== null) { out(''); out(doc.replace(/\s+$/, '')); return 0; }
260
+ if (doc !== null) { out(''); out(renderInstallDoc(doc, packageVersion()).replace(/\s+$/, '')); return 0; }
245
261
  const spec = resolvePluginSpec(PACKAGE_ROOT);
246
262
  out('');
247
263
  out('# Installing HeySpex in GitHub Copilot CLI (built-in fallback; docs/copilot-plugin-install.md not found)');
@@ -385,6 +401,10 @@ export function main(argv = process.argv.slice(2), io = {}) {
385
401
  err('usage: hs setup copilot [--hooks] [--statusline [--force]] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
386
402
  return 2;
387
403
  }
404
+ if (a.help) {
405
+ out('usage: hs setup copilot [--hooks] [--statusline [--force]] [--check [--json]] [--uninstall] [--print] [--target <dir>]');
406
+ return 0;
407
+ }
388
408
  if (a.print) return doPrint(out);
389
409
  const target = a.target ? path.resolve(a.target) : findRepoRoot(process.env.HS_SETUP_TARGET?.trim() || process.cwd());
390
410
  if (a.check) return doCheck(a, target, out);
@@ -42,13 +42,14 @@ import {
42
42
  hookScope,
43
43
  invokedDirectly,
44
44
  planCustomerLauncherWrite,
45
+ resolveSetupTarget,
45
46
  samePlatformPath,
46
47
  } from './lib/customer-kit.mjs';
47
48
 
48
49
  const PLATFORM_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
49
- const REPO_ROOT = process.env.HS_SETUP_TARGET?.trim()
50
- ? path.resolve(process.env.HS_SETUP_TARGET.trim())
51
- : PLATFORM_ROOT;
50
+ // HS_SETUP_TARGET, else this checkout -- or, from an installed package, the
51
+ // customer project found from cwd (never the package folder itself).
52
+ const REPO_ROOT = resolveSetupTarget({ platformRoot: PLATFORM_ROOT });
52
53
  const SETTINGS_PATH = path.join(REPO_ROOT, '.cursor', 'hooks.json');
53
54
 
54
55
  // CUSTOMER TARGET (`hs setup cursor` / `hs doctor --fix` with HS_SETUP_TARGET