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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|