@heretek-ai/epistemic-swarm 0.7.18 → 0.7.19

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 (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/antigravity/plugin.json +1 -1
  4. package/plugins/opencode/config-io.js +1041 -0
  5. package/plugins/opencode/index.js +129 -10
  6. package/plugins/opencode/tui.js +771 -0
  7. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  28. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  29. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  58. package/runner/tests/test_opencode_ux.py +756 -2
  59. package/runner/tests/test_opencode_v2_registrars.py +256 -0
  60. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  61. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  62. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  63. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  64. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  66. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  67. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  68. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  69. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  70. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  71. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
@@ -0,0 +1,1041 @@
1
+ /**
2
+ * IUMBTEMS Epistemic Swarm — dependency-free config read/validate/format/write.
3
+ *
4
+ * Direct-fs fallback for the settings surface (the phase-03 RPC bridge later
5
+ * keeps this path as its offline fallback). No dependencies beyond
6
+ * node:fs/node:path/node:crypto/node:os.
7
+ *
8
+ * Semantics mirror `skills/swarm_config/configure.py` (phase 01 contract):
9
+ * - Validation is derived from `schemas/config.schema.json` (the generated
10
+ * artifact): the dependency-free subset type/enum/minimum/maximum/minLength/
11
+ * pattern (+ required/properties/items/additionalProperties) is interpreted
12
+ * by a line-for-line port of `runner/schema_validate.py`. Unknown keys are
13
+ * allowed and preserved; null is accepted only for nullable keys.
14
+ * - `saveConfig` merges over whatever is on disk, validates, and publishes via
15
+ * a unique temp file + rename. A crash between temp-write and rename leaves
16
+ * the original byte-identical (the temp is unlinked on failure).
17
+ * - Single-writer discipline over `.research/.config.lock`: O_EXCL create with
18
+ * a pid+timestamp payload; a live lock is respected (CONFIG_LOCKED, nothing
19
+ * written); a stale lock (> 5s) is taken over. The lock is released (unlinked
20
+ * when still ours) after every save attempt, so the steady state is lockless.
21
+ *
22
+ * RESIDUAL WINDOW (documented, negligible): the Python writer holds its lock
23
+ * via fcntl.flock on the same path but never writes a pid+timestamp payload
24
+ * and never unlinks. A lockfile without our payload therefore reads as
25
+ * foreign/stale and is ADOPTED BY OVERWRITING (never by unlinking) — so a
26
+ * live Python holder can still be adopted if its write spans the adoption.
27
+ * Python writes are millisecond-scale read-merge-write cycles under its own
28
+ * flock (which still serializes Python-side writers), so the window is
29
+ * negligible; human-paced TUI saves will essentially never land inside it.
30
+ * `release()` unlinks ONLY when the on-disk payload's pid is our own pid —
31
+ * a foreign live lock is never removed, and a lock adopted away from us by
32
+ * a concurrent takeover is left intact.
33
+ * - Optional expected-hash guard (both `expected_hash` and `expectedHash`
34
+ * spellings; both present must agree or the call is rejected with
35
+ * CONFLICTING_EXPECTED_HASH). Full SHA-256 or a prefix of at least 8 hex
36
+ * chars; a mismatch raises CONFIG_STALE with a fresh snapshot and writes
37
+ * nothing. A malformed guard raises CONFIG_INVALID_EXPECTED_HASH.
38
+ * - Every function is pure/defensive: an absent or malformed workspace never
39
+ * throws — readers return defaults/null and writers surface structured
40
+ * errors instead of exceptions from fs reads.
41
+ */
42
+
43
+ import { createHash, randomBytes } from 'node:crypto';
44
+ import {
45
+ existsSync,
46
+ mkdirSync,
47
+ readFileSync,
48
+ renameSync,
49
+ unlinkSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import path from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+
55
+ const __filename = fileURLToPath(import.meta.url);
56
+ const __dirname = path.dirname(__filename);
57
+ const PKG_ROOT = path.resolve(__dirname, '../..');
58
+
59
+ export const DEFAULT_RESEARCH_DIR = '.research';
60
+ export const CONFIG_FILE_NAME = 'config.json';
61
+ export const CONFIG_LOCK_NAME = '.config.lock';
62
+ /** A lock older than this is taken over (mirrors the documented 5s rule). */
63
+ export const LOCK_STALE_MS = 5000;
64
+
65
+ /** Legacy per-role backend pins (pre-0.7.6 default persisted verbatim). */
66
+ export const LEGACY_AGENT_BACKEND_PINS = [['claude', '-p']];
67
+ export const AGENT_ROLES = ['alpha', 'beta'];
68
+
69
+ /**
70
+ * Env vars that override a file value at runtime (effective source becomes
71
+ * `env-override`). Verified against runner/research_swarm.py + preflight.py:
72
+ * SEARXNG_URL, IUMBTEMS_FETCH_TTL_DAYS, IUMBTEMS_SEARCH_TIMEOUT_S,
73
+ * IUMBTEMS_CACHE_RAW_MARKDOWN, IUMBTEMS_OPENCODE_AUTO,
74
+ * IUMBTEMS_BACKEND_<ROLE>, IUMBTEMS_MODEL_<ROLE>.
75
+ */
76
+ export const ENV_OVERRIDES = {
77
+ searxng_url: ['SEARXNG_URL'],
78
+ cache_ttl_days: ['IUMBTEMS_FETCH_TTL_DAYS'],
79
+ search_timeout_s: ['IUMBTEMS_SEARCH_TIMEOUT_S'],
80
+ cache_raw_markdown: ['IUMBTEMS_CACHE_RAW_MARKDOWN'],
81
+ opencode_auto: ['IUMBTEMS_OPENCODE_AUTO'],
82
+ agents: [
83
+ 'IUMBTEMS_BACKEND_ALPHA',
84
+ 'IUMBTEMS_BACKEND_BETA',
85
+ 'IUMBTEMS_MODEL_ALPHA',
86
+ 'IUMBTEMS_MODEL_BETA',
87
+ ],
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // structured errors (mirror configure.py codes/messages)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export class ConfigError extends Error {
95
+ constructor(message, details = {}) {
96
+ super(message);
97
+ this.name = 'ConfigError';
98
+ const { code, ...rest } = details || {};
99
+ this.code = code || 'CONFIG_ERROR';
100
+ this.message = message;
101
+ this.details = { ...(rest || {}) };
102
+ }
103
+
104
+ toDict() {
105
+ const out = { code: this.code, message: this.message };
106
+ if (this.details && Object.keys(this.details).length > 0) {
107
+ out.details = this.details;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ toString() {
113
+ const lines = [`[${this.code}] ${this.message}`];
114
+ for (const problem of this.details.errors || []) {
115
+ lines.push(` - ${problem}`);
116
+ }
117
+ if (this.details.current_hash) {
118
+ lines.push(` current_hash: ${this.details.current_hash}`);
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+ }
123
+
124
+ export class ConfigValidationError extends ConfigError {
125
+ constructor(problems) {
126
+ const errors = (problems || []).map(String);
127
+ super(
128
+ `Config validation failed with ${errors.length} violation(s); nothing written.`,
129
+ { errors }
130
+ );
131
+ this.name = 'ConfigValidationError';
132
+ this.code = 'CONFIG_VALIDATION_FAILED';
133
+ }
134
+
135
+ get errors() {
136
+ return [...(this.details.errors || [])];
137
+ }
138
+ }
139
+
140
+ export class ConfigStaleError extends ConfigError {
141
+ constructor(expectedHash, currentHash, snapshot = {}) {
142
+ const details = {
143
+ expected_hash: expectedHash,
144
+ current_hash: currentHash,
145
+ written: false,
146
+ ...(snapshot || {}),
147
+ };
148
+ super(
149
+ 'Config changed on disk since it was read; refusing the stale write. ' +
150
+ 'Re-read the fresh snapshot and retry.',
151
+ details
152
+ );
153
+ this.name = 'ConfigStaleError';
154
+ this.code = 'CONFIG_STALE';
155
+ }
156
+ }
157
+
158
+ export class ConfigHashError extends ConfigError {
159
+ constructor(expectedHash) {
160
+ super(
161
+ 'expected_hash must be 8-64 hex characters (a full SHA-256 or a ' +
162
+ 'unique prefix of at least 8 hex chars).',
163
+ { expected_hash: String(expectedHash) }
164
+ );
165
+ this.name = 'ConfigHashError';
166
+ this.code = 'CONFIG_INVALID_EXPECTED_HASH';
167
+ }
168
+ }
169
+
170
+ /** A live `.config.lock` is held by another writer; nothing was written. */
171
+ export class ConfigLockedError extends ConfigError {
172
+ constructor(details = {}) {
173
+ super(
174
+ 'Another writer holds .research/.config.lock; refusing to clobber a ' +
175
+ 'live write. Retry once it is released (stale locks are taken over ' +
176
+ 'automatically after 5s).',
177
+ { written: false, ...(details || {}) }
178
+ );
179
+ this.name = 'ConfigLockedError';
180
+ this.code = 'CONFIG_LOCKED';
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // schema loading (derived from the generated artifact, never hand-copied)
186
+ // ---------------------------------------------------------------------------
187
+
188
+ let cachedSchema = null;
189
+ let schemaAttempted = false;
190
+
191
+ function schemaArtifactPath() {
192
+ return path.join(PKG_ROOT, 'schemas', 'config.schema.json');
193
+ }
194
+
195
+ /**
196
+ * The canonical CONFIG schema (generated artifact). Returns null when
197
+ * unreadable — validation then degrades to [] with a warning, mirroring the
198
+ * standalone-skill path in configure.py. Never throws.
199
+ */
200
+ export function loadConfigSchema() {
201
+ if (schemaAttempted) return cachedSchema;
202
+ schemaAttempted = true;
203
+ try {
204
+ const raw = readFileSync(schemaArtifactPath(), 'utf-8');
205
+ const parsed = JSON.parse(raw);
206
+ if (parsed && typeof parsed === 'object') cachedSchema = parsed;
207
+ } catch {
208
+ try {
209
+ console.error(
210
+ '[iumbtems config-io] Warning: schemas/config.schema.json unreadable; ' +
211
+ 'skipping canonical validation.'
212
+ );
213
+ } catch {
214
+ /* logging must never throw */
215
+ }
216
+ cachedSchema = null;
217
+ }
218
+ return cachedSchema;
219
+ }
220
+
221
+ /** Test hook: forget the cached schema so the next load re-reads disk. */
222
+ export function _resetSchemaCache() {
223
+ cachedSchema = null;
224
+ schemaAttempted = false;
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // dependency-free validator subset (port of runner/schema_validate.py)
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function typeMatches(value, expected) {
232
+ switch (expected) {
233
+ case 'integer':
234
+ return typeof value === 'number' && Number.isInteger(value);
235
+ case 'number':
236
+ // NaN passes the type check and is rejected by the finiteness check,
237
+ // exactly like the Python port (bool can never reach here in JS).
238
+ return typeof value === 'number';
239
+ case 'boolean':
240
+ return typeof value === 'boolean';
241
+ case 'string':
242
+ return typeof value === 'string';
243
+ case 'array':
244
+ return Array.isArray(value);
245
+ case 'object':
246
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
247
+ case 'null':
248
+ return value === null;
249
+ default:
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /** Python type() names, so messages match schema_validate.py wording. */
255
+ function typeName(value) {
256
+ if (value === null || value === undefined) return 'NoneType';
257
+ if (Array.isArray(value)) return 'list';
258
+ if (typeof value === 'number') {
259
+ return Number.isInteger(value) ? 'int' : 'float';
260
+ }
261
+ if (typeof value === 'string') return 'str';
262
+ if (typeof value === 'boolean') return 'bool';
263
+ if (typeof value === 'object') return 'dict';
264
+ return typeof value;
265
+ }
266
+
267
+ /** Python repr() for scalars/collections (single quotes, True/None). */
268
+ function pyRepr(value) {
269
+ if (typeof value === 'string') {
270
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
271
+ }
272
+ if (value === null || value === undefined) return 'None';
273
+ if (value === true) return 'True';
274
+ if (value === false) return 'False';
275
+ if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
276
+ if (typeof value === 'object') {
277
+ const parts = Object.entries(value).map(
278
+ ([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`
279
+ );
280
+ return `{${parts.join(', ')}}`;
281
+ }
282
+ if (typeof value === 'number') {
283
+ if (Number.isNaN(value)) return 'nan';
284
+ if (value === Infinity) return 'inf';
285
+ if (value === -Infinity) return '-inf';
286
+ }
287
+ return String(value);
288
+ }
289
+
290
+ function pyReprEnum(values) {
291
+ return `[${(values || []).map(pyRepr).join(', ')}]`;
292
+ }
293
+
294
+ /**
295
+ * Human-readable violations (dotted paths); [] == valid. Ports
296
+ * `runner/schema_validate.py:validate` keyword for keyword.
297
+ */
298
+ export function validateAgainstSchema(data, schema, currentPath = '') {
299
+ if (!schema || typeof schema !== 'object') return [];
300
+ const problems = [];
301
+ const at = currentPath || '<root>';
302
+
303
+ const expected = schema.type;
304
+ if (expected !== undefined) {
305
+ const types = Array.isArray(expected) ? expected : [expected];
306
+ if (!types.some((t) => typeMatches(data, t))) {
307
+ return [`${at}: expected ${types.join('|')}, got ${typeName(data)}`];
308
+ }
309
+ }
310
+
311
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
312
+ for (const key of schema.required || []) {
313
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
314
+ problems.push(`${at}: missing required key '${key}'`);
315
+ }
316
+ }
317
+ const props = schema.properties || {};
318
+ for (const key of Object.keys(props)) {
319
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
320
+ const child = currentPath ? `${currentPath}.${key}` : key;
321
+ problems.push(...validateAgainstSchema(data[key], props[key], child));
322
+ }
323
+ }
324
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
325
+ const known = new Set(Object.keys(props));
326
+ for (const key of Object.keys(data)) {
327
+ if (!known.has(key)) {
328
+ const child = currentPath ? `${currentPath}.${key}` : key;
329
+ problems.push(
330
+ ...validateAgainstSchema(data[key], schema.additionalProperties, child)
331
+ );
332
+ }
333
+ }
334
+ }
335
+ if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
336
+ problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
337
+ }
338
+ }
339
+
340
+ if (Array.isArray(data) && schema.items && typeof schema.items === 'object') {
341
+ for (let i = 0; i < data.length; i += 1) {
342
+ problems.push(...validateAgainstSchema(data[i], schema.items, `${at}[${i}]`));
343
+ }
344
+ }
345
+
346
+ if (data === null || typeof data !== 'object') {
347
+ if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
348
+ problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
349
+ }
350
+ }
351
+
352
+ if (typeof data === 'number') {
353
+ if (!Number.isFinite(data)) {
354
+ problems.push(`${at}: value ${pyRepr(data)} is not finite`);
355
+ } else {
356
+ if (schema.minimum !== undefined && data < schema.minimum) {
357
+ problems.push(`${at}: value ${pyRepr(data)} is below minimum ${pyRepr(schema.minimum)}`);
358
+ }
359
+ if (schema.maximum !== undefined && data > schema.maximum) {
360
+ problems.push(`${at}: value ${pyRepr(data)} is above maximum ${pyRepr(schema.maximum)}`);
361
+ }
362
+ }
363
+ }
364
+
365
+ if (typeof data === 'string') {
366
+ if (schema.minLength !== undefined && data.length < schema.minLength) {
367
+ problems.push(`${at}: string shorter than minLength ${pyRepr(schema.minLength)}`);
368
+ }
369
+ if (typeof schema.pattern === 'string') {
370
+ let matched = false;
371
+ try {
372
+ matched = new RegExp(schema.pattern).test(data);
373
+ } catch {
374
+ matched = true; // an invalid pattern must never fail a valid value
375
+ }
376
+ if (!matched) {
377
+ problems.push(
378
+ `${at}: value ${pyRepr(data)} does not match pattern ${pyRepr(schema.pattern)}`
379
+ );
380
+ }
381
+ }
382
+ }
383
+
384
+ return problems;
385
+ }
386
+
387
+ /** Violations of the canonical CONFIG schema; [] == valid. Never throws. */
388
+ export function validateConfig(cfg) {
389
+ try {
390
+ const schema = loadConfigSchema();
391
+ if (!schema) return [];
392
+ return validateAgainstSchema(cfg, schema);
393
+ } catch {
394
+ return [];
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // defaults / merging / legacy-pin migration (ports of configure.py)
400
+ // ---------------------------------------------------------------------------
401
+
402
+ function deepCopy(value) {
403
+ if (value === undefined) return undefined;
404
+ return JSON.parse(JSON.stringify(value));
405
+ }
406
+
407
+ /**
408
+ * Fallback defaults used ONLY when the schema artifact is unreadable.
409
+ * Pinned equal to runner.schemas.CONFIG_DEFAULTS by the parity suite.
410
+ */
411
+ export const FALLBACK_DEFAULTS = {
412
+ search_engine: 'duckduckgo',
413
+ max_iterations: 2,
414
+ divergence_threshold: 0.75,
415
+ mode: 'research',
416
+ backend: 'auto',
417
+ cache_raw_markdown: true,
418
+ cache_ttl_days: null,
419
+ search_timeout_s: null,
420
+ searxng_url: null,
421
+ license_whitelist: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'ISC'],
422
+ output_dir: '.research',
423
+ allocation: 'dag',
424
+ domain_pack: null,
425
+ verify: { min_fuzzy_confidence: 0.88 },
426
+ agents: {
427
+ alpha: { backend: null, model: null, opencode_agent: null },
428
+ beta: { backend: null, model: null, opencode_agent: null },
429
+ },
430
+ opencode_auto: null,
431
+ opencode_agent: null,
432
+ };
433
+
434
+ /** Effective defaults, derived from the schema `default` keywords. */
435
+ export function configDefaults() {
436
+ try {
437
+ const schema = loadConfigSchema();
438
+ if (schema && schema.properties && typeof schema.properties === 'object') {
439
+ const out = {};
440
+ for (const [key, sub] of Object.entries(schema.properties)) {
441
+ if (sub && typeof sub === 'object' && 'default' in sub) {
442
+ out[key] = deepCopy(sub.default);
443
+ }
444
+ }
445
+ if (Object.keys(out).length > 0) return out;
446
+ }
447
+ } catch {
448
+ /* fall through to the pinned fallback */
449
+ }
450
+ return deepCopy(FALLBACK_DEFAULTS);
451
+ }
452
+
453
+ /** Recursively merge updates over base (updates win; lists replace). */
454
+ export function mergeConfig(base, updates) {
455
+ if (
456
+ !base || typeof base !== 'object' || Array.isArray(base) ||
457
+ !updates || typeof updates !== 'object' || Array.isArray(updates)
458
+ ) {
459
+ return deepCopy(updates);
460
+ }
461
+ const merged = deepCopy(base);
462
+ for (const [key, value] of Object.entries(updates)) {
463
+ if (
464
+ value !== null && typeof value === 'object' && !Array.isArray(value) &&
465
+ merged[key] !== null && typeof merged[key] === 'object' && !Array.isArray(merged[key])
466
+ ) {
467
+ merged[key] = mergeConfig(merged[key], value);
468
+ } else {
469
+ merged[key] = deepCopy(value);
470
+ }
471
+ }
472
+ return merged;
473
+ }
474
+
475
+ function pinsEqual(a, b) {
476
+ return JSON.stringify(a) === JSON.stringify(b);
477
+ }
478
+
479
+ /**
480
+ * Drop stale pre-0.7.6 `["claude", "-p"]` pins in place; returns True when
481
+ * changed. Only fires when the top-level backend is not an explicit "claude".
482
+ */
483
+ export function migrateLegacyAgentBackends(cfg) {
484
+ try {
485
+ if (String((cfg && cfg.backend) || 'auto').trim().toLowerCase() === 'claude') {
486
+ return false;
487
+ }
488
+ const agents = cfg && cfg.agents;
489
+ if (!agents || typeof agents !== 'object' || Array.isArray(agents)) return false;
490
+ let changed = false;
491
+ for (const role of AGENT_ROLES) {
492
+ const roleCfg = agents[role];
493
+ if (
494
+ roleCfg && typeof roleCfg === 'object' && !Array.isArray(roleCfg) &&
495
+ LEGACY_AGENT_BACKEND_PINS.some((pin) => pinsEqual(roleCfg.backend, pin))
496
+ ) {
497
+ roleCfg.backend = null;
498
+ changed = true;
499
+ }
500
+ }
501
+ return changed;
502
+ } catch {
503
+ return false;
504
+ }
505
+ }
506
+
507
+ function mergeAgents(defaultAgents, fileAgents) {
508
+ const merged =
509
+ defaultAgents && typeof defaultAgents === 'object' && !Array.isArray(defaultAgents)
510
+ ? deepCopy(defaultAgents)
511
+ : {};
512
+ if (!fileAgents || typeof fileAgents !== 'object' || Array.isArray(fileAgents)) {
513
+ return merged;
514
+ }
515
+ for (const [role, roleCfg] of Object.entries(fileAgents)) {
516
+ if (!roleCfg || typeof roleCfg !== 'object' || Array.isArray(roleCfg)) {
517
+ merged[role] = deepCopy(roleCfg);
518
+ continue;
519
+ }
520
+ const base = merged[role];
521
+ if (base && typeof base === 'object' && !Array.isArray(base)) {
522
+ Object.assign(base, deepCopy(roleCfg));
523
+ } else {
524
+ merged[role] = deepCopy(roleCfg);
525
+ }
526
+ }
527
+ return merged;
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // paths + reads (never throw)
532
+ // ---------------------------------------------------------------------------
533
+
534
+ export function getConfigPath(baseDir = DEFAULT_RESEARCH_DIR) {
535
+ return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_FILE_NAME);
536
+ }
537
+
538
+ export function getLockPath(baseDir = DEFAULT_RESEARCH_DIR) {
539
+ return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_LOCK_NAME);
540
+ }
541
+
542
+ function readBytes(file) {
543
+ try {
544
+ if (!existsSync(file)) return null;
545
+ return readFileSync(file);
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ /** SHA-256 of the config file's exact bytes, or null when absent. */
552
+ export function configHash(baseDir = DEFAULT_RESEARCH_DIR) {
553
+ try {
554
+ const bytes = readBytes(getConfigPath(baseDir));
555
+ if (!bytes) return null;
556
+ return createHash('sha256').update(bytes).digest('hex');
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ function parseJsonObject(bytes) {
563
+ if (!bytes) return null;
564
+ try {
565
+ const data = JSON.parse(bytes.toString('utf-8'));
566
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
567
+ } catch {
568
+ return null;
569
+ }
570
+ }
571
+
572
+ /** Best-effort raw JSON object on disk (null when absent/malformed). */
573
+ export function readRawConfig(baseDir = DEFAULT_RESEARCH_DIR) {
574
+ try {
575
+ return parseJsonObject(readBytes(getConfigPath(baseDir)));
576
+ } catch {
577
+ return null;
578
+ }
579
+ }
580
+
581
+ /** True when a config file exists but is not a parseable JSON object. */
582
+ export function isConfigMalformed(baseDir = DEFAULT_RESEARCH_DIR) {
583
+ try {
584
+ const bytes = readBytes(getConfigPath(baseDir));
585
+ if (!bytes) return false;
586
+ return parseJsonObject(bytes) === null;
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Load config merged over defaults. Pure — never writes. Applies the
594
+ * legacy-pin migration in memory; unknown keys are preserved verbatim.
595
+ * Never throws (malformed workspace falls back to defaults with a warning).
596
+ */
597
+ export function loadConfig(baseDir = DEFAULT_RESEARCH_DIR) {
598
+ try {
599
+ const base = configDefaults();
600
+ const raw = readRawConfig(baseDir);
601
+ if (!raw) {
602
+ if (isConfigMalformed(baseDir)) {
603
+ try {
604
+ console.error(
605
+ `[iumbtems config-io] Warning: ${getConfigPath(baseDir)} is not a ` +
606
+ 'JSON object. Using defaults.'
607
+ );
608
+ } catch {
609
+ /* logging must never throw */
610
+ }
611
+ }
612
+ return base;
613
+ }
614
+ const merged = { ...base, ...deepCopy(raw) };
615
+ merged.agents = mergeAgents(base.agents, raw.agents);
616
+ if (
617
+ raw.verify && typeof raw.verify === 'object' && !Array.isArray(raw.verify) &&
618
+ base.verify && typeof base.verify === 'object' && !Array.isArray(base.verify)
619
+ ) {
620
+ merged.verify = { ...base.verify, ...deepCopy(raw.verify) };
621
+ }
622
+ migrateLegacyAgentBackends(merged);
623
+ return merged;
624
+ } catch {
625
+ try {
626
+ return deepCopy(FALLBACK_DEFAULTS);
627
+ } catch {
628
+ return {};
629
+ }
630
+ }
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // effective sources + settings rows (drive the wizard and the panel)
635
+ // ---------------------------------------------------------------------------
636
+
637
+ function envIsSet(env, name) {
638
+ try {
639
+ return String((env || {})[name] ?? '').trim() !== '';
640
+ } catch {
641
+ return false;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Per-key provenance: `env-override` when a mapped env var is set, `file`
647
+ * when the key is present in the raw file, else `default`. Nested role keys
648
+ * fold into their top-level row (`agents` reports env-override when any
649
+ * IUMBTEMS_BACKEND_/MODEL_<ROLE> var is set).
650
+ */
651
+ export function effectiveSources(
652
+ baseDir = DEFAULT_RESEARCH_DIR,
653
+ env = process.env
654
+ ) {
655
+ const sources = {};
656
+ try {
657
+ const schema = loadConfigSchema();
658
+ const keys = schema && schema.properties
659
+ ? Object.keys(schema.properties)
660
+ : Object.keys(FALLBACK_DEFAULTS);
661
+ const raw = readRawConfig(baseDir) || {};
662
+ for (const key of keys) {
663
+ const mapped = ENV_OVERRIDES[key] || [];
664
+ if (mapped.some((name) => envIsSet(env, name))) {
665
+ sources[key] = 'env-override';
666
+ } else if (Object.prototype.hasOwnProperty.call(raw, key)) {
667
+ sources[key] = 'file';
668
+ } else {
669
+ sources[key] = 'default';
670
+ }
671
+ }
672
+ } catch {
673
+ /* sources stay empty rather than throwing */
674
+ }
675
+ return sources;
676
+ }
677
+
678
+ /**
679
+ * Schema-ordered settings rows: {key, label, value, source, restart,
680
+ * description, deprecated, schema}. Pure; reads disk defensively.
681
+ */
682
+ export function describeSettings(
683
+ baseDir = DEFAULT_RESEARCH_DIR,
684
+ env = process.env
685
+ ) {
686
+ try {
687
+ const schema = loadConfigSchema();
688
+ const props =
689
+ (schema && schema.properties) || {};
690
+ const values = loadConfig(baseDir);
691
+ const raw = readRawConfig(baseDir) || {};
692
+ return buildSettingRows(values, raw, env, props);
693
+ } catch {
694
+ return [];
695
+ }
696
+ }
697
+
698
+ /** Pure row builder over already-loaded values (used by the live wizard). */
699
+ export function buildSettingRows(values, raw, env = process.env, props = null) {
700
+ try {
701
+ const properties =
702
+ props ||
703
+ (loadConfigSchema() && loadConfigSchema().properties) ||
704
+ {};
705
+ const safe = values && typeof values === 'object' ? values : {};
706
+ const safeRaw = raw && typeof raw === 'object' ? raw : {};
707
+ const rows = [];
708
+ for (const [key, sub] of Object.entries(properties)) {
709
+ const mapped = ENV_OVERRIDES[key] || [];
710
+ const source = mapped.some((name) => envIsSet(env, name))
711
+ ? 'env-override'
712
+ : Object.prototype.hasOwnProperty.call(safeRaw, key)
713
+ ? 'file'
714
+ : 'default';
715
+ rows.push({
716
+ key,
717
+ label: (sub && sub['x-label']) || key,
718
+ value: Object.prototype.hasOwnProperty.call(safe, key)
719
+ ? deepCopy(safe[key])
720
+ : null,
721
+ source,
722
+ restart: (sub && sub['x-restart']) || 'next-run',
723
+ description: (sub && sub.description) || '',
724
+ deprecated: Boolean(sub && sub.deprecated),
725
+ schema: sub || {},
726
+ });
727
+ }
728
+ return rows;
729
+ } catch {
730
+ return [];
731
+ }
732
+ }
733
+
734
+ function prettyValue(value) {
735
+ if (value === undefined) return 'unset';
736
+ try {
737
+ const text = JSON.stringify(value);
738
+ return text === undefined ? String(value) : text;
739
+ } catch {
740
+ return String(value);
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Mask an environment-shadowed cell for display: the file value is struck
746
+ * through and marked `(set via environment)` — it is NEVER presented bare as
747
+ * the effective value, and the env value itself is NEVER rendered (secrets
748
+ * stay in env). Takes already-formatted text so panel (full) and wizard
749
+ * (truncated) titles share one rule.
750
+ */
751
+ export function maskEnvCell(text) {
752
+ return `~~${text}~~ (set via environment)`;
753
+ }
754
+
755
+ /** Display text for a settings row: masked when env-shadowed, plain otherwise. */
756
+ export function displayCell(row, format = prettyValue) {
757
+ let text;
758
+ try {
759
+ text = format(row ? row.value : undefined);
760
+ } catch {
761
+ text = String(row ? row.value : undefined);
762
+ }
763
+ return row && row.source === 'env-override' ? maskEnvCell(text) : text;
764
+ }
765
+
766
+ /** Human-readable settings text for the read-only panel / confirm screen. */
767
+ export function formatSettingsText(baseDir = DEFAULT_RESEARCH_DIR, env = process.env) {
768
+ try {
769
+ const rows = describeSettings(baseDir, env);
770
+ if (rows.length === 0) return 'IUMBTEMS settings: no schema available.';
771
+ const lines = rows.map((row) => {
772
+ const restartNote =
773
+ row.restart === 'next-run'
774
+ ? 'applies to next swarm run'
775
+ : `restart: ${row.restart}`;
776
+ const stale = row.deprecated ? ' (deprecated, display-only)' : '';
777
+ return `- ${row.label} (${row.key}): ${displayCell(row)} [${row.source}] — ${restartNote}${stale}`;
778
+ });
779
+ return ['IUMBTEMS settings (effective values):', ...lines].join('\n');
780
+ } catch {
781
+ return 'IUMBTEMS settings: unavailable (workspace unreadable).';
782
+ }
783
+ }
784
+
785
+ /** Human-readable config block (mirrors configure.py display_config). */
786
+ export function formatConfig(cfg) {
787
+ try {
788
+ const c = cfg && typeof cfg === 'object' ? cfg : {};
789
+ const verify = c.verify && typeof c.verify === 'object' ? c.verify : {};
790
+ const lines = [
791
+ '',
792
+ '⚙️ EPISTEMIC SWARM ACTIVE CONFIGURATION',
793
+ '========================================',
794
+ ` 🔍 Primary Search Engine: ${String(c.search_engine ?? 'duckduckgo').toUpperCase()}`,
795
+ ` 🔄 Max Iterations (Depth): ${c.max_iterations ?? 2}`,
796
+ ` ⚖️ Divergence Threshold: ${c.divergence_threshold ?? 0.75} (advisory)`,
797
+ ` 🎯 Operating Mode: ${String(c.mode ?? 'research').toUpperCase()}`,
798
+ ` 🖥️ Agent Backend: ${String(c.backend ?? 'auto').toUpperCase()} (auto = host-native)`,
799
+ ` 💾 Raw Markdown Caching: ${(c.cache_raw_markdown ?? true) ? 'ENABLED' : 'DISABLED'}`,
800
+ ` 📜 License Whitelist: ${Array.isArray(c.license_whitelist) ? c.license_whitelist.join(', ') : ''}`,
801
+ ` 🔬 Min Fuzzy Confidence: ${verify.min_fuzzy_confidence ?? 0.88}`,
802
+ ` 📁 Output Directory: ${c.output_dir ?? '.research'} (deprecated)`,
803
+ '========================================',
804
+ '',
805
+ ];
806
+ return lines.join('\n');
807
+ } catch {
808
+ return 'Epistemic Swarm configuration: unavailable.';
809
+ }
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // expected-hash guard (both spellings, phase-01 semantics)
814
+ // ---------------------------------------------------------------------------
815
+
816
+ /**
817
+ * Validate/normalize an expected-hash guard; null means "no guard". Accepts a
818
+ * full 64-hex SHA-256 or a prefix of at least 8 hex chars. Anything else
819
+ * malformed raises ConfigHashError. Never throws on null/empty.
820
+ */
821
+ export function normalizeExpectedHash(expectedHash) {
822
+ if (expectedHash === null || expectedHash === undefined) return null;
823
+ const value = String(expectedHash).trim().toLowerCase();
824
+ if (!value) return null;
825
+ if (!/^[0-9a-f]{8,64}$/.test(value)) {
826
+ throw new ConfigHashError(expectedHash);
827
+ }
828
+ return value;
829
+ }
830
+
831
+ /**
832
+ * Resolve the guard from `expected_hash`/`expectedHash` spellings. Both
833
+ * present must agree (mirror the MCP R4 rule) or a CONFLICTING_EXPECTED_HASH
834
+ * ConfigError is raised and nothing is written.
835
+ */
836
+ export function resolveExpectedHash(opts = {}) {
837
+ const snake = opts ? opts.expected_hash : undefined;
838
+ const camel = opts ? opts.expectedHash : undefined;
839
+ const hasSnake = snake !== null && snake !== undefined;
840
+ const hasCamel = camel !== null && camel !== undefined;
841
+ if (hasSnake && hasCamel && String(snake) !== String(camel)) {
842
+ throw new ConfigError(
843
+ 'expected_hash and expectedHash were both provided with different ' +
844
+ 'values; provide one spelling (or two equal values).',
845
+ {
846
+ code: 'CONFLICTING_EXPECTED_HASH',
847
+ written: false,
848
+ expected_hash: String(snake),
849
+ expectedHash: String(camel),
850
+ }
851
+ );
852
+ }
853
+ const chosen = hasSnake ? snake : camel;
854
+ return normalizeExpectedHash(chosen ?? null);
855
+ }
856
+
857
+ function hashMatches(expectedHash, currentHash) {
858
+ if (!expectedHash || !currentHash) return false;
859
+ return String(currentHash).startsWith(expectedHash);
860
+ }
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // lockfile discipline (O_EXCL create, pid+timestamp, stale takeover > 5s)
864
+ // ---------------------------------------------------------------------------
865
+
866
+ function readLockPayload(lockPath) {
867
+ try {
868
+ const raw = readFileSync(lockPath, 'utf-8');
869
+ const parsed = JSON.parse(raw);
870
+ if (parsed && typeof parsed === 'object' && Number.isFinite(Number(parsed.ts))) {
871
+ return { pid: parsed.pid, ts: Number(parsed.ts), token: parsed.token };
872
+ }
873
+ return null;
874
+ } catch {
875
+ return null;
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Acquire the workspace lock. Creates `.config.lock` with O_EXCL; a live lock
881
+ * raises ConfigLockedError; a stale lock (> LOCK_STALE_MS) or a foreign file
882
+ * without our pid+timestamp payload is taken over. Returns {release}.
883
+ */
884
+ export function acquireLock(baseDir = DEFAULT_RESEARCH_DIR) {
885
+ const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
886
+ mkdirSync(dir, { recursive: true });
887
+ const lockPath = getLockPath(dir);
888
+ const token = `${process.pid}:${Date.now()}:${randomBytes(4).toString('hex')}`;
889
+ const payload = JSON.stringify({ pid: process.pid, ts: Date.now(), token });
890
+
891
+ const takeOver = () => {
892
+ // Adopt by OVERWRITING the payload — never by unlinking. Unlinking a
893
+ // foreign lockfile would delete another writer's liveness signal (and a
894
+ // live Python/flock holder's file, which carries no timestamp at all);
895
+ // overwriting leaves a valid pid+timestamp payload behind that our own
896
+ // release() can later verify as ours before unlinking.
897
+ writeFileSync(lockPath, payload, 'utf-8');
898
+ };
899
+
900
+ try {
901
+ writeFileSync(lockPath, payload, { flag: 'wx', encoding: 'utf-8' });
902
+ } catch (err) {
903
+ if (!err || err.code !== 'EEXIST') throw err;
904
+ const existing = readLockPayload(lockPath);
905
+ if (existing) {
906
+ const age = Date.now() - existing.ts;
907
+ if (age <= LOCK_STALE_MS) {
908
+ throw new ConfigLockedError({ lock_age_ms: age, lock_pid: existing.pid });
909
+ }
910
+ }
911
+ // Stale (> 5s) or foreign (no pid+timestamp payload, e.g. a leftover
912
+ // advisory lockfile from the Python writer, which never unlinks): take
913
+ // over rather than deadlocking. Residual window documented above: a live
914
+ // Python holder can still be adopted here; its writes are
915
+ // millisecond-scale so the window is negligible.
916
+ takeOver();
917
+ }
918
+
919
+ let released = false;
920
+ const release = () => {
921
+ if (released) return;
922
+ released = true;
923
+ try {
924
+ // Unlink ONLY when the on-disk payload is verifiably ours (pid match):
925
+ // a foreign live lock is never removed, and if a concurrent writer
926
+ // adopted the lock away from us after we acquired it, their payload
927
+ // (different pid, and in-process a different token) keeps the file.
928
+ const current = readLockPayload(lockPath);
929
+ if (
930
+ current &&
931
+ current.pid === process.pid &&
932
+ current.token === token
933
+ ) {
934
+ unlinkSync(lockPath);
935
+ }
936
+ } catch {
937
+ /* release is best-effort */
938
+ }
939
+ };
940
+ return { release, token, lockPath };
941
+ }
942
+
943
+ let tempCounter = 0;
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // atomic save
947
+ // ---------------------------------------------------------------------------
948
+
949
+ /**
950
+ * Atomically persist cfg into `<baseDir>/config.json`.
951
+ *
952
+ * - Merges over whatever is on disk (unknown keys survive); cfg wins.
953
+ * - Validates the merged dict (disable only with validate:false when the
954
+ * exact dict was already validated); failures raise
955
+ * ConfigValidationError and write nothing.
956
+ * - Honors the expected-hash guard (both spellings); mismatches raise
957
+ * ConfigStaleError with a fresh snapshot (no absolute path, mirroring the
958
+ * Python R5 rule) and write nothing.
959
+ * - Publishes via a unique temp file + rename; `injectFailure:
960
+ * 'after-temp-write'` is a test-only hook that throws between the two so
961
+ * failure-injection tests can prove the original stays intact.
962
+ */
963
+ export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
964
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
965
+ throw new ConfigValidationError([
966
+ `<root>: expected object, got ${typeName(cfg)} (config must be a JSON object)`,
967
+ ]);
968
+ }
969
+ const expected = resolveExpectedHash(opts || {});
970
+ const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
971
+ mkdirSync(dir, { recursive: true });
972
+ const cfgFile = getConfigPath(dir);
973
+ const lock = acquireLock(dir);
974
+ let tmp = null;
975
+ try {
976
+ const existing = readBytes(cfgFile);
977
+ const currentHash = existing
978
+ ? createHash('sha256').update(existing).digest('hex')
979
+ : null;
980
+ if (expected !== null && !hashMatches(expected, currentHash)) {
981
+ throw new ConfigStaleError(expected, currentHash, {
982
+ exists: existing !== null,
983
+ hash: currentHash,
984
+ config: loadConfig(dir),
985
+ });
986
+ }
987
+ const onDisk = parseJsonObject(existing);
988
+ const merged = mergeConfig(onDisk || {}, cfg);
989
+ if (!opts || opts.validate !== false) {
990
+ const problems = validateConfig(merged);
991
+ if (problems.length > 0) {
992
+ throw new ConfigValidationError(problems);
993
+ }
994
+ }
995
+ tempCounter += 1;
996
+ tmp = path.join(
997
+ dir,
998
+ `${CONFIG_FILE_NAME}.${process.pid}-${tempCounter}-${randomBytes(4).toString('hex')}.tmp`
999
+ );
1000
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8');
1001
+ if (opts && opts.injectFailure === 'after-temp-write') {
1002
+ throw new Error(
1003
+ 'injected failure between temp write and rename (test hook)'
1004
+ );
1005
+ }
1006
+ renameSync(tmp, cfgFile);
1007
+ tmp = null;
1008
+ const written = readBytes(cfgFile);
1009
+ return {
1010
+ path: cfgFile,
1011
+ hash: written ? createHash('sha256').update(written).digest('hex') : null,
1012
+ written: true,
1013
+ };
1014
+ } catch (err) {
1015
+ if (tmp) {
1016
+ try {
1017
+ unlinkSync(tmp);
1018
+ } catch {
1019
+ /* temp cleanup is best-effort */
1020
+ }
1021
+ }
1022
+ throw err;
1023
+ } finally {
1024
+ lock.release();
1025
+ }
1026
+ }
1027
+
1028
+ /** Fresh read-only snapshot: existence, file hash, effective config. */
1029
+ export function snapshot(baseDir = DEFAULT_RESEARCH_DIR) {
1030
+ try {
1031
+ const cfgFile = getConfigPath(baseDir);
1032
+ return {
1033
+ path: cfgFile,
1034
+ exists: existsSync(cfgFile),
1035
+ hash: configHash(baseDir),
1036
+ config: loadConfig(baseDir),
1037
+ };
1038
+ } catch {
1039
+ return { path: getConfigPath(baseDir), exists: false, hash: null, config: {} };
1040
+ }
1041
+ }