diff --git a/README.md b/README.md index 290ddcdf..9a6663fd 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ loopstra --version ``` cd your-repo loopstra init # writes loopstra/config.yaml, the stamp, the owner's guide and the skill -loopstra setup # budgets, commands, gates, GitHub, chat, models (init offers it at the end) +loopstra setup # budgets, commands, gates, GitHub, chat, models, agents (init offers it at the end) git add -A && git commit -m "Set up Loopstra" loopstra start # runs the loop; watch it with loopstra status or loopstra ui ``` @@ -91,7 +91,7 @@ It writes no prompts, no hook and no `.claude/settings.json`: the prompts and th Then: -1. Run `loopstra setup`. In a terminal, `init` offers it at the end ("Walk through the settings now?"). It goes through every setting, one section at a time (budgets, commands, gates, GitHub, chat, models), showing the current value and a suggestion: Enter takes the suggestion, and `-` leaves an optional answer empty. A command left out with `-` stays out: its `# install:` line stays, and later runs do not suggest the detected command again. It saves `loopstra/config.yaml` once, at the end, keeping its comments; if you quit (Ctrl-D) or the result would not load, it saves nothing. Then it checks what it can for the sections it ran, all at the same time: that `claude` is installed, that `commands.test` passes on main (in a throwaway checkout, after `commands.install`; a program that is not installed fails, naming it), with a git remote, that it answers and `gh` is signed in (every change then goes through a pull request), and that the Slack and Discord tokens work. What is not ok is listed under "To fix", with the `loopstra setup
` to run again; it never undoes the save. +1. Run `loopstra setup`. In a terminal, `init` offers it at the end ("Walk through the settings now?"). It goes through every setting, one section at a time (budgets, commands, gates, GitHub, chat, models, agents), showing the current value and a suggestion: Enter takes the suggestion, and `-` leaves an optional answer empty. A command left out with `-` stays out: its `# install:` line stays, and later runs do not suggest the detected command again. It saves `loopstra/config.yaml` once, at the end, keeping its comments; if you quit (Ctrl-D) or the result would not load, it saves nothing. Then it checks what it can for the sections it ran, all at the same time: that `claude` is installed, that `commands.test` passes on main (in a throwaway checkout, after `commands.install`; a program that is not installed fails, naming it), with a git remote, that it answers and `gh` is signed in (every change then goes through a pull request), that the Slack and Discord tokens work, and that every agent the steps name is in place. The agents section can also add skills and agents to the repository: from this machine's own, from a git repository or folder (copied as data, never run), or a new agent written from a short form. Those files are written after the config saves, only under `.claude/agents` and `.claude/skills`; setup never commits them, so commit them with the config. What is not ok is listed under "To fix", with the `loopstra setup
` to run again; it never undoes the save. 2. Commit everything `init` wrote, with the config as setup left it, on that branch. The loop works in its own checkouts, which only see what is committed, and `loopstra start` refuses until the config, the stamp and any prompt override (the `.md` files in `loopstra/prompts/`; anything else there is passed over) are committed. Commit the config again whenever setup changes it. 3. Start the loop with `loopstra start` (see Run). @@ -184,6 +184,8 @@ The orchestrator is a drawer on every screen (Ctrl J; see Chat); opened from a c **Acting from the console.** On a change's page and in the inbox you can approve, send back with a note, retry, start, hold, release, set the priority of and close a change, and the top bar pauses or resumes the loop. Pause and Resume write `.loopstra/hold.json`; every other button leaves a request in `.loopstra/requests/`, and the loop applies it within a couple of seconds, even while steps run, and commits it like an edit of `intent.md` (`loopstra(): plan approved by from the dashboard`). A status change is refused when the change moved on since you looked; priority, hold and release apply to any open change. The toast that says how it went offers Undo for a few seconds (approve, send back, priority and hold; close asks first instead). A held change keeps its place, but the loop starts no step for it until it is released; a paused loop lets running steps finish and starts no new one. Chat can do the same after a plain yes, and editing `intent.md` still works. +**What a session ran as.** A session's page lists, among its facts, the agent setup the step ran with, from the trace's `agent_setup` event: the agent (or "none"), its subagents (with the model each was given, if any), teams with their members (and models), skills, tools, MCP tools, and "repo only" when only the repository's own agents and skills loaded. Empty lists are left out, and a session without that event (every session before the setup existed, or one with no setup) shows no such lines. When the agent hands work to a subagent, that work is nested under the call that started it (the row labelled "Handing off to "), in a block that opens and closes: the prompt it was given, then its own messages and tool calls. A subagent's prompt is shown as the prompt it is, never as a message from you. + ## Steering You can steer an agent while it works, from its tile on Agents or from its session page (open it from a tile, or a change's timeline). Each has a box with three buttons (shorter on a tile, where Enter sends): @@ -271,26 +273,150 @@ This copies the bullets under "Proposed CLAUDE.md additions" into a `## Lessons` ## How it works -Status moves through: +A change moves through five stages. Each stage runs a few steps, each step is one Claude Code session, and a gate at the end of the stage decides whether the change moves on. A person always accepts a change (`draft` to `accepted`); by default nobody has to do anything after that. + +```mermaid +flowchart TD + draft(["draft"]) -->|a person accepts| accepted + subgraph sDesign ["Design"] + accepted(["accepted"]) --> designing(["designing"]) --> specGate{"spec gate"} + specGate -->|pass, person set| specReview(["spec-review"]) + specGate -.->|"fail: written again once"| designing + end + subgraph sPlan ["Plan"] + specApproved(["spec-approved"]) --> planning(["planning"]) --> planGate{"plan gate"} + planGate -->|pass, person set| planReview(["plan-review"]) + planGate -.->|"fail: written again once"| planning + end + subgraph sBuild ["Build"] + planApproved(["plan-approved"]) --> building(["building"]) --> buildChecks["tests, then verify"] + buildChecks -.->|"fail: fix, test again"| building + end + subgraph sReview ["Review and merge"] + reviewing(["reviewing"]) -->|review rounds, merge checks| mergeGate{"merge gate"} + mergeGate -->|"pull request, or person set"| mergeReview(["merge-review"]) + mergeGate -.->|"checks made fixes: review again"| reviewing + mergeReview -->|person approves| mergeApproved(["merge-approved"]) + end + subgraph sVerify ["Verify"] + merged(["merged"]) --> doneGate{"done gate"} + doneGate -->|pass, person set| verifying(["verifying"]) + end + specGate -->|pass| specApproved + specReview -->|person approves| specApproved + planGate -->|pass| planApproved + planReview -->|person approves| planApproved + buildChecks -->|pass| reviewing + mergeGate -->|"pass, no remote, no person"| merged + mergeReview -->|pull request merges| merged + mergeApproved --> merged + doneGate -->|pass| done(["done"]) + verifying -->|person confirms| done + classDef person stroke-dasharray: 5 4 + class draft,specReview,planReview,mergeReview,verifying person +``` + +Rounded boxes are statuses; the Steps table below says which steps run in each stage. The dashed ones wait for a person, and the review statuses appear only when a person is set on that gate (see When something needs a person); with nobody set, a passing gate goes straight on. Dotted arrows go back for another try, within the limits below. Any status can also go to `blocked` or `closed`. The step called `verify` belongs to the build stage, where it runs the finished change's checks; the Verify stage runs `done-check` and `lessons` after the merge. + +### Steps + +Each step is one session, with its own prompt (see Prompts), model (see Models for each step) and, if you set one, agent setup (see Agents for each step). + +| Step | Stage | What it does | What it may do | +| --- | --- | --- | --- | +| `intake` | design | reads a new intent, and sets its priority when the owner gave none | read | +| `design` | design | writes the spec | read (plan mode) | +| `spec-check` | design | judges the spec | read | +| `plan` | plan | writes the plan | read (plan mode) | +| `plan-challenge` | plan | judges the plan | read | +| `build` | build | writes the code | edit, and run the project's commands | +| `reconcile` | build | rewrites the plan when the build changed files it did not list | edit, and run commands | +| `fix` | build | fixes failing checks, or a failed verify | edit, and run commands | +| `verify` | build | runs the checks and reads what they show | read, and run commands | +| `review` | review | reviews the code | read, and read-only git (`diff`, `log`, `show`, `status`) | +| `revise` | review | fixes the review's important findings | edit, and run commands | +| `done-check` | verify | judges whether the change did what "Done when" says, on a copy of `main` | read, and run commands | +| `lessons` | verify | writes `lessons.md` | read | +| `orchestrator` | chat | talks with you (see Chat) | read | +| `write-intent` | chat | writes an agreed brief up as intents | read | + +"Run the project's commands" means the configured test, lint, build, run and install commands; build sessions also get `claude.allowed_tools`. + +### Gates + +Gates sit at every stage boundary. Each is a list of checks: deterministic code, a fresh-context agent reviewer, or a person. A gate's checks run in the step that produced the artifact; a pass goes straight to the approved status, or to the review status if a person is set. A failure gets one automatic rewrite with the findings, then blocks. + +```mermaid +flowchart LR + writer["writer session
design or plan"] -->|spec.md or plan.md| checks{"code checks,
then a fresh-context judge"} + checks -->|pass, no person on the gate| approved(["spec-approved"]) + checks -->|pass, person set| waiting(["spec-review"]) + waiting -->|person approves| approved + checks -.->|"first failure: the findings"| writer + checks -.->|"second failure, or the judge cannot run"| blocked(["blocked"]) + classDef person stroke-dasharray: 5 4 + class waiting person +``` +The spec gate is drawn; the plan gate has the same shape. The merge and done gates run their checks the same way, but a failure is not written again: + +| Gate | Its checks | A person can be | When it fails | +| --- | --- | --- | --- | +| `spec` | the required headings; the `spec-check` judge | `none` or `status` | written again once with the findings, then blocked | +| `plan` | the required headings; every file it lists exists or is new; the `plan-challenge` judge | `none` or `status` | written again once with the findings, then blocked | +| `merge` | the branch is up to date with `main`, the tests pass, and the newest review has no important findings | `none`, `status` or `pr` | fixes the checks had to make go back to review; anything else blocks | +| `done` | the `done-check` judge, against "Done when" | `none` or `status` | unmet criteria block, and `outcome.md` lists them | + +### Build and review + +```mermaid +flowchart LR + nBuild["build"] --> nDrift{"files outside
the plan?"} + nDrift -->|yes| nReconcile["reconcile
rewrites the plan"] --> nTests + nDrift -->|no| nTests{"tests, lint, build"} + nTests -.->|fail| nFix["fix
up to 3 tries"] -->|test again| nTests + nTests -->|pass| nVerify{"verify"} + nVerify -.->|fail| nFixOnce["fix
one try"] -->|"test, verify again"| nTests + nVerify -->|pass| nReviewing(["reviewing"]) + nReviewing --> nReview{"review round"} + nReview -.->|important findings| nRevise["revise
up to 2 rounds"] -->|test again| nReview + nReview -->|none| nMergeChecks{"merge checks"} + nMergeChecks -.->|checks made fixes| nReview + nMergeChecks -->|pass| nMergeGate{"merge gate"} ``` -draft -> accepted -> designing -> spec-review -> spec-approved - -> planning -> plan-review -> plan-approved - -> building -> reviewing -> merge-review -> merge-approved -> merged - -> verifying -> done -any status -> blocked | closed + +Every loop has a limit: `stages.build.max_fix_loops` (3) fixes while the tests fail, one fix when `verify` fails, and `stages.review.max_rounds` (2) revisions after review. Past a limit the change is blocked, and its note says so. `reconcile`, `fix` and `revise` continue the build's own session, so they know what the build did. With `speed.overlap_judges`, round 1's review runs beside `verify` on the same commit, and is used only if verify passes. + +**Merge checks** bring the branch up to date with `main`, make sure the tests pass (not run again when they already passed on the same code, since `main` only gained records), and read the newest review. Files changed in the change's worktree and not committed (a review `after` command, or a `lint --fix` in the build's own test runs, that rewrote them) are committed to the branch and reviewed once more first, so a formatting fix still reaches review. What the merge checks' own commands rewrite in tracked files (a `lint --fix`, a report with a timestamp) is put back, never kept, so it cannot start one review round after another; fixes the checks commit are reviewed once more. A review `after` command that changes the same files again right after they were kept blocks the change with a note naming them. New files the test, lint and build commands write (a coverage folder, a test report) are removed after each run, during the build as well; a fix session that runs the tests itself can still leave one, and the fix's commit takes it, so list such outputs in `.gitignore`. + +### Where sessions read and write + +```mermaid +flowchart TB + main[("main checkout
branch main, records, people's edits")] + snap[("snapshot of main's last commit
one per step")] + wt[("change worktree
branch intent/#lt;slug#gt;")] + main -->|copied for each step| snap + main -->|branched| wt + snap -->|read| ro["read-only steps
intake, design, spec-check,
plan, plan-challenge, lessons"] + wr["writing steps
build, fix, reconcile, revise"] -->|edit| wt + wt -->|read, run checks| chk["review, verify"] + ro -->|return spec, plan| rt["Loopstra runtime
code, not an agent"] + rt -->|commits| wt + rt -->|merges, records| main ``` -- **Design and plan** sessions are read-only and return content; the runtime writes `spec.md`, `plan.md`, `review.md`, `outcome.md`. They (and intake, the spec and plan judges, and lessons) read a snapshot of `main`'s last commit taken once per step (`.loopstra/snapshots/`, removed when the step ends), never the main checkout: a person's uncommitted edits there are not part of a spec or plan, and `main` moving meanwhile (another change merging) does not change what one step reads. Only the build session edits code, in a worktree on its own branch `intent/`, and the runtime makes every commit and merge. The build session may run the configured commands (test, lint, build, run, install) on top of `claude.allowed_tools`. Sessions run with background tasks turned off (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`), so a long command runs in the foreground and no task is left pending for the next session that resumes it. A chained command (`a && b`, `||`, `;`) is allowed whole and part by part, since Claude Code checks each part on its own. -- **Merge checks** bring the branch up to date with `main`, make sure the tests pass (not run again when they already passed on the same code, since `main` only gained records), and read the newest review. Files changed in the change's worktree and not committed (a review `after` command, or a `lint --fix` in the build's own test runs, that rewrote them) are committed to the branch and reviewed once more first, so a formatting fix still reaches review. What the merge checks' own commands rewrite in tracked files (a `lint --fix`, a report with a timestamp) is put back, never kept, so it cannot start one review round after another; fixes the checks commit are reviewed once more. A review `after` command that changes the same files again right after they were kept blocks the change with a note naming them. New files the test, lint and build commands write (a coverage folder, a test report) are removed after each run, during the build as well; a fix session that runs the tests itself can still leave one, and the fix's commit takes it, so list such outputs in `.gitignore`. -- **Gates** sit at every boundary. Each is a list of checks: deterministic code, a fresh-context agent reviewer, or a person. A gate's checks run in the step that produced the artifact; a pass goes straight to the approved status, or to the review status if a person is set. A failure gets one automatic rewrite with the findings, then blocks. +**Design and plan** sessions are read-only and return content; the runtime writes `spec.md`, `plan.md`, `review.md`, `outcome.md`. They (and intake, the spec and plan judges, and lessons) read a snapshot of `main`'s last commit taken once per step (`.loopstra/snapshots/`, removed when the step ends), never the main checkout: a person's uncommitted edits there are not part of a spec or plan, and `main` moving meanwhile (another change merging) does not change what one step reads. Only the build session edits code, in a worktree on its own branch `intent/`, and the runtime makes every commit and merge. The build session may run the configured commands (test, lint, build, run, install) on top of `claude.allowed_tools`. Sessions run with background tasks turned off (`CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`), so a long command runs in the foreground and no task is left pending for the next session that resumes it. A chained command (`a && b`, `||`, `;`) is allowed whole and part by part, since Claude Code checks each part on its own. + +### Around the stages + - **After a merge**, the done-when criteria are checked, `outcome.md` and `lessons.md` are written, and main's tests are run. If main goes from green to red, a new draft intent describing the breach is opened. - **The trace** is in `.loopstra/` (gitignored): `trace.db` (SQLite), and per change `runs//events.jsonl` plus a folder per phase holding the prompt, the result envelope and the raw session. `status`, `tail` and `ui` read it, and it stays on your machine. Chat sessions are traced there too, under `_chat`, with their state in `.loopstra/chat/`. The only things that leave it are what you set up: pull requests on GitHub, and chat messages and announcements posted to Slack or Discord. - **Design and plan beside the lanes** (`speed.think_ahead`, on): while any change runs a step, design and plan steps of other changes run beside it, one at a time, in queue order, so a change accepted during another's build is designed and planned by the time the build ends. Those steps only read code and run none of the project's commands, so they do not count against `max_parallel_changes`; a design or plan stage with `before` or `after` commands is left to the lanes. The lanes take build, review, merge and verify steps first. It starts nothing once every lane has ended (the next tick takes over), and only with room in the day's budget for its session beside the others, so never with a daily budget and no session limit. - **More than one change at a time** (`speed.max_parallel_changes` above 1): each tick runs a step for up to that many changes at once, each in its own worktree, and waits for all of them before the next tick. Fewer run when what is left of the day's budget covers fewer whole sessions (`claude.max_budget_usd`); the trace says so. Loopstra's own writes to the main checkout (its records, moving `main`, a local merge) go one at a time, and nothing slower waits with them: checking out a change's worktree and talking to GitHub happen beside them. Changes merge one at a time: without GitHub, a change that merges after another is brought up to date and tested again first; with GitHub, a pull request that another change's merge left out of date is brought up to date and waits for GitHub's checks again. A change whose plan lists a file another change in progress also lists (in its plan, or in the files its build added to the plan) waits until that change has merged, and `queue.md` says so ("Waits for add-numbers to be merged first, because both change the same files."), so the two do not clash when they merge. That wait holds at one change at a time too, since a pull request can wait to merge while the one lane works on the next change. The loop line names every change being worked on ("working on add-numbers and say-goodbye"). - **Chat** is a separate process (`loopstra chat`, or the console). It never runs a stage or writes the main checkout: what it needs there (starting a draft, acting on a change, or adding written intents when there is no remote) it leaves as a request in `.loopstra/requests/`, as the console does, and the loop applies it at the start of a tick and every 2 seconds beside the running steps. -The full design is in `docs/superpowers/specs/2026-09-28-loopstra-design.md`, with chat in `docs/superpowers/specs/2026-10-01-orchestrator-chat-design.md`, more than one change at a time in `docs/superpowers/specs/2026-10-16-parallel-changes-design.md`, how `main` is synced, pull requests are watched, sessions read `main` and the checkout is locked in `docs/superpowers/specs/2026-10-16-simpler-core-design.md`, and packaging and `loopstra upgrade` in `docs/superpowers/specs/2026-10-18-packaging-upgrade-design.md`; `docs/decisions.md` records why. +The full design is in `docs/superpowers/specs/2026-09-28-loopstra-design.md`, with chat in `docs/superpowers/specs/2026-10-01-orchestrator-chat-design.md`, more than one change at a time in `docs/superpowers/specs/2026-10-16-parallel-changes-design.md`, how `main` is synced, pull requests are watched, sessions read `main` and the checkout is locked in `docs/superpowers/specs/2026-10-16-simpler-core-design.md`, packaging and `loopstra upgrade` in `docs/superpowers/specs/2026-10-18-packaging-upgrade-design.md`, and agents for each step in `docs/superpowers/specs/2026-10-26-agent-setup-design.md`; `docs/decisions.md` records why. ## Configuration @@ -298,14 +424,15 @@ The full design is in `docs/superpowers/specs/2026-09-28-loopstra-design.md`, wi - `main_branch`, `poll_seconds` - `commands`: test (required), install, lint, build, run -- `claude`: models (`cheap`, `default`, `strong`, `max`: by default the aliases `haiku`, `sonnet`, `opus` and `fable`, which Claude Code resolves to the newest model of each family; a full name such as `claude-opus-5-5` pins one), `timeout_minutes`, optional spending limits (`max_budget_usd` per session, `max_budget_usd_per_day` for the loop's sessions together; unset means no limit), and `allowed_tools` for build sessions (the configured commands are always added; anything else a build needs, like `"Bash(make *)"`, goes here) +- `claude`: models (`cheap`, `default`, `strong`, `max`: by default the aliases `haiku`, `sonnet`, `opus` and `fable`, which Claude Code resolves to the newest model of each family; a full name such as `claude-opus-5-5` pins one), `timeout_minutes`, optional spending limits (`max_budget_usd` per session, `max_budget_usd_per_day` for the loop's sessions together; unset means no limit), and `allowed_tools` for build sessions (the configured commands are always added; anything else a build needs, like `"Bash(make *)"`, goes here). Sessions load the MCP servers, skills and CLAUDE.md files of the machine and repository they run in, but without an allow rule an MCP call is refused (a session cannot ask). A server can still be connecting when a session starts, so its tools may only appear after the first turn +- `agents`: which agents, subagents, teams, skills, tools and MCP tools each step's session gets (see Agents for each step) - `gates`: spec, plan, merge, done, each with `human` (`status` or `none`; merge also `pr`) and `agent` (independent reviewer); merge also `method` (`squash` or `merge`) - `stages`: per-stage model, `steps` (a model for single steps; see Models for each step), skills, `before`/`after` commands, and loop limits - `speed`: work that runs at the same time to save waiting; only `cheap_judges: first` changes who decides (once earned, a cheaper judge's pass stands). `overlap_judges` (on) runs the first review alongside the verifier (in a copy of the tested commit), and lessons alongside the final check; when the verifier fails, the review running beside it is stopped at once and round 1 runs after the fix. With `claude.max_budget_usd_per_day` set, they overlap only when what is left of the day covers two whole sessions (`claude.max_budget_usd`), so never without a session limit. `parallel_checks` (off) starts test, lint and build together, and stops the rest once one fails; leave it off if they write the same folder. `plan_ahead` (on) writes the first plan while the spec is being checked. The plan is stopped if the spec does not pass, and it is used only if the code on `main` it read (outside `intent/` and `loopstra/config.yaml`) is still the same commit's code when planning starts. It follows the same daily budget rule, and does not run with a person on the spec gate, without a spec judge, with plan `before` commands, or while another change is about to merge. `cheap_judges` (off) asks a cheaper model (`claude.models.default`) the same question as the spec check, the plan challenge and the review: `shadow` only records whether it agreed with the strong judge, which still decides, and `first` lets it pass something alone once it has also caught the strong judge's last 30 rejections, sending anything it rejects or is unsure of to the strong judge (`loopstra judges` shows the record). In `first`, every 5th call still runs both judges as an audit, with the strong judge deciding, so a later miss sends it back to `shadow`. Like the overlaps, `shadow` and the audits run only with room for every session at once (three when the early review runs beside the verifier, or the spec check beside a plan written ahead), so never with a daily budget and no session limit; then the strong judge decides alone, and in `first` the cheaper judge never decides alone. `think_ahead` (on) runs design and plan steps beside the changes running a step (see How it works). `max_parallel_changes` (1, up to 4) is how many changes may each run a step at the same time. Raise it only if the project's commands (test, lint, build, install) can run twice at once on this machine: no fixed ports, no shared test database, no output outside the worktree. Each command, and each session that may run commands, gets `LOOPSTRA_SLUG` and `LOOPSTRA_SLOT`: a small number (0, 1, ...) that no other change running at the same time has, so it can pick its own ports, database and Docker project names (port `3100 + LOOPSTRA_SLOT`, say). Main's health check runs alone and gets no slot. More changes at once also spend the daily budget and the subscription's usage limits faster. With `claude.max_budget_usd_per_day` set, only as many run at once as what is left of the day covers whole sessions (`claude.max_budget_usd`), so one at a time without a session limit; each overlap above also counts the sessions of the other changes running. - `signals`: how often main's health check runs - `chat`: the orchestrator's model (`model`), the model of the write-up (`steps.write-intent`), optional spending limits (`max_budget_usd_per_day`, `max_budget_usd_per_session`), and the Slack and Discord bots under `transports` (see Chat) -`loopstra setup` asks about the commands, spending limits, gates, merging, chat and models (the four names, each stage's model, and, if you say yes, a model for each of its steps); edit the file by hand for the rest (`poll_seconds`, `allowed_tools`, stage skills and commands, speed, signals). +`loopstra setup` asks about the commands, spending limits, gates, merging, chat, models (the four names, each stage's model, and, if you say yes, a model for each of its steps) and agents (each step's agent, subagents, teams, skills, tools and MCP tools, for all steps or one step); edit the file by hand for the rest (`poll_seconds`, `allowed_tools`, stage skills and commands, speed, signals). During onboarding the `loopstra` skill lists the MCP tools its own session has, says which only read and which change things, and has you enter the ones you pick in `loopstra setup agents`. **Models for each step.** The four names are `cheap`, `default`, `strong` and `max`. Each stage has a model, and so does chat; a step can have its own under `stages..steps.` (or `chat.steps.write-intent`), set to one of the four names. A step with none of its own runs on its default below. Nothing is put on `max` by a default: only a person does that. @@ -329,10 +456,59 @@ The full design is in `docs/superpowers/specs/2026-09-28-loopstra-design.md`, wi Change them in `loopstra setup models` (it asks the four names, then each stage's model, then "Choose models for 's steps one by one? (y/N)"; answer `auto` for a step to remove its own setting, since `default` is a model's name), or on the console's Settings page (`/settings`). Each change on that page saves on its own, as a `settings` request that the loop commits as `loopstra(_settings): → by from the dashboard`. It applies to sessions that start after it is saved; model settings are read fresh before each session, so a running one is not disturbed. One change is made at a time, and it is refused if the file changed since the page read it, or if `loopstra/config.yaml` has uncommitted edits in the main checkout. When the loop is not running, the change waits ("Saved when the loop next runs.") and can be cancelled. *Last ran on* shows the model each name actually ran on, so a moved alias is visible. `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL` and `ANTHROPIC_DEFAULT_FABLE_MODEL` still apply to the aliases. A name Claude Code does not accept fails the step with reason `model` (not an outage, so it is not retried): "`strong` is `opuss`, which Claude Code does not accept as a model (step spec-check). Change it in Settings or `loopstra setup models`, then retry." In chat, that sentence is the reply. A cheaper judge (`speed.cheap_judges`) never stands in for a judge put on `max`. Fix, reconcile and revise resume the build's session even when their model differs from the build's; the first turn on the new model is not cached. +The Settings page also has an **Agents** part; see Agents for each step. + **Budgets.** No spending limit is set by default: on a subscription the dollar figures are only an estimate, and `timeout_minutes` already ends a stuck session. Set limits with `loopstra setup budgets`, as minutes (`30m`, `2h`, turned into dollars at $0.20 a minute unless you give another rate in that run) or dollars (`$6`); `none` removes one, and amounts are shown with two decimals. Its check uses the same rate and warns when `claude.max_budget_usd` runs out before `timeout_minutes`, when a session limit is above its day limit, or when `chat.max_budget_usd_per_day` is set without `chat.max_budget_usd_per_session`. A config from the old template (every limit there is `5` or `2` as it wrote them, and no `claude.max_budget_usd_per_day`) shows them as old defaults, and Enter, or `--defaults`, removes them; any other mix counts as chosen and is kept. A step that hits `claude.max_budget_usd` blocks with a note naming it. `claude.max_budget_usd_per_day` counts every change's sessions since local midnight, not chat's, including what an interrupted or crashed session cost; a session killed before it reported its cost (at `timeout_minutes`, or by a stop) counts at about $0.20 a minute, never more than it held. Each step holds its share of what is left while it runs; when what has ended reaches the limit, no new step starts until midnight. A session cut short by the day's limit is not the step's fault: the step keeps its status and resumes, the loop line reads "Paused — " with the reason, and `loopstra status` and the console list "The loop has used today's budget" under "Needs attention". A step that runs out partway on two days at the same status, having had (nearly) the whole day to itself both times (at least 90% of the limit spent by that change), would never finish, so it is blocked with a note saying it needs more than the daily budget; one that other changes left less of the day only waits. When the rest of the day is only held by a step still running, the loop waits for it to end, with nothing for a person to do (a hold left by a killed process stops counting after `timeout_minutes` plus 10 minutes). +## Agents for each step + +Each step's session can run as an agent of your own, hand work to subagents or to teams of them, load skills, and get extra tools and MCP tools. All of it is optional: with no `agents` block in `loopstra/config.yaml`, sessions start exactly as they did before. Set it up with `loopstra setup agents`, on the console's Settings page, or by hand. + +```mermaid +flowchart LR + all["agents.all
every loop step"] --> one + stage["stages.#lt;stage#gt;.skills"] --> one + own["agents.steps.#lt;step#gt;
the step's own"] --> one + rules{{"rules: read-only steps,
no history-changing git"}} --> one + one["one step's setup"] --> cli["claude -p flags"] --> init{"session starts:
did it load them?"} + init -->|yes| work["the step runs"] + init -.->|"an agent or skill is missing"| fail(["step fails: setup"]) +``` + +**What a step gets.** `agents.all` applies to every loop step and `agents.steps.` adds to it; each takes `agent`, `subagents`, `teams`, `skills`, `tools` and `mcp_tools` (`mcp__` for all of a server's tools, `mcp____` for one; MCP tools reach every session of the step, judges included). Lists add up (`all`, then the stage's `skills`, then the step's own), each name once; `agent` is one value and the step's own wins. `teams` names groups of subagents (`members`, and `way`: `parallel` gives each the same task, `pick` hands each piece of work to the member that fits). `sources` is `all` (the default: also the personal skills, agents, plugins and MCP servers of whoever runs `loopstra start`) or `repo` (only what the repository declares, the same on every machine; the user's `apiKeyHelper` and `env` still pass through so sessions can sign in). Agents and skills are Claude Code's own files, committed in the repository's `.claude/`. + +**How the session gets it.** + +| Setting | On the `claude -p` command | +| --- | --- | +| `agent` | `--agent loopstra-`. Loopstra reads the agent's file from main's last commit and passes it inline with `--agents`, so a change cannot rewrite the role that reviews it. | +| `subagents`, `teams` | their files inline in `--agents`; a line in the prompt naming them, and how each team works; and `--forward-subagent-text`, so their work shows on the session page | +| `skills` | a line in the prompt naming them. A session that works outside the main checkout also gets the snapshot of `main` with `--add-dir`, so it finds the repository's skills as `main` has them, and may not edit them there. | +| `tools`, `mcp_tools` | added to `--allowedTools`, on top of the step's own tools | +| `sources: repo` | `--setting-sources project`, with Loopstra's hook and your `apiKeyHelper` and `env` passed back in `--settings` | +| a read-only step | `--disallowedTools` removes `Edit`, `Write` and `NotebookEdit`, for the step's agent and every subagent | + +`--agents` and `--settings` are passed as files rather than as text on the command line: each session gets a temporary folder of its own, with files only you can read, removed when the session ends. So other users of the machine cannot read your settings' `env` or `apiKeyHelper` in the process list, and a large agent setup does not run into the system's limit on the length of a command. + +**Rules that do not move.** Read-only steps never get `Edit`, `MultiEdit`, `Write`, `NotebookEdit` or a Bash rule that matches any command (`Bash`, `Bash(*)`, `Bash(:*)` and the like), and no step's `tools` may hold git that changes history or a remote (`commit`, `push`, `merge`, `rebase`, `reset`, `checkout`, `switch`, `branch`, `tag`, `stash`, bare `git`). A wildcard counts when it could match one of those (`Bash(git c*)` could be `git commit`), and so does git after `env`, `command` or a `VAR=value` (`Bash(env git push *)`). `agent` cannot be set on `fix`, `reconcile` or `revise`, which continue the build's session and run as the build's agent, and a built-in agent such as `Explore` can be a subagent but not a step's agent. Chat's two steps (`orchestrator`, `write-intent`) take only their own entries under `steps` (never `all`, and not the old shared MCP rule), and anyone allowed to chat can steer the tools given there. + +**Subagents and teams.** + +```mermaid +flowchart LR + main["the step's agent
returns the step's result"] -->|Agent call| sub["a subagent"] + main -->|the same task to each| par["team, way: parallel
sec, perf, a11y"] + main -->|each piece to the member that fits| pick["team, way: pick
db, ui, api"] +``` + +The step's agent hands work on with Claude Code's Agent tool. Claude Code's own agent teams are deprecated, so a Loopstra team is a named group of subagents, and the prompt tells the agent how to use it. Subagents help but never answer for the step: the agent still returns the structured result Loopstra reads. The cost a session reports includes its subagents, and the session page nests each subagent's work under the call that started it (see What a session ran as, under Watch). + +**When something is missing.** `loopstra start` refuses while a step names an agent that is missing, and only warns about a missing skill (built-in and claude.ai skills are not files on disk). If a session starts without a named agent or skill, the step fails with the reason `setup` and is not retried; the owner note says what is missing. What each step ran as shows in its trace (`agent_setup`) and on its session page. + +**Adding agents and skills.** `loopstra setup agents` can add them from this machine's own, from a git repository or folder, or as a new agent written from a short form (see Set up a repo). The console's Settings page has an **Agents** part: each step's agent, subagents, skills and teams, the tools, and the repository's agents and skills (copy one from this machine, fetch from a git repository, write a new one, edit or remove one). The console never writes the repository itself: a config change is a `settings` request (`agents.*` paths), and a file change is an `agent-files` request, both applied by the loop. The loop refuses a file change when its target has uncommitted changes in the main checkout, goes through a link, is outside `.claude/agents` and `.claude/skills`, or changed since the page read it. Fetching from a git repository runs on the server, in a temporary folder removed afterwards. One write is pending at a time across the page. + ## Prompts Every phase's prompt, chat's `orchestrator.md` and `write-intent.md` included, ships with Loopstra. A repository has none of its own until you customize one: diff --git a/docs/architecture.md b/docs/architecture.md index 38ed18be..1c482b7e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ Loopstra is an unattended development loop on top of the Claude Code CLI. Code o ## Layers -About 150 modules under `src/`, in eight layers. A module imports only from its own layer or the ones below it. There is no runtime import cycle, and every exported name is imported somewhere: `bun run deadcode` fails otherwise. +About 165 modules under `src/`, in eight layers. A module imports only from its own layer or the ones below it. There is no runtime import cycle, and every exported name is imported somewhere: `bun run deadcode` fails otherwise. 1. **Foundations**, which import no other Loopstra module. - `text.ts`, `ids.ts`, `paths.ts` and `fsutil.ts` hold small helpers: `writeFileAtomic`, and `readJsonAs`, which reads a file through a zod schema. @@ -22,6 +22,7 @@ About 150 modules under `src/`, in eight layers. A module imports only from its - `heartbeat.ts` holds the loop's lock and pause. - `requests.ts`, `inbox.ts`, `conversation.ts` and `questions.ts` are what people leave for the loop. - `stamp.ts` holds what an upgrade knows. + - `agents.ts` resolves a step's agent setup from the config (`resolveSetup`) and holds its rules: which steps are read-only, which git a step may never get, and which steps run as the build's agent. 4. **Records.** - `record-draft.ts` holds `RecordDraft`. - `context.ts` holds `StepContext` and `commitRecord`, the one way anything is written to main, with `settleRecord` and `landed` (see Records). @@ -31,6 +32,8 @@ About 150 modules under `src/`, in eight layers. A module imports only from its - `sessions.ts` traces each session. - `phases.ts` holds `agentPhase` and `codePhase`, plus `alongside`, for work that runs beside another phase. - `judges.ts` holds the cheaper first-pass judges. + - `agent-files.ts` finds the agents and skills a setup names, reading the repository's agents from main's last commit so a change cannot rewrite the agent that judges it, and turns a setup into session options (`setupSession`) and a check of the session's start. + - `agent-library.ts` lists, copies and fetches agents and skills, for `loopstra setup agents` and the console. 6. **Stages**: `stages/*`. Design, plan, build, review, merge and verify, one step per tick, each a short sequence of phases and gates. 7. **The loop.** - `scheduler.ts` runs `tick`, with up to `speed.max_parallel_changes` lanes, a thinker for design and plan, and a pull-request watcher beside them. diff --git a/docs/decisions.md b/docs/decisions.md index 515681d5..d7f393c7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -808,3 +808,68 @@ A record (a status, an artifact, a conversation entry, a person's edits, chat's - **One change at a time, with a version check.** A change is refused if the file changed since the page read it, or if `loopstra/config.yaml` has uncommitted edits in the main checkout. The uncommitted-edits check runs inside the record write, after a cut-short record is settled, so a kill does not cause a false refusal. If the loop is not running the change waits and can be cancelled. - **A bad model name is its own failure.** A name Claude Code does not accept fails the step with reason `model`: not an outage, not retried, with a note naming the setting and where to change it. In chat the same sentence is the reply. - **Resume across models.** `--resume` with another `--model` was checked on the real CLI (Claude Code 2.1.309): a session started on haiku and resumed with `--model sonnet` kept its session id and ran the turn on sonnet. So fix, reconcile and revise keep resuming the build's session when their model differs; the cost is that the first turn on the new model is not cached. The resumed session's reported cost included the earlier turn's cost, so a total of the two would count it twice. + +## MCP tools for every session (2026-10-26) + +- **`claude.mcp_tools`, allowed in every tool set.** Sessions load the user's and the repository's MCP servers (no `--strict-mcp-config`), but a `-p` session cannot ask, and no allow list had an `mcp__` rule, so every MCP call was refused outside plan mode (checked on the real CLI: refused in `default` and `acceptEdits`, allowed in `plan` for a tool marked read-only). The rules are added after each set's own tools, judges' included: a judge may need the same lookup (an issue, a doc) as the build. They are not limited to read-only tools; what an MCP tool may change is the owner's call when they list it. +- **Rules, not tool lists.** Each entry must start with `mcp__`, so the setting cannot widen a judge's shell or file access; `allowed_tools` stays the place for those, build sessions only. +- **The skill offers the MCP tools; setup does not ask.** `loopstra setup` cannot see an MCP server's tools without connecting to it (`claude mcp list` names servers only). The operator skill runs in a Claude Code session that already has them, so it lists them by server, says which only read and which change things, and writes the engineer's picks. None is picked by default. A project's `.mcp.json` servers load in `-p` sessions without approval (checked on the real CLI), so committing the file is enough for the loop to see them. + +## Agent setup for every step (2026-10-26) + +A new `agents` block in `loopstra/config.yaml` gives each step an agent to play, subagents and teams to hand work to, skills, and extra tools and MCP tools. With no block, or an empty one, every session's flags and prompt are what they were before (pinned by a test). + +- **What the CLI does (checked on Claude Code 2.1.311, `-p`, stream-json).** `--agents ''` with `--agent ` runs the main session as an inline agent, and `--model` wins over the agent's `model`. `--disallowedTools` also binds subagents and `--agent`, so the read-only guard holds. `--add-dir ` loads that folder's `.claude/skills`; a skill of the same name in the working directory wins. `--setting-sources project` drops user skills, agents, plugins and user MCP servers while project ones load. `total_cost_usd` includes subagents. The `init` event carries `agents` and `skills`; the fake CLI's fixtures carry neither, so absent means "cannot tell". +- **`claude.mcp_tools` moved to `agents.all.mcp_tools` before release.** The setting had not shipped, so there is no alias: one place says which MCP tools every session may use. Chat steps never inherit it; they keep `CHAT_DENIED` and the scrubbed environment and take only the tools on their own entries. The operator skill writes `agents.all.mcp_tools`. +- **Agent files are read from main and passed inline as `loopstra-`.** The loop's settings are main's, so the definitions are: they are read from the step's commit of main with `git cat-file` (or the user's folder with `sources: all`), never from a checkout, and handed over with `--agents`, so a same-named file in a change's worktree never wins and a change cannot rewrite the role that reviews it. Not even through the snapshot: a writing session may reach it as an added dir (below), and later phases of the same step read the same commit. Chat reads the main checkout it runs in. `:` becomes `-` (`superpowers:code-reviewer` is `loopstra-superpowers-code-reviewer`). The snapshot is made only when the setup names an agent, subagent, team or skill. +- **`StructuredOutput` is added to a main agent's tools.** An agent file with its own `tools:` list loses the structured-output tool otherwise (checked: `structured_output` comes back null). When the file lists tools, the inline copy gets `StructuredOutput` too. Tools are not intersected with the step's allow list: permissions (allow rules and `--disallowedTools`) still gate every call, so a definition cannot widen a step; a tool it names that the step refuses is denied and shows in the trace. +- **A built-in agent cannot be a step's main agent.** `Explore`, `Plan` and the others have fixed tool lists without `StructuredOutput`, so the step could not report. They work as subagents; a team that wants one as a role defines its own. +- **`fix`, `reconcile` and `revise` take no `agent`.** They resume the build's session and run as the build's agent; the inline agents are passed again on every resume. Their other lists layer as for any step. +- **Stage skills alone keep today's behaviour.** The setup counts as empty when only `stages..skills` is set: the prompt line, with no snapshot, `--add-dir` or init check. Once the setup is non-empty for another reason, stage skills join the `--add-dir` and the init check. +- **Skills only main has reach worktree steps by `--add-dir`.** Claude Code loads skills from the working directory, which for a step in a worktree is the change's branch. The step's snapshot of main is added with `--add-dir`, so main's skills are the ones available. `--add-dir` also lets a session edit there, so such a session gets `Edit`, `Write` and `NotebookEdit` denied under the snapshot (`///**`, the absolute-path form of Claude Code's rules; a Windows drive as `/c/`). +- **`sources: repo` passes the user's sign-in through.** It is `--setting-sources project`, which also drops the user's `apiKeyHelper` and `env` from `~/.claude/settings.json`; Loopstra passes those two through its own `--settings` so sessions can still sign in. `.claude/settings.local.json` stays out, since it is personal. `--settings` still applies under `--setting-sources project`, so the test-protection hook holds: checked live on CLI 2.1.311 (a haiku session with `--setting-sources project` and a `PreToolUse` hook on `Read` passed in `--settings` read one file, and the hook wrote its marker). +- **The start check refuses on missing agents and warns on skills.** It runs once `start` holds the loop's lock (a second start never touches the running loop's snapshots), and a snapshot of main it cannot make (a `main_branch` that names no branch) is a refusal too. `loopstra start` refuses while a step names an agent, subagent or team member that is not in the repository (or, with `sources: all`, the user's folder). A missing skill is a warning: built-in and claude.ai skills are not files on disk, so the file check cannot tell them from a typo. A name found only on this machine also warns. +- **The init event is the last check.** A session whose `init` event lacks a named agent or skill fails the step with reason `setup`: not an outage, not retried, with a note naming the step and what is missing. When the event lists neither (the fake CLI), nothing is checked. +- **Chat steps take only their own entries.** `orchestrator` and `write-intent` run in the main checkout, steered by people in channels, so `agents.all` does not reach them. A missing name in a chat turn is answered in the reply and traced under `_chat`, not a refusal. +- **The trace shows it.** Each agent step writes an `agent_setup` event before the session (agent, subagents, teams, skills, tools, MCP tools, sources, models). + +### Live check (Claude Code, haiku for every model, `max_budget_usd: 1`, a toy Bun project, about $0.71 in all) + +- **Intake through an inline agent parsed.** `agents.steps.intake.agent: lead` (a committed `.claude/agents/lead.md` with `tools: Read`) ran as `--agent loopstra-lead`; the session called `StructuredOutput` and the envelope came back valid (priority, clarity and summary). This is the case that returned `null` before `StructuredOutput` was added. +- **The build's `agent_setup` event.** `{"agent":"lead","subagents":["Explore"],"teams":[],"skills":[],"tools":[],"mcp_tools":[],"sources":"all","models":{}}` for build with `lead` and `subagents: [Explore]`. With `lead` the build reported that it had only `Read` and `StructuredOutput` and could not write: the agent's narrow tools do narrow the step, as designed. Switching build to a `builder` agent with `Read, Write, Edit, Glob, Grep, Bash` built the change, and it went through to `done`. +- **`--resume` with the same `--agent` kept the session id.** On a second change a failing lint check sent the loop to fix-1, fix-2 and fix-3. All three, and the build, ran under session `1394159d-3245-4ded-a5e1-1cb973d8e60f`, each with `agent_setup` showing agent `builder` (and no subagents on the fixes). The toy blocked after three fixes only because the agent declined to create a file its plan called out of scope; that is the project's doing, not the setup's. + +## Agent setup in `loopstra setup` (2026-10-27) + +- **The `agents` section is last, and asked for.** It follows models in the section order, and a full setup asks "Set up agents for the loop's steps now?" first (the default, no, skips it). `loopstra setup agents` goes straight in. It walks the same `agents` block the config documents: each step's agent, subagents and skills, the tools, teams, and `sources`. Its check fails on an agent a step names that is not in the repository, and warns on a name found only on this machine. +- **Files are written only after the config saves.** Skills and agents are collected as pending writes while the questions run and written once the config has saved, so a quit (Ctrl-D) or a config that would not load leaves no files. A path is checked before every write: never through a link, and only under `.claude/agents` and `.claude/skills`. Setup never commits; the owner commits the files with the config. +- **Copies are data.** A skill or agent from a git repository is a shallow clone into a temporary folder that is removed afterwards, with `.git` skipped. A name with a backslash or a control character is refused, and every write stays inside its own skill folder. What a source says is never printed raw: a refusal (git's own words included) loses its control characters, and an item whose name Loopstra would not copy is left out of the list and the choices, with a count of how many were. A file setup cannot read refuses the copy in words, never ends the run, and an agent whose front matter is not YAML is refused rather than copied without it. Nothing copied is run or read as an instruction by setup. +- **Where a copy came from is recorded, without credentials.** A skill gets a `SOURCE.md`, an agent a `source:` line in its front matter. URL credentials (`https://user:token@host/...`) are stripped before anything is recorded or shown. +- **ssh does not wait for a password, and the user's ssh is kept.** A clone runs with `GIT_TERMINAL_PROMPT=0`. When `GIT_SSH` is set, or `git config --get core.sshCommand` (run in the current folder, without a shell) prints a command, `GIT_SSH_COMMAND` is left unset so that ssh runs as the user set it up (it may be a program that does not take `-o`), and `GIT_TERMINAL_PROMPT=0` is what keeps git from prompting. Otherwise `GIT_SSH_COMMAND` is `ssh -o BatchMode=yes`, or the user's own `GIT_SSH_COMMAND` with ` -o BatchMode=yes` added, so a missing key fails at once instead of waiting for a password in a terminal that is also asking setup's questions. +- **Binary files are refused for now.** A skill with a binary file is not copied; at most 200 files and 5 MB per skill. Text covers what skills hold today; binary support can come when a skill needs it. +- **Pickers take the choice's text.** A picked skill or agent is the text of the choice, not its position in a list, so a list that changes between runs never selects the wrong one. + +## Agents in the console (2026-10-27) + +- **The console never writes the repo.** It only files requests; the loop applies them. A config change is a `settings` request, now with `agents.*` paths. A file change is a new `agent-files` request. +- **An `agent-files` request is built on the server from an operation** (copy, fetch, new, edit, remove), not sent as file contents by the page. Every file carries the version the page read (remove and replace carry it explicitly), and the loop refuses the request if a target changed since, has uncommitted, staged or untracked changes in the main checkout, goes through a link, is outside `.claude/agents` and `.claude/skills`, or if `what` holds a control character. +- **Settings values may be a string, a list or null.** An empty list removes the key, and a map emptied by that is pruned too, so the config does not keep `agents: {steps: {build: {}}}` shells. +- **Fetching from a git repository runs on the server**, in a temporary folder removed afterwards. The clone listing (`/api/agents/source`) needs `Sec-Fetch-Site: same-origin` or `none`, or, from a browser that does not send that header, the page's own Origin (or a Referer on the console), so another site cannot make the console clone. +- **One pending write at a time across the Settings page.** Config and file changes share it. + +## Agent setup on the session page (2026-10-27) + +- **What the stream does with subagents (checked on Claude Code 2.1.311, with `--forward-subagent-text`).** A subagent starts with a `tool_use` named `Agent` (older CLIs: `Task`) whose input has `subagent_type`, `description` and `prompt`. Every event of the subagent carries `parent_tool_use_id`, the id of that call. Its first event is a `user` event whose content is a text block holding the prompt. Its own `tool_use`, `tool_result` and `text` events follow, each with the parent id. The parent's `tool_result` for the `Agent` call has no parent id. +- **A subagent's prompt is a `delegated` item, never a person's message.** The transcript turns a subagent's `user` text event into an item of kind `delegated`, so it cannot render as something a person said. Steering's echo counting is unaffected: with `--input-format stream-json --replay-user-messages --forward-subagent-text` the subagent's prompt carries `parent_tool_use_id` and no `isReplay`; only the person's own message is `isReplay: true`. +- **Items carry `parent`, and the page nests by it.** Each item a subagent gives has `parent`, the id of the `Agent` call. The page groups the rows under that call by id, in a collapsible block, instead of guessing from order, so interleaved work stays with the call it belongs to. A row whose call is not in the transcript stays where it is, marked as an orphan. Prompts and outputs are untrusted and render as text, as the rest of the transcript does. +- **The orchestrator's context names a subagent's prompt.** The context handed to the orchestrator labels such an item "subagent's prompt", so it is not read as something the person wrote. +- **The setup shows in the session's facts, models by plain name.** `setupView` looks up each subagent's model by its inline name and keys it by the plain name for the page (the event's `loopstra-` is not shown). Without an `agent_setup` event nothing is added, so a session renders exactly as before. + +## Review fixes to agent setup (2026-10-27) + +- **`--agents` and `--settings` go to the CLI as files.** Both took JSON on the command line. With `sources: repo` the settings carry the user's `env` and `apiKeyHelper`, which any local user could read in the process list, and `--agents` carries every inline agent's whole prompt, which can pass Linux's 128 KB limit on one argument (Windows' limit on the whole command line is about 32 KB). Claude Code 2.1.311 takes a path for both (`--agents` only with `--print`, which every session uses). Each session writes them to a `mkdtemp` folder of its own (owner-only on POSIX), as `0600` files, and removes the folder when the session ends. Checked live: a session ran as an agent read from the file, and saw an `env` value set only in the settings file. The fake CLI records each file's text in place of its path, so the tests still read what a session was given. +- **The git rule check sees wildcards and wrappers.** `Bash(git c*)` passed, since only the first word after git was compared, and Claude Code's matching would let it run `git commit`. A wildcard in the subcommand's place is now refused when it matches a history-changing subcommand (any of them: bare git), and so is a wildcard in git's place that matches `git` (`g*`). `env`, `command`, `exec`, `nohup`, `time`, `nice` and `sudo` (with their options) and `VAR=value` words before the command are skipped. A Bash rule that names other commands is still the engineer's call, `Bash(sed -i *)` on a read-only step included. +- **A read-only step refuses every match-all Bash rule.** `Bash(:*)` (the legacy prefix form, with an empty prefix) and `Bash(**)` passed the old check, which knew only `Bash`, `Bash()` and `Bash(*)`. Any Bash rule made only of wildcards now counts as bare. `MultiEdit` is refused with the other editing tools. +- **A subagent's todo list is a row in its block.** The transcript turned every `TodoWrite` into the session's todo list; a subagent's was dropped, since it must not replace the session's. It is now an ordinary tool row ("Updating its todo list") under the subagent's call. +- **The `agent_setup` event records each team's members** (`team_members`), so the session's facts show a team as `reviewers: sec (opus), perf`, with each member's model. An event from before has no members, and its teams show by name as before. +- **Only skills make the snapshot for a setup.** A writing step that only names an agent made a full detached worktree of main just to look agents up, though agents are read from the commit. The snapshot is now made only when the step names skills. diff --git a/docs/superpowers/plans/2026-10-26-agent-setup-1-runtime.md b/docs/superpowers/plans/2026-10-26-agent-setup-1-runtime.md new file mode 100644 index 00000000..1e74ad0c --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-agent-setup-1-runtime.md @@ -0,0 +1,546 @@ +# Agent Setup, Part 1: Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Each loop and chat step can run as a named agent, with its own subagents, teams, skills, tools and MCP tools, as set in a new `agents` block of `loopstra/config.yaml`; with nothing set, sessions start exactly as today. + +**Architecture:** A pure layer (`src/agents.ts`) turns the config into one `AgentSetup` per step and into prompt lines. A disk layer (`src/agent-files.ts`) finds agent and skill definitions (main's committed tree, the user's `~/.claude`, installed plugins), parses agent files, builds the inline `--agents` JSON, and checks that every named definition exists. `runPhase` gains the flags and an init-event check that ends a session which did not load what it was told to, with the new failure reason `setup`. `agentPhase` and `runChatAgent` wire it in. + +**Tech Stack:** Bun, TypeScript, Zod 4, `yaml`, bun:test with the fake CLI (`tests/fake-claude/claude.ts`, `LOOPSTRA_FAKE_ARGS`). + +**Spec:** `docs/superpowers/specs/2026-10-26-agent-setup-design.md` (read its "Settled after review" first). Parts 2-4 (setup section, console screen, operator skill and docs) get their own plans. + +## Global Constraints + +- With no `agents` block, or an empty one, every session's argv and prompt are byte-identical to today's (pinned by a test). +- The runtime alone commits, branches and merges; read-only steps never get `Edit`, `Write`, `NotebookEdit`, bare `Bash` or `Bash(*)`; no step's `tools` may hold history- or remote-changing git (`commit`, `push`, `merge`, `rebase`, `reset`, `checkout`, `switch`, `branch`, `tag`, `stash`, bare `git`). +- `mcp_tools` entries match `/^mcp__[\w*-]+$/` (no commas: the allow list reaches the CLI comma-joined). +- Inline agents are named `loopstra-` with `:` replaced by `-` (`superpowers:code-reviewer` → `loopstra-superpowers-code-reviewer`). +- A main agent's inline `tools`, when its file lists any, always include `StructuredOutput` (checked on CLI 2.1.311: without it `structured_output` is null). +- `agents.all` and today's `claude.mcp_tools` never reach chat steps; chat steps keep `CHAT_DENIED` and `dropEnv`. +- `agent` is a config error on `fix`, `reconcile`, `revise` (they resume the build's session and run as the build's agent). +- Stage skills (`stages..skills`) keep reaching exactly the steps they reach today (callers pass them as `spec.skills`, unchanged). +- Windows, macOS, Linux: paths via `node:path`, home via `os.homedir()` (tests pass their own), CRLF agent files parse. +- Comments and naming follow the codebase: plain sentences, `/** */` on exports. + +## CLI facts this plan relies on (checked on Claude Code 2.1.311, `-p`, stream-json) + +- `--agents ''` + `--agent ` run the main session as an inline agent; `--model` wins over the agent's `model`. +- `--disallowedTools` also binds subagents and `--agent`. +- `--add-dir ` loads that dir's `.claude/skills`; when the working dir has a skill of the same name, the working dir's wins (the other shows as `:`). +- `--setting-sources project` drops user skills, agents, plugins and user MCP servers; project ones load. +- `total_cost_usd` includes subagents (verified: haiku main + sonnet subagent, total = sum of `modelUsage`). +- The `system`/`init` event carries `agents: string[]` and `skills: string[]`; the fake CLI's fixtures carry neither (treat absent as "cannot tell"). + +## Review Focus + +1. A config with only `sources: all` or `agents: {}` → argv and prompt unchanged for every step. (Task 6.) +2. An agent file with CRLF line endings, no frontmatter, or frontmatter without `tools` → parsed; no `tools` key in the inline copy, so the agent gets the session's tools. (Task 3.) +3. A named skill that exists only as a Claude Code built-in or claude.ai-synced skill (not on disk) → the file check warns, does not refuse; the init check decides. (Task 4.) +4. A plugin agent named `plugin:name` whose plugin is installed but disabled in `enabledPlugins` → "missing", with the fix in words. (Task 3.) +5. A worktree step naming a skill only main has → `--add-dir ` supplies it; the session's init list has it under its plain name. (Task 6.) + +--- + +## File structure + +- Create `src/agents.ts`: config types and checks for the `agents` block helpers, `resolveSetup`, `setupLines`, `isEmptySetup`, `inlineName`. Pure. +- Create `src/agent-files.ts`: `parseAgentFile`, `findAgent`, `findSkill`, `inlineAgents`, `checkAgentSetup`, `BUILTIN_AGENTS`, `userSettingsPassthrough`. Reads disk. +- Modify `src/config.ts`: the `agents` block; drop `claude.mcp_tools` (moved to `agents.all.mcp_tools`; never released). +- Modify `src/models.ts`: `withModels` also carries `agents`; `ModelSettings` gains `agents`. +- Modify `src/claude.ts`: `RunPhaseInput` flags, `checkInit`, `FailureReason` `"setup"`. +- Modify `src/sessions.ts`: nothing unless `"setup"` needs a branch (it does not: it is a plain failure). +- Modify `src/phases.ts`: `toolsFor` drops `claude.mcp_tools`; `phasePrompt` uses `setupLines`; `attempt` passes the setup; `ownerNote("setup")`. +- Modify `src/chat/agents.ts`, `src/chat/orchestrator.ts`: chat wiring and the reply for `setup`. +- Modify `src/scheduler.ts`: `start` refuses on missing agents. +- Modify `templates/config.yaml`, `templates/skill/SKILL.md` (step 5 writes `agents.all.mcp_tools`), `README.md`, `docs/decisions.md`. +- Tests: `tests/unit/agents.test.ts`, `tests/unit/agent-files.test.ts` (new); `tests/unit/config.test.ts`, `tests/unit/phases.test.ts`, `tests/unit/claude-run.test.ts`, `tests/unit/chat-*.test.ts` (whichever covers `runChatAgent`), `tests/unit/scheduler*.test.ts` (whichever covers `start`). + +--- + +### Task 1: The `agents` config block + +**Files:** +- Modify: `src/config.ts` (the `claude` object, around `mcp_tools`; add `agents` to `ConfigSchema`) +- Create: `src/agents.ts` (types and the step-key list only, so config can import them) +- Test: `tests/unit/config.test.ts` + +**Interfaces:** +- Produces in `src/agents.ts`: + - `export const TEAM_WAYS = ["parallel", "pick"] as const; export type TeamWay = (typeof TEAM_WAYS)[number];` + - `export const READ_ONLY_STEPS: ReadonlySet`: every step except `build`, `reconcile`, `fix`, `revise`. + - `export const CONTINUES_BUILD: ReadonlySet = new Set(["fix", "reconcile", "revise"]);` + - `export const CHAT_STEPS: ReadonlySet = new Set(["orchestrator", "write-intent"]);` + - `export function toolProblem(rule: string, readOnly: boolean): string | null`: the reason a `tools` rule is refused, or null. +- Produces in `src/config.ts`: `Config["agents"]` shaped + `{ sources: "all" | "repo"; teams: Record; all: StepAgents; steps: Partial> }` where + `StepAgents = { agent?: string; subagents: string[]; teams: string[]; skills: string[]; tools: string[]; mcp_tools: string[] }` (lists default `[]`). Export `type StepAgents`. +- `claude.mcp_tools` is removed. + +- [ ] **Step 1: Write the failing tests** in `tests/unit/config.test.ts` + +```ts +test("the agents block defaults to nothing set", async () => { + const cfg = validateConfig({ version: 1, commands: { test: "bun test" } }); + expect(cfg.agents).toEqual({ sources: "all", teams: {}, all: { subagents: [], teams: [], skills: [], tools: [], mcp_tools: [] }, steps: {} }); + expect("mcp_tools" in cfg.claude).toBe(false); +}); + +test("agents settings are refused in plain words", () => { + const ok = { version: 1, commands: { test: "bun test" } }; + const problems = (agents: unknown) => { try { validateConfig({ ...ok, agents }); return []; } catch (e) { return (e as ConfigError).problems.map((p) => p.text); } }; + expect(problems({ steps: { reviews: {} } })[0]).toStartWith("agents.steps: unknown step reviews; the steps are intake, design,"); + expect(problems({ steps: { fix: { agent: "x" } } })).toEqual(["agents.steps.fix.agent: fix continues the build session and runs as the build's agent; set the agent on build"]); + expect(problems({ steps: { review: { tools: ["Edit"] } } })).toEqual(["agents.steps.review.tools.0: review is read-only; Edit would let it change files"]); + expect(problems({ steps: { review: { tools: ["Bash"] } } })).toEqual(["agents.steps.review.tools.0: review is read-only; a bare Bash rule would let it run anything"]); + expect(problems({ steps: { build: { tools: ["Bash(git push *)"] } } })).toEqual(["agents.steps.build.tools.0: only Loopstra commits, branches, merges and pushes; git push is not allowed"]); + expect(problems({ all: { tools: ["Write"] } })).toEqual(["agents.all.tools.0: these reach read-only steps too; Write would let them change files"]); + expect(problems({ all: { mcp_tools: ["mcp__x,Bash"] } })).toEqual(["agents.all.mcp_tools.0: must be an MCP rule, like mcp__github or mcp__github__get_issue"]); + expect(problems({ teams: { Reviewers: { members: ["a"] } } })).toEqual(["agents.teams.Reviewers: a team name is lowercase words joined by dashes"]); + expect(problems({ teams: { r: { members: [] } } })).toEqual(["agents.teams.r.members: a team needs at least one member"]); + expect(problems({ steps: { review: { teams: ["nope"] } } })).toEqual(["agents.steps.review.teams.0: there is no team nope under agents.teams"]); + expect(problems({ sources: "mine" })).toEqual(["agents.sources: must be all or repo"]); + // Narrow commands and read-only git are fine anywhere; Bash(git diff *) is not history-changing. + expect(problems({ steps: { review: { tools: ["Bash(npm run e2e *)", "Bash(git diff *)", "WebFetch"] } } })).toEqual([]); +}); +``` + +Also update the existing `claude.mcp_tools` assertions in this file to `agents.all.mcp_tools` (same three refused entries, same message). + +- [ ] **Step 2: Run them to see them fail** + +Run: `bun test tests/unit/config.test.ts` +Expected: FAIL (`cfg.agents` undefined). + +- [ ] **Step 3: Implement** + +In `src/agents.ts`, `toolProblem(rule, readOnly)`: +- `Bash(git ...)` or `Bash(git)`/`Bash(git *)` where `` is in `commit push merge rebase reset checkout switch branch tag stash` or absent → `only Loopstra commits, branches, merges and pushes; git is not allowed` (`` is `*` text as written for a bare git rule: use "git" alone → `git is not allowed`). +- When `readOnly`: `Edit`/`Write`/`NotebookEdit` (with or without a `(...)` specifier) → ` would let it change files`; `Bash` or `Bash(*)` → `a bare Bash rule would let it run anything`. +- Otherwise null. The config layer prefixes `" is read-only; "` for a step and `"these reach read-only steps too; "` for `all` (with `readOnly: true`). For build steps only the git check applies. + +In `src/config.ts`: a `stepAgents` Zod object (`.strict()`, lists `.default([])`, `agent` optional trimmed non-empty), `agents` object (`sources` enum with error `must be all or repo`, `teams` record with name check via `superRefine`, `all`, `steps` as a record checked against `STEP_KEYS` with the "unknown step" message listing `STEP_KEYS` joined by `, `), and a `superRefine` on `agents` for: `agent` on `CONTINUES_BUILD`, `toolProblem` per `tools` entry, team references. Reuse the existing `mcp_tools` regex and message for both `all.mcp_tools` and `steps.*.mcp_tools`. `prefault({})` the whole block. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/unit/config.test.ts && bun run typecheck` +Expected: PASS. Typecheck then fails where `cfg.claude.mcp_tools` is read (`toolsFor` in `src/phases.ts`): make `toolsFor` return the set's own tools only (Task 6 adds the setup's), and delete the mcp test in `tests/unit/phases.test.ts` (Task 6 brings it back on `agents.all.mcp_tools`). + +- [ ] **Step 5: Commit** + +```bash +git add src/agents.ts src/config.ts src/phases.ts tests/unit/config.test.ts tests/unit/phases.test.ts +git commit -m "feat(config): agents block for per-step agent setup" +``` + +--- + +### Task 2: Layering and prompt lines + +**Files:** +- Modify: `src/agents.ts` +- Test: `tests/unit/agents.test.ts` (new) + +**Interfaces:** +- Consumes: `Config["agents"]`, `StepAgents`, `CHAT_STEPS`, `CONTINUES_BUILD` (Task 1). +- Produces: + - `export interface Team { name: string; members: string[]; way: TeamWay }` + - `export interface AgentSetup { sources: "all" | "repo"; agent: string | null; subagents: string[]; teams: Team[]; skills: string[]; tools: string[]; mcpTools: string[] }` + - `export function resolveSetup(agents: Config["agents"], step: StepKey, stageSkills: readonly string[]): AgentSetup` + - `export function isEmptySetup(s: AgentSetup): boolean`: no agent, subagents, teams, skills, tools, mcpTools, and `sources: "all"`. + - `export function inlineName(name: string): string`: `loopstra-` + name with `:` → `-`. + - `export function setupLines(s: AgentSetup, describe: (name: string) => string | null): string`: the text put before the prompt template; `""` when there are no skills, subagents or teams. + +- [ ] **Step 1: Write the failing tests** + +```ts +const agents = (over: object = {}) => validateConfig({ version: 1, commands: { test: "t" }, agents: over }).agents; + +test("all, then stage skills, then the step's own; each name once", () => { + const a = agents({ all: { skills: ["house"], mcp_tools: ["mcp__docs"] }, steps: { review: { skills: ["threat", "house"], subagents: ["Explore"], agent: "lead" } } }); + expect(resolveSetup(a, "review", ["stage-skill"])).toEqual({ sources: "all", agent: "lead", subagents: ["Explore"], teams: [], skills: ["house", "stage-skill", "threat"], tools: [], mcpTools: ["mcp__docs"] }); +}); + +test("fix, reconcile and revise run as the build's agent; chat steps never get all", () => { + const a = agents({ all: { skills: ["house"], tools: ["WebFetch"] }, steps: { build: { agent: "senior" }, orchestrator: { skills: ["chat-tone"] } } }); + expect(resolveSetup(a, "fix", []).agent).toBe("senior"); + expect(resolveSetup(a, "revise", []).skills).toEqual(["house"]); + expect(resolveSetup(a, "orchestrator", [])).toMatchObject({ agent: null, skills: ["chat-tone"], tools: [] }); +}); + +test("teams resolve to their members; an empty setup is empty", () => { + const a = agents({ teams: { reviewers: { members: ["sec", "perf"], way: "parallel" } }, steps: { review: { teams: ["reviewers"] } } }); + expect(resolveSetup(a, "review", []).teams).toEqual([{ name: "reviewers", members: ["sec", "perf"], way: "parallel" }]); + expect(isEmptySetup(resolveSetup(agents(), "build", []))).toBe(true); + expect(isEmptySetup(resolveSetup(agents({ sources: "repo" }), "build", []))).toBe(false); +}); + +test("prompt lines: skills as today, then subagents and teams by their inline names", () => { + const s = { sources: "all", agent: null, subagents: ["Explore", "test-writer"], teams: [{ name: "reviewers", members: ["sec", "perf"], way: "parallel" }], skills: ["a", "b"], tools: [], mcpTools: [] } as AgentSetup; + const lines = setupLines(s, (n) => (n === "test-writer" ? "Writes tests" : null)); + expect(lines).toStartWith("Use these skills: `a`, `b`.\n\n"); + expect(lines).toContain("`Explore`"); // built-ins keep their own name + expect(lines).toContain("`loopstra-test-writer` (Writes tests)"); + expect(lines).toContain("`loopstra-sec`, `loopstra-perf`"); + expect(lines).toContain("same task"); // parallel wording + expect(lines).toContain("structured output"); // the session still reports itself + expect(lines).toEndWith("\n\n"); + expect(setupLines({ ...s, subagents: [], teams: [] }, () => null)).toBe("Use these skills: `a`, `b`.\n\n"); +}); +``` + +- [ ] **Step 2: Run to see them fail** + +Run: `bun test tests/unit/agents.test.ts` +Expected: FAIL (`resolveSetup` not exported). + +- [ ] **Step 3: Implement** in `src/agents.ts`. + +`setupLines` needs to know built-ins: define `export const BUILTIN_AGENTS: ReadonlySet = new Set(["general-purpose", "Explore", "Plan", "claude", "statusline-setup"]);` here in `src/agents.ts` (`src/agent-files.ts` imports from this file, never the other way). The skills line is exactly today's (`Use these skills: \`a\`, \`b\`.\n\n`). Wording for subagents and teams is yours; it must name each agent by its inline name (built-ins by their own), give the description when `describe` returns one, say for `parallel` that every member gets the same task at the same time and the session weighs their answers, for `pick` that each piece of work goes to the member that fits it, and end with one sentence that the session still returns its own structured output. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/unit/agents.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents.ts tests/unit/agents.test.ts +git commit -m "feat(agents): layer each step's agent setup and its prompt lines" +``` + +--- + +### Task 3: Finding and parsing definitions, inline agents + +**Files:** +- Modify: `src/agent-files.ts` +- Test: `tests/unit/agent-files.test.ts` (new) + +**Interfaces:** +- Consumes: `AgentSetup`, `inlineName` (Task 2). +- Consumes also: `BUILTIN_AGENTS` (Task 2, `src/agents.ts`). +- Produces: + - `export interface AgentDefinition { name: string; description: string; prompt: string; tools: string[] | null; model: string | null; skills: string[] | null; dropped: string[]; from: "repo" | "personal" | "plugin"; path: string }` + - `export function parseAgentFile(name: string, text: string, from: AgentDefinition["from"], path: string): AgentDefinition` (frontmatter via `yaml`; `tools` accepts a comma-separated string or a list; keys other than `name description tools model skills color` go to `dropped`, in file order) + - `export interface Where { repo: string; home: string; sources: "all" | "repo" }` (`repo`: a checkout of main: the step's snapshot, or the root) + - `export async function findAgent(name: string, where: Where): Promise`: `BUILTIN_AGENTS` → null is not used for them (callers skip built-ins); order: `/.claude/agents/.md`; with `sources: "all"`: `/.claude/agents/.md`, then for `plugin:agent` the plugin's `agents/.md` (see Step 3). + - `export async function findSkill(name: string, where: Where): Promise<"repo" | "personal" | "plugin" | null>`: same order, `skills//SKILL.md`. + - `export function inlineAgents(main: AgentDefinition | null, others: AgentDefinition[]): Record`: keys by `inlineName`; `main`'s `tools`, when not null, gain `StructuredOutput` if missing; `model` only for `others` (a main agent runs on the step's model); `skills` copied when set. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("an agent file parses with CRLF, a tools string, and unknown keys dropped", () => { + const d = parseAgentFile("lead", "---\r\nname: lead\r\ndescription: Leads\r\ntools: Read, Grep\r\nmodel: opus\r\npermissionMode: bypassPermissions\r\nhooks: {}\r\n---\r\nYou lead.\r\n", "repo", "/r/.claude/agents/lead.md"); + expect(d).toMatchObject({ description: "Leads", prompt: "You lead.", tools: ["Read", "Grep"], model: "opus", dropped: ["permissionMode", "hooks"] }); + expect(parseAgentFile("x", "Just a prompt.\n", "repo", "p")).toMatchObject({ description: "", prompt: "Just a prompt.", tools: null, model: null, dropped: [] }); +}); + +test("findAgent looks in the repo, then the user's folder, then installed and enabled plugins", async () => { + const t = tempDir(); const repo = join(t.path, "repo"); const home = join(t.path, "home"); + write(join(repo, ".claude/agents/lead.md"), "---\ndescription: repo lead\n---\nR"); + write(join(home, ".claude/agents/lead.md"), "---\ndescription: home lead\n---\nH"); + write(join(home, ".claude/agents/mine.md"), "---\ndescription: mine\n---\nM"); + const plug = join(home, "plugins-cache/sp"); + write(join(plug, "agents/code-reviewer.md"), "---\ndescription: cr\n---\nC"); + write(join(home, ".claude/plugins/installed_plugins.json"), JSON.stringify({ version: 2, plugins: { "sp@market": [{ scope: "user", installPath: plug }] } })); + write(join(home, ".claude/settings.json"), JSON.stringify({ enabledPlugins: { "sp@market": true } })); + const all = { repo, home, sources: "all" as const }; + expect((await findAgent("lead", all))?.description).toBe("repo lead"); + expect((await findAgent("mine", all))?.from).toBe("personal"); + expect((await findAgent("sp:code-reviewer", all))?.from).toBe("plugin"); + expect(await findAgent("mine", { ...all, sources: "repo" })).toBeNull(); + write(join(home, ".claude/settings.json"), JSON.stringify({ enabledPlugins: { "sp@market": false } })); + expect(await findAgent("sp:code-reviewer", all)).toBeNull(); + t.cleanup(); +}); + +test("inline agents: loopstra names, StructuredOutput for the main agent, its model left to the step", () => { + const lead = parseAgentFile("lead", "---\ndescription: L\ntools: [Read]\nmodel: opus\n---\nP", "repo", "p"); + const sec = parseAgentFile("sec", "---\ndescription: S\nmodel: haiku\n---\nQ", "repo", "p"); + expect(inlineAgents(lead, [sec])).toEqual({ + "loopstra-lead": { description: "L", prompt: "P", tools: ["Read", "StructuredOutput"] }, + "loopstra-sec": { description: "S", prompt: "Q", model: "haiku" }, + }); +}); +``` + +(`write` and `tempDir` come from `tests/helpers.ts`; add a two-line `write(path, text)` that makes the folder there if it has none.) + +- [ ] **Step 2: Run to see them fail** + +Run: `bun test tests/unit/agent-files.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +Plugins: read `/.claude/plugins/installed_plugins.json` (`plugins: Record<"@", Array<{ installPath: string }>>`) and `/.claude/settings.json` `enabledPlugins` (`Record`); a plugin counts when its key's value is `true`. For `plugin:agent` take the entry whose key starts with `@`, its first `installPath`. A file that does not exist or does not parse is "not there", never a throw. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/unit/agent-files.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agent-files.ts tests/unit/agent-files.test.ts tests/helpers.ts +git commit -m "feat(agents): find and parse agent and skill definitions, build inline agents" +``` + +--- + +### Task 4: Checking a whole config's names + +**Files:** +- Modify: `src/agent-files.ts` +- Test: `tests/unit/agent-files.test.ts` + +**Interfaces:** +- Consumes: `resolveSetup` (Task 2), `findAgent`, `findSkill` (Task 3), `STEPS`/`STEP_KEYS` (`src/models.ts`). +- Produces: + - `export interface SetupProblem { level: "fail" | "warn"; step: StepKey | "all"; name: string; text: string }` + - `export async function checkAgentSetup(cfg: Config, where: Where): Promise`: for every step, the resolved setup's agent, subagents and team members (skipping `BUILTIN_AGENTS`) and skills. + +Rules (one problem per name per step, steps in `STEP_KEYS` order): +- agent not found → `fail`: `` ``, the agent runs as, is not in .claude/agents/ on [ or on this machine]. Add it with `loopstra setup agents`, or commit it. `` +- subagent or team member not found → `fail`, same wording with "a subagent of " / "in the team of ". +- found only `personal` or `plugin` → `warn`: `` `` () is only on this machine, so a loop running elsewhere will not have it. `` +- a definition with `dropped` keys → `warn`: `` : Loopstra leaves out when it runs this agent. `` +- skill not found → `warn`: `` The skill `` () is not in .claude/skills/[ or on this machine]; it may be built in to Claude Code. If a session does not load it, the step stops and says so. `` +- `[ or on this machine]` appears only with `sources: all`. + +- [ ] **Step 1: Write the failing test** + +```ts +test("checkAgentSetup: missing agents fail, machine-only ones and unknown skills warn", async () => { + const t = tempDir(); const repo = join(t.path, "repo"); const home = join(t.path, "home"); + write(join(repo, ".claude/agents/lead.md"), "---\ndescription: L\nhooks: {}\n---\nP"); + write(join(home, ".claude/agents/mine.md"), "M"); + const cfg = validateConfig({ version: 1, commands: { test: "t" }, agents: { steps: { review: { agent: "lead", subagents: ["mine", "Explore", "gone"], skills: ["pdf"] } } } }); + const p = await checkAgentSetup(cfg, { repo, home, sources: "all" }); + expect(p.map((x) => [x.level, x.step, x.name])).toEqual([ + ["warn", "review", "lead"], ["warn", "review", "mine"], ["fail", "review", "gone"], ["warn", "review", "pdf"], + ]); + expect(p[2]!.text).toContain("Add it with `loopstra setup agents`"); + t.cleanup(); +}); +``` + +- [ ] **Step 2: Run to see it fail.** `bun test tests/unit/agent-files.test.ts` → FAIL. +- [ ] **Step 3: Implement `checkAgentSetup`.** +- [ ] **Step 4: Run tests.** → PASS. +- [ ] **Step 5: Commit** `feat(agents): check that every named agent and skill exists`. + +--- + +### Task 5: Session flags and the init check + +**Files:** +- Modify: `src/claude.ts` (`FailureReason`, `RunPhaseInput`, `claudeCommand`, `runSession`'s `onLine` and return) +- Modify: `src/phases.ts` (`ownerNote`, `BlockingReason` stays `Exclude` so it gains `"setup"`; `"setup"` is not in `RETRIED`) +- Test: `tests/unit/claude-run.test.ts` + +**Interfaces:** +- Produces on `RunPhaseInput`: + - `agents?: Record` → `--agents ` when it has keys + - `agent?: string` → `--agent ` + - `settingSources?: string` → `--setting-sources ` + - `addDirs?: string[]` → `--add-dir ` per dir + - `forwardSubagentText?: boolean` → `--forward-subagent-text` + - `checkInit?: (init: { agents?: string[]; skills?: string[] }) => string | null`: called with the first `system`/`init` event; a string ends the session: reading stops (`onLine` returns true), and the result is a failure with reason `"setup"` and that string as detail. +- `FailureReason` gains `"setup"`. `ownerNote("setup")`: `"This step's agent setup is not complete on this machine."` (callers append the detail). +- Flags go after `--permission-mode` and the tool lists, before `--settings`, in the order listed above; none appear when unset. + +- [ ] **Step 1: Write the failing tests** (fake CLI; follow the file's existing `LOOPSTRA_FAKE_ARGS` pattern) + +```ts +test("agent flags reach the CLI only when set", async () => { + // run once without them: none of the five flags in args + // run with { agents: { "loopstra-x": { description: "d", prompt: "p" } }, agent: "loopstra-x", settingSources: "project", addDirs: ["/snap"], forwardSubagentText: true } + expect(args).toEqual(expect.arrayContaining(["--agents", JSON.stringify({ "loopstra-x": { description: "d", prompt: "p" } }), "--agent", "loopstra-x", "--setting-sources", "project", "--add-dir", "/snap", "--forward-subagent-text"])); +}); + +test("an init event that lacks a named skill ends the session as a setup failure", async () => { + // fixture: init with skills: ["a"], agents: ["loopstra-x"], then a long tail of events (use an existing slow fixture, or LOOPSTRA_FAKE_DELAY_MS) + const r = await runPhase({ ...base, checkInit: (i) => (i.skills?.includes("b") ? null : "this session did not load the skill `b`") }); + expect(r).toMatchObject({ ok: false, reason: "setup", detail: "this session did not load the skill `b`" }); +}); + +test("an init event without lists is not judged", async () => { + // the default fixture's init has no agents or skills + const r = await runPhase({ ...base, checkInit: () => "would fail" }); + // checkInit is not called when both lists are absent + expect(r.ok).toBe(true); +}); +``` + +Add the fixture `tests/fake-claude/fixtures/init-lists.jsonl`: an init event with `"skills":["a"],"agents":["loopstra-x"]`, then the lines of `simple-success.jsonl`. + +- [ ] **Step 2: Run to see them fail.** `bun test tests/unit/claude-run.test.ts` → FAIL. +- [ ] **Step 3: Implement.** In `runSession`, keep a `setupProblem: string | null`; in `onLine`, for the first `system`/`init` event that has `agents` or `skills`, call `checkInit`; a string sets `setupProblem` and returns `true`. After the loop, `setupProblem` wins over every other outcome except a stop or cancel: `return failure(collector.finish(), { started, nudged }, "setup", setupProblem)`. `checkInit` is not called on resent processes (`--resume` sends) after the first passed. +- [ ] **Step 4: Run tests.** `bun test tests/unit/claude-run.test.ts && bun run typecheck` → PASS. +- [ ] **Step 5: Commit** `feat(claude): agent flags and the init check, with failure reason setup`. + +--- + +### Task 6: Wire the setup into loop sessions + +**Files:** +- Modify: `src/models.ts` (`withModels` carries `agents`; `ModelSettings` = `Pick`; its doc says model and agent settings) +- Modify: `src/context.ts` (`models()` doc: model and agent settings) +- Modify: `src/agent-files.ts` (`userSettingsPassthrough`) +- Modify: `src/phases.ts` (`toolsFor`, `phasePrompt`, `attempt`) +- Test: `tests/unit/phases.test.ts` + +**Interfaces:** +- Consumes: Tasks 2-5. +- Produces: + - `export function userSettingsPassthrough(home: string): { apiKeyHelper?: string; env?: Record }`: those two keys from `/.claude/settings.json`, when present (with `sources: repo`, `--setting-sources project` would drop them, and a machine that signs in with them could not start sessions). + - `export async function stepSetup(ctx: StepContext, spec: AgentPhaseSpec): Promise` in `src/phases.ts`: `resolveSetup((await ctx.models()).agents, spec.name as StepKey, spec.skills ?? [])`. (Every `PhaseName` used with `agentPhase` is a `StepKey`; assert with a type guard, not a cast, and fall back to an empty setup for any that is not.) + - `toolsFor(ctx, set, setup?: AgentSetup)`: the set's own tools, then `setup.tools`, then `setup.mcpTools`, each once. + +`attempt` gets the setup once per attempt and, when `isEmptySetup(setup)`, passes nothing new (today's argv). Otherwise: +1. `allowedTools: toolsFor(ctx, spec.tools, setup)`. +2. `where = { repo: await ctx.snapshot(), home: os.homedir(), sources: setup.sources }`. Definitions: main agent via `findAgent(setup.agent)`; subagents and team members via `findAgent` (built-ins skipped). Any non-built-in not found → return `{ ok: false, reason: "setup", note: " ", sessionId: null }` before starting a session (trace an `error` event `{ where: traceName, reason: "setup", detail }` as `missing-prompt` does). +3. `agents: inlineAgents(main, others)` when any definition; `agent: inlineName(setup.agent)` when there is a main agent. +4. `settingSources: "project"` and settings `JSON.stringify({ ...JSON.parse(hookSettings()), ...userSettingsPassthrough(home) })` when `sources === "repo"`. +5. `addDirs: [snapshot]` when `cwd !== snapshot` and `setup.skills.length`. +6. `forwardSubagentText: true` when subagents or teams. +7. `checkInit`: every expected inline agent name (and built-in) must be in `init.agents` when that list is present; every skill in `init.skills` when present (a skill counts if listed as `name` or `:name`); the problem names what is missing and says `` Add it with `loopstra setup agents`, then retry. `` +8. After `startPhase`, `ctx.trace.event(ctx.slug, "agent_setup", { agent, subagents, teams: teams.map((t) => t.name), skills, tools, mcp_tools: mcpTools, sources, models: { : model } }, seq)`, only when not empty. +9. `phasePrompt` uses `setupLines(setup, describe)` in place of today's `skillsLine`. `describe` is synchronous: `phasePrompt` first looks up the subagents' and team members' definitions with `findAgent` (same `where` as above) and passes `(n) => found.get(n)?.description || null`. With an empty setup `setupLines` returns today's skills line, so prompts do not change. +10. A failed `"setup"` attempt's note is `` `${ownerNote("setup")} ${detail}` ``. + +- [ ] **Step 1: Write the failing tests** in `tests/unit/phases.test.ts` (use the file's `setup()` and `LOOPSTRA_FAKE_ARGS`) + +```ts +test("with nothing set, a session's args and prompt are what they were", async () => { + // run intake and build once with cfg.agents as validated from {} and once from { sources: "all", all: {}, steps: {} }: + // identical args arrays and identical prompt.md text; neither has --agents, --agent, --setting-sources, --add-dir, --forward-subagent-text +}); + +test("a step's agent, subagents, team and tools reach its session", async () => { + // repo: commit .claude/agents/lead.md (tools: Read), sec.md, perf.md on main + // cfg.agents = { teams: { reviewers: { members: ["sec", "perf"], way: "parallel" } }, steps: { review: { agent: "lead", teams: ["reviewers"], tools: ["WebFetch"], mcp_tools: ["mcp__gh"] } } } + // run the review phase (tools "read+git", cwd: the worktree) + expect(args[args.indexOf("--agent") + 1]).toBe("loopstra-lead"); + expect(Object.keys(JSON.parse(args[args.indexOf("--agents") + 1]))).toEqual(["loopstra-lead", "loopstra-sec", "loopstra-perf"]); + expect(JSON.parse(args[args.indexOf("--agents") + 1])["loopstra-lead"].tools).toEqual(["Read", "StructuredOutput"]); + expect(allowed).toEqual(expect.arrayContaining(["WebFetch", "mcp__gh"])); + expect(args).toContain("--forward-subagent-text"); + expect(prompt).toContain("`loopstra-sec`, `loopstra-perf`"); +}); + +test("agent files come from main, not from the change's branch", async () => { + // main: .claude/agents/lead.md prompt "MAIN"; the worktree branch commits lead.md with prompt "BRANCH" + // build-stage step in the worktree: the inline lead prompt is "MAIN" +}); + +test("a named agent nobody has stops the step before a session starts", async () => { + // steps: { design: { agent: "ghost" } } → result { ok: false, reason: "setup" }, note mentions `ghost` and `loopstra setup agents`; no fake call recorded +}); + +test("repo-only sessions load project settings and keep the user's sign-in", async () => { + // a temp home with .claude/settings.json { apiKeyHelper: "/bin/key", env: { A: "1" }, theme: "x" }; the code reads os.homedir(), + // so set process.env.HOME and process.env.USERPROFILE to it for this test and restore them after + expect(args[args.indexOf("--setting-sources") + 1]).toBe("project"); + expect(JSON.parse(args[args.indexOf("--settings") + 1])).toMatchObject({ apiKeyHelper: "/bin/key", env: { A: "1" }, hooks: expect.any(Object) }); + expect(JSON.parse(args[args.indexOf("--settings") + 1]).theme).toBeUndefined(); +}); + +test("a worktree step that names skills also gets main's snapshot as an added dir", async () => { + // all: { skills: ["house"] } → build session args contain --add-dir ; a snapshot-run step (design) does not +}); + +test("mcp rules from agents.all reach every tool set, once each", async () => { + // the former claude.mcp_tools test, moved: ctx.cfg.agents.all.mcp_tools = ["mcp__github", "mcp__docs__guide", "mcp__github"] +}); +``` + +Fill each with the setup and run calls the neighbouring tests in this file use; the assertions above are the required ones. + +- [ ] **Step 2: Run to see them fail.** `bun test tests/unit/phases.test.ts` → FAIL. +- [ ] **Step 3: Implement** as listed above. +- [ ] **Step 4: Run tests.** `bun test tests/unit/phases.test.ts && bun run check` → PASS (the whole suite: stage tests must be untouched by an empty setup). +- [ ] **Step 5: Commit** `feat(phases): run each step with its agent setup`. + +--- + +### Task 7: Chat steps + +**Files:** +- Modify: `src/chat/agents.ts` (`runChatAgent`) +- Modify: `src/chat/orchestrator.ts` (the failure reply around line 277) +- Test: the test file that covers `runChatAgent` (find it with `grep -l runChatAgent tests/unit`) + +**Interfaces:** +- Consumes: `resolveSetup` (it already leaves `all` out of chat steps), `findAgent`, `inlineAgents`, `inlineName`, `setupLines`, `userSettingsPassthrough`. +- Behaviour: + - Setup for `o.name`; definitions with `where = { repo: o.root, home: os.homedir(), sources }` (chat runs in the main checkout). + - `allowedTools: [...CHAT_TOOLS, ...setup.tools, ...setup.mcpTools]` (only the chat step's own entries, by `resolveSetup`); `disallowedTools: CHAT_DENIED` unchanged; `dropEnv` unchanged. + - Same flags and `checkInit` as Task 6; prompt lines prepended to `o.prompt` when not empty. + - A missing definition or a `setup` failure: `{ ok: false, reason: "setup", detail }` with no session (or the init check's detail). The orchestrator replies: `` My setup is not complete: ``. Write-intent's failure path shows the same detail (follow how it shows other failures). + +- [ ] **Step 1: Write the failing tests** + +```ts +test("a chat step gets only its own tools; agents.all never reaches it", async () => { + // cfg.agents = { all: { tools: ["WebFetch"], mcp_tools: ["mcp__x"] }, steps: { orchestrator: { skills: ["tone"], mcp_tools: ["mcp__docs"] } } } + // allowed = CHAT_TOOLS + mcp__docs; no WebFetch, no mcp__x; disallowed is CHAT_DENIED; prompt starts with "Use these skills: `tone`." +}); + +test("a chat agent that cannot be found is answered in words", async () => { + // steps: { orchestrator: { agent: "ghost" } } → the orchestrator's reply starts with "My setup is not complete:" and names `ghost` +}); +``` + +- [ ] **Step 2: Run to see them fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run tests.** `bun test tests/unit/chat-*.test.ts` → PASS. +- [ ] **Step 5: Commit** `feat(chat): agent setup for the orchestrator and the intent writer`. + +--- + +### Task 8: `start` refuses while a named agent is missing + +**Files:** +- Modify: `src/scheduler.ts` (`start`, before `takeLoopLock`) +- Test: the test file covering `start` refusals (`grep -l "await start(" tests`) + +**Interfaces:** +- Consumes: `checkAgentSetup` (Task 4). +- Behaviour: load the config; when `agents` is not empty, make a detached worktree of `main_branch` in `.loopstra/snapshots/_start` (reuse `addDetachedWorktree`/`removeWorktree` from `src/git.ts`, removed in a `finally`), run `checkAgentSetup` with it as `repo`. Any `fail` → return `"Some steps name agents that are not set up:\n" + fails.map((p) => `- ${p.text}`).join("\n")`. `warn`s are printed with `log` and do not refuse. A config that does not load is left to the existing handling (no new refusal). + +- [ ] **Step 1: Write the failing test** + +```ts +test("start refuses while a step names an agent that main does not have", async () => { + // config with agents.steps.design.agent: "ghost", committed on main + const refused = await start(repo.path, { once: true, installSignals: false }); + expect(refused).toStartWith("Some steps name agents that are not set up:\n- `ghost`, the agent design runs as"); +}); +``` + +- [ ] **Step 2: Run to see it fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run tests.** → PASS. +- [ ] **Step 5: Commit** `feat(start): refuse while a step names a missing agent`. + +--- + +### Task 9: Templates, docs, and a live check + +**Files:** +- Modify: `templates/config.yaml` (replace the commented `mcp_tools` lines under `claude:` with a commented `agents:` block: `sources`, a `teams` example, `all.mcp_tools`, one `steps.review` example; every line commented out) +- Modify: `templates/skill/SKILL.md` (Onboard step 5 writes `agents.all.mcp_tools` under `agents:`; step 2's mention of `claude.mcp_tools` becomes `agents.all.mcp_tools`) +- Modify: `README.md` (config section: replace the `mcp_tools` sentence under `claude` with an `agents` bullet: sources, teams, all, steps; the safety rules; chat's rule; fix/reconcile/revise; the `setup` failure) +- Modify: `docs/decisions.md` (new section "Agent setup for every step (2026-10-26)": the CLI facts above, `claude.mcp_tools` moved before release, definitions from main, inline names, `StructuredOutput`, skills by `--add-dir`, the sign-in passthrough, file check warns for skills) +- Test: `tests/unit/templates*.test.ts` stay green. + +- [ ] **Step 1: Edit the four files.** +- [ ] **Step 2: Run** `bun run check` → all green. +- [ ] **Step 3: Live check with the real CLI** in a scratch repo (`loopstra init`, a committed `.claude/agents/lead.md` with `tools: Read`, `agents.steps.intake.agent: lead`, `agents.steps.build.agent: lead` and `subagents: [Explore]`, one accepted intent, `loopstra start --once` until build has run and a fix has resumed if one happens). Record in `docs/decisions.md`: the intake envelope parsed (StructuredOutput through an inline agent), the build's `agent_setup` trace event, whether `--resume` with the same `--agent` kept the session id. If resume with `--agent` fails, stop and report it before changing anything. +- [ ] **Step 4: Commit** `docs: agent setup in the config template, skill, README and decisions`. diff --git a/docs/superpowers/plans/2026-10-27-agent-setup-2-setup.md b/docs/superpowers/plans/2026-10-27-agent-setup-2-setup.md new file mode 100644 index 00000000..18ae185a --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-agent-setup-2-setup.md @@ -0,0 +1,249 @@ +# Agent Setup, Part 2: Setup Section and the Agent Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `loopstra setup agents` lets an engineer choose where sessions get their setup from, set each step's agent, subagents, teams, skills, tools and MCP tools, and add skills and agents the repo lacks (copied from this machine or a plugin, fetched from a git repository or folder, or a new agent from a short form); `loopstra setup --check` reports missing names. + +**Architecture:** A new library module (`src/agent-library.ts`) lists what is available (repo, personal, plugins), copies a skill folder or agent file into the repo, scans a fetched folder, clones a git source shallowly into a temporary folder, and writes a new agent file. It returns files to write; it never writes the repo itself, so the console (Part 3) can reuse it through its request files. The setup runner gains pending file writes, written only when the config saves. The new `agents` section asks the questions with the existing `Prompt` and `ConfigDocument`. + +**Tech Stack:** Bun, TypeScript, Zod 4, `yaml`, bun:test; setup test helpers in `tests/setup-helpers.ts` (`scripted`, `configRepo`, `askSection`, `checkSection`). + +**Spec:** `docs/superpowers/specs/2026-10-26-agent-setup-design.md` ("Getting skills and agents", "Setup (`loopstra setup agents`)", "Checks"). Part 1 (`docs/superpowers/plans/2026-10-26-agent-setup-1-runtime.md`) is built on this branch. + +## Global Constraints + +- The `agents` section runs in a full `loopstra setup` only after a yes to `Set up agents for the loop's steps now?` (suggestion: no). Run alone (`loopstra setup agents`) it skips that question. Under `--defaults` it changes nothing. +- Setup never commits. Nothing (config or files) is written when setup stops or fails; files are written only after the config saved (or was unchanged and files were added), then it says to commit `loopstra/config.yaml` and `.claude/` on main. +- Files written only under `/.claude/agents/` and `/.claude/skills/`; every path is checked to stay inside them (no `..`, no absolute paths, no symlinks followed out of a source). +- Downloaded and copied content is data: nothing in it runs; a git source is cloned with `git clone --depth 1 -- ` into a new temporary folder that is removed afterwards; a URL starting with `-` is refused; before anything is written, the person sees the list of files to be written. +- A copied skill or agent records where it came from: a skill folder gets `SOURCE.md` (one line: `Copied from [ ()] on .`); an agent file gets a `source:` frontmatter key. `source` is added to `parseAgentFile`'s known keys (not dropped, not passed inline). +- Limits on a copy: at most 200 files and 5 MB per skill folder; larger is refused in words. +- Names: an agent or skill name is lowercase letters, digits and dashes (`/^[a-z0-9][a-z0-9-]*$/`); a plugin item keeps its `plugin:name` when used from the plugin, and gets its plain `name` when copied into the repo. +- Answers: `-` empties an optional answer (as in the other sections); lists are typed comma-separated or picked with `pickMany`. +- Copy follows the codebase: plain sentences; the other sections' question style ("Model for the design stage"). + +## Review Focus + +1. A git source with a skill whose folder holds a symlink pointing outside the clone → the link is not followed; the copy is refused naming the file. (Task 1.) +2. A config with `agents.steps.review.agent: lead` where `.claude/agents/lead.md` is only in the working tree, not committed → `--check` reads the working tree at `` (setup edits that tree, and its save message says to commit), so it passes; `loopstra start` (Part 1) checks main and refuses until it is committed. Pinned: `--check` reads ``. (Task 4.) +3. The person adds a skill, then stops setup (Ctrl-C, `SetupStopped`) → no file is written. (Task 2.) +4. Picking "none" for a step that had an agent removes `agents.steps..agent`, and a step left with no settings is removed from `agents.steps` entirely (no empty `{}` left in the file). (Task 3.) +5. A plugin skill copied into the repo whose name collides with an existing repo skill → refused unless the person confirms overwriting; the existing folder is replaced whole, not merged. (Task 1 for the function, Task 3 for the question.) + +--- + +## File structure + +- Create `src/agent-library.ts`: `LibraryItem`, `FileWrite`, `listLibrary`, `copyItem`, `scanFolder`, `cloneSource`, `newAgentFile`, `repoPath`. +- Modify `src/agent-files.ts`: `source` is a known frontmatter key. +- Modify `src/setup/types.ts`: `SetupContext.files` (pending writes) and `SetupContext.only`. +- Modify `src/setup/index.ts`: create `files`, set `only`, write files after save, say so. +- Create `src/setup/sections/agents.ts`; modify `src/setup/sections/index.ts` (last in `SECTIONS`). +- Modify `tests/setup-helpers.ts` (`askSection` writes pending files, accepts `only`). +- Tests: `tests/unit/agent-library.test.ts` (new), `tests/unit/setup-agents.test.ts` (new), `tests/unit/setup.test.ts` or the file covering `setup()` (find with `grep -l "await setup(" tests`). +- Docs: `README.md` (setup paragraph), `docs/decisions.md` (Part 2 bullets). + +--- + +### Task 1: The agent library + +**Files:** +- Create: `src/agent-library.ts` +- Modify: `src/agent-files.ts` (known keys) +- Test: `tests/unit/agent-library.test.ts` + +**Interfaces:** +- Consumes: `findAgent`, `findSkill`, `parseAgentFile`, `Where`, `userHome` (src/agent-files.ts); plugin listing reads the same `installed_plugins.json` and `enabledPlugins` as `findAgent` (reuse its private helpers by exporting one function `enabledPlugins(home): Array<{ plugin: string; installPath: string }>` from src/agent-files.ts). +- Produces: + - `export interface LibraryItem { kind: "agent" | "skill"; name: string; description: string; from: "repo" | "personal" | "plugin" | "source"; path: string }` (`path`: the agent file, or the skill folder; `name` is `plugin:name` for plugin items) + - `export interface FileWrite { path: string; content: string | null }` (`path` relative to the repo root, `/`-separated; `null` removes) + - `export function listLibrary(root: string, home: string, sources: "all" | "repo"): LibraryItem[]`: repo items, then (with `all`) personal, then enabled plugins' items; sorted by kind then name within each group. + - `export function scanFolder(dir: string): LibraryItem[]` (`from: "source"`): every `SKILL.md` (its folder is the skill; name from frontmatter `name`, else the folder name) and every `*.md` directly in a folder named `agents` (name from frontmatter, else the file name); hidden folders other than `.claude` and `node_modules` are skipped. + - `export function copyItem(item: LibraryItem, o: { root: string; source?: string; today: string }): FileWrite[] | { refused: string }`: an agent → one write `.claude/agents/.md` (frontmatter gains `source: `); a skill → every file of the folder under `.claude/skills//`, plus `SOURCE.md`. Refusals: a symlink inside the folder; over 200 files or 5 MB; a name failing the name rule. Text files are read as UTF-8; a binary file (contains a NUL byte in its first 8000 bytes) is refused for now (`content` is a string). + - `export async function cloneSource(url: string): Promise<{ dir: string; cleanup: () => void } | { refused: string }>`: `url` starting with `-`, or not `https://`, `ssh://`, `git@`, `file://` or an existing local folder path → refused. A local folder is returned as is with a no-op cleanup. + - `export function newAgentFile(o: { name: string; description: string; prompt: string; tools: string[]; model: string | null }): FileWrite` + - `export function repoPath(p: string): string | null`: the normalized repo-relative path when it lies inside `.claude/agents/` or `.claude/skills/`, else null. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("listLibrary: repo, then personal, then enabled plugins", () => { + // repo: .claude/agents/lead.md, .claude/skills/house/SKILL.md; home: .claude/agents/mine.md, .claude/skills/tone/SKILL.md; + // plugin sp@m (enabled): agents/cr.md, skills/tdd/SKILL.md + expect(listLibrary(repo, home, "all").map((i) => `${i.from}:${i.kind}:${i.name}`)).toEqual([ + "repo:agent:lead", "repo:skill:house", "personal:agent:mine", "personal:skill:tone", "plugin:agent:sp:cr", "plugin:skill:sp:tdd", + ]); + expect(listLibrary(repo, home, "repo").map((i) => i.name)).toEqual(["lead", "house"]); +}); + +test("copyItem copies a skill folder whole, records its source, and refuses links and size", () => { + // personal skill tone/ with SKILL.md, scripts/run.sh, references/a.md + const w = copyItem(tone, { root: repo, source: "https://example.com/skills.git (tone)", today: "2026-10-27" }) as FileWrite[]; + expect(w.map((f) => f.path).sort()).toEqual([".claude/skills/tone/SKILL.md", ".claude/skills/tone/SOURCE.md", ".claude/skills/tone/references/a.md", ".claude/skills/tone/scripts/run.sh"]); + expect(w.find((f) => f.path.endsWith("SOURCE.md"))!.content).toBe("Copied from https://example.com/skills.git (tone) on 2026-10-27.\n"); + // symlinkSync(join(t, "outside.txt"), join(toneDir, "leak")) → { refused } naming "leak" + // 201 files → { refused } saying "at most 200 files" +}); + +test("copyItem gives a plugin agent its plain name and a source key", () => { + const w = copyItem(pluginCr, { root: repo, today: "2026-10-27" }) as FileWrite[]; + expect(w[0]!.path).toBe(".claude/agents/cr.md"); + expect(parseAgentFile("cr", w[0]!.content!, "repo", "p")).toMatchObject({ dropped: [] }); + expect(w[0]!.content).toContain("source: "); +}); + +test("scanFolder finds skills and agents in a fetched source", () => { + // dir: skills/a/SKILL.md, nested/b/SKILL.md (name: bee), agents/x.md, .git/agents/y.md (skipped), docs/agents.md (not in an agents folder) + expect(scanFolder(dir).map((i) => `${i.kind}:${i.name}`).sort()).toEqual(["agent:x", "skill:a", "skill:bee"]); +}); + +test("cloneSource refuses option-like and unknown URLs, and clones a file:// repo shallowly", async () => { + expect(await cloneSource("--upload-pack=x")).toEqual({ refused: expect.stringContaining("") }); + expect(await cloneSource("ftp://x")).toHaveProperty("refused"); + // a temp git repo with one commit: cloneSource(`file://${repo}`) → dir has its files; cleanup removes dir +}); + +test("repoPath keeps writes inside .claude/agents and .claude/skills", () => { + expect(repoPath(".claude/skills/a/SKILL.md")).toBe(".claude/skills/a/SKILL.md"); + expect(repoPath(".claude/agents/../settings.json")).toBeNull(); + expect(repoPath("/etc/passwd")).toBeNull(); + expect(repoPath(".claude\\agents\\x.md")).toBe(".claude/agents/x.md"); +}); + +test("newAgentFile writes frontmatter that parses back", () => { + const f = newAgentFile({ name: "lead", description: "Leads reviews", prompt: "You lead.", tools: ["Read", "Grep"], model: null }); + expect(f.path).toBe(".claude/agents/lead.md"); + expect(parseAgentFile("lead", f.content!, "repo", "p")).toMatchObject({ description: "Leads reviews", prompt: "You lead.", tools: ["Read", "Grep"], model: null, dropped: [] }); +}); +``` + +- [ ] **Step 2: Run to see them fail.** `bun test tests/unit/agent-library.test.ts` → FAIL (module missing). +- [ ] **Step 3: Implement.** `cloneSource` runs `git clone --depth 1 --quiet -- ` with `Bun.spawn` (no shell), a 2-minute timeout, `GIT_TERMINAL_PROMPT=0` in its env so it never waits for a password. Use `lstatSync` to detect symlinks. Frontmatter is written with `yaml`'s `stringify` so descriptions with `:` stay valid. +- [ ] **Step 4: Run tests.** → PASS; `bun run check` → PASS. +- [ ] **Step 5: Commit** `feat(agents): a library to list, copy, fetch and write skills and agents`. + +--- + +### Task 2: Pending file writes in setup + +**Files:** +- Modify: `src/setup/types.ts`, `src/setup/index.ts`, `tests/setup-helpers.ts` +- Test: the test file covering `setup()` + +**Interfaces:** +- Consumes: `FileWrite`, `repoPath` (Task 1). +- Produces: + - `SetupContext.files: FileWrite[]` (pending; a later write to the same path replaces an earlier one) and `SetupContext.only: boolean` (true when one section was named). + - `export function writeFiles(root: string, files: FileWrite[]): string[]` in `src/setup/index.ts` or a small `src/setup/files.ts`: writes each (via `writeFileAtomic`, making folders; `null` removes the file; a skill folder replaced whole is expressed by the section as removals plus writes), refuses (throws) any path `repoPath` rejects, returns the paths written. + - `runSetup`: after `doc.validate()` and `doc.save()`, when `files.length`, `writeFiles` and print `Wrote file(s) under .claude/: . Commit them with loopstra/config.yaml on the main branch.` When the config was unchanged and files were written, print that line instead of "No changes.". A section that throws, or `SetupStopped`, writes nothing. + - `askSection(section, root, answers, env, o?: { only?: boolean })` in tests/setup-helpers.ts writes pending files after `doc.save()` and returns them as `files: string[]` too. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("files a section adds are written after the config saves, and not at all when setup stops", async () => { + // a fake Section whose ask() pushes { path: ".claude/skills/a/SKILL.md", content: "x" } and sets one config value + // setup(root, { sections: [fake], prompt: scripted(...).prompt, interactive: true }) → 0; the file exists; output contains "Wrote 1 file under .claude/: .claude/skills/a/SKILL.md" + // a fake Section that pushes a file then throws SetupStopped → 1; the file does not exist + // a fake Section that pushes ".claude/../x" → not saved, output names the path +}); +``` + +- [ ] **Step 2: Run to see it fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run tests.** → PASS; `bun run check`. +- [ ] **Step 5: Commit** `feat(setup): sections can add files, written only when the config saves`. + +--- + +### Task 3: The `agents` section + +**Files:** +- Create: `src/setup/sections/agents.ts` +- Modify: `src/setup/sections/index.ts` (`SECTIONS` ends with `agents`) +- Test: `tests/unit/setup-agents.test.ts` + +**Interfaces:** +- Consumes: Tasks 1-2; `STEPS`, `STEP_KEYS` (src/models.ts); `TEAM_WAYS`, `CONTINUES_BUILD`, `CHAT_STEPS`, `BUILTIN_AGENTS` (src/agents.ts); `userHome`. +- Produces: `export const agents: Section` with `name: "agents"`, `title: "Agents"`, `covers: ["agents"]` (and `"agents.*"` paths: whatever `coveredBy` needs so a bad `agents` value can be fixed by this section). + +The questions, in order (wording is the implementer's except where quoted): +1. Unless `ctx.only`: `Set up agents for the loop's steps now?` (suggestion no). No → return. +2. `ctx.ask.say` one paragraph: what an agent, subagents, teams and skills are for a step, that picks reach the loop's sessions, that fix, reconcile and revise run as the build's agent, and that chat's two steps are steered by channel members. +3. `Where sessions get skills, agents, plugins and MCP servers` pick `["all", "repo"]` with one line each said first (`all`: the setup of whoever runs `loopstra start` plus the repo's; `repo`: only what the repository declares, the same on every machine). `doc.put(["agents","sources"], v, "all")`. +4. Say the library (`listLibrary(root, userHome(), sources)`): one line per item ` (): `, or "No agents or skills found yet." +5. Loop: `Which step to set up` pick from `["done", "all steps", "teams", ...STEP_KEYS]` (suggestion `done`). For a step (or all steps → path `agents.all`): + - agent (not for `fix`/`reconcile`/`revise`, and not offered built-ins): pick from `["none", "add one", ...agent names usable as a main agent]`, suggestion the current or `none`. + - subagents: `pickMany` over agent names including built-ins, plus a separate yes/no `Add an agent first?` that runs the add flow. + - teams: `pickMany` over team names (skip the question when there are none). + - skills: `pickMany` over skill names, with the same `Add a skill first?`. + - tools: `text` comma-separated, `-` empty, `check` = each rule through `toolProblem(rule, READ_ONLY_STEPS.has(step))` (for all steps: read-only true). + - MCP tools: `text` comma-separated, `-` empty, `check` = the mcp rule regex message. + - For chat steps, `say` first that anyone allowed to chat can steer these tools. + - Writes with `doc.put`/`doc.unset`; a step left with no settings is unset whole (`agents.steps.`); an empty `agents.steps`/`agents.all`/`agents` is not left behind as `{}` (unset it). +6. "teams": pick `["done", "new team", ...team names]`; a team: members `pickMany` (at least one), way pick `TEAM_WAYS`, or `delete` (refused while a step uses it: say which steps). +7. "add one" flow: pick `["from this machine", "from a git repository or folder", "a new agent"]` (skills: the first two only): + - this machine: pick from `listLibrary` items of that kind not from the repo → `copyItem`. + - git/folder: `text` URL or path → `cloneSource` → `scanFolder` → `pickMany` of items of that kind → `copyItem` each with `source: url`. + - new agent: name (`check`: the name rule and not taken), description, role text (one line), tools (comma-separated, `-` for the session's), model (`-` for the step's). + - Before adding, `say` the files that will be written; if a target already exists in the repo, `yesNo("Replace the existing ?", false)`; replacing a skill removes the old folder's files first (`content: null` writes for each existing file). + - Push to `ctx.files`; the new item is then listed in this run's picks (keep a run-local list of added names). + - `cleanup()` the clone in a `finally`. + +`check(ctx, cfg)`: `checkAgentSetup(cfg, { repo: ctx.root, home: userHome(), sources: cfg.agents.sources })` mapped to `Check { level: p.level, text: p.text }`; when there are no problems and agents are set, one `ok` line `Every agent the steps name is in place.`; when agents are not set, `[]`. + +- [ ] **Step 1: Write the failing tests** (with `askSection` and `scripted` answers; pickers take a number or the choice text, as the other section tests do) + +```ts +test("in a full setup the section asks first and changes nothing on no", async () => { + // configRepo(minimal); askSection(agents, root, ["n"]) → config text unchanged + // askSection(agents, root, "defaults") → unchanged +}); + +test("a step's agent, subagents and skills are written; none removes them; an emptied step is removed", async () => { + // repo .claude/agents/lead.md, .claude/skills/house/SKILL.md + // answers (only: true): sources all; step review; agent lead; subagents Explore; skills house; tools -; mcp -; step done + // → agents.steps.review = { agent: lead, subagents: [Explore], skills: [house] }; sources not written (default) + // second run: step review; agent none; subagents (none); skills (none); tools -; mcp -; done → no agents.steps.review in the file, no "steps: {}" +}); + +test("tools are refused in words on a read-only step", async () => { + // step review; ...; tools "Edit" → shown contains "review is read-only" and asks again; then "-" +}); + +test("adding a skill from this machine copies it into the repo after the config saves", async () => { + // home .claude/skills/tone/SKILL.md; answers: sources all; step build; agent none; subagents: (none) / add first? n; skills: add first? y → from this machine → tone → (shown lists .claude/skills/tone/SKILL.md, SOURCE.md); then pick tone; ...; done + // → files written: .claude/skills/tone/SKILL.md and SOURCE.md; agents.steps.build.skills = [tone] +}); + +test("a team is made and used, and cannot be deleted while used", async () => {}); + +test("check: missing agents fail, machine-only names warn, nothing set is silent", async () => {}); +``` + +- [ ] **Step 2: Run to see them fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run tests.** → PASS; `bun run check` (the full-setup tests in the setup test file still pass: their scripted answers now need one more `n` at the end for the agents question, or the setup under test passes `sections` explicitly; update those tests). +- [ ] **Step 5: Commit** `feat(setup): an agents section for each step's agent, subagents, teams, skills and tools`. + +--- + +### Task 4: `--check`, docs + +**Files:** +- Modify: `README.md` (the setup paragraph lists `agents`; one sentence on adding skills and agents from this machine, a git repository or folder, or a new agent; that setup never commits), `docs/decisions.md` (Part 2 bullets: files written only after save; copies are data, clone shallow into a temporary folder, `SOURCE.md` / `source:`; binary files refused for now; the section is last and asked for) +- Modify: `templates/skill/SKILL.md` Onboard step 2's section list includes `agents` +- Test: the setup `--check` test file + +- [ ] **Step 1: Write the failing test** + +```ts +test("setup --check lists a step's missing agent under the agents section and fails", async () => { + // config agents.steps.design.agent: ghost; setup(root, { check: true, output }) → 1; output has "FAIL `ghost`, the agent design runs as" and "To fix: ... loopstra setup agents" +}); +``` + +- [ ] **Step 2: Run to see it fail** (if Task 3's `check` already makes it pass, record that and keep the test). +- [ ] **Step 3: Edit the docs.** +- [ ] **Step 4:** `bun run check` → PASS. +- [ ] **Step 5: Commit** `docs: setup agents section`. diff --git a/docs/superpowers/plans/2026-10-27-agent-setup-3-console.md b/docs/superpowers/plans/2026-10-27-agent-setup-3-console.md new file mode 100644 index 00000000..5c62e686 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-agent-setup-3-console.md @@ -0,0 +1,200 @@ +# Agent Setup, Part 3: Console Agents Screen and Operator Skill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The console's Settings page gets an Agents section where a person sets each step's agent, subagents, teams, skills, tools and MCP tools, manages teams, and adds, edits or removes the repo's agents and skills; the loopstra operator skill gets an onboarding step that sets agents up with the engineer. + +**Architecture:** The console never writes the repository. Config changes reuse the `settings` request (extended to `agents.*` paths and list values). File changes are a new `agent-files` request: the console server turns an operation (copy from this machine, fetch from a git repository or folder, new agent, edit agent, remove) into file writes with `src/agent-library.ts` (Part 2), and the loop commits them through `commitRecord` under `_settings`, refusing if any file changed since the page read it. A read-only `/api/agents` view feeds the page. The page's logic lives in a pure `src/ui/agent-settings.ts` (tested like `src/ui/settings.ts`); the component is `src/ui/views/AgentSettings.tsx`, rendered inside the Settings view. + +**Tech Stack:** Bun, TypeScript, Preact (`preact/hooks`), the console's server and request plumbing (`src/console/*`, `src/requests.ts`, `src/apply.ts`, `src/record-draft.ts`), bun:test, `tests/console-helpers.ts`. + +**Spec:** `docs/superpowers/specs/2026-10-26-agent-setup-design.md` ("The console: an Agents settings screen", "The operator skill (onboarding)", "Observability"). Parts 1-2 are built on this branch. + +## Global Constraints + +- The console never writes the repo or `loopstra/config.yaml` itself: every change is a request the loop applies and commits (`loopstra(_settings): …`), as the models page does. +- Config writes: `agents.sources`, `agents.all.`, `agents.steps..`, `agents.teams..members`, `agents.teams..way`, and `agents.teams.` / `agents.steps.` set to null (remove). `` is one of `agent`, `subagents`, `teams`, `skills`, `tools`, `mcp_tools`. List fields take a list of strings (sent whole); `agent` and `way` and `sources` take a string; any field may be null (removed). After a write, empty `agents.steps.`, `agents.steps`, `agents.all`, `agents.teams` and `agents` maps are removed, not left as `{}`. +- Every write is checked twice, by the console before submitting and by the loop before committing: the config must still load (`validateConfig`), and the file's version must be the one the page read. +- File writes only under `.claude/agents/` and `.claude/skills/` (`repoPath`), text only, at most 200 files and 5 MB per request; each file's base version is the sha256 of its text (`configVersion`-style) or `"absent"`; any mismatch refuses the whole request with the stale wording. +- Removing an agent or skill a step or team uses is refused, naming the users (`agents.steps.review`, `team reviewers`). +- Commit messages: `loopstra(_settings): agents.steps.review.teams → [reviewers] by from the dashboard`; files: `loopstra(_settings): add .claude/agents/lead.md by from the dashboard` (`add`, `edit`, `remove`; several files: `add skill tone (3 files) by …`). +- Writes come only from the page (`jsonWrite`: POST, same Origin, JSON). Fetching a git source happens on the server, into a temporary folder removed after the answer; only `https://`, `ssh://`, `git@`, `file://` URLs or an existing folder (Part 2's `cloneSource`). +- The page keeps the Settings page's look (the tokens in `src/ui/theme.css`, `set-row`/`set-group` classes) and works at phone width. +- Chat steps show the warning that anyone allowed to chat can steer their tools, next to their tools and MCP fields. + +## Review Focus + +1. Two tabs: tab A adds a skill while tab B, holding the old version, edits a step's skills → B's write still lands (config version unchanged by a file-only write), and A's file write lands; a second file write from a tab that read the old file is refused as stale. (Tasks 1-2.) +2. A config edited by hand while the page is open (uncommitted) → every agents write is refused with the existing "commit loopstra/config.yaml first" wording, as model writes are. (Task 1.) +3. A git source URL that needs a password → the clone fails within its timeout (GIT_TERMINAL_PROMPT=0) and the page shows the reason; no temporary folder is left. (Task 3.) +4. Removing a step's last setting from the page leaves no `steps: {}` or `review: {}` in the file. (Task 1.) +5. A list value sent as a string, or with a non-string member, is refused with a 400 in words, before any request file is written. (Task 1.) + +--- + +## File structure + +- Modify `src/settings.ts` (paths, problems, list values, pruning), `src/requests.ts` (settings `value` union; new `agent-files` kind), `src/console/writes.ts` (parse both), `src/apply.ts` (apply `agent-files`; settings trace text for lists). +- Create `src/console/agents.ts` (the view and the operations → writes); modify `src/console/server.ts` (routes), `src/console/types.ts` (`AgentsView`, `AgentFilesOp`). +- Create `src/ui/agent-settings.ts` (pure helpers), `src/ui/views/AgentSettings.tsx`; modify `src/ui/views/Settings.tsx` (render it), `src/ui/requests.ts` (`sendAgentFiles`), `src/ui/theme.css` (only if a new class is needed). +- Modify `templates/skill/SKILL.md`, `README.md`, `docs/decisions.md`. +- Tests: `tests/unit/settings.test.ts` (or the file covering `src/settings.ts`), `tests/unit/apply*.test.ts` (whichever covers settings requests), `tests/unit/console-*.test.ts` (routes), `tests/unit/ui-logic.test.ts`. + +--- + +### Task 1: Agents settings through the `settings` request + +**Files:** `src/settings.ts`, `src/requests.ts`, `src/console/writes.ts`, `src/apply.ts`; tests for each. + +**Interfaces:** +- Produces: + - `type SettingValue = string | string[] | null` (export from `src/settings.ts`); the settings request's `value` is `z.union([z.string(), z.array(z.string()).max(64), z.null()])`. + - `settingPath(dotted)` accepts the agents paths in Global Constraints (step keys from `STEP_KEYS`; team names by the team-name rule from Part 1). + - `settingProblem(path, value: SettingValue)`: list fields need a list, scalar fields a string; `null` allowed for every agents path; model paths unchanged. + - `applySetting(file, text, path, value)`: lists written in flow style (`[a, b]`); after a set or unset, prune empty maps (Global Constraints); `doc.validate()` so config problems (a read-only step's `Edit`, a built-in main agent, an unknown team) come back as `problem` in words. + - The `settings` apply trace and toast text show a list as `[a, b]`. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("agents paths are writable settings; others are not", () => { + expect(settingPath("agents.steps.review.teams")).toEqual(["agents", "steps", "review", "teams"]); + expect(settingPath("agents.all.mcp_tools")).toEqual(["agents", "all", "mcp_tools"]); + expect(settingPath("agents.teams.reviewers.way")).toEqual(["agents", "teams", "reviewers", "way"]); + expect(settingPath("agents.steps.review")).toEqual(["agents", "steps", "review"]); + expect(settingPath("agents.steps.nope.agent")).toBeNull(); + expect(settingPath("agents.steps.review.model")).toBeNull(); + expect(settingPath("agents.teams.Bad.way")).toBeNull(); +}); + +test("list fields take lists, scalars take strings", () => { + expect(settingProblem(["agents", "steps", "review", "skills"], "house")).toBe("agents.steps.review.skills takes a list of names."); + expect(settingProblem(["agents", "steps", "review", "agent"], ["a"])).toBe("agents.steps.review.agent takes one name."); + expect(settingProblem(["agents", "steps", "review"], null)).toBeNull(); +}); + +test("applySetting writes flow lists and prunes empty maps", () => { + const base = "version: 1\ncommands:\n test: bun test\n"; + const a = applySetting("c.yaml", base, ["agents", "steps", "review", "skills"], ["a", "b"]); + expect(a.ok).toBe(true); + expect(parse((a as { text: string }).text).agents.steps.review.skills).toEqual(["a", "b"]); // `parse` from "yaml" + expect((a as { text: string }).text).toMatch(/skills: \[.*a.*b.*\]/); // flow style, one line + const b = applySetting("c.yaml", (a as { text: string }).text, ["agents", "steps", "review", "skills"], null); + expect(b.ok && b.text).toBe(base); + const c = applySetting("c.yaml", base, ["agents", "steps", "review", "tools"], ["Edit"]); + expect(c).toEqual({ ok: false, problem: expect.stringContaining("review is read-only") }); +}); + +test("the console refuses a malformed agents value before writing a request", async () => { + // POST /api/requests { kind: "settings", path: "agents.steps.review.skills", value: "house", version } → 400 "agents.steps.review.skills takes a list of names." + // value: ["a", 1] → 400; no file under .loopstra/requests +}); + +test("the loop commits an agents setting under _settings", async () => { + // submit { kind: settings, path: agents.steps.review.teams, value: ["reviewers"] } with agents.teams.reviewers defined → commit subject "loopstra(_settings): agents.steps.review.teams → [reviewers] by …" + // with an uncommitted config edit → refused with UNCOMMITTED_CONFIG +}); +``` + +- [ ] **Step 2: Run to see them fail.** +- [ ] **Step 3: Implement.** Pruning lives in `applySetting` (walk from the written path up while the map is empty and is under `agents`). +- [ ] **Step 4: Run tests**; `bun run check`. +- [ ] **Step 5: Commit** `feat(console): agents settings through settings requests`. + +--- + +### Task 2: The `agent-files` request + +**Files:** `src/requests.ts`, `src/console/writes.ts` (submit path from Task 3's route, not from `/api/requests`), `src/apply.ts`, `src/record-draft.ts` only if a records' `paths` need anything new; tests. + +**Interfaces:** +- Consumes: `FileWrite`, `repoPath` (Part 2). +- Produces: + - Request kind `agent-files`: `{ kind: "agent-files", what: string (≤ 200 chars, the commit's words: "add .claude/agents/lead.md"), writes: Array<{ path: string; content: string | null }> (1..200), base: Record (path → sha256 hex of the text the page/server read, or "absent") }`. + - `export function fileVersion(text: string | null): string` in `src/settings.ts`: `"absent"` for null, else the same hash as `configVersion`. + - `applyAgentFiles(root, cfg, trace, req)`: every path through `repoPath` (else refused); total content ≤ 5 MB; `commitRecord(root, main, SETTINGS_SLUG, \`${req.what} by ${byWhom(req)}\`, draft => { for each path: fileVersion(draft.read(path)) must equal base[path] (else stale); draft.write(path, content) }, { paths: writes.map(w => w.path), … })`. Outcome texts: `Saved: .` / `That change was not saved: ` / `STALE_SETTINGS`. + - A config-file write is never part of `agent-files` (refused if `loopstra/config.yaml` appears). + +- [ ] **Step 1: Write the failing tests** + +```ts +test("agent-files writes and removes files under .claude and commits them", async () => { + // repo on main with .claude/skills/old/SKILL.md; request writes .claude/agents/lead.md (base absent) and removes old/SKILL.md (base = its hash) + // → one commit "loopstra(_settings): add .claude/agents/lead.md by …" with both changes; the working tree matches +}); + +test("agent-files refuses paths outside .claude/agents and .claude/skills, and stale bases", async () => { + // writes ".claude/settings.json" → refused, nothing committed + // base for lead.md says absent but lead.md exists on main → STALE_SETTINGS, nothing committed +}); +``` + +- [ ] **Step 2-5:** fail → implement → pass (+ `bun run check`) → commit `feat(console): agent-files requests the loop commits`. + +--- + +### Task 3: The agents view and operations on the server + +**Files:** create `src/console/agents.ts`; modify `src/console/server.ts`, `src/console/types.ts`; test `tests/unit/console-agents.test.ts`. + +**Interfaces:** +- Consumes: `resolveSetup`, `READ_ONLY_STEPS`, `CHAT_STEPS`, `CONTINUES_BUILD`, `BUILTIN_AGENTS`, `TEAM_WAYS` (src/agents.ts); `checkAgentSetup`, `parseAgentFile`, `userHome` (src/agent-files.ts); `listLibrary`, `scanFolder`, `cloneSource`, `copyItem`, `newAgentFile` (src/agent-library.ts); `configVersion`, `fileVersion` (src/settings.ts); `STEPS` (src/models.ts). +- Produces (in `src/console/types.ts`; the page imports only types): + - `AgentsView { version: string | null; problem: string | null; sources: "all" | "repo"; steps: AgentStepRow[]; teams: Array<{ name: string; members: string[]; way: string; usedBy: string[] }>; library: Array; problems: Array<{ level: "fail" | "warn"; step: string; name: string; text: string }>; builtins: string[]; ways: string[] }` + - `AgentStepRow { key: string; what: string; readOnly: boolean; chat: boolean; continuesBuild: boolean; own: Partial>; fromAll: Partial>; fromStage: string[] /* stage skills */ }` plus one row with `key: "all"`. + - `LibraryItemView { kind: "agent" | "skill"; name: string; description: string; from: "repo" | "personal" | "plugin"; usedBy: string[]; base: string /* fileVersion of the agent file, or of SKILL.md */; editable: boolean /* repo agents */ ; text?: string /* a repo agent's file, for the edit form */ }` + - `AgentFilesOp = { op: "copy"; kind; name; from } | { op: "fetch"; url: string; names: string[]; kind } | { op: "new"; name; description; prompt; tools: string[]; model: string | null } | { op: "edit"; name; text: string; base: string } | { op: "remove"; kind; name }` + - Routes: `GET /api/agents` → `AgentsView`; `GET /api/agents/source?url=…` → `{ items: Array<{ kind; name; description }> }` or `{ error }` (clone, scan, cleanup); `POST /api/agents/files` (via `jsonWrite`) with an `AgentFilesOp` → builds `writes` and `base` from the working tree at `root` (the files' current text) and submits an `agent-files` request (202 `{ id, loop }`), or refuses (409/400) in words: a name in use (remove), a copy refused by `copyItem`, a target that exists for `new`/`copy`/`fetch` unless the op says `replace: true`. + +- [ ] **Step 1: Write the failing tests** (server-level, like the existing console route tests: start the console app on a temp repo, `fetch` with the page's Origin) + +```ts +test("GET /api/agents shows each step's own, inherited and stage values, the library and problems", async () => {}); +test("POST /api/agents/files copy submits an agent-files request with the skill's files and absent bases", async () => {}); +test("POST /api/agents/files remove is refused while a step uses the agent", async () => {}); +test("GET /api/agents/source lists what a file:// repository holds and leaves no temp folder", async () => {}); +test("a write without the page's Origin is refused (403)", async () => {}); +``` + +Fill each with the assertions their names state; the existing console route tests show the server setup. + +- [ ] **Step 2-5:** fail → implement → pass (+ `bun run check`) → commit `feat(console): agents view and file operations`. + +--- + +### Task 4: The Agents section on the Settings page + +**Files:** create `src/ui/agent-settings.ts`, `src/ui/views/AgentSettings.tsx`; modify `src/ui/views/Settings.tsx`, `src/ui/requests.ts`; test `tests/unit/ui-logic.test.ts`. + +**Interfaces:** +- Consumes: `AgentsView`, `AgentFilesOp` (Task 3); `sendSetting`/`cancelSetting` (src/ui/requests.ts), extended so `value` may be a list. +- Produces: + - `src/ui/agent-settings.ts` (pure): `effective(row: AgentStepRow, field: Field): { value: string | string[] | null; from: "own" | "all" | "stage" | "build" | null }` (fix/reconcile/revise agent shows `from: "build"`); `choicesFor(view, field, step): string[]` (agents for `agent` exclude built-ins; subagents include them; teams by name; skills from the library); `listText(v: string[]): string` (`"a, b"`, `"none"`); `parseList(text: string): string[]` (split on commas, trim, drop empties, keep order, each once). + - `sendAgentFiles(op: AgentFilesOp, onDone)` in `src/ui/requests.ts`, same shape as `sendSetting`. + - `AgentSettings` component: under the models, an "Agents" heading; a problems callout (fails first) when `problems` is not empty; a Sources picker; a table of rows (All steps first, then the steps in `STEPS` order): each field shows its effective value with a muted "from all steps" / "stage skill" / "as build" note, and an edit control (a `