Skip to content

senior-dev: a coding agent built into codeaf takes a whole task, in the folder itself - #1488

Merged
AbirAbbas merged 205 commits into
devfrom
zeropoint95/senior-dev-harness
Sep 26, 2026
Merged

AbirAbbas merged 205 commits into
devfrom
zeropoint95/senior-dev-harness

Conversation

@ZeroPoint95

@ZeroPoint95 ZeroPoint95 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Draft: not ready to merge. The test of the chat's routing against a real model has not been run yet.

How /senior-dev works

  • It ships inside codeaf. senior-dev (formerly swe-pro-go) is compiled into the codeaf binary, so there is nothing to install and no separate key to set. That is why the diff is so large: about 69k of the ~99k added lines are internal/seniordev, and about 37k are tests. Its solution process is unchanged; codeaf hosts it and meters it.
  • The person or the chat can start it.
    • The person types /senior-dev <brief>.
    • The chat proposes a task with via: "senior-dev". The chat prefers senior-dev for complex, multi-part coding work, like an issue in a mature codebase, and nearly always uses it when asked by name.
    • Either way it goes through the usual approval card. From a shell, codeaf senior-dev <brief> runs it without the TUI.
  • It is a task in the conversation's task sidebar. It appears there with a [senior-dev] badge ([sd] when narrow), beside ordinary /tasks.
    • The row shows its current step and spend.
    • Opening it shows the actions senior-dev takes, grouped by the step of its process each serves: SETUP, SPEC, EXPLORE, PIN, CHECKLIST, IMPLEMENT, SUBMIT, VERIFY and FINISH. It does not show a chat with a model.
    • ctrl+y shows the raw model calls.
  • It runs once, from a single brief. One brief goes in and it works alone to the end: done, not finished, or stopped.
    • It reads no messages while it runs and asks nothing; the person can watch it and stop it.
    • A run is never resumed. Instead, its ending goes to the chat, which acts on it: checks the work, fixes a small gap on the branch, or hands it back to senior-dev with a sharper brief. It sends it back at most twice on its own, and never after a dollar or time limit without the person's word.
    • Every attempt on the same work stays on one branch.
  • It works in git and non-git projects, in the folder itself. No copy is made.
    • In a git repo: codeaf switches the checkout to a new branch, task/<title>-<id>. The person's own branch never moves. At the end, the work is committed on that branch and the branch stays checked out. A run that changed nothing switches back and deletes its branch. A checkout with uncommitted changes is refused before the card.
    • In a plain folder: it works in place and commits nothing. senior-dev itself uses git only if it is there, so a folder without git history can never end a run with "not a git repository".
    • Either way: one run per folder, and while it runs, codeaf's own tools and tasks won't write there. The home folder is never used.
  • Money and time are codeaf's. senior-dev holds no key: every model call goes through a loopback model API in codeaf.
    • Every call is priced into the conversation's total, the spend limit, the machine ledger, /cost and home.
    • The run's dollar and time ceilings come from the conversation.
    • Its wall time runs from the hand-off to the moment its process exits.

Added

  • The /senior-dev command, the via field on propose_task, and codeaf senior-dev in the shell.
  • internal/seniordev (the program), internal/delegate (the protocol a built-in program uses to talk to codeaf; spec in docs/design/delegate/PROTOCOL.md), and internal/run/delegateworker.go (the host).
  • Program badges: any program's tasks wear its name ([senior-dev]) wherever a task is named. Ordinary tasks wear none.
  • The actions view of a program's task page, and ctrl+y for the raw calls.
  • Manual pages internal/manual/chat/senior-dev.md and delegates.md.
  • scripts/clean-run.sh (the make clean-run target), which opens bin/codeaf on a fresh state root.

Changed

  • The chat's prompt tells it to hand complex, multi-part coding work to a built-in program. If the person named senior-dev and the proposal leaves out via, it is sent back once.
  • When the person asked for a program, the trivial-ask floor no longer refuses the proposal. A commit, undo or revert still stays in the chat.
  • While a program holds a folder, the chat's write, edit and file-generating tools refuse paths inside it, and so does every ordinary task road. bash is not fenced.
  • SIZE-BUDGET rises from 54,600,000 to 57,400,000, the size the engine adds; PERF.md shows the figures.
  • The prompt-size caps after merging dev santos/dev2 lands on dev: the worker harness as default, runs as work, skills from other harnesses, the chat as manager #1410 are 57,124 bytes (full) and 49,590 (lean): dev's 56,146 and 48,814 plus this branch's 978 and 776.
  • senior-dev's ending informs codeaf, not the person. Every run's end wakes the conversation with a verdict (passed, unverified, failed, limit, crashed), which run it was, its cost, and one next step, under a playbook page of its own (prompts/program-outcome.md). The chat checks, fixes or re-hands the work. A third automatic hand-off, or one after a limit, is refused in code. The landed card says ended and "senior-dev's ending went to the chat" instead of the status.
  • senior-dev's page names its task once. The head is one row: title, badge, a ▸ brief dropdown that shows the whole brief in grey (also ctrl+o), and the pinned facts. The trail crumb and the Reading: label are gone. Every action opens to its whole step on a click. The page's notes say the ending went to the chat, not the program's status.
  • Each change to the work wears git's +N,-M on senior-dev's page, the added lines in green and the removed in red. The file tools count them (write now counts its own, as edit and apply_patch did), and the protocol's step record carries them as added and removed.
  • The macOS unix socket limit is 103 bytes, not 104. A path of exactly 104 bytes passed the check, then failed at bind().

