diff --git a/README.md b/README.md index 744595bf8..94fd4a4f9 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ OpenCode supports 75+ LLM providers (OpenAI, Anthropic, Google, Ollama, etc.). W - OpenCode reads `AGENTS.md` for project instructions (already present in Codev projects) - OpenCode is only supported as a **builder** shell, not as an architect shell -Other shells (Codex, Gemini) are also supported via the harness system. See `packages/codev/src/agent-farm/utils/harness.ts` for details, or define a custom harness in `.codev/config.json`. +Codex is also supported via the harness system. See `packages/codev/src/agent-farm/utils/harness.ts` for details, or define a custom harness in `.codev/config.json`. (The built-in **Gemini CLI** harness is **retired** — see the note under Autonomous Builder Flags below.) ## Architect-Builder Pattern @@ -433,7 +433,6 @@ Builders need permission-skipping flags to run autonomously without human approv | CLI Tool | Flag | Purpose | |----------|------|---------| | Claude Code | `--dangerously-skip-permissions` | Skip permission prompts for file/command operations | -| Gemini CLI | `--yolo` | Enable autonomous mode without confirmations | Configure in `.codev/config.json` (created by `codev init` or `codev adopt`): ```json @@ -445,19 +444,45 @@ Configure in `.codev/config.json` (created by `codev init` or `codev adopt`): } ``` -Or for Gemini (the standalone **Gemini CLI** as a *builder/architect* coding agent — a separate -concern from the `gemini` **consult lane**, which now uses the Antigravity CLI `agy`). Note: Google -retired the Gemini CLI for Pro/Ultra/free tiers on 2026-06-18, so this builder harness will stop -working for those tiers — prefer a Claude or Codex builder, or an enterprise Gemini CLI. (Tracked as -a follow-up; out of scope for the consult-lane migration.) +The built-in **Gemini CLI** harness is **retired** as a builder/architect shell: Google ended +consumer Gemini CLI access (Pro, Ultra, and free tiers) on 2026-06-18, so it is no longer offered as +a supported built-in shell. Switch to a supported harness — `claude` or `codex` for either role, or +`opencode` for builders (OpenCode is not supported as an architect shell). For example, Codex for +both roles: ```json { "shell": { - "architect": "gemini --yolo", - "builder": "gemini --yolo" + "architect": "codex", + "builder": "codex" } } ``` +If you retain Gemini CLI access (a Standard/Enterprise subscription or API-key auth), you can still +run it by defining a **custom harness** named `gemini` in `.codev/config.json` **and selecting it +explicitly** with `shell.builderHarness` / `shell.architectHarness`. The explicit selector is +**required**: a bare auto-detected `gemini` command (e.g. `shell.builder: "gemini --yolo"` with no +`builderHarness`) stays retired, because auto-detection resolves the built-in namespace only. +```json +{ + "shell": { + "builder": "gemini --yolo", + "builderHarness": "gemini" + }, + "harness": { + "gemini": { + "roleArgs": [], + "roleEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" }, + "roleScriptFragment": "", + "roleScriptEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" } + } + } +} +``` +The Gemini CLI reads its system prompt from the `GEMINI_SYSTEM_MD` environment variable pointing at +the role file, so the custom harness injects via `roleEnv` / `roleScriptEnv` (with empty `roleArgs`) — +reproducing exactly what the retired built-in harness did. +See `packages/codev/src/agent-farm/utils/harness.ts` for the custom-harness fields. This is separate +from the `gemini` **consult lane**, which is unaffected and now uses the Antigravity CLI `agy`. **Warning**: These flags allow the AI to execute commands and modify files without asking. Only use in development environments where you trust the AI's actions. diff --git a/codev/plans/1338-retire-gemini-cli-as-a-builder.md b/codev/plans/1338-retire-gemini-cli-as-a-builder.md new file mode 100644 index 000000000..2133f93cf --- /dev/null +++ b/codev/plans/1338-retire-gemini-cli-as-a-builder.md @@ -0,0 +1,454 @@ +# Plan: Retire Gemini CLI as a Builder Harness + +## Metadata +- **ID**: plan-2026-08-03-retire-gemini-cli-builder +- **Status**: Approved (plan-approval gate 2026-08-03) — implemented (PR #1342) +- **Specification**: [codev/specs/1338-retire-gemini-cli-as-a-builder.md](../specs/1338-retire-gemini-cli-as-a-builder.md) +- **Created**: 2026-08-03 +- **Issue**: #1338 + +## Executive Summary +Implement the spec's recommended **Approach 1 — a retirement sentinel in the shared harness +resolver**. `resolveHarness` (`agent-farm/utils/harness.ts`) is role-agnostic and shared by both +`getArchitectHarness` and `getBuilderHarness`, so a single retired-name registry, consulted on every +resolver exit, retires `gemini` for **both** roles (architect-path retirement explicitly accepted by +the architect, 2026-08-03). Selecting `gemini` — by explicit `builderHarness`/`architectHarness` or by +auto-detection from a `gemini …` command (string or array form) — fails **closed** with one clear +retirement message, never a silent Claude fallback (harness.ts:392) and never an `undefined`/TypeError +(harness.ts:387). + +The retirement then surfaces cleanly where a user meets it: a builder **spawn preflight** rejects it +*before* any worktree/porch/db state is created (resolution currently happens at spawn.ts:471, after +`createWorktree`/`initPorchInWorktree` — a raw throw would orphan state); the four `getArchitectHarness` +call sites are handled per-site so a gemini architect fails cleanly without crashing Tower; `codev +doctor` diagnoses it for both roles; and the README + CHANGELOG stop presenting Gemini as a supported +builder. The `agy` / `consult -m gemini` subsystem is untouched. + +Four phases, each an independently committable unit that leaves `build` + the full test suite green. +(Committable ≠ individually shippable: after Phase 1 the resolver throws but nothing surfaces it yet — +do not stop before Phase 3.) + +## Success Metrics +Copied from the spec's Success Criteria, plus implementation-specific checks: +- [ ] `gemini` removed from `BUILTIN_HARNESSES` and absent from the resolver's "Available harnesses" + listing (spec criterion 1). +- [ ] Explicit `builderHarness: "gemini"` → clear **retirement** error naming alternatives (criterion 2). +- [ ] Auto-detected `builder: "gemini …"` (string form) → same retirement error; **neither** + `CLAUDE_HARNESS` **nor** `undefined` returned (criterion 1+2; both footguns closed). +- [ ] Real config paths fail closed: `getBuilderHarness`, `--builder-cmd gemini`, and **array-form** + `builder: ["gemini", "--yolo"]`. +- [ ] Architect path fails closed: `getArchitectHarness` / `--architect-cmd gemini`. +- [ ] A rejected gemini **spawn creates no worktree/porch/db state** (preflight before mutation). +- [ ] A gemini **architect** config fails cleanly at every `getArchitectHarness` site — no uncaught + Tower crash (including the non-launch predicate at tower-utils.ts:291). +- [ ] `codev doctor` no longer says gemini is "supported for builders"; it presents the retirement and + flags a `gemini` **builder** config, verified via structured `issue:`/`recommendation:` fields. +- [ ] README "other shells" line, autonomous-flags table, and **both** architect+builder config-example + lines updated; a CHANGELOG `[Unreleased]` entry records the breaking change. +- [ ] Claude, Codex, OpenCode, and custom harnesses resolve/spawn unchanged (criterion 3); a custom + `gemini` definition still resolves via explicit selection (escape hatch). +- [ ] Each removed gemini test is **replaced** by a retirement-behavior test (coverage-by-replacement). +- [ ] Governance docs (`arch.md`/`lessons-learned.md`) updated (Review phase, via `update-arch-docs`). +- [ ] `agy` / `consult -m gemini` untouched; `pnpm --filter @cluesmith/codev build` + full test suite green. + +## Phases (Machine Readable) + + + +```json +{ + "phases": [ + {"id": "phase_1", "title": "Retire gemini in the shared resolver (core + resolver/config tests)"}, + {"id": "phase_2", "title": "Fail closed at spawn/launch boundaries (no orphaned state, no Tower crash)"}, + {"id": "phase_3", "title": "codev doctor: retirement guidance + builder-side flagging"}, + {"id": "phase_4", "title": "User-facing docs: README + CHANGELOG"} + ] +} +``` + +## Phase Breakdown + +### Phase 1: Retire `gemini` in the shared resolver (core + resolver/config tests) +**Dependencies**: None + +#### Objectives +- Make `gemini` a **retired** harness name that fails closed on every `resolveHarness` exit, for both + builder and architect resolution, with one clear retirement message — without changing precedence + for any supported harness. +- Remove the `GEMINI_HARNESS` provider and its registry entry; expose a small `isRetiredHarness`/ + `getRetirement` predicate for downstream preflights (Phases 2–3). + +#### Deliverables +- [ ] `agent-farm/utils/harness.ts`: + - [ ] New retired-names registry, e.g. `RETIRED_HARNESSES: Record` (name → + explanation), plus an exported `getRetirement(name): string | undefined` (and/or + `isRetiredHarness(name): boolean`) and a helper that throws a consistent retirement `Error`. + - [ ] Remove the `GEMINI_HARNESS` provider (harness.ts:177-186) and the `gemini:` entry in + `BUILTIN_HARNESSES` (:212). + - [ ] **Keep** the `gemini` case in `detectHarnessFromCommand` (:338) — it is what makes both the + auto-detect retirement *and* doctor's diagnosis work (doctor resolves via the detector, not + `resolveHarness`). + - [ ] Update `resolveHarness` with the **exact precedence below** (this corrects the ordering the + reviewers flagged). + - [ ] Update the module header comment (:4-6) to drop Gemini from the "built-in providers" list. +- [ ] Replace/adjust affected tests (retirement behavior, not `GEMINI_HARNESS`): + - [ ] `__tests__/harness.test.ts` — drop the `GEMINI_HARNESS` import + describe block and the + `resolveHarness('gemini')` / auto-detect-gemini / explicit-beats-detect assertions; add: + explicit `gemini` throws retirement; auto-detected `gemini …` throws retirement and returns + **neither** `CLAUDE_HARNESS` **nor** `undefined`; an unrelated unknown name still throws the + **generic** "Unknown harness" error; `detectHarnessFromCommand('gemini')` still returns + `'gemini'`; **custom `gemini` (explicit) resolves to the custom provider**; **built-in + precedence preserved** (a custom `claude`/`codex`/`opencode` does NOT shadow the built-in). + - [ ] `__tests__/harness-integration.test.ts` — replace the `GEMINI_SYSTEM_MD` / gemini-script + assertions (:116-125, :242-249) with a retirement assertion; keep the codex "no + `GEMINI_SYSTEM_MD`" guard. + - [ ] `__tests__/discover-resume-session.test.ts` — the gemini regression guard (:106-112) imports + `GEMINI_HARNESS`; re-express with a still-valid non-resuming harness (codex) or delete if + fully subsumed, preserving the "no stale-Claude-jsonl resume for non-claude harnesses" intent. + - [ ] `__tests__/config.test.ts` — `--builder-cmd gemini` (:138-141) now asserts the **retirement + throw** from `getBuilderHarness`; add `--architect-cmd gemini` → `getArchitectHarness` throws; + add **array-form** `builder: ["gemini", "--yolo"]` fails closed. + - [ ] Grep `__tests__` for any assertion that `gemini` is a recognized/builtin harness and update. + +#### Implementation Details +- **`resolveHarness` precedence (corrected).** Preserve today's behavior exactly except for gemini: + - *Explicit `harnessName`*: `built-in lookup → custom lookup → retired check → generic "Unknown + harness" throw`. Built-ins keep precedence over same-named custom (so a custom `claude`/`codex`/ + `opencode` can NOT shadow a built-in — current behavior). Custom lookup precedes the retired check, + so an explicit **custom `gemini` still resolves** (the escape hatch); with no custom `gemini`, the + retired check fires. + - *Auto-detect branch*: after `detectHarnessFromCommand` returns a name, run the **retired check on + that detected name before** `return BUILTIN_HARNESSES[detected]` (:387). Auto-detection resolves + the built-in namespace only (it never consulted custom), so an auto-detected `gemini …` command is + **retired even if a custom `gemini` exists** — the custom escape hatch requires *explicit* + `builderHarness`/`architectHarness: "gemini"`. This is the only unambiguous, behavior-preserving + reading. + - No path can reach a removed `BUILTIN_HARNESSES['gemini']` (→ `undefined`, :387) or fall through to + `CLAUDE_HARNESS` (:392) for a gemini command. +- **Retirement message** (single source in `RETIRED_HARNESSES['gemini']`) must contain: that the + built-in Gemini CLI harness is **retired**; the cause (Google ended Gemini CLI availability for + consumer accounts — free/Pro/Ultra — on **2026-06-18**); the supported alternatives (**claude, + codex, opencode**); the **custom-harness** escape hatch in `.codev/config.json` for retained + (Standard/Enterprise/API-key) access; and a pointer to issue #1338. Exact wording finalized in + implementation; these elements are required. +- No production file imports `GEMINI_HARNESS` by name (verified: only `harness.ts` + tests), so + removing the export breaks only tests, which are updated in-phase. + +#### Acceptance Criteria +- [ ] `resolveHarness` throws the retirement for explicit `gemini` and auto-detected `gemini …`; + returns neither `CLAUDE_HARNESS` nor `undefined` on any gemini path. +- [ ] Unrelated unknown names still throw the generic "Unknown harness" error listing available names + (without `gemini`); a custom `gemini` (explicit) resolves; built-ins are never shadowed by custom. +- [ ] `claude`, `codex`, `opencode` resolve exactly as before. +- [ ] `pnpm --filter @cluesmith/codev build` and the unit test suite pass. + +#### Test Plan +- **Unit Tests**: as enumerated (harness.test.ts, harness-integration.test.ts, + discover-resume-session.test.ts, config.test.ts). Assert fail-closed (`not CLAUDE_HARNESS`, + `not undefined`), custom-`gemini`-escape-hatch, and built-in-precedence-preserved. +- **Integration Tests**: `getBuilderHarness`/`getArchitectHarness` retirement via config.test.ts + (string, array, CLI-override forms). +- **Manual Testing**: none at this phase (surfacing is Phases 2–3). + +#### Rollback Strategy +Single commit; `git revert` restores `GEMINI_HARNESS`, its registry entry, and prior tests. No state. + +#### Risks +- **Risk**: An unguarded resolver exit reopens a footgun. **Mitigation**: tests assert all three exits + fail closed; required acceptance criterion. +- **Risk**: Reordering precedence changes behavior for supported/custom harnesses. **Mitigation**: + keep `built-in → custom` order; explicit regression tests for built-in-not-shadowed and + custom-`gemini`-resolves. + +--- + +### Phase 2: Fail closed at spawn/launch boundaries (no orphaned state, no Tower crash) +**Dependencies**: Phase 1 + +#### Objectives +- Reject a retired builder harness **before** any state is created, and make a gemini **architect** + config fail cleanly at every `getArchitectHarness` call site — including the non-launch predicate. + +#### Deliverables +- [ ] **Builder spawn preflight** (`agent-farm/commands/spawn.ts`): before `ensureDirectories` / + `createWorktree(FromBranch)` / `initPorchInWorktree` (currently ~:423-442, i.e. *above* the + existing `getBuilderHarness` call at :471), resolve the builder harness name (override-aware, via + `getResolvedCommands` + `shell.builderHarness`, using `detectHarnessFromCommand` for the command + form) and, if `getRetirement(name)` is set, abort with the retirement message. Apply the same + preflight to the other spawn entry points that create worktrees (the sibling spawn functions + around :506/:583/:662/:764 that call `createWorktree`) — factor a single `assertBuilderHarnessNotRetired(config)` helper. +- [ ] **Architect call sites** — handle each of the four `getArchitectHarness` consumers explicitly + (a gemini architect now makes each throw): + - [ ] `buildArchitectArgs` (`servers/tower-utils.ts:179`) — the shared launch-injection helper used + by `launchInstance` (fresh), `add-architect`, shellper reconnect (×2), and no-Tower `afx + architect`. Add a preflight at the **architect-launch command boundary** so a gemini architect + launch fails with the retirement message (clean CLI/log error), not an opaque stack. + - [ ] `resolveArchitectLaunch` (`tower-utils.ts:357`) — launch-resolution; covered by the same + launch-boundary preflight (it is only reached during a launch). + - [ ] `freshLaunch` closure `next()` (`tower-utils.ts:509`) — lazily invoked during launch; same + coverage. + - [ ] `siblingRegistrationIsLive` (`tower-utils.ts:291`) — a **boolean liveness predicate**, NOT a + launch. Guard it: catch the retirement (or check `getRetirement` first) and return `false` + (a retired architect's registration is not live → the reconcile loop prunes it), so the throw + never escapes a Tower-side predicate. +- [ ] Tests: + - [ ] Spawn: a gemini builder spawn is rejected and creates **no** worktree/porch/db state (assert + the builders dir / porch project are absent afterward). + - [ ] Architect: a gemini architect launch fails with the retirement message; `siblingRegistrationIsLive` + with a gemini architect returns `false` and does not throw. + - [ ] Regression: a claude/codex architect resolves/launches unchanged (the guards are scoped to the + retirement error only — rethrow anything else). + +#### Implementation Details +- Reuse Phase 1's `getRetirement`/`isRetiredHarness` predicate everywhere so there is one source of + truth for "retired." Prefer failing at the narrowest boundary that still carries the verbatim + message to the user. +- Blast radius of the architect handling is narrow — only workspaces with a `gemini` architect, which + `doctor` already warns is unsupported — but the predicate guard prevents a reconcile-loop crash. +- The path for reset consumers is documented in Integration Points; **no reset code change** is needed + (accepted outcome). + +#### Acceptance Criteria +- [ ] Rejected gemini spawn leaves zero new worktree/porch/db state. +- [ ] All four `getArchitectHarness` sites handle a gemini config without an uncaught throw; + `siblingRegistrationIsLive` returns `false`. +- [ ] Supported-harness spawn/launch paths behave identically; build + suite green. + +#### Test Plan +- **Unit Tests**: `siblingRegistrationIsLive` returns false for a retired architect (no throw); + preflight helper detects retired builder configs (string/array/override). +- **Integration Tests**: gemini spawn creates no state; gemini architect launch fails cleanly; + claude/codex launch unchanged. +- **Manual Testing**: `afx spawn … --builder-cmd gemini` against a temp workspace → clean rejection, + no `.builders/` left behind. Run longer commands in the background per CLAUDE.md. + +#### Rollback Strategy +Single commit; `git revert` removes the preflight + guards. Resolver behavior (Phase 1) is unaffected. + +#### Risks +- **Risk**: A launch-boundary catch alters error semantics for other harnesses. **Mitigation**: scope + the catch to the retirement error; rethrow others; regression-test claude/codex. +- **Risk**: Missing a worktree-creating spawn entry point leaves an unguarded path. **Mitigation**: + centralize in one helper and call it from every `createWorktree` entry; grep for `createWorktree` + callers. + +--- + +### Phase 3: `codev doctor` — retirement guidance + builder-side flagging +**Dependencies**: Phase 1 (independent of Phase 2) + +#### Objectives +- Make `codev doctor` diagnose the retirement for both roles at config-check time — correcting the + now-inverted "supported for builders" message and adding builder-side detection. + +#### Deliverables +- [ ] `commands/doctor.ts` (816-828): redefine the `gemini` branch: + - [ ] Message presents the **retirement** (both roles), not "supported for builders." + - [ ] Additionally detect and flag a `gemini` **builder** configuration, mirroring the architect + branch's **persisted-config** detection — read raw `shell.builder` / `shell.builderHarness` + (array-or-string), via `detectHarnessFromCommand`. **Not** override-aware (the architect branch + reads raw `shell.architect`/`shell.architectHarness`, not CLI/env overrides — matching the + spec's persisted-config scope; do not claim otherwise). + - [ ] Update the structured `issue:` / `recommendation:` strings (stable assertion target). + - [ ] Factor the raw-config harness resolution into a shared local helper used by both the architect + and builder branches to avoid drift. +- [ ] Tests: assert the structured fields for a `gemini` **architect** config and a `gemini` + **builder** config; assert no output claims "supported for builders." + +#### Implementation Details +- Keep severity as a **warning** (consistent with the existing branch) unless review argues for error. +- Doctor never calls `resolveHarness`, so it does not throw on a gemini config — it detects and reports. + +#### Acceptance Criteria +- [ ] `codev doctor` on a gemini builder config emits a retirement `issue:`/`recommendation:`; on a + gemini architect config the message no longer claims builder support. +- [ ] Build + suite green. + +#### Test Plan +- **Unit Tests**: doctor output assertions on structured fields (both roles). +- **Manual Testing**: `codev doctor` against temp configs with `shell.builder: "gemini --yolo"` and, + separately, `shell.architect: "gemini --yolo"`. + +#### Rollback Strategy +Single commit; `git revert` restores the prior doctor branch. + +#### Risks +- **Risk**: Builder detection duplicates architect logic and drifts. **Mitigation**: shared local + helper for both branches. + +--- + +### Phase 4: User-facing docs — README + CHANGELOG +**Dependencies**: Phase 3 + +#### Objectives +- Stop presenting the standalone Gemini CLI as a supported shell in current user-facing docs, and + record the breaking change. + +#### Deliverables +- [ ] `README.md`: + - [ ] "Other shells (Codex, Gemini) are also supported…" (:392) — drop Gemini (or mark the built-in + Gemini CLI harness retired). + - [ ] Autonomous-flags table (:433-436) — remove/annotate the `Gemini CLI | --yolo` row as retired. + - [ ] Config example (:448-460) — replace the gemini block (**both** `"architect"` and `"builder"` + lines) with a supported harness, and rewrite the prose to a plain statement that the built-in + Gemini CLI harness is **retired** (consumer tiers ended 2026-06-18), with the custom-harness + pointer. Preserve the `agy` **consult lane** distinction already noted. +- [ ] `CHANGELOG.md` — add an entry under the maintained `## [Unreleased]` section (a "Removed" / + breaking-change note): the built-in `gemini` builder/architect harness is retired; how to + migrate (supported harnesses or a custom harness). This is a user-visible breaking change. +- [ ] Scoped documentation-consistency check: grep confirms no **current user-facing harness-selection** + doc still presents `gemini` as a supported builder shell. **Exempt**: historical artifacts + (`codev/specs`, `plans`, `reviews`, `projects`, `docs/releases/*`) and every `consult -m gemini` + / `agy` reference. Re-grep **both** `codev/` and `codev-skeleton/` (verified today no skeleton + doc presents gemini as a builder — re-confirm before done). + +#### Implementation Details +- Governance docs (`codev/resources/arch.md` :291/:311-317, `lessons-learned.md` :80) carry the stale + "Gemini is builder-only" framing; per SPIR + the spec they are updated in the **Review** phase via + the `update-arch-docs` skill (hot/cold routing) — tracked in Documentation Updates, not this phase. +- No `codev-skeleton/` twin exists for the README/governance edits (README is top-level; arch/lessons + are self-hosted), so no dual-tree mirror is required — the re-grep guards that. + +#### Acceptance Criteria +- [ ] README no longer presents Gemini as a supported shell (all three spots); consult-lane note intact. +- [ ] CHANGELOG `[Unreleased]` records the breaking change with a migration pointer. +- [ ] Scoped doc-consistency grep passes (historical + consult refs exempt). Docs-only; suite green. + +#### Test Plan +- **Integration Tests**: the scoped grep check. +- **Manual Testing**: read the rendered README + CHANGELOG entries for coherence. + +#### Rollback Strategy +Single commit; `git revert` restores prior README + CHANGELOG text. + +#### Risks +- **Risk**: The grep flags an out-of-scope historical/consult reference. **Mitigation**: the check is + explicitly scoped with exemptions; do not touch `consult -m gemini`, `agy`, or historical artifacts. + +--- + +## Dependency Map +``` +Phase 1 (core resolver + tests) + ├──→ Phase 2 (spawn/launch fail-closed) + └──→ Phase 3 (doctor) ──→ Phase 4 (README + CHANGELOG) +``` +Phases 2 and 3 both depend only on Phase 1; sequenced 1→2→3→4 for a linear commit history. Governance-doc +updates (arch.md / lessons-learned.md) happen in the Review phase, after Phase 4. + +## Resource Requirements +### Development Resources +- **Engineers**: one builder (this agent); familiarity with the agent-farm harness + Tower launch paths. +- **Environment**: local dev; `pnpm --filter @cluesmith/codev build` + `test`. + +### Infrastructure +- No database changes, new services, or config-schema changes (`builderHarness`/`architectHarness` stay + free-form `string`; only their *resolution* changes). No monitoring additions. + +## Integration Points +### External Systems +- **Standalone Gemini CLI** — builder/architect harness being retired — Phase 1. **Fallback**: supported + harnesses (claude/codex/opencode) or a user custom harness. + +### Internal Systems +- **`resolveHarness` / `BUILTIN_HARNESSES` / `detectHarnessFromCommand`** (`agent-farm/utils/harness.ts`) — Phase 1. +- **`getBuilderHarness` / `getArchitectHarness`** (`agent-farm/utils/config.ts:280/261`) — inherit + retirement via the shared resolver — Phases 1 (tests), 2 (surfacing). +- **Builder spawn** (`agent-farm/commands/spawn.ts`, harness resolved at :471 *after* worktree/porch + creation) and **`spawn-worktree.ts:912`** — Phase 2 preflight. +- **Architect launch/predicate** (`agent-farm/servers/tower-utils.ts`: `buildArchitectArgs`:179, + `siblingRegistrationIsLive`:291 [predicate], `resolveArchitectLaunch`:357, `freshLaunch`:509) — Phase 2. +- **`codev doctor`** (`commands/doctor.ts:816-828`) — Phase 3. +- **`afx reset`** (`agent-farm/commands/reset/context.ts`) — **no code change** (accepted outcome). TWO + direct `BUILTIN_HARNESSES` consumers exist and both degrade correctly after removal: + `harnessFromLaunchScript` (:414, name set) and `harnessProviderFor` (:468, direct index → returns + `null` → reset refuses "this harness cannot reset"). Named here so implementation does not rediscover + and improvise. (Note: full path is `agent-farm/commands/reset/context.ts`; the spec's shorter + `reset/context.ts` citation is the same file.) +- **OUT OF SCOPE (do not touch)**: `agy` consult lane, `consult -m gemini`, `agy` architect (#1063). + +## Risk Analysis +### Technical Risks +| Risk | Probability | Impact | Mitigation | Owner | +|------|------------|--------|------------|-------| +| A resolver exit left unguarded reopens a footgun | Medium | High | Tests assert all 3 exits fail closed; required acceptance criterion | builder | +| Rejected gemini spawn orphans worktree/porch state | Medium | High | Phase 2 preflight before any state mutation; test asserts no state created | builder | +| Architect throw crashes Tower (esp. predicate :291) | Medium | High | Per-site handling; guard the predicate to return false; regression test | builder | +| Reordering precedence shadows a built-in or breaks custom | Low | High | Keep built-in→custom order; regression tests both ways | builder | +| Scope creep into `agy`/`consult -m gemini` | Low | Medium | Non-goals pinned; doc check exempts consult refs | builder | +| A skeleton/framework doc missed | Low | Low | Re-grep both trees before done; verified no skeleton builder-doc today | builder | + +### Schedule Risks +| Risk | Probability | Impact | Mitigation | Owner | +|------|------------|--------|------------|-------| +| External-maintainer review latency (no self-merge) | Medium | Low | Keep phases small, self-contained, reviewable; architect coordinates merge | architect | + +## Validation Checkpoints +1. **After Phase 1**: resolver fails closed on all gemini paths; supported/custom harnesses correct; unit suite green. +2. **After Phase 2**: gemini spawn creates no state; all architect sites fail cleanly (no Tower crash). +3. **After Phase 3**: doctor diagnoses gemini for both roles via structured fields. +4. **Before PR**: full `build` + `test` green; scoped doc check passes; CHANGELOG updated; criteria mapped. + +## Monitoring and Observability +### Metrics to Track +- N/A — retirement of a code path plus diagnostics/doc edits; no runtime metrics. +### Logging Requirements +- The retirement message is the only new user-visible output; no new persistent logging. +### Alerting +- N/A. + +## Documentation Updates Required +- [ ] README.md — Phase 4. +- [ ] CHANGELOG.md `[Unreleased]` — Phase 4. +- [ ] `codev/resources/arch.md` (:291/:311-317) — **Review phase**, via `update-arch-docs` (retire the + "Gemini is builder-only" framing). +- [ ] `codev/resources/lessons-learned.md` (:80, #929 note) — **Review phase**, if accuracy changes. +- [ ] Confirm CLAUDE.md/AGENTS.md need no change (no gemini-builder guidance there today; re-grep). + +## Post-Implementation Tasks +- [ ] Full test suite + build green. +- [ ] Scoped documentation-consistency grep (both trees). +- [ ] Security/behavioral: fail-closed assertions present and passing. +- [ ] (Review) Governance-doc routing via `update-arch-docs`. +- [ ] N/A: load testing, perf validation (no runtime perf surface). + +## Expert Review +**Date**: 2026-08-03 (SPIR plan review, iteration 1) +**Model**: Gemini (via agy) — APPROVE; Codex (GPT-5.6 Sol) — REQUEST_CHANGES; Claude Opus 5 — COMMENT. +**Key Feedback**: +- Corrected `resolveHarness` precedence to `built-in → custom → retired → unknown` (Codex + Claude). +- Auto-detected `gemini` stays retired even with a custom `gemini`; escape hatch is explicit-only (Codex). +- Added a **spawn preflight** before state mutation, with a "no orphaned state" test (Codex). +- Doctor detection is **persisted-config**, not override-aware; corrected wording (Codex). +- Enumerated the four `getArchitectHarness` sites with per-site behavior; `:291` is a predicate, guard + it to return false (Claude). +- Named the second `BUILTIN_HARNESSES` consumer `harnessProviderFor` (:468) + path correction (Claude). +- Added a **CHANGELOG** deliverable for the breaking change (Claude). +- Noted Phase 1 is committable but not individually shippable (Claude). + +**Plan Adjustments**: restructured 3→4 phases (resolver / fail-closed spawn+launch / doctor / docs); +all points above incorporated. Full disposition in +`codev/projects/1338-*/1338-plan-iter1-rebuttals.md`. + +## Approval +- [ ] Technical Lead Review +- [ ] Engineering Manager Approval +- [ ] Resource Allocation Confirmed +- [x] Expert AI Consultation Complete (iteration 1; re-verification pending) + +## Change Log +| Date | Change | Reason | Author | +|------|--------|--------|--------| +| 2026-08-03 | Initial plan draft | Spec approved; Approach 1; role-agnostic retirement confirmed | builder | +| 2026-08-03 | Iter-1 review revisions | Precedence fix, spawn preflight, per-site architect handling, doctor scope, CHANGELOG; 3→4 phases | builder | + +## Notes +- **Phase 1 file count**: `harness.ts` + 4 test files move together because the behavioral change flips + every gemini-resolution path at once — the phase's atomicity is behavioral, not per-file. Deliberate, + not a phase to split. +- **Role-agnostic retirement** is intended and architect-approved: one sentinel in the shared resolver + covers architect + builder; no role parameter is threaded. Phase 2 handles the architect call-site + consequences (narrow blast radius — gemini architect is already doctor-warned). +- The retirement targets the **built-in `gemini` name**; a user's own explicit custom `gemini` harness + still resolves (custom precedence on the explicit path; auto-detect stays retired). diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-rebuttals.md new file mode 100644 index 000000000..50febad54 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-rebuttals.md @@ -0,0 +1,96 @@ +# Phase 2 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude APPROVE (HIGH)**. +Disposition: **both Codex points accepted and fixed in commit `11527838`.** No point rejected. +Codex found two *reachable* fail-closed gaps on the restart/reconnect paths that the initial +"unreachable" analysis (and my own phase_2 design note) missed — both are now fixed and +regression-tested. Claude's and Gemini's approvals stand; Claude's non-blocking observations are +dispositioned below. Each claim was re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) — both accepted, both fixed + +### C1 — Fail-OPEN on reconnect: retired architect can still relaunch the Gemini binary +**Accepted (important, real bug).** Verified: `resolveArchitectRestart` (tower-utils.ts:441) +propagates `RetiredHarnessError`, and BOTH reconnect consumers in `tower-terminals.ts` +(`_reconcileTerminalSessionsInner` at the startup-reconcile site, and `getTerminalsForWorkspace` +at the on-the-fly reconnect site) wrapped that call in a `try/catch` that caught **all** harness +errors and fell back to `{ command: cmdParts[0], args: cmdParts.slice(1) }`. For a gemini architect +`cmdParts[0]` **is** the retired `gemini` binary, so Tower would auto-restart straight into it with +no role injection — a direct violation of fail-closed retirement. My phase_2 design had reasoned +these paths "unreachable" because the *initial* launch throws first; Codex correctly showed they are +reachable when a config is edited to gemini **mid-session**, or when **Tower restarts** and reads a +gemini architect config (no initial launch happened in this process). + +**Change (commit `11527838`):** Extracted the two duplicated consumer blocks into one exported +helper, `buildArchitectReconnectRestartOptions` (tower-utils.ts:589), co-located with the +`resolveArchitectRestart` family. It **fails closed** on `RetiredHarnessError` (tower-utils.ts:630): +returns `undefined`, so the session reconnects to a live shellper if one exists but Tower **never +configures an auto-restart into the retired binary**. Any *other* harness-resolution error still +degrades to the plain configured command (identity preserved via `cleanEnv` / `CODEV_ARCHITECT_NAME`, +Spec 786) — that transient-failure behavior is deliberately unchanged. The single `includeFreshLaunch` +parameter preserves each call site's prior behavior exactly: startup reconcile passes `true` +(tower-terminals.ts:684, wires the #1264 clean-exit rerun), on-the-fly reconnect passes `false` +(tower-terminals.ts:920, no clean-exit rerun — as before). The #832 resume-bake and #1149 +crash-loop fallback are carried through unchanged. + +### C2 — Uncaught throw on clean-exit relaunch: Tower exception +**Accepted (important, real bug).** Verified: `buildArchitectFreshLaunch().next()` resolved +`getArchitectHarness` unguarded, and `SessionManager` invokes it with **no** try/catch +(`session-manager.ts:1175`: `session.options.freshLaunch?.next() ?? null`, inside an async +clean-exit handler). So an architect whose config flipped to gemini, then exited cleanly, would +throw `RetiredHarnessError` into that handler — an uncaught Tower exception. + +**Change (commit `11527838`):** Guarded the `getArchitectHarness` call inside `next()` +(tower-utils.ts:544). On `RetiredHarnessError` it logs a WARN and returns the **plain** original +launch `{ args: baseArgs, env: baseEnv }` — no throw, and no re-injection of the retired harness +(`baseArgs` come from the *original supported-harness* launch, so they can never be a gemini command). +Any non-retirement error is a genuine fault and is rethrown unchanged. + +### C3 — "Regression tests for both reconnect and clean-exit relaunch paths" +**Accepted.** Added **+7** tests to `tower-utils.test.ts`: +- `buildArchitectFreshLaunch retirement (#1338)` (2): gemini → `next()` does **not** throw and + returns the plain launch; codex → resolves normally (regression). +- `buildArchitectReconnectRestartOptions retirement (#1338)` (5): gemini → `undefined` regardless of + `includeFreshLaunch`; codex → real restart options with `command`/env preserved; `includeFreshLaunch` + toggles `freshLaunch`; a non-retirement harness error → plain-command fallback (proves the + fail-closed change did **not** regress the transient-error path). + +--- + +## Claude (APPROVE, HIGH) — non-blocking observations dispositioned + +Claude approved and asked that two items be recorded in the review artifact; both are handled here +and will be carried into the final `codev/reviews/1338-*.md`: + +- **(a) Preflight centralized at the `spawn()` dispatcher** rather than per-`createWorktree`-entry. + **Intentional deviation from the plan's letter** — one guard above handler dispatch + (`spawn.ts`, `mode !== 'shell'`) gives equivalent coverage of all five worktree-creating handlers + **and** `createWorktree`'s own internal `getBuilderHarness` (spawn-worktree.ts:912), with one call + site instead of five. Recorded; will appear in the review's Deviations section. +- **(b) `session-manager.ts:1175` `freshLaunch?.next()` is unguarded by design / unreachable.** + This is precisely the path **Codex's C2 proved reachable** (config flipped to gemini before a clean + exit). So rather than accept it as an unreachable non-issue, the C2 fix now **guards it at the + source** (`buildArchitectFreshLaunch().next()`). Claude's non-blocking note and Codex's HIGH finding + converge on the same site; it is now actively fail-closed, not merely assumed unreachable. + +Claude's other minor notes are acknowledged, no change: **#2** (harness resolved twice on the happy +spawn path — negligible config read) and **#3** (`afx setup` / `installHarnessWorktreeFiles` still call +`getBuilderHarness` raw — out of Phase 2 scope; the CLI catch prints the retirement `.message` cleanly, +so a pre-existing gemini worktree degrades to a readable message rather than a crash). + +--- + +## Gemini (APPROVE) +No changes requested. Gemini independently confirmed the builder preflight, the `buildArchitectArgs` +fail-closed launch boundary, the `siblingRegistrationIsLive` guard, and the `tower-instances` +`addArchitect`/`launchInstance` clean-error surfacing. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `tower-utils.test.ts` → 61 passed (includes the +7 above). +- Consumer suites `tower-terminals.test.ts` + `tower-instances.test.ts` + `bugfix-430-tower-restart.test.ts` + → 133 passed (the reconnect/restart refactor introduces no regression). diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-context.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-context.md new file mode 100644 index 000000000..394894821 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-context.md @@ -0,0 +1,111 @@ +### Iteration 1 Reviews +- gemini: APPROVE +- codex: REQUEST_CHANGES — Builder preflight is correct, but Tower restart paths can still launch Gemini or crash. +- claude: APPROVE — Phase 2 fails closed at every spawn and architect-launch boundary with no orphaned state and no Tower-crash path; tests and typecheck are green. + +### Builder Response to Iteration 1 +# Phase 2 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude APPROVE (HIGH)**. +Disposition: **both Codex points accepted and fixed in commit `11527838`.** No point rejected. +Codex found two *reachable* fail-closed gaps on the restart/reconnect paths that the initial +"unreachable" analysis (and my own phase_2 design note) missed — both are now fixed and +regression-tested. Claude's and Gemini's approvals stand; Claude's non-blocking observations are +dispositioned below. Each claim was re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) — both accepted, both fixed + +### C1 — Fail-OPEN on reconnect: retired architect can still relaunch the Gemini binary +**Accepted (important, real bug).** Verified: `resolveArchitectRestart` (tower-utils.ts:441) +propagates `RetiredHarnessError`, and BOTH reconnect consumers in `tower-terminals.ts` +(`_reconcileTerminalSessionsInner` at the startup-reconcile site, and `getTerminalsForWorkspace` +at the on-the-fly reconnect site) wrapped that call in a `try/catch` that caught **all** harness +errors and fell back to `{ command: cmdParts[0], args: cmdParts.slice(1) }`. For a gemini architect +`cmdParts[0]` **is** the retired `gemini` binary, so Tower would auto-restart straight into it with +no role injection — a direct violation of fail-closed retirement. My phase_2 design had reasoned +these paths "unreachable" because the *initial* launch throws first; Codex correctly showed they are +reachable when a config is edited to gemini **mid-session**, or when **Tower restarts** and reads a +gemini architect config (no initial launch happened in this process). + +**Change (commit `11527838`):** Extracted the two duplicated consumer blocks into one exported +helper, `buildArchitectReconnectRestartOptions` (tower-utils.ts:589), co-located with the +`resolveArchitectRestart` family. It **fails closed** on `RetiredHarnessError` (tower-utils.ts:630): +returns `undefined`, so the session reconnects to a live shellper if one exists but Tower **never +configures an auto-restart into the retired binary**. Any *other* harness-resolution error still +degrades to the plain configured command (identity preserved via `cleanEnv` / `CODEV_ARCHITECT_NAME`, +Spec 786) — that transient-failure behavior is deliberately unchanged. The single `includeFreshLaunch` +parameter preserves each call site's prior behavior exactly: startup reconcile passes `true` +(tower-terminals.ts:684, wires the #1264 clean-exit rerun), on-the-fly reconnect passes `false` +(tower-terminals.ts:920, no clean-exit rerun — as before). The #832 resume-bake and #1149 +crash-loop fallback are carried through unchanged. + +### C2 — Uncaught throw on clean-exit relaunch: Tower exception +**Accepted (important, real bug).** Verified: `buildArchitectFreshLaunch().next()` resolved +`getArchitectHarness` unguarded, and `SessionManager` invokes it with **no** try/catch +(`session-manager.ts:1175`: `session.options.freshLaunch?.next() ?? null`, inside an async +clean-exit handler). So an architect whose config flipped to gemini, then exited cleanly, would +throw `RetiredHarnessError` into that handler — an uncaught Tower exception. + +**Change (commit `11527838`):** Guarded the `getArchitectHarness` call inside `next()` +(tower-utils.ts:544). On `RetiredHarnessError` it logs a WARN and returns the **plain** original +launch `{ args: baseArgs, env: baseEnv }` — no throw, and no re-injection of the retired harness +(`baseArgs` come from the *original supported-harness* launch, so they can never be a gemini command). +Any non-retirement error is a genuine fault and is rethrown unchanged. + +### C3 — "Regression tests for both reconnect and clean-exit relaunch paths" +**Accepted.** Added **+7** tests to `tower-utils.test.ts`: +- `buildArchitectFreshLaunch retirement (#1338)` (2): gemini → `next()` does **not** throw and + returns the plain launch; codex → resolves normally (regression). +- `buildArchitectReconnectRestartOptions retirement (#1338)` (5): gemini → `undefined` regardless of + `includeFreshLaunch`; codex → real restart options with `command`/env preserved; `includeFreshLaunch` + toggles `freshLaunch`; a non-retirement harness error → plain-command fallback (proves the + fail-closed change did **not** regress the transient-error path). + +--- + +## Claude (APPROVE, HIGH) — non-blocking observations dispositioned + +Claude approved and asked that two items be recorded in the review artifact; both are handled here +and will be carried into the final `codev/reviews/1338-*.md`: + +- **(a) Preflight centralized at the `spawn()` dispatcher** rather than per-`createWorktree`-entry. + **Intentional deviation from the plan's letter** — one guard above handler dispatch + (`spawn.ts`, `mode !== 'shell'`) gives equivalent coverage of all five worktree-creating handlers + **and** `createWorktree`'s own internal `getBuilderHarness` (spawn-worktree.ts:912), with one call + site instead of five. Recorded; will appear in the review's Deviations section. +- **(b) `session-manager.ts:1175` `freshLaunch?.next()` is unguarded by design / unreachable.** + This is precisely the path **Codex's C2 proved reachable** (config flipped to gemini before a clean + exit). So rather than accept it as an unreachable non-issue, the C2 fix now **guards it at the + source** (`buildArchitectFreshLaunch().next()`). Claude's non-blocking note and Codex's HIGH finding + converge on the same site; it is now actively fail-closed, not merely assumed unreachable. + +Claude's other minor notes are acknowledged, no change: **#2** (harness resolved twice on the happy +spawn path — negligible config read) and **#3** (`afx setup` / `installHarnessWorktreeFiles` still call +`getBuilderHarness` raw — out of Phase 2 scope; the CLI catch prints the retirement `.message` cleanly, +so a pre-existing gemini worktree degrades to a readable message rather than a crash). + +--- + +## Gemini (APPROVE) +No changes requested. Gemini independently confirmed the builder preflight, the `buildArchitectArgs` +fail-closed launch boundary, the `siblingRegistrationIsLive` guard, and the `tower-instances` +`addArchitect`/`launchInstance` clean-error surfacing. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `tower-utils.test.ts` → 61 passed (includes the +7 above). +- Consumer suites `tower-terminals.test.ts` + `tower-instances.test.ts` + `bugfix-430-tower-restart.test.ts` + → 133 passed (the reconnect/restart refactor introduces no regression). + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-rebuttals.md new file mode 100644 index 000000000..d59b2ab87 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-rebuttals.md @@ -0,0 +1,86 @@ +# Phase 2 (1338) — Rebuttals, iteration 2 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude APPROVE (HIGH)**. +Disposition: **Codex's point accepted and fixed in commit `9ec14c4d`.** No point rejected. Codex +found that the iter1 C2 fix stopped the *throw* but not the *relaunch* on the clean-exit path — a +genuine, deeper fail-open that both approving reviewers missed. Fixed by extending the `FreshLaunch` +contract with a fail-closed `stop` signal. Claude's non-blocking test-isolation nit is also addressed. +Each claim was re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) — accepted, fixed + +### C3 — Clean-exit path still relaunches the retained retired `command` +**Accepted (important, real fail-open).** Verified against source. My iter1 C2 fix had +`buildArchitectFreshLaunch().next()` return `{ args: baseArgs, env: baseEnv }` on retirement. That +prevents the uncaught throw, but **`FreshLaunch.next()` can only influence `args`/`env` — never the +launch `command`.** SessionManager's clean-exit handler retains the original command and respawns it: + +``` +// session-manager.ts (pre-fix) +const fresh = session.options.freshLaunch?.next() ?? null; +if (fresh) { session.options.args = fresh.args; if (fresh.env) session.options.env = fresh.env; } +... +session.client.spawn({ command: session.options.command, // ← RETAINED, not from next() + args: session.options.args, ... }); +``` + +So if the retained `command` is itself the retired binary — reachable when an architect launched via +a **custom `gemini` harness** (the explicit escape hatch) whose definition is later removed, or a +config edit flips the harness to the retired built-in before a clean exit — returning `baseArgs` +still respawns `gemini`. Codex is right; Claude's iter2 "safe because freshLaunch is only wired when +the harness resolved cleanly" reasoning missed that a *custom gemini* resolves cleanly at wire time, +so the retained command can legitimately be `gemini`. + +**Change (commit `9ec14c4d`):** gave `FreshLaunch` a fail-closed **stop** signal — the only way for +the factory to prevent a respawn it cannot re-command. +- `FreshLaunch.next()` return type is now + `{ args: string[]; env?: Record } | { stop: true } | null` (session-manager.ts:73), + documented in the interface's doc comment. +- SessionManager's clean-exit handler honors it (session-manager.ts:1184): on `{ stop: true }` it does + **not** respawn — it ends the session (`removeDeadSession`) and surfaces the reason in the pane via + `session-gave-up` → `PtySession.notice` (tower-server.ts:479), the same visible-teardown UX as the + existing fast-clean-exit valve. It returns before the rerun/`session-fresh-restart` path. +- `buildArchitectFreshLaunch().next()` returns `{ stop: true }` on `RetiredHarnessError` + (tower-utils.ts:551) instead of `{ args, env }`. Non-retirement errors are still rethrown. + +**End-to-end regression test (exactly what Codex asked for):** `session-manager.test.ts:2493` drives +the **real** clean-exit handler with a session whose retained `command: "gemini"` and a `{stop:true}` +freshLaunch → asserts `client.spawn` is **never** called, the session is removed, and a `retired` +reason (containing `gemini`) is surfaced. The iter1 test only checked returned args; this exercises the +retained command, which is where the bug lived. + +**Blast radius:** `FreshLaunch` is architect-only — the sole implementer is `buildArchitectFreshLaunch` +and the sole `.next()` consumer is `session-manager.ts:1184`. Other freshLaunch return paths (no +recovery / resumable-session) are unchanged; every non-architect session (plain shells, builders) never +constructs a `FreshLaunch`, so the added branch cannot affect them. + +--- + +## Claude (APPROVE, HIGH) — non-blocking nit addressed + +Claude approved. Its one non-blocking observation: the three `#1338` retirement describes in +`tower-utils.test.ts` didn't isolate `HOME` or clear `TOWER_ARCHITECT_CMD`, unlike +`spawn-retirement.test.ts` / `config.test.ts`. Verified the precedence: `getArchitectHarness` → +`getResolvedCommands` gives `TOWER_ARCHITECT_CMD` precedence over workspace config (config.ts:241), +and `loadUserConfig` can read a global `~/.codev/config.json` (HOME). A dev with either set locally +would see a loud, misleading failure. +**Change (`9ec14c4d`):** added a shared `isolateHarnessEnv()` helper (sets an isolated `HOME`, clears +`TOWER_ARCHITECT_CMD`/`TOWER_BUILDER_CMD`, restores in teardown) and wired it into all three describes. + +--- + +## Gemini (APPROVE) +No changes requested. Gemini re-confirmed the spawn preflight, the `buildArchitectArgs` / +`launchInstance` / `addArchitect` launch-boundary handling, the `siblingRegistrationIsLive` guard, the +C1 reconnect fail-closed, and the C2 clean-exit guard. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `tower-utils.test.ts` + `session-manager.test.ts` → 152 passed (incl. the new stop assertion, the + env isolation, and the `command: "gemini"` e2e regression). +- Consumer suites `tower-terminals.test.ts` + `tower-instances.test.ts` + `bugfix-430-tower-restart.test.ts` + → 133 passed (no regression from the `FreshLaunch` contract addition). diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter3-context.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter3-context.md new file mode 100644 index 000000000..19099e85d --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter3-context.md @@ -0,0 +1,205 @@ +### Iteration 1 Reviews +- gemini: APPROVE +- codex: REQUEST_CHANGES — Builder preflight is correct, but Tower restart paths can still launch Gemini or crash. +- claude: APPROVE — Phase 2 fails closed at every spawn and architect-launch boundary with no orphaned state and no Tower-crash path; tests and typecheck are green. + +### Builder Response to Iteration 1 +# Phase 2 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude APPROVE (HIGH)**. +Disposition: **both Codex points accepted and fixed in commit `11527838`.** No point rejected. +Codex found two *reachable* fail-closed gaps on the restart/reconnect paths that the initial +"unreachable" analysis (and my own phase_2 design note) missed — both are now fixed and +regression-tested. Claude's and Gemini's approvals stand; Claude's non-blocking observations are +dispositioned below. Each claim was re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) — both accepted, both fixed + +### C1 — Fail-OPEN on reconnect: retired architect can still relaunch the Gemini binary +**Accepted (important, real bug).** Verified: `resolveArchitectRestart` (tower-utils.ts:441) +propagates `RetiredHarnessError`, and BOTH reconnect consumers in `tower-terminals.ts` +(`_reconcileTerminalSessionsInner` at the startup-reconcile site, and `getTerminalsForWorkspace` +at the on-the-fly reconnect site) wrapped that call in a `try/catch` that caught **all** harness +errors and fell back to `{ command: cmdParts[0], args: cmdParts.slice(1) }`. For a gemini architect +`cmdParts[0]` **is** the retired `gemini` binary, so Tower would auto-restart straight into it with +no role injection — a direct violation of fail-closed retirement. My phase_2 design had reasoned +these paths "unreachable" because the *initial* launch throws first; Codex correctly showed they are +reachable when a config is edited to gemini **mid-session**, or when **Tower restarts** and reads a +gemini architect config (no initial launch happened in this process). + +**Change (commit `11527838`):** Extracted the two duplicated consumer blocks into one exported +helper, `buildArchitectReconnectRestartOptions` (tower-utils.ts:589), co-located with the +`resolveArchitectRestart` family. It **fails closed** on `RetiredHarnessError` (tower-utils.ts:630): +returns `undefined`, so the session reconnects to a live shellper if one exists but Tower **never +configures an auto-restart into the retired binary**. Any *other* harness-resolution error still +degrades to the plain configured command (identity preserved via `cleanEnv` / `CODEV_ARCHITECT_NAME`, +Spec 786) — that transient-failure behavior is deliberately unchanged. The single `includeFreshLaunch` +parameter preserves each call site's prior behavior exactly: startup reconcile passes `true` +(tower-terminals.ts:684, wires the #1264 clean-exit rerun), on-the-fly reconnect passes `false` +(tower-terminals.ts:920, no clean-exit rerun — as before). The #832 resume-bake and #1149 +crash-loop fallback are carried through unchanged. + +### C2 — Uncaught throw on clean-exit relaunch: Tower exception +**Accepted (important, real bug).** Verified: `buildArchitectFreshLaunch().next()` resolved +`getArchitectHarness` unguarded, and `SessionManager` invokes it with **no** try/catch +(`session-manager.ts:1175`: `session.options.freshLaunch?.next() ?? null`, inside an async +clean-exit handler). So an architect whose config flipped to gemini, then exited cleanly, would +throw `RetiredHarnessError` into that handler — an uncaught Tower exception. + +**Change (commit `11527838`):** Guarded the `getArchitectHarness` call inside `next()` +(tower-utils.ts:544). On `RetiredHarnessError` it logs a WARN and returns the **plain** original +launch `{ args: baseArgs, env: baseEnv }` — no throw, and no re-injection of the retired harness +(`baseArgs` come from the *original supported-harness* launch, so they can never be a gemini command). +Any non-retirement error is a genuine fault and is rethrown unchanged. + +### C3 — "Regression tests for both reconnect and clean-exit relaunch paths" +**Accepted.** Added **+7** tests to `tower-utils.test.ts`: +- `buildArchitectFreshLaunch retirement (#1338)` (2): gemini → `next()` does **not** throw and + returns the plain launch; codex → resolves normally (regression). +- `buildArchitectReconnectRestartOptions retirement (#1338)` (5): gemini → `undefined` regardless of + `includeFreshLaunch`; codex → real restart options with `command`/env preserved; `includeFreshLaunch` + toggles `freshLaunch`; a non-retirement harness error → plain-command fallback (proves the + fail-closed change did **not** regress the transient-error path). + +--- + +## Claude (APPROVE, HIGH) — non-blocking observations dispositioned + +Claude approved and asked that two items be recorded in the review artifact; both are handled here +and will be carried into the final `codev/reviews/1338-*.md`: + +- **(a) Preflight centralized at the `spawn()` dispatcher** rather than per-`createWorktree`-entry. + **Intentional deviation from the plan's letter** — one guard above handler dispatch + (`spawn.ts`, `mode !== 'shell'`) gives equivalent coverage of all five worktree-creating handlers + **and** `createWorktree`'s own internal `getBuilderHarness` (spawn-worktree.ts:912), with one call + site instead of five. Recorded; will appear in the review's Deviations section. +- **(b) `session-manager.ts:1175` `freshLaunch?.next()` is unguarded by design / unreachable.** + This is precisely the path **Codex's C2 proved reachable** (config flipped to gemini before a clean + exit). So rather than accept it as an unreachable non-issue, the C2 fix now **guards it at the + source** (`buildArchitectFreshLaunch().next()`). Claude's non-blocking note and Codex's HIGH finding + converge on the same site; it is now actively fail-closed, not merely assumed unreachable. + +Claude's other minor notes are acknowledged, no change: **#2** (harness resolved twice on the happy +spawn path — negligible config read) and **#3** (`afx setup` / `installHarnessWorktreeFiles` still call +`getBuilderHarness` raw — out of Phase 2 scope; the CLI catch prints the retirement `.message` cleanly, +so a pre-existing gemini worktree degrades to a readable message rather than a crash). + +--- + +## Gemini (APPROVE) +No changes requested. Gemini independently confirmed the builder preflight, the `buildArchitectArgs` +fail-closed launch boundary, the `siblingRegistrationIsLive` guard, and the `tower-instances` +`addArchitect`/`launchInstance` clean-error surfacing. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `tower-utils.test.ts` → 61 passed (includes the +7 above). +- Consumer suites `tower-terminals.test.ts` + `tower-instances.test.ts` + `bugfix-430-tower-restart.test.ts` + → 133 passed (the reconnect/restart refactor introduces no regression). + + +### Iteration 2 Reviews +- gemini: APPROVE +- codex: REQUEST_CHANGES — Reconnect handling is fixed, but the clean-exit path can still relaunch the retired Gemini binary. +- claude: APPROVE — Both Codex iteration-1 fail-closed gaps are genuinely fixed at the correct layer, verified against source; all Phase 2 launch/spawn boundaries fail closed with no orphaned state and no Tower-crash path; typecheck and the full 4136-test suite are green. + +### Builder Response to Iteration 2 +# Phase 2 (1338) — Rebuttals, iteration 2 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude APPROVE (HIGH)**. +Disposition: **Codex's point accepted and fixed in commit `9ec14c4d`.** No point rejected. Codex +found that the iter1 C2 fix stopped the *throw* but not the *relaunch* on the clean-exit path — a +genuine, deeper fail-open that both approving reviewers missed. Fixed by extending the `FreshLaunch` +contract with a fail-closed `stop` signal. Claude's non-blocking test-isolation nit is also addressed. +Each claim was re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) — accepted, fixed + +### C3 — Clean-exit path still relaunches the retained retired `command` +**Accepted (important, real fail-open).** Verified against source. My iter1 C2 fix had +`buildArchitectFreshLaunch().next()` return `{ args: baseArgs, env: baseEnv }` on retirement. That +prevents the uncaught throw, but **`FreshLaunch.next()` can only influence `args`/`env` — never the +launch `command`.** SessionManager's clean-exit handler retains the original command and respawns it: + +``` +// session-manager.ts (pre-fix) +const fresh = session.options.freshLaunch?.next() ?? null; +if (fresh) { session.options.args = fresh.args; if (fresh.env) session.options.env = fresh.env; } +... +session.client.spawn({ command: session.options.command, // ← RETAINED, not from next() + args: session.options.args, ... }); +``` + +So if the retained `command` is itself the retired binary — reachable when an architect launched via +a **custom `gemini` harness** (the explicit escape hatch) whose definition is later removed, or a +config edit flips the harness to the retired built-in before a clean exit — returning `baseArgs` +still respawns `gemini`. Codex is right; Claude's iter2 "safe because freshLaunch is only wired when +the harness resolved cleanly" reasoning missed that a *custom gemini* resolves cleanly at wire time, +so the retained command can legitimately be `gemini`. + +**Change (commit `9ec14c4d`):** gave `FreshLaunch` a fail-closed **stop** signal — the only way for +the factory to prevent a respawn it cannot re-command. +- `FreshLaunch.next()` return type is now + `{ args: string[]; env?: Record } | { stop: true } | null` (session-manager.ts:73), + documented in the interface's doc comment. +- SessionManager's clean-exit handler honors it (session-manager.ts:1184): on `{ stop: true }` it does + **not** respawn — it ends the session (`removeDeadSession`) and surfaces the reason in the pane via + `session-gave-up` → `PtySession.notice` (tower-server.ts:479), the same visible-teardown UX as the + existing fast-clean-exit valve. It returns before the rerun/`session-fresh-restart` path. +- `buildArchitectFreshLaunch().next()` returns `{ stop: true }` on `RetiredHarnessError` + (tower-utils.ts:551) instead of `{ args, env }`. Non-retirement errors are still rethrown. + +**End-to-end regression test (exactly what Codex asked for):** `session-manager.test.ts:2493` drives +the **real** clean-exit handler with a session whose retained `command: "gemini"` and a `{stop:true}` +freshLaunch → asserts `client.spawn` is **never** called, the session is removed, and a `retired` +reason (containing `gemini`) is surfaced. The iter1 test only checked returned args; this exercises the +retained command, which is where the bug lived. + +**Blast radius:** `FreshLaunch` is architect-only — the sole implementer is `buildArchitectFreshLaunch` +and the sole `.next()` consumer is `session-manager.ts:1184`. Other freshLaunch return paths (no +recovery / resumable-session) are unchanged; every non-architect session (plain shells, builders) never +constructs a `FreshLaunch`, so the added branch cannot affect them. + +--- + +## Claude (APPROVE, HIGH) — non-blocking nit addressed + +Claude approved. Its one non-blocking observation: the three `#1338` retirement describes in +`tower-utils.test.ts` didn't isolate `HOME` or clear `TOWER_ARCHITECT_CMD`, unlike +`spawn-retirement.test.ts` / `config.test.ts`. Verified the precedence: `getArchitectHarness` → +`getResolvedCommands` gives `TOWER_ARCHITECT_CMD` precedence over workspace config (config.ts:241), +and `loadUserConfig` can read a global `~/.codev/config.json` (HOME). A dev with either set locally +would see a loud, misleading failure. +**Change (`9ec14c4d`):** added a shared `isolateHarnessEnv()` helper (sets an isolated `HOME`, clears +`TOWER_ARCHITECT_CMD`/`TOWER_BUILDER_CMD`, restores in teardown) and wired it into all three describes. + +--- + +## Gemini (APPROVE) +No changes requested. Gemini re-confirmed the spawn preflight, the `buildArchitectArgs` / +`launchInstance` / `addArchitect` launch-boundary handling, the `siblingRegistrationIsLive` guard, the +C1 reconnect fail-closed, and the C2 clean-exit guard. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `tower-utils.test.ts` + `session-manager.test.ts` → 152 passed (incl. the new stop assertion, the + env isolation, and the `command: "gemini"` e2e regression). +- Consumer suites `tower-terminals.test.ts` + `tower-instances.test.ts` + `bugfix-430-tower-restart.test.ts` + → 133 passed (no regression from the `FreshLaunch` contract addition). + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-rebuttals.md new file mode 100644 index 000000000..1ae271377 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-rebuttals.md @@ -0,0 +1,107 @@ +# Phase 3 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Both reviewers converged on one real correctness bug — doctor +false-flagged the spec's sanctioned custom-`gemini` **escape hatch** — plus Claude found the new +negative test was **vacuous** (missing `chalk.gray` mock). Both are fixed and re-verified against +source; the minor recommendation-wording and array-form nits are addressed too. No point rejected. +Gemini's approval stands. Each claim was re-verified against the actual files before revising. + +Fix commit: `` (production + tests). Files: `packages/codev/src/commands/doctor.ts`, +`packages/codev/src/__tests__/doctor.test.ts`. + +--- + +## Codex (REQUEST_CHANGES, HIGH) — both points accepted + +### C1 — Doctor false-flags the custom-`gemini` escape hatch (correctness) +**Accepted (real bug).** Verified: the previous `resolvedShellHarness(role)` returned only a harness +*name* and the caller ran `getRetirement(name)` on it unconditionally — so an explicit +`builderHarness: "gemini"` / `architectHarness: "gemini"` was reported as retired **even when a valid +custom `harness.gemini` definition exists**. That contradicts the resolver precedence +(`resolveHarness`, harness.ts: explicit → built-in → **custom** → retired) and the spec's sanctioned +escape hatch — the retirement message *itself* tells users to "configure a custom harness in +`.codev/config.json`", yet following that advice would leave doctor's warning stuck on. + +**Change.** `resolveShell(role)` now returns `{ name, retirement }` and encodes the resolver's own +precedence, so doctor can never drift from spawn behavior: +- **Explicit `shell.Harness`**: suppress retirement iff `config.harness` has an own-property of + that name (`Object.prototype.hasOwnProperty.call(customHarnesses, explicit)` — prototype-safe, same + guard style as `isRetiredHarness`). A custom `gemini` def ⇒ not flagged (escape hatch honored). +- **Auto-detected `shell.` command**: retirement **always** applies for a detected retired name + — auto-detection resolves the built-in namespace only and never consults custom harnesses, exactly + as `resolveHarness` does (a bare `gemini …` command throws retired even when a custom `gemini` + exists). So `builder: "gemini --yolo"` stays flagged. + +This is the precise built-in-vs-custom, explicit-vs-detected distinction Codex asked for, and it keeps +doctor a faithful mirror of the resolver rather than a second, divergent policy. + +### C2 — Add builder + architect tests for explicit custom `gemini` +**Accepted. Added** to `doctor.test.ts` (`shell-harness retirement flagging (#1338)`): +- `does NOT flag an explicit custom gemini BUILDER harness (escape hatch)` — `builderHarness: "gemini"` + + `harness: { gemini: {...} }` ⇒ no `builder shell (harness retired)`, retirement text absent. +- `does NOT flag an explicit custom gemini ARCHITECT harness (escape hatch)` — the architect twin. +- `STILL flags an auto-detected gemini command even when a custom gemini harness exists` — the crucial + distinction: `builder: "gemini --yolo"` + `harness: { gemini: {...} }` ⇒ **still** flagged (proves + doctor matches the resolver's auto-detect-is-always-retired rule, so it can't green-light a config + that fails closed at spawn). Fixture custom defs are the minimal valid shape (`roleArgs`, + `roleScriptFragment`) so `loadConfig`'s `validateCustomHarnessConfig` accepts them. + +--- + +## Claude (REQUEST_CHANGES, HIGH) — all points accepted + +### 1 — Escape-hatch false positive +Same finding as Codex C1 (Claude independently traced it through `getBuilderHarness` → +`resolveHarness` and the `harness.test.ts` escape-hatch case). Fixed as above. + +### 2 — The new negative test was vacuous (missing `chalk.gray` mock) +**Accepted (real test-quality bug).** Verified: the chalk mock defined `bold/green/yellow/red/blue/dim` +but not **`gray`**. The codex-architect "supported" branch calls `chalk.gray` (doctor.ts, two lines +after the `✓ supported` line); with `gray` undefined that throws `TypeError` into the shell-section +`catch {}`, aborting **before** the builder branch runs. So `does NOT flag a supported-harness config +(claude builder + codex architect)` asserted nothing about the path it guards — a future regression +that false-flagged a `claude` builder would have gone uncaught. + +**Change.** Added `gray: createChainableColor()` to the mock, and **strengthened the guard beyond +Claude's suggestion.** Asserting the `✓ supported` line alone is insufficient — that line prints +*before* the `chalk.gray` call, so it passes with or without the mock. Instead the test asserts a line +printed **after** both `chalk.gray` calls (`Select the architect harness via .codev/config.json …`): +its presence proves the section ran to completion and the builder branch was actually reached, making +the two "not flagged" assertions non-vacuous. **Empirically confirmed**: with `gray` removed the test +fails at exactly that post-gray assertion (`expected false to be true`); restored, all green. + +### 3 — Recommendation misdirects on the explicit-harness path +**Accepted.** For `builderHarness: "gemini"` (test 3's own fixture) the old advice "Set shell.builder +to a supported harness…" is ineffective — an explicit `shell.builderHarness` beats the `shell.builder` +command, so changing only the command wouldn't clear it. Both role recommendations (and the inline +console guidance) now name **both** selectors: "Set `shell.builder` / `shell.builderHarness` …" and +"Set `shell.architect` / `shell.architectHarness` …". The two existing assertions were updated to the +new wording. + +### 4 — Nits +- **(a) No array-form test** — **added** `flags an array-form gemini builder command (parity with the + resolver)`: `builder: ["gemini", "--yolo"]` ⇒ flagged (exercises the array-join branch of + `resolveShell`, matching the resolver's array handling). +- **(b) Duplicate full retirement paragraph when both roles are gemini** — **acknowledged, no change.** + Each role's warning is independently actionable and self-contained; deduping the second paragraph + would couple the two branches for a purely cosmetic gain on a rare misconfiguration. Recorded for the + Review doc as a deliberate, low-value trade-off. + +--- + +## Gemini (APPROVE) +No changes requested. Gemini confirmed the Phase 3 deliverables (redefined architect branch, new +builder-side flagging, structured issue/recommendation fields) align with the plan. The escape-hatch +correctness gap it did not surface is now closed per Codex/Claude. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `doctor.test.ts` → **25 passed** (21 prior + 4 new #1338 cases; the 4 existing #1338 cases updated + for the escape hatch and both-selector recommendation wording). +- Non-vacuity of the supported-config guard proved by controlled removal of the `gray` mock (test fails + on the post-gray assertion, then passes on restore). +- Full unit suite (excl. e2e) re-run green — the change is isolated to `doctor.ts` (no non-test + importer) and its test file, so no other suite is affected. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter2-context.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter2-context.md new file mode 100644 index 000000000..5a3c1af0b --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter2-context.md @@ -0,0 +1,122 @@ +### Iteration 1 Reviews +- gemini: APPROVE — The implementation of codev doctor's retirement guidance and builder-side flagging aligns perfectly with the phase 3 plan. +- codex: REQUEST_CHANGES — Doctor correctly flags built-in Gemini selections, but incorrectly warns when an explicit custom `gemini` harness—the documented escape hatch—is configured. +- claude: REQUEST_CHANGES — Phase 3 meets every planned deliverable and is green, but doctor false-flags the spec's sanctioned custom-`gemini` escape hatch, and the new negative test passes vacuously due to a missing `chalk.gray` mock. + +### Builder Response to Iteration 1 +# Phase 3 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Both reviewers converged on one real correctness bug — doctor +false-flagged the spec's sanctioned custom-`gemini` **escape hatch** — plus Claude found the new +negative test was **vacuous** (missing `chalk.gray` mock). Both are fixed and re-verified against +source; the minor recommendation-wording and array-form nits are addressed too. No point rejected. +Gemini's approval stands. Each claim was re-verified against the actual files before revising. + +Fix commit: `` (production + tests). Files: `packages/codev/src/commands/doctor.ts`, +`packages/codev/src/__tests__/doctor.test.ts`. + +--- + +## Codex (REQUEST_CHANGES, HIGH) — both points accepted + +### C1 — Doctor false-flags the custom-`gemini` escape hatch (correctness) +**Accepted (real bug).** Verified: the previous `resolvedShellHarness(role)` returned only a harness +*name* and the caller ran `getRetirement(name)` on it unconditionally — so an explicit +`builderHarness: "gemini"` / `architectHarness: "gemini"` was reported as retired **even when a valid +custom `harness.gemini` definition exists**. That contradicts the resolver precedence +(`resolveHarness`, harness.ts: explicit → built-in → **custom** → retired) and the spec's sanctioned +escape hatch — the retirement message *itself* tells users to "configure a custom harness in +`.codev/config.json`", yet following that advice would leave doctor's warning stuck on. + +**Change.** `resolveShell(role)` now returns `{ name, retirement }` and encodes the resolver's own +precedence, so doctor can never drift from spawn behavior: +- **Explicit `shell.Harness`**: suppress retirement iff `config.harness` has an own-property of + that name (`Object.prototype.hasOwnProperty.call(customHarnesses, explicit)` — prototype-safe, same + guard style as `isRetiredHarness`). A custom `gemini` def ⇒ not flagged (escape hatch honored). +- **Auto-detected `shell.` command**: retirement **always** applies for a detected retired name + — auto-detection resolves the built-in namespace only and never consults custom harnesses, exactly + as `resolveHarness` does (a bare `gemini …` command throws retired even when a custom `gemini` + exists). So `builder: "gemini --yolo"` stays flagged. + +This is the precise built-in-vs-custom, explicit-vs-detected distinction Codex asked for, and it keeps +doctor a faithful mirror of the resolver rather than a second, divergent policy. + +### C2 — Add builder + architect tests for explicit custom `gemini` +**Accepted. Added** to `doctor.test.ts` (`shell-harness retirement flagging (#1338)`): +- `does NOT flag an explicit custom gemini BUILDER harness (escape hatch)` — `builderHarness: "gemini"` + + `harness: { gemini: {...} }` ⇒ no `builder shell (harness retired)`, retirement text absent. +- `does NOT flag an explicit custom gemini ARCHITECT harness (escape hatch)` — the architect twin. +- `STILL flags an auto-detected gemini command even when a custom gemini harness exists` — the crucial + distinction: `builder: "gemini --yolo"` + `harness: { gemini: {...} }` ⇒ **still** flagged (proves + doctor matches the resolver's auto-detect-is-always-retired rule, so it can't green-light a config + that fails closed at spawn). Fixture custom defs are the minimal valid shape (`roleArgs`, + `roleScriptFragment`) so `loadConfig`'s `validateCustomHarnessConfig` accepts them. + +--- + +## Claude (REQUEST_CHANGES, HIGH) — all points accepted + +### 1 — Escape-hatch false positive +Same finding as Codex C1 (Claude independently traced it through `getBuilderHarness` → +`resolveHarness` and the `harness.test.ts` escape-hatch case). Fixed as above. + +### 2 — The new negative test was vacuous (missing `chalk.gray` mock) +**Accepted (real test-quality bug).** Verified: the chalk mock defined `bold/green/yellow/red/blue/dim` +but not **`gray`**. The codex-architect "supported" branch calls `chalk.gray` (doctor.ts, two lines +after the `✓ supported` line); with `gray` undefined that throws `TypeError` into the shell-section +`catch {}`, aborting **before** the builder branch runs. So `does NOT flag a supported-harness config +(claude builder + codex architect)` asserted nothing about the path it guards — a future regression +that false-flagged a `claude` builder would have gone uncaught. + +**Change.** Added `gray: createChainableColor()` to the mock, and **strengthened the guard beyond +Claude's suggestion.** Asserting the `✓ supported` line alone is insufficient — that line prints +*before* the `chalk.gray` call, so it passes with or without the mock. Instead the test asserts a line +printed **after** both `chalk.gray` calls (`Select the architect harness via .codev/config.json …`): +its presence proves the section ran to completion and the builder branch was actually reached, making +the two "not flagged" assertions non-vacuous. **Empirically confirmed**: with `gray` removed the test +fails at exactly that post-gray assertion (`expected false to be true`); restored, all green. + +### 3 — Recommendation misdirects on the explicit-harness path +**Accepted.** For `builderHarness: "gemini"` (test 3's own fixture) the old advice "Set shell.builder +to a supported harness…" is ineffective — an explicit `shell.builderHarness` beats the `shell.builder` +command, so changing only the command wouldn't clear it. Both role recommendations (and the inline +console guidance) now name **both** selectors: "Set `shell.builder` / `shell.builderHarness` …" and +"Set `shell.architect` / `shell.architectHarness` …". The two existing assertions were updated to the +new wording. + +### 4 — Nits +- **(a) No array-form test** — **added** `flags an array-form gemini builder command (parity with the + resolver)`: `builder: ["gemini", "--yolo"]` ⇒ flagged (exercises the array-join branch of + `resolveShell`, matching the resolver's array handling). +- **(b) Duplicate full retirement paragraph when both roles are gemini** — **acknowledged, no change.** + Each role's warning is independently actionable and self-contained; deduping the second paragraph + would couple the two branches for a purely cosmetic gain on a rare misconfiguration. Recorded for the + Review doc as a deliberate, low-value trade-off. + +--- + +## Gemini (APPROVE) +No changes requested. Gemini confirmed the Phase 3 deliverables (redefined architect branch, new +builder-side flagging, structured issue/recommendation fields) align with the plan. The escape-hatch +correctness gap it did not surface is now closed per Codex/Claude. + +--- + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc + vite clean). +- `doctor.test.ts` → **25 passed** (21 prior + 4 new #1338 cases; the 4 existing #1338 cases updated + for the escape hatch and both-selector recommendation wording). +- Non-vacuity of the supported-config guard proved by controlled removal of the `gray` mock (test fails + on the post-gray assertion, then passes on restore). +- Full unit suite (excl. e2e) re-run green — the change is isolated to `doctor.ts` (no non-test + importer) and its test file, so no other suite is affected. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-rebuttals.md new file mode 100644 index 000000000..7f81c64b6 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-rebuttals.md @@ -0,0 +1,89 @@ +# Phase 4 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Codex and Claude converged on one real correctness bug — the +README/CHANGELOG documented the custom-`gemini` **escape hatch** in a form that does not work — plus +Codex flagged an `opencode`-as-architect inaccuracy. Both are fixed in README + CHANGELOG (this phase's +scope). Minors addressed; one cross-phase consistency note is accepted and deferred to the Review phase +(see below). No point rejected. Each claim was re-verified against the resolver before revising. + +Fix commit: `` (README.md, CHANGELOG.md — docs only). + +--- + +## Blocking — both accepted, both fixed in README + CHANGELOG + +### B1 — The documented escape hatch, followed literally, is rejected (Codex C1 + Claude, HIGH) +**Accepted (real bug).** My phase_4 draft said "define a **custom harness** named `gemini` … (keep +`--yolo` for autonomous mode)" — implying a `harness.gemini` definition plus `shell.builder: "gemini +--yolo"` is enough. It is not. This is exactly the resolver behavior retired in Phase 1 and mirrored by +Phase 3's doctor: +- `resolveHarness` consults custom harnesses **only on the explicit-`harnessName` path** (built-in → + custom → retired), so an explicit `gemini` backed by a custom def resolves (escape hatch). +- The **auto-detect** path intercepts a detected `gemini` **before** any custom lookup, so a bare + `gemini …` command is retired **even when a custom `gemini` exists** (asserted at + `harness.test.ts` — `resolveHarness(undefined, {gemini}, 'gemini --yolo')` throws). + +So the escape hatch **requires** the explicit `shell.builderHarness` / `shell.architectHarness: +"gemini"` selector. **Fix:** both README and CHANGELOG now state the explicit selector is required and +explain why (auto-detection resolves the built-in namespace only). The README carries a working config +snippet (`shell.builder` + `shell.builderHarness` + a `harness.gemini` definition using the documented +`roleArgs` / `roleScriptFragment` fields, matching `harness.test.ts`'s escape-hatch fixture). + +### B2 — `opencode` is builder-only, not an architect migration target (Codex C2, HIGH) +**Accepted.** The README itself states OpenCode "is only supported as a **builder** shell, not as an +architect shell," and the built-in OpenCode provider rejects architect use. My migration guidance +listed `opencode` generically for both roles. **Fix:** README and CHANGELOG migration guidance is now +role-specific — `claude` or `codex` for either role; `opencode` for **builders only** (OpenCode is not +a supported architect shell). The README's worked example uses `codex` for both roles (valid). + +--- + +## Minor (Claude) — addressed + +- **README `:392` single-item parenthetical** ("Other shells (Codex) are also supported") — reworded to + "Codex is also supported via the harness system." **Fixed.** +- **Autonomous-flags table now lists only Claude Code** (Claude: adding `codex`/`opencode` rows is a + nice follow-up, "arguably outside this phase's scope"). Left as-is — the config prose immediately below + documents `codex`/`opencode` selection; adding rows is out of the "retire gemini" scope. Noted for a + possible docs follow-up. + +--- + +## Cross-phase consistency (Claude, explicitly non-blocking) — accepted, deferred to Review + +Claude noted the **same** escape-hatch omission exists in two Phase 1/3 artifacts and was explicit: *"I'm +not asking you to re-open them."* Codex did not raise them. Both are genuine and **accepted**: +- Runtime retirement message — `RETIRED_HARNESSES.gemini` (`harness.ts`): "configure it as a custom + harness … under the 'harness' section" omits the required explicit selector. +- Doctor recommendation (`doctor.ts`): the "or configure a custom harness" tail omits it too. + +**Disposition: apply in the Review phase, not here.** Phase 4's plan scope is explicitly *README + +CHANGELOG*, and the porch phase prompt restricts edits to this phase's files; `harness.ts` / `doctor.ts` +are approved Phase 1/3 code. Aligning all three user touchpoints (README ✓ now, runtime message, doctor +rec) to name the explicit `builderHarness` / `architectHarness` selector is a one-line change each and +will land in the Review phase (reviewed at the PR consult), tracked in the review doc's follow-ups. +Neither reviewer blocks on it, so it does not gate Phase 4. + +--- + +## Governance docs (already planned) +`codev/resources/arch.md` (`:291`, `:309-317`) and `lessons-learned.md` (`:80`) still carry the stale +"gemini is a supported/builder-only harness" framing. Per the spec + plan these are updated in the +**Review** phase via the `update-arch-docs` skill (hot/cold routing) — not a Phase 4 gap. Both reviewers +noted this is correctly deferred. + +## Doc-consistency check (re-affirmed) +The corrected README escape-hatch snippet contains `"builder": "gemini --yolo"` + `"builderHarness": +"gemini"`. This is the **reviewer-requested** custom-harness example (retained-access escape hatch, +explicit selector required) — it does **not** present `gemini` as a supported *built-in* shell, which is +what the criterion forbids; the surrounding prose frames it as retired-built-in + custom-harness-only. +The scoped grep (both `codev/` and `codev-skeleton/`) otherwise shows only exempt refs (consult lane / +`agy`, historical artifacts, Review-phase governance docs). + +## Gemini (APPROVE) +No changes requested; Gemini confirmed README + CHANGELOG accurately reflect the retirement. + +## Verification after fixes +- Docs-only change (README.md, CHANGELOG.md); no source or test touched. Build + full unit suite + unaffected (green at 4145/0 from the phase_4 iter1 checks); re-confirmed via porch check. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-context.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-context.md new file mode 100644 index 000000000..e50308676 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-context.md @@ -0,0 +1,104 @@ +### Iteration 1 Reviews +- gemini: APPROVE — README and CHANGELOG updates accurately reflect the retirement of the Gemini CLI harness as specified in Phase 4. +- codex: REQUEST_CHANGES — The retirement messaging is present, but the documented migration path is incomplete and partly misleading. +- claude: REQUEST_CHANGES — All four Phase 4 doc deliverables are correct and the scoped grep passes, but the README's custom-harness escape-hatch instruction omits the required explicit `builderHarness`/`architectHarness` key, so a user who follows it still gets rejected. + +### Builder Response to Iteration 1 +# Phase 4 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Codex and Claude converged on one real correctness bug — the +README/CHANGELOG documented the custom-`gemini` **escape hatch** in a form that does not work — plus +Codex flagged an `opencode`-as-architect inaccuracy. Both are fixed in README + CHANGELOG (this phase's +scope). Minors addressed; one cross-phase consistency note is accepted and deferred to the Review phase +(see below). No point rejected. Each claim was re-verified against the resolver before revising. + +Fix commit: `` (README.md, CHANGELOG.md — docs only). + +--- + +## Blocking — both accepted, both fixed in README + CHANGELOG + +### B1 — The documented escape hatch, followed literally, is rejected (Codex C1 + Claude, HIGH) +**Accepted (real bug).** My phase_4 draft said "define a **custom harness** named `gemini` … (keep +`--yolo` for autonomous mode)" — implying a `harness.gemini` definition plus `shell.builder: "gemini +--yolo"` is enough. It is not. This is exactly the resolver behavior retired in Phase 1 and mirrored by +Phase 3's doctor: +- `resolveHarness` consults custom harnesses **only on the explicit-`harnessName` path** (built-in → + custom → retired), so an explicit `gemini` backed by a custom def resolves (escape hatch). +- The **auto-detect** path intercepts a detected `gemini` **before** any custom lookup, so a bare + `gemini …` command is retired **even when a custom `gemini` exists** (asserted at + `harness.test.ts` — `resolveHarness(undefined, {gemini}, 'gemini --yolo')` throws). + +So the escape hatch **requires** the explicit `shell.builderHarness` / `shell.architectHarness: +"gemini"` selector. **Fix:** both README and CHANGELOG now state the explicit selector is required and +explain why (auto-detection resolves the built-in namespace only). The README carries a working config +snippet (`shell.builder` + `shell.builderHarness` + a `harness.gemini` definition using the documented +`roleArgs` / `roleScriptFragment` fields, matching `harness.test.ts`'s escape-hatch fixture). + +### B2 — `opencode` is builder-only, not an architect migration target (Codex C2, HIGH) +**Accepted.** The README itself states OpenCode "is only supported as a **builder** shell, not as an +architect shell," and the built-in OpenCode provider rejects architect use. My migration guidance +listed `opencode` generically for both roles. **Fix:** README and CHANGELOG migration guidance is now +role-specific — `claude` or `codex` for either role; `opencode` for **builders only** (OpenCode is not +a supported architect shell). The README's worked example uses `codex` for both roles (valid). + +--- + +## Minor (Claude) — addressed + +- **README `:392` single-item parenthetical** ("Other shells (Codex) are also supported") — reworded to + "Codex is also supported via the harness system." **Fixed.** +- **Autonomous-flags table now lists only Claude Code** (Claude: adding `codex`/`opencode` rows is a + nice follow-up, "arguably outside this phase's scope"). Left as-is — the config prose immediately below + documents `codex`/`opencode` selection; adding rows is out of the "retire gemini" scope. Noted for a + possible docs follow-up. + +--- + +## Cross-phase consistency (Claude, explicitly non-blocking) — accepted, deferred to Review + +Claude noted the **same** escape-hatch omission exists in two Phase 1/3 artifacts and was explicit: *"I'm +not asking you to re-open them."* Codex did not raise them. Both are genuine and **accepted**: +- Runtime retirement message — `RETIRED_HARNESSES.gemini` (`harness.ts`): "configure it as a custom + harness … under the 'harness' section" omits the required explicit selector. +- Doctor recommendation (`doctor.ts`): the "or configure a custom harness" tail omits it too. + +**Disposition: apply in the Review phase, not here.** Phase 4's plan scope is explicitly *README + +CHANGELOG*, and the porch phase prompt restricts edits to this phase's files; `harness.ts` / `doctor.ts` +are approved Phase 1/3 code. Aligning all three user touchpoints (README ✓ now, runtime message, doctor +rec) to name the explicit `builderHarness` / `architectHarness` selector is a one-line change each and +will land in the Review phase (reviewed at the PR consult), tracked in the review doc's follow-ups. +Neither reviewer blocks on it, so it does not gate Phase 4. + +--- + +## Governance docs (already planned) +`codev/resources/arch.md` (`:291`, `:309-317`) and `lessons-learned.md` (`:80`) still carry the stale +"gemini is a supported/builder-only harness" framing. Per the spec + plan these are updated in the +**Review** phase via the `update-arch-docs` skill (hot/cold routing) — not a Phase 4 gap. Both reviewers +noted this is correctly deferred. + +## Doc-consistency check (re-affirmed) +The corrected README escape-hatch snippet contains `"builder": "gemini --yolo"` + `"builderHarness": +"gemini"`. This is the **reviewer-requested** custom-harness example (retained-access escape hatch, +explicit selector required) — it does **not** present `gemini` as a supported *built-in* shell, which is +what the criterion forbids; the surrounding prose frames it as retired-built-in + custom-harness-only. +The scoped grep (both `codev/` and `codev-skeleton/`) otherwise shows only exempt refs (consult lane / +`agy`, historical artifacts, Review-phase governance docs). + +## Gemini (APPROVE) +No changes requested; Gemini confirmed README + CHANGELOG accurately reflect the retirement. + +## Verification after fixes +- Docs-only change (README.md, CHANGELOG.md); no source or test touched. Build + full unit suite + unaffected (green at 4145/0 from the phase_4 iter1 checks); re-confirmed via porch check. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-rebuttals.md new file mode 100644 index 000000000..3d2c8af97 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-rebuttals.md @@ -0,0 +1,102 @@ +# Phase 4 (1338) — Rebuttals, iteration 2 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Codex and Claude converged (both HIGH) on one real correctness +bug that iteration 1 introduced while fixing the *selector* problem: the README's custom-`gemini` +escape-hatch snippet injects the role via `--system`, but the Gemini CLI reads its system prompt from +the **`GEMINI_SYSTEM_MD` environment variable** — so a copy-paste user still gets a launch line the CLI +rejects (same user-visible outcome iter1 blocked on, different cause). Fixed in README, and the +same-named `harness.test.ts` escape-hatch assertion is realigned to the corrected shape. No point +rejected. Each claim was re-verified against the retired built-in in git history before revising. + +Fix commit: `` (README.md — docs; harness.test.ts — realign the escape-hatch fixture). + +--- + +## Blocking — accepted, fixed (Codex C1 + Claude, both HIGH — converged) + +### B1 — README escape-hatch snippet uses `--system`; Gemini injects via `GEMINI_SYSTEM_MD` +**Accepted (real bug).** `README.md:472` documented: +```json +"gemini": { "roleArgs": ["--system", "${ROLE_FILE}"], "roleScriptFragment": "--system '${ROLE_FILE}'" } +``` +`--system` is not a Gemini CLI flag and was never Codev's mechanism for `gemini`. The retired built-in +`GEMINI_HARNESS` — deleted in Phase 1, still at `git show e222b9ef^:packages/codev/src/agent-farm/utils/ +harness.ts:177-186` — injected the role via the **`GEMINI_SYSTEM_MD` env var** with empty args and an +empty script fragment: +```ts +buildRoleInjection: (_c, filePath) => ({ args: [], env: { GEMINI_SYSTEM_MD: filePath } }), +buildScriptRoleInjection: (_c, filePath) => ({ fragment: '', env: { GEMINI_SYSTEM_MD: filePath } }), +``` +A retained-access user copy-pasting the old snippet gets `gemini --yolo --system '' …`; the config +validates and resolves (so Codev accepts it), then the CLI rejects the unknown flag — the escape hatch is +structurally right (explicit selector, from iter1) but functionally still broken. + +**Fix (README.md):** the snippet now reproduces the retired provider verbatim — +```json +"gemini": { + "roleArgs": [], + "roleEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" }, + "roleScriptFragment": "", + "roleScriptEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" } +} +``` +I confirmed this plumbs through end-to-end: `validateCustomHarnessConfig` accepts empty `roleArgs` / +`roleScriptFragment`; `buildCustomHarnessProvider` expands `${ROLE_FILE}` inside `roleEnv` / `roleScriptEnv` +(`harness.ts:311-330`); and `spawn-worktree.ts:923-927` emits `export GEMINI_SYSTEM_MD=''` into +the launch script — byte-equivalent to what the built-in produced. Added one sentence of prose explaining +*why* the injection is env-based (empty `roleArgs`), so the shape is not mysterious to a reader. + +--- + +## Non-blocking (both reviewers marked optional) — addressed + +### N1 — `harness.test.ts:319-328` escape-hatch fixture used the same `--system` shape (Codex + Claude) +**Accepted and fixed** (both called it optional; done because this test is *literally named* the +"retained-access escape hatch" test and encoded the exact bug the README had — an asserted-but-wrong +shape would invite the docs to drift back). Realigned to the `GEMINI_SYSTEM_MD` env injection so the +**asserted** escape hatch is now identical to the **documented** one and to the retired built-in. The test +now asserts both surfaces: `buildRoleInjection` → `{ args: [], env: { GEMINI_SYSTEM_MD } }` and +`buildScriptRoleInjection` → `{ fragment: '', env: { GEMINI_SYSTEM_MD } }`. This does **not** reduce +generic template-expansion coverage — the arbitrary-`--system` `${ROLE_FILE}`/`${ROLE_CONTENT}` expansion +paths remain covered by the dedicated `buildCustomHarnessProvider` describe (`harness.test.ts:122-149`, +`harness-integration.test.ts:150-165`), which are generic mechanism tests, not gemini-specific. + +### N2 — escape-hatch example still shows `--yolo` after the flags table dropped that row (Claude) +**Kept, by design.** `--yolo` is the user's *own* retained Gemini CLI invocation (`shell.builder`), not a +Codev-presented autonomous-mode row. A retained-access user who wants autonomous behavior still passes it; +the flags table dropped the row because Codev no longer *presents* gemini as a built-in, which is a +distinct claim. Slightly incongruous, as Claude noted, but correct — removing it would make the worked +example less faithful to a real retained-access setup. + +### N3 — cross-phase selector omission in runtime message + doctor rec (Claude — explicitly deferred) +Claude reaffirmed the iter1 deferral: `RETIRED_HARNESSES.gemini` (`harness.ts:233-236`) and doctor's +builder-recommendation tail (`doctor.ts:874`) still say "configure a custom harness" without naming the +explicit `builderHarness` / `architectHarness` selector. **Both reviewers endorsed landing all three +touchpoints together in the Review phase** (out of Phase 4's README/CHANGELOG scope; both are approved +Phase 1/3 code). Unchanged here; tracked for Review. + +--- + +## Governance docs (already planned) +`codev/resources/arch.md` (`:291`, `:309-317`) and `lessons-learned.md` (`:80`) still carry the stale +"gemini is a supported/builder-only harness" framing → updated in the **Review** phase via the +`update-arch-docs` skill (hot/cold routing). Not a Phase 4 gap. + +## Doc-consistency check (re-affirmed) +The corrected snippet still contains `"builder": "gemini --yolo"` + `"builderHarness": "gemini"` — the +reviewer-requested retained-access custom-harness example (explicit selector required), framed as +retired-built-in + custom-only. It does **not** present `gemini` as a supported *built-in* shell. Scoped +grep across README/CHANGELOG/`docs/`/`codev-skeleton/` shows the only `--system` gemini instance was +README:472 (now fixed); `docs/releases/*` already reference `GEMINI_SYSTEM_MD` correctly (exempt +historical artifacts). + +## Gemini (APPROVE) +No changes requested; Gemini confirmed the README + CHANGELOG accurately reflect the retirement and the +explicit-selector logic. + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc clean; the realigned fixture type-checks — + `roleEnv` / `roleScriptEnv` are valid `CustomHarnessConfig` fields). +- `harness.test.ts` → **59/59 passed**, including the realigned escape-hatch assertion. +- Docs change (README) carries no runtime risk; the single source edit is test-only. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter3-context.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter3-context.md new file mode 100644 index 000000000..df6e63404 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter3-context.md @@ -0,0 +1,214 @@ +### Iteration 1 Reviews +- gemini: APPROVE — README and CHANGELOG updates accurately reflect the retirement of the Gemini CLI harness as specified in Phase 4. +- codex: REQUEST_CHANGES — The retirement messaging is present, but the documented migration path is incomplete and partly misleading. +- claude: REQUEST_CHANGES — All four Phase 4 doc deliverables are correct and the scoped grep passes, but the README's custom-harness escape-hatch instruction omits the required explicit `builderHarness`/`architectHarness` key, so a user who follows it still gets rejected. + +### Builder Response to Iteration 1 +# Phase 4 (1338) — Rebuttals, iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Codex and Claude converged on one real correctness bug — the +README/CHANGELOG documented the custom-`gemini` **escape hatch** in a form that does not work — plus +Codex flagged an `opencode`-as-architect inaccuracy. Both are fixed in README + CHANGELOG (this phase's +scope). Minors addressed; one cross-phase consistency note is accepted and deferred to the Review phase +(see below). No point rejected. Each claim was re-verified against the resolver before revising. + +Fix commit: `` (README.md, CHANGELOG.md — docs only). + +--- + +## Blocking — both accepted, both fixed in README + CHANGELOG + +### B1 — The documented escape hatch, followed literally, is rejected (Codex C1 + Claude, HIGH) +**Accepted (real bug).** My phase_4 draft said "define a **custom harness** named `gemini` … (keep +`--yolo` for autonomous mode)" — implying a `harness.gemini` definition plus `shell.builder: "gemini +--yolo"` is enough. It is not. This is exactly the resolver behavior retired in Phase 1 and mirrored by +Phase 3's doctor: +- `resolveHarness` consults custom harnesses **only on the explicit-`harnessName` path** (built-in → + custom → retired), so an explicit `gemini` backed by a custom def resolves (escape hatch). +- The **auto-detect** path intercepts a detected `gemini` **before** any custom lookup, so a bare + `gemini …` command is retired **even when a custom `gemini` exists** (asserted at + `harness.test.ts` — `resolveHarness(undefined, {gemini}, 'gemini --yolo')` throws). + +So the escape hatch **requires** the explicit `shell.builderHarness` / `shell.architectHarness: +"gemini"` selector. **Fix:** both README and CHANGELOG now state the explicit selector is required and +explain why (auto-detection resolves the built-in namespace only). The README carries a working config +snippet (`shell.builder` + `shell.builderHarness` + a `harness.gemini` definition using the documented +`roleArgs` / `roleScriptFragment` fields, matching `harness.test.ts`'s escape-hatch fixture). + +### B2 — `opencode` is builder-only, not an architect migration target (Codex C2, HIGH) +**Accepted.** The README itself states OpenCode "is only supported as a **builder** shell, not as an +architect shell," and the built-in OpenCode provider rejects architect use. My migration guidance +listed `opencode` generically for both roles. **Fix:** README and CHANGELOG migration guidance is now +role-specific — `claude` or `codex` for either role; `opencode` for **builders only** (OpenCode is not +a supported architect shell). The README's worked example uses `codex` for both roles (valid). + +--- + +## Minor (Claude) — addressed + +- **README `:392` single-item parenthetical** ("Other shells (Codex) are also supported") — reworded to + "Codex is also supported via the harness system." **Fixed.** +- **Autonomous-flags table now lists only Claude Code** (Claude: adding `codex`/`opencode` rows is a + nice follow-up, "arguably outside this phase's scope"). Left as-is — the config prose immediately below + documents `codex`/`opencode` selection; adding rows is out of the "retire gemini" scope. Noted for a + possible docs follow-up. + +--- + +## Cross-phase consistency (Claude, explicitly non-blocking) — accepted, deferred to Review + +Claude noted the **same** escape-hatch omission exists in two Phase 1/3 artifacts and was explicit: *"I'm +not asking you to re-open them."* Codex did not raise them. Both are genuine and **accepted**: +- Runtime retirement message — `RETIRED_HARNESSES.gemini` (`harness.ts`): "configure it as a custom + harness … under the 'harness' section" omits the required explicit selector. +- Doctor recommendation (`doctor.ts`): the "or configure a custom harness" tail omits it too. + +**Disposition: apply in the Review phase, not here.** Phase 4's plan scope is explicitly *README + +CHANGELOG*, and the porch phase prompt restricts edits to this phase's files; `harness.ts` / `doctor.ts` +are approved Phase 1/3 code. Aligning all three user touchpoints (README ✓ now, runtime message, doctor +rec) to name the explicit `builderHarness` / `architectHarness` selector is a one-line change each and +will land in the Review phase (reviewed at the PR consult), tracked in the review doc's follow-ups. +Neither reviewer blocks on it, so it does not gate Phase 4. + +--- + +## Governance docs (already planned) +`codev/resources/arch.md` (`:291`, `:309-317`) and `lessons-learned.md` (`:80`) still carry the stale +"gemini is a supported/builder-only harness" framing. Per the spec + plan these are updated in the +**Review** phase via the `update-arch-docs` skill (hot/cold routing) — not a Phase 4 gap. Both reviewers +noted this is correctly deferred. + +## Doc-consistency check (re-affirmed) +The corrected README escape-hatch snippet contains `"builder": "gemini --yolo"` + `"builderHarness": +"gemini"`. This is the **reviewer-requested** custom-harness example (retained-access escape hatch, +explicit selector required) — it does **not** present `gemini` as a supported *built-in* shell, which is +what the criterion forbids; the surrounding prose frames it as retired-built-in + custom-harness-only. +The scoped grep (both `codev/` and `codev-skeleton/`) otherwise shows only exempt refs (consult lane / +`agy`, historical artifacts, Review-phase governance docs). + +## Gemini (APPROVE) +No changes requested; Gemini confirmed README + CHANGELOG accurately reflect the retirement. + +## Verification after fixes +- Docs-only change (README.md, CHANGELOG.md); no source or test touched. Build + full unit suite + unaffected (green at 4145/0 from the phase_4 iter1 checks); re-confirmed via porch check. + + +### Iteration 2 Reviews +- gemini: APPROVE — Phase 4 user-facing docs (README and CHANGELOG) accurately reflect the retirement of the Gemini CLI harness and successfully incorporate the explicit custom-harness selector logic requested in the previous iteration. +- codex: REQUEST_CHANGES — Retirement and migration guidance is improved, but the README’s custom Gemini harness example is not executable as documented. +- claude: REQUEST_CHANGES — Both iteration-1 blockers are genuinely fixed and the scoped grep is clean, but the README's custom-harness escape-hatch snippet documents `--system ${ROLE_FILE}` instead of the `GEMINI_SYSTEM_MD` env injection the retired built-in actually used, so a copy-paste user still gets a broken launch. + +### Builder Response to Iteration 2 +# Phase 4 (1338) — Rebuttals, iteration 2 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES (HIGH)**, **Claude REQUEST_CHANGES (HIGH)**. +Disposition: **every point accepted.** Codex and Claude converged (both HIGH) on one real correctness +bug that iteration 1 introduced while fixing the *selector* problem: the README's custom-`gemini` +escape-hatch snippet injects the role via `--system`, but the Gemini CLI reads its system prompt from +the **`GEMINI_SYSTEM_MD` environment variable** — so a copy-paste user still gets a launch line the CLI +rejects (same user-visible outcome iter1 blocked on, different cause). Fixed in README, and the +same-named `harness.test.ts` escape-hatch assertion is realigned to the corrected shape. No point +rejected. Each claim was re-verified against the retired built-in in git history before revising. + +Fix commit: `` (README.md — docs; harness.test.ts — realign the escape-hatch fixture). + +--- + +## Blocking — accepted, fixed (Codex C1 + Claude, both HIGH — converged) + +### B1 — README escape-hatch snippet uses `--system`; Gemini injects via `GEMINI_SYSTEM_MD` +**Accepted (real bug).** `README.md:472` documented: +```json +"gemini": { "roleArgs": ["--system", "${ROLE_FILE}"], "roleScriptFragment": "--system '${ROLE_FILE}'" } +``` +`--system` is not a Gemini CLI flag and was never Codev's mechanism for `gemini`. The retired built-in +`GEMINI_HARNESS` — deleted in Phase 1, still at `git show e222b9ef^:packages/codev/src/agent-farm/utils/ +harness.ts:177-186` — injected the role via the **`GEMINI_SYSTEM_MD` env var** with empty args and an +empty script fragment: +```ts +buildRoleInjection: (_c, filePath) => ({ args: [], env: { GEMINI_SYSTEM_MD: filePath } }), +buildScriptRoleInjection: (_c, filePath) => ({ fragment: '', env: { GEMINI_SYSTEM_MD: filePath } }), +``` +A retained-access user copy-pasting the old snippet gets `gemini --yolo --system '' …`; the config +validates and resolves (so Codev accepts it), then the CLI rejects the unknown flag — the escape hatch is +structurally right (explicit selector, from iter1) but functionally still broken. + +**Fix (README.md):** the snippet now reproduces the retired provider verbatim — +```json +"gemini": { + "roleArgs": [], + "roleEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" }, + "roleScriptFragment": "", + "roleScriptEnv": { "GEMINI_SYSTEM_MD": "${ROLE_FILE}" } +} +``` +I confirmed this plumbs through end-to-end: `validateCustomHarnessConfig` accepts empty `roleArgs` / +`roleScriptFragment`; `buildCustomHarnessProvider` expands `${ROLE_FILE}` inside `roleEnv` / `roleScriptEnv` +(`harness.ts:311-330`); and `spawn-worktree.ts:923-927` emits `export GEMINI_SYSTEM_MD=''` into +the launch script — byte-equivalent to what the built-in produced. Added one sentence of prose explaining +*why* the injection is env-based (empty `roleArgs`), so the shape is not mysterious to a reader. + +--- + +## Non-blocking (both reviewers marked optional) — addressed + +### N1 — `harness.test.ts:319-328` escape-hatch fixture used the same `--system` shape (Codex + Claude) +**Accepted and fixed** (both called it optional; done because this test is *literally named* the +"retained-access escape hatch" test and encoded the exact bug the README had — an asserted-but-wrong +shape would invite the docs to drift back). Realigned to the `GEMINI_SYSTEM_MD` env injection so the +**asserted** escape hatch is now identical to the **documented** one and to the retired built-in. The test +now asserts both surfaces: `buildRoleInjection` → `{ args: [], env: { GEMINI_SYSTEM_MD } }` and +`buildScriptRoleInjection` → `{ fragment: '', env: { GEMINI_SYSTEM_MD } }`. This does **not** reduce +generic template-expansion coverage — the arbitrary-`--system` `${ROLE_FILE}`/`${ROLE_CONTENT}` expansion +paths remain covered by the dedicated `buildCustomHarnessProvider` describe (`harness.test.ts:122-149`, +`harness-integration.test.ts:150-165`), which are generic mechanism tests, not gemini-specific. + +### N2 — escape-hatch example still shows `--yolo` after the flags table dropped that row (Claude) +**Kept, by design.** `--yolo` is the user's *own* retained Gemini CLI invocation (`shell.builder`), not a +Codev-presented autonomous-mode row. A retained-access user who wants autonomous behavior still passes it; +the flags table dropped the row because Codev no longer *presents* gemini as a built-in, which is a +distinct claim. Slightly incongruous, as Claude noted, but correct — removing it would make the worked +example less faithful to a real retained-access setup. + +### N3 — cross-phase selector omission in runtime message + doctor rec (Claude — explicitly deferred) +Claude reaffirmed the iter1 deferral: `RETIRED_HARNESSES.gemini` (`harness.ts:233-236`) and doctor's +builder-recommendation tail (`doctor.ts:874`) still say "configure a custom harness" without naming the +explicit `builderHarness` / `architectHarness` selector. **Both reviewers endorsed landing all three +touchpoints together in the Review phase** (out of Phase 4's README/CHANGELOG scope; both are approved +Phase 1/3 code). Unchanged here; tracked for Review. + +--- + +## Governance docs (already planned) +`codev/resources/arch.md` (`:291`, `:309-317`) and `lessons-learned.md` (`:80`) still carry the stale +"gemini is a supported/builder-only harness" framing → updated in the **Review** phase via the +`update-arch-docs` skill (hot/cold routing). Not a Phase 4 gap. + +## Doc-consistency check (re-affirmed) +The corrected snippet still contains `"builder": "gemini --yolo"` + `"builderHarness": "gemini"` — the +reviewer-requested retained-access custom-harness example (explicit selector required), framed as +retired-built-in + custom-only. It does **not** present `gemini` as a supported *built-in* shell. Scoped +grep across README/CHANGELOG/`docs/`/`codev-skeleton/` shows the only `--system` gemini instance was +README:472 (now fixed); `docs/releases/*` already reference `GEMINI_SYSTEM_MD` correctly (exempt +historical artifacts). + +## Gemini (APPROVE) +No changes requested; Gemini confirmed the README + CHANGELOG accurately reflect the retirement and the +explicit-selector logic. + +## Verification after fixes +- `pnpm --filter @cluesmith/codev build` → exit 0 (tsc clean; the realigned fixture type-checks — + `roleEnv` / `roleScriptEnv` are valid `CustomHarnessConfig` fields). +- `harness.test.ts` → **59/59 passed**, including the realigned escape-hatch assertion. +- Docs change (README) carries no runtime risk; the single source edit is test-only. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-plan-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-plan-iter1-rebuttals.md new file mode 100644 index 000000000..3cb0c64e5 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-plan-iter1-rebuttals.md @@ -0,0 +1,86 @@ +# Plan 1338 — Rebuttals, Plan iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES**, **Claude COMMENT**. +Disposition: **all points accepted and incorporated.** No point rejected. The plan was restructured +from 3 → 4 phases to keep each phase small after adding the spawn preflight and per-site architect +handling. Each claim below was independently re-verified against source before revising. + +--- + +## Codex (REQUEST_CHANGES) + +### X1 — Precedence order would change built-in/custom behavior +**Accepted (important).** Verified: current `resolveHarness` is `built-in → custom → unknown` +(harness.ts:363-381) — built-ins win over same-named custom. My draft's "custom → retired → built-in" +inverted that and would let a custom `claude`/`codex`/`opencode` shadow a built-in. +**Change**: pinned the order to **`built-in → custom → retired → generic throw`** for the explicit +path (Phase 1 Implementation Details), with regression tests that (a) a custom `claude` does NOT +shadow the built-in and (b) an explicit custom `gemini` still resolves. + +### X2 — Spawn validation is too late; leaves partial state +**Accepted (important).** Verified: `spawn.ts` creates the worktree (`createWorktree`, ~:429/431) and +porch state (`initPorchInWorktree`, :442) **before** resolving the builder harness at :471 — a raw +throw orphans state. **Change**: Phase 2 now adds a **preflight** (`assertBuilderHarnessNotRetired`) +above all `createWorktree` entry points, aborting before any worktree/porch/db mutation, with a test +asserting a rejected gemini spawn creates **no** state. + +### X3 — Escape-hatch ambiguity for auto-detected `gemini` +**Accepted.** **Change**: Phase 1 states explicitly that an auto-detected `gemini …` command is +retired **even when a custom `gemini` exists**; the custom escape hatch requires *explicit* +`builderHarness`/`architectHarness: "gemini"`. This matches current behavior (auto-detect only ever +resolved the built-in namespace, never custom). + +### X4 — Doctor is not "override-aware" +**Accepted.** Verified: `doctor.ts:797-803` reads raw `shell.architect`/`shell.architectHarness`, not +`getResolvedCommands`/CLI/env overrides. **Change**: Phase 3 now specifies **persisted-config** +detection for the builder branch (raw `shell.builder`/`shell.builderHarness`, array-or-string) and +explicitly drops the "override-aware" claim, matching the spec's scope. + +--- + +## Claude (COMMENT — all four verified accurate against source) + +### #1 — Phase 2 mislabels `tower-utils.ts:291` / `:509` as "architect launch" +**Accepted (highest-value).** Verified: `:291` is `siblingRegistrationIsLive()` — a **boolean liveness +predicate** (returns bool; used by sibling-registration reconcile), and `:509` is a lazy `freshLaunch` +closure `next()`. **Change**: Phase 2 now enumerates all **four** `getArchitectHarness` sites with +per-site expected behavior: `:179`/`:357`/`:509` are launch paths → clean failure at the launch +boundary; **`:291` is guarded to return `false`** (a retired architect isn't live → reconcile prunes +it) so the throw never escapes a Tower predicate. + +### #2 — Second `BUILTIN_HARNESSES` consumer + wrong path +**Accepted.** Verified: `harnessProviderFor` (`agent-farm/commands/reset/context.ts:468`) indexes +`BUILTIN_HARNESSES` directly → returns `null` post-removal → reset refuses (the spec's **accepted** +outcome; no code change). **Change**: Integration Points now names *both* reset consumers +(`harnessFromLaunchScript`:414 and `harnessProviderFor`:468) and corrects the path to the full +`agent-farm/commands/reset/context.ts`. (The approved spec's shorter `reset/context.ts` citation is +the same file; left as-is to avoid re-opening the approved spec for a path abbreviation.) + +### #3 — Retired-check ordering stated inconsistently +**Accepted.** Resolved by the X1 fix: pinned as **custom lookup → retired check → generic throw** +(after built-in). Consistent everywhere now. + +### #4 — Missing CHANGELOG deliverable +**Accepted.** Verified: `CHANGELOG.md` has a maintained `## [Unreleased]` section (line 10, active +Spec-786 entries). Removing a supported harness is a user-visible breaking change. **Change**: Phase 4 +now includes a CHANGELOG `[Unreleased]` entry with a migration pointer. + +### #5 — Phase 1 committable but not shippable +**Accepted.** **Change**: Executive Summary and the phase notes now state that committable ≠ +individually shippable (after Phase 1 the resolver throws but nothing surfaces it), with an explicit +"do not stop before Phase 3." + +--- + +## Gemini (APPROVE) +No changes requested; confirmed the plan accurately translates the role-agnostic spec into precise +phases with robust test replacements. Reinforces the direction. + +--- + +## Net effect +Approach unchanged (retirement sentinel in the shared resolver). Correctness hardened: precedence +pinned to preserve built-in/custom behavior, spawn preflight prevents orphaned state, all four +architect sites handled per-site (predicate guarded), doctor scoped to persisted config, CHANGELOG +added, reset consumers named. Restructured 3→4 phases for small, reviewable units. Ready for +re-verification. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-review-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-review-iter1-rebuttals.md new file mode 100644 index 000000000..43987dc96 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-review-iter1-rebuttals.md @@ -0,0 +1,53 @@ +# Review iteration 1 — rebuttals (PR #1342) + +Verdicts: **Gemini APPROVE**, **Codex REQUEST_CHANGES**, **Claude APPROVE**. Codex's two points are both +protocol-record issues (it explicitly found "no production correctness or security blockers"); both +accepted and addressed. Claude's notes were all non-blocking; the two directly-relevant ones are +addressed, the rest logged as follow-ups. + +## Codex — REQUEST_CHANGES (both accepted + fixed) + +1. **Commit the five untracked `*-context.md` consultation artifacts.** + - ACCEPTED + DONE. Verified the convention rather than assuming: `git ls-files` shows 11 `*-context.md` + files already tracked across prior projects (e.g. `0104-custom-session-manager`), alongside the + `*-rebuttals.md` files (which this project already commits). Committed all five 1338 context files + (`phase_2-iter2/iter3`, `phase_3-iter2`, `phase_4-iter2/iter3`) for audit-trail parity. + +2. **Stale review-doc metrics (47 commits / 4145 tests vs. the current branch).** + - ACCEPTED + DONE. Refreshed Key Metrics: commits `47 → ≈60` (marked approximate — it grows as review + iterations land), tests `4145 → 4148`; also fixed the Flaky-Tests line (`4145 → 4148`) and the + Consultation-Summary counts (`33 files / 11 rounds / 7 rebuttals → 36 / 12 / 8`, through this + PR-review round). + - Note: Codex could not independently rerun Vitest (its read-only sandbox blocked `.vite-temp`); the + suite is green here — **4148 passed / 48 pre-existing skips / 0 failed** — and `tsc --noEmit` is exit 0. + +## Gemini — APPROVE + +No issues raised. + +## Claude — APPROVE (no blocking). Non-blocking notes: + +- **(Minor) `assertBuilderHarnessNotRetired` swallows non-retirement errors; in `--shell` nothing + downstream re-raises them.** ADDRESSED. Added a `console.debug` in the catch. Rationale: it is + functionally harmless (shell mode runs `commands.builder` as a raw command and never uses the harness + provider), but *my* `--shell` change made this the one path where such an error no longer re-surfaces — + so logging it (never swallow silently) is the right close. Fires only on an actual resolution error, so + it adds no noise to the normal path. +- **(Cosmetic) The doctor `${role.name}` interpolation assertion cannot fail under the old hard-coded + literal; the comment overstated it.** ADDRESSED. Reworded both comments (builder + architect tests): the + assertion locks the *rendered* clause for the current retired harness; it cannot by itself prove the + interpolation with a single `RETIRED_HARNESSES` entry — that is verified by inspection and by the + identical `${role.name}` pattern on the already-asserted console/issue lines. (Consistent with my own + Phase-3 decision to avoid a fragile second-retired-harness mock.) +- **(Minor, pre-existing) `resolveHarness`'s `BUILTIN_HARNESSES[name]` / `name in customHarnesses` walk the + prototype chain.** OUT OF SCOPE (pre-existing). Logged as a Follow-up Item (`Object.hasOwn` candidate). +- **(Out of scope, pre-existing since #778) `INSTALL.md:245` + `.claude/skills/codev/SKILL.md:48` still list + Gemini CLI as an AI-CLI dependency.** OUT OF SCOPE. The spec scopes the doc-consistency criterion to + harness-selection docs; added to Follow-up Items as a doc-sweep candidate. +- **(Judgment) Hot-tier `arch-critical.md` untouched.** Deferred to architect/MAINTAIN judgment — the hot + tier is cap-bound and the cold docs (`arch.md`/`lessons-learned.md`) carry the retirement fact. + +## Result + +Fixes are docs/test-comment/diagnostic-log only — no production-logic change. Build exit 0; full unit +suite 4148 / 48 / 0. Ready for re-verification (iteration 2) on the updated HEAD. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-specify-iter1-rebuttals.md b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-specify-iter1-rebuttals.md new file mode 100644 index 000000000..a0c145998 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-specify-iter1-rebuttals.md @@ -0,0 +1,97 @@ +# Spec 1338 — Rebuttals, Specify iteration 1 + +Reviews: **Gemini APPROVE**, **Codex REQUEST_CHANGES**, **Claude REQUEST_CHANGES**. +Disposition: all substantive points **accepted and incorporated**. No point rejected. Details below. + +--- + +## Claude (REQUEST_CHANGES) — verified against source, high confidence + +### C1 (blocking) — `resolveHarness` is role-agnostic; architect outcome undefined +**Accepted.** Correct and important: `resolveHarness(harnessName, customHarnesses, command)` +(harness.ts:358) takes no role parameter and is shared by `getArchitectHarness` (config.ts:261) and +`getBuilderHarness` (config.ts:280). A sentinel inside it retires gemini for **both** roles. +**Decision — accept role-agnostic retirement** (Claude's recommended option): the upstream CLI is +unavailable for the same tiers regardless of role, and gemini-as-architect is *already* unsupported +(doctor warns today), so failing closed for both roles is the correct, minimal implementation — +preferable to threading a role parameter through a shared signature. +**Changes**: new Clarifying Question 4; Desired State now covers architect + builder; a dedicated +Success Criterion for the architect path (`getArchitectHarness` / `--architect-cmd gemini`); Test +Scenario 4; README criterion now covers **both** the architect and builder lines of the config +example (README:456-457). Flagged to the architect at the gate as a slight broadening of mechanism +beyond "builder-only." + +### C2 — Doctor's architect branch needs a defined end state, not a reworded string +**Accepted.** The branch's premise ("supported for builders") fully inverts post-retirement. +**Changes**: Desired State now defines the new end state — the `gemini` branch presents the +retirement for both roles **and** doctor additionally flags a `gemini` *builder* config. Test +Scenario 7 and the doctor Success Criterion now assert on the **structured `issue:`/`recommendation:` +fields** (doctor.ts:826) rather than console text, per your stability suggestion. + +### C3 — Problem Statement's "silently falls back to Claude" is imprecise +**Accepted** — this was factually loose. Tightened to the **two** distinct failure modes, matching +the code exactly: +- remove detector case **and** registry entry → `detected` is undefined → `return CLAUDE_HARNESS` + (harness.ts:392) = silent Claude fallback; +- remove **only** the registry entry → `detected === 'gemini'` → `return BUILTIN_HARNESSES['gemini']` + (harness.ts:387) = `undefined` → TypeError. +**Changes**: Problem Statement rewritten with both modes; Approach 1 now explicitly guards *before* +both :387 and :392; Security Considerations + Non-Functional Test 3 assert the retired paths return +neither `CLAUDE_HARNESS` nor `undefined`. + +### C4 — `afx reset` is underweighted as "Nice-to-Know"; record as decided +**Accepted.** `harnessFromLaunchScript` (reset/context.ts:414) derives its recognizable set from +`Object.keys(BUILTIN_HARNESSES)`; dropping gemini → pre-existing gemini builder → `null` → reset +declines. **Changes**: moved out of Open Questions into **Assumptions** as a decided/accepted outcome +(a retired harness can't context-reset anyway — only Claude declares `supportsContextReset` — and +reset already refuses unrecognized harnesses loudly). Gemini's review independently agreed this is +acceptable. + +### C5 — Minor (coverage baseline; decide the doctor open question) +**Accepted both.** "No reduction in coverage" reframed to **replacement** (each removed gemini test +→ a retirement-behavior test); the "Important" doctor open question is now **decided (yes)** and is a +Success Criterion, so the plan inherits no unresolved criterion. + +--- + +## Codex (REQUEST_CHANGES) — high confidence + +### X1 — "CLI no longer exists / gone" is inaccurate; frame as a Codev product retirement +**Accepted.** Google ended **consumer-account** (free/Pro/Ultra) access on 2026-06-18; +Standard/Enterprise subscriptions and API-key auth reportedly remain. **Changes**: Problem Statement ++ Clarifying Question 2 reframe this as a **Codev product retirement** of the built-in option (not a +claim the binary is gone everywhere); the retained-access path is served via a **custom harness** +(Assumptions). The retirement *message* says the built-in option is retired, not "the CLI no longer +exists." + +### X2 — Resolve the `codev doctor` open question in the spec, not the plan +**Accepted.** Now decided in-spec: doctor **must** flag a `gemini` builder configuration with the +retirement explanation, and its architect branch is redefined (no longer "supported for builders"). +Promoted from Open Question to Success Criterion + Test Scenario 7. + +### X3 — "Every path" is broader than the tests specified +**Accepted.** **Changes**: Test Scenarios now cover the real config integration paths — +`getBuilderHarness`, the `--builder-cmd gemini` CLI override, and the **array-form** builder command +(`builder: ["gemini", "--yolo"]`) — plus the architect twin (`getArchitectHarness` / +`--architect-cmd gemini`), not only direct `resolveHarness` calls. Matching Success Criteria added. + +### X4 — Clarify the repo-wide documentation criterion (exempt historical + consult refs) +**Accepted.** **Changes**: the documentation criterion (Non-Functional Test 1 + the final Success +Criterion) is now scoped to **current, user-facing harness-selection docs** only, and **explicitly +exempts** historical artifacts (`codev/specs`, `plans`, `reviews`, `projects`, `docs/releases/*`) and +every `consult -m gemini` / `agy` reference — removing the conflict with the consult-lane non-goal. + +--- + +## Gemini (APPROVE) +No changes required. Confirmed Approach 1 as the correct fit, the footgun analysis as accurate, and +that `afx reset` leave-as-is is acceptable (folded into the C4 decision) and doctor should flag +builder-side gemini (folded into X2/C5). Its confirmations reinforce the decisions above. + +--- + +## Net effect +No design reversal — the recommended **Approach 1 (retirement sentinel in the shared resolver)** +stands and is strengthened: precise two-mode footgun analysis, explicit role-agnostic scope, a +defined doctor end state with builder-side flagging, broader integration-path test coverage, accurate +"Codev product retirement" framing, and a scoped documentation criterion. Ready for re-verification. diff --git a/codev/projects/1338-retire-gemini-cli-as-a-builder/status.yaml b/codev/projects/1338-retire-gemini-cli-as-a-builder/status.yaml new file mode 100644 index 000000000..1f6c9b280 --- /dev/null +++ b/codev/projects/1338-retire-gemini-cli-as-a-builder/status.yaml @@ -0,0 +1,123 @@ +id: '1338' +title: retire-gemini-cli-as-a-builder +protocol: spir +phase: review +plan_phases: + - id: phase_1 + title: Retire gemini in the shared resolver (core + resolver/config tests) + status: complete + - id: phase_2 + title: Fail closed at spawn/launch boundaries (no orphaned state, no Tower crash) + status: complete + - id: phase_3 + title: 'codev doctor: retirement guidance + builder-side flagging' + status: complete + - id: phase_4 + title: 'User-facing docs: README + CHANGELOG' + status: complete +current_plan_phase: null +gates: + spec-approval: + status: approved + requested_at: '2026-08-03T19:34:19.277Z' + approved_at: '2026-08-03T19:35:31.931Z' + plan-approval: + status: approved + requested_at: '2026-08-03T19:49:19.697Z' + approved_at: '2026-08-03T19:52:33.839Z' + pr: + status: pending + requested_at: '2026-08-04T02:52:39.784Z' + verify-approval: + status: pending +iteration: 1 +build_complete: true +history: + - iteration: 1 + plan_phase: phase_2 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-codex.txt + - model: claude + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter1-claude.txt + - iteration: 2 + plan_phase: phase_2 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-codex.txt + - model: claude + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_2-iter2-claude.txt + - iteration: 1 + plan_phase: phase_3 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-codex.txt + - model: claude + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_3-iter1-claude.txt + - iteration: 1 + plan_phase: phase_4 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-codex.txt + - model: claude + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter1-claude.txt + - iteration: 2 + plan_phase: phase_4 + build_output: '' + reviews: + - model: gemini + verdict: APPROVE + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-gemini.txt + - model: codex + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-codex.txt + - model: claude + verdict: REQUEST_CHANGES + file: >- + /home/user/code/codev_root/codev/.builders/spir-1338/codev/projects/1338-retire-gemini-cli-as-a-builder/1338-phase_4-iter2-claude.txt +started_at: '2026-08-03T19:12:09.462Z' +updated_at: '2026-08-04T02:52:39.784Z' +pr_history: + - phase: review + pr_number: 1342 + branch: builder/spir-1338 + created_at: '2026-08-04T01:14:21.523Z' +pr_ready_for_human: true diff --git a/codev/resources/arch.md b/codev/resources/arch.md index c9933590e..f68bed67d 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -288,7 +288,7 @@ All architect sessions (at all 3 creation points) receive a role prompt injected 1. Loads the architect role from `codev/roles/architect.md` (local) or `skeleton/roles/architect.md` (bundled fallback) via `loadRolePrompt()` 2. Writes the role content to `.architect-role.md` in the project directory -3. Delegates the CLI-specific injection to the configured `HarnessProvider` (`agent-farm/utils/harness.ts`, Spec 591): claude `--append-system-prompt`, codex `-c model_instructions_file=`, gemini `GEMINI_SYSTEM_MD` env var +3. Delegates the CLI-specific injection to the configured `HarnessProvider` (`agent-farm/utils/harness.ts`, Spec 591): claude `--append-system-prompt`, codex `-c model_instructions_file=`. (The built-in `gemini` `GEMINI_SYSTEM_MD` provider was retired in #1338; retained-access users wire it back as a custom harness — see Supported Harnesses below.) **Three architect creation points** where role injection is applied: - `tower-instances.ts` → `launchInstance()` (new project activation) @@ -308,7 +308,7 @@ A `codev doctor` audit (`lib/framework-ref-audit.ts`) flags shell-fetch of frame #### Supported Architect Harnesses & Conversation Resume (#929) -**Supported architect harnesses** (Issue #929): claude and codex are supported as architects, selected via `.codev/config.json` (`shell.architect` / `shell.architectHarness`) — the same config-driven mechanism builders use, and the *recommended* one. **Gemini is builder-only** — the Gemini CLI is retiring (#778), so it is not offered or affirmed as an architect (its `GEMINI_SYSTEM_MD` builder surface stays); `doctor` warns if `gemini` is configured as an architect. (agy, the gemini successor, is deferred as an architect to #1063 — its only role-injection channel is a visible first user turn.) Harness auto-detection is **override-aware**: `getArchitectHarness` / `getBuilderHarness` resolve the harness from the override-aware command (`getResolvedCommands` → `cliOverrides` / `TOWER_ARCHITECT_CMD` / config), so a `--architect-cmd codex` / `TOWER_ARCHITECT_CMD=codex` / `--builder-cmd gemini` with no matching harness config still resolves the *non-claude* harness, not claude. (Before #929 it auto-detected from the raw config value only — an override launched the non-claude CLI but resolved the claude harness, re-arming the resume crash-loop below.) An explicit `shell.architectHarness` / `shell.builderHarness` still wins over auto-detection. OpenCode remains builder-only (file-based injection needs an ephemeral worktree). Codex reads project context (`AGENTS.md`) natively, so no architect context-file seam is needed; the `getArchitectFiles` seam #1059 added for gemini was removed with gemini's architect support. +**Supported architect harnesses** (Issue #929): claude and codex are supported as architects, selected via `.codev/config.json` (`shell.architect` / `shell.architectHarness`) — the same config-driven mechanism builders use, and the *recommended* one. **The built-in `gemini` harness is retired (#1338)** — Google ended consumer Gemini CLI access (2026-06-18), so `gemini` is no longer a supported built-in builder *or* architect. It **fails closed** at every spawn / launch / reconnect / clean-exit boundary with a retirement message (never a silent claude fallback), and `codev doctor` flags a persisted `gemini` builder/architect config. Retained-access users (Standard/Enterprise or API-key) can still run it only via an **explicit** custom `gemini` harness selected through `shell.builderHarness` / `shell.architectHarness` — a bare auto-detected `gemini` command stays retired; the custom harness reproduces the old `GEMINI_SYSTEM_MD` env injection. (agy, the gemini successor, is deferred as an architect to #1063 — its only role-injection channel is a visible first user turn.) Harness auto-detection is **override-aware**: `getArchitectHarness` / `getBuilderHarness` resolve the harness from the override-aware command (`getResolvedCommands` → `cliOverrides` / `TOWER_ARCHITECT_CMD` / config), so a `--architect-cmd codex` / `TOWER_ARCHITECT_CMD=codex` / `--builder-cmd opencode` with no matching harness config still resolves the *non-claude* harness, not claude. (Before #929 it auto-detected from the raw config value only — an override launched the non-claude CLI but resolved the claude harness, re-arming the resume crash-loop below.) An explicit `shell.architectHarness` / `shell.builderHarness` still wins over auto-detection. OpenCode remains builder-only (file-based injection needs an ephemeral worktree). Codex reads project context (`AGENTS.md`) natively, so no architect context-file seam is needed; the `getArchitectFiles` seam #1059 added for gemini was removed with gemini's architect support. > **Caveat — unrecognized override commands still default to the claude harness (tracked in cluesmith/codev#1062).** `#929`'s override-awareness only covers *recognized* harness commands (claude/codex/gemini/opencode, matched by `detectHarnessFromCommand`). An override command the detector does **not** recognize — e.g. `TOWER_ARCHITECT_CMD=bash`, a wrapper script, or any custom launcher — with **no** explicit `shell.architectHarness` / `shell.builderHarness` falls through `resolveHarness` to the **claude** harness (`harness.ts`, the final `return CLAUDE_HARNESS`). With a stale Claude `.jsonl` present, that can still build ` --resume ` for the unrecognized command. This is **pre-existing and narrow** (not a #929 regression — #929 strictly *improved* the recognized codex case) and separable. Mitigation today: set an explicit `shell.architectHarness` / `shell.builderHarness` when using an unrecognized launcher command. diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index 3e3ec782d..9ff7d2785 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -176,6 +176,7 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From 0589] When migrating multiple call sites to a new abstraction, configuration threading (passing `forgeConfig`/`workspaceRoot` to every call) is easy to miss at non-obvious sites like porch checks and merge instructions. Phase-scoped consultation reviews are effective at catching these gaps. - [From 818] An acceptance criterion of "rule structurally identical to X" is a written-rule trap when the rule lives as duplicated prose in two views. Two copies drift even with diligence; the only durable enforcement is one shared function both views import. Extract when the second consumer lands — not before (no abstraction without users) and not later (drift starts on day one). - [From 1107] To place an *interactive* React widget (text input, buttons) inside an `innerHTML`-managed body, don't hand-build DOM there — inject an empty placeholder node in an effect and `createPortal` the React component into it. React owns the widget's state/focus/keyboard, while it still sits in normal document flow. Make the placeholder-injection effect idempotent (reuse a correctly-placed node; bail when `previousElementSibling` already matches the anchor) or the `setState`-on-inject loops; an `html` rebuild disconnects the node, which the same guard detects and re-creates. This is the read-while-write composer (#1107) but applies to any overlay/widget over imperatively-rendered content. +- [From #1338] Retiring an entry from a shared resolver/registry must **fail closed at every resolution path**, not just delete the entry: a pure delete makes the explicit-name path throw a generic "unknown" error (no migration guidance) and the auto-detect path *silently* fall back to the default provider (here, the claude harness) — a dangerous mis-injection, not a visible failure. Keep the retired name in the detector and add a retirement sentinel checked BEFORE both exits so every path yields the same specific message; then grep every caller — spawn preflight, launch, and especially the reconnect/clean-exit relaunch paths that mint fresh sessions — because those are exactly the ones an "it's unreachable" analysis misses (three of them surfaced only under adversarial review here). ## Process @@ -376,6 +377,7 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From #1012] A placeholder filled by the agent-driven review path needs an explicit "replace me" marker (mirror the hot-tier `` convention), not just an italic `_None yet._` line — otherwise the builder may append below it, leaving a self-contradictory file. The review prompts and `update-arch-docs` skill never mention the placeholder, so the file itself must signal replacement. - [From 778] In a self-hosted Codev repo the four-tier resolver means `codev/` instance copies *shadow* `codev-skeleton/`, so the two trees (and the `codev/` copies themselves) drift independently. A terminology/backend change (Gemini-CLI → `agy`) cost 3 review iterations because each round surfaced another stale copy (skeleton → `DEPENDENCIES.md` → `resources/commands/consult.md` → `codev.md` + `arch.md`). When changing any shared doc, grep BOTH trees in one pass and run `diff codev/ codev-skeleton/` for every shared file — empty diff is the consistency proof. Distinguish in-scope current docs from historical artifacts (`specs/`, `plans/`, dated analyses) which must keep their original wording. - When provider discovery requires duplicated on-disk trees, make drift failure explicit: recursively compare skill inventories, relative file paths, and bytes in CI, with one reviewed exception allowlist for genuine provider-specific content. Apply customization preservation per provider and per complete skill directory, so backfilling one provider never overwrites or couples the user's other provider tree. +- [From #1338] A documented config/escape-hatch snippet must reproduce the tool's **real** mechanism, verified against the actual (or retired) implementation — not a plausible-looking guess. A snippet that passes Codev's own config validation but the underlying CLI rejects (e.g. injecting a role via a `--system` flag when the CLI reads the `GEMINI_SYSTEM_MD` env var) is worse than none: it looks authoritative and fails only at runtime (two review iterations here). Assert the real shape in a test named for the behavior so the docs can't silently drift back to the wrong one. ## 3-Way Reviews diff --git a/codev/reviews/1338-retire-gemini-cli-as-a-builder.md b/codev/reviews/1338-retire-gemini-cli-as-a-builder.md new file mode 100644 index 000000000..25e5319e5 --- /dev/null +++ b/codev/reviews/1338-retire-gemini-cli-as-a-builder.md @@ -0,0 +1,328 @@ +# Review: Retire Gemini CLI as a builder harness + +## Summary + +Retired the standalone **Gemini CLI** as a supported built-in builder/architect harness (issue #1338). +Delivered across four implementation phases: a retirement sentinel in the shared harness resolver that +fails closed on both the explicit and auto-detect paths; fail-closed guards at every spawn / architect +launch / reconnect / clean-exit boundary (no orphaned worktree, no Tower crash); `codev doctor` +retirement flagging for both roles with the sanctioned custom-harness escape hatch preserved; and +user-facing docs (README). The Review phase aligned the three retirement-guidance +touchpoints (runtime message, `doctor` recommendation, README) to name the explicit +`shell.builderHarness` / `shell.architectHarness` selector, and refreshed the governance docs. + +## Spec Compliance + +- [x] AC1: Gemini CLI is no longer presented or treated as a supported builder option (Phases 1, 4) — + built-in `GEMINI_HARNESS` removed from the registry; README no longer presents it as a + built-in shell. +- [x] AC2: A user selecting Gemini CLI as a builder receives a clear explanation that the option has + been retired (Phases 1–3) — `resolveHarness` throws a `RetiredHarnessError` carrying a specific + retirement message (2026-06-18 cause, migration targets, escape hatch); spawn fails closed with it; + `codev doctor` flags a persisted `gemini` builder/architect config with the same message. +- [x] AC3: Existing Claude, Codex, OpenCode, and custom builder support remains unaffected (all phases) — + built-ins untouched (regression-tested both roles); the explicit custom-`gemini` escape hatch + resolves and spawns (retained-access path preserved). +- [x] AC (fail-closed, from spec): neither footgun survives — the auto-detect path no longer silently + falls back to `CLAUDE_HARNESS`, and the explicit path no longer throws a generic "unknown" error. +- [x] AC (consult lane untouched): the `gemini` **consult lane** (now `agy`) and `agy` architect (#1063) + are out of scope and unchanged. + +## Deviations from Plan + +- **Phase 2 grew from the planned single-preflight design to four fail-closed boundaries.** Codex's + consultation (3 iterations) surfaced three reachable paths the plan's "initial launch" framing missed: + architect **restart/reconnect** (`buildArchitectReconnectRestartOptions` → fail closed to `undefined`), + and clean-exit **relaunch** (`FreshLaunch` gained a `{ stop: true }` contract so a factory can veto a + respawn it can't re-command). Net: the retirement fails closed at spawn preflight, launch, reconnect, + and clean-exit — a stronger invariant than the plan specified. Documented in the phase_2 iter1/iter2 + rebuttals. +- **Governance-doc + three-touchpoint alignment deferred to Review (as planned/endorsed).** The plan + routed arch/lessons updates to Review; the cross-phase runtime-message/doctor-rec selector alignment + was accepted-and-deferred across phase_4 iterations with all three reviewers' endorsement, and landed + here. +- **Integration-review adjustments (post-PR #1342, architect review).** Four changes after the PR opened: + (1) the drafted CHANGELOG `[Unreleased]` retirement entry was **reverted** — contributors don't edit the + upstream release changelog; the breaking change is documented via README + the runtime/`doctor` + retirement messaging instead. (2) The spawn preflight was made **unconditional** (previously gated + `if (mode !== 'shell')`): `spawnShell` still runs `commands.builder` and persists a shell row, so a + retired `gemini` `shell.builder` now fails closed too — regression-tested (no PTY, no row). (3) The + `doctor` custom-harness recommendations interpolate `role.name` instead of a hard-coded `"gemini"`, so + the advice stays correct as `RETIRED_HARNESSES` grows. (4) `siblingRegistrationIsLive` logs the + retirement reason when it prunes a retired-architect row, so the reconcile loop's generic "no resumable + session" line can't misattribute the cause. + +## Key Metrics + +- **Commits**: ≈60 on the branch (`[Spec 1338]` artifact/code commits + porch orchestration chores + the + PR integration-review round). Approximate — grows as review iterations land. +- **Tests**: 4148 passing, 48 pre-existing skips, 0 failing (full unit suite, e2e excluded). Substantial + new coverage added: `spawn-retirement.test.ts` (new — real `spawn()` against a real temp workspace, + asserts 0 orphaned state), plus additions across `harness`, `config`, `doctor`, `tower-utils`, + `tower-instances`, and `session-manager` tests. +- **Files created**: `packages/codev/src/agent-farm/__tests__/spawn-retirement.test.ts`; + `codev/reviews/1338-retire-gemini-cli-as-a-builder.md`. +- **Files deleted**: none (the built-in `GEMINI_HARNESS` export + its registry entry were removed + in-place from `harness.ts`). +- **Net LOC impact**: ≈ +1,300 / −230 across ~20 source + doc files (heavily test-weighted). + +## Timelog + +All times America/New_York (EDT, −0400), 2026-08-03 (spec through phase_4 iter2), with phase_4 iter3 + +Review completed after a resume the following UTC day. + +| Time | Event | +|------|-------| +| 15:12 | Session start (porch `started_at`) | +| 15:25 | First commit: Initial specification draft | +| 15:28 | Spec consult (Gemini APPROVE, Codex/Claude REQUEST_CHANGES) | +| — | **GATE: spec-approval** (human approval required) | +| 15:35 | spec-approval approved | +| 15:42 | Plan consult (Gemini APPROVE, Codex REQUEST_CHANGES, Claude COMMENT) | +| — | **GATE: plan-approval** (human approval required) | +| 15:52 | plan-approval approved; implementation begins | +| 16:20 | Phase 1 complete (1 iter, unanimous) → phase_2 | +| 18:09 | Phase 2 complete (3 iters; Codex C1/C2/C3) → phase_3 | +| 20:05 | Phase 3 complete (2 iters) → phase_4 | +| 20:46 | Phase 4 iter2 committed; paused at architect request (state snapshot) | +| — | Resume ("unpause") → phase_4 iter3 unanimous APPROVE → Review | +| — | **GATE: pr** (pending at time of writing) | + +### Autonomous Operation + +| Period | Duration | Activity | +|--------|----------|----------| +| Spec + Plan | ~40m | Draft + 2 gated consults | +| Human gate waits | ~short | spec-approval + plan-approval approved same session | +| Implementation → phase_4 | ~5h | 4 phases, 9 implement consult rounds (1+3+2+3) | +| Pause → resume → Review | — | phase_4 iter3 + Review-phase alignment + PR | + +**Total wall clock** (first commit → PR): spec 15:25 → PR (Review phase, next UTC day). +**Context window resets**: ~3 (phase_2 iter1 fixes, phase_3 iter1 fixes, and this Review resume — each +recovered from `state-snapshot.md` + the thread log; all resumed automatically without losing state). + +## Consultation Iteration Summary + +36 consultation files (12 rounds × 3 models) + 8 rebuttal files, through the first PR integration-review +round. Verdicts trended APPROVE as iterations converged; every implement phase ended unanimous APPROVE. + +| Phase | Iters | Who Blocked | What They Caught | +|-------|-------|-------------|------------------| +| Specify | 1 | Codex, Claude | Product-retirement framing (consumer tiers vs "CLI gone"); role-agnostic sentinel scope; doctor premise inversion | +| Plan | 1 | Codex | Resolver precedence (built-in→custom→retired); spawn preflight must precede state mutation; per-site architect handling | +| Phase 1 | 1 | — | Unanimous APPROVE, no changes | +| Phase 2 | 3 | Codex | C1 reconnect fail-open, C2 clean-exit uncaught throw, C3 clean-exit relaunch mints a retired session — all reachable via config-flip/restart | +| Phase 3 | 2 | Codex, Claude | Doctor false-flagged the sanctioned custom-`gemini` escape hatch; a vacuous negative test (missing `chalk.gray` mock) | +| Phase 4 | 3 | Codex, Claude | Escape-hatch guidance omitted the explicit selector (iter1); README snippet used `--system` instead of `GEMINI_SYSTEM_MD` (iter2) | +| Review | (at PR) | — | pending PR consult | + +**Most frequent blocker**: **Codex** — blocked in 5 of the 6 pre-PR rounds it reviewed, focused on +reachability of "unreachable" failure paths (restart/reconnect/clean-exit) and executable-doc accuracy. + +### Avoidable Iterations + +1. **Trace every resolution/launch call site up front.** Phase 2 cost 2 extra iterations because the + initial design reasoned "unreachable in practice" about the reconnect and clean-exit relaunch paths. + A caller-by-caller grep of `getArchitectHarness` / `FreshLaunch` before the first consult would have + surfaced C1/C2/C3 without reviewer prompting. (Captured as a lessons-learned entry.) +2. **Verify doc snippets against the real mechanism before the consult.** Phase 4 cost 2 iterations on + the escape-hatch snippet: first the missing explicit selector, then `--system` vs `GEMINI_SYSTEM_MD`. + Checking the retired provider's actual injection (`git show e222b9ef^`) before writing the snippet + would have collapsed both into zero. (Captured as a lessons-learned entry.) + +## Consultation Feedback + +### Specify Phase (Round 1) + +#### Gemini +- No concerns raised (APPROVE). + +#### Codex +- **Concern**: Frame as a *product* retirement (consumer tiers ended 2026-06-18; Standard/Enterprise + + API-key remain), served via a custom-harness escape hatch — not "the CLI is gone." + - **Addressed**: Spec reframed; escape hatch made a first-class requirement. + +#### Claude +- **Concern**: Make the sentinel role-agnostic (shared `resolveHarness` has no role param) and guard + both footgun modes (silent `CLAUDE_HARNESS` fallback; undefined/TypeError) before both exits. + - **Addressed**: Spec specifies a sentinel checked before both exits; flagged the role-agnostic + broadening to the architect, who confirmed it at the gate. + +### Plan Phase (Round 1) + +#### Gemini +- No concerns raised (APPROVE). + +#### Codex +- **Concern**: Keep resolver precedence built-in→custom→retired→generic; spawn preflight must run + *before* `createWorktree`/`initPorch` (which themselves resolve the harness) to avoid orphaned state; + handle the 4 `getArchitectHarness` sites per-site (predicate vs launch). + - **Addressed**: Plan restructured 3→4 phases; preflight centralized in the spawn dispatcher before + handler dispatch; per-site architect behavior enumerated. + +#### Claude +- **Concern (COMMENT)**: CHANGELOG `[Unreleased]` is live — add a breaking-change entry; the escape hatch + resolves only via the explicit selector. + - **Addressed**: The explicit-selector guidance landed in Phase 4 (README). The CHANGELOG `[Unreleased]` + entry was drafted in Phase 4 and later **reverted** at integration review — contributors don't edit + the upstream release changelog (see Deviations); the breaking change is documented via README + the + runtime/`doctor` retirement messaging. + +### Phase 1 (Round 1) +- No concerns raised — all three APPROVE (HIGH). The resolver retirement + coverage-by-replacement tests + landed cleanly. + +### Phase 2 (Rounds 1–3) + +#### Codex +- **Concern (iter1)**: Two fail-open paths on restart/reconnect — `resolveArchitectRestart` consumers + fall back to relaunching the raw `gemini` command. + - **Addressed**: Extracted `buildArchitectReconnectRestartOptions` (fails closed → `undefined`); + guarded `buildArchitectFreshLaunch.next()`. +- **Concern (iter2)**: C3 — the iter1 fresh-launch fix stopped the throw but not the *relaunch* + (session retains `options.command`); a retained custom-`gemini` respawns gemini on clean exit. + - **Addressed**: Extended `FreshLaunch` with a fail-closed `{ stop: true }` contract; clean-exit + handler honors it (no respawn, session removed, reason surfaced). +- **Concern (iter3)**: None — "fails closed across builder spawn, architect launch, reconnect, and + clean-exit paths, with adequate regression coverage." + +#### Gemini / Claude +- APPROVE across iters; Claude flagged C2 as non-blocking (Codex proved it reachable) and independently + verified tsc + 283 affected suites at iter3. + +### Phase 3 (Rounds 1–2) + +#### Codex +- **Concern (iter1)**: Doctor false-flagged the sanctioned custom-`gemini` escape hatch (ran + `getRetirement` on the name unconditionally, ignoring `config.harness`). + - **Addressed**: `resolveShell(role)` now encodes the resolver precedence (explicit `Harness` + + own-prop custom def ⇒ retirement suppressed; auto-detect ⇒ always retired). +- **Concern (iter2)**: None — escape hatch preserved, robust regression tests. + +#### Claude +- **Concern (iter1)**: The new negative test was vacuous — a missing `chalk.gray` mock threw into the + section `catch {}` before the builder branch ran. + - **Addressed**: Added `gray`; strengthened with a post-gray assertion that proves the section ran to + completion (empirically confirmed the test fails without the fix). + +### Phase 4 (Rounds 1–3) + +#### Codex +- **Concern (iter1)**: Escape-hatch guidance needs the explicit `shell.builderHarness`/`architectHarness` + selector; `opencode` is builder-only. + - **Addressed**: README requires the explicit selector and is role-specific. +- **Concern (iter2)**: README snippet used `roleArgs: ["--system", …]`; the retired built-in injected via + the `GEMINI_SYSTEM_MD` env var. + - **Addressed**: Snippet reproduces the retired provider verbatim (`roleEnv`/`roleScriptEnv`); the + same-named escape-hatch test realigned to assert the `GEMINI_SYSTEM_MD` shape. +- **Concern (iter3)**: None (HIGH) — the example matches the retired built-in's env injection. + +#### Claude +- **Concern (iter1/iter2)**: Same two blockers as Codex; plus (non-blocking, deferred with endorsement) + the runtime message + doctor rec omit the explicit selector. + - **Addressed**: Blockers fixed in-phase; the deferred touchpoints landed in this Review phase. +- **Concern (iter3)**: None blocking — verified the fix end-to-end from disk (validation, template + expansion, script emission, spawn preflight). (An optional CHANGELOG `GEMINI_SYSTEM_MD` pointer was + added in Phase 4, then reverted with the rest of the CHANGELOG entry at integration review — see + Deviations.) + +#### Gemini +- APPROVE every round. + +## Lessons Learned + +### What Went Well +- **The retirement-sentinel design held under adversarial review.** Framing the change as "fail closed at + every resolution path" (not "delete the entry") meant every reviewer concern was about *coverage of + paths*, never about the core mechanism — the mechanism never needed rework. +- **Coverage-by-replacement kept the suite honest.** Re-pointing existing `gemini` fixtures to + `codex`/`opencode` (rather than deleting them) preserved the override-awareness and resume-seam + regression tests that would otherwise have silently disappeared with the harness. +- **The thread + state-snapshot survived three context resets** with zero lost state — each resume + re-oriented from `codev/state/spir-1338_thread.md` and continued the exact porch step. + +### Challenges Encountered +- **"Unreachable" paths were reachable.** Phase 2's restart/reconnect/clean-exit relaunch paths each + looked unreachable from the initial-launch mental model but were reachable via config-flip-before-exit + or Tower-restart-reading-a-gemini-config. Cost 2 iterations; resolved by a fail-closed + `FreshLaunch.{ stop: true }` contract and a shared reconnect helper. +- **An executable doc snippet that validates but doesn't run.** The escape-hatch config passed Codev's + own validation yet used a role-injection mechanism the Gemini CLI never accepted. Cost 2 iterations + (Phase 4); resolved by reproducing the retired provider verbatim and locking it with a same-shaped test. + +### What Would Be Done Differently +- Grep every call site of a shared resolver/launcher (including restart/reconnect/clean-exit) **before** + the first consultation, rather than relying on "unreachable in practice" reasoning. +- Verify any documented config/escape-hatch snippet against the real (or retired) implementation before + writing it, and assert its real shape in a behavior-named test in the same change. + +### Methodology Improvements +- **SPIR worked as designed here** — Codex's per-phase consultation caught three genuinely reachable + fail-open paths that solo review missed; the value of the 3-way review was concentrated in one + reviewer's reachability analysis. +- Minor tooling note (already in lessons-learned/cold): the `agy`/Gemini consult lane reviews against an + empty sandbox and can default to `REQUEST_CHANGES`; not encountered as a blocker this project. + +## Architecture Updates + +Routed to the **COLD** archive (`codev/resources/arch.md`) — reference detail about a subsystem's current +shape, not a cross-cutting invariant that earns a capped hot-tier slot. No `arch-critical.md` (HOT) change: +the hot facts are broad invariants (resolver tiers, dual trees, gates), and a single harness's retirement +is narrower than any current hot entry, so nothing was displaced; the hot file's "Map of arch.md" stays +accurate (no top-level sections were added or renamed). + +- Routed: **cold** — *Architect Role Prompt Injection* (`arch.md:291`) — dropped `gemini` from the + built-in `HarnessProvider` injection list; noted the built-in provider was retired (#1338) with a + pointer to the escape hatch. +- Routed: **cold** — *Supported Architect Harnesses & Conversation Resume* (`arch.md:311`) — replaced the + stale "**Gemini is builder-only**" framing with current state: the built-in `gemini` harness is retired + for **both** roles (#1338), fails closed at spawn/launch/reconnect/clean-exit, and is reachable only via + an explicit custom `gemini` harness selected through `shell.builderHarness`/`architectHarness`. Also + refreshed the now-stale override-awareness example (`--builder-cmd gemini` → `--builder-cmd opencode`, + since a bare `gemini` command now fails closed rather than resolving a harness). + +## Lessons Learned Updates + +Routed both new lessons to the **COLD** archive (`codev/resources/lessons-learned.md`) — durable patterns, +but reference-depth rather than must-know-up-front, so no `lessons-critical.md` (HOT) change / displacement +(the hot cap is full of broader rules). The historical `[From #929]` resume-seam lesson (`:80`) was left +intact — its `gemini` mention is an accurate record of a past bug, not a current-support claim. + +- Routed: **cold — Architecture** — *Retiring a shared resolver/registry entry must fail closed at every + resolution path* (explicit-name path throws generic "unknown"; auto-detect path silently falls back to + the default provider — a dangerous mis-injection). Keep the retired name in the detector, add a + retirement sentinel before both exits, and grep every caller (spawn preflight, launch, and especially + reconnect/clean-exit relaunch paths that mint fresh sessions). +- Routed: **cold — Documentation** — *A documented config/escape-hatch snippet must reproduce the tool's + real mechanism, verified against the actual/retired implementation.* A snippet that passes internal + validation but the underlying CLI rejects is worse than none; assert the real shape in a + behavior-named test so the docs can't drift back. + +## Technical Debt + +- None introduced. The built-in `gemini` name is intentionally retained in `detectHarnessFromCommand` (so + a `gemini` command is *recognized* and retired, not misclassified as unknown) — a deliberate design + point, documented in `harness.ts` and the resolver tests, not debt. + +## Flaky Tests + +- No flaky tests encountered. The full unit suite ran deterministically (4148 passed / 48 pre-existing + skips / 0 failed) across every phase and Review verification. + +## Follow-up Items + +- **`agy` as a builder harness** — explicitly out of scope for #1338 (issue non-goal). Would need its own + issue if desired; `agy` architect support is separately tracked at #1063. +- **Generic unknown-harness error (`harness.ts:451`)** — its "configure a custom harness" advice omits the + explicit-selector requirement too. Not part of the flagged gemini-retirement touchpoints (it fires for + any unknown name), so intentionally left as-is; a candidate for a small consistency follow-up. +- **`arch.md:1062` caveat** — the pre-existing "unrecognized override commands default to the claude + harness" footgun (cluesmith/codev#1062) is untouched by this work and remains tracked upstream. +- **Prototype-chain lookups in `resolveHarness`** (Claude, PR review) — `BUILTIN_HARNESSES[name]` and + `name in customHarnesses` walk the prototype chain (so e.g. `toString` resolves to a truthy provider), + unlike the new `isRetiredHarness` which uses `hasOwnProperty`. Pre-existing and out of scope here; an + `Object.hasOwn`/own-property hardening follow-up candidate. +- **Stale Gemini-CLI mentions outside harness-selection docs** (Claude, PR review) — `INSTALL.md:245` and + `.claude/skills/codev/SKILL.md:48` (+ skeleton twin) still list "Gemini CLI" as a satisfying AI-CLI + dependency. Stale since #778 (not this PR); the spec scopes the doc-consistency criterion to + harness-selection docs, so intentionally excluded — a follow-up doc-sweep candidate. diff --git a/codev/specs/1338-retire-gemini-cli-as-a-builder.md b/codev/specs/1338-retire-gemini-cli-as-a-builder.md new file mode 100644 index 000000000..b4d5bd16a --- /dev/null +++ b/codev/specs/1338-retire-gemini-cli-as-a-builder.md @@ -0,0 +1,433 @@ +# Specification: Retire Gemini CLI as a Builder Harness + + + +## Metadata +- **ID**: spec-2026-08-03-retire-gemini-cli-builder +- **Status**: Approved (spec-approval gate 2026-08-03) — implemented (PR #1342) +- **Created**: 2026-08-03 +- **Issue**: #1338 + +## Clarifying Questions Asked + + +1. **What exactly is being retired — the Gemini *builder* harness, the `gemini` *consult* lane, or `agy`?** + Only the standalone **Gemini CLI (`gemini`) builder harness**. The `agy`/Antigravity consult + lane, `consult -m gemini`, and any `agy` architect support are explicitly **out of scope** + (issue Non-goals). + +2. **Is this "the CLI no longer exists," or a Codev product decision?** + A **Codev product retirement**. Google ended Gemini CLI access for **consumer accounts** + (free/Pro/Ultra tiers) on **2026-06-18**; Standard/Enterprise subscriptions and API-key + authentication reportedly remain. Codev is retiring the *built-in* `gemini` harness as a + *supported* option because it is unavailable to most users — not asserting the binary is gone + everywhere. Users who retain access can still wire it via a **custom harness** (see Assumptions). + +3. **Should Gemini be hard-removed, or retired-with-a-message?** + Retired with a **clear explanation**. Acceptance criterion 2 requires that a user selecting + Gemini CLI as a builder "receive a clear explanation that the option has been retired" — a + silent removal or a generic error does not satisfy this. + +4. **`resolveHarness` is role-agnostic — does retirement affect the architect path too?** + Yes, and that is **intended**. The single `resolveHarness` (harness.ts:358) takes no role + parameter and is shared by both `getArchitectHarness` and `getBuilderHarness` (config.ts:261, + 280). Because the CLI is unavailable for the same tiers regardless of role — and gemini is + *already* unsupported as an architect (doctor warns today) — the retirement is applied + **role-agnostically** rather than threading a role parameter through a shared signature. This is + broader in *mechanism* than "builder-only," but consistent with the issue's intent and with the + existing architect stance. (Surfaced by the Claude review; flagged to the architect at the gate.) + +5. **What must remain unaffected?** + Claude, Codex, OpenCode, and custom builder harnesses (acceptance criterion 3), and the entire + `agy` / `consult -m gemini` subsystem. + +6. **Are there pinned architectural decisions (Baked Decisions) to honor verbatim?** + No. Issue #1338 contains no "Baked Decisions" section. + +7. **Can this PR self-merge once approved?** + No. We are not upstream maintainers on this repo; the PR requires an external maintainer to + approve/merge (architect constraint, 2026-08-03). The change must stand on its own for external + review. + +## Problem Statement +Codev still presents and wires the standalone **Gemini CLI (`gemini`)** as a supported *builder* +harness. On **2026-06-18** Google ended Gemini CLI availability for **consumer accounts** (Pro, +Ultra, and free tiers), so for most users the option no longer works. Continuing to advertise it as +a supported builder — in the harness registry, in command auto-detection, in `codev doctor` +guidance, and in the README configuration example — is misleading: users who follow that guidance +configure a builder that cannot launch for them, and the failure they hit today is not a clear +"this is retired" explanation. This is therefore a **Codev product retirement** of the built-in +`gemini` harness, not a claim that the Gemini binary is gone for everyone (Standard/Enterprise and +API-key access reportedly persist — see Assumptions for the custom-harness escape hatch). + +Compounding the case, the retirement must be done carefully because the shared resolver has two +distinct latent failure modes if `gemini` is naively removed (verified against `harness.ts`): + +- **Remove the auto-detect case *and* the registry entry** → `detectHarnessFromCommand` no longer + returns `'gemini'`, so a config with `builder: "gemini …"` falls through to the final + `return CLAUDE_HARNESS` (harness.ts:392). Result: Codev **silently launches the Claude harness** + for a gemini command, injecting Claude-only args (`--append-system-prompt`) into a non-Claude + binary — the same class of silent mismatch Issue #929 fixed for architects. +- **Remove *only* the registry entry** (leave the detector) → `detected === 'gemini'` (truthy), so + `return BUILTIN_HARNESSES[detected]` (harness.ts:387) returns **`undefined`** typed as a + `HarnessProvider` → a downstream `TypeError`, not a clean failure. + +Both are unacceptable. The retirement must fail **clearly and closed** on every resolution path — +never a silent Claude fallback, never an `undefined`/TypeError. + +## Current State +Today the `gemini` builder harness is a first-class, supported option: + +- **Harness registry & provider.** A `GEMINI_HARNESS` provider (role injection via the + `GEMINI_SYSTEM_MD` environment variable) is registered under the name `gemini` in the built-in + harness registry (`BUILTIN_HARNESSES`, harness.ts:209-214) that enumerates all valid harness + names (`claude`, `codex`, `gemini`, `opencode`). +- **Command auto-detection.** `detectHarnessFromCommand` (harness.ts:336-341) maps any command whose + first token contains `gemini` to the `gemini` harness, so `builder: "gemini --yolo"` resolves to + `GEMINI_HARNESS` even without an explicit `builderHarness` setting. +- **Shared, role-agnostic resolver.** `resolveHarness(harnessName, customHarnesses, command)` + (harness.ts:358) has **no role parameter** and is called by *both* `getArchitectHarness` and + `getBuilderHarness` (config.ts:261, 280). It throws a **generic** "Unknown harness" error for + unrecognized explicit names (harness.ts:376-380), returns `BUILTIN_HARNESSES[detected]` for + auto-detected names (:387), and otherwise **defaults to the Claude harness** (:392). None of these + communicates "retired," and two of them are the failure modes described above. +- **`codev doctor`.** Doctor warns when `gemini` is configured as an *architect* and its message + explicitly states "gemini is supported **for builders**, not architects" (doctor.ts:816-826), + emitting a structured `issue:`/`recommendation:` pair at :826. After retirement this premise fully + **inverts** — there is no supported builder either — so the branch needs a *defined new end state*, + not just a reworded sentence. Doctor does not currently flag `gemini` configured as a *builder* at + all. +- **README.** The README presents Gemini as a supported shell ("Other shells (Codex, Gemini) are + also supported", README:392), lists the Gemini CLI `--yolo` autonomous flag (README:436), and + shows a `.codev/config.json` example with **both** `"architect": "gemini --yolo"` *and* + `"builder": "gemini --yolo"` (README:456-457). A soft caveat already notes the CLI "will stop + working" for retired tiers and is "tracked as a follow-up" — this spec is that follow-up. +- **`afx reset`.** `harnessFromLaunchScript` (reset/context.ts:405-421) builds its + recognizable-name set from `Object.keys(BUILTIN_HARNESSES)`, so removing `gemini` changes how + reset treats a pre-existing gemini builder (see Assumptions — decided outcome). +- **Governance docs.** `codev/resources/arch.md` (291, 311-317) and `lessons-learned.md` (80) + describe gemini as "builder-only," reflecting the pre-retirement state. +- **Tests.** Several unit/integration tests assert `GEMINI_HARNESS` behavior, `GEMINI_SYSTEM_MD` + injection, gemini auto-detection, and `--builder-cmd gemini` resolution. + +Scope confirmation: the `agy` consult lane and `consult -m gemini` are a **separate** subsystem, +already migrated off the retired CLI; they are out of scope here and remain untouched. + +## Desired State +The standalone Gemini CLI is no longer offered or treated as a supported harness (builder **or** +architect, since the resolver is shared), and every path a user could take to select it produces a +**clear, specific retirement explanation** rather than a silent fallback, a generic error, an +`undefined`/TypeError, or a broken launch: + +- Selecting `gemini` — via explicit `shell.builderHarness`/`shell.architectHarness: "gemini"` **or** + by auto-detection from `shell.builder`/`shell.architect: "gemini …"` (string or array form) — + fails **loudly and closed** with a message stating the option has been retired (Google ended + consumer-tier Gemini CLI access on 2026-06-18) and naming the supported alternatives (claude, + codex, opencode, or a custom harness). No silent Claude fallback and no `undefined` return on any + path — the retired name is intercepted before both harness.ts:387 and :392. +- `gemini` is no longer enumerated among the supported/available built-in harnesses (it disappears + from `BUILTIN_HARNESSES` and from the resolver's "Available harnesses" error listing). +- `codev doctor` no longer claims gemini is "supported for builders." Its `gemini` branch is + redefined to present the **retirement** for both roles, and it additionally **flags a `gemini` + builder configuration** with the same explanation (so users learn at config-check time, not only + when a spawn fails). The structured `issue:`/`recommendation:` fields are updated accordingly. +- The README no longer presents Gemini as a supported shell: the "other shells" line, the + autonomous-flags table, and the config example (both the architect and builder lines) are updated + to supported harnesses, with a plain statement that the built-in Gemini CLI harness is retired. +- Governance docs (`arch.md`, `lessons-learned.md`) reflect that gemini is retired as a harness + (updated in the Review phase per the hot/cold routing discipline). +- **Claude, Codex, OpenCode, and custom harnesses behave exactly as before**, and the entire + `agy` / `consult -m gemini` subsystem is untouched. + +## Stakeholders +- **Primary Users**: Codev users configuring a builder (or architect) harness in + `.codev/config.json` — especially anyone with an existing `gemini` config who will now get a clear + retirement message. +- **Secondary Users**: Architects spawning builders; adopters reading the README to choose a shell. +- **Technical Team**: Codev maintainers of the agent-farm harness subsystem (this builder) and the + external upstream maintainer who will review/merge the PR. +- **Business Owners**: Codev project owners (issue author / architect). + +## Success Criteria +- [ ] `gemini` is no longer registered or enumerated as a supported built-in harness — removed from + `BUILTIN_HARNESSES` and absent from the resolver's "Available harnesses" listing (criterion 1). +- [ ] Selecting `gemini` via **explicit** `builderHarness: "gemini"` fails with a clear message that + the option is **retired**, naming supported alternatives (criterion 2). +- [ ] Selecting `gemini` via **auto-detection** from `builder: "gemini …"` (string form) fails with + the same clear retirement message — specifically **not** the Claude harness and **not** + `undefined` (criterion 1 + 2; closes both #929-class footguns). +- [ ] The retirement holds through the real config integration paths, not just direct + `resolveHarness` calls: `getBuilderHarness`, the `--builder-cmd gemini` CLI override, and the + **array-form** builder command (`builder: ["gemini", "--yolo"]`) all fail closed with the + retirement message. +- [ ] The **architect** path is defined and covered: `getArchitectHarness` / `--architect-cmd gemini` + also fail closed with the retirement message (consequence of the shared resolver; stated + explicitly rather than left undefined). +- [ ] `codev doctor` no longer states gemini is "supported for builders"; its `gemini` branch + presents the retirement and **also flags a `gemini` builder config**, verified via the + structured `issue:`/`recommendation:` fields (a stabler assertion target than console text). +- [ ] The README no longer presents Gemini as a supported shell; the "other shells" line, the + autonomous-flags table, and **both** the architect and builder lines of the config example are + updated, with a plain retirement note. +- [ ] Claude, Codex, OpenCode, and custom harnesses resolve and spawn unchanged (criterion 3), + demonstrated by existing green tests for those paths. +- [ ] Each removed gemini test is **replaced by a retirement-behavior test** (rather than merely + deleted), so the retired paths are positively asserted. (Coverage is measured by + replacement, not by an absolute baseline delta.) +- [ ] Governance docs (`arch.md` / `lessons-learned.md`) updated to reflect the retirement. +- [ ] No *current, user-facing harness-selection* documentation still presents gemini as a supported + builder (see the scoped documentation criterion in Test Scenarios — historical artifacts and + the `consult -m gemini` lane are exempt). + +## Constraints +### Technical Constraints +- **Shared, role-agnostic resolver.** The retirement is implemented once in the shared + `resolveHarness` and therefore applies to *both* architect and builder resolution. This is a + deliberate design choice (see Clarifying Question 4), not a threaded role parameter. +- **Both resolution branches must be covered on the retired name**: the explicit-name path, the + auto-detected-name path (must not return `BUILTIN_HARNESSES['gemini']` → `undefined`, harness.ts:387), + and the no-match default (must not degrade to `CLAUDE_HARNESS`, :392). Intercept `gemini` before + both. +- **Fail closed, never mis-inject**: a retired harness must never launch under another harness's + role-injection mechanism, and must never resolve to `undefined`. +- **No changes to the `agy` consult lane, `consult -m gemini`, or `agy` architect support** (issue + Non-goals). This is strictly the harness resolver + its presentation. +- **Existing harnesses untouched**: claude/codex/opencode/custom resolution and spawning must be + behavior-identical after the change. +- **Framework-file mirroring**: any change to a framework doc shipped in `codev-skeleton/` must be + mirrored in both trees. (Verified: **no** skeleton doc currently presents gemini as a builder, so + the doc changes here are the top-level README + self-hosted `codev/resources/*` governance docs, + which have no skeleton twin — but re-grep both trees during implementation before claiming done, + per lessons-critical.) + +### Business Constraints +- **No self-merge**: the PR must be approved/merged by an external upstream maintainer; the change + must be self-contained and reviewable on its own. +- No time estimates (per protocol). + +## Assumptions +- The retirement is a **hard retirement of the built-in option**, not a temporary deprecation: + Codev will not present or affirm the built-in `gemini` harness as supported. +- **Standard/Enterprise and API-key access is served via a custom harness.** Users who still have a + working Gemini CLI (enterprise subscription or API-key auth) can define a **custom harness** in + `.codev/config.json` — the sanctioned extension point, which remains fully available (this is what + keeps criterion 3's "custom builder support" intact). The retirement targets the built-in `gemini` + *name*, not a user's own custom definition. +- **Already-running gemini builders are unaffected** — they are already launched; only *new* + selections are gated. Such sessions cannot be re-created once the upstream CLI is unavailable to + the user. +- **`afx reset` outcome — DECIDED (accepted).** After `gemini` leaves `BUILTIN_HARNESSES`, + `harnessFromLaunchScript` (reset/context.ts:414) will no longer recognize a pre-existing gemini + builder's launch script and will return `null` → `afx reset` reports "cannot determine harness" + and declines. This is acceptable and requires no extra handling: a retired harness cannot + context-reset anyway (only Claude declares `supportsContextReset`), and reset already refuses + unrecognized harnesses loudly. Recorded here as a decided outcome, not an open question. + +## Solution Approaches + +### Approach 1: Retirement sentinel in the shared resolver (Recommended) +**Description**: Introduce an explicit notion of a **retired harness name** (a small +retired-names registry carrying a per-name explanation). The shared `resolveHarness` consults it +early on **both** entry paths — when a name is supplied explicitly, and after command auto-detection +resolves a name (intercepting *before* the `return BUILTIN_HARNESSES[detected]` at harness.ts:387 +and the `return CLAUDE_HARNESS` default at :392) — and fails with a clear, specific retirement +message. `detectHarnessFromCommand` continues to recognize `gemini` so the auto-detect path lands on +the retirement message rather than falling through. The `GEMINI_HARNESS` provider and its registry +entry are removed, so gemini no longer appears among supported harnesses. Because the resolver is +role-agnostic, this covers architect and builder paths in one place. + +**Pros**: +- Single source of truth for "what is retired and why." +- Clear, identical retirement message on *every* selection path (explicit + auto-detect, builder + + architect), and guards the `undefined`-return path at :387. +- Eliminates the silent Claude fallback footgun (fails closed). +- Extensible: future retirements slot into the same mechanism. +- gemini disappears from the "available harnesses" enumeration → satisfies "no longer presented." + +**Cons**: +- Slightly more than a one-line delete; a partial implementation (covering only one branch) would + reopen a footgun, so all three resolver exits must be handled. +- Retires the architect path too — correct here, but a design point to state explicitly (done). + +**Estimated Complexity**: Low–Medium +**Risk Level**: Low + +### Approach 2: Throwing provider (mirror the OpenCode-architect pattern) +**Description**: Keep a `gemini` entry in the built-in registry but replace its provider with one +whose role-injection methods **throw** the retirement error (mirroring how the OpenCode harness +throws when misused as an architect). Auto-detection unchanged. + +**Pros**: +- Minimal structural change; reuses an existing in-repo precedent. +- Auto-detect still finds `gemini`, and the throw fires when role injection is attempted. + +**Cons**: +- Fails **late** (deep in the spawn path at role-injection time) rather than at resolution — worse + diagnostics and a later failure point. +- Keeps a "live-looking" registry entry: gemini still appears in the resolver's "available + harnesses" list and in every consumer that enumerates `BUILTIN_HARNESSES` (e.g. `afx reset`), + contradicting "no longer presented as supported." +- The error only surfaces on paths that actually call role injection. + +**Estimated Complexity**: Low +**Risk Level**: Medium (weaker on criterion 1; later failure) + +### Approach 3: Hard removal only (Rejected) +**Description**: Delete `GEMINI_HARNESS`, its registry entry, and the auto-detect case, with no +retirement sentinel. + +**Pros**: +- Least code. + +**Cons**: +- Explicit `builderHarness: "gemini"` → generic "Unknown harness" error (no retirement explanation + → **fails criterion 2**). +- Auto-detected `builder: "gemini …"` → **silently** resolves to the Claude harness and injects + Claude args into the gemini command (**fails criterion 1**, dangerous #929-class mismatch). If + instead only the registry entry is removed, the auto-detect path returns `undefined` → TypeError. + +**Rejected**: does not meet the acceptance criteria and reintroduces a known footgun. + +**Recommendation**: **Approach 1.** It is the only approach that satisfies all three acceptance +criteria, closes both silent-failure footguns, and covers the shared architect/builder paths in one +place, at modest cost. The plan also corrects the stale `codev doctor` messaging (redefining its +`gemini` branch and adding builder-side flagging) and the README presentation as part of the same +change. + +## Open Questions + +### Critical (Blocks Progress) +- [ ] None. Approach, scope, and the architect-path/doctor/afx-reset decisions are all resolved + above. + +### Important (Affects Design) +- [ ] None outstanding. (The doctor question — "should a `gemini` *builder* config be flagged?" — is + **decided: yes**, and is now a success criterion. The architect-path outcome is **decided: + role-agnostic retirement**.) + +### Nice-to-Know (Optimization) +- [ ] Exact wording of the single retirement message string (must name the 2026-06-18 consumer-tier + end-of-availability and the alternatives). Deferred to the plan; not blocking. + +## Performance Requirements +N/A — this change removes/gates a code path and edits docs; it has no runtime performance dimension +(no measurable response-time, throughput, or resource-usage impact). + +## Security Considerations +- **Fail closed (primary safety property)**: the retirement must never cause a `gemini` command to + be launched under another harness's role-injection mechanism (no Claude `--append-system-prompt` + into a non-Claude command — the Issue #929 class of bug), and must never resolve to `undefined` + (a TypeError is an unclean failure, not a safe one). Clear, early failure on every resolver exit + is the safe behavior, and is encoded as explicit tests. +- **No new input surfaces, auth, or data-handling changes.** Authentication/authorization model: N/A + (unchanged). + +## Test Scenarios +### Functional Tests +1. **Explicit retired name**: resolving with explicit `builderHarness: "gemini"` throws a clear + error containing the retirement explanation and supported alternatives (not a generic "Unknown + harness"). +2. **Auto-detected retired command (string form)**: resolving from `builder: "gemini --yolo"` (no + explicit `builderHarness`) throws the **same** retirement error — asserting it returns neither + `CLAUDE_HARNESS` nor `undefined`. +3. **Config integration paths** (per the "every path" concern): `getBuilderHarness` with a gemini + config, the `--builder-cmd gemini` CLI override, and the **array-form** builder command + (`builder: ["gemini", "--yolo"]`) each fail closed with the retirement message. +4. **Architect path**: `getArchitectHarness` / `--architect-cmd gemini` also fail closed with the + retirement message (shared-resolver consequence). +5. **Unrelated unknown harness still generic**: an unrelated unknown name (e.g. `"frobnicate"`) + still throws the ordinary "Unknown harness" error (retirement handling is specific to retired + names, not a catch-all). +6. **Unaffected harnesses**: claude, codex, opencode, and a representative custom harness each still + resolve to their correct provider and produce correct role injection (regression guard for + criterion 3). +7. **Doctor guidance**: `codev doctor` no longer asserts gemini is "supported for builders"; for a + gemini config it emits the retirement via its structured `issue:`/`recommendation:` fields + (assert on those fields, not console text). + +### Non-Functional Tests +1. **Scoped documentation consistency**: a check confirms no **current, user-facing + harness-selection** doc still presents `gemini` as a supported builder shell (README config + example / autonomous-flags / "other shells" line; governance harness docs). **Explicitly + exempt**: historical artifacts (`codev/specs`, `plans`, `reviews`, `projects`, `docs/releases/*`, + release notes) and every `consult -m gemini` / `agy` consult-lane reference (out of scope by the + Non-goals). +2. **Coverage by replacement**: each removed gemini-specific test has a corresponding + retirement-behavior test (measured as replacement, since an absolute coverage baseline is not + meaningful for a removal). +3. **Security/behavioral (fail-closed encoded as tests)**: explicit assertions that the retired + paths return neither `CLAUDE_HARNESS` nor `undefined` on both the auto-detect and default exits. + +## Dependencies +- **External Services**: None. +- **Internal Systems**: The agent-farm harness subsystem (`agent-farm/utils/harness.ts` resolver, + registry, and detector), `codev doctor` (`commands/doctor.ts`), the config typing for + `builderHarness`/`architectHarness` (`agent-farm/types.ts`, `lib/config.ts`), and the `afx reset` + harness-recognition path (`reset/context.ts`, sanity-checked for the decided edge case). +- **Libraries/Frameworks**: None added. + +## References +- Issue #1338 — Retire Gemini CLI as a builder harness (this spec's source). +- Issue #778 — Gemini CLI retirement and migration of the consultation lane to `agy`. +- Issue #929 — gemini made builder-only; introduced override-aware harness resolution (the + silent-mismatch class of bug this spec must avoid reopening). +- Issue #1063 — possible `agy` architect support (out of scope; does not cover builder support). +- Google Gemini Code Assist deprecations (consumer-tier CLI access ended 2026-06-18; + Standard/Enterprise + API-key reportedly remain) — basis for the "Codev product retirement" framing. +- `packages/codev/src/agent-farm/utils/harness.ts` — current registry, provider, detector, resolver + (orientation only; edits belong in the plan). +- `codev/resources/arch.md` (harness section) — governance description of harness support. + +## Risks and Mitigation +| Risk | Probability | Impact | Mitigation Strategy | +|------|------------|--------|---------------------| +| Partial fix covers only one resolver exit, leaving `builder: "gemini …"` to silently return Claude (harness.ts:392) or `undefined` (harness.ts:387) | Medium | High | Test scenarios 2 + 3 + Non-Functional 3 assert the retired paths return neither `CLAUDE_HARNESS` nor `undefined`; made a required success criterion. | +| Retiring the shared resolver unintentionally breaks a *supported* architect (codex/claude) | Low | High | Sentinel keys only on the retired name (`gemini`); scenario 6 regression-guards claude/codex/opencode/custom for both roles. | +| Scope creep into the `agy`/`consult -m gemini` lanes | Low | Medium | Constraints + Non-goals pin the boundary; documentation criterion explicitly exempts the consult lane. | +| Stale `doctor` message left asserting "supported for builders" | Medium | Medium | Explicit success criterion + functional test 7 on doctor's structured fields. | +| Over-broad "no lingering presentation" criterion flags historical artifacts / consult refs | Medium | Low | Documentation criterion is scoped to *current user-facing harness-selection* docs; historical + consult refs explicitly exempt. | +| A framework doc in `codev-skeleton/` missed during doc updates | Low | Low | Verified no skeleton doc presents gemini as a builder; re-grep both trees during implementation before claiming done. | + +## Expert Consultation +**Date**: 2026-08-03 +**Models Consulted**: Gemini (via agy), Codex (GPT-5.6 Sol), Claude Opus 5 — SPIR spec review, iteration 1. +**Verdicts**: Gemini APPROVE; Codex REQUEST_CHANGES; Claude REQUEST_CHANGES. +**Sections Updated (this iteration)**: +- *Problem Statement / Clarifying Questions*: reframed as a **Codev product retirement** and corrected + the availability wording (consumer tiers ended; Standard/Enterprise + API-key remain) — Codex. +- *Problem Statement / Approaches / Security*: split the naive-removal footgun into its **two** precise + failure modes (silent Claude fallback vs. `undefined`/TypeError) and required guarding both — Claude. +- *Desired State / Success Criteria / Constraints / Clarifying Q4*: made the **role-agnostic** (shared + `resolveHarness`) retirement explicit, covering the architect path and the README architect line — Claude. +- *Desired State / Success Criteria / Test 7*: defined doctor's new end state, added **builder-side + flagging**, and switched assertions to the structured `issue:`/`recommendation:` fields — Codex + Claude. +- *Success Criteria / Test Scenarios*: added `getBuilderHarness`, `--builder-cmd gemini`, and + **array-form** builder-command coverage; reframed coverage as **replacement** — Codex + Claude. +- *Assumptions*: recorded the **`afx reset`** outcome as a decided/accepted result (moved out of Open + Questions) — Claude + Gemini. +- *Test Scenarios (Non-Functional 1)*: scoped the documentation criterion to exempt historical + artifacts and the `consult -m gemini` lane — Codex. + +Note: All consultation feedback has been incorporated directly into the relevant sections above; the +rebuttal document records the point-by-point disposition. + +## Approval +- [ ] Technical Lead Review +- [ ] Product Owner Review +- [ ] Stakeholder Sign-off +- [x] Expert AI Consultation Complete (iteration 1; re-verification pending after this revision) + +## Notes +- The **custom harness** mechanism remains the sanctioned extension point for any user who still has + access to a (e.g. enterprise / API-key) Gemini CLI: the retirement targets the built-in `gemini` + name, not a user's own custom harness definition. +- This spec fixes *behavior* and *acceptance*, deliberately leaving implementation mechanics (exact + files, the shape of the retired-name registry, the precise message string) to the plan. diff --git a/codev/state/spir-1338_thread.md b/codev/state/spir-1338_thread.md new file mode 100644 index 000000000..0c4c3883d --- /dev/null +++ b/codev/state/spir-1338_thread.md @@ -0,0 +1,605 @@ +# spir-1338 — Retire Gemini CLI as a builder harness + +## Phase: Specify (started) + +Strict-mode SPIR builder. Porch drives; I write artifacts + `porch done`. + +### Architect constraint (2026-08-03T19:12Z) +We are NOT cluesmith/codev maintainers on this repo. The PR needs an upstream +maintainer to approve/merge — do NOT expect self-merge. Spec/plan/impl must stand +on their own for external review; architect handles merge coordination. + +### Scope (issue #1338) +Retire the standalone **Gemini CLI (`gemini`) builder harness**. NON-goals (leave +alone): `agy`/Antigravity consult lane, `consult -m gemini`, `agy` architect (#1063). +No "Baked Decisions" section in the issue body. + +### Verified surface (builder-harness only) +Core: `packages/codev/src/agent-farm/utils/harness.ts` + - 177-186 `GEMINI_HARNESS` (GEMINI_SYSTEM_MD injection) + - 212 `gemini:` in `BUILTIN_HARNESSES` + - 338 `basename.includes('gemini')` in `detectHarnessFromCommand` + - 358-393 `resolveHarness` (throws generic "Unknown harness" on miss; auto-detect + branch silently returns CLAUDE_HARNESS on undetected — the footgun) + - 4-6 module header comment +Config/types: `types.ts:207`, `lib/config.ts:26,28` — `builderHarness?: string` (free-form). +Doctor: `doctor.ts:816-828` — warns only about gemini-as-ARCHITECT; message + "gemini is supported for builders, not architects" becomes STALE/wrong after retirement. +Reset: `reset/context.ts:414` iterates `Object.keys(BUILTIN_HARNESSES)` to recognize a + running builder's harness (edge case if gemini leaves the registry). +Tests: `harness.test.ts`, `harness-integration.test.ts`, `discover-resume-session.test.ts`, + `config.test.ts:138-141`. +Docs (user-facing): `README.md:392,436,448-460` (config example presents gemini builder; + prose already has a soft "will stop working / follow-up" caveat — strengthen to "retired"). +Governance (Review-phase updates): `codev/resources/arch.md:291,311,313,317`, + `lessons-learned.md:80`. + +### Corrections to Explore agent's inventory +- FALSE: `packages/codev/skeleton/resources/commands/agent-farm.md:618-627` "Builder + harnesses" section naming gemini. That dir does NOT exist on disk; canonical + `codev-skeleton/resources/commands/agent-farm.md` has no harness/gemini section. + → No skeleton doc presents gemini as a builder. No dual-tree doc mirror needed for + this change (only self-hosted `codev/resources/*` governance docs, no skeleton twin). + +### Design through-line (for Solution Approaches) +Must recognize `gemini` as a RETIRED name and fail with a SPECIFIC retirement message at +BOTH resolution paths — explicit `builderHarness: "gemini"` AND auto-detected +`builder: "gemini ..."`. A pure delete (a) throws a generic "Unknown harness" on the +explicit path (no retirement explanation) and (b) SILENTLY falls back to CLAUDE_HARNESS on +the auto-detect path (#929-class dangerous mismatch). Recommend a retirement-sentinel in +the resolver + correct doctor's stale builder-support message + fix README example. + +## Specify — consult iteration 1 (2026-08-03) +Verdicts: Gemini APPROVE; Codex REQUEST_CHANGES; Claude REQUEST_CHANGES. All substantive +points accepted + incorporated (rebuttal: `codev/projects/.../1338-specify-iter1-rebuttals.md`). +Key decisions locked into the spec: +- **Role-agnostic retirement**: `resolveHarness` (harness.ts:358) has NO role param and is shared + by getArchitectHarness + getBuilderHarness → sentinel retires gemini for BOTH roles. Correct + because the CLI is dead for both tiers regardless of role AND gemini-architect is already + unsupported (doctor warns). Slight broadening beyond "builder-only" mechanism — will flag to + architect at the gate. +- **Two footgun modes** (not one): remove detector+registry → silent CLAUDE_HARNESS fallback + (harness.ts:392); remove registry only → undefined/TypeError (harness.ts:387). Sentinel must + guard BEFORE both exits. +- **Codev product retirement** framing (Codex): consumer tiers (free/Pro/Ultra) ended 2026-06-18; + Standard/Enterprise + API-key remain → served via CUSTOM HARNESS escape hatch. Not "CLI is gone." +- **Doctor**: redefine the gemini branch (premise inverts), ADD builder-side flagging, assert on + structured issue:/recommendation: fields (doctor.ts:826), not console text. +- **afx reset**: DECIDED/accepted — gemini leaves BUILTIN_HARNESSES → reset won't recognize a + pre-existing gemini builder → declines. Fine (retired harness can't reset anyway). +- **Tests**: cover getBuilderHarness, --builder-cmd gemini, array-form builder cmd, architect twin; + coverage-by-replacement; doc-consistency criterion scoped (exempt historical + consult lane). +Next: porch done → re-verification. Expecting spec-approval gate soon → STOP for human approval. + +## Spec APPROVED (2026-08-03) + architect confirmation +Human approved spec-approval gate. Architect confirmed role-agnostic retirement (one sentinel, +no role param), independently verified the footgun analysis, reaffirmed agy/consult out of scope ++ external-maintainer merge (no self-merge). + +## Plan — consult iteration 1 (2026-08-03) +Verdicts: Gemini APPROVE; Codex REQUEST_CHANGES; Claude COMMENT. All points accepted; restructured +3→4 phases. Rebuttal: `codev/projects/.../1338-plan-iter1-rebuttals.md`. Key corrections (all +re-verified against source): +- **Precedence FIX**: resolveHarness is built-in→custom→unknown today; keep that. Correct order: + built-in → custom → retired → generic throw. (My draft wrongly put custom before built-in.) +- **Spawn preflight**: spawn.ts resolves builder harness at :471 AFTER createWorktree(:429/431) + + initPorch(:442). Need preflight BEFORE state mutation → no orphaned worktree/porch on rejected + gemini spawn. Test asserts no state created. +- **4 getArchitectHarness sites** (tower-utils.ts): :179 buildArchitectArgs (launch), :291 + siblingRegistrationIsLive (PREDICATE — guard→return false, don't throw), :357 resolveArchitectLaunch + (launch), :509 freshLaunch closure (launch). Per-site behavior, not blanket. +- **Doctor NOT override-aware**: reads raw persisted shell.* (not CLI/env). Scope to persisted-config. +- **2nd BUILTIN_HARNESSES consumer**: harnessProviderFor (agent-farm/commands/reset/context.ts:468) + → null post-removal → reset refuses (accepted, no code change). Full path corrected. +- **CHANGELOG**: [Unreleased] section is live → add breaking-change entry (Phase 4). +- Escape hatch: custom `gemini` resolves ONLY via explicit builderHarness:"gemini"; auto-detect stays retired. + +4 phases: (1) resolver core+tests (2) fail-closed spawn/launch (no orphaned state / no Tower crash) +(3) doctor (4) README+CHANGELOG. Governance arch/lessons → Review phase. +Next: porch done → re-verify → plan-approval gate → STOP for human approval. + +## Plan APPROVED (2026-08-03) → Implement phase_1 +Architect cleared plan-approval and locked two decisions (do NOT re-open): +1. Role-agnostic retirement via shared resolver — no role param. +2. Full architect-side handling in tower-utils.ts: guard siblingRegistrationIsLive (:291) + → return false AND fail gemini-architect launches with the retirement (scope catch to the + retirement error, rethrow others; regression-test claude/codex). [phase 2] +Governance arch/lessons deferred to Review. Small self-contained commits (external merge). + +### phase_1 — retire gemini in the shared resolver (harness.ts + resolver/config tests) +Blast radius verified: GEMINI_HARNESS imported only by harness.ts (prod) + harness.test.ts, +harness-integration.test.ts, discover-resume-session.test.ts. config.test.ts uses the 'gemini' +STRING (not the export). All other gemini test refs = consult/bench/image lanes (out of scope) +or doctor.ts (phases 2–3). Two comment-only refs to sweep: spawn-worktree.test.ts:344, +tower-instances.test.ts:612 ("codex/gemini builders…"). +Implementation: +- harness.ts: + RETIRED_HARNESSES registry (gemini→message w/ 2026-06-18 cause, claude/codex/ + opencode alternatives, custom-harness escape hatch, #1338) + isRetiredHarness/getRetirement/ + throwRetired (own-property check, prototype-safe). Removed GEMINI_HARNESS + its BUILTIN entry. + Kept gemini in detectHarnessFromCommand. resolveHarness: explicit = builtin→custom→retired→ + generic throw; auto-detect = retired-check BEFORE BUILTIN_HARNESSES[detected]. Fail closed — + never CLAUDE_HARNESS, never undefined. Updated module header + resolver doc comment. +- Tests updated: harness.test.ts (retirement + escape-hatch + built-in-not-shadowed + + isRetiredHarness/getRetirement incl. prototype-safety), harness-integration.test.ts + (Scenario 3 → retirement; kept codex no-GEMINI_SYSTEM_MD guard; Scenario 8 → call-site + propagates retirement via mockImplementationOnce(throwRetired)), discover-resume-session.test.ts + (gemini regression guard → opencode, same intent), config.test.ts (vi.hoisted mutable shell + mock → --builder-cmd/--architect-cmd/explicit builderHarness/array-form all fail closed + + message content; #929 builder test re-pointed gemini→codex to keep override-awareness). + Swept 2 comment-only refs (spawn-worktree.test.ts, tower-instances.test.ts). +- Env gotcha (recorded for siblings): fresh worktree had NO node_modules and codev-core + was unbuilt. Fix: `pnpm install --frozen-lockfile` (root) then build codev-core BEFORE codev + (`pnpm --filter @cluesmith/codev-core build`). Without core built, ~all unit suites fail with + `Cannot find package '@cluesmith/codev-core/*'` + `Skeleton directory not found` — purely + environmental, not code. Also: `pnpm test -- run ` injects a stray `--` that makes + vitest ignore path filters; use `pnpm --filter @cluesmith/codev exec vitest run `. +- VERIFIED: `pnpm --filter @cluesmith/codev build` exit 0 (tsc clean). 4 target files 98/98. + Full unit suite (excl. e2e) 4116 passed / 48 pre-existing skips / 0 failed. GEMINI_HARNESS + fully gone (grep clean). Next: porch check → porch done (build-complete) → 3-way consult. + +### phase_1 CONSULT — iter1 UNANIMOUS APPROVE (2026-08-03) +Gemini APPROVE/HIGH, Codex APPROVE/HIGH, Claude APPROVE/HIGH. No change requests. Porch advanced +to phase_2. NOTE: porch's chore commits only touch status.yaml — the builder commits the phase CODE +(phase_2 precondition: "previous phase committed"). Committing phase_1 code as its own commit. + +## phase_2 — fail closed at spawn/launch boundaries (no orphaned state, no Tower crash) +Confirmed line refs vs source: +- spawn.ts worktree-creating entry points: spawnSpec(:314), spawnTask(:500), spawnProtocol(:575), + spawnWorktree(:658), spawnIssueDrivenBuilder(:718). spawnShell(:633) creates NO worktree. + getBuilderHarness currently at :471 / :850 (AFTER state creation). Plan's ~:506/:583/:662/:764 + drifted; real functions above. +- tower-utils.ts getArchitectHarness sites: buildArchitectArgs(:174 def,:179 call), + siblingRegistrationIsLive(:286 def,:291 call — PREDICATE, guard→false), + resolveArchitectLaunch(:334 def,:357 call), freshLaunch closure(:509 call). +Design: new `assertBuilderHarnessNotRetired(workspaceRoot?)` in config.ts (mirrors resolveHarness +precedence: builtin→return, custom-same-name→return escape hatch, retired→throwRetired, unknown→ +defer to later call). Call it at top of each worktree-creating spawn fn BEFORE ensureDirectories/ +createWorktree/initPorch. Architect: guard siblingRegistrationIsLive (catch retirement→false, +rethrow others); launch boundary (buildArchitectArgs) already throws retirement via getArchitectHarness +— make it a clean scoped error. Regression-test claude/codex both roles. + +### phase_2 FINALIZED design (post source-trace, 2026-08-03) +Traced every caller of the 5 spawn handlers + 4 getArchitectHarness sites. Refinements vs draft: +- **SPAWN preflight = ONE guard in the `spawn()` DISPATCHER** (spawn.ts:921, after getSpawnMode), + gated `if (mode !== 'shell')`, before `handlers[mode]()`. Cleaner + safer than per-fn: ALL 7 + createWorktree calls live in the 5 handlers, ALL under the dispatcher; shell is the only + worktree-less mode. CRUCIAL: `createWorktree` internally calls getBuilderHarness at + spawn-worktree.ts:912 (BEFORE spawn.ts:471) — so the preflight MUST precede handler dispatch or a + gemini spawn orphans a half-built worktree. `assertBuilderHarnessNotRetired` already in config.ts + (uncommitted), re-exported via utils barrel (`export * from './config.js'`). Add to spawn.ts:20 import. +- **siblingRegistrationIsLive (tower-utils.ts:291)**: real guard — try getArchitectHarness, catch + RetiredHarnessError→return false (retired reg not live → reconcile prunes it), rethrow others. + Verified: uncaught throw here is caught by tower-instances.ts:809 `catch(siblingErr)` but aborts the + WHOLE sibling-reconcile pass for ALL architects — guard also stops the gemini row from reaching the + addArchitect launch at :804 (pruned+continue at :797/:802 first). +- **Launch sites throw = correct fail-closed** (retired architect must not launch; RetiredHarnessError + .message IS the full retirement text). Entry-point surfacing: + · launchInstance (tower-instances.ts:564) → already clean via try/catch :814 → `{success:false, + error:"Failed to launch: "}`. No change. + · **addArchitect (tower-instances.ts:1070)** → NOT wrapped; throws out to tower-routes.ts:547 (HTTP) + + workspace-add-architect CLI. GAP the snapshot missed. FIX: scoped try/catch around + resolveArchitectLaunch → RetiredHarnessError→return `{success:false, error: err.message}`, rethrow + others. Mirrors launchInstance, scoped. No persistent state created before :1070 (name/cmd resolve + only) → clean bail. + · architect() no-Tower CLI (architect.ts:31 buildArchitectArgs) → throw propagates to afx top-level + (prints .message, same path as today's "Unknown harness"). No new gap; leave as-is. + · freshLaunch.next() (tower-utils.ts:509) → only a shellper rerun of an ALREADY-running architect; + unreachable for gemini (initial launch threw first). No change. +- **buildArchitectArgs (:179)**: fail-closed DOC COMMENT only — the getArchitectHarness throw is + intentional; re-wrapping would just duplicate the message. Callers surface `.message`. +- Imports: tower-utils.ts:18 add `RetiredHarnessError` (value) from ../utils/harness.js; + tower-instances.ts add same import. +- Reset consumers (reset/context.ts:414/:468): NO change (plan-verified degrade). + +### phase_2 IMPLEMENTED + VERIFIED (2026-08-03) +Production edits: +- spawn.ts: import `assertBuilderHarnessNotRetired`; dispatcher guard `if (mode !== 'shell')` before + `handlers[mode]()`. +- tower-utils.ts: import `RetiredHarnessError`; siblingRegistrationIsLive try/catch + (RetiredHarnessError→false, rethrow); buildArchitectArgs fail-closed doc comment. +- tower-instances.ts: import `RetiredHarnessError`; addArchitect scoped try/catch around + resolveArchitectLaunch (retirement→`{success:false,error}`, rethrow others). +- (config.ts assertBuilderHarnessNotRetired + harness.ts RetiredHarnessError were the pre-existing + uncommitted phase_2 groundwork — now part of this commit.) +Tests (+12, coverage-by-addition): +- config.test.ts (+6): assertBuilderHarnessNotRetired — aborts on gemini (cmd/explicit/array), + no-op for claude+codex, DEFERS (no throw) on unknown harness. +- tower-utils.test.ts (+4): siblingRegistrationIsLive→false (no throw) for gemini; buildArchitectArgs + throws retirement for gemini, no-throw for codex+claude. (Fixed 1 over-strict assert: buildArchitectArgs + loads the role from BUNDLED skeleton roles, so codex injects `-c model_instructions_file` — assert + baseArgs preserved at front, not equality.) +- tower-instances.test.ts (+2): launchInstance + addArchitect both return `{success:false,/retired/i}` + for a gemini architect, createSession NOT called (HOME isolated so global config can't mask). +E2E manual verify (strongest "no orphaned state" proof): scratchpad/verify-spawn-gemini.mjs drives the +REAL built `spawn()` against a REAL temp workspace (`.codev/config.json` builderHarness=gemini, real +config loader, no mocks) → threw RetiredHarnessError + 0 `.builders/` + 0 codev/projects state. PASS. +Results: `pnpm --filter @cluesmith/codev build` exit 0 (tsc clean). Full unit suite 4128 passed / 48 +pre-existing skips / 0 failed (was 4116; +12 mine). freshLaunch(:509) left untouched — unreachable for +gemini (launchInstance/addArchitect throw at resolveArchitectLaunch BEFORE buildArchitectFreshLaunch is +even constructed). + +### phase_2 committed + integration test added +- d200edf6 `[Spec 1338][Phase: phase_2] feat: fail closed at spawn/launch boundaries` (prod + 3 unit files). +- porch check/done → build+tests green → build-complete chore (906ff8ca). +- Promoted the scratchpad e2e proof into a COMMITTED regression test: spawn-retirement.test.ts drives the + REAL spawn() (real temp git workspace, real config loader, no mocks) → rejects gemini with the + retirement AND asserts 0 `.builders/` + 0 codev/projects afterward. Catches the regression class the + unit tests miss (guard moved below state creation). Confirmed importing real spawn() in vitest is clean + (no side-effect hang) — the spawn.test.ts "avoid side-effect import" caution doesn't bite here because + the guard throws before Tower/GitHub are touched. Committed as a 2nd phase_2 commit (porch chore was on + top of d200edf6, so amend wasn't clean). Next: porch next → 3-way consult (verification). + +### phase_2 CONSULT iter1: Gemini APPROVE, Claude APPROVE/HIGH, Codex REQUEST_CHANGES/HIGH +Codex found TWO REAL gaps my "unreachable" analysis missed — both on RESTART/RECONNECT paths (not +initial launch), reachable when a config is edited to gemini mid-session OR Tower restarts reading a +gemini config. Verified against source (both confirmed): +1. FAIL-OPEN: resolveArchitectRestart (tower-utils.ts:455) propagates RetiredHarnessError, but BOTH + consumers (tower-terminals.ts:717 _reconcileTerminalSessionsInner, :982 getTerminalsForWorkspace) + catch ALL harness errors and fall back to restartOptions = { command: cmdParts[0] (= gemini), + args: cmdParts.slice(1) } — actually relaunching the retired gemini binary (no role injection). +2. UNCAUGHT THROW: buildArchitectFreshLaunch.next() (tower-utils.ts:533) resolves the harness unguarded; + session-manager.ts:1175 calls freshLaunch?.next() with NO try/catch → clean-exit relaunch of an + architect whose config flipped to gemini throws into the exit handler → Tower exception. +Claude(APPROVE) flagged #2 as non-blocking ("unreachable in practice"); Codex(HIGH) is right it IS +reachable via config-change-before-clean-exit. Fixing both (Codex asks: fail closed + clean error +surfacing + regression tests for reconnect AND clean-exit paths). +FIX (all logic in tower-utils.ts; tower-terminals just calls the new helper): +- Fix 2: guard getArchitectHarness in buildArchitectFreshLaunch.next() → RetiredHarnessError → log + + return plain { args: baseArgs, env: baseEnv } (no throw, no retired injection; baseArgs are the ORIGINAL + supported-harness launch's, so never gemini). +- Fix 1: extract the 2 duplicated consumer try/catch blocks into ONE exported helper + buildArchitectReconnectRestartOptions({workspacePath, architectName, cmdParts, cleanEnv, + includeFreshLaunch, log}) in tower-utils (co-located w/ resolveArchitectRestart family; imports + ReconnectRestartOptions type from session-manager — no cycle). Fails CLOSED on RetiredHarnessError + (return undefined → session reconnects to a live process if any, but NEVER auto-restarts into gemini); + keeps the plain-command fallback for OTHER harness errors. Both tower-terminals sites call it + (includeFreshLaunch: site1=true, site2=false — preserves each site's behavior; unifies only the + cosmetic Resuming-log text). +- Tests (tower-utils.test.ts): buildArchitectFreshLaunch.next gemini→plain/no-throw + codex/claude + unchanged; buildArchitectReconnectRestartOptions gemini→undefined, codex/claude→opts w/ command, + unknown→plain fallback, includeFreshLaunch toggles freshLaunch. + +### phase_2 iter1 fixes COMMITTED + rebuttal written (2026-08-03, resumed session) +Resumed from state-snapshot.md. Re-verified the uncommitted Codex fixes against source (diffs clean; +no stray usages of the 3 dropped tower-terminals imports) and independently re-ran checks BEFORE +committing: build exit 0 (tsc+vite); tower-utils.test.ts 61/61 (incl. +7 new #1338 tests); consumer +suites tower-terminals+tower-instances+bugfix-430-tower-restart 133/133 (refactor = no regression). +- Committed `11527838` [Spec 1338][Phase: phase_2] fix: fail closed on architect restart/reconnect. +- Wrote 1338-phase_2-iter1-rebuttals.md: both Codex points accepted+fixed; Claude's non-blocking + (a) dispatcher-centralized preflight + (b) session-manager.ts:1175 freshLaunch.next() — note (b) is + the SAME site as Codex C2, now actively guarded (not "unreachable by design"). Both recorded for the + final review doc. Gemini/Claude approvals stand. +NOTE: line refs in the iter1 consult note above are pre-fix (tower-utils.ts:455/533, etc.); post-fix +refs are in the rebuttal (helper def :589, freshLaunch guard :544, reconnect guard :630). +Next: porch done 1338 → re-verification → iter2 3-way consult on the fixed code. + +### phase_2 CONSULT iter2: Gemini APPROVE, Claude APPROVE/HIGH, Codex REQUEST_CHANGES/HIGH +Codex found a THIRD, deeper gap (C3) that the iter1 C2 fix missed AND both approving reviewers missed. +Verified real against source: +- C3 FAIL-OPEN (clean-exit relaunch): my iter1 C2 fix returned {args,baseEnv} from + buildArchitectFreshLaunch.next() on retirement — stops the throw but NOT the relaunch. FreshLaunch.next() + can only change args/env; session-manager.ts RETAINS session.options.command and respawns it. If the + retained command IS the retired binary (custom `gemini` harness later removed, or config flip before a + clean exit), gemini is respawned. Claude's iter2 "safe: freshLaunch only wired when harness resolved + cleanly" reasoning missed that a CUSTOM gemini resolves cleanly at wire time. +FIX (commit 9ec14c4d): +- Extended FreshLaunch contract with a fail-closed `{ stop: true }` (session-manager.ts:73) — the only way + a factory can prevent a respawn it can't re-command. Clean-exit handler honors it (:1184): no respawn, + removeDeadSession, surface reason via session-gave-up → PtySession.notice (same UX as fast-exit valve). +- buildArchitectFreshLaunch.next() returns { stop: true } on RetiredHarnessError (tower-utils.ts:551). +- E2E regression (session-manager.test.ts:2493): real clean-exit handler, retained command:"gemini", + {stop:true} freshLaunch → spawn NOT called, session removed, `retired` reason surfaced. (iter1 test only + checked returned args — missed the retained command, exactly Codex's ask.) +- Addressed Claude's non-blocking nit: isolateHarnessEnv() (HOME + TOWER_ARCHITECT_CMD/TOWER_BUILDER_CMD) + wired into all three retirement describes in tower-utils.test.ts. +- Blast radius: FreshLaunch is architect-only (1 implementer, 1 consumer) — contained core change. +- Verified: build exit 0; tower-utils+session-manager 152/152; consumers 133/133. +Wrote 1338-phase_2-iter2-rebuttals.md. Next: porch done → re-verify → iter3 consult. + +### phase_2 CONSULT iter3: UNANIMOUS APPROVE (Gemini APPROVE, Codex APPROVE/HIGH, Claude APPROVE/HIGH) +Codex (the C1/C2/C3 finder) now: "Phase 2 fails closed across builder spawn, architect launch, reconnect, +and clean-exit paths, with adequate regression coverage. KEY_ISSUES: None." Claude independently verified +tsc exit 0 + 283/283 affected suites. All four retirement boundaries fail closed: spawn preflight (builder), +buildArchitectArgs launch (architect), buildArchitectReconnectRestartOptions→undefined (reconnect), +buildArchitectFreshLaunch.next()→{stop:true} (clean-exit). Phase_2 DONE. +CLAUDE's 4 non-blocking notes → carry to REVIEW doc (codev/reviews/1338-*.md), do NOT fix now (all 3 +approved; fixing = wasteful re-consult of an approved phase): +1. siblingRegistrationIsLive→false PRUNES persisted sibling rows for a retired-architect config (matches + approved plan; deliberate trade-off; `true`="unlaunchable not dead" is the conservative alt if it bites). +2. CRASH-restart path (non-clean exit) respawns launch-time-baked args, never re-resolves harness → a + retained custom-`gemini` (later removed) can crash-restart gemini. IN-SPEC ("already-running sessions + unaffected"), no mis-injection risk (baked args are from that custom harness). Documented trade-off, + NOT a gap — distinct from C3's clean-exit path which MINTS a fresh session (re-resolves → retirement matters). +3. --shell exemption correct (startShellSession resolves no harness; gemini shell.builder runs bare; Phase 3 + doctor covers user education). +4. Minor test gap: no POSITIVE test that a supported-harness config PASSES the preflight — optional 1-liner + in spawn-retirement.test.ts to lock the "doesn't over-block" half. Consider in Review-phase refinement. +Next: porch next → advance to phase_3 (doctor: retirement guidance + builder-side flagging). + +## phase_3 — codev doctor: retirement guidance + builder-side flagging +Porch advanced phase_2→phase_3 (89d25fca). doctor.ts is at packages/codev/src/commands/doctor.ts +(NOT commands/doctor.ts under agent-farm — plan/spec path was loose); stale gemini branch was at :816-828 +("The Gemini CLI is retiring (#778); gemini is supported for builders, not architects"). +IMPLEMENTED (packages/codev/src/commands/doctor.ts): +- Added getRetirement to the harness import (already had detectHarnessFromCommand). +- Factored a shared local `resolvedShellHarness(role)` helper (raw shell./Harness, array-or-string, + via detectHarnessFromCommand) used by BOTH architect+builder branches → no drift. NOT override-aware + (reads raw persisted config, not CLI/env) — matches spec's persisted-config scope. +- getRetirement() truthiness = single source of truth for "retired" (returns msg iff retired). Architect + branch: opencode (unchanged) → retired (both-role framing + full retirement msg, replaces the inverted + "builder-only" text) → codex (unchanged). NEW builder branch: flags a retired builder harness. +- Updated structured issue:/recommendation: strings (stable assertion target): " configured as + {architect,builder} shell (harness retired)". +TESTS (doctor.test.ts, +4, new describe 'shell-harness retirement flagging (#1338)'): gemini builder → +structured issue/rec + 2026-06-18 surfaced; gemini architect → structured issue/rec + asserts NO +"supported for builders"/"builder-only"; explicit builderHarness:gemini detected; supported (claude +builder+codex architect) → no "harness retired". Workspace fixture: codev/ marker + .codev/config.json, +chdir, mocked child_process (modeled on existing structure-checks describe). +VERIFIED: build exit 0; doctor.test.ts 21/21 (17 existing + 4 new). Doctor never calls resolveHarness so +it never throws — detects+reports only. Next: porch check → done → iter1 3-way consult. + +### phase_3 CONSULT iter1: Gemini APPROVE, Codex REQUEST_CHANGES/HIGH, Claude REQUEST_CHANGES/HIGH +Two blockers, both accepted; the reviewers converged: +1. CORRECTNESS (Codex C1 + Claude #1): doctor false-flagged the sanctioned custom-`gemini` escape + hatch — `resolvedShellHarness` returned only a name and the caller ran `getRetirement` on it + unconditionally, ignoring `config.harness`. So explicit `builderHarness/architectHarness: "gemini"` + backed by a custom `harness.gemini` def was reported retired even though it resolves + spawns fine + (contradicts resolveHarness precedence built-in→custom→retired, and the retirement msg's own + "configure a custom harness" advice). +2. TEST-QUALITY (Claude #2): the new negative test was vacuous — chalk mock lacked `gray`, so the + codex-architect "supported" branch (`chalk.gray`) threw into the shell-section `catch {}` BEFORE the + builder branch ran; the "supported-config not flagged" test asserted nothing about its guarded path. +Also: Claude #3 recommendation misdirects on the explicit-harness path (explicit Harness beats +the command); Claude #4 nits (no array-form test; duplicate retirement paragraph when both roles gemini). + +### phase_3 iter1 fixes IMPLEMENTED + VERIFIED (resumed from state-snapshot.md) +Production (doctor.ts): `resolveShell(role)` now returns `{ name, retirement }` and encodes the +resolver's precedence so doctor can't drift from spawn: +- explicit `shell.Harness` with a `config.harness` own-prop of that name ⇒ retirement suppressed + (escape hatch honored; prototype-safe hasOwnProperty check); +- auto-detected `shell.` command ⇒ retirement ALWAYS applies (never consults custom harnesses — + mirrors resolveHarness's auto-detect-is-always-retired rule); +- both role recommendations + inline guidance now name `shell.` AND `shell.Harness`. +Tests (doctor.test.ts): +`gray` to chalk mock; STRENGTHENED the supported-config test with a POST-gray +assertion (`Select the architect harness via .codev/config.json …`) — the `✓ supported` line prints +BEFORE the gray call so it can't detect the vacuity; the post-gray line proves the section completed and +the builder branch was reached. Empirically confirmed: removing `gray` makes the test FAIL on that +assertion, restore → green. Added escape-hatch tests (builder + architect), the crucial +auto-detect-gemini-with-custom-harness-STILL-flagged distinction, and an array-form test; updated the 2 +recommendation assertions to both-selector wording. +VERIFIED: build exit 0; doctor.test.ts 25/25; full unit suite (excl e2e) 4145 passed / 48 skipped / 0 +failed. Wrote 1338-phase_3-iter1-rebuttals.md. Next: commit fix + docs → porch check → done → next → +iter2 3-way consult on the fixed code. +Committed `3119e93f` (fix: escape hatch + non-vacuous tests) + `92d1f5d8` (docs: rebuttal + thread). +NOTE on porch flow: on resume, porch was still at iter1 pre-re-iteration (the earlier session had +launched the iter1 consult but paused before porch recorded verdicts). Running porch check→done→next +consumed the on-disk iter1 verdicts, re-iterated to iter2, and emitted the "fix iter1 issues" task — +which my committed fixes already satisfy. Re-ran porch check→done→next for iter2 → fresh 3-way consult +on the fixed HEAD (rebuttal passed as reviewer context). + +### phase_3 CONSULT iter2: UNANIMOUS APPROVE (Gemini APPROVE/HIGH, Codex APPROVE/HIGH, Claude APPROVE/HIGH) +Codex (the C1 finder): "correctly diagnoses retired Gemini harnesses for both roles, preserves the +explicit custom-harness escape hatch, robust regression tests. KEY_ISSUES: None." Gemini: all iter1 +feedback addressed. Claude INDEPENDENTLY re-verified both disputed findings from disk — including +empirically commenting out the gray mock and confirming the supported-config test fails at the POST-gray +assertion (doctor.test.ts:756) while the 'supported' line still passes → validated the strengthened guard +is the correct one, not just the mock addition. Build + full suite green (4145/0). +CLAUDE's 3 non-blocking notes → carry to REVIEW doc (do NOT fix in approved phase_3; fixing = wasteful +re-consult): +1. doctor.ts:790 block comment still reads "Warn if OpenCode… (unsupported)" but the block now covers + opencode + retirement (both roles) + codex + builder flagging. One-line comment refresh next time the + file is touched. +2. resolveShell uses hasOwnProperty vs resolveHarness's `in` — harmless (doctor's form is safer under + prototype pollution). Leave it. +3. Doctor is persisted-config-only by design (TOWER_BUILDER_CMD=gemini not flagged; spawn still rejects + it) — decided at plan review + documented in the code comment. Don't re-litigate at PR. +porch advanced phase_3 → phase_4 (chore 1648db64). phase_3 DONE. + +## phase_4 — user-facing docs: README + CHANGELOG (started) +Final implement phase. Scope: README (:392 "other shells" line; :433-436 autonomous-flags table gemini +row; :448-460 config example — both architect+builder lines + prose → retired framing with custom-harness +pointer, PRESERVE the agy consult-lane note) + CHANGELOG [Unreleased] Removed/breaking-change entry with +migration pointer + scoped doc-consistency grep (EXEMPT historical artifacts codev/specs|plans|reviews| +projects|docs/releases + every `consult -m gemini`/`agy` ref; re-grep BOTH codev/ AND codev-skeleton/). +Governance arch/lessons (arch.md :291/:311-317, lessons-learned.md :80) → Review phase via update-arch-docs. +Committed `9bc6398b` (README+CHANGELOG) + `a7cc65b4` (thread). Ran porch check→done→next → phase_4 iter1 consult. + +### phase_4 CONSULT iter1: Gemini APPROVE, Codex REQUEST_CHANGES/HIGH, Claude REQUEST_CHANGES/HIGH +Codex + Claude CONVERGED on the primary bug; both accepted + fixed (README + CHANGELOG only): +- **B1 escape-hatch selector** (Codex C1 + Claude): my draft said "define a custom harness named gemini + (keep --yolo)" — but that FAILS. Auto-detected `gemini` stays retired even with a custom def; the escape + hatch needs the EXPLICIT `shell.builderHarness`/`architectHarness: "gemini"` selector (exactly the + Phase 1/3 resolver behavior). Fixed: README + CHANGELOG now require the explicit selector + explain why; + README has a working snippet (builder + builderHarness + harness.gemini def w/ roleArgs/roleScriptFragment). +- **B2 opencode builder-only** (Codex C2): migration guidance listed opencode for BOTH roles; opencode is + builder-only (README already says so). Fixed: role-specific (claude/codex either role; opencode builder-only). +- Minor (Claude): README :392 "Other shells (Codex)" → "Codex is also supported". Fixed. +CROSS-PHASE (Claude, EXPLICITLY non-blocking — "not asking you to re-open them"): the SAME omission is in +the runtime message (harness.ts RETIRED_HARNESSES.gemini) + doctor rec (doctor.ts:875) — both say "configure +a custom harness" without the explicit selector. ACCEPTED, DEFERRED to Review phase (phase_4 scope = README/ +CHANGELOG only; harness.ts/doctor.ts are approved Phase 1/3 code; neither reviewer blocks). Will align all 3 +touchpoints in Review (reviewed at PR consult). Governance arch/lessons also → Review. +Note: the corrected README escape-hatch snippet DOES contain `"builder": "gemini --yolo"` + `"builderHarness": +"gemini"` — that's the reviewer-REQUESTED custom-harness example (retained-access, explicit selector), NOT +gemini-as-supported-built-in; framed as retired-built-in + custom-only. Doc-consistency criterion still holds. +Wrote 1338-phase_4-iter1-rebuttals.md. Docs-only; build+suite green. Next: commit fix+docs → porch next +(record iter1 verdicts, re-iter to iter2) → porch check→done→next → iter2 consult on the fixed docs. + +### phase_4 CONSULT iter2: Gemini APPROVE, Codex REQUEST_CHANGES/HIGH, Claude REQUEST_CHANGES/HIGH +Codex + Claude CONVERGED (both HIGH) on one real bug iter1's selector-fix left behind: the README +custom-`gemini` escape-hatch snippet injected the role via `--system`, but Gemini CLI reads its system +prompt from the **GEMINI_SYSTEM_MD env var** (retired GEMINI_HARNESS CONFIRMED via +`git show e222b9ef^:...harness.ts:177-186` → `args:[] + env:{GEMINI_SYSTEM_MD:filePath}`, empty script +fragment + same env). Copy-paste user → `gemini --yolo --system ''` → config validates+resolves, +then the CLI rejects the unknown flag. Same broken-escape-hatch *outcome* as iter1, different cause. +FIX (docs + test; resumed session, "unpause" from architect): +- README.md:472 snippet → `roleArgs:[]` + `roleEnv`/`roleScriptEnv:{GEMINI_SYSTEM_MD:"${ROLE_FILE}"}` + + `roleScriptFragment:""` (reproduces retired built-in verbatim) + 1 prose sentence explaining the + env-var mechanism. Verified plumbing: validateCustomHarnessConfig accepts empty args/fragment; + buildCustomHarnessProvider expands `${ROLE_FILE}` in roleEnv/roleScriptEnv (harness.ts:311-330); + spawn-worktree.ts:923-927 emits `export GEMINI_SYSTEM_MD=...`. +- harness.test.ts:319-328 — the test LITERALLY NAMED "retained-access escape hatch" — realigned from the + `--system` shape to assert GEMINI_SYSTEM_MD env injection on BOTH buildRoleInjection ({args:[],env}) and + buildScriptRoleInjection ({fragment:'',env}). Non-blocking per BOTH reviewers; done anyway because an + asserted-but-wrong shape invites the docs to drift back. Generic `--system` template-expansion coverage + PRESERVED (harness.test.ts:122-149 + harness-integration.test.ts:150-165 are generic mechanism tests, + not gemini escape-hatch) — no coverage loss. +- Kept: `--yolo` in the example (user's OWN retained CLI invocation, not a Codev-presented row). +- Deferred (both reviewers ENDORSE landing in REVIEW): runtime-message/doctor-rec selector alignment + (harness.ts:233-236, doctor.ts:874) + governance arch.md/lessons-learned.md. +- CHANGELOG needs NO change (its [Unreleased] entry is prose-only, no `--system` snippet). +Verified: build exit 0 (tsc clean, realigned fixture type-checks); harness.test.ts 59/59. Wrote +1338-phase_4-iter2-rebuttals.md. Next: commit → porch done (re-verify) → iter3 3-way consult on fixed +docs. Unanimous approve → phase_4 done → porch advances to Review (where the deferred touchpoints land). + +### phase_4 CONSULT iter3: UNANIMOUS APPROVE (Gemini APPROVE/HIGH, Codex APPROVE/HIGH, Claude APPROVE/HIGH) +Codex (the every-iteration blocker) now clean: escape-hatch example matches the retired built-in's +env-based injection. Claude re-verified the fix END-TO-END from disk (not trusting the rebuttal): README +snippet == GEMINI_HARNESS shape (e222b9ef^), validateCustomHarnessConfig accepts empty args/fragment, +${ROLE_FILE} expands in roleEnv/roleScriptEnv (harness.ts:311-330), spawn-worktree.ts:924-928 emits +`export GEMINI_SYSTEM_MD=...`, explicit selector required+works (resolveHarness:431-467), Phase-2 spawn +preflight does NOT reject the documented hatch (assertBuilderHarnessNotRetired honors explicit selector), +harness+integration tests 78/78. phase_4 DONE. porch advanced → REVIEW phase (iter1). + +## REVIEW phase (started) — deferred touchpoints land here +porch REVIEW task = build review artifact + PR + `## Architecture Updates`/`## Lessons Learned Updates` +(porch greps both headings) + porch done → PR consult → pr gate (HUMAN). +Review-phase work plan (accepted+deferred across iters, endorsed by all 3 reviewers): +- Align the THIRD/second touchpoints to the README's explicit-selector requirement: + · harness.ts RETIRED_HARNESSES.gemini msg (:228-244) + resolveHarness generic custom-harness string (:451). + · doctor.ts builder rec (:875) + architect rec (:851) "or configure a custom harness" tail. + Both name shell.builderHarness/architectHarness (bare auto-detect stays retired). Update asserting tests. +- Governance (update-arch-docs, hot/cold): arch.md :291 (gemini built-in provider) + :311-317 ("Gemini is + builder-only" → RETIRED both roles); lessons-learned.md :80 (#929 lesson's gemini-architect example). +- Optional (Claude iter3 non-blocking): CHANGELOG pointer to the README GEMINI_SYSTEM_MD snippet. +Then: build+full suite green → review doc → commit → PR (Closes #1338) → porch done → notify architect. +NOTE: doctor.ts:875 recommendation ALREADY names shell.builderHarness for the *supported* path; the gap is +the "or configure a custom harness" tail omitting the explicit-selector requirement. Check tests assert +current message strings before editing (harness.test.ts + doctor.test.ts) — verify vs file, don't guess. + +### REVIEW phase — deferred touchpoints landed + governance refreshed + review doc (DONE, pre-PR) +All Review edits made + verified (build exit 0; full unit suite 4145 pass / 48 skip / 0 fail — unchanged +baseline, my additions were assertions WITHIN existing tests): +- Touchpoint alignment (all 3 now name the EXPLICIT selector; verified message strings vs file first): + · harness.ts RETIRED_HARNESSES.gemini msg → "define a custom harness named 'gemini' … and select it + explicitly with shell.builderHarness / shell.architectHarness — a bare auto-detected 'gemini' stays + retired." Kept asserted substrings (/retired/i, 2026-06-18). +assertion: msg contains + shell.builderHarness + shell.architectHarness. + · doctor.ts BOTH recs (:851 architect, :875 builder) → appended "or define a custom 'gemini' harness and + select it explicitly via shell.{builder,architect}Harness (a bare shell.{builder,architect} command + stays retired)". Preserved asserted prefixes (test :726/:734). +assertions on 'explicitly via + shell.*Harness' ("via" is unique to the rec; retirement msg uses "with" → non-vacuous). + · README already fixed in phase_4. (:451 generic unknown-harness error left as-is — not a flagged + gemini touchpoint; fires for any unknown name. Noted as optional follow-up in review doc.) +- CHANGELOG: added pointer to README's GEMINI_SYSTEM_MD snippet (Claude iter3 optional note). +- Governance (update-arch-docs, COLD routing; no HOT change — retirement narrower than any hot fact, + maps unchanged): arch.md :291 (dropped gemini from built-in provider list) + :311 ("Gemini is + builder-only" → RETIRED both roles, fail-closed, custom-only via explicit selector) + fixed a NOW-STALE + override example (`--builder-cmd gemini` → `--builder-cmd opencode`, since bare gemini now fails closed). + lessons-learned.md +2 COLD (Architecture: fail-closed-at-every-resolution-path; Documentation: + doc-snippet-must-reproduce-real-mechanism). Left historical #929 lesson (:80) intact (accurate history). +- Review doc: codev/reviews/1338-retire-gemini-cli-as-a-builder.md (full template; ## Architecture Updates + + ## Lessons Learned Updates present — porch greps both). Metrics: 47 branch commits, 4145 tests, 11 + consult rounds × 3, 7 rebuttals. +Next: commit (3 logical commits) → PR (Closes #1338) → porch done 1338 (PR consult + pr gate) → afx send +architect "PR ready". pr gate = HUMAN. External maintainer merges (no self-merge — architect constraint). + +### PR OPENED — #1342 (2026-08-03 EDT / resumed session) +3 Review commits (d660c92c touchpoints, 62dfcd07 governance, 0a915bd1 review doc+thread) pushed to +origin/builder/spir-1338 (origin=cluesmith upstream; fork=mohidmakhdoomi). PR #1342 +https://github.com/cluesmith/codev/pull/1342 → base main, head builder/spir-1338, OPEN, Closes #1338. +porch done 1338 --pr 1342 --branch builder/spir-1338 recorded the PR in pr_history (note: the --pr form +ONLY records the PR; it does NOT run the review checks/advance — must still porch check → done → next). +porch review CRITERIA: pr_exists / review_has_arch_updates / review_has_lessons_updates / e2e_tests. +Running porch check now. Then porch done → next → PR 3-way consult → pr gate (HUMAN). Do NOT self-merge. + +### REVIEW — integration-review adjustments (2026-08-04, resumed after architect unpause) +Architect integration review of PR #1342 (Codex REQUEST_CHANGES, architect-verified): 2 required + 2 +recommended. Architect approved doing ALL FOUR. Implemented + verified: build exit 0 (tsc + dashboard + +skeleton copy); full unit suite 4148 pass / 48 skip / 0 fail (was 4145; +3 test cases). +- REQUIRED (1) Revert CHANGELOG: dropped the `### Removed (Spec 1338)` block — contributors don't edit the + upstream release changelog. Scrubbed stale CHANGELOG claims from the review doc (summary/AC/consult-log, + lines 10/17/153/199/211) + PR #1342 body; recorded the revert + rationale in the review's Deviations. +- REQUIRED (2) Close the --shell fail-closed gap: preflight was gated `if (mode !== 'shell')`, but + spawnShell runs commands.builder (startShellSession=PTY) + upsertBuilder (shell row). Made the preflight + UNCONDITIONAL (delegates escape-hatch decision to getBuilderHarness). Regression test in + spawn-retirement.test.ts: `spawn({shell:true})` w/ gemini builderHarness rejects /retired/i + leaves no + state. GOTCHA: `force` is invalid for shell mode (validateSpawnOptions:187 requires issue/task/protocol); + dropped it — the untracked `.codev/config.json` is ignored by the tracked-changes cleanliness check (:900). +- RECOMMENDED (3) doctor role.name interpolation (doctor.ts:851 architect / :875 builder): custom-harness + clause hard-coded "gemini"; now interpolates architect.name/builder.name (matches the already-tested + console/issue lines two lines up). Byte-identical for gemini → existing tests pass; +regression + assertions lock the `define a custom "gemini" harness` clause. Keeps advice correct as RETIRED_HARNESSES + grows (architect's rationale). No fragile second-retired-harness mock (that's the phase_3 vacuous-mock + anti-pattern) — interpolation proven by the shared `${role.name}` pattern the issue/console lines test. +- RECOMMENDED (4) sibling-prune retirement log: siblingRegistrationIsLive returned false silently on + retirement → tower-instances.ts:799 prune log misattributed the reason ("no resumable session"). + Threaded an optional `log` into opts (idiomatic — tower-utils uses injected loggers, no module logger) + + emit the retirement reason in the catch; caller (tower-instances.ts:795) passes `_deps.log`. +2 tests + (fires w/ reason on retired; does NOT fire for a live codex sibling). Updated the pre-existing #1150 + caller test (tower-instances.test.ts:1463) to include the new 3rd `{ log }` arg. +Next: commit (5 atomic) → push → update PR body → re-run porch PR 3-way consult on fixed HEAD → porch next +(record verdicts) → pr gate (HUMAN). External maintainer merges — no self-merge. + +### REVIEW iter1 CONSULT (fresh, on integration-review HEAD f009d11c): Gemini APPROVE, Claude APPROVE, Codex REQUEST_CHANGES +Forced a fresh consult by clearing the 3 stale old-HEAD review-iter1 verdict files (unrecorded in +status.yaml; porch then reported "3-way review not completed") → `porch next` emitted the consult task → +ran all 3 on f009d11c. +- Gemini APPROVE (no issues). Claude APPROVE/HIGH (independently reproduced tsc + 4148/0; verified all 4 + architect fixes in code; non-blocking notes only). Codex REQUEST_CHANGES/HIGH but "no production + correctness or security blockers" — 2 audit-record asks. +iter2 fixes (committed): +- Codex#1 — commit the 5 `*-context.md` audit artifacts (convention: 11 tracked across prior projects; + verdict `.txt` files are gitignored, context/rebuttals committed). +- Codex#2 — refresh stale review metrics (47→≈60 commits, 4145→4148 tests, 33/11/7→36/12/8 consult counts). +- Claude#1 (non-blocking; my --shell change made it relevant): `assertBuilderHarnessNotRetired` swallowed + non-retirement errors silently, and shell mode never re-resolves downstream → added a `console.debug` in + the catch (fires only on a real error). +- Claude#3 (cosmetic): reworded the doctor interpolation-assertion comments (a single RETIRED_HARNESSES + entry can't prove interpolation — don't overstate). +- Claude#2 (resolveHarness prototype-chain) + Claude#4 (INSTALL.md/SKILL.md stale gemini, pre-existing + #778) → Follow-up Items (out of scope). +Build exit 0; full suite 4148/48/0 (unchanged — behavior-preserving, no new tests). Wrote +1338-review-iter1-rebuttals.md. Next: commit+push → porch done → next → iter2 3-way consult on updated HEAD. + +### PR GATE + UPSTREAM MAINTAINER REVIEW (2026-08-05, resumed session) +Prior session pushed iter2 and requested the `pr` gate (status.yaml gates.pr=pending, pr_ready_for_human= +true; commit 7bd4267f). PR #1342 OPEN, base main. THIS resumed session received an ARCHITECT INSTRUCTION +relaying upstream maintainer (waleedkadous) review: approve-with-one-change — 1 BLOCKING + 3 OPTIONAL; +architect approved TAKE ALL FOUR. Implemented + verified against the ACTUAL diff (not from memory): + +- BLOCKING — restartOnExit inconsistency. Both tower-terminals.ts reconnect paths forced + `ptySession.restartOnExit = true` for EVERY architect. A retired-harness architect resolves + `restartOptions` to undefined (buildArchitectReconnectRestartOptions fail-closed), so PtySession held + WebSocket clients in a "restarting…" wait (startRestartWait) for a process SessionManager will never + restart (`restartOnExit: hasRestart`, session-manager.ts:577). Fix: gate BOTH sites on + `dbSession.type === 'architect' && restartOptions` (site 1 reconcile — added restartOptions to the + probeResults destructure; site 2 on-the-fly — restartOptions already in scope). Aligns the PTY flag with + SessionManager's hasRestart. + TEST (consumer path, per maintainer's explicit ask): NEW file tower-terminals-restart-gating.test.ts — + mocks buildArchitectReconnectRestartOptions and drives BOTH real reconnect paths (reconcile + + on-the-fly) × {undefined→false, defined→true} = 4 tests. GOTCHA: a real-config end-to-end version + tripped a vitest module-duplication artifact — the helper's `err instanceof RetiredHarnessError` bound a + DIFFERENT harness.js instance than config.ts's throw under this file's vi.mock graph, so instanceof + failed → generic fallback → restartOptions DEFINED. NOT a production bug (tower-utils.test.ts retirement + tests + a throwaway diag file both confirm the real chain returns undefined in a single module graph). + Mocking the helper isolates and tests the consumer's gating branch — exactly what was asked. +- OPTIONAL 1 — siblingRegistrationIsLive returns TRUE on retirement (was false). false → reconcile DELETEs + the sibling config row (setArchitectByName null); true → routes to addArchitect, which fails closed on + the same retirement (tower-instances.ts:1094, "no state created before this point") and LEAVES the row + so the user can repair the harness. Same launch safety, non-destructive. Updated docstring + the 2 + tower-utils.test.ts tests (false→true, log "pruning"→"keeping the row"); fixed a now-stale + "retired-harness prune" comment in tower-instances.test.ts (the reconcile tests mock the predicate, so + behavior unaffected). +- OPTIONAL 2 — console.debug → logger.debug in config.ts assertBuilderHarnessNotRetired catch (Tower + imports the module; bare console.debug always writes stdout and pollutes Tower's stream; logger.debug is + DEBUG-gated). Strengthened config.test.ts "defers on unknown harness" to spy logger.debug. +- OPTIONAL 3 — added getBuiltinHarness(name) own-property accessor (mirrors isRetiredHarness) and used it + at the two USER-CONTROLLED lookups: resolveHarness:433 (config shell.builderHarness) and harnessProviderFor + context.ts:468 (builder launch-script name). Closes the inherited-key footgun (BUILTIN_HARNESSES + ['constructor'] → Object ctor, truthy → bogus provider that TypeErrors downstream). +tests in + harness.test.ts (resolveHarness proto-keys throw Unknown; getBuiltinHarness describe). + +6 affected test files green (279 pass). Next: full build + full unit suite → commit (grouped by fix) → +push → reply on PR #1342 summarizing each fix (verified vs diff). Do NOT self-merge (upstream maintainer's +call); do NOT approve the pr gate. diff --git a/packages/codev/src/__tests__/doctor.test.ts b/packages/codev/src/__tests__/doctor.test.ts index b86516c97..38ffbb6ee 100644 --- a/packages/codev/src/__tests__/doctor.test.ts +++ b/packages/codev/src/__tests__/doctor.test.ts @@ -90,6 +90,11 @@ vi.mock('chalk', () => { red: createChainableColor(), blue: identity, dim: identity, + // #1338: the codex-architect "supported" branch calls chalk.gray (doctor.ts). + // Without this, chalk.gray throws into the shell-section catch {}, aborting + // before the builder branch runs — which made the supported-config test below + // pass vacuously (it never reached the path it guards). + gray: createChainableColor(), }, }; }); @@ -657,6 +662,160 @@ describe('doctor command', () => { }); }); + // Issue #1338 — codev doctor flags a retired harness (gemini) on BOTH the + // architect and builder shells, and no longer claims gemini is "supported for + // builders". The structured issue/recommendation (rendered in the warning + // summary) is the assertion target — stabler than the inline console text. + describe('shell-harness retirement flagging (#1338)', () => { + const testBaseDir = path.join(tmpdir(), `codev-doctor-1338-${Date.now()}`); + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + // `codev/` marks the workspace root for doctor's findWorkspaceRoot; + // `.codev/config.json` is what loadConfig reads for the shell config. + fs.mkdirSync(path.join(testBaseDir, 'codev'), { recursive: true }); + fs.mkdirSync(path.join(testBaseDir, '.codev'), { recursive: true }); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (fs.existsSync(testBaseDir)) { + fs.rmSync(testBaseDir, { recursive: true }); + } + }); + + // Every dependency present so doctor() runs through to the shell-config + // section without bailing early on a missing tool. + async function runDoctorWith(config: object): Promise { + fs.writeFileSync(path.join(testBaseDir, '.codev', 'config.json'), JSON.stringify(config)); + process.chdir(testBaseDir); + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (cmd.includes('gh auth status')) return Buffer.from('Logged in'); + return Buffer.from('/usr/bin/command'); + }); + vi.mocked(spawnSync).mockImplementation((cmd: string) => { + const responses: Record = { + node: 'v20.0.0', tmux: 'tmux 3.4', git: 'git version 2.40.0', + claude: '1.0.0', codex: '0.60.0', + }; + return { + status: 0, + stdout: responses[cmd] || 'working', + stderr: '', + signal: null, + output: [null, responses[cmd] || 'working', ''], + pid: 0, + }; + }); + vi.resetModules(); + const logOutput: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { + logOutput.push(args.join(' ')); + }); + const { doctor } = await import('../commands/doctor.js'); + await doctor(); + return logOutput; + } + + it('flags a gemini BUILDER config as retired via the structured issue/recommendation', async () => { + const out = await runDoctorWith({ shell: { builder: 'gemini --yolo' } }); + expect(out.some((l) => l.includes('gemini configured as builder shell (harness retired)'))).toBe(true); + // Recommendation names BOTH selectors — an explicit shell.builderHarness beats + // the shell.builder command, so switching only one wouldn't clear it (#1338). + expect(out.some((l) => l.includes('Set shell.builder / shell.builderHarness to a supported harness'))).toBe(true); + // The custom-harness escape hatch names the EXPLICIT selector (#1338): a bare + // shell.builder "gemini" stays retired, so the rec points at shell.builderHarness. + // ("via" is unique to the recommendation; the retirement message uses "with".) + expect(out.some((l) => l.includes('select it explicitly via shell.builderHarness'))).toBe(true); + // Locks the RENDERED custom-harness clause for the configured retired harness. + // (This assertion can't by itself prove the `${role.name}` interpolation — with a + // single RETIRED_HARNESSES entry it reads identically to a hard-coded literal; the + // interpolation is verified by inspection and shares the pattern of the + // already-asserted console/issue lines above. #1338.) + expect(out.some((l) => l.includes('define a custom "gemini" harness'))).toBe(true); + // The single-source-of-truth retirement explanation is surfaced (2026-06-18 cause). + expect(out.some((l) => l.includes('2026-06-18'))).toBe(true); + }); + + it('flags a gemini ARCHITECT config as retired and never claims builder support', async () => { + const out = await runDoctorWith({ shell: { architect: 'gemini --yolo' } }); + expect(out.some((l) => l.includes('gemini configured as architect shell (harness retired)'))).toBe(true); + expect(out.some((l) => l.includes('Set shell.architect / shell.architectHarness to "codex"'))).toBe(true); + // The custom-harness escape hatch names the EXPLICIT architect selector (#1338). + expect(out.some((l) => l.includes('select it explicitly via shell.architectHarness'))).toBe(true); + // Locks the RENDERED custom-harness clause for the configured retired harness + // (not a proof of the `${role.name}` interpolation — see the builder test's note). #1338. + expect(out.some((l) => l.includes('define a custom "gemini" harness'))).toBe(true); + // The inverted pre-retirement message must be gone. + expect(out.some((l) => l.includes('supported for builders'))).toBe(false); + expect(out.some((l) => l.includes('builder-only'))).toBe(false); + }); + + it('detects gemini via explicit builderHarness, not only the command form', async () => { + const out = await runDoctorWith({ shell: { builder: 'some-wrapper', builderHarness: 'gemini' } }); + expect(out.some((l) => l.includes('gemini configured as builder shell (harness retired)'))).toBe(true); + }); + + it('does NOT flag a supported-harness config (claude builder + codex architect)', async () => { + const out = await runDoctorWith({ shell: { builder: 'claude', architect: 'codex' } }); + // Non-vacuity guard (#1338). The codex-architect branch prints the "supported" + // line, THEN two chalk.gray lines, THEN control falls through to the builder + // branch. Without the chalk.gray mock, gray throws into the section's catch {} + // right after "supported" — skipping the builder branch entirely and making the + // "not flagged" checks below vacuous. The "supported" line alone can't detect + // that (it prints before the throw), so assert a line printed AFTER the gray + // calls: its presence proves the section ran to completion and the builder + // branch was actually reached. + expect(out.some((l) => l.includes('codex is configured as architect shell') && l.includes('supported'))).toBe(true); + expect(out.some((l) => l.includes('Select the architect harness via .codev/config.json'))).toBe(true); + expect(out.some((l) => l.includes('harness retired'))).toBe(false); + expect(out.some((l) => l.includes('supported for builders'))).toBe(false); + }); + + // The sanctioned escape hatch (#1338): an EXPLICIT shell.builderHarness "gemini" + // backed by a matching custom `harness.gemini` definition resolves and spawns + // fine (resolveHarness precedence: built-in → custom → retired), so doctor must + // NOT flag it — otherwise following the retirement advice ("configure a custom + // harness") would leave the warning stuck on. Mirrors the resolver's escape-hatch + // case in harness.test.ts. + it('does NOT flag an explicit custom gemini BUILDER harness (escape hatch)', async () => { + const out = await runDoctorWith({ + shell: { builderHarness: 'gemini' }, + harness: { gemini: { roleArgs: [], roleScriptFragment: '' } }, + }); + expect(out.some((l) => l.includes('builder shell (harness retired)'))).toBe(false); + expect(out.some((l) => l.includes('2026-06-18'))).toBe(false); + }); + + it('does NOT flag an explicit custom gemini ARCHITECT harness (escape hatch)', async () => { + const out = await runDoctorWith({ + shell: { architectHarness: 'gemini' }, + harness: { gemini: { roleArgs: [], roleScriptFragment: '' } }, + }); + expect(out.some((l) => l.includes('architect shell (harness retired)'))).toBe(false); + expect(out.some((l) => l.includes('2026-06-18'))).toBe(false); + }); + + // The distinction that keeps the escape hatch safe: auto-detection resolves the + // BUILT-IN namespace only, so a bare `gemini …` command is retired even when a + // same-named custom harness exists (matches resolveHarness in harness.ts). Doctor + // must keep flagging it — otherwise it would green-light a config that fails + // closed at spawn. + it('STILL flags an auto-detected gemini command even when a custom gemini harness exists', async () => { + const out = await runDoctorWith({ + shell: { builder: 'gemini --yolo' }, + harness: { gemini: { roleArgs: [], roleScriptFragment: '' } }, + }); + expect(out.some((l) => l.includes('gemini configured as builder shell (harness retired)'))).toBe(true); + }); + + it('flags an array-form gemini builder command (parity with the resolver)', async () => { + const out = await runDoctorWith({ shell: { builder: ['gemini', '--yolo'] } }); + expect(out.some((l) => l.includes('gemini configured as builder shell (harness retired)'))).toBe(true); + }); + }); + describe('protocol PR-gate audit (#943)', () => { const testBaseDir = path.join(tmpdir(), `codev-doctor-prgate-${Date.now()}`); let originalCwd: string; diff --git a/packages/codev/src/agent-farm/__tests__/config.test.ts b/packages/codev/src/agent-farm/__tests__/config.test.ts index 295091e9a..138034ffc 100644 --- a/packages/codev/src/agent-farm/__tests__/config.test.ts +++ b/packages/codev/src/agent-farm/__tests__/config.test.ts @@ -8,8 +8,10 @@ import { ensureDirectories, getArchitectHarness, getBuilderHarness, + assertBuilderHarnessNotRetired, setCliOverrides, } from '../utils/config.js'; +import { logger } from '../utils/logger.js'; import { existsSync } from 'node:fs'; import { rm, mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -17,9 +19,24 @@ import { resolve } from 'node:path'; // Mock loadConfig to avoid depending on the real workspace's config files. // The agent-farm config.ts imports from lib/config.ts which would detect // af-config.json in the real workspace and error. +// +// The shell block is a mutable, hoisted object so the retirement tests (#1338) +// can drive builder/architect to a gemini command (string OR array form) or an +// explicit gemini *Harness. Every retirement test resets it in afterEach, so +// the default (claude everywhere) is what all other tests observe. +const configMock = vi.hoisted(() => ({ + shell: { architect: 'claude', builder: 'claude', shell: 'bash' } as { + architect: string | string[]; + builder: string | string[]; + shell: string; + architectHarness?: string; + builderHarness?: string; + }, +})); + vi.mock('../../lib/config.js', () => ({ loadConfig: () => ({ - shell: { architect: 'claude', builder: 'claude', shell: 'bash' }, + shell: configMock.shell, porch: { consultation: { models: ['gemini', 'codex', 'claude'] } }, framework: { source: 'local' }, }), @@ -135,8 +152,114 @@ describe('getArchitectHarness / getBuilderHarness override-awareness (#929)', () expect(getArchitectHarness().buildResume).toBeUndefined(); }); - it('--builder-cmd gemini → gemini builder harness (no claude resume)', () => { - setCliOverrides({ builder: 'gemini' }); + it('--builder-cmd codex → codex builder harness (no claude resume)', () => { + setCliOverrides({ builder: 'codex' }); expect(getBuilderHarness().buildResume).toBeUndefined(); }); }); + +// Issue #1338 — the built-in gemini harness is retired. Every config path that +// resolves to gemini (an explicit *Harness, a --*-cmd override, or an +// auto-detected gemini command in string OR array form) must fail closed with +// the retirement, never silently resolve the Claude harness (#929-class +// mismatch) or undefined. +describe('gemini harness retirement (#1338)', () => { + const savedArchitectCmd = process.env.TOWER_ARCHITECT_CMD; + + afterEach(() => { + setCliOverrides({}); + configMock.shell.architect = 'claude'; + configMock.shell.builder = 'claude'; + delete configMock.shell.architectHarness; + delete configMock.shell.builderHarness; + if (savedArchitectCmd === undefined) { + delete process.env.TOWER_ARCHITECT_CMD; + } else { + process.env.TOWER_ARCHITECT_CMD = savedArchitectCmd; + } + }); + + it('--builder-cmd gemini fails closed with the retirement', () => { + setCliOverrides({ builder: 'gemini' }); + expect(() => getBuilderHarness()).toThrow(/retired/i); + }); + + it('--architect-cmd gemini fails closed with the retirement', () => { + delete process.env.TOWER_ARCHITECT_CMD; + setCliOverrides({ architect: 'gemini' }); + expect(() => getArchitectHarness()).toThrow(/retired/i); + }); + + it('explicit builderHarness "gemini" fails closed with the retirement', () => { + configMock.shell.builderHarness = 'gemini'; + expect(() => getBuilderHarness()).toThrow(/retired/i); + }); + + it('array-form builder ["gemini", "--yolo"] fails closed with the retirement', () => { + configMock.shell.builder = ['gemini', '--yolo']; + expect(() => getBuilderHarness()).toThrow(/retired/i); + }); + + it('the retirement message names the cause and a supported alternative', () => { + setCliOverrides({ builder: 'gemini' }); + expect(() => getBuilderHarness()).toThrow(/2026-06-18/); + expect(() => getBuilderHarness()).toThrow(/claude/); + }); +}); + +// Issue #1338 — the spawn preflight. `assertBuilderHarnessNotRetired` is called +// in the spawn() dispatcher BEFORE any worktree/porch/db state is created, so a +// retired builder harness aborts with no orphaned state. It must abort on the +// retirement for every config form (explicit *Harness, --builder-cmd override, +// auto-detected command in string OR array form), stay a no-op for supported +// harnesses, and — crucially — defer (NOT abort) on any non-retirement error so +// an unknown harness still surfaces at its normal resolution call site. +describe('assertBuilderHarnessNotRetired spawn preflight (#1338)', () => { + afterEach(() => { + setCliOverrides({}); + configMock.shell.architect = 'claude'; + configMock.shell.builder = 'claude'; + delete configMock.shell.architectHarness; + delete configMock.shell.builderHarness; + }); + + it('aborts on --builder-cmd gemini with the retirement', () => { + setCliOverrides({ builder: 'gemini' }); + expect(() => assertBuilderHarnessNotRetired()).toThrow(/retired/i); + }); + + it('aborts on explicit builderHarness "gemini" with the retirement', () => { + configMock.shell.builderHarness = 'gemini'; + expect(() => assertBuilderHarnessNotRetired()).toThrow(/retired/i); + }); + + it('aborts on array-form builder ["gemini", "--yolo"] with the retirement', () => { + configMock.shell.builder = ['gemini', '--yolo']; + expect(() => assertBuilderHarnessNotRetired()).toThrow(/retired/i); + }); + + it('is a no-op for a supported builder harness (claude default)', () => { + expect(() => assertBuilderHarnessNotRetired()).not.toThrow(); + }); + + it('is a no-op for a supported builder harness (codex)', () => { + setCliOverrides({ builder: 'codex' }); + expect(() => assertBuilderHarnessNotRetired()).not.toThrow(); + }); + + it('defers (does NOT abort) on an unknown builder harness — surfaces later', () => { + // An unknown name throws a generic "Unknown harness" (not the retirement). + // The preflight only aborts spawns for retired harnesses; every other + // resolution error is left to surface at the real getBuilderHarness call. + // The deferred error is routed through `logger.debug` (NOT `console.debug`), + // so it stays out of Tower's stdout log stream unless DEBUG is set (#1338 + // review): Tower imports this module and a bare console.debug always prints. + configMock.shell.builderHarness = 'no-such-harness'; + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); + expect(() => assertBuilderHarnessNotRetired()).not.toThrow(); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringMatching(/non-retirement, deferred/), + ); + debugSpy.mockRestore(); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts b/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts index e44c47e72..87f47b2ea 100644 --- a/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts +++ b/packages/codev/src/agent-farm/__tests__/discover-resume-session.test.ts @@ -5,7 +5,7 @@ * * Issue #929: resume is now gated on the builder harness, not the Claude * session store directly. Only the Claude harness implements buildResume; - * codex/gemini return undefined even when a stale Claude jsonl exists (the + * codex/opencode return undefined even when a stale Claude jsonl exists (the * regression guard against `codex --resume ` crash-loops). */ @@ -16,7 +16,7 @@ import { join } from 'node:path'; import { discoverResumeSession } from '../commands/spawn.js'; import { encodeClaudeProjectDir } from '../utils/claude-session-discovery.js'; -import { CLAUDE_HARNESS, CODEX_HARNESS, GEMINI_HARNESS } from '../utils/harness.js'; +import { CLAUDE_HARNESS, CODEX_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; // discoverResumeSession reads from $HOME via os.homedir() through // findLatestSessionId. Override the env var for the duration of the test so @@ -103,11 +103,11 @@ describe('discoverResumeSession', () => { }); }); - it('returns undefined for gemini even when a stale Claude jsonl exists (regression guard)', () => { - const worktree = '/Users/x/repo/.builders/pir-gemini'; + it('returns undefined for opencode even when a stale Claude jsonl exists (regression guard)', () => { + const worktree = '/Users/x/repo/.builders/pir-opencode'; writeSession(projectsRoot, worktree, 'stale-claude-uuid', 1_700_000_000_000); pinHome(fakeHome, () => { - expect(discoverResumeSession(worktree, true, GEMINI_HARNESS)).toBeUndefined(); + expect(discoverResumeSession(worktree, true, OPENCODE_HARNESS)).toBeUndefined(); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/harness-integration.test.ts b/packages/codev/src/agent-farm/__tests__/harness-integration.test.ts index 43cb48202..8a803292d 100644 --- a/packages/codev/src/agent-farm/__tests__/harness-integration.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness-integration.test.ts @@ -2,7 +2,9 @@ * Integration tests for the agent harness system. * * Tests that all call sites (buildWorktreeLaunchScript, buildArchitectArgs) - * produce correct output for each harness type: claude, codex, gemini, custom. + * produce correct output for each harness type: claude, codex, custom. The + * built-in gemini harness is retired (Issue #1338) — its scenarios now assert + * the retirement instead of role injection. * * @see codev/specs/591-af-workspace-failure-with-code.md (Test Scenarios 1–8) */ @@ -11,10 +13,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CLAUDE_HARNESS, CODEX_HARNESS, - GEMINI_HARNESS, resolveHarness, buildCustomHarnessProvider, shellEscapeSingleQuote, + throwRetired, type CustomHarnessConfig, } from '../utils/harness.js'; @@ -109,20 +111,16 @@ describe('harness integration', () => { }); // =========================================================================== - // Spec Test Scenario 3: Gemini harness + // Spec Test Scenario 3: Gemini harness retirement (Issue #1338) // =========================================================================== - describe('gemini harness', () => { - it('buildRoleInjection returns GEMINI_SYSTEM_MD env var', () => { - const { args, env } = GEMINI_HARNESS.buildRoleInjection(ROLE_CONTENT, ROLE_FILE); - expect(args).toEqual([]); - expect(env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); + describe('gemini harness retirement', () => { + it('explicit gemini resolution throws the retirement', () => { + expect(() => resolveHarness('gemini')).toThrow(/retired/i); }); - it('buildScriptRoleInjection returns env with empty fragment', () => { - const { fragment, env } = GEMINI_HARNESS.buildScriptRoleInjection(ROLE_CONTENT, ROLE_FILE); - expect(fragment).toBe(''); - expect(env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); + it('auto-detected gemini command throws the retirement (no silent claude fallback)', () => { + expect(() => resolveHarness(undefined, undefined, 'gemini --yolo')).toThrow(/retired/i); }); }); @@ -135,15 +133,11 @@ describe('harness integration', () => { expect(() => resolveHarness('nonexistent')).toThrow('Unknown harness "nonexistent"'); }); - it('error message lists available harnesses', () => { - try { - resolveHarness('bad'); - } catch (e: unknown) { - const msg = (e as Error).message; - expect(msg).toContain('claude'); - expect(msg).toContain('codex'); - expect(msg).toContain('gemini'); - } + it('error message lists available harnesses (without the retired gemini)', () => { + expect(() => resolveHarness('bad')).toThrow(/claude/); + expect(() => resolveHarness('bad')).toThrow(/codex/); + expect(() => resolveHarness('bad')).toThrow(/opencode/); + expect(() => resolveHarness('bad')).not.toThrow(/gemini/); }); }); @@ -239,13 +233,14 @@ describe('harness integration', () => { expect(script).not.toContain('--append-system-prompt'); }); - it('gemini harness: script contains GEMINI_SYSTEM_MD export', async () => { - mockGetBuilderHarness.mockReturnValue(GEMINI_HARNESS); + it('retired gemini builder: buildWorktreeLaunchScript surfaces the retirement (no broken script)', async () => { + // The real getBuilderHarness throws the retirement for a gemini config; + // assert the call site propagates it rather than emitting a launch script. + mockGetBuilderHarness.mockImplementationOnce(() => throwRetired('gemini')); const { buildWorktreeLaunchScript } = await import('../commands/spawn-worktree.js'); const role = { content: 'You are a builder', source: 'test' }; - const script = buildWorktreeLaunchScript('/tmp/worktree', 'gemini', role, '/tmp/workspace'); - expect(script).toContain("export GEMINI_SYSTEM_MD='"); - expect(script).not.toContain('--append-system-prompt'); + expect(() => buildWorktreeLaunchScript('/tmp/worktree', 'gemini', role, '/tmp/workspace')) + .toThrow(/retired/i); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/harness.test.ts b/packages/codev/src/agent-farm/__tests__/harness.test.ts index f01e8daa2..0ece26224 100644 --- a/packages/codev/src/agent-farm/__tests__/harness.test.ts +++ b/packages/codev/src/agent-farm/__tests__/harness.test.ts @@ -2,15 +2,27 @@ import { describe, it, expect } from 'vitest'; import { CLAUDE_HARNESS, CODEX_HARNESS, - GEMINI_HARNESS, OPENCODE_HARNESS, buildCustomHarnessProvider, validateCustomHarnessConfig, resolveHarness, detectHarnessFromCommand, + isRetiredHarness, + getRetirement, + getBuiltinHarness, type CustomHarnessConfig, } from '../utils/harness.js'; +// Capture whether resolveHarness returned a provider or threw — lets a test +// assert a retired path returns NEITHER a provider NOR undefined (it throws). +function resolveResult(fn: () => unknown): { returned?: unknown; threw?: Error } { + try { + return { returned: fn() }; + } catch (e) { + return { threw: e as Error }; + } +} + describe('harness', () => { const ROLE_CONTENT = '# Role\n\nYou are an architect.'; const ROLE_FILE = '/tmp/workspace/.builder-role.md'; @@ -61,25 +73,10 @@ describe('harness', () => { // so architects on Codex spawn fresh and nothing is persisted. it('has no session capability', () => { expect(CODEX_HARNESS.session).toBeUndefined(); - expect(GEMINI_HARNESS.session).toBeUndefined(); expect(OPENCODE_HARNESS.session).toBeUndefined(); }); }); - describe('GEMINI_HARNESS', () => { - it('buildRoleInjection returns GEMINI_SYSTEM_MD env var', () => { - const result = GEMINI_HARNESS.buildRoleInjection(ROLE_CONTENT, ROLE_FILE); - expect(result.args).toEqual([]); - expect(result.env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); - }); - - it('buildScriptRoleInjection returns env with empty fragment', () => { - const result = GEMINI_HARNESS.buildScriptRoleInjection(ROLE_CONTENT, ROLE_FILE); - expect(result.fragment).toBe(''); - expect(result.env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); - }); - }); - describe('OPENCODE_HARNESS', () => { it('buildRoleInjection throws (architect use unsupported)', () => { expect(() => OPENCODE_HARNESS.buildRoleInjection(ROLE_CONTENT, ROLE_FILE)) @@ -116,10 +113,6 @@ describe('harness', () => { it('CODEX_HARNESS does not have getWorktreeFiles', () => { expect(CODEX_HARNESS.getWorktreeFiles).toBeUndefined(); }); - - it('GEMINI_HARNESS does not have getWorktreeFiles', () => { - expect(GEMINI_HARNESS.getWorktreeFiles).toBeUndefined(); - }); }); // =========================================================================== @@ -271,9 +264,14 @@ describe('harness', () => { expect(provider).toBe(CODEX_HARNESS); }); - it('resolves built-in gemini', () => { - const provider = resolveHarness('gemini'); - expect(provider).toBe(GEMINI_HARNESS); + it('explicit gemini fails closed with the retirement (never claude, never undefined)', () => { + // Fail closed: a retired name resolves to NEITHER CLAUDE_HARNESS (the #929 + // silent-mismatch class) NOR undefined. Throwing is that guarantee. + const r = resolveResult(() => resolveHarness('gemini')); + expect(r.returned).toBeUndefined(); + expect(r.returned).not.toBe(CLAUDE_HARNESS); + expect(r.threw?.message).toMatch(/retired/i); + expect(r.threw?.message).toContain('2026-06-18'); }); it('resolves built-in opencode', () => { @@ -307,14 +305,70 @@ describe('harness', () => { expect(() => resolveHarness('bad', customHarnesses)).toThrow('my-agent'); }); + it('unrelated unknown name throws the generic error, not the retirement', () => { + expect(() => resolveHarness('frobnicate')).toThrow('Unknown harness "frobnicate"'); + expect(() => resolveHarness('frobnicate')).not.toThrow(/retired/i); + }); + + it('the available-harnesses listing no longer includes gemini', () => { + expect(() => resolveHarness('frobnicate')).toThrow(/claude/); + expect(() => resolveHarness('frobnicate')).toThrow(/codex/); + expect(() => resolveHarness('frobnicate')).toThrow(/opencode/); + expect(() => resolveHarness('frobnicate')).not.toThrow(/gemini/); + }); + + it('explicit custom gemini resolves to the custom provider (retained-access escape hatch)', () => { + // Mirrors the documented escape hatch (README) and the retired built-in GEMINI_HARNESS: + // the Gemini CLI reads its system prompt from the GEMINI_SYSTEM_MD env var (empty args / + // fragment), not a --system flag. Keeping the asserted shape identical to the documented one + // prevents the docs from drifting back to a launch line the CLI would reject. + const customHarnesses: Record = { + gemini: { + roleArgs: [], + roleEnv: { GEMINI_SYSTEM_MD: '${ROLE_FILE}' }, + roleScriptFragment: '', + roleScriptEnv: { GEMINI_SYSTEM_MD: '${ROLE_FILE}' }, + }, + }; + const provider = resolveHarness('gemini', customHarnesses); + const spawn = provider.buildRoleInjection(ROLE_CONTENT, ROLE_FILE); + expect(spawn.args).toEqual([]); + expect(spawn.env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); + const script = provider.buildScriptRoleInjection(ROLE_CONTENT, ROLE_FILE); + expect(script.fragment).toBe(''); + expect(script.env).toEqual({ GEMINI_SYSTEM_MD: ROLE_FILE }); + }); + + it('auto-detected gemini is retired even when a custom gemini exists', () => { + // Auto-detection never consults custom harnesses, so a `gemini …` command + // is retired regardless of a same-named custom definition. + const customHarnesses: Record = { + gemini: { roleArgs: [], roleScriptFragment: '' }, + }; + expect(() => resolveHarness(undefined, customHarnesses, 'gemini --yolo')).toThrow(/retired/i); + }); + + it('built-in harnesses are never shadowed by same-named custom harnesses', () => { + const customHarnesses: Record = { + claude: { roleArgs: ['x'], roleScriptFragment: 'x' }, + codex: { roleArgs: ['x'], roleScriptFragment: 'x' }, + opencode: { roleArgs: ['x'], roleScriptFragment: 'x' }, + }; + expect(resolveHarness('claude', customHarnesses)).toBe(CLAUDE_HARNESS); + expect(resolveHarness('codex', customHarnesses)).toBe(CODEX_HARNESS); + expect(resolveHarness('opencode', customHarnesses)).toBe(OPENCODE_HARNESS); + }); + it('auto-detects codex from command string', () => { const provider = resolveHarness(undefined, undefined, 'codex'); expect(provider).toBe(CODEX_HARNESS); }); - it('auto-detects gemini from full path', () => { - const provider = resolveHarness(undefined, undefined, '/opt/homebrew/bin/gemini'); - expect(provider).toBe(GEMINI_HARNESS); + it('auto-detected gemini command fails closed with the retirement (never claude, never undefined)', () => { + const r = resolveResult(() => resolveHarness(undefined, undefined, '/opt/homebrew/bin/gemini')); + expect(r.returned).toBeUndefined(); + expect(r.returned).not.toBe(CLAUDE_HARNESS); + expect(r.threw?.message).toMatch(/retired/i); }); it('auto-detects claude from command with flags', () => { @@ -328,14 +382,53 @@ describe('harness', () => { }); it('explicit harnessName takes priority over auto-detection', () => { - const provider = resolveHarness('gemini', undefined, 'codex'); - expect(provider).toBe(GEMINI_HARNESS); + const provider = resolveHarness('codex', undefined, 'claude'); + expect(provider).toBe(CODEX_HARNESS); }); it('falls back to claude for unknown command', () => { const provider = resolveHarness(undefined, undefined, 'my-custom-agent'); expect(provider).toBe(CLAUDE_HARNESS); }); + + it('inherited Object keys are not providers — throws Unknown harness, never a bogus provider (#1338)', () => { + // `harnessName` is user-controlled (config `shell.builderHarness` / a builder + // launch script). A bare `BUILTIN_HARNESSES[name]` for an inherited Object + // member returns a truthy value (`Object` for 'constructor', a function for + // 'toString'/'hasOwnProperty', `Object.prototype` for '__proto__'), which the + // pre-#1338 `if (builtin) return builtin` handed back as a bogus provider that + // TypeErrors at the first buildRoleInjection. The own-property guard makes + // these fail closed with the generic "Unknown harness" error instead. + for (const protoKey of ['constructor', 'toString', 'hasOwnProperty', 'valueOf', '__proto__']) { + const r = resolveResult(() => resolveHarness(protoKey)); + expect(r.returned, `${protoKey} must not resolve to a provider`).toBeUndefined(); + expect(r.threw?.message, `${protoKey} must throw Unknown harness`).toMatch(/Unknown harness/); + } + }); + }); + + // =========================================================================== + // getBuiltinHarness (own-property accessor — #1338) + // =========================================================================== + + describe('getBuiltinHarness', () => { + it('returns the provider for each built-in name', () => { + expect(getBuiltinHarness('claude')).toBe(CLAUDE_HARNESS); + expect(getBuiltinHarness('codex')).toBe(CODEX_HARNESS); + expect(getBuiltinHarness('opencode')).toBe(OPENCODE_HARNESS); + }); + + it('returns undefined for an unknown name', () => { + expect(getBuiltinHarness('nonexistent')).toBeUndefined(); + }); + + it('returns undefined for inherited Object keys (the footgun the guard closes)', () => { + // Mirrors isRetiredHarness's own-property check: these must never resolve to + // Object.prototype members even though `BUILTIN_HARNESSES[key]` would be truthy. + for (const protoKey of ['constructor', 'toString', 'hasOwnProperty', 'valueOf', '__proto__']) { + expect(getBuiltinHarness(protoKey)).toBeUndefined(); + } + }); }); // =========================================================================== @@ -387,4 +480,39 @@ describe('harness', () => { expect(detectHarnessFromCommand('')).toBeUndefined(); }); }); + + // =========================================================================== + // Retired harnesses (Issue #1338) + // =========================================================================== + + describe('retired harnesses', () => { + it('isRetiredHarness is true for gemini, false for supported and unknown names', () => { + expect(isRetiredHarness('gemini')).toBe(true); + expect(isRetiredHarness('claude')).toBe(false); + expect(isRetiredHarness('codex')).toBe(false); + expect(isRetiredHarness('opencode')).toBe(false); + expect(isRetiredHarness('frobnicate')).toBe(false); + }); + + it('isRetiredHarness is not fooled by inherited Object.prototype keys', () => { + expect(isRetiredHarness('constructor')).toBe(false); + expect(isRetiredHarness('toString')).toBe(false); + expect(isRetiredHarness('hasOwnProperty')).toBe(false); + }); + + it('getRetirement returns the gemini explanation and undefined otherwise', () => { + const msg = getRetirement('gemini'); + expect(msg).toMatch(/retired/i); + expect(msg).toContain('2026-06-18'); + expect(msg).toContain('claude'); + // The escape-hatch guidance names the EXPLICIT selector (#1338), matching the + // README + doctor: a bare auto-detected `gemini` stays retired, so a custom + // `gemini` def must be selected via shell.builderHarness / shell.architectHarness. + expect(msg).toContain('shell.builderHarness'); + expect(msg).toContain('shell.architectHarness'); + expect(getRetirement('claude')).toBeUndefined(); + expect(getRetirement('frobnicate')).toBeUndefined(); + expect(getRetirement('constructor')).toBeUndefined(); + }); + }); }); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-retirement.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-retirement.test.ts new file mode 100644 index 000000000..f95d054a9 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spawn-retirement.test.ts @@ -0,0 +1,106 @@ +/** + * Integration test for Issue #1338, phase_2: a builder spawn whose workspace + * selects the retired gemini harness must fail closed at the spawn() dispatcher + * BEFORE any worktree / porch / db state is created — leaving nothing behind. + * + * This drives the REAL spawn() entry point against a REAL temp workspace and the + * REAL config loader (no harness/config mocks), so it protects the actual + * invariant: the preflight runs above every state-creating handler. `createWorktree` + * itself resolves the builder harness, so a guard placed below dispatch would + * orphan a half-built worktree — this test would catch that regression. + * + * Tower/GitHub are never reached: the retirement throws before the dispatcher + * hands off to a handler, so no server mocks are needed. + * + * A second case covers `--shell` (Issue #1338 follow-up): shell mode creates no + * worktree, but `spawnShell` still starts a PTY (`startShellSession`) and persists + * a shell row (`upsertBuilder`). The preflight is now unconditional (it used to be + * gated `if (mode !== 'shell')`), so a retired gemini shell.builder must also fail + * closed — no PTY, no row. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { spawn } from '../commands/spawn.js'; + +describe('spawn retirement preflight (#1338)', () => { + let ws: string; + let originalCwd: string; + let originalHome: string | undefined; + + beforeEach(() => { + originalCwd = process.cwd(); + originalHome = process.env.HOME; + ws = mkdtempSync(join(tmpdir(), 'spawn-retire-')); + const git = (c: string) => execSync(c, { cwd: ws, stdio: 'pipe' }); + git('git init -q'); + git('git config user.email test@test.local'); + git('git config user.name Test'); + git('git config commit.gpgsign false'); + mkdirSync(join(ws, 'codev'), { recursive: true }); + writeFileSync(join(ws, 'codev', '.keep'), ''); + git('git add codev/.keep'); + git('git commit -q -m init'); + // Isolate HOME so a developer's global ~/.codev/config.json cannot mask the + // workspace's gemini builder config through the shared config loader. + process.env.HOME = ws; + // spawn() resolves the workspace from process.cwd(). + process.chdir(ws); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(ws, { recursive: true, force: true }); + }); + + function writeBuilderHarness(harness: string): void { + mkdirSync(join(ws, '.codev'), { recursive: true }); + writeFileSync(join(ws, '.codev', 'config.json'), JSON.stringify({ shell: { builderHarness: harness } })); + } + + it('rejects a gemini builder spawn with the retirement and creates NO state', async () => { + writeBuilderHarness('gemini'); + + await expect(spawn({ protocol: 'maintain', force: true })).rejects.toThrow(/retired/i); + + // The preflight aborts above ensureDirectories / createWorktree / initPorch: + // no worktree and no porch project may exist afterward. + const builders = existsSync(join(ws, '.builders')) ? readdirSync(join(ws, '.builders')) : []; + const porch = existsSync(join(ws, 'codev', 'projects')) ? readdirSync(join(ws, 'codev', 'projects')) : []; + expect(builders).toEqual([]); + expect(porch).toEqual([]); + }); + + it('rejects a gemini --shell spawn (shell mode is NOT exempt) and creates no PTY / db row', async () => { + writeBuilderHarness('gemini'); + + // `--shell` was previously exempt from the preflight (`if (mode !== 'shell')`). + // The guard is now unconditional, so a retired gemini `shell.builder` is rejected + // at the dispatcher. This `/retired/i` rejection IS the regression guard: the old + // exempt code fell through to `spawnShell` and did NOT throw the retirement — it + // would have started a PTY and persisted a shell row instead. + // + // No `force` here: `--force` is invalid for shell mode (validateSpawnOptions + // requires an issue/task/protocol alongside it) and isn't needed — the workspace + // has no uncommitted *tracked* changes (the untracked `.codev/config.json` that + // `writeBuilderHarness` creates is ignored by the pre-spawn cleanliness check). + await expect(spawn({ shell: true })).rejects.toThrow(/retired/i); + + // The preflight is the statement immediately before `handlers[mode]()`, and + // `spawnShell` is the only shell-mode side-effect producer: `startShellSession` + // (the PTY) and `upsertBuilder` (the shell row) both run only via that dispatch. + // A thrown preflight therefore guarantees neither ran — no PTY, no row — and, as + // on the worktree path, no on-disk builder/porch state. (The global db lives + // under a HOME resolved at import time and is shared across the test run, so it + // is intentionally not introspected here; the pre-dispatch throw is the proof.) + const builders = existsSync(join(ws, '.builders')) ? readdirSync(join(ws, '.builders')) : []; + const porch = existsSync(join(ws, 'codev', 'projects')) ? readdirSync(join(ws, 'codev', 'projects')) : []; + expect(builders).toEqual([]); + expect(porch).toEqual([]); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index c486563be..ad5d39536 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -341,7 +341,7 @@ describe('spawn-worktree', () => { // baked ` --resume ""` into .builder-start.sh. The fix threads a // harness-provided, pre-escaped `scriptFragment` through instead of // re-deriving the flag (which risked a comma-joined / word-split argv), and - // codex/gemini builders never reach this branch (resume === undefined → fresh + // codex/opencode builders never reach this branch (resume === undefined → fresh // role-injected script, no --resume). // ========================================================================= diff --git a/packages/codev/src/agent-farm/__tests__/tower-instances.test.ts b/packages/codev/src/agent-farm/__tests__/tower-instances.test.ts index ff4a9f333..e3372e695 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-instances.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-instances.test.ts @@ -18,6 +18,7 @@ import { getInstances, getDirectorySuggestions, launchInstance, + addArchitect, killTerminalWithShellper, stopInstance, removeArchitect, @@ -606,10 +607,107 @@ describe('tower-instances', () => { }); }); + // ========================================================================= + // Issue #1338 — a retired architect harness (gemini) must fail closed at the + // launch entry points WITHOUT crashing Tower: launchInstance and addArchitect + // both return a result object, so the retirement throw from resolveArchitectLaunch + // must be converted to a clean `{ success: false, error }` (not an uncaught + // throw / opaque 500). No architect terminal is created on the rejected path. + // ========================================================================= + describe('architect launch retirement (#1338)', () => { + const originalHome = process.env.HOME; + let isoHome: string; + + beforeEach(() => { + // Isolate HOME so a developer's global ~/.codev/config.json cannot mask the + // workspace's gemini architect config through the shared config loader. + isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'inst-home-')); + process.env.HOME = isoHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(isoHome, { recursive: true, force: true }); + }); + + function writeGeminiArchitect(dir: string): void { + fs.mkdirSync(path.join(dir, '.codev'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.codev', 'config.json'), + JSON.stringify({ shell: { architect: 'gemini' } }), + ); + } + + it('launchInstance fails cleanly (no crash) for a gemini main architect', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inst-launch-gemini-')); + fs.mkdirSync(path.join(tmpDir, 'codev')); + writeGeminiArchitect(tmpDir); + try { + const createSession = vi.fn(); + const deps = makeDeps({ + getTerminalManager: vi.fn().mockReturnValue({ + getSession: vi.fn(), + killSession: vi.fn(), + createSession, + createSessionRaw: vi.fn(), + listSessions: vi.fn().mockReturnValue([]), + }) as any, + }); + initInstances(deps); + + const result = await launchInstance(tmpDir); + expect(result.success).toBe(false); + expect(result.error).toMatch(/retired/i); + // Fail closed BEFORE the architect terminal is created. + expect(createSession).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('addArchitect returns a clean retirement error (no uncaught throw) for a gemini architect', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inst-add-gemini-')); + fs.mkdirSync(path.join(tmpDir, 'codev')); + writeGeminiArchitect(tmpDir); + const resolvedPath = fs.realpathSync(tmpDir); + try { + const createSession = vi.fn(); + // addArchitect early-returns unless the workspace already has ≥1 + // architect; seed a 'main' row so it reaches the launch boundary. + const workspaceTerminals = new Map([ + [resolvedPath, { + architects: new Map([['main', { terminalId: 'main-t' }]]), + builders: new Map(), + shells: new Map(), + }], + ]); + const deps = makeDeps({ + workspaceTerminals: workspaceTerminals as any, + getTerminalManager: vi.fn().mockReturnValue({ + getSession: vi.fn(), + killSession: vi.fn(), + createSession, + createSessionRaw: vi.fn(), + listSessions: vi.fn().mockReturnValue([]), + }) as any, + }); + initInstances(deps); + + const result = await addArchitect(tmpDir, 'reviewer'); + expect(result.success).toBe(false); + expect(result.error).toMatch(/retired/i); + expect(createSession).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); + // ========================================================================= // Issue #929 / #1145 — architect launch never discovery-resumes // - // #929's crash-loop guard (codex/gemini + stale Claude jsonl must not launch + // #929's crash-loop guard (a non-claude architect like codex + stale Claude jsonl must not launch // ` --resume`) is subsumed by #1145: launchInstance no longer consults // the jsonl store at all. Its ONLY resume source is the session id stored on // this workspace's `main` architect row. A fresh workspace with a personal @@ -1361,8 +1459,15 @@ describe('tower-instances', () => { expect(entry.architects.has('ghost')).toBe(false); // Only main's terminal was created. expect(mockManager.createSession).toHaveBeenCalledTimes(1); - // The gate consulted the session-artifact check with the row's stored id. - expect(mockSiblingRegistrationIsLive).toHaveBeenCalledWith(tmpDir, 'dead-session-id'); + // The gate consulted the session-artifact check with the row's stored id, + // and threaded the reconcile logger so a prune's real reason stays + // diagnosable (Issue #1338 wired the log through; note a *retired* harness + // now KEEPS its row and logs that itself — see siblingRegistrationIsLive). + expect(mockSiblingRegistrationIsLive).toHaveBeenCalledWith( + tmpDir, + 'dead-session-id', + { log: expect.any(Function) }, + ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/packages/codev/src/agent-farm/__tests__/tower-terminals-restart-gating.test.ts b/packages/codev/src/agent-farm/__tests__/tower-terminals-restart-gating.test.ts new file mode 100644 index 000000000..cc98a799c --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/tower-terminals-restart-gating.test.ts @@ -0,0 +1,196 @@ +/** + * Issue #1338 — retired-harness architect: restartOnExit gated on reconnect. + * + * BLOCKING review fix (upstream maintainer, PR #1342). Both reconnect paths in + * tower-terminals.ts used to force `ptySession.restartOnExit = true` for EVERY + * architect. A retired-harness architect resolves `restartOptions` to `undefined` + * — no auto-restart is configured (SessionManager mirrors this with + * `restartOnExit: hasRestart`) — so forcing the PTY flag true made the pane hold + * WebSocket clients in a "restarting…" wait for a process that can never restart. + * + * These tests exercise the CONSUMER (tower-terminals reconnect) on BOTH paths — + * startup `reconcileTerminalSessions` and on-the-fly `getTerminalsForWorkspace` — + * and assert the PTY flag tracks `restartOptions`. `buildArchitectReconnectRestart- + * Options` is mocked so its return (undefined = retired, an object = supported) is + * the controlled input: this isolates the consumer's gating branch from the helper, + * whose real undefined-return is separately unit-tested in tower-utils.test.ts. + * (A real-config end-to-end version tripped a vitest module-duplication artifact — + * the helper's `err instanceof RetiredHarnessError` check and config.ts's throw + * bound different harness.js instances under this file's vi.mock graph — which does + * not occur in production's single module graph.) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { EventEmitter } from 'node:events'; + +// --- Mocks (mirror tower-terminals.test.ts for db + file tabs) --- +const { mockDbPrepare, mockDbRun, mockDbAll, mockBuildRestartOptions } = vi.hoisted(() => ({ + mockDbPrepare: vi.fn(), + mockDbRun: vi.fn(), + mockDbAll: vi.fn(), + mockBuildRestartOptions: vi.fn(), +})); + +vi.mock('../db/index.js', () => ({ + getGlobalDb: () => ({ + prepare: (...args: unknown[]) => { + mockDbPrepare(...args); + return { run: mockDbRun, all: mockDbAll }; + }, + }), +})); + +vi.mock('../utils/file-tabs.js', () => ({ + saveFileTab: vi.fn(), + deleteFileTab: vi.fn(), + loadFileTabsForWorkspace: vi.fn(() => new Map()), +})); + +// The unit under test: keep every real export (notably normalizeWorkspacePath, +// which tower-terminals also imports) and override only the restart-options helper +// so the consumer's `restartOptions` input is controlled per-test. +vi.mock('../servers/tower-utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, buildArchitectReconnectRestartOptions: mockBuildRestartOptions }; +}); + +import { + initTerminals, + shutdownTerminals, + getWorkspaceTerminals, + getTerminalManager, + reconcileTerminalSessions, + getTerminalsForWorkspace, + __resetStartupReconcileSettledForTest, + type TerminalDeps, +} from '../servers/tower-terminals.js'; + +function makeDeps(overrides: Partial = {}): TerminalDeps { + return { + log: vi.fn(), + shellperManager: null, + registerKnownWorkspace: vi.fn(), + getKnownWorkspacePaths: vi.fn(() => []), + ...overrides, + }; +} + +// reconnectSession must return a non-null client to reach attachShellper + the +// restartOnExit assignment. Minimal surface the reconnect path touches: +// EventEmitter + lastDataAt/connected/write/resize + waitForReplay. +function makeReconnectClient(): unknown { + const client = new EventEmitter() as EventEmitter & Record; + Object.defineProperty(client, 'lastDataAt', { get: () => 1 }); + Object.defineProperty(client, 'connected', { get: () => true }); + client.write = () => true; + client.resize = () => true; + client.waitForReplay = async () => Buffer.alloc(0); + client.getReplayData = () => null; + return client; +} + +const WS = '/real/project'; +const LIVE_RESTART_OPTIONS = { command: 'claude', args: [], cwd: WS, env: {} }; + +describe('Issue #1338 — restartOnExit gated on restartOptions (consumer path)', () => { + const createdLogIds: string[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + shutdownTerminals(); + getWorkspaceTerminals().clear(); + __resetStartupReconcileSettledForTest(); + mockDbPrepare.mockReturnValue({ run: mockDbRun, all: mockDbAll }); + // Reconcile pre-filter: workspace must exist and not be under /tmp. Config + // reads (loadConfig) resolve to defaults because the config path returns false. + vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => String(p) === WS); + }); + + afterEach(() => { + vi.restoreAllMocks(); + shutdownTerminals(); + getWorkspaceTerminals().clear(); + // createSessionRaw logs under AGENT_FARM_DIR (~/.agent-farm/logs); drop them. + for (const id of createdLogIds.splice(0)) { + fs.rmSync(path.join(os.homedir(), '.agent-farm', 'logs', `${id}.log`), { force: true }); + } + }); + + function architectRow(id: string) { + createdLogIds.push(id); + return { + id, + workspace_path: WS, + type: 'architect', + role_id: 'main', + pid: 5000, + shellper_socket: `/var/run/shellper-${id}.sock`, + shellper_pid: 6000, + shellper_start_time: 1, + cwd: WS, + created_at: '2026-01-01T00:00:00.000Z', + }; + } + + function initWithClient(): void { + const deps = makeDeps({ + shellperManager: { reconnectSession: vi.fn(async () => makeReconnectClient()) } as any, + }); + initTerminals(deps); + } + + describe('startup reconcile (reconcileTerminalSessions)', () => { + it('retired architect (restartOptions undefined) → restartOnExit stays false', async () => { + mockBuildRestartOptions.mockReturnValue(undefined); + initWithClient(); + mockDbAll.mockReturnValue([architectRow('arch-rec-retired')]); + + await reconcileTerminalSessions(); + + const session = getTerminalManager().getSession('arch-rec-retired'); + expect(session).toBeDefined(); + expect(session!.restartOnExit).toBe(false); + }); + + it('supported architect (restartOptions defined) → restartOnExit true (happy path preserved)', async () => { + mockBuildRestartOptions.mockReturnValue(LIVE_RESTART_OPTIONS); + initWithClient(); + mockDbAll.mockReturnValue([architectRow('arch-rec-live')]); + + await reconcileTerminalSessions(); + + const session = getTerminalManager().getSession('arch-rec-live'); + expect(session).toBeDefined(); + expect(session!.restartOnExit).toBe(true); + }); + }); + + describe('on-the-fly reconnect (getTerminalsForWorkspace)', () => { + it('retired architect (restartOptions undefined) → restartOnExit stays false', async () => { + mockBuildRestartOptions.mockReturnValue(undefined); + initWithClient(); + mockDbAll.mockReturnValue([architectRow('arch-otf-retired')]); + + await getTerminalsForWorkspace(WS, 'http://example.test'); + + const session = getTerminalManager().getSession('arch-otf-retired'); + expect(session).toBeDefined(); + expect(session!.restartOnExit).toBe(false); + }); + + it('supported architect (restartOptions defined) → restartOnExit true (happy path preserved)', async () => { + mockBuildRestartOptions.mockReturnValue(LIVE_RESTART_OPTIONS); + initWithClient(); + mockDbAll.mockReturnValue([architectRow('arch-otf-live')]); + + await getTerminalsForWorkspace(WS, 'http://example.test'); + + const session = getTerminalManager().getSession('arch-otf-live'); + expect(session).toBeDefined(); + expect(session!.restartOnExit).toBe(true); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/tower-utils.test.ts b/packages/codev/src/agent-farm/__tests__/tower-utils.test.ts index f714bc8e3..03f0c5748 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-utils.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-utils.test.ts @@ -18,10 +18,13 @@ import { normalizeWorkspacePath, isTempDirectory, serveStaticFile, + buildArchitectArgs, resolveArchitectLaunch, resolveArchitectRestart, siblingRegistrationIsLive, buildArchitectCrashLoopFallback, + buildArchitectFreshLaunch, + buildArchitectReconnectRestartOptions, sessionHasLiveHolder, } from '../servers/tower-utils.js'; @@ -490,6 +493,14 @@ describe('siblingRegistrationIsLive (Issue #1150)', () => { ); } + function forceGeminiHarness(): void { + fs.mkdirSync(path.join(workspace, '.codev'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, '.codev', 'config.json'), + JSON.stringify({ shell: { architect: 'gemini' } }), + ); + } + it('live when the stored session jsonl exists on disk', () => { writeSessionFixture(fakeHome, workspace, 'sib-1'); expect(siblingRegistrationIsLive(workspace, 'sib-1', { homeDir: fakeHome })).toBe(true); @@ -515,6 +526,243 @@ describe('siblingRegistrationIsLive (Issue #1150)', () => { writeSessionFixture(fakeHome, '/somewhere/else/entirely', 'foreign-1'); expect(siblingRegistrationIsLive(workspace, 'foreign-1', { homeDir: fakeHome })).toBe(false); }); + + // Issue #1338: a retired gemini architect makes getArchitectHarness throw. This + // predicate must NOT propagate that throw (it would abort the whole + // sibling-reconcile pass for every architect in the workspace). It returns `true` + // — not because a retired registration can launch (it cannot; addArchitect fails + // closed on the same retirement), but so the reconcile loop routes to addArchitect + // and leaves the user's config row intact instead of DELETEing it. Same launch + // safety, non-destructive to a registration the user can still repair. + it('live (returns true, no throw) for a retired gemini architect config so its row survives', () => { + forceGeminiHarness(); + expect(() => siblingRegistrationIsLive(workspace, 'sib-1', { homeDir: fakeHome })).not.toThrow(); + expect(siblingRegistrationIsLive(workspace, 'sib-1', { homeDir: fakeHome })).toBe(true); + // Also true with no stored id (the reconcile loop's other liveness input): the + // decision is harness retirement, independent of any session evidence. + expect(siblingRegistrationIsLive(workspace, null, { homeDir: fakeHome })).toBe(true); + }); + + // Issue #1338: returning true keeps the row, but the reason must stay diagnosable. + // The catch emits an INFO explaining the row is KEPT (addArchitect fails closed) + // rather than pruned, so a retired sibling that re-fails on every reconcile is + // traceable to its harness instead of a misattributed "no resumable session". + it('logs the retirement reason (INFO) for a retired gemini architect (row kept, not pruned)', () => { + forceGeminiHarness(); + const logged: Array<{ level: string; message: string }> = []; + const log = (level: 'INFO' | 'WARN' | 'ERROR', message: string) => logged.push({ level, message }); + expect(siblingRegistrationIsLive(workspace, 'sib-1', { homeDir: fakeHome, log })).toBe(true); + expect(logged).toHaveLength(1); + expect(logged[0].level).toBe('INFO'); + expect(logged[0].message).toMatch(/retired harness/i); + // The message describes keeping the row, not pruning it (the behavior change). + expect(logged[0].message).toMatch(/keeping the row/i); + }); + + it('does NOT log for a live (supported) sibling registration', () => { + // Session-less codex is always live → the retirement catch is never reached, + // so no diagnostic line is emitted (non-vacuity: the log is retirement-specific, + // not emitted on every call). + forceCodexHarness(); + const logged: string[] = []; + const log = (_level: 'INFO' | 'WARN' | 'ERROR', message: string) => logged.push(message); + expect(siblingRegistrationIsLive(workspace, null, { homeDir: fakeHome, log })).toBe(true); + expect(logged).toEqual([]); + }); +}); + +// Issue #1338 tests write a workspace `.codev/config.json` and assert the harness +// resolves from it. But `getArchitectHarness` → `getResolvedCommands` gives +// `TOWER_ARCHITECT_CMD` precedence over workspace config, and `loadUserConfig` can +// pick up a global `~/.codev/config.json` (HOME) — either set locally would mask +// the fixture with a loud, misleading failure (phase_2 iter2 review nit). Isolate +// both for every retirement describe below. +function isolateHarnessEnv(): { restore: () => void } { + const home = fs.mkdtempSync(path.join(tmpdir(), 'ret-home-')); + const saved: Record = { + HOME: process.env.HOME, + TOWER_ARCHITECT_CMD: process.env.TOWER_ARCHITECT_CMD, + TOWER_BUILDER_CMD: process.env.TOWER_BUILDER_CMD, + }; + process.env.HOME = home; + delete process.env.TOWER_ARCHITECT_CMD; + delete process.env.TOWER_BUILDER_CMD; + return { + restore: () => { + fs.rmSync(home, { recursive: true, force: true }); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }, + }; +} + +// Issue #1338 — buildArchitectArgs is the shared launch-injection boundary every +// architect launch funnels through. A retired architect harness (gemini) must +// fail closed here with the retirement message (a retired architect must not +// launch); a supported harness resolves unchanged. +describe('buildArchitectArgs retirement (#1338)', () => { + let workspace: string; + let envGuard: { restore: () => void }; + + beforeEach(() => { + envGuard = isolateHarnessEnv(); + workspace = fs.mkdtempSync(path.join(tmpdir(), 'baa-ws-')); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + envGuard.restore(); + }); + + function writeArchitectConfig(architect: string): void { + fs.mkdirSync(path.join(workspace, '.codev'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, '.codev', 'config.json'), + JSON.stringify({ shell: { architect } }), + ); + } + + it('throws the retirement for a gemini architect config', () => { + writeArchitectConfig('gemini'); + expect(() => buildArchitectArgs([], workspace)).toThrow(/retired/i); + }); + + it('does not throw for a codex architect config (regression)', () => { + writeArchitectConfig('codex'); + // Codex resolves normally (no retirement); baseArgs stay at the front, with + // any role injection (from the bundled skeleton architect role) appended. + expect(() => buildArchitectArgs(['--base'], workspace)).not.toThrow(); + expect(buildArchitectArgs(['--base'], workspace).args[0]).toBe('--base'); + }); + + it('does not throw for the default (claude) architect (regression)', () => { + expect(() => buildArchitectArgs([], workspace)).not.toThrow(); + }); +}); + +// Issue #1338 — the clean-exit relaunch path. SessionManager invokes +// freshLaunch.next() WITHOUT a try/catch, so if the architect's harness was +// retired after launch (a config edit before a clean exit), next() must not throw +// and must not re-inject/relaunch the retired harness. +describe('buildArchitectFreshLaunch retirement (#1338)', () => { + let workspace: string; + let envGuard: { restore: () => void }; + + beforeEach(() => { + envGuard = isolateHarnessEnv(); + workspace = fs.mkdtempSync(path.join(tmpdir(), 'bafl-ws-')); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + envGuard.restore(); + }); + + function writeArchitectConfig(architect: string): void { + fs.mkdirSync(path.join(workspace, '.codev'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, '.codev', 'config.json'), + JSON.stringify({ shell: { architect } }), + ); + } + + it('next() for a retired gemini architect signals stop (fail closed) and does not throw', () => { + writeArchitectConfig('gemini'); + const fl = buildArchitectFreshLaunch({ + workspacePath: workspace, architectName: 'main', baseArgs: ['--base'], baseEnv: { FOO: 'bar' }, log: vi.fn(), + }); + // next() can only change args/env, not the retained launch command — which + // may itself be the retired binary. So returning baseArgs would still let + // SessionManager respawn it (fail-open). Fail closed by signalling stop; the + // clean-exit handler then ends the session instead of relaunching. No throw + // (SessionManager calls next() without a try/catch). + expect(() => fl.next()).not.toThrow(); + expect(fl.next()).toEqual({ stop: true }); + }); + + it('next() for a supported (codex) architect resolves normally (regression)', () => { + writeArchitectConfig('codex'); + const fl = buildArchitectFreshLaunch({ + workspacePath: workspace, architectName: 'main', baseArgs: ['--base'], baseEnv: {}, log: vi.fn(), + }); + expect(() => fl.next()).not.toThrow(); + expect(fl.next().args[0]).toBe('--base'); + }); +}); + +// Issue #1338 — the shellper reconnect / startup-reconcile path. A retired harness +// must NOT produce restart options that relaunch the retired command (the previous +// fail-OPEN bug); it fails closed by returning undefined (reconnect only, never an +// auto-restart into the retired binary). Non-retirement harness errors still +// degrade to the plain configured command so a transient failure can reconnect. +describe('buildArchitectReconnectRestartOptions retirement (#1338)', () => { + let workspace: string; + let envGuard: { restore: () => void }; + + beforeEach(() => { + envGuard = isolateHarnessEnv(); + workspace = fs.mkdtempSync(path.join(tmpdir(), 'barro-ws-')); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + envGuard.restore(); + }); + + function writeConfig(cfg: object): void { + fs.mkdirSync(path.join(workspace, '.codev'), { recursive: true }); + fs.writeFileSync(path.join(workspace, '.codev', 'config.json'), JSON.stringify(cfg)); + } + + it('returns undefined (fail closed) for a retired gemini architect', () => { + writeConfig({ shell: { architect: 'gemini' } }); + const opts = buildArchitectReconnectRestartOptions({ + workspacePath: workspace, architectName: 'main', cmdParts: ['gemini', '--yolo'], cleanEnv: {}, includeFreshLaunch: true, log: vi.fn(), + }); + expect(opts).toBeUndefined(); + }); + + it('returns undefined for a retired gemini architect regardless of includeFreshLaunch', () => { + writeConfig({ shell: { architect: 'gemini' } }); + expect(buildArchitectReconnectRestartOptions({ + workspacePath: workspace, architectName: 'main', cmdParts: ['gemini'], cleanEnv: {}, includeFreshLaunch: false, log: vi.fn(), + })).toBeUndefined(); + }); + + it('returns restart options for a supported (codex) architect (regression)', () => { + writeConfig({ shell: { architect: 'codex' } }); + const opts = buildArchitectReconnectRestartOptions({ + workspacePath: workspace, architectName: 'main', cmdParts: ['codex', '--flag'], cleanEnv: { CODEV_ARCHITECT_NAME: 'main' }, includeFreshLaunch: false, log: vi.fn(), + }); + expect(opts).toBeDefined(); + expect(opts!.command).toBe('codex'); + expect(opts!.env.CODEV_ARCHITECT_NAME).toBe('main'); + expect(opts!.freshLaunch).toBeUndefined(); + }); + + it('wires freshLaunch only when includeFreshLaunch is true', () => { + writeConfig({ shell: { architect: 'codex' } }); + const base = { workspacePath: workspace, architectName: 'main', cmdParts: ['codex'], cleanEnv: {}, log: vi.fn() }; + expect(buildArchitectReconnectRestartOptions({ ...base, includeFreshLaunch: true })!.freshLaunch).toBeDefined(); + expect(buildArchitectReconnectRestartOptions({ ...base, includeFreshLaunch: false })!.freshLaunch).toBeUndefined(); + }); + + it('falls back to the plain command for a non-retirement harness error', () => { + // An unknown harness name throws a generic error (not the retirement), so the + // helper degrades to the plain configured command so the session can reconnect + // (identity preserved via cleanEnv). This must NOT change for retired harnesses. + writeConfig({ shell: { architectHarness: 'no-such-harness', architect: 'weirdcmd' } }); + const opts = buildArchitectReconnectRestartOptions({ + workspacePath: workspace, architectName: 'main', cmdParts: ['weirdcmd', '--x'], cleanEnv: { CODEV_ARCHITECT_NAME: 'main' }, includeFreshLaunch: true, log: vi.fn(), + }); + expect(opts).toBeDefined(); + expect(opts!.command).toBe('weirdcmd'); + expect(opts!.args).toEqual(['--x']); + expect(opts!.env.CODEV_ARCHITECT_NAME).toBe('main'); + expect(opts!.freshLaunch).toBeUndefined(); + }); }); describe('resolveArchitectRestart (Issue #832 — shellper auto-restart bake)', () => { diff --git a/packages/codev/src/agent-farm/commands/reset/context.ts b/packages/codev/src/agent-farm/commands/reset/context.ts index ac41da849..43b1a8a50 100644 --- a/packages/codev/src/agent-farm/commands/reset/context.ts +++ b/packages/codev/src/agent-farm/commands/reset/context.ts @@ -31,6 +31,7 @@ import { join } from 'node:path'; import { parseAgentName } from '../../utils/agent-names.js'; import { BUILTIN_HARNESSES, + getBuiltinHarness, buildCustomHarnessProvider, type CustomHarnessConfig, type HarnessProvider, @@ -465,7 +466,10 @@ export function harnessProviderFor( harnessName: string, customHarnesses?: Record, ): HarnessProvider | null { - const builtin = BUILTIN_HARNESSES[harnessName]; + // Own-property lookup (see getBuiltinHarness): `harnessName` comes from a running + // builder's launch script — a user-controlled key — so a bare index could hand + // back an inherited Object member as a bogus provider. + const builtin = getBuiltinHarness(harnessName); if (builtin) return builtin; if (customHarnesses && harnessName in customHarnesses) { return buildCustomHarnessProvider(customHarnesses[harnessName]); diff --git a/packages/codev/src/agent-farm/commands/spawn.ts b/packages/codev/src/agent-farm/commands/spawn.ts index 217398f43..c1761391e 100644 --- a/packages/codev/src/agent-farm/commands/spawn.ts +++ b/packages/codev/src/agent-farm/commands/spawn.ts @@ -17,7 +17,7 @@ import { resolve, basename } from 'node:path'; import { existsSync, writeFileSync, readdirSync } from 'node:fs'; import type { SpawnOptions, BuilderType, Config } from '../types.js'; -import { getConfig, ensureDirectories, getResolvedCommands, getBuilderHarness } from '../utils/index.js'; +import { getConfig, ensureDirectories, getResolvedCommands, getBuilderHarness, assertBuilderHarnessNotRetired } from '../utils/index.js'; import type { HarnessProvider } from '../utils/harness.js'; import { logger, fatal } from '../utils/logger.js'; import { run } from '../utils/shell.js'; @@ -920,6 +920,19 @@ export async function spawn(options: SpawnOptions): Promise { const mode = getSpawnMode(options); + // Fail closed on a retired builder harness (Issue #1338) BEFORE dispatching to + // any handler, for EVERY mode. Worktree modes create worktree/porch/db state, + // and `createWorktree` itself resolves the builder harness (spawn-worktree.ts), + // so a guard placed below dispatch would orphan a half-built worktree. `shell` + // mode has no worktree, but `spawnShell` still runs `commands.builder` via + // `startShellSession` and persists a shell row — so a retired `gemini` + // shell.builder would otherwise launch the retired CLI AND leave a shell row. + // The retirement decision (including the custom-harness escape hatch) is + // delegated to `getBuilderHarness`; only the retirement aborts here — an + // unknown-harness name still surfaces at its existing resolution call site, so + // behavior is unchanged for every supported harness and every mode. + assertBuilderHarnessNotRetired(config.workspaceRoot); + const handlers: Record Promise> = { spec: () => spawnSpec(options, config), bugfix: () => spawnBugfix(options, config), diff --git a/packages/codev/src/agent-farm/servers/tower-instances.ts b/packages/codev/src/agent-farm/servers/tower-instances.ts index 0f6ddb56a..3fec0b11c 100644 --- a/packages/codev/src/agent-farm/servers/tower-instances.ts +++ b/packages/codev/src/agent-farm/servers/tower-instances.ts @@ -41,6 +41,7 @@ import { validateArchitectName, DEFAULT_ARCHITECT_NAME, } from '../utils/architect-name.js'; +import { RetiredHarnessError } from '../utils/harness.js'; import { setArchitect, setArchitectByName, getArchitects, getArchitectByName } from '../state.js'; // ============================================================================ @@ -791,7 +792,10 @@ export async function launchInstance(workspacePath: string): Promise<{ success: if (entry.architects.has(a.name)) continue; if ( !hasArchitectTerminalSession(a.name, resolvedPath, workspacePath) && - !siblingRegistrationIsLive(workspacePath, a.sessionId ?? null) + // Pass the reconcile logger so a retired-harness prune (Issue #1338) logs + // its real reason instead of being misattributed to the generic + // "no resumable session" line below. + !siblingRegistrationIsLive(workspacePath, a.sessionId ?? null, { log: _deps.log }) ) { try { setArchitectByName(resolvedPath, a.name, null); @@ -1067,14 +1071,30 @@ export async function addArchitect( log: _deps.log, })); } - const { args: cmdArgs, env: harnessEnv, sessionId: conversationSessionId, resumed, fallback } = resolveArchitectLaunch({ - workspacePath, - name, - baseArgs: cmdParts.slice(1), - storedSessionId, - hasLiveHolder: () => foreignHolder, - log: _deps.log, - }); + // Issue #1338: a retired architect harness (e.g. gemini) makes + // resolveArchitectLaunch throw at the launch boundary. This entry point (the + // `add-architect` CLI / HTTP route) returns a result object rather than + // throwing, so convert the retirement into a clean `{ success: false }` — the + // caller in tower-routes.ts awaits this without its own try/catch, so an + // uncaught throw would become an opaque 500 instead of the retirement message. + // Scope the catch to the retirement only (mirrors launchInstance's fail path, + // narrowed): every other error propagates unchanged. No worktree/porch/db + // state is created before this point, so bailing here leaves nothing behind. + let launch; + try { + launch = resolveArchitectLaunch({ + workspacePath, + name, + baseArgs: cmdParts.slice(1), + storedSessionId, + hasLiveHolder: () => foreignHolder, + log: _deps.log, + }); + } catch (err) { + if (err instanceof RetiredHarnessError) return { success: false, error: err.message }; + throw err; + } + const { args: cmdArgs, env: harnessEnv, sessionId: conversationSessionId, resumed, fallback } = launch; if (resumed && conversationSessionId) { _deps.log('INFO', `Resuming architect '${name}' session ${conversationSessionId.slice(0, 8)}… in ${workspacePath}`); } diff --git a/packages/codev/src/agent-farm/servers/tower-terminals.ts b/packages/codev/src/agent-farm/servers/tower-terminals.ts index f366adedc..dd2eea933 100644 --- a/packages/codev/src/agent-farm/servers/tower-terminals.ts +++ b/packages/codev/src/agent-farm/servers/tower-terminals.ts @@ -51,7 +51,7 @@ function extractShellperSessionId(socketPath: string | null): string | null { import type { SessionManager, ReconnectRestartOptions } from '../../terminal/session-manager.js'; import type { PtySession } from '../../terminal/pty-session.js'; import type { WorkspaceTerminals, TerminalEntry, DbTerminalSession } from './tower-types.js'; -import { normalizeWorkspacePath, resolveArchitectRestart, buildArchitectCrashLoopFallback, buildArchitectFreshLaunch } from './tower-utils.js'; +import { normalizeWorkspacePath, buildArchitectReconnectRestartOptions } from './tower-utils.js'; import { setArchitectByName } from '../state.js'; import { isIntentionallyStopping } from './tower-instances.js'; @@ -669,66 +669,21 @@ async function _reconcileTerminalSessionsInner(): Promise { // populated them; this is belt-and-suspenders). const architectName = dbSession.role_id || 'main'; cleanEnv['CODEV_ARCHITECT_NAME'] = architectName; - try { - // Issue #832: bake `--resume ` into the auto-restart args so a - // claude crash inside a live shellper revives the SAME conversation (the - // silent-context-loss path). The id was stored at the original spawn; a - // legacy architect with none falls through to a fresh session, then self- - // heals (the next spawn/revival stores an id). The minted id on the fresh - // branch is not persisted here (the bake precedes the actual restart) — - // fine, since post-#832 architects always carry a stored id and resume. - const { args: architectArgs, env: harnessEnv, resumed, storedSessionId, fallback } = - resolveArchitectRestart(workspacePath, architectName, cmdParts.slice(1)); - if (resumed && storedSessionId) { - _deps.log('INFO', `Resuming architect '${architectName}' session ${storedSessionId.slice(0, 8)}… on restart in ${workspacePath}`); - } - restartOptions = { - command: cmdParts[0], - args: architectArgs, - cwd: workspacePath, - env: { ...cleanEnv, ...harnessEnv }, - restartDelay: 2000, - maxRestarts: 50, - // Issue #1264: a clean exit inside this reconnected session reruns - // the harness fresh rather than ending the session. Built from - // cmdParts, not `architectArgs` — the latter has `--resume` baked in - // by the #832 restart resolution just above. - freshLaunch: buildArchitectFreshLaunch({ - workspacePath, - architectName, - baseArgs: cmdParts.slice(1), - baseEnv: cleanEnv, - log: _deps.log, - }), - }; - // Issue #1149: if the resumed session fast-fails at runtime (jsonl - // vanished after the bake, or corrupt), degrade to a fresh launch - // instead of burning all 50 restarts on identical args. - if (resumed && storedSessionId && fallback) { - restartOptions.crashLoopFallback = buildArchitectCrashLoopFallback({ - workspacePath, - architectName, - storedSessionId, - fallback, - baseEnv: cleanEnv, - log: _deps.log, - }); - } - } catch (err) { - _deps.log('WARN', `Harness resolution failed for workspace ${workspacePath}: ${err instanceof Error ? err.message : err}`); - // Fall back to plain command without harness role-prompt args so the - // session can still reconnect. `cleanEnv` still carries - // CODEV_ARCHITECT_NAME (set above for Spec 786 Phase 2), so identity - // is preserved even on harness failure. - restartOptions = { - command: cmdParts[0], - args: cmdParts.slice(1), - cwd: workspacePath, - env: cleanEnv, - restartDelay: 2000, - maxRestarts: 50, - }; - } + // Issue #832/#1149/#1264: bake `--resume ` + a #1264 clean-exit + // fresh-launch factory + a #1149 crash-loop fallback into the auto-restart + // args, so a claude crash inside a live shellper revives the SAME + // conversation. Issue #1338: a retired harness returns `undefined` here + // (fail closed — no auto-restart into the retired binary), never a fallback + // that relaunches it. `includeFreshLaunch: true` — this is the startup + // reconcile path that also wires the #1264 clean-exit rerun. + restartOptions = buildArchitectReconnectRestartOptions({ + workspacePath, + architectName, + cmdParts, + cleanEnv, + includeFreshLaunch: true, + log: _deps.log, + }); } probeTasks.push({ dbSession, restartOptions }); @@ -780,7 +735,7 @@ async function _reconcileTerminalSessionsInner(): Promise { } // Process probe results sequentially (shared state mutations) - for (const { dbSession, client, replayData } of probeResults) { + for (const { dbSession, client, replayData, restartOptions } of probeResults) { if (!client) { _deps.log('INFO', `Shellper session ${dbSession.id} is stale (PID/socket dead) — will clean up`); continue; // Will be cleaned up in Phase 2 @@ -803,8 +758,14 @@ async function _reconcileTerminalSessionsInner(): Promise { // which the `if (!client)` guard already excluded — fall back to // empty defensively rather than asserting. ptySession.attachShellper(client, replayData ?? Buffer.alloc(0), dbSession.shellper_pid!, shellperSessId); - // Architect sessions have auto-restart — keep WebSocket clients connected on exit - if (dbSession.type === 'architect') { + // Architect sessions with a live auto-restart config keep WebSocket clients + // connected on exit. Gate on `restartOptions`: a retired-harness architect + // (#1338) resolves to `undefined` here, so `reconnectSession` was told NOT to + // configure an auto-restart (SessionManager mirrors this exactly with + // `restartOnExit: hasRestart`). Setting the PTY flag true anyway would make + // PtySession hold WebSocket clients in a "restarting…" wait for a process that + // can never come back — the flag must track whether a restart is real. + if (dbSession.type === 'architect' && restartOptions) { ptySession.restartOnExit = true; } } @@ -951,45 +912,20 @@ export async function getTerminalsForWorkspace( // restart (see matching block in reconcileTerminalSessionsInner above). const architectName = dbSession.role_id || 'main'; cleanEnv['CODEV_ARCHITECT_NAME'] = architectName; - try { - // Issue #832: revive the same conversation on auto-restart via the - // stored session id (see matching block above). - const { args: architectArgs, env: harnessEnv, resumed, storedSessionId, fallback } = - resolveArchitectRestart(dbSession.workspace_path, architectName, cmdParts.slice(1)); - if (resumed && storedSessionId) { - _deps.log('INFO', `Resuming architect '${architectName}' session ${storedSessionId.slice(0, 8)}… on reconnect in ${dbSession.workspace_path}`); - } - restartOptions = { - command: cmdParts[0], - args: architectArgs, - cwd: dbSession.workspace_path, - env: { ...cleanEnv, ...harnessEnv }, - restartDelay: 2000, - maxRestarts: 50, - }; - // Issue #1149: degrade a fast-failing resume to a fresh launch - // (see matching block in reconcileTerminalSessionsInner above). - if (resumed && storedSessionId && fallback) { - restartOptions.crashLoopFallback = buildArchitectCrashLoopFallback({ - workspacePath: dbSession.workspace_path, - architectName, - storedSessionId, - fallback, - baseEnv: cleanEnv, - log: _deps.log, - }); - } - } catch (err) { - _deps.log('WARN', `Harness resolution failed for workspace ${dbSession.workspace_path}: ${err instanceof Error ? err.message : err}`); - restartOptions = { - command: cmdParts[0], - args: cmdParts.slice(1), - cwd: dbSession.workspace_path, - env: cleanEnv, - restartDelay: 2000, - maxRestarts: 50, - }; - } + // Issue #832/#1149: revive the same conversation on auto-restart via the + // stored session id, with the #1149 crash-loop fallback (see matching + // block in reconcileTerminalSessionsInner above). Issue #1338: a retired + // harness returns `undefined` (fail closed — no auto-restart into the + // retired binary). `includeFreshLaunch: false` — the on-the-fly reconnect + // path does not wire the #1264 clean-exit rerun (unchanged from before). + restartOptions = buildArchitectReconnectRestartOptions({ + workspacePath: dbSession.workspace_path, + architectName, + cmdParts, + cleanEnv, + includeFreshLaunch: false, + log: _deps.log, + }); } _deps.log('INFO', `On-the-fly shellper reconnect for ${dbSession.id}`); @@ -1014,8 +950,12 @@ export async function getTerminalsForWorkspace( if (ptySession) { const shellperSessId = extractShellperSessionId(dbSession.shellper_socket) ?? dbSession.id; ptySession.attachShellper(client, replayData, dbSession.shellper_pid!, shellperSessId); - // Architect sessions have auto-restart — keep WebSocket clients connected on exit - if (dbSession.type === 'architect') { + // Gate on `restartOptions` (same rationale as the reconcile path above): + // a retired-harness architect (#1338) resolves to `undefined`, so holding + // clients for an auto-restart that was never configured would strand the + // pane in a "restarting…" wait. `restartOptions` is in scope from the + // architect block above. + if (dbSession.type === 'architect' && restartOptions) { ptySession.restartOnExit = true; } diff --git a/packages/codev/src/agent-farm/servers/tower-utils.ts b/packages/codev/src/agent-farm/servers/tower-utils.ts index 828ef8b7f..21a3fd121 100644 --- a/packages/codev/src/agent-farm/servers/tower-utils.ts +++ b/packages/codev/src/agent-farm/servers/tower-utils.ts @@ -15,9 +15,9 @@ import type { RateLimitEntry } from './tower-types.js'; import crypto from 'node:crypto'; import { loadRolePrompt, type RoleConfig } from '../utils/roles.js'; import { getArchitectHarness } from '../utils/config.js'; -import type { HarnessProvider } from '../utils/harness.js'; +import { RetiredHarnessError, type HarnessProvider } from '../utils/harness.js'; import { getArchitectByName, setArchitectSessionId } from '../state.js'; -import type { CrashLoopFallback, FreshLaunch } from '../../terminal/session-manager.js'; +import type { CrashLoopFallback, FreshLaunch, ReconnectRestartOptions } from '../../terminal/session-manager.js'; import { cmdlineHoldsSession } from './architect-session-holder.js'; // ============================================================================ @@ -176,6 +176,16 @@ export function buildArchitectArgs(baseArgs: string[], workspacePath: string): { const bundledRolesDir = path.resolve(import.meta.dirname, '../../../skeleton/roles'); const config: RoleConfig = { codevDir, bundledRolesDir, workspaceRoot: workspacePath }; + // Launch-boundary fail-closed (Issue #1338): this is the shared launch-injection + // helper every architect launch funnels through (fresh/reconnect/add-architect/ + // no-Tower `afx architect`). A retired architect harness (e.g. gemini) makes + // getArchitectHarness throw RetiredHarnessError — that throw IS the intended + // behavior: a retired architect must not launch, and the error's message is the + // full retirement explanation. Do NOT catch-and-swallow it here; the launch + // entry points surface it cleanly (launchInstance → {success:false, error}, + // addArchitect → {success:false, error}, the `afx architect` CLI prints + // .message). The non-launch liveness predicate is handled separately in + // siblingRegistrationIsLive. const harness = getArchitectHarness(workspacePath); const role = loadRolePrompt(config, 'architect'); @@ -282,13 +292,42 @@ export function sessionHasLiveHolder( * A session-capable harness row with no stored id, or an id whose session * artifact is gone, is a dead registration: the reconcile loop prunes it * instead of resurrecting a removed architect (the #1150 bug class). + * + * Issue #1338: a retired architect harness (e.g. gemini) makes getArchitectHarness + * throw. This is a Tower-side liveness predicate, NOT a launch, so it must not let + * that throw escape — an uncaught throw here aborts the whole sibling-reconcile + * pass (tower-instances.ts) for every architect in the workspace. Catch the + * retirement and return `true`: NOT because the registration can launch (it cannot; + * addArchitect fails closed on the same retirement), but because returning `false` + * makes the reconcile loop DELETE the user's sibling row (setArchitectByName null), + * destroying a registration they can still repair (fix the harness, or wire a custom + * "gemini"). Returning `true` routes to addArchitect, which fails closed with the + * retirement message and leaves the row intact — same launch safety, non-destructive + * to user config. Any other error is a real fault and is rethrown unchanged. */ export function siblingRegistrationIsLive( workspacePath: string, sessionId: string | null, - opts?: { homeDir?: string }, + opts?: { homeDir?: string; log?: (level: 'INFO' | 'WARN' | 'ERROR', message: string) => void }, ): boolean { - const harness = getArchitectHarness(workspacePath); + let harness: HarnessProvider; + try { + harness = getArchitectHarness(workspacePath); + } catch (err) { + if (err instanceof RetiredHarnessError) { + // Don't prune: returning `true` keeps the user's sibling row so a later + // harness fix can revive it. The reconcile loop then calls addArchitect, + // which fails closed on the same retirement (no partial state created) and + // logs that failure itself — so this INFO explains why the retired row is + // KEPT rather than pruned. Callers that pass no `log` still get `true`. + opts?.log?.( + 'INFO', + `Sibling architect registration under '${workspacePath}' selects a retired harness; keeping the row (addArchitect fails closed) rather than pruning it, so it survives a harness fix (${err.message.split('\n')[0]})`, + ); + return true; + } + throw err; + } if (!harness.session) return true; if (!sessionId) return false; return sessionIsOwned(harness, sessionId, workspacePath, opts?.homeDir); @@ -506,7 +545,28 @@ export function buildArchitectFreshLaunch(opts: { const { workspacePath, architectName, baseArgs, baseEnv, log } = opts; return { next: () => { - const harness = getArchitectHarness(workspacePath); + let harness: HarnessProvider; + try { + harness = getArchitectHarness(workspacePath); + } catch (err) { + // Issue #1338: the architect's configured harness was retired AFTER this + // session launched (a custom `gemini` harness removed, or a config edit + // before this clean exit). Two hazards, both handled by failing closed: + // 1. SessionManager invokes next() with no try/catch (session-manager.ts), + // so a raw throw here becomes an uncaught Tower exception. + // 2. next() can only change the args/env — the launch `command` is + // retained by SessionManager. The retained command may itself be the + // retired binary (a custom `gemini` command), so returning baseArgs + // would still relaunch it (fail-open). + // Signal STOP: SessionManager ends the session and surfaces the retirement + // in the pane instead of respawning. Non-retirement errors are real faults, + // rethrown. + if (err instanceof RetiredHarnessError) { + log('WARN', `Architect '${architectName}' harness is retired; not relaunching on clean exit in ${workspacePath}: ${err.message}`); + return { stop: true }; + } + throw err; + } // No resumable-session concept for this harness: there is no recovery to // disable, so a plain rebuild of the launch args is already "fresh". if (!harness.session) { @@ -529,6 +589,79 @@ export function buildArchitectFreshLaunch(opts: { }; } +/** + * Issue #832 / #1149 / #1264 / #1338: resolve the shellper auto-restart options for + * an architect session being reconciled at startup or reconnected on the fly. + * Consolidates the two previously-duplicated blocks in tower-terminals.ts; + * `includeFreshLaunch` is the only behavioral difference between them (the startup + * reconcile path also wires the #1264 clean-exit rerun; the on-the-fly reconnect + * path does not). + * + * Fail-closed retirement (#1338): if the configured harness is retired, + * `resolveArchitectRestart` throws and this returns `undefined` — the caller + * reconnects to a live shellper if one exists, but NEVER configures an + * auto-restart into the retired binary. This replaces the previous fail-OPEN + * behavior, where the generic catch relaunched `cmdParts[0]` (the retired command + * itself) with no role injection. Any OTHER harness-resolution error still + * degrades to the plain configured command so a transient failure can reconnect + * (identity preserved via `cleanEnv`, Spec 786). + */ +export function buildArchitectReconnectRestartOptions(opts: { + workspacePath: string; + architectName: string; + cmdParts: string[]; + cleanEnv: Record; + includeFreshLaunch: boolean; + log: (level: 'INFO' | 'WARN' | 'ERROR', message: string) => void; +}): ReconnectRestartOptions | undefined { + const { workspacePath, architectName, cmdParts, cleanEnv, includeFreshLaunch, log } = opts; + const command = cmdParts[0]; + const baseArgs = cmdParts.slice(1); + try { + // Issue #832: revive the same conversation on auto-restart via the stored id. + const { args: architectArgs, env: harnessEnv, resumed, storedSessionId, fallback } = + resolveArchitectRestart(workspacePath, architectName, baseArgs); + if (resumed && storedSessionId) { + log('INFO', `Resuming architect '${architectName}' session ${storedSessionId.slice(0, 8)}… on shellper restart in ${workspacePath}`); + } + const restartOptions: ReconnectRestartOptions = { + command, + args: architectArgs, + cwd: workspacePath, + env: { ...cleanEnv, ...harnessEnv }, + restartDelay: 2000, + maxRestarts: 50, + }; + // Issue #1264: a clean exit reruns the harness fresh (no --resume). Built from + // the ORIGINAL baseArgs, never `architectArgs` — the latter may carry --resume. + if (includeFreshLaunch) { + restartOptions.freshLaunch = buildArchitectFreshLaunch({ + workspacePath, architectName, baseArgs, baseEnv: cleanEnv, log, + }); + } + // Issue #1149: degrade a fast-failing resume to a fresh launch. + if (resumed && storedSessionId && fallback) { + restartOptions.crashLoopFallback = buildArchitectCrashLoopFallback({ + workspacePath, architectName, storedSessionId, fallback, baseEnv: cleanEnv, log, + }); + } + return restartOptions; + } catch (err) { + if (err instanceof RetiredHarnessError) { + // Fail closed: do NOT relaunch the retired command. The session reconnects + // to its existing process if alive, but no auto-restart into the retired + // harness is configured. + log('WARN', `Architect '${architectName}' harness is retired; not configuring auto-restart into it in ${workspacePath}: ${err.message}`); + return undefined; + } + // Fall back to the plain command without harness role-prompt args so the + // session can still reconnect. `cleanEnv` carries CODEV_ARCHITECT_NAME + // (Spec 786 Phase 2), so identity is preserved even on harness failure. + log('WARN', `Harness resolution failed for workspace ${workspacePath}: ${err instanceof Error ? err.message : err}`); + return { command, args: baseArgs, cwd: workspacePath, env: cleanEnv, restartDelay: 2000, maxRestarts: 50 }; + } +} + /** * Serve a static file from the React dashboard dist. * Returns true if the file was served, false otherwise. diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index dee9d5033..d19f8d8f2 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -11,7 +11,8 @@ import type { Config, UserConfig, ResolvedCommands } from '../types.js'; import { getSkeletonDir } from '../../lib/skeleton.js'; import { loadConfig } from '../../lib/config.js'; import type { CodevConfig } from '../../lib/config.js'; -import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js'; +import { resolveHarness, RetiredHarnessError, type HarnessProvider, type CustomHarnessConfig } from './harness.js'; +import { logger } from './logger.js'; import type { ResolvedWorktreeConfig, WorktreeDevUrl, ResolvedActivityHooks, ActivityHook, ActivityEvent } from '@cluesmith/codev-types'; // Re-export so existing internal callers that import the resolved types @@ -288,6 +289,38 @@ export function getBuilderHarness(workspaceRoot?: string): HarnessProvider { ); } +/** + * Spawn pre-flight (Issue #1338): throw if the configured builder harness resolves + * to a retired built-in (e.g. gemini). Call this BEFORE any worktree / porch / db + * state is created so a retired selection fails closed with no orphaned state, + * rather than only surfacing at the later `getBuilderHarness` call site. + * + * The retirement decision — including the custom-harness escape hatch — is + * delegated to `getBuilderHarness`, the single source of truth. Only the + * retirement aborts here; any other resolution error (e.g. an unknown harness + * name) is left to surface at its existing call site, so this changes behavior + * for retired harnesses only. + */ +export function assertBuilderHarnessNotRetired(workspaceRoot?: string): void { + try { + getBuilderHarness(workspaceRoot); + } catch (err) { + if (err instanceof RetiredHarnessError) throw err; + // A non-retirement resolution error (e.g. an unknown harness name) is left to + // re-surface at the real `getBuilderHarness` call site for worktree modes. + // Shell mode never resolves a harness downstream (`spawnShell` runs + // `commands.builder` as a raw command), so this preflight is the only place + // such an error is seen — log it rather than swallowing it silently, so a + // misconfigured `builderHarness` stays diagnosable (Issue #1338 review). Route + // through `logger.debug` (DEBUG-gated), not `console.debug`: Tower imports this + // module, and a bare console.debug always writes to stdout and pollutes Tower's + // log stream. `logger.debug` stays silent unless DEBUG is set. + logger.debug( + `[spawn preflight] builder harness resolution error (non-retirement, deferred): ${(err as Error).message}`, + ); + } +} + // ResolvedWorktreeConfig + WorktreeDevUrl now live in // @cluesmith/codev-types (they cross HTTP via /api/worktree-config). // Re-exported from this module at the top of the file so existing diff --git a/packages/codev/src/agent-farm/utils/harness.ts b/packages/codev/src/agent-farm/utils/harness.ts index fb7f438be..1cc5c2d46 100644 --- a/packages/codev/src/agent-farm/utils/harness.ts +++ b/packages/codev/src/agent-farm/utils/harness.ts @@ -1,9 +1,14 @@ /** * Agent harness abstraction. * - * Encapsulates how different agent CLI tools (Claude, Codex, Gemini, etc.) + * Encapsulates how different agent CLI tools (Claude, Codex, etc.) * handle role/system prompt injection. Built-in providers cover Claude, Codex, - * and Gemini. Custom providers can be defined in .codev/config.json. + * and OpenCode. Custom providers can be defined in .codev/config.json. + * + * The built-in Gemini CLI harness was retired in Issue #1338 (Google ended + * consumer-tier Gemini CLI availability on 2026-06-18); selecting it now fails + * closed with a retirement message instead of resolving a provider. See + * RETIRED_HARNESSES below. * * Two integration patterns exist: * - Node spawn() call sites: use buildRoleInjection() → returns args + env @@ -174,17 +179,6 @@ export const CODEX_HARNESS: HarnessProvider = { }), }; -export const GEMINI_HARNESS: HarnessProvider = { - buildRoleInjection: (_content, filePath) => ({ - args: [], - env: { GEMINI_SYSTEM_MD: filePath }, - }), - buildScriptRoleInjection: (_content, filePath) => ({ - fragment: '', - env: { GEMINI_SYSTEM_MD: filePath }, - }), -}; - export const OPENCODE_HARNESS: HarnessProvider = { buildRoleInjection: () => { throw new Error( @@ -209,10 +203,97 @@ export const OPENCODE_HARNESS: HarnessProvider = { export const BUILTIN_HARNESSES: Record = { claude: CLAUDE_HARNESS, codex: CODEX_HARNESS, - gemini: GEMINI_HARNESS, opencode: OPENCODE_HARNESS, }; +/** + * The built-in provider for `name`, or `undefined` when `name` is not a built-in + * harness. Uses an own-property check — the same guard `isRetiredHarness` gives + * `RETIRED_HARNESSES` — so inherited Object members (`constructor`, `toString`, + * `hasOwnProperty`, `valueOf`, …) on a *user-controlled* name are never misread as + * a provider. A bare `BUILTIN_HARNESSES[name]` for `name = 'constructor'` returns + * `Object`'s constructor (a truthy function), which a `if (builtin) return builtin` + * check would then hand back as a bogus "provider" that TypeErrors at the first + * `buildRoleInjection` call. The name reaches here straight from config + * (`shell.builderHarness` / a builder launch script), so the key is untrusted. + */ +export function getBuiltinHarness(name: string): HarnessProvider | undefined { + return Object.prototype.hasOwnProperty.call(BUILTIN_HARNESSES, name) + ? BUILTIN_HARNESSES[name] + : undefined; +} + +// ============================================================================= +// Retired harnesses +// ============================================================================= + +/** + * Built-in harness names Codev no longer supports, each mapped to the + * explanation shown when a user still selects it. + * + * A retired name is intercepted on *every* `resolveHarness` exit — the explicit + * path and the command auto-detect path — so it fails loudly and closed rather + * than silently falling back to Claude (the Issue #929 mis-injection class) or + * returning `undefined` (a `BUILTIN_HARNESSES[name]` miss → downstream + * TypeError). See `resolveHarness` and Issue #1338. + * + * Escape hatch: a user who retains access to a retired CLI (e.g. an + * enterprise/API-key Gemini subscription) can still wire it as a *custom* + * harness in .codev/config.json — the retirement targets the built-in name, + * not a user's own definition. + */ +export const RETIRED_HARNESSES: Record = { + gemini: + 'The built-in Gemini CLI harness is retired. Google ended Gemini CLI ' + + 'availability for consumer accounts (free, Pro, and Ultra tiers) on ' + + '2026-06-18, so it no longer works for most users. Use a supported harness ' + + 'instead: claude, codex, or opencode. If you still have Gemini CLI access ' + + '(a Standard/Enterprise subscription or API-key auth), define a custom ' + + 'harness named "gemini" in .codev/config.json under the "harness" section ' + + 'and select it explicitly with shell.builderHarness / shell.architectHarness ' + + '— a bare auto-detected "gemini" command stays retired. See issue #1338.', +}; + +/** + * Whether `name` is a retired built-in harness. Uses an own-property check so + * inherited Object keys (`constructor`, `toString`, …) are never misread as + * retired. + */ +export function isRetiredHarness(name: string): boolean { + return Object.prototype.hasOwnProperty.call(RETIRED_HARNESSES, name); +} + +/** + * The retirement explanation for `name`, or `undefined` when `name` is not a + * retired harness. + */ +export function getRetirement(name: string): string | undefined { + return isRetiredHarness(name) ? RETIRED_HARNESSES[name] : undefined; +} + +/** + * Error thrown when a retired harness name is selected. A distinct type lets a + * caller scope a `catch` to the retirement — return a safe default, or abort a + * spawn before it creates state — and rethrow every other error unchanged. Used + * by the spawn pre-flight and the Tower-side `siblingRegistrationIsLive` + * predicate (Issue #1338). `harnessName` is the retired name that triggered it. + */ +export class RetiredHarnessError extends Error { + constructor(public readonly harnessName: string, message: string) { + super(message); + this.name = 'RetiredHarnessError'; + } +} + +/** + * Throw the consistent retirement error for retired harness `name`. Returns + * `never` so callers can use it as a resolver exit on any branch and keep one + * identical message regardless of which path selected the retired name. + */ +export function throwRetired(name: string): never { + throw new RetiredHarnessError(name, getRetirement(name) ?? `The "${name}" harness is retired.`); +} + // ============================================================================= // Template expansion // ============================================================================= @@ -349,11 +430,15 @@ export function detectHarnessFromCommand(command: string): string | undefined { * Resolve a harness name to a HarnessProvider. * * Resolution order: - * 1. Explicit harnessName → built-in or custom provider - * 2. Auto-detect from command string basename (if command provided) - * 3. Default to claude (backward compatible) + * 1. Explicit harnessName → built-in provider, else custom provider + * 2. Retired name → throw the retirement error (fail closed). A same-named + * custom harness still wins for an *explicit* name (the escape hatch), but an + * auto-detected retired command is always retired — auto-detection never + * consults custom harnesses (Issue #1338). + * 3. Auto-detect from command string basename (if command provided) + * 4. Default to claude (backward compatible) * - * Throws if harnessName is set but doesn't match any known provider. + * Throws if harnessName is retired, or is set but doesn't match any provider. */ export function resolveHarness( harnessName: string | undefined, @@ -362,13 +447,20 @@ export function resolveHarness( ): HarnessProvider { // Explicit harness name takes priority if (harnessName) { - const builtin = BUILTIN_HARNESSES[harnessName]; + // Own-property lookup: `harnessName` is user-controlled, so a bare index could + // return an inherited Object member (`constructor`, …) as a bogus provider. + const builtin = getBuiltinHarness(harnessName); if (builtin) return builtin; if (customHarnesses && harnessName in customHarnesses) { return buildCustomHarnessProvider(customHarnesses[harnessName]); } + // A retired name with no custom override fails closed with a clear message. + // Checked after the custom lookup so an explicit custom `gemini` (the + // escape hatch for retained enterprise/API-key access) still resolves. + if (isRetiredHarness(harnessName)) throwRetired(harnessName); + const knownNames = Object.keys(BUILTIN_HARNESSES); const customNames = customHarnesses ? Object.keys(customHarnesses) : []; const allNames = [...knownNames, ...customNames]; @@ -384,6 +476,12 @@ export function resolveHarness( if (command) { const detected = detectHarnessFromCommand(command); if (detected) { + // Intercept a retired detected name BEFORE the BUILTIN_HARNESSES lookup: + // it must never return undefined (removed registry entry) nor fall through + // to the Claude default below (the #929 silent-mismatch class). Auto-detect + // resolves the built-in namespace only, so a detected `gemini` is retired + // even when a custom `gemini` exists. + if (isRetiredHarness(detected)) throwRetired(detected); return BUILTIN_HARNESSES[detected]; } } diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 95568a054..73013d78a 100644 --- a/packages/codev/src/commands/doctor.ts +++ b/packages/codev/src/commands/doctor.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk'; import { executeForgeCommandSync, loadForgeConfig, validateForgeConfig, resolveAllConcepts, type ConceptResolution } from '../lib/forge.js'; -import { detectHarnessFromCommand } from '../agent-farm/utils/harness.js'; +import { detectHarnessFromCommand, getRetirement } from '../agent-farm/utils/harness.js'; import { auditPrGates, formatPrGateWarning } from '../lib/pr-gate-audit.js'; import { auditStateFileIgnore } from '../lib/gitignore.js'; import { auditFrameworkRefs, formatFrameworkRefFinding, hasFrameworkOverrides } from '../lib/framework-ref-audit.js'; @@ -794,15 +794,35 @@ export async function doctor(): Promise { if (root) { const config = loadConfig(root) as Record; const shell = config?.shell as Record | undefined; - // Check explicit architectHarness or auto-detect from architect command - const architectHarness = shell?.architectHarness as string | undefined; - const architectCmd = Array.isArray(shell?.architect) - ? (shell.architect as string[]).join(' ') - : (shell?.architect as string ?? ''); - const resolvedHarness = architectHarness || - (architectCmd ? detectHarnessFromCommand(architectCmd) : undefined); - const isOpencode = resolvedHarness === 'opencode'; - if (isOpencode) { + // Diagnose the harness a PERSISTED shell config selects for a role, WITHOUT + // CLI/env overrides (doctor reports on the persisted config, not a one-off + // `--architect-cmd`/`TOWER_*_CMD` run). Mirrors resolveHarness's precedence + // but never throws (doctor reports; it does not launch), and is shared by + // both role branches so they can't drift: + // • explicit shell.Harness — a same-named CUSTOM harness is the + // sanctioned escape hatch and wins over retirement (built-in → custom → + // retired), so an explicit `gemini` backed by a `harness.gemini` + // definition is NOT flagged (following doctor's own advice clears it); + // • auto-detected shell. command — retirement applies regardless of + // custom harnesses, because auto-detection resolves the built-in + // namespace only (Issue #1338), so a bare `gemini …` command is flagged. + const customHarnesses = config?.harness as Record | undefined; + const resolveShell = (role: 'architect' | 'builder'): { name: string | undefined; retirement: string | undefined } => { + const explicit = shell?.[`${role}Harness`] as string | undefined; + if (explicit) { + const hasCustom = !!customHarnesses && Object.prototype.hasOwnProperty.call(customHarnesses, explicit); + return { name: explicit, retirement: hasCustom ? undefined : getRetirement(explicit) }; + } + const raw = shell?.[role]; + const cmd = Array.isArray(raw) ? (raw as string[]).join(' ') : (raw as string ?? ''); + const detected = cmd ? detectHarnessFromCommand(cmd) : undefined; + return { name: detected, retirement: detected ? getRetirement(detected) : undefined }; + }; + const architect = resolveShell('architect'); + const builder = resolveShell('builder'); + + // --- Architect shell --- + if (architect.name === 'opencode') { console.log(''); console.log(chalk.yellow(' ⚠') + ' OpenCode is configured as architect shell — this is unsupported.'); console.log(chalk.yellow(' ') + 'OpenCode uses file-based role injection that requires an ephemeral worktree.'); @@ -813,26 +833,54 @@ export async function doctor(): Promise { issue: 'OpenCode configured as architect shell (unsupported)', recommendation: 'Set shell.architect to "claude --dangerously-skip-permissions" in .codev/config.json', }); - } else if (resolvedHarness === 'gemini') { - // Issue #929: gemini is builder-only; the Gemini CLI is retiring (#778), - // so it is no longer supported as an architect. + } else if (architect.retirement) { + // Issue #1338: the built-in gemini harness is retired for BOTH roles + // (Google ended consumer Gemini CLI access 2026-06-18). This branch + // previously claimed gemini was "supported for builders, not architects"; + // that premise is now wrong — it is retired for builders too. console.log(''); - console.log(chalk.yellow(' ⚠') + ' Gemini is configured as architect shell — this is unsupported.'); - console.log(chalk.yellow(' ') + 'The Gemini CLI is retiring (#778); gemini is supported for builders, not architects.'); + console.log(chalk.yellow(' ⚠') + ` ${architect.name} is configured as the architect shell — this harness is retired.`); + console.log(chalk.yellow(' ') + architect.retirement); console.log(chalk.yellow(' ') + 'Use codex or claude for the architect (e.g., "codex" or "claude --dangerously-skip-permissions").'); warnings++; warningDetails.push({ name: 'Shell config', - issue: 'Gemini configured as architect shell (builder-only, not architect)', - recommendation: 'Set shell.architect to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json', + issue: `${architect.name} configured as architect shell (harness retired)`, + // Cover both selectors: an explicit shell.architectHarness beats the + // shell.architect command, so switching only the command wouldn't help. + // The custom-harness name is the configured (retired) harness, not a + // hard-coded "gemini", so the advice stays correct if another harness is + // ever retired (RETIRED_HARNESSES is extensible). + recommendation: `Set shell.architect / shell.architectHarness to "codex" or "claude --dangerously-skip-permissions" in .codev/config.json, or define a custom "${architect.name}" harness and select it explicitly via shell.architectHarness (a bare shell.architect command stays retired)`, }); - } else if (resolvedHarness === 'codex') { + } else if (architect.name === 'codex') { // Issue #929: codex is a supported architect (config-driven). console.log(''); console.log(chalk.green(' ✓') + ' codex is configured as architect shell — supported.'); console.log(chalk.gray(' ') + 'Conversation resume is Claude-main-only; codex architects relaunch fresh with role injection.'); console.log(chalk.gray(' ') + 'Select the architect harness via .codev/config.json (shell.architect / shell.architectHarness).'); } + + // --- Builder shell (#1338) --- + // A retired builder harness fails closed at spawn (Phase 2); doctor flags + // the persisted config proactively so users learn before a spawn is rejected. + if (builder.retirement) { + console.log(''); + console.log(chalk.yellow(' ⚠') + ` ${builder.name} is configured as the builder shell — this harness is retired.`); + console.log(chalk.yellow(' ') + builder.retirement); + console.log(chalk.yellow(' ') + 'Use claude, codex, or opencode for builders (set shell.builder / shell.builderHarness in .codev/config.json).'); + warnings++; + warningDetails.push({ + name: 'Shell config', + issue: `${builder.name} configured as builder shell (harness retired)`, + // Cover both selectors: an explicit shell.builderHarness beats the + // shell.builder command, so switching only the command wouldn't help. + // The custom-harness name is the configured (retired) harness, not a + // hard-coded "gemini", so the advice stays correct if another harness is + // ever retired (RETIRED_HARNESSES is extensible). + recommendation: `Set shell.builder / shell.builderHarness to a supported harness (claude, codex, or opencode) in .codev/config.json, or define a custom "${builder.name}" harness and select it explicitly via shell.builderHarness (a bare shell.builder command stays retired)`, + }); + } } } catch { // Config loading may fail in non-project contexts — skip warning diff --git a/packages/codev/src/terminal/__tests__/session-manager.test.ts b/packages/codev/src/terminal/__tests__/session-manager.test.ts index b76bb0b48..bb62cda31 100644 --- a/packages/codev/src/terminal/__tests__/session-manager.test.ts +++ b/packages/codev/src/terminal/__tests__/session-manager.test.ts @@ -2339,7 +2339,7 @@ describe('clean exits rerun the harness without recovery (Bugfix #1264)', () => // // (#1241 ended the session on a clean exit, which made double Ctrl-C // unrecoverable without a manual respawn — the #1264 regression.) - function fakeSession(socketDir: string, freshLaunch?: { next: () => any }) { + function fakeSession(socketDir: string, freshLaunch?: { next: () => any }, command = 'claude') { const client = new EventEmitter() as any; client.spawn = vi.fn(); return { @@ -2349,7 +2349,7 @@ describe('clean exits rerun the harness without recovery (Bugfix #1264)', () => startTime: 0, options: { sessionId: 'clean-1', - command: 'claude', + command, args: ['--resume', 'old-conversation-id'], cwd: '/tmp', env: { KEEP: '1' }, @@ -2376,6 +2376,7 @@ describe('clean exits rerun the harness without recovery (Bugfix #1264)', () => function driveExit( exit: { code: number | null; signal: string | null }, freshLaunch?: { next: () => any }, + command = 'claude', ) { const socketDir = tmpDir(); const manager = new SessionManager({ @@ -2383,15 +2384,17 @@ describe('clean exits rerun the harness without recovery (Bugfix #1264)', () => shellperScript: '/nonexistent/shellper.js', nodeExecutable: process.execPath, }); - const session = fakeSession(socketDir, freshLaunch); + const session = fakeSession(socketDir, freshLaunch, command); (manager as any).sessions.set('clean-1', session); const cleanExits: string[] = []; manager.on('session-clean-exit', (id: string) => cleanExits.push(id)); const freshRestarts: string[] = []; manager.on('session-fresh-restart', (id: string) => freshRestarts.push(id)); + const gaveUp: Array<{ id: string; reason: string }> = []; + manager.on('session-gave-up', (id: string, reason: string) => gaveUp.push({ id, reason })); (manager as any).setupAutoRestart(session, 'clean-1'); session.client.emit('exit', exit); - return { manager, session, cleanExits, freshRestarts, socketDir }; + return { manager, session, cleanExits, freshRestarts, gaveUp, socketDir }; } /** A fresh-launch factory that mints a new id per call, like the real one. */ @@ -2480,6 +2483,36 @@ describe('clean exits rerun the harness without recovery (Bugfix #1264)', () => } }); + // Issue #1338 — a freshLaunch factory may signal `{ stop: true }` when the + // architect harness was retired after launch (buildArchitectFreshLaunch does + // this). next() cannot change the retained launch command, only the args/env — + // and here the retained command IS the retired binary (a custom `gemini` command + // whose harness was later removed). So the ONLY fail-closed move is to not + // respawn at all. This is the end-to-end regression Codex asked for: exercise the + // retained `command: "gemini"`, not just the returned args. + it('does NOT respawn a retired-harness command on clean exit; ends the session with a visible reason (#1338)', async () => { + const stopFactory = { next: () => ({ stop: true }) }; + const { manager, session, freshRestarts, gaveUp, socketDir } = driveExit( + { code: 0, signal: null }, + stopFactory, + 'gemini', // the retained launch command is the retired binary itself + ); + try { + await new Promise((r) => setTimeout(r, 50)); + // Fail closed: the retired command is never relaunched... + expect(session.client.spawn).not.toHaveBeenCalled(); + // ...the session is torn down rather than left half-alive with no process... + expect((manager as any).sessions.has('clean-1')).toBe(false); + expect(freshRestarts).toEqual([]); + // ...and the reason is surfaced to the pane (session-gave-up → PtySession.notice). + expect(gaveUp).toHaveLength(1); + expect(gaveUp[0].reason).toMatch(/retired/i); + expect(gaveUp[0].reason).toContain('gemini'); + } finally { + rmrf(socketDir); + } + }); + // --- The fast-clean-exit valve (#1264) --- // // Unlimited reruns are correct for gestures but must not become an infinite diff --git a/packages/codev/src/terminal/session-manager.ts b/packages/codev/src/terminal/session-manager.ts index 9c4ccbded..fc8e6758f 100644 --- a/packages/codev/src/terminal/session-manager.ts +++ b/packages/codev/src/terminal/session-manager.ts @@ -60,9 +60,17 @@ export interface CrashLoopFallback { * Returning null (or omitting the option entirely) means "relaunch with the * args as they stand" — correct for sessions that have no recovery concept, * such as plain shells. + * + * Returning `{ stop: true }` means "do NOT relaunch — end the session instead." + * next() can only influence the args/env; the launch `command` is retained by + * SessionManager. So when the harness has become invalid since launch (Issue + * #1338: a retired harness, whose retained command may itself be the retired + * binary), rerunning would be fail-open. `stop` lets the factory fail closed: + * SessionManager tears the session down with a visible reason rather than + * respawning the retired command. */ export interface FreshLaunch { - next: () => { args: string[]; env?: Record } | null; + next: () => { args: string[]; env?: Record } | { stop: true } | null; } export interface CreateSessionOptions { @@ -1173,6 +1181,22 @@ export class SessionManager extends EventEmitter { return; } const fresh = session.options.freshLaunch?.next() ?? null; + if (fresh && 'stop' in fresh) { + // Issue #1338: the harness resolved to a RETIRED one after this session + // launched (a custom `gemini` harness later removed, or a config edit + // before this clean exit). next() cannot change the launch `command`, + // only the args/env — and the retained command may itself be the retired + // binary, so rerunning it would be fail-open. Fail closed: do not respawn. + // End the session and surface the reason in the pane (session-gave-up → + // PtySession.notice), the same visible-teardown UX as the fast-exit valve. + const reason = + `Not relaunching '${session.options.command}' after a clean exit: its harness has been ` + + `retired. Reconfigure the architect to a supported harness and respawn.`; + this.log(`Session ${sessionId} not rerun (harness retired): ${reason}`); + this.emit('session-gave-up', sessionId, reason); + this.removeDeadSession(sessionId); + return; + } if (fresh) { session.options.args = fresh.args; if (fresh.env) session.options.env = fresh.env;