heyspex 0.2.5 → 0.2.7
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/.claude-plugin/marketplace.json +1 -1
- package/bin/hs.mjs +349 -38
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +40 -3
- package/dist/cli/hs-defer.mjs +3817 -3741
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +228 -37
- package/dist/cli/hs-index-reconcile.mjs +231 -40
- package/dist/cli/hs-init.mjs +22 -3
- package/dist/cli/hs-install.mjs +22 -3
- package/dist/cli/hs-join.mjs +40 -3
- package/dist/cli/hs-next-launch.mjs +2777 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12403 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +233 -42
- package/dist/cli/hs-vault-push.mjs +231 -40
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +9 -2
- package/docs/mcp-client-config.md +6 -0
- package/package.json +266 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +41 -3
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +53 -3
- package/scripts/lib/hs-companion-settings.mjs +26 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/scripts/record-grounding.mjs +38 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
- package/bin/postinstall.mjs +0 -88
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// semantic_enabled: true|false (default true — turns Layer 2 on/off entirely)
|
|
34
34
|
// mode: self_check|model (default self_check — deterministic injection;
|
|
35
35
|
// `model` opts into the Ollama fallback below)
|
|
36
|
-
// endpoint: Ollama base URL (default http://
|
|
36
|
+
// endpoint: Ollama base URL (default http://localhost:11434 — mode:model only; HS-J3NY: never the owner's LAN box)
|
|
37
37
|
// model: model name (default qwen3:8b — mode:model only)
|
|
38
38
|
// timeout_ms: request budget (default 2500 — the hook's overall budget is 5s)
|
|
39
39
|
// min_confidence: fire threshold (default 0.7 — consumed by the calling hook)
|
|
@@ -74,7 +74,7 @@ export const SEMANTIC_MODES = Object.freeze(['self_check', 'model']);
|
|
|
74
74
|
export const DEFAULTS = Object.freeze({
|
|
75
75
|
semanticEnabled: true,
|
|
76
76
|
semanticMode: 'self_check',
|
|
77
|
-
endpoint: 'http://
|
|
77
|
+
endpoint: 'http://localhost:11434',
|
|
78
78
|
model: 'qwen3:8b',
|
|
79
79
|
timeoutMs: 2500,
|
|
80
80
|
minConfidence: 0.7,
|
|
@@ -49,6 +49,7 @@ import fs from 'node:fs';
|
|
|
49
49
|
import path from 'node:path';
|
|
50
50
|
import { fileURLToPath } from 'node:url';
|
|
51
51
|
import { getActiveConfig, isResolvedVault } from '../../vendor/heyspex-mcp/vault-resolve.mjs';
|
|
52
|
+
import { parseSpecId } from '../../vendor/heyspex-mcp/spec-id-lib.mjs';
|
|
52
53
|
import { hookVaultHeaders } from '../lib/hook-vault-header.mjs';
|
|
53
54
|
import { resolveConsoleUrl as resolveConsoleUrlShared } from '../lib/console-url-resolver.mjs';
|
|
54
55
|
import { readCredentialsMcpToken, resolveDevMode } from '../lib/dev-mode.mjs';
|
|
@@ -105,9 +106,23 @@ function ledgerRoots() {
|
|
|
105
106
|
// HS-EPIC-BUD-PROTECT under vault id `vault-test`). Real ids are server-minted
|
|
106
107
|
// file keys; a SessionStart preview must never present one of these as the
|
|
107
108
|
// prior session's hand-off.
|
|
108
|
-
|
|
109
|
+
// Parsed through the sanctioned id parser (spec-id-lib parseSpecId), never a
|
|
110
|
+
// hand-rolled type-first regex ([[HS-MIL-KY1F]]): only legacy type-first EPIC
|
|
111
|
+
// ids whose family token is one of the known fixture families qualify.
|
|
112
|
+
const FIXTURE_EPIC_FAMILIES = {
|
|
113
|
+
FILLER: (slug) => /^\d+$/.test(slug), // HS-EPIC-FILLER-07
|
|
114
|
+
BUD: (slug) => slug !== '', // HS-EPIC-BUD-PROTECT
|
|
115
|
+
CLAIMED: (slug) => slug === 'BUD', // HS-EPIC-CLAIMED-BUD
|
|
116
|
+
TRIGGER: () => true, // HS-EPIC-TRIGGER, HS-EPIC-TRIGGER-X
|
|
117
|
+
};
|
|
109
118
|
function isFixtureLedgerEntry(epicId) {
|
|
110
|
-
|
|
119
|
+
if (typeof epicId !== 'string') return false;
|
|
120
|
+
const parsed = parseSpecId(epicId);
|
|
121
|
+
if (!parsed || parsed.ordering !== 'type-first' || parsed.typeLabel !== 'EPIC') return false;
|
|
122
|
+
const { familyKey, titleSlug } = parsed;
|
|
123
|
+
if (/^OLD\d+$/.test(familyKey)) return titleSlug === ''; // HS-EPIC-OLD34
|
|
124
|
+
const rule = FIXTURE_EPIC_FAMILIES[familyKey];
|
|
125
|
+
return rule ? rule(titleSlug) : false;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
128
|
async function readStdinJson() {
|
|
@@ -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);
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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(),
|
|
341
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
338
342
|
try {
|
|
339
|
-
const res = await fetch(`${base}/api/control/pin`, {
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/scripts/hs-companion.mjs
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* start|stop|status|build tray-host lifecycle → companion/start.ps1 (PowerShell 7),
|
|
6
6
|
* which launches companion/src/HeySpex.Companion.Host directly
|
|
7
7
|
* (the versioned-folder indirection was retired 2026-08-12).
|
|
8
|
+
* From an installed npm package (no companion/ source) they
|
|
9
|
+
* drive the SpexCompanion the Windows setup installed, or offer
|
|
10
|
+
* the install ([[HS-056P-EH9T-EPIC-npx-heyspex-companion-install-front-door]]).
|
|
11
|
+
* install [--quiet|--dry-run] download + SHA-256 + Authenticode check + run the signed
|
|
12
|
+
* SpexCompanionSetup.exe (scripts/lib/companion-install.mjs).
|
|
8
13
|
* settings list|get|set every Companion setting, via the console's
|
|
9
14
|
* GET/PUT /api/companion/settings ([[HS-UDH1]]); plus the
|
|
10
15
|
* short aliases (mute, volume, orb, wake, ...).
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
renderHelp,
|
|
26
31
|
runCompanionSettings,
|
|
27
32
|
} from "./lib/hs-companion-settings.mjs";
|
|
33
|
+
import { runCompanionInstall, runInstalledCompanionHost } from "./lib/companion-install.mjs";
|
|
28
34
|
|
|
29
35
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
36
|
const START_PS1 = path.join(PKG_ROOT, "companion", "start.ps1");
|
|
@@ -35,6 +41,12 @@ function resolvePwsh() {
|
|
|
35
41
|
|
|
36
42
|
function runHost(sub, rest) {
|
|
37
43
|
if (!existsSync(START_PS1)) {
|
|
44
|
+
// No companion/ source beside this script: an installed npm package, not a
|
|
45
|
+
// repo checkout (the tarball never ships companion/). Drive the Companion
|
|
46
|
+
// the Windows setup installed, or offer `npx heyspex companion install`.
|
|
47
|
+
if (!existsSync(path.join(PKG_ROOT, "companion"))) {
|
|
48
|
+
process.exit(runInstalledCompanionHost(sub));
|
|
49
|
+
}
|
|
38
50
|
process.stderr.write(`hs companion: start script not found at ${START_PS1}\n`);
|
|
39
51
|
process.exit(1);
|
|
40
52
|
}
|
|
@@ -55,7 +67,32 @@ function runHost(sub, rest) {
|
|
|
55
67
|
});
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/** A one-line download progress meter, on an interactive stderr only. */
|
|
71
|
+
function progressMeter() {
|
|
72
|
+
if (!process.stderr.isTTY) return null;
|
|
73
|
+
let last = -2;
|
|
74
|
+
return ({ received, total }) => {
|
|
75
|
+
const pct = total ? Math.floor((received / total) * 100) : -1;
|
|
76
|
+
if (pct === last && pct !== -1) return;
|
|
77
|
+
last = pct;
|
|
78
|
+
const mb = (received / 1048576).toFixed(1);
|
|
79
|
+
process.stderr.write(`\r ${pct < 0 ? `${mb} MB` : `${pct}% (${mb} MB)`}${pct === 100 ? "\n" : ""}`);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The async paths below (install, settings) make TLS requests, so they set
|
|
84
|
+
// process.exitCode and return instead of calling process.exit(): on Windows with
|
|
85
|
+
// Node 24, process.exit() while undici's pooled socket is still closing aborts
|
|
86
|
+
// with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)` (exit
|
|
87
|
+
// 0xC0000409/127) after the right output was printed
|
|
88
|
+
// ([[HS-5HM6-EH9T-DIAG-companion-install-dry-run-native]], same root cause as
|
|
89
|
+
// [[HS-94QX-ZZKK-DIAG-cli-entrypoints-exit-with-process-exit]]).
|
|
58
90
|
async function main(argv) {
|
|
91
|
+
if (argv[0] === "install") {
|
|
92
|
+
process.exitCode = await runCompanionInstall(argv.slice(1), { progress: progressMeter() });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
const parsed = parseCompanionArgs(argv);
|
|
60
97
|
|
|
61
98
|
if (parsed.error) {
|
|
@@ -76,13 +113,14 @@ async function main(argv) {
|
|
|
76
113
|
const result = await runCompanionSettings(parsed, { consoleFetch });
|
|
77
114
|
if (result.ok) {
|
|
78
115
|
process.stdout.write(`${result.stdout}\n`);
|
|
79
|
-
process.
|
|
116
|
+
process.exitCode = 0;
|
|
117
|
+
return;
|
|
80
118
|
}
|
|
81
119
|
process.stderr.write(`hs companion: ${result.stderr}\n`);
|
|
82
|
-
process.
|
|
120
|
+
process.exitCode = result.exitCode;
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
main(process.argv.slice(2)).catch((err) => {
|
|
86
124
|
process.stderr.write(`hs companion: ${err.message}\n`);
|
|
87
|
-
process.
|
|
125
|
+
process.exitCode = 1;
|
|
88
126
|
});
|
|
@@ -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
|
|
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 {
|
|
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 (
|
|
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();
|