From 9a2c1b059ad972bd5a9acbda9f4d89debdee19d6 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 04:25:54 -0700 Subject: [PATCH 1/5] sminos plane: mark the rows off the live chain in DX4 (7.179.0) The desk drew every row a transcript holds, so a prompt sent again after an Esc showed twice and a steer under an undone turn showed as read. The engine reads only the chain from the leaf; each subscription now follows its transcript's chain (Chain: built with the index, then fed each row as it is released) and marks what is off it: `offChain` on the snapshot and every `rows` page, and on a `row` that took rows off (with `onChain` for rows it put back). A tool result joins while its issuing assistant is on the chain, so parallel results and rows delivered between a call and its result stay on; any other row not parented on the leaf starts a branch. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CLAUDE.md | 2 +- package.json | 2 +- skills/sminos/scripts/sminos_plane.py | 231 +++++++++++++++-- tests/sminos/test_session_plane.py | 345 +++++++++++++++++++++++++- 7 files changed, 559 insertions(+), 27 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fd7487daf60..bf4feb2adb0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agent", - "version": "7.177.0", + "version": "7.179.0", "source": "./", "author": { "name": "SSFSKIM", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53b9fafc51b..0265a6d78b2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "doperpowers", "description": "Emerges by humans, Converges by Agents", - "version": "7.177.0", + "version": "7.179.0", "author": { "name": "SSFSKIM", "email": "supremekim17@gmail.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 157aefaa8a6..e841e140175 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.177.0", + "version": "7.179.0", "description": "One software-development methodology for coding agents, sized by the work: brainstorm, design, spec, TDD, review, ship — one spec that carries its plan of work as milestones: one milestone runs in order from the spec, several run a fresh subagent per milestone reviewed at each dependency frontier, and the same method runs unattended on the board loop.", "author": { "name": "SSFSKIM", diff --git a/CLAUDE.md b/CLAUDE.md index d72193d75d6..0d605383795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ CLI adapter, Codex portal packaging — are automatically irrelevant. | Path | What it is | |------|-----------| | `agents/` | Registered agents, addressed as `doperpowers:`: `critique` (design critic, debated over SendMessage); `reviewer-low` / `reviewer-medium` / `reviewer-high` (one rubric body, pinned to sol/high, sol/xhigh, astra/high through the local gateway — the single-reviewer rungs of `review-code`, dispatchable from any session or subagent; every reviewer agent, and `adversarial-reviewer`, blocks `Agent` and `Skill` so a reviewer cannot route back into `review-code` and start a review inside its own) and `adversarial-reviewer` (its spec/plan challenge reviewer); `qa-loop` (opus/high) is the board's review loop — the seat that owns the ticket (an Architect or an Executor) dispatches it on the PR it opened and stays bound through review, and it owns the engine rounds, the compliance audit, triage, fix waves, the merge, and the trail, returning only a spec conflict, a design gap, or a dismissal request; it blocks `Skill` so no repository instruction can route it into a review skill, and keeps `Agent` for the reviewers and fixers it dispatches (a PR nobody owns draws a review stand-in seat, which dispatches the same agent); `plan-executor` (opus/high) executes a spec's Plan of Work — or a ticket body — for the session that authored it, messaging that session at the checkpoints it judges meaningful and returning at completion or a genuine block — SDE controller when the Plan of Work has several milestones, sequential for one; dispatched by the board Architect past its build edge, and by execspec's step 4 in an interactive session (a one-milestone spec in an interactive session is run inline by its author); `task-executor` (opus/high) / `task-reviewer` (sol/xhigh) are subagent-driven-execution's per-milestone workers — the invariant worker contracts live here so a controller's dispatch carries only the task's paths and context (the reviewer is read-only and can neither spawn agents nor load skills; the executor keeps `Agent` for orientation fan-out, its body rules out spawning a reviewer, and it may be downgraded to sonnet per dispatch). Both executors run detached from the human: `AskUserQuestion` is disallowed in their definitions, and whatever needs an input only the human or the work's owners can give — environmental friction, an approval, a fork — is escalated up the dispatch chain (the task-executor's return to its controller, the plan-executor's checkpoint message or escalation return to its dispatching session) until the main session puts it to the human and relays the answer down. The three pins — plan-executor and task-executor opus/high, task-reviewer sol/xhigh — change only on an explicit instruction, by edit or by dispatch override, whatever model wrote the spec (usually the frontier one): the frontier's work is the design and the spec; running the plan and building its milestones is close-frontier work. The panel workflow reaches the reviewers by `agentType`, overriding model and effort per rung. | -| `skills/` | The core product — one dir per skill, each with `SKILL.md` (frontmatter `name` + `description` that drives auto-trigger). 15 skills. `brainstorming` closes and presents the design; `execspec` writes the living spec from the approved design, reviews it, and executes it (its `references/living-spec.md` is the document's doctrine, read by SDE, decomposing, and the board protocols). Worker protocols for the board loop (architect / implement / spike / the review stand-in) are plain files under `issue-tracker/references/`, deliberately not skills — nothing invokes them by name; the dispatcher pins their path into the spawn bootstrap. The review loop lives there too: `references/review-loop.md` is its operator manual, `review-standin-protocol.md` and `review-standin-bootstrap.md` the stand-in's, and `scripts/review-dispatch.sh` the trigger for the reviews nobody owns. `sminos` is the project family: a committed `.smns` names a project, every session working under it (spawned seat or one the human opened) is a member with no join step, and what crosses sessions — `say` to peers (untagged is a record, `@alias` delivers, `@all` wakes every live member), a member's `` to the human, joining, an in-charge line, spawning a sibling, retiring — is one event in the project's append-only log, one file per writer under `$SMINOS_HOME/projects//log/`. With a remote in `.smns` the log is a git working tree mirrored through it, and `sminos daemon` (one per machine; `daemon install` writes a launchd agent on macOS, a systemd user unit on Linux) syncs the mirror, delivers remote messages to local members, and publishes their state, so a project spans machines. A project whose `.smns` names a desk hub instead (`project init --hub`, on a machine enrolled by `sminos hub enroll`, which reads the enrollment code — it carries the org key — from a hidden prompt or stdin and refuses it as an argument; `hub show` prints the machine's identity, never a secret) syncs through the hub alone, never through git: `daemon run` supervises `daemon uplink`, a child process holding the hub's stream, which uploads the machine's own writers sealed with authentication codes and verifies every event the hub sends before it enters the log, and the machine writes under its enrolled name. Over the same stream the uplink serves the desk's session plane and its RPCs, the daemon being the execution boundary: presence from the engine's session records, each subscribed session's transcript and its bridge's stream joined in one order, the bridges' pending requests and their settlements, and the outcome of each delivery of a message from outside the machine; an RPC (steer, interrupt, stop, answer, decide, model, compact, spawn, retire, resume) runs only once its code, the machine, project, and session it names, the at-most-once ledger (`daemon/rpcs.json`), its age, the machine's policy, and, for an answer or a decision, the digest of the request still pending all hold, and its result is sent again until the hub acknowledges it. The policy (`daemon/policy.json`, set by `sminos daemon policy [--read-only on|off] [--ceiling MODE|settings]`) can make the machine read-only — no RPC runs, nothing from outside is delivered — and caps a spawn's permission mode; Resume runs `claude --bg --resume ` with no other flag once no process of the machine holds the session, and stops any copy the engine starts instead. Such a project's remote keeps only the log's archive, which the one machine the hub designates for it writes on a schedule (`SMINOS_ARCHIVE_EVERY`, 15 minutes) from a clone at `projects//archive/`, never read back into the log. The CLI is `scripts/sminos.py` with `sminos_daemon.py` (mirror and delivery), `sminos_hub.py` (enrollment), `sminos_uplink.py` and `sminos_hublog.py` (the hub's stream, and one project's log plane over it), `sminos_plane.py` (the session plane: presence and subscriptions, each transcript's offset index and its worker thread, so a subscription pages a session's whole history — an ended one's too — off the uplink's thread), `sminos_jobs.py` (the subagents' status, background jobs and MCP names derived from the rows, and `output`'s containment; pinned with MAWS's fake machine by `tests/sminos/dx4-jobs.json`; plan `docs/doperpowers/plans/2026-10-17-desk-dc8-machine.md`), `sminos_requests.py` and `sminos_outcomes.py` (pending requests and their settlements, and delivery outcomes, as the hub's sealed messages), `sminos_policy.py` (the machine's policy and its known checkouts), `sminos_rpc.py` (the RPCs: their checks, the ledger, execution; plan `docs/doperpowers/plans/2026-10-15-desk-dc7-controls.md`), `sminos_codes.py` (the codes, pinned to the hub's vectors by `tests/sminos/codes_parity.py`), `sminos_archive.py` (the archive), `sminos_marks.py` (the to-human mark grammar, ported from `to-human/hooks/mods/to-human.tsx` and pinned by `tests/sminos/marks_parity.py`), `sminos_bridge.py` (the child the bridge mod spawns, serving the session's endpoint at `$SMINOS_HOME/bridge/.sock`, and `sminos watch`), `sminos_control.py` (`steer`, `interrupt`, `stop`, `model`, `compact`, `decide`, `answer`, and a delivery's last hop to a live session: the bridge, else a `--bg` job's supervisor `reply` for a human's message, else the inbox frame), and the chart and TUI; a session the bridge mod runs in is watched and driven from any terminal on the machine with `sminos watch|steer|interrupt|stop|model|compact|decide|answer `, its protocol DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/sminos/run-sminos-tests.sh`, which isolate `HOME` and `SMINOS_HOME` — a working copy's CLI run against the real `~/.claude/sminos` performs the one-way import of the old per-host `chats/` on its first call. Design in `docs/doperpowers/specs/2026-10-13-smns-project-log-design.md`; the hub side in `docs/doperpowers/plans/2026-10-15-desk-dc4-uplink.md`, a child of the desk's composite design in MAWS. | +| `skills/` | The core product — one dir per skill, each with `SKILL.md` (frontmatter `name` + `description` that drives auto-trigger). 15 skills. `brainstorming` closes and presents the design; `execspec` writes the living spec from the approved design, reviews it, and executes it (its `references/living-spec.md` is the document's doctrine, read by SDE, decomposing, and the board protocols). Worker protocols for the board loop (architect / implement / spike / the review stand-in) are plain files under `issue-tracker/references/`, deliberately not skills — nothing invokes them by name; the dispatcher pins their path into the spawn bootstrap. The review loop lives there too: `references/review-loop.md` is its operator manual, `review-standin-protocol.md` and `review-standin-bootstrap.md` the stand-in's, and `scripts/review-dispatch.sh` the trigger for the reviews nobody owns. `sminos` is the project family: a committed `.smns` names a project, every session working under it (spawned seat or one the human opened) is a member with no join step, and what crosses sessions — `say` to peers (untagged is a record, `@alias` delivers, `@all` wakes every live member), a member's `` to the human, joining, an in-charge line, spawning a sibling, retiring — is one event in the project's append-only log, one file per writer under `$SMINOS_HOME/projects//log/`. With a remote in `.smns` the log is a git working tree mirrored through it, and `sminos daemon` (one per machine; `daemon install` writes a launchd agent on macOS, a systemd user unit on Linux) syncs the mirror, delivers remote messages to local members, and publishes their state, so a project spans machines. A project whose `.smns` names a desk hub instead (`project init --hub`, on a machine enrolled by `sminos hub enroll`, which reads the enrollment code — it carries the org key — from a hidden prompt or stdin and refuses it as an argument; `hub show` prints the machine's identity, never a secret) syncs through the hub alone, never through git: `daemon run` supervises `daemon uplink`, a child process holding the hub's stream, which uploads the machine's own writers sealed with authentication codes and verifies every event the hub sends before it enters the log, and the machine writes under its enrolled name. Over the same stream the uplink serves the desk's session plane and its RPCs, the daemon being the execution boundary: presence from the engine's session records, each subscribed session's transcript and its bridge's stream joined in one order, the bridges' pending requests and their settlements, and the outcome of each delivery of a message from outside the machine; an RPC (steer, interrupt, stop, answer, decide, model, compact, spawn, retire, resume) runs only once its code, the machine, project, and session it names, the at-most-once ledger (`daemon/rpcs.json`), its age, the machine's policy, and, for an answer or a decision, the digest of the request still pending all hold, and its result is sent again until the hub acknowledges it. The policy (`daemon/policy.json`, set by `sminos daemon policy [--read-only on|off] [--ceiling MODE|settings]`) can make the machine read-only — no RPC runs, nothing from outside is delivered — and caps a spawn's permission mode; Resume runs `claude --bg --resume ` with no other flag once no process of the machine holds the session, and stops any copy the engine starts instead. Such a project's remote keeps only the log's archive, which the one machine the hub designates for it writes on a schedule (`SMINOS_ARCHIVE_EVERY`, 15 minutes) from a clone at `projects//archive/`, never read back into the log. The CLI is `scripts/sminos.py` with `sminos_daemon.py` (mirror and delivery), `sminos_hub.py` (enrollment), `sminos_uplink.py` and `sminos_hublog.py` (the hub's stream, and one project's log plane over it), `sminos_plane.py` (the session plane: presence and subscriptions, each transcript's offset index and its worker thread, so a subscription pages a session's whole history — an ended one's too — off the uplink's thread; and each transcript's live chain, so the snapshot, its pages and each row mark the rows an Esc or a /rewind left off it (`offChain`, `onChain`), the rows the engine no longer holds), `sminos_jobs.py` (the subagents' status, background jobs and MCP names derived from the rows, and `output`'s containment; pinned with MAWS's fake machine by `tests/sminos/dx4-jobs.json`; plan `docs/doperpowers/plans/2026-10-17-desk-dc8-machine.md`), `sminos_requests.py` and `sminos_outcomes.py` (pending requests and their settlements, and delivery outcomes, as the hub's sealed messages), `sminos_policy.py` (the machine's policy and its known checkouts), `sminos_rpc.py` (the RPCs: their checks, the ledger, execution; plan `docs/doperpowers/plans/2026-10-15-desk-dc7-controls.md`), `sminos_codes.py` (the codes, pinned to the hub's vectors by `tests/sminos/codes_parity.py`), `sminos_archive.py` (the archive), `sminos_marks.py` (the to-human mark grammar, ported from `to-human/hooks/mods/to-human.tsx` and pinned by `tests/sminos/marks_parity.py`), `sminos_bridge.py` (the child the bridge mod spawns, serving the session's endpoint at `$SMINOS_HOME/bridge/.sock`, and `sminos watch`), `sminos_control.py` (`steer`, `interrupt`, `stop`, `model`, `compact`, `decide`, `answer`, and a delivery's last hop to a live session: the bridge, else a `--bg` job's supervisor `reply` for a human's message, else the inbox frame), and the chart and TUI; a session the bridge mod runs in is watched and driven from any terminal on the machine with `sminos watch|steer|interrupt|stop|model|compact|decide|answer `, its protocol DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/sminos/run-sminos-tests.sh`, which isolate `HOME` and `SMINOS_HOME` — a working copy's CLI run against the real `~/.claude/sminos` performs the one-way import of the old per-host `chats/` on its first call. Design in `docs/doperpowers/specs/2026-10-13-smns-project-log-design.md`; the hub side in `docs/doperpowers/plans/2026-10-15-desk-dc4-uplink.md`, a child of the desk's composite design in MAWS. | | `application-agents/` | Deployed agent applications that are not skills — nothing invokes them by name from a session. `triaging-feedback/`: the feedback→triage poller (TypeScript, its own `npm test`), its worker protocol, and setup docs; the worker-host under `infra/` runs it. | | `hooks/` | The `kairos` proactive mode: `kairos.sh` on `SessionStart` injects the `kairos` skill's body at startup and after every compaction for a session in the mode — launched with `KAIROS=1`, or switched on mid-session by typing `/kairos`, which `kairos-toggle.sh` (on `UserPromptExpansion`) records as a flag file under `~/.claude/kairos/`; `/kairos off` removes it. Every other session gets nothing. `smns-record.sh` on `Stop` and `SessionStart` passes the payload to `sminos record`, which joins the session to its project and exports what it sent its human since the last export (its `` spans, code-aware, and its async asks and their answers) into the project's log; it is bounded (5 s, `SMNS_RECORD_TIMEOUT`), exits 0 on any failure, and does nothing outside a project or in sminos's own internal forks (`SMNS_INTERNAL`). `mods/` holds the plugin's function hooks ("mods"): `hooks.json` names `mods/register.tsx` under `modules`, which the engine loads only under `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` and ignores elsewhere. `to-human-stream.sh` on `MessageDisplay` draws the `` mark's labels flush by flush while a message streams (the engine mounts an `AssistantMessage` only once a block is whole); the report view that folds the transcript to those marks is the `to-human` plugin (`to-human/`), whose parser reads either that form or the raw tags; `kairos.tsx` draws a `[ kairos off ]` / `[ kairos on ]` button in the prompt footer that runs `/kairos` or `/kairos off` on a click, reading the mode's state from the shell hooks and never writing it; `agents.tsx` adds an `[ N subagents ]` button beside the kairos switch (registered ahead of it, wrapping its tree) from the session's first subagent on, which opens a pane drawing the session's agents as the tree they spawned in (`$.agent.list()`'s `parentId`), and a press on a row draws that agent's transcript file beneath the tree, following its tail while it runs. `ask.tsx` registers `mcp__doperpowers__AskUserQuestion`, the model's one way to ask the human in an interactive session: the built-in's input plus `async`. Blocking, it opens the engine's own dialog (a `$.ui.ask` raised inside the tool's hook and widened to the full question set by the mod's own `tool.call` hook on `AskUserQuestion`, correlated by a token in the header) and returns the engine's text; async, it returns at once, the questions wait behind `[ answer ]` in the call's row and in an `N open questions` band above the prompt, and the answer reaches the model as `context` on its next tool result, or as a plugin prompt when the session is idle, or with the human's next prompt after a stop. The built-in is deferred and refused while the tool exists; design in `docs/doperpowers/specs/2026-10-13-ask-async-design.md`. `compact.tsx` registers two tools the model calls, `mcp__doperpowers__context_usage` (the status line's context figures and where the engine compacts on its own, at no cost) and `mcp__doperpowers__compact` (the model compacting its own context at a checkpoint of its choosing): the engine compacts only between turns, so the tool answers that the compaction runs when the turn ends and the model ends its turn; at the `turn.complete` of a main-conversation turn the model answered the mod calls `$.session.compact` (the `/compact` path: one model call over the live conversation, so the model that chose to compact writes the summary, the PreCompact hook's instructions merged in) and submits the `resume` prompt the model wrote, whatever the outcome, so an unwatched session never stalls; nothing is aborted, so background shells, monitors, MCP servers and subagents run on (a `$.turn.abort` variant was rejected: it kills Monitors, whose shell runner gets no `caller` and so does not spare `turn-abort`). Interactive sessions only (headless has no compactor a plugin can call); a subagent's call to either is refused (`context_usage` would read the parent's figures). The engine hands every subagent every MCP tool and a hook cannot filter per agent, so the agent definitions this repo owns, and the user's own under `~/.claude/agents/`, withhold both through `disallowedTools`; built-in types (general-purpose, Explore) and forks still list them. The engine admits one unmatched registration of an event per module, and `$` may only be passed to functions in the same file, so the mod takes `session.start` and `turn.complete` with matchers (`isInteractive: true`, `reason: "answer"`) rather than sharing kairos's and agents' registrations. `interim.ts` drops, at `prompt.submit`, an agent's task notification the engine marks interim (it stopped with a background shell, monitor or workflow of its own still running) when its whole result is the engine's waiting placeholder, the agent's transcript shows the hand-back contract on (so the engine wrote that sentence, not the agent; reached through the notification's own `` link, so the hook keeps no session state, and searched with `grep`, past `$.fs.read`'s 4 MiB), and it tells the session nothing its conversation does not hold (the session's own loop spawned the agent or the conversation, read as it stands through `$.session.messages({ as: "api" })`, holds an earlier notice of it, and of any worktree it names), so a subagent parked on its own work no longer wakes the session for a turn with nothing to act on; a pointer to a delivered report passes (it can name a message that went to an ended parent), as do the first notice of each worktree and of each orphan, the final notification, and whatever a check errs on or cannot settle. Each drop still leaves the engine's "Prompt dropped by a hook" line, a system notice no render hook reaches. `appeal.tsx` (with `appeal-judge.tsx`, the logic that takes no `$`) lets the agent contest an auto-mode classifier block: a `tool.call` hook on every tool gives each appealable block an id and a note (not `[Data Exfiltration]`, not `[User Deny Rules]`), `mcp__doperpowers__appeal` puts one appeal per block to an independent judge (`$.model.complete`, opus at high effort, reading the blocked call whole, the argument as the agent's claim, the instruction files, the `user`/`policy`/`flag` settings' `autoMode` and `permissions.deny` — never the merged settings a repository feeds — the answered async asks, and the conversations with provenance-stripped rows named), and a grant leaves a one-shot pass that the `tool.check` hook applies to the identical re-run in the same loop within 10 minutes, only over core's plain `ask` (no rule, hook, setting, decider or ceiling); any `prompt.submit` ends every pass; the log is `~/.claude/appeals/.jsonl` and `/appeals` prints the last 30 days with the autoMode entries the grants suggest, for the human to apply (design `docs/doperpowers/specs/2026-10-17-auto-mode-appeal-design.md`). `bridge.tsx` (registered first, so its hooks are the outermost) makes the session reachable from outside it on this machine: at `session.start` it spawns a session-lifetime child, `sminos bridge --session `, which serves the session's endpoint at `$SMINOS_HOME/bridge/.sock` (the socket answering is the session's bridge presence); the mod posts the session's events there from one poster loop (each chunk of the main loop and of every subagent, tool calls and results, turns, subagents, usage, a heartbeat, and a notice for every row the session keeps, as it is written, each loop's last notice the snapshot's cut, seeded, for a loop older than the module, from its transcript tail at the module's first hello; every post carries the hello's nonce, so a reloaded module's old instance is refused and stops) and reads commands from the child's stdout: steer (a user row the loop reads at its next request), interrupt (the turn aborted, each Bash the abort moved to the background stopped, and two user rows in before the turn can end: Esc's marker whole, the only text a transcript's reader knows an interrupt by, then a note telling the model where it came from and what was stopped), model and effort (rewritten on the main loop's requests, never `$.config.set`), compact (at the turn's end when asked during one), an owner's log event (kind `human`, written by `/human` or `hub/`) delivered as the human's words once per `(project, writer, seq)` (a peer's event goes instead to the engine's inbox as a peer message, in the engine's envelope, closed by the reply line), and an answer from outside to a permission prompt or a question, settled once on the engine's own `tool_decision` record of who decided. It exports the main loop's `` spans and async questions to the project's log as their rows are written, through the record path's own writer and `(kind, src)` keys, so the Stop hook's export finds them there. Never in `SMNS_INTERNAL`. `bridge.tsx` keeps the hooks and the code that takes `$` (the session start, the child, control, the wait for a decision); its other concerns sit beside it, none taking `$`, each importing only the ones listed before it: `bridge-state.tsx` (the one state object, its types, `resetBridge`), `bridge-stream.tsx` (the live state every resync carries, and the poster), `bridge-requests.tsx` (requests from outside and their settlement), `bridge-rows.tsx` (each kept row's notice, and the export to the host), `bridge-agents.tsx` (the agent state seeded and refreshed from `$.agent.list()`, so agents that predate the module are known). The protocol is DX5 in `docs/doperpowers/plans/2026-10-15-arkho-desk-c2-bridge.md`; tests `tests/mods/bridge.test.ts` and `tests/sminos/test-bridge*.sh`, live checks `tests/mods/live-*-check.py` (they spend model calls; run by hand). `/secret KEY` ships in the `cua` plugin (SSFSKIM/cua, `hooks/mods/secrets.tsx`), same store `~/.config/claude-secrets/KEY`; the guard that keeps secrets out of what the model reads and the transcript keeps is the devcloud plugin's (`devcloud/`). Tests in `tests/mods/`, run with `tests/mods/run-mods-tests.sh`; the streaming hook's are `tests/claude-code/test-to-human-stream-hook.sh`. `compact-instructions.md` is printed by a `PreCompact` hook on every compaction, auto or `/compact`: the harness appends a PreCompact hook's stdout to the summary request as "Additional Instructions" (confirmed in the 2.1.283 source and by a canary hook), after the stock nine-section template that tells the summarizer to follow its structure — text that does not say it overrides the template is read as a hint and barely moves the summary (2026-09-28: the first wording produced summaries indistinguishable from no instructions), so each summary is written as the note a memory-reset self resumes from — pointers for what is on disk, copies of what lived only in the conversation (the summarizer can call no tools, and the transcript path is appended after the summary). Subagent compactions do not receive it. The upstream `SessionStart` bootstrap that used to live here (injecting the `using-doperpowers` skill index) was torn out; skills load from `skills/` directly. | | `devcloud/` | A second plugin in this marketplace (its entry found by name, versioned on its own: `scripts/bump-version.sh --plugin devcloud`), opt-in, design in `docs/doperpowers/specs/2026-10-15-devcloud-design.md`. Today its secrets guard mod, `hooks/mods/secrets.tsx` (pure parts beside it: `secrets-detect.tsx` the token detector, `secrets-handles.tsx` the handles and their restoration, `secrets-sources.tsx` the store layout and credential-file parsers, `secrets-shell.tsx` when a value may be restored into a Bash command — inert, in a plain string or word, in a plain command, never quoted — and `secrets-reads.tsx` the exact read a notice names where it is not, both proved by `devcloud/tests/mods/shell-exec.ts` under bash and zsh): stored `/secret` values (cua's store, read here), credential-file values and token-shaped strings become handles (`[secret:KEY]`, `[redacted:#.]`, ...) in tool results and their records, every appended row, oversized outputs' `tool-results/` files, the system prompt's sections, context blocks, attachments and compactions; handles in Write/Edit/MultiEdit/NotebookEdit/Bash input are restored to their bytes before the call runs; nothing is refused. It sets its guard marker in `$.state` (`devcloud.secrets-guard`, typed in `types/devcloud.d.ts`) so `cua`'s scrubbing stands down. What it does not reach is listed in `devcloud/README.md`. Tests `devcloud/tests/mods/run.sh` (the `devcloud-mods` gate). The host: `image/` (`setup.sh`, the `Dockerfile` whose one step it is) and `boot/` (`devcloud-boot`, steps 1–12 in `devcloudlib/steps.py`, step 12's projects and mirror cache in `devcloudlib/projects.py`, step 11's sminos daemon on an enrolled persistent host in `devcloudlib/sminos_host.py`, the tailnet join with the Tailscale OAuth client in step 4; `devcloud` for status and the jobs; `devcloud-services`, the supervisor where PID 1 is not systemd), tested in containers by `devcloud/tests/run.sh` (the `devcloud` gate). `skills/devenv/` writes a repository's `devenv/` from its CI, manifests and README and proves it with `devenv up`. | diff --git a/package.json b/package.json index 2a0b2102036..c372e1d1c85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doperpowers", - "version": "7.177.0", + "version": "7.179.0", "description": "Doperpowers skills and runtime bootstrap for coding agents", "type": "module", "keywords": [ diff --git a/skills/sminos/scripts/sminos_plane.py b/skills/sminos/scripts/sminos_plane.py index 54a35204952..820c15f52a2 100644 --- a/skills/sminos/scripts/sminos_plane.py +++ b/skills/sminos/scripts/sminos_plane.py @@ -53,6 +53,17 @@ so does one for a history row whose block the subscriber still holds, which the seeded cut rules out and is kept as a guard. +The live chain. The engine reads only the chain from a transcript's leaf; the +rows a branch left behind (an Esc's or a /rewind's, a response the engine +dropped) stay on disk. Each subscription follows its transcript's chain +(Chain: the build's, read with the index, then each row as it is released) +and marks what is off it, so a consumer can draw the conversation the engine +holds: the snapshot and every `rows` page carry `offChain`, the uuids of +their rows off the chain; a `row` carries `offChain` when it took rows off +(a branch's start: the rows it left behind; itself, when it is off), and +`onChain` when it put rows back on. Each is read off the subscription's own +chain as its message goes, so in message order the latest says. + A gap in the bridge's stream, and its end while the session runs (the child ends every subscription on a module reload), end the subscription with `discontinuity`: the browser subscribes again. Without a bridge the rows come @@ -306,18 +317,168 @@ def older(self, hi, limit): return lo +class Chain: + """Which of a transcript's rows are on the conversation's live chain: the + rows the engine holds. A transcript is a tree — each row names its + `parentUuid` — and the engine reads only the chain from its leaf, while + the rows a branch left behind stay in the file: a prompt sent again after + an Esc handed it back, the turns a /rewind undid, a response the engine + dropped when a message reached its turn mid-stream. + + Fed every row in file order, it follows the engine's writes. A row whose + parent is the leaf extends the chain. A tool result names its issuing + assistant rather than the leaf, so parallel calls' results hang off their + own assistants (the engine's `sourceToolAssistantUUID`): it joins the + chain while that assistant is on it, and so what the leaf's ancestry + skips stays on — sibling results and everything delivered between a call + and its result, as the live engine holds it (its resume-time loader + keeps only the meta and attachment rows of those). Any other row whose + parent is not the leaf starts a branch: the rows that joined after the + branch point leave the chain (but a tool result whose assistant stays on + it, or an assistant of a response that does, as the loader recovers + them), and the branch's own rows rejoin it if a branch had taken them + off. A tool result whose assistant is off the chain is off it too, and + moves nothing. + + Rows are written out of order around a session's start (a child a few ms + ahead of its parent), so a row whose parent is not placed yet waits for + it and is placed with it; one whose parent never comes stays unplaced, + and unmarked. A null parent (a compaction's boundary, a session's first + row) continues the chain: the history before a compaction stays drawn. + Progress rows pass their parent through and are never marked; in a main + transcript, so are sidechain rows (an old engine's subagent rows). + `add` returns the rows each row took off the chain and put back on.""" + + def __init__(self, main=True): + self.main = main + self.parent = {} # uuid → its parentUuid (None: a root), every row seen + self.progress = set() + self.source = {} # a tool result's uuid → the assistant it answers + self.group = {} # an assistant's uuid → its response's message id + self.groups = {} # message id → the assistants of that response + self.waiting = {} # uuid → the rows seen whose parent it is, while it is not placed + self.line = [] # the chain, in the order its rows joined it + self.pos = {} # uuid → its index in `line`: the rows on the chain + self.offs = set() # the rows placed off it + self.passed = set() # the progress rows placed + + def copy(self): + c = Chain(self.main) + c.parent, c.progress, c.source = dict(self.parent), set(self.progress), dict(self.source) + c.group, c.groups = dict(self.group), {k: list(v) for k, v in self.groups.items()} + c.waiting = {k: list(v) for k, v in self.waiting.items()} + c.line, c.pos, c.offs, c.passed = list(self.line), dict(self.pos), set(self.offs), set(self.passed) + return c + + def off(self, uuid): + return uuid in self.offs + + def placed(self, uuid): + return uuid in self.pos or uuid in self.offs or uuid in self.passed + + def add(self, row): + """One row, in file order: (off, on), the rows it took off the chain, + itself among them when it was placed off it, and the rows it put back + on. A row seen before changes nothing (the engine writes a uuid once).""" + u = row.get("uuid") + if not isinstance(u, str) or not u or u in self.parent or (self.main and row.get("isSidechain") is True): + return [], [] + p = row.get("parentUuid") + p = p if isinstance(p, str) and p else None + self.parent[u] = p + message = row.get("message") if isinstance(row.get("message"), dict) else {} + content = message.get("content") + if row.get("type") == "progress": + self.progress.add(u) + elif row.get("type") == "assistant": + mid = message.get("id") + if isinstance(mid, str) and mid: + self.group[u] = mid + self.groups.setdefault(mid, []).append(u) + elif row.get("type") == "user" and isinstance(content, list) and \ + any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content): + src = row.get("sourceToolAssistantUUID") + self.source[u] = src if isinstance(src, str) and src else p + if p is not None and not self.placed(p): + self.waiting.setdefault(p, []).append(u) + return [], [] + off, on = [], [] + todo = collections.deque([u]) + while todo: + x = todo.popleft() + self.place(x, off, on) + todo.extend(self.waiting.pop(x, ())) + return off, on + + def through(self, p): + while p in self.progress: + p = self.parent[p] + return p + + def join(self, x): + self.offs.discard(x) + self.pos[x] = len(self.line) + self.line.append(x) + + def place(self, x, off, on): + if x in self.progress: # passes its parent through; never the leaf, never marked + self.passed.add(x) + return + p = self.through(self.parent[x]) + if p is None or (self.line and p == self.line[-1]): + self.join(x) + elif x in self.source: + if p in self.pos or self.source[x] in self.pos: + self.join(x) + else: + self.offs.add(x) + off.append(x) + else: + self.branch(x, p, off, on) + + def kept(self, r): + """Whether a row the branch point passed stays on: a tool result whose + assistant is on the chain, or an assistant of a response that is.""" + return self.source.get(r) in self.pos or any(a in self.pos for a in self.groups.get(self.group.get(r), ())) + + def branch(self, x, p, off, on): + """`x` starts a branch at `p`, a placed row other than the leaf: the + rows after the last of p's ancestors on the chain leave it, and p's + rows back to that ancestor (if a branch had taken them off) rejoin.""" + walk, y = [], p + while y is not None and y not in self.pos: + walk.append(y) + y = self.through(self.parent[y]) + at = self.pos[y] + 1 if y is not None else 0 + left = self.line[at:] + del self.line[at:] + for r in left: + del self.pos[r] + for r in left: + if self.kept(r): + self.join(r) + else: + self.offs.add(r) + off.append(r) + for r in reversed(walk): + self.join(r) + on.append(r) + self.join(x) + + class Transcript: - """One transcript file's index, its loop's jobs ledger, and the worker - that reads it: every read of the file's bulk — an open's build, a page, - a look at the disk facts, an `output` — is a job run here, one at a - time, in the order submitted. A job is `job(transcript)` and hands its - own result over; the index and the ledger, fed every row the index reads, - are touched by jobs alone.""" + """One transcript file's index, its loop's jobs ledger, its chain, and + the worker that reads it: every read of the file's bulk — an open's + build, a page, a look at the disk facts, an `output` — is a job run here, + one at a time, in the order submitted. A job is `job(transcript)` and + hands its own result over; the index, the ledger and the chain, fed every + row the index reads, are touched by jobs alone.""" def __init__(self, path): self.path = path self.index = Index(path) self.ledger = jobs.Ledger() + self.chain = Chain(main=not is_agent_path(path)) self.users = 0 # the subscriptions holding it (under Transcripts' lock) self.lock = threading.Lock() self.jobs = collections.deque() @@ -344,10 +505,16 @@ def extended(self): """The index, brought up to the file's end. A file replaced or shrunk since leaves a fresh index for whoever reads next, and the Reset says so to the reader holding rows of the old one.""" + ledger, chain = self.ledger, self.chain + + def on_row(row): + ledger.add(row) + chain.add(row) + try: - self.index.extend(self.ledger.add) + self.index.extend(on_row) except Reset: - self.index, self.ledger = Index(self.path), jobs.Ledger() + self.index, self.ledger, self.chain = Index(self.path), jobs.Ledger(), Chain(self.chain.main) raise return self.index @@ -390,13 +557,14 @@ class Built: no file yet); `emitted` the history's uuids; `shown` the history's newest rows that fit, newest first, `more` whether older ones are left; `past` the rows past the cut, (uuid, row) oldest first, which go out live; - `ledger` the loop's jobs ledger as of `offset`, the subscription's own to - feed from there; `facts` the disk facts, once looked at.""" + `ledger` the loop's jobs ledger and `chain` its chain as of `offset`, the + subscription's own to feed from there; `facts` the disk facts, once + looked at.""" - def __init__(self, ino, offset, emitted, shown, more, past, ledger): + def __init__(self, ino, offset, emitted, shown, more, past, ledger, chain): self.ino, self.offset = ino, offset self.emitted, self.shown, self.more, self.past = emitted, shown, more, past - self.ledger = ledger + self.ledger, self.chain = ledger, chain self.facts = None @@ -423,7 +591,7 @@ def build(t, cut, bridged): lo = index.older(split, None) return "built", Built(index.ino, index.end, dict.fromkeys(index.uuids[:split], "history"), index.rows(lo, split)[::-1], lo > 0, [(r["uuid"], r) for r in index.rows(split, n)], - t.ledger.copy()) + t.ledger.copy(), t.chain.copy()) def page_of(t, ino, before, limit): @@ -447,6 +615,10 @@ def agent_path(main, agent): return "%s/subagents/agent-%s.jsonl" % (main[:-len(".jsonl")], agent) if main.endswith(".jsonl") else "" +def is_agent_path(path): + return os.path.basename(os.path.dirname(path)) == "subagents" and os.path.basename(path).startswith("agent-") + + # ------------------------------------------------------------- the disk facts @@ -892,6 +1064,10 @@ def __init__(self, project, machine, session, agent, sub, locate, reader, presen # worker every METAS_EVERY; and the body last sent. self.disk = disk or DISK self.ledger = jobs.Ledger() + # The loop's chain (Chain): the build's, then fed each row as it is + # released, so every mark this subscription sends is read off it in + # the order the messages go. + self.chain = Chain(main=agent is None) self.agents = None # agentId → the bridge's state of it: its spawn's fields, `running` self.facts = Facts() self.looking = False # a look at the disk is asked and not answered yet @@ -1049,7 +1225,7 @@ def opening(t): out += self.end("session-ended", "no transcript of the session on this machine") return False self.tail.path, self.tail.offset, self.tail.ino = self.source.path, built.offset, built.ino - self.ledger, self.facts = built.ledger, built.facts + self.ledger, self.chain, self.facts = built.ledger, built.chain, built.facts self.emitted = built.emitted for u, row in built.past: if u not in self.emitted and u not in self.pool: @@ -1088,7 +1264,8 @@ def snapshot(self, now, shown, more, out): self.next_look = now + METAS_EVERY self.was_alive = self.alive() self.body = self.derive(self.was_alive) - body = {"rows": shown, "more": more, **self.body, "presence": self.presence(), "streaming": streaming} + body = {"rows": shown, "more": more, "offChain": self.off_chain(shown), **self.body, "presence": self.presence(), + "streaming": streaming} if self.agent is None: body["usage"] = usage_of(self.snap.get("usage")) if self.bridged else None out.append(self.message("snapshot", body)) @@ -1218,10 +1395,23 @@ def release(self, now, out): def release_row(self, u): """The row message for a row past the cut; it counts as named when its - own notice came.""" + own notice came. It carries what the row did to the chain: the rows + it took off (itself, when it is off it) and those it put back on.""" row = self.pool.pop(u)[0] self.emitted[u] = "named" if self.noticed.pop(u, None) is not None else "unnamed" - return self.message("row", {"row": row, "agentId": self.agent}) + off, on = self.chain.add(row) + if not off and not on and self.chain.off(u): # placed by the build, which read past the cut + off = [u] + body = {"row": row, "agentId": self.agent} + if off: + body["offChain"] = off + if on: + body["onChain"] = on + return self.message("row", body) + + def off_chain(self, rows): + """The uuids of `rows` off the chain, as this subscription knows it now.""" + return [r["uuid"] for r in rows if isinstance(r, dict) and self.chain.off(r.get("uuid"))] def subagents(self, now, out): """DX4 `subagents` whenever the body derived changes: from a row the @@ -1300,7 +1490,7 @@ def page(self, before, limit): if self.ended or self.n is None: return [] if self.tail.ino is None: - return [self.message("rows", {"rows": [], "before": before, "more": False})] + return [self.message("rows", {"rows": [], "before": before, "more": False, "offChain": []})] ino = self.tail.ino self.ask(self.tail.path, lambda t: page_of(t, ino, before, limit)) return [] @@ -1308,14 +1498,15 @@ def page(self, before, limit): def answer(self, out): """What the worker answered, in the order asked — pages, `output`s, looks at the disk; a transcript replaced under a page is a - discontinuity.""" + discontinuity. A page's rows are marked off the chain here, from this + subscription's chain, in the order its messages go.""" while not self.ended: try: kind, value = self.answers.get_nowait() except queue.Empty: return if kind == "page": - out.append(self.message("rows", value)) + out.append(self.message("rows", {**value, "offChain": self.off_chain(value["rows"])})) elif kind == "output": out.append(self.message("output", value)) elif kind == "facts": diff --git a/tests/sminos/test_session_plane.py b/tests/sminos/test_session_plane.py index 6d4920aa7e8..9b431f28d5e 100755 --- a/tests/sminos/test_session_plane.py +++ b/tests/sminos/test_session_plane.py @@ -91,6 +91,8 @@ def __init__(self): self.step_ends = [] # (turnId, agentId, step), in order self.dropped = {} # a block a step-end dropped (no block-row came): its partial text self.kinds = [] + self.off = set() # the rows marked off the live chain, as the marks stand in message order + self.marked = {} # a `row` that carried marks: its uuid → (offChain, onChain) def take(self, env): n, kind, body = env["n"], env["kind"], env["body"] @@ -104,6 +106,7 @@ def take(self, env): self.snapshot = body for row in reversed(body["rows"]): self.add_row(row) + self.mark(body["rows"], body["offChain"]) for e in body["streaming"]: key = (e["turnId"], e["agentId"], e["step"]) self.upto[key] = e["upTo"] @@ -127,6 +130,13 @@ def take(self, env): self.other[body["uuid"]] = self.blocks.pop(bk, None) elif kind == "row": self.add_row(body["row"]) + for key in ("offChain", "onChain"): + if key in body and not body[key]: + self.errors.append("a row's %s present and empty" % key) + self.off |= set(body.get("offChain", ())) + self.off -= set(body.get("onChain", ())) + if "offChain" in body or "onChain" in body: + self.marked[body["row"]["uuid"]] = (body.get("offChain", []), body.get("onChain", [])) elif kind == "step-end": key = (body["turnId"], body["agentId"], body["step"]) self.step_ends.append(key) @@ -145,9 +155,21 @@ def take(self, env): self.usage.append(body) elif kind == "rows": self.pages.append(body) + self.mark(body["rows"], body["offChain"]) elif kind == "diff": self.diff = body + def mark(self, rows, off): + """A snapshot's or a page's marks: each of its rows on or off.""" + held = {r["uuid"] for r in rows} + if not set(off) <= held: + self.errors.append("offChain names rows the message does not carry: %s" % sorted(set(off) - held)) + self.off = (self.off - held) | set(off) + + def drawn(self): + """The rows held on the chain, in order: what the desk draws as read.""" + return [u for u in self.uuids() if u not in self.off] + def scope_agent(self): return self.agent @@ -228,6 +250,48 @@ def mcp(self, added=(), removed=()): "removedNames": list(removed)}) +class Thread(Rows): + """Rows chained as the engine writes them (2.1.300): each names the last + one written as its parent, a tool result its issuing assistant (the + engine's sourceToolAssistantUUID), unless the test names another — an + Esc's or a /rewind's branch point.""" + + def __init__(self, sidechain=False): + super().__init__() + self.leaf = None + self.sidechain = sidechain + + def chained(self, row, parent=Ellipsis): + row.update(parentUuid=self.leaf if parent is Ellipsis else parent, isSidechain=self.sidechain) + self.leaf = row["uuid"] + return row + + def prompt(self, text, parent=Ellipsis): + return self.chained(self.row(type="user", message={"role": "user", "content": text}), parent) + + def attachment(self, kind="output_style", parent=Ellipsis): + return self.chained(self.row(type="attachment", attachment={"type": kind}), parent) + + def answer(self, text, mid, parent=Ellipsis): + return self.chained(self.row(type="assistant", message={"id": mid, "role": "assistant", "content": [ + {"type": "text", "text": text}]}), parent) + + def tool_call(self, tid, mid, parent=Ellipsis): + return self.chained(self.row(type="assistant", message={"id": mid, "role": "assistant", "content": [ + {"type": "tool_use", "id": tid, "name": "Bash", "input": {}}]}), parent) + + def tool_result(self, tid, call): + return self.chained(self.row(type="user", sourceToolAssistantUUID=call["uuid"], message={ + "role": "user", "content": [{"type": "tool_result", "tool_use_id": tid, "content": "ok"}]}), call["uuid"]) + + def steer(self, text, parent=Ellipsis): + """A row the bridge appended mid-turn (`$.session.append`): kept isMeta with the plugin's origin.""" + return self.chained(self.plugin_note(text), parent) + + def turn_end(self, parent=Ellipsis): + return self.chained(self.row(type="system", subtype="turn_duration", durationMs=1000), parent) + + def by_agent(body): """agentId → (status, evidence) of a widened `subagents` body.""" return {e["agentId"]: (e["status"], e["evidence"]) for e in body["subagents"]} @@ -361,9 +425,10 @@ def test_snapshot_fits_its_budget_and_older_rows_page(self): page = self.page(sub, b, ids[1], 50) self.assertEqual([r["uuid"] for r in page["rows"]], [ids[0]]) self.assertFalse(page["more"]) - self.assertEqual(self.page(sub, b, ids[0], 50), {"rows": [], "before": ids[0], "more": False}, + self.assertEqual(self.page(sub, b, ids[0], 50), {"rows": [], "before": ids[0], "more": False, "offChain": []}, "nothing before the first row") - self.assertEqual(self.page(sub, b, "no-such-row", 50), {"rows": [], "before": "no-such-row", "more": False}) + self.assertEqual(self.page(sub, b, "no-such-row", 50), {"rows": [], "before": "no-such-row", "more": False, + "offChain": []}) self.assertEqual(b.errors, []) def test_pages_reach_the_first_row_on_a_live_session(self): @@ -934,6 +999,123 @@ def test_a_second_subscription_extends_the_index_before_the_first_answer_is_take self.assertEqual(b.errors, []) +class OffChain(Session): + """The live chain on a subscription: the snapshot and its pages mark the + rows off it, and a row that starts a branch takes off, live, the rows the + desk already drew — a prompt sent again after an Esc, a steer under an + undone turn — while the rows of a parallel call stay on.""" + + def write(self, rows, agent=None, seen=False): + for r in rows: + self.fake.write_row(agent, r) + if seen: + self.fake.seen(r["uuid"], agent) + return rows + + def undone_turn(self, t): + """A turn the session ran — a parallel call, a steer appended while + it ran — and its end; the branch point an Esc goes back to first.""" + rows = [t.prompt("hello"), t.answer("hi", "m0"), t.turn_end()] + end = rows[-1] + prompt = t.prompt("check both files") + a, b = t.tool_call("toolu_a", "m1"), t.tool_call("toolu_b", "m1") + steer = t.steer("and the third one") + ra, rb = t.tool_result("toolu_a", a), t.tool_result("toolu_b", b) + turn = [prompt, a, b, steer, ra, rb, t.answer("both fine", "m2")] + return rows, end, turn, steer + + def test_a_snapshot_and_its_pages_mark_the_rows_off_the_chain(self): + t = Thread() + before, end, turn, steer = self.undone_turn(t) + pad = "x" * 200000 + again = t.prompt("check both files " + pad, parent=end["uuid"]) # sent again: a branch + later = [t.answer("checking", "m3")] + [t.prompt("next %d %s" % (i, pad)) for i in range(3)] + self.write(before + turn + [again] + later) + sub, b = self.subscribe(bridge=False) + self.assertTrue(self.pump(sub, b, lambda: b.snapshot is not None)) + self.assertTrue(b.snapshot["more"], "older rows left to page: the undone turn is among them") + self.assertEqual(b.snapshot["offChain"], [], "the newest rows are all on the chain") + older = self.page_to_first(sub, b) + off = [u for p in b.pages for u in p["offChain"]] + self.assertEqual(sorted(off), sorted(r["uuid"] for r in turn), "the undone turn, steer and all, marked off") + self.assertTrue(steer["uuid"] in b.off) + self.assertEqual(len(older) + len(b.rows), len(before + turn + [again] + later), "every row still sent") + self.assertEqual(b.errors, []) + + def test_a_snapshot_marks_a_rewound_prompt_and_keeps_parallel_results_on(self): + t = Thread() + before, end, turn, steer = self.undone_turn(t) + live = [t.turn_end()] + first = t.prompt("fix it") + hook = t.attachment() + again = t.prompt("fix it", parent=live[-1]["uuid"]) # Esc before the answer; sent again + self.write(before + turn + live + [first, hook, again, t.answer("fixed", "m4")]) + sub, b = self.subscribe(bridge=False) + self.assertTrue(self.pump(sub, b, lambda: b.snapshot is not None)) + self.assertFalse(b.snapshot["more"]) + self.assertEqual(sorted(b.snapshot["offChain"]), sorted([first["uuid"], hook["uuid"]]), + "the first copy and what it held; the parallel results and the steer stay on") + self.assertEqual(b.drawn(), [r["uuid"] for r in before + turn + live + [again]] + [b.uuids()[-1]]) + self.assertEqual(b.errors, []) + + def test_a_live_rewind_takes_off_what_the_desk_drew(self): + """Through the bridge's release order, each row released by its own + notice: the turn goes out row by row with nothing off, the parallel + results included; the prompt sent again carries the retraction.""" + t = Thread() + before, end, turn, steer = self.undone_turn(t) + self.write(before) + self.fake.start_bridge() + sub, b = self.subscribe() + self.assertTrue(self.pump(sub, b, lambda: b.snapshot is not None)) + self.write(turn, seen=True) + self.assertTrue(self.pump(sub, b, lambda: len(b.rows) == len(before + turn))) + self.assertEqual((b.off, b.marked), (set(), {}), "a live turn, its parallel results and its steer: nothing off") + self.assertEqual(b.drawn(), [r["uuid"] for r in before + turn]) + again = t.prompt("check both files", parent=end["uuid"]) + self.write([again], seen=True) + self.assertTrue(self.pump(sub, b, lambda: again["uuid"] in b.uuids())) + self.assertEqual(b.marked, {again["uuid"]: ([r["uuid"] for r in turn], [])}, + "the row that starts the branch carries what it took off, oldest first") + self.assertIn(steer["uuid"], b.off, "the steer under the undone turn among it") + self.assertEqual(b.drawn(), [r["uuid"] for r in before] + [again["uuid"]]) + self.settle(sub, b) + self.assertClean(b) + self.assertIsNone(b.end) + + def test_rows_past_the_cut_carry_their_own_marks(self): + """The build reads past the cut: a row it found off the chain goes out + marked off, the branch that took it off with nothing to say again.""" + t = Thread() + before, end, turn, steer = self.undone_turn(t) + self.write(before) + self.fake.start_bridge() # its seed, the cut: the last row of `before` + again = t.prompt("check both files", parent=end["uuid"]) + self.write(turn + [again]) # on disk past the cut, noticed after the snapshot + sub, b = self.subscribe() + self.assertTrue(self.pump(sub, b, lambda: b.snapshot is not None)) + for r in turn + [again]: + self.fake.seen(r["uuid"]) + self.assertTrue(self.pump(sub, b, lambda: again["uuid"] in b.uuids())) + self.assertEqual(b.marked, {r["uuid"]: ([r["uuid"]], []) for r in turn}, "each marks itself off") + self.assertEqual(b.drawn(), [r["uuid"] for r in before] + [again["uuid"]]) + self.assertEqual(b.errors, []) + + def test_an_agents_transcript_has_its_own_chain(self): + t = Thread(sidechain=True) + rows = [t.prompt("the brief"), t.tool_call("toolu_1", "m1")] + result = t.tool_result("toolu_1", rows[-1]) + dropped = t.answer("handing back", "m2") + note = t.chained(t.row(type="user", isMeta=True, isSidechain=True, + message={"role": "user", "content": "a message reached the turn"}), result["uuid"]) + self.write(rows + [result, dropped, note], agent="a1") + self.fake.meta("a1", {"agentType": "worker"}) + sub, b = self.subscribe(agent="a1", bridge=False) + self.assertTrue(self.pump(sub, b, lambda: b.snapshot is not None)) + self.assertEqual(b.snapshot["offChain"], [dropped["uuid"]], "the response the engine dropped") + self.assertEqual(b.errors, []) + + class Held: """`plane.build` on the worker, held where a test opens it: before it reads (`entered`, released by `go`) and after (`built`, then `read`: the @@ -1636,6 +1818,165 @@ def test_every_output_case_through_the_plane(self): self.assertEqual(got, want) +class ChainRules(unittest.TestCase): + """The live chain (Chain) row by row, on the shapes the engine writes: + what each row takes off the chain and puts back on.""" + + def feed(self, chain, rows): + """(off, on) per row, by uuid.""" + return {r["uuid"]: chain.add(r) for r in rows} + + def first_turn(self, t, mid="m0"): + """A prompt answered, its turn ended: the branch point an Esc goes back to.""" + return [t.prompt("hello"), t.attachment(), t.answer("hi", mid), t.turn_end()] + + def test_a_prompt_sent_again_after_an_esc_leaves_the_first_copy_off(self): + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + end = before[-1]["uuid"] + sent = [t.prompt("fix the bug"), t.attachment()] + again = t.prompt("fix the bug", parent=end) # the Esc handed it back; sent again from the turn's end + after = [t.attachment(), t.answer("on it", "m1")] + got = self.feed(c, before + sent + [again] + after) + self.assertEqual(got[again["uuid"]], ([r["uuid"] for r in sent], []), + "the copy sent again takes the first one off, and the rows it held") + self.assertTrue(all(got[r["uuid"]] == ([], []) for r in before + sent + after), "nothing else moves") + self.assertEqual({u for u in c.offs}, {r["uuid"] for r in sent}) + self.assertFalse(any(c.off(r["uuid"]) for r in before + [again] + after)) + + def test_a_steer_stays_on_through_its_turn_and_goes_off_with_it(self): + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + end = before[-1]["uuid"] + prompt = t.prompt("run the suite") + call = t.tool_call("toolu_1", "m1") + steer = t.steer("also run the linter") # appended while the call runs + result = t.tool_result("toolu_1", call) # names its call, not the steer: the leaf's ancestry skips it + more = [t.answer("running the linter too", "m2")] + got = self.feed(c, before + [prompt, call, steer, result] + more) + self.assertTrue(all(off == [] for off, _ in got.values()), "a live turn takes nothing off") + self.assertFalse(c.off(steer["uuid"]), "the steer the turn read is on the chain") + undone = [prompt, call, steer, result] + more + again = t.prompt("run the suite", parent=end) # a /rewind undid the turn + off, on = c.add(again) + self.assertEqual((off, on), ([r["uuid"] for r in undone], [])) + self.assertTrue(c.off(steer["uuid"]), "the steer went off with the turn it was read in") + + def test_parallel_tool_results_stay_on(self): + """One response, two calls: split into two assistant rows, each result + hanging off its own call; what the leaf's ancestry skips stays on.""" + t, c = Thread(), plane.Chain() + self.feed(c, self.first_turn(t)) + end = t.leaf + rows = [t.prompt("check both")] + a = t.tool_call("toolu_a", "m1") + b = t.tool_call("toolu_b", "m1") + ra = t.tool_result("toolu_a", a) + hook = t.attachment("hook_success") # on the first result: the second's ancestry skips it + rb = t.tool_result("toolu_b", b) + rows += [a, b, ra, hook, rb, t.attachment(), t.answer("both pass", "m2"), t.turn_end()] + got = self.feed(c, rows) + self.assertTrue(all(v == ([], []) for v in got.values()), "no row takes another off") + self.assertEqual(c.offs, set()) + self.assertLessEqual({r["uuid"] for r in rows}, set(c.pos), "every row on the chain") + again = t.prompt("check both", parent=end) # undo the whole turn + off, _ = c.add(again) + self.assertEqual(off, [r["uuid"] for r in rows], "and a rewind past the turn takes all of it off") + + def test_a_response_the_engine_dropped_goes_off(self): + """A message reaching the turn while a response streams: the engine + drops the response and asks again, the message parented on the row + before it.""" + t, c = Thread(), plane.Chain() + rows = [t.prompt("go")] + call = t.tool_call("toolu_1", "m1") + result = t.tool_result("toolu_1", call) + dropped = [t.answer("thinking it over", "m2"), t.answer("done, I think", "m2")] + note = t.chained(t.row(type="user", isMeta=True, message={"role": "user", "content": "a message"}), + result["uuid"]) + got = self.feed(c, rows + [call, result] + dropped + [note]) + self.assertEqual(got[note["uuid"]], ([r["uuid"] for r in dropped], [])) + + def test_rows_written_ahead_of_their_parent_wait_for_it(self): + """Around a session's start the engine writes a child a few ms ahead + of its parent: it waits, unplaced and unmarked, and joins with it.""" + t, c = Thread(), plane.Chain() + hook = t.attachment("hook_success", parent=None) + prompt = t.prompt("start") + env = t.attachment("environment") + model = t.attachment("model") + order = [env, hook, prompt, model] # env on disk before its parent + got = self.feed(c, order) + self.assertTrue(all(v == ([], []) for v in got.values()), "nothing taken off") + self.assertEqual(c.line, [hook["uuid"], prompt["uuid"], env["uuid"], model["uuid"]], + "the chain in the order of its parents, not of the file") + orphan = t.prompt("a fork's first row", parent=str(uuidlib.uuid4())) + self.assertEqual(c.add(orphan), ([], [])) + self.assertFalse(c.off(orphan["uuid"]), "a row whose parent never comes stays unmarked") + + def test_a_compaction_keeps_the_history_and_a_null_parent_continues(self): + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + boundary = t.chained(t.row(type="system", subtype="compact_boundary", logicalParentUuid=t.leaf), None) + after = [t.prompt("This session is being continued", parent=boundary["uuid"]), t.answer("ok", "m9")] + got = self.feed(c, before + [boundary] + after) + self.assertTrue(all(v == ([], []) for v in got.values())) + self.assertEqual(c.offs, set(), "the history before a compaction stays on") + + def test_a_result_of_a_call_off_the_chain_is_off_and_moves_nothing(self): + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + end = before[-1]["uuid"] + prompt = t.prompt("slow one") + call = t.tool_call("toolu_1", "m1") + again = t.prompt("never mind", parent=end) + reply = t.answer("ok", "m2") + late = t.tool_result("toolu_1", call) # the undone call's result, written late + got = self.feed(c, before + [prompt, call, again, reply, late]) + self.assertEqual(got[late["uuid"]], ([late["uuid"]], [])) + self.assertEqual(c.line[-2:], [again["uuid"], reply["uuid"]], "the chain did not move") + + def test_a_branch_back_onto_rows_taken_off_puts_them_back(self): + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + end = before[-1]["uuid"] + first = [t.prompt("one"), t.answer("1", "m1")] + other = t.prompt("two", parent=end) + back = t.prompt("continue one", parent=first[-1]["uuid"]) + got = self.feed(c, before + first + [other, back]) + self.assertEqual(got[other["uuid"]], ([r["uuid"] for r in first], [])) + self.assertEqual(got[back["uuid"]], ([other["uuid"]], [r["uuid"] for r in first])) + self.assertEqual(c.offs, {other["uuid"]}) + + def test_progress_and_sidechain_rows_are_never_marked(self): + t, c = Thread(), plane.Chain() + prompt = t.prompt("x") + progress = t.chained(t.row(type="progress", data={})) + reply = t.answer("y", "m1") # parented on the progress row: passed through to the prompt + side = t.chained(t.row(type="user", message={"role": "user", "content": "old subagent row"}), prompt["uuid"]) + side["isSidechain"] = True + got = self.feed(c, [prompt, progress, reply, side]) + self.assertTrue(all(v == ([], []) for v in got.values())) + self.assertEqual(c.line, [prompt["uuid"], reply["uuid"]]) + agent = plane.Chain(main=False) # an agent's transcript: every row is a sidechain row + t2 = Thread(sidechain=True) + rows = [t2.prompt("task"), t2.answer("a", "m1")] + again = t2.prompt("task", parent=None) + self.feed(agent, rows) + self.assertEqual(agent.add(again), ([], []), "a null parent continues") + + def test_copy_is_independent(self): + t, c = Thread(), plane.Chain() + first = self.first_turn(t) + second = self.first_turn(t, mid="m1") # another response: a response's rows share its message id + self.feed(c, first + second) + d = c.copy() + again = t.prompt("hello", parent=first[-1]["uuid"]) + self.assertEqual(d.add(again)[0], [r["uuid"] for r in second]) + self.assertEqual(c.offs, set(), "the original did not see it") + self.assertEqual(c.add(again)[0], [r["uuid"] for r in second]) + + class TranscriptIndex(unittest.TestCase): """The offset index (DC8 M2): each row's uuid, byte offset and length in file order, extended from where it stopped; the transcripts kept a while From d291ea086e0911c820f1409c205b134c5d91ab5b Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 04:36:22 -0700 Subject: [PATCH 2/5] sminos plane: a rejoined branch brings back its responses; net moves per row Review of 9a2c1b05: a branch running back through rows another branch took off restored only its own ancestry, leaving off the response's other calls and their results, which the engine's loader recovers with any one of them; and a cascade of waiting rows could report a row off, on, and off again. The rejoin now recovers each rejoined row's response siblings and results, and `add` reports each row's net move once, in the order it first moved. --- skills/sminos/scripts/sminos_plane.py | 54 +++++++++++++++++++-------- tests/sminos/test_session_plane.py | 30 +++++++++++++++ 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/skills/sminos/scripts/sminos_plane.py b/skills/sminos/scripts/sminos_plane.py index 820c15f52a2..0e3f3783287 100644 --- a/skills/sminos/scripts/sminos_plane.py +++ b/skills/sminos/scripts/sminos_plane.py @@ -357,15 +357,18 @@ def __init__(self, main=True): self.group = {} # an assistant's uuid → its response's message id self.groups = {} # message id → the assistants of that response self.waiting = {} # uuid → the rows seen whose parent it is, while it is not placed + self.results = {} # an assistant's uuid → the tool results that answer it self.line = [] # the chain, in the order its rows joined it self.pos = {} # uuid → its index in `line`: the rows on the chain self.offs = set() # the rows placed off it self.passed = set() # the progress rows placed + self.moved = {} # while a row is added: each row it moved → whether that row was off before def copy(self): c = Chain(self.main) c.parent, c.progress, c.source = dict(self.parent), set(self.progress), dict(self.source) c.group, c.groups = dict(self.group), {k: list(v) for k, v in self.groups.items()} + c.results = {k: list(v) for k, v in self.results.items()} c.waiting = {k: list(v) for k, v in self.waiting.items()} c.line, c.pos, c.offs, c.passed = list(self.line), dict(self.pos), set(self.offs), set(self.passed) return c @@ -399,28 +402,41 @@ def add(self, row): any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content): src = row.get("sourceToolAssistantUUID") self.source[u] = src if isinstance(src, str) and src else p + self.results.setdefault(self.source[u], []).append(u) if p is not None and not self.placed(p): self.waiting.setdefault(p, []).append(u) return [], [] - off, on = [], [] + # A cascade of waiting rows can move one row more than once: what it + # reports is each row's net move, in the order it first moved. + self.moved = {} todo = collections.deque([u]) while todo: x = todo.popleft() - self.place(x, off, on) + self.place(x) todo.extend(self.waiting.pop(x, ())) - return off, on + moved, self.moved = self.moved, {} + return ([r for r, was in moved.items() if not was and r in self.offs], + [r for r, was in moved.items() if was and r in self.pos]) def through(self, p): while p in self.progress: p = self.parent[p] return p + def note(self, x): + self.moved.setdefault(x, x in self.offs) + def join(self, x): + self.note(x) self.offs.discard(x) self.pos[x] = len(self.line) self.line.append(x) - def place(self, x, off, on): + def leave(self, x): + self.note(x) + self.offs.add(x) + + def place(self, x): if x in self.progress: # passes its parent through; never the leaf, never marked self.passed.add(x) return @@ -431,38 +447,44 @@ def place(self, x, off, on): if p in self.pos or self.source[x] in self.pos: self.join(x) else: - self.offs.add(x) - off.append(x) + self.leave(x) else: - self.branch(x, p, off, on) + self.branch(x, p) def kept(self, r): - """Whether a row the branch point passed stays on: a tool result whose - assistant is on the chain, or an assistant of a response that is.""" + """Whether a row stays on, or comes back, with the chain: a tool + result whose assistant is on it, or an assistant of a response that + is (the engine's loader recovers both).""" return self.source.get(r) in self.pos or any(a in self.pos for a in self.groups.get(self.group.get(r), ())) - def branch(self, x, p, off, on): + def branch(self, x, p): """`x` starts a branch at `p`, a placed row other than the leaf: the rows after the last of p's ancestors on the chain leave it, and p's - rows back to that ancestor (if a branch had taken them off) rejoin.""" + rows back to that ancestor (if a branch had taken them off) rejoin, + with the responses' other rows and results the loader recovers.""" walk, y = [], p while y is not None and y not in self.pos: walk.append(y) y = self.through(self.parent[y]) at = self.pos[y] + 1 if y is not None else 0 left = self.line[at:] - del self.line[at:] for r in left: + self.note(r) del self.pos[r] + del self.line[at:] for r in left: if self.kept(r): self.join(r) else: - self.offs.add(r) - off.append(r) - for r in reversed(walk): + self.leave(r) + back = collections.deque(reversed(walk)) + while back: + r = back.popleft() + if r in self.pos: + continue self.join(r) - on.append(r) + back.extend(a for a in self.groups.get(self.group.get(r), ()) if a in self.offs) + back.extend(t for t in self.results.get(r, ()) if t in self.offs) self.join(x) diff --git a/tests/sminos/test_session_plane.py b/tests/sminos/test_session_plane.py index 9b431f28d5e..eb359573248 100755 --- a/tests/sminos/test_session_plane.py +++ b/tests/sminos/test_session_plane.py @@ -1948,6 +1948,36 @@ def test_a_branch_back_onto_rows_taken_off_puts_them_back(self): self.assertEqual(got[back["uuid"]], ([other["uuid"]], [r["uuid"] for r in first])) self.assertEqual(c.offs, {other["uuid"]}) + def test_a_branch_back_into_a_parallel_response_brings_all_of_it_back(self): + """The loader recovers a response's other calls and their results with + any one of them: so does a branch that runs back through one.""" + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + prompt = t.prompt("check both") + a, b = t.tool_call("toolu_a", "m1"), t.tool_call("toolu_b", "m1") + ra, rb = t.tool_result("toolu_a", a), t.tool_result("toolu_b", b) + other = t.prompt("never mind", parent=prompt["uuid"]) + back = t.answer("a passed", "m2", parent=ra["uuid"]) + got = self.feed(c, before + [prompt, a, b, ra, rb, other, back]) + self.assertEqual(got[other["uuid"]], ([a["uuid"], b["uuid"], ra["uuid"], rb["uuid"]], [])) + off, on = got[back["uuid"]] + self.assertEqual(off, [other["uuid"]]) + self.assertEqual(sorted(on), sorted(r["uuid"] for r in (a, b, ra, rb)), "the whole response, both results") + self.assertEqual(c.offs, {other["uuid"]}) + + def test_a_cascade_reports_each_rows_net_move(self): + """Waiting rows placed in one cascade can move a row more than once; + what the row reports is where each ended up.""" + c = plane.Chain() + x, cc, d, e, f = (str(uuidlib.uuid4()) for _ in range(5)) + rows = [{"uuid": e, "parentUuid": cc}, {"uuid": f, "parentUuid": d}, {"uuid": cc, "parentUuid": x}, + {"uuid": d, "parentUuid": x}, {"uuid": x, "parentUuid": None}] + got = self.feed(c, [dict(r, type="user") for r in rows]) + off, on = got[x] + self.assertEqual(set(off), c.offs, "every row reported off is off, and every off row reported") + self.assertEqual(len(off), len(set(off)), "once each") + self.assertEqual(on, [], "nothing was off before the row came") + def test_progress_and_sidechain_rows_are_never_marked(self): t, c = Thread(), plane.Chain() prompt = t.prompt("x") From 3e64f1d7ccfa4baeb4922aeb21e41d61545a3f87 Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 04:39:38 -0700 Subject: [PATCH 3/5] sminos plane: rows a branch brings back stand in the order they were written Re-review of d291ea08: recovered response rows were appended after the rest of the rejoined ancestry, so a later branch cut at a recovered result left the turn after it on the chain. The rejoined set now joins in file order, as its rows first stood. --- skills/sminos/scripts/sminos_plane.py | 19 +++++++++++-------- tests/sminos/test_session_plane.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/skills/sminos/scripts/sminos_plane.py b/skills/sminos/scripts/sminos_plane.py index 0e3f3783287..365b784b67b 100644 --- a/skills/sminos/scripts/sminos_plane.py +++ b/skills/sminos/scripts/sminos_plane.py @@ -352,6 +352,7 @@ class Chain: def __init__(self, main=True): self.main = main self.parent = {} # uuid → its parentUuid (None: a root), every row seen + self.seq = {} # uuid → its place in the file's order self.progress = set() self.source = {} # a tool result's uuid → the assistant it answers self.group = {} # an assistant's uuid → its response's message id @@ -366,7 +367,7 @@ def __init__(self, main=True): def copy(self): c = Chain(self.main) - c.parent, c.progress, c.source = dict(self.parent), set(self.progress), dict(self.source) + c.parent, c.seq, c.progress, c.source = dict(self.parent), dict(self.seq), set(self.progress), dict(self.source) c.group, c.groups = dict(self.group), {k: list(v) for k, v in self.groups.items()} c.results = {k: list(v) for k, v in self.results.items()} c.waiting = {k: list(v) for k, v in self.waiting.items()} @@ -389,6 +390,7 @@ def add(self, row): p = row.get("parentUuid") p = p if isinstance(p, str) and p else None self.parent[u] = p + self.seq[u] = len(self.seq) message = row.get("message") if isinstance(row.get("message"), dict) else {} content = message.get("content") if row.get("type") == "progress": @@ -477,14 +479,15 @@ def branch(self, x, p): self.join(r) else: self.leave(r) - back = collections.deque(reversed(walk)) - while back: - r = back.popleft() - if r in self.pos: - continue + back, todo = set(), list(walk) + while todo: + r = todo.pop() + if r not in back: + back.add(r) + todo.extend(a for a in self.groups.get(self.group.get(r), ()) if a in self.offs) + todo.extend(t for t in self.results.get(r, ()) if t in self.offs) + for r in sorted(back, key=self.seq.__getitem__): # in the order they were written, as they first stood self.join(r) - back.extend(a for a in self.groups.get(self.group.get(r), ()) if a in self.offs) - back.extend(t for t in self.results.get(r, ()) if t in self.offs) self.join(x) diff --git a/tests/sminos/test_session_plane.py b/tests/sminos/test_session_plane.py index eb359573248..ad40cd344fe 100755 --- a/tests/sminos/test_session_plane.py +++ b/tests/sminos/test_session_plane.py @@ -1965,6 +1965,23 @@ def test_a_branch_back_into_a_parallel_response_brings_all_of_it_back(self): self.assertEqual(sorted(on), sorted(r["uuid"] for r in (a, b, ra, rb)), "the whole response, both results") self.assertEqual(c.offs, {other["uuid"]}) + def test_rows_brought_back_stand_in_the_order_they_were_written(self): + """A later branch cuts by where rows stand on the chain, so a result + brought back with its response stands before the turn that followed.""" + t, c = Thread(), plane.Chain() + before = self.first_turn(t) + prompt = t.prompt("check both") + a, b = t.tool_call("toolu_a", "m1"), t.tool_call("toolu_b", "m1") + ra, rb = t.tool_result("toolu_a", a), t.tool_result("toolu_b", b) + answer = t.answer("both pass", "m2") # after rb: the branch back runs through rb, not ra + other = t.prompt("never mind", parent=prompt["uuid"]) + back = t.prompt("go on", parent=answer["uuid"]) + fork = t.prompt("redo a", parent=ra["uuid"]) + got = self.feed(c, before + [prompt, a, b, ra, rb, answer, other, back, fork]) + self.assertEqual(got[fork["uuid"]], ([answer["uuid"], back["uuid"]], []), + "the turn after the response goes; the response's rows stay") + self.assertEqual(c.offs, {other["uuid"], answer["uuid"], back["uuid"]}) + def test_a_cascade_reports_each_rows_net_move(self): """Waiting rows placed in one cascade can move a row more than once; what the row reports is where each ended up.""" From 0b8566dc7ecb4c9613807f74a93cb03dabf2f83d Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 04:40:49 -0700 Subject: [PATCH 4/5] TECH-DEBT: the live chain's two edges (an unplaced parent, a retraction that waits for the branch) --- docs/doperpowers/TECH-DEBT.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/doperpowers/TECH-DEBT.md b/docs/doperpowers/TECH-DEBT.md index 023512bf286..1d514d2a731 100644 --- a/docs/doperpowers/TECH-DEBT.md +++ b/docs/doperpowers/TECH-DEBT.md @@ -442,3 +442,23 @@ the spec's Decision Log entry "the branch review round" busy, then an Esc; and a failed post to the child's local socket. Trigger: a delivery reported lost with no refusal, or the poster's resync gaining retained work for another reason. + +## sminos plane: the live chain (2026-10-18, PR #239) + +- **A row whose parent never comes stays unmarked, and so does all that + follows it.** The chain places a row only once its parent is placed (the + engine writes a child a few ms ahead of its parent around a session's + start). A transcript whose first row names a parent outside the file — 1 of + 1,500 on this machine, a resumed subagent — leaves every row unplaced: the + desk draws them all, as before the chain. The engine's loader treats such a + row as a root. Trigger: a transcript the desk draws with a dead branch whose + rows the plane left unplaced; then place a parent that has not come after a + bound (rows read) as a root. +- **An Esc's retraction lands with the branch's first row, not at the Esc.** + The interactive engine writes nothing when an Esc hands a prompt back; the + rows it left behind go off when the next row is written (the prompt sent + again, a delivery, a system notice). Until then the desk draws them as + live. Its SDK rewind writes a `last-prompt {rewound: true}` anchor, which + has no uuid and is no row here. Trigger: the owner reading a handed-back + prompt as sent, or a desk-side rewind control (whose anchor the plane would + then read). From 686cf6eacc8847fe93bf9a74df2417011a295f9b Mon Sep 17 00:00:00 2001 From: SSFSKIM Date: Sun, 18 Oct 2026 04:45:39 -0700 Subject: [PATCH 5/5] TECH-DEBT: an Esc that empties the conversation leaves no branch the chain can see --- docs/doperpowers/TECH-DEBT.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/doperpowers/TECH-DEBT.md b/docs/doperpowers/TECH-DEBT.md index 1d514d2a731..7467caac2cc 100644 --- a/docs/doperpowers/TECH-DEBT.md +++ b/docs/doperpowers/TECH-DEBT.md @@ -462,3 +462,14 @@ the spec's Decision Log entry "the branch review round" has no uuid and is no row here. Trigger: the owner reading a handed-back prompt as sent, or a desk-side rewind control (whose anchor the plane would then read). +- **An Esc that empties the conversation leaves no branch.** When the Esc + hands back a session's first prompt (nothing before it), the engine writes + its next row (a re-appended delivery, the wake) under the file's stale + leaf, the abandoned turn's last row. The file's chain then runs through the + prompt the live conversation no longer holds, and the engine's own resume + would rebuild it too. The only trace is a `last-prompt {leafUuid: + "00000000-…", explicit: true}` written after the next turn's first rows + (2.1.300 class `ydt`): it says a rewind happened, not where. Seen in + #238's evidence (run 7, `restore`). The desk keeps #42's frame-line fold + for the delivery; the prompt draws as read. Trigger: the engine writing + a null parent there, or a rewind trace that names the cut.