@heretek-ai/epistemic-swarm 0.7.19 → 0.7.21

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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +23 -2
  3. package/package.json +1 -1
  4. package/plugins/antigravity/plugin.json +1 -1
  5. package/plugins/antigravity/rules/AGENTS.md +5 -3
  6. package/plugins/opencode/config-io.js +55 -12
  7. package/plugins/opencode/index.js +611 -0
  8. package/plugins/opencode/tui.js +278 -6
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  29. package/runner/mcp_server.py +81 -10
  30. package/runner/research_swarm.py +12 -2
  31. package/runner/schemas.py +18 -0
  32. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  63. package/runner/tests/test_config_schema.py +106 -0
  64. package/runner/tests/test_opencode_ux.py +462 -7
  65. package/runner/tests/test_opencode_v2_registrars.py +1112 -3
  66. package/runner/tests/test_swarm_config.py +21 -0
  67. package/schemas/config.schema.json +11 -0
  68. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  69. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  70. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  71. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  72. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  73. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  74. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  76. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  77. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  78. package/skills/swarm_config/SKILL.md +26 -0
  79. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  81. package/skills/swarm_config/configure.py +40 -6
@@ -25,6 +25,15 @@ import {
25
25
  } from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import {
29
+ loadConfig,
30
+ loadConfigFromRaw,
31
+ mergeConfig,
32
+ readConfigSnapshot,
33
+ readRawConfig,
34
+ resolveExpectedHash,
35
+ validateConfig,
36
+ } from './config-io.js';
28
37
 
29
38
  const __filename = fileURLToPath(import.meta.url);
30
39
  const __dirname = path.dirname(__filename);
@@ -282,6 +291,11 @@ export const TOOL_CATALOG = [
282
291
  type: ['string', 'null'],
283
292
  description: 'Default named opencode agent profile for all roles; per-role agents.<role>.opencode_agent wins.',
284
293
  },
294
+ mcp_servers: {
295
+ type: 'object',
296
+ additionalProperties: { type: 'boolean' },
297
+ description: 'Persisted toggle map for the plugin-controllable MCP server set (bundled `iumbtems` server plus research servers declared in the project OpenCode config). Keys are server names; values are booleans (true = enabled, false = disabled). Absent keys mean enabled. Applied via ctx.mcp.transform; the catalog file config/mcp-research-servers.json is never modified.',
298
+ },
285
299
  },
286
300
  },
287
301
  },
@@ -2048,6 +2062,586 @@ async function switchSessionAgent(host, sessionID, agentId) {
2048
2062
  return false;
2049
2063
  }
2050
2064
 
2065
+ // ---------------------------------------------------------------------------
2066
+ // Phase 03 (opencode-settings-menu): `iumbtems.settings` RPC domain, storage
2067
+ // mirror, MCP toggles, temperature hook.
2068
+ //
2069
+ // v2 RPC pattern (https://opencode.ai/v2/docs/build/plugins/rpc): the domain
2070
+ // is a portable `Rpc.define`-shaped definition (plain JSON-Schema I/O, no
2071
+ // dependency so the TUI entry can import it too), registered with
2072
+ // `ctx.rpc.register(Def, handlers)`; the TUI calls it via
2073
+ // `context.client.rpc(Def)`; implementations emit with
2074
+ // `registration.events.emit(name, data)`. Every registration below is
2075
+ // best-effort + feature-detected: a host without the domain yields null and
2076
+ // setup still succeeds (pinned by the graceful-degradation suites).
2077
+ // ---------------------------------------------------------------------------
2078
+
2079
+ /** RPC domain id for the settings control plane. */
2080
+ export const SETTINGS_RPC_ID = 'iumbtems.settings';
2081
+
2082
+ /** `ctx.storage` key mirroring the last effective config + hash. */
2083
+ export const SETTINGS_STORAGE_KEY = 'iumbtems.settings.snapshot';
2084
+
2085
+ /**
2086
+ * Portable `iumbtems.settings` RPC definition (`get`/`set`/`validate` plus
2087
+ * the `changed` event). Shaped for `Rpc.define` (JSON-Schema I/O; event data
2088
+ * is an object as the RPC contract requires) so it can be passed straight to
2089
+ * a real `ctx.rpc.register` and to `context.client.rpc(...)` on the TUI side.
2090
+ */
2091
+ export const SettingsRpc = {
2092
+ id: SETTINGS_RPC_ID,
2093
+ methods: {
2094
+ get: {
2095
+ input: { type: 'object', properties: {}, additionalProperties: false },
2096
+ output: {
2097
+ type: 'object',
2098
+ properties: {
2099
+ config: { type: 'object' },
2100
+ raw: { type: ['object', 'null'] },
2101
+ hash: { type: ['string', 'null'] },
2102
+ migrated: { type: 'boolean' },
2103
+ },
2104
+ required: ['config'],
2105
+ additionalProperties: false,
2106
+ },
2107
+ },
2108
+ set: {
2109
+ input: {
2110
+ type: 'object',
2111
+ properties: {
2112
+ updates: { type: 'object' },
2113
+ expected_hash: { type: 'string' },
2114
+ expectedHash: { type: 'string' },
2115
+ },
2116
+ required: ['updates'],
2117
+ additionalProperties: false,
2118
+ },
2119
+ output: {
2120
+ type: 'object',
2121
+ properties: {
2122
+ config: { type: 'object' },
2123
+ hash: { type: 'string' },
2124
+ },
2125
+ required: ['config', 'hash'],
2126
+ additionalProperties: false,
2127
+ },
2128
+ errors: {
2129
+ stale: {
2130
+ type: 'object',
2131
+ properties: {
2132
+ expected_hash: { type: 'string' },
2133
+ current_hash: { type: ['string', 'null'] },
2134
+ written: { type: 'boolean' },
2135
+ },
2136
+ required: ['written'],
2137
+ additionalProperties: false,
2138
+ },
2139
+ invalid: {
2140
+ type: 'object',
2141
+ properties: {
2142
+ errors: { type: 'array', items: { type: 'string' } },
2143
+ written: { type: 'boolean' },
2144
+ },
2145
+ required: ['written'],
2146
+ additionalProperties: false,
2147
+ },
2148
+ conflict: {
2149
+ type: 'object',
2150
+ properties: {
2151
+ expected_hash: { type: 'string' },
2152
+ expectedHash: { type: 'string' },
2153
+ written: { type: 'boolean' },
2154
+ },
2155
+ required: ['written'],
2156
+ additionalProperties: false,
2157
+ },
2158
+ },
2159
+ },
2160
+ validate: {
2161
+ input: {
2162
+ type: 'object',
2163
+ properties: { updates: { type: 'object' } },
2164
+ required: ['updates'],
2165
+ additionalProperties: false,
2166
+ },
2167
+ output: {
2168
+ type: 'object',
2169
+ properties: {
2170
+ valid: { type: 'boolean' },
2171
+ problems: { type: 'array', items: { type: 'string' } },
2172
+ },
2173
+ required: ['valid', 'problems'],
2174
+ additionalProperties: false,
2175
+ },
2176
+ },
2177
+ },
2178
+ events: {
2179
+ changed: {
2180
+ schema: {
2181
+ type: 'object',
2182
+ properties: {
2183
+ hash: { type: 'string' },
2184
+ keys: { type: 'array', items: { type: 'string' } },
2185
+ },
2186
+ required: ['hash'],
2187
+ additionalProperties: false,
2188
+ },
2189
+ },
2190
+ },
2191
+ };
2192
+
2193
+ function settingsResearchDir(root) {
2194
+ try {
2195
+ return path.join(root || process.cwd(), '.research');
2196
+ } catch {
2197
+ return path.join(process.cwd(), '.research');
2198
+ }
2199
+ }
2200
+
2201
+ function parseMcpContent(result) {
2202
+ try {
2203
+ return JSON.parse(String(result?.content ?? ''));
2204
+ } catch {
2205
+ return null;
2206
+ }
2207
+ }
2208
+
2209
+ /**
2210
+ * Declared-error return for an RPC method handler: uses the real
2211
+ * `context.error(type, message, data)` when the host provides it, otherwise
2212
+ * throws a structured error carrying the same type/data (the mock-host path
2213
+ * in the suites). Never returns normally — always throws or returns the
2214
+ * host's error value.
2215
+ */
2216
+ function rpcFail(context, type, message, data) {
2217
+ if (context && typeof context.error === 'function') {
2218
+ return context.error(type, message, data);
2219
+ }
2220
+ const err = new Error(message);
2221
+ err.rpcType = type;
2222
+ err.rpcData = data || {};
2223
+ throw err;
2224
+ }
2225
+
2226
+ /** `iumbtems_config` control args are never config keys; strip them defensively. */
2227
+ const SETTINGS_CONTROL_ARGS = new Set(['base_dir', 'dir', 'show', 'expected_hash', 'expectedHash']);
2228
+
2229
+ /**
2230
+ * Leak signatures that must never reach the RPC transport (phase-03 R7/R9):
2231
+ * tracebacks, `File "…", line N` frames, interpreter failure lines, and
2232
+ * absolute filesystem paths of ANY root — POSIX (e.g. `/workspace/…`,
2233
+ * `/srv/…`, `/Users/…`, `/app/…`), Windows drive (`C:\Users\…`), and UNC
2234
+ * (`\\server\share`). Structured validation problems (e.g. `mode: value … not
2235
+ * in enum …`, type/range violations carrying ordinary values) match none of
2236
+ * these and pass through intact.
2237
+ */
2238
+ const SETTINGS_ERROR_LEAK_PATTERNS = [
2239
+ // Tracebacks and Python/Node interpreter failure lines.
2240
+ /Traceback(?: \(most recent call last\))?/i,
2241
+ /File\s+["'][^"']+["']\s*,\s*line\s+\d+/i,
2242
+ /\b(?:ModuleNotFoundError|ImportError|SyntaxError|NameError|OSError|Errno \d+)\b/,
2243
+ // Absolute POSIX path of any root: a bounded leading slash + >=2 segments.
2244
+ /(?:^|[\s"'(=:,])\/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/,
2245
+ // Windows drive-letter absolute path (`C:\Users\…`) or UNC share (`\\srv\share`).
2246
+ /\b[A-Za-z]:[\\/](?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+/,
2247
+ /\\\\[^\\/\s"'<>|]+[\\/][^\\/\s"'<>|]+/,
2248
+ ];
2249
+
2250
+ /** Fixed reason when an individual failure item is unreadable/leaky (mirrors `get`). */
2251
+ const SETTINGS_WRITE_UNREADABLE = 'Config write rejected: canonical config surface returned an unreadable failure.';
2252
+
2253
+ /**
2254
+ * Sanitize a `set`-leg failure list PER ITEM (phase-03 R9): each structured
2255
+ * problem passes through untouched; any single item carrying a leak signature
2256
+ * is replaced by the fixed reason — one leaky entry never discards the rest of
2257
+ * the list. Never throws; never returns an empty list.
2258
+ */
2259
+ function sanitizeRpcErrors(errors) {
2260
+ try {
2261
+ const list = Array.isArray(errors) ? errors.map((e) => String(e)) : [];
2262
+ if (list.length > 0) {
2263
+ return list.map((item) =>
2264
+ SETTINGS_ERROR_LEAK_PATTERNS.some((re) => re.test(item))
2265
+ ? SETTINGS_WRITE_UNREADABLE
2266
+ : item
2267
+ );
2268
+ }
2269
+ } catch {
2270
+ /* fall through to the fixed reason */
2271
+ }
2272
+ return [SETTINGS_WRITE_UNREADABLE];
2273
+ }
2274
+
2275
+ /**
2276
+ * Method handlers for the `iumbtems.settings` domain. `set` validates, applies
2277
+ * the expected-hash guard (8-hex minimum, both `expected_hash`/`expectedHash`
2278
+ * spellings, conflict -> error — the phase-01/02 semantics shared with
2279
+ * `config-io.js`), and writes through the canonical Python surface
2280
+ * (`callMcp('iumbtems_config')`) so the single-writer discipline holds. A stale
2281
+ * guard returns the structured `stale` error with a fresh snapshot and writes
2282
+ * nothing. `emitChanged` fires exactly once per successful write; the
2283
+ * `ctx.storage` mirror and `mcp.reload()` are best-effort.
2284
+ */
2285
+ export function createSettingsHandlers(opts = {}) {
2286
+ const root = opts.root;
2287
+ const researchDir = settingsResearchDir(root);
2288
+ const callMcpImpl = opts.callMcpImpl || callMcp;
2289
+ const storage = opts.storage || null;
2290
+ const emitChanged = opts.emitChanged || null;
2291
+ const reloadMcp = opts.reloadMcp || null;
2292
+
2293
+ return {
2294
+ get: async () => {
2295
+ let res;
2296
+ try {
2297
+ res = await callMcpImpl('iumbtems_config', { show: true, base_dir: researchDir }, root);
2298
+ } catch (err) {
2299
+ // Fail closed: a spawn failure (e.g. ENOENT) must never leak raw
2300
+ // internals to the TUI — fixed message, stable code, nothing written.
2301
+ const sanitized = new Error('iumbtems.settings.get failed: canonical config surface unavailable');
2302
+ sanitized.code = 'SETTINGS_RPC_UNAVAILABLE';
2303
+ throw sanitized;
2304
+ }
2305
+ const payload = parseMcpContent(res);
2306
+ if (!payload || typeof payload.config !== 'object' || payload.config === null) {
2307
+ const sanitized = new Error('iumbtems.settings.get failed: canonical config surface unavailable');
2308
+ sanitized.code = 'SETTINGS_RPC_UNAVAILABLE';
2309
+ throw sanitized;
2310
+ }
2311
+ // Single-read (phase-03 R5): the raw bytes are read EXACTLY ONCE below;
2312
+ // the returned effective `config` is merged from those bytes, the `raw`
2313
+ // on-disk subset is parsed from those bytes, and the hash is computed
2314
+ // over those bytes — so an era-A config with an era-B hash (or raw) is
2315
+ // impossible. The Python surface still supplies the `migrated` flag
2316
+ // (computed in memory; the show leg never writes); the returned payload
2317
+ // is one snapshot regardless of what either side read on its own clock.
2318
+ const snap = readConfigSnapshot(researchDir);
2319
+ const config = loadConfigFromRaw(snap.raw);
2320
+ // Refresh-on-read (phase-03 R1): a direct-fs fallback-leg write bypasses
2321
+ // the mirror, so every server-mediated read re-converges it to disk
2322
+ // truth. The mirror can lag between writes, but it can no longer lie
2323
+ // permanently after a fallback-leg success.
2324
+ try {
2325
+ if (storage && typeof storage.set === 'function') {
2326
+ await storage.set(SETTINGS_STORAGE_KEY, {
2327
+ config,
2328
+ hash: snap.hash,
2329
+ updated: new Date().toISOString(),
2330
+ });
2331
+ }
2332
+ } catch {
2333
+ /* the storage mirror is best-effort */
2334
+ }
2335
+ // `raw` (phase-04 R1) is the parsed ON-DISK file config (null when
2336
+ // absent/malformed) so the TUI can compute provenance: a key present in
2337
+ // `raw` is `file`, otherwise `default` (env-override still wins).
2338
+ return { config, raw: snap.raw, hash: snap.hash, migrated: Boolean(payload.migrated) };
2339
+ },
2340
+ validate: async (input = {}) => {
2341
+ const updates = input?.updates;
2342
+ if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
2343
+ return { valid: false, problems: ['updates: expected an object'] };
2344
+ }
2345
+ let merged;
2346
+ try {
2347
+ merged = mergeConfig(loadConfig(researchDir), updates);
2348
+ } catch {
2349
+ return { valid: false, problems: ['updates: could not be merged over the current config'] };
2350
+ }
2351
+ const problems = validateConfig(merged);
2352
+ return { valid: problems.length === 0, problems };
2353
+ },
2354
+ set: async (input = {}, context = {}) => {
2355
+ const updates = input?.updates;
2356
+ if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
2357
+ return rpcFail(context, 'invalid', 'updates must be an object', {
2358
+ written: false,
2359
+ errors: ['updates: expected an object'],
2360
+ });
2361
+ }
2362
+ let expected = null;
2363
+ try {
2364
+ expected = resolveExpectedHash({
2365
+ expected_hash: input?.expected_hash,
2366
+ expectedHash: input?.expectedHash,
2367
+ });
2368
+ } catch (err) {
2369
+ if (err && err.code === 'CONFLICTING_EXPECTED_HASH') {
2370
+ return rpcFail(context, 'conflict', String(err.message), { written: false });
2371
+ }
2372
+ return rpcFail(context, 'invalid', String((err && err.message) || err), { written: false });
2373
+ }
2374
+ const sanitized = {};
2375
+ for (const [key, value] of Object.entries(updates)) {
2376
+ if (!SETTINGS_CONTROL_ARGS.has(key)) sanitized[key] = value;
2377
+ }
2378
+ const args = { ...sanitized, base_dir: researchDir };
2379
+ if (expected !== null) args.expected_hash = expected;
2380
+ const res = await callMcpImpl('iumbtems_config', args, root);
2381
+ const payload = parseMcpContent(res);
2382
+ if (payload && payload.status === 'stale') {
2383
+ return rpcFail(context, 'stale', 'Config changed on disk since it was read; refusing the stale write.', {
2384
+ expected_hash: payload.expected_hash,
2385
+ current_hash: payload.current_hash ?? null,
2386
+ config: payload.config,
2387
+ written: false,
2388
+ });
2389
+ }
2390
+ if (!payload || payload.status !== 'updated' || typeof payload.config !== 'object') {
2391
+ // Sanitize (phase-03 R7): raw transport text (tracebacks, absolute
2392
+ // paths) must never reach the RPC transport — structured problems
2393
+ // pass through, everything else becomes the fixed reason.
2394
+ const errors = sanitizeRpcErrors(
2395
+ (payload && payload.errors) || (res && res.content ? [String(res.content).slice(0, 300)] : [])
2396
+ );
2397
+ return rpcFail(context, 'invalid', 'Config write rejected.', { written: false, errors });
2398
+ }
2399
+ const hash = payload.hash || null;
2400
+ try {
2401
+ if (storage && typeof storage.set === 'function') {
2402
+ await storage.set(SETTINGS_STORAGE_KEY, {
2403
+ config: payload.config,
2404
+ hash,
2405
+ updated: new Date().toISOString(),
2406
+ });
2407
+ }
2408
+ } catch {
2409
+ /* the storage mirror is best-effort */
2410
+ }
2411
+ try {
2412
+ if (typeof reloadMcp === 'function') await reloadMcp();
2413
+ } catch {
2414
+ /* mcp reload after a toggle change is best-effort */
2415
+ }
2416
+ try {
2417
+ if (typeof emitChanged === 'function') {
2418
+ await emitChanged({ hash, keys: Object.keys(sanitized) });
2419
+ }
2420
+ } catch {
2421
+ /* event delivery is best-effort */
2422
+ }
2423
+ return { config: payload.config, hash };
2424
+ },
2425
+ };
2426
+ }
2427
+
2428
+ /**
2429
+ * Register the `iumbtems.settings` domain on a v2 host (`ctx.rpc.register`).
2430
+ * Returns the registration, or null when the host has no RPC domain (the TUI
2431
+ * then uses the direct-fs degraded path). Never throws.
2432
+ */
2433
+ export async function registerSettingsRpc(host, opts = {}) {
2434
+ try {
2435
+ const register = host?.rpc?.register;
2436
+ if (typeof register !== 'function') return null;
2437
+ const root = opts.root || host?.location?.directory || undefined;
2438
+ const storage = opts.storage || host?.storage || null;
2439
+ let registration = null;
2440
+ const emitChanged = async (data) => {
2441
+ try {
2442
+ if (registration && registration.events && typeof registration.events.emit === 'function') {
2443
+ await registration.events.emit('changed', data);
2444
+ }
2445
+ } catch (err) {
2446
+ log(host, 'warn', 'settings changed emit failed', errDetail(err));
2447
+ }
2448
+ };
2449
+ const reloadMcp = async () => {
2450
+ try {
2451
+ if (host && host.mcp && typeof host.mcp.reload === 'function') {
2452
+ await host.mcp.reload();
2453
+ }
2454
+ } catch (err) {
2455
+ log(host, 'warn', 'mcp.reload() after settings write failed', errDetail(err));
2456
+ }
2457
+ };
2458
+ const handlers = createSettingsHandlers({
2459
+ root,
2460
+ callMcpImpl: opts.callMcpImpl,
2461
+ storage,
2462
+ emitChanged,
2463
+ reloadMcp,
2464
+ });
2465
+ registration = await register.call(host.rpc, SettingsRpc, handlers);
2466
+ if (!registration) return null;
2467
+ log(host, 'info', 'iumbtems.settings RPC registered', { id: SettingsRpc.id });
2468
+ return registration;
2469
+ } catch (err) {
2470
+ log(host, 'warn', 'registering iumbtems.settings RPC failed', errDetail(err));
2471
+ return null;
2472
+ }
2473
+ }
2474
+
2475
+ // ---------------------------------------------------------------------------
2476
+ // MCP toggles (M1): `disabled` reconcile for the plugin-controllable set.
2477
+ // ---------------------------------------------------------------------------
2478
+ //
2479
+ // The persisted `mcp_servers` map (canonical config key) holds one boolean per
2480
+ // server (true = enabled). The transform applies `disabled` for names that are
2481
+ // (a) in the map, (b) in the plugin-controllable set (bundled `iumbtems` plus
2482
+ // research servers from `config/mcp-research-servers.json`), and (c) already
2483
+ // present in the host registry. It never `set`s new servers and never
2484
+ // `remove`s any — the catalog file stays byte-identical.
2485
+
2486
+ /** Plugin-controllable MCP server names (the research-server catalog). */
2487
+ export function loadControllableMcpServers() {
2488
+ try {
2489
+ const catalog = JSON.parse(
2490
+ readFileSync(path.join(PKG_ROOT, 'config', 'mcp-research-servers.json'), 'utf-8')
2491
+ );
2492
+ const names = Object.keys(catalog?.mcpServers || {});
2493
+ if (names.length > 0) return names;
2494
+ } catch {
2495
+ /* fall through to the bundled default */
2496
+ }
2497
+ return ['iumbtems'];
2498
+ }
2499
+
2500
+ /** Raw persisted toggle map (never throws; malformed workspace -> {}). */
2501
+ export function readPersistedMcpToggles(root) {
2502
+ try {
2503
+ const raw = readRawConfig(settingsResearchDir(root));
2504
+ const map = raw?.mcp_servers;
2505
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return {};
2506
+ return map;
2507
+ } catch {
2508
+ return {};
2509
+ }
2510
+ }
2511
+
2512
+ /**
2513
+ * Pure reconcile: persisted map x registry presence x controllable set ->
2514
+ * `[{name, disabled}]`. Non-boolean values are ignored (never throw). Exported
2515
+ * for tests.
2516
+ */
2517
+ export function resolveMcpToggles(persistedMap, existingNames, controllable) {
2518
+ const out = [];
2519
+ try {
2520
+ if (!persistedMap || typeof persistedMap !== 'object' || Array.isArray(persistedMap)) {
2521
+ return out;
2522
+ }
2523
+ const existing = new Set(existingNames || []);
2524
+ const allowed = new Set(controllable || []);
2525
+ for (const [name, enabled] of Object.entries(persistedMap)) {
2526
+ if (typeof enabled !== 'boolean') continue;
2527
+ if (!allowed.has(name)) continue;
2528
+ if (!existing.has(name)) continue;
2529
+ out.push({ name, disabled: !enabled });
2530
+ }
2531
+ } catch {
2532
+ /* reconcile never throws */
2533
+ }
2534
+ return out;
2535
+ }
2536
+
2537
+ /**
2538
+ * Apply the persisted toggle map through `ctx.mcp.transform`. Returns the
2539
+ * registration, or null when the host has no MCP domain. Never throws.
2540
+ */
2541
+ export async function registerMcpToggles(host, opts = {}) {
2542
+ try {
2543
+ const transform = host?.mcp?.transform;
2544
+ if (typeof transform !== 'function') return null;
2545
+ const root = opts.root || host?.location?.directory || undefined;
2546
+ const controllable = opts.controllable || loadControllableMcpServers();
2547
+ const registration = await transform.call(host.mcp, (editor) => {
2548
+ let existing = [];
2549
+ try {
2550
+ existing = (editor.list() || []).map(([name]) => name);
2551
+ } catch {
2552
+ existing = [];
2553
+ }
2554
+ const toggles = resolveMcpToggles(readPersistedMcpToggles(root), existing, controllable);
2555
+ for (const t of toggles) {
2556
+ try {
2557
+ editor.update(t.name, (cfg) => {
2558
+ cfg.disabled = t.disabled;
2559
+ });
2560
+ } catch {
2561
+ /* one bad entry never breaks the pass */
2562
+ }
2563
+ }
2564
+ log(host, 'debug', 'mcp.transform pass', { toggles: toggles.map((t) => t.name) });
2565
+ });
2566
+ return registration || null;
2567
+ } catch (err) {
2568
+ log(host, 'warn', 'registering MCP toggles failed', errDetail(err));
2569
+ return null;
2570
+ }
2571
+ }
2572
+
2573
+ // ---------------------------------------------------------------------------
2574
+ // Temperature (S4): per-role `session.hook("context")`.
2575
+ // ---------------------------------------------------------------------------
2576
+ //
2577
+ // Configured via `IUMBTEMS_TEMPERATURE_<ROLE>` (e.g. IUMBTEMS_TEMPERATURE_ALPHA;
2578
+ // `IUMBTEMS_TEMPERATURE` is the fallback for every role) so no schema change
2579
+ // is needed. The hook keys off `event.agent` — type-guaranteed on the context
2580
+ // hook event (`SessionContext extends SessionRequest { readonly agent }` in
2581
+ // @opencode/plugin@2.0.18 dist/promise/session.d.ts) — never off the
2582
+ // `IUMBTEMS_AGENT_ROLE` env var, which only tells a spawned child its own role
2583
+ // (see the S3 spike record in the phase receipt).
2584
+
2585
+ /** Configured temperature for a role, or undefined when unset/unparseable. */
2586
+ export function temperatureForRole(role, env = process.env) {
2587
+ try {
2588
+ const name = String(role || '').trim().toUpperCase();
2589
+ if (!name) return undefined;
2590
+ // Inheritance (phase-03 R4a): an agent id with no role-specific variable
2591
+ // inherits the global IUMBTEMS_TEMPERATURE — unknown and future agent ids
2592
+ // are deliberately NOT allowlisted (maintaining an id allowlist is riskier
2593
+ // than the silent fallback: an unlisted id would silently keep host
2594
+ // defaults either way, while a stale allowlist would misroute known
2595
+ // roles). Behavior unchanged by this comment.
2596
+ const raw = env?.[`IUMBTEMS_TEMPERATURE_${name}`] ?? env?.IUMBTEMS_TEMPERATURE;
2597
+ if (raw === undefined || raw === null || String(raw).trim() === '') return undefined;
2598
+ const num = Number(String(raw).trim());
2599
+ if (!Number.isFinite(num) || num < 0 || num > 2) return undefined;
2600
+ return num;
2601
+ } catch {
2602
+ return undefined;
2603
+ }
2604
+ }
2605
+
2606
+ /**
2607
+ * Apply the configured per-role temperature to one context-hook event.
2608
+ * Returns true when `event.options.temperature` was set. Never throws.
2609
+ */
2610
+ export function applyTemperatureToContext(event, env = process.env) {
2611
+ try {
2612
+ const role = event?.agent;
2613
+ if (typeof role !== 'string' || !role) return false;
2614
+ const temp = temperatureForRole(role, env);
2615
+ if (temp === undefined) return false;
2616
+ if (!event.options || typeof event.options !== 'object') event.options = {};
2617
+ event.options.temperature = temp;
2618
+ return true;
2619
+ } catch {
2620
+ return false;
2621
+ }
2622
+ }
2623
+
2624
+ /**
2625
+ * Register the temperature context hook (`session.hook("context")`). Returns
2626
+ * the registration, or null when the host has no session-hook domain. Never
2627
+ * throws.
2628
+ */
2629
+ export async function registerTemperatureHook(host, opts = {}) {
2630
+ try {
2631
+ const hook = host?.session?.hook;
2632
+ if (typeof hook !== 'function') return null;
2633
+ const env = opts.env || process.env;
2634
+ const registration = await hook.call(host.session, 'context', (event) => {
2635
+ applyTemperatureToContext(event, env);
2636
+ });
2637
+ log(host, 'info', 'temperature context hook registered', {});
2638
+ return registration || null;
2639
+ } catch (err) {
2640
+ log(host, 'warn', 'registering temperature hook failed', errDetail(err));
2641
+ return null;
2642
+ }
2643
+ }
2644
+
2051
2645
  export function createOpenCodePlugin(context = {}) {
2052
2646
  return {
2053
2647
  id: 'heretek.iumbtems.epistemic-swarm',
@@ -2105,6 +2699,23 @@ export function createOpenCodePlugin(context = {}) {
2105
2699
  } catch (err) {
2106
2700
  log(host, 'error', 'registering compaction hook failed', errDetail(err));
2107
2701
  }
2702
+ const settingsRoot =
2703
+ host.location?.directory || context.location?.directory || undefined;
2704
+ try {
2705
+ adopt(await registerSettingsRpc(host, { root: settingsRoot }));
2706
+ } catch (err) {
2707
+ log(host, 'error', 'registering settings RPC failed', errDetail(err));
2708
+ }
2709
+ try {
2710
+ adopt(await registerMcpToggles(host, { root: settingsRoot }));
2711
+ } catch (err) {
2712
+ log(host, 'error', 'registering MCP toggles failed', errDetail(err));
2713
+ }
2714
+ try {
2715
+ adopt(await registerTemperatureHook(host, {}));
2716
+ } catch (err) {
2717
+ log(host, 'error', 'registering temperature hook failed', errDetail(err));
2718
+ }
2108
2719
  // Slow work AFTER registration.
2109
2720
  try {
2110
2721
  await applyConfigOptions(host, opts, context);