@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -2
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/antigravity/rules/AGENTS.md +5 -3
- package/plugins/opencode/index.js +10 -5
- package/plugins/opencode/tui.js +20 -4
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +44 -6
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_config_schema.py +65 -0
- package/runner/tests/test_opencode_ux.py +133 -13
- package/runner/tests/test_opencode_v2_registrars.py +51 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/SKILL.md +26 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +35 -4
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
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
2312
|
-
//
|
|
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
|
-
// (
|
|
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
|
-
|
|
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;
|
package/plugins/opencode/tui.js
CHANGED
|
@@ -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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/mcp_server.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
1768
|
-
|
|
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 () => (
|
|
1783
|
+
get: async () => snapshot(),
|
|
1773
1784
|
set: async (input) => {
|
|
1774
1785
|
state.setCalls.push(input);
|
|
1775
|
-
|
|
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
|
-
|
|
1784
|
-
|
|
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
|
|
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."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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`
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|