Fixed

  • Opening a task's room froze its side-list clock at the click. The row now hides its clock while the room is open.
  • On reopen, a run row codeaf had closed under could make the whole task list be set aside as corrupt. It is now kept.
  • A run codeaf closed under read running for ever. It now reads ended, with its time stopped.
  • Home counted a run's dollars twice; now it counts them once.
  • The run summary card, and questions asked of a run on its page, were model calls no spend record counted. They are counted now.
  • A senior-dev task's landed card (opened) and the chat's tasks tool show what the run cost. Its settled row now carries the run's total, as its index row already did. It's a display figure only, so nothing is charged twice.
  • A senior-dev proposal whose depends_on names unfinished work is refused before its card; before, it started at once and dropped the dependency. Any task may name a senior-dev run that ended done, and one still going is refused in its own sentence.
  • senior-dev tasks no longer offer a steer in the @ block or a retry on the task card. ContinueRun and the task row refuse to carry a senior-dev run on, in one sentence.
  • A senior-dev task opened from another window shows its actions view, read-only.
  • Opening another conversation's running work from the tasks list ("enter read it as it runs") was unreachable on a normal launch, for every task. The window couldn't read other conversations' presence through its engine connection. It now reads them from disk (session.ElsewhereOf). This also switches on the "another window is already in these files" warning for /task on a normal launch.
  • senior-dev ended at once with "workspace is not a git repository" whenever it was started without --in-place in a folder with no git history. It now chooses git only for a work tree with a commit, and its snapshot recorder otherwise.
  • A second senior-dev run in a folder the last one left on its branch stacked a new branch on it and called the first run's branch "your branch". A second run that changed nothing deleted its branch and switched back to the first's. It now carries on on the same branch, and its ending names the person's real branch.
  • Opening any conversation built an empty task graph, because endInterruptedProgramRun called graph() rather than tasker().

