Skip to content

Shared agent home: observe/plan/apply reconcile for ~/.agents - #66

Merged
ndr-ds merged 4 commits into
mainfrom
ndr-ds/agentctl
Aug 24, 2026
Merged

ndr-ds merged 4 commits into
mainfrom
ndr-ds/agentctl

Conversation

@ndr-ds

@ndr-ds ndr-ds commented Aug 24, 2026 •

Copy link
Copy Markdown

Task 1 of the agentctl multi-provider plan: the canonical shared agent home at ~/.agents, with each agent product getting a thin generated adapter rendered from it. Purely additive — no existing behaviour changes outside the clap struct and one line in src/lib.rs.

What it does, concretely

$ claudectl --shared-home
Shared agent home: ~/.agents
  create    ~/.agents/instructions
  create    ~/.agents/skills
  create    ~/.agents/memory
  ...
  render    ~/.claude/CLAUDE.md    <- ~/.agents/instructions/global.md
  render    ~/.codex/AGENTS.md     <- ~/.agents/instructions/global.md
  render    ~/AGENTS.md            <- ~/.agents/instructions/global.md
  adopt     ~/.agents/memory/adopted-graph -> ~/.claude/projects/<p>/memory

Nothing written. Re-run with --apply to execute.

Edit ~/.agents/instructions/global.md once and the next --apply pushes it to every product. Re-running with nothing changed prints "up to date", so it is safe on a hook or in a shell alias.

Which products — one table, not five call sites

PROVIDERS in shared_home.rs gives each product its config directory, its global-instructions file, and where it keeps transcripts and any adoptable memory graph. Adapter rendering, classify() and memory discovery all iterate it.

This was the main review finding on the branch: the module doc claimed "adding a provider is a registry entry rather than a classify arm", and that was false — twelve hardcoded .claude/.codex references across five places, so a third product meant five edits. Now it is one row, and every_provider_in_the_table_is_handled_without_a_special_case iterates the table asserting all three behaviours, so a row that would need a special case fails there.

Checked rather than assumed: adding a Cursor row and changing nothing else renders ~/.cursor/rules/agent.md and keeps every test green.

The model

PathOwnership decides every write:

Class Written by
SharedSource the user, and agentctl on their behalf
GeneratedAdapter agentctl, rendered from shared source
ProviderNative the product only
TranscriptEvidence the product only, never rewritten
External nobody here

~/.agents/plugins is ProviderNative despite sitting inside the shared home. ~/.agents is OpenAI's own convention — codex-cli 0.148.0 discovers and rewrites ~/.agents/plugins/marketplace.json implicitly. Claiming it would put agentctl and Codex in a write race over one file.

observe → plan → apply

Split so the middle step is pure: observe() is the only pre-apply read, plan() is total over its input, apply() executes with atomic temp-then-rename writes.

A plan cannot delete. There is no Action::Remove; that is a property of the type, not a runtime check. A reconcile that runs on every hook event and can delete is one bad reading away from taking a user's own instructions with it — the same failure this repo just shipped three fixes for on the session registry.

An existing memory graph is adopted in place. Copying would duplicate every note and fork its history on the next write. Discovery returns nothing when more than one candidate exists, counting across all products: adopting the wrong project's graph points every product at someone else's notes, while a missed adoption is a no-op the user can correct.

Drift

Each render records the bytes it wrote, under adapters/. That stamp separates we wrote this and the source has since moved — ours to re-render — from a human changed it — theirs, reported and never overwritten. Without it the two are indistinguishable, and guessing "drift" breaks the main use case, because then editing the shared instructions reaches nobody.

Credentials

McpRegistry::validated refuses any env value that is not a ${VAR} reference, naming the server and the variable. The registry is shared, version-controllable and destined for every product's config, so one literal secret there becomes a secret in all of them. It refuses rather than redacting: a redacting writer fails open on the shape it did not anticipate, silently. Validation runs in the dry run, before anything is rendered.

Four defects the pre-PR review caught

All were invisible to cargo test, clippy and fmt, which were green before the review.

  1. A regression to an unrelated flag. The new clap fields were spliced between --restore-sessions's doc comment and its #[arg]. A doc comment binds to the next field, so --shared-home inherited "restore your local Claude sessions… pair with --dry-run" and --restore-sessions shipped with no help text at all.

  2. The reconcile never settled. observe() never reported the memory/adopted-graph pointer that plan() tests for, so AdoptInPlace re-planned forever on any machine with a Claude memory directory — the exact machine this targets. The idempotence test passed only because its tempdir had no .claude/projects, so the branch never executed.

  3. A documented guarantee with no code behind it. observe() hardcoded drifted: Vec::new(), so Action::ReportDrift and the CLI's DRIFT output were unreachable. "Never overwritten" held; "reported" did not.

  4. The genericity claim was false, per the section above.

McpRegistry::render_claude is deleted rather than kept: it had no production caller, and rendering registries into native config is per-product work that belongs in PROVIDERS — a renderer named after one vendor is the shape this deliberately avoids. docs/shared-agent-home.md now lists it under "Not yet implemented" instead of implying the MCP side is finished.

Verified end to end on a real filesystem, not only in tests: run 1 applies its actions, run 2 prints "up to date", hand-editing ~/.claude/CLAUDE.md then reports DRIFT, and a staged memory directory is adopted where it lives.

Verification

cargo fmt --check clean, cargo clippy --all-targets -- -D warnings clean, 19 shared-home tests plus the rest of the suite. CI green on ubuntu and macOS.

Locally three usage_ledger tests fail on a loaded box; they pass in isolation and in CI, are untouched by this branch, and only one is a real failure — perf_eviction_stays_linear_with_many_rows asserts a wall-clock ratio, and when it panics it poisons cache_test_lock(), so the other two die on .expect("test serialise mutex poisoned") as collateral.

No #[allow(dead_code)] was added. shared_home lives only in the lib — the bin reaches it as claudectl::shared_home, the way it already reaches claudectl::reaper.

Not in this PR

Rendering the MCP and hook registries into native config, populating sessions/index.jsonl, and migrating memory out of a product's tree. All three are listed in docs/shared-agent-home.md under "Not yet implemented" rather than left as silent gaps.

@ndr-ds
ndr-ds marked this pull request as ready for review August 24, 2026 13:46
@ndr-ds
ndr-ds merged commit f254239 into main Aug 24, 2026
5 checks passed
@ndr-ds
ndr-ds deleted the ndr-ds/agentctl branch August 24, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant