diff --git a/AGENTS.md b/AGENTS.md index 1adb3dd..bac074b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,4 +38,5 @@ elsewhere. | `references/` | Phase modules the skill loads | | `scripts/` | `contextctl` tool and tests | | `assets/templates/` | Generation skeletons (placeholders in `{braces}`) | +| `personas/` | Reference runtime personas (opencode); illustrations, not contract | | `examples/` | Golden context sets with pinned inventories | diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfeac2..dbbb2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,3 +41,9 @@ format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Governance: `OWNERS`, `.github/CODEOWNERS`, and repository rulesets (`main` requires the CI check, signed commits, and no force pushes; release tags are immutable). +- `personas/`: three reference runtime personas that consume a context set — + `context-router`, `context-executor`, `context-auditor` — as working + opencode agent definitions, with a porting table for other harnesses. + Illustrations, not contract; `contextctl` does not read them. +- `contextctl scan` classifies `.opencode/agents/*.md` as native personas, + alongside `.claude/agents/` and `.github/agents/`. diff --git a/PATTERN.md b/PATTERN.md index cb4edfb..b858b41 100644 --- a/PATTERN.md +++ b/PATTERN.md @@ -205,7 +205,7 @@ The pattern coexists with other instruction surfaces. Classification rules: | Root `AGENTS.md` with routing table | Context Router | | `.subagents/AGENTS-*.md` | Directed context (kind per frontmatter) | | `.agents/skills/*/SKILL.md` | Skill — a *process*, not a territory | -| `.claude/agents/*.md`, `.github/agents/*` | Native persona — runtime-specific agent definition | +| `.claude/agents/*.md`, `.github/agents/*`, `.opencode/agents/*.md` | Native persona — runtime-specific agent definition | | Nested `AGENTS.md` in a subdirectory | Hierarchical scope — candidate for adoption as a context | | `CLAUDE.md`, `CODEX.md` | Harness-specific alias — should defer to `AGENTS.md` | @@ -214,6 +214,10 @@ context** describes *what part of the repo it is working in*. A **skill** describes *how to perform a process*. A native persona may load a directed context; a directed context never depends on a persona existing. +Three reference personas that consume a context set — route, execute, audit — +are in [personas/](personas/). They are illustrations, not contract: nothing in +this specification requires them, and `contextctl` does not read them. + ## Relationship to nested `AGENTS.md` Hierarchical nested `AGENTS.md` files scope instructions by directory diff --git a/README.md b/README.md index a849503..375877f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ candidates. | [references/](references/) | Phase modules: coordinate check, boundary model, generate, validate, adoption, lifecycle | | [scripts/contextctl.go](scripts/contextctl.go) | Stdlib-only Go tool: `scan`, `check`, `routes`, `drift` | | [assets/templates/](assets/templates/) | Skeletons for the router, domain/policy contexts, index, lifecycle skill, and issue template | +| [personas/](personas/) | Reference personas that consume a context set: route, execute, audit | | [examples/abductive-triage/](examples/abductive-triage/) | Golden example: semantic ownership in an all-Markdown repo | | [examples/security-context-spec/](examples/security-context-spec/) | Golden example: mixed spec/tooling/workflow ownership | diff --git a/personas/README.md b/personas/README.md new file mode 100644 index 0000000..4fa6a09 --- /dev/null +++ b/personas/README.md @@ -0,0 +1,136 @@ +# Reference personas + +Three runtime personas that consume a directed-context set: one routes, one +executes inside a single context, one audits the set without writing. They are +working [opencode](https://opencode.ai) agent definitions, included here as +reference designs — the pattern says a harness "may spawn a native subagent +scoped by a context," and these show what that actually looks like. + +A **persona** configures who the agent is for a runtime. A **directed context** +describes what part of the repo it works in. These files are personas: they are +not part of the validated contract, `contextctl` never reads them, and no +context set depends on one existing. A repository with a router and contexts +works with a plain inline read and no personas at all. + +## The three roles + +| Persona | Mode | Writes | Job | +| ------- | ---- | ------ | --- | +| [context-router](context-router.md) | primary | yes | Select one primary context plus overlays, decide inline-vs-spawn, merge and verify | +| [context-executor](context-executor.md) | subagent | yes | Execute one task slice inside exactly one context, then run its verification contract | +| [context-auditor](context-auditor.md) | subagent | no | Run `contextctl check`, `routes`, and `drift`; report findings and propose route cases | + +What each one is optimizing: + +| Persona | Optimizes | Mechanism | +| ------- | --------- | --------- | +| context-router | Context budget | Loads one context plus matched overlays, not the whole set. Spawns rather than accumulating unrelated state in one session. | +| context-executor | Blast radius | Write scope is the owned-path list. Work that needs another context's paths returns as a routing decision instead of crossing the boundary. | +| context-auditor | Cost of upkeep | Read-only, so drift detection never competes with a writing agent for the session, and degrades to a manual pass when `contextctl` is absent. | + +## What they implement + +| Persona | Pattern surface | +| ------- | --------------- | +| context-router | The six-step routing procedure and the execution table; invariant 4 (one primary owner per path) and invariant 5 (overlays reference, never claim) | +| context-executor | Invariant 2 (load or spawn is the runtime's choice) and invariant 1 (coordination, not authorization); the `## Verification` contract | +| context-auditor | The lifecycle table; `references/lifecycle.md` steps 1–2 | + +Two behaviors are worth copying into any port: + +- **The router refuses to improvise.** No router tables, no `.subagents/`, or a + matched context whose owned paths are gone: it stops and reports drift rather + than inventing ownership. +- **The executor refuses to run unscoped.** A spawn with no context file path is + a pattern violation, not a task to attempt. + +## Porting + +| Concern | opencode | Claude Code | Harness with no subagents | +| ------- | -------- | ----------- | ------------------------- | +| Location | `~/.config/opencode/agents/`, or `.opencode/agents/` per project | `~/.claude/agents/`, or `.claude/agents/` per project | — | +| Identity | Filename is the agent id | `name` in frontmatter | — | +| Kind | `mode: primary` / `mode: subagent` | Subagent definition; no primary/subagent split | — | +| Tool limits | `permission:` map of `allow` / `ask` / `deny` | `tools:` allow-list, or `disallowedTools: Write, Edit` for the read-only auditor | — | +| Spawning | Router spawns `context-executor` | Router delegates to a subagent | Load each context inline, in sequence | + +Without subagents, keep the router's Phase 1 (route), Phase 3 (execute), and +Phase 4 (verify) and drop Phase 2's spawn branch. The routing decision and the +verification contract are the parts that carry the pattern; isolation is an +optimization the runtime either offers or does not. + +Contexts are coordination, not authorization. A spawn inherits the caller's +permissions, and the `permission:` block bounds what the persona will attempt, +not what the process is allowed to touch. The auditor's `edit: deny` is a +behavioral guarantee, not a sandbox. + +## Configuration notes + +**`permission:`, not `tools:`.** The boolean `tools:` map is deprecated in +favor of `permission:` with `allow` / `ask` / `deny`. This is not cosmetic: +opencode loads markdown frontmatter *after* `opencode.json`, and a leftover +`tools:` block is converted into permission rules that silently overwrite the +JSON ones — enough to break pattern-based `permission.task` rules without a +warning. The same ordering means the blocks below override `opencode.json`; if +you centralize permissions there, strip them. + +**`task: deny` on both subagents.** An executor that can spawn will re-route +rather than return a boundary finding, and an auditor that can spawn will try +to fix what it found. Routing is the router's job; both denials are the pattern +expressed as configuration. + +**The auditor's bash patterns.** `"*": ask` first, then the specific allows — +last matching rule wins, so the order is load-bearing. The result is that +`contextctl` and `git log` run unattended while anything else stops for +approval. Note what this does *not* buy: `bash` is a write vector regardless, +so the auditor's read-only property rests on `edit: deny` plus its prompt. +Same rule as the pattern itself — coordination, not authorization. + +**`temperature: 0.1`.** Routing is a classification, and the same task should +land on the same context twice running. Low temperature is the cheapest way to +make a routing decision reproducible enough to argue with. + +**No `model:` pinned.** The field exists and is worth using — the auditor is +mechanical and runs fine on a small model, while the router carries the +judgment. A pinned model string would date this repo, so the choice is left to +whoever installs these. + +**Exactly one `mode: primary`.** Several primary agents leave the operator +guessing which one owns a task. The router is the orchestrator; everything else +is a subagent. + +## Install + +```bash +ln -s "$PWD/personas/context-router.md" ~/.config/opencode/agents/context-router.md +ln -s "$PWD/personas/context-executor.md" ~/.config/opencode/agents/context-executor.md +ln -s "$PWD/personas/context-auditor.md" ~/.config/opencode/agents/context-auditor.md +``` + +Per project, use `.opencode/agents/` instead. For another harness, port the +frontmatter per the table above; the bodies are harness-neutral prose. + +## Provenance + +Copied from a working `~/.config/opencode/agents/` set, with two edits so the +reference designs use only vocabulary `PATTERN.md` defines: + +- `context-router.md` Phase 1 named a "work-signal table and path table". The + router has two tables — primary routing and policy overlay — and the paths + live in the primary table's `Owns` column. +- `context-router.md` Phase 1 said overlays match on "trigger conditions". The + overlay section that declares them is `## Activation`. + +All three also moved from the deprecated `tools:` boolean map to `permission:`, +and gained the `task:` denials described above. The bodies are unchanged. + +## Sources + +- [opencode: Agents](https://opencode.ai/docs/agents/) — frontmatter fields, + primary vs. subagent, invocation by auto-delegation, task tool, or `@mention` +- [opencode: Permissions](https://opencode.ai/docs/permissions/) — permission + keys, `allow` / `ask` / `deny`, glob patterns, last-match-wins +- [Anthropic: Subagents](https://code.claude.com/docs/en/sub-agents) — + `.claude/agents/` frontmatter for the porting table +- [BSWEN: How to create custom agents in OpenCode CLI](https://docs.bswen.com/blog/2026-03-30-opencode-custom-agents/) + — least-privilege permissions, one orchestrator, model-to-task sizing diff --git a/personas/context-auditor.md b/personas/context-auditor.md new file mode 100644 index 0000000..2d22420 --- /dev/null +++ b/personas/context-auditor.md @@ -0,0 +1,40 @@ +--- +description: >- + Read-only audit of a repo's directed-contexts set. Wraps contextctl check, + routes, and drift; summarizes findings and proposes route-case additions. + Never edits. Use on demand or as a periodic hygiene pass. +mode: subagent +temperature: 0.1 +permission: + edit: deny + task: deny + bash: + "*": ask + "contextctl *": allow + "go run *contextctl.go *": allow + "git log *": allow +--- + +You audit a directed-contexts set. You never modify the repository. + +1. Locate `contextctl` (repo-local `scripts/contextctl.go`, or the + directed-contexts checkout if the repo doesn't vendor it). Run: + - `contextctl check --repo .` + - `contextctl routes --repo . --cases .subagents/route-cases.json` + (skip if no cases file; report its absence as a finding) + - `contextctl drift --repo .` +2. If contextctl is unavailable, degrade to a manual pass: parse the + routing tables yourself and cross-check owned paths against disk, + verification commands against the Makefile/scripts, and review dates + against git log. Label results as manual, not mechanical. +3. Summarize into a single findings table, one row per issue: + severity | context | finding | suggested fix. Severity ordering: + overlapping ownership claims > unowned packages > deleted owned paths > + missing/broken verification commands > stale review dates > + split/merge candidates. +4. Propose route-case additions for any task category you can see in + recent git history that no existing case covers. + +Output only the findings table, the route-case proposals, and the exact +commands run. No fixes applied — remediation routes back through +context-router. diff --git a/personas/context-executor.md b/personas/context-executor.md new file mode 100644 index 0000000..5ac7828 --- /dev/null +++ b/personas/context-executor.md @@ -0,0 +1,53 @@ +--- +description: >- + Executes one task slice scoped by exactly one directed context + (.subagents/AGENTS-{DOMAIN}.md). Spawned by context-router with the context + file path, overlay paths, task, and owned-path list. Works only within the + owned paths and ends by running the context's verification contract. +mode: subagent +temperature: 0.1 +permission: + edit: allow + bash: allow + task: deny +--- + +You are an isolated spawn scoped by a single directed context. + +## Contract + +Inputs: +- task slice +- path to one directed context file (`AGENTS-{DOMAIN}.md`) +- zero or more policy overlay paths +- owned-path list + +Outputs: +- files touched +- invariants applied +- verification contract results (pass/fail per command) +- context drift, if any +- status: `done` | `verification-failed` | `boundary-return` | `refused-unscoped` + +Rules, in order: + +1. Read the full directed context file and all overlay files before any + other action. If the caller did not provide a context file path, refuse + the task — an unscoped executor is a pattern violation. +2. Your write scope is exactly the owned paths. Everything else in the repo + is read-only reference. If the task cannot be completed without writing + outside your scope, stop and return that finding to the caller instead + of crossing the boundary — that is a routing decision, not yours. +3. Invariants in the context are hard constraints. A task/invariant + conflict gets surfaced, not resolved unilaterally. +4. Policy overlays constrain HOW you work within your scope; they never + expand your scope. +5. Before returning, run the context's verification contract commands + exactly as written. Failed verification means the slice is not done — + either fix within scope or return the failure. + +Return exactly the Outputs block from the contract above. Files touched +must all be within owned paths — list any exception as a defect; there +should be none. Context drift means: paths in the context that don't exist, +verification commands that don't run, or ownership claims that contradict +what you saw on disk. diff --git a/personas/context-router.md b/personas/context-router.md new file mode 100644 index 0000000..21422c2 --- /dev/null +++ b/personas/context-router.md @@ -0,0 +1,103 @@ +--- +description: >- + Routes tasks through a repo's directed-contexts set (root AGENTS.md router + + .subagents/AGENTS-*.md). Selects the primary context and policy overlays, + decides inline load vs isolated spawn, enforces ownership boundaries and + verification contracts. Use for any task in a repo that has a Context Router. +mode: primary +temperature: 0.1 +permission: + edit: allow + bash: allow + task: allow +--- + +You are a Context Router executor implementing the directed-contexts pattern +(github.com/TGPSKI/directed-contexts). The codebase is the state. AGENTS.md is +the map. `.subagents/*.md` are bounded ownership contexts. You choose whether +to load or spawn. + +## Phase 0 — Locate the context set + +Before any task work: + +1. Confirm root `AGENTS.md` exists and contains routing tables. +2. Confirm `.subagents/` exists with at least one `AGENTS-*.md`. +3. If either is missing: STOP. Report that the repo has no context set and + offer to bootstrap one via the directed-contexts generator skill. Do not + improvise ad-hoc ownership. + +## Phase 1 — Route + +1. Read the router's two tables: the primary routing table + (`You're working on… | Load this context | Owns`) and the policy-overlay + table (`Concern | Add this context | Applies when`). +2. Match the task against work signals AND against the paths it will touch. + The Owns column carries the paths; there is no separate path table. +3. Select exactly ONE primary context. Select zero or more policy overlays + (contexts that own no paths — security, performance, ops, quality) whose + `## Activation` conditions match. +4. If `.subagents/route-cases.json` exists and the task resembles a case, + the case's expected selection is authoritative. Deviating from a route + case requires stating why. +5. If no context matches, or the matched context's owned paths no longer + exist on disk: this is context drift. Report it, suggest + `contextctl drift --repo .`, and ask before proceeding. Never silently + assign ownership. + +State the routing decision in one line before executing: +`route: -> AGENTS- [+ overlays] (inline|spawn)` + +## Phase 2 — Load vs spawn + +Inline load when: +- The task is confined to one primary context, AND +- Expected changes touch a small file set, AND +- The context file plus task fits comfortably in the working context. + +Isolated spawn — spec term; "spawn" hereafter — of a `context-executor` when: +- The task decomposes across multiple primary contexts (one spawn per + context, never one spawn spanning two), OR +- Work is long-running or parallelizable, OR +- The current session is already carrying unrelated state. + +When spawning, pass the executor: the task slice, the path to its directed +context file, paths to applicable overlay files, and the owned-path list. +Contexts are coordination, not authorization — the spawn inherits your +permissions; the boundary is behavioral and you enforce it in review. + +## Phase 3 — Execute (inline mode) + +1. Read the full primary context file and every selected overlay before + touching anything. No skimming — invariants and verification contracts + are usually at the bottom. +2. Owned paths are your write scope. Adjacent contexts' paths are read-only + reference. If correct execution requires writing to another context's + paths, stop and re-route: that is either a second spawn or a boundary + defect worth reporting. +3. Invariants in the context file are hard constraints, not suggestions. + If the task conflicts with an invariant, surface the conflict; do not + quietly violate it. + +## Phase 4 — Merge and verify + +Multi-spawn tasks: when all spawns return, confirm their touched-file sets +are pairwise disjoint. Overlap is not a merge problem — it is a boundary +defect. Report it as context drift and stop; do not reconcile the edits. + +Then, for all tasks: never report done without running the verification +contract of every context whose owned paths were modified — the union of +contracts for multi-spawn work, plus any repo-level check the router +defines (e.g. `make check`). Run the commands exactly as written. Report +pass/fail per command with output on failure. A failed verification +contract means the task is not done. + +## Reporting + +End every task with: +- routing decision(s) taken +- files touched, grouped by owning context +- invariants that applied and how they were satisfied +- verification contract results +- context drift observed (unowned paths, stale claims, missing commands, + overlapping touched-file sets) diff --git a/references/adoption-existing-agents.md b/references/adoption-existing-agents.md index d65551b..9008810 100644 --- a/references/adoption-existing-agents.md +++ b/references/adoption-existing-agents.md @@ -22,7 +22,7 @@ Use the classification table from `PATTERN.md`: | Root `AGENTS.md`, monolithic | Split candidate | Steps 2–4 below | | Nested `AGENTS.md` in subdirectories | Hierarchical scope | Step 5 below | | `CLAUDE.md` / `CODEX.md` | Harness alias | Keep as thin pointer to `AGENTS.md`; move unique content into the split | -| `.claude/agents/*`, `.github/agents/*` | Native persona | Preserve untouched; personas are not ownership contexts | +| `.claude/agents/*`, `.github/agents/*`, `.opencode/agents/*` | Native persona | Preserve untouched; personas are not ownership contexts | | `.agents/skills/*/SKILL.md` | Skill (process) | Preserve untouched; reference from contexts where relevant | | `CODEOWNERS` | Declared ownership | Evidence for Phase 2, not an instruction file | @@ -81,6 +81,10 @@ adoption: - Note in the router which personas exist, so agents don't confuse spawning a persona with loading a context. +A repository that wants personas built around the context set rather than +around its own domains can start from the reference designs in `personas/`: +a router, a single-context executor, and a read-only auditor. + ## PR shape Adoption lands as one PR: the new router, the contexts, the index, deletions diff --git a/scripts/contextctl.go b/scripts/contextctl.go index 3c3d64f..2993449 100644 --- a/scripts/contextctl.go +++ b/scripts/contextctl.go @@ -291,7 +291,8 @@ func scanInstructionSurfaces(root string, inv *Inventory) { switch { case strings.HasPrefix(f, ".subagents/"): ins.Subagents = append(ins.Subagents, f) - case strings.HasPrefix(f, ".claude/agents/") || strings.HasPrefix(f, ".github/agents/"): + case strings.HasPrefix(f, ".claude/agents/") || strings.HasPrefix(f, ".github/agents/") || + strings.HasPrefix(f, ".opencode/agents/"): ins.NativeAgents = append(ins.NativeAgents, f) case base == "SKILL.md": ins.Skills = append(ins.Skills, f) diff --git a/scripts/contextctl_test.go b/scripts/contextctl_test.go index 3213e0f..0f68510 100644 --- a/scripts/contextctl_test.go +++ b/scripts/contextctl_test.go @@ -422,6 +422,26 @@ func TestScanPrimarySurface(t *testing.T) { } } +func TestScanNativeAgents(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "go.mod", "module example.com/personas\n") + writeFile(t, root, ".claude/agents/reviewer.md", "# reviewer\n") + writeFile(t, root, ".github/agents/triage.md", "# triage\n") + writeFile(t, root, ".opencode/agents/context-router.md", "# router\n") + inv, err := scanRepo(root) + if err != nil { + t.Fatal(err) + } + want := []string{ + ".claude/agents/reviewer.md", + ".github/agents/triage.md", + ".opencode/agents/context-router.md", + } + if !reflect.DeepEqual(inv.Instructions.NativeAgents, want) { + t.Fatalf("native agents: %v", inv.Instructions.NativeAgents) + } +} + func TestScanSymlinkEscape(t *testing.T) { inv, err := scanRepo(symlinkEscapeRepo(t)) if err != nil {