Known gaps (not in this change)

  • In the sessions list, pressing Enter on a running senior-dev task opens a nearly empty stored page that reads incomplete, instead of its actions view.
  • /stop on a page opened from another window does nothing and says nothing.
  • tasks.md quotes the stale-status line differently from the code (current status unavailable — showing the last known state).
  • One run per conversation, now that /task defaults to the run road (santos/dev2 lands on dev: the worker harness as default, runs as work, skills from other harnesses, the chat as manager #1410). A conversation drives one run, and senior-dev runs alone in its folder. So while senior-dev works, a /task or proposal in the same conversation is refused ("task N did not start: … senior-dev runs alone …"), where it used to run beside it on the old engine. Other conversations can still run tasks on other folders.

How it was checked

  • make pr-ready, run in its parts, on 2f2dc745d, and test-touched again after the gap fixes (rebased onto dev f101ad2e7):
    • test-quick, shard-test_test.sh, and test-touched all pass. test-touched covered internal/session (8 shards), internal/tui3 (8 shards), cmd/codeaf and every other package this branch touches.
    • make test-laws passes in all 28 packages.
    • Two parts fail on dev on this Mac too:
      • scripts/one-suite_test.sh, because this Mac has no flock;
      • TestTheWarmTranscriptEncodeCostsTheSameAtEightyTurnsAsAtEight.
    • It caught two failures, both fixed here:
      • Opening any conversation built a task graph, because endInterruptedProgramRun called graph() rather than tasker(). That was the first CI failure, in TestTheSessionAcceptanceDoesNotMintChecksFromProse.
      • The ssh policy test's control socket came to 104 bytes under macOS's $TMPDIR.
  • Merged with dev santos/dev2 lands on dev: the worker harness as default, runs as work, skills from other harnesses, the chat as manager #1410 (the worker harness as default, runs as work) at fff90333c:
    • The run start keeps dev's start lock and join-or-wait, and adds senior-dev's road (its folder, program and requested models; it never joins a run).
    • dev's set-aside ends an abandoned senior-dev run in its own words first.
    • A closed conversation leaves an ordinary run interrupted; a senior-dev run is ended and its folder finished on its own branch.
    • Commits carry dev's gitSignature, and the task page uses dev's trail and facts head with senior-dev's badge and pinned line.
    • After the merge, test-touched passes for session (8 shards), tui3 (8 shards) and every other touched package except three tests that also fail on a clean dev on this Mac: two cmd/codeaf /var vs /private/var path tests, and the provider allocation test.
    • On screen with the merged binary: a senior-dev run with its badge and actions page, a /task refused while it runs, the landed card at $0.05, and the stub's $0.1353 matching the conversation's $0.14.
  • On screen, in tmux with a stub model:
    • the landed card for a senior-dev run, opened, shows $0.05 for a run whose row records $0.0492. The stub charged $0.2583 across two conversations showing $0.14 + $0.12, so nothing is counted twice;
    • a second window's tasks list offers enter read it as it runs on another conversation's senior-dev run. It opens the actions view read-only, ctrl+y shows the raw calls and back, typing is refused in a sentence, there is no stop, and the final page shows once the run ends;
    • the card and badge, the [sd] row at 110 columns, the actions view and ctrl+y;
    • a repo run that cuts a branch and leaves it checked out, with main untouched;
    • the uncommitted-changes refusal, a plain folder, and a new folder on a fake home's Desktop;
    • stop, a busy folder, and a /task refused while senior-dev holds the folder;
    • the shell road, and a run that changed nothing.
  • Two independent reviews confirmed 24 findings: 7 about the actions view, routing and badge, and 17 about working in the folder. All are fixed, each with a test.
  • Not yet run: the real-model test of whether the chat proposes senior-dev by itself. The harness is ready, but it needs an OpenRouter key in the environment.

Checklist

  • A change entry: docs/changes/unreleased/1488-senior-dev.md.
  • The manual knows about it: internal/manual/chat/ is updated in this change.
  • No new line in .github/known-red.txt.
  • Only my own paths are staged; no git add -A.

🤖 Generated with Claude Code

ZeroPoint95 and others added 30 commits September 24, 2026 19:08
A draft for discussion: an outside program such as swe-pro becomes one more
worker kind behind the run supervisor rather than a slash command of its own.
Names the seams it rides, the contract it asks of a program, what swe-pro
would have to change, and the waves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ands drawn beside it

Owner feedback 2026-09-21: the UX is /swe-pro <brief>, not /task via. The
row is generated from the manifest and rides the task door. The control-plane
change and the spend-record question are handed to the swe-pro side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s spend

Records what landed on the swe-pro side (f3b9716): no plane needed, a
cumulative `stage: spend` record the live limit reads, and the rule never to
sum message.updated costs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oad time

Owner's call 2026-09-21: delegates are added at a person's discretion, so
the /<name> row cannot be a build-time list. The manifest ships its own
manual page, the corpus layers installed pages over the packed one, and a
page that does not spell its command refuses the manifest. codeaf-as-delegate
moves to later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner's call 2026-09-21: one commit per delegated task, subject the task's
title, body the terminal record's claim and observation, then the ordinary
merge home. Eager commits stay on inside the copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Decisions up front in one table, short numbered sections, every "already
exists" seam in a table, no content removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ext-landing delegates

Owner's call 2026-09-21: no per-program readers. stage, spend, step and
terminal on stdout, everything else ignored; swe-pro already conforms bar
two optional asks, and a delegate may land text instead of a tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d and a step record

swe-pro 5793499 landed both optional asks, output-only. The reader needs no
compatibility spelling and the task page gets steps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The interface a program meets to be a codeaf delegate, standalone: launch,
the four stdout records, the terminal, SIGTERM, tree or text, the manifest
and its manual page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ch, and the run's delegate worker

Wave 1 of docs/design/delegate/DESIGN.md. internal/delegate is a leaf: a
manifest beside its manual page, loaded from ~/.codeaf/delegates with the
manual law checked at load; a reader over the four stdout records that
ignores everything else; and a launch under its own process group that
SIGTERMs on the context, waits a grace, then kills, keeping a terminal
written on the way out. internal/run seats it as one more worker kind:
stages feed the live step, step records enter the trajectory, spend reaches
the run's bank, and the terminal record is the ending. Proven against a
scripted program and a recorded swe-pro stream. No door reaches it yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…/delegate lists them; via on propose_task

Wave 2 of docs/design/delegate/DESIGN.md. The conversation's door: a
delegated run rides the run road whatever the belt switch says, the
program is seated on the root task, nothing joins it, and its commits are
squashed into one task commit that comes home the way every run's copy
does. The surface generates one command row per installed delegate at
launch, appended to the live table and never to the literal; /delegate
lists what is here, what is absent and what was not added. The model is
told the delegates this launch has, by name, and names one with `via`.
The manual gets its Delegates page and the probes that reach it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… over the packed corpus

Wave 3 of docs/design/delegate/DESIGN.md. A delegate ships its page beside
its manifest; the loader keeps the text and the conversation's manual tool
answers from the packed corpus with those pages layered under
`delegate-<name>`, so "what does /swe-pro do" is answered from swe-pro's
page and nowhere else. The overlay adds and never replaces. A ready-to-copy
swe-pro manifest and page sit under docs/delegates. Wave 4 needs no wire:
a hosted surface installs no rows and /delegate says whose delegates they
are. The design doc records the decisions taken while building.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s row

startKnownTaskRun became a wrapper over startKnownTaskRunVia when the
delegate door landed, so the row's publisher is the via variant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the loop, and the laws hold

Wave 4 of docs/design/delegate/DESIGN.md. Delegate.List and Delegate.Start
ride wire version 18, so a --host surface generates its rows from the far
machine's registry and a delegate runs there; the surface asks for the
list beside the line at the launch and on a switch, and starts one in the
line. The two roads the door lengthened are split along their phases
(joinBeltRun, commitProposalToRun), the stop-law ledger names both
publishers, and the manual pages name no retired worker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dir it before installing one

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its own repository

swe-pro-go renamed its binary and namespace to senior-dev at b43daaf: the
binary and cmd/senior-dev, the .senior-dev/ run folder, refs/senior-dev/*
and every SENIOR_DEV_* variable. Its command line, its four stdout records
and its terminal data spellings are unchanged, so the reader needs nothing
new; the manifest, the page, the fixture, the tests and both documents take
the new name, and the docs pin senior-dev at 6103488 with one line saying
what it used to be called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a placeholder

fill substituted a brief into argv and then kept rewriting it in map order,
and checked the RESULT for unknown placeholders. So a review of a Go template
or a Helm chart ({{ .Name }}) refused the launch every time, and a brief that
said {{key}} or {{workspace}} could be rewritten after insertion, splicing the
person's API key into a command line. Each element is now substituted in one
pass by a strings.Replacer, which never rescans what it inserted, and the
unknown-placeholder check reads the manifest's own text. Reported by the pr-af
session with a reproduction; both faults are pinned by a test through Run that
fails on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What was true: a delegate was an outside program installed by hand — a
manifest and a page in ~/.codeaf/delegates, a binary on PATH, `{{placeholders}}`
filled at launch with the person's key among them — listed by `/delegate` and
documented in the public docs/DELEGATE-PROTOCOL.md.

What is true now (the owner's direction of 2026-09-23): the programs codeaf
can hand a whole task to are compiled into it. internal/delegate is the v2
contract — a Delegate is a Go value in internal/delegate/builtin's list, with
its own commands and flags; it runs as a child of codeaf's own executable
(`codeaf <name> run --json --dir … -- <brief>`); it writes `hello`, `stage`,
`step` and `terminal` on stdout; and it reaches a model only through the model
API codeaf will serve each run, whose address and token are the only model
names in its environment. ChildEnv takes every provider key and redirection
out, so no key reaches a program or a command its model runs. A `hello` of
another protocol stops the run: the engine outlived a rebuild. The
conversation log record (delegate.Turn) is fixed here for the API to write and
the task page to read.

Gone: the manifest, its validator and fill, the loader and its folder,
`/delegate`, the manual overlay, docs/delegates and the public protocol page
(kept on the tag delegate-manifest-v1). The chat's `/<name>` rows, the door,
`propose_task`'s `via`, the run road and the squashed landing carry over.
"Delegate" is a working title, so every sentence a person or the model reads
names the program itself. docs/design/delegate/PROTOCOL.md is the internal page.

The build carries no program yet; senior-dev joins the list with its engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… lanes

Two pieces three lanes will build on at once, fixed here so none of them
invents its own: internal/provider/modelapi.ChatURL, the one place a program
in codeaf's tree gets its model route from (the funnel law lets only
internal/provider spell it), and delegate.ProgramFile, the record of which
program a run handed its task to and the stages its hello named, written by
the worker and read by the task page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What was true: a task a run handed to a program (senior-dev) read like any
other task. Its page carried the store row, the brief and the steps its
worker recorded, and nothing of the program: not its name, not the stages it
said it would move through, not the calls it made to a model. PlanTaskPage
never set the page's own Live, so the stage the worker publishes as the live
step was drawn nowhere on the page, and a row named no program at all.

What is true now: a row names the program its task was handed to and the
stage it is in (PlanTaskRow.Program, .Stage), read off the program record in
the task's own folder, or off the conversation's live run in the moment
before the program's hello. A page carries PlanProgram for a task whose
folder holds a program record or a conversation log: the name and stages,
the newest 200 calls in the order they started with every text cut to its
first line and the run's own copy taken out of paths, how many earlier calls
it leaves out, and how many calls reached a model. The page's Live is set.
The spend a page shows is still the store's spend rows, which is the figure
that moves while the run goes once the model API banks one row per call.
The run's ceiling has a field and no source yet: it is worked out when the
run starts and written down nowhere a page can read.

Every read is inside the page and row reads the surface already makes off
its loop, and the remote wire carries the new fields unchanged on the page's
own call (client_test.go proves the round trip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What was true: a run handed to a program (senior-dev) opened on the page
every task opens on — a telemetry line, the brief, the steps the program
reported — with a note box and the sentence "the worker reads a note at its
next step", neither of which a program ever honoured. What the program was
doing, the calls it made to a model, was drawn nowhere; the rail row wore
only its clock, and a plan row drew the program's stage behind the shell's
`$` as if it were a command.

What is true now: a program's page is the conversation between the program
and the model that answered it, drawn from the page the surface already
holds (taskconversation.go). The brief opens it under the program's name;
each call is the program's side (a tool's result as `<tool>: <line>`, its own
words, or `summarized its history so far`) and the model's (its short name,
the first line of its answer, one dim row per tool it asked for behind that
tool's action mark); a refused call is codeaf's one line, a failed call the
model's; the call in flight is the last line, the running mark, the model
and its seconds, and it goes when the call returns. The line under the title
is pinned — stage (or the state word), spend so far, calls, age — so it
survives the page following its bottom edge; the head and the foot are now
counted by the frame, the window and the scroll alike. A program's page, and
its run's tab, have no box and take no note: the foot is the rule and the
keys, and typed letters are nothing. The rail row of a program's run says its
stage and its spend so far; a program's plan row draws its stage and never a
`$` command. Nothing drawn is zero or unknown, every row is fitted to its
width, every text is one clean row, and every mark comes through the glyph
door.

The manual's worker-harness page says what the page shows, and three probes
reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot all of it

What was true: the new section on a program's task page said the whole of
every call is kept in the task's own record. The conversation log keeps each
message cut at a cap and the newest messages of a long call, so that claimed
more than the record holds.

What is true now: the section says the task's own record keeps more of every
call than the page draws, which is what the log does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an show it

What was true: a program's task page had a place for the run's dollar ceiling
beside its spend, but nothing wrote the ceiling down where a page could read it
after the run started, so the page never drew it.

What is true now: delegate.ProgramRecord carries CeilingUSD, written with the
hello, and the page reads it into PlanProgram.CeilingUSD; zero still draws
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dapted

senior-dev lived in its own repository (swe-pro-go at the tag codeaf-absorb,
6103488) and ran as its own binary with its own key. Its packages are now
under internal/seniordev at the same sub-paths, and the product half of its
command (the pipeline, the solo run, verification, the workspace recorders,
the engine adapters, the durable sessions and their tests) is the package
internal/seniordev/app.

This commit is the copy and only the copy, so the adaptation that follows can
be read as a diff: the module path is rewritten, `package main` is `package
app`, and every Go file carries a !windows constraint (the two stat files keep
their darwin and linux ones). Left out entirely: internal/afield (the control
plane), internal/attribution (OpenRouter's attribution headers), the
tree-sitter marker file, and the command's CLI glue (main, args, the parser,
cli_run, serve, cpbridge, trace) and their tests. It does not build on its
own yet; the next commit makes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n folder stays out of a worktree's git

Four of senior-dev's tests failed on macOS, all on the same fact: every
temporary folder there is /var/folders/…, a link to /private/var/folders/….
Three were tests comparing two spellings of one folder. The fourth was real:
the per-file commit measured the written file against git's resolved top
level, so in any workspace reached through a link the path walked out of the
repository, `git add` refused it, and every `wip(write)`/`wip(edit)` commit
stopped without a word. Both sides are now resolved before they are compared,
and the three tests compare resolved folders.

senior-dev also wrote its `.senior-dev/` exclude into <git-dir>/info/exclude.
In a linked worktree, which is what codeaf cuts for a task, git ignores that
folder in favour of the common dir's, so `.senior-dev/` stayed untracked and
a landing that stages the tree's status would have committed senior-dev's
database and spec into the person's branch. The exclude now goes where
`git rev-parse --git-path info/exclude` says git reads it.

Its commit identity came from the AgentField attribution package, which is
not carried; senior-dev's own commits now carry `senior-dev
<senior-dev@localhost>` as -c overrides (GIT_AUTHOR_*/GIT_COMMITTER_* still
win), and no AgentField trailer. Those commits never reach a person's branch
as they are: codeaf squashes a run into one commit under its own identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rts only through the host

senior-dev was a binary of its own. It read OPENROUTER_API_KEY and
OPENROUTER_BASE_URL (or a config's apiKey/baseURL), called OpenRouter
directly with attribution headers and an optional provider-routing block,
printed every bus payload and a running `spend` on stdout as NDJSON, mirrored
itself onto an AgentField control plane, and ended with a terminal event its
CLI layer wrote.

Now it is the program internal/seniordev.Program: one command, `run`, whose
body takes a delegate.Host. Every model call goes to
modelapi.ChatURL(host.Models().BaseURL) and leaves by one door, the backend's
fetch, which puts the run's token on it over the one http.Client. No key or
base URL is read anywhere, and a senior-dev.json that sets a service's apiKey,
baseURL or any providerRouting block is refused by name; an ad-hoc `provider`
option is dropped. prompt_cache_key, x-session-affinity, reasoning,
usage.include and the tools are kept. The one "openrouter" spelling it needs
is modelsource.DefaultID, read through orclient.Service.

On stdout it writes only the protocol's records, through the host: a hello
naming its thirteen stages in the order a run reaches them (a law in
stages_test.go holds the list to the source), a stage per phase change, a step
per finished tool call, and exactly one terminal, written by the body so a
panic is an ending too. The terminal keeps the model's claim (its submission
reason) and what senior-dev itself observed of the project's build and tests
apart, in words a person reads. Bus payloads and spend stay in the process;
each stage's data goes to stderr, which codeaf keeps beside the task.

A stop is the truth, not a crash: SIGTERM ends the run's context, it starts
nothing new, ships what it has, and says `stopped before it finished` (or
budget-exhausted past a ceiling, or pass for a frozen candidate). A check cut
by the stop is recorded as incomplete rather than failed, so it neither fails
a submitted candidate nor restores an unsubmitted tree. Every file carries a
!windows constraint, and a test holds the tree to it. Left behind with the
command line: --format, --tui, serve, the control plane and the stderr trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The build's list of carried programs was empty on every platform, so there
was no /senior-dev row and `codeaf senior-dev` was an unknown command.

Now the unix list carries internal/seniordev.Program: `/senior-dev <brief>` in
the chat and `codeaf senior-dev <brief>` at a shell, `run` its one command,
landing a tree. Windows still carries nothing.

In the same change, the chat's manual gains internal/manual/chat/senior-dev.md,
written from what the code does: what it does and when to use it, how to write
the brief, what it cannot do (it cannot ask you anything, has no step cap,
reaches a model only through codeaf, needs git unless --in-place, is absent on
Windows), where its work lands (one squashed commit, the claim and what it
observed kept apart, .senior-dev/ never lands), what a run costs (every call
through codeaf, the dollar ceiling refuses the call that would cross it, its
landing reserve), its flags, and how a run ends. Six questions in a person's
words hold it reachable; two older questions that the first draft crowded out
("what flags does codeaf do take", "is my task stuck while it waits for its
test suite") were fixed in the page, not the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Was: internal/provider/modelapi held only the route (ChatURL). A program
codeaf carries had an address and a token in its environment and nothing
answering at them, so no call it made could reach a model.

Now: modelapi.Open serves one run's OpenAI-style chat-completions API on
127.0.0.1, opened by one minted token and closed with the run, so the
token dies with it. Every call:

- is refused 401 without the token, and 402 in OpenRouter's error shape
  before it is made once the run's metered spend has reached its ceiling,
  written down as a refused turn and counted (RefusedAtCeiling);
- is decoded whole into the funnel's types — messages of every role with
  assistant tool calls and tool results, tools and the program's own
  tool_choice, max_tokens/max_completion_tokens, temperature,
  response_format, the reasoning depth (as a configured effort), the
  prompt_cache_key (or the x-session-affinity header) as the cache key,
  and working handed back on assistant messages — while the program's
  provider-routing fields are dropped for codeaf's router to decide;
- goes out through the completer the run hands it, armed with
  provider.WithBilling and WithReconcile so every billed answer and every
  late receipt is metered once, as it happens, to Config.Bank;
- is answered in OpenRouter's own shape: one chat.completion, or an event
  stream ending in a usage chunk carrying cost and cached tokens and then
  [DONE], with `: keepalive` comments every 15 s while the funnel thinks
  (whitespace on a whole body), and model failures as the router's error
  envelope with a status that means the same thing;
- is written to the task's conversation log twice under one number — at
  its start and at its end — with only what the thread had not sent
  before (Restarted when the program rewrote its history), per thread.

Which model answers is one rule, Resolve: the model the program asked for
when one of this person's services can take a call on it, the run's work
seat when none can (Served names it), and never a refusal only because
this machine does not know the id; a call the funnel itself cannot serve
(no key, a withdrawn model) goes out once more on the seat.

Two small seams in internal/session carry it: ServesModel exposes the
account pool's own "can a service answer this model" test (and
RunSpec.Serves hands it to a run, read live), so the API and the pool
cannot disagree; and WithOwnCacheLineage lets a marked call keep the
program's own prompt_cache_key instead of the conversation's stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y call

Was: the delegate worker started the program with an empty model API, so
nothing it asked a model could be answered; it banked the dollars the
program reported about itself in v1 `spend` records, trusted the
terminal's cost_usd when it was higher, and wrote one spend row for the
whole run at the end. A program refused at the ceiling ended as whatever
it said it was, `crashed` included.

Now: before the program starts, the worker opens the run's model API
(modelapi.Open) over the conversation's own completer, with the run's
dollar ceiling, the services the conversation can reach and the run's
work seat (WorkSeat, the crew's own seatModel answer), and hands the child
its address and token through delegate.ChildEnv; it closes the API the
moment the program exits. Every metered call reaches three books as it
happens: the run's live bank (bankSpend), one spend row per call under
`delegate/<name>`, and this machine's spending ledger, one row per call,
written here and nowhere else — the conversation folds the run's total
without a ledger row of its own. Report.USD is the API's metered total;
the terminal's own cost is never banked and there is no end-of-run row.
On the child's hello the program record (delegate.WriteProgram) is
written beside the conversation log in the task's own folder.

A program the API refused at the ceiling is reported in the ceiling's
words — "<name> reached the run's dollar ceiling of $X" — whatever it said
it was, and the run ends on its cost limit.

The v1 `spend` record is gone from the protocol: RecordSpend, Sink.Spend
and Reading.SpendUSD are removed, and a spend line a program still writes
is one more line the reader ignores and counts.

The tests run the worker against real children: this test binary started
as the program (delegate_child_test.go), asking the real API over a real
socket, and a shell script that curls it; they pin the three books, the
turns, the dead token, the ceiling's refusal and the cost limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts it

Was: `codeaf <name> <brief>` from a shell refused with "runs from a shell
once its model API is in this build"; `codeaf --help` never named a
carried program, and a typo of one was answered with nothing.

Now: a shell run is the same two processes a chat's run is. The host
reads the person's own profile the way `codeaf exec` does (config.Load,
the crew's work seat, one adapter per model with the service's own id put
on the wire, so an `openrouter/` spelling reaches the router bare), opens
a model API, and starts this executable as the program's child with the
API's address and token and no key, handing it the person's own line with
--json added so a command they named and its own flags arrive intact. It
prints the stage as it changes, each step, each model call as
`model · N in · N out · $X`, and the ending — or, with --json, passes the
records through as records. Every call is written to this machine's
spending ledger once; the conversation, the program record and its stderr
are kept under ~/.codeaf/v3/carried/<name>/<run>/. --max-cost and
--max-hours stop the program from outside (the limit rung), Ctrl-C sends
SIGTERM with its grace (the incomplete rung), a run with no brief starts
nothing, and the exit is carriedExit's.

`codeaf --help` lists every carried program in a group of its own after
the work you hand it — one synopsis line and a one-line summary each,
inside the eighty-cell law, the page's cap moving by exactly that
(carriedPageLines) — and prints no group in a build that carries none. A
program's name joins the typo suggester (commandWords), and a test fails
the build when a program's name would shadow any word run() answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas and others added 26 commits September 25, 2026 14:26
… made, and the engine reaps its whole process tree

Two gaps an adversarial pass found in the first round of fixes.

The rescue before a restore listed untracked files under the CURRENT ignore
rules, so a person who added an ignore line and created the file mid-run lost
it; and it skipped a tracked file the person deleted, which the restore then
silently recreated. Every path changed after submission is now accounted for
against the rules recorded at the run's start: modified and new files are
copied as before, deletions are listed in a plain manifest in the rescue
folder, paths ignored at the start are still never touched, and no rescue
folder is made or named when there is nothing in it.

Processes were found at the end only by an environment marker a command could
drop (setsid env -u ..., env -i), so such a process outlived the run. On Linux
the senior-dev engine, and only it (never the codeaf process), is a child
subreaper and walks /proc parent links to kill and reap its descendants before
it exits, on a normal ending, a stop and a deadline; the host's marker sweep
remains only as the best effort after the engine itself was killed. A person's
own processes and codeaf are never touched.

Review of #1488, verification findings V1.1, V1.2, V1.4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tasks.md said a task has no dollar limit of its own and models-and-cost.md
that a task is never stopped on its own dollar count; both now say that of an
ordinary /task and point to senior-dev's run ceiling (0 and 3h by default).
The senior-dev page states the restore's deletion manifest, the estimated-cost
refusal as a limit, and each platform's boundary on background processes.

Review of #1488, verification finding V1.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…true now

The fix round had left a "Fix round" section keyed by review labels (C1, E2,
F1–F5) under the entry. Each item is now an invalidates line in the entry's
own shape, saying what somebody may still believe and what is true instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… its credit

In a real run the model ran git reset --soft main and git commit through its own
bash. That commit took the person's git identity from the repository and had no
Assisted-by, because codeaf signs only the finishing commit it makes itself and
there was nothing left to stage. The model's shell commands now carry the same
author and committer identity codeaf's run commits use (one identity, now in
internal/gitidentity), and when the finish has nothing to stage and the tip of
the run's branch is a commit the run made without the credit, codeaf amends that
tip's message to add it, keeping its tree. A commit that existed before the run
is never amended.

Review of #1488, verification finding V2.3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The missing ended card seen on one real engine-road run did not reproduce once
the landing stood outside the work fold (d2415a8): through the proposal road
with a stub model the card showed before and after the wake, with the task page
open or not, and after reopening. This pins the order that test did not cover,
a landing delivered after the chat has already answered, and that it survives a
reopen.

Review of #1488, verification finding V2.4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…says so

/budget conversation 1.5 wrote the profile and answered "per conversation ·
$1.50", but the open agent kept the spend rail it was launched with, so the next
chat turn, ordinary task and senior-dev run in that conversation used the old
figure; a senior-dev card showed $10 beside the chat's $1.50. The agent now
publishes a live override that every rail reader uses, the engine road carries
it over a new remote call, and /budget and the settings panel wait for the bind
before they show the new reading. When the bind fails (an older engine host that
does not know the call), the receipt says the saved figure applies to the next
conversation. /budget has no time row, and the manual no longer says it changes
a run's wall-clock limit.

Review of #1488, verification finding V2.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ship road built its terminal before restoring the frozen candidate, so the
person's later edits were saved under codeaf's state root but the shell ending,
the task's end record and the chat's landing never said where. It now restores
first and then builds the one terminal, with the rescue folder and the deletion
manifest. A test holds the post-submit verification in a real child process
under the delegate host, edits and deletes files meanwhile, and proves the path
crosses the protocol into the stored end action; the failed-suite road (which
already restored first) and an unchanged tree are covered too.

Review of #1488, verification finding V2.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fix that keeps generated caches out of a run's commits had internal/session
call util.GeneratedRunPath, and every file of internal/seniordev/util is
!windows, so the Windows build of codeaf stopped compiling ("build constraints
exclude all Go files"). The pull-request gate cross-builds nothing; ci-full does,
on the way into staging. The generated-path list and the engine's commit
identity now live in runshape.go, the one file of the package without a build
constraint; GOOS=windows builds again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nd a pushed one

The finish credited a run-made tip only when it carried codeaf's identity, so in
the common ending, where every write was already checkpointed by the engine as
senior-dev and nothing was left to stage, no commit on the branch carried
Assisted-by (seen on a real run). A tip made under either of the run's two
identities is now credited. The amend also now requires that HEAD is the run's
branch and that no remote-tracking ref holds the tip: a model that pushed its
commit used to have the finish rewrite it, leaving the local branch diverged
from the remote. A person's commit on the run branch is still never amended.

Review of #1488, final verification.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the folder changed after submission and the rescue copy could not be made,
the restore refused (correctly: nothing is lost) and the folder kept the later
changes, but the run still ended "submitted a change, and the project's own
build and tests passed", about a tree nothing had checked. A failed restore on
the ship road now turns a pass into pass-unverified, which the chat treats as
work to check, and both restore roads record why it failed; the ending adds that
the folder could not be put back, and why, so it also holds later changes that
nothing checked.

Review of #1488, final verification.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ndows build

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…side senior-dev

Brings in #1494 (team delegation), #1511 and #1436 (per-task worker, planner and
checker routing). A senior-dev run keeps its own road beside them: it works
alone in the folder it holds, so a via proposal starts one program run and does
not go through the team split; it keeps the models it was asked for, or the
pinned or profile worker, and #1436's per-task routing and $5 task cap apply to
codeaf's own tasks, while senior-dev keeps its finite per-run ceiling. Both
sides had claimed remote protocol version 18; the combined wire is version 19.
Program badges fit #1494's grouped side column, the manuals describe the merged
model choice, and the prompt-size ledger records the combined fixed prefix
(57,218 bytes). dev's unbounded-launch test now holds its six workers at a
barrier instead of guessing with a sleep, which failed on a loaded machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The previous fix kept the Windows build compiling by giving internal/seniordev/util
one file without a build constraint, which broke the PR's own law that no file
of senior-dev reaches a Windows build (TestNoFileOfSeniorDevReachesAWindowsBuild).
The engine's commit identity and the list of caches a run's tests leave now live
in internal/gitidentity, beside codeaf's own run identity, which builds on every
platform; the engine keeps its names for them as aliases, and internal/session
no longer imports the engine at all.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ut exception

The dev merge made TestNoFileOfSeniorDevReachesAWindowsBuild allow one file of
internal/seniordev/util to build on Windows, the one that held the run facts
codeaf reads on every platform. Those facts now live in internal/gitidentity,
so the law is back to its original form: no file of senior-dev builds there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e name the rest

The environment-pin law found two CODEAF_ names nothing had registered. The
strip list named CODEAF_API_KEY, which codeaf never reads and which the
*_API_KEY suffix rule in the same function already removes (the older spelling
too), so it is no longer named. CODEAF_DELEGATE_RUN, the mark codeaf sets on a
program's process to find what its commands left behind after the engine is
killed, is plumbing like the model API's own two names, and is registered
beside them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… does

TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffects
drove soloConverse without the recorder's Prepare, which every real run calls
first and which installs git's exclusion for the live session store. Without it
the store's temporary files could race git add during submit, the submit failed,
the model was nudged that it had stopped without submitting, and the stub saw a
fourth request it did not expect. Under load it failed 2 runs in 40; with the
recorder prepared it passes 40 of 40, and 10 of 10 with -race.

Review of #1488.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With senior-dev's pages beside dev's new team-manager page, the probe "what
happens if it will not stop" reached home, tasks, team-manager and senior-dev
instead of the keys page that answers it. The heading already answered it as
"will not let go"; it now says both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ommit it makes

The finish amended the run branch's tip to add Assisted-by when nothing was left
to stage. The final verification showed that rewrote a commit the model had
pushed by direct URL (no remote-tracking ref records such a push) and dropped
the signature of a signed commit; guarding the amend further would always miss
a case. The amend is gone: commits senior-dev makes as it works keep the run's
identity and are never rewritten, and the credit is carried by the finishing
commit codeaf makes when there is something left to commit.

Review of #1488, final verification V4.1, V4.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After the Pareto-crew merge, codeaf senior-dev run --high <model> on a fresh
profile with a key but no crew rows was refused before its first call ("no
allowed model on a connected provider can start"), because the profile seat was
resolved before the explicit model was applied. The seat is now resolved only
when the run needs a default, which keeps that refusal and its /crew door for a
run with no model named.

Review of #1488, final verification V4.4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three ways a run could claim or cost more than it should.

A comparison of the folder with the submitted candidate that failed (git gone,
say) left the run at pass, and even a failed restore could keep a "build and
tests passed" reason beside its pass-unverified status. Both now end unchecked,
and every field of the terminal record agrees with the final status.

A verification command that ran zero tests counted as a pass: a real run's
unittest fallback ran plain discovery from the root, found nothing in a tests/
folder without __init__.py, printed "Ran 0 tests ... OK", and the run said its
build and tests passed. An empty run from any runner the discovery picks now
ends unchecked with a sentence saying so, and the unittest fallback discovers
the project's test folders.

The rescue before a restore copied whatever had changed, of any size, with the
project's permissions. It now checks a 25 MiB per-file and 250 MiB total limit
before it touches anything; over them nothing is restored and the run ends
unchecked, and what it does copy is private to the user (0700 folders, 0600
files). The manual states all of it, and a test keeps its figures on the
constants.

Review of #1488, final verification V4.3, V4.5, V4.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he task room

Brings in #1516, #1517, #1518 and #1519. The two conflicts were dev editing the
inline run-road code that this branch had moved into helpers: the --one-model
seat (#1517) now rides the precomputed crew factory in enginewire, and the
hand-off's named model reaches the crew router as a worker pin (#1518) through
the crew wish set before commitProposalToRun. That wish is read only by the
ordinary task's crew routing, so a senior-dev run keeps the models it names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ary"

Dev's #1518 pinned three sentences that carry the crew's default per-task limit
("No task may cost more than its limit: $5 ..."). With senior-dev in the tree
those sentences are false for a senior-dev run, which is not a crew task and
keeps its own ceiling, and the pages already say "an ordinary /task" there. The
pins now match the qualified sentences; the figure still comes from
config.CrewTaskCapDefault.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit dd0fcc6 into dev Sep 26, 2026
4 checks passed
@AbirAbbas
AbirAbbas deleted the zeropoint95/senior-dev-harness branch September 26, 2026 00:24
AbirAbbas added a commit that referenced this pull request Sep 26, 2026
…1530)

* senior-dev: a plain folder starts with a readable safety list again

On dd0fcc6 (#1488 merged) a senior-dev run in a folder with no git crashed at
bootstrap before its first model call: "read start-time ignore list: open
.../ignored-at-start: no such file or directory". Folder preparation returned on
the plain road (and the gitignored-subfolder road, which takes it too) before
writing the start-time ignore list, while the shell and the chat both still
handed its path to the child, which refuses an unreadable list. Preparation now
writes an empty, readable list there first, so the run works in place and
commits nothing again; a repository's list is written and honoured as before.
Tests start the real child on the shell road and as the chat's run worker in a
plain folder.

Found by a real-model run of the merged build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* senior-dev: shell model words resolve before the child starts

codeaf senior-dev --high z-ai/glm-5.3-flash failed after launch with
'models.dev: provider "z-ai" not found' because the value went to senior-dev as
typed and its first segment was read as a provider; only openrouter/z-ai/...
worked. The shell now resolves --high and --asked through the same model
matcher and connected-service check the chat's proposals use, and hands the
child the qualified id. A bare OpenRouter id, a service-prefixed id and a short
crew word all work; a model no connected service can serve is refused before
the child starts, naming /crew and codeaf connect. The manual says what --high
accepts.

Found by a real-model run of the merged build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* changes: the plain-folder and shell model-name fix is #1530

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* changes: #1530's entry carries its number

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants