@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
@@ -26,11 +26,174 @@ import {
26
26
  saveConfig,
27
27
  validateConfig,
28
28
  } from './config-io.js';
29
- import { OPENCODE_COMMANDS } from './index.js';
29
+ import { OPENCODE_COMMANDS, SettingsRpc } from './index.js';
30
30
 
31
31
  const PLUGIN_ID = 'heretek.iumbtems.epistemic-swarm.tui';
32
32
  const REFRESH_MS = 5000;
33
33
 
34
+ // ---------------------------------------------------------------------------
35
+ // Phase 03 (opencode-settings-menu): RPC-preferred reads/writes, `changed`
36
+ // subscription, UI-only prefs.
37
+ //
38
+ // The server plugin's `iumbtems.settings` RPC domain is the preferred path:
39
+ // `api.client.rpc(SettingsRpc)` (the v2 TUI context carries `client:
40
+ // OpenCodeClient`). When absent — or when an RPC call fails with a transport
41
+ // error — the wizard/panel fall back to the direct-fs `config-io.js` path
42
+ // with a visible degraded-path toast. Both paths share validation and the
43
+ // expected-hash guard. Live refresh subscribes to the `changed` event; the
44
+ // existing 5s mtime poll stays as the floor.
45
+ //
46
+ // UI preferences live in `api.storage.store(...)` (durable, synced across TUI
47
+ // instances) and are NEVER run-affecting: only dialog conveniences such as
48
+ // the last-save timestamp. Effective values always come from the RPC snapshot
49
+ // or `.research/config.json`.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Live-refresh floor: the sidebar poll interval, in ms (unchanged). */
53
+ export const SETTINGS_POLL_MS = REFRESH_MS;
54
+
55
+ /** UI-only preferences key (TUI storage; never run-affecting). */
56
+ export const UI_PREFS_KEY = 'iumbtems.ui.prefs';
57
+
58
+ // Invariant (phase-03 R4c): UI prefs are write-only conveniences (e.g. the
59
+ // last-save timestamp). NO read path — wizard, panel, or otherwise — consults
60
+ // them when computing effective settings: effective values come exclusively
61
+ // from the RPC snapshot or `.research/config.json`. `TestUiPrefsBoundary`
62
+ // pins this: prefs deliberately named like config keys never surface in the
63
+ // panel body or on disk. No behavior change by this comment.
64
+
65
+ /**
66
+ * RPC subclient for the settings domain, or null when the host has no
67
+ * `client.rpc` (degraded direct-fs path). Never throws.
68
+ */
69
+ export function getSettingsRpcClient(api) {
70
+ try {
71
+ const rpc = api?.client?.rpc;
72
+ if (typeof rpc !== 'function') return null;
73
+ return rpc.call(api.client, SettingsRpc);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Subscribe to the server `changed` event for live refresh. Returns the
81
+ * unsubscribe function, or null when the host has no RPC events (the 5s poll
82
+ * remains the floor). Never throws.
83
+ */
84
+ export function subscribeSettingsChanged(api, onChanged) {
85
+ try {
86
+ const client = getSettingsRpcClient(api);
87
+ if (!client || !client.events || typeof client.events.on !== 'function') {
88
+ return null;
89
+ }
90
+ const unsub = client.events.on('changed', (event) => {
91
+ try {
92
+ if (typeof onChanged === 'function') onChanged(event);
93
+ } catch {
94
+ /* refresh callbacks are best-effort */
95
+ }
96
+ });
97
+ return typeof unsub === 'function' ? unsub : () => {};
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /** Read UI-only prefs ({} when the host has no TUI storage). Never throws. */
104
+ export function readUiPrefs(api) {
105
+ try {
106
+ const store = api?.storage?.store;
107
+ if (typeof store !== 'function') return {};
108
+ const [state] = store.call(api.storage, UI_PREFS_KEY, { initial: {} });
109
+ return { ...(state || {}) };
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Merge UI-only prefs (never run-affecting; false when the host has no TUI
117
+ * storage). Never throws.
118
+ */
119
+ export async function writeUiPrefs(api, patch) {
120
+ try {
121
+ const store = api?.storage?.store;
122
+ if (typeof store !== 'function') return false;
123
+ const parts = store.call(api.storage, UI_PREFS_KEY, { initial: {} });
124
+ const mutate = parts && parts[1];
125
+ if (typeof mutate !== 'function') return false;
126
+ await mutate((draft) => {
127
+ Object.assign(draft, patch || {});
128
+ });
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /** Visible toast marking the degraded direct-fs path. */
136
+ function degradedRpcToast(host, reason) {
137
+ wizardToast(
138
+ host,
139
+ 'IUMBTEMS settings',
140
+ `Server RPC ${reason} — using direct file write (degraded path). ` +
141
+ 'Changes still validate and apply to the next swarm run.'
142
+ );
143
+ }
144
+
145
+ /** Human-readable settings text for already-loaded rows (the RPC path). */
146
+ function formatSettingRows(rows) {
147
+ try {
148
+ if (!rows || rows.length === 0) return 'IUMBTEMS settings: no schema available.';
149
+ const lines = rows.map((row) => {
150
+ const restartNote =
151
+ row.restart === 'next-run'
152
+ ? 'applies to next swarm run'
153
+ : `restart: ${row.restart}`;
154
+ const stale = row.deprecated ? ' (deprecated, display-only)' : '';
155
+ return `- ${row.label} (${row.key}): ${displayCell(row)} [${row.source}] — ${restartNote}${stale}`;
156
+ });
157
+ return ['IUMBTEMS settings (effective values):', ...lines].join('\n');
158
+ } catch {
159
+ return 'IUMBTEMS settings: unavailable (workspace unreadable).';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Effective settings body: RPC snapshot when available, direct-fs text
165
+ * otherwise. Never throws.
166
+ */
167
+ async function effectiveSettingsBody(host, baseDir, env) {
168
+ let body = null;
169
+ const rpc = getSettingsRpcClient(host);
170
+ if (rpc) {
171
+ try {
172
+ const snap = await rpc.get();
173
+ if (snap && snap.config && typeof snap.config === 'object') {
174
+ // Provenance comes from the ON-DISK `raw`, not the merged effective
175
+ // `config` (every key is present there, which made every row `file`).
176
+ const rows = buildSettingRows({ ...snap.config }, snap.raw, env);
177
+ if (rows.length > 0) body = formatSettingRows(rows);
178
+ }
179
+ } catch {
180
+ /* fall through to the direct-fs read */
181
+ }
182
+ }
183
+ if (body === null) body = formatSettingsText(baseDir, env);
184
+ // R10: a malformed file must not render as silent defaults. Advisory local-fs
185
+ // check (the same rule the wizard warns on); surfaced in the panel body —
186
+ // never throws, never writes.
187
+ try {
188
+ if (isConfigMalformed(baseDir)) {
189
+ body = `⚠️ Could not parse ${baseDir}/config.json — showing defaults.\n\n${body}`;
190
+ }
191
+ } catch {
192
+ /* advisory only — a failed check must not break the panel */
193
+ }
194
+ return body;
195
+ }
196
+
34
197
  /** Palette command opening the read-only settings status panel. */
35
198
  export const SETTINGS_PANEL_COMMAND_ID = 'iumbtems.swarm-settings';
36
199
  /** Palette command opening the settings dialog wizard. */
@@ -206,6 +369,26 @@ function SwarmSidebar(api, root) {
206
369
  }
207
370
  }, REFRESH_MS);
208
371
  onCleanup(() => clearInterval(timer));
372
+ // Live refresh on the server `changed` event; the poll above is the floor.
373
+ let unsub = null;
374
+ try {
375
+ unsub = subscribeSettingsChanged(api, () => {
376
+ try {
377
+ setSnapshot(readSwarmStatus(root));
378
+ } catch {
379
+ /* keep last snapshot */
380
+ }
381
+ });
382
+ } catch {
383
+ /* poll remains the floor */
384
+ }
385
+ onCleanup(() => {
386
+ try {
387
+ if (typeof unsub === 'function') unsub();
388
+ } catch {
389
+ /* unsubscribe is best-effort */
390
+ }
391
+ });
209
392
  });
210
393
  return box({}, [
211
394
  () => {
@@ -829,8 +1012,31 @@ export async function openSettingsWizard(host, root, opts = {}) {
829
1012
  );
830
1013
  return { ok: false, reason: 'no-dialog', written: false };
831
1014
  }
832
- const raw = readRawConfig(baseDir);
833
- if (isConfigMalformed(baseDir)) {
1015
+ // Preferred path: the server RPC snapshot. Absent `client.rpc` (or a
1016
+ // failed RPC read) falls back to the direct-fs path with a visible toast.
1017
+ let rpc = getSettingsRpcClient(host);
1018
+ if (!rpc) degradedRpcToast(host, 'unavailable');
1019
+ let raw;
1020
+ let draft;
1021
+ let startHash;
1022
+ if (rpc) {
1023
+ try {
1024
+ const snap = await rpc.get();
1025
+ if (!snap || typeof snap.config !== 'object') throw new Error('empty snapshot');
1026
+ // R1: provenance uses the RPC's on-disk `raw` (null/absent -> {}),
1027
+ // never the merged effective `config`.
1028
+ raw = snap.raw && typeof snap.raw === 'object' ? snap.raw : {};
1029
+ draft = JSON.parse(JSON.stringify(snap.config));
1030
+ startHash = typeof snap.hash === 'string' ? snap.hash : null;
1031
+ } catch {
1032
+ degradedRpcToast(host, 'read failed');
1033
+ rpc = null;
1034
+ }
1035
+ }
1036
+ // B2 (phase-03 R6): the malformed-config warning fires on the RPC path
1037
+ // too — the server snapshot degrades to defaults for a corrupt file, and
1038
+ // silent defaults are not B2-compliant. Local fs read, advisory only.
1039
+ if (rpc && isConfigMalformed(baseDir)) {
834
1040
  wizardToast(
835
1041
  host,
836
1042
  'IUMBTEMS settings',
@@ -838,8 +1044,19 @@ export async function openSettingsWizard(host, root, opts = {}) {
838
1044
  'Saving will replace the malformed file; cancel writes nothing.'
839
1045
  );
840
1046
  }
841
- const startHash = configHash(baseDir);
842
- const draft = loadConfig(baseDir);
1047
+ if (!rpc) {
1048
+ raw = readRawConfig(baseDir);
1049
+ if (isConfigMalformed(baseDir)) {
1050
+ wizardToast(
1051
+ host,
1052
+ 'IUMBTEMS settings',
1053
+ `Could not parse ${baseDir}/config.json — showing defaults. ` +
1054
+ 'Saving will replace the malformed file; cancel writes nothing.'
1055
+ );
1056
+ }
1057
+ startHash = configHash(baseDir);
1058
+ draft = loadConfig(baseDir);
1059
+ }
843
1060
  if (raw && rawHasLegacyPin(raw, draft.backend)) {
844
1061
  wizardToast(
845
1062
  host,
@@ -893,6 +1110,56 @@ export async function openSettingsWizard(host, root, opts = {}) {
893
1110
  return { ok: false, reason: 'dialog-error', written: false };
894
1111
  }
895
1112
  if (!confirmed) continue;
1113
+ // Preferred write path: the server RPC (validates + hash-guards
1114
+ // server-side, mirrors storage, emits `changed`). Transport failures
1115
+ // fall back to the direct-fs write below.
1116
+ if (rpc) {
1117
+ try {
1118
+ const rpcInput = { updates: draft };
1119
+ if (opts && opts.expected_hash !== undefined) {
1120
+ rpcInput.expected_hash = opts.expected_hash;
1121
+ }
1122
+ if (opts && opts.expectedHash !== undefined) {
1123
+ rpcInput.expectedHash = opts.expectedHash;
1124
+ }
1125
+ if (rpcInput.expected_hash === undefined && rpcInput.expectedHash === undefined) {
1126
+ rpcInput.expectedHash = startHash;
1127
+ }
1128
+ const res = await rpc.set(rpcInput);
1129
+ wizardToast(
1130
+ host,
1131
+ 'IUMBTEMS settings',
1132
+ 'Saved via server RPC — applies to the next swarm run.'
1133
+ );
1134
+ try {
1135
+ await writeUiPrefs(host, { lastSavedAt: new Date().toISOString() });
1136
+ } catch {
1137
+ /* UI prefs are best-effort */
1138
+ }
1139
+ return { ok: true, written: true, hash: (res && res.hash) || null };
1140
+ } catch (err) {
1141
+ if (err && (err.type === 'stale' || (err.data && err.data.current_hash !== undefined))) {
1142
+ wizardToast(
1143
+ host,
1144
+ 'IUMBTEMS settings',
1145
+ 'Config changed on disk since it was read; refusing the stale write. ' +
1146
+ 'Re-open the wizard for a fresh snapshot — nothing was written.'
1147
+ );
1148
+ return { ok: false, reason: 'stale', written: false };
1149
+ }
1150
+ const failure = err && (err.rpcType || err.type);
1151
+ if (failure === 'invalid' || failure === 'conflict') {
1152
+ wizardToast(
1153
+ host,
1154
+ 'IUMBTEMS settings',
1155
+ `Save failed: ${(err && err.message) || err} — nothing was written.`
1156
+ );
1157
+ return { ok: false, reason: 'save-failed', written: false };
1158
+ }
1159
+ degradedRpcToast(host, 'write failed');
1160
+ rpc = null;
1161
+ }
1162
+ }
896
1163
  const guard =
897
1164
  opts && (opts.expected_hash !== undefined || opts.expectedHash !== undefined)
898
1165
  ? { expected_hash: opts.expected_hash, expectedHash: opts.expectedHash }
@@ -904,6 +1171,11 @@ export async function openSettingsWizard(host, root, opts = {}) {
904
1171
  'IUMBTEMS settings',
905
1172
  `Saved ${res.path} — applies to the next swarm run.`
906
1173
  );
1174
+ try {
1175
+ await writeUiPrefs(host, { lastSavedAt: new Date().toISOString() });
1176
+ } catch {
1177
+ /* UI prefs are best-effort */
1178
+ }
907
1179
  return { ok: true, written: true, hash: res.hash };
908
1180
  } catch (err) {
909
1181
  if (err && err.code === 'CONFIG_STALE') {
@@ -941,7 +1213,7 @@ export async function openSettingsPanel(host, root, opts = {}) {
941
1213
  try {
942
1214
  const baseDir = researchDirOf(host, root);
943
1215
  const env = (opts && opts.env) || process.env;
944
- const body = formatSettingsText(baseDir, env);
1216
+ const body = await effectiveSettingsBody(host, baseDir, env);
945
1217
  let show = null;
946
1218
  try {
947
1219
  if (typeof host?.ui?.panel === 'function') show = host.ui.panel.bind(host.ui);
@@ -145,6 +145,35 @@ def _allows_null(schema: Dict[str, Any]) -> bool:
145
145
  return types == "null"
146
146
 
147
147
 
148
+ def _legacy_pin_migration_pending(base_dir: str) -> bool:
149
+ """Would the legacy-pin migration apply on the next write? (read-only)
150
+
151
+ Phase-04 R11: the `show` leg reports this WITHOUT persisting, so a read API
152
+ stays side-effect free. Mirrors `heal_config`'s decision — the on-disk file
153
+ carries a legacy ``["claude", "-p"]`` pin AND the would-be-persisted config
154
+ passes canonical validation (an invalid file is never healed, R1) — but
155
+ never writes.
156
+ """
157
+ try:
158
+ from skills.swarm_config.configure import (
159
+ load_config,
160
+ migrate_legacy_agent_backends,
161
+ validate_config,
162
+ )
163
+
164
+ cfg_file = Path(base_dir) / "config.json"
165
+ if not cfg_file.is_file():
166
+ return False
167
+ raw = json.loads(cfg_file.read_bytes().decode("utf-8"))
168
+ if not isinstance(raw, dict):
169
+ return False
170
+ if not migrate_legacy_agent_backends(raw):
171
+ return False
172
+ return not validate_config(load_config(base_dir))
173
+ except Exception:
174
+ return False
175
+
176
+
148
177
  def _handle_config(args: Dict[str, Any]) -> str:
149
178
  """Inspect or modify swarm parameters in .research/config.json.
150
179
 
@@ -154,10 +183,11 @@ def _handle_config(args: Dict[str, Any]) -> str:
154
183
  locked, validated, atomic, unknown-key-preserving) with an optional
155
184
  `expected_hash` guard (both `expected_hash` and `expectedHash` spellings).
156
185
  """
186
+ import hashlib
187
+
157
188
  from skills.swarm_config.configure import (
189
+ DEFAULT_CONFIG,
158
190
  ConfigError,
159
- config_hash,
160
- heal_config,
161
191
  load_config,
162
192
  merge_config,
163
193
  save_config,
@@ -167,10 +197,6 @@ def _handle_config(args: Dict[str, Any]) -> str:
167
197
 
168
198
  base_dir = str(_resolve_base_dir(args))
169
199
  cfg = load_config(base_dir)
170
- # Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
171
- # stops silently spawning Claude on every run. `heal_config` refuses to
172
- # persist a schema-rejected file (R1).
173
- healed = heal_config(base_dir)
174
200
 
175
201
  # R4: both spellings must agree; never silently prefer one.
176
202
  snake = args.get("expected_hash")
@@ -240,7 +266,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
240
266
  )
241
267
 
242
268
  if not updates:
243
- return _tool_text({"status": "current", "config": cfg, "migrated": healed})
269
+ # R11: the show leg is strictly read-only — a settings panel reads
270
+ # through it, so it must never persist the legacy-pin migration. Report
271
+ # whether a migration is pending; only the write leg below heals.
272
+ return _tool_text(
273
+ {
274
+ "status": "current",
275
+ "config": cfg,
276
+ "migrated": _legacy_pin_migration_pending(base_dir),
277
+ }
278
+ )
244
279
 
245
280
  candidate = merge_config(cfg, updates)
246
281
  problems = validate_config(candidate)
@@ -255,6 +290,11 @@ def _handle_config(args: Dict[str, Any]) -> str:
255
290
  }
256
291
  )
257
292
 
293
+ # R12 (F1): do NOT heal before saving. The candidate is already migrated in
294
+ # memory (`load_config`) and `save_config` merges it over the on-disk bytes,
295
+ # so the legacy-pin migration persists as a side effect — while the caller's
296
+ # `expected_hash` guard still matches the bytes they read. A heal-before-save
297
+ # rewrote the file and turned a valid guarded write into a false `stale`.
258
298
  try:
259
299
  save_config(
260
300
  candidate,
@@ -269,12 +309,43 @@ def _handle_config(args: Dict[str, Any]) -> str:
269
309
  payload.update(exc.details)
270
310
  return _tool_text(payload)
271
311
 
312
+ # R10 (single read): derive BOTH the effective config and the hash from ONE
313
+ # read of the just-published bytes, so the storage mirror and the `changed`
314
+ # event can never receive an era-A config paired with an era-B hash.
315
+ # `save_config` publishes a fully defaults-merged, validated object
316
+ # atomically, so the bytes on disk are already the effective config; the
317
+ # earlier `load_config()` + `config_hash()` were two independent reads.
318
+ cfg_file = Path(base_dir) / "config.json"
319
+ try:
320
+ raw_bytes = cfg_file.read_bytes()
321
+ except OSError:
322
+ raw_bytes = None
323
+ if raw_bytes is None:
324
+ # A successful save always leaves bytes; keep the payload shape without
325
+ # a second read in this (unreachable) defensive branch.
326
+ return _tool_text(
327
+ {
328
+ "status": "updated",
329
+ "config": candidate,
330
+ "written_to": str(cfg_file),
331
+ "hash": None,
332
+ }
333
+ )
334
+ try:
335
+ parsed = json.loads(raw_bytes.decode("utf-8"))
336
+ except (ValueError, UnicodeDecodeError):
337
+ parsed = None
338
+ effective = (
339
+ merge_config(DEFAULT_CONFIG, parsed)
340
+ if isinstance(parsed, dict)
341
+ else merge_config(DEFAULT_CONFIG, {})
342
+ )
272
343
  return _tool_text(
273
344
  {
274
345
  "status": "updated",
275
- "config": load_config(base_dir),
276
- "written_to": str(Path(base_dir) / "config.json"),
277
- "hash": config_hash(base_dir),
346
+ "config": effective,
347
+ "written_to": str(cfg_file),
348
+ "hash": hashlib.sha256(raw_bytes).hexdigest(),
278
349
  }
279
350
  )
280
351
 
@@ -146,7 +146,10 @@ def _verify_threshold(config: Optional[Dict[str, Any]]) -> float:
146
146
 
147
147
 
148
148
  def _config_child_env(
149
- base_dir: Path, agent_cwd: str, config: Optional[Dict[str, Any]]
149
+ base_dir: Path,
150
+ agent_cwd: str,
151
+ config: Optional[Dict[str, Any]],
152
+ role: Optional[str] = None,
150
153
  ) -> Dict[str, str]:
151
154
  """Environment for a spawned agent child: base env + config passthrough.
152
155
 
@@ -158,6 +161,11 @@ def _config_child_env(
158
161
  * `cache_ttl_days` -> IUMBTEMS_FETCH_TTL_DAYS (webcache, when set)
159
162
  * `search_timeout_s` -> IUMBTEMS_SEARCH_TIMEOUT_S (search.py, when set)
160
163
  * `searxng_url` -> SEARXNG_URL (search provider + searxng_mcp)
164
+ * `role` -> IUMBTEMS_AGENT_ROLE (S3 temperature spike: lets
165
+ a spawned `opencode run` child know its swarm role so the project
166
+ plugin's `session.hook("context")` can key per-role temperature off
167
+ `event.agent` — if the child loads the project plugin; absent/empty
168
+ role exports nothing)
161
169
  """
162
170
  cfg = config or {}
163
171
  env: Dict[str, str] = {
@@ -181,6 +189,8 @@ def _config_child_env(
181
189
  searxng = cfg.get("searxng_url")
182
190
  if isinstance(searxng, str) and searxng.strip():
183
191
  env["SEARXNG_URL"] = searxng.strip()
192
+ if role:
193
+ env["IUMBTEMS_AGENT_ROLE"] = str(role)
184
194
  return env
185
195
 
186
196
 
@@ -524,7 +534,7 @@ class SwarmRunner:
524
534
  # IUMBTEMS_RESEARCH_DIR points agent-invoked skill scripts
525
535
  # (search.py/hasher.py) at this workspace for retrieval telemetry;
526
536
  # the cache/search keys ride the same env channel.
527
- env=_config_child_env(self.base_dir, agent_cwd, self.config),
537
+ env=_config_child_env(self.base_dir, agent_cwd, self.config, role),
528
538
  # stdin MUST be DEVNULL: `opencode run` reads piped stdin to
529
539
  # EOF before starting, and the MCP server's inherited stdin
530
540
  # pipe is held open by the harness — every agent hung forever
package/runner/schemas.py CHANGED
@@ -311,6 +311,7 @@ CONFIG_DEFAULTS: Dict[str, Any] = {
311
311
  },
312
312
  "opencode_auto": None,
313
313
  "opencode_agent": None,
314
+ "mcp_servers": {},
314
315
  }
315
316
 
316
317
 
@@ -590,6 +591,23 @@ CONFIG: Dict[str, Any] = {
590
591
  "agents.<role>.opencode_agent wins."
591
592
  ),
592
593
  },
594
+ "mcp_servers": {
595
+ "type": "object",
596
+ "default": copy.deepcopy(CONFIG_DEFAULTS["mcp_servers"]),
597
+ "x-label": "MCP Servers",
598
+ "x-scope": "dispatch",
599
+ "x-restart": "next-run",
600
+ "description": (
601
+ "Persisted toggle map for the plugin-controllable MCP server "
602
+ "set (bundled `iumbtems` server plus research servers declared "
603
+ "in the project OpenCode config). Keys are server names; "
604
+ "values are booleans (true = enabled, false = disabled). "
605
+ "Absent keys mean enabled. Applied via ctx.mcp.transform "
606
+ "(`disabled` reconcile); the catalog file "
607
+ "config/mcp-research-servers.json is never modified."
608
+ ),
609
+ "additionalProperties": {"type": "boolean"},
610
+ },
593
611
  },
594
612
  }
595
613