@link-assistant/hive-mind 2.11.13 → 2.12.1

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +4 -1
  3. package/src/agent-command.lib.mjs +74 -0
  4. package/src/agent.lib.mjs +59 -34
  5. package/src/agentic-cli-updater.lib.mjs +241 -0
  6. package/src/claude.connection.lib.mjs +209 -0
  7. package/src/claude.lib.mjs +6 -202
  8. package/src/codex.lib.mjs +0 -128
  9. package/src/formal-ai-isolation.lib.mjs +62 -0
  10. package/src/formal-ai-maintenance.lib.mjs +106 -0
  11. package/src/formal-ai-model.lib.mjs +25 -0
  12. package/src/formal-ai-runtime.lib.mjs +10 -0
  13. package/src/formal-ai-sidecar.lib.mjs +565 -0
  14. package/src/formal-ai-updater.lib.mjs +294 -0
  15. package/src/formal-ai-version.lib.mjs +100 -0
  16. package/src/formal-ai.lib.mjs +11 -16
  17. package/src/github-rate-limit.lib.mjs +3 -0
  18. package/src/github-url-parser.lib.mjs +255 -0
  19. package/src/github.lib.mjs +22 -343
  20. package/src/hive.mjs +0 -152
  21. package/src/interactive-mode.lib.mjs +0 -43
  22. package/src/isolation-runner.lib.mjs +44 -173
  23. package/src/limits.lib.mjs +0 -89
  24. package/src/model-args.lib.mjs +32 -0
  25. package/src/models/index.mjs +5 -19
  26. package/src/session-monitor.lib.mjs +14 -172
  27. package/src/solve.auto-merge.lib.mjs +70 -164
  28. package/src/solve.mjs +31 -193
  29. package/src/solve.repository.lib.mjs +0 -83
  30. package/src/solve.results.lib.mjs +2 -92
  31. package/src/solve.session.lib.mjs +52 -19
  32. package/src/solve.tool-uncommitted.lib.mjs +22 -0
  33. package/src/state-lock.lib.mjs +82 -0
  34. package/src/telegram-bot.mjs +17 -65
  35. package/src/telegram-fix-command.lib.mjs +1 -8
  36. package/src/telegram-merge-queue.lib.mjs +3 -155
  37. package/src/telegram-solve-queue.lib.mjs +9 -168
  38. package/src/telegram-task-command.lib.mjs +1 -8
  39. package/src/use-m-bootstrap.lib.mjs +6 -5
  40. package/src/use-with-retry.lib.mjs +128 -2
  41. package/src/working-session-summary.lib.mjs +47 -1
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Idle-only Formal AI image updates with non-destructive memory migration
3
+ * (issue #2146, PR #2147 review).
4
+ *
5
+ * The maintainer asked for three things this module owns:
6
+ *
7
+ * - "Auto update formal-ai docker container when no formal-ai tasks running."
8
+ * - "When stopped and new version available update to latest."
9
+ * - "In hive mind docker only initial version of formal-ai should be pinned."
10
+ *
11
+ * The safety property that makes an unattended update acceptable is Formal AI
12
+ * 0.336.0's persisted-memory upgrade contract (formal-ai#982). Every update
13
+ * follows the same sequence upstream validates in its own container fixture:
14
+ *
15
+ * pull → compare digests → preflight (`memory upgrade-status`, side-effect
16
+ * free) → `memory migrate --backup --receipt` → boot the new image and
17
+ * assert `/health` reports compatible memory → stop again.
18
+ *
19
+ * If any step after the migration fails, the byte-exact backup named in the
20
+ * receipt is restored, its SHA-256 is compared against `original_sha256`, and
21
+ * the previous image stays in service. An update therefore either completes or
22
+ * leaves memory exactly as it was; it can never half-migrate.
23
+ *
24
+ * The sidecar is left *stopped* on success, because "stop the container when no
25
+ * formal-ai tasks are running" still applies — the boot is a verification step,
26
+ * not a deployment.
27
+ *
28
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
29
+ * @see https://github.com/link-assistant/formal-ai/issues/982
30
+ */
31
+
32
+ import { execFile } from 'node:child_process';
33
+ import fs from 'node:fs';
34
+ import { promisify } from 'node:util';
35
+
36
+ import { FORMAL_AI_IMAGE_REPOSITORY, FORMAL_AI_MEMORY_MOUNT, FORMAL_AI_MEMORY_PATH, FORMAL_AI_MEMORY_VOLUME_NAME, FORMAL_AI_SIDECAR_CONTAINER_NAME, buildFormalAiSidecarRunArgs, ensureFormalAiMemoryVolume, ensureFormalAiNetwork, inspectDockerContainer, readDockerImageDigest, readFormalAiSidecarState, reconcileFormalAiSidecar, stopFormalAiSidecar, waitForFormalAiSidecarHealth, withFormalAiSidecarLock, writeFormalAiSidecarState } from './formal-ai-sidecar.lib.mjs';
37
+
38
+ const execFileAsync = promisify(execFile);
39
+
40
+ const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
41
+ const DEFAULT_PULL_TIMEOUT_MS = 30 * 60 * 1000;
42
+
43
+ /** The moving tag every Formal AI release publishes alongside its bare version. */
44
+ export const FORMAL_AI_UPDATE_TAG = 'latest';
45
+
46
+ /**
47
+ * Resolve the image an update pulls.
48
+ *
49
+ * `HIVE_MIND_FORMAL_AI_IMAGE` is an operator pin — a deliberate choice of a
50
+ * specific build — so it disables auto-update rather than being silently
51
+ * overwritten.
52
+ */
53
+ export const resolveFormalAiUpdateImage = (env = process.env) => `${FORMAL_AI_IMAGE_REPOSITORY}:${String(env.HIVE_MIND_FORMAL_AI_UPDATE_TAG || '').trim() || FORMAL_AI_UPDATE_TAG}`;
54
+
55
+ /** Auto-update is on by default and off when the operator pinned an image or opted out. */
56
+ export const isFormalAiAutoUpdateEnabled = (env = process.env) => {
57
+ if (String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim()) return false;
58
+ const raw = String(env.HIVE_MIND_FORMAL_AI_AUTO_UPDATE ?? '')
59
+ .trim()
60
+ .toLowerCase();
61
+ if (!raw) return true;
62
+ return !['0', 'false', 'no', 'off'].includes(raw);
63
+ };
64
+
65
+ /**
66
+ * Extract the first JSON object in a command's output.
67
+ *
68
+ * The published image's DinD entrypoint can print its own banner before the
69
+ * command runs, so the payload is not always the whole of stdout.
70
+ */
71
+ export const parseJsonBlock = text => {
72
+ const raw = String(text ?? '');
73
+ const start = raw.indexOf('{');
74
+ const end = raw.lastIndexOf('}');
75
+ if (start < 0 || end <= start) throw new Error(`Expected a JSON object in Formal AI output but found none: ${raw.slice(0, 400)}`);
76
+ return JSON.parse(raw.slice(start, end + 1));
77
+ };
78
+
79
+ /** `parseJsonBlock` for output that is allowed to contain no JSON at all. */
80
+ export const tryParseJsonBlock = text => {
81
+ try {
82
+ return parseJsonBlock(text);
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Human-readable one-liner for the two refusal shapes the memory CLI prints:
90
+ * an upgrade status with `refusal_code`/`refusal_reason`, and a migration
91
+ * error object with `code`/`message`.
92
+ */
93
+ export const describeMemoryRefusal = payload => {
94
+ if (!payload || typeof payload !== 'object') return null;
95
+ if (payload.error?.code || payload.error?.message) return `${payload.error.code ?? 'error'}: ${payload.error.message ?? 'no message given'}`;
96
+ if (payload.refusal_code || payload.refusal_reason) return `${payload.refusal_code ?? 'incompatible'}: ${payload.refusal_reason ?? 'no reason given'}`;
97
+ return null;
98
+ };
99
+
100
+ const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
101
+ const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
102
+ return String(result?.stdout ?? '').trim();
103
+ };
104
+
105
+ /**
106
+ * Run one `formal-ai memory …` subcommand against the persisted-memory volume
107
+ * using a throwaway container of the given image.
108
+ *
109
+ * The inner Docker daemon is skipped (`DIND_SKIP_DAEMON=1`) and the image's own
110
+ * entrypoint is kept, so the command runs as `box` and can read the volume.
111
+ */
112
+ export const runFormalAiMemoryCommand = async (image, memoryArgs, { run = execFileAsync, timeoutMs } = {}) => {
113
+ const args = ['run', '--rm', '--env', 'DIND_SKIP_DAEMON=1', '--env', `FORMAL_AI_MEMORY_PATH=${FORMAL_AI_MEMORY_PATH}`, '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, 'formal-ai', 'memory', ...memoryArgs];
114
+ try {
115
+ return parseJsonBlock(await dockerText(run, args, { timeoutMs }));
116
+ } catch (error) {
117
+ // A refusal is *also* a documented answer: `memory upgrade-status` and
118
+ // `memory migrate` print their JSON on stdout and only then exit nonzero.
119
+ // Re-throwing the bare process error would discard the refusal code the
120
+ // operator needs, so the payload travels with the failure.
121
+ const payload = tryParseJsonBlock(error?.stdout);
122
+ if (!payload) throw error;
123
+ const failure = new Error(describeMemoryRefusal(payload) || error?.message || 'Formal AI refused the persisted memory');
124
+ failure.payload = payload;
125
+ throw failure;
126
+ }
127
+ };
128
+
129
+ /** Byte-exact SHA-256 of a file inside the memory volume, or null when it is absent. */
130
+ export const readMemoryFileSha256 = async (image, filePath, { run = execFileAsync, timeoutMs } = {}) => {
131
+ try {
132
+ const raw = await dockerText(run, ['run', '--rm', '--entrypoint', 'sha256sum', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, filePath], { timeoutMs });
133
+ return raw.split(/\s+/)[0] || null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Restore the byte-exact backup recorded in a migration receipt.
141
+ *
142
+ * `cp` + `sync` is the rollback strategy the upgrade contract documents
143
+ * (`rollback_strategy: "restore_backup_path"`). Success is only claimed when
144
+ * the restored file hashes to the receipt's `original_sha256`.
145
+ */
146
+ export const rollbackFormalAiMemory = async ({ image, receipt, run = execFileAsync, timeoutMs, log = null } = {}) => {
147
+ const backupPath = receipt?.backup_path;
148
+ if (!backupPath) return { restored: false, verified: false, error: 'the migration receipt named no backup path' };
149
+
150
+ const memoryPath = receipt.memory_path || FORMAL_AI_MEMORY_PATH;
151
+ try {
152
+ await dockerText(run, ['run', '--rm', '--entrypoint', 'sh', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, '-c', `cp -- '${backupPath}' '${memoryPath}' && sync`], { timeoutMs });
153
+ } catch (error) {
154
+ return { restored: false, verified: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
155
+ }
156
+
157
+ const restoredSha = await readMemoryFileSha256(image, memoryPath, { run, timeoutMs });
158
+ const verified = Boolean(receipt.original_sha256) && restoredSha === receipt.original_sha256;
159
+ if (log) await log(verified ? `↩️ Formal AI memory restored byte-exactly from ${backupPath} (sha256 ${restoredSha})` : `🚨 Formal AI memory rollback could not be verified: expected sha256 ${receipt.original_sha256}, found ${restoredSha}`);
160
+ return { restored: true, verified, sha256: restoredSha, error: verified ? null : 'restored file did not match the receipt SHA-256' };
161
+ };
162
+
163
+ /**
164
+ * Boot the sidecar on `image`, wait for `/health`, then stop it again.
165
+ *
166
+ * This is how an update proves the new binary can actually serve the migrated
167
+ * memory before the change is accepted.
168
+ */
169
+ const verifyImageServesMemory = async ({ image, env, fsImpl, run, timeoutMs, log, verbose, sleepImpl, healthAttempts, healthDelayMs }) => {
170
+ await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
171
+ const existing = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
172
+ if (existing.exists) await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log: null, verbose, reason: 'verification restart' });
173
+
174
+ await dockerText(run, buildFormalAiSidecarRunArgs({ image, env }), { timeoutMs });
175
+ const health = await waitForFormalAiSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
176
+ const memory = health.health?.memory ?? null;
177
+ const ok = health.healthy && memory?.compatible !== false && memory?.migration_required !== true;
178
+ await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log: null, verbose, reason: 'verification complete' });
179
+ return { ok, health: health.health, error: ok ? null : health.error || `memory not ready after update (state=${memory?.migration_state ?? 'unknown'}, required=${memory?.migration_required ?? 'unknown'})` };
180
+ };
181
+
182
+ /**
183
+ * Update the Formal AI sidecar image, but only while no Formal AI task holds a
184
+ * lease.
185
+ *
186
+ * @returns {Promise<{status: 'disabled'|'busy'|'up-to-date'|'updated'|'rolled-back'|'failed', ...}>}
187
+ */
188
+ export const updateFormalAiSidecarWhenIdle = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, pullTimeoutMs = DEFAULT_PULL_TIMEOUT_MS, log = null, verbose = false, sleepImpl, healthAttempts, healthDelayMs, now = () => new Date(), lockOptions = {} } = {}) => {
189
+ if (!isFormalAiAutoUpdateEnabled(env)) {
190
+ if (verbose && log) await log('[VERBOSE] formal-ai-updater: auto-update disabled (operator pin or HIVE_MIND_FORMAL_AI_AUTO_UPDATE=0)');
191
+ return { status: 'disabled', reason: String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim() ? 'HIVE_MIND_FORMAL_AI_IMAGE pins a specific build' : 'HIVE_MIND_FORMAL_AI_AUTO_UPDATE is off' };
192
+ }
193
+
194
+ return withFormalAiSidecarLock(
195
+ async () => {
196
+ const { leaseCount } = await reconcileFormalAiSidecar({ env, fsImpl, run, timeoutMs, log, verbose });
197
+ if (leaseCount > 0) {
198
+ if (verbose && log) await log(`[VERBOSE] formal-ai-updater: ${leaseCount} Formal AI task(s) running; deferring the update`);
199
+ return { status: 'busy', leaseCount };
200
+ }
201
+
202
+ const image = resolveFormalAiUpdateImage(env);
203
+ const previousDigest = await readDockerImageDigest(image, { run, timeoutMs });
204
+
205
+ if (log) await log(`⬇️ Checking for a newer Formal AI image (${image})`);
206
+ try {
207
+ await dockerText(run, ['pull', '--quiet', image], { timeoutMs: pullTimeoutMs });
208
+ } catch (error) {
209
+ const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
210
+ if (log) await log(`⚠️ Could not pull ${image}; keeping the current Formal AI image: ${message}`);
211
+ return { status: 'failed', stage: 'pull', image, error: message };
212
+ }
213
+
214
+ const pulledDigest = await readDockerImageDigest(image, { run, timeoutMs });
215
+ const state = readFormalAiSidecarState({ env, fsImpl });
216
+ const runningDigest = state.imageDigest || previousDigest;
217
+ if (pulledDigest && pulledDigest === runningDigest) {
218
+ if (verbose && log) await log(`[VERBOSE] formal-ai-updater: ${image} already at ${pulledDigest}`);
219
+ return { status: 'up-to-date', image, digest: pulledDigest };
220
+ }
221
+
222
+ // Memory must not be touched while a container has it open.
223
+ await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log, verbose, reason: 'updating the Formal AI image' });
224
+ await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
225
+
226
+ let preflight;
227
+ try {
228
+ preflight = await runFormalAiMemoryCommand(image, ['upgrade-status', '--path', FORMAL_AI_MEMORY_PATH, '--format', 'json'], { run, timeoutMs });
229
+ } catch (error) {
230
+ const message = error?.payload ? error.message : error?.stderr?.toString?.().trim() || error?.message || String(error);
231
+ if (log) await log(`🚨 Formal AI ${image} refused the persisted memory during preflight; keeping ${runningDigest ?? 'the current image'}: ${message}`);
232
+ return { status: 'failed', stage: 'preflight', image, digest: pulledDigest, preflight: error?.payload ?? null, error: message };
233
+ }
234
+
235
+ if (preflight.compatible === false) {
236
+ if (log) await log(`🚨 Formal AI ${image} reports the persisted memory as incompatible (${preflight.refusal_code ?? 'unknown'}: ${preflight.refusal_reason ?? 'no reason given'}); update abandoned`);
237
+ return { status: 'failed', stage: 'preflight', image, digest: pulledDigest, preflight, error: preflight.refusal_reason || 'incompatible memory' };
238
+ }
239
+
240
+ let receipt = null;
241
+ if (preflight.migration_required && preflight.path_exists) {
242
+ const backupPath = `${FORMAL_AI_MEMORY_MOUNT}/memory.lino.schema-${preflight.detected_schema_version ?? 'unknown'}.bak`;
243
+ const receiptPath = `${FORMAL_AI_MEMORY_MOUNT}/memory-upgrade-receipt.json`;
244
+ if (log) await log(`🧬 Migrating Formal AI memory from schema ${preflight.detected_schema_version} to ${preflight.target_schema_version} (${preflight.migration_id ?? 'unnamed migration'})`);
245
+ try {
246
+ receipt = await runFormalAiMemoryCommand(image, ['migrate', '--path', FORMAL_AI_MEMORY_PATH, '--backup', backupPath, '--receipt', receiptPath, '--format', 'json'], { run, timeoutMs });
247
+ } catch (error) {
248
+ const message = error?.payload ? error.message : error?.stderr?.toString?.().trim() || error?.message || String(error);
249
+ // The contract is refuse-or-succeed: a failed migrate leaves the file alone.
250
+ if (log) await log(`🚨 Formal AI memory migration refused to modify the file; keeping the current image: ${message}`);
251
+ return { status: 'failed', stage: 'migrate', image, digest: pulledDigest, preflight, refusal: error?.payload ?? null, error: message };
252
+ }
253
+
254
+ // A preflight that mutated the file would break the contract's core
255
+ // promise, so prove it did not before trusting the rest.
256
+ if (preflight.source_sha256 && receipt.original_sha256 && preflight.source_sha256 !== receipt.original_sha256) {
257
+ if (log) await log(`🚨 Formal AI preflight changed the memory file (${preflight.source_sha256} → ${receipt.original_sha256}); rolling back`);
258
+ const rollback = await rollbackFormalAiMemory({ image, receipt, run, timeoutMs, log });
259
+ return { status: 'rolled-back', stage: 'preflight-side-effect', image, digest: pulledDigest, preflight, receipt, rollback };
260
+ }
261
+ if (verbose && log) await log(`[VERBOSE] formal-ai-updater: migration ${receipt.migration_id} ${receipt.from_schema_version}→${receipt.to_schema_version}, ${receipt.event_count} event(s), backup ${receipt.backup_path}, rollback=${receipt.rollback_supported}`);
262
+ } else if (verbose && log) {
263
+ await log(`[VERBOSE] formal-ai-updater: no memory migration required (schema ${preflight.detected_schema_version ?? 'none'}, state ${preflight.migration_state})`);
264
+ }
265
+
266
+ const verification = await verifyImageServesMemory({ image, env, fsImpl, run, timeoutMs, log, verbose, sleepImpl, healthAttempts, healthDelayMs });
267
+ if (!verification.ok) {
268
+ if (log) await log(`🚨 Formal AI ${image} could not serve the persisted memory after the update: ${verification.error}`);
269
+ const rollback = receipt ? await rollbackFormalAiMemory({ image, receipt, run, timeoutMs, log }) : { restored: false, verified: true, error: null };
270
+ return { status: 'rolled-back', stage: 'verify', image, digest: pulledDigest, preflight, receipt, rollback, error: verification.error };
271
+ }
272
+
273
+ const updatedAt = now().toISOString();
274
+ writeFormalAiSidecarState({ ...readFormalAiSidecarState({ env, fsImpl }), image, imageDigest: pulledDigest, lastUpdate: { image, digest: pulledDigest, previousDigest: runningDigest, version: verification.health?.version ?? null, migrationId: receipt?.migration_id ?? null, memorySchemaVersion: verification.health?.memory?.schema_version ?? null, updatedAt } }, { env, fsImpl });
275
+
276
+ if (log) await log(`✅ Formal AI updated to ${verification.health?.version ?? pulledDigest}${receipt ? ` (memory migrated ${receipt.from_schema_version}→${receipt.to_schema_version}, backup ${receipt.backup_path})` : ''}; the sidecar stays stopped until a Formal AI task needs it`);
277
+ return { status: 'updated', image, digest: pulledDigest, previousDigest: runningDigest, preflight, receipt, health: verification.health, updatedAt };
278
+ },
279
+ { env, fsImpl, sleepImpl, log, ...lockOptions }
280
+ );
281
+ };
282
+
283
+ export default {
284
+ FORMAL_AI_UPDATE_TAG,
285
+ describeMemoryRefusal,
286
+ isFormalAiAutoUpdateEnabled,
287
+ parseJsonBlock,
288
+ tryParseJsonBlock,
289
+ readMemoryFileSha256,
290
+ resolveFormalAiUpdateImage,
291
+ rollbackFormalAiMemory,
292
+ runFormalAiMemoryCommand,
293
+ updateFormalAiSidecarWhenIdle,
294
+ };
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Formal AI 0.326.1 fixed plans that were recorded but never executed and
10
+ * 0.333.2 added the tool-result evidence fixes required by issues #2119/#2130.
11
+ * 0.336.0 is the first release that answers formal-ai#982: `memory
12
+ * upgrade-status`, `memory migrate --backup --receipt`, and the `/health`
13
+ * memory compatibility block. Hive Mind now replaces the Formal AI container
14
+ * while it is idle, so an unattended non-destructive memory upgrade is part of
15
+ * the baseline rather than an optional extra.
16
+ */
17
+ export const FORMAL_AI_MINIMUM_VERSION = '0.336.0';
18
+
19
+ /**
20
+ * The first release exposing the persisted-memory upgrade contract. Kept
21
+ * separate from {@link FORMAL_AI_MINIMUM_VERSION} so the container updater can
22
+ * state precisely why a candidate image is refused even if the run-time floor
23
+ * later moves for an unrelated reason.
24
+ */
25
+ export const FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION = '0.336.0';
26
+
27
+ /**
28
+ * The version baked into Hive Mind's images. Per the maintainer's review on
29
+ * PR #2147 this is the *initial* pin only: once the container is running,
30
+ * `src/formal-ai-updater.lib.mjs` replaces it with the newest published image
31
+ * while no Formal AI task holds a lease. 0.339.0 restored `cargo install
32
+ * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routes
33
+ * command execution through the published command-stream component; the
34
+ * memory-contract sources (`src/cli_memory.rs`, `src/server.rs`,
35
+ * `src/shared_memory.rs`) are byte-identical to 0.337.0 and
36
+ * `src/memory/upgrade.rs` only adds an explicit advisory-lock release.
37
+ */
38
+ export const FORMAL_AI_BOOTSTRAP_VERSION = '0.339.1';
39
+
40
+ export const parseFormalAiVersion = stdout => {
41
+ const line = String(stdout || '')
42
+ .split('\n')
43
+ .map(entry => entry.trim())
44
+ .find(Boolean);
45
+ if (!line) return null;
46
+ return line.replace(/^formal-ai\s+/i, '').trim() || null;
47
+ };
48
+
49
+ /** Read a Formal AI binary's version without allowing the probe to throw. */
50
+ export const readFormalAiBinaryVersion = async ({ formalAiPath = 'formal-ai', env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
51
+ try {
52
+ const result = await run(formalAiPath, ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
53
+ return parseFormalAiVersion(result?.stdout ?? result);
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+
59
+ const parseComparableVersion = version => {
60
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version || ''));
61
+ if (!match) return null;
62
+ return { core: match.slice(1, 4).map(Number), prerelease: match[4] || null };
63
+ };
64
+
65
+ export const isFormalAiVersionAtLeast = (version, minimumVersion) => {
66
+ const candidate = parseComparableVersion(version);
67
+ const minimum = parseComparableVersion(minimumVersion);
68
+ if (!candidate || !minimum) return false;
69
+ for (let index = 0; index < candidate.core.length; index += 1) {
70
+ if (candidate.core[index] !== minimum.core[index]) return candidate.core[index] > minimum.core[index];
71
+ }
72
+ // A prerelease is lower than the stable release with the same numeric core.
73
+ if (candidate.prerelease && !minimum.prerelease) return false;
74
+ if (!candidate.prerelease && minimum.prerelease) return true;
75
+ return !candidate.prerelease || candidate.prerelease >= minimum.prerelease;
76
+ };
77
+
78
+ /** Reject unknown and stale binaries before a model server or native CLI starts. */
79
+ export const assertSupportedFormalAiVersion = (version, minimumVersion = FORMAL_AI_MINIMUM_VERSION) => {
80
+ if (!version) {
81
+ throw new Error(`Could not determine the Formal AI version; Hive Mind requires Formal AI >= ${minimumVersion}. Check HIVE_MIND_FORMAL_AI_PATH and upgrade Formal AI.`);
82
+ }
83
+ if (!parseComparableVersion(version)) {
84
+ throw new Error(`Hive Mind requires Formal AI >= ${minimumVersion}, but formal-ai --version returned an invalid version: ${version}`);
85
+ }
86
+ if (!isFormalAiVersionAtLeast(version, minimumVersion)) {
87
+ throw new Error(`Hive Mind requires Formal AI >= ${minimumVersion}, found ${version}. Upgrade Formal AI before retrying.`);
88
+ }
89
+ return version;
90
+ };
91
+
92
+ export default {
93
+ assertSupportedFormalAiVersion,
94
+ FORMAL_AI_BOOTSTRAP_VERSION,
95
+ FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION,
96
+ FORMAL_AI_MINIMUM_VERSION,
97
+ isFormalAiVersionAtLeast,
98
+ parseFormalAiVersion,
99
+ readFormalAiBinaryVersion,
100
+ };
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
 
6
6
  import { findFormalAiClient, loadFormalAiClientRegistry, prepareFormalAiRuntime } from './formal-ai-runtime.lib.mjs';
7
+ import { assertSupportedFormalAiVersion, parseFormalAiVersion, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
7
8
  import { isFormalAiModel } from './models/index.mjs';
8
9
 
9
10
  const execFileAsync = promisify(execFile);
@@ -112,28 +113,16 @@ export const buildFormalAiEnvExports = env =>
112
113
  * `formal-ai --version` prints a line such as "formal-ai 0.317.0"; keep only the
113
114
  * version so the log records a value that can be compared against a release.
114
115
  */
115
- export const parseFormalAiVersion = stdout => {
116
- const line = String(stdout || '')
117
- .split('\n')
118
- .map(entry => entry.trim())
119
- .find(Boolean);
120
- if (!line) return null;
121
- return line.replace(/^formal-ai\s+/i, '').trim() || null;
122
- };
116
+ export { parseFormalAiVersion };
123
117
 
124
118
  /**
125
119
  * The wrapper's behaviour changes between releases — issue #2130's round-2
126
120
  * failures could not be pinned to a mechanism because no log recorded which
127
- * wrapper produced them. Ask the wrapper for its version, but never let that
128
- * question decide whether the run may proceed.
121
+ * wrapper produced them. This shared reader remains non-throwing so connection
122
+ * validation and runtime preparation can apply one actionable support policy.
129
123
  */
130
124
  export const readFormalAiVersion = async ({ env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
131
- try {
132
- const result = await run(resolveFormalAiPath(env), ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
133
- return parseFormalAiVersion(result?.stdout);
134
- } catch {
135
- return null;
136
- }
125
+ return readFormalAiBinaryVersion({ formalAiPath: resolveFormalAiPath(env), env, run, timeoutMs });
137
126
  };
138
127
 
139
128
  /**
@@ -145,6 +134,12 @@ export const validateFormalAiToolConnection = async (tool, { env = process.env,
145
134
  const args = ['clients', '--format', 'json'];
146
135
  const formalAiVersion = await readFormalAiVersion({ env, run, timeoutMs });
147
136
 
137
+ try {
138
+ assertSupportedFormalAiVersion(formalAiVersion);
139
+ } catch (error) {
140
+ return { valid: false, command, args, formalAiVersion, error: error.message };
141
+ }
142
+
148
143
  let clients;
149
144
  try {
150
145
  clients = await loadFormalAiClientRegistry({ formalAiPath: command, run, env, timeoutMs });
@@ -462,6 +462,9 @@ export const execGhWithRetry = async (command, options = {}) => {
462
462
  * @returns {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>}
463
463
  */
464
464
  export const wrapDollarWithGhRetry = (dollar, options = {}) => {
465
+ if (typeof dollar !== 'function') {
466
+ throw new TypeError(`Expected command-stream's $ export to be a function, received ${typeof dollar}. Enable HIVE_MIND_USE_M_DEBUG=1 for loader diagnostics.`);
467
+ }
465
468
  const wrapped = (strings, ...values) => {
466
469
  // Options-call form: `$({ mirror: false })` returns a new tag bound to
467
470
  // those options. Template literals always arrive as an array of quasis, so