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,88 @@
1
+ #!/usr/bin/env node
2
+ // Root postinstall guard. The dev-machine setup steps (PM control-center deps,
3
+ // Claude Code hooks, CommandCode update) live under scripts/, which
4
+ // .vercelignore excludes from the Vercel upload — so on Vercel (or any CI, or
5
+ // any environment where scripts/ is absent) this must exit 0 instead of
6
+ // crashing `npm install` with MODULE_NOT_FOUND. Lives in bin/ (uploaded to
7
+ // Vercel) precisely so the guard itself always resolves.
8
+ import { existsSync } from "node:fs";
9
+ import { spawnSync } from "node:child_process";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
14
+
15
+ // Installed as somebody else's dependency (HS-ZJSP): the published `heyspex`
16
+ // package ships this file because npm runs the `postinstall` script for every
17
+ // consumer, but NONE of the dev-machine setup below belongs in a customer's
18
+ // install — and printing a "skipping" line at them is noise, not information.
19
+ // A package installed from the registry always lands under a node_modules/
20
+ // directory; a clone of this repo never does. Exit silently in the former case.
21
+ if (repoRoot.replace(/\\/g, "/").includes("/node_modules/")) {
22
+ process.exit(0);
23
+ }
24
+
25
+ const steps = [
26
+ ["scripts/ensure-pm-cc-deps.mjs"],
27
+ ["scripts/install-claude-code-hooks.mjs"],
28
+ // Cursor's two install surfaces. `.cursor/mcp.json` is gitignored (it is
29
+ // machine-local), so without this step a fresh clone opens in Cursor with no
30
+ // heyspex MCP server at all; `.cursor/hooks.json` is tracked but merges
31
+ // idempotently, and the installer also retires superseded registrations.
32
+ ["scripts/install-cursor-mcp.mjs"],
33
+ ["scripts/install-cursor-hooks.mjs"],
34
+ ["scripts/install-commandcode.mjs", "--update"],
35
+ ["scripts/sync-skills.mjs"],
36
+ // Git hooks (.git/hooks/ is never cloned) — installs pre-commit + pre-push so a
37
+ // single `npm install` on a fresh clone is enough, with no second `npm run setup`.
38
+ // No --force: an idempotent re-run no-ops on the HeySpex hook and refuses to
39
+ // clobber a foreign pre-commit (exit 1 surfaces that rather than silently replacing).
40
+ ["scripts/install-git-hooks.mjs"],
41
+ ];
42
+
43
+ if (process.env.VERCEL || process.env.CI) {
44
+ console.log("[postinstall] VERCEL/CI detected — skipping dev-machine setup.");
45
+ process.exit(0);
46
+ }
47
+
48
+ for (const [script, ...args] of steps) {
49
+ const abs = join(repoRoot, script);
50
+ if (!existsSync(abs)) {
51
+ console.log(`[postinstall] ${script} not present — skipping dev-machine setup.`);
52
+ process.exit(0);
53
+ }
54
+ const { status } = spawnSync(process.execPath, [abs, ...args], {
55
+ cwd: repoRoot,
56
+ stdio: "inherit",
57
+ });
58
+ if (status !== 0) process.exit(status ?? 1);
59
+ }
60
+
61
+ // Advisory only, never fatal, and deliberately NOT `npm link` itself: a
62
+ // postinstall that mutates the machine's global npm prefix would repoint `hs`
63
+ // away from whatever clone owned it, silently, on every install. `npm install`
64
+ // completing clean while `hs` resolves nowhere is exactly the fresh-machine
65
+ // failure in [[HS-BFXF-WBJU-DIAG-hs-never-resolves-on-a-fresh-machine]] — so
66
+ // say so here, where a new operator is already looking, and let `npm run
67
+ // doctor` carry the full check. Imported dynamically so the published package
68
+ // (which ships this file but not scripts/) can never fail to resolve it.
69
+ const hsLinkLib = join(repoRoot, "scripts/lib/hs-link.mjs");
70
+ if (existsSync(hsLinkLib)) {
71
+ try {
72
+ const { describeHsLink } = await import(pathToFileURL(hsLinkLib).href);
73
+ const link = describeHsLink(repoRoot);
74
+ if (link.status !== "linked") {
75
+ const what = {
76
+ missing: "does not resolve on PATH",
77
+ foreign: `resolves to an unrelated tool (${link.target})`,
78
+ "other-checkout": `resolves to a DIFFERENT checkout (${link.target})`,
79
+ unreadable: `resolves to ${link.shim}, whose target could not be read`,
80
+ }[link.status];
81
+ console.log(`[postinstall] heads up: \`hs\` ${what}.`);
82
+ console.log("[postinstall] Run `npm link` once from this repo root to put hs/heyspex/heyspex-mcp on PATH.");
83
+ console.log("[postinstall] Until then use `node bin/hs.mjs …` — never `npx hs` (unrelated package). Details: `npm run doctor`.");
84
+ }
85
+ } catch {
86
+ // An advisory that crashes the install would be worse than no advisory.
87
+ }
88
+ }
@@ -0,0 +1,274 @@
1
+ // dev-factory/lib/fleet-readiness.mjs — shared fleet-readiness predicate (E1 / HS-CXO0).
2
+ // Single source of truth for "should dev-factory seat identity be injected?", reused by
3
+ // the MCP server AND scripts/claude-hooks/inject-coo-seat.mjs so the two never drift
4
+ // (epic AC: "the MCP server and the SessionStart hook use the same predicate — no drift").
5
+ // Build brief: dev-factory/state/briefs/fleet-readiness.brief.md
6
+ //
7
+ // The embedded `--self-test` below IS the deterministic gate (run via
8
+ // `node dev-factory/controller.mjs --self-test-gate --target dev-factory/lib/fleet-readiness.mjs`).
9
+ // Its assertions ARE the acceptance criteria made executable — the Builder seat implements
10
+ // the two function bodies until every case passes. Do NOT weaken the assertions.
11
+ //
12
+ // Pure logic + injectable inputs (no network). Fail-closed: any missing/corrupt input
13
+ // resolves to not-ready, never throws.
14
+ import { loadFleetState } from "./fleet-state.mjs";
15
+ import { readFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+
19
+ export const DEFAULT_FLEET_FRESHNESS_MS = 15 * 60 * 1000; // 15 minutes
20
+
21
+ /** Repo root — honour CLAUDE_PROJECT_DIR, else walk up from this file (dev-factory/lib → root). */
22
+ function repoRoot() {
23
+ if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
24
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
25
+ }
26
+
27
+ /**
28
+ * Is the local build fleet "set up and enabled"?
29
+ * @param {{fleetState?: object, routingTable?: object, nowMs?: number, freshnessMs?: number, repoRoot?: string}} opts
30
+ * All injectable for tests. fleetState → loadFleetState({ repoRoot }) if omitted;
31
+ * routingTable → JSON.parse(readFileSync(<repoRoot>/dev-factory/routing-table.json)) if omitted;
32
+ * nowMs → Date.now(); freshnessMs → DEFAULT_FLEET_FRESHNESS_MS.
33
+ * @returns {{ready: boolean, reason: string, freshMachines: string[]}}
34
+ *
35
+ * ready === true only when BOTH hold:
36
+ * 1. ≥1 machine in fleetState.machines has lastProbe.ok === true AND
37
+ * nowMs - Date.parse(lastSeen) <= freshnessMs (collected into freshMachines).
38
+ * 2. routingTable.purposes.build has ≥1 entry with kind === 'local' whose `machine`
39
+ * key exists in routingTable.machines (a local build seat actually resolves).
40
+ * Fail-closed: any missing/corrupt input → { ready:false, reason:"fleet state unavailable: <msg>", freshMachines:[] }.
41
+ */
42
+ export function isFleetReady(opts = {}) {
43
+ const {
44
+ fleetState: fsInput,
45
+ routingTable: rtInput,
46
+ nowMs: nowMsInput,
47
+ freshnessMs: freshnessMsInput,
48
+ repoRoot: repoRootInput,
49
+ } = opts;
50
+ const repoRootVal = repoRootInput ?? repoRoot();
51
+ try {
52
+ const fleetState = fsInput ?? loadFleetState({ repoRoot: repoRootVal });
53
+ if (!fleetState || typeof fleetState !== "object" || !fleetState.machines)
54
+ throw new Error("invalid fleetState");
55
+ const routingTable =
56
+ rtInput ??
57
+ JSON.parse(
58
+ readFileSync(
59
+ path.join(repoRootVal, "dev-factory", "routing-table.json"),
60
+ "utf8",
61
+ ),
62
+ );
63
+ if (!routingTable || typeof routingTable !== "object")
64
+ throw new Error("invalid routingTable");
65
+ const nowMs = nowMsInput ?? Date.now();
66
+ const freshnessMs = freshnessMsInput ?? DEFAULT_FLEET_FRESHNESS_MS;
67
+ // compute freshMachines
68
+ const freshMachines = [];
69
+ for (const [machine, info] of Object.entries(fleetState.machines || {})) {
70
+ try {
71
+ if (!info.lastProbe?.ok) continue;
72
+ // lastSeen is the primary freshness stamp, but historically it was never
73
+ // written on some paths (sidecar rows with lastSeen: null). A healthy
74
+ // probe timestamp is an equivalent liveness signal — fall back to
75
+ // lastProbe.at so a null/unparseable lastSeen doesn't permanently
76
+ // report the fleet as not-ready.
77
+ let seenMs = Date.parse(info.lastSeen);
78
+ if (isNaN(seenMs)) seenMs = Date.parse(info.lastProbe?.at);
79
+ if (isNaN(seenMs)) continue;
80
+ if (nowMs - seenMs <= freshnessMs) freshMachines.push(machine);
81
+ } catch {}
82
+ }
83
+ if (freshMachines.length === 0) {
84
+ return {
85
+ ready: false,
86
+ reason: "no recent healthy probe",
87
+ freshMachines: [],
88
+ };
89
+ }
90
+ // check routing table
91
+ const localBuild = (routingTable.purposes?.build ?? []).find(
92
+ (entry) => entry.kind === "local" && routingTable.machines[entry.machine],
93
+ );
94
+ if (!localBuild) {
95
+ return {
96
+ ready: false,
97
+ reason: "no resolvable local build seat",
98
+ freshMachines: [],
99
+ };
100
+ }
101
+ return { ready: true, reason: "fleet ready", freshMachines };
102
+ } catch (e) {
103
+ return {
104
+ ready: false,
105
+ reason: "fleet state unavailable: " + String(e),
106
+ freshMachines: [],
107
+ };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Resolve the effective on/off state given seats.json settings.cooMode (true | false | "auto").
113
+ * @param {boolean|"auto"|null|undefined} cooMode
114
+ * @param {object} opts forwarded to isFleetReady when cooMode === "auto".
115
+ * @returns {{enabled: boolean, reason: string}}
116
+ * true → { enabled:true, reason:"cooMode=on (explicit)" }
117
+ * false/null/undefined → { enabled:false, reason:"cooMode=off (explicit)" }
118
+ * "auto" → { enabled: isFleetReady(opts).ready, reason:"cooMode=auto → " + <that reason> }
119
+ * else → { enabled:false, reason:"cooMode unrecognized: " + String(cooMode) }
120
+ */
121
+ export function resolveCooModeEnabled(cooMode, opts = {}) {
122
+ if (cooMode === true || cooMode === "on") {
123
+ return { enabled: true, reason: "cooMode=on (explicit)" };
124
+ }
125
+ if (cooMode === false || cooMode == null) {
126
+ return { enabled: false, reason: "cooMode=off (explicit)" };
127
+ }
128
+ if (cooMode === "auto") {
129
+ const r = isFleetReady(opts);
130
+ return { enabled: r.ready, reason: "cooMode=auto → " + r.reason };
131
+ }
132
+ return { enabled: false, reason: "cooMode unrecognized: " + String(cooMode) };
133
+ }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+ // Deterministic gate — `node dev-factory/lib/fleet-readiness.mjs --self-test`
137
+ // These assertions ARE the acceptance criteria. Do NOT weaken them.
138
+ // All inputs INJECTED (no disk, no network).
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+ function runSelfTest() {
141
+ let ok = true;
142
+ const check = (name, cond) => {
143
+ console.log(` ${cond ? "✓" : "✗"} ${name}`);
144
+ if (!cond) ok = false;
145
+ };
146
+ console.log("== dev-factory/lib/fleet-readiness.mjs self-test ==\n");
147
+
148
+ const nowMs = Date.parse("2026-07-05T08:00:00.000Z");
149
+ const iso = (deltaMs) => new Date(nowMs + deltaMs).toISOString();
150
+
151
+ // A routing table with a resolvable local build seat (machine 'basement' exists).
152
+ const routingTable = {
153
+ machines: { "night-action": {}, "basement-geek": {} },
154
+ purposes: { build: [{ kind: "local", machine: "basement-geek" }] },
155
+ };
156
+ // A routing table with NO resolvable local build seat.
157
+ const noBuildTable = { machines: {}, purposes: { build: [] } };
158
+
159
+ const fresh = {
160
+ version: 1,
161
+ machines: {
162
+ basement: {
163
+ lastProbe: { at: iso(-60_000), ok: true },
164
+ lastSeen: iso(-60_000),
165
+ },
166
+ },
167
+ }; // 1 min ago
168
+ const stale = {
169
+ version: 1,
170
+ machines: {
171
+ basement: {
172
+ lastProbe: { at: iso(-60 * 60_000), ok: true },
173
+ lastSeen: iso(-60 * 60_000),
174
+ },
175
+ },
176
+ }; // 60 min ago
177
+
178
+ check(
179
+ "fresh fleet + resolvable local build seat → ready",
180
+ isFleetReady({ fleetState: fresh, routingTable, nowMs }).ready === true,
181
+ );
182
+ check(
183
+ "stale fleet (probe > freshnessMs old) → not ready",
184
+ isFleetReady({ fleetState: stale, routingTable, nowMs }).ready === false,
185
+ );
186
+ check(
187
+ "fresh fleet but no resolvable local build seat → not ready",
188
+ isFleetReady({ fleetState: fresh, routingTable: noBuildTable, nowMs })
189
+ .ready === false,
190
+ );
191
+
192
+ // lastSeen was never stamped on some sidecar paths — a fresh healthy probe
193
+ // (lastProbe.ok + fresh lastProbe.at) must still count the machine as fresh.
194
+ const nullSeenFreshProbe = {
195
+ version: 1,
196
+ machines: {
197
+ basement: {
198
+ lastProbe: { at: iso(-60_000), ok: true },
199
+ lastSeen: null,
200
+ },
201
+ },
202
+ };
203
+ const nullSeenStaleProbe = {
204
+ version: 1,
205
+ machines: {
206
+ basement: {
207
+ lastProbe: { at: iso(-60 * 60_000), ok: true },
208
+ lastSeen: null,
209
+ },
210
+ },
211
+ };
212
+ check(
213
+ "lastSeen null but fresh healthy probe → ready (lastProbe.at fallback)",
214
+ isFleetReady({ fleetState: nullSeenFreshProbe, routingTable, nowMs })
215
+ .ready === true,
216
+ );
217
+ check(
218
+ "lastSeen null and stale probe → not ready",
219
+ isFleetReady({ fleetState: nullSeenStaleProbe, routingTable, nowMs })
220
+ .ready === false,
221
+ );
222
+
223
+ check(
224
+ "resolveCooModeEnabled(true) → enabled",
225
+ resolveCooModeEnabled(true).enabled === true,
226
+ );
227
+ check(
228
+ "resolveCooModeEnabled(false) → disabled",
229
+ resolveCooModeEnabled(false).enabled === false,
230
+ );
231
+ check(
232
+ "resolveCooModeEnabled('auto', fresh) → enabled",
233
+ resolveCooModeEnabled("auto", { fleetState: fresh, routingTable, nowMs })
234
+ .enabled === true,
235
+ );
236
+ check(
237
+ "resolveCooModeEnabled('auto', stale) → disabled",
238
+ resolveCooModeEnabled("auto", { fleetState: stale, routingTable, nowMs })
239
+ .enabled === false,
240
+ );
241
+
242
+ // Fail-closed: a corrupt fleetState (machines is null) must NOT throw and must be not-ready.
243
+ check(
244
+ "corrupt input → ready:false and does NOT throw",
245
+ (() => {
246
+ try {
247
+ const r = isFleetReady({
248
+ fleetState: { machines: null },
249
+ routingTable,
250
+ nowMs,
251
+ });
252
+ return r.ready === false;
253
+ } catch {
254
+ return false;
255
+ }
256
+ })(),
257
+ );
258
+
259
+ console.log(
260
+ `\n${
261
+ ok
262
+ ? "✓ self-test PASS — fresh/stale gating, local-build-seat resolution, cooMode true/false/auto, fail-closed corrupt input verified."
263
+ : "✗ self-test FAIL"
264
+ }`,
265
+ );
266
+ return ok ? 0 : 1;
267
+ }
268
+
269
+ if (
270
+ process.argv[1] &&
271
+ pathToFileURL(process.argv[1]).href === import.meta.url
272
+ ) {
273
+ if (process.argv.includes("--self-test")) process.exit(runSelfTest());
274
+ }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/fleet-state.mjs — per-machine runtime state sidecar (engine-side half of a twin pair).
4
+ *
5
+ * Operates on the SAME store file as spex-console/src/lib/fleet/fleet-state-store.ts, with a
6
+ * BYTE-IDENTICAL schema (the join-codes.mjs ↔ join-code-store.ts twin pattern):
7
+ * store: <repo>/.heyspex/fleet-state.json
8
+ *
9
+ * This is RUNTIME state (lastSeen / probe results / model inventory drift / roles / fleet
10
+ * version) — deliberately NOT routing-table.json (identity + routing, sole writer
11
+ * lib/routing-updater.mjs). The routing table says what a machine IS; this file says what it
12
+ * was DOING last time anyone looked.
13
+ *
14
+ * Schema (version field for migration):
15
+ * { "version": 1, "machines": { "<machineKey>": {
16
+ * "roles": ["build"], "firstSeen": "<iso>", "lastSeen": "<iso|null>",
17
+ * "lastProbe": { "at": "<iso>", "ok": true },
18
+ * "knownModels": ["llama3:8b"], "newModels": ["qwen3:32b"],
19
+ * "fleetVersion": null, "notes": null } } }
20
+ *
21
+ * Model drift contract: recordProbe diffs the probed model list against knownModels — anything
22
+ * unseen lands in newModels (the "new model appeared on this box" signal for the console) and
23
+ * STAYS there until acknowledgeModels() moves it into knownModels.
24
+ *
25
+ * SCHEMA NOTE: this file is the store's FIRST reader/writer; the TypeScript module is the
26
+ * second. Keep field names in lock-step.
27
+ *
28
+ * node dev-factory/lib/fleet-state.mjs --self-test
29
+ */
30
+
31
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
32
+ import { dirname, join, resolve } from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import { tmpdir } from "node:os";
35
+
36
+ const SCHEMA_VERSION = 1;
37
+
38
+ // ── Repo-root resolution (walk up to the heyspex.yaml vault marker — same as join-codes.mjs) ──
39
+ export function findRepoRoot(startDir = process.cwd()) {
40
+ let dir = resolve(startDir);
41
+ for (;;) {
42
+ if (existsSync(join(dir, "heyspex.yaml"))) return dir;
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return resolve(startDir);
48
+ }
49
+
50
+ function heyspexDir(opts = {}) {
51
+ const root = opts.rootDir ?? findRepoRoot();
52
+ const dir = join(root, ".heyspex");
53
+ mkdirSync(dir, { recursive: true });
54
+ return dir;
55
+ }
56
+ const storePath = (opts = {}) => join(heyspexDir(opts), "fleet-state.json");
57
+
58
+ const emptyState = () => ({ version: SCHEMA_VERSION, machines: {} });
59
+
60
+ /** Load the fleet-state document. Fail closed: missing/corrupt file → an empty v1 state. */
61
+ export function loadFleetState(opts = {}) {
62
+ const p = storePath(opts);
63
+ if (!existsSync(p)) return emptyState();
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
66
+ if (!parsed || typeof parsed !== "object" || typeof parsed.machines !== "object" || parsed.machines === null) {
67
+ return emptyState();
68
+ }
69
+ return { version: parsed.version ?? SCHEMA_VERSION, machines: parsed.machines };
70
+ } catch {
71
+ return emptyState();
72
+ }
73
+ }
74
+
75
+ function writeFleetState(state, opts = {}) {
76
+ const p = storePath(opts);
77
+ const tmp = `${p}.tmp`;
78
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
79
+ renameSync(tmp, p);
80
+ }
81
+
82
+ const defaultMachine = (nowIso) => ({
83
+ roles: [],
84
+ firstSeen: nowIso,
85
+ lastSeen: null,
86
+ lastProbe: null,
87
+ knownModels: [],
88
+ newModels: [],
89
+ fleetVersion: null,
90
+ notes: null,
91
+ });
92
+
93
+ function ensureMachine(state, machineKey, nowIso) {
94
+ if (!state.machines[machineKey]) state.machines[machineKey] = defaultMachine(nowIso);
95
+ return state.machines[machineKey];
96
+ }
97
+
98
+ /**
99
+ * Record one probe result for a machine (creates the entry on first sight).
100
+ * - lastProbe = { at, ok } always
101
+ * - lastSeen stamped ONLY on ok
102
+ * - models (when given): diffed against knownModels — anything unseen joins newModels
103
+ * until acknowledgeModels() moves it to knownModels.
104
+ * Returns the updated machine record.
105
+ */
106
+ export function recordProbe(machineKey, { ok, models } = {}, opts = {}) {
107
+ if (!machineKey) throw new Error("fleet-state: recordProbe needs a machineKey");
108
+ const nowIso = new Date().toISOString();
109
+ const state = loadFleetState(opts);
110
+ const m = ensureMachine(state, machineKey, nowIso);
111
+ m.lastProbe = { at: nowIso, ok: Boolean(ok) };
112
+ if (ok) m.lastSeen = nowIso;
113
+ if (Array.isArray(models)) {
114
+ for (const model of models) {
115
+ if (typeof model !== "string" || !model) continue;
116
+ if (!m.knownModels.includes(model) && !m.newModels.includes(model)) m.newModels.push(model);
117
+ }
118
+ }
119
+ writeFleetState(state, opts);
120
+ return m;
121
+ }
122
+
123
+ /**
124
+ * Acknowledge models: move each listed model out of newModels and into knownModels
125
+ * (added to knownModels even if it was never in newModels — an explicit ack wins).
126
+ * Returns the updated machine record, or null if the machine is unknown.
127
+ */
128
+ export function acknowledgeModels(machineKey, models = [], opts = {}) {
129
+ const state = loadFleetState(opts);
130
+ const m = state.machines[machineKey];
131
+ if (!m) return null;
132
+ for (const model of models) {
133
+ if (typeof model !== "string" || !model) continue;
134
+ m.newModels = m.newModels.filter((x) => x !== model);
135
+ if (!m.knownModels.includes(model)) m.knownModels.push(model);
136
+ }
137
+ writeFleetState(state, opts);
138
+ return m;
139
+ }
140
+
141
+ /** Replace a machine's roles list (creates the entry if unseen). Returns the record. */
142
+ export function setRoles(machineKey, roles = [], opts = {}) {
143
+ if (!machineKey) throw new Error("fleet-state: setRoles needs a machineKey");
144
+ const nowIso = new Date().toISOString();
145
+ const state = loadFleetState(opts);
146
+ const m = ensureMachine(state, machineKey, nowIso);
147
+ m.roles = roles.filter((r) => typeof r === "string" && r);
148
+ writeFleetState(state, opts);
149
+ return m;
150
+ }
151
+
152
+ /** Set a machine's fleet software version (creates the entry if unseen). Returns the record. */
153
+ export function setFleetVersion(machineKey, v, opts = {}) {
154
+ if (!machineKey) throw new Error("fleet-state: setFleetVersion needs a machineKey");
155
+ const nowIso = new Date().toISOString();
156
+ const state = loadFleetState(opts);
157
+ const m = ensureMachine(state, machineKey, nowIso);
158
+ m.fleetVersion = v ?? null;
159
+ writeFleetState(state, opts);
160
+ return m;
161
+ }
162
+
163
+ /** Remove a machine's runtime state entirely. Returns true if an entry was removed. */
164
+ export function forgetMachine(machineKey, opts = {}) {
165
+ const state = loadFleetState(opts);
166
+ if (!state.machines[machineKey]) return false;
167
+ delete state.machines[machineKey];
168
+ writeFleetState(state, opts);
169
+ return true;
170
+ }
171
+
172
+ // ── Self-test (temp-dir store — never touches the real .heyspex) ───────────────────
173
+ function runSelfTest() {
174
+ let ok = true;
175
+ const check = (name, cond) => { console.log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
176
+ console.log("== lib/fleet-state.mjs self-test ==\n");
177
+
178
+ const root = join(tmpdir(), `hs-fleetstate-selftest-${process.pid}`);
179
+ mkdirSync(join(root, ".heyspex"), { recursive: true });
180
+ const opts = { rootDir: root };
181
+
182
+ check("empty store loads as version-1 empty state", (() => {
183
+ const s = loadFleetState(opts);
184
+ return s.version === 1 && Object.keys(s.machines).length === 0;
185
+ })());
186
+
187
+ const first = recordProbe("night-action", { ok: true, models: ["gpt-oss:20b"] }, opts);
188
+ check("first ok probe creates the machine with firstSeen/lastSeen/lastProbe stamped",
189
+ first.firstSeen != null && first.lastSeen != null && first.lastProbe?.ok === true);
190
+ check("first-ever models land in newModels (nothing is silently 'known')",
191
+ first.newModels.includes("gpt-oss:20b") && first.knownModels.length === 0);
192
+
193
+ acknowledgeModels("night-action", ["gpt-oss:20b"], opts);
194
+ const acked = loadFleetState(opts).machines["night-action"];
195
+ check("acknowledgeModels moves the model newModels → knownModels",
196
+ acked.knownModels.includes("gpt-oss:20b") && !acked.newModels.includes("gpt-oss:20b"));
197
+
198
+ const drift = recordProbe("night-action", { ok: true, models: ["gpt-oss:20b", "qwen3:32b"] }, opts);
199
+ check("re-probe diffs against knownModels — only the NEW model joins newModels",
200
+ drift.newModels.length === 1 && drift.newModels[0] === "qwen3:32b" && drift.knownModels.includes("gpt-oss:20b"));
201
+
202
+ const seenBefore = drift.lastSeen;
203
+ const failed = recordProbe("night-action", { ok: false }, opts);
204
+ check("failed probe records lastProbe.ok=false but does NOT advance lastSeen",
205
+ failed.lastProbe.ok === false && failed.lastSeen === seenBefore);
206
+
207
+ const withRoles = setRoles("basement-geek", ["build", "critic"], opts);
208
+ check("setRoles creates an unseen machine and stores the roles",
209
+ withRoles.roles.join(",") === "build,critic" && withRoles.lastSeen === null);
210
+
211
+ const withVersion = setFleetVersion("basement-geek", "0.3.1", opts);
212
+ check("setFleetVersion stamps fleetVersion", withVersion.fleetVersion === "0.3.1");
213
+
214
+ check("state persists across a fresh load (atomic tmp+rename write)", (() => {
215
+ const s = loadFleetState(opts);
216
+ return s.machines["night-action"]?.knownModels.includes("gpt-oss:20b")
217
+ && s.machines["basement-geek"]?.fleetVersion === "0.3.1"
218
+ && s.version === 1;
219
+ })());
220
+
221
+ check("forgetMachine removes the entry (true) and is a no-op the second time (false)",
222
+ forgetMachine("basement-geek", opts) === true && forgetMachine("basement-geek", opts) === false
223
+ && loadFleetState(opts).machines["basement-geek"] === undefined);
224
+
225
+ check("corrupt store fails closed to an empty state (never throws)", (() => {
226
+ writeFileSync(join(root, ".heyspex", "fleet-state.json"), "{not json");
227
+ const s = loadFleetState(opts);
228
+ return s.version === 1 && Object.keys(s.machines).length === 0;
229
+ })());
230
+
231
+ check("acknowledgeModels on an unknown machine returns null (no phantom entry)",
232
+ acknowledgeModels("ghost", ["m"], opts) === null);
233
+
234
+ console.log(`\n${ok ? "✓ self-test PASS — create-on-probe, lastSeen gating, model drift → newModels, acknowledge, roles/version, forget, atomic persistence, fail-closed load verified." : "✗ self-test FAIL"}`);
235
+ return ok ? 0 : 1;
236
+ }
237
+
238
+ // ── Main ─────────────────────────────────────────────────────────────────────────
239
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
240
+ if (process.argv.includes("--self-test")) process.exit(runSelfTest());
241
+ console.log([
242
+ "lib/fleet-state.mjs — per-machine runtime state sidecar (.heyspex/fleet-state.json).",
243
+ "",
244
+ " import { loadFleetState, recordProbe, acknowledgeModels, setRoles, setFleetVersion, forgetMachine } from \"./lib/fleet-state.mjs\";",
245
+ " node dev-factory/lib/fleet-state.mjs --self-test",
246
+ ].join("\n"));
247
+ process.exit(0);
248
+ }
249
+
250
+ export const FLEET_STATE_VERSION = SCHEMA_VERSION;