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,722 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-server-status — ground-truth answer to "is the spex-console dev server
4
+ * running, and on what port(s)?"
5
+ *
6
+ * Why this exists: the only prior signal was `.heyspex/dev-server.lock.json`,
7
+ * and that file can LIE — a stale lock (the launcher died uncleanly), an
8
+ * untracked server (someone ran bare `next dev` with no wrapper, so no lock),
9
+ * a port conflict, or two servers at once. An agent that trusts the file can
10
+ * start a database migration "while the server is down" when it is actually
11
+ * up — which strands the running server's in-memory schema and forces a
12
+ * restart. (That incident is what this capability prevents.)
13
+ *
14
+ * The fix: derive truth from the OS, never from the lockfile alone.
15
+ * 1. HTTP-probe candidate ports — liveness is the authoritative "is it up?".
16
+ * 2. OS-scan the same ports (Get-NetTCPConnection on Windows, lsof/ss on
17
+ * Unix — the read-only half of the kill-ports skill) for pid/name.
18
+ * 3. Read the lockfile + .heyspex/console-url ONLY as hints to attribute a
19
+ * pid/url and to detect disagreement.
20
+ * 4. Emit explicit discrepancy flags (stale_lock, untracked_server,
21
+ * multiple_servers, port_mismatch) so every way the file could be wrong
22
+ * is surfaced, not hidden.
23
+ *
24
+ * The `running` verdict comes from the HTTP probe, so the answer is grounded
25
+ * in what is actually listening — it cannot be a false positive off a stale
26
+ * file, nor a false negative when an untracked server is up.
27
+ *
28
+ * Three faces share this core (per the "capability = core → MCP + CLI" rule):
29
+ * - MCP tool heyspex_dev_server_status (vendor/heyspex-mcp/cli.mjs)
30
+ * - hs CLI hs server status (bin/hs.mjs → runs this file)
31
+ * - PreToolUse hook reminder (scripts/claude-hooks/…)
32
+ *
33
+ * Run directly: node scripts/dev-server-status.mjs [--json]
34
+ * Import: import { getDevServerStatus } from './dev-server-status.mjs'
35
+ */
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import http from 'node:http';
39
+ import { spawnSync } from 'node:child_process';
40
+ import { fileURLToPath, pathToFileURL } from 'node:url';
41
+
42
+ // `/api/version` is the spex-console liveness endpoint (cheap JSON, no page
43
+ // render). Probing `/` false-negatives during Next dev JIT compiles — the full
44
+ // app shell can exceed 2s while API routes the UI already uses keep returning
45
+ // 200. See HS-EPIC-OBSV.
46
+ const HTTP_PROBE_TIMEOUT_MS = 4000;
47
+ const PROBE_PATH = '/api/version';
48
+ // HS-09JU: a single 4s probe can miss a live-but-cold-compiling server (Next
49
+ // dev JIT-compiling the /api/version route) and false-flag it dead
50
+ // (running:false, stale_lock) — the exact false positive that made agents
51
+ // start a duplicate server against an already-live one. One retry after a
52
+ // backoff is enough to ride out a cold compile without materially slowing
53
+ // down the common "genuinely nothing there" case (connection refused/reset
54
+ // returns near-instantly, so the retry+delay path is only paid when a real
55
+ // TCP timeout occurred on the first attempt).
56
+ const PROBE_RETRY_ATTEMPTS = 2;
57
+ const PROBE_RETRY_DELAY_MS = 1500;
58
+ // HS-D4O9-DIAG: API routes answering is NOT proof the console is usable — a
59
+ // missing dependency (the incident: @clerk/nextjs declared but not installed)
60
+ // left every PAGE route throwing "Cannot find module" while API handlers kept
61
+ // returning 200, so the API-only probe reported a dead UI as healthy. A SECOND
62
+ // probe against a real page route catches this — the broken layout.tsx sat
63
+ // ABOVE every route, so any page route would have caught the incident. /sign-in
64
+ // specifically because it's the route the HS-D4O9-DIAG fix was verified against
65
+ // (confirmed 200 post-fix; the diagnosis's own AC4 recommendation names it) —
66
+ // prefer a route that renders REAL content over one that may 404 (a 404 still
67
+ // proves the framework compiled/routed, but a 200 is stronger positive
68
+ // evidence). `ok` below still treats any non-5xx as healthy, so an environment
69
+ // where /sign-in genuinely doesn't exist degrades to "route not found", never
70
+ // a false failure. Longer timeout than the API probe — a page render can be
71
+ // slower than a JSON handler, especially on the first post-restart compile.
72
+ const PAGE_PROBE_PATH = '/sign-in';
73
+ const PAGE_PROBE_TIMEOUT_MS = 6000;
74
+ const OS_SCAN_TIMEOUT_MS = 2500;
75
+ // Default candidate ports: Next dev defaults plus a few it bumps to when 3000
76
+ // is taken (3000 → 3001 → … ). Kept small on purpose — a wide range would slow
77
+ // the probe and is rarely needed; the lockfile / console-url ports are added on
78
+ // top so an auto-bumped server is still found.
79
+ const DEFAULT_PORTS = [3000, 3001, 3002, 3003, 3004, 3005];
80
+
81
+ function defaultVaultRoot() {
82
+ // Honor the same override the hs CLI uses; otherwise fall back to this file's
83
+ // own location (it lives at <vaultRoot>/scripts/, so one level up is the root).
84
+ if (process.env.HS_VAULT_ROOT) return process.env.HS_VAULT_ROOT;
85
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
86
+ }
87
+
88
+ function readLockfile(vaultRoot) {
89
+ const file = path.join(vaultRoot, '.heyspex', 'dev-server.lock.json');
90
+ try {
91
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
92
+ if (parsed && typeof parsed === 'object') {
93
+ return {
94
+ present: true,
95
+ pid: typeof parsed.pid === 'number' ? parsed.pid : null,
96
+ url: typeof parsed.url === 'string' ? parsed.url : null,
97
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : null,
98
+ // Bind host, recorded by ensure-next-dev.mjs. `host` is the interface
99
+ // passed to `next dev -H`; `lan` says whether that interface is
100
+ // reachable from off this machine. null on a lock written by an older
101
+ // launcher — UNKNOWN, which readers must not read as "loopback".
102
+ host: typeof parsed.host === 'string' ? parsed.host : null,
103
+ lan: typeof parsed.lan === 'boolean' ? parsed.lan : null,
104
+ };
105
+ }
106
+ } catch {
107
+ // Missing or malformed — no lock.
108
+ }
109
+ return { present: false, pid: null, url: null, startedAt: null, host: null, lan: null };
110
+ }
111
+
112
+ function readConsoleUrlFile(vaultRoot) {
113
+ for (const name of ['console-url', 'cockpit-url']) { // legacy-cockpit fallback
114
+ try {
115
+ const raw = fs.readFileSync(path.join(vaultRoot, '.heyspex', name), 'utf8').trim();
116
+ if (raw.length > 0) return raw.replace(/\/+$/, '');
117
+ } catch {
118
+ // try next candidate
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Atomically publish the live console origin for MCP + hooks ([[HS-EPIC-PT8A]]).
126
+ * Called by ensure-next-dev on bind and by the MCP transport recovery path when
127
+ * a port scan finds the console on a port the stale discovery file missed.
128
+ */
129
+ export function writeConsoleUrlFile(vaultRoot, url) {
130
+ const root = vaultRoot || defaultVaultRoot();
131
+ const dir = path.join(root, '.heyspex');
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ const file = path.join(dir, 'console-url');
134
+ const tmp = `${file}.${process.pid}.tmp`;
135
+ const normalized = String(url).trim().replace(/\/+$/, '');
136
+ fs.writeFileSync(tmp, `${normalized}\n`, 'utf8');
137
+ fs.renameSync(tmp, file);
138
+ try {
139
+ fs.writeFileSync(path.join(dir, 'cockpit-url'), `${normalized}\n`, 'utf8'); // legacy-cockpit shadow
140
+ } catch {
141
+ // Best-effort — canonical console-url above is authoritative.
142
+ }
143
+ return normalized;
144
+ }
145
+
146
+ // Signal-0 liveness probe — mirrors pidAlive() in ensure-next-dev.mjs. EPERM
147
+ // means the process exists but is owned by another user, so it is still alive.
148
+ function pidAlive(pid) {
149
+ if (!pid || typeof pid !== 'number') return false;
150
+ try {
151
+ process.kill(pid, 0);
152
+ return true;
153
+ } catch (err) {
154
+ return err.code === 'EPERM';
155
+ }
156
+ }
157
+
158
+ function portFromUrl(url) {
159
+ if (typeof url !== 'string') return null;
160
+ try {
161
+ const u = new URL(url);
162
+ if (u.port) return Number(u.port);
163
+ return u.protocol === 'https:' ? 443 : 80;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function candidatePorts(vaultRoot, override) {
170
+ if (Array.isArray(override) && override.length > 0) {
171
+ return [...new Set(override.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
172
+ }
173
+ const lock = readLockfile(vaultRoot);
174
+ const fromLock = portFromUrl(lock.url);
175
+ const fromConsole = portFromUrl(readConsoleUrlFile(vaultRoot));
176
+ const ports = [...DEFAULT_PORTS];
177
+ if (fromLock) ports.push(fromLock);
178
+ if (fromConsole) ports.push(fromConsole);
179
+ return [...new Set(ports)];
180
+ }
181
+
182
+ // ---- OS-level listener scan (pid attribution; best-effort) -----------------
183
+
184
+ function scanListenersWindows(ports) {
185
+ // Read-only twin of kill-ports.ps1's Get-PortListeners. PowerShell 5.1's
186
+ // ConvertTo-Json emits a bare object for a single row, so we force an array
187
+ // and parse defensively below.
188
+ const portList = ports.join(',');
189
+ const script =
190
+ `$ports=@(${portList});` +
191
+ `$rows=@(Get-NetTCPConnection -State Listen -LocalPort $ports -ErrorAction SilentlyContinue|` +
192
+ `ForEach-Object{$p=Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue;` +
193
+ `[pscustomobject]@{port=[int]$_.LocalPort;pid=[int]$_.OwningProcess;name=if($p){$p.ProcessName}else{''}}});` +
194
+ `ConvertTo-Json -InputObject $rows -Compress`;
195
+ const res = spawnSync(
196
+ 'powershell',
197
+ ['-NoProfile', '-NonInteractive', '-Command', script],
198
+ { encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS },
199
+ );
200
+ if (res.status !== 0 || !res.stdout) return [];
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(res.stdout.trim());
204
+ } catch {
205
+ return [];
206
+ }
207
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
208
+ return rows
209
+ .filter((r) => r && Number.isInteger(r.port))
210
+ .map((r) => ({ port: r.port, pid: Number.isInteger(r.pid) ? r.pid : null, name: r.name || '' }));
211
+ }
212
+
213
+ function scanListenersUnix(ports) {
214
+ const rows = [];
215
+ const haveLsof = spawnSync('sh', ['-c', 'command -v lsof'], { encoding: 'utf8' }).status === 0;
216
+ for (const port of ports) {
217
+ let pids = [];
218
+ if (haveLsof) {
219
+ const res = spawnSync('lsof', ['-t', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], {
220
+ encoding: 'utf8',
221
+ timeout: OS_SCAN_TIMEOUT_MS,
222
+ });
223
+ if (res.stdout) pids = res.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean);
224
+ } else {
225
+ const res = spawnSync('ss', ['-ltnp', `sport = :${port}`], {
226
+ encoding: 'utf8',
227
+ timeout: OS_SCAN_TIMEOUT_MS,
228
+ });
229
+ if (res.stdout) {
230
+ for (const m of res.stdout.matchAll(/pid=(\d+)/g)) pids.push(Number(m[1]));
231
+ }
232
+ }
233
+ for (const pid of [...new Set(pids)]) {
234
+ let name = '';
235
+ const ps = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' });
236
+ if (ps.stdout) name = ps.stdout.trim();
237
+ rows.push({ port, pid, name });
238
+ }
239
+ }
240
+ return rows;
241
+ }
242
+
243
+ function scanListeners(ports) {
244
+ try {
245
+ return process.platform === 'win32' ? scanListenersWindows(ports) : scanListenersUnix(ports);
246
+ } catch {
247
+ // OS scan is best-effort — HTTP probe still establishes liveness without it.
248
+ return [];
249
+ }
250
+ }
251
+
252
+ // A spex-console dev server (however it was started — wrapper, bare `next
253
+ // dev`, a manual `-p 8080`) is always a `node` process. Filtering the wide
254
+ // scan to this name keeps it from ballooning into probing every unrelated
255
+ // listener on the machine (browsers, Docker, print spooler, …) the way an
256
+ // unfiltered machine-wide scan would.
257
+ function isNodeLikeListenerName(name) {
258
+ return /^node(\.exe)?$/i.test(name || '');
259
+ }
260
+
261
+ // ---- Wide OS scan (no port filter) — HS-09JU ------------------------------
262
+ //
263
+ // `scanListeners(ports)` above only ever confirms ports ALREADY in the
264
+ // candidate set (DEFAULT_PORTS + lockfile/console-url hints). A live console
265
+ // server that Next auto-bumped past 3005, or that someone started with a
266
+ // manual `-p`, is invisible to it — the exact "off-candidate-port live server
267
+ // reported dead" false positive from the diagnosis. This scan has NO port
268
+ // filter, so it sees every listener on the machine, then narrows to node-like
269
+ // process names before returning — the discovered ports get folded into the
270
+ // HTTP probe list by getDevServerStatus, not returned as candidates on their
271
+ // own (an unrelated node process squatting some other port must still fail
272
+ // the API-probe 'console' classification before it counts as a hit).
273
+ function scanWideListenersWindows() {
274
+ const script =
275
+ `$rows=@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue|` +
276
+ `ForEach-Object{$p=Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue;` +
277
+ `[pscustomobject]@{port=[int]$_.LocalPort;pid=[int]$_.OwningProcess;name=if($p){$p.ProcessName}else{''}}});` +
278
+ `ConvertTo-Json -InputObject $rows -Compress`;
279
+ const res = spawnSync(
280
+ 'powershell',
281
+ ['-NoProfile', '-NonInteractive', '-Command', script],
282
+ { encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS },
283
+ );
284
+ if (res.status !== 0 || !res.stdout) return [];
285
+ let parsed;
286
+ try {
287
+ parsed = JSON.parse(res.stdout.trim());
288
+ } catch {
289
+ return [];
290
+ }
291
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
292
+ return rows
293
+ .filter((r) => r && Number.isInteger(r.port) && isNodeLikeListenerName(r.name))
294
+ .map((r) => ({ port: r.port, pid: Number.isInteger(r.pid) ? r.pid : null, name: r.name || '' }));
295
+ }
296
+
297
+ function scanWideListenersUnix() {
298
+ const haveLsof = spawnSync('sh', ['-c', 'command -v lsof'], { encoding: 'utf8' }).status === 0;
299
+ const rows = [];
300
+ if (haveLsof) {
301
+ const res = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-nP'], {
302
+ encoding: 'utf8',
303
+ timeout: OS_SCAN_TIMEOUT_MS,
304
+ });
305
+ if (!res.stdout) return [];
306
+ for (const line of res.stdout.split('\n').slice(1)) { // header row first
307
+ const cols = line.trim().split(/\s+/);
308
+ if (cols.length < 9) continue;
309
+ const name = cols[0];
310
+ const pid = Number(cols[1]);
311
+ const addr = cols[8];
312
+ const portMatch = addr && addr.match(/:(\d+)$/);
313
+ if (!portMatch || !isNodeLikeListenerName(name)) continue;
314
+ rows.push({ port: Number(portMatch[1]), pid: Number.isInteger(pid) ? pid : null, name });
315
+ }
316
+ return rows;
317
+ }
318
+ const res = spawnSync('ss', ['-ltnp'], { encoding: 'utf8', timeout: OS_SCAN_TIMEOUT_MS });
319
+ if (!res.stdout) return [];
320
+ for (const line of res.stdout.split('\n')) {
321
+ const portMatch = line.match(/:(\d+)\s/);
322
+ const nameMatch = line.match(/users:\(\("([^"]+)"/);
323
+ if (!portMatch || !nameMatch || !isNodeLikeListenerName(nameMatch[1])) continue;
324
+ const pidMatch = line.match(/pid=(\d+)/);
325
+ rows.push({ port: Number(portMatch[1]), pid: pidMatch ? Number(pidMatch[1]) : null, name: nameMatch[1] });
326
+ }
327
+ return rows;
328
+ }
329
+
330
+ function scanWideListeners() {
331
+ try {
332
+ return process.platform === 'win32' ? scanWideListenersWindows() : scanWideListenersUnix();
333
+ } catch {
334
+ // Best-effort discovery only — the base candidatePorts scan still covers
335
+ // the common case without this.
336
+ return [];
337
+ }
338
+ }
339
+
340
+ // ---- HTTP liveness + identity probe (authoritative "is it up?") ------------
341
+
342
+ function probePort(port) {
343
+ return new Promise((resolve) => {
344
+ const req = http.request(
345
+ // Probe `localhost` (not a hardcoded 127.0.0.1) so resolution matches how
346
+ // Next bound — on Windows `localhost` often resolves to ::1 (IPv6), and a
347
+ // hardcoded IPv4 connect would miss a server listening only on ::1.
348
+ { host: 'localhost', port, path: PROBE_PATH, method: 'GET', timeout: HTTP_PROBE_TIMEOUT_MS },
349
+ (res) => {
350
+ const poweredBy = String(res.headers['x-powered-by'] || '');
351
+ const chunks = [];
352
+ let bytes = 0;
353
+ res.on('data', (chunk) => {
354
+ if (bytes < 512) chunks.push(chunk);
355
+ bytes += chunk.length;
356
+ });
357
+ res.on('end', () => {
358
+ const body = Buffer.concat(chunks).toString('utf8');
359
+ const isConsole =
360
+ /next/i.test(poweredBy) ||
361
+ (res.statusCode === 200 &&
362
+ String(res.headers['content-type'] || '').includes('application/json') &&
363
+ (() => {
364
+ try {
365
+ return typeof JSON.parse(body).buildId === 'string';
366
+ } catch {
367
+ return false;
368
+ }
369
+ })());
370
+ resolve({
371
+ port,
372
+ responded: true,
373
+ httpStatus: res.statusCode ?? null,
374
+ probe: isConsole ? 'console' : 'unknown',
375
+ });
376
+ });
377
+ },
378
+ );
379
+ req.on('timeout', () => {
380
+ req.destroy();
381
+ resolve({ port, responded: false, httpStatus: null, probe: 'no-response' });
382
+ });
383
+ req.on('error', () => {
384
+ resolve({ port, responded: false, httpStatus: null, probe: 'no-response' });
385
+ });
386
+ req.end();
387
+ });
388
+ }
389
+
390
+ // HS-09JU: retry probePort with backoff before concluding a port is dead.
391
+ // A single 4s attempt can land squarely inside a cold Next dev compile and
392
+ // miss it; a retry after PROBE_RETRY_DELAY_MS gives the compile a second
393
+ // window. Returns as soon as any attempt responds — this only adds latency to
394
+ // the "actually not there" case, never to the "it's live" case.
395
+ async function probePortWithRetry(port) {
396
+ let last = { port, responded: false, httpStatus: null, probe: 'no-response' };
397
+ for (let attempt = 0; attempt < PROBE_RETRY_ATTEMPTS; attempt++) {
398
+ last = await probePort(port);
399
+ if (last.responded) return last;
400
+ if (attempt < PROBE_RETRY_ATTEMPTS - 1) {
401
+ await new Promise((resolve) => setTimeout(resolve, PROBE_RETRY_DELAY_MS));
402
+ }
403
+ }
404
+ return last;
405
+ }
406
+
407
+ // HS-D4O9-DIAG: the PAGE-render probe — a second, independent liveness check
408
+ // against a real page ROUTE (not an API handler). A 5xx response or a timeout
409
+ // means the root layout/page cannot compile; 2xx/3xx/4xx all mean the
410
+ // framework itself is up and routing (a 404 is a routing miss, not a compile
411
+ // failure; a 3xx is a legitimate auth-middleware redirect, e.g. Clerk sending
412
+ // an unauthenticated request to /sign-in — neither should be misread as
413
+ // "broken"). Mirrors probePort's { responded, httpStatus } shape but adds
414
+ // `ok` (the pass/fail verdict this probe exists to produce) instead of a
415
+ // console/unknown classification, which only the API probe needs.
416
+ function probePage(port, pagePath = PAGE_PROBE_PATH) {
417
+ return new Promise((resolve) => {
418
+ const req = http.request(
419
+ { host: 'localhost', port, path: pagePath, method: 'GET', timeout: PAGE_PROBE_TIMEOUT_MS },
420
+ (res) => {
421
+ res.resume(); // drain the body — only the status code matters here
422
+ res.on('end', () => {
423
+ const httpStatus = res.statusCode ?? null;
424
+ resolve({
425
+ path: pagePath,
426
+ responded: true,
427
+ httpStatus,
428
+ timedOut: false,
429
+ ok: typeof httpStatus === 'number' && httpStatus < 500,
430
+ });
431
+ });
432
+ },
433
+ );
434
+ req.on('timeout', () => {
435
+ req.destroy();
436
+ resolve({ path: pagePath, responded: false, httpStatus: null, timedOut: true, ok: false });
437
+ });
438
+ req.on('error', () => {
439
+ resolve({ path: pagePath, responded: false, httpStatus: null, timedOut: false, ok: false });
440
+ });
441
+ req.end();
442
+ });
443
+ }
444
+
445
+ // ---- Discrepancy guidance (HS-09JU) -----------------------------------------
446
+ //
447
+ // Every discrepancy flag below fires ONLY in the presence of some server
448
+ // evidence (a live console server the lockfile doesn't vouch for, a lock
449
+ // pointing at the wrong port, a page-render failure on an otherwise-live
450
+ // server, …) — none of them mean "nothing is running, go ahead and start
451
+ // one". Pre-fix, callers (the SessionStart awareness hook, agents reading
452
+ // this tool's output) read "trust the verdict and any discrepancy flags" as
453
+ // license to treat a flag as a restart TRIGGER; the flags are diagnostic
454
+ // information about WHY the lockfile disagreed with reality, not a verdict
455
+ // that no server exists. This table is additive — `discrepancies` (the plain
456
+ // string codes) is unchanged for existing consumers; `discrepancyGuidance`
457
+ // is the new field that spells out the correct reading for each code.
458
+ const DISCREPANCY_GUIDANCE_TEXT = {
459
+ stale_lock:
460
+ 'The lockfile looks stale (its recorded launcher pid is dead, or it claims a server that is not answering) — this is NOT proof no server is running. A launcher can die on Windows while the `next dev` it started keeps running. Check `running`/`servers` in THIS SAME response first: if a console server is listed, do NOT start a new one — reuse it. Only treat the server as actually down when `running` is false and no server entry answers.',
461
+ untracked_server:
462
+ 'A live spex-console server is answering that no lockfile/console-url vouches for (started outside the wrapper with a bare `next dev`, or the discovery file was cleaned). This means a server IS running right now — do NOT start a second one; two servers compiling into the same .next corrupts it. Reuse the server shown in `servers`; only stop-then-relaunch via `npm run dev` if you specifically need it wrapper-managed.',
463
+ port_mismatch:
464
+ 'The lockfile points at one port but the live console server is answering on a different one. A server IS running (see `servers`) — do not start a new one; use the port shown in `servers`, not the lockfile\'s port.',
465
+ multiple_servers:
466
+ 'More than one console server is answering at once — this is the corrupted-.next state two concurrent `next dev` processes cause. Do NOT start another. Stop the extras first (`hs server stop` / `npm run console:stop`).',
467
+ page_probe_failed:
468
+ 'The API probe answered but a real page route failed to render (HS-D4O9-DIAG: e.g. a missing dependency leaves every page throwing while API handlers keep answering 200). The server is up but likely broken — check server logs / reinstall deps. Restarting the dev server will usually NOT fix this on its own, so do not treat this flag as "not running".',
469
+ };
470
+
471
+ /**
472
+ * Expand `discrepancies` (plain string codes, unchanged for backward
473
+ * compatibility) into the guidance objects new callers should read instead of
474
+ * inferring meaning from the bare code. Every known code is restart-blocking
475
+ * by construction (see the table's own comment above) — an unrecognized
476
+ * future code still gets a safe generic message rather than being silently
477
+ * dropped.
478
+ */
479
+ function buildDiscrepancyGuidance(discrepancies) {
480
+ return discrepancies.map((code) => ({
481
+ code,
482
+ restartBlocking: true,
483
+ message:
484
+ DISCREPANCY_GUIDANCE_TEXT[code] ||
485
+ 'Diagnostic flag — verify `running`/`servers` above before treating this as license to start a server.',
486
+ }));
487
+ }
488
+
489
+ /**
490
+ * Ground-truth dev-server status. Returns a structured verdict whose `running`
491
+ * field is derived from the HTTP probe (never from the lockfile alone), plus
492
+ * the discrepancy flags that name every way the lockfile disagreed with reality.
493
+ */
494
+ export async function getDevServerStatus({ vaultRoot, ports, pagePath } = {}) {
495
+ const root = vaultRoot || defaultVaultRoot();
496
+ const basePorts = candidatePorts(root, ports);
497
+
498
+ // HS-09JU: fold in any node-like listener the wide OS scan finds outside
499
+ // `basePorts` (DEFAULT_PORTS + lockfile/console-url hints) — a live console
500
+ // server that Next auto-bumped past 3005, or that was started with a manual
501
+ // `-p`, must still be found and probed, not reported dead just because it's
502
+ // off the well-known range. Skipped when the caller passed an explicit
503
+ // `ports` override (tests, a narrowed `hs server status` check) — that's
504
+ // "check exactly these ports", so widening would defeat the point.
505
+ const explicitPorts = Array.isArray(ports) && ports.length > 0;
506
+ const wideRows = explicitPorts ? [] : scanWideListeners();
507
+ const discoveredPorts = wideRows.map((r) => r.port).filter((p) => !basePorts.includes(p));
508
+ const portsToCheck = [...new Set([...basePorts, ...discoveredPorts])];
509
+
510
+ const [probes, baseOsRows] = await Promise.all([
511
+ Promise.all(portsToCheck.map((p) => probePortWithRetry(p))),
512
+ Promise.resolve(scanListeners(basePorts)),
513
+ ]);
514
+
515
+ const osByPort = new Map();
516
+ for (const row of baseOsRows) {
517
+ if (!osByPort.has(row.port)) osByPort.set(row.port, row);
518
+ }
519
+ // Ports the wide scan discovered aren't in `basePorts`, so the exact-match
520
+ // scan above never attributed them — attribute them from the wide scan's
521
+ // own pid/name data instead (it already has it; no third OS call needed).
522
+ for (const row of wideRows) {
523
+ if (!osByPort.has(row.port)) osByPort.set(row.port, row);
524
+ }
525
+ const osRows = [...osByPort.values()];
526
+
527
+ // A server entry exists for any port that EITHER answered HTTP OR the OS
528
+ // reports as listening — so an OS-listening-but-HTTP-dead port is still shown.
529
+ const ports_seen = new Set([
530
+ ...probes.filter((p) => p.responded).map((p) => p.port),
531
+ ...osRows.map((r) => r.port),
532
+ ]);
533
+
534
+ // HS-D4O9-DIAG: the page probe only makes sense against a port the API probe
535
+ // already identified as spex-console — probing an 'unknown'/'no-response' port
536
+ // for a page render would just add noise, and a port with no server at all has
537
+ // nothing to probe. Run these in parallel with everything above already
538
+ // resolved so we know exactly which ports qualify.
539
+ const consolePorts = [...ports_seen].filter(
540
+ (port) => (probes.find((p) => p.port === port) || {}).probe === 'console',
541
+ );
542
+ const pageProbes = await Promise.all(consolePorts.map((port) => probePage(port, pagePath)));
543
+ const pageProbeByPort = new Map(consolePorts.map((port, i) => [port, pageProbes[i]]));
544
+
545
+ const servers = [...ports_seen]
546
+ .sort((a, b) => a - b)
547
+ .map((port) => {
548
+ const probe = probes.find((p) => p.port === port) || { responded: false, httpStatus: null, probe: 'no-response' };
549
+ const os = osByPort.get(port) || { pid: null, name: '' };
550
+ return {
551
+ port,
552
+ pid: os.pid,
553
+ name: os.name,
554
+ url: `http://localhost:${port}`,
555
+ listening: true,
556
+ responded: probe.responded,
557
+ httpStatus: probe.httpStatus,
558
+ probe: probe.probe, // 'console' | 'unknown' | 'no-response'
559
+ // Additive (HS-D4O9-DIAG): null for any port the API probe didn't call
560
+ // 'console' — the page probe never runs against those.
561
+ pageProbe: pageProbeByPort.get(port) ?? null,
562
+ };
563
+ });
564
+
565
+ const consoleServers = servers.filter((s) => s.probe === 'console');
566
+ const running = consoleServers.length > 0;
567
+ // Additive top-level summary (HS-D4O9-DIAG): true only when every identified
568
+ // console server's page probe also succeeded; null when there is no console
569
+ // server to judge (distinct from false — "not applicable" vs "checked and
570
+ // failed"). Deliberately NOT folded into `running` — that field's existing
571
+ // API-probe-only meaning stays backward compatible for callers already keyed
572
+ // off it; this is the new, stricter "is the UI actually usable" signal.
573
+ const uiHealthy = consoleServers.length === 0 ? null : consoleServers.every((s) => s.pageProbe?.ok === true);
574
+
575
+ const lock = readLockfile(root);
576
+ const lockfile = {
577
+ present: lock.present,
578
+ pid: lock.pid,
579
+ url: lock.url,
580
+ startedAt: lock.startedAt,
581
+ // Bind host as the launcher recorded it. null ⇒ UNKNOWN (no lock, or a lock
582
+ // written before the loopback-default change) — never read null as loopback.
583
+ host: lock.host,
584
+ lan: lock.lan,
585
+ pidAlive: lock.present ? pidAlive(lock.pid) : null,
586
+ };
587
+ const consoleUrlFile = readConsoleUrlFile(root);
588
+
589
+ // ---- Reconcile: surface every disagreement ----
590
+ //
591
+ // Reconcile by PORT, not pid. The lock records the LAUNCHER pid
592
+ // (ensure-next-dev.mjs), while the OS reports the listening pid (its child
593
+ // `next dev`) — they never match, so a pid comparison would false-flag every
594
+ // properly-wrapped server as untracked. The port the lock points at is the
595
+ // stable join key.
596
+ const discrepancies = [];
597
+ const lockPort = portFromUrl(lock.url);
598
+ // Ports the lockfile / console-url file vouch for (a console server here is
599
+ // "tracked"). console-url alone is a weaker signal (it isn't cleared as
600
+ // reliably as the lock), so it only widens the tracked set, never narrows it.
601
+ const trackedPorts = new Set([lockPort, portFromUrl(consoleUrlFile)].filter((p) => p != null));
602
+
603
+ if (lock.present) {
604
+ if (!lockfile.pidAlive) {
605
+ // Launcher process is gone but the lock file survives → stale.
606
+ discrepancies.push('stale_lock');
607
+ } else if (consoleServers.length === 0) {
608
+ // Lock claims a live server (launcher alive) but nothing console-like is
609
+ // actually answering → the lock is lying about "running".
610
+ discrepancies.push('stale_lock');
611
+ }
612
+ if (lockPort != null && consoleServers.length > 0 && !consoleServers.some((s) => s.port === lockPort)) {
613
+ // Lock points at one port; the live console server is on another.
614
+ discrepancies.push('port_mismatch');
615
+ }
616
+ }
617
+
618
+ if (consoleServers.some((s) => !trackedPorts.has(s.port))) {
619
+ // A live console server on a port no lock/console-url vouches for — a bare
620
+ // `next dev` started outside the wrapper, with no lock to record it.
621
+ discrepancies.push('untracked_server');
622
+ }
623
+
624
+ if (consoleServers.length > 1) discrepancies.push('multiple_servers');
625
+
626
+ // HS-D4O9-DIAG: the API probe answering is NOT proof the console is usable —
627
+ // surface the page-compile failure as its own discrepancy so a caller that
628
+ // already inspects `discrepancies` (the existing extension point for "the
629
+ // lockfile/API signal disagrees with reality") picks this up for free,
630
+ // without needing to know about the new `uiHealthy`/`pageProbe` fields.
631
+ if (consoleServers.some((s) => s.pageProbe && s.pageProbe.ok === false)) {
632
+ discrepancies.push('page_probe_failed');
633
+ }
634
+
635
+ return {
636
+ running,
637
+ // Additive (HS-D4O9-DIAG): the stricter "does the UI actually render"
638
+ // signal — see the field's own comment above for why it's separate from
639
+ // `running` rather than folded into it. null = no console server to judge.
640
+ uiHealthy,
641
+ servers,
642
+ lockfile,
643
+ consoleUrlFile,
644
+ candidatePorts: portsToCheck,
645
+ discrepancies: [...new Set(discrepancies)],
646
+ // Additive (HS-09JU): the corrected reading of `discrepancies` — every
647
+ // entry is restart-BLOCKING (evidence a server exists), never a restart
648
+ // trigger. See DISCREPANCY_GUIDANCE_TEXT above for the per-code rationale.
649
+ discrepancyGuidance: buildDiscrepancyGuidance([...new Set(discrepancies)]),
650
+ platform: process.platform,
651
+ checkedAt: new Date().toISOString(),
652
+ };
653
+ }
654
+
655
+ // ---- Human-readable one-liner (for the CLI + hook reminders) ---------------
656
+
657
+ export function summarizeStatus(status) {
658
+ if (!status.running && status.servers.length === 0) {
659
+ return 'Dev server: NOT running (no listener on candidate ports).';
660
+ }
661
+ const parts = status.servers.map((s) => {
662
+ const who = s.pid ? ` pid ${s.pid}${s.name ? ` (${s.name})` : ''}` : '';
663
+ const tag =
664
+ s.probe === 'console' ? 'spex-console' : s.probe === 'unknown' ? 'non-console process' : 'listening, no HTTP response';
665
+ // HS-D4O9-DIAG: name the page-probe failure right on the server line — the
666
+ // discrepancy list below already carries `page_probe_failed`, but a human
667
+ // scanning per-server lines should see it without cross-referencing.
668
+ const pageNote =
669
+ s.pageProbe && s.pageProbe.ok === false
670
+ ? ` — ⚠ PAGE PROBE FAILED (${s.pageProbe.timedOut ? 'timeout' : `HTTP ${s.pageProbe.httpStatus ?? 'no response'}`} on ${s.pageProbe.path})`
671
+ : '';
672
+ return `:${s.port} — ${tag}${who}${pageNote}`;
673
+ });
674
+ // running=true (API answered) but uiHealthy===false (page compile failed) is
675
+ // exactly the HS-D4O9-DIAG false-positive this fix closes — the headline must
676
+ // say so instead of a flat "RUNNING" that reads as "everything is fine".
677
+ const head = !status.running
678
+ ? 'Dev server: port(s) occupied (no console identified)'
679
+ : status.uiHealthy === false
680
+ ? 'Dev server: RUNNING but UI NOT rendering (API up, page compile FAILED)'
681
+ : 'Dev server: RUNNING';
682
+ // HS-09JU: discrepancies are diagnostic, not restart triggers — a flag next
683
+ // to a RUNNING headline still means "a server exists"; spell that out here
684
+ // so a human (or an agent) reading only this line, not the JSON, doesn't
685
+ // misread the flag list as "start a new one".
686
+ const flags = status.discrepancies.length
687
+ ? `\n⚠ discrepancies (diagnostic — do NOT restart because of these; see \`running\`/\`servers\` above): ${status.discrepancies.join(', ')}`
688
+ : '';
689
+ // Bind host: only worth a line when the launcher recorded one. LAN mode is
690
+ // called out because it is the state that exposes the console off-machine.
691
+ const lock = status.lockfile;
692
+ const bind = !lock?.present
693
+ ? ''
694
+ : lock.lan === true
695
+ ? `\n bind: ${lock.host ?? '?'} — ⚠ LAN MODE (reachable from your network; only /api/env-vault/join needs this)`
696
+ : lock.lan === false
697
+ ? `\n bind: ${lock.host ?? '?'} — loopback only`
698
+ : '';
699
+ return `${head}\n ${parts.join('\n ')}${bind}${flags}`;
700
+ }
701
+
702
+ // ---- CLI entrypoint --------------------------------------------------------
703
+
704
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
705
+ if (invokedDirectly) {
706
+ const asJson = process.argv.includes('--json');
707
+ getDevServerStatus()
708
+ .then((status) => {
709
+ if (asJson) {
710
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
711
+ } else {
712
+ process.stdout.write(summarizeStatus(status) + '\n');
713
+ }
714
+ process.exit(0);
715
+ })
716
+ .catch((err) => {
717
+ // Even on failure, print a usable message and exit 0 — callers (hook, CLI)
718
+ // should never be broken by a status check.
719
+ process.stdout.write(`Dev server status check failed: ${err?.message ?? err}\n`);
720
+ process.exit(0);
721
+ });
722
+ }