internal/ was 141 packages side by side, so nothing but a reader's memory
said which were foundations and which assembled the rest. Each now sits in a
layer directory, and the directory is the declaration:
base L0 foundation with no domain in it
contract L1 types and interfaces the layers above agree on
model, tools, safety, ext, state, platform
L2 subsystems, peers of one another
runtime L3 the turn engine
session L4 the controller
assembly L5 the composition root
frontend L6 cli, serve, acp and the host adapters
Only paths change: package names, code and behaviour do not. Imports,
repolint and coverage baselines, sensitive paths, CI package lists and docs
follow the new paths; release notes keep the paths they were written with,
and sample paths inside tests and fixtures keep theirs.
repolint gains layer-order: a package may import its own layer and those
below it, read from where it lives. The 47 imports that point upward today
are recorded as that rule's opening debt (-allow-widen, a new rule's first
baseline) and can only go down. Most of it is config, which still reaches
into eight subsystems and is the next thing to take apart; the rest is
event, history and a few tools reaching into runtime for types that belong
in contract.
Two ways to sort a string slice were in the tree at once — slices.Sort in 30
places, sort.Strings in 148, and internal/hook/inspect.go used both. Since Go
1.22 sort.Strings is a call to slices.Sort, so this settles which one a reader
meets, not what the code does.
The four files that would have grown by an import line are already over their
size budget, so their remaining sort.Slice calls move too: cmp.Or over
cmp.Compare says a multi-field ordering in one expression where the boolean
form needed a chain of ifs, and the four files come out shorter than they went
in.
math/rand becomes math/rand/v2 throughout. The seeded call sites all use a
fixed seed for repeatability rather than asserting particular values, so PCG
replacing the v1 source changes which sequence they walk and nothing about
what they prove.
Backoff stays as it is. Four packages compute a retry delay and it is tempting
to share that too, but provider caps at 15s and the stream retry at 8s to match
a reference implementation's timing, while feishu uses proportional jitter and
remote full jitter. Those are four calibrated decisions, not one judgement
copied four times, and the formula is the part that was never hard.
The barrier analyzer anchored on "complete_subtask accepted", which the verdict
change removed, so it would have reported no children at all. It now reads both
vocabularies and anchors on the same thing in each: the host saying the sub-task
is done. In the arm that had one word for both outcomes, an accepted report with
no lowered claim is that moment.
Re-anchoring changes what the control says. Nothing follows the first closure in
14 of 14 control children, and nothing follows it in 17 of 17 treatment children.
Tool calls after closure were never the problem: the model already stopped when
the host said done. The earlier reading came from anchoring on the first
well-formed call, which is not the same event as the first closure — the host had
answered both with the same word, and the analysis inherited the conflation.
What the treatment bought is therefore not a behaviour change, and the record
says so. Closure is now exactly one identifiable event per child, counted in the
audit instead of recoverable only from a trajectory, and needs_work produces real
work: across 21 verdicts, only 2 were resubmitted with nothing done in between.
Recovery attribution earns its commit here: all 29 repairs were
missing_reasoning_model_silent, 14 inside children, so host recovery is not what
drove the continuations. That is now evidence rather than an assumption.
A new offline mode answers one question against already-recorded runs: if a
child's first accepted complete_subtask had closed it, what work would that
have suppressed. It spends no tokens and changes no runtime behaviour, and it
classifies with the host's own readers rather than a second reading of the same
commands — ToolCallMutationClass and CommandRunsVerification, with verification
tested first because a command whose extent the host cannot establish already
classes as a mutation, and a check is exactly such a command. MutationProven
and MutationUnknown stay apart: reading them as one turned four real writes
into eight, which is the answer this study exists to get right.
The result rejects the barrier. Of 14 children with an accepted report, 1 wrote
a named path afterwards and 7 ran commands of unestablished extent; only 4 did
nothing further. The 47 post-report bash calls are mostly ad-hoc assertions and
inspection that CommandRunsVerification does not recognise as checks, so the
opaque bucket bounds unclassified work rather than claiming it mutated.
What it found instead is that the host asks for the continuation. First-report
adjudication and continued work separate perfectly across all 14: every child
whose first report had claims lowered kept working, every child whose report
stood intact stopped. complete_subtask answers a lowered report with "accepted
... run the check or cite what you really did" while the appended contract says
to call it exactly once as the final tool call. The child follows the later and
more specific instruction. The gap is not a contract without enforcement — it
is an adjudication that is not a state, since a lowered report and an intact
one both return "accepted".
The research record is corrected in the same commit. Its HostDowngrade 0/14 was
read off the handoff audit, which adjudicates only a child's latest report; over
all 39 accepted calls, 25 were adjudicated partial and carried lowered claims.
Extracts the offline-mode dispatch out of main rather than widening its
function-size budget for one more case.