heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-mcp-freshness — cheap ground-truth answer to "is the RUNNING heyspex
4
+ * MCP server (vendor/heyspex-mcp/cli.mjs) the same build as the code on disk?"
5
+ *
6
+ * Why this exists: the MCP server is a long-running stdio process. MCP tool
7
+ * schemas are registered once at process start, so an edit to
8
+ * `vendor/heyspex-mcp/*.mjs` (yours or the overnight factory's) does NOT reach
9
+ * the running server until it restarts. On 2026-07-12 this produced a
10
+ * promotion deadlock — the running server enforced a newly-shipped
11
+ * sufficiency gate but silently stripped the `force_promote` override that
12
+ * shipped with it, because its in-memory tool schema predated the param.
13
+ * See [[HS-G263-DIAG-2F0X-stale-heyspex-mcp-server-enforces-new]] /
14
+ * [[mcp-server-restart-after-vendor-updates]].
15
+ *
16
+ * The fix: a freshness handshake.
17
+ * 1. At server startup, cli.mjs calls `writeBuildRecord()` here, which
18
+ * hashes the CONTENTS of every `vendor/heyspex-mcp/*.mjs` file (sorted by
19
+ * name for a stable digest) and writes the fingerprint + startedAt + pid
20
+ * to `.heyspex/mcp-server-build.json`.
21
+ * 2. Any time later, `checkFreshness()` recomputes the fingerprint from disk
22
+ * and compares it to that recorded build — a mismatch means vendor code
23
+ * changed since the server started, i.e. the running process is stale.
24
+ *
25
+ * Per-process scoping ([[HS-H29P-8XLD-DIAG-mcp-freshness-handshake-is-a-single]]):
26
+ * the single shared record is last-writer-wins, so with several concurrent
27
+ * cli.mjs processes a stale-but-running server could read "fresh" off another
28
+ * process's restart. writeBuildRecord() therefore ALSO writes
29
+ * `.heyspex/mcp-server-builds/<pid>.json` ({ pid, sessionId, startedAt,
30
+ * fingerprint }) and prunes dead-pid records, and `checkFreshness({ pid })`
31
+ * (or `{ sessionId }`, or CLI `--pid <n>`) answers for that one process.
32
+ * Unscoped verdicts carry scope: "machine-wide (unscoped — may be another
33
+ * process)".
34
+ *
35
+ * Three faces share this core (per the "capability = core → MCP + CLI" rule):
36
+ * - Server startup vendor/heyspex-mcp/cli.mjs calls writeBuildRecord()
37
+ * - SessionStart hook scripts/claude-hooks/check-mcp-freshness-hook.mjs
38
+ * - CLI node scripts/check-mcp-freshness.mjs [--json]
39
+ *
40
+ * Run directly: node scripts/check-mcp-freshness.mjs [--json]
41
+ * exit 0 + "fresh" recorded fingerprint matches code on disk
42
+ * exit 0 + "unknown freshness" no build record (server predates this
43
+ * feature, or the file is missing/corrupt) —
44
+ * safe: never crashes, never false-flags stale
45
+ * exit 1 + loud one-line warning recorded fingerprint disagrees with code
46
+ * on disk — the running server is STALE
47
+ */
48
+ import { createHash } from 'node:crypto';
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import { fileURLToPath, pathToFileURL } from 'node:url';
52
+
53
+ function defaultVaultRoot() {
54
+ // Honor the same override the hs CLI / dev-server-status.mjs use; otherwise
55
+ // fall back to this file's own location (it lives at <vaultRoot>/scripts/,
56
+ // so one level up is the root).
57
+ if (process.env.HS_VAULT_ROOT) return process.env.HS_VAULT_ROOT;
58
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
59
+ }
60
+
61
+ function defaultVendorDir(vaultRoot) {
62
+ return path.join(vaultRoot, 'vendor', 'heyspex-mcp');
63
+ }
64
+
65
+ function defaultBuildFile(vaultRoot) {
66
+ return path.join(vaultRoot, '.heyspex', 'mcp-server-build.json');
67
+ }
68
+
69
+ function defaultBuildsDir(vaultRoot) {
70
+ return path.join(vaultRoot, '.heyspex', 'mcp-server-builds');
71
+ }
72
+
73
+ /**
74
+ * Best-effort session id from the environment. Deliberately dependency-free
75
+ * (no vendor/ import): the MCP server's own identity detection lives in
76
+ * vendor/heyspex-mcp, but this module must stay importable standalone.
77
+ */
78
+ function detectSessionIdFromEnv(env = process.env) {
79
+ return (
80
+ env.CLAUDE_CODE_SESSION_ID ||
81
+ env.CLAUDE_SESSION_ID ||
82
+ env.HS_SESSION_ID ||
83
+ null
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Is `pid` a live process? `process.kill(pid, 0)` sends no signal, only
89
+ * checks existence. EPERM means "exists but not ours" — that is alive.
90
+ */
91
+ export function pidAlive(pid) {
92
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
93
+ try {
94
+ process.kill(pid, 0);
95
+ return true;
96
+ } catch (err) {
97
+ return err && err.code === 'EPERM';
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Opportunistic hygiene: delete per-process records in `buildsDir` whose pid
103
+ * is no longer alive. `keepPid` (the record about to be written) is never
104
+ * pruned. Never throws.
105
+ */
106
+ function pruneDeadRecords(buildsDir, keepPid) {
107
+ let entries;
108
+ try {
109
+ entries = fs.readdirSync(buildsDir, { withFileTypes: true });
110
+ } catch {
111
+ return; // directory missing — nothing to prune
112
+ }
113
+ for (const e of entries) {
114
+ if (!e.isFile() || !/^\d+\.json$/.test(e.name)) continue;
115
+ const recordPid = Number(e.name.slice(0, -'.json'.length));
116
+ if (recordPid === keepPid) continue;
117
+ if (pidAlive(recordPid)) continue;
118
+ try {
119
+ fs.rmSync(path.join(buildsDir, e.name), { force: true });
120
+ } catch {
121
+ // best-effort — a locked/vanished file must not break startup
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * sha256 over the sorted (name, content) pairs of every `*.mjs` file directly
128
+ * under `vendorDir` (no recursion — matches the vendor/heyspex-mcp/*.mjs glob
129
+ * this class of bug is scoped to). Contents, not mtimes, so a checkout/rebase
130
+ * that preserves bytes but changes mtimes never false-flags stale, and a
131
+ * whitespace-only edit correctly does flag stale.
132
+ */
133
+ export function computeFingerprint(vendorDir) {
134
+ let entries;
135
+ try {
136
+ entries = fs.readdirSync(vendorDir, { withFileTypes: true });
137
+ } catch {
138
+ return { fingerprint: null, fileCount: 0, files: [] };
139
+ }
140
+ const files = entries
141
+ .filter((e) => e.isFile() && e.name.endsWith('.mjs'))
142
+ .map((e) => e.name)
143
+ .sort();
144
+ const hash = createHash('sha256');
145
+ for (const name of files) {
146
+ let content;
147
+ try {
148
+ content = fs.readFileSync(path.join(vendorDir, name));
149
+ } catch {
150
+ content = Buffer.from(''); // vanished mid-scan — hash the miss, don't crash
151
+ }
152
+ hash.update(name);
153
+ hash.update('\0');
154
+ hash.update(content);
155
+ hash.update('\0');
156
+ }
157
+ return { fingerprint: hash.digest('hex'), fileCount: files.length, files };
158
+ }
159
+
160
+ /**
161
+ * Called by vendor/heyspex-mcp/cli.mjs at server startup. Writes the current
162
+ * fingerprint + startedAt + pid to .heyspex/mcp-server-build.json (atomic
163
+ * write via tmp+rename, mirroring writeConsoleUrlFile in dev-server-status.mjs)
164
+ * AND a per-process record at .heyspex/mcp-server-builds/<pid>.json so
165
+ * freshness can be answered for THIS process instead of last-writer-wins
166
+ * ([[HS-H29P-8XLD-DIAG-mcp-freshness-handshake-is-a-single]]). Dead-pid
167
+ * records in that directory are pruned opportunistically on each write.
168
+ * Never throws — startup must not fail because this bookkeeping failed.
169
+ */
170
+ export function writeBuildRecord({ vaultRoot, vendorDir, buildFile, buildsDir, pid, startedAt, sessionId } = {}) {
171
+ const root = vaultRoot || defaultVaultRoot();
172
+ const vDir = vendorDir || defaultVendorDir(root);
173
+ const target = buildFile || defaultBuildFile(root);
174
+ const perDir = buildsDir || defaultBuildsDir(root);
175
+ const { fingerprint, fileCount } = computeFingerprint(vDir);
176
+ const recordPid = typeof pid === 'number' ? pid : process.pid;
177
+ const record = {
178
+ fingerprint,
179
+ fileCount,
180
+ startedAt: startedAt || new Date().toISOString(),
181
+ pid: recordPid,
182
+ vendorDir: path.relative(root, vDir).split(path.sep).join('/'),
183
+ };
184
+ // Legacy machine-wide record — kept for back-compat readers.
185
+ const dir = path.dirname(target);
186
+ fs.mkdirSync(dir, { recursive: true });
187
+ const tmp = `${target}.${process.pid}.tmp`;
188
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
189
+ fs.renameSync(tmp, target);
190
+
191
+ // Per-process record + opportunistic dead-pid pruning. Best-effort: a
192
+ // failure here must never take server startup down with it.
193
+ try {
194
+ fs.mkdirSync(perDir, { recursive: true });
195
+ pruneDeadRecords(perDir, recordPid);
196
+ const perRecord = {
197
+ pid: recordPid,
198
+ sessionId: sessionId !== undefined ? sessionId : detectSessionIdFromEnv(),
199
+ startedAt: record.startedAt,
200
+ fingerprint,
201
+ fileCount,
202
+ vendorDir: record.vendorDir,
203
+ };
204
+ const perTarget = path.join(perDir, `${recordPid}.json`);
205
+ const perTmp = `${perTarget}.${process.pid}.tmp`;
206
+ fs.writeFileSync(perTmp, `${JSON.stringify(perRecord, null, 2)}\n`, 'utf8');
207
+ fs.renameSync(perTmp, perTarget);
208
+ record.perProcessRecord = perRecord;
209
+ } catch {
210
+ // legacy record already landed — per-process bookkeeping is best-effort
211
+ }
212
+ return record;
213
+ }
214
+
215
+ /** Verdict label for legacy unscoped checks — the single shared file answers
216
+ * for the whole machine, not necessarily the caller's own server process. */
217
+ export const UNSCOPED_SCOPE_LABEL = 'machine-wide (unscoped — may be another process)';
218
+
219
+ /** Read + JSON-parse a per-process record file; null on any failure. */
220
+ function readRecordFile(file) {
221
+ try {
222
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
223
+ return parsed && typeof parsed === 'object' ? parsed : null;
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /** Find the per-process record for a sessionId (latest startedAt wins). */
230
+ function findRecordBySession(buildsDir, sessionId) {
231
+ let entries;
232
+ try {
233
+ entries = fs.readdirSync(buildsDir, { withFileTypes: true });
234
+ } catch {
235
+ return null;
236
+ }
237
+ let best = null;
238
+ for (const e of entries) {
239
+ if (!e.isFile() || !/^\d+\.json$/.test(e.name)) continue;
240
+ const rec = readRecordFile(path.join(buildsDir, e.name));
241
+ if (!rec || rec.sessionId !== sessionId) continue;
242
+ if (!best || String(rec.startedAt || '') > String(best.startedAt || '')) best = rec;
243
+ }
244
+ return best;
245
+ }
246
+
247
+ /**
248
+ * Ground-truth freshness verdict: recompute the fingerprint from disk and
249
+ * compare to the recorded build. Never throws.
250
+ *
251
+ * Optional scoping (`{ pid }` or `{ sessionId }`) answers for ONE server
252
+ * process via its `.heyspex/mcp-server-builds/<pid>.json` record: the pid
253
+ * must be alive, and ITS fingerprint is compared against current code — so a
254
+ * live-but-stale process reads stale even when another process restarted
255
+ * fresh more recently. Unscoped calls keep legacy single-file behavior but
256
+ * the verdict carries `scope: UNSCOPED_SCOPE_LABEL`.
257
+ *
258
+ * Returns { status: 'fresh' | 'stale' | 'unknown', message, scope, current, recorded }.
259
+ */
260
+ export function checkFreshness({ vaultRoot, vendorDir, buildFile, buildsDir, pid, sessionId } = {}) {
261
+ const root = vaultRoot || defaultVaultRoot();
262
+ const vDir = vendorDir || defaultVendorDir(root);
263
+ const current = computeFingerprint(vDir);
264
+
265
+ // ---- Scoped path: answer for one specific server process ----------------
266
+ if (pid !== undefined || sessionId !== undefined) {
267
+ const perDir = buildsDir || defaultBuildsDir(root);
268
+ const scope = pid !== undefined ? `process ${pid}` : `session ${sessionId}`;
269
+ const recorded =
270
+ pid !== undefined
271
+ ? readRecordFile(path.join(perDir, `${pid}.json`))
272
+ : findRecordBySession(perDir, sessionId);
273
+
274
+ if (!recorded || typeof recorded.fingerprint !== 'string') {
275
+ return {
276
+ status: 'unknown',
277
+ message:
278
+ `heyspex MCP server freshness (${scope}): unknown — no per-process build ` +
279
+ 'record in .heyspex/mcp-server-builds/ (that server predates the per-process ' +
280
+ 'handshake, or never started). Restart it to establish one.',
281
+ scope,
282
+ current,
283
+ recorded: null,
284
+ };
285
+ }
286
+
287
+ if (!pidAlive(recorded.pid)) {
288
+ return {
289
+ status: 'unknown',
290
+ message:
291
+ `heyspex MCP server freshness (${scope}): unknown — recorded pid ${recorded.pid} ` +
292
+ 'is no longer alive; the record is an orphan, not a running server.',
293
+ scope,
294
+ current,
295
+ recorded,
296
+ };
297
+ }
298
+
299
+ if (recorded.fingerprint === current.fingerprint) {
300
+ return {
301
+ status: 'fresh',
302
+ message: `heyspex MCP server (${scope}) is fresh.`,
303
+ scope,
304
+ current,
305
+ recorded,
306
+ };
307
+ }
308
+
309
+ const startedAt = recorded.startedAt || 'unknown time';
310
+ return {
311
+ status: 'stale',
312
+ message:
313
+ `heyspex MCP server (${scope}) is STALE — restart it: pid ${recorded.pid} started ` +
314
+ `${startedAt}, code changed since (another process restarting fresh does not make it fresh).`,
315
+ scope,
316
+ current,
317
+ recorded,
318
+ };
319
+ }
320
+
321
+ // ---- Unscoped legacy path: last-writer-wins machine-wide record ---------
322
+ const target = buildFile || defaultBuildFile(root);
323
+ let recorded = null;
324
+ try {
325
+ recorded = JSON.parse(fs.readFileSync(target, 'utf8'));
326
+ } catch {
327
+ recorded = null; // missing / unreadable / malformed — treated as "no record"
328
+ }
329
+
330
+ const scope = UNSCOPED_SCOPE_LABEL;
331
+
332
+ if (!recorded || typeof recorded.fingerprint !== 'string') {
333
+ return {
334
+ status: 'unknown',
335
+ message:
336
+ 'heyspex MCP server freshness: unknown freshness (no build record at ' +
337
+ '.heyspex/mcp-server-build.json — the running server predates the freshness ' +
338
+ 'handshake, or the file is missing/corrupt). Restart the server to establish one.',
339
+ scope,
340
+ current,
341
+ recorded: null,
342
+ };
343
+ }
344
+
345
+ if (recorded.fingerprint === current.fingerprint) {
346
+ return { status: 'fresh', message: 'heyspex MCP server is fresh.', scope, current, recorded };
347
+ }
348
+
349
+ const startedAt = recorded.startedAt || 'unknown time';
350
+ return {
351
+ status: 'stale',
352
+ message: `heyspex MCP server is STALE — restart it: started ${startedAt}, code changed since.`,
353
+ scope,
354
+ current,
355
+ recorded,
356
+ };
357
+ }
358
+
359
+ // ---- CLI entrypoint --------------------------------------------------------
360
+
361
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
362
+ if (invokedDirectly) {
363
+ const argv = process.argv.slice(2);
364
+ const asJson = argv.includes('--json');
365
+ // Optional scoping: --pid=<n> / --pid <n> answers for that server process
366
+ // via its per-process record; default stays the legacy unscoped check.
367
+ let pid;
368
+ for (let i = 0; i < argv.length; i += 1) {
369
+ if (argv[i] === '--pid' && argv[i + 1] !== undefined) pid = Number(argv[i + 1]);
370
+ else if (argv[i].startsWith('--pid=')) pid = Number(argv[i].slice('--pid='.length));
371
+ }
372
+ const result = checkFreshness(Number.isInteger(pid) && pid > 0 ? { pid } : {});
373
+ if (asJson) {
374
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
375
+ } else {
376
+ process.stdout.write(`${result.message}\n`);
377
+ }
378
+ process.exit(result.status === 'stale' ? 1 : 0);
379
+ }