Make ward agent-agnostic: isolate per-agent code into one package (a folder + docs file per agent) #401

Closed
opened 2026-07-01 06:00:11 +00:00 by coilysiren · 4 comments
Owner

Design ticket. Filed from the read-only director surface (she/her).

Thesis

rg -i claude | wc -l => 483. Per-agent logic - creds seeding, onboarding, config compose, install, vision capability, exec/argv, mode dispatch - is scattered across container_bootstrap.go, container_compute.go, agent_signature.go, agent.go, and a dozen more files. ward's core should be agent-agnostic: the per-agent special logic isolated into a single package with a folder per agent that configures everything special about that agent, paired with a dedicated docs file per agent. Adding a harness should mean one folder + one docs file + one registry entry, not editing hundreds of sites.

This is the code half. It is orthogonal to and composes with the data half already designed (see reconciliation below).

Grounding (the scatter, measured in /substrate/ward)

Per-driver hit counts, non-test, cmd/ward: claude 124, codex 80, goose 72, ollama 47, qwen 37, opencode 30. Densest cluster: container_bootstrap.go (51 claude hits) - all the per-agent creds/config/install functions.

The per-agent CODE seams (57 mode-dispatch sites) - the functions to consolidate behind an interface:

  • visionCapable() - container_compute.go:179
  • parseMode() - container_compute.go:239
  • agentIdentity() - agent_signature.go:20 (mostly DATA - see #306; the lookup mechanism stays, the values move)
  • installOpencode() - container_bootstrap.go:406
  • writeClaudeCreds() - container_bootstrap.go:844
  • seedClaudeOnboarding() - container_bootstrap.go:870
  • writeCodexCreds() - container_bootstrap.go:903
  • composeCodexConfig() - container_bootstrap.go:934
  • composeGooseConfig() - container_bootstrap.go:998

Note the qwen/opencode entanglement flagged in #308: ward has no opencode mode name - it calls it modeQwen -> "qwen" -> binary opencode. The design should untangle the roster naming as part of this.

Relationship to #306 / #308 (must not conflict)

  • #306 externalizes per-agent DATA into an aos-authored agents.yaml (model, endpoint, identity, attribution) - channel B + cli-guard parser C, Kai-decided.
  • #308 Bucket A is the concrete data move-list.
  • This ticket is the orthogonal half: the per-agent CODE that #306 explicitly says stays ("ward genuinely needs per-agent code... that stays"). Rather than leave it scattered, isolate it into a per-agent package whose interface reads its data from the agents.yaml manifest.

The boundary the design must draw crisply: data -> agents.yaml (#306); code -> per-agent package (this). No site claimed by both. agentIdentity() is the test case - the values are data (#306), the interface method that serves them is code (here).

Deliverable (design comment, no code)

An advisor design that:

  1. Defines the per-agent interface - the seam contract each agent implements (creds, onboarding, config compose, install, vision capability, exec/argv, manifest-data access). Name what stays generic in ward core vs what each agent owns.
  2. Proposes the package + folder layout - one folder per agent (claude / codex / qwen-opencode / goose) configuring its special logic, plus the registry that lets ward core dispatch through the interface instead of switch-on-mode. Decide the location (cmd/ward/agents/<name>/ vs a pkg/agents), and how it coexists with agent_adapter.go's existing manifest.
  3. Enumerates every per-agent code site that moves in (build on #308 Bucket A + the 57 seams above) and every genuinely-shared path that stays in core (the mode->binary mapping, context-level ladder, env-var names - #308 already marks these as legitimately code-shaped).
  4. States the data-vs-code boundary vs #306 so agents.yaml and the package do not overlap.
  5. Pairs each agent folder with a docs file (docs/agent-<name>.md) - reconcile with the agent docs that already exist (agent-credentials, agent-local-harnesses, agent-adapter-manifest, agent-roster).
  6. Gives a migration order that keeps the mode switch and the registry from drifting during the transition (contract tests, the ward#152 pattern); the switch dies last, after contract-green.
  7. Lists the concrete implementation issues to file, sequenced, with the repo each lands in.

Then Kai picks the package boundary and the build issues get dispatched.

Mode

consult - design ticket, human picks the boundary. Companion to #306 (data) and #308 (ownership audit).

Design ticket. Filed from the read-only director surface (she/her). ## Thesis `rg -i claude | wc -l` => 483. Per-agent logic - creds seeding, onboarding, config compose, install, vision capability, exec/argv, mode dispatch - is scattered across `container_bootstrap.go`, `container_compute.go`, `agent_signature.go`, `agent.go`, and a dozen more files. ward's **core** should be agent-agnostic: the per-agent special logic isolated into a **single package with a folder per agent** that configures everything special about that agent, paired with a **dedicated docs file per agent**. Adding a harness should mean one folder + one docs file + one registry entry, not editing hundreds of sites. This is the **code** half. It is orthogonal to and composes with the **data** half already designed (see reconciliation below). ## Grounding (the scatter, measured in /substrate/ward) Per-driver hit counts, non-test, `cmd/ward`: **claude 124, codex 80, goose 72, ollama 47, qwen 37, opencode 30**. Densest cluster: `container_bootstrap.go` (51 claude hits) - all the per-agent creds/config/install functions. The per-agent CODE seams (57 mode-dispatch sites) - the functions to consolidate behind an interface: - `visionCapable()` - `container_compute.go:179` - `parseMode()` - `container_compute.go:239` - `agentIdentity()` - `agent_signature.go:20` (mostly DATA - see #306; the lookup mechanism stays, the values move) - `installOpencode()` - `container_bootstrap.go:406` - `writeClaudeCreds()` - `container_bootstrap.go:844` - `seedClaudeOnboarding()` - `container_bootstrap.go:870` - `writeCodexCreds()` - `container_bootstrap.go:903` - `composeCodexConfig()` - `container_bootstrap.go:934` - `composeGooseConfig()` - `container_bootstrap.go:998` Note the qwen/opencode entanglement flagged in #308: ward has no `opencode` mode name - it calls it `modeQwen` -> "qwen" -> binary `opencode`. The design should untangle the roster naming as part of this. ## Relationship to #306 / #308 (must not conflict) - **#306** externalizes per-agent **DATA** into an aos-authored `agents.yaml` (model, endpoint, identity, attribution) - channel B + cli-guard parser C, Kai-decided. - **#308 Bucket A** is the concrete data move-list. - **This ticket** is the orthogonal half: the per-agent **CODE** that #306 explicitly says stays ("ward genuinely needs per-agent code... that stays"). Rather than leave it scattered, isolate it into a per-agent package whose interface **reads its data from the agents.yaml manifest**. The boundary the design must draw crisply: **data -> agents.yaml (#306); code -> per-agent package (this)**. No site claimed by both. `agentIdentity()` is the test case - the values are data (#306), the interface method that serves them is code (here). ## Deliverable (design comment, no code) An advisor design that: 1. **Defines the per-agent interface** - the seam contract each agent implements (creds, onboarding, config compose, install, vision capability, exec/argv, manifest-data access). Name what stays generic in ward core vs what each agent owns. 2. **Proposes the package + folder layout** - one folder per agent (claude / codex / qwen-opencode / goose) configuring its special logic, plus the registry that lets ward core dispatch through the interface instead of switch-on-mode. Decide the location (`cmd/ward/agents/<name>/` vs a `pkg/agents`), and how it coexists with `agent_adapter.go`'s existing manifest. 3. **Enumerates every per-agent code site that moves in** (build on #308 Bucket A + the 57 seams above) and every genuinely-shared path that stays in core (the `mode->binary` mapping, context-level ladder, env-var names - #308 already marks these as legitimately code-shaped). 4. **States the data-vs-code boundary vs #306** so `agents.yaml` and the package do not overlap. 5. **Pairs each agent folder with a docs file** (`docs/agent-<name>.md`) - reconcile with the agent docs that already exist (`agent-credentials`, `agent-local-harnesses`, `agent-adapter-manifest`, `agent-roster`). 6. **Gives a migration order** that keeps the mode switch and the registry from drifting during the transition (contract tests, the ward#152 pattern); the switch dies last, after contract-green. 7. **Lists the concrete implementation issues** to file, sequenced, with the repo each lands in. Then Kai picks the package boundary and the build issues get dispatched. ## Mode consult - design ticket, human picks the boundary. Companion to #306 (data) and #308 (ownership audit).
Author
Owner

Recovered advisor design (posted from the director surface). The advisor produced this twice but could not post it - both runs died on ward#402 (commentIssue --body-file flag skew on the host write path). Kai recovered the streamed output from the host dispatch log. Reproducing the more refined of the two runs here so the deliverable is durable in-thread; an earlier run with a slightly different layout is in the host log if a second angle helps. One correction from Kai supersedes the design where they differ: visionCapable should be retired, not moved to agents.yaml (its sole use is the ward#400 seed branch - see note at the end). Design below is the advisor's, verbatim.


Advisor design: agent-agnostic ward via a per-agent SPI + folder-per-agent registry

Read live against /substrate/ward at HEAD. ward is already mid-migration on exactly this pattern for the data half: agent_adapter.go + the embedded containerassets/agent-adapters.yaml + agent_adapter_test.go are the ward#152 precedent - a manifest mirroring the live Go switches, pinned entry-for-entry by a contract test, switches still live and dying last. This design generalizes that same three-part move from data to code, so it composes with #306/#308 by construction.

Two ground-truth corrections up front:

  • There are exactly four modes (agent.go:210: agentModes = {modeClaude, modeCodex, modeQwen, modeGoose}). No ollama mode, no opencode mode. The issue's hit counts (ollama 47, opencode 30) are backend/binary references, not roster entries. The package roster is four folders, not six.
  • agent-roster.md is a different axis. The roster (agent_roster.go) enumerates roles (engineer/director/advisor). This ticket is about harnesses (claude/codex/opencode/goose). Two orthogonal registries - keep them separate in the docs or they get conflated forever.

The real shape of the scatter (two-host seam)

Per-agent code is a two-host seam: the launching host resolves+injects, the container writes+launches. Both sides branch per agent. An interface covering only the container half leaves half the scatter behind.

  • Host side (container.go): resolveClaudeCreds (:213, macOS keychain else ~/.claude/.credentials.json), resolveCodexCreds (:275), resolveOllamaHost (:288, SSM), claudeCredsHealth (:243), credEnvLines (:300).
  • Container side (container_bootstrap.go): installOpencode (:406), writeClaudeCreds (:844), seedClaudeOnboarding (:870), writeCodexCreds (:903), composeCodexConfig (:934), composeOpencodeConfig+opencodeConfigJSON (:961/978), composeGooseConfig+gooseConfigYAML (:998/1029), smokeTestClaudeAuth (:1094), per-mode arms of buildAgentArgv (:1304) and logAgentArgv (:1338).
  • Compute (container_compute.go): visionCapable (:179), agentBinary (:191), hostPreflightArgv (:208), contextLevel (:223), parseMode (:239).
  • Signature (agent_signature.go): agentIdentity (:20) + agentSigner/signBody/commitTrailer.

The anti-pattern tell: every container-side function is shaped func (r *Runner) composeGooseConfig(e) { if e.Mode != "goose" { return }; ... } - dispatch inverted, the function self-skips; runContainerBootstrap (:207-225) calls all six unconditionally, five no-op. The registry inverts that: core holds agent := registry.Lookup(mode) and only that agent's capabilities fire. Done-condition, grep-measurable: after migration no e.Mode == "..." / case modeX: exists in core.

1. The per-agent interface (capability-shaped, not one god method)

Do not define a single fat interface - most agents no-op most methods (only claude onboards, only qwen installs, only claude smoke-tests). Model it as a small core record served from the manifest plus optional capability interfaces core feature-tests. Idiomatic Go (io.ReaderFrom-style); "this agent doesn't do X" is the absence of an impl, not a guard clause.

// internal/agentspi - contract package, types only, no behavior
package agentspi
type Agent interface {
    Name() string                 // roster key, e.g. "claude"
    Record() Manifest             // binary, contextLevel, stream, argv, auth-kind, identity, model (from agents.yaml)
    Signer() attribution.Signer   // built from Record().Identity (the #306 test case)
    LaunchArgv(RunCtx) (argv []string, stream bool)
    PreflightArgv(prompt string) ([]string, bool)
}
type CredentialProvider interface { ResolveCreds(HostCtx) []EnvLine; WriteCreds(RunCtx) error }
type ConfigComposer   interface { ComposeConfig(RunCtx) error }
type Installer        interface { Install(RunCtx) error }
type OnboardingSeeder interface { SeedOnboarding(RunCtx) error }
type LaunchGate       interface { PreLaunchCheck(RunCtx) error }  // smokeTestClaudeAuth
  • RunCtx/HostCtx are narrow structs carved out of bootstrapEnv, carrying only what agents touch (AgentHome, TargetName, model/effort fields, ollama URL, a blog logger, an Exec). Do not pass the whole Runner/bootstrapEnv across the boundary - that reintroduces the import cycle you just split.
  • Core dispatch replaces the six unconditional calls with feature-tests: if c, ok := agent.(agentspi.Installer); ok { c.Install(rc) }, etc.
  • Stays generic in core: the runContainerBootstrap sequence, git auth, clone, pre-commit install, read-only push guard, extra-repo clone, substrate warm, context compose, permissions compose, reap, chown, setpriv drop, the stream-json wrapper, looksLikeAuthError/commandExists/lowDiskPaths/envOr/blog/base64, the manifest parse+validate, and the WARD_* env-var names (shared wire contract).

2. Package layout + registry

internal/agentspi/          // interface + Manifest/RunCtx/HostCtx/EnvLine value types, no behavior
internal/agents/claude/     // one package per agent implementing the SPI
internal/agents/codex/
internal/agents/opencode/    // was "qwen" - see 7
internal/agents/goose/
internal/agents/registry.go  // imports the four, builds map[string]Agent, Lookup(mode)

Core imports only agentspi + registry, never a concrete agent. Adding a harness = new folder + one registry line + one manifest entry + one docs file - the deliverable's success test, met literally. Recommend internal/agents/<name>/ over pkg/agents: these are ward-internal orchestration glue, not a reusable library; reserve pkg/ (cli-guard) for the genuinely reusable engine where attribution.Signer already lives.

Honest cost: ward is today one flat package main in cmd/ward with unexported Runner/bootstrapEnv. Sub-packages can't reach unexported main symbols, so this requires carving the narrow RunCtx/HostCtx structs into agentspi - that carve is the first real unit of work, and why the contract-test net matters (moving behavior across a new boundary, not just renaming). Coexistence: agent_adapter.go becomes the data layer - agentAdapter -> agentspi.Manifest (superset: today's fields + #306 identity/model/endpoint), loadAgentManifest() stays the loader, the registry injects each agent's record at construction. agent-adapters.yaml and #306's agents.yaml converge into one embedded manifest - do not ship two.

3. Data-vs-code boundary vs #306 (no site claimed by both)

  • A per-agent value otherwise inert -> agents.yaml (#306): name, binary, contextLevel, stream-format id, auth-kind enum, argv dialects, identity{name,pronouns}, model defaults, ollama URL default, attribution email domain.
  • A per-agent procedure -> the package (this ticket): how to satisfy each auth-kind (keychain vs file vs SSM vs none), decode+write+chmod+scrub, config-file templating, install curl, onboarding JSON, smoke probe, plus the methods that serve the data.

agentIdentity() worked example: strings "Claude"/"she/her" are data (agents.yaml.identity, #306 owns the key); the Signer() method reading them into attribution.Signer is code (this ticket owns the method). Symmetric for auth: auth: claude-keychain is data (#306); security find-generic-password -s "Claude Code-credentials" is code (here). One enum value, one procedure, no overlap. Explicitly not this ticket: #308 Bucket C (infra topology) -> infra/SSM; Bucket B (doctrine prose) -> referenced not recopied.

4. Docs per agent

docs/agent-<name>.md documents internal/agents/<name> - capabilities, cred channel, config-file shape, install stance, launch dialect, smoke gate. One doc to one folder, guarded by a drift test (a registered agent with no doc = red build, the agent-roster self-curing pattern). Roster of four: agent-claude.md, agent-codex.md, agent-opencode.md, agent-goose.md. Reconcile: agent-adapter-manifest.md stays as the data schema doc (each agent doc links to its manifest entry); agent-credentials.md keeps the shared channel and delegates per-agent specifics; agent-local-harnesses.md splits into agent-opencode.md + agent-goose.md; agent-roster.md left alone (roles axis) with one sentence added to agent.md drawing the roles-vs-harnesses line.

5. Migration order (ward#152 pattern, contract-green throughout, switch dies last)

Invariant: at no commit do the live switches and the new registry disagree - a contract test fails the build if they do (generalizing TestAgentManifestMatchesHardcodedSwitches from data to behavior).

  • Phase 0 (aos): land #306/#308 data into agents.yaml (identity/model/endpoint on the existing adapter manifest). Ward embeds. No control-flow change.
  • Phase 1 (ward): carve internal/agentspi (interface + RunCtx/HostCtx). Pure extraction, core still switches. Green.
  • Phase 2 (ward): four packages, each capability delegating to the still-live main functions (thin wrappers); build the registry; flip no call site; add the generalized contract test (registry output == live switch output). Both live, test pins them. Green.
  • Phase 3 (ward): flip core call sites one capability at a time (creds, config, install, onboarding, launch argv, then vision/context/binary/preflight reads). Each flip its own small PR, old function unreferenced-but-present, contract green.
  • Phase 4 (ward): switch dies last - once every seam dispatches through the registry, replace parseMode's switch with a membership check, delete the dead switches + orphaned funcs in one sweep. Contract test flips from "registry == switch" to "registry == golden fixtures". This is where rg -i claude drops.
  • Phase 5 (ward): docs + drift test + credentials/local-harnesses reconciliation.

6. qwen -> opencode roster untangle (#308's naming collision)

Category error: every sibling names its harness (claude/codex/goose); qwen names a model. Mode string "qwen" (modeQwen) -> binary opencode (agentBinary, container_compute.go:196) -> backend ollama/qwen3-coder:30b. And goose also runs ollama/qwen3-coder, so "qwen" can't identify a harness. Resolution, landed in Phase 2 so it rides the contract net: rename modeQwen -> modeOpencode, roster "qwen" -> "opencode", package internal/agents/opencode/; the model qwen3-coder:30b moves to agents.yaml as a data model field; keep a --mode qwen back-compat alias in the registry/parseMode with a deprecation blog, do not hard-break in the rename PR. One data decision for #306, flag don't decide: whether opencode signs Opencode, Qwen (via opencode), or keeps Qwen - an agents.yaml identity value.

7. Implementation issues to file, sequenced, with landing repo

  1. aos - agents.yaml: add identity/model/endpoint/(vision - see Kai's note) to the adapter manifest. Blocks everything.
  2. ward - carve internal/agentspi (interface + RunCtx/HostCtx); no behavior moves. (Phase 1)
  3. ward - four internal/agents/<name> packages delegating to live funcs + generalized contract test; registry built, no call sites flipped. (Phase 2)
  4. ward - qwen -> opencode roster untangle with --mode qwen alias. (Phase 2)
  5. ward - flip cred resolution + seeding to CredentialProvider. (Phase 3)
  6. ward - flip config compose to ConfigComposer. (Phase 3)
  7. ward - flip install/onboarding/smoke-gate. (Phase 3)
  8. ward - flip launch argv + context/binary/preflight to Record(); delete the dead switches + parseMode enumeration (switch dies last). (Phase 4)
  9. ward - docs/agent-<name>.md per package + drift test; reconcile agent-credentials.md, split/retire agent-local-harnesses.md, add the roles-vs-harnesses note. (Phase 5)

Boundary for Kai to pick: internal/agents/<name> sub-packages with HostCtx (compiler-enforced isolation, more plumbing) vs file-per-agent in one package holding *Runner (lighter, isolation by convention). Advisor recommends the sub-package form. Mode: consult.


Kai's correction (supersedes the design on this point): visionCapable is not moving to agents.yaml. Its only consumer is the agent.go:168 seed branch that ward#400 is already rewriting to inline the body for all drivers. It is being retired as a rider on ward#400, so drop the proposed vision: manifest field from issue #1 above.

**Recovered advisor design (posted from the director surface).** The advisor produced this twice but could not post it - both runs died on ward#402 (`commentIssue --body-file` flag skew on the host write path). Kai recovered the streamed output from the host dispatch log. Reproducing the more refined of the two runs here so the deliverable is durable in-thread; an earlier run with a slightly different layout is in the host log if a second angle helps. One correction from Kai supersedes the design where they differ: **`visionCapable` should be retired, not moved to `agents.yaml`** (its sole use is the ward#400 seed branch - see note at the end). Design below is the advisor's, verbatim. --- ### Advisor design: agent-agnostic ward via a per-agent SPI + folder-per-agent registry Read live against `/substrate/ward` at HEAD. ward is **already mid-migration on exactly this pattern** for the data half: `agent_adapter.go` + the embedded `containerassets/agent-adapters.yaml` + `agent_adapter_test.go` are the ward#152 precedent - a manifest mirroring the live Go switches, pinned entry-for-entry by a contract test, switches still live and dying last. This design **generalizes that same three-part move from data to code**, so it composes with #306/#308 by construction. Two ground-truth corrections up front: - **There are exactly four modes** (`agent.go:210`: `agentModes = {modeClaude, modeCodex, modeQwen, modeGoose}`). No `ollama` mode, no `opencode` mode. The issue's hit counts (ollama 47, opencode 30) are backend/binary references, not roster entries. The package roster is **four folders**, not six. - **`agent-roster.md` is a different axis.** The roster (`agent_roster.go`) enumerates **roles** (engineer/director/advisor). This ticket is about **harnesses** (claude/codex/opencode/goose). Two orthogonal registries - keep them separate in the docs or they get conflated forever. ### The real shape of the scatter (two-host seam) Per-agent code is a **two-host seam**: the launching host resolves+injects, the container writes+launches. Both sides branch per agent. An interface covering only the container half leaves half the scatter behind. - **Host side** (`container.go`): `resolveClaudeCreds` (:213, macOS keychain else `~/.claude/.credentials.json`), `resolveCodexCreds` (:275), `resolveOllamaHost` (:288, SSM), `claudeCredsHealth` (:243), `credEnvLines` (:300). - **Container side** (`container_bootstrap.go`): `installOpencode` (:406), `writeClaudeCreds` (:844), `seedClaudeOnboarding` (:870), `writeCodexCreds` (:903), `composeCodexConfig` (:934), `composeOpencodeConfig`+`opencodeConfigJSON` (:961/978), `composeGooseConfig`+`gooseConfigYAML` (:998/1029), `smokeTestClaudeAuth` (:1094), per-mode arms of `buildAgentArgv` (:1304) and `logAgentArgv` (:1338). - **Compute** (`container_compute.go`): `visionCapable` (:179), `agentBinary` (:191), `hostPreflightArgv` (:208), `contextLevel` (:223), `parseMode` (:239). - **Signature** (`agent_signature.go`): `agentIdentity` (:20) + `agentSigner`/`signBody`/`commitTrailer`. The anti-pattern tell: every container-side function is shaped `func (r *Runner) composeGooseConfig(e) { if e.Mode != "goose" { return }; ... }` - dispatch inverted, the function self-skips; `runContainerBootstrap` (:207-225) calls all six unconditionally, five no-op. The registry inverts that: core holds `agent := registry.Lookup(mode)` and only that agent's capabilities fire. **Done-condition, grep-measurable: after migration no `e.Mode == "..."` / `case modeX:` exists in core.** ### 1. The per-agent interface (capability-shaped, not one god method) Do **not** define a single fat interface - most agents no-op most methods (only claude onboards, only qwen installs, only claude smoke-tests). Model it as a small **core record served from the manifest** plus **optional capability interfaces** core feature-tests. Idiomatic Go (`io.ReaderFrom`-style); "this agent doesn't do X" is the absence of an impl, not a guard clause. ```go // internal/agentspi - contract package, types only, no behavior package agentspi type Agent interface { Name() string // roster key, e.g. "claude" Record() Manifest // binary, contextLevel, stream, argv, auth-kind, identity, model (from agents.yaml) Signer() attribution.Signer // built from Record().Identity (the #306 test case) LaunchArgv(RunCtx) (argv []string, stream bool) PreflightArgv(prompt string) ([]string, bool) } type CredentialProvider interface { ResolveCreds(HostCtx) []EnvLine; WriteCreds(RunCtx) error } type ConfigComposer interface { ComposeConfig(RunCtx) error } type Installer interface { Install(RunCtx) error } type OnboardingSeeder interface { SeedOnboarding(RunCtx) error } type LaunchGate interface { PreLaunchCheck(RunCtx) error } // smokeTestClaudeAuth ``` - `RunCtx`/`HostCtx` are **narrow structs carved out of `bootstrapEnv`**, carrying only what agents touch (`AgentHome`, `TargetName`, model/effort fields, ollama URL, a `blog` logger, an `Exec`). Do **not** pass the whole `Runner`/`bootstrapEnv` across the boundary - that reintroduces the import cycle you just split. - Core dispatch replaces the six unconditional calls with feature-tests: `if c, ok := agent.(agentspi.Installer); ok { c.Install(rc) }`, etc. - **Stays generic in core:** the `runContainerBootstrap` sequence, git auth, clone, pre-commit install, read-only push guard, extra-repo clone, substrate warm, context compose, permissions compose, reap, chown, setpriv drop, the stream-json wrapper, `looksLikeAuthError`/`commandExists`/`lowDiskPaths`/`envOr`/`blog`/base64, the manifest parse+validate, and the `WARD_*` env-var **names** (shared wire contract). ### 2. Package layout + registry ``` internal/agentspi/ // interface + Manifest/RunCtx/HostCtx/EnvLine value types, no behavior internal/agents/claude/ // one package per agent implementing the SPI internal/agents/codex/ internal/agents/opencode/ // was "qwen" - see 7 internal/agents/goose/ internal/agents/registry.go // imports the four, builds map[string]Agent, Lookup(mode) ``` Core imports only `agentspi` + `registry`, never a concrete agent. **Adding a harness = new folder + one registry line + one manifest entry + one docs file** - the deliverable's success test, met literally. Recommend `internal/agents/<name>/` over `pkg/agents`: these are ward-internal orchestration glue, not a reusable library; reserve `pkg/` (cli-guard) for the genuinely reusable engine where `attribution.Signer` already lives. Honest cost: ward is today one flat `package main` in `cmd/ward` with **unexported** `Runner`/`bootstrapEnv`. Sub-packages can't reach unexported `main` symbols, so this **requires carving the narrow `RunCtx`/`HostCtx` structs into `agentspi`** - that carve is the first real unit of work, and why the contract-test net matters (moving behavior across a new boundary, not just renaming). Coexistence: `agent_adapter.go` **becomes the data layer** - `agentAdapter` -> `agentspi.Manifest` (superset: today's fields + #306 identity/model/endpoint), `loadAgentManifest()` stays the loader, the registry injects each agent's record at construction. `agent-adapters.yaml` and #306's `agents.yaml` **converge into one embedded manifest** - do not ship two. ### 3. Data-vs-code boundary vs #306 (no site claimed by both) - **A per-agent value otherwise inert -> `agents.yaml` (#306):** name, binary, contextLevel, stream-format id, auth-**kind** enum, argv dialects, `identity{name,pronouns}`, model defaults, ollama URL default, attribution email domain. - **A per-agent procedure -> the package (this ticket):** how to satisfy each auth-kind (keychain vs file vs SSM vs none), decode+write+chmod+scrub, config-file templating, install curl, onboarding JSON, smoke probe, plus the methods that serve the data. `agentIdentity()` worked example: strings `"Claude"`/`"she/her"` are **data** (`agents.yaml.identity`, #306 owns the key); the `Signer()` method reading them into `attribution.Signer` is **code** (this ticket owns the method). Symmetric for auth: `auth: claude-keychain` is data (#306); `security find-generic-password -s "Claude Code-credentials"` is code (here). One enum value, one procedure, no overlap. **Explicitly not this ticket:** #308 Bucket C (infra topology) -> infra/SSM; Bucket B (doctrine prose) -> referenced not recopied. ### 4. Docs per agent `docs/agent-<name>.md` documents `internal/agents/<name>` - capabilities, cred channel, config-file shape, install stance, launch dialect, smoke gate. One doc to one folder, guarded by a **drift test** (a registered agent with no doc = red build, the `agent-roster` self-curing pattern). Roster of four: `agent-claude.md`, `agent-codex.md`, `agent-opencode.md`, `agent-goose.md`. Reconcile: `agent-adapter-manifest.md` stays as the **data** schema doc (each agent doc links to its manifest entry); `agent-credentials.md` keeps the **shared** channel and delegates per-agent specifics; `agent-local-harnesses.md` **splits** into `agent-opencode.md` + `agent-goose.md`; `agent-roster.md` **left alone** (roles axis) with one sentence added to `agent.md` drawing the roles-vs-harnesses line. ### 5. Migration order (ward#152 pattern, contract-green throughout, switch dies last) Invariant: **at no commit do the live switches and the new registry disagree** - a contract test fails the build if they do (generalizing `TestAgentManifestMatchesHardcodedSwitches` from data to behavior). - **Phase 0 (aos):** land #306/#308 data into `agents.yaml` (identity/model/endpoint on the existing adapter manifest). Ward embeds. No control-flow change. - **Phase 1 (ward):** carve `internal/agentspi` (interface + `RunCtx`/`HostCtx`). Pure extraction, core still switches. Green. - **Phase 2 (ward):** four packages, each capability **delegating to the still-live `main` functions** (thin wrappers); build the registry; **flip no call site**; add the generalized contract test (registry output == live switch output). Both live, test pins them. Green. - **Phase 3 (ward):** flip core call sites **one capability at a time** (creds, config, install, onboarding, launch argv, then vision/context/binary/preflight reads). Each flip its own small PR, old function unreferenced-but-present, contract green. - **Phase 4 (ward):** **switch dies last** - once every seam dispatches through the registry, replace `parseMode`'s switch with a membership check, delete the dead switches + orphaned funcs in one sweep. Contract test flips from "registry == switch" to "registry == golden fixtures". This is where `rg -i claude` drops. - **Phase 5 (ward):** docs + drift test + credentials/local-harnesses reconciliation. ### 6. qwen -> opencode roster untangle (#308's naming collision) Category error: every sibling names its **harness** (claude/codex/goose); `qwen` names a **model**. Mode string `"qwen"` (`modeQwen`) -> binary `opencode` (`agentBinary`, `container_compute.go:196`) -> backend `ollama/qwen3-coder:30b`. And goose **also** runs `ollama/qwen3-coder`, so "qwen" can't identify a harness. Resolution, landed in Phase 2 so it rides the contract net: rename `modeQwen -> modeOpencode`, roster `"qwen" -> "opencode"`, package `internal/agents/opencode/`; the model `qwen3-coder:30b` moves to `agents.yaml` as a **data** `model` field; keep a `--mode qwen` **back-compat alias** in the registry/`parseMode` with a deprecation `blog`, do not hard-break in the rename PR. One data decision for #306, flag don't decide: whether opencode signs `Opencode`, `Qwen (via opencode)`, or keeps `Qwen` - an `agents.yaml` identity value. ### 7. Implementation issues to file, sequenced, with landing repo 1. **aos** - `agents.yaml`: add identity/model/endpoint/(vision - see Kai's note) to the adapter manifest. Blocks everything. 2. **ward** - carve `internal/agentspi` (interface + `RunCtx`/`HostCtx`); no behavior moves. (Phase 1) 3. **ward** - four `internal/agents/<name>` packages delegating to live funcs + generalized contract test; registry built, no call sites flipped. (Phase 2) 4. **ward** - qwen -> opencode roster untangle with `--mode qwen` alias. (Phase 2) 5. **ward** - flip cred resolution + seeding to `CredentialProvider`. (Phase 3) 6. **ward** - flip config compose to `ConfigComposer`. (Phase 3) 7. **ward** - flip install/onboarding/smoke-gate. (Phase 3) 8. **ward** - flip launch argv + context/binary/preflight to `Record()`; delete the dead switches + `parseMode` enumeration (switch dies last). (Phase 4) 9. **ward** - `docs/agent-<name>.md` per package + drift test; reconcile `agent-credentials.md`, split/retire `agent-local-harnesses.md`, add the roles-vs-harnesses note. (Phase 5) **Boundary for Kai to pick:** `internal/agents/<name>` sub-packages with `HostCtx` (compiler-enforced isolation, more plumbing) vs file-per-agent in one package holding `*Runner` (lighter, isolation by convention). Advisor recommends the sub-package form. **Mode:** consult. --- **Kai's correction (supersedes the design on this point):** `visionCapable` is not moving to `agents.yaml`. Its only consumer is the `agent.go:168` seed branch that ward#400 is already rewriting to inline the body for all drivers. It is being **retired** as a rider on ward#400, so drop the proposed `vision:` manifest field from issue #1 above.
Author
Owner

Boundary decided (Kai, via director surface): sub-packages + HostCtx - the advisor's recommended form.

internal/agents/<name>/ sub-packages implementing the agentspi SPI over narrow RunCtx/HostCtx structs carved out of bootstrapEnv. Isolation is compiler-enforced: core cannot reach per-agent internals, and the struct carve is accepted as the first real unit of work. NOT the file-per-agent-on-*Runner form.

Sequenced execution plan

Data prerequisite (aos, not this ticket): aos#306 (agents.yaml authoring) + aos#308 (ownership audit / move-list) are the DATA half. ward#401 Phase 2+ consumes the landed manifest; do not re-file that work here. Phase 1 below has NO data dependency.

Strict contract-green invariant throughout (generalize TestAgentManifestMatchesHardcodedSwitches from data to behavior): at no commit do the live switches and the registry disagree. The director cannot dispatch these out of order (no dependency graph), so each phase's issue is filed only once its predecessor merges - NOT all at once.

  • Phase 1 (ward, UNBLOCKED - dispatching now): carve internal/agentspi (the Agent interface + capability interfaces + RunCtx/HostCtx/Manifest/EnvLine value types). Pure extraction, core still switches, no behavior moves. Green trivially.
  • Phase 2 (ward, blocked on Phase 1 + aos data): four internal/agents/{claude,codex,opencode,goose} packages, each capability delegating to the still-live main funcs (thin wrappers); build registry.go; flip NO call site; add the generalized contract test. Fold the qwen->opencode roster untangle here (rename modeQwen->modeOpencode, keep a --mode qwen back-compat alias with a deprecation log).
  • Phase 3 (ward, blocked on Phase 2): flip core call sites one capability at a time - creds (CredentialProvider), then config (ConfigComposer), then install/onboarding/smoke-gate. Each its own small PR, old func unreferenced-but-present, contract green.
  • Phase 4 (ward, blocked on Phase 3): switch dies last - once every seam dispatches through the registry, replace parseMode's switch with a membership check and delete the dead switches + orphaned funcs in one sweep. Contract test flips from 'registry == switch' to 'registry == golden fixtures'. This is where rg -i claude drops.
  • Phase 5 (ward, blocked on Phase 4): docs/agent-<name>.md per package + a drift test (registered agent with no doc = red build); reconcile agent-credentials.md, split agent-local-harnesses.md into agent-opencode.md+agent-goose.md, add a roles-vs-harnesses note to agent.md (agent-roster.md left alone - it's the roles axis).

Correction already folded: visionCapable is retired as a rider on ward#400 (its sole consumer was the seed branch #400 rewrote), not moved to the manifest.

Filing + dispatching Phase 1 now; subsequent phases get filed as each predecessor lands.

**Boundary decided (Kai, via director surface): sub-packages + HostCtx** - the advisor's recommended form. `internal/agents/<name>/` sub-packages implementing the `agentspi` SPI over narrow `RunCtx`/`HostCtx` structs carved out of `bootstrapEnv`. Isolation is compiler-enforced: core cannot reach per-agent internals, and the struct carve is accepted as the first real unit of work. NOT the file-per-agent-on-`*Runner` form. ## Sequenced execution plan **Data prerequisite (aos, not this ticket):** aos#306 (agents.yaml authoring) + aos#308 (ownership audit / move-list) are the DATA half. ward#401 Phase 2+ consumes the landed manifest; do not re-file that work here. Phase 1 below has NO data dependency. Strict contract-green invariant throughout (generalize `TestAgentManifestMatchesHardcodedSwitches` from data to behavior): at no commit do the live switches and the registry disagree. The director cannot dispatch these out of order (no dependency graph), so each phase's issue is filed only once its predecessor merges - NOT all at once. - **Phase 1 (ward, UNBLOCKED - dispatching now):** carve `internal/agentspi` (the `Agent` interface + capability interfaces + `RunCtx`/`HostCtx`/`Manifest`/`EnvLine` value types). Pure extraction, core still switches, no behavior moves. Green trivially. - **Phase 2 (ward, blocked on Phase 1 + aos data):** four `internal/agents/{claude,codex,opencode,goose}` packages, each capability delegating to the still-live `main` funcs (thin wrappers); build `registry.go`; flip NO call site; add the generalized contract test. Fold the qwen->opencode roster untangle here (rename `modeQwen`->`modeOpencode`, keep a `--mode qwen` back-compat alias with a deprecation log). - **Phase 3 (ward, blocked on Phase 2):** flip core call sites one capability at a time - creds (`CredentialProvider`), then config (`ConfigComposer`), then install/onboarding/smoke-gate. Each its own small PR, old func unreferenced-but-present, contract green. - **Phase 4 (ward, blocked on Phase 3):** switch dies last - once every seam dispatches through the registry, replace `parseMode`'s switch with a membership check and delete the dead switches + orphaned funcs in one sweep. Contract test flips from 'registry == switch' to 'registry == golden fixtures'. This is where `rg -i claude` drops. - **Phase 5 (ward, blocked on Phase 4):** `docs/agent-<name>.md` per package + a drift test (registered agent with no doc = red build); reconcile `agent-credentials.md`, split `agent-local-harnesses.md` into `agent-opencode.md`+`agent-goose.md`, add a roles-vs-harnesses note to `agent.md` (`agent-roster.md` left alone - it's the roles axis). **Correction already folded:** `visionCapable` is retired as a rider on ward#400 (its sole consumer was the seed branch #400 rewrote), not moved to the manifest. Filing + dispatching Phase 1 now; subsequent phases get filed as each predecessor lands.
Author
Owner

Manifest home decided upstream (aos#310): the 'one embedded manifest' this design converges on is authored in ward-kdl as a new config dialect (dialect 2) - compiled + cli-guard-validated + embedded into ward, NOT fetched. A director-surface consult with Kai locked a config-placement law ('config lives at the lowest layer that fully determines it, never fetched downward; aos authors zero ward-runtime config') and broadened ward-kdl from 'the audited-CLI generator' to 'the build-time authoring layer'. So the internal/agents/<name> SPI reads its data (Record()/Manifest) from that ward-kdl-authored embedded manifest, and agent-adapters.yaml folds into it. No change to this ticket's phases - Phase 1 (ward#410, in flight) carves the SPI types and is unaffected; later phases source manifest data from the ward-kdl dialect once aos#310's design lands. Full design + build sequence: aos#310.

**Manifest home decided upstream (aos#310):** the 'one embedded manifest' this design converges on is authored in **ward-kdl as a new config dialect (dialect 2)** - compiled + cli-guard-validated + embedded into ward, NOT fetched. A director-surface consult with Kai locked a config-placement law ('config lives at the lowest layer that fully determines it, never fetched downward; aos authors zero ward-runtime config') and broadened ward-kdl from 'the audited-CLI generator' to 'the build-time authoring layer'. So the `internal/agents/<name>` SPI reads its data (`Record()`/`Manifest`) from that ward-kdl-authored embedded manifest, and `agent-adapters.yaml` folds into it. No change to this ticket's phases - Phase 1 (ward#410, in flight) carves the SPI types and is unaffected; later phases source manifest data from the ward-kdl dialect once aos#310's design lands. Full design + build sequence: aos#310.
coilysiren added this to the ward launch milestone 2026-07-01 08:47:36 +00:00
Author
Owner

Complete - all 5 phases landed; ward is now agent-agnostic.

  • Phase 1 - carved internal/agentspi (SPI + RunCtx/HostCtx) - ward#410
  • Phase 2 - four internal/agents/{claude,codex,opencode,goose} packages + registry + contract test; qwen->opencode untangle with a qwen back-compat alias; capabilities forward via injected agents_wire.go closures (the boundary made "delegate to unexported main funcs" impossible - resolved cleanly) - ward#412
  • Phase 3 - runContainerBootstrap + the data-read call sites now dispatch through the registry; switches retained - ward#418
  • Phase 4 - parseMode collapsed into a fleetconfig membership check, dead switches deleted, contract pinned to a golden fixture (switch died last) - ward#420
  • Phase 5 - per-agent docs/agent-<name>.md + a registry-backed drift test; split agent-local-harnesses.md; roles-vs-harnesses note - ward#422

The SPI reads its per-agent data from the embedded fleetconfig.Fleet (aos#310 dialect 2), so "one embedded manifest, do not ship two" is now sourced, not aspirational. Boundary chosen: sub-packages + HostCtx (compiler-enforced isolation).

Follow-up: ward#423 (golden-fixture sync smell, low priority). Note: Phases 4-5 landed on --driver codex (host Claude auth smoke test wedged - worth a re-auth). Closing.

**Complete - all 5 phases landed; ward is now agent-agnostic.** - **Phase 1** - carved `internal/agentspi` (SPI + `RunCtx`/`HostCtx`) - ward#410 - **Phase 2** - four `internal/agents/{claude,codex,opencode,goose}` packages + registry + contract test; `qwen`->`opencode` untangle with a `qwen` back-compat alias; capabilities forward via injected `agents_wire.go` closures (the boundary made "delegate to unexported main funcs" impossible - resolved cleanly) - ward#412 - **Phase 3** - `runContainerBootstrap` + the data-read call sites now dispatch through the registry; switches retained - ward#418 - **Phase 4** - `parseMode` collapsed into a `fleetconfig` membership check, dead switches deleted, contract pinned to a golden fixture (switch died last) - ward#420 - **Phase 5** - per-agent `docs/agent-<name>.md` + a registry-backed drift test; split `agent-local-harnesses.md`; roles-vs-harnesses note - ward#422 The SPI reads its per-agent **data** from the embedded `fleetconfig.Fleet` (aos#310 dialect 2), so "one embedded manifest, do not ship two" is now sourced, not aspirational. Boundary chosen: sub-packages + `HostCtx` (compiler-enforced isolation). Follow-up: ward#423 (golden-fixture sync smell, low priority). Note: Phases 4-5 landed on `--driver codex` (host Claude auth smoke test wedged - worth a re-auth). Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#401
No description provided.