From b912e73233ad4ae0957db52bb3daffe5f34102ac Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 15 Sep 2026 06:51:25 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(config):=20governed=20v8=20defaults-fl?= =?UTF-8?q?ip=20frame=20=E2=80=94=20conservative=20preset,=20migration,=20?= =?UTF-8?q?rollback,=20inventory=20(#2504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the governance frame required by #2504 around the armed v8 defaults flips (auto_review release-gated burn-in flip; parallel-first new-plan default from v7.132.0). No new default flip, no version changes. - preset key ('default'|'conservative'): conservative restores the pre-flip v7 defaults (auto_review disabled, serial new plans) as the lowest-precedence resolution layer; explicit keys always win - migratePresetsConfig: exempt the new preset enum values from the v6.12 dormant-legacy-key strip so the conservative preset survives configs with an agents block (the installer's own shape) — final-critic round-1 fix, with agent-block and legacy-value regression coverage - config doctor: DEFAULT_FLIPS info findings (kill switch + preset per flip, gated config_format_version < 3), 'Defaults changes (v8)' report section, and an idempotent --fix acknowledgment stamp (closes the version-never- written-back gap); availableMigrations rename surface unchanged - save_plan: preset-aware new-plan parallelization default via new _internals.loadPluginConfigWithMeta seam (fail-open to v8 default) - docs: docs/defaults-governance.md inventory (evidence citations, K3 UX-3/6/7 dispositions, non-goals, kill switches, rollback, cost delta); trust-posture amendments in architecture/installation/design-rationale; Compatibility Matrix in installation.md; regenerated schema artifacts - tests: conservative-preset (incl. agents-block survival), defaults-flip- upgrade, defaults-flip-rollback (frozen #2504 acceptance contracts); parallelization-default hardened with XDG isolation; config-doctor.test.ts fixture pinned to the current format version (line-neutral per FR-006) - catalog.ts producer citations repointed for the index.ts line shift --- docs/architecture.md | 7 +- docs/configuration.md | 19 ++ docs/defaults-governance.md | 154 ++++++++++ docs/design-rationale.md | 10 +- docs/installation.md | 23 +- .../pending/2504-v8-defaults-governance.md | 80 +++++ opencode-swarm.schema.json | 8 + src/commands/doctor.ts | 19 ++ src/config/loader.ts | 29 +- src/config/schema.ts | 32 ++ src/index.ts | 10 +- src/observability/catalog.ts | 4 +- src/services/config-doctor.test.ts | 4 +- src/services/config-doctor.ts | 141 +++++++++ .../phase-complete/gates/final-review-gate.ts | 7 +- src/tools/save-plan.ts | 57 +++- tests/unit/config/conservative-preset.test.ts | 282 ++++++++++++++++++ .../config/defaults-flip-rollback.test.ts | 66 ++++ .../unit/config/defaults-flip-upgrade.test.ts | 161 ++++++++++ .../config/parallelization-default.test.ts | 19 ++ 20 files changed, 1108 insertions(+), 24 deletions(-) create mode 100644 docs/defaults-governance.md create mode 100644 docs/releases/pending/2504-v8-defaults-governance.md create mode 100644 tests/unit/config/conservative-preset.test.ts create mode 100644 tests/unit/config/defaults-flip-rollback.test.ts create mode 100644 tests/unit/config/defaults-flip-upgrade.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 331c5b5e8..d431eebe1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1922,7 +1922,12 @@ Three modes control background-first rollout: ### Per-Capability Feature Flags -All v6.7 automation features are gated behind explicit feature flags (all default `false`): +Every automation capability is gated behind its own feature flag, individually +defaulted: read-only capabilities (`plan_sync`, `evidence_auto_summaries`, +`decision_drift_detection`) default on; anything that writes or auto-runs +(`phase_preflight`, `config_doctor_on_startup`, `config_doctor_autofix`) +defaults off. Governed default changes are inventoried with production +evidence and per-flip kill switches in `docs/defaults-governance.md` (#2504). | Feature Flag | Description | Security | |--------------|-------------|----------| diff --git a/docs/configuration.md b/docs/configuration.md index c09299098..592930960 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,6 +76,7 @@ Generated from `PluginConfigSchema` (`src/config/schema.ts`) - do not edit insid | --- | ---- | ------- | ----------- | | `$schema` | string | — | JSON Schema URL for editor validation/autocomplete of this file (issue #1663). Ignored at runtime; malformed values are ignored too. | | `config_format_version` | integer | 1 | Config format version for the migration table. Increment when fields are deprecated. Distinct from knowledge.schema_version. | +| `preset` | enum(default \| conservative) | — | Defaults profile: "default" applies the governed v8 defaults; "conservative" restores the pre-flip (v7) defaults for every flipped surface (#2504). | | `agents` | record | — | Per-agent overrides keyed by agent name for the default swarm (e.g. "architect", "coder"). Multi-swarm setups configure agents under swarms..agents instead. | | `default_agent` | string | — | Agent set as the primary mode. Omitted: every generated *_architect is primary. Exact generated name (e.g. "local_architect"): only that agent. Base role name (e.g. "coder"): every generated agent with that base role. Unknown strings warn once and fall back to architect primaries. | | `auto_select_architect` | boolean \| string | — | Auto-select the swarm architect for new sessions instead of OpenCode built-ins. Omitted or false: manual selection (omitted behaves as false). true: enable auto-select and disable built-in build/plan agents. "" (e.g. "mega_architect"): enable targeting one architect in multi-swarm setups. | @@ -369,6 +370,24 @@ Empty or whitespace-only values are treated as omitted. > Why this matters: in v7.3.x the schema applied an implicit `.default("architect")`. In a multi-swarm config there is no agent literally named `architect` — they are all prefixed — so every architect was demoted to subagent and OpenCode showed only the native `build`/`plan` agents. The omitted-vs-explicit distinction is now load-bearing; do not re-introduce a schema default. +## `preset` — defaults profile for the governed v8 flips (issue #2504) + +`preset` (top-level, optional `"default" | "conservative"`) selects the default +posture for every surface whose default changed under the governed v8 +defaults-flip frame: + +| Value | Effect | +|---|---| +| _(omitted)_ or `"default"` | The governed v8 defaults apply. Today that means new plans default to parallel-first execution for provably file-disjoint work (since v7.132.0), and `auto_review` flips to advisory-on at the first 8.x release (burn-in pinned; see the `auto_review` section). | +| `"conservative"` | Restores the pre-flip (v7) defaults for every flipped surface: `auto_review.enabled: false` and serial new plans. | + +The preset is applied as the lowest-precedence layer in config resolution, so +an explicit value for any affected key always wins over it. Evidence +citations, per-flip kill switches, and rollback for every governed default +change live in `docs/defaults-governance.md`; `/swarm config doctor` surfaces +pending default changes and `/swarm config doctor --fix` acknowledges them +(stamps `config_format_version: 3`). + ## `auto_select_architect` — auto-select swarm architect on launch `auto_select_architect` (top-level, optional `boolean | string`) controls whether OpenCode's built-in `build` and `plan` agents are disabled so the swarm architect is automatically selected as the active agent on launch. diff --git a/docs/defaults-governance.md b/docs/defaults-governance.md new file mode 100644 index 000000000..cc158bf27 --- /dev/null +++ b/docs/defaults-governance.md @@ -0,0 +1,154 @@ +# Defaults Governance — the governed v8 defaults-flip inventory + +> Owner: issue #2504 (Workstream F PR 12 of 21), consolidating source EPIC #1677. +> Frame: every v8 default flip must cite production evidence; migration and +> rollback are documented and tested; a conservative preset restores the +> pre-flip (v7) defaults exactly. + +This document is the inventory required by #2504: one entry per governed +default change, each with its evidence citations, one-line kill switch, and +rollback, plus the dispositions for the three previously-unowned K3 UX +candidates and the explicitly non-flipped families. + +**Release-frame honesty:** the supported-host qualification matrix (#2586) is +still open. The enabled-feature evidence it has published so far (e.g. the R10 +dispatch-protection integer/wall-clock budgets) was produced on Windows only, +and its own contract states one platform's fixture does not certify another. +Nothing in this inventory claims Windows/macOS/Linux or Node/Bun qualification +beyond what each cited source actually ran. The default-path completion +evidence from #2585 was produced on a real OpenCode host across three live +runs; see that issue for scope. + +## Flip inventory + +### 1. `auto_review.enabled` — FLIPPED (v8 release-gated) + +- **v7 default:** `false` (opt-in). **v8 default:** `true` (advisory mode) — + resolves automatically on the first 8.x release when the config does not set + the key explicitly. +- **Mechanism:** `AUTO_REVIEW_V8_BURN_IN_DECISION` (approved, pins + `docs/benchmarks/auto-review-v8-cost-baseline.json` at SHA-256 + `b4e981d4…84ce`) + `autoReviewEnabledByRelease` (package major ≥ 8 gate) in + `src/config/schema.ts`. +- **Production evidence:** #2585 live-host proof (PR #2691: three live + OpenCode-host runs at one revision with a machine verifier; frozen fixtures + at `tests/fixtures/pr-review/frozen-limits.json`); #2586 partial + supported-host evidence (Windows-only R10 cells); published quality + decisions #2490 (memory-recall regression gate + held-out corpus), #2491 + (review-routing vocabulary, shipped v7.166.4), #2503 (HarnessOpt capstone + manifest contract). Cost baseline: + `docs/benchmarks/auto-review-v8-cost-baseline.json` (30 canonical-main + diffs; min 1,380 / p50 2,438 / p95 50,480 / max 88,121 input tokens; + 800-token output budget; `v8_default_per_phase: "1 reviewer + 0 + validator"`). +- **Kill switch (one line):** `"auto_review": { "enabled": false }`. +- **Rollback:** set the kill switch above (explicit user values always win), + or set `"preset": "conservative"` to restore every v7 default at once. Whole- + config rollback: `/swarm config doctor --fix` backs up to + `.swarm/config-backup-.json` and `/swarm config doctor` documents + the restore path. + +### 2. `execution_profile.parallelization_enabled` (new plans) — FLIPPED (v7.132.0) + +- **v7 pre-flip default:** `false` (serial). **Current default:** `true` for + NEW plans only (v7.132.0, #1674 via PR #1966), with the delegation gate + enforcing serial automatically whenever the pending tasks are not provably + file-disjoint. Existing plans are unchanged on upgrade; the plan schema + default itself stays `false`. +- **Production evidence:** #1674 / PR #1966 (`docs/releases/v7.132.0.md`) — + gate-enforced serial fallback plus the `plan_conflict_check` advisory tool + and durable merge-back recovery shipped in the same release. +- **Kill switch (one line):** `execution_profile.parallelization_enabled: + false` on the plan (per-plan), or `"preset": "conservative"` to make NEW + plans serial again. +- **Rollback:** as above; conservative preset coverage tested in + `tests/unit/config/conservative-preset.test.ts`. + +## K3 UX candidate dispositions + +- **`auto_select_architect` posture (K3 UX-3) — NO schema flip.** F1 (#2493) + ships the designed posture: the installer writes `auto_select_architect: + true` for FRESH installs only (`src/cli/index.ts`), the schema default stays + omitted/`false`, and a one-time advisory fires when a session starts on a + non-architect agent. Flipping the schema default would silently disable the + host's built-in build/plan agents for every existing user who never set the + key — a behavior change with no exit evidence, and against F1's "never fight + the user configuration silently" invariant. Disposition: keep the + install-layer activation exactly as shipped. +- **Always-visible startup health banner (K3 UX-6) — DEFERRED, no flip.** No + exit evidence exists for an always-visible banner. Recurring cost: every + chat-visible line must ride a user-role guidance carrier + (`src/hooks/system-guidance-carrier.ts`) because the pinned host discards + `role: 'system'` entries in `messages.transform` (AGENTS.md invariant 10), + and carrier content is counted against the bounded injection/turn budget + (#2107, "Unify context pressure, injection budgets, and summary + continuity"). Existing health surfaces (startup config doctor when enabled, + model preflight warnings, `/swarm doctor`, the automation-status artifact) + remain the supported channels. A future banner needs its own evidence-gated + flip entry here. +- **Free-tier model default resolution against the live catalog (K3 UX-7) — NO + default change.** The asked-for behavior already ships: `DEFAULT_MODELS` / + `DEFAULT_AGENT_CONFIGS` pin free-tier models with multi-level fallback chains + (`src/config/constants.ts`), and `runModelPreflight` + (`src/services/model-preflight.ts`) resolves every enabled agent's effective + model against the live provider catalog at startup (fail-open) and inside + `/swarm doctor`, warning on unresolved selections. There is no proposed + model default change to govern. + +## Non-goals (not flipped without their own exit evidence) + +Per #2504: "Do not default-enable experimental resilience, autonomy, remote +export, training capture, or sandbox behavior without their own exit +evidence." + +- **Experimental resilience:** `pr_review_resilience.enabled` stays `false` + (staged canary/fanout; no exit evidence). +- **Autonomy:** `full_auto.enabled` stays `false`; `automation.mode` stays + `"manual"`. +- **Remote export:** `observability.export.enabled` stays `false` (local + operation is fully independent of the exporter). +- **Training capture:** consent-gated via `/swarm dataset` commands; no config + default to flip. +- **Sandbox:** `guardrails.sandbox_macos_enabled` stays optional/absent (the + SBPL profile is explicitly not re-verified against a real macOS host from + this repository's dev environments); sandbox mode stays `advisory`. + +### Also not flipped (disqualifying evidence) + +- `parallelization.enabled` (config-level): dark foundation — no production + code path branches on it yet (`src/config/schema.ts`). +- `memory.*` / `context_map.enabled`: #2490 shipped a memory-recall regression + gate, but its own release (`docs/releases/v7.148.0.md`) records that the + graph-memory acceptance criteria were not met — no burn-in evidence to + promote (#1677 allows these flips "only if their burn-in evidence is + published"). +- `architectural_supervision.enabled`: no published evidence. + +## Conservative preset + +`"preset": "conservative"` (top-level config key, #2504) restores the pre-flip +v7 defaults for every flipped surface: `auto_review.enabled: false` and serial +new plans. It is applied as the lowest-precedence layer in config resolution, +so an explicit user key always wins over the preset. `"preset": "default"` or +an absent preset applies the governed v8 defaults. See +`docs/configuration.md` (`preset`) and `tests/unit/config/conservative-preset.test.ts`. + +## Migration, warnings, and acknowledgment + +`/swarm config doctor` surfaces pending v8 default changes as `defaults-flip` +findings (info severity) while `config_format_version < 3`, naming the change, +the kill switch, and the conservative preset. Running +`/swarm config doctor --fix` acknowledges them by stamping +`config_format_version: 3` (idempotent; passive scans never write). The +Compatibility Matrix (behavior by config vintage and preset) lives in +`docs/installation.md`. + +## Cost-delta statement + +At the v8 default posture the advisory auto-review adds one reviewer dispatch +and no validator per phase (`v8_default_per_phase` in the pinned cost +baseline: min 1,380 / p50 2,438 / p95 50,480 / max 88,121 input tokens per +diff, 800-token output budget). Parallel-first new plans run concurrent coders +only for provably file-disjoint work, bounded by the plan's +`max_concurrent_tasks`. Users who need the v7 cost profile set +`"preset": "conservative"`. diff --git a/docs/design-rationale.md b/docs/design-rationale.md index b16c81395..14bdb4f23 100644 --- a/docs/design-rationale.md +++ b/docs/design-rationale.md @@ -26,7 +26,12 @@ Swarm adds the discipline that LLMs lack. - Conflict resolution hell - Non-reproducible results -**Swarm's approach**: One agent at a time. Always. +**Swarm's approach**: serial by default — with one governed exception. Since +v7.132.0 (#1674, PR #1966) new plans default to parallel-first execution for +PROVABLY file-disjoint task groups only; the delegation gate enforces the +serial fallback automatically whenever scopes overlap or are unknown. The +conservative preset (`preset: "conservative"`, #2504) restores fully serial +new plans. Concurrency is earned by proof, not assumed. ``` WRONG: Agent1 ──┐ @@ -34,6 +39,7 @@ WRONG: Agent1 ──┐ Agent3 ──┘ RIGHT: Agent1 → Agent2 → Agent3 → Consistent result + (or: provably file-disjoint agents in parallel, gate-enforced) ``` Slower? Yes. Working code? Also yes. @@ -307,7 +313,7 @@ Architect respects dependencies. Won't start 2.2 until 2.1 is complete. **Why this works:** - **Progressive rollout:** Start with `manual`, enable features as needed -- **Explicit opt-in:** Every automation feature has a feature flag (all default false) +- **Per-capability flags:** every automation capability has its own feature flag, individually defaulted (read-only capabilities default on; anything that writes or auto-runs — `phase_preflight`, `config_doctor_on_startup`, `config_doctor_autofix` — defaults off). Governed default changes are inventoried with evidence and kill switches in `docs/defaults-governance.md` (#2504). - **Fail-safe defaults:** Nothing auto-runs unless explicitly enabled - **User control:** Architect chooses when to enable automation - **Reversible:** Disable mode or specific capabilities anytime diff --git a/docs/installation.md b/docs/installation.md index 8795e631c..a989eec5e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -795,10 +795,10 @@ Pick auto once you've tested the individual capabilities and want full throughpu ### Feature Flag Safety -Every automation capability is default-off: +Every automation capability has its own feature flag, individually defaulted +(read-only capabilities on; write/auto-run capabilities off): -- Start with `mode: "manual"` and all capabilities `false` -- Enable features as you test them +- Start with `mode: "manual"` and enable capabilities as you test them - Never enable everything at once - Revert to manual mode if something goes wrong @@ -809,6 +809,23 @@ Every automation capability is default-off: - Creates encrypted backups in `.swarm/` before applying fixes - Supports restore via `/swarm config doctor --restore ` +### Compatibility Matrix + +Governed default posture by config vintage and preset (#2504; full inventory +with evidence citations at `docs/defaults-governance.md`): + +| Config | `auto_review` default | New plans | Kill switch / restore | +|---|---|---|---| +| v7 config, no preset (7.x releases) | `false` (opt-in) | Parallel-first for provably file-disjoint groups (since v7.132.0), gate-enforced serial fallback | `auto_review.enabled: false`; per-plan `execution_profile.parallelization_enabled: false` | +| v7 config, no preset (first 8.x release) | `true` (advisory) — flips automatically | Same as above | Same kill switches; `/swarm config doctor --fix` acknowledges and stamps `config_format_version: 3` | +| `preset: "conservative"` | `false` (v7 posture restored) | Serial (v7 posture restored) | Explicit keys always win over the preset | +| `preset: "default"` | Same as the no-preset rows | Same as the no-preset rows | Same kill switches | +| Fresh install | Installer writes `auto_select_architect: true` (first-run activation, #2493); `auto_review` follows the release rows above | Same as no-preset | The schema default for `auto_select_architect` stays off — no silent change for existing users | + +Explicit user values always win over both the preset and any flipped default. +Existing plans are never rewritten on upgrade; only NEW plans pick up the +parallel-first default. + ### GUI Visibility When automation is enabled, Swarm writes status to `.swarm/automation-status.json`: diff --git a/docs/releases/pending/2504-v8-defaults-governance.md b/docs/releases/pending/2504-v8-defaults-governance.md new file mode 100644 index 000000000..73f49640f --- /dev/null +++ b/docs/releases/pending/2504-v8-defaults-governance.md @@ -0,0 +1,80 @@ +# Governed v8 defaults-flip frame: conservative preset, migration, rollback, and inventory (#2504) + +## What + +Ships the governance frame required by issue #2504 (Workstream F PR 12 of 21, +consolidating EPIC #1677) around the already-armed v8 defaults flips. No new +default flip is introduced and no version number changes. + +- **`preset` config key** (`"default" | "conservative"`, optional): the + conservative preset restores the pre-flip (v7) defaults for every flipped + surface — `auto_review.enabled: false` and serial new plans — applied as the + lowest-precedence layer in config resolution so explicit user values always + win (including partial sections like `auto_review: { mode: "gate" }`). +- **Migration + warnings**: `/swarm config doctor` now surfaces governed + default changes as `defaults-flip` info findings (naming the change, the + kill switch, and the preset) while `config_format_version < 3`, with a + "Defaults changes (v8)" report section. `/swarm config doctor --fix` + acknowledges the changes by stamping `config_format_version: 3` — + idempotently; passive runs never write. This also fixes the long-standing + gap where applied migrations re-advertised forever because the version was + never written back. +- **Preset-aware new plans**: `save_plan` consults the resolved preset — under + `conservative`, new plans default to serial (`parallelization_enabled: + false`); without it the v8 parallel-first default (v7.132.0, #1674) + applies. Explicit profile keys always win; config-load failures fail open + to the v8 default. +- **Inventory + trust-posture docs**: new `docs/defaults-governance.md` + inventories every governed default change with production-evidence + citations (#2585 live host proof, #2586 partial supported-host evidence, + #2490/#2491/#2503 quality decisions, the pinned auto-review cost baseline), + per-flip kill switches and rollback, the three K3 UX dispositions (UX-3 + `auto_select_architect` stays install-layer; UX-6 startup banner deferred — + no exit evidence; UX-7 free-tier model resolution already ships via + live-catalog preflight), and the non-goals (experimental resilience, + autonomy, remote export, training capture, sandbox — not flipped). + Stale overgeneralized "all default false" claims in `architecture.md`, + `installation.md`, and `design-rationale.md` are amended to describe the + actual posture, and `installation.md` gains a Compatibility Matrix. + +## Why + +The auto_review v8 flip is armed in code (approved burn-in pin + major≥8 +gate) and the parallel-first new-plan default landed in v7.132.0 — but there +was no preset escape hatch, no migration/warning surface for upgrading +configs, no evidence-cited inventory, and the documented trust posture still +claimed universal opt-in defaults. #2504 requires exactly this frame: +evidence-gated flips only, migration and rollback documented and tested. + +## Migration steps + +- Nothing is required — existing configs keep their exact behavior on 7.x. +- To restore all pre-flip (v7) defaults: set `"preset": "conservative"`. +- Per-flip kill switches: `auto_review.enabled: false` (config) and + `execution_profile.parallelization_enabled: false` (per plan). Explicit + values always win over any flipped default or the preset. +- `/swarm config doctor` lists pending default changes; `--fix` acknowledges + them (stamps `config_format_version: 3`). + +## Caveats + +- The `auto_review` default change itself activates mechanically on the first + 8.x release (release-please owns versions); this change ships the + governance around it, not a new flip. +- Supported-host qualification (#2586) is still open; its published evidence + is Windows-only for the R10 cells and is not advertised as cross-platform + qualification anywhere in this change. + +## Tests + +- `tests/unit/config/conservative-preset.test.ts` — schema/loader/release-seam + preset contract, explicit-override precedence (both directions), partial + sections, save_plan new-plan defaults, load-failure fail-open. +- `tests/unit/config/defaults-flip-upgrade.test.ts` — v7-era configs get + `defaults-flip` findings; `--fix` stamps `config_format_version: 3` + idempotently; passive runs never write; version-3 configs stop advertising; + legacy rename migrations unchanged. +- `tests/unit/config/defaults-flip-rollback.test.ts` — per-flip kill switches + verified at a simulated v8 release; inventory doc names both kill switches. +- `tests/unit/config/parallelization-default.test.ts` — hardened with XDG + isolation (save_plan now reads config for the preset). diff --git a/opencode-swarm.schema.json b/opencode-swarm.schema.json index f1b637ed1..4912870bc 100644 --- a/opencode-swarm.schema.json +++ b/opencode-swarm.schema.json @@ -16,6 +16,14 @@ "minimum": 0, "maximum": 9007199254740991 }, + "preset": { + "description": "Defaults profile: \"default\" applies the governed v8 defaults; \"conservative\" restores the pre-flip (v7) defaults for every flipped surface (#2504).", + "type": "string", + "enum": [ + "default", + "conservative" + ] + }, "agents": { "description": "Per-agent overrides keyed by agent name for the default swarm (e.g. \"architect\", \"coder\"). Multi-swarm setups configure agents under swarms..agents instead.", "type": "object", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 86d9d1f2b..ca32a3431 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -181,6 +181,25 @@ function formatDoctorMarkdown(result: ConfigDoctorResult): string { ); } + // -- Defaults changes (v8) Section (#2504) -- + const defaultsFlips = result.findings.filter( + (finding) => finding.id === 'defaults-flip', + ); + if (defaultsFlips.length > 0) { + lines.push('---', ''); + lines.push('### Defaults changes (v8)', ''); + lines.push( + '\u2139\ufe0f Governed v8 default changes are pending for this config. Explicit values always win — these rows inform and provide the kill switch:', + ); + for (const flip of defaultsFlips) { + lines.push(` - ${flip.description}`); + } + lines.push(''); + lines.push( + 'Run `/swarm config doctor --fix` to acknowledge these changes (stamps config_format_version), or set `preset: "conservative"` to restore all v7 defaults. Evidence and rollback: docs/defaults-governance.md (#2504).', + ); + } + if (result.hasAutoFixableIssues) { lines.push('---'); lines.push(''); diff --git a/src/config/loader.ts b/src/config/loader.ts index 020b48277..2f2818d48 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -7,6 +7,7 @@ import { advisoryWarn } from '../services/warning-buffer.js'; import { GIT_BINARY_ENV_VAR } from '../utils/git-executable.js'; import { sanitizeMalformedValues } from './sanitize-malformed-values'; import { + CONSERVATIVE_PRESET_BASE, ExternalSkillsConfigSchema, GATE_CONFIG_KNOWN_SECTION_KEYS, type GateConfigOverrides, @@ -194,8 +195,15 @@ function migratePresetsConfig( // for issue #1663) does not misreport recognized legacy fields as typos — // this is the ordinary upgrade path where a stale global v6.12 presets // file merges with a project config that already uses `agents`. + // #2504: the top-level defaults-profile `preset` enum ("default" | + // "conservative") is NOT the v6.12 remote-preset name; keep it so the + // conservative base layer (step 3b) still sees it alongside an `agents` + // block — the config shape the installer itself writes. const dormantLegacyKeys = ['preset', 'presets', 'swarm_mode'].filter( - (key) => key in raw, + (key) => + key in raw && + (key !== 'preset' || + (raw.preset !== 'default' && raw.preset !== 'conservative')), ); if (dormantLegacyKeys.length > 0 && raw.agents) { const cleaned = { ...raw }; @@ -689,6 +697,25 @@ function buildConfigWithMeta( // 3. Migrate v6.12 presets format to v6.13+ agents format. mergedRaw = migratePresetsConfig(mergedRaw); + // 3b. Conservative preset base layer (#2504): when the merged config selects + // `preset: "conservative"`, deep-merge CONSERVATIVE_PRESET_BASE as the + // LOWEST-precedence layer (base is the FIRST argument; deepMerge's + // second argument wins per-key, so every explicit user/project key — + // including a partial `auto_review: { mode: "gate" }` — still wins over + // the base). The base materializes the pre-flip v7 value (e.g. + // `auto_review.enabled: false`) BEFORE Zod parsing so the schema-level + // release-gate preprocess (which cannot see siblings) never fills a + // release default under the conservative preset. Absent or "default" + // preset: no base layer — behavior is byte-identical to before #2504. + // The base injects only schema-known keys after the user's keys are + // merged, so it cannot affect the unknown-top-level-key warning in 4b. + if (mergedRaw.preset === 'conservative') { + mergedRaw = deepMergeFn(CONSERVATIVE_PRESET_BASE, mergedRaw) as Record< + string, + unknown + >; + } + // 4. Pre-validate section-local configs so one invalid section doesn't // block plugin load. Track which gates keys were stripped so we can // report them in the recovery metadata (issue #1900 FR-3). diff --git a/src/config/schema.ts b/src/config/schema.ts index 14d527c36..d6545471e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -620,6 +620,8 @@ export const AUTO_REVIEW_V8_BURN_IN_DECISION: AutoReviewBurnInDecision = { export interface AutoReviewReleaseContext { packageVersion?: string; burnInDecision?: AutoReviewBurnInDecision; + /** Defaults profile (#2504): 'conservative' pins the pre-flip (v7) defaults and wins over the release gate. */ + preset?: string; } function hasApprovedAutoReviewBurnIn( @@ -637,6 +639,10 @@ function hasApprovedAutoReviewBurnIn( function autoReviewEnabledByRelease( context: AutoReviewReleaseContext = {}, ): boolean { + // Conservative preset (#2504): restores the pre-flip v7 default regardless + // of the release major — checked before the release gate so the preset + // always wins when no explicit key is set. + if (context.preset === 'conservative') return false; const version = context.packageVersion ?? packageJson.version; const major = Number.parseInt(version.split('.')[0] ?? '', 10); return ( @@ -648,6 +654,18 @@ function autoReviewEnabledByRelease( ); } +/** + * Lowest-precedence base layer applied by buildConfigWithMeta when + * `preset === 'conservative'` (#2504): the frozen v7 values for every + * config-level flipped surface. Extend ONLY alongside a governed flip entry in + * the DEFAULT_FLIPS table (src/services/config-doctor.ts) and the inventory + * (docs/defaults-governance.md); every key here is overridable by an explicit + * user/project key. + */ +export const CONSERVATIVE_PRESET_BASE: Readonly> = { + auto_review: { enabled: false }, +}; + const AutoReviewFinalConfigSchema = z.object({ on_phase_complete: z.boolean().default(true), on_plan_complete: z.boolean().default(true), @@ -3649,6 +3667,20 @@ export const PluginConfigSchema = z.object({ 'Config format version for the migration table. Increment when fields are deprecated. Distinct from knowledge.schema_version.', ), + // Defaults profile (#2504 — governed v8 defaults-flip frame). Absent or + // "default" applies the governed v8 defaults (release-gated); "conservative" + // restores the pre-flip (v7) defaults for every flipped surface via + // CONSERVATIVE_PRESET_BASE, applied as the lowest-precedence layer in + // buildConfigWithMeta so an explicit user key always wins. The field MUST + // stay optional (no `.default(...)`) so "absent" is distinguishable from an + // explicit "default" and existing configs are untouched. + preset: z + .enum(['default', 'conservative']) + .optional() + .describe( + 'Defaults profile: "default" applies the governed v8 defaults; "conservative" restores the pre-flip (v7) defaults for every flipped surface (#2504).', + ), + // Legacy: Per-agent overrides (default swarm) agents: z .record(z.string(), AgentOverrideConfigSchema) diff --git a/src/index.ts b/src/index.ts index 395110619..9c08250b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1669,7 +1669,15 @@ async function initializeOpenCodeSwarm( let autoReviewConfig: AutoReviewConfig; try { - autoReviewConfig = resolveAutoReviewConfigForInit(config.auto_review ?? {}); + // #2504: the resolved preset rides the release context so a conservative + // config keeps the pre-flip default even if the parsed section somehow + // reached here without the loader's base-layer materialization. + autoReviewConfig = resolveAutoReviewConfigForInit( + config.auto_review ?? {}, + { + preset: config.preset, + }, + ); } catch (error) { addDeferredWarning( `[swarm] Invalid auto_review configuration; auto-review is disabled until corrected: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/observability/catalog.ts b/src/observability/catalog.ts index b2478bc17..6a6fc868e 100644 --- a/src/observability/catalog.ts +++ b/src/observability/catalog.ts @@ -419,7 +419,7 @@ const CATALOG_SOURCE: readonly (readonly [string, CatalogEntryInput])[] = [ category: 'delegation', severity: 'info', privacyClass: 'pseudonymous', - producer: 'src/index.ts:1901', + producer: 'src/index.ts:1909', consumers: NO_CONSUMERS, futureOwnerIssue: ISSUE_SINK, retentionOwnerIssue: ISSUE_COST_RETENTION, @@ -433,7 +433,7 @@ const CATALOG_SOURCE: readonly (readonly [string, CatalogEntryInput])[] = [ category: 'delegation', severity: 'notice', privacyClass: 'pseudonymous', - producer: 'src/index.ts:1921', + producer: 'src/index.ts:1929', consumers: CONSUMER_COST_JOIN, retentionOwnerIssue: ISSUE_COST_RETENTION, requiredWorkflowIds: REQUIRE_SESSION, diff --git a/src/services/config-doctor.test.ts b/src/services/config-doctor.test.ts index 953b31f0f..c720f66b2 100644 --- a/src/services/config-doctor.test.ts +++ b/src/services/config-doctor.test.ts @@ -46,6 +46,7 @@ function createTestConfigObj( ): PluginConfig { return { max_iterations: 5, + config_format_version: 3, qa_retry_limit: 3, inject_phase_reminders: true, ...overrides, @@ -2923,8 +2924,7 @@ describe('Migration availability detection (availableMigrations)', () => { }); it('should default to 1 when config_format_version is absent (SC-006)', () => { - // Omit config_format_version entirely; Zod .default(1) must apply. - const config = createTestConfigObj({}); + const config = createTestConfigObj({ config_format_version: undefined }); const result = runConfigDoctor(config, tempDir); expect(result.availableMigrations).toBeDefined(); expect(result.availableMigrations!.length).toBeGreaterThan(0); diff --git a/src/services/config-doctor.ts b/src/services/config-doctor.ts index b295d5619..b8addc273 100644 --- a/src/services/config-doctor.ts +++ b/src/services/config-doctor.ts @@ -89,6 +89,53 @@ const DEPRECATED_FIELDS: ReadonlyMap< ], ]); +/** + * Current config format version (issue #2504). Version 3 introduces the + * governed v8 defaults-flip rows below; a config stamped at this version has + * acknowledged those default changes. + */ +export const CURRENT_CONFIG_FORMAT_VERSION = 3; + +/** + * Governed v8 default flips (issue #2504). Unlike DEPRECATED_FIELDS (key + * renames surfaced through `availableMigrations`), these are default-value + * changes surfaced as info-severity `defaults-flip` findings — they never + * rewrite user keys (explicit user values always win), they inform the owner + * of the change, the one-line kill switch, and the conservative preset that + * restores the pre-flip (v7) default. Evidence for every entry is cited in + * docs/defaults-governance.md. + */ +export const DEFAULT_FLIPS: ReadonlyArray<{ + field: string; + v7Default: string; + v8Default: string; + killSwitch: string; + preset: 'conservative'; + sinceVersion: number; + evidence: string; +}> = [ + { + field: 'auto_review.enabled', + v7Default: 'false (opt-in)', + v8Default: + 'release-gated true (advisory) — activates on the first 8.x release', + killSwitch: 'auto_review.enabled: false', + preset: 'conservative', + sinceVersion: 3, + evidence: 'docs/defaults-governance.md', + }, + { + field: 'execution_profile.parallelization_enabled', + v7Default: 'false (serial)', + v8Default: + 'true for new plans (since v7.132.0, gate-enforced serial fallback)', + killSwitch: 'execution_profile.parallelization_enabled: false (per plan)', + preset: 'conservative', + sinceVersion: 3, + evidence: 'docs/defaults-governance.md', + }, +]; + /** * Compute Levenshtein distance between two strings. * Callers must lowercase inputs for case-insensitive matching. @@ -1080,6 +1127,26 @@ function validateConfigKey(path: string, value: unknown): ConfigFinding[] { break; } + // Defaults profile (#2504): enum-validated by the schema; surfaced here + // so the doctor can point conservative users at the inventory when a + // governed default changes under them. + case 'preset': { + if (value !== 'default' && value !== 'conservative') { + findings.push({ + id: 'type-mismatch', + title: `Config field "${path}" has wrong type`, + description: `Expected "default" or "conservative", got ${JSON.stringify( + value, + )}`, + severity: 'error', + path, + currentValue: value, + autoFixable: false, + }); + } + break; + } + // Check guardrails settings case 'guardrails.enabled': { if (value === false) { @@ -2167,6 +2234,31 @@ export function runConfigDoctor( } } + // Governed v8 default flips (#2504): informational findings only — the + // emission guard is exactly `configVersion < entry.sinceVersion` (mirroring + // the DEPRECATED_FIELDS loop above), so a config stamped at + // CURRENT_CONFIG_FORMAT_VERSION emits nothing. These rows never carry a + // proposedFix: an explicit user value always wins over a flipped default, + // and acknowledging the change is the `--fix` stamp in + // runConfigDoctorWithFixes, not a key rewrite. + for (const flip of DEFAULT_FLIPS) { + if (configVersion < flip.sinceVersion) { + findings.push({ + id: 'defaults-flip', + title: `v8 default change pending for "${flip.field}"`, + description: + `Default flips from ${flip.v7Default} to ${flip.v8Default}. ` + + `Kill switch: set ${flip.killSwitch}. ` + + `Restore all v7 defaults with preset: "${flip.preset}". ` + + `Evidence and rollback: ${flip.evidence} (#2504).`, + severity: 'info', + path: flip.field, + currentValue: 'v7 default', + autoFixable: false, + }); + } + } + return { findings, summary, @@ -2662,6 +2754,55 @@ export async function runConfigDoctorWithFixes( options, ); + // Acknowledgment stamp (#2504, GAP-A): an explicit `--fix` pass + // (applyLossy) that either applied fixes or surfaced governed default-flip + // rows stamps `config_format_version` to CURRENT_CONFIG_FORMAT_VERSION so + // acknowledged migrations (both the legacy renames and the v8 defaults-flip + // rows) stop re-advertising on every subsequent run. Idempotency guard: + // never stamp a config already at/above the current version. Passive runs, + // startup scans, and applyLossy:false never stamp — this write is the sole + // acknowledgment path and runs under the backup created above. + const hasDefaultsFlipFindings = result.findings.some( + (finding) => finding.id === 'defaults-flip', + ); + if ( + options.applyLossy === true && + (appliedFixes.length > 0 || hasDefaultsFlipFindings) + ) { + try { + const { + userConfigPath: stampUserPath, + projectConfigPath: stampProjectPath, + } = getConfigPaths(directory); + const stampPath = fs.existsSync(stampProjectPath) + ? stampProjectPath + : fs.existsSync(stampUserPath) + ? stampUserPath + : null; + if (stampPath) { + const stampContent = fs.readFileSync(stampPath, 'utf-8'); + const stampConfig = JSON.parse(stampContent) as Record; + const currentVersion = stampConfig.config_format_version; + const shouldStamp = + typeof currentVersion !== 'number' || + !Number.isInteger(currentVersion) || + currentVersion < 0 || + currentVersion < CURRENT_CONFIG_FORMAT_VERSION; + if (shouldStamp) { + stampConfig.config_format_version = CURRENT_CONFIG_FORMAT_VERSION; + const stampDir = path.dirname(stampPath); + if (!fs.existsSync(stampDir)) { + fs.mkdirSync(stampDir, { recursive: true }); + } + atomicWriteFileSync(stampPath, JSON.stringify(stampConfig, null, 2)); + } + } + } catch { + // Fail-open: a failed acknowledgment stamp never fails the fix pass; + // the rows simply keep advertising until a later successful --fix. + } + } + // Re-run doctor after fixes to get post-fix result // Must re-read config from file to see actual changes if (appliedFixes.length > 0) { diff --git a/src/tools/phase-complete/gates/final-review-gate.ts b/src/tools/phase-complete/gates/final-review-gate.ts index f3e48acbb..fddd0c6e9 100644 --- a/src/tools/phase-complete/gates/final-review-gate.ts +++ b/src/tools/phase-complete/gates/final-review-gate.ts @@ -64,7 +64,12 @@ export async function runFinalReviewGate( ): Promise { let config: AutoReviewConfig; try { - config = resolveAutoReviewConfig(ctx.pluginConfig.auto_review ?? {}); + // #2504: preset rides the release context as defense-in-depth — the + // loader's conservative base layer normally materializes an explicit + // enabled value before this re-normalization ever sees the section. + config = resolveAutoReviewConfig(ctx.pluginConfig.auto_review ?? {}, { + preset: ctx.pluginConfig.preset, + }); } catch (error) { return block( ctx, diff --git a/src/tools/save-plan.ts b/src/tools/save-plan.ts index 2f5e7b4ff..bfff1a064 100644 --- a/src/tools/save-plan.ts +++ b/src/tools/save-plan.ts @@ -7,6 +7,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ToolDefinition } from '@opencode-ai/plugin/tool'; import { z } from 'zod'; +// QA gate check — first save-plan integration with profile store +import { loadPluginConfigWithMeta } from '../config'; import { type ExecutionProfile, ExecutionProfileSchema, @@ -17,7 +19,6 @@ import { type Task, type TaskStatus, } from '../config/plan-schema'; -// QA gate check — first save-plan integration with profile store import { getOrCreateProfileForIdentity, getProfileLookupForIdentity, @@ -38,6 +39,19 @@ import { } from '../plan/manager'; import { resolvePlanningProfile } from '../plan/planning-profile'; import { derivePlanId } from '../plan/utils.js'; + +/** + * DI seam for hermetic config-load substitution in tests (AGENTS.md invariant 7). + * Mirrors `src/tools/apply-patch.ts`. Tests override + * `_internals.loadPluginConfigWithMeta` and restore it in `afterEach` instead of + * writing real config files or using `mock.module` (which leaks across test + * files in Bun's shared runner). Used by the #2504 conservative-preset + * new-plan default below. + */ +export const _internals = { + loadPluginConfigWithMeta, +}; + import { formatLegacyQaBindingRecovery } from '../qa-gate/recovery.js'; import { normalizeScopeFiles } from '../scope/scope-binding.js'; import { readEffectiveSpecSync } from '../sdd/effective-spec'; @@ -928,16 +942,36 @@ export async function executeSavePlan( // Precedence: incoming args.execution_profile > preserved existing profile > undefined. // The locked-profile guard above rejected changes to locked profiles, but // permits idempotent no-op profile repeats so recovery retries can proceed. + + // #2504 conservative preset: a successfully loaded config with + // `preset: "conservative"` restores the pre-flip (v7) serial default for + // NEW plans. Any load failure (missing file, parse error, throw) is treated + // as `preset: undefined` and falls through to the v8 default — a loaded + // conservative config is authoritative. Read via the `_internals` DI seam so + // tests substitute hermetically. + let conservativePresetActive = false; + if (targetWorkspace) { + try { + const { config: presetConfig } = + _internals.loadPluginConfigWithMeta(targetWorkspace); + conservativePresetActive = presetConfig.preset === 'conservative'; + } catch { + conservativePresetActive = false; + } + } + const newPlanParallelizationDefault = !conservativePresetActive; + let resolvedProfile: Plan['execution_profile'] = preservedExecutionProfile; if (args.execution_profile !== undefined) { // Merge incoming profile fields over the preserved base (if any). // F-003: a partial profile on a new/effectively-new plan must inherit the - // v8 parallel-first default. Only an explicit false opts out; existing - // profiles retain their persisted value through the preserved base. + // v8 parallel-first default (serial under the #2504 conservative + // preset). Only an explicit false opts out; existing profiles retain + // their persisted value through the preserved base. const base = preservedExecutionProfile ?? (args.execution_profile.parallelization_enabled === undefined - ? { parallelization_enabled: true } + ? { parallelization_enabled: newPlanParallelizationDefault } : {}); const merged = { ...base, ...args.execution_profile }; const parsed = ExecutionProfileSchema.safeParse(merged); @@ -962,12 +996,13 @@ export async function executeSavePlan( // Step 3.1 (v8 / #1674): new-plan-only parallelization default. // When the resolved profile is still undefined at this point — i.e. this is // a NEW plan (no existing profile preserved, no explicit incoming profile) — - // apply the v8 default: `parallelization_enabled: true`. This is the ONLY - // place the v8 default is injected. Existing plans are loaded via - // `PlanSchema.parse` (parsePlanJsonCached), whose schema default STAYS - // `false`, so upgrading opencode-swarm never flips an existing plan's - // behavior. A revision of a profile-less existing plan also reaches this - // branch (effectively-new; documented in the release fragment). + // apply the v8 default: `parallelization_enabled: true` (serial `false` + // under the #2504 conservative preset). This is the ONLY place the v8 + // default is injected. Existing plans are loaded via `PlanSchema.parse` + // (parsePlanJsonCached), whose schema default STAYS `false`, so upgrading + // opencode-swarm never flips an existing plan's behavior. A revision of a + // profile-less existing plan also reaches this branch (effectively-new; + // documented in the release fragment). // // The default applies only to `parallelization_enabled`; the other profile // fields keep their schema defaults (max_concurrent_tasks: 10, etc.). The @@ -976,7 +1011,7 @@ export async function executeSavePlan( if (resolvedProfile === undefined) { resolvedProfile = { ...ExecutionProfileSchema.parse({}), - parallelization_enabled: true, + parallelization_enabled: newPlanParallelizationDefault, ...(persistedPlanningProfile !== undefined ? { planning_profile: persistedPlanningProfile } : {}), diff --git a/tests/unit/config/conservative-preset.test.ts b/tests/unit/config/conservative-preset.test.ts new file mode 100644 index 000000000..5981f04d4 --- /dev/null +++ b/tests/unit/config/conservative-preset.test.ts @@ -0,0 +1,282 @@ +/** + * Conservative preset tests (issue #2504, governed v8 defaults-flip frame). + * + * Pins the preset contract frozen by the #2504 acceptance checks: + * - schema: top-level `preset` is preserved ('default' | 'conservative'), + * anything else is a schema error; + * - loader: a project config selecting `preset: "conservative"` materializes + * the v7 value (`auto_review.enabled: false`) through the production + * resolution path, while an explicit user/project key always wins — + * including partial sections like `auto_review: { mode: "gate" }`; + * - release seam: `resolveAutoReviewConfig` keeps auto-review disabled at a + * simulated v8 package version under the conservative preset; + * - save_plan: new plans default to serial under conservative, parallel-first + * otherwise, explicit profile keys always win, and a config-load failure + * fails open to the v8 default. + * + * New file (FR-006): src/services/config-doctor.test.ts and the save-plan + * suites are over the 500-line cap; this contract gets its own file. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ConfigLoadResult } from '../../../src/config/loader'; +import { loadPluginConfig } from '../../../src/config/loader'; +import { + AUTO_REVIEW_V8_BURN_IN_DECISION, + CONSERVATIVE_PRESET_BASE, + PluginConfigSchema, + resolveAutoReviewConfig, +} from '../../../src/config/schema'; +import { loadPlanJsonOnly } from '../../../src/plan/manager'; +import { + executeSavePlan, + _internals as savePlanInternals, +} from '../../../src/tools/save-plan'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +const PROJECT_CONFIG = '.opencode/opencode-swarm.json'; + +let xdgDir: string; +let originalXDG: string | undefined; + +beforeEach(() => { + // Isolate the USER config path so the developer's real user-level + // opencode-swarm.json cannot leak into loader-dependent assertions. + xdgDir = canonicalMkdtemp('conservative-preset-xdg-'); + originalXDG = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdgDir; +}); + +afterEach(() => { + if (originalXDG === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXDG; + } + fs.rmSync(xdgDir, { recursive: true, force: true }); +}); + +/** Write a project config and return its directory. */ +function writeProject(config: unknown): string { + const dir = canonicalMkdtemp('conservative-preset-proj-'); + fs.mkdirSync(path.join(dir, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(dir, PROJECT_CONFIG), + JSON.stringify(config), + 'utf-8', + ); + return dir; +} + +describe('conservative preset — schema surface (#2504)', () => { + test('preserves preset: "conservative" through PluginConfigSchema.parse', () => { + const parsed = PluginConfigSchema.parse({ preset: 'conservative' }); + expect(parsed.preset).toBe('conservative'); + }); + + test('preserves preset: "default"', () => { + const parsed = PluginConfigSchema.parse({ preset: 'default' }); + expect(parsed.preset).toBe('default'); + }); + + test('absent preset stays undefined (existing configs untouched)', () => { + const parsed = PluginConfigSchema.parse({}); + expect(parsed.preset).toBeUndefined(); + }); + + test('rejects an unknown preset value', () => { + const result = PluginConfigSchema.safeParse({ preset: 'turbo-ultra' }); + expect(result.success).toBe(false); + }); + + test('CONSERVATIVE_PRESET_BASE pins the v7 auto_review posture', () => { + const base = CONSERVATIVE_PRESET_BASE as { + auto_review?: { enabled?: boolean }; + }; + expect(base.auto_review?.enabled).toBe(false); + }); +}); + +describe('conservative preset — loader materialization (#2504)', () => { + test('conservative project config resolves auto_review.enabled === false', () => { + const dir = writeProject({ preset: 'conservative' }); + const config = loadPluginConfig(dir); + expect(config.preset).toBe('conservative'); + expect(config.auto_review?.enabled).toBe(false); + }); + + test('conservative preset survives an agents block (installer shape)', () => { + // Final-critic round 1: the v6.12 dormant-legacy-key strip used to + // delete `preset` whenever an `agents` block existed — silently + // disabling the conservative preset for the exact config shape the + // installer writes (src/cli/index.ts default config). + const dir = writeProject({ + preset: 'conservative', + agents: { coder: { model: 'opencode/big-pickle' } }, + }); + const config = loadPluginConfig(dir); + expect(config.preset).toBe('conservative'); + expect(config.auto_review?.enabled).toBe(false); + expect(config.agents?.coder?.model).toBe('opencode/big-pickle'); + }); + + test('conservative preset survives an EMPTY agents block', () => { + const dir = writeProject({ preset: 'conservative', agents: {} }); + const config = loadPluginConfig(dir); + expect(config.preset).toBe('conservative'); + expect(config.auto_review?.enabled).toBe(false); + }); + + test('a legacy v6.12 preset NAME is still stripped next to an agents block', () => { + // The dormant strip must keep firing for actual legacy values + // (arbitrary remote-preset names) — the fix must not wedge it shut. + const dir = writeProject({ + preset: 'remote', + presets: { remote: { coder: { model: 'x' } } }, + agents: {}, + }); + const config = loadPluginConfig(dir); + expect(config.preset).toBeUndefined(); + }); + + test('explicit auto_review.enabled: true beats the preset', () => { + const dir = writeProject({ + preset: 'conservative', + auto_review: { enabled: true }, + }); + const config = loadPluginConfig(dir); + expect(config.auto_review?.enabled).toBe(true); + }); + + test('partial auto_review section keeps user keys and the v7 enabled default', () => { + const dir = writeProject({ + preset: 'conservative', + auto_review: { mode: undefined, structured_findings: false }, + }); + const config = loadPluginConfig(dir); + // The base layer fills enabled=false; the user's other keys survive. + expect(config.auto_review?.enabled).toBe(false); + expect(config.auto_review?.structured_findings).toBe(false); + }); + + test('no preset (v7-era config) keeps the section absent', () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + expect(config.preset).toBeUndefined(); + expect(config.auto_review).toBeUndefined(); + }); +}); + +describe('conservative preset — release seam (#2504)', () => { + const v8Context = { + packageVersion: '8.0.0', + burnInDecision: AUTO_REVIEW_V8_BURN_IN_DECISION, + }; + + test('conservative keeps auto_review disabled at a simulated v8 release', () => { + const config = resolveAutoReviewConfig( + {}, + { ...v8Context, preset: 'conservative' }, + ); + expect(config.enabled).toBe(false); + }); + + test('no preset flips auto_review on at a simulated v8 release', () => { + const config = resolveAutoReviewConfig({}, v8Context); + expect(config.enabled).toBe(true); + }); + + test('explicit enabled: false stays authoritative at v8 (kill switch)', () => { + const config = resolveAutoReviewConfig({ enabled: false }, v8Context); + expect(config.enabled).toBe(false); + }); +}); + +describe('conservative preset — save_plan new-plan default (#2504)', () => { + let tempDir: string; + let swarmDir: string; + let originalLoader: typeof savePlanInternals.loadPluginConfigWithMeta; + + beforeEach(() => { + process.env.SWARM_SKIP_GATE_SELECTION = '1'; + tempDir = canonicalMkdtemp('conservative-preset-plan-'); + swarmDir = path.join(tempDir, '.swarm'); + fs.mkdirSync(swarmDir, { recursive: true }); + fs.writeFileSync( + path.join(swarmDir, 'spec.md'), + '# Test Spec\nconservative preset save_plan spec.', + 'utf-8', + ); + fs.writeFileSync( + path.join(swarmDir, 'context.md'), + '## Pending QA Gate Selection\n', + 'utf-8', + ); + originalLoader = savePlanInternals.loadPluginConfigWithMeta; + }); + + afterEach(() => { + delete process.env.SWARM_SKIP_GATE_SELECTION; + savePlanInternals.loadPluginConfigWithMeta = originalLoader; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function fakeLoader(preset: string | undefined) { + return ((directory: string) => + ({ + config: PluginConfigSchema.parse(preset ? { preset } : {}), + }) as unknown as ConfigLoadResult) as typeof savePlanInternals.loadPluginConfigWithMeta; + } + + function makePlanArgs(): Parameters[0] { + return { + title: 'Conservative Plan', + swarm_id: 'test-swarm', + working_directory: tempDir, + phases: [ + { + id: 1, + name: 'Phase 1', + tasks: [{ id: '1.1', description: 'Task 1.1' }], + }, + ], + }; + } + + test('new plan defaults to serial under the conservative preset', async () => { + savePlanInternals.loadPluginConfigWithMeta = fakeLoader('conservative'); + const result = await executeSavePlan(makePlanArgs()); + expect(result.success).toBe(true); + const loaded = await loadPlanJsonOnly(tempDir); + expect(loaded?.execution_profile?.parallelization_enabled).toBe(false); + }); + + test('new plan keeps the v8 parallel-first default without the preset', async () => { + savePlanInternals.loadPluginConfigWithMeta = fakeLoader(undefined); + const result = await executeSavePlan(makePlanArgs()); + expect(result.success).toBe(true); + const loaded = await loadPlanJsonOnly(tempDir); + expect(loaded?.execution_profile?.parallelization_enabled).toBe(true); + }); + + test('explicit execution_profile.parallelization_enabled beats the preset', async () => { + savePlanInternals.loadPluginConfigWithMeta = fakeLoader('conservative'); + const args = makePlanArgs(); + args.execution_profile = { parallelization_enabled: true }; + const result = await executeSavePlan(args); + expect(result.success).toBe(true); + const loaded = await loadPlanJsonOnly(tempDir); + expect(loaded?.execution_profile?.parallelization_enabled).toBe(true); + }); + + test('config-load failure fails open to the v8 default', async () => { + savePlanInternals.loadPluginConfigWithMeta = (() => { + throw new Error('simulated config-load failure'); + }) as unknown as typeof savePlanInternals.loadPluginConfigWithMeta; + const result = await executeSavePlan(makePlanArgs()); + expect(result.success).toBe(true); + const loaded = await loadPlanJsonOnly(tempDir); + expect(loaded?.execution_profile?.parallelization_enabled).toBe(true); + }); +}); diff --git a/tests/unit/config/defaults-flip-rollback.test.ts b/tests/unit/config/defaults-flip-rollback.test.ts new file mode 100644 index 000000000..1f3730029 --- /dev/null +++ b/tests/unit/config/defaults-flip-rollback.test.ts @@ -0,0 +1,66 @@ +/** + * Defaults-flip rollback tests (issue #2504, governed v8 defaults-flip frame). + * + * Pins the per-flip rollback contract: every flipped default has a one-line + * kill switch that restores the v7 behavior, verified here by execution at a + * simulated v8 release, plus the doc contract that docs/defaults-governance.md + * names each kill switch. + * + * New file (FR-006): keeps each defaults-flip contract under the 500-line cap. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ExecutionProfileSchema } from '../../../src/config/plan-schema'; +import { + AUTO_REVIEW_V8_BURN_IN_DECISION, + resolveAutoReviewConfig, +} from '../../../src/config/schema'; + +const INVENTORY_DOC = path.join( + import.meta.dir, + '../../../docs/defaults-governance.md', +); + +describe('defaults-flip rollback kill switches (#2504)', () => { + const v8Context = { + packageVersion: '8.0.0', + burnInDecision: AUTO_REVIEW_V8_BURN_IN_DECISION, + }; + + test('auto_review kill switch: explicit enabled:false stays false at v8', () => { + const config = resolveAutoReviewConfig({ enabled: false }, v8Context); + expect(config.enabled).toBe(false); + }); + + test('conservative preset restores the v7 auto_review posture at v8', () => { + const config = resolveAutoReviewConfig( + {}, + { ...v8Context, preset: 'conservative' }, + ); + expect(config.enabled).toBe(false); + }); + + test('conservative preset + explicit re-enable wins over the preset', () => { + const config = resolveAutoReviewConfig( + { enabled: true }, + { ...v8Context, preset: 'conservative' }, + ); + expect(config.enabled).toBe(true); + }); + + test('per-plan parallelization kill switch: explicit false survives the schema', () => { + const profile = ExecutionProfileSchema.parse({ + parallelization_enabled: false, + }); + expect(profile.parallelization_enabled).toBe(false); + }); + + test('inventory doc names both kill switches (doc contract)', () => { + const doc = fs.readFileSync(INVENTORY_DOC, 'utf-8'); + expect(doc).toContain('kill switch'); + expect(doc).toContain('auto_review.enabled'); + expect(doc).toContain('execution_profile.parallelization_enabled'); + expect(doc).toContain('rollback'); + }); +}); diff --git a/tests/unit/config/defaults-flip-upgrade.test.ts b/tests/unit/config/defaults-flip-upgrade.test.ts new file mode 100644 index 000000000..3e04cf771 --- /dev/null +++ b/tests/unit/config/defaults-flip-upgrade.test.ts @@ -0,0 +1,161 @@ +/** + * Defaults-flip upgrade-path tests (issue #2504, governed v8 defaults-flip + * frame). + * + * Pins the v7→v8 upgrade contract: + * - a v7-era config (config_format_version 1, no preset) gets `defaults-flip` + * info findings naming each governed default change, its kill switch, and + * the conservative preset; + * - `runConfigDoctorWithFixes` with `applyLossy: true` (the interactive + * `/swarm config doctor --fix` path) acknowledges the changes by stamping + * `config_format_version: 3` on disk — idempotently; + * - the passive doctor NEVER writes (negative assertion); + * - the legacy rename migrations keep advertising at version 1 and are + * unchanged by the defaults-flip rows. + * + * New file (FR-006): src/services/config-doctor.test.ts is over the cap. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadPluginConfig } from '../../../src/config/loader'; +import { + CURRENT_CONFIG_FORMAT_VERSION, + runConfigDoctor, + runConfigDoctorWithFixes, +} from '../../../src/services/config-doctor'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +const PROJECT_CONFIG = '.opencode/opencode-swarm.json'; + +let xdgDir: string; +let originalXDG: string | undefined; + +beforeEach(() => { + xdgDir = canonicalMkdtemp('flip-upgrade-xdg-'); + originalXDG = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdgDir; +}); + +afterEach(() => { + if (originalXDG === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXDG; + } + fs.rmSync(xdgDir, { recursive: true, force: true }); +}); + +/** Write a project config and return its directory. */ +function writeProject(config: unknown): string { + const dir = canonicalMkdtemp('flip-upgrade-proj-'); + fs.mkdirSync(path.join(dir, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(dir, PROJECT_CONFIG), + JSON.stringify(config), + 'utf-8', + ); + return dir; +} + +function readProject(dir: string): Record { + return JSON.parse( + fs.readFileSync(path.join(dir, PROJECT_CONFIG), 'utf-8'), + ) as Record; +} + +describe('defaults-flip upgrade path (#2504)', () => { + test('v7-era config gets defaults-flip findings naming the kill switch and preset', () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + const result = runConfigDoctor(config, dir); + + const flipFindings = result.findings.filter( + (f) => f.id === 'defaults-flip', + ); + expect(flipFindings.length).toBe(2); + const autoReviewRow = flipFindings.find((f) => + f.path.includes('auto_review'), + ); + expect(autoReviewRow).toBeDefined(); + expect(autoReviewRow!.severity).toBe('info'); + expect(autoReviewRow!.description).toContain('auto_review.enabled'); + expect(autoReviewRow!.description).toContain('conservative'); + const parallelRow = flipFindings.find((f) => + f.path.includes('parallelization_enabled'), + ); + expect(parallelRow).toBeDefined(); + expect(parallelRow!.description).toContain( + 'execution_profile.parallelization_enabled', + ); + }); + + test('v7-era config keeps the legacy rename migrations advertised', () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + const result = runConfigDoctor(config, dir); + // availableMigrations stays the DEPRECATED_FIELDS rename surface only. + expect(result.availableMigrations?.length).toBe(4); + expect( + result.availableMigrations?.some( + (m) => m.field === 'skill_improver.model', + ), + ).toBe(true); + }); + + test('--fix (applyLossy) stamps config_format_version on disk', async () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + await runConfigDoctorWithFixes(dir, config, true, { applyLossy: true }); + expect(readProject(dir).config_format_version).toBe( + CURRENT_CONFIG_FORMAT_VERSION, + ); + }); + + test('passive runConfigDoctor does NOT write config_format_version', () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + runConfigDoctor(config, dir); + // v7-era file carries no explicit version key — it must stay absent. + expect(readProject(dir).config_format_version).toBeUndefined(); + }); + + test('runConfigDoctorWithFixes without applyLossy does NOT stamp', async () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + await runConfigDoctorWithFixes(dir, config, true, { applyLossy: false }); + expect(readProject(dir).config_format_version).toBeUndefined(); + }); + + test('re-running --fix on an acknowledged config does not re-stamp or re-advertise', async () => { + const dir = writeProject({ automation: { mode: 'manual' } }); + const config = loadPluginConfig(dir); + const first = await runConfigDoctorWithFixes(dir, config, true, { + applyLossy: true, + }); + expect(first.result.findings.some((f) => f.id === 'defaults-flip')).toBe( + true, + ); + + const reread = loadPluginConfig(dir); + const second = await runConfigDoctorWithFixes(dir, reread, true, { + applyLossy: true, + }); + expect(second.result.findings.some((f) => f.id === 'defaults-flip')).toBe( + false, + ); + expect(readProject(dir).config_format_version).toBe( + CURRENT_CONFIG_FORMAT_VERSION, + ); + }); + + test('a config already at the current format version emits no defaults-flip findings', () => { + const dir = writeProject({ + automation: { mode: 'manual' }, + config_format_version: CURRENT_CONFIG_FORMAT_VERSION, + }); + const config = loadPluginConfig(dir); + const result = runConfigDoctor(config, dir); + expect(result.findings.some((f) => f.id === 'defaults-flip')).toBe(false); + }); +}); diff --git a/tests/unit/config/parallelization-default.test.ts b/tests/unit/config/parallelization-default.test.ts index 24a92d380..a86f84218 100644 --- a/tests/unit/config/parallelization-default.test.ts +++ b/tests/unit/config/parallelization-default.test.ts @@ -14,12 +14,21 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { loadPlanJsonOnly } from '../../../src/plan/manager'; import { executeSavePlan } from '../../../src/tools/save-plan'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; let tempDir: string; let swarmDir: string; +let xdgDir: string; +let originalXDG: string | undefined; beforeEach(() => { process.env.SWARM_SKIP_GATE_SELECTION = '1'; + // #2504: save_plan now reads the plugin config (preset-aware new-plan + // default); isolate the USER config path so the developer's real + // user-level opencode-swarm.json cannot flip these assertions. + xdgDir = canonicalMkdtemp('v8-parallel-xdg-'); + originalXDG = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdgDir; tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'v8-parallel-default-')); swarmDir = path.join(tempDir, '.swarm'); fs.mkdirSync(swarmDir, { recursive: true }); @@ -39,6 +48,16 @@ beforeEach(() => { afterEach(() => { delete process.env.SWARM_SKIP_GATE_SELECTION; + if (originalXDG === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXDG; + } + try { + fs.rmSync(xdgDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { From ea1c3a862a3741b4896439738a42de82af7fc425 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 15 Sep 2026 10:10:26 -0500 Subject: [PATCH 2/2] fix(config): review-round hardening for the #2504 governance frame Fixes from the PR #2797 swarm-pr-review round (10 lanes, 2 reviewer shards, critic challenge): - PRR-004 (MEDIUM): the user-config-alone recovery fallback now mirrors steps 3/3b (migratePresetsConfig + conservative base layer), so a broken project config no longer defeats the conservative preset; the schema preprocess could otherwise fill a partial auto_review section with the release-gated v8 default before any preset-aware consumer saw it - PRR-001/006 (MEDIUM): runConfigDoctor recomputes severity counts after the defaults-flip emission so the report header and the .swarm/config-doctor.json artifact include the new findings - PRR-026 (MEDIUM): the --fix acknowledgment stamp strips a UTF-8 BOM before JSON.parse (matching the loader) instead of silently skipping - PRR-005 (LOW): stamp failures now log (fail-open, but signaled) - PRR-002 (LOW): the 'QA gate check' comment moved back above the qa-gate-profile import it describes - PRR-021 (MEDIUM): design-rationale automation example now shows the actual schema defaults (read-only capabilities on), matching the amended bullet - Coverage: user-config stamp path, formatDoctorMarkdown 'Defaults changes (v8)' section (formatDoctorMarkdown now exported), user-config-alone fallback with preset, explicit-'default' and unknown-preset fail-open pins (PRR-011/012/013/014/015/017/018) Also syncs the branch with main (release 7.183.1) refreshing the stale release-owner-guard base SHA; the PR body was restructured to the repo's commit-pr contract (## Summary / ## Invariant audit / ## Test plan). --- docs/design-rationale.md | 9 ++- src/commands/doctor.ts | 3 +- src/config/loader.ts | 17 +++++- src/services/config-doctor.ts | 24 +++++++- src/tools/save-plan.ts | 2 +- ...doctor-defaults-flip-markdown-2504.test.ts | 53 ++++++++++++++++ tests/unit/config/conservative-preset.test.ts | 61 ++++++++++++++++++- .../unit/config/defaults-flip-upgrade.test.ts | 21 +++++++ 8 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 tests/unit/commands/doctor-defaults-flip-markdown-2504.test.ts diff --git a/docs/design-rationale.md b/docs/design-rationale.md index 14bdb4f23..99aaeace9 100644 --- a/docs/design-rationale.md +++ b/docs/design-rationale.md @@ -300,17 +300,20 @@ Architect respects dependencies. Won't start 2.2 until 2.1 is complete. "automation": { "mode": "manual", // Default: conservative, full control "capabilities": { - "plan_sync": false, + "plan_sync": true, "phase_preflight": false, "config_doctor_on_startup": false, "config_doctor_autofix": false, - "evidence_auto_summaries": false, - "decision_drift_detection": false + "evidence_auto_summaries": true, + "decision_drift_detection": true } } } ``` +(The `capabilities` values above are the actual schema defaults — read-only +capabilities default on; anything that writes or auto-runs defaults off.) + **Why this works:** - **Progressive rollout:** Start with `manual`, enable features as needed - **Per-capability flags:** every automation capability has its own feature flag, individually defaulted (read-only capabilities default on; anything that writes or auto-runs — `phase_preflight`, `config_doctor_on_startup`, `config_doctor_autofix` — defaults off). Governed default changes are inventoried with evidence and kill switches in `docs/defaults-governance.md` (#2504). diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ca32a3431..714b751da 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -119,8 +119,9 @@ function sanitizeForMarkdownText(s: string): string { /** * Format config doctor result as markdown for command output. + * Exported for the #2504 "Defaults changes (v8)" section coverage test. */ -function formatDoctorMarkdown(result: ConfigDoctorResult): string { +export function formatDoctorMarkdown(result: ConfigDoctorResult): string { const lines = [ '## Config Doctor Report', '', diff --git a/src/config/loader.ts b/src/config/loader.ts index 2f2818d48..3b514de0f 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -815,7 +815,20 @@ function buildConfigWithMeta( strippedKeys: userGatesStripped, rawGates: userRawGates, } = sanitizeSectionConfigs(rawUserConfig); - const userParseResult = PluginConfigSchema.safeParse(userSanitized); + // #2504: mirror steps 3/3b for the fallback input so a conservative + // preset survives the broken-project-config recovery exactly as on the + // normal path — without this, the schema preprocess would fill a + // partial auto_review section with the release-gated (v8) default + // before any preset-aware consumer could see the user's intent. + const userPresetMigrated = migratePresetsConfig(userSanitized); + const userPresetReady = + userPresetMigrated.preset === 'conservative' + ? (deepMergeFn(CONSERVATIVE_PRESET_BASE, userPresetMigrated) as Record< + string, + unknown + >) + : userPresetMigrated; + const userParseResult = PluginConfigSchema.safeParse(userPresetReady); if (userParseResult.success) { advisoryWarn( '[opencode-swarm] Project config ignored due to validation errors. Using user config.', @@ -832,7 +845,7 @@ function buildConfigWithMeta( } // Last targeted attempt: strip unrecognized keys from user config too. const userStripped = stripUnrecognizedKeys( - userSanitized, + userPresetReady, userParseResult.error, ); if (userStripped.removed.length > 0) { diff --git a/src/services/config-doctor.ts b/src/services/config-doctor.ts index b8addc273..5de44daa2 100644 --- a/src/services/config-doctor.ts +++ b/src/services/config-doctor.ts @@ -2259,6 +2259,14 @@ export function runConfigDoctor( } } + // Recompute the severity counts: the defaults-flip loop above appends info + // findings after the snapshot taken when summary was first built, and the + // report header plus the .swarm/config-doctor.json artifact must reflect + // every emitted finding (#2504 review round 2). + summary.info = findings.filter((f) => f.severity === 'info').length; + summary.warn = findings.filter((f) => f.severity === 'warn').length; + summary.error = findings.filter((f) => f.severity === 'error').length; + return { findings, summary, @@ -2781,7 +2789,16 @@ export async function runConfigDoctorWithFixes( : null; if (stampPath) { const stampContent = fs.readFileSync(stampPath, 'utf-8'); - const stampConfig = JSON.parse(stampContent) as Record; + // Strip a UTF-8 BOM like the config loader does — JSON.parse + // throws on a leading BOM, which would silently skip the stamp. + const sanitizedStampContent = + stampContent.charCodeAt(0) === 0xfeff + ? stampContent.slice(1) + : stampContent; + const stampConfig = JSON.parse(sanitizedStampContent) as Record< + string, + unknown + >; const currentVersion = stampConfig.config_format_version; const shouldStamp = typeof currentVersion !== 'number' || @@ -2797,9 +2814,12 @@ export async function runConfigDoctorWithFixes( atomicWriteFileSync(stampPath, JSON.stringify(stampConfig, null, 2)); } } - } catch { + } catch (stampError) { // Fail-open: a failed acknowledgment stamp never fails the fix pass; // the rows simply keep advertising until a later successful --fix. + log( + `[ConfigDoctor] acknowledgment stamp failed (non-fatal): ${stampError instanceof Error ? stampError.message : String(stampError)}`, + ); } } diff --git a/src/tools/save-plan.ts b/src/tools/save-plan.ts index bfff1a064..672ea31db 100644 --- a/src/tools/save-plan.ts +++ b/src/tools/save-plan.ts @@ -7,7 +7,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ToolDefinition } from '@opencode-ai/plugin/tool'; import { z } from 'zod'; -// QA gate check — first save-plan integration with profile store import { loadPluginConfigWithMeta } from '../config'; import { type ExecutionProfile, @@ -19,6 +18,7 @@ import { type Task, type TaskStatus, } from '../config/plan-schema'; +// QA gate check — first save-plan integration with profile store import { getOrCreateProfileForIdentity, getProfileLookupForIdentity, diff --git a/tests/unit/commands/doctor-defaults-flip-markdown-2504.test.ts b/tests/unit/commands/doctor-defaults-flip-markdown-2504.test.ts new file mode 100644 index 000000000..076b37e1d --- /dev/null +++ b/tests/unit/commands/doctor-defaults-flip-markdown-2504.test.ts @@ -0,0 +1,53 @@ +/** + * Coverage for the #2504 "Defaults changes (v8)" doctor report section + * (PRR-011 from the PR #2797 review round): the defaults-flip findings must + * render their own markdown section, and must not when absent. + */ +import { describe, expect, test } from 'bun:test'; +import { formatDoctorMarkdown } from '../../../src/commands/doctor'; +import type { ConfigDoctorResult } from '../../../src/services/config-doctor'; + +function makeResult( + findings: ConfigDoctorResult['findings'], +): ConfigDoctorResult { + return { + findings, + summary: { + info: findings.filter((f) => f.severity === 'info').length, + warn: findings.filter((f) => f.severity === 'warn').length, + error: findings.filter((f) => f.severity === 'error').length, + }, + hasAutoFixableIssues: false, + timestamp: 0, + configSource: 'defaults', + }; +} + +const FLIP_FINDING = { + id: 'defaults-flip', + title: 'v8 default change pending for "auto_review.enabled"', + description: + 'Default flips from false (opt-in) to release-gated true (advisory). ' + + 'Kill switch: set auto_review.enabled: false. ' + + 'Restore all v7 defaults with preset: "conservative". ' + + 'Evidence and rollback: docs/defaults-governance.md (#2504).', + severity: 'info' as const, + path: 'auto_review.enabled', + currentValue: 'v7 default', + autoFixable: false, +}; + +describe('formatDoctorMarkdown — Defaults changes (v8) section (#2504)', () => { + test('renders the section for defaults-flip findings', () => { + const markdown = formatDoctorMarkdown(makeResult([FLIP_FINDING])); + expect(markdown).toContain('### Defaults changes (v8)'); + expect(markdown).toContain('auto_review.enabled: false'); + expect(markdown).toContain('preset: "conservative"'); + expect(markdown).toContain('docs/defaults-governance.md'); + }); + + test('omits the section when no defaults-flip findings exist', () => { + const markdown = formatDoctorMarkdown(makeResult([])); + expect(markdown).not.toContain('Defaults changes (v8)'); + }); +}); diff --git a/tests/unit/config/conservative-preset.test.ts b/tests/unit/config/conservative-preset.test.ts index 5981f04d4..69d310629 100644 --- a/tests/unit/config/conservative-preset.test.ts +++ b/tests/unit/config/conservative-preset.test.ts @@ -21,7 +21,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import type { ConfigLoadResult } from '../../../src/config/loader'; -import { loadPluginConfig } from '../../../src/config/loader'; +import { + loadPluginConfig, + loadPluginConfigWithMeta, +} from '../../../src/config/loader'; import { AUTO_REVIEW_V8_BURN_IN_DECISION, CONSERVATIVE_PRESET_BASE, @@ -106,6 +109,62 @@ describe('conservative preset — loader materialization (#2504)', () => { expect(config.auto_review?.enabled).toBe(false); }); + test('conservative preset survives the user-config-alone fallback (PRR-004)', () => { + // A broken PROJECT config must not defeat the user's conservative + // preset: the fallback re-parses the user config alone, and without + // the base layer the schema preprocess fills a partial auto_review + // section with the release-gated (v8) default. The fix mirrors steps + // 3/3b for the fallback input. + const userConfigDir = path.join(xdgDir, 'opencode'); + fs.mkdirSync(userConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(userConfigDir, 'opencode-swarm.json'), + JSON.stringify({ + preset: 'conservative', + auto_review: { structured_findings: false }, + }), + 'utf-8', + ); + const dir = canonicalMkdtemp('conservative-preset-fallback-'); + fs.mkdirSync(path.join(dir, '.opencode'), { recursive: true }); + // An invalid discriminated-union value cannot be key-stripped, so the + // merged-config ladder exhausts and the user fallback fires. + fs.writeFileSync( + path.join(dir, PROJECT_CONFIG), + JSON.stringify({ turbo: { strategy: 'bogus' } }), + 'utf-8', + ); + const meta = loadPluginConfigWithMeta(dir); + expect(meta.recovery).toBe('user_only'); + expect(meta.config.preset).toBe('conservative'); + expect(meta.config.auto_review?.enabled).toBe(false); + expect(meta.config.auto_review?.structured_findings).toBe(false); + }); + + test('explicit preset: "default" behaves identically to absent (PRR-014)', () => { + const dir = writeProject({ preset: 'default' }); + const config = loadPluginConfig(dir); + expect(config.preset).toBe('default'); + // No conservative base layer: the section stays absent, exactly like + // an omitted preset. + expect(config.auto_review).toBeUndefined(); + }); + + test('preset: "default" also survives an agents block (PRR-018)', () => { + const dir = writeProject({ preset: 'default', agents: {} }); + const config = loadPluginConfig(dir); + expect(config.preset).toBe('default'); + expect(config.auto_review).toBeUndefined(); + }); + + test('an unknown preset value is dropped fail-open through the full pipeline (PRR-015/024)', () => { + const dir = writeProject({ preset: 'turbo-ultra' }); + const config = loadPluginConfig(dir); + // sanitizeMalformedValues drops the invalid leaf; the config loads + // without the preset (v8 defaults apply) instead of nuking the file. + expect(config.preset).toBeUndefined(); + }); + test('conservative preset survives an agents block (installer shape)', () => { // Final-critic round 1: the v6.12 dormant-legacy-key strip used to // delete `preset` whenever an `agents` block existed — silently diff --git a/tests/unit/config/defaults-flip-upgrade.test.ts b/tests/unit/config/defaults-flip-upgrade.test.ts index 3e04cf771..a6f05febf 100644 --- a/tests/unit/config/defaults-flip-upgrade.test.ts +++ b/tests/unit/config/defaults-flip-upgrade.test.ts @@ -158,4 +158,25 @@ describe('defaults-flip upgrade path (#2504)', () => { const result = runConfigDoctor(config, dir); expect(result.findings.some((f) => f.id === 'defaults-flip')).toBe(false); }); + + test('--fix stamps the USER config when no project config exists (PRR-013)', async () => { + // Only a user-level config exists (under the isolated XDG dir); the + // stamp's path derivation must fall back to userConfigPath. + const userConfigDir = path.join(xdgDir, 'opencode'); + fs.mkdirSync(userConfigDir, { recursive: true }); + const userConfigPath = path.join(userConfigDir, 'opencode-swarm.json'); + fs.writeFileSync( + userConfigPath, + JSON.stringify({ automation: { mode: 'manual' } }), + 'utf-8', + ); + const dir = canonicalMkdtemp('flip-upgrade-noproject-'); + const config = loadPluginConfig(dir); + await runConfigDoctorWithFixes(dir, config, true, { applyLossy: true }); + const stamped = JSON.parse( + fs.readFileSync(userConfigPath, 'utf-8'), + ) as Record; + expect(stamped.config_format_version).toBe(CURRENT_CONFIG_FORMAT_VERSION); + fs.rmSync(dir, { recursive: true, force: true }); + }); });