PR #1003 — Agent-Roles-Charters Phase 1: Giving Workspace Agents a CLI

The Goal at the End of the Previous PR

PR #993 (chore/rename-agent-cli-to-harness, merged 2026-08-15) freed up the synlynk agent verb group by renaming synlynk's execution-backend commands from agent to harness. That PR existed purely to clear a naming collision — it shipped no new capability itself. Its explicit purpose was to unblock the next piece of work: a persistent, org-chart-style Agent concept (dev, qa, pm, architect, tpm, designer, marketing, synlynk-bot) that is distinct from the swappable Harness (Claude, Agy, Grok, Codex) that executes a dispatched task. That distinction — Agent as who, Harness as how — is documented in docs/glossary-agent-vs-harness.md, itself the product of brainstorming that preceded #993.

The underlying storage layer for this — synlynk/agent_store.py, with agent registration, charter storage with revision provenance, and per-agent config projections — had already shipped in PR #988. What was missing was any way for a human or another agent to actually use it: no CLI to create an agent, no way to dispatch work under an agent's identity, no way for dispatch_agent() to resolve "the dev agent" into a concrete harness + role + GitHub identity.

Strategic Shift in This PR

None — this PR is exactly the Phase 1 scope that #993 was clearing space for. The one refinement made mid-execution was a plan-design fix, not a strategic pivot: the original implementation plan specified a test (test_cmd_agent_edit_stale_revision_exits_1) asserting that cmd_agent_edit detects a stale charter revision across two sequential CLI invocations. On inspection, no correct implementation of cmd_agent_edit could ever fail that test — it reads parent_revision fresh via read_charter() immediately before writing, so there's no cross-invocation window for staleness to occur. The fix was to monkeypatch agent_store.propose_charter_revision to raise RevisionConflictError directly, exercising the CLI's exception-handling path in isolation rather than chasing an unachievable race condition. This is exactly the kind of gap the writing-plans skill's self-review pass is supposed to catch, and didn't — worth remembering for future plans that touch revision-conflict handling.

What This PR Shipped

CLI onboarding surface (synlynk/agent_cli.py, wired into synlynk/cli.py):

synlynk agent init <role>      # one agent per org-chart role; seeds charter + capability_grants: {}
synlynk agent list              # table of all registered agents, active/disabled
synlynk agent show <id|alias>   # resolves by full id or role-slug alias
synlynk agent edit <id> <file>  # propose a charter revision; exits 1 on stale revision
synlynk agent disable <id>      # idempotent disable

Each agent init writes a registry entry (agent_store.register_agent) and a .synlynk/agents/<id>.yaml projection carrying agent id, workspace id, role, and an overrides.capability_grants: {} map — a placeholder for the capability-scoping work planned for a later phase. The charter itself is not duplicated into this projection; it stays in the agent store's own revision-tracked storage, read via agent_store.read_charter().

Dispatch integration (synlynk/dispatch.py): dispatch_agent() gained an optional agent_id parameter. When supplied, it resolves the agent's org-chart role (raising ValueError if the agent is unregistered or disabled — a fail-closed check before any subprocess spawns), maps that role into the existing baseline-role vocabulary via a new _ORG_ROLE_TO_BASELINE_ROLE table:

_ORG_ROLE_TO_BASELINE_ROLE = {
    "dev": "builder", "qa": "verifier", "architect": "architect",
    "tpm": "architect", "pm": "architect", "designer": "builder",
    "marketing": "builder", "synlynk-bot": "builder",
}

and falls back to a new _harness_for_org_role() selector only when the existing story_id-based _best_agent_for_story auto-selection doesn't produce a pick — for harness auto-selection, story_id still wins when both are present, and agent_id is additive rather than a replacement for the existing routing path. GitHub identity/token resolution is a separate lookup with the opposite precedence: it reads role = agent_role or _role_for_story(story_id) or "dev", so an explicit agent_id overrides story_id for which role's gh token gets used, even though it doesn't override story_id for which harness gets picked.

CLI flag: synlynk dispatch --as-agent <id_or_alias> resolves the agent at the CLI layer before calling dispatch_agent(), and makes the harness positional argument optional so --as-agent alone can trigger auto-selection.

Test approach: TDD throughout, executed via subagent-driven-development — each of the plan's 8 tasks was dispatched to Codex individually (synlynk dispatch codex --task "..." --force-agent --context-mode full), reviewed inline against the plan and against a fresh pytest run before the next task began. Final state: 109 tests across test_agent_cli.py + test_dispatch.py, full suite at 2019 passed / 2 skipped / 0 failed.

Brainstorm Visuals Used

None — this phase built directly on the already-approved 2026-08-09-synlynk-agent-roles-charters-design.md spec and its Phase 1 sub-spec (2026-08-16-agent-roles-phase1-cli-design.md); no new visual brainstorming was needed for CLI plumbing work.

What This Achieved on the Path to Autonomy

This is the first point where a workspace agent identity — not just a harness selection — can drive a real dispatch. Previously, "who is doing this work" and "what tool is executing it" were the same variable (args.agent meant harness). Now they're separable: synlynk dispatch codex --as-agent dev says "the dev agent's work, executed by Codex" — which is the precondition for later phases where an agent's charter, capability grants, and memory namespace actually constrain or inform what gets dispatched on its behalf, rather than every dispatch being anonymous.

The Goal at the End of This PR

Phase 1 (CLI onboarding + dispatch integration) is done. The agent_id field is resolved and used for routing/identity but is not yet persisted on daemon_jobs for attribution/grouping (flagged as a non-blocking follow-up in code review — filed as a gap, not silently dropped), and cmd_agent_edit still unconditionally resets capability_grants: {} on every edit rather than preserving non-empty grants — both are real footguns for the next phase, where capability grants are expected to become load-bearing rather than a placeholder. That's the shape of Phase 2: making capability_grants mean something, and making dispatched jobs traceable back to the agent identity that requested them, not just the harness that ran them.