Repository navigation
Releases: ultus-net/opencode-workflow-guard
Release list
v1.19.0
Minor Changes
-
5f97b7e: feat: opt-in pre-deployment dogfood gate (Policy 27)
Gates PR creation on a recorded run of the change on real hardware. Off by
default, so no existing repository changes behavior.record_dogfoodrecords a run asdogfoodevidence bound to the workspace,
commit hash, and tracked-content worktree fingerprint. A tracked edit or new
commit after the run makes the evidence stale.- Only a
passis durable, and only the newest record per workspace and host
is kept. Aholdstays diagnosable in memory with its taxonomy class and
never becomes evidence. Recording aholdalso discards that host's cached
passes, so a process restart cannot re-open the gate on an older green run. - A
passrequires every check row to bepassand at least one check row
to exist, so the gate cannot be satisfied by a skipped check or by an empty
observation. Aholdmust name a taxonomy class. requireDogfoodanddogfoodDeployMatrix(orWORKFLOW_GUARD_REQUIRE_DOGFOOD
andWORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX) enable the gate and declare the
host labels that each need a passing run. Retention and hold eviction are per
host and scoped to the workspace, so repeated runs in one repository or on one
host cannot evict another repository's or host's only pass.WORKFLOW_GUARD_DOGFOOD_ACTOR/WORKFLOW_GUARD_DOGFOOD_DEPTHmark a session
spawned by a repository runner. Every policy still applies there, and the
marker only refusesrecord_dogfood, so a tested session cannot certify
itself.guard_statusreportslastDogfoodand addsdogfoodto
outstandingRequirementswhen the gate is unsatisfied.
v1.18.1
Patch Changes
-
c983c70: Report a verification timeout as a timeout, not as a failing test.
Finalization runs the verify command with a bounded timeout. When the command
exceeded it, the guard killed the process tree and then reported the outcome
with the same message it uses for a failing assertion: "all tasks marked done
but verification is failing". That wording sent the agent looking for a test
that failed. Nothing had failed; the suite never finished, so the result was
unknown.The two conditions now carry separate codes and reasons.
runVerifyreturns
timedOutand the limit that was hit, and the dispatcher emits
verification_timed_outwith a message naming the elapsed limit and the remedy
(verifyTimeoutMsin.opencode/workflow-guard.json, or
WORKFLOW_GUARD_VERIFY_TIMEOUT_MS). A genuine failure keeps
verification_failedand its output tail unchanged.Both still block finalization. A timeout is not evidence of passing work, so
allowing it through would be a silent weakening of the verification gate. Only
the attribution changed.Regression coverage asserts five things: a timeout blocks finalization, the
block reason does not claim a failure, the snipped tail carries no "Verification
Failed" header, the limit is named at the precision it was set, and the reason
names a remedy. Four of those fail against the previous message; the block
itself already held, and must keep holding.The remedy is the one lever the agent cannot pull.
.opencode/workflow-guard.json
is tamper-protected, so the reason names both the project setting and the
environment variable, and the operator applies it. That is deliberate: the guard
reports what it knows, and does not pretend the agent can widen its own gate.
v1.18.0
Minor Changes
-
2a68c41: Qualify against OpenCode 2.2.0.
OpenCode 2.2.0 reorganizes the
@opencode/aiexport surface.Messageis no
longer reachable from the package root there, and a root named import throws
SyntaxError: The requested module '@opencode/ai' does not provide an export named 'Message'. A V2 plugin that cannot be evaluated registers no hooks, so
every policy would be inert while the host looks healthy. This release moves the
import and the pins to the 2.2.0 package set.- Import path.
Messagemoves from the@opencode/airoot to
@opencode/ai/effect. On 2.2.0 the root no longer re-exports the schema
surface, so a root named import throws, as quoted above; verified against the
installed 2.2.0 dist.@opencode/plugin@2.2.0itself importsMessagefrom
/effect, so this is the host's own sanctioned path. No call site changes:
Message.useris still the constructor used atsrc/lib/v2-plugin.ts:542. - Pins.
@opencode/pluginand@opencode/aimove to2.2.0, and
@opentui/solidto0.7.0, which@opencode/plugin@2.2.0declares as an
exact peer requirement.@opentui/coreis deliberately not added as a direct
dependency:@opentui/solid@0.7.0already declares it as an exact dependency,
so an extra pin would duplicate a resolution the tree already settles. The
@opencode-ai/plugin1.19.3V1 pin is unchanged. Of the existingoverrides
entries,solid-js1.9.17still applies because@opentui/solid@0.7.0still
peer-pins exactly1.9.12;toml5.0.1is unaffected. - Todo capability. The registry probe already covers this host, so 2.2.0
needs no version check. Verified on the live 2.2.0 binary:availablewith
experimental.todo: true,unavailablewithout it. - Docs.
docs/installation.mdcarries a 1.18.x / 2.2.0 qualification row
listing only the seats actually exercised, and the pin-set paragraph now
explains the@opencode/airoot-versus-/effectboundary and why
@opentui/coreneeds no direct pin. - Test coverage.
test/test-e2e.mtsgains two canaries that need no model
provider: the permission seat denies a real finding and abstains on an
undecidable push. They run inside the real host setup with the workspace taken
from the host's ownlocation.directory; the push case passes no client,
because the point is that simulation skips the remote PR probe. The deny
canary asserts policy identity (secrets/secret_read), not message prose.
Verified on OpenCode v2.2.0:
npm run typecheckclean,npm test1370 passed /
0 failed,npm run test:install32 passed / 0 failed. The opt-in model-driven
probes remained unavailable because the provider returned
Model unavailablebefore guard behavior could run, which that harness reports
aslive unavailablerather than as a guard failure.The
zodadvisory is also cleared here.GHSA-mrhx-c7xc-m3w5covers
zod <4.6.6, and the vulnerable4.1.8copy arrived through the exact
@opencode-ai/plugin@1.19.3V1 pin while the V2 entrypoint importszod
without declaring it. A newzod: 4.6.6override dedupes both onto one patched
copy, andnpm auditnow reports zero vulnerabilities. That override must be
applied with a clean reinstall: an incrementalnpm installleaves the old
resolution in place and fails 14test:installchecks, which reads as an
incompatibility but is a stale tree.One pre-existing issue is deliberately not fixed here, because it needs a real
binary probe and changes behavior beyond a host qualification:
src/lib/policy-posture.tsdescribes thecode-mode-surfacerule as inert on the
qualified hosts because they "register noexecutetool". That rationale is not
accurate: the tool is registered, and this repository's own session calls it on
2.1.3. - Import path.
Patch Changes
-
2a68c41: Let the V2 permission seat abstain when remote PR state is unverified.
Every non-protected
git pushwas denied on OpenCode 2, with a message that contradicted itself. The seat always evaluates the policy core withsimulate: true, which deliberately skips the remote GitHub/Azure PR probe. The core then reportsneeds_approval(remote_state_unchecked), and the seat collapsed every non-allowedstatus intoeffect = "deny". The denial therefore demanded the result of a probe the same code path had just skipped.needs_approvalis a third state meaning "cannot decide here", not a finding.PermissionSeatResult.effectnow carriesabstainalongsideallowanddeny. Only ablockeddecision denies. An abstention leaves the host's configured effect and message untouched, so a configuredaskstaysask, andexecute.beforeruns the real check with the probe. The seat still cannot upgrade an effect.The abstention is journaled with
decision: "abstain",reason: "deferred_to_execute_before", and the preservedneeds_approvaldecision, so a skipped probe stays readable in the audit trail instead of appearing as a plain allow. The seat's fail-closedcatchblock is unchanged.guard_whystill reportsneeds_approval, because that is the honest answer for a simulation; this changes the seat, not the policy core.
v1.17.0
Minor Changes
-
495ba8a: Add Policy 25, the live-documentation research floor. After repeated tool failures (default 3) with no live-documentation lookup since, the guard blocks the next mutation or shell call and directs the agent to consult a current source (
websearch/webfetch, or an MCP documentation tool) instead of guessing from training data. Reads, greps, and research tools are never blocked.The floor is debt-based rather than success-based: an unrelated successful tool does not clear the debt, so a benign command cannot slip a guessed change back through. Only a successful research call clears it. It is enabled by default to force the failover and can be disabled with
requireLiveDocs: false/WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0, or retuned withliveDocFailureThreshold/WORKFLOW_GUARD_LIVE_DOC_THRESHOLD. Decisions are audited (reason: live_docs_research_floor) and block feedback flows to the app log at warn level.Note for operators: the acting agent must be permitted to call a research tool, or the floor will (correctly) stall it. Agents whose toolset denies
websearch/webfetchand exposes no MCP docs server should be granted one. Both the V1 and V2 entrypoints enforce the floor in parity.The gated surface is every mutating call, not just edits and shell: it includes
guard_worktree_create,guard_worktree_cleanup, andguard_recovery_restore, so a guard-provided tool cannot be the way past a guard gate. Research recognition matches documented MCP server names (mcp__context7__*,mcp__deepwiki__*,mcp__tavily__*,mcp__brave__*), so a guard inspection tool or generic file reader cannot clear the debt by name accident. An unrecognised docs server can be declared withresearchTools/WORKFLOW_GUARD_RESEARCH_TOOLS. A failed research attempt never accrues debt, so a flaky network cannot deepen the block it was meant to clear. An unparseable or non-positive threshold falls back to the default of 3 instead of silently disabling the floor.Also add Policy 26, sweeping repository claims vs read coverage. It is observability-only (like Policy 24): when the final response asserts a whole-repository fact ("the entire codebase uses X", "no other callers reference this") but the session has read or authored fewer than 2 distinct files, the mismatch is journaled and logged at info level. The response is never blocked or rewritten, since a search-heavy investigation can legitimately back such a claim with little read coverage. Both entrypoints report it in parity.
v1.16.0
Minor Changes
-
e5fd6a1: Enforce per-task completion evidence for
todowrite.A task may now enter
completedonly when the session recorded at least one file mutation inside that task's active window. Previously, a list-widetodowritecould flip every item tocompletedwith no work behind it; the guard only journalled acompletion_claim_mismatchaudit record and allowed it. That signal is now an enforced policy.- Per-task windows, not a session baseline. Each active task records the session mutation count when it entered its current window. One mutation cannot vouch for several completions, so N completions need N mutations counted from the earliest of them, and work done before a task became active never counts toward it. A finished or dropped task releases its window, so reusing a task's text starts clean. Task identity is the task text with the read-only marker stripped, and occurrences are counted rather than matched by last write, so duplicate text cannot hide a new completion.
[read-only]escape for review and investigation. Tasks that legitimately change no file carry[read-only]in their text (also[read_only]/[read only]) and complete freely. Because the marker is an annotation rather than part of identity, it can be appended to an already-planned task instead of forcing a re-plan. Every use is journaled astodo_completed_read_only, so relabeling real work as read-only is visible in the audit trail instead of passing silently. Deliberately dropped work usescancelled.- Planned late. A task first observed already
completedhas no window, so it is scored against the session's mutation history, bounded by the one-mutation-per-completion rule. - Block message names the remedy. A rejected write names the offending tasks and states the three legitimate paths: do the work, mark it
[read-only], or mark itcancelled. - Simulation predicts the gate.
guard_whynow reports the same verdict the enforcement path would, and does not move any task window. - Steering, not just enforcement. The
todowritetool description, the system-prompt guidance, and the compaction injection now state the lifecycle contract: plan before working, keep the list truthful, complete a task only after its work is done, and use the marker for genuinely read-only tasks. guard_statusreportstaskEvidence(active,completed,tasksWithoutRecordedWork,readOnlyTasks), so the requirement is visible before it blocks anything.
Todo state is per session and cleared on
session.deleted. The completion check never replaces the finalization gates: fresh verification evidence and the review gate remain the authority on whether the work is done. -
2818ad0: Adopt five OpenCode 2 plugin APIs that were previously unused, and correct documentation that had drifted from the host.
ctx.command.transformnow registers a server-side/guard-statuscommand, so guard state is readable from every V2 client without the separately installed TUI companion. The report is delivered as a synthetic message, which costs no model turn.Read-only subagent roles now lose the mutating tools from their per-request tool snapshot in
session.hook("context")(write,edit,patch,apply_patch,shell,bash,execute,todowrite,subagent/task, and the worktree lifecycle tools). Previously they received guidance text and were denied late inexecute.before; now the model cannot plan the call at all. Theexecute.beforeseat is unchanged and remains the backstop for tools supplied outside the host snapshot. Every pruning is journaled assession.context/read_only_role_tools_pruned.guard_worktree_createuses the host worktree API when the host exposes a project id, so the worktree lands in the operator's configuredworktree.directoryand appears in host inventory. The host's answer is validated independently (existing directory, a registered worktree of this repository, never the primary tree) and any failure falls back to the existing git path.guard_worktree_cleanupstill owns removal, because the host'sremovehas aforceflag and no snapshot step, so delegating it could destroy uncommitted work the guard preserves. Cleanup accepts a directory outside the guard's storage only when the host declares it, and it asks the host to refresh its inventory afterwards.The MCP policy now widens its guarded server tokens from
ctx.mcp.list(), refreshed onmcp.tools.changed. A renamed or enterprise instance of a guarded product is matched without a code change. Widening is one-way: removing a server adds nothing and the static token list stays the floor, because tool names are model-supplied text and the MCP policy is a live-system rail.command.executedis now journaled. The previous comment claimed V2 had no such event; the installed 2.0.10 schema has it, and the 2.0.15 host emits it.Docs corrected in the same pass:
docs/policies.mdnow lists the V2 hook names alongside the V1 ones (V1 plugin implementations do not run on OpenCode 2), qualifies thetodowriteversion boundary (absent through 2.0.15, reintroduced in 2.1.x behindexperimental.todo), and namessession.hook("compaction")as the V2 compaction seat. Stale comments insrc/lib/v2-todo.tsandsrc/lib/v2-plugin.tsnow state the version boundary instead of asserting that notodowriteexists. -
7842250: The V2
permission.hook("evaluate")seat now enforces instead of only journaling. It maps the host's normalized permissionaction/resourcesback onto the policy core and setseffect = "deny"with an attributable message, so a denied shell command or file mutation never starts. Shell resources feed the shell, git, destructive, and workspace policies,readresources feed the secret policy,edit/write/patch/apply_patchresources feed the boundary, tamper, secret, protected-branch, and todo policies, and guarded MCP servers feed the MCP policy. The seat only tightens: an explicit configureddenystays final, unmapped host actions remain with host policy, and a seat failure fails closed.execute.beforeis kept as the last-resort seat because it is the only one that sees raw tool input. -
f320ecf: OpenCode 2 policy posture: close a write-confinement escape, add the permission
seat, ship installable static policies, restore the task/todo distinction, and
pin the host dependency.Security fix. A session could widen its write boundary into another
repository through a single allowed read, because a path-derived workspace
candidate was adopted even for an already-bound session. That is the one change
in this release with a security impact; see the dedicated write-confinement
changeset for the mechanism and its regression checks.The rest:
- Permission seat.
ctx.permission.hook("evaluate")now denies before a
tool starts, tightening only: a configureddenyis already final, so the
hook never re-permits one. - Static policy posture.
opencode-workflow-guard policies --printemits a
host-level policy block for invariants a repository cannot lift. It prints and
refuses to install, because a plugin that writes the config holding its own
static rails has moved them back inside the plugin. - Todo capability. The
todowritecapability is probed from the tool
registry instead of inferred from history, so OpenCode 2.1.x gates a session
that never planned while 2.0.x still fails open, now with a once-per-session
warning and atodoCapabilityfield inguard_status. - Pinned host dependency. Direct dependencies no longer float on
latest,
andguard_statusreports the qualified host version next to the loaded one. setupwrites the right key. OpenCode 2 readsplugins, not the singular
plugin; the old writer produced a config that looked configured and loaded
nothing on 2.x. The TUI file is the exception:tui.jsonkeeps singular
pluginon both majors, because 2.x reads that key and migrates it into
cli.json.plugins.- Subagents inherit the parent's write boundary. A subagent has no direct
binding of its own, so a path-derived candidate used to look like a first bind
even when its parent was confined: one allowed foreign read moved the child
into another repository. Registration now resolves the nearest bound ancestor
first, so the child stays inside the parent's boundary and the refusal is
journaled. - Adopted current plugin APIs. Server-side
/guard-statuscommand, read-only
tool pruning from the per-request snapshot, native worktree creation with
host-declared cleanup, and a widen-only MCP inventory.
Verification status
Probe-gated, not date-gated. Verified on the installed host:
Gate Result npm run typecheckclean on the final tree npm test1261 passed, 0 failed ... - Permission seat.
v1.15.5
Patch Changes
- aeadda1: Reap stale project-memory coordination markers during the daily maintenance pass: markers (
*.sqlite.active.*) left behind by processes that died without closing their store previously accumulated until the 100 MB storage ceiling triggered eviction, so a healthy machine collected unbounded zero-byte files. Maintenance now removes provably dead owners opportunistically, keeping the project-memory directory's file count bounded without storage pressure. Markers owned by live or unidentifiable processes are never removed.
v1.15.3
Patch Changes
- d786bd5: Accept workspace-symlink-aliased absolute paths in the workspace boundary: a target that lexically looks outside the workspace root (for example
/home/hunter/...where/homeis a symlink tovar/homeand the workspace root lives under/var/home/hunter/...) is no longer blocked asworkspace_escapewhen its canonical resolution is inside the root. Symlinks inside the workspace that point outside stay blocked (including new files under them, via the nearest-existing-ancestor walk), and indeterminate canonicalization still fails closed to the lexical verdict. - 35871df: Fix branch-creation staleness classification when the command carries shell redirections:
git switch -c feat/x 2>&1(or2>/dev/null,> /dev/null) no longer misclassifies the redirection token as an explicit start point and fails closed; redirections are ignored and the HEAD-based or explicit start-point check applies as intended. - 93bc6bf: Expand the provider-free e2e harness to verify guard policy decisions via
guard_whysimulate (protected-branch push, secret-file reads, workspace boundary escape, interactive TTY shell-safety), project configuration loading from.opencode/workflow-guard.json, and the full config-gated 18-tool registration surface through the real OpenCode runtime. Test-only change; no runtime behavior changes. - c8021fd: Dynamic shell syntax blocks now name the detected construct and a concrete remedy instead of a generic message: command/process substitution ($( ), backticks, <( ) / >( )) points to rewriting with literal values or separate literal commands and to simulating with guard_why, malformed quoting points to simple balanced quoting, and the IFS/ambiguous-whitespace variants name their construct. The fail-closed decision, block code, and all detectors are unchanged.
- 0c21118: Seed stale-write freshness observations from allowed shell redirect writes: a file the session just created or rewrote via
cmd > filecan be edited without a redundant re-read, extending the existing edit/write seeding (LL-003) to shell writers. Redirects to/dev/null, the/tmp/opencodescratch directory, or outside the workspace seed nothing, identical-byte (no-op) redirects do not seed, and the not-read block message now also suggests coordinating when another session may own the file.
v1.15.2
Patch Changes
-
ec96e30: Resolve every workspace-dependent guard decision from the call's own context and the session's own workspace binding instead of one process-global slot: concurrent loop sessions sharing a single guard process no longer re-anchor each other's boundary checks, git push/branch evaluation,
guard_status, orrecord_reviewbindings (session workspaces are registered from tool-call arguments or host-provided per-call worktrees, and a plugin-instance default can no longer re-anchor an already-bound session).Bind secondary-review verdicts per exact worktree:
getLastReviewResultForWorkspaceprefers an exact worktree-path match, then a same-repo review whose tracked-content fingerprint matches the consuming worktree, and only then falls back to repository-level recency — so two loops sharing one repository stop displacing each other's approvals betweenrecord_reviewandgh pr create.record_reviewwithout any resolvable reviewed workspace (no explicitdirectory, no session binding, no per-call worktree) is now rejected with an actionable cause instead of silently binding to a latched root.Make the PR-creation lockfile gate section-aware for
package.json: it fires when a section the lockfile records changed (dependencies,devDependencies,optionalDependencies,peerDependencies,peerDependenciesMeta,engines,packageManager,overrides,resolutions,workspaces,pnpm,name,version). Scripts-only or metadata-only manifest edits — which regeneratepackage-lock.jsonbyte-identical — no longer block PR creation with an unsatisfiable demand.Cargo.toml/go.modkeep the conservative manifest-changed trigger.
v1.15.1
Patch Changes
-
14a07f1: Branch creation freshness gate honors explicit fresh start points
git switch -c <name> origin/main(andgit checkout -b <name> origin/main)
no longer fails when the current branch is behind the remote default: a start
point that already containsorigin/HEADmakes the new branch fresh by
construction, so the HEAD-based staleness check does not apply to it.
Classifiable start points are the literal ref or SHA operand of the creation
flag (including-c<name>-style attached forms and valueless flags around
them); variables, quoted or otherwise indeterminate tokens, and unknown or
value-taking flags fail closed to the previous check. Creations without a
start point are unchanged.Block reasons now name what was actually checked — the current branch and its
distance from the remote default — and remedies that work under the guard
(rebase or ff-merge the branch, or branch from the remote default directly),
instead of suggestinggit pullon main, which does not address a stale
non-main branch and previously kept the block in place even after the local
mainref was updated. -
2cbeab1: TUI companion: drop the duplicate home-footer badge on OpenCode 2
The V2 companion claimed both
home.footer.statusandprompt.footer.status,
and OpenCode 2 mounts both footers on the home screen, so the
Workflow Guard 🛡️badge rendered twice there (once in the prompt footer row
next to the location path, once in the bottom status bar next to the version).
The companion now claims onlyprompt.footer.status, which is mounted on every
screen (home and session composers), so the badge renders exactly once; the
home footer bar returns to its builtin contents (MCP/plugin indicators,
version).
v1.15.0
Minor Changes
- 4797e62: The loaded guard version is now visible:
guard_statusreports it aspluginVersion, and the startup app log statesWorkflow Guard v<version> plugin initialized. This closes the silent-stale-installation trap where OpenCode resolves a configured bare plugin name through Node's parent-directory lookup from its configuration location — a copy in a parentnode_modules(for example a pnpm-managed home directory) shadows both OpenCode's plugin cache and a globalnpm install -g, so updates to the wrong surface leave an old version running.docs/installation.mdanddocs/troubleshooting.mdnow document the shadowing behavior, how to verify which copy actually loaded, and how to update it (opencode service restartreloads the background service). - 3ec7161:
record_reviewsupports an opt-in strict recorder mode: settingrequireSubagentReview: truein.opencode/workflow-guard.json(orWORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1) rejects approvals recorded by the root session itself, so a passing verdict must be recorded from a subagent session with a parent. This closes the root-session self-approval path that content binding intentionally leaves open for relay flows with read-only reviewer toolsets; changes-requested findings remain relayable, the default behavior is unchanged, the audit entry records reasonsubagent_recorder_required, andWORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0disables the requirement even when project config enables it.guard_statusechoes the effective requirement in itsprojectConfigsummary. - aae4ca6:
guard_statusnow includes a read-onlygitHygienesnapshot: local branches already merged into the mainline (capped list plus true count, excluding the current and unmerged branches), prunable worktree admin entries (clearable withguard_worktree_cleanup/cleanupGitWorktreeorgit worktree prune), and the current branch's base-behind distance when a remote base exists. Hygiene itself stays manual — the guard reports accumulated git state, it never prunes, fetches, or deletes branches on its own.
Patch Changes
- 99dd23f: Workspace-boundary blocks now attribute the unresolvable-variable cause: a mutation, redirect, or patch target containing an unresolvable
$VARIABLEreference (for examplemkdir -p $D/subor> $CACHE/out.txt) is indeterminate without executing shell semantics and is treated as outside the workspace, but the block message now says so — "contains an unresolvable variable reference … use a literal workspace-relative path" — instead of the generic escape message, across the edit/write path,apply_patch, and shell mutations. Deterministically expandable forms (~,$HOME) and literal paths inside the workspace keep the generic messages; the fail-closed behavior itself is unchanged. docs/policies.md and docs/troubleshooting.md document the rule and the remedy. - e1065bf:
guard_review_rubricdiff invocations keep a trailing--after the revision so a configured base ref can never parse as a pathspec (defense-in-depth on top of the revision-before---fix), and the default-bases fallback loop (origin/main → origin/master → main → master, then HEAD~1) is directly asserted: a repository with no origin remote embeds the real branch diff from the local main branch. - 634d748: Scratch-directory cause attribution:
/tmp/opencodeis the OpenCode environment's designated agent scratch space, but the workspace boundary is workspace-absolute and does not cover it, so writes there stay blocked. Block messages for targets under it (edit/write, apply_patch, and shell mutations) now name the directory and point to the remedy — write scratch as an untracked file inside the workspace instead of outside it — instead of the generic escape message that read as a guard bug (six of the device audit's boundary blocks were this class). The enforcement decision is unchanged. - 2cb9fd8: Stale file-claim takeover for a missed idle release: a direct-edit claim created before its owner's last recorded idle timestamp cannot belong to an in-flight tool call, so when another session hits that conflict every provably stale claim of that owner is released (each re-checked after the owner lookup) with an audited
stale_claim_takeoverentry instead of blocking every session until the owner is deleted or the service restarts. Owners still mid-session (no idle timestamp), clients without a session lookup, and failed lookups keep the block (fail-closed), andguard_whyremains read-only. Claims now record their creation timestamp, and a same-session re-claim refreshes the stored claim so its release matches the in-flight call.