Skip to content

feat(runner): a role runs through the command-line harness the operator chose (itd-2609201916056194) - #767

Merged
REPPL merged 18 commits into
mainfrom
feat/runner-loop
Sep 30, 2026
Merged

REPPL merged 18 commits into
mainfrom
feat/runner-loop

Conversation

@REPPL

@REPPL REPPL commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

A role in the build loop can run through a command-line harness the machine enables, instead of an agent the host starts. When a stage hands the lane to a role that roles.<role>.runner routes to claude or opencode, abcd implement step starts the harness itself with the same brief and receipt path the host would get, checks its receipt with the stage's own verifier, stores its transcript in abcd's history store, and records which route ran it. When the harness is missing, refuses, fails or writes a receipt that does not verify, the host is handed the role exactly as before and the run records the fallback; implement status and implement record count fallbacks per runner and per role. A role left unset behaves exactly as today.

This delivers itd-2609201916056194: its spec, spc-2609221533057881, closes here with impact additive, under the product thinker's ruling RN1 of 2026-09-30 ("the runner may close on the fake-harness tests with the three live checks recorded as owed"). It also lands piece 3 of spc-2609202134338445 in part.

Owed, not done: the three live checks have not been run: (1) a real review routed to opencode, its record read beside a host-run review's and differing only in the route; (2) the claude runner under --bare with the role's tools granted, confirming a nested claude does not refuse under an inherited session variable (it spends an API key, not the subscription); (3) opencode's run mode, confirming its JSON event shape and whether it stops to ask for a permission. Every harness in the tests is the test binary on a PATH of its own; no real claude or opencode binary has run a role. The checks are tracked on iss-2609301519558538, which stays open, and the intent's Audit Notes say so.

What changes:

  • loop.Drive (internal/core/implement/loop/drive.go): the step starts a routed role through runner.Dispatcher; the dispatcher's validator is the loop's own Receipt, run under the run's lock. State schema 8 adds fallbacks and a route on a verified receipt or a validator's return.
  • abcd build and build next read the runner configuration before the run is created, and refuse a model route off its provider's allowlist at the runner stage with nothing created.
  • implement step drives through Drive, with an interrupt killing the runner's process group; status and record render the fallback counts and the route.
  • The runner refuses a harness binary inside the run's checkout as well as inside the lane's worktree.
  • The runner refuses a harness binary, or the directory it is reached through, that other can write, or that a group other than darwin's admin group (gid 80, on darwin only) can write (iss-2609301557191251), and treats a harness-reported model that is not a plain id of at most 128 characters as an unparsable answer, so the route falls back rather than writing it into the run's state (iss-2609301557141123).
  • Homebrew exception: on a Homebrew Mac /opt/homebrew/bin is mode 0775, group admin, so a harness installed there was refused. A directory or binary that only darwin's admin group (gid 80) can write beyond its owner is admitted, on darwin only, since that group's members may sudo by default; gid 0 is refused on every OS (Linux's root group and darwin's wheel do not by themselves let a member act as root), gid 80 is refused off darwin, other-writable stays refused whatever the group, and so does every other group (internal/core/runner/proc.go adminGroupWritableOnly, recorded in DECISIONS.md).
  • The runner's config follows main's ruling H9 (adr-2609300107513982): the allowlist alone decides, and a configured oracle.denylist entry still refuses.
  • Help text, the CLI reference, commands/build.md, commands/implement.md, the build brief chapter and ACKNOWLEDGEMENTS (the claude CLI's print mode and opencode). The docs review for the close found two false sentences in the build chapter and the step help (a runner record line that is not written, and tools granted unasked for opencode, which leaves permissions to its own configuration); both are corrected here and the review is saved as PROMOTE.

Not built: the loop driving itself with no host session (runner.fallback_host at the surface). That is the process driver's reversal of the host-delegated boundary, and decision 6 of itd-2609201916151817 owes an ADR before it ships.

For the reviewer: the claude CLI's headless page says --bare never reads OAuth credentials or the keychain, so a claude runner spends an API key from its environment, not the person's subscription. opencode's CLI page does not document its JSON events' shape, so the adapter's parse of it is an assumption the live check must confirm. The administrator-group exception is darwin's admin group (gid 80) only; gid 0 is refused on every OS.

Delivers: itd-2609201916056194
Refs: iss-2609301519558538
Resolves: iss-2609301557141123
Resolves: iss-2609301557191251
Refs: spc-2609221533057881, itd-2609201916151817, spc-2609202134338445

Assisted-by: Claude:claude-opus-5-5

…re only

internal/core/runner is phase 1 of spc-2609221533057881: the runner
interface, the claude CLI and opencode adapters, the route per role, the
one fallback branch with its receipt, and the tally the run's summary
reports. Nothing calls it yet; the loop and the CLI wire it in phase 2.

- Route: roles.<role>.runner through the layered resolver (host when
  unset); runner.<name> enables a shipped runner with an optional
  <provider>/<model> route; runner.fallback_host is the landing with no
  host session.
- Allowlist: a runner's model route is admitted through the oracle
  adapter's Admit (allowlist, then the vendor denylist) when the
  configuration is read, and again before launch.
- Fallback: absent, refused, failed, unparsable or invalid hands the role
  to the host session, else to the configured host, and writes exactly one
  receipt naming the role, the runner asked for, the reason and the route
  that ran. No host session and no fallback host is refused before launch.
- claude: --print --bare, stream-json, no session persistence,
  --permission-mode dontAsk with the contract's tools allowed.
- opencode: run --format=json --pure --dir --file, the prompt behind --.
- Process hygiene: argv vector, binary on PATH refused inside the
  repository, gitutil.ScrubbedEnv, null stdin, bounded stdout/stderr,
  own process group killed on timeout, errors written by abcd only.
- Transcripts land in the history store (history.Capture, redacted).

Decisions taken here, not in the record:
- The runner namespace is machine-only (a repository declaring it is
  refused), on the grounds of ruling AA(b) and the provider blocks'
  precedent: which harness starts, on whose credential, is the machine's.
  roles.<role>.runner may come from either layer.
- A role routed to a runner the machine did not enable is an absent
  runner: recorded and fallen back, never a silent host run.
- Environment is ScrubbedEnv, not IsolatedEnv: an implementer's git must
  keep the person's global identity; repository selection and config
  injection are still stripped.
- opencode gets --pure (no external plugins) as its analogue of --bare,
  and not --auto.
- A failed fallback host is an error naming both; the receipt of the
  fallback tried is still recorded.

Refs: itd-2609201916056194
Assisted-by: Claude:claude-opus-5-5
…on a rune boundary

failureOf had no caller once the dispatch took failures by errors.As; the
validator refusal a fallback receipt carries is bounded at 300 bytes and is
now cut without splitting a rune.

Refs: itd-2609201916056194
Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
…n's ruling H9

Main retired the bundled anthropic/* vendor denylist (ruling H9,
adr-2609300107513982, superseding Decision 2 of adr-2609221009491186). The
runner's configuration comment still named "the vendor denylist", and
TestModelOffTheAllowlistIsRefused carried a "denied vendor prefix" case
(local/anthropic/claude-opus) that, after the merge, passed only because
the model was not on the provider's list, not because of any denylist.

The stale case is dropped, and TestAllowlistAloneDecides states the ruling:
a vendor-prefixed model a provider lists is admitted, and an oracle.denylist
entry the configuration writes still refuses it. The test was watched fail
on the pre-merge base (the bundled denylist refused the listed model) and
pass after the merge.

Refs: itd-2609201916056194, spc-2609221533057881

Assisted-by: Claude:claude-opus-5-5
… the loop

The loop's process driver (spc-2609202134338445 piece 3) and the runner's
loop half (itd-2609201916056194 phase 2). loop.Drive performs the next stage
as Advance does; when the stage awaits an agent whose role
roles.<role>.runner routes to a runner, it starts that agent through
runner.Dispatcher with the brief and receipt path the host would get, and
the dispatcher's validator is the stage's own receipt verifier, run through
Receipt under the run's lock, so a runner's answer is judged exactly as a
host sub-agent's and a verified one advances the lane there and then.

- A role left on the host returns the await unchanged, with the same state
  bytes a plain step writes (criterion 2).
- A verified receipt, or a validator's recorded return, names the route that
  ran it (asked, ran, reported model); a host-run one names none, so a
  runner-run review's record differs from a host-run one's in the route
  alone (criterion 7, structural). The run record gains a "runner" line.
- A runner that is absent, refuses, fails, answers unparsably or writes a
  receipt the verifier refuses leaves the lane awaiting, appends one
  fallback receipt to the state's fallbacks[] and a "fallback" record line,
  and hands the host the role with the reason (criterion 3); the run record
  carries the fallbacks and runner.Tally's counts per runner and per role
  (criterion 4).
- State schema 8 (fallbacks, route); a version-7 file is read and written
  back at 8, and one carrying either is refused.
- The runner's Validate is handed the name of the runner that ran, so the
  loop can stamp the route.
- The tools a runner grants each loop role: a reviewer's agent definition's
  tools plus Write (its brief tells it to write its return), held to
  agents/*.md by a test; the implementer's are what a lane's work takes.

A host session always drives the call: the no-host path (runner.fallback_host
for a host-routed role) is the process driver's reversal of the
host-delegated boundary, whose ADR (itd-2609201916151817 decision 6) is owed
before it ships, so it stays in the core and no surface reaches it here.

Tests watched fail against a stub Drive that only advanced:
TestARoutedRoleRunsThroughItsRunner,
TestARunnerThatCannotRunTheRoleFallsBackAndIsRecorded (absent, invalid),
TestAReviewThroughARunnerDiffersFromAHostReviewOnlyInItsRoute,
TestRoleToolsFollowTheAgentDefinitions. Every harness is the test binary on
a PATH of its own; no real harness runs.

Refs: itd-2609201916056194, spc-2609221533057881, itd-2609201916151817, spc-2609202134338445

Assisted-by: Claude:claude-opus-5-5
… step

The front door onto the runner's loop half (itd-2609201916056194 phase 2,
spc-2609202134338445 piece 3).

- `abcd build` and `build next` read the runner configuration
  (loop.LoadRunners over runner.Load at layered.RootsFor) before the run is
  created: a fault, a model route its provider's allowlist does not admit
  included, is refused at the `runner` stage with nothing created or
  launched (criterion 5); the diagnostics go to stderr.
- `abcd implement step` reads it on every call and drives through
  loop.Drive: a routed role is started by the step itself, its transcript
  stored in abcd's history store keyed on the root commit (resolved on the
  first transcript), and the result names the `route` that ran it or the
  `fallback` it recorded. An interrupt or a termination ends the context, so
  the runner kills the process group it started.
- `implement status` and `implement record` count the fallbacks per runner
  and per role (criterion 4); the record names the route on a receipt or a
  verdict a runner produced, and lists each fallback.
- The verbs' help, the generated CLI reference, commands/build.md,
  commands/implement.md and the build surface chapter describe it;
  ACKNOWLEDGEMENTS credits the claude CLI's print mode and opencode.
- The driver's tests cite the harness docs as read on 2026-09-30: the
  claude page documents the stream-json events the fake prints, dontAsk and
  --bare; opencode's page does not document its JSON events' shape, which
  stays the adapter's assumption and a live check owed.

A step that re-tells an await starts no runner and records no second
fallback (TestAStepThatReTellsAnAwaitStartsNoRunner; found in design, not
watched fail).

Tests watched fail on the previous commit (a scratch copy with the new test
file) and pass here: TestBuildRefusesARunnerModelOffItsAllowlistBeforeTheRun
(build exited 0), TestAStepWhoseRunnerIsAbsentHandsTheHostTheRoleAndCountsIt
(no fallback named). That test runs with PATH holding git's own directory
alone and asserts no claude or opencode resolves.

Refs: itd-2609201916056194, spc-2609221533057881, itd-2609201916151817, spc-2609202134338445

Assisted-by: Claude:claude-opus-5-5
`implement step` reaches the loop through Drive, so Advance and the role
tool table had no caller outside the package and the exported-reach audit
refused them as new unreached exports. They are unexported (advance,
toolsFor) rather than baselined; the package doc names the process driver.

Refs: spc-2609202134338445

Assisted-by: Claude:claude-opus-5-5
…e proof

spc-2609221533057881 gains a Progress section: the core and the loop
wiring have landed, criteria 1 to 6 are met on fake harnesses, and the
spec stays open for the live proof and the security review. The loop
spec (spc-2609202134338445) records piece 3 as landed in part: the step
starts a routed role through its runner; the loop driving itself with no
host session waits on decision 6's ADR.

The owed live proof is captured as iss-2609301519558538: what a person must
run, including the headless page's statement that --bare never reads OAuth
or the keychain, so the claude runner spends an API key rather than the
person's subscription.

Refs: iss-2609301519558538
Refs: itd-2609201916056194, spc-2609221533057881, spc-2609202134338445

Assisted-by: Claude:claude-opus-5-5
…nly the worktree

A lane's worktree lives in the machine-scoped store, outside the checkout
the run belongs to, and the launcher refused a PATH binary only inside the
directory the role runs in. Wired to the loop, that left a program planted
in the checkout itself (repository content, reachable through a PATH entry
into it) admissible. runner.Request gains Checkout, the loop sets it to the
run's checkout, and admit refuses a binary inside either, lexically or
through a symlink.

TestBinaryInsideTheCheckoutIsRefused was watched fail (the planted opencode
launched) and passes.

Refs: itd-2609201916056194, spc-2609221533057881

Assisted-by: Claude:claude-opus-5-5
…hers can write

A claude init event's model went unbounded and unshaped into the answer
and, through the route record, into state.json: a model over 4 MiB pushed
the state past its read bound and bricked the run, and control bytes
travelled verbatim. The model is now held to modelRe, bounded and plain as
sessionRe holds a session id (a bracketed context suffix admitted), and any
other is an unparsable answer: the route falls back and the fallback is
recorded, the model never written.

admit started a harness binary on PATH even when the binary, the directory
PATH reaches it through, or the directory it resolves into was writable by
group or other. Each is now stat'd after EvalSymlinks and refused as absent
when its mode carries a group or other write bit, through the one test
CallersAlone applies, exported as fsutil.WritableByOthers. Ownership is not
required: a root-owned system binary is a legitimate harness.

Tests watched fail first: TestAModelPastItsShapeIsARefusalOfTheRoute,
TestAHarnessOthersCanWriteIsRefused, and the huge-model and control-model
cases of TestARunnerThatCannotRunTheRoleFallsBackAndIsRecorded.

Refs: iss-2609301557141123
Refs: iss-2609301557191251
Assisted-by: Claude:claude-opus-5-5
…s model bounded, writable harness refused

Resolves: iss-2609301557141123
Resolves: iss-2609301557191251
Assisted-by: Claude:claude-opus-5-5
Brings the branch up to main at df8815a before the runner lands. The
merge is textually clean (docs/reference/cli/commands.md auto-merged) and
the tree builds and vets.

Assisted-by: Claude:claude-opus-5-5
On a Homebrew Mac /opt/homebrew/bin is group admin, mode 0775, so a
harness installed there was refused as one "group or other can write".
The runner now admits a binary or directory whose only write bit beyond
its owner's is the group's, when that group is the system administrator
group (gid 0, or gid 80 on darwin): its members can already act as root,
so the write grants nothing new. Other-writable stays refused whatever the
group, and so does every other group and an unreadable one.

TestAHarnessOnlyTheAdministratorGroupCanWriteIsAdmitted names the group
through a test seam (fileGroup), since a test cannot chown to gid 0 or 80.
The decision is recorded in DECISIONS.md.

Assisted-by: Claude:claude-opus-5-5
The runner paragraph said the record gains a `runner` line. The record
writes none: it names the runner as a suffix on the receipt or verdict
line (build.go routeSuffix). Found by the docs review this lane recorded
as HOLD before the spec close.

Assisted-by: Claude:claude-opus-5-5
The build chapter and the implement step help said every runner runs with
the role's tools granted without a prompt. The opencode runner passes no
tool grant and leaves permissions at the harness's own configuration
(opencode.go), so both now say so. The CLI reference is regenerated.
Found by the docs review this lane recorded as HOLD before the spec close.

Assisted-by: Claude:claude-opus-5-5
Closes spc-2609221533057881 with impact additive under the product
thinker's ruling RN1 of 2026-09-30: the runner closes on the
fake-harness tests, with the three live checks recorded as owed. The
intent's Audit Notes carry the dated ruling; the live checks stay open on
their capture. The docs review for the close is saved as PROMOTE after
two HOLD findings were corrected in the brief and the step help.

Refs: iss-2609301519558538
Delivers: itd-2609201916056194
Assisted-by: Claude:claude-opus-5-5
The preflight on this branch failed two detectors that predate it:
TestNoPrivateDelimiterCompare refused the transcript's stderr marker,
which opened with a `---` run a frontmatter reader could take for a block
delimiter, and TestTestGitCallsAreHermetic refused dispatch_test.go's git
init, which built its own environment instead of gittest.Env. The marker
opens with `==` and the test's git runs through gittest.Env.

Assisted-by: Claude:claude-opus-5-5
The administrator-group exception admitted a group-writable harness
binary or directory whose group was gid 0 on every OS, on the premise
that its members can already act as root. That holds for darwin's admin
group (gid 80, whose members may sudo by default), not for Linux's root
group nor darwin's wheel: membership of either does not by itself let
someone act as root. The exception now holds only on darwin and only
for gid 80, which still covers Homebrew's /opt/homebrew/bin; gid 0 is
refused on every OS, and other-writable stays refused whatever the
group. Recorded as a new dated line in DECISIONS.md.

Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL enabled auto-merge September 30, 2026 20:25
@REPPL
REPPL added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit a76d7f5 Sep 30, 2026
14 checks passed
@REPPL
REPPL deleted the feat/runner-loop branch September 30, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant