@heretek-ai/epistemic-swarm 0.7.20 → 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 (76) 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/index.js +10 -5
  7. package/plugins/opencode/tui.js +20 -4
  8. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  28. package/runner/mcp_server.py +44 -6
  29. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  30. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  60. package/runner/tests/test_config_schema.py +65 -0
  61. package/runner/tests/test_opencode_ux.py +133 -13
  62. package/runner/tests/test_opencode_v2_registrars.py +51 -0
  63. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  64. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  65. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  66. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  67. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  68. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  69. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  71. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  72. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/SKILL.md +26 -0
  74. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  76. package/skills/swarm_config/configure.py +35 -4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistemic-swarm",
3
- "version": "0.7.20",
3
+ "version": "0.7.21",
4
4
  "description": "High-Integrity Dialectic Research Agent Harness for Claude Code enforcing empirical evidence over parametric hallucination.",
5
5
  "author": {
6
6
  "name": "Heretek AI",
package/README.md CHANGED
@@ -157,7 +157,7 @@ Related environment variables:
157
157
 
158
158
  Spawned agents inherit `PWD` set to their working directory, so harnesses that resolve their project root from `$PWD` (OpenCode) land in the same tree the runner reads from.
159
159
 
160
- > **Legacy config migration:** configs written before 0.7.6 could pin `agents.<role>.backend = ["claude", "-p"]`, which silently overrode the host-native default. That pin is now migrated to `null` on load (only when the top-level `backend` is not an explicit `"claude"`), and the migration is persisted by `iumbtems config`. To deliberately keep Claude on an OpenCode host, set `"backend": "claude"`.
160
+ > **Legacy config migration:** configs written before 0.7.6 could pin `agents.<role>.backend = ["claude", "-p"]`, which silently overrode the host-native default. That pin is now migrated to `null` on load (only when the top-level `backend` is not an explicit `"claude"`), and the migration is persisted on the next config **write** (a read / `--show` never heals). To deliberately keep Claude on an OpenCode host, set `"backend": "claude"`.
161
161
 
162
162
  ---
163
163
 
@@ -265,6 +265,27 @@ Nine canonical skills live in `skills/*/SKILL.md`. Each maps to a slash command
265
265
 
266
266
  ---
267
267
 
268
+ ## ⚙️ Settings Surface (`/swarm-config`)
269
+
270
+ The settings surface is the recommended way to tune the harness from inside OpenCode V2. It has two parts:
271
+
272
+ - **`/swarm-config` wizard** — a native dialog wizard (`ui.dialog.select/prompt/confirm`) showing every canonical key with a provenance badge (`default` / `file` / `env-override`), one editable row at a time, validated and written atomically on save.
273
+ - **Status panel** — a read-only panel opened from the command palette / keymap (`iumbtems.swarm-settings`) rendering the effective rows with a **next-run** badge. It never writes.
274
+
275
+ The canonical config it edits is **`.research/config.json`** — the TUI resolves `.research` from the OpenCode host root; `IUMBTEMS_PROJECT_DIR` / `--dir` / `base_dir` select the workspace for the CLI, MCP, and runner surfaces. Configurable values include the search engine, depth (`max_iterations`), operating `mode`, `backend`, per-role (`agents.*`) backend/model, `license_whitelist`, the cache / TTL / search-timeout knobs, `searxng_url`, `verify.min_fuzzy_confidence`, the persistence `mcp_servers` toggle map, `allocation`, `domain_pack`, and the deprecated display-only `output_dir`. (`divergence_threshold` is advisory: recorded, never gating a verdict.)
276
+
277
+ **Honest boundaries**
278
+
279
+ - **Values apply to the NEXT swarm run.** The Python runner reads `.research/config.json` once at run start, so a save never hot-swaps a running swarm — the wizard and panel both say "applies to the next swarm run".
280
+ - **Per-role temperature does not govern spawned agents.** The OpenCode session `context` hook honors `IUMBTEMS_TEMPERATURE[_<ROLE>]` for sessions running inside OpenCode, but there is **no verified path** that sets per-role temperature for spawned swarm agents — that leg is recorded `[NEGATIVE_KNOWLEDGE]` (S3, dated 2026-09-29; waiver W3). The UI deliberately makes no temperature claim.
281
+ - **No secrets in config.** API keys stay in the environment (`FIRECRAWL_API_KEY`, …). The surface only reports whether an env override is set for the keys it models; it never renders an env value, and it does not probe engine availability.
282
+
283
+ Writes prefer the plugin's `iumbtems.settings` RPC (server-side validation + an `expectedHash` lost-update guard; the RPC surfaces a `conflict` error type, and Python's code is `CONFLICTING_EXPECTED_HASH`) and degrade to a direct-fs write with a visible toast when `client.rpc` is absent; `.research/config.json` stays the single source of truth. See [docs/SYSTEM_ARCHITECTURE.md](docs/SYSTEM_ARCHITECTURE.md) §6.7 for the full data flow, the atomic-write + lock protocol, and the `ctx.storage` mirror.
284
+
285
+ *Reviewed settings-surface patterns from Gemini CLI, Codex, Goose, Aider, Cline, OpenCode, and Crush (docs-only) informed this design; no code was copied.*
286
+
287
+ ---
288
+
268
289
  ## 🗂️ Workspace & Evidence Model
269
290
 
270
291
  All state lives in a local `.research/` directory (resolved from `IUMBTEMS_PROJECT_DIR` when set, else the process cwd):
@@ -366,7 +387,7 @@ The package is distributed on npm as `@heretek-ai/epistemic-swarm` using [npm Tr
366
387
 
367
388
  **Release flow** (see [AGENTS.md](AGENTS.md#releases)):
368
389
 
369
- 1. Bump `package.json` **and** `.claude-plugin/plugin.json` **and** the lockfile in lockstep.
390
+ 1. Bump the **four enforced manifests** in lockstep: `package.json`, `package-lock.json`, `.claude-plugin/plugin.json`, and `plugins/antigravity/plugin.json` (`python3 scripts/build_adapters.py --check` guards the AntiGravity one).
370
391
  2. Commit and push to `main`.
371
392
  3. `gh release create vX.Y.Z` — the `Publish to npm` workflow
372
393
  (`.github/workflows/publish.yml`) runs on `release: published`, executes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "0.7.20",
3
+ "version": "0.7.21",
4
4
  "description": "IUMBTEMS (Epistemic Swarm) \u2014 High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi (pi.dev), OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://antigravity.google/schemas/v1/plugin.json",
3
3
  "name": "epistemic-swarm",
4
- "version": "0.7.20",
4
+ "version": "0.7.21",
5
5
  "description": "IUMBTEMS (Epistemic Swarm) \u2014 High-Integrity Dialectic Research, Architecture Auditing, Competitor Teardowns, and Socratic Planning for Antigravity.",
6
6
  "author": {
7
7
  "name": "Heretek AI",
@@ -109,9 +109,11 @@ or `[NEGATIVE_KNOWLEDGE: <query>]`. Never present parametric recall as verified.
109
109
  effect only after release + reinstall.
110
110
 
111
111
  ## Releases
112
- - Bump `package.json` (+ lockfile sync) **and `.claude-plugin/plugin.json`**
113
- (0.7.5 shipped with a stale plugin manifest — keep all three in lockstep),
114
- commit, push to `main`, then
112
+ - Bump the **four enforced manifests** in lockstep — `package.json`,
113
+ `package-lock.json`, `.claude-plugin/plugin.json`, and
114
+ `plugins/antigravity/plugin.json` (0.7.5 shipped a stale plugin manifest and
115
+ v0.7.18 missed the AntiGravity manifest, failing `Validate All Harnesses` +
116
+ `Publish to npm` twice). Then commit, push to `main`, then
115
117
  `gh release create vX.Y.Z`. The `Publish to npm` workflow (OIDC trusted
116
118
  publishing) runs on release-published. Verify with
117
119
  `curl https://registry.npmjs.org/@heretek-ai%2Fepistemic-swarm`
@@ -2097,6 +2097,7 @@ export const SettingsRpc = {
2097
2097
  type: 'object',
2098
2098
  properties: {
2099
2099
  config: { type: 'object' },
2100
+ raw: { type: ['object', 'null'] },
2100
2101
  hash: { type: ['string', 'null'] },
2101
2102
  migrated: { type: 'boolean' },
2102
2103
  },
@@ -2308,11 +2309,12 @@ export function createSettingsHandlers(opts = {}) {
2308
2309
  throw sanitized;
2309
2310
  }
2310
2311
  // Single-read (phase-03 R5): the raw bytes are read EXACTLY ONCE below;
2311
- // the returned config is merged from those bytes and the hash is
2312
- // computed over those bytes, so an era-A config with an era-B hash is
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
2313
2315
  // impossible. The Python surface still supplies the `migrated` flag
2314
- // (and keeps its heal-on-read side effect); the returned triple is one
2315
- // snapshot regardless of what either side read on its own clock.
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.
2316
2318
  const snap = readConfigSnapshot(researchDir);
2317
2319
  const config = loadConfigFromRaw(snap.raw);
2318
2320
  // Refresh-on-read (phase-03 R1): a direct-fs fallback-leg write bypasses
@@ -2330,7 +2332,10 @@ export function createSettingsHandlers(opts = {}) {
2330
2332
  } catch {
2331
2333
  /* the storage mirror is best-effort */
2332
2334
  }
2333
- return { config, hash: snap.hash, migrated: Boolean(payload.migrated) };
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) };
2334
2339
  },
2335
2340
  validate: async (input = {}) => {
2336
2341
  const updates = input?.updates;
@@ -165,19 +165,33 @@ function formatSettingRows(rows) {
165
165
  * otherwise. Never throws.
166
166
  */
167
167
  async function effectiveSettingsBody(host, baseDir, env) {
168
+ let body = null;
168
169
  const rpc = getSettingsRpcClient(host);
169
170
  if (rpc) {
170
171
  try {
171
172
  const snap = await rpc.get();
172
173
  if (snap && snap.config && typeof snap.config === 'object') {
173
- const rows = buildSettingRows({ ...snap.config }, snap.config, env);
174
- if (rows.length > 0) return formatSettingRows(rows);
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);
175
178
  }
176
179
  } catch {
177
180
  /* fall through to the direct-fs read */
178
181
  }
179
182
  }
180
- return formatSettingsText(baseDir, env);
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;
181
195
  }
182
196
 
183
197
  /** Palette command opening the read-only settings status panel. */
@@ -1009,7 +1023,9 @@ export async function openSettingsWizard(host, root, opts = {}) {
1009
1023
  try {
1010
1024
  const snap = await rpc.get();
1011
1025
  if (!snap || typeof snap.config !== 'object') throw new Error('empty snapshot');
1012
- raw = snap.config;
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 : {};
1013
1029
  draft = JSON.parse(JSON.stringify(snap.config));
1014
1030
  startHash = typeof snap.hash === 'string' ? snap.hash : null;
1015
1031
  } catch {
@@ -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
 
@@ -159,7 +188,6 @@ def _handle_config(args: Dict[str, Any]) -> str:
159
188
  from skills.swarm_config.configure import (
160
189
  DEFAULT_CONFIG,
161
190
  ConfigError,
162
- heal_config,
163
191
  load_config,
164
192
  merge_config,
165
193
  save_config,
@@ -169,10 +197,6 @@ def _handle_config(args: Dict[str, Any]) -> str:
169
197
 
170
198
  base_dir = str(_resolve_base_dir(args))
171
199
  cfg = load_config(base_dir)
172
- # Persist a legacy ["claude", "-p"] agent pin migration so an OpenCode host
173
- # stops silently spawning Claude on every run. `heal_config` refuses to
174
- # persist a schema-rejected file (R1).
175
- healed = heal_config(base_dir)
176
200
 
177
201
  # R4: both spellings must agree; never silently prefer one.
178
202
  snake = args.get("expected_hash")
@@ -242,7 +266,16 @@ def _handle_config(args: Dict[str, Any]) -> str:
242
266
  )
243
267
 
244
268
  if not updates:
245
- 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
+ )
246
279
 
247
280
  candidate = merge_config(cfg, updates)
248
281
  problems = validate_config(candidate)
@@ -257,6 +290,11 @@ def _handle_config(args: Dict[str, Any]) -> str:
257
290
  }
258
291
  )
259
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`.
260
298
  try:
261
299
  save_config(
262
300
  candidate,
@@ -321,6 +321,71 @@ class TestMcpConfigSurface(unittest.TestCase):
321
321
  self.assertFalse((Path(tmp) / "config.json").exists())
322
322
  shown = self._call({"base_dir": tmp, "show": True})
323
323
  self.assertEqual(shown["status"], "current")
324
+ # R11: the legacy-pin case must NOT write (the old test was vacuous
325
+ # here). Seed a pin, snapshot the bytes, and prove the show leg
326
+ # leaves them byte-identical while still reporting the migration.
327
+ cfg_file = _write_config(
328
+ Path(tmp), {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
329
+ )
330
+ before = cfg_file.read_bytes()
331
+ pinned = self._call({"base_dir": tmp})
332
+ self.assertEqual(pinned["status"], "current")
333
+ self.assertTrue(pinned["migrated"])
334
+ self.assertEqual(cfg_file.read_bytes(), before)
335
+
336
+ def test_write_leg_heals_legacy_pin_and_persists(self):
337
+ # R11 preserves phase-01 A6: a write still migrates the legacy pin on disk.
338
+ with tempfile.TemporaryDirectory() as tmp:
339
+ base = Path(tmp)
340
+ cfg_file = _write_config(
341
+ base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
342
+ )
343
+ payload = self._call({"max_iterations": 2, "base_dir": tmp})
344
+ self.assertEqual(payload["status"], "updated", payload)
345
+ healed = json.loads(cfg_file.read_text(encoding="utf-8"))
346
+ self.assertIsNone(healed["agents"]["alpha"]["backend"])
347
+ self.assertEqual(healed["max_iterations"], 2)
348
+
349
+ def test_guarded_write_on_legacy_pin_succeeds_and_heals(self):
350
+ # F1/R12: the wizard always sends `expected_hash`. With no heal-before-
351
+ # save, the guard still matches so the write is `updated` (never a false
352
+ # `stale`) and the pin is migrated on disk.
353
+ with tempfile.TemporaryDirectory() as tmp:
354
+ base = Path(tmp)
355
+ cfg_file = _write_config(
356
+ base, {"agents": {"alpha": {"backend": ["claude", "-p"]}}}
357
+ )
358
+ start_hash = config_hash(tmp)
359
+ payload = self._call(
360
+ {"max_iterations": 3, "expected_hash": start_hash, "base_dir": tmp}
361
+ )
362
+ self.assertEqual(payload["status"], "updated", payload)
363
+ healed = json.loads(cfg_file.read_text(encoding="utf-8"))
364
+ self.assertIsNone(healed["agents"]["alpha"]["backend"])
365
+ self.assertEqual(healed["max_iterations"], 3)
366
+
367
+ def test_invalid_pin_file_show_reports_no_migration_and_write_blocked(self):
368
+ # R12/R11: an invalid file is never healed, and a write that would leave
369
+ # it invalid is rejected without touching the bytes.
370
+ with tempfile.TemporaryDirectory() as tmp:
371
+ base = Path(tmp)
372
+ cfg_file = _write_config(
373
+ base,
374
+ {
375
+ "max_iterations": 99,
376
+ "agents": {"alpha": {"backend": ["claude", "-p"]}},
377
+ },
378
+ )
379
+ before = cfg_file.read_bytes()
380
+ shown = self._call({"base_dir": tmp})
381
+ self.assertEqual(shown["status"], "current")
382
+ self.assertFalse(shown["migrated"])
383
+ self.assertEqual(cfg_file.read_bytes(), before)
384
+ blocked = self._call({"mode": "audit", "base_dir": tmp})
385
+ self.assertEqual(blocked["status"], "error")
386
+ self.assertEqual(blocked["code"], "CONFIG_VALIDATION_FAILED")
387
+ self.assertFalse(blocked["written"])
388
+ self.assertEqual(cfg_file.read_bytes(), before)
324
389
 
325
390
  def test_null_clears_a_nullable_key(self):
326
391
  with tempfile.TemporaryDirectory() as tmp:
@@ -1761,33 +1761,41 @@ import {
1761
1761
  SETTINGS_POLL_MS, UI_PREFS_KEY,
1762
1762
  } from "./plugins/opencode/tui.js";
1763
1763
  import { SettingsRpc } from "./plugins/opencode/index.js";
1764
+ import { loadConfigFromRaw } from "./plugins/opencode/config-io.js";
1764
1765
  import fs from "node:fs";
1765
1766
  import path from "node:path";
1766
1767
 
1767
- function rpcState(config, hash) {
1768
- return { config: { ...config }, hash, setCalls: [], subs: [], unsubbed: false, staleAlways: false };
1768
+ // Realistic RPC snapshot (phase-04 R1): `raw` is the parsed ON-DISK file config;
1769
+ // `config` is the defaults-merged EFFECTIVE config (the production `get` builds
1770
+ // it with `loadConfigFromRaw`). A partial-config mock here is exactly what let
1771
+ // the RPC-path provenance defect ship.
1772
+ function rpcState(raw, hash) {
1773
+ const onDisk = raw && typeof raw === "object" ? raw : {};
1774
+ return {
1775
+ raw: { ...onDisk },
1776
+ config: loadConfigFromRaw(onDisk),
1777
+ hash, setCalls: [], subs: [], unsubbed: false, staleAlways: false,
1778
+ };
1769
1779
  }
1770
1780
  function rpcClient(state) {
1781
+ const snapshot = () => ({ config: { ...state.config }, raw: { ...state.raw }, hash: state.hash });
1771
1782
  return {
1772
- get: async () => ({ config: { ...state.config }, hash: state.hash }),
1783
+ get: async () => snapshot(),
1773
1784
  set: async (input) => {
1774
1785
  state.setCalls.push(input);
1775
- if (state.staleAlways) {
1786
+ const stale = () => {
1776
1787
  const e = new Error("stale");
1777
1788
  e.type = "stale";
1778
1789
  e.data = { current_hash: state.hash, config: { ...state.config } };
1779
1790
  throw e;
1780
- }
1791
+ };
1792
+ if (state.staleAlways) stale();
1781
1793
  const exp = input?.expected_hash !== undefined ? input.expected_hash : input?.expectedHash;
1782
- if (exp !== undefined && exp !== null && exp !== state.hash) {
1783
- const e = new Error("stale");
1784
- e.type = "stale";
1785
- e.data = { current_hash: state.hash, config: { ...state.config } };
1786
- throw e;
1787
- }
1788
- state.config = { ...state.config, ...(input?.updates || {}) };
1794
+ if (exp !== undefined && exp !== null && exp !== state.hash) stale();
1795
+ state.raw = { ...state.raw, ...(input?.updates || {}) };
1796
+ state.config = loadConfigFromRaw(state.raw);
1789
1797
  state.hash = `hash${state.setCalls.length}`;
1790
- return { config: { ...state.config }, hash: state.hash };
1798
+ return snapshot();
1791
1799
  },
1792
1800
  events: {
1793
1801
  on: (name, fn) => { state.subs.push({ name, fn }); return () => { state.unsubbed = true; }; },
@@ -1972,6 +1980,118 @@ class TestRpcPreferredPath(unittest.TestCase):
1972
1980
  self.assertIn("applies to next swarm run", data["body"])
1973
1981
  self.assertTrue(data["intact"])
1974
1982
 
1983
+ def test_panel_rpc_path_provenance_distinguishes_sources(self):
1984
+ # R1 (the shipped defect): on the PREFERRED RPC path, provenance must be
1985
+ # default | file | env-override, derived from the RPC `raw` (on-disk)
1986
+ # rather than the merged effective `config`. Pre-fix, every row was
1987
+ # `[file]` and `[default]` was unreachable ({file: 18}).
1988
+ res = run_node(
1989
+ RPC_TUI_HARNESS
1990
+ + """
1991
+ const state = rpcState({ search_engine: "brave" }, "h0");
1992
+ const shown = [];
1993
+ const host = {
1994
+ directory: "/tmp/oc-rpc-prov",
1995
+ client: { rpc: (def) => rpcClient(state) },
1996
+ ui: { panel: async (p) => shown.push(p) },
1997
+ };
1998
+ const r = await openSettingsPanel(host, "/tmp/oc-rpc-prov", {
1999
+ env: { IUMBTEMS_SEARCH_TIMEOUT_S: "9" },
2000
+ });
2001
+ const body = shown[0].body;
2002
+ const rowOf = (key) => body.split("\\n").find((l) => l.includes("(" + key + ")"));
2003
+ console.log(JSON.stringify({
2004
+ ok: r.ok,
2005
+ searchEngine: rowOf("search_engine"),
2006
+ mode: rowOf("mode"),
2007
+ timeout: rowOf("search_timeout_s"),
2008
+ fileCount: (body.match(/\\[file\\]/g) || []).length,
2009
+ defaultCount: (body.match(/\\[default\\]/g) || []).length,
2010
+ envCount: (body.match(/\\[env-override\\]/g) || []).length,
2011
+ }));
2012
+ """
2013
+ )
2014
+ self.assertEqual(res.returncode, 0, f"rpc provenance failed: {res.stderr}")
2015
+ d = last_json_object(res.stdout)
2016
+ self.assertTrue(d["ok"])
2017
+ # The on-disk file sets only `search_engine` -> `file`, and exactly one row.
2018
+ self.assertIn("[file]", d["searchEngine"])
2019
+ self.assertEqual(d["fileCount"], 1)
2020
+ # `default` is REACHABLE for keys the on-disk file does not set.
2021
+ self.assertIn("[default]", d["mode"])
2022
+ self.assertGreater(d["defaultCount"], 0)
2023
+ # env-override wins for the mapped key.
2024
+ self.assertIn("[env-override]", d["timeout"])
2025
+ self.assertEqual(d["envCount"], 1)
2026
+
2027
+ def test_panel_rpc_path_warns_on_malformed_config(self):
2028
+ # R10: the read-only panel must warn on a malformed config (the wizard
2029
+ # already does), never render silent defaults, never throw, never write.
2030
+ with tempfile.TemporaryDirectory() as tmp:
2031
+ research = Path(tmp) / ".research"
2032
+ research.mkdir()
2033
+ garbage = "{oops not json"
2034
+ research.joinpath("config.json").write_text(garbage, encoding="utf-8")
2035
+ res = run_node(
2036
+ RPC_TUI_HARNESS
2037
+ + f"""
2038
+ const root = {tmp!r};
2039
+ const state = rpcState({{}}, null);
2040
+ const shown = [];
2041
+ const host = {{
2042
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
2043
+ ui: {{ panel: async (p) => shown.push(p) }},
2044
+ }};
2045
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
2046
+ const body = (shown[0] && shown[0].body) || "";
2047
+ const after = fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8");
2048
+ console.log(JSON.stringify({{
2049
+ ok: r.ok,
2050
+ warns: body.includes("Could not parse"),
2051
+ hasRows: body.includes("effective values"),
2052
+ intact: after === {garbage!r},
2053
+ }}));
2054
+ """
2055
+ )
2056
+ self.assertEqual(res.returncode, 0, f"panel malformed failed: {res.stderr}")
2057
+ data = last_json_object(res.stdout)
2058
+ self.assertTrue(data["ok"])
2059
+ self.assertTrue(data["warns"])
2060
+ self.assertTrue(data["hasRows"])
2061
+ self.assertTrue(data["intact"])
2062
+
2063
+ def test_panel_rpc_path_no_notice_for_valid_config(self):
2064
+ # R10 companion: a valid config renders NO malformed notice.
2065
+ with tempfile.TemporaryDirectory() as tmp:
2066
+ research = Path(tmp) / ".research"
2067
+ research.mkdir()
2068
+ seed = json.dumps({"mode": "audit"})
2069
+ research.joinpath("config.json").write_text(seed, encoding="utf-8")
2070
+ res = run_node(
2071
+ RPC_TUI_HARNESS
2072
+ + f"""
2073
+ const root = {tmp!r};
2074
+ const state = rpcState({{ mode: "audit" }}, "h0");
2075
+ const shown = [];
2076
+ const host = {{
2077
+ directory: root, client: {{ rpc: (def) => rpcClient(state) }},
2078
+ ui: {{ panel: async (p) => shown.push(p) }},
2079
+ }};
2080
+ const r = await openSettingsPanel(host, root, {{ env: {{}} }});
2081
+ const body = (shown[0] && shown[0].body) || "";
2082
+ console.log(JSON.stringify({{
2083
+ ok: r.ok,
2084
+ warns: body.includes("Could not parse"),
2085
+ hasMode: body.includes("audit"),
2086
+ }}));
2087
+ """
2088
+ )
2089
+ self.assertEqual(res.returncode, 0, f"panel valid failed: {res.stderr}")
2090
+ data = last_json_object(res.stdout)
2091
+ self.assertTrue(data["ok"])
2092
+ self.assertFalse(data["warns"])
2093
+ self.assertTrue(data["hasMode"])
2094
+
1975
2095
  def test_wizard_rpc_path_warns_on_malformed_config(self):
1976
2096
  # Phase-03 R6 (phase-02 B2 on the primary path): corrupt config with
1977
2097
  # an RPC-preferred host warns instead of rendering silent defaults.
@@ -693,6 +693,7 @@ class TestSettingsRpcDomain(unittest.TestCase):
693
693
  rpcId: SETTINGS_RPC_ID,
694
694
  methods,
695
695
  getIn: SettingsRpc.methods.get.input,
696
+ getOut: SettingsRpc.methods.get.output,
696
697
  setOutRequired: SettingsRpc.methods.set.output.required,
697
698
  events: Object.keys(SettingsRpc.events),
698
699
  changedRequired: SettingsRpc.events.changed.schema.required,
@@ -708,6 +709,10 @@ class TestSettingsRpcDomain(unittest.TestCase):
708
709
  self.assertEqual(d["methods"], ["get", "set", "validate"])
709
710
  # JSON-Schema I/O per the v2 RPC pattern; event data is an object.
710
711
  self.assertEqual(d["getIn"]["type"], "object")
712
+ # R1: `get` carries the on-disk `raw` alongside the effective `config`.
713
+ self.assertEqual(d["getOut"]["type"], "object")
714
+ self.assertIn("raw", d["getOut"]["properties"])
715
+ self.assertIn("config", d["getOut"]["required"])
711
716
  self.assertIn("config", d["setOutRequired"])
712
717
  self.assertIn("hash", d["setOutRequired"])
713
718
  self.assertEqual(d["events"], ["changed"])
@@ -1436,6 +1441,52 @@ class TestSingleReadSnapshot(unittest.TestCase):
1436
1441
  self.assertEqual(d["mirrorMode"], d["gotMode"])
1437
1442
  self.assertEqual(d["mirrorHash"], d["gotHash"])
1438
1443
 
1444
+ def test_get_payload_carries_raw_and_effective_from_one_read(self):
1445
+ """R1: `get` carries the on-disk `raw` alongside the merged effective
1446
+ `config`, and the hash covers the same bytes (single-read preserved)."""
1447
+ import hashlib
1448
+ import tempfile
1449
+
1450
+ with tempfile.TemporaryDirectory() as tmp:
1451
+ research = Path(tmp) / ".research"
1452
+ research.mkdir()
1453
+ cfg = research / "config.json"
1454
+ on_disk = {"mode": "scout"}
1455
+ cfg.write_text(json.dumps(on_disk), encoding="utf-8")
1456
+ res = run_node(
1457
+ PHASE3_HOST
1458
+ + f"""
1459
+ const box = makePhase3Host({tmp!r});
1460
+ const stub = async () => ({{
1461
+ content: JSON.stringify({{ status: "current", config: {{}}, migrated: false }}),
1462
+ status: "success",
1463
+ }});
1464
+ const handlers = createSettingsHandlers({{
1465
+ root: {tmp!r},
1466
+ callMcpImpl: stub,
1467
+ storage: box.host.storage,
1468
+ }});
1469
+ const got = await handlers.get({{}}, rpcCtx());
1470
+ console.log(JSON.stringify({{
1471
+ raw: got.raw,
1472
+ effectiveMode: got.config.mode,
1473
+ effectiveHasDefaults: ("search_engine" in got.config) && ("max_iterations" in got.config),
1474
+ rawHasSearchEngine: Boolean(got.raw && ("search_engine" in got.raw)),
1475
+ hash: got.hash,
1476
+ }}));
1477
+ """
1478
+ )
1479
+ self.assertEqual(res.returncode, 0, res.stderr)
1480
+ d = last_json_object(res.stdout)
1481
+ # `raw` is exactly the on-disk file config (not the merged result).
1482
+ self.assertEqual(d["raw"], on_disk)
1483
+ self.assertFalse(d["rawHasSearchEngine"])
1484
+ # `config` is the merged effective config.
1485
+ self.assertEqual(d["effectiveMode"], "scout")
1486
+ self.assertTrue(d["effectiveHasDefaults"])
1487
+ # Single-read: the hash is of the same bytes `raw` was parsed from.
1488
+ self.assertEqual(d["hash"], hashlib.sha256(cfg.read_bytes()).hexdigest())
1489
+
1439
1490
 
1440
1491
  class TestRpcGetSanitizedError(unittest.TestCase):
1441
1492
  """Phase-03 R4b: `get` spawn failures are structured, fail-closed errors."""
@@ -124,6 +124,32 @@ prefix of **at least 8 hex characters**; conflicting spellings are rejected with
124
124
  canonical validation (the legacy-pin migration stays in memory until the
125
125
  invalid values are fixed).
126
126
 
127
+ ## 4. TUI Settings Surface (OpenCode V2)
128
+
129
+ On OpenCode V2 hosts the plugin ships a native settings surface — the recommended
130
+ path when you are already inside OpenCode:
131
+
132
+ - **`/swarm-config` wizard** — `ui.dialog.select/prompt/confirm`, one row per
133
+ canonical key with a provenance badge (`default` / `file` / `env-override`);
134
+ edits validate on save.
135
+ - **`iumbtems.swarm-settings` status panel** — a read-only panel opened from the
136
+ command palette / keymap showing the effective rows with a **next-run** badge;
137
+ it never writes.
138
+
139
+ Both read and write the same canonical `.research/config.json`, preferring the
140
+ server `iumbtems.settings` RPC (server-side validation + `expectedHash` guard) and
141
+ degrading to a direct-fs write (`plugins/opencode/config-io.js`) with a visible
142
+ toast when `client.rpc` is absent. Live refresh subscribes to the RPC `changed`
143
+ event; the 5s poll remains the floor.
144
+
145
+ The honest boundaries are unchanged from the CLI/MCP paths: values apply to the
146
+ **next** swarm run (the runner reads config at run start); per-role temperature
147
+ does **not** govern spawned agents (dated `[NEGATIVE_KNOWLEDGE]`, waiver W3);
148
+ out-of-range `IUMBTEMS_TEMPERATURE[_<ROLE>]` values are DISCARDED (fail-closed),
149
+ not clamped; secrets stay in the environment. See `docs/SYSTEM_ARCHITECTURE.md`
150
+ §6.7 for the full data flow, the `.research/.config.lock` protocol, the
151
+ `expectedHash` guard, and the `ctx.storage` mirror.
152
+
127
153
  ## 5. Known limitations (recorded by phase 01)
128
154
 
129
155
  - **Crash-safety scope (F9):** the atomic-write regression patches `os.replace`
@@ -694,15 +694,38 @@ def _save_or_report(cfg: Dict[str, Any]) -> Optional[Path]:
694
694
  return None
695
695
 
696
696
 
697
+ def _legacy_pin_pending(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
698
+ """Would the legacy-pin migration apply on the next save? (read-only)
699
+
700
+ R12/F2: the CLI read paths (`--show` / bare) must never write, so this
701
+ reports a pending migration without persisting it. Mirrors `heal_config`'s
702
+ decision — a legacy ``["claude", "-p"]`` pin is present AND the
703
+ would-be-persisted config passes canonical validation — but never writes.
704
+ """
705
+ try:
706
+ raw = _read_raw_config(get_config_path(base_dir))
707
+ if not isinstance(raw, dict):
708
+ return False
709
+ if not migrate_legacy_agent_backends(raw):
710
+ return False
711
+ return not validate_config(load_config(base_dir))
712
+ except Exception:
713
+ return False
714
+
715
+
697
716
  def main():
698
717
  cfg = load_config()
699
- if heal_config():
700
- print(
701
- "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
702
- )
703
718
  args = sys.argv[1:]
719
+ # R12/F2: compute the pending migration READ-ONLY. Heal only when an update
720
+ # is actually saved; a read (`--show` / bare) must never write.
721
+ pending_migration = _legacy_pin_pending()
704
722
 
705
723
  if not args or "--show" in args:
724
+ if pending_migration:
725
+ print(
726
+ "ℹ️ A legacy ['claude', '-p'] agent backend pin is present; it "
727
+ "will migrate to the host-native default on the next save."
728
+ )
706
729
  display_config(cfg)
707
730
  if not args:
708
731
  print("To edit settings, run:")
@@ -717,6 +740,10 @@ def main():
717
740
  cfg_path = _save_or_report(cfg)
718
741
  if cfg_path is None:
719
742
  raise SystemExit(1)
743
+ if pending_migration:
744
+ print(
745
+ "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
746
+ )
720
747
  print(f"✅ Configuration saved to {cfg_path}")
721
748
  display_config(cfg)
722
749
  return
@@ -726,6 +753,10 @@ def main():
726
753
  cfg_path = _save_or_report(cfg)
727
754
  if cfg_path is None:
728
755
  raise SystemExit(1)
756
+ if pending_migration:
757
+ print(
758
+ "ℹ️ Migrated legacy ['claude', '-p'] agent backend pin → host-native default."
759
+ )
729
760
  print(f"✅ Configuration updated and saved to {cfg_path}")
730
761
  display_config(cfg)
731
762