Conflict resolution beyond line merges: the ACP bridge's mode/set
re-notify moved inside master's new containment finally (a throwing
presenter must not desync the picker), and session/new keeps the modes
advertisement beside master's plain object return. dsh-mode's boundary
flush moved off the session/event feed onto the loop's interception
seams (agent/prompt-submit / agent/turn-continuation): master's scoped
dispatch made post-commit session observers observe-only — an append
from one re-enters the publishing append — while the two waterfalls
fire outside any log emit and bracket the same boundaries; mode.spec's
boundary helper now dispatches those seams via agentEvents, and the
containment tests stub only the mode/set append. Catalogs, doc graphs,
and the module graph regenerated.
A preset names a bundle of the two mechanism knobs — request =
workspace-write + ask, yolo = danger-full-access + never — so the editor
shows ONE 'Permissions' select where the sandbox-mode and approval-policy
tiers stay orthogonal capabilities (the Codex /approvals shape: presets over
two dials). ctx.permission (dsh-permission) owns the config-defined table,
validates the default preset's bundle against the composed knob defaults at
load (fails loud), and writes a switch THROUGH: one log-only
permission/preset event (the audit fact reverse-mapping cannot recover —
the planned 'agent' preset shares request's knob values and differs only in
composed policy) plus each knob event via its own setter, deduped — a
net-zero switch appends nothing. Every knob consumer keeps reading its own
fold, untouched.
The current preset DERIVES from the effective knob values — the fold breaks
bundle ties, a knob state outside the table is the reserved 'custom' value
(a state, not an error: shown while it holds, switchable FROM, never a
target), and defaultPreset disappears (zero-event state reverse-maps from
the composition defaults).
The ACP bridge drops the two per-knob selects for the one preset select
(advertised only when ctx.permission is composed); pending/anchor/no-op
semantics carry over unchanged, with the no-op echo acknowledged before
vocabulary validation so a client re-pushing a derived 'custom' current
never errors. The sandbox variant example composes the
service with a workspace-write default; the permission-switching,
escalation-approved and escalation-rejected scenarios are re-recorded under
it (escalations now target an outside-workspace /tmp path under
danger-full-access, self-cleaning) and config-options is re-authored on the
single-select wire.
The stack rebases onto a moved master through its base branch. Beyond
mechanical unions (both branches' demo scripts, example rows, service
roles, tool lists, acp deps, doc budgets — each side fit alone, the
union needs the higher ceilings), three semantic reconciliations:
- The ACP bridge now carries BOTH per-session surfaces: the sandbox
stack's config options + approval answerer and this branch's session
modes; session/new and session/load advertise modes AND configOptions
side by side.
- The feature matrix supersedes the sandbox stance per the RFC's
second-lander rule: session/set_mode and current_mode_update flip to
shipped-by-dsh-mode, config-option rows stay as #169 wrote them, and
§6 records both landed features under the picker-to-modes /
knobs-to-config-options division.
- The snapshot pin grammar (#254: one header snapshot + declared deltas
+ a Markdown prompt golden) gains a symmetric declaration for what a
delta cannot express: expectedHeaderSnapshots — a plan-mode flip
resorts the canonical tool list, so its widening lands as a second
full snapshot, now its own Markdown section. The pin-less-class and
model-turn-only-pin amendments carry over; new fixtures cover the
extended writer paths, and the plan-acp-agent scenarios re-recorded
under the merged composition (the app now bundles the skill tool)
with the suite's refresh mode wired through.
Every packages/*/* README now carries a canonical '## Known Limitations and
Deferred Work' section: condensed, evidence-backed bullets for consumer-visible
gaps (unimplemented features, platform caveats, MVP cuts) and consciously
postponed work (TODO/FIXME/XXX markers, RFC deferrals still open). The ten
pre-existing ad-hoc variants ('What is NOT here (TODO)', 'Deferred',
'Limitations (MVP)', 'Known limitations (tracked TODOs)', ...) are normalized
into the canonical heading.
A new doc-sync gate, scripts/verify-readme-limitations.ts, enforces the shape:
exactly one limitations-like heading per package README, byte-equal to the
canonical h2, with at least one bullet; near-miss headings fail so variants
cannot creep back. Packages with genuinely nothing to declare (dsh-brand,
dsh-timeout, dsh-subagent-mock, dsh-app-boot) are whitelisted in the script and
must NOT carry the section; whitelist entries are validated against the scanned
package set so a rename fails loud.
Wired into the doc-sync chain (package.json) and the run-gates doc-sync leaf
set; the standing rule lands in packages/AGENTS.md and the adding-a-package
cookbook; decision record in
docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md
(RFC index regenerated).
Also fixes two stale '(deferred)' markers claiming dsh-compact-basic is
unimplemented (the dsh-compact seam README's package table and the seam's
module doc comment).
Review hardening (the finding's ordering premise did not hold — see the
PR thread — but its failure-path kernel did): onBoundary cleared the
pending intent BEFORE appending the mode/set, so a backend rejecting
that one write lost the switch forever while the picker kept showing it
optimistically. The intent is now cleared only after the append lands;
a failed flush stays parked and the next healthy boundary converges the
log with the picker. The containment test extends to pin the re-park
and the retry.
The bridge's re-notify keeps deriving from the logged event's value —
now documented in place: the service holds ONE coalesced pending slot
(every flush reads the latest selection, so a stale flush cannot
exist), and for any other writer the logged value is the truth the
picker should track, in log order.
The three-entry cordis.yml (dsh-sandbox-local + dsh-bash-sandbox at a
read-only default + dsh-approval) served over ACP: the first live approval
composition. Recorded snapshot scenarios pin the wire end to end —
config-options advertisement, the mode-switching arc as the suite pinned
header (both switches, the prompt-section delta, one changed-by-the-user
notice per knob, a confined write landing under the switched mode), and
both escalation branches over scripted permissionAnswers (a grant runs
confined under workspace-write; a rejection executes nothing and pins the
fail-closed text). The with-key escalation e2e drives a real model +
real runner + the real bridge answerer, world-verified; ci.yml snapshot
lane and e2e.yml install bubblewrap so the confined replays actually
execute.
Both RFCs move to implemented/ (Decision/Consequences form, deferred
phases tracked in their own sections), with every cross-reference flipped.
effective(session) = findLast(the session own knob events)?.value ?? the
composition-config default. One log-only event per knob, owned by its
domain (bash/sandbox-mode in dsh-bash, approval/policy in dsh-approval),
each exporting the same three-piece kit: the event declaration, a pure
fold, and THE write path — a switch IS its event; no owner service, no
facts map. Restart immunity and multi-session isolation fall out of the
log replay by construction.
Execution follows the fold on both sides: the bash tool stamps
escalation grant > session override > executor default, and the approval
seam prepends the never-gate that auto-rejects before any interactive
answerer. Visibility is two layers per knob: a per-agent prompt section
states the effective value on every request (logged through
request/header*, so what-the-model-was-told replays from the log), and an
agent/pre-step narrator injects at most one coalesced delta notice with
positional attribution (user switch vs operator/config drift). The ACP
bridge advertises one capability-gated select per composable knob with
currentValue folded per session, validates set_config_option against the
closed vocabularies, and anchors idle switches at the next turn
prompt-submit under the turn-enclosure contract.
The ACP bridge registers the first real approval answerer: an ask for an
agent it owns becomes session/request_permission attached to the already-
streamed tool call (one-shot allow_once/reject_once only), outcomes map
conservatively (unknown optionId never grants, client cancel → cancelled),
and foreign or call-less requests delegate down the waterfall. The snapshot
harness accepts scripted permissionAnswers (FIFO; an unscripted prompt
answers cancelled, fail closed) so recorded scenarios can drive the wire
keylessly.
The proposal survives contact with the code with three amendments, per
the RFCs-are-proposals rule. (1) A mode transition logs a
request/header-delta only when expressible: adding exit_plan_mode
resorts the canonical tool list, and a pure reordering has no delta
form, so entering plan mode logs the full fallback snapshot — the
attributability claim holds either way. (2) The proposed/ skeleton
converts to the implemented grammar: Proposal → Decision, the roadmap's
staging (now history) drops to the standing Deferred list, and
Acceptance criteria + Risks fold into Consequences (what holds, then the
accepted costs, including the ACP v2 mode-removal migration). (3) The
two recorded scenarios stay pending a with-key session, recorded in
Deferred.
The docs tail completes: the cookbook's plan-mode row upgrades from
sketch to the shipped package, architecture.md gains the ctx.modes
capability row (ceiling 1640 → 1650: a new capability service's table
row does not fit the old budget), and every reference repoints to
implemented/.
Plan mode's stage 2 (RFC 2026-07-07-plan-mode). The exit tool: one
required plan argument (the durable log artifact), execute re-checks the
folded mode, then conducts the review over the user-interaction seam —
one single-select question (Approve / Keep planning) with free text open
— so an approval appends mode/set back to default in-turn and every
other outcome (keep-planning feedback verbatim, aborted, no provider)
returns the corrective isError with the mode unchanged. presentCall is a
generic card titled by the plan's first heading carrying the plan
markdown; over ACP the review rides the ask_user elicitation flow, in
the terminal the stdio prompt queue — no approval-seam dependency.
The ACP bridge maps the picker 1:1 onto ctx.modes (opportunistic, a
type-only peer edge): session/new + session/load advertise
availableModes/currentModeId, session/set_mode validates through set()
and echoes an optimistic current_mode_update (the pending mode IS the
selection; the logged mode/set lands at the boundary and, matching, is
not re-sent), and a session/event listener re-notifies on each logged
flip that differs from the last sent — the tool-driven exit updates the
picker. The feature matrix rows move from 'not modeled' to the
picker-to-modes / knobs-to-config-options division, with the ACP v2
removal direction recorded as a mechanical-migration risk.
The snapshot harness gains the setMode/setModeExpectError ops and a
scripted elicitationAnswers FIFO (cancel on exhaustion; a stray choice
string reaches the agent verbatim as a non-consenting custom answer, so
a scenario bug fails safe). The suite factory's header-pin requirement
now applies only to model-turn scenarios — a protocol-only suite has no
header content to anchor. examples/plan-acp-agent is the live
composition; its keyless modes-advertise scenario pins the wire surface
(advertisement, both set_mode round-trips, unknown-id rejection). The
recorded plan-mode approve/reject arc awaits a with-key recording
session; its texts are pinned at the unit tier meanwhile.
examples/AGENTS.md ceiling 653 → 680: the new example's required smoke
row does not fit the old budget.
Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker,
the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard,
agent/session-prefix, the ui reorganization). Beyond the ten textual
conflicts, the merge reconciles master's new seams with this branch's
scoped-registration world:
- tools/execute (new waterfall around core dispatch): dispatched with the
SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper
times/retries only its own agent's calls — and its base thunk resolves the
tool through the caller's visible view (get(exec.name, exec.agent)), so a
scoped/shadowed tool dispatches and a restricted-away global stays
UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered
doc sentence; invariants table + verify-scoped-dispatch pin it (21 events).
- agent/session-prefix (new waterfall, once per loop instance): composed via
the fused agentEvents dispatcher (scope-filtered like every agent-subject
event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps
master's new sessionPrefix parameter with this branch's Scoped this.
- timeout-policy reads the budget through the caller's visible view
(get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its
calls; a global name-twin's budget is never misapplied to a shadowing
per-agent variant.
- tool-cordis: cordis_inspect's tools section lists the CALLING agent's view
(its description promises "what you can call"); the sandbox tool façade's
reads resolve through the mount's own scope, mirroring where its register
lands writes; sandboxRegisterTool's return type carries the exact-disposer
union honestly. dsh-scope declared as peer+dev with the project reference.
- doc-sync chain unions master's verify-cordis-api with this branch's
verify-scoped-dispatch; the generated catalogs, event matrix (the
zero-dispatcher guard passes over master's new events), module graph, and
the cordis api-catalog are regenerated on the merged surface.
Full gate sequence green on the merged tree: typecheck, lint, per-file 100%
coverage (2668 tests), snapshots (38), doc-sync, module graph, build,
hygiene, demo smoke.
Adopts #185 (dsh-timeout: clampTimeout/deadline/timeoutOf drive bash
run() timeout classification; runBash loses its own timer) and #108
(ask_user_question) across the task-runtime rework: bash-local keeps
the BashProcess handle shape with master's deadline mechanics, tool
catalogs/expectations carry both the task_* and ask-user tools, and
generated docs are regenerated on the union.