ADR 0032: The default loop works the way pil did
- Status: accepted
- Date: 2026-09-30
Context
On a SWE-bench Verified cohort run locally (the 121 tasks whose gold patches validate, Qwen3.8 27B at Q4, one attempt each), nodwyn's defaults were tuned through Scheme strategy files over the unattended-defaults branch. The combination that held up, called pil, resolved 86 of 121, against 87 for Pi, a separate harness, on the same model and tasks. Each part came from a failure seen in the logs:
- the model sent command lines through
shell-exec'scmd/args(8–15 malformed calls per task on some tasks) and read results as JSON strings with escaped newlines; - test output and reads of code that turned out not to matter filled most of the main context;
- scripts that searched a file for a definition and printed it, where one call would do;
- tests run with the system interpreter, then worked around with patched standard libraries or installed packages;
- a plan written without running anything overriding what the model's own checks showed;
- checks retyped each time, paraphrasing the request's example, and a compaction note that kept "it does not reproduce" and dropped the check it rested on;
- compaction at half the window stopping runs that would have finished.
The parts lived in a benchmark harness's strategy files, outside the project, while the project moved on to the console, workspaces and the rename.
Decision
pil becomes nodwyn's default, for every session, interactive or unattended:
- The unattended-defaults branch is merged: the unattended prompt, evidence
to finish on, sub-agents that end only themselves, tool offers per
request, and
fold-result. working-rules(prompts.scm) joins both system prompts: shell command lines, the project's environment, reading with the file tools, a plan as a first guess, the check loop, following the request's conclusion, widening in general form, whole test modules, comparing failures with the unchanged code, and finding regressions file by file.- The working habits load with the default loop (docs/scheme.md §9.6):
shell.scm,hygiene.scm,readdef.scm(theread-definitiontool),envhint.scmandcheckloop.scm(thesecond-looktool). - Handover notes keep each finding's evidence and name the check script, and the compacted context re-checks the note before building on it.
- Compaction is on by default, a twenty-fifth below the window's high water,
read through a new primitive,
(window-limits);(model-info [name])and(model-names)give strategies the configured models' properties too.
Consequences
- The system prompts are longer by about 3,900 characters, and every session offers two more tools.
- The interactive prompt gains rules measured only unattended; the check loop in particular asks for a script before changing code.
- Long interactive sessions now compact instead of only trimming; a project
that prefers trimming sets
compact-thresholdto#f. - A project's
.nodwyn/init.scmwritten for the old defaults still loads, and wraps the habits as they wrap the defaults. - A leave-one-out ablation of pil's parts on the same tasks is queued; a part that does not pay its way there is a candidate for removal.
Amendment 2026-09-30: compaction measures what the window trims by
Compaction compared the server's prompt-tokens for the last request with a
threshold taken from the high water, which is in the window's own estimate
(bytes/3). The two differ: on meta/muse-spark-1.3 through OpenRouter the
estimate ran about a quarter above the server's count (53,339 against
42,661 for one request), so the window crossed its high water and elided 20
messages while the server's count was near 43k, far under the 58,983
threshold. Compaction never ran, and old reads were dropped rather than
summarized. The last request's size also lacks the tool results just added:
that request was already under the threshold in the window's own units, and
one round of reads took the next one past the high water.
Now maybe-compact measures (context-tokens): the window's estimate of
the context the next model-turn would send, before any trim, in the units
of (window-limits), whatever the provider's tokenizer. The handover note's
request is asked with '((headroom . #t)), so it may run past the high
water up to the window's ceiling, the room the model's context has left
above it, and a round that jumps past the high water is still compacted
rather than trimmed (llm.req high_water records that request's trim line;
window-v10).
The window is also sized for the model: a 60k window on a 1M-token model
compacted long before the model needed it. When [control] gives
num_ctx, or a chat-completions server's model list gives the context
length (OpenRouter's context_length, vLLM's max_model_len), the high
water grows to three quarters of the context less the reply cap, the low
water to half that, and the last quarter is the ceiling's room for the
note. [window] water marks set in config.toml are kept as set. A context no
bigger than the window, such as a local model sized by it, has no such room:
there a round that jumps past the high water still trims before the note is
written, unless the reply reserve is given up for it.
Amendment 2026-10-01: the evidence loop reads the scratch directory and git
The check loop asks for one script in $TMPDIR, but the evidence loop knew
only test-command-words and counted any fs-write as a change: writing
the check reset "tested", and bash $TMPDIR/check.sh never set it. In the
pil run on the 121 tasks, 18 of the 23 "You changed files after your last
test run" reminders (12 tasks) came after the model had run its scratch
check since its last edit to the repository. Now a write into the scratch
directory is not a change, a command that runs a file written there is a
test run, and a failing check, which fails until the work is done, never
counts toward the way out for tests that cannot run.
In a git work tree the loop also asks git (git-status, read by the
strategy, not offered as a model call): something changed when a tracked
file's status differs from the start of the turn, or the file tools wrote a
file git now lists as new or listed already. An edit through the shell
counts; a write of the same content, or a file a run leaves behind, does
not. Files new since the turn began that git does
not track are named once (finish-new-files-message). In the same run, 22
tasks left such files: changelog entries, new test roots and *_tmp.py
repro tests. The benchmark's patch is git diff, which leaves untracked
files out, so this note does not move that score; it is for repositories
where what is left in the tree is the change. Both system prompts gain a
rule for programs that keep running or may hang (shell-start) and, in a
git work tree, (git-rule): look at the change with git-status and
git-diff before finishing.
Hygiene was measured on the same run before changing it, and stays as it is. Reads folded in 22 of the 121 tasks: 137 folds (65 of them stale), 0.87 MB. 106 later reads went back to a folded passage, mostly as ranges, 0.28 MB; counted as bytes times the requests they stay in the context, the folds keep 66% of what they save after those re-reads. A request right after a fold took a median 3.2 s of prompt evaluation against 0.4 s otherwise, about 30 such requests over the run. Long read-only sessions differ: on a repository review with meta/muse-spark-1.3 (32 rounds, no edits) 24 of 28 re-reads followed a fold of that file, 16 of them whole files, since nothing is edited and every read ages out.
Amendment 2026-10-02: a fold waits until it pays for the cache it costs
A fold changes a message in the middle of the prompt, so a server with a
prompt cache reads everything after it again at the full input price. In
the review described in ADR 9's amendment of 2026-10-02, 12 of 34 requests
followed a fold. Given what nodwyn sent, at most 64% of the prompt tokens
could have come from the cache, against 93% had every request kept its
predecessor's prompt. Each fold cut the reusable prefix back to the oldest
read it folded, eight rounds back or more, so the server read 26,000 to
50,000 tokens again each time. hygiene-min-bytes also counts a read's raw bytes, while the
window shows a read longer than max-result-tokens shortened: the 109,563
bytes of the first fold were about 11,000 tokens in the window.
Keeping F tokens costs F times the cached price in each later request. Folding them costs the A tokens after them, less F, once at the input price less the cached price. With cached tokens at a tenth of the input price, a fold that is half of what follows pays for itself after 9 more requests; at a fiftieth (meta/muse-spark-1.3), after 49. A smaller fold takes longer. Without a working cache there is nothing to lose, and the fold saves the full price of F on every later request.
So a due batch now folds when at least one of these holds:
- the fold is at least as large as the rest of the window after it
((context-tokens result), new: the window from a result to its end, in
the window's units, with reads counted as the window shows them);
- the provider's cache is not serving the context: it read less than
hygiene-cache-share (a quarter) of the last hygiene-cache-requests
(4) prompts in the main context;
- the context is past hygiene-pressure (half) of the high water, where
room matters more than the cache.
A provider that reports no cache, such as an older Ollama server, counts as serving the
context, since this ADR's window is built for its prefix cache. On a local
model reads therefore wait until a batch is half of what follows or the
context passes half the high water. On the 121-task cohort that is a change
from the measured pil configuration and has not been run yet. A run with the
old rule is (define (hygiene-fold-reason due) "always") in init.scm.
Each fold's log note now ends with the reason.
Amendment 2026-10-02: what three runs of one small task showed
The same request ("make it easier to configure the jev key in the ui ... Propose something before working on the changes") went through a six-turn run, a one-turn run and a chat, and failed each time for reasons in the harness, not in the request:
- The model's thinking is not kept. It is logged, not sent back, and a
reply that only calls tools says nothing. In the chat's second turn the
model worked out the same design in nearly every one of 45 requests and
changed nothing. The working rules now say so, and the model writes its
plan with
plan-write, an agent tool that records it (gate.plan, decisionmodel) where the window pins it andplan-stepmarks it. - The evidence loop overrode "propose first". A model that proposed
and stopped was told "No file has changed yet. Make the change", and
started implementing what nobody had approved, or wrote the proposal
into the repository to have a change. When a person reads the answer
before anything else happens,
(reviewed?)(a chat, or a supervised run's turn that a checkpoint follows), the reminder now says the request may ask for no change yet, and after it the turn may end unchanged. An unattended run, and a run's last turn (SWE-bench runs one turn), still need a change. - The explorer's findings reached only the planner. The model doing the
work read the same files again within a minute. They are now handed to
it once the plan is recorded (
plan-findings-message). - The denylist refused the task's own files (
credentials.js,credentials.gomatchedcredentials.*), so the model read and edited them through the shell, which the person had to approve for the whole session, andgit-statusleft them out, so the evidence loop said nothing had changed after the model changed one. Code no longer matches the name globs, andgit-statusnames every file (ADR 28, amended). - nodwyn's own tests failed when nodwyn ran them, for reasons the model could not tell from its own breakage (ADR 28, amended).
Amendment 2026-10-02: a change is checked where it runs
The same jev-key request, done in a chat once the harness fixes above were
in (session 20261003T002506Z-f564a28c, meta/muse-spark-1.3), was
reviewed four times, and each review found what the model's own checks
could not:
- The page crashed on a click. One edit named a text
detailin the function that also called the module'sdetail(), so storing the judge key or opening its row blanked the Credentials tab. Go's tests andnode --checkpassed in every round: neither runs the console's JavaScript. The repository's browser check for that tab (test/browser/checks/credentials.js) was not run until the fourth turn; once it was run, and extended to the judge panel, it found the two defects that were left within minutes. - The replies reported checks that could not show what they claimed.
"All review items fixed" rested on
node --check. - Unanswered questions were settled silently. The proposal ended with three questions; told "go do it!", the model chose without saying so, and one choice (no key field while the judge is off) left Settings linking to a panel without a field.
- It added what nothing used: a function for a step of its own plan, and a field the page did not read.
- It stashed the work around tests that had passed, to compare with the unchanged code, which the rule asks for only when tests fail.
So the prompts change:
- The working rule on whole test modules adds that a build or a syntax check shows code is well formed, not that it works, and that code a browser runs is checked by the project's browser or end-to-end checks, extended to the states the change adds; with none, the page is reported unchecked.
- The comparison with the unchanged code is "only when tests fail".
(git-rule): the change holds nothing it does not use.- The interactive prompt: the answer says what changed, how it was checked and what was not, and when the user goes ahead without answering a choice, the model chooses and says what it chose.
The system prompt's test no longer pins its text byte for byte. The copy
showed that the move of the prompts into Scheme (2026-09-26) reproduced the
Go strings; after that it only made every edit twice. The test now checks
that the session sends what (system-prompt repo) builds, that two
sessions of one repository send the same prompt (the prefix ADR 9 caches),
that sections that do not apply are left out, and what the planner's and
runs' prompts must carry.
None of this has run on the 121-task cohort. The working rules grow by
386 characters in both system prompts and (git-rule) by 87 in a git work
tree; the interactive prompt grows by 217 more. On the benchmark's tasks
there is no page to check.
Amendment 2026-10-02: rounds that only read get a reminder, a question an answer
A chat request ("we seem to have support for worktrees, but the UI doesn't
seem to allow me to enable that", session 20261003T052943Z-4e994237,
meta/muse-spark-1.3) ran for nine minutes and 40 requests, 99 calls, every
one a read, a search or a listing, and was stopped without a word to the
person: every reply's text was empty. Its thinking at the sixth request
already said what the answer was (ADR 34 is proposed, and only the git work
tree check exists); at each of the next 34 it said the same thing in other
words and read more to confirm it. The rule that the thinking is not kept
(amendment above) did not apply: it tells the model to write its plan once
it knows what to change, and this model never decided whether there was
anything to change. A fold at request 34 then took out 33 reads, and it read
five of those files again. The pattern is not one session's: the jev-key
chat (20261003T002506Z-f564a28c) made 402 requests with five replies that
said anything, its first edit at the 30th.
Nothing in the loop looked at this. The plain loop asks again while a reply
calls tools, and the evidence loop acts only on a reply that calls none. So
both now count the rounds in a row whose calls all only look
(look-tools: fs-read, fs-list, fs-find, index-grep,
read-definition, git-status, git-diff; this repository adds its
grep-repo, find-files, read-adr and git-log), and after
reading-reminder-after (12) add a message: the thinking behind them is
lost, so write it down now, with plan-write, and start; when a person
reads the answer ((reviewed?)), answer now if the request asks a question
or needs the person's decision. Any other call starts the count again, and
so does the reminder, which comes again after twelve more.
Twelve is the explorer's budget before a plan (plan-explore-rounds), and
twice the rounds the worktree turn needed. Counted over the last nine
sessions on this repository, the reminder would have come four times in the
worktree turn (at its 12th request, not its 40th), twice in the first turn
of two of the three "give me 5 things this harness is missing" questions,
which ask for an answer and no change, and in nine of the fifteen jev-key
turns, where it asks for what the prompt already asks: a written plan. Four
sessions never read for twelve rounds in a row.
None of this has run on the 121-task cohort, where the evidence loop runs too: a task that reads for twelve rounds now gets the unreviewed reminder.
The reminder comes after twelve rounds; the interactive prompt now says what to do before them. The worktree request expected something that had only been designed, and the model went back and forth between saying so and building it. A question is answered; a request that expects something never built is told so, with what exists, what is missing and what the model would build, before anything is built; and either is answered as soon as what the model has read settles it. A request that reports a defect in something that exists is still fixed. The rule, 357 characters, is in the interactive prompt alone, so the benchmark, which runs unattended, does not see it.
ADR 9 (amended 2026-10-02) now sends the thinking back on its reply, as the
server reported it. The working rule, the reading reminders and
plan-write's description said "your thinking is not kept"; they now say it
comes back only as the server reported it, often a summary or not at all,
which is true of every provider, and still ask for the plan in writing.
Amendment 2026-10-03: a turn with a plan is told to go on with it
The reading reminder (amendment above) said "write your plan with
plan-write now" whether or not the turn had one. Writing a plan again
starts its marks over. In session 20261003T063228Z-73791682, after the
turn whose plan it lost to a provider's rate limit (ADR 35, amended the
same day), the model had written its plan again and marked three of five
steps. It then read for twelve rounds, every one of them for step 4, and
was told to write its plan. It wrote it again (seq 6042) with "[done]"
typed after the first three steps, since the marks were gone. The
console's plan panel showed no step marked. Earlier in the session the
reminder at seq 3496 had the model rewrite an unchanged plan.
A turn that has a plan ((plan-recorded?)) is now told that the plan is
in front of it: make the change for its next unmarked step, read more only
for that step, and write the plan again only if it must change, since that
starts its marks over. Without a plan the reminder is as before, word for
word. This includes the unreviewed reminder of the evidence loop, so on
the benchmark it changes only for turns that have a plan. A run's turns
always do, and there the reminder no longer asks for a plan the gate
already drafted.
Amendment 2026-10-03: plans name their tests and docs
The worktree sessions of 2026-10-03 (20261003T143916Z-48f31857 and
20261003T063228Z-73791682, reviewed and reverted) planned in stage
labels. The plan recorded at seq 1344 of 48f31857 read "2. WT0:
repository-behind-checkout lookup (git common dir) and key project data on
repository, with tests", and its last step was "Docs (manual, scheme,
protocol, browser, ADRs) and full validation". No step said which test or
which doc, and what followed shows what that cost:
- ADR 34's specific clauses (the stash habit, the
.gitignore) never became steps, and were dropped. - A test written for "a clean close keeps attachments" passed on the code that deleted them.
- The browser check only looked for the new checkbox, so it passed while the toggle did nothing.
- A new primitive was missing from the book's and the expert guide's tables, and a new working rule had no amendment here.
The plans that worked, the handoffs written after those reviews, all had one shape: steps that change code, test steps that each say what they check and why they fail on today's code, docs steps that name the files, and a last step that runs the checks.
So every plan is asked for in that shape: numbered steps, each starting
with its kind, Change: (a file or function and what changes in it),
Test: (the test file and name, the behaviour it checks, and "fails
today: …", or "passes today: guards …" for a test of behaviour that already
works), Docs: (each file a person or a later session reads that the
change makes wrong or incomplete) and, last, Check: (the command that
shows the whole change works). A plan with no test or no doc to change says
so in a step, with the reason (Test: none — only docs change).
plan-writechecks the shape (plan-problems, over the steps(plan-steps plan)reads, the same readingplan-stepmarks). A step of no known kind, noChange:,Test:orDocs:step, a last step that is not aCheck:, or aTest:step that says neither "fails today" nor "passes today" (or the same "on the unchanged code") turns the plan down, unrecorded, with each problem by step number and what to add. After two refusals in a turn (plan-write-refusals) the next plan is recorded as written, answeredPlan recorded:and what it still lacks, so a model that cannot get the shape right is not kept from the work. Its description states the shape with a two-line example.plan-step's description says aTest:step is done once its test has failed without the change and passes with it, and the note names both runs.- The gate's planner is asked for the shape (
(plan-shape), built fromplan-kinds, inplan-draft-promptandplan-revise-prompt; a run's replanning asks for what remains in the same shape), and the explorer reports the docs involved and what the repository's agent instructions ask a change to include, since the planner sees nothing else. The judge's rubric asks for the shape, and a plan whose tests do not say what they check, or that leaves out the docs, does not reach its top level. - The working rules and the reading reminders still say "write your plan
with
plan-write" and nothing more: the tool's description and its refusals carry the shape, and the rules stay short. - The kinds, the wording a
Test:step may use, the refusal count and the messages are definitions inplan.scm, so a project redefines them in.nodwyn/init.scm;(define (plan-problems steps) '())turns the check off.record-planchecks nothing: project code is trusted as written.
The check reads what a step says, not what it does: nothing runs a
Test: step's test on the unchanged code to see it fail.
This has not been measured on the 121-task cohort. There every instance is
a supervised one-turn run, and in pil's run (nibb-1fd0696-d-pi121-pil)
all 121 recorded a gate plan, decision required, unscored (no judge), and
none called plan-write. So the benchmark sees the planner's draft prompt
(138 characters before the request, now 656), the explorer's task (215,
now 355), plan-write's description in every request's tool list (382,
now 1028), and its check whenever the model writes a plan of its own; the
rubric and its levels (182 characters, now 517) apply only with a judge.
To measure it, run the cohort twice on the same model and instances as
pil (Qwen3.8 27B at Q4, qwen3.8-27b-96k:latest, the 121 tasks, one
attempt each): once at the commit before this change and once at this
change's commit. Compare:
- the resolved rate, from each run's tagged eval (
resolved_ids); - requests per instance,
llm.reqevents per session log (pil: mean 50.7, median 41), main context and sub-agents apart; - how often plans were turned down:
plan-writeresults that start "Plan not recorded", and those recorded "as written" after two refusals, per instance; - whether
Test:steps' tests fail on the unchanged code: for eachTest:step in a recorded plan, the firstshell-execthat names its test file or test before the first edit outside the tests, and its exit status. The patch alone cannot show it: the bridge builds it withgit diff, so a new test file is not in it.
Amendment 2026-10-03: a piped command reports its failure, and leaves nothing running
The shell rule above says to keep long output short with | tail -n 40.
A pipeline's status is its last command's, so a check piped that way
reported 0 when it failed. In session 20261003T190332Z-547f0bd6 two runs
of a failing browser check came back as status 0. The model read the text
that time, but the evidence loop's ran-ok? counts a piped test run by its
status. A bash -c command line through shell-exec or shell-start now
runs with pipefail, so its status is that of its last command that
failed. The rule's text is unchanged. Only bash's -c form changes: a
script keeps its own options, and sh may be a shell without pipefail.
A pipeline into head can now end in 141 when head stops reading early,
which is why the rule says tail.
shell-exec ran its command in a process group of its own, but killed the
group only on a timeout. The same session started the browser checks' demo
with &, and it kept running after the session ended. A child that kept
the output open made the call wait five seconds and then fail with "WaitDelay
expired". Now the call returns when the program ends, and what it left in
its group is stopped: SIGTERM, then SIGKILL two seconds later, as
shell-stop does. A program meant to keep running goes to shell-start,
as the rule above already says.
Neither change has been measured on the benchmark. The working rules are
unchanged; shell-exec's own description now says both.
Amendment 2026-10-03: in a worktree, no stash
A session started in a git worktree of its own (ADR 34) adds
(worktree-section) to both system prompts, after (git-rule). Besides
naming the branch and saying that other sessions may work in other
worktrees of the repository, it overrides one working rule there: compare
with the unchanged code on a copy of the last commit (git archive HEAD |
tar -x -C "$TMPDIR/base"), never with git stash. The failure it comes
from is ADR 34's: every worktree of a repository shares one stash, so the
rule's git stash; <command>; git stash pop pops whatever another session
stashed last, bringing its work into this one or taking this one's away.
A session working on nodwyn itself is warned of the same trap in
AGENTS.md. Outside a worktree the section is "", so the measured
prompts are unchanged byte for byte; working-rules keeps its stash
example. The section has not been measured on the benchmark, whose runs
start in a folder of their own.
Amendment 2026-10-04: plan marks move as the work happens
The plan header asked for each mark "as you finish it", and the model read
that as a summary to make at the end. In session
20261004T053047Z-1b4c67dc (meta/muse-spark-1.3 through OpenRouter) the
gate recorded a plan of six steps at 05:36:43 and the model marked steps
1–5 together at 06:34:25–06:34:42 and step 6 at 06:36:53: the last two and
a half minutes of work that had run for an hour. Whoever followed it saw
the console's plan panel sit at 0 of 6 until the work was over, which is
the progress the marks are there to show.
The wording that asks for the marks now says when: plan-header,
plan-recorded-message (both branches), plan-step's description and the
run turn prompt say to mark each step as its work is done, before starting
the next, never several together at the end; plan-step's description
says why — whoever follows the task sees the plan's progress move as the
work happens — and plan-write's description says its marks follow the
work. The working rules are unchanged, so the prompts measured on the
benchmark stay byte for byte; this wording has not been measured there.
Amendment 2026-10-04: plans give each step a short name
The plan panel folds each step to its title, the clause before its first
dash, and hides the rest behind that fold. The plans the person
screenshotted on 2026-10-03 had no such clause: steps read "Change: read
internal/frontend/rpc/workspaces.go, the desk state builder and …", the
work itself as the title, cut at the console's 80-character cap, with
nothing to fold to. The kinds were right; the shape said nothing about the
name, so neither the planner nor plan-write asked for one.
Now each step gives a short name for what it does, then a dash (—) and the
detail after it; a step that says all it should in its name needs no dash.
The name is what comes before the first boundary the console folds a step
on (a dash with a space before it, or a sentence end or semicolon with a
space after), the kind included, and it stays within 80 characters, the
console's cap, so what plan-write accepts is what the panel shows whole.
A step of a known kind whose name runs past that turns the plan down with
the other shape faults: plan-name-cap and plan-name-boundaries hold
the number and the boundaries, plan-name-length measures a step's name,
and plan-name-problem answers with the step's number and what to write;
after plan-write-refusals the plan is recorded as written, as before.
The planner is asked for the name in (plan-shape), and the judge's
rubric and its top level ask for it too. plan-write's description and
its plan parameter say the shape; the example in the description already
carried the dash.
The check reads what a step says, as the others do: nothing runs the
console to see what it would fold. A step with no boundary that stays
within the cap is its own name and is accepted, so Check: go test ./...
needs no dash.
This has not been measured on the 121-task cohort; the caveats of the
amendment that asked plans to name their tests and docs apply. The
benchmark sees plan-write's description grow by about 75 characters in
every request's tool list, and its plan parameter by about 55; the
planner's draft prompt grows by about 135.
Amendment 2026-10-05: a question gets no plan, and a reply is read with what it answers
The plan gate turned a question into a change. Asked "review all setting
options against the documentation, what makes and what doesn't make sense
anymore?" (sessions 20261005T054223Z-36b27caa and
20261005T060116Z-ef99f036), the judge said it needed a plan, and a plan
in the shape this ADR asks for (amended 2026-10-03) is a plan of changes:
the model, told to follow it, wrote a review document into the repository,
tests and a dozen documents' edits, where the working rule says a question
is answered (amended 2026-10-02). In a chat's later turn the gate read the
person's message alone, so "folding, to reduce visual polution for the
user", a reply to the model's question, was judged and would have been
planned without what it answered (session 20261005T060224Z-2f9eb922).
plan-needed-question now asks whether the request means changing the
repository and says a question, a review, a comparison, an explanation or
a proposal needs no plan; and the gate's judge, explorer, planner and score
read a chat's request after the person's two messages before it
(plan-earlier-requests, plan-earlier-cap, plan-chat-request,
plan-request-with-earlier). ADR 19 (amended 2026-10-05) has the cases and
what the judge of a command is told. No working rule changed. The
benchmark's runs plan on every turn without asking the judge whether to,
and read their own task alone, so the 121-task cohort sees none of this.
Amendment 2026-10-05: the explorer's rounds and the reminder's count are tables by model family
Twelve rounds was one number for models that batch their calls very
differently. In the 73 session logs on this machine (2026-09-30 to
2026-10-05), calls to a round in the main loop were 2.75 for gpt-6.1-sol,
1.85 for gpt-6-luna, about 1.9 for claude-sonnet, 1.37 for muse-spark, 1.22
for glm-5.3-flash, 1.36 for qwen3.8-27b and 1.29 for deepseek-v4.1-flash. Of
the 16 explorer runs, gpt-6.1-sol (5) finished in 7 to 10 rounds at 5 to 6.6
calls a round and claude-sonnet-5-5 (2) in 4 and 10, but 5 of 7 muse-spark
runs (3.4 calls a round) and both gpt-6-luna runs (4 to 5) were stopped at
the twelfth with calls still asked for, and wrote their report from what
they had (session 20261005T153019Z-08490dd4 is one). The console showed
those skipped calls as failures (ADR 21, amended 2026-10-05).
plan-explore-rounds is now a table ((tuned knob), model-family-id;
docs/scheme.md §9.7): 16 for the muse-spark, glm, qwen, deepseek and
gpt-6-luna families and the default, 12, for the rest, gpt-6.1-sol and
claude-sonnet among them. The uncapped muse-spark runs ended at 10 and 11
rounds, the capped ones needed more than 12, and 16 is a first step, to be
read against the next logs; glm, qwen and deepseek have no explorer run
there and share muse-spark's row because they batch as it does (1.2 to 1.4
calls a round). A number still works for any model and 0 still turns the
explorer off.
reading-reminder-after is a table too, with no family row. The reminder
at 12 ended the reading in the next round for 8 of 11 muse-spark turns and
for 16 of 27 glm, 5 of 13 qwen and 0 of 2 deepseek turns, which another
count would not change, and gpt-6.1-sol and gpt-6-luna never read 12 rounds
in a row (their longest runs were 7 and 5), claude-sonnet once. Scaling it
by calls to a round would raise it for the families it works for. A row is
one line when a family's logs show 12 is wrong for it.
The local logs hold 16 explorer runs, six of them from one session, and show nothing about whether a stopped explorer's report is worse: the main loop reads 3 to 64 files after the explorer whether it was stopped or not. No benchmark ran with this.
Amendment 2026-10-05: a go test that ran is not folded as one that ran nothing
Hygiene's first rule folds a test command that never got to run a test
once a later one does, and it knew a run by pytest's and unittest's words
only ("passed", "failed", "ran "). go test says "ok
- A test command ran tests when it exited 0, since only a command that
failed may have failed before running one, or when its output says so in
a runner's words (
hygiene-ran-words, now with Go's, mocha's, jest's, node's and cargo's). - go test's "[build failed]" and "[setup failed]" say a package ran no
test, so their "failed" does not count (
hygiene-unran-words). - The fold's reason is the end of stderr, or of the output when stderr is empty, as go test reports a package that does not build: the folds above all said "()".
TestGoTestRunsAreNotFoldedAsRunningNothing runs a passing and a failing go test, a package that does not build and a pytest run: only the build is folded, and its fold names "[build failed]". On the unchanged code the two go test runs are folded instead.
Amendment 2026-10-05: the reads of a file being edited stay
Hygiene folds a stale read (of a file edited since) two rounds after it, and any read eight rounds old. Both sent the model back to the file it was editing: transcript.js was read 49 times in session 20261005T004207Z-f0ddf504, each fold followed by four or five ranged re-reads (seq 474-492, 560-578, 615-633, 704-722), and "old text not found" clustered after the folds.
Neither rule now folds a read of a file edited in the last
hygiene-editing-rounds (4) rounds: the file is being worked on. A read of
it folds once the edits move on, as before. fs-edit answers with the lines it
changed (ADR 43, amended the same day), so what the stale read no longer
shows the edit's answer does.
TestHygieneKeepsTheReadsOfAFileBeingEdited reads big.py and bigger.py, edits big.py twice: big.py's read stays, bigger.py's folds. On the unchanged code big.py's read folds two rounds after the first edit.
Amendment 2026-10-05: compaction that goes round in circles stops the turn
Session 20261004T182412Z-747045aa ran qwen-3.8-27b on Cerebras in a window left at its default 61,440 tokens and compacted 15 times in 24 minutes. Five compactions in seven minutes (18:38:51 to 18:45:21) made 125 requests and 103 reads, 88 of them of files read before, and no edit: each handover note asked the model to re-check its findings, which in 40K of room meant reading the same files again. 42% of the session's output went into notes, and it ended on a raw 402 with no report.
The second automatic compaction of a turn with no fs-write or fs-edit asked
for since the first now stops the turn instead (compact-stop-unchanged,
compact.scm): a note says why, the model is told to stop and say what it
found, what is done, what is left and what is in the way, and turn-stopping
(strategy.scm) makes ask-model offer no tools, so that answer ends the
turn; the finish checks (strategy.scm's evidence loop and this repository's
validate reminder) ask nothing more of it. A write between the two
compactions is progress, and the turn compacts again as before.
TestCompactionGoingRoundInCirclesStopsTheTurn covers both.
Amendment 2026-10-05: a worktree session lands its work with git-land
The worktree section said the changes reach the repository only through the branch, and to say they are on it. Asked to "commit and push to main", 34 times in the local sessions, models did it through the shell: quoting broke, signing failed and was turned off, a rebase hung, a branch was pushed where main was meant (ADR 34, amended the same day). The section now tells the model to run validate and then call git-land when the request asks for the work to land, never to commit, rebase or push through the shell, and never to turn commit signing off.
Amendment 2026-10-05: long thinking without an edit is told to write it down
In session 20261005T151325Z-d9519026, 14 calls in a row averaged about
19,000 reasoning tokens each, each emitting one read and no text, the
thinking working out the same fix again. The reply's thinking now goes back
to a Claude model (ADR 9, amended the same day), but not to every model, and
a model can think in circles anyway. Three rounds in a row whose reply
thought for 10,000 reasoning tokens or more and whose calls wrote nothing
now get a line asking the model to write the design it reached into a plan
step's note or a plan, then make the edit (stall-check in strategy.scm,
stall-rounds, stall-reasoning-tokens); model-turn's reply carries
reasoning-tokens for it. TestLongThinkingWithoutAnEditIsToldToWriteItDown
covers the count.
Amendment 2026-10-05: calls that do not depend on each other go in one reply
Most models asked for one call a reply, and every reply sends the whole conversation again: in the 75 local session logs of 2026-09-30 to 10-05, muse-spark made 1.41 calls a reply, glm 1.18, qwen 1.37, deepseek 1.27, claude-sonnet 1.64, gpt-6-luna 1.99 and gpt-6.1-sol 2.88. On the same task (model providers and efforts in the console), gpt-6.1-sol finished in 97 requests, muse-spark in 261 with a narrower result, and qwen3.8-max had 9 of 15 steps after 164 (20261005T022031Z-06eaad19, 20261005T003810Z-cebad9c2, 20261004T185312Z-c3eeb9d4). No working rule said calls could go together. One now does: the reads and searches the model already knows it needs go in the same reply, as does a read beside the test it waits for, since they run in order and come back together. Not measured on the benchmark yet.
Amendment 2026-10-05: the unchanged code is a copy of the last commit, never a stash
The working rule for comparing a failure with the unchanged code gave
git stash; <command>; git stash pop as its example, and a worktree session
was then told, in its own section, never to use git stash (ADR 34). Outside
a worktree a stash is no safer: a stash not popped, or popped onto other
changes, loses work, and this repository's own instructions forbid it. In
the local session logs of 2026-10-02 and 03 models stashed around tests that
had passed and once to rebase, in the person's own checkout
(20261003T002506Z-f564a28c seq 3111, 4515, 5803). The rule now compares on a
copy of the last commit (git archive HEAD into $TMPDIR/base) in every
session, and says never to use git stash for it. ADR 34's "outside a
worktree the habit is unchanged" no longer holds.
Amendment 2026-10-05: the explorer and the planner read the agent's last answer
The plan gate's explorer and planner, each in a context of its own, read a
chat's request after the person's two messages before it (amended above),
but not what the agent had last said, which a reply answers, nor the files
attached. In session 20261005T060224Z-2f9eb922 the explorer, shown neither,
said the request "does not identify what 'there' refers to", when a
screenshot and the agent's own question did; in 20261005T151325Z-d9519026
the explorer and planner of "fix all of them" could not see the findings
the agent's last answer listed. They now read the request after the
agent's last answer (cut at plan-earlier-cap) and with the names of the
files attached, and are told to say what they cannot tell from a file they
cannot see rather than guess (plan-model-request, (request-context),
plan-request-after-reply, plan-request-attachments). The judge reads
what it read: at jev.content = "plan" it is sent the person's asks and the
plan, and the agent's replies are not sent (ADR 19); nor is an attached
file's name. An image itself still reaches no narrowed context (ADR 22):
the planner reads its name only. TestThePlannerReadsTheLastAnswerAndTheAttachments,
TestTheJudgeReadsAChatsEarlierMessages and
TestImagesStayOutOfNarrowedContextsAndTheJudge hold this.
Amendment 2026-10-05: a plan written again keeps its marks
plan-write revised a plan and started its marks over. In
20261003T143916Z-48f31857 a rewrite that changed only step 6 (seq 5508)
dropped the marks of steps 1 to 5, done over the hour before, and a later
one word for word (seq 6528) was followed by six marks in 30 seconds. Each
mark is a reply that sends the whole conversation, and a lost mark shows
done work as undone, to the model and in the console's plan panel. A plan
recorded without marks in a turn that has marked steps now keeps the mark
and note of every step it keeps word for word, at its new number; a new or
reworded step starts unmarked (carriedMarks in internal/loop/prims.go). An explicit marks
(a carried run plan) is taken as given. The window shows kept marks under
the plan as "Marked before the plan was written again:", and a carried
plan's as before; only a carried plan had marks until now, so older logs
build the same windows. The reading reminder no longer says that writing
the plan again starts its marks over. TestAPlanWrittenAgainKeepsItsMarks
covers the marks and what the model reads.
Amendment 2026-10-05: the model keeps the project notes
The system prompt shows a repository's notes as the agent's own, but only a
person could write them, from the console, and none did: the 75 local
session logs of 2026-09-30 to 10-05 hold no save, and no repository had
notes (asked about it in 20261005T170658Z-fe9ea317, the person had never
seen a model use them). project-notes-save is now a tool the
model is offered, and a working rule asks it to keep there what a later
session would need and could not quickly find, with the notes it was shown
that are still true. Who writes the notes and why that is safe: they live
in nodwyn's data directory, never in the repository, so a clone cannot
write them; the model can, and a model misled by what it read could leave a
misleading note, which the next session in the same repository, reading the
same files, would meet anyway. The prompt calls the notes leads to check,
not instructions; a save is redacted, capped at 8 KiB and audited, content
that looks like a credential needs a person's approval, and the console
shows the notes and lets a person edit them. TestTheModelKeepsProjectNotes
covers the tool, the rule, a save carrying a token left to a person, and
the next session reading the notes.
Amendment 2026-10-06: optimiser edits wait for project trust
The optimiser previously treated every reload refusal as a failed edit and
restored the old file. With ADR 45, that would discard an approved proposal
because its new revision has not yet been trusted. It now keeps that edit and
reports that it has not run, while ordinary reload failures still roll back.
This is a host-authority correction, not a new model working rule; the regression
is TestOptimizeKeepsAnEditPendingTrustReview, using a scripted model.
Amendment 2026-10-16: a reload runs init.scm over what it first found
(reload) evaluated init.scm again over the bindings its first run left.
The wrap-and-extend pattern docs/scheme.md teaches, (define base-system-prompt
system-prompt) before redefining system-prompt, then captured its own
wrapper, which called itself without end, and before ADR 7's 2026-10-16
amendment that ended the daemon. The session now records, when init.scm
first loads, each global it changed and how that global was bound before; a
reload puts those back, runs the file, and records what the new revision
changes. Globals a turn sets are left alone, and names register-tool binds
stay with the tool table. A reload that fails, or whose prompt check fails,
puts every global back as it was, so the failure the optimiser reports leaves
the running strategy unchanged as well as the file. This is a host
correction, not a working rule; the regression is
TestReloadRunsAWrapperOverWhatItFoundFirst.
Amendment 2026-10-06: a failing test run is told, and an empty reply is asked again
Two checks in the default loop, built after a review of its finish gate. The
counts below are from the 161 local session logs of 2026-09-21 to 10-05 (185
turns, 59 of which changed a repository file), read from llm.resp,
tool.call and tool.result.
- A test run that exited non-zero is told once. The evidence loop set
testedfor any test or check run however it ended, andshell.scmcounts a run with failures as a run so the "cannot run the tests" way out stays for commands that never ran. A turn could therefore end with its last test run red and the evidence satisfied. The loop now tracks whether the latest test or check run since the last edit exited non-zero (run-failed?,redinevidence-loop), and a turn that ends on one is toldfinish-failing-messageonce: fix it, or compare on the unchanged code as the working rules ask, say the failure is not the change's, and finish. A run the model was told of is not told again unless it runs and fails again, andfinish-remindersstill caps the turn. A check script's failing run counts, soTestCheckScriptIsATestNotAChangeandTestFailingCheckRunsAreNotUnrunnablenow expect the reminder before the turn ends. No logged turn ended this way: of the 59, 25 ended after a green last run, 33 had noshell-exectest command after the last edit (this loop does not run in a chat, and a run through thevalidatetool is not such a command), and the one my count marked red had runvalidategreen after it. The gate comes from reading the code, not from a logged failure.TestAFailingRunBeforeTheLastEditIsNotToldpasses without the change; it is there so the reminder never counts a run before the last edit. - An empty reply is asked again once. A plain
(ask-model), the one that offers every tool, whose reply has no words and no tool call, is asked again withempty-reply-message, once (empty-reply-retries). The planner has its own retry (plan-empty-retries) and asks that offer no tools are left as they were. No logged reply was empty: 0 of 205 replies without a tool call, in the main context, across the local logs. - Not built. A re-ask for a reply that announces an action and calls nothing ("Let me…"): one of the 205, glm ending on "Committing now:" (20261004T043621Z-4f1e4886 seq 419), is too few for a rule that reads the reply's words. A planner rule to write steps as behaviour and not as code: none of the 89 logged plans quotes code. Keeping the last passing patch when a turn hits its limit: the repository already holds every edit, and restoring an earlier state would delete work the person can see.
Amendment 2026-10-06: plans say what the code should do, not code
plan-shape, the shape the planner and the revision prompt ask for, now adds:
say what the code should do as behaviour, in which cases and with what result,
not as code to type, since whoever carries the plan out reads the code and
writes it, and a function name or signature quoted from memory that the code
does not have misleads them. It answers the failure in this ADR's context, a
plan written without running anything overriding what the model's own checks
showed, from the plan's side: the planner reads the code but cannot run it.
No logged plan has the fault: none of the 89 in the local session logs of
2026-09-21 to 10-05 quotes code, so this is a guard that costs a sentence a
plan, not a repair. It was added at the person's request, and is worth
removing if a plan's quality, as the judge scores it, falls. The companion
rule, that a model checks a helper's definition before an edit that calls it,
is not added: it repeats "Read before you write" and cannot be checked.
TestThePlannerIsAskedForBehaviourNotCode covers the request.
Amendment 2026-10-10: the plan says where, the run decides how
The long-context SWE-bench cohort on workshop ran pi, OpenCode and nodwyn
(d64209d) on the same 40 tasks, three times each, with the same model
(qwen3.8-27b at 160k context) and the same task text, 2026-10-07 to 10.
Every attempt was read by hand; the per-task notes are kept with the
benchmark runs, not in this repository. nodwyn resolved the most (73 of 107
evaluated attempts, against 73 of 120 for pi and 66 of 120 for OpenCode), and
lost on two counts. On process: a median attempt took 11.6 minutes and 78
calls, against 5.1 and 44 for pi, half of it before the plan was recorded. On
outcome, on one kind of task: where a single detail decides (which helper,
which literal, which of two fixes), the harnesses without a plan chose it at
the edit, with the definition on screen, and nodwyn chose it in the plan,
from reading alone, and kept it.
- The plan's choices are guesses; the run decides. The working rule
called the plan's author "someone who read the code", and a model quoted
that back to keep the plan's wrong
offset=Noneagainst its own round trip (xarray-6599, 0 of 3, pi 3 of 3). The rule,plan-headerandplan-findings-messagenow say the plan was written before anything ran, that a run which contradicts it wins, and that a plan is rewritten, never a test or a reproduction made to agree with it (django-15916 edited its repro; scikit-learn-13124 and matplotlib-25960 rewrote tests).TestThePromptsLeaveThePlansChoicesToTheRuns. - A detail is settled at the edit, from what decides it. A new working
rule asks for the definition called or imitated, the code nearby that does
the same, and the request's own example in view before an edit that
settles an open detail, and not memory of an upstream fix. The 2026-10-06
amendment left this rule out as unmeasured; these runs measure it:
django-16877 (
conditional_escape, nodwyn 0 of 3, OpenCode 3 of 3 withescape_filterread just before the edit), django-16938, django-16899, django-11815; and recall lost django-11848 and scikit-learn-12973. - A test tells the fix from its nearest wrong version. Every losing run,
in all three harnesses, checked its change with the case it was designed
for (timedelta data starting at 0, plain strings,
'255'for 255). A working rule and theTest:kind now ask for an input that tells them apart. - The explorer lists; it does not choose. Its system prompt and task
put the code at the failure site first, ask for every place the change
must reach, the request's literals copied exactly and the options found
without a choice. What it listed won scikit-learn-12973 (nodwyn 2 of 3,
the others 0 of 6), django-15161 and matplotlib-24637; what it decided
("The fix is a one-line change …
offset=None", "escapeis already imported") lost xarray-6599 and django-16877, and rounds spent on release notes ran out before the definition that settled django-16938.plan-shapeasks the planner to name an open choice and what decides it rather than make it, and to copy the request's literals; the behaviour-only rule alone had dropped a detail exploration found (utcnow, django-11848). AChange:reaches every place the request covers, and aDocs:step names a document that describes the changed behaviour, never a ticket number nobody gave: 45 of 107 nodwyn patches edited docs (pi 23 of 120, OpenCode 13 of 120), two citing invented tickets.TestTheExplorerAndThePlannerLeaveChoicesOpen. - Only the tests the request changes are updated. "Update the existing
tests that encode the old behaviour" was quoted to leave eight tests red
(django-16938) and read as licence to rewrite pinned tests the request
never named. It now says which tests, that another test the change breaks
means the change is wrong, and asks the report to name each existing test
whose expectations changed.
finish-failing-messagesays to fix the change, not an existing test's expectations. - The unchanged code is the copy's code. In an editable install the copy of the last commit still imported the repository's patched code, and unpicking that cost about 25 calls on django-10914 and 50 on django-16938. The comparison rule puts the copy first on the import path and prints where the module came from. The stash stays forbidden (amended 2026-10-05).
- Finding the environment names no folder outside the repository. The
rule's example listed
/opt/*/envs, which the guard asks a person about: 55 of the 223 shell commands refused in nodwyn's attempts did that. It now usesconda env listand runs the environment's interpreter by its path, which the guard lets through (ADR 17, amended 2026-10-10). - The evidence loop reads state, not events.
- An exit that says no test ran is no outcome: 126 and 127, and pytest's 4
and 5 (
not-run-statuses,pytest-not-run-statuses). A missingflake8after passing tests (django-15161) and pytest on a missing path after the work was reported (scikit-learn-13124, 35 calls) were told as failing runs.TestAnExitThatRanNoTestsIsNoFailure. - A run on
$TMPDIR/basethat fails after the change's run failed is the comparison done (base-run?, outcomecompared), and the failing-run reminder is not said (django-13158, about 12 calls).TestAFailureOnTheUnchangedCodeTooIsNotTold. - The failing-run reminder is said once per edit, not again when the same
tests are run again.
TestAFailingRunIsToldOncePerEdit. finish-no-change-messageoffers a way out with evidence: a request the code already answers ends unchanged once a check has run. All three nodwyn runs of django-13513 found the fix in place and were sent back to change something, one breaking five tests for a while; pi and OpenCode stopped in 2 to 4 minutes. A stop with no check run is reminded as before.TestAnUnattendedTurnMayEndUnchangedAfterACheck.- New files the plan names are not called leftovers: the reminder named
the test and release-note files the plan asked for in 13 attempts.
TestNewFilesThePlanNamesAreNotNamed. - A reply cut off while it weighed options is told to settle them with a
run, not by which is shorter or which the plan named: told only to think
less, xarray-6599 r1 wrote "Let me be decisive: …
offset=None(the plan)".TestACutReplyIsToldToSettleOptionsWithARun. - The planner is asked again for a reply that is not a plan. Four of
107 recorded plans were none (a refused call, or command transcripts of
20,660 and 45,577 characters in fenced blocks), and two more quoted code in
fenced blocks. A reply with a call, a line opening a fenced block, or more
than
plan-max-chars(8000) characters is asked again withinplan-empty-retries.TestPlanGateAsksAgainForANonPlan. - Handover notes are short and act first. Two notes on django-15280 were
cut off at the output limit after about 59,000 characters of thinking and
kept one sentence, and a note listed the right fix as "ruled out" because
it seemed unlikely to match the maintainers'. The note request asks for at
most 600 words, the decided but unmade edit first, and "ruled out" only
for what a run ruled out; a note cut off is asked for again, shorter
(
compact-note-cut-message), and the handover makes the decided edit before re-running the reproduction, which a fix found before the clear on django-14351 waited 11 to 13 minutes for.TestACutHandoverNoteIsAskedForAgain. - Not built. Letting the explorer run code, or planning after the first reproduction: the core allows nothing irreversible before a plan is recorded in a turn that must plan, and the shell is irreversible, so it would change what the gate guarantees (ADR 19). A fast path that skips exploration and the plan for an ask that points at one function: the 40 tasks show no sign in the request that separates those where the explorer's listing won (scikit-learn-12973, django-15161, matplotlib-24637) from those where it was overhead (django-13315, sympy-15809, django-16116); the narrower explorer task above is what the data supports.
Each test above fails on d208b2c and passes with the change.
TestPlannerExploresFirst, TestUnreviewedTurnIsNotOfferedAWayOut and
TestModelWritesItsOwnPlan now expect the new texts.
Amendment 2026-10-15: older results can be recalled
Both system prompts said older tool results may appear as [elided seq=N]
or cut, and to "re-read a file if you need it again". Nothing brought back
anything else: in the 78 local logs of 2026-09-30 to 10-09 the window
elided 691 results in 8 sessions, compactions cleared three sessions'
earlier context, and 51 shell results lost their middle to the plain-text
rendering (shell.scm keeps the first 1,500 and last 6,000 characters).
The line now names recall, which brings an event back by its seq as it
was, and index-search with scope "conversation", which finds what was
said, run or shown earlier, and keeps fs-read for a file as it is now
(ADR 50). The line says what the tools are for; whether models use them is
measured as ADR 50 §13 says, and the line goes with them if they do not.
look-tools gains index-search and recall, which only look, so a run of
rounds spent searching the conversation counts towards the reading reminder
as rounds of reading do.
The same day, with the repository's index (ADR 50, R1), the rule on
reading code names index-search with scope "repo" for code found by
what it does or under names that would have to be guessed, keeping
index-grep for an exact string or pattern: 917 of the 1,614
regular-expression searches in those logs (57%) were alternations of three
or more guessed names, such as modelForm|credential|host|bind|freeClass|api_key.
index-search also joins the tools of the sub-agents that read the
repository: the plan's explorer, second-look-tools and the optimizer's.
Whether models use it is measured as ADR 50 §13 says; the rule and the
tool go if they do not.
Amendment 2026-10-16: benchmark habits fire only where they fit
Three habits and hints written for the benchmark's runs reached every chat.
The history hint (checkloop.scm: a git log or git blame before the
first code edit gets "call second-look with your check script") answered a
question such as "what changed in the last five commits?" with advice about a
check script, up to twice a session, and the edit flag it depended on was
never reset, so it stayed off for the rest of a session after one edit. The
hint is now given only in a turn that works toward evidence, as the finish
checks are ((or (unattended?) (plan-required?) (plan-recorded?))), and the
flag is reset at the start of every turn. The check-loop rule in both system
prompts now says it is for a request that asks for code to change and that a
question needs no script. The worktree section's "run validate first" named a
tool only nodwyn's own .nodwyn/init.scm defines: in another project's
worktree the model asked to land its work called it and was told it was
unknown, or skipped the step. The sentence is added only where (primitive-info "validate")
succeeds, as git-rule does for git-diff.
The per-turn resets (turn-stopping, stalled, compact-writes-at, the
check loop's request and edit flag, the sub-agent depth and label) moved from
wrappers of nodwyn-turn to wrappers of a new turn-start, which the host
applies to the request just before nodwyn-turn. A project that redefined
nodwyn-turn, as docs/scheme.md tells it to plan its own way, lost them, and
one turn that filled the context twice with nothing written left every later
turn of the session asking the model with no tools. ran-ok? is defined once,
in shell.scm, which owns it.
Calls the shell habit refuses (a command line sent through cmd and args)
reach the progress guard and the tool-call budget, through a third argument of
skip-tools: twelve identical ls -la | head calls with a tool-call limit of
5 ended with no stop after 13 model requests, where the same twelve real calls
stop at the fourth. The strategy's own skips (a sub-agent's repeats, tools off
for a handover note) stay out, since the sub-agent ends itself before the guard.
Amendment 2026-10-11: a check must be able to fail
Two runs of 9c44245 on the long-context cohort, read by hand against the
d64209d runs, showed one failure behind every task that flipped: no run fed the
candidate fixes the input that tells them apart. django-16877 r2 asserted its
recalled output; xarray-6599 never ran timedelta data that does not start at
0; django-14787 r2 read bound_method called with… as success; pytest-7205
r2's reproduction used a flag that does not reproduce. The person's own
sessions show the same in work that is no bug fix: Go tests and node --check
green while the page went blank on a click (the jev-key console change), and
"done" twice on broken UI with Go tests green (muse-spark). A rule to test the
nearest wrong version (2026-10-10) shows up in plans and rarely in the tests
that run, so the loop now checks what a rule cannot:
- Each part of the change against the check. Before an unattended turn
with a change ends on a check that passed since the last edit, the host runs
that check again with each part of the change taken out in turn and put back
(
check-without,internal/prim/without.go): a hunk of a changed code file, or a whole new or deleted file, at most 8 and within 5 minutes in all, documents, tests and comment-only hunks left out. The parts it still passes without are named, once a turn (finish-untested-parts-message): either a part is not needed, or the check cannot tell whether it works. It needs no notion of the task, only the diff and the model's own check, so it applies to a feature, a refactor or a fix alike. New files the untracked-files reminder already named are left out of the answer.TestCheckWithoutTakesOutEachPart,TestCheckWithoutStopsAtItsBudget,TestTheChecksUntestedPartsAreNamed. - A cited command must have run since the last edit. A command the final
report cites in backquotes, starting with a program the turn ran, that no
command line run since the last edit matches is named, once
(
stale-citations,finish-stale-citations-message). A plain "done" cites nothing and is not asked.TestAReportCitingAnOldRunIsAsked. - The rejected option, too. A working rule asks to run the check once
against the option a choice rejected: a check that passes there as well does
not decide it.
TestThePromptsLeaveThePlansChoicesToTheRunscovers its text. - Not built. Asking every claim in a report for its run: most reports end on a plain "done" with the evidence above it, and a reminder on each would nag. A check that the rejected option really fails: that needs the option written out as code, which the plan only names.
They are to be measured on the commit-replay set kept with the benchmark runs
(features, refactors and fixes from this repository's history, graded by each
commit's own tests) as well as on SWE-bench, since SWE-bench alone covers bug
fixes. Each test above fails on 9c44245 and passes with the change.
Amendment 2026-10-14: the repository's own tools are runs the harness sees
A trusted repository runs its checks through tools its .nodwyn/init.scm
registers: nodwyn's validate (make validate, in the background),
go-test {pkg} (go test -race) and run-make {target}. The evidence loop,
check-without and the plan's marks counted shell-exec runs only, so on
commit-bench (dsl18m, 1e595e5, trusted, 2026-10-14) they did not see those
runs:
- the end-of-turn evidence message ("has not passed since your last edit") fired in every attempt, mostly after a validate or go-test that had passed since the last edit: on 3422faf1 steps 3, 4, 6 and 8 after validate passed at call 111. About 73 calls, 0.6M tokens and 33 minutes over 10 attempts followed it, an upper bound since some of it was real work;
- hygiene's first rule folded validate's passing result as "this test command
did not run any test ()" in 7 of 10 attempts, since no word of
hygiene-ran-wordsis in its summary; - a run whose exit status was another command's marked plan steps passed: on
3422faf1 steps 3-6 from a run on the copy of the last commit whose tests had
failed, each ending in
| tail -n 8 || true, and on c80b38ac a step from| tail; echo exit=$?.
So:
- A tool is the command it runs.
tool-commands(strategy.scm) mapsvalidateto make validate,go-test {pkg}to go testand run-make {target}to make; a project may add rows. tool-callmakes the shell-exec call that runs that command from the tool call's own arguments, never from its output, andtool-resultreads the status the tool's text reports ("exit N" first, or a background run's "pN: exited N").command-lineandtest-call?see the command, and the evidence loop counts such a run toward the turn's test runs, the commands that passed (passed), the passing check check-without takes apart (passing), and the commands a report may cite. The plan's marks (plan-language-round) see them too. A tool's whole check (validate) is not check-without's check: it runs for minutes against check-without's five for all its runs, as a shellmake validate && make accept; echo exit=$?did on 3422faf1, naming nothing; the check before it stays. Its pass counts as the whole check's for the plan's evidence and for the report's citations (whole-check-passed?), and the repository's own gate test (TestValidateCountsAsARunsTestRun) still sees a run end after validate. - validate's summary says it ran.
hygiene-ran-wordsgains "validate: pass", "pass gofmt" and "tests vs unchanged"; a failing validate already said "failed". The fold of a tool's run that ran nothing gives the end of the tool's text as its reason, not "()". - A masked status is no evidence without output. A run passed, for the
plan's marks and its end-of-turn evidence, when it exited 0, was not on the
copy of the last commit (
base-run?), and, when its status may be another command's (masked-run?: an unquoted pipe without pipefail, an||, or a$?in the line), when its output shows tests passing (--- PASS:, go test'sok,validate: PASS) and none failing (run-entry). A Go test step (a_test.gofile) takes a run only when its output shows the test (--- PASS: <name>) or its package (ok ... /<dir>, not "[no tests to run]") passing; a go test of the package with no-runcounts for every test step in it, which is how the go-test tool runs.TestTheRepositorysToolsAreTheRunsTheyMake,TestARunWhoseStatusHidesItsTestsIsNoEvidence,TestARepositorysTestToolsAreFoldedByWhatTheyRan. - Not built. Masking for the failing-run reminder and for check-without:
check-without already tells a part apart by output as well as status. Test
steps whose test is not in the file the step names (an exfil scenario run
by
TestScenarios). Reading a browser check's own summary (== credentials: ok in 6.1s): it is this repository's runner, not a test runner's, so a browser check piped into tail (on c80b38ac's runs,run.sh credentials 2>&1 | tail -n 3) is no longer a step's evidence, and validate, which runs the browser checks for a console change, is. Countingmake acceptas a whole check.
Replayed through run-entry, the logged calls of 3422faf1 give: the
base-copy run (call 78) no evidence; generate.py --check; echo exit=$?
(call 108, which printed exit=1) none either; make validate && make
accept; echo exit=$? (call 119), whose output shows its passes, evidence; and
each go-test of a planned test's package, evidence for that test.
A go-test run is now check-without's check when it is the latest that passed,
which no trusted run's was before: one package's tests may still pass with a
part in another package taken out, so the untested-parts message may name
parts other runs test, as the dsl18m review found for shell runs. Each test
above fails on 1e595e5 and passes with the change.
Amendment 2026-10-14: a sub-agent wraps up with the tools it had
A sub-agent out of rounds, or stopped for repeating a call, was asked for its answer with no tools. A local server lists the tools near the start of the prompt, so that request shared no prefix with the ones before it and the whole context was read again. On commit-bench (dsl18m, 2026-10-14) the explorer's last request re-read 25,000 to 48,000 tokens so in 16 of 18 attempts, about 0.03-0.05M cost-eq (4-7% of a task) and 13-27 s each, and dsl16 and A did the same.
So sub-agent-wrap-up asks as the sub-agent did, offering its tools, after
sub-agent-wrap-up-message; a call the model makes anyway is skipped with
sub-agent-text-only-reason, and the model is asked once more with no tools,
as before. At most two asks, as before. TestSubAgentOutOfRoundsStillAnswers,
TestSubAgentRepeatingItselfEndsOnlyItself and
TestSubAgentWrapUpSurvivesAToolCall now expect the tools on the wrap-up
request, and fail on 1e595e5.
Amendment 2026-10-15: the whole check stands only for the plan's check steps it runs
Before an unattended turn ends, the plan's steps are set against what
happened (plan-language-check, planspec.scm), and the repository's whole
check passing since the last edit (whole-check-passed?: make validate,
make check, make test, ...) met every Check step, whatever its command. A
whole check runs the repository's tests, builds and linters, not every
command a plan names: on commit-bench (dsl16, 550e3b9, c97f9a41, session
20261013T224535Z-ac7eaa5c) the planner's python3
docs/book/source/generate.py && python3 docs/book/source/generate.py
--check step was met by a run of make validate (call 3217) that ran no
generator, after two test edits (3187, 3211) had changed the line counts the
book's inventory records. The evidence message (3248) named two test steps
and not that one, and the change ended with the inventory stale, which the
parent's validate did not check.
So the whole check stands for a Check step only when the step's command is
one that checks the code (check-command?: a test run, a build, go vet, a
linter, a make target), as the whole check does. Any other Check step, a
generator's --check, a browser check or a go generate, is met only by a
passing run of its own command since the last edit; until then the evidence
message names it ("step N (Check) TestAWholeCheckStandsOnlyForTheChecksItRuns fails
on defffcd and passes with the change.
Of the 107 Check forms in the planners' final plans across the recorded
commit-bench sessions (257), 101 are commands that check the code, which
this leaves as they were. Of the other six, two are this generator (dsl15
and dsl16, c97f9a41), two go generate ./internal/frontend/rpc, one the
bare tool name validate, which the validate tool's own run (make validate)
meets, and one go run ./scripts/validate (dsl16 on another model,
8e172440), which is what make validate runs: that step would now be said
after a pass of the validate tool alone, a round spent on a step already
met. In that session the model ran it itself (call 1085), which meets it.
- Not built. The evidence message is said once a turn, so a Check step
met when it fires stays met through later edits. The deep study's example
of this rule, 107251d0 under dsl19, is that case and not this one: its
plan's check was the planner's
go build ./... && ... && make validate, a check command, and the message had fired (call 3921) before the test file that made the inventory stale (3935). For nodwyn's own tasks validate now runs the inventory check (ADR 37, amended 2026-10-15); this amendment covers repositories, and older snapshots of this one, whose whole check does not.
Amendment 2026-10-15: a compaction gives the plan back
A compaction narrows the context, and the window keeps what follows the narrowing: the handover note. The turn's plan is a message where it was recorded, before the narrowing, so it went with the cleared conversation. In the trusted commit-bench runs of A, dsl16, dsl18m and dsl19 (2026-10-14 and 15), none of the 37 compactions left a plan in the window, though every one of those turns had recorded one. On 6e18b7ba (dsl16) the model had worked out all six edits by ten minutes and wrote none before the context filled; its note said "plan.md is on disk at repo root with all 19 steps", but plan.md is the repository's own roadmap and the plan was the harness's. After the clear it concluded the plan "isn't actually in the repository's plan.md file, so I need to rebuild it", read the code again and spent 13,200 tokens working out the design a second time.
So compact-context adds the turn's plan after the note (compact-plan,
compact.scm): the plan as recorded, under plan-header, with the latest
mark of each step and its note under "Marked before the conversation was
cleared:", as the window lists a carried plan's marks, and a line saying the
harness keeps it and no file holds it (compact-plan-message, prompts.scm).
It is read from the log with unfinished-plan, the plan this turn would
leave to the next, and only when the turn has recorded one (plan-steps):
a /compact between turns adds none, since the next turn carries an
unfinished plan itself. It is a harness message, so the window and its
replay are unchanged; it stays in the conversation after the turn, as a
recorded plan does. The note request now says the plan comes back after the
note, so the note leaves it out and does not say a file holds it, and that a
file holds something only when the work wrote it there or read it there.
A compaction now carries about the plan's size more (a few hundred to 2,000
tokens). TestCompactionCarriesThePlanWithItsMarks fails on d880bb3: the
compacted request is the system prompt and the note alone, and the note
request says nothing of where the plan is.
Amendment 2026-10-15: a turn that fills twice gets a write round before its report
The stop of 2026-10-05 is right that a third compaction would repeat the second, and wrong to write nothing when the model knows the edits. On 6e18b7ba (dsl16) the context filled the second time at 36 of the run's 60 minutes with nothing written; asked without tools for a report, the model answered that all six edit sites were confirmed, listed every code change as left, and the run ended with an empty patch. The other runs of that task (A, dsl18m, Pi) had written their code before their compactions and found it on disk after them.
So the stop first gives a write round (compact-stop, compact.scm): the
model is told "Your note and plan name the edits: make the code changes now;
tests and docs come after", and to report what is done and what is left
after them, and is offered compact-write-tools (fs-edit, fs-write,
read-definition, and a repository's go-build: those of them the session
has) for at most compact-write-rounds (6) requests. Then
turn-stop-report asks for the report without tools, and that answer, or
one the model gives sooner, ends the turn as before; the finish checks still
ask nothing more. While the turn stops, ask-model skips a call to a tool it
did not offer (as compact-note does), and a reply whose every call it
skipped spends its round and is asked again; before, such a call with the
report would have run. A turn that is stopping is not compacted again, so
the writes of the write round do not lead to a third compaction. With
compact-write-rounds at 0, or neither edit tool in the session, the turn
stops with the report alone, as before.
Not built: the reading reminder at 4 rounds rather than 12 while nothing has
been written since a compaction. The risk is a hasty edit in a run that
would otherwise have ended with none.
TestCompactionGoingRoundInCirclesGetsAWriteRound and
TestAWriteRoundEndsWithAReportWithoutTools fail on the commit before this
one; TestCompactionGoingRoundInCirclesStopsTheTurn now covers the report
alone, with compact-write-rounds at 0.
Amendment 2026-10-15: a shorter explorer for qwen, and folds at three quarters
Two knobs set by the earlier amendments were read against commit-bench's trusted runs of A, dsl16, dsl18m and dsl19 (qwen3.8-27b, high water 102,400, 2026-10-14 and 15).
The explorer. qwen's row of plan-explore-rounds was 16, set on 2026-10-05
with no qwen explorer run to read. The explorer used all 16 rounds in 39 of
48 runs. It read 571 files, 296 of them first after its eighth round, and
the model doing the work read 313 of the 571 again itself, 154 of those 296
among them. The deep study put the explorer at 0.13-0.15M cost-eq a task. The
row is now 8. Expected: about half the explorer's requests and their prefill
go, an estimated 0.05M a task. Risk: the planner sees fewer facts; the 142
files the explorer read after its eighth round and the work never read are
the facts it may miss, so plans and their reviews on the nine are what to
read next.
Hygiene's pressure. 94 of the 108 hygiene folds in those runs were
pressure folds ("the context is filling the window", past half the high
water), 63 to 79 of them with the context under three quarters of it. Each
fold makes a caching server read the rest of the prompt again, and the
model read files whose reads had been folded again 57 to 156 times an arm
(some of them to edit them, which a fold does not cause). hygiene-pressure
is now 0.75. Expected: most of those folds wait until three quarters, or do
not happen before the turn ends or compacts, and below three quarters a
batch still folds when it pays for its re-read or the cache is not serving.
Risk: requests carry more context, which a working prefix cache serves
cheaply, and a long turn may reach the compaction threshold sooner.
It meets hygiene-keep-pressure (0.75, 2026-10-14), which holds the reads of
files the plan will change until the turn edits them. The two are equal: below
three quarters a held read stays and the others fold only when it pays; past
it every due read, held ones too, folds in the same sweep, one re-read of the
prompt where the old values made two (the others at half, the held ones at
three quarters). The held reads fold at the share of the high water they
did, though a context folded less may reach it sooner. Were
hygiene-pressure set above hygiene-keep-pressure, a held read would lose its
hold before the others fold whatever the cache; a project that raises one
raises both.
TestTunedValuesFollowTheModelsFamily and TestExplorerRoundsFollowTheModelsFamily
(a qwen explorer is stopped after its eighth round) and
TestHygieneWaitsForThreeQuartersOfTheHighWater fail on the commit before
this one: at 0.62 of the high water a fold that does not pay waits, and a
held read stays while the other folds because it pays, where before both
folded because the context was past half. Its case past three quarters, the
held and the other read folding in one sweep, passes on both.
Amendment 2026-10-15: the finish checks are said once, together
Before an unattended turn ended with a change, four checks could each send the model back once a turn: the new files git does not track, the plan's steps without their evidence, check-without's untested parts and the report's stale citations. Each answer was an edit or a script, which re-armed the reminders before them ("You changed files after your last test run"), and a trusted repository's own reminder ("validate has not run") came on top. In the deep study of commit-bench (2026-10-15, reports 12 and 13):
- on five tasks set against Pi, the rounds after the first finished report cost A 0.85M tokens and dsl18m 0.41M, in 21 segments 17 of which edited nothing in the repository; on the ten held-out tasks 8 of A's 10 runs were sent back after a final reply, adding 193 calls, check-without 1.80M of the 2.16M;
- on c97f9a41 (A) six chained reminders cost 0.38M with no edit, among them
"validate has not run" twice, right after
make validatehad run in the shell:.nodwyn/init.scmsetsvalidated-stateonly when its validate tool runs; - deletions made 7 of the 69 parts check-without named, and on 8c795994 one message named nine deleted empty logs "(deleted file, +0 -0 lines)", whose answer cost 19 calls and 200k tokens changing only a script in the scratch directory.
So:
- One finish review, once a turn. When a turn with a change first ends
and no reminder of the turn's evidence is due (no change, no test run since
the last edit, a failing run),
finish-review-points(strategy.scm) gathers the new files, the plan's unmet evidence (the steps that have it are marked as before), check-without's untested parts and the stale citations, andfinish-review-message(prompts.scm) says them in one message: one point as before, several numbered, closed byfinish-review-close, which says it is asked once, asks for the check to run again after any change, and says the next answer ends the turn.TestTheFinishReviewIsOneMessage. - The turn is accepted after it. An answer to the review ends the turn;
the evidence reminders are not asked again.
TestAnAnswerAfterTheFinishReviewEndsTheTurn. - check-without leaves deletions alone. A deleted file and a hunk that
only deletes lines (its added lines, if any, comments or blank) are no
parts: a check that passes with the deletion put back cannot be extended to
fail without code that is gone, and asked to, models wrote ablation
scripts or kept dead code. Documents were never parts (
codeFile).TestCheckWithoutLeavesDeletionsAlone. - A shell make validate is validate's run. After a run of
make validatethat ran to an exit,validated-runsetsvalidated-stateto(validation-state), as the validate tool does, in a repository whoseinit.scmdefines both; elsewhere it does nothing. The benchmark runs the harness against old snapshots whoseinit.scmit cannot change, and every snapshot since validate came (ea9921b, 2026-10-03) defines both.TestAShellMakeValidateCountsAsValidated.
What is kept: every check still runs at the first finish, with the same
findings and wording; the evidence reminders still come first, and the
failing-run reminder still lets a turn end on failures the unchanged code
shares. What is given up: a second chance after the review, so an edit made
in answer to it and not tested again ends the turn untested (the review asks
for the run), and an untested deletion goes unnamed. The analysts estimate
0.1-0.2M tokens saved per task where the checks fire; the three segments of
the study in which a reminder led to a stronger test (A and dsl18m on
1d401bf3, dsl18m on 7513f7eb) would get their message once. Each test above
fails on d880bb3 and passes with the change.
Amendment 2026-10-15: the finish review names what the change removed
Three of the benchmark's losses came from what a change took away, each after the model's own tests had passed (deep study of commit-bench, 2026-10-15, reports 02, 03 and 04):
- on 67a1f950 dsl16 and Pi fixed the behaviour by rerouting
rootRefusaland deletingevalOras dead code; the hidden test callsevalOr, which the task never names, so neither patch built against it. check-without named the deletion and dsl16 kept it, "rather than reintroduce an unused helper"; - on 94f5cac4 Pi turned the visible case "count 0 is an error" into one for -1 before its first test run, and dsl16 followed its planner in the same;
- on ef04de9c Pi deleted the negative case its new JWT pattern broke, and on 5040f9fe it rewrote the stream cases of the test that then failed as a pass-to-pass test.
The working rule that names each changed test and the request's words behind it was in every one of these versions, and was not followed.
So change-removals (internal/prim/removed.go, hidden) reads the change
against HEAD and the finish review names, when the latest run since the last
edit passed (the model's check did not notice):
- Definitions gone from their package. A top-level Go function, method,
type, variable or constant (Python's functions and classes, Scheme's
defines) that HEAD has in a changed non-test file and no file of the same
language in its directory defines now; a name in another case counts,
since a test calls it by name. A name the request gives is left out, whole
or in words that spell it ("the guard's workspace-kind checker is gone"
for
WorkspaceKind, "the first known finding" forfirstKnown).finish-removed-definitions-messageasks to keep it and have its callers go through it with the new behaviour, which a hidden caller and staticcheck both accept, or to say which words of the request remove it. - Expectations of existing tests. A line of an existing test
(
x_test.go,test_x.py,x_test.py) that states an expectation, a case of a table (a line ending in a comma that holds a literal) or an assertion that carries one (testify, Python'sassert), removed and added back nowhere in the change's test files once white space and trailing comments are set aside: gofmt's realigned columns and a case moved to another file are not removed. At.Errorf's message and theifbefore it are not counted: with them the replay below flagged 84 patches, most of them ordinary rewrites.finish-removed-expectations-messageasks to restore each line the request does not change, or to quote the words that change it.
TestTheReviewNamesWhatTheChangeRemoved, TestTheReviewIsQuietOnRemovalsTheRequestNames,
TestRemovedDefinitionsAreTheOnesGoneFromThePackage,
TestRemovedDefinitionsTheRequestNamesAreNotNamed,
TestRemovedExpectationsAreCasesAndAssertions,
TestChangeRemovalsReadsTheWorkingTree.
Replayed over the benchmark's patches (each applied to its task's parent,
306 that apply: 233 attempts and 73 original commits): definitions are
named in 12 attempts and 5 originals, among them dsl16's and Pi's evalOr;
6 of the 12 remove what the original removes too (e752f151's errDocsEval
and code-workspace?, 1fd4c3e7's latex). Expectations are named in 31
attempts, among them Pi's ef04de9c, 94f5cac4 and 5040f9fe cases; the
originals cannot be counted, since their patches leave out the hidden test
files. So the note stays advisory, and costs a round on its own in at most
about one attempt in six. Not built: running the change's check with the
test files as HEAD had them (report 04's "tests as they were"), which would
name only cases the change breaks, at the cost of a run of the check; and
the planner rule that a (change ...) step fixes a function rather than
deleting it (report 02), held for want of more than one case.
Amendment 2026-10-15: the lines a change adds to its documents stand on the turn's runs
No rule read what a change wrote into its documents and comments, and the deep study of commit-bench (2026-10-15, reports 05, 07, 09, 10 and 11) found 15 claims in 36 candidate patches that nothing in their run showed:
- the task's own account of its author's fuzz run, "40 s (9.4M executions) found nothing", in ADR 25 of three builds: dsl18m and dsl19 never fuzzed (dsl19's fuzz runs were refused by the guard), and A's own run, made after a reminder, counted 29.5M executions while the ADR kept 9.4M;
- "make validate passed with zero warnings" written before validate ran (dsl16, 107251d0; both runs passed later);
- amendment dates guessed: "the day after the last amendment, the natural today" (dsl18m), "I'll use 2026-10-03" (dsl19);
- an amendment of ADR 9 pasted twice by one fs-edit whose new text re-typed the paragraph before its anchor (A, 5040f9fe);
- 24 Baseten entries of models.json, whose head names models.dev as its source, written from memory by dsl18m, dsl19 and A's other run, and copied by dsl16 from another agent's catalogue (dc679e46).
So the finish review names them (change-claims, internal/prim/claims.go,
hidden; finish-unshown-claims-message, one point however many lines):
- A sentence that reports a run (in the past tense or "passes with":
passed, N or zero warnings, found nothing, executions, a fuzz or benchmark
figure; not a caveat such as "not run here"), in a document or a Go or
Scheme comment, stands on a run of each command it names (
make validate,make accept,go test, a fuzz, a benchmark, or any test command) that exited 0 in the turn, whose last six lines (of a fuzz run's progress lines, the last) hold each figure it gives within 5%. The request's account of its author's runs is no such run. - An amendment date the file did not have stands on a command that
printed it, as
2026-10-15or20261015, or on the request; the message suggestsdate +%F. Each date is named once. - Three or more lines a document's edit inserted (120 characters or
more) that stand word for word elsewhere in it are named, as report 05
proposed for
editedRegions; read from the diff at the finish rather than in fs-edit's answer, so it is one point of the one message. - Entries added by hand to a generated file (its head says Code
generated or Generated by, or names a
"source"address, or a trusted.nodwyn/init.scmlists it ingenerated-files) stand on a command after the last hand edit holdinggo generate, the generator its head names, or its source's address or host.
The loop keeps the turn's commands for it (shown-entry): each command
line, its exit status (none for a run on the copy of the last commit) and
the first 2,000 and last 8,000 characters it printed, at most 200; the file
tools and the tools that only look are left out, since their answers show
the model's own text. TestTheReviewNamesClaimsNothingShowed,
TestTheReviewIsQuietOnClaimsARunShowed,
TestARunClaimStandsOnARunThatPrintedIt,
TestRunClaimsNameEachCommandAndLeaveProseAlone,
TestAnAmendmentDateStandsOnACommandThatPrintedIt,
TestARepeatedParagraphIsNamed,
TestHandEditsOfAGeneratedFileStandOnItsGenerator,
TestChangeClaimsReadsTheWorkingTree.
Replayed over the benchmark's patches with each nodwyn run's session log (its commands, exit statuses and output, and its hand edits): on the 36 diffs of the study 10 runs are named, all for what the reports found: the fuzz line of A, dsl18m and dsl19, the two guessed dates, A's pasted paragraph, and the four hand-written catalogues. dsl16's backed validate line and its own fuzz stay silent. Over the 176 nodwyn attempts with a log, 21 are named; the one false alarm found is a fuzz run read in the background whose exit no read saw (A, 0a9f700, 3422faf1), which the harness does not count as a run either. Not built: the server behaviour stated as known, and a citation the plan made up (report 11's other cases), which no command's output can settle.
Amendment 2026-10-15: a dropped part of a condition is named, and plans say which parts stay
On commit-bench's c80b38ac (deep study, 2026-10-15, report 08) dsl19's
planner turned "judge requires ready" into result.Judge = (state ==
stateReady), though the old line it had been given read
s.options.Config.Jev.Enabled() && .... The executor saw the dropped check
four times, named the case where the judge's content is none, and followed
the plan each time; no test had that case, so every check passed and
validate passed. dsl16's planner made the same drop and its executor put the
operand back, "safer". Nothing in the harness looked at a condition the
change removed: the plan's evidence marks a change step done when its file is
written, and check-without takes out whole hunks, and this hunk was needed.
So:
- The finish review names a dropped part of a condition.
change-dropped-conditions(internal/prim/operands.go, hidden) reads each Go function of a changed non-test file whose body changed: the operands of the&&and||of its old body, and the condition of each old guard (anifwhose block ends in a return), that its new body no longer holds, white space and a leading!aside. An operand that stands in the body of a function of the package the new body calls, one the change added or rewrote, is still checked; so are error and ok checks, literals and one-letter names, left out; and so is an operand with a word the request names (the request is about it). When the latest run since the last edit passed,finish-dropped-conditions-messagesays: "Your change no longer checks X in F(), and your check passed without it. If the task drops it, add a test case where it is false and say so in your report; otherwise put it back." Report 08 proposed "your check passes either way"; the harness has only run the check without the operand, so it says that.TestTheReviewNamesADroppedCondition,TestDroppedOperandsAreThePartsTheNewBodyLacks,TestDroppedOperandsLeaveMovedAndNamedConditionsAlone,TestChangeDroppedConditionsReadsTheWorkingTree. - A plan step that changes a condition names its old parts.
plan-language-shape(planspec.scm) asks: "When a step changes an existing condition, it names each part of the old condition and says whether that part stays."TestThePlannerSaysWhichPartsOfAConditionStay.
Replayed over the benchmark's patches: on c80b38ac's original and the four
builds of the study (A, dsl16, dsl18m, dsl19) it names dsl19's
s.options.Config.Jev.Enabled() alone; the keychain operands the others
dropped from that line still stand in its stored line. Over all patches
that apply (310: a run in progress had added four since the replay of the
removals above) it names something in 24 of 237 attempts and 13 of 73
original commits, most of them conditions the task rewrote on purpose (validate's
book rules on 1fd4c3e7, the pending ask's fields on 48f90ea8), so it stays
advisory. Without the request's words left out it named 53 attempts and 21
originals. Not built: Python and Scheme conditions.
Amendment 2026-10-16: a changed expectation of an existing test is asked about at the edit
The review of the Muse runs on commit-bench (2026-10-16, eight tasks, transcripts of dsl20r, dsl16r and Pi) found that both losses of dsl20r and the one of Pi are one mistake: an existing test fails after the change, and the agent picks the wrong side.
- 94f5cac4 (dsl20r, at high and at low): the planner wrote "count 0 stays
and means default", which the request does not state (it says "fs-read's
start 0 is line 1", and nothing of count). Both runs then rewrote the
existing row
(fs-read "big.txt" 1 0), "count must be at least 1", to1 -1and1 -2; at the high run's round 22 the old failure was printed and round 23 called it "failing as expected". dsl16r and Pi kept the row. The finish review named the row, as one of six lines of one of four points, some twenty rounds after the edits, and the answer defended all six by quoting the request's rule for a skipped argument, whose words do not name a count given as 0. - 855e90e7 (Pi, the mirror): an existing test failed after Pi's correct
wiring, and Pi added an untested
overlapsReposkip to production code that removes exactly the directories the hidden test expects denied. dsl20r met the same failure and fixed the test, as the real change did.
None of the extra verification (review loops, check-without, comparisons) caught either. The owner's principle is that a harness does not block the model's choices: it asks, and follows the choice. So both additions below are a rule and a question, and no edit is refused.
- A working rule. Both system prompts say: when an existing test fails
after the change, decide which side is wrong before editing either; the old
expectation stands unless the request names the new behaviour for that
input or case; never add a conditional skip or exception to production code
(least of all in a guard or denylist) to make an old test pass unless the
request asks for it, and fix the test's setup when it contradicts what the
request names; a branch you add gets a test for each side of it.
TestThePromptsDecideWhichSideOfAFailingTestIsWrong. - The question at the edit. After a round whose calls wrote a test file
(
test-file-path?:x_test.go,test_x.py,x.test.js, atests/folder) that the turn has not been asked about, the evidence loop sets the change against HEAD withchange-removalsand, for that file's lines, says once (test-edit-nudge,edited-expectations-message): "Which sentence of the request requires the new expectation, for that input or case? A rule the request states in general words does not cover an input it does not name. If none does, restore the line and change the code to meet it; if one does, say which in your answer." It lists each line aspath:line: text, as HEAD had it. The edit is never refused. A test the turn added has no line at HEAD, and neither has a case it adds to a table, so neither is asked about; a file is asked about once in a turn (the evidence loop's state is the turn's), whatever else of it the turn changes. The finish review still names every such line when its latest run passed: it is the last place to ask, and it is unchanged. The question is about the input or case, not the rule: on 94f5cac4 the wrong row's count was given as 0, and "a skipped argument gets its empty value" does not name it. The chat's loop, where a person reads each answer, does not ask.TestAnEditedExpectationOfAnExistingTestIsAskedAbout,TestTheQuestionOnAnEditedExpectationIsAskedOncePerFile,TestTheQuestionIsNotAskedOfTestsTheTurnAdded.
The new tests fail on 204f6f1 (each asserts what that code does not say) and
pass here. The 94f5cac4 patch of dsl20r, applied to its parent and read by
change-removals, yields the six lines of the review above: the question
would have been asked three times, at the rounds that edited
json_test.go (98, 99), fs_read_range_test.go (59, 60: the start row the
request requires and the count row it does not, together) and
index_grep_test.go (187, and 188, which was only moved from a map to a
direct assertion, a false alarm of change-removals that the finish review
already had). No model was run: whether the rule or the question changes what
a model does is for the next benchmark. Not built: refusing or reverting the
edit; the question for a test deleted or rewritten through the shell (the
finish review names it).
Amendment 2026-10-16: plans keep unstated behaviour apart, and the review asks for the lines no test ran
Two more losses of the same review (2026-10-16, muse-review):
- 94f5cac4, the plan: "count 0 stays and means default" was written as a
step although the request never states it, and the exploration's report had
listed
TestReadRangesas testing "start 0/count 0 errors" and quoted thecount must be at least 1check. A behaviour the request lacks, planned as if it were stated, is what the run then rewrote a test to match. - 3422faf1 (dsl20r, third replicate):
mergeFindingsheld a dead branch,f.Class == ClassUnknownEntropy && curIsBlock, which tests the wrong variable. The plan said only "drop an overlapping unknown-entropy token", with no positions; the probes covered a token inside the other and one crossing its start, never one crossing its end; the diff review missed it. The same test failed in dsl15, an earlier Muse dsl16 build and OpenCode.
So:
- Plans copy only what the request states. The planner's prose shape
(
plan-shape), the plan language (plan-language-shape) andplan-write's description say: a step carries only what the request states; behaviour it does not state goes on an unnumbered line after the steps, "Assumptions: ...", each checked against the existing tests the exploration found before a step relies on it, and one a test contradicts is dropped; for each rule the request states, the cases are listed by clause, and for a range, an overlap or an edge by position: inside, crossing the start, crossing the end, equal, empty. In the plan language the line is a part likenamesandcontext:(assumes "behaviour" "the test or code it was checked against"), written out as one unnumbered line (a plan's steps are its numbered lines, so the marks and the console are unchanged). Nothing checks it.TestThePlannerKeepsUnstatedBehaviourApartAndListsCases. - The finish review asks for the changed lines no test ran. When the
latest run since the last edit passed, a source file (
coverage-extensions) was changed, the lines the change adds hold a branch or a condition (branch-words, read fromgit-diffand from new files) and no command run since the last edit measured coverage (coverage-run?), the review addsfinish-uncovered-lines-message: the check passed, which does not show a test ran each changed line; run the tests of the changed files once with coverage, name the changed lines none executed, and add a test that executes each or delete it; if coverage cannot run, read the change against the tests. The loop knows which commands passed and none of them says which lines ran, so it asks the model to run coverage once rather than parse a tool's output. check-without takes out whole hunks and a dead branch lives inside a hunk the check needs, which is why 3422faf1's review was quiet. Gated on an added branch so a straight-line change does not cost a review of its own: none of the earlier finish-review tests adds one, and none changed.TestTheReviewAsksForTheChangedLinesNoTestRan,TestTestFilesAndBranchesAreToldApart.
No model was run. The review's point costs a round (the coverage run and what follows) in each turn that adds a branch and passes; a benchmark run is the measure of whether that buys the tests it asks for. Not built: reading coverage output in the loop; a check that an assumption was compared with a test; the report's other candidates (gate work, sandbox paths, re-reads after folds).
Amendment 2026-10-16: the scratch rule says what TMPDIR is, long files are read by the part, and the explorer's reads go with its findings
The review of the nine tasks' dsl20r transcripts (commit-bench, Muse,
2026-10-16, muse-review-2026-10-16.md) found two costs the working rules
could name.
- The scratch directory and the commands that use it. The model's
TMPDIRwas inside the guard-protected data directory: 5 to 17 calls of a run (67a1f950, 855e90e7, 7513f7eb) went on tests that failed because their temporary directory was a protected path, and onHOMEandTMPDIRset to/tmppaths (13 of the 23 shell commands refused in the 26 runs named/tmp) or to a folder of the repository. The directory is now in the system's temporary directory (ADR 28, amended 2026-10-16), and the scratch rule adds the sentence that follows from it: commands run withTMPDIRset to it, so a test suite puts its temporary files there without being told; do not setTMPDIRorHOMEelsewhere, and do not use/tmp.TestPromptsSayWhereScratchIsAndHowToReadLongFiles. - Reading again what the explorer read. The explorer and the planner are
folded away, and the model doing the work read again what the explorer had
read. In the nine high-effort attempts (eight tasks, one of them twice),
33,000 to 280,000 characters of a run's
fs-readresults were of paths the explorer had read, 73 to 100% of the main loop's reads in eight of nine runs (37% in the ninth), with the same sizes right after the fold; in 7513f7eb the main loop read agents.scm, prompts.scm and egress.go whole again, and docs/scheme.md (53,000 characters) nine times, twice whole. Pi, which never folds, reads whole files too. Two changes, both information and advice, no forced read: - The lines the findings cite go with them. The explorer is asked to
cite places as
path:line(plan-explore-task), andplan-hand-overadds to the findings message the lines around each place cited, copied from what the explorer read as the file has them, under a heading with their numbers (plan-excerpts: 2 lines before, 12 after, at mostplan-excerpts-cap, 12000, characters in all and 3000 an excerpt; a file named without a line only when what the explorer read of it is short). The reads are kept bysub-agent-seen, which the sub-agent loop fills while plan-explore asks for it. The message says to read more of a file in a range, and a failure to make the excerpts leaves the findings as they were (TestAFailureToMakeExcerptsLeavesTheFindingsAndThePlan).TestWhatTheExplorerReadGoesWithItsFindingsToTheModelDoingTheWork,TestTheExcerptsAreBounded,TestAReportThatCitesMoreThanFitsGetsTheCapNotMore. Fewer than half of the mentions of a path in the nine explorers' reports carry a line (150 of 368, the rest a bare name), and a bare one is carried only when short, so the excerpts are a floor: they do not replace a read of a file the model is going to edit. - Long files are read by the part. The reading rule names
fs-readwithstartandcount, and a second rule says to read a long file or document (docs/scheme.md, a manual, a large source file) by the part needed, found withindex-grepfirst, and that what was read or given is in front of the model: a range is read again when it changed or a result was folded.
Not measured yet: the next run on these tasks is what shows whether the re-reads fall and whether the 12000 characters pay for themselves; the folds broke even at best on this review (6e18b7ba). The caps are a first step.
Amendment 2026-10-16: the closing rounds stop making work
The Muse review of 2026-10-16 (transcripts of nodwyn dsl20r and dsl16r on eight commit-bench tasks, with Pi) found that the gates and reminders at the end of a run cost 9-38% of a run's tokens and bought no correctness. Each change below is one such round:
- 6e18b7ba. validate regenerated the untracked
docs/expert/expert.pdf(the snapshots the benchmark runs against still built the books), the model deleted it, the repository's reminder ("validate has not run since your last change") counted the deletion as a change since validate and asked for validate again, which made the file again: R96-R104, about 100k tokens, 7%, in two builds. In a third the last validate run left the 1 MB file in the patch (1,053,931 bytes). On dc679e46 the same happened withdocs/book/nodwyn.pdf, twice. - 7513f7eb. About 14 calls (dsl20r) and 30 (dsl16r) went on proving that an existing test fails on a pristine copy too, each time after "your last test run after your last edit failed"; the model had compared the two sets already, on a run the loop did not recognise as the comparison.
- 3422faf1. A 21-call tail, 22% of the run: the plan named
TestExfilScenario019for a scenario file, the gate wanted that test in the file, and the run refactored a test file, regenerated the catalogue and ran validate again. dsl16r'sgo run ./scripts/validatethroughshell-execwas not counted as validate. - The closing check-without rounds took 9% of a run (dsl20r, high effort) and 37-38% in two others (dsl16r, dsl20r at low effort).
So:
- A deletion of what a validate run left is no change. The loop keeps the
untracked files the round's validate runs and
tool-commandstools made (files-made: new ingit-statussince the round began, and not written by the file tools) and the repository as validate left it (repo-print). When the only thing that moved since is that some of those files are gone,validate-settlesetsvalidated-stateagain, so.nodwyn/init.scm's reminder is not said; any other difference ends the tracking and the reminder stands. The loop cannot change what validate does in a snapshot (andscripts/validateno longer builds the books, 1fd4c3e, so here it leaves nothing), so the validate result carries one line naming what it left (validate-left-note: delete them unless they are part of the change, which needs no new run), where the model can act on it at once, rather than the loop deleting a file the model did not choose to delete; and the finish review still names a file that stays, saying its deletion needs no new run.TestDeletingWhatValidateLeftIsNoChange,TestDeletingWhatTheValidateToolLeftIsNoChange,TestDeletingWhatABackgroundValidateLeftIsNoChange,TestWhatValidateLeftIsNamedInTheReview; andTestAnEditAfterValidateIsStillAChangefor the change that is one. go run ./scripts/validateis validate.validate-commandslists it withmake validate; a run of either through the shell is a test run (test-command-words), the whole check (whole-check-words), no check for check-without to take apart (a shellmake validatewas eligible, though the tool's run is not), and setsvalidated-state.TestAShellGoRunValidateCountsAsValidated,TestAShellValidateMeetsThePlansCheckStep,TestAValidateRunInTheShellIsNotTakenApart.- A failure the unchanged code has is said once, not proved again.
failure-namesreads the failing tests and packages from a run's output (Go, pytest, unittest). The loop keeps those of the runs on the unchanged code: any run on$TMPDIR/base, and a full test run (no-run,-kor node id) before the turn's first edit, in a turn that began with no tracked change. They stay known through later edits, so the comparison is done once. A run after the last edit that fails only known tests is not told as a failing run; its result says they failed on the unchanged code (failure-known-hint). When some are known the reminder names them and the others (finish-failing-known-message: say so in your answer, do not fix them). A test is not the unchanged code's to answer for when the request or the plan names it, or a file the turn wrote mentions it: the model's own test fails on the copy of the last commit as it should, and the test a task asks to fix failing again is not done. A package that fails with no known test (a build failure, a panic) is not excused either. Not used: validate's own "fail on the unchanged code" lines, which are the change's tests failing there by design.TestAFailureTheUnchangedCodeHadIsNotTold,TestANewFailureIsToldWithTheSharedOnes,TestAComparisonOnTheCopyIsRememberedAcrossEdits,TestAFailingTestTheRequestNamesIsStillTold,TestAFailingTestTheTurnWroteIsStillTold,TestAFailingValidateAfterAKnownFailureIsStillTold,TestAFailureBeforeTheFirstEditIsNotTheLastCommitsIfTheTurnBeganDirty,TestFailureNamesReadWhatTheOutputSays. - A scenario file meets a test step. A test step whose path runs through
a
scenarios,testdataorfixturesfolder is met by that file (the repository's, or one the turn wrote whose name starts with the step's path) and by a passinggo testof the folder above; the new file is no stray when a step's path starts it (plan-path-starts?; only a scenario path, so the backup of a planned file is still named). A test the existing runner's cases hold under no name of their own is not asked for by name.TestAScenarioFileMeetsItsTestStep,TestABackupOfAPlannedFileIsStillNamed. - Nothing is asked again for a run that stands. The plan's evidence that
a command passed is cleared only by an edit through the file tools to a
file that is not a document or in the scratch directory (as before), so a
deletion made through the shell never cleared it; what asked again with
nothing changed was
validated-state, the repository's own reminder, and the three points above are the ways it was: a deletion of validate's output, a validate run the loop did not count, and a failure already compared. - The finish review is made at most twice in a run nobody reads.
finish-review-limit(2), counted infinish-reviewsover the session's turns; a turn after the second ends on its report. A turn that edited nothing has no review, so a later review needs new edits as the first did. A chat's turn, which a person reads, is not held to it.TestTheFinishReviewIsMadeAtMostTwiceInARun.
What is kept: every check still runs; check-without still names the parts of
the change a passing check does not test, once a turn; a model that wants
to compare with the unchanged code still can, and the comparison is now kept.
What is not built: the single-turn benchmark runs of dsl20r already had one
review each (the cap above has nothing to do there; its cost is the work the
review's points ask for, which the points that disappear above reduce and the
check-without point, which stays, does not); a plan's check step that failed
only on known failures (7513f7eb, make accept) is still asked for; the
chase of a flaky browser check inside validate (c80b38ac, R61-R84) was the
model's, not a reminder's, and nothing here touches it; the plain chat loop
does not run evidence-loop, so none of this applies to a chat that is
neither unattended nor planned. None of this was replayed on the benchmark.
Amendment 2026-10-16: the original tests are run at the finish, a want string is an expectation, and a skip is deleted, not tested
Two more misses of dsl21 (Muse, effort low, muse-review-2026-10-16.md), each
reproduced by a reviewer. They are one lesson from both sides: when an old
test fails after a change, which side is wrong depends on the request. The old
assertion stands unless the request names the new behaviour for that input or
case; the fixture's setup may change, and be named, when the request
contradicts it.
- 5040f9fe (replicate 2): the request added the response's Content-Type to
an error. The model reworded the error so that "Responses stream ended
without a terminal response", a phrase an existing test pins, was split, and
then edited that test's
want := "..."to match. The real commit left the test alone and appended the Content-Type to the intact phrase, so the hidden (original) test fails on the patch. The test-edit question of the 2026-10-16 amendment did not fire:expectationLineread the cases of a table and testify's asserts, and a want string beside astrings.Containsis neither.validatesaid the test passed on the unchanged code and the model answered "deliberately widened", without a sentence of the request. Nine attempts of the benchmark's runs edited the samewantline (the builds dsl3, dsl6, dsl15, dsl17m, dsl19, dsl21, OpenCode, Pi and tok2). - 855e90e7: an old test (
TestResumeAfterProcessKilled) failed after the model's correct change because its fixture uses one folder as repository, configuration and data directory. The model added a skip todenylist()(private directories that overlap the repository root) and left the fixture, which removes exactly the directories the hidden test expects denied; the real commit changed the fixture. The finish review made it worse: "a skip nothing exercises: add a test or delete" and "a branch gets a test for each side" led the model to add a test that pinned the skip, and check-without, run on the exfiltration suite alone (the last passing command), named four hunks no test of it covers, which cost 53 calls of per-hunk revert experiments; the last six of those runs were told "Your last 6 test commands did not run successfully".
The owner's principle holds: nothing is refused, the harness asks, and follows what the model decides. So:
- The original tests are run at the finish. A grader lays the maintainers'
test files over the patch; the finish review now does the same for the
files the turn edited.
original-tests(internal/prim/original.go, hidden, registered in a git work tree) takes the turn's changed test paths and, for each Go test file HEAD has and the working tree edited or deleted, runs the tests HEAD's version declares with HEAD's version in place of the working tree's, against the change as it is:go test -overlay, so nothing on disk is touched and there is nothing to put back if the run is stopped, within 3 minutes in all, withGOPROXY=offand the allowlisted environment. A test that also fails with every changed Go file as HEAD had it (a second overlay run, only for the failures) was not broken by the change and is left out; a directory whose tests do not build with HEAD's files in place (the request changed a signature, or a helper moved) isunrunand named in the log only. A test the turn added to the file, or in a file of its own, is not declared by HEAD and is never run. The point isfinish-original-tests-message, in the finish review, so it is said once a turn, never refuses anything and never repeats for a file in the turn: "these original tests fail on your change", with three ways out: the change is wrong (change the code, appending to what a test pins rather than rewording it); the request names the new result for that input or case (the assertion changes; say which sentence requires it); the request contradicts the test's setup, a fixture (change the setup, not its assertions, and say which sentence requires it). The middle one is the owner's rule that the old assertion stands unless the request names the new behaviour; the first message left it out, and a request that changes a result changes the test that pins it, as 35 of the 244 resolved attempts that edit an existing Go test did (see the replay below). Go only: another language's tests would need a copy of the tree or an edit in place, and the loop says in its log that it did not run them (original-test-failures). Who runs what: this runs the repository's code, the package of a test file the model edited, as the model's owngo testof that package would, in the environment a shell call gets, with no network; it is no new authority, and (ADR 45) trust is not a sandbox here either.TestOriginalTestsNameTheTestsTheChangeBreaks,TestOriginalTestsOfADeletedFileAndOfOneThatDoesNotBuild,TestOriginalTestsPrimitive,TestTheReviewNamesOriginalTestsTheChangeBreaks,TestTheReviewIsQuietWhenTheOriginalTestsPass,TestOriginalTestsAreRunForGoOnly. - A want string is an expectation.
expectationLinealso reads a literal assigned towant,expect,expectedor a name that starts with one (wantErr,expected_msg; Go and Python), and in Go astringsorbytescheck (Contains,ContainsAny,HasPrefix,HasSuffix,EqualFold) of a non-empty string literal, a switch'scaseline left out, which picks the expectation and is not one: the question at the edit and the finish review both read them throughchange-removals. Moved lines are not changes, as before for a line added whole elsewhere, and now also for a line whose string literals all stand on one line the change adds (kept): the 94f5cac4 replay named a case that had only moved from a map to a direct assertion. The question's text adds that to add to what a test pins, the model appends to the phrase and does not reword it.TestRemovedExpectationsAreAlsoWantsAndPhraseChecks,TestExpectationLinesOfWantsAndPhraseChecks,TestStringLiterals,TestAnEditedWantStringOfAnExistingTestIsAskedAbout. - A skip is deleted, not tested. The working rule says: a branch the
request asks for gets a test for each side of it; a branch added for any other
reason is not tested into place, delete it; and a fixture is setup, its
assertions are not.
finish-uncovered-lines-messageoffers, for a branch no case reaches (a condition that cannot be true, a skip nothing exercises), deleting it or quoting the sentence of the request that requires it, adding the test only then, and says a skip or exception added after an existing test failed gets no test, since the test would pin it: fix that test's setup or the code instead.finish-untested-parts-messageends the same way: a part the request does not ask for is removed, or defended by the sentence that requires it, not given a test.TestThePromptsDecideWhichSideOfAFailingTestIsWrong,TestTheReviewAsksForTheChangedLinesNoTestRan,TestTheUntestedPartsPointDoesNotTestASkipIntoPlace. - The check taken apart is the broadest passing run, and a revert experiment
is not a run that failed to run. check-without and the coverage point used
the last passing command, the exfiltration suite on 855e90e7. They use
broadest-passingnow: of the commands that passed since the last edit, the one that names the most of the changed files' directories (./...and the whole-check words name all of them), one not narrowed with-runor-kbefore one that is, the newest of those that tie, so a turn with one passing command is as before.unrunnable-aftercounted every failing test run, edits between them leaving the count alone. A failing run now ends the count when its output shows tests ran (tests-ran?: a failing test named, a test or package that passed, or a package that saysFAILwith a time of its own), and leaves it where it was after an edit that took part of the model's own change out (shrinking-edit?: the file's changes against HEAD got smaller, measured withgit-difffrom the turn's first command on, since a part is taken out to see a test fail only after one has run), which is a revert experiment, expected to fail. A run on$TMPDIR/basedid not count already.TestBroadestPassingPicksTheRunThatRunsTheMostOfTheChange,TestTheReviewTakesApartTheBroadestPassingCheck,TestTheLastOfEqualPassingChecksIsTheOneTakenApart,TestTheCoveragePointNamesTheBroadestPassingCheck,TestFailingTestsAreNotCommandsThatDidNotRun,TestRevertExperimentsAreNotCommandsThatDidNotRun,TestGrowingEditsFollowedByFailingRunsStillCountAsUnrunnable.
Every new test fails on 95834ba (dsl21) or cannot compile there: the three
TestOriginalTests... of the primitive and TestStringLiterals call functions
the old code lacks, and TestBroadestPassingPicksTheRunThatRunsTheMostOfTheChange
calls Scheme functions it lacks. TestTheReviewIsQuietWhenTheOriginalTestsPass
asserts an absence, and fails there only through the row that moved into a
direct check, which the old code asks about.
The replay (each patch applied to its task's parent and read by the new code, no
model run; the 291 distinct patches of the benchmark's runs that edit an
existing Go test file): the 5040f9fe patch of this miss yields the want line
at responses_test.go:280 from change-removals and, from original-tests, the
hidden test itself, TestResponsesEventStreamRejectsMissingOrFailedTerminal.
change-removals names expectations in 37 of the 291 patches before and in 50
after, and 98 lines before against 87 after: 12 patches stop being named (a map
every attempt of 107251d0 extended and rewrapped, table rows whose literals all
stand on a line the change adds, 94f5cac4's index-grep case that moved from a
map to a direct assertion), 25 are named
that were not, nine of them 5040f9fe's want line and the others phrase checks
and want strings that were changed. original-tests names failing tests in 51
of them: 35 of the 244 resolved attempts (14%) and 14 of the 39 unresolved
(36%), 2 of the 8 not graded. The tasks it names are the known misses: all 8
attempts of 94f5cac4 (TestReadRanges among them), and for 5040f9fe the three
patches whose error no longer holds the pinned phrase (this one, not graded,
and OpenCode's and Pi's, both unresolved on exactly the test named,
TestResponsesEventStreamRejectsMissingOrFailedTerminal) against none of the
20 resolved ones. The resolved
attempts named are the ones whose request changes a result, so the test that
pins it changes: 107251d0's 17 (the Responses round trip), 855e90e7's 6 (they
edited the fixture of TestResumeAfterProcessKilled as the real commit did,
and it fails as the last commit had it, which is the point's third way out),
67a1f950's 6, 7513f7eb's 2 and four more. The point therefore costs a round
for about one attempt in seven that edits a Go test (one in six counting the
unresolved), and asks the model for the sentence of the request. 7 patches (2%) are unrun. The dsl21 patch of 855e90e7 is not named:
it did not edit resume_test.go, the skip was in production code, and that is
what the third and fourth points address (the finish review's own text led to
the test of the skip, and check-without on the exfiltration suite to the 53
calls).
Not built: running the original tests of another language (pytest would need a copy of the tree); naming failing tests of files the turn did not edit (the model's own runs show those; 855e90e7's resume test was not edited, and the skip is what the third and fourth points ask about); recognising a revert experiment by what the model says; refusing or reverting any edit. Not measured: whether the new points change what a model does is for the next benchmark.
Amendment 2026-10-16: the review stops asking for the lines no test ran, bounds the tests it asks for, and the question on an edited test is answered in a form and read
dsl22 (a000653, Muse, effort low, 32 runs; muse-review-2026-10-16.md and its
digests) resolved 31 of 32, as dsl20r did, for about 17% more cost, and still
failed 94f5cac4. Two findings.
Cost: 6e18b7ba took 167 calls and 2.38M cost-eq (uncached input, a tenth of cached input and five times the output), against 0.83M for dsl20r. The first 65 calls cost 0.59M. The finish review came at 400 s with three test-writing points, and the model spent R66-R160 answering them, which the model's own reasoning summaries and its coverage runs attribute:
| calls | cost-eq | what asked for it |
|---|---|---|
| R1-R65, before the review | 0.59M | |
| R66-R98 (33) | 0.52M | the plan's evidence (three test steps: two passed on the unchanged code, one was not written) and check-without's six small hunks, four of one line: a concurrency test, a PATH-shadowing git wrapper to assert a flag |
| R99-R160 (62) | 1.12M | the coverage point (finish-uncovered-lines-message): the model ran go test -coverprofile, mapped the zero-count blocks to the changed lines and wrote tests for the branches of its own defensive code, the waiter and retry paths of Restore, the empty-list branch of versions.list ("extending the RPC versions test to cover the store fallback and empty-list branches") |
| R161-R167 (7) | 0.14M | validate and the report |
- The coverage point goes: its trigger (
uncovered-lines-due?: the latest run passed, a source file changed, the added lines hold a branch word, no command measured coverage) and its message, with the helpers only they used (added-source-lines,branch-words,branchy?,coverage-run?). It was added on 2026-10-16 for 3422faf1 (a dead branch that tested the wrong variable), and that miss is the planner's to prevent, which the same amendment's cases by position and assumptions do at the cost of a prompt, not of a run: they stay. In 6e18b7ba the point took 62 of 167 calls and 1.12M of 2.38M cost-eq, for tests of code the request did not ask for.coverage-source?stays assource-path?, whichchanged-dirs(and sobroadest-passing) reads. - The tests a review asks for are bounded.
finish-asknames at mostfinish-ask-limit(3) items of each kind: the parts check-without found (the largest first), the plan's test steps that are missing or pass on the unchanged code ("make it fail there"), and the dropped conditions; says how many more there were; and keeps every item it considered infinish-asked, so a later review of the run (finish-review-limitis 2) does not name it again, the ones past the limit included: the model answered the review once.finish-untested-parts-messageasks for a case only for a part that implements what the request asks for, says "do not test anything else because of this message", and says a flag, a rename, a refactor, a skip or an exception added to make a test pass gets no test: remove it, or defend it with the sentence of the request that requires it. The review is still made once a turn.TestTheReviewDoesNotAskForTheChangedLinesNoTestRan,TestTheUntestedPartsPointNamesTheLargestThree,TestAnItemTheReviewAskedAboutIsNotAskedAboutAgain,TestThePlansUnwrittenTestsAreNamedThreeAtATime,TestTheUntestedPartsPointDoesNotTestASkipIntoPlace(changed).TestTheReviewAsksForTheChangedLinesNoTestRanandTestTheCoveragePointNamesTheBroadestPassingCheckare gone with the point;TestTestFilesAndBranchesAreToldApartisTestTestFilesAndSourceFilesAreToldApart.
94f5cac4 again: the question was asked three times and waved away. The
test-edit question fired at 73, 85 and 89 s (the ends of R26, R30 and R31).
The model answered the first (R27) with "restoring the type-check case", and
then (R28, R31) with the request's rule for a skipped argument (A skipped
optional argument before a later one that is given now gets its type's empty
value ("", 0, #f, ())), which it applied to (fs-read "big.txt" 1 0) "count
must be at least 1": a row the request never mentions (the plan had invented
"count 0 means default"), and the hidden (original) test keeps it. The patch rewrote it
to 1 -1. A prose answer the loop did not read cost nothing to give.
- The answer has a form, and is read. The question asks for one line for
each changed row: the row, then a sentence copied word for word from the
request that names the input or case the row tests, or
none(restore it and change the code). The next reply with words is set against the request byedit-support(edit-answers, strategy.scm), which judges text and nothing else: a quote is a run of three words or more of the reply that stands in the request in the same order (case, punctuation and white space aside), and it supports a row when it shares a distinctive token with the row's string literals, its input and the message it expects, and with the line that replaces it (change-removalspairs the lines a change block removes and adds by the words they share, and says the replacement innew). A token is a word of three letters or more, or a number from 2 up (0 and 1 are in every sentence about an index or a count), lightly stemmed, that is no stop word and no part of the name of a function the row calls (a sentence that namesfs-readsays nothing of which case of it the row tests); a row whose literals give none is read by its identifiers' words. A row with no token to judge by is left to the review's other point, as before. - A rewrite that cuts or splits a phrase needs more. 5040f9fe's pinned
phrase: the request has the error name its Content-Type, which adds to the
phrase, and the attempts that appended " (Content-Type: text/event-stream)" to
it passed the maintainers' test, while the one that cut the want string to
"ended without a terminal response" and OpenCode's, which put
(content type "text/event-stream")inside it, failed it. When the last string literal of the replacement keeps the phrase whole at its start or its end, it is an append and no more is asked. When it cuts two words or more, the quote must also hold most of the words cut ("responses stream"); when it puts two words or more inside the phrase, all the words put, as a run. The sentence about the error ("A stream that ends without a terminal event names its Content-Type in the error") supports the append and not the cut or the split. - A row without a supported quote gets one firmer message, when the reply
quoted the request at all and the row is still unsupported
(
edited-expectations-firm-message, once for each row, so the second wrong quote is not told again): it names what the request does not mention (the input and the expectation, as the request is read for a quote; the words cut or put), says the default is to restore the old expectation and change the code because the last commit's version of that test is what a grader runs, and that the row is kept only by quoting the sentence that names its input or case, and saying why it requires the new result. A reply that quotes nothing is no answer yet; the reply that ends the turn is read for the rows it supports and gets no message (the review is firm enough, and a message there would be another round). A row the model restored is answered. - The finish review lists what is still unsupported as its first point, with
the original tests (
finish-unsupported-edits-message: the rows, and thefinish-original-tests-messagefailures, the grader's side of the same rows, in the same point). The rows whose answer was read and supported are not named again in the removed-expectations point. - Nothing is reverted or refused. The edit stands; the model may decide
otherwise, against the evidence.
TestQuotesAreRunsOfTheRequest,TestTheTokensOfARow,TestTheRepliesOf94f5cac4AreReadRowByRow,TestRewritesArePairedByWhatTheyShare,TestAnAppendedPhraseIsSupportedAndACutOrASplitIsNot,TestARewrappedMapTheRequestAddsToIsSupported,TestARowWithNoTokenIsUnchecked,TestEditSupportPrimitive(internal/prim, with the requests, patches and replies of the benchmark intestdata/support);TestAnAnswerIsReadAgainstTheRequest,TestAReplyThatQuotesNothingIsNoAnswerYet,TestTheAnswerThatEndsTheTurnIsRead,TestTheFirmerMessageIsSaidOnce(internal/core);TestAnEditedExpectationOfAnExistingTestIsAskedAbout(changed).
Tuned on the benchmark's own patches and replies, not on a model run.
TestSupportReplaysCommitBench (set NODWYN_COMMIT_BENCH and NODWYN_REPO;
skipped without them) reads the 304 attempt patches of the benchmark's runs
(the gold patches left out) for the rows change-removals names and sets each
against its task's request, twice: with every sentence of the request as a
possible quote (the most the request can do for a row) and with the sentence a
model would most likely quote, the one that shares the most tokens with the row.
- 53 patches name 94 rows. Some sentence of the request supports 81 of them; the natural quote supports 80. Of the 48 rows the real commit also changes (its test diff, read from the repository), 41 are supported either way; the 7 that are not are a4dbffc7's two rows in four attempts (one has only the second), where the attempt put its own sentence in the phrase ("a step you reword starts unmarked") and the real commit cut "since that starts its marks over" (supported by "The reading reminder no longer says a rewrite starts the marks over").
- 5040f9fe: 9 patches rewrite the
wantline, and the natural quote flags the two that fail the maintainers' test with a phrase it can read, the cut one (want := "ended without a terminal response", dsl21's second replicate, not graded) and OpenCode's split one (unresolved); the five that appended to the phrase (dsl3, dsl6, dsl15, dsl17m, dsl19, all resolved) are supported. tok2 (resolved) and Pi's (unresolved on that test) restructured the test so that no line replaces the old one, and are supported. Some sentence of the request supports the cut one: the title, which holds "Responses" and "stream" and is about neither the error nor the test, so the any-sentence count flags one row fewer than the natural quote. - 107251d0: 5 patches, 5 rows (a map rewrapped around "instructions" in four,
one
{Role: RoleSystem, Content: "sys"}row), all supported. 94f5cac4's other correct rows ((fs-read "big.txt" 0), theindex-grepcontext, the twocount needs start toocases) are supported in all 9 of its patches. - 94f5cac4's
(fs-read "big.txt" 1 0)row, rewritten to1 -1(or1 -2) in 5 of those patches, is supported by the natural quote: the sentences that mention "count" do so of another message ("count needs start too"), so a model that quoted that sentence for it would pass. None did. Set against the real replies of dsl22 (R27, R28, R31, R32) the check reads: R27 quotes nothing; R28 supports the twocountcases ofjson_test.go(the rule's "argument" is a word of their message); R31 supports(fs-read "big.txt" 0)withfs-read's start 0 is line 1and does not support1 0, for which it quoted the rule for a skipped argument; R32 supports theindex-greprow. Set against dsl21's three replies to the same question, all four rows it rewrote are supported. So the one row the check tells the model about in either run is the one the hidden test keeps. - Flagged and not discussed above: the 14 rows of the 94 flagged are a4dbffc7 7,
c4cde303 4, 5040f9fe 2 (above) and 94f5cac4 1. c4cde303's is a
strings.Containsmoved inside anifblock by a refactor (its literals stand on two added lines, sokeptdoes not hold it), and 94f5cac4's is dsl20r's{"path":"p"}:1,number table.change-removalsnamed both before.
Not built: a second firmer message, a message to the reply that ends the turn,
reverting or refusing any edit, and a stricter reading of "count" (the rule is
one shared token; two made the real count/start cases of
94f5cac4 unsupported by the sentence the model quoted for them, and could not
tell count needs start too from the row it is not about). Not measured: whether
the model changes what it does with the form and the firmer message, and
whether three tests a review is enough; for the next benchmark.
Amendment 2026-10-16: the benchmark stack merged with main
The benchmark stack claude/dsl23 and main changed the same loop on two
branches. The merge keeps both, with these choices:
- Per-turn resets the stack added are made in
turn-start, where main's amendment "benchmark habits fire only where they fit" moved the others: the write round'sturn-stop-toolsandturn-stop-rounds(strategy.scm), the plan language's state for the turn (planspec.scm had wrappednodwyn-turn, so a project that plans its own way kept the last turn's plan steps), andsub-agent-seen, which a stop inside the explorer left collecting every later sub-agent's calls. - The explorer's rounds are the sub-agent's
roundsoption (main), what it read is still collected for the excerpts it hands over (the stack), and it hasindex-searchas well. - A compaction gives the turn's plan back once, as the message after
the note (the stack's
compact-plan, the way commit-bench ran it). Main'splan-carry, built on its own branch for the same failure, stands down then, and records the plan again only when that message cannot be made (compact-plangives#f):TestCompactionCarriesThePlanWithItsMarksholds the first,TestCompactionRecordsThePlanTheLogCannotGivethe second, andTestCompactionKeepsTheTurnsPlanboth. Both at once put the plan twice in a compacted context, a few hundred to about 2,000 tokens more. - The finish review's diff readers use main's bounded
myers(removed expectations, the change without each part, the documents' added lines), and leave out a change too large to diff, as they leave out a file too long.
Amendment 2026-10-16: what the evidence loop was given on 2026-10-12, recorded
The commits of claude/dsl23 made on 2026-10-12 cited an amendment of
that date that was never written. This records what they decided, from
their messages, and the code and documents that cited it now cite this one.
- A check is compared by what it prints (33b6b87).
check-withoutruns the check once with the whole change, and counts a part untested only when the check exits 0 and prints the same without it: a check ending in; echo EXIT=$?passed without every part on commit-bench. - The commands a report cites are read as the shell reads them
(33b6b87). They are matched against the commands the turn ran, read by
guard.ShellCommands(shell-commands), so heredoc lines and grep patterns are no programs; runs on$TMPDIR/basenever go stale, and a passing whole check (make validate, make test, ...) covers the check commands a report cites. - A background test run counts (33b6b87, c768750, 11e0c51). A
background run that exits after the last edit is a test run, and only
its own calls are read for its exit.
check-withoutruns it again as the shell-start call the model made, never as one rebuilt from the shell's output, which carried that output's taint and was refused by the guard as a command built from tool output (commit-bench, 556d6d12). - Slow checks go to shell-exec, and a check's exit status is not masked (33b6b87): the system prompt says both, and shell-read may wait 600 seconds.
- A plan step is marked with the next step's first call (1fbbac4). On commit-bench (nodwyn 0a9f700, nine tasks) 69 of the 103 replies that marked a step held nothing else, each a request that sends the whole context again: plan-step, the plan header and a run's turn prompt ask for the mark in the same reply as the next step's first call, with a one-line note.
- A name the request lists is taken as spelled (c75286f). On commit-bench (556d6d12) the request named three headers; the planner read session-id as the X-Session-Id the code already sent and planned the other two, and the main loop shared the belief without testing it, so the change failed the maintainers' test. plan-shape asks that every name the request lists appear in a step as spelled, with a Test: step of the request's spelling when the planner thinks it is already there, and the working rules ask the main loop to show such a belief with a run.
Amendment 2026-10-16: the plan as data of 2026-10-13, recorded
The commits of claude/dsl23 made on 2026-10-13 built the plan language
(planspec.scm) and cited an amendment of that date that was never written.
This records what was built and what stayed, from their messages, and the
code and documents that cited it now cite this one.
- The planner writes its plan as Scheme forms (e73155c): change, test
(with how it fails today and the command that runs it), docs, check,
none, names and context, read with
read-dataand written out as the numbered steps the window and the console show. A reply whose(run ...)sits inside(fails-today ...), a parenthesis short, is read form by form with the parentheses added (75503e3). A plan the model writes again in prose replaces the forms (78a353a), and each form is kept under the new plan's step of its kind that names its file, test or command; a form the rewrite dropped is dropped (e592a64). - Every name the request gives is in the plan, as something the change
produces or only uses, or the plan is asked for again (e73155c).
request-namestakes the names that read as code and leaves out words in capitals and capitalised names without a separator: the planner had been asked again for AGENTS.md, ChatGPT and NODWYN_RUN_COMPLETE (75503e3). A phrase is a quote, and a name HEAD already holds whole (head-has-name) is no name the change must produce: on 5040f9fe the model, told the change lacked such names, wrote them into its ADR (6ff12b7). A name the plan leaves out and HEAD has goes into the plan's context without asking the planner again (7595b15). - Before an unattended turn ends, each step is set against what
happened (e73155c,
plan-evidence): an unchanged file, a test not in its file or not run since the last edit, a check not passed since, a name the change does not contain, said once. A step that names a directory is met by an edit in it (78a353a), and an edit to a .md, .tex, .rst or .adoc file leaves the runs before it standing (75503e3). - The harness marks the steps as the work happens (5f0739e): after each round of the main context's calls, a change or docs step whose file a call wrote, a test step whose file was written and whose test a passing command named, and a check step whose command passed.
- A planned test that says it fails today is run once on the unchanged
code (5f0739e, 7f0ed56,
test-before): on a copy of HEAD with the test file and the change's other test files and test support put in; one that passes there does not show the change, and is said so. With the test file alone, two tests that drive the change through a fake it rewrote passed on the copy (dsl13, 107251d0). - A background run is read to its end (3391516): shell-read waits up to a second after new output for the rest or the exit, since make validate's PASS came with "running", the model read no further, and the loop never saw the run end (dsl6, 556d6d12).
- The documents that describe what the change alters are named when it
edited none (43d920d, 28c9ed4,
docs-mentioning): those that hold a function the plan changes, and the ADRs named for a package the change alters. On 3422faf1 the planner wrote(none docs ...)and the reference amended ADR 25, which describes the Scanner.Scan and TextSecret the change altered. - New files are marked for the next commit as the model writes them
(7d48641, 96a304d,
git-intend): every new file of a planned turn, so a generator that lists the tracked files counts it. The book's catalogue, regenerated last, had missed a new test file (dsl8, 107251d0) and a new exfil scenario (3422faf1). Files untracked before the turn are left alone. - Dropped the same day (550e3b9): the harness running the plan's tests after each edit (961f0e3) and its check before the end (3391516), check-before on a clone of HEAD (b7befea) and the missing-program hint (817b637). dsl6, which had them, scored 75.5 in blind review against dsl3's 77.5 on the same three tasks, for 18% more tokens; check-before never fired, since make validate passes on the unchanged code. qwen's explorer went to 8 rounds (3e91d63) and back to 16 (be6a8f2) when a blind review scored the 8-round builds lower; the amendment of 2026-10-15 ("a shorter explorer for qwen") made it 8 again.
Amendment 2026-10-16: the original tests in any language, and what eight SWE-bench misses showed
dsl23 on SWE-bench Verified's 121 clean tasks (Muse, effort low) resolved 98
against Pi's 104; eight tasks Pi resolved and dsl23 did not were read one by
one. The second replicate then showed most of them were not stable: dsl23
resolved four of the eight, and Pi missed three that it had resolved. Two
held (django-15916, django-16877), the rest were the loop's own gaps, each
with a task it cost in at least one run. The loop's guards for them were Go's
alone: original-tests ran Go tests and said in the log that it ran no other.
original-teststakes the check, not a language (internal/prim/original.go). The evidence loop gives it the program and arguments of the passing run that runs the edited test files (original-check; a run that names none of them, or none that passed, is not repeated, and the log says so). The test files the turn edited are written as HEAD has them and the ones it made are taken away, the check runs, every file goes back to its bytes and its time (a bytecode cache keyed on a source's time and size must not take one version for the other); if it fails, it runs once more with every changed file as HEAD had it, and only a test that fails with the originals and not there is named. Names come from the output (pytest, unittest and Django,go test, cargo, jest, vitest, rspec:failureIDs); a failure whose output names no test is told by its exit status. The Go overlay is gone. pytest-5787 and scikit-learn-13124 rewrote an existing test, in Python.- A run that failed is held against the last commit (
regression-failures,finish-regressed-tests-message): the same command, no file put back, and with every changed file as HEAD had it; the tests that fail only with the change are named. psf-requests-2931 broke an existing test, ran the whole file, and put 81 failures down to the environment, its own among them. - A run's output is read in every ecosystem (
passing-lines,failing-output?,failure-line-name): "3 passed", "OK", "test result: ok.", "1 failed", "FAILED (failures=1)", cargo's, jest's and rspec's lines. Until now only Go's and validate's counted, so a Python run piped throughtailwas no run that passed. - The plan's run and check match by what they run (
plan-language-ran?): flags, numbers and launchers do not count, so-v 1against-v 2, orpython -m pytest xagainstpytest x, are the same run. The review said a check "has not passed since your last edit" for the first on two tasks. - A task template's fields are not names the plan must hold
(
request-names): the key of a "- key: value" line (instance_id,base_commit), a product's name (SWE-bench), and a name with a version inside (a platform string). The planner was asked again for them on all eight tasks reviewed. - The expectation question reads more test files (
expectationTest): tests by name or folder, in Go, Python, JavaScript and TypeScript, Ruby, Rust, Java, Kotlin, C#, PHP;expect(,assert_eq!,assertEquals(as well as Go's and Python's assertions. Django'stests.pywas not read. - The harness never measures a path outside the repository (
repo-relative?):edit-sizescalledgit-diffon the path of a write the guard was about to refuse, the guard refused the harness's own call, which a strategy cannot catch, and django-11400 ended withdenied: path /tmp/check_order.pyat its first write to /tmp after a run (one of 121 tasks). - A habit line: before finishing, say the request's title as a claim and
check its smallest form (django-15916, where the probe printed
Base2: Falsefor the plain ModelForm and the run moved on). - The plan prompt's example is Python's and says a test's run is the repository's own command.
Not built, and why: change-dropped-conditions stays Go's, since it reads Go
syntax; the explorer is not given git log (one task, a new primitive and the
documents that come with it; the failed-run point above covers requests-2931
from the other side); and the plan-form changes proposed for django-11400 and
django-14534 (Pi missed both in the second replicate, so they are not shown to
be the loop's). The bridge drops new test files from the submitted patch for
both harnesses (django-16877: a new file at the path the hidden test creates
makes the evaluation's git apply fail), in swebench_nibb_bridge.py, not
here.
Amendment 2026-10-17: the default loop has a lean profile and a full one
The working habits above were measured on a benchmark, and the same benchmark now says most of what the loop adds around the model does not pay. On SWE-bench Verified's 121 clean tasks (Muse, effort low, replicates of each arm), the loop of dsl23, with these habits, the explorer and the planner, resolved 98 and 98; Pi, which has none of them and four tools, resolved 104, 106 and 103. The lean ladder took the loop apart one rung at a time, each rung a switch in the Scheme files, and measured what each cost:
plan-minimal: a run records the one lineplan-minimal-textand neither explores nor plans. The explorer and the planner were 31% of a task's cost (1,081 requests in 121 tasks for the first, 245 for the second).lean-quiet: nothing the harness says while the model works or as it ends: no finish review, no reminders, no question on an edited test, no hint on failures the unchanged code has. Of dsl23's 4,385 requests in 121 tasks, 707 came after the finish review began, 17% of its cost. A turn that changed nothing is still told so once (lean-empty-reminders): shell2 on matplotlib-25960 ended its turn with no edit and no patch.lean-prompt,lean-tools: a system prompt of about 250 tokens where dsl23's is 12,240 characters, and the tools the control model is offered named in a list. Pi's first request is 2.2k tokens against nodwyn's 15.7k. Last,lean-toolswent from six tools to two: on the 121 tasks with two replicates each,shell-execandfs-editalone resolved 103 and 102 wherefs-read,fs-list,index-grep,fs-edit,fs-writeandshell-execresolved 102 and 101 (Pi 104, 106, 103), at about 20% lower cost per task: the model reads withsedranges, and its tool results total 32,600 characters a session against 51,500.fs-writeadded to the pair was called 5 times in 242 sessions, and the code index (outline,tests-forand the commands of the binary,lean-cli) in about 13%, neither with an effect on the score.- Tools that say what to do (from six readers' pass over 136 transcripts):
an
fs-editerror echoes a valid call, names every line an ambiguousoldoccurs on and says where a longer one stops matching; its answer counts the lines added and removed (a swapped line was "no change");fs-writesays how many lines it wrote and warns when most of a file went (one run's rewrite dropped 117 of 426 lines and the ImportError showed three calls later);index-grepsearches a large tree's source files instead of refusing it (33 of 748 calls in the transcripts);shell-execnotes exit 141 (matplotlib-25960:grep ... | headended 141 and was not noticed) and a read that printed nothing (django-14534: ased -nrange past the end of the file cost four calls of 20); the denial of a/tmppath gives the scratch directory's own path. The reading nudge counts ashell-execcall that only reads (lean-shell-look): shell2 made 21 read-only calls on matplotlib-25960, was never nudged, and ended with no edit. - Experiments, offered to no one by default: the
schemetool, a program that calls the other tools as procedures through the guard;outlineandtests-for, withfs-read'ssymbol; andlean-cli, the same index as commands of the binary run throughshell-exec.
Decision. The lean loop becomes the default of an unattended session, and
the loop before it stays, as the default of one a person is in. [loop]
profile in config.toml is "auto" (the default), "lean" or "full";
NODWYN_PROFILE, when it is not empty, overrides it, for a test or one run.
auto is lean when nobody approves the session's calls ((unattended?):
--yolo, a run's auto_approve, judge or unattended) and full when a person
does. The lean prompt says "You work unattended: nobody approves or watches
your calls", which is false in a session a person answers, and everything
above was measured on runs nobody watched; the interactive loop is neither
shorter for its person nor better for it, as far as anyone has measured.
The profile is resolved when the session starts and fixed with its prompt and
tools (ADR 9); a resumed session resolves it again. The person chooses it, in
config.toml or the environment: a repository cannot, since nothing in the
repository is read for it. Its init.scm, which loads only after the person
trusted that revision (ADR 45), may redefine the switches below as it may
redefine any prompt, and has no more authority for it than for that.
The Scheme files keep the lean values, since lean is what the binary does by
default, and internal/loop/full.scm is evaluated after them and before
.nodwyn/init.scm when the profile is full, so a project's init.scm has the
last word under either and can mix the two switch by switch:
(define lean-prompt #f)
(define plan-minimal #f)
(define lean-quiet #f)
(define finish-reminders 2)
(define finish-review-limit 2)
(define lean-empty-reminders 0)
(define lean-shell-look #f)
The three numbers are computed from lean-quiet when strategy.scm loads, so
setting lean-quiet alone does not bring them back. In the Go host
prim.Options.Lean carries the tool surface the same way: only a lean session
registers scheme, outline and tests-for, gives fs-read its symbol
parameter, counts the lines an edit added and removed, has fs-write say what
it wrote, lets index-grep search source files alone in a tree of more
than 3000, gives fs-edit its line, its closest text and its note of other
copies, and shell-exec its view of a long stream and its saved files. A full session's catalogue, tool schemas and answers are the ones
it had. TestFullProfileTurnsOffEverySwitchTheDefaultsTurnOn fails when a
switch named lean-... is added to the defaults and not to full.scm, and the
tests of internal/core, internal/frontend/... and the rest that were
written against the previous loop run it (test/fakes/home sets
NODWYN_PROFILE=full for them); origin/main's own test files pass unchanged
against it.
Tools that name the occurrence, show the closest text and keep long output
(lean profile alone). On 484 shell-only SWE-bench sessions (Muse, a small
cheap model; shell-exec and fs-edit) the tools' answers cost the model
calls it did not need to make. prim.Options.Lean carries the changes, so a
full session's tools, schemas and answers are origin/main's, and lean-prompt
carries the one in the shell habit:
fs-editnames no tool. Of 73fs-editerrors in the two-tool arms, 24 were "old text not found" whose generic variant ended "read the region again with fs-read" (and one more "read them with fs-read and copy them exactly"), thoughfs-readis not offered; the model switched tosed -nand recovered in 3.9 calls on average (1.6 in the six-tool arm). A miss now shows the closest text: the file line that shares most words with old's first line (at least two words and half of old's; of equal lines the one whose next lines resemble old's more, then the first), with the lines around it, five in all, numbered, bounded byeditHintBytes; or says that no line resembles it and the text may be in another file.fs-edittakes aline. Another 23 errors were "old text occurs N times" (counts like 33 and 40) with no way to say which, though the model knew the line fromgrep -n.line(1-based; also in each edit ofedits, where it counts the text the edits before it leave) chooses among the occurrences: the one that holds the line, else the nearest, the earlier on a tie. It is ignored for an old that occurs once and withreplace_all, and it is the last parameter, so a positional(fs-edit path "" "" #f edits)keeps its meaning. The ambiguity error says "pass line to choose one, or replace_all".fs-editsays where the replaced text also occurs. django-16938 needs the identical change atdjango/core/serializers/xml_serializer.py:158as atpython.py, and every failing session edited one and never saw the other. After a successful edit withoutreplace_allof at least two non-blank lines or 40 non-space characters, the answer endsthe replaced text also occurs at: path:line, … (and N more); check whether the same change applies there, at most three named: "occurs", not "edit", since the same text is not always the same fault. The search takes the edited file as it now is, its folder, then the repository's source files in path order, skipping hidden entries,vendor,node_modules, binary files and files over 1 MiB, at most 5,000 files and 400 ms; when it runs out it says what it had found. The edit's own new text is left out (an insertion after an anchor holds the old text), tracked through the edits of a list. A file is named only if the model's own read of it would be allowed: the credential denylist and the guard, including a checker of the user's, for each file that matches (a nestedfs-read, asindex-grepdoes).shell-execshows a long stream as a view with a file behind it. In the same sessions 84% of test runs were piped to| tail -Nor| head -N, and in 30% of the runs that executed (362 of 1,208) the output then showed no verdict: Django's parallel runner prints only "Destroying test database ..." lines at the end, and the model ran the suite again with other flags, a call and minutes each. The lines were never lost to the tool, only to the pipe. The principle is a pager's: a long output is not poured into the model's context and is not trimmed by the model; the tool shows a bounded view and a handle to the rest. On the model's own call, a stream (stdout and stderr judged apart) of more than 8 KiB or 150 lines is returned as its first 20 lines, a marker line where the others are, and its last 50: at most 6 KiB, a line cut at 300 characters, two views sharing the 6 KiB. The marker names the missing lines and the file:[... lines 21-310 of 330 not shown; full output is saved at $TMPDIR/out/7.stdout: sed -n '21,120p' $TMPDIR/out/7.stdout shows a range, grep -n PATTERN $TMPDIR/out/7.stdout searches it ...].- The saved files.
<scratch>/out/N.stdoutorN.stderr, N being 1, 2, 3 ... for the session, shared by a call's two streams and taken only by a call that needs one (a counter under a mutex, files made withO_EXCL, so primitives that share a scratch directory do not overwrite one another). A file is made when the stream passes a threshold and holds the whole stream up to 8 MiB, written from a tee in front of the existing 256 KiBcappedwriter, which keeps its behaviour, so what it drops from the middle is on disk; a file that stopped says so, with its last line. Without a scratch directory there is no handle and nothing changes.$TMPDIR/out/N.stdoutis read withsed -norgrep -nwithout a question: the scratch directory is not outside the repository to the argv trigger, as a test shows through the guard's checkers, with$TMPDIRwritten by the shell and as an argument of a command without a shell (which the host expands). - What the view leaves alone. A command line that reads a file or limits its
own output (
readLikeRE:cat,head,tail,nl,sed -n, anywhere in the line) is not shown as a view: whoever asks for a range of a file wants that range, and a pipe throughtailorheadworks exactly as it did; nothing of the model's command is rewritten or inspected. Only the model's own call gets a view: a program of theschemetool (whose point is to split and filter the whole result), a strategy and the person's own call get the stream as before. A view is under what the window cuts (a result overmax_result_tokens, 3,072 tokens by default, 9 KiB, is shown head and tail), as a test shows through the window's ownBuild, for the JSON the tool returns and for the plain text the shell habit gives it, which clips at 7,700 characters. - The note reaches the model.
shell.scm'sshell-plainrendered a result fromstatus,stdoutandstderralone, so thenotefield (exit 141, and a read that printed nothing, both added above) was in the log and the clients and never in what the model read: matplotlib-25960'sgrep ... | headand django-14534'ssed -npast the end of the file had the notes in their results all along. Withlean-prompton, the note follows the output on a line of its own,[note] ...; with it off the text is what it was. No new switch, sofull.scmis unchanged. The saved-file handle is in the stream's own text, not in the note, so it reaches the model whatever the rendering.
Kept in both profiles, because each is an improvement that origin/main's
tests do not contradict: the finish review that runs the original tests in
any language and reads a run's output in every ecosystem (the amendment of
2026-10-16), the plan language's matching of a run by what it runs, the
request-name filters, the expectation question's wider reading of test files,
repo-relative?, the guard's scratch-directory path, fs-edit's and
shell-exec's clearer errors and notes, and the working rule on the request's
title. The one test of origin/main they supersede is
TestOriginalTestsAreRunForGoOnly, whose Go-only behaviour that amendment
removed.
Not built. The lean tool list hides what a project adds: its own tools
(register-tool in init.scm, such as this repository's validate), MCP
tools and the delegate tool are not among lean-tools, and a project adds
them with (define lean-tools (append lean-tools '("validate"))); the
measurement had none. The profile is not a setting of the console. Lean was
measured on Python repositories, on one benchmark and one model family;
auto keeps it away from sessions a person is in until a measurement says it
does no harm there, and profile = "lean" is the way to try it.
Also not built, of the tools above: stripping a trailing | tail -N from the
model's command and returning the last N lines exactly, and a scanner for test
runners' verdict lines (unittest, pytest, go test, cargo, jest) to quote in a
note. The owner rejected rewriting the model's command; the view gives the same
lines and a handle without reading the command, and a test of a Django-style
run (the verdict, then twenty "Destroying test database" lines) finds its
verdict among the last 50 lines. recall still renders a shell result
without its note.
Amendment 2026-10-17: the lean profile says only what it must
The owner's aim for the lean profile is to be as generic as possible, to lean on the model as much as possible and to give it useful tools and nothing else: every sentence the harness adds must have earned its place in the measurements, and none of the behavioural nudges has (FINDINGS-0a9f700.md, "Full 121-task passes"; tool-review-2-2026-10-17.md; the readers of the afternoon of 2026-10-17). What the evidence says:
- Two tools,
shell-execandfs-edit, resolve as many tasks as six, at about 20% lower cost. The old shell2 build, which never sent the model a harness message, resolved 103 and 102; the builds with the read-streak reminder, the no-change reminder and the profile resolved 99 and 99 (A) and 101 and 99 (B). The readers put every single loss down to variance, but the arms with nudges sit about three tasks lower over two replicates each. - The read-streak reminder: of 47 firings read, about 8 weakly helped, 20 were
neutral, 15 false alarms, and up to 4 possibly hurt; it tells the model to
use
plan-write, which the lean profile does not offer. - Every lean request carried the plan block, "Plan for the current task (work
through it, and the harness marks a step done ... mark one with plan-step ...
write the plan again)", over a one-line plan;
plan-stepandplan-writeare not offered. - The environment hint was added in 99% of sessions and plausibly mattered in 2 or 3 of 20, about half a call per task; Pi has none.
- Of the prompt's instructions, "act rather than announce" led to a
placeholder edit before any reading, "nothing unrelated" was never cited,
"finish with a short report: how you checked it" was followed but invites
overclaiming, and "check with the project's own tests" is a useful detector.
Prompt sentences change behaviour strongly (
git log -Sfrom 3% of sessions to 61%) and the score not at all, at 13% more cost. fs-edit: the recurring miss is a literal backslash-n inold(5 sessions, 3 or 4 calls each,fs-editgiven up for a Python script); errors namedfs-read; the note of other copies was right once in 8; the closest text showed the right lines 3 times of 4;linewas used 4 times in 242 sessions.- A write to
/tmp/xwas refused in 10 to 22% of sessions, 2.2 calls each; the model checked$TMPDIRon its next call 15 times of 19 whatever the refusal said, and read it as a sandbox's wall. shell-exec: 80% of test runs are piped throughtailorhead; the view with a saved file fires rarely and is never read back, but costs nothing.
Decision, for the lean profile alone; full.scm turns every new switch
off, so a full session is origin/main's, as origin/main's own test files show
when laid over the branch and run with NODWYN_PROFILE=full:
- The system prompt (
lean-system-prompt) is about 90 tokens: who and where, that nobody approves or answers the calls, one phrase per tool oflean-tools(so a project that addsfs-readis told of it), paths and throwaway files ($TMPDIRor/tmp), "Do what the request asks, then check it by running something that exercises the change", and "When you are done, say what you changed, what you ran and what it showed, and anything you could not check". The sentences onoutline,tests-for,schemeand the code-index commands stay, said only when those are offered. - No plan is shown (
lean-plan-hidden).gate.plangainshidden: the one-line plan is recorded, so the core still finds a plan before anything irreversible and the person sees it, and the window shows the model none, where it is recorded, carried to a run's later turn and brought back after a compaction. The run's turn prompt says nothing of the plan's steps. - Harness messages: no reading reminder (
lean-no-read-reminder), no environment hint (lean-no-envhint); the no-change reminder is kept, once, in general words: "No file has changed. If the request needs a change, make it; if it does not, say what you ran that shows the code already does what is asked." (finish-no-change-lean-message, underlean-quiet). The empty-reply message is unchanged. shell-execdescribes itself in a few generic sentences: the program and its arguments,bash -cfor shell syntax, status, stdout and stderr, a long stream shown as its first and last lines with the whole in a file whose path the marker gives, the time limit. The python -c advice and the sentence ontailandheadgo; the view, the saved files and the visible notes stay./tmpis a scratch directory too for a lean session nobody approves. The core gives such a sessionprim.Options.Temp,/tmpwith its links resolved (guard.TempDir,/private/tmpon macOS), and the repository pin and the argv trigger allow a path in it as they allow the scratch directory: written, read and run. The change is narrow. It is made only for the lean profile and only when nobody approves the calls (a person still decides about/tmpin any other session); a path is judged with its links resolved and without a parent component, so a link out of/tmpor/tmp/../etcis outside; another session's scratch directory under/tmp(nodwyn-scratch-*) is outside; a file with a credential's name (the denylist's globs) is outside; nothing else outside the repository is allowed, and the repository's protected paths are judged as before, by checkers this does not touch. WhereTempis set nobody could approve a path outside, so the argv trigger refuses one outright instead of asking, and the refusal names it and says nothing ran ("the command line names /etc/hosts, outside the repository, $TMPDIR and /tmp, so it was refused and nothing of it ran"); the pin's says the call was refused and nothing was done. The files never reach the repository's diff. The guard's tests cover the allowed and the refused cases, and the exfiltration suite passes.fs-edit: its schema is path, old, new, replace_all and edits again, in both profiles (linegoes, with its code); no error names a tool; anoldthat matches nowhere as written but holds\n,\tor\"sequences, and matches exactly once with one level of escaping undone, is applied so, withnewunescaped the same way, and the answer says "old held literal \n sequences; they were matched as real newlines"; the closest text is shown only at a resemblance of 0.5 or more (the cases the tests show score 0.57 and up; a line that shares two of old's words among four others, 0.44, is not), otherwise the error says there is no close match; the note of other copies is given only for anoldof two lines or 40 non-space characters that is not all import lines or decorators, looks only in the files git tracks, outsidebuild,dist,vendorandnode_modules, and says nothing when it finds more than three other places, runs out of files or time, or the repository is no git work tree.
Not built here: the run that measures it. The plan is SWE-bench Verified's
121 tasks, Muse at low effort, through the same bridge: the revamp four
replicates, and the old shell2 build two more so that both have four (the
difference's noise is about 1.2 tasks); measured are the tasks resolved, cost
and calls per task, and the mechanisms (scratch refusals, fs-edit misses and
their recovery, escape repairs, notes of other copies, test runs piped through
tail, no-change reminders, empty patches); then Qwen on the nine
commit-bench tasks after the shell2 build's nine. recall still renders a
shell result without its note.
Amendment 2026-10-17: the lean profile after its first commit-bench session
Commit-bench task 6e18b7ba is nodwyn's own repository at an older commit,
trusted, run with Muse under the lean profile on 2026-10-17. The model made
the change in 4 minutes and then spent 50 on validate, which the
repository's AGENTS.md asks for. Three things stood in its way:
- The repository registers its own tools in
.nodwyn/init.scm(validate, which runsmake validatethroughshell-startand reads it withshell-readuntil it exits) and thedev/*.scmfiles it loads (go-test,go-build,go-vet,go-fmt-check,run-make,git-log,grep-repo), and the lean profile offered none of them: all 104 requests offeredshell-execandfs-editalone. Under the full profile (dsl23, dsl24) the model calledvalidate5 to 8 times and finished in 8 to 13 minutes. go run ./scripts/validate 2>&1 | tail -n 60andgo test -count=1 ./internal/prim/ ./cmd/nodwyn/ 2>&1 | tail -n 10each came back{"status":"timed out after 10m0s","stdout":"","stderr":""}:shell-execkilled them at its ten minutes, andtailprints at its end, so nothing came back for 20 minutes. The model then started the check itself withsetsid nohup bash -c '...; echo EXIT:$? >> /tmp/vd.done' &and polled it withsleep 115; ls /tmp/vd.done || echo RUNNING; tail /tmp/vd.logten times.- The guard refused
sleep 110; ls /tmp/vd3.done 2>/dev/null || echo RUNNING; D=$(ls -td /tmp/nodwyn-scratch-*/nodwyn-validate-* 2>/dev/null | head -n1); ...as naming a path "outside the repository, $TMPDIR and /tmp", though the session's$TMPDIRwas/tmp/nodwyn-scratch-20261018T030629Z-60251800-878901457. The pattern was judged as written: its first component,nodwyn-scratch-*, read as the name of another session's scratch directory, which/tmp's allowance excludes.ls -td /tmp/nodwyn-validate-*had been allowed.
Decision:
shell-execleaves a lean session's command running at its time limit instead of killing it and returning nothing. The model's own call (not a program's of theschemetool or a strategy's, which compute with the result) is run byrunHolding: at the limit the command is kept in the session'sprim.Procs, both streams go on into the saved-output files the view uses,$TMPDIR/out/N.stdoutandN.stderrunder the call's number (made then if the call had none: a command piped intotail, which is never shown as a view, keeps up to 8 MiB of each stream for this), and when it ends whatever it left in its process group is stopped, as for every command, the files are closed and its exit status is written, whole and renamed into place, toN.exit. The result isstatus"still running after 10m0s", the view of what it printed by then (empty undertail), and a note: "the command was not stopped and is still running: its output goes on into $TMPDIR/out/N.stdout and $TMPDIR/out/N.stderr, and its exit status is written to $TMPDIR/out/N.exit when it ends (the session stops it if it is still running when the session ends)".Procs.Close, which the session calls when it closes (a daemon closes its sessions when it is stopped), stops each such command's process group and waits for its files before the scratch directory is removed; a command whose session is already closing, or whose file cannot be made, is stopped at the limit as before, and an abort stops it at once. The lean description's last sentence says so: "A command still running at the time limit is left running, and the result names the files its output and exit status go to." The full profile kills at the limit as it did, in the same code. Not built: a bound on how many commands a session may leave running; each costs the model ten minutes of waiting first.- A glob pattern in a command line is judged by where it can match, in
both profiles, since the refusal was a false one. A word with
*,?or[in a path component reaches its leading directory (the path up to the first such component, cleaned) and every path it matches when the call is judged (filepath.Glob, a relative word's in the repository root); each is judged as that path written out would be, its links resolved as a plain path's are, and the word is inside only when all of them are inside the repository, the scratch directory, or/tmpwhere that allowance applies (guard.ArgvTrigger,globPlaces). Nothing else is loosened. A match through a link in/tmpor in the repository that points outside is outside, which a pattern used to hide (cat /tmp/l*/hostswas judged by/tmpalone); another session's scratch directory is outside when a pattern matches it; a pattern in/tmpnamed as a credential file (/tmp/*.pem) is outside, as the name is; a word with a parent component is judged as written, as every path with one is (/tmp/../etc/*is outside either way, and cleaningsrc/../*to the root would hide a link thatsrcis); an option (-*) and a word Go cannot read as a pattern are judged as before. Every path of a word is judged, a flag's value after=included: the trigger used to judge only the first one outside the repository, and let the rest go when that one was in/tmp. The commit-bench line is the test, run in an unattended lean session whose$TMPDIRis under/tmp; it is refused on the unchanged code.
Tried and not kept: offering the tools a trusted project registers
itself, the first of the three. It was built on 2026-10-17: the lean offer was
lean-tools and then every tool register-tool recorded while the project's
init file ran, and a session of nodwyn's repository was offered validate,
go-test and the rest. In the commit-bench run its sessions were shorter, 39
model calls a task against 43, and their patches less complete: 84% had tests
against 91%, 53% changed the docs against 59%, and a blind review scored them
lower. Several sessions ended right after the repository's validate said
PASS. The lean profile offers shell-exec and fs-edit alone, as before (a
project that wants more offered names it in lean-tools), and the model runs
the repository's checks through shell-exec, which the first decision keeps
from losing a long check's output at the time limit.
A lean session's end on a relay's 404 the same evening (21:49, the only model request that failed among thousands) is ADR 41's amendment of 2026-10-17: such a request is now sent again, in both profiles.
Measured so far (2026-10-17, Muse, effort low): task 6e18b7ba was run
again. The landed lean build's first attempt had run under the evening's
heaviest load (ten commit-bench attempts, twelve SWE-bench containers and the
local model's queue on one host); two more attempts under ordinary load
resolved it in 6 and 8 minutes. The build with these two changes and the
project tools resolved it 3 times of 3 in 5 to 9 minutes, and no command of
its 34 sessions reached shell-exec's limit. This build, without the project
tools, is not measured yet; both changes apply to the 121 SWE-bench tasks as
well, where a check piped into tail past ten minutes and a pattern in
/tmp can show them.
Amendment 2026-10-18: the lean prompt carries the repository's AGENTS.md
ADR 36 says a repository's root AGENTS.md rides in the system prompt, and
the manual said both system prompts carry it. Since the lean ladder's third
rung (2026-10-16) the lean prompt is a third one, lean-system-prompt, and it
never called (project-instructions-section): an unattended lean session, the
default for nodwyn run with --yolo, --unattended or --judge, had none
of the repository's instructions in its prompt, and learned of them only if
it read the file itself. Nothing decided that; the amendments of 2026-10-17
do not mention the file.
Decision: the lean prompt ends with (project-instructions-section), the
same heading and text as the full prompts, after its last fixed sentence, so
the fixed part stays the provider's cached prefix and a repository without
the file gets the lean prompt byte for byte as before. The section's first
newline is dropped, since the lean prompt's lines already end in one. The
32 KiB cap, the redaction and the reading rules are the primitive's and
apply as in the full prompts; a project that redefines the section to ""
gets the fixed lean prompt. The full profile's prompts are unchanged.
Why: the bar of 2026-10-17, that every sentence must earn its place in the
measurements, is for what the harness adds. AGENTS.md is the repository
speaking, its maintainers' rules for how a change there is built, tested
and validated, which ADR 36 put in every prompt with no setting to turn it
off; a coding agent that reads AGENTS.md is what the convention expects.
nodwyn's own file adds 10,340 characters, about 2,600 tokens, to a lean
prompt of about 650 characters; a repository without the file pays nothing.
Its effect was measured on commit-bench, where a task such as 6e18b7ba is
nodwyn's own repository at an older commit.
Not carried: the project notes ((project-notes-section)). They are the
agent's own notes from earlier sessions, which the full prompts call leads to
check, not the maintainers' word, and whether they help a lean session has
not been measured; a project that wants them appends the section in its
init.scm. Also unchanged: the cap's last line says to read the rest of a
long file with fs-read, which the lean profile does not offer; the model
reads it through shell-exec.
Measured (2026-10-18, Muse, the 64 commit-bench jobs of experiment E
below): with AGENTS.md in its prompt the lean profile resolved 59 of 64,
and of its patches 92% had a test, 62% a document and 50% an ADR change, at
38.5 model calls a task. That is within the noise of the landed lean build
(88%, 61% and 47% of its 96 patches): commit-bench's task text already sends
the model to AGENTS.md, so the prompt mostly carries what the model would
have read. The section stays for the reason above, not for a measured gain.
Amendment 2026-10-18: this repository's completeness rules are its own (experiment E)
On commit-bench, this repository at each task's parent run trusted and
unattended, the lean profile's patches leave out more of what AGENTS.md asks
a change here to carry than the full loop's: of 96 lean patches 88% had a
test and 61% a document, against 100% and 78% under the full loop, with
correctness and the blind reviews' overall scores about equal. The lean loop
says nothing as a turn ends (lean-quiet), and was measured on SWE-bench,
whose repositories ask for none of it. A test, a document, a decision record
and a regenerated source catalogue are this repository's rules, not every
repository's: they belong in its own customisation, which a session loads
once the person trusts the revision (ADR 45), not in the loop nodwyn ships.
The commits the tasks were made from carry them as follows: all 73 a test,
63 a document, 50 a change under docs/adr, and 18 of the 19 whose parent
has a source catalogue regenerated it.
What was found first, of the validate reminder .nodwyn/init.scm already
had: it runs in a lean session too, since the file redefines
default-agent-turn, which agent-turn calls in either profile, but the
lean profile does not offer validate (lean-tools is shell-exec and
fs-edit, and the file adds nothing to it), so the reminder names a tool the
model cannot call. The model runs go run ./scripts/validate through
shell-exec instead. The evidence loop counts such a run as validate's
(validated-run), but the reminder's own continuation (validate-continue,
the chat loop) does not, so the second reminder follows a validate run made
after the first whatever it found: in the lean run
cb-landed-d635ad3-or-muse13c-el-trusted-home-r3 the reminder came twice in
each of 3 of its 9 sessions, and on 48f90ea8 the second came after a validate
run with no edit since ("No code changes this turn; re-ran validate").
Neither is changed by this decision; ADR 37's amendment of the same day
changes both.
Decision. .nodwyn/init.scm gains a finish check of its own, in both
profiles, after the validate reminder and last before a turn ends. It reads
the change with git-diff against the commit the session's worktree started
from, else the one HEAD named when the turn began, and adds the files git
does not track. When the turn changed code (a .go file that is not a test,
or a .scm file outside test/, in the change now and not, or not so, when
the turn began) and the repository has an AGENTS.md, which the message cites,
the turn is told in one message each of these the change lacks:
- a test: no
_test.gofile and nothing undertest/exfil/ortest/browser/checks/, as scripts/validate counts tests; - a document: nothing under
docs/but the generated catalogue and wire schema, and no Markdown file; - a decision record: nothing under
docs/adr/; - the regenerated source catalogue, where
docs/book/source/catalogue.texexists: it is not in the change, and a Go or Scheme file was added or deleted, or the lines added and removed differ for the Go files, the Go test files or the Scheme files, which is whatgenerate.pycounts.
The message ends "Add what the change needs, or say in your answer why it
needs none of it.": not every change needs each, and a third of the commits
above changed nothing under docs/adr. The turn then goes on as the chat
loop does. It is said at most once a session, and not while the turn is
stopping (turn-stopping). A diff cut at the tools' output limit is not
judged; a directory git does not track is one entry, its contents unread
(one of the 73 commits adds a directory), and an untracked file the turn
edited again is not seen, as with the validate reminder.
scripts/validate/repo_finish_test.go runs the repository's own init.scm in
a full chat and a lean run: what is missing is said, and only that; nothing
is said of a whole change or where there is no AGENTS.md; and it is said once
in a run of two turns.
The section uses nothing else in init.scm, only the loop the binary carries,
so the measurement can lay the same text over the older revisions the tasks
start from, whose init.scm lacks it: commit-bench's run.py --extra-init
copies the file into the attempt's .nodwyn/, loads it from the end of the
parent's init.scm (or an init.scm of its own where the parent has none) and
keeps both out of git's view, so neither the model's git diff nor the patch
holds them.
Measured (experiment E, 2026-10-18, Muse, 64 commit-bench jobs): the lean profile with the check resolved 64 of 64, and of its patches 94% had a test, 77% a document and 67% an ADR change, against 88%, 61% and 47% of the landed lean build's 96 patches. The blind reviews scored them 71.0 against 66.9, higher on 6 of the 9 tasks reviewed. Not yet counted from the same runs: the regenerated catalogue, cost and calls per task, how often the check was said and what the model did after it.
Amendment 2026-10-18: the lean loop in sandbox mode
ADR 55 adds sandbox mode, a run on a disposable machine such as a
Terminal-Bench task container, where the folder is often no repository and
every path but nodwyn's own and credential files is the tools'. Two things
the lean loop says were false there, and are said differently only there,
behind (sandbox?); every other session's prompt and loop are byte for byte
as they were, as TestASandboxSessionChangesFilesOutsideItsFolder and
TestASandboxTurnIsNotToldThatNoFileChanged show both ways.
- The prompt. "You are a coding agent working in the repository at /app" and "Paths are relative to the repository root" tell the model its paths stop at a repository root, which they do not in a sandbox, and the task's folder is often no repository. In sandbox mode the first sentence names the folder and adds "This machine is a disposable sandbox: anything on it is yours to read, change, install or run.", and the second becomes "Relative paths are relative to /app". These are statements of fact about where the model works, as "unattended: nobody approves or answers your calls" is, not habits; they were not measured, and the comparison on Terminal-Bench is what will.
- The no-change reminder.
lean-empty-reminderstells a turn that changed nothing so, once: with git, a tracked file that differs; without it, a write by the file tools. A Terminal-Bench turn changes the machine mostly through the shell (echo ... > /app/regex.txt,apt-get install, a file in/etc), which in a folder that is no git work tree the loop cannot see, and git sees only the folder. The reminder would say "No file has changed" after nearly every such turn, falsely, and cost a request. In sandbox mode it is not said, in either profile.
shell-exec's behaviour changes too (ADR 55 §4): in sandbox mode what a
command starts in the background keeps running. The lean description never
spoke of background processes and is unchanged; the full one says so.
Not changed: the full profile's prompts, which say every path stays inside the repository; sandbox mode is meant for the lean loop.
Amendment 2026-10-19: a sandbox run's finish check
In sandbox mode only ((sandbox?), ADR 55), the default loop says one more
thing as a turn ends: the first time a turn of the session ends on the
model's word, sandbox-finish-message asks the model to check its result as
someone who did not watch it work would, to replace a part that only makes
its checks pass, or that came from anywhere but its own work (a published
solution or answer key, the runner's own files), with a real solution or
remove it and say what does not work, and the turn goes on as the evidence
loop. It comes from Terminal-Bench
2.1, where a sandbox run twice made a task's checks pass with a result
shaped to them (three pixel values hard-coded; expected pages left in place
as a fallback behind a deploy hook) and said nothing of it. It is said under
lean-quiet too, in either profile, since it is sandbox mode's and not a
rung of the lean ladder; no other session hears it. Not yet measured; ADR 55,
amended 2026-10-19, has the rest.