diff --git a/.sdd/graph/2026/09/23-185316-d-cpt-5gk.md b/.sdd/graph/2026/09/23-185316-d-cpt-5gk.md new file mode 100644 index 00000000..c9f012ff --- /dev/null +++ b/.sdd/graph/2026/09/23-185316-d-cpt-5gk.md @@ -0,0 +1,45 @@ +--- +type: decision +layer: conceptual +kind: directive +refs: + - id: 20260706-170000-d-prc-imp + kind: refines + desc: sharpens what the procedure may demand of agent and user; every change to it is tested against this + - id: 20260914-180822-d-cpt-9kv + kind: related + desc: the active mechanism directive this measures without fulfilling or replacing it + - id: 20260902-160151-s-tac-mtv + kind: grounded-in + desc: the landing failure, cited as evidence that mechanism-level fixes let the friction return + - id: 20260422-124325-d-stg-2wb + kind: grounded-in + desc: capture ambient to the work; the run must not pull the agent out of the work to serve the engine + - id: 20260422-123814-d-stg-7lu + kind: grounded-in + desc: a confirmation that restates an approval already given is the coordination overhead this aspiration pushes against + - id: 20260628-130403-d-cpt-h4l + kind: grounded-in + desc: branch and worktree operations are host work; the procedure performs none and now demands none + - id: 20260713-214050-d-cpt-65i + kind: related + desc: explicit authority stays; declared once when true, not re-reported; how far a local run follows the checkout is left to the mechanism decisions +participants: + - Christopher +confidence: high +intent: guiding +topics: + - engine/base-procedures + - collaboration/concurrent-work + - agent/ux + - reliability/discipline +summary: 'This conceptual directive commits implementation runs to demand nothing of agent or user beyond what the work itself needs: the agent works as it would in its host (branch, commit, pull request, merge) while SDD rides along, and the run must never require unneeded Git operations, redundant reports, or confirmations restating already-given approvals. It refines the implementation procedure (20260706-170000-d-prc-imp), which every change is tested against, and measures the active mechanism directive (20260914-180822-d-cpt-9kv) without replacing it. It is grounded in the landing failure that showed mechanism-level fixes let friction return (20260902-160151-s-tac-mtv), ambient capture, autonomy, host-owned branch operations, and explicit authority (20260422-124325-d-stg-2wb, 20260422-123814-d-stg-7lu, 20260628-130403-d-cpt-h4l, 20260713-214050-d-cpt-65i).' +--- + +An implementation run demands nothing of the agent or the user that the work itself does not need: the agent works the way it works in its host — branch, commit, pull request, merge, back to the main line — and SDD rides along, so that everyone sees the work is taken, its reasoning and its closing done land next to the code, and the commitment closes when the work ships. Those three outcomes are the whole purpose of the run. Markers, branches, bindings and landing are mechanism, and every change to the mechanism is measured against this experience, never against the mechanism it replaces. + +What the run must never demand: a Git action the work does not need — a checkout, branch or worktree created to satisfy a validation; a report of something the host state already implies or the engine already holds; a word from the user that only confirms what has already happened. The user is asked for real decisions — what to build, whether the result is good, whether to merge — and nothing else. A coordination fact the engine needs and cannot observe is declared once by the agent, at the moment it becomes true, and is not re-reported at later steps. The observable violation is a served instruction that asks for a Git operation, a report, or a confirmation the work did not require; that is the test every change to 20260706-170000-d-prc-imp passes before it ships. + +Why this is recorded now. Since the branch lifecycle moved into the engine, every correction was made at the level of the mechanism, and the friction returned in a new shape: the run demanded an explicit work-branch report even when it equalled base, then locked its reads to that branch until landing failed once the branch was gone (20260902-160151-s-tac-mtv); agents created checkouts only to satisfy branch validation; a session bound to a deleted branch could not be resumed; finished runs waited at the landing junction for the word "landed" after the pull request had already been approved; and in the run that built 20260914-180822-d-cpt-9kv the work was done before the setup was answered, because the setup stood in the work's way. Each is the procedure serving itself. That directive fixes real mechanism — one branch fact, reads and writes following the session binding — and stays active, but it keeps a user gate on marker removal and was judged against the previous lifecycle; from here it and its successors are judged against this directive. + +Grounding. Ambient capture (20260422-124325-d-stg-2wb): participants stay in their natural activity and the graph grows from that motion — a run that pulls the agent out of the work to serve the engine is the mode-switch that aspiration pushes against. Autonomy (20260422-123814-d-stg-7lu): a blocking approval where shared context suffices is coordination overhead — a confirmation that only restates an approval already given on the pull request is exactly that. Host-owned branch operations (20260628-130403-d-cpt-h4l): the procedure performs none of them, and by this directive it demands none either. Explicit authority (20260713-214050-d-cpt-65i) is the standing context this must account for: where a write lands stays an explicit fact, but explicit means declared once when the host makes it true, not re-reported at every step — how far a local run may follow the host's checkout instead is left to the mechanism decisions that implement this directive. diff --git a/.sdd/graph/2026/09/23-230301-d-cpt-rrt.md b/.sdd/graph/2026/09/23-230301-d-cpt-rrt.md new file mode 100644 index 00000000..5405eb2e --- /dev/null +++ b/.sdd/graph/2026/09/23-230301-d-cpt-rrt.md @@ -0,0 +1,61 @@ +--- +type: decision +layer: conceptual +kind: directive +refs: + - id: 20260923-185316-d-cpt-5gk + kind: grounded-in + desc: the experience standard this mechanism is measured against + - id: 20260706-170000-d-prc-imp + kind: refines + desc: the procedure whose steps, fields and closing order change + - id: 20260902-160151-s-tac-mtv + kind: addresses + desc: the landing failure, answered by never reading a checkout the landing makes disposable + - id: 20260722-112853-d-tac-ln1 + kind: grounded-in + desc: the session binding kept as the single declaration; its explicit baseBranch requirement on WIP writes is withdrawn + - id: 20260713-214050-d-cpt-65i + kind: grounded-in + desc: the never-ambient rule that rejects following the serving checkout's HEAD + - id: 20260628-130403-d-cpt-h4l + kind: grounded-in + desc: branch and worktree operations stay host work + - id: 20260923-182850-s-cpt-ct5 + kind: grounded-in + desc: markers as the interim carrier of the event stream's coordination, not worth more machinery + - id: 20260914-113737-d-cpt-qst + kind: grounded-in + desc: the log is authoritative and the projection disposable, which is what replay-as-history rests on; accepted intents for remote branch delivery + - id: 20260827-232430-d-cpt-voa + kind: grounded-in + desc: the declared session property pattern the coding-host declaration follows, carried forward + - id: 20260715-113417-s-cpt-vxd + kind: related + desc: the remote logical-branch gap left open as a question, carried forward +supersedes: + - 20260914-180822-d-cpt-9kv +participants: + - Christopher +confidence: high +intent: pending +topics: + - engine/base-procedures + - collaboration/concurrent-work + - implementation/engine + - portability/mcp + - portability/runtime +summary: 'The implementation procedure holds no branch state and has no landing step: the WIP marker is written at setup on the session''s effective branch and deleted on the work branch right after the closing done, and the merge carries both to base, superseding 20260914-180822-d-cpt-9kv under the no-unneeded-demands standard (20260923-185316-d-cpt-5gk). One rule names the branch: the session binding (20260722-112853-d-tac-ln1) always names the agent''s current branch, declared through bind_branch whenever it changes, with served instructions naming the moments and the engine never reading the serving checkout''s HEAD (20260713-214050-d-cpt-65i). It refines 20260706-170000-d-prc-imp, answers the landing failure 20260902-160151-s-tac-mtv, treats markers as the interim carrier until the project event stream (20260923-182850-s-cpt-ct5), makes replay treat the session log as history (20260914-113737-d-cpt-qst), and carries forward capability reporting at session open (20260827-232430-d-cpt-voa), remote branch delivery, and the open question 20260715-113417-s-cpt-vxd.' +--- + +The implementation procedure holds no branch state and has no landing step: the WIP marker is written on the session's effective branch at setup, the closing done and the marker's deletion are written on the work branch right after the done is recorded, and the merge carries both to base. This supersedes 20260914-180822-d-cpt-9kv and is measured against 20260923-185316-d-cpt-5gk. + +One rule for the branch: the session binding (20260722-112853-d-tac-ln1) always names the agent's current branch. The agent declares it through `bind_branch` whenever that branch changes: at session start when the checkout is not on the configured default branch, after entering a work branch, after returning to base. Without a declaration the engine assumes the configured default branch; it never reads the serving checkout's HEAD (20260713-214050-d-cpt-65i). Served instructions name these moments (the session shell at start, implementation setup and closeout), the framing says when no checkout has the default branch, and a write to a branch without a checkout fails naming the remedy. Branch operations stay host work (20260628-130403-d-cpt-h4l). + +Why: 20260914-180822-d-cpt-9kv kept a reported `baseBranch` field and a user-guarded `landed` report. Both are demands the work does not need (20260923-185316-d-cpt-5gk), and in a pull-request flow the report duplicates the merge approval. Deleting the marker on the work branch needs no guard: base shows the work as taken until the branch merges. Markers are the interim carrier until the project event stream exists (20260923-182850-s-cpt-ct5), so no more machinery than this. This also answers 20260902-160151-s-tac-mtv: no read of the run depends on a checkout the landing makes disposable. + +Retired fields and steps leave the procedure. Replay treats the session log as history, dropping reports of undeclared fields and passing through retired steps, following 20260914-113737-d-cpt-qst. Keeping a retired field declared as optional exposed it in every report schema (PR #19); rejected. + +Carried forward from 20260914-180822-d-cpt-9kv, unchanged and still to build: the client reports its capabilities when opening a session, a coding host with a checkout being the first (following 20260827-232430-d-cpt-voa), and the implementation move is offered only then; the hosted engine delivers captures to a declared remote branch as accepted intents. 20260715-113417-s-cpt-vxd stays open as a question. + +Fulfilled when the procedure, the WIP write rule, the replay tolerance and the instruction moments ship, with a test that runs a branch-mode run on a two-branch test repository, merges the branch and checks that the marker is gone from main. diff --git a/.sdd/graph/2026/09/23-230855-d-cpt-34w.md b/.sdd/graph/2026/09/23-230855-d-cpt-34w.md new file mode 100644 index 00000000..b6460806 --- /dev/null +++ b/.sdd/graph/2026/09/23-230855-d-cpt-34w.md @@ -0,0 +1,64 @@ +--- +type: decision +layer: conceptual +kind: directive +refs: + - id: 20260914-180822-d-cpt-9kv + kind: builds-on + desc: the superseded predecessor whose remaining points this carries forward in full + - id: 20260923-185316-d-cpt-5gk + kind: grounded-in + desc: the experience standard this mechanism is measured against + - id: 20260706-170000-d-prc-imp + kind: refines + desc: the procedure whose steps, fields and closing order change + - id: 20260902-160151-s-tac-mtv + kind: addresses + desc: the landing failure, answered by never reading a checkout the landing makes disposable + - id: 20260722-112853-d-tac-ln1 + kind: grounded-in + desc: the session binding kept as the single declaration; its explicit baseBranch requirement on WIP writes is withdrawn + - id: 20260713-214050-d-cpt-65i + kind: grounded-in + desc: the never-ambient rule that rejects following the serving checkout's HEAD + - id: 20260628-130403-d-cpt-h4l + kind: grounded-in + desc: branch and worktree operations stay host work + - id: 20260923-182850-s-cpt-ct5 + kind: grounded-in + desc: markers as the interim carrier of the event stream's coordination, not worth more machinery + - id: 20260914-113737-d-cpt-qst + kind: grounded-in + desc: the log is authoritative and the projection disposable, which is what replay-as-history rests on; accepted intents for remote branch delivery + - id: 20260827-232430-d-cpt-voa + kind: grounded-in + desc: the declared session property pattern the capability declaration follows, carried forward + - id: 20260715-113417-s-cpt-vxd + kind: related + desc: the remote logical-branch gap left open as a question, carried forward +supersedes: + - 20260923-230301-d-cpt-rrt +participants: + - Christopher +confidence: high +intent: pending +topics: + - engine/base-procedures + - collaboration/concurrent-work + - implementation/engine + - portability/mcp + - portability/runtime +summary: 'The implementation procedure holds no branch state and has no landing step: the WIP marker is written at setup on the session''s effective branch and deleted on the work branch right after the closing done, and the merge carries both to base; this supersedes 20260923-230301-d-cpt-rrt and carries forward in full the still-valid points of 20260914-180822-d-cpt-9kv, measured against 20260923-185316-d-cpt-5gk. One rule names the branch: the session binding (20260722-112853-d-tac-ln1) always names the agent''s current branch, declared through bind_branch whenever it changes, with served instructions naming the moments and the engine never reading the serving checkout''s HEAD (20260713-214050-d-cpt-65i). It refines 20260706-170000-d-prc-imp, answers the landing failure 20260902-160151-s-tac-mtv, keeps the abandon path, treats markers as the interim carrier until the project event stream (20260923-182850-s-cpt-ct5), makes replay treat the session log as history (20260914-113737-d-cpt-qst), and carries forward capability reporting at session open (20260827-232430-d-cpt-voa), remote branch delivery, the branchless remote store, and the open question 20260715-113417-s-cpt-vxd.' +--- + +The implementation procedure holds no branch state and has no landing step: the WIP marker is written on the session's effective branch at setup, the closing done and the marker's deletion are written on the work branch right after the done is recorded, and the merge carries both to base. This supersedes 20260923-230301-d-cpt-rrt, the same decision minus three points its compression dropped; 20260914-180822-d-cpt-9kv, which that entry superseded, stays superseded and is carried forward here in full. The decision is measured against 20260923-185316-d-cpt-5gk. + +One rule for the branch: the session binding (20260722-112853-d-tac-ln1) always names the agent's current branch. The agent declares it through `bind_branch` whenever that branch changes: at session start when the checkout is not on the configured default branch, after entering a work branch, after returning to base. Without a declaration the engine assumes the configured default branch; it never reads the serving checkout's HEAD (20260713-214050-d-cpt-65i). Served instructions name these moments (the session shell at start, implementation setup and closeout), the session framing the engine serves at session start and on resume says when no checkout has the default branch, and a write to a branch without a checkout fails naming the remedy. Branch operations stay host work (20260628-130403-d-cpt-h4l). + +Why: 20260914-180822-d-cpt-9kv kept a reported `baseBranch` field and a user-guarded `landed` report. Both are demands the work does not need (20260923-185316-d-cpt-5gk), and in a pull-request flow the report duplicates the merge approval. Deleting the marker on the work branch needs no guard: base shows the work as taken until the branch merges. Markers are the interim carrier until the project event stream exists (20260923-182850-s-cpt-ct5), so no more machinery than this. This also answers 20260902-160151-s-tac-mtv: no read of the run depends on a checkout the landing makes disposable. + +The base-target and landing steps and the `baseBranch` and `workBranch` fields leave the procedure. The abandon path stays: the host returns to base, the binding is cleared, the marker is deleted there. Replay treats the session log as history, dropping reports of undeclared fields and passing through retired steps, following 20260914-113737-d-cpt-qst. Keeping a retired field declared as optional exposed it in every report schema (PR #19); rejected. + +Carried forward from 20260914-180822-d-cpt-9kv unchanged: the engine acquires no graph for a command that declares no reads, and a failed read names the read and the state field that chose the branch (both delivered). Where no code branch exists, a remote store stays branchless: marker, captures and done on base, and an observation made during discarded work remains an observation. Still to build: the client reports its capabilities when opening a session, a coding host with a checkout being the first (following 20260827-232430-d-cpt-voa), and the implementation move is offered only then; the hosted engine delivers captures to a declared remote branch as accepted intents. 20260715-113417-s-cpt-vxd stays open as a question. + +Fulfilled when the procedure, the WIP write rule, the replay tolerance and the instruction moments ship, with a test that runs a branch-mode run on a two-branch test repository, merges the branch and checks that the marker is gone from main. diff --git a/.sdd/graph/2026/09/23-231440-s-tac-hz2.md b/.sdd/graph/2026/09/23-231440-s-tac-hz2.md new file mode 100644 index 00000000..df43160f --- /dev/null +++ b/.sdd/graph/2026/09/23-231440-s-tac-hz2.md @@ -0,0 +1,21 @@ +--- +type: signal +layer: tactical +kind: gap +refs: + - id: 20260706-170000-d-prc-imp + kind: grounded-in + desc: the procedure whose work junction offers abandon as an agent choice without the user's words + - id: 20260923-230855-d-cpt-34w + kind: related + desc: keeps the abandon path; the fix belongs with its implementation +participants: + - Christopher +confidence: high +topics: + - engine/base-procedures + - reliability/discipline +summary: 'Signals a gap in the implementation procedure (20260706-170000-d-prc-imp): the `abandon` option sits in an agent chooser, so choosing it logs only the agent''s choice with no `userWords` field, unlike the user-chosen `abort` at setup which relays the user''s words verbatim. The directive decision (20260923-230855-d-cpt-34w) keeps the abandon path unchanged, so the fix belongs with the procedure''s implementation. Candidate remedy: the abandon answer should carry the user''s words.' +--- + +The implementation procedure's abandon option records no user words: the work junction of 20260706-170000-d-prc-imp is an agent chooser, so answering it with `abandon` logs the agent's choice only, while the step text demands the user's explicit word. Observed by reading the procedure spec as of PR #19 on 2026-09-23: the `abandon` option sits in the agent chooser `work`, whose answer schema has no `userWords` field; `abort` at setup, by contrast, is a user chooser and relays the words verbatim. Expected: a run dropped on the user's say-so shows that word in the session log, as every other user decision in the procedure does. 20260923-230855-d-cpt-34w keeps the abandon path unchanged. Candidate remedy from the dialogue, Christopher's preference: the abandon answer carries the user's words. diff --git a/.sdd/graph/2026/09/23-233057-d-cpt-ekd.md b/.sdd/graph/2026/09/23-233057-d-cpt-ekd.md new file mode 100644 index 00000000..78c8d585 --- /dev/null +++ b/.sdd/graph/2026/09/23-233057-d-cpt-ekd.md @@ -0,0 +1,42 @@ +--- +type: decision +layer: conceptual +kind: directive +refs: + - id: 20260923-230855-d-cpt-34w + kind: refines + desc: replaces its configured-default rule for an unbound session with the derived base + - id: 20260713-214050-d-cpt-65i + kind: refines + desc: its launch-directory rule no longer holds for an unbound local session + - id: 20260923-185316-d-cpt-5gk + kind: grounded-in + desc: the declaration at session start is a demand the work does not need + - id: 20260713-121507-s-prc-akz + kind: related + desc: host launch contexts vary, the accepted cost of deriving the base from the serving checkout + - id: 20260728-004132-s-tac-cjt + kind: addresses + desc: the framing names the base, so an unbound session is no longer indistinguishable from one bound to the default +participants: + - Christopher +confidence: high +intent: pending +topics: + - collaboration/concurrent-work + - implementation/engine + - portability/mcp + - portability/runtime + - agent/ux +summary: 'For an unbound session, the branch is derived rather than declared: a local server takes the serving checkout''s live HEAD at each read or write, a hosted server takes the repository default, and detached HEAD falls back to the configured default, with each write keeping the concretely resolved branch. The session framing names the base and the shell tells the agent to declare a different branch when it works elsewhere. This refines the rules that unbound sessions use the configured default and that the launch directory never selects a destination (20260923-230855-d-cpt-34w, 20260713-214050-d-cpt-65i), ending the local read/write disagreement and removing an unneeded declaration (20260923-185316-d-cpt-5gk); it accepts host launch-context variance (20260713-121507-s-prc-akz) and makes unbound sessions distinguishable from default-bound ones (20260728-004132-s-tac-cjt).' +--- + +When the agent has declared no branch, an engine session's branch is a base the composition derives: a local server takes the branch its serving checkout has checked out at the moment of each read or write, a hosted server takes the repository's default branch, and a detached HEAD falls back to the configured default branch. This refines 20260923-230855-d-cpt-34w, which assumed the configured default branch and ruled out reading the serving checkout's HEAD, and 20260713-214050-d-cpt-65i, whose rule that the launch directory never selects a mutation's destination no longer holds for an unbound session. + +The agent reports nothing to set the base. The session framing names the base branch and where it came from, on session start, on resume and whenever it changes, and the session shell tells the agent to declare a different branch through `bind_branch` when the work happens elsewhere. A declared binding outranks the derived base for reads and writes alike. Each write resolves the base when the write is recorded and keeps that concrete branch, so a retry lands where the first attempt aimed even if the checkout has switched since. When the derived branch has no single registered checkout, the write fails and names the declaration as the remedy. + +Why. With the configured default as the unbound branch, local reads and writes disagreed: reads came from the serving checkout, writes went to the default branch's checkout, and a session started on a feature branch failed its first write when no checkout had the default. For a stdio server the serving checkout is usually where the agent started, so deriving the base there removes a declaration the work does not need (20260923-185316-d-cpt-5gk). The live HEAD was chosen over the branch the checkout had at server start because working directories and worktrees change branch during a session; the framing reports each change. A hosted server has no checkout, and the default branch is the only base it knows. + +Accepted cost: launch context varies by host (20260713-121507-s-prc-akz), so a server started in a worktree derives that worktree's branch as base. The framing shows it and a declaration corrects it. Naming the base in the framing also answers 20260728-004132-s-tac-cjt, where an unbound session could not be told apart from one bound to the default branch. + +Fulfilled together with 20260923-230855-d-cpt-34w: unbound reads and writes resolve the derived base, the framing reports it, the shell carries the declaration instruction, and writes record the concrete branch they resolved. diff --git a/.sdd/graph/2026/09/24-091433-s-tac-9bu.md b/.sdd/graph/2026/09/24-091433-s-tac-9bu.md new file mode 100644 index 00000000..7e501acc --- /dev/null +++ b/.sdd/graph/2026/09/24-091433-s-tac-9bu.md @@ -0,0 +1,41 @@ +--- +type: signal +layer: tactical +kind: done +refs: + - id: 20260923-185316-d-cpt-5gk + kind: grounded-in + desc: the standard under which worktreeMode was dropped as an unneeded report + - id: 20260923-231440-s-tac-hz2 + kind: related + desc: the abandon option's missing user words, left open by this delivery +closes: + - 20260923-230855-d-cpt-34w + - 20260923-233057-d-cpt-ekd + - 20260902-160151-s-tac-mtv +participants: + - Christopher +confidence: high +topics: + - implementation/engine + - reliability/testing +summary: 'Five commits on branch claude/d-cpt-9kv-landing-on-binding (PR #19) deliver and close the branch-state-free implementation procedure (20260923-230855-d-cpt-34w) and its derived-base refinement (20260923-233057-d-cpt-ekd): the base-target, work-target and landing steps and the branch fields leave, worktreeMode is dropped as an unneeded report (20260923-185316-d-cpt-5gk), the marker is written and removed on the session''s current branch, replay reads older session logs as history, and the framing names the composition-derived base. Verified by an in-process test over the production local Git adapters on a real two-branch repository and live by replaying this run''s own session on the new build. It closes the landing failure (20260902-160151-s-tac-mtv) and leaves open the abandon option''s missing user words (20260923-231440-s-tac-hz2), client capability reporting and hosted remote-branch delivery.' +--- + +Commits f68601bc, b8a014d2, 0ffae5fb, 9c030948 and 3b4e2cbe on branch claude/d-cpt-9kv-landing-on-binding (PR #19, not merged at the time of the record) deliver 20260923-230855-d-cpt-34w together with its refinement 20260923-233057-d-cpt-ekd. + +Procedure: the implementation procedure loses the baseTarget, workTarget and landing steps and the baseBranch and workBranch fields. A run goes contract, setup, work, record, then removes the marker in an automatic step right after the recorded done, then closeout; the abandon path stays. worktreeMode was dropped too: it only persisted a choice no step reads any more, a report 20260923-185316-d-cpt-5gk rules out. + +WIP write rule: marker writes go to the session's current branch, resolved to a concrete branch that the write records together with its provenance. + +Replay tolerance: replay drops start inputs, reports and answer fields the procedure no longer declares, and a transition to a step it no longer has leaves the instance on its last known step until a later transition moves it on. Verified live: this run's own session, recorded against the previous revision with a baseTarget step and a baseBranch report, replayed on the new build after a server restart and finished on it. + +Derived base (20260923-233057-d-cpt-ekd): delivered as an optional base resolver on the runtime, which the local composition answers from the serving checkout. Beyond the directive's text: a directory outside Git counts as a checkout without a branch, and when the configured default has no checkout either, unbound reads stay on the serving checkout so the session still opens, while unbound writes fail. A write whose branch cannot be acquired names the remedies: check the branch out and retry, or declare the branch and redo the write. + +Instruction moments: the session framing names the base branch and its source, with a notice when no checkout has it; the session shell tells the agent to declare a different branch and to clear the declaration back on base; setup names the declaration after entering the work branch unless the framing already names it; closeout names clearing it after returning to base, and landing is host work with no report. + +Test: an in-process test over the production local Git adapters drives a branch-mode and a worktree-mode run on a real two-branch Git repository; the runs write the marker on main, remove it on the feature branch right after the done, merge, and find the marker gone from main and the done on it. Branch mode declares nothing, the base follows the checkout; worktree mode declares the worktree's branch. + +Closes 20260902-160151-s-tac-mtv: no read of the run depends on the work branch's checkout after the done, covered by a test that finishes and replays the run after that checkout is gone. + +Stays open: the still-to-build items 20260923-230855-d-cpt-34w carries forward, client capability reporting at session open and hosted delivery to a declared remote branch, which are outside its fulfilment line. A session log ending on a retired step resumes at the last step the procedure knows; for a run parked at the old workTarget step that re-serves the already answered setup. The abandon option still logs no user words (20260923-231440-s-tac-hz2). diff --git a/.sdd/graph/2026/09/24-091500-s-tac-ien.md b/.sdd/graph/2026/09/24-091500-s-tac-ien.md new file mode 100644 index 00000000..6a8552c3 --- /dev/null +++ b/.sdd/graph/2026/09/24-091500-s-tac-ien.md @@ -0,0 +1,28 @@ +--- +type: signal +layer: tactical +kind: gap +refs: + - id: 20260706-224422-s-tac-9td + kind: builds-on + desc: the closed short-ID fix whose "never by reading graph files" line is scoped to ID resolution only + - id: 20260628-123931-d-stg-dn6 + kind: grounded-in + desc: 'agent-agnostic foundations: file reads fail for a remote or hosted graph' + - id: 20260706-171500-d-prc-grm + kind: related + desc: the procedure whose sweep sends the agent to repository history, where the direct read happened +participants: + - Christopher +confidence: high +topics: + - portability/mcp + - agent/ux +summary: 'This signal records a gap: the served rule against reading graph files only covers short-ID resolution, leaving no guidance that agents must not read graph content generally — observed when an agent read WIP marker files directly from `.sdd/graph/wip/` during a groom run although the sweep had already served that content. It builds on the short-ID fix (s-tac-9td), whose "never by reading graph files" line is scoped to ID resolution only, and is grounded in the agent-agnostic foundations directive (d-stg-dn6), since file reads fail for a remote or hosted graph. The groom sweep (d-prc-grm) sends the agent to repository history, putting it in a shell next to `.sdd/graph/` without drawing a line between repository checks and graph reads.' +--- + +The served rule against reading graph files covers only short-ID resolution, so an agent with a local checkout still reads other graph content off disk: in a groom run on the sdd-product graph on 2026-09-23, Claude printed both WIP marker files from `.sdd/graph/wip/` in the same shell call that checked whether the marked branches were merged, although the sweep step had already served the marker list with the same content. + +Expected: an agent reads graph content only through the engine's read tools, so its work holds when the graph is remote or hosted (d-stg-dn6) and reflects what the engine serves. The earlier short-ID fix (s-tac-9td) added "never by reading graph files" to the dialogue shell's orientation, but as part of the ID-resolution instruction; no served instruction states it for graph content in general, and the project's agent instructions and the `/sdd` skill don't either. + +Actual: nothing in the served guidance marked the read as off-limits. The groom sweep (d-prc-grm) points the agent both at the read tools and at repository history for the code side, which puts it in a shell next to `.sdd/graph/` without drawing a line between checking the repository and reading the graph. In this instance the read was redundant and the finding did not rest on it; the user noticed it and asked. diff --git a/internal/baseprocedures/baseprocedures_test.go b/internal/baseprocedures/baseprocedures_test.go index 708e8440..5f55549a 100644 --- a/internal/baseprocedures/baseprocedures_test.go +++ b/internal/baseprocedures/baseprocedures_test.go @@ -113,6 +113,38 @@ func TestEntries_EmbeddedSetLoads(t *testing.T) { } } +// TestImplementationHoldsNoBranchState: the run's reads and writes follow the +// session's current branch, so the procedure declares no branch field and has +// no base-target, work-target or landing step (20260923-230855-d-cpt-34w). +// Older sessions that ran them replay as history. +func TestImplementationHoldsNoBranchState(t *testing.T) { + entries, err := Entries() + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.Canonical != "implementation" { + continue + } + spec, err := engine.ParseSpec(entry) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"baseBranch", "workBranch", "worktreeMode"} { + if _, ok := spec.State[field]; ok { + t.Errorf("implementation still declares %s", field) + } + } + for _, step := range []string{"baseTarget", "workTarget", "landing"} { + if spec.StepByID[step] != nil { + t.Errorf("implementation still has the %s step", step) + } + } + return + } + t.Fatal("embedded set ships no implementation procedure") +} + func TestCaptureCarriesFactIndexThroughPlaybackAndWrite(t *testing.T) { entries, err := Entries() if err != nil { diff --git a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md index 73b49375..501b1e6b 100644 --- a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md +++ b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md @@ -44,6 +44,8 @@ You are an SDD (Signal → Dialogue → Decision) partner in a dialogue session Your session framing — local participant, configured language, search modes, and recent graph movement — is served alongside this orientation; read it there, not here.{{if .sessionInfo.language}} Dialogue may flow in any language, but entry content is authored in the configured graph language.{{end}} +**The branch you work on.** The session framing names the base branch — what the engine reads and writes while no branch is declared. When your work happens on another branch, declare it through the engine's session branch-binding capability; declare again whenever the branch you work on changes, and clear the declaration once you are back on the base. Where the base is where you work, declare nothing. + {{if .sessionInfo.recovery}}{{.sessionInfo.recovery}} {{end}} diff --git a/internal/baseprocedures/entries/20260706-170000-d-prc-imp.md b/internal/baseprocedures/entries/20260706-170000-d-prc-imp.md index 58301bac..4f88a951 100644 --- a/internal/baseprocedures/entries/20260706-170000-d-prc-imp.md +++ b/internal/baseprocedures/entries/20260706-170000-d-prc-imp.md @@ -17,9 +17,6 @@ state: anchor: {type: entry-id, desc: "the plan, directive, or activity being implemented"} contract: {type: text, desc: "the work contract: acceptance criteria and augmenting commitments with their status, plus the readiness read"} widenReport: {type: text, desc: "searches run for what bears on this work, and what they surfaced"} - baseBranch: {type: text, desc: "the explicit branch authority carrying coordination and the WIP marker"} - workBranch: {type: text, desc: "the explicit branch authority carrying implementation captures and done evidence"} - worktreeMode: {type: text, optional: true, desc: "set to `worktree` only when the user selected worktree mode; the non-empty marker persists that choice so worktree-only binding guidance survives resume"} wipDescription: {type: text, optional: true, desc: one line naming the work for the WIP marker} progressNotes: {type: text, optional: true, desc: "the running record between slices: what landed (commit hashes), what is next, decisions made along the way"} roadblock: {type: text, optional: true, desc: "the choice no decision covers, when work stops on it"} @@ -36,33 +33,22 @@ steps: collect: [contract, widenReport] transitions: - when: hasContract and hasWidenReport - to: baseTarget - - id: baseTarget - collect: [baseBranch] - transitions: - - when: hasBaseBranch to: setup - id: setup chooser: user options: - - {choice: inPlace, collect: [wipDescription], call: wipStart, to: workTarget} - - {choice: branch, collect: [wipDescription], call: wipStart, to: workTarget} - - {choice: worktree, collect: [wipDescription, worktreeMode], call: wipStart, to: workTarget} - - {choice: quick, to: workTarget} + - {choice: inPlace, collect: [wipDescription], call: wipStart, to: work} + - {choice: branch, collect: [wipDescription], call: wipStart, to: work} + - {choice: worktree, collect: [wipDescription], call: wipStart, to: work} + - {choice: quick, to: work} - choice: hold dispatch: procedure: capture seed: widenReport: widenReport anchor: anchor - captureBranch: baseBranch to: setup - {choice: abort, to: end(abandoned)} - - id: workTarget - collect: [workBranch] - transitions: - - when: hasWorkBranch - to: work - id: work chooser: agent options: @@ -73,7 +59,6 @@ steps: seed: widenReport: widenReport anchor: anchor - captureBranch: workBranch to: work - choice: conclude dispatch: @@ -81,20 +66,28 @@ steps: seed: widenReport: widenReport anchor: anchor - captureBranch: workBranch to: record + - {choice: abandon, to: unwind} + - id: unwind + transitions: + - when: hasWipMarker + to: unmark + - to: end(abandoned) + - id: unmark + op: wipDone + transitions: + - to: end(abandoned) - id: record collect: [doneEntry] transitions: - when: hasDoneEntry and doneEntryResolves and hasWipMarker - to: landing + to: release - when: hasDoneEntry and doneEntryResolves to: closeout - - id: landing - chooser: user - options: - - {choice: landed, call: wipDone, to: closeout} - - {choice: defer, to: landing} + - id: release + op: wipDone + transitions: + - to: closeout - id: closeout chooser: user options: @@ -110,6 +103,8 @@ steps: The implementation procedure runs committed work — a plan, directive, or activity — as a working loop around host work: the building happens outside the engine, and the procedure holds the discipline around it. It serves the work contract, settles the run mode with the user and creates the WIP marker itself, takes progress check-ins between slices, routes undecided design choices into dialogue instead of improvisation, and closes in order — code committed, done signal recorded, marker removed, evaluation offered. +The run holds no branch of its own. Every read and write, the WIP marker included, goes to the session's current branch: the branch declared as the session binding, or without one the base the session framing names. The agent declares the branch whenever the host changes it; the procedure never asks for it. + ## unit: anchor Establish what is being implemented — a resolved anchor advances this step, and a seeded one (dispatched from an engagement, or passed as a known entry ID) auto-advances past this prompt.{{if .anchorHint}} The user's pointer: "{{.anchorHint}}".{{end}} Report `anchor`: the plan, directive, or activity this run realizes. @@ -142,25 +137,18 @@ How should this run be executed? The mode is the user's call — recommend one f - **hold** — a needed decision is missing: capture it first (the answer seeds your grounding into that capture), then return here to pick a mode. - **abort** — wrong anchor or wrong time; abandons the run. -A tracked mode collects `wipDescription` — one line naming the work — and creates the exclusive WIP marker on the anchor as part of the answer; the engine removes it again at closeout. Branch and worktree moves themselves are host work — follow your harness's way of doing them. Relay the user's answer verbatim. - -## unit: baseTarget - -Resolve the concrete branch that carries coordination for this run. Report `baseBranch` explicitly before the marker is created. Use repository evidence; never infer it from cwd, never assume `main`, and do not substitute the configured ordinary-capture default. In-place work will later report the same branch as `workBranch`. - -## unit: workTarget +A tracked mode collects `wipDescription` — one line naming the work — and creates the exclusive WIP marker on the anchor as part of the answer, on the session's current branch; the engine removes it right after the closing done. Relay the user's answer verbatim. -The host now owns any branch switch or worktree creation selected by the user.{{if .worktreeMode}} Only if this run actually enters a worktree: after the host has entered that worktree, use the engine's available session branch-binding capability to declare its concrete work branch as the current session binding. This is a routing declaration, not checkout creation, and it does not fill this procedure's state automatically. If this run does not enter a worktree, make no session branch-binding change.{{end}} - -The current session binding, when one is declared in the served session framing, is the natural candidate and default suggestion for `workBranch`. Verify it against repository evidence and report `workBranch` explicitly anyway: the engine never copies or adopts the binding into this field. For in-place work `workBranch` must equal `baseBranch`; for branch/worktree modes it names the existing checked-out work branch. Do not proceed until that branch authority is explicit and registered. +**Entering the work branch is host work**, done after the answer — a branch switch or worktree creation your harness performs its own way, branching from where the marker was just written so the work branch carries it. Once the host is on the work branch, declare it as the session branch binding through the engine's session branch-binding capability, unless the session framing already names it as the base — a branch switch in the checkout the engine serves moves the base with it. The declaration routes this run's reads and captures to the branch the work is on; it is not a checkout operation. In-place and quick runs stay where they are and declare nothing. ## unit: work -You are between slices of host work. Sanity-check each slice as you go — problems surface cheapest early. The junction: +You are between slices of host work. Sanity-check each slice as you go — problems surface cheapest early. Reads and captures follow the session's current branch named in the served session framing; if the host is on a branch the framing does not name, declare it before capturing anything. The junction: - **continue** — report `progressNotes` and keep building: the full running record — what landed (with commit hashes), what is next, which decisions were made in dialogue along the way. Keep it current; it is what a later session resumes from, and stale notes mislead better than no notes. - **blocked** — the work hit a choice no decision covers. Do not decide alone, and do not capture yet: present the roadblock to the user in plain words, propose one or more ways forward, and settle it in dialogue. What crystallizes becomes zero or more captures — an augmenting directive, a directive, a gap, a question — each played back and confirmed the normal way; the blocked answer seeds your grounding into them. Report `roadblock` with the stop, then return here to continue or to pause. - **conclude** — the contract is met, or the run deliberately stops short (say which): move to recording. +- **abandon** — the user drops this run: it ends without a done, and the engine removes the marker on the session's current branch. Only on the user's explicit word; observations made along the way are still captured as ordinary entries. If the host is on a work branch, return it to base and clear the session binding first, so the marker is removed where base sees it. Pausing needs no answer: the run stays here, resumable, with `progressNotes` carrying the pickup. @@ -168,15 +156,11 @@ Pausing needs no answer: the run stays here, resumable, with `progressNotes` car Close the loop in order — the artifacts must outlive this session: -1. **Commit the code**, then capture the closing **done signal** as the dispatched sub-move on `workBranch` — enter capture with `kind` done and `closes` naming the fulfilled commitments. The body cites the commit hashes, addresses every acceptance criterion and augmenting commitment (or names what stays open — a partial stop records the same way, honest about what remains), and carries work-shape `topics` for the kind of work concluded: `implementation/<...>`, e.g. `implementation/engine` or `implementation/skill-text`. -2. Report `doneEntry` — the captured done signal's ID. A tracked run then enters the landing junction with the marker still present on `baseBranch`; quick runs skip it. - -## unit: landing - -The work branch now carries code and its completion evidence together. Present the landing read: which acceptance criteria are met, deviations, inner and outer judgment, and merge-ready or hold. The merge or in-place landing is host work. Wait for the user's explicit report: **landed** means the host has successfully placed the work on `baseBranch`, so the engine may remove the marker there; **defer** keeps the marker and this resumable landing junction intact. Relay the user's answer verbatim. +1. **Commit the code**, then capture the closing **done signal** as the dispatched sub-move — enter capture with `kind` done and `closes` naming the fulfilled commitments. It lands where the work is, on the session's current branch, so it reaches base together with the code. The body cites the commit hashes, addresses every acceptance criterion and augmenting commitment (or names what stays open — a partial stop records the same way, honest about what remains), and carries work-shape `topics` for the kind of work concluded: `implementation/<...>`, e.g. `implementation/engine` or `implementation/skill-text`. +2. Report `doneEntry` — the captured done signal's ID. The engine then removes a tracked run's marker on the same branch, so the merge that carries the done to base removes the marker there too. ## unit: closeout -Only if this run actually entered a worktree, and only after the host has reported a successful landing, clear the session branch binding through the engine's available session branch-binding capability before finishing or starting evaluation. If this run did not enter a worktree or its landing was not successful, make no session branch-binding change. Clearing the routing declaration does not delete the branch or worktree; it only stops later unqualified session reads and captures from following the landed work branch. +The work is recorded, and its marker removal travels with it. Landing is host work and needs no report here: present the landing read — which acceptance criteria are met, deviations, merge-ready or hold — then land the work the way your harness does, on the user's go-ahead. Once the host is back on base, clear the session binding through the engine's session branch-binding capability, so later reads and captures follow base again. -The work is recorded. One more offer — evaluation is its own work, recorded as its own entry, and this is the cheapest moment to start it: the run's grounding seeds it, and the fresh done signal is its anchor. Put it to the user: **evaluate** starts the evaluate procedure on the just-captured done; **finish** ends the run — the learning loop stays open for a later session or another participant. +One more offer — evaluation is its own work, recorded as its own entry, and this is the cheapest moment to start it: the run's grounding seeds it, and the fresh done signal is its anchor. Put it to the user: **evaluate** starts the evaluate procedure on the just-captured done; **finish** ends the run — the learning loop stays open for a later session or another participant. diff --git a/internal/cliapp/serve.go b/internal/cliapp/serve.go index 259b1207..a007cf50 100644 --- a/internal/cliapp/serve.go +++ b/internal/cliapp/serve.go @@ -307,7 +307,7 @@ func buildLocalApplication(ctx context.Context, cmd *cli.Command, graphDir, sddD } runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: project, DisplayName: displayName}, DefaultBranch: cfg.DefaultBranch, Language: language, - Dependencies: dependencies, Graph: localBranchReadStore{GraphStore: graph, branches: targets}, Targets: targets, Branches: targets, + Dependencies: dependencies, Graph: localadapter.BranchReadStore{GraphStore: graph, Branches: targets, DefaultBranch: cfg.DefaultBranch}, Targets: targets, Branches: targets, Base: targets, Embedder: embeddings, SearchIndex: optionalSearchIndex(embeddings, baseIndex), LLM: runner, }) @@ -412,18 +412,6 @@ func collectSessions(ctx context.Context, application *sdd.Application, retentio } } -type localBranchReadStore struct { - sdd.GraphStore - branches sdd.SnapshotReader -} - -func (s localBranchReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { - if q.Branch != "" { - return s.branches.AcquireSnapshot(ctx, q) - } - return s.GraphStore.(sdd.SnapshotReader).AcquireSnapshot(ctx, q) -} - func optionalSearchIndex(embeddings embed.Embedder, index sdd.SearchIndexStore) sdd.SearchIndexStore { if embeddings == nil { return nil diff --git a/internal/engine/instance.go b/internal/engine/instance.go index fb31587c..0de3dc47 100644 --- a/internal/engine/instance.go +++ b/internal/engine/instance.go @@ -58,6 +58,17 @@ type Instance struct { // only, deliberately: a process restart or resume forgets it, so those // paths serve whole (20260826-120330-d-tac-8f8). draftServed map[string]map[string]string + // retiredStep is the logged position when the log placed the instance on + // a step its procedure no longer has (see ReplaySession). + retiredStep string +} + +// loggedStep is the instance's position as its log records it. +func (i *Instance) loggedStep() string { + if i.retiredStep != "" { + return i.retiredStep + } + return i.Step } // currentStep returns the instance's step definition, nil when terminal. @@ -430,6 +441,7 @@ func (s *Session) transitionTo(inst *Instance, to string, reopen bool) error { } inst.Step = to inst.opDone = false + inst.retiredStep = "" } return nil } diff --git a/internal/engine/mutation.go b/internal/engine/mutation.go index 05db5760..c9fe9b53 100644 --- a/internal/engine/mutation.go +++ b/internal/engine/mutation.go @@ -246,7 +246,12 @@ func (s *Session) applyCancellation(inst *Instance, returnStep string) { inst.Status, inst.Outcome = StatusAbandoned, MutationCancelled return } + if inst.Spec.StepByID[returnStep] == nil { + inst.retiredStep = returnStep + return + } inst.Step = returnStep + inst.retiredStep = "" } func (s *Session) restoreIntent(event Event) error { @@ -270,7 +275,7 @@ func (s *Session) restoreIntent(event Event) error { if err := json.Unmarshal(raw, &data); err != nil { return err } - if event.Position == 0 || data.Step != inst.Step || data.Command == "" { + if event.Position == 0 || data.Step != inst.loggedStep() || data.Command == "" { return fmt.Errorf("mutation intent has an invalid position or invocation") } s.intent = &MutationIntent{Ref: event.Position, Instance: inst.ID, Step: data.Step, Command: data.Command, Values: data.Values, To: data.To} diff --git a/internal/engine/predicates.go b/internal/engine/predicates.go index 8abe932f..e39e71fc 100644 --- a/internal/engine/predicates.go +++ b/internal/engine/predicates.go @@ -70,8 +70,6 @@ var presenceFields = map[string]string{ "hasInspectedIds": "inspectedIds", "hasPlan": "plan", "hasContract": "contract", - "hasBaseBranch": "baseBranch", - "hasWorkBranch": "workBranch", "hasDoneEntry": "doneEntry", "hasCandidates": "candidates", "hasSynthesis": "synthesis", diff --git a/internal/engine/replay_history_test.go b/internal/engine/replay_history_test.go new file mode 100644 index 00000000..cef582b8 --- /dev/null +++ b/internal/engine/replay_history_test.go @@ -0,0 +1,124 @@ +package engine_test + +import ( + "testing" + + "github.com/networkteam/sdd/internal/engine" + "github.com/networkteam/sdd/internal/model" +) + +// The revision a session ran: a middle step collecting a field, both retired +// in the revision the session is replayed against. +const historyMachineRan = `state: + kept: {type: text, desc: survives the revision} + retired: {type: text, desc: retired by the revision} +steps: + - id: first + chooser: agent + options: + - {choice: go, collect: [kept], to: middle} + - id: middle + chooser: agent + options: + - {choice: on, collect: [retired], to: last} + - id: last + chooser: agent + options: + - {choice: finish, to: end(completed)}` + +const historyMachineNow = `state: + kept: {type: text, desc: survives the revision} +steps: + - id: first + chooser: agent + options: + - {choice: go, collect: [kept], to: last} + - id: last + chooser: agent + options: + - {choice: finish, to: end(completed)}` + +func historySpec(t *testing.T, machine string) *engine.Spec { + t.Helper() + content := "---\ntype: decision\nlayer: prc\nkind: procedure\ncanonical: historyproc\n" + + machine + "\n---\n\nhistory procedure\n" + entry, err := model.ParseEntry("20260923-120000-d-prc-hst.md", content) + if err != nil { + t.Fatalf("fixture entry: %v", err) + } + spec, err := engine.ParseSpec(entry) + if err != nil { + t.Fatal(err) + } + return spec +} + +func replayAgainst(t *testing.T, spec *engine.Spec, events []engine.Event) *engine.Session { + t.Helper() + replayed, err := engine.New(engine.NewRegistry(), engine.StaticGraphs{Graph: model.NewGraph(nil)}). + ReplaySession("s_history", "tester", events, func(string) (*engine.Spec, error) { return spec, nil }, &recordingSink{}) + if err != nil { + t.Fatalf("replaying a log recorded against the earlier revision: %v", err) + } + return replayed +} + +// TestReplay_ReadsTheLogAsHistory: a session recorded against an earlier +// revision of its procedure replays against the current one — the retired +// field's start seed and report are dropped and the retired step is passed +// through (20260923-230855-d-cpt-34w). +func TestReplay_ReadsTheLogAsHistory(t *testing.T) { + ran := historySpec(t, historyMachineRan) + sink := &recordingSink{} + session := engine.New(engine.NewRegistry(), engine.StaticGraphs{Graph: model.NewGraph(nil)}).NewSession("s_history", "tester", sink) + sv, err := session.Start(ran, map[string]any{"retired": "seeded"}, "") + if err != nil { + t.Fatal(err) + } + instance := sv.Instance + if _, err := session.Answer(instance, "first", "go", map[string]any{"kept": "k"}, ""); err != nil { + t.Fatal(err) + } + atMiddle := len(sink.events) + if _, err := session.Answer(instance, "middle", "on", map[string]any{"retired": "r"}, ""); err != nil { + t.Fatal(err) + } + + now := historySpec(t, historyMachineNow) + + t.Run("passes through the retired step", func(t *testing.T) { + replayed := replayAgainst(t, now, sink.events) + inst, _ := replayed.Instance(instance) + if inst.Step != "last" { + t.Fatalf("replayed step = %q, want last", inst.Step) + } + if kept, _ := inst.Store.Get("kept"); kept != "k" { + t.Fatalf("kept = %v, want the logged value", kept) + } + if _, ok := inst.Store.Get("retired"); ok { + t.Fatal("the retired field survived replay") + } + serve, err := replayed.Answer(instance, "last", "finish", nil, "") + if err != nil { + t.Fatal(err) + } + if serve.Status != engine.StatusCompleted { + t.Fatalf("status = %s, want completed", serve.Status) + } + }) + + t.Run("a log ending on the retired step resumes at the last known step", func(t *testing.T) { + replayed := replayAgainst(t, now, sink.events[:atMiddle]) + inst, _ := replayed.Instance(instance) + if inst.Step != "first" { + t.Fatalf("replayed step = %q, want first", inst.Step) + } + serve, err := replayed.Answer(instance, "first", "go", map[string]any{"kept": "k"}, "") + if err != nil { + t.Fatal(err) + } + if serve.Step != "last" { + t.Fatalf("step after answering again = %q, want last", serve.Step) + } + }) +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 30978cde..326de118 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -780,6 +780,12 @@ type SpecResolver func(canonical string) (*Spec, error) // applied directly from the logged values — reports, op results, and // transitions — never by re-running commands, so replay is free of side // effects. The returned session continues appending to sink. +// +// The log is history (20260923-230855-d-cpt-34w): a session recorded against +// an earlier revision of its procedure replays with the fields the procedure +// no longer declares dropped, and a transition to a step it no longer has +// leaves the instance on its last known step until a later transition moves +// it on. func (e *Engine) ReplaySession(id, participant string, events []Event, resolve SpecResolver, sink EventSink, opts ...SessionOption) (*Session, error) { s := e.NewSession(id, participant, nil, opts...) for _, ev := range events { @@ -833,7 +839,7 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { Parent: parent, } if params, ok := ev.Data["params"].(map[string]any); ok { - if err := inst.Store.SetStart(params); err != nil { + if err := inst.Store.SetStart(declaredInputs(spec, params)); err != nil { return err } } else if err := inst.Store.SetStart(nil); err != nil { @@ -843,7 +849,7 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { // the started event, re-applied here so a resumed child keeps the // record its gate passed on (no re-widen after a restart). if seed, ok := ev.Data["seed"].(map[string]any); ok { - if _, err := inst.Store.WriteState(seed); err != nil { + if _, err := inst.Store.WriteState(declaredState(spec, seed)); err != nil { return err } } @@ -859,12 +865,12 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { return err } fields, _ := ev.Data["fields"].(map[string]any) - if _, err := inst.Store.WriteState(fields); err != nil { + if _, err := inst.Store.WriteState(declaredState(inst.Spec, fields)); err != nil { return err } s.clearCancellation(inst.ID) - if inst.currentStep().Op == "" { - inst.interactionStep = inst.Step + if inst.retiredStep != "" || inst.currentStep().Op == "" { + inst.interactionStep = inst.loggedStep() } case EventChooserAnswer: @@ -873,11 +879,11 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { return err } s.clearCancellation(inst.ID) - inst.interactionStep = inst.Step + inst.interactionStep = inst.loggedStep() // The answer's own effects were logged separately (op_result, // transition); collected fields ride the answer event. if fields, ok := ev.Data["fields"].(map[string]any); ok { - if _, err := inst.Store.WriteState(fields); err != nil { + if _, err := inst.Store.WriteState(declaredState(inst.Spec, fields)); err != nil { return err } } @@ -948,8 +954,10 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { case inst.Spec.StepByID[to] != nil: inst.Step = to inst.opDone = false + inst.retiredStep = "" default: - return fmt.Errorf("transition target %q not in procedure %s", to, inst.Spec.Canonical) + inst.retiredStep = to + inst.opDone = false } case EventCompleted: @@ -1007,6 +1015,29 @@ func (s *Session) applyEvent(ev Event, resolve SpecResolver) error { return nil } +// declaredState drops logged fields the procedure no longer declares. +func declaredState(spec *Spec, fields map[string]any) map[string]any { + kept := make(map[string]any, len(fields)) + for name, value := range fields { + if _, ok := spec.State[name]; ok { + kept[name] = value + } + } + return kept +} + +// declaredInputs drops logged start inputs the procedure no longer declares +// as a param or as seedable state. +func declaredInputs(spec *Spec, inputs map[string]any) map[string]any { + kept := declaredState(spec, inputs) + for name, value := range inputs { + if _, ok := spec.Params[name]; ok { + kept[name] = value + } + } + return kept +} + func (s *Session) replayInstance(ev Event) (*Instance, error) { inst, ok := s.instances[ev.Instance] if !ok { diff --git a/internal/proctest/document_writes_test.go b/internal/proctest/document_writes_test.go index 2eba0fa5..667d6505 100644 --- a/internal/proctest/document_writes_test.go +++ b/internal/proctest/document_writes_test.go @@ -112,7 +112,7 @@ func TestImplementation_WIPStartRetryPublishesOneMarker(t *testing.T) { finalizer := &failingFinalizer{failCall: 1} world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry()), proctest.WithFinalizers(finalizer)) session := world.Open(t, "wip-retry") - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) failed, err := session.AnswerErr(t, serve.Instance, "setup", "inPlace", map[string]any{"wipDescription": "implement the anchor"}, "in place") if err != nil { t.Fatal(err) @@ -128,7 +128,7 @@ func TestImplementation_WIPStartRetryPublishesOneMarker(t *testing.T) { if err != nil { t.Fatal(err) } - proctest.RequireStep(t, retried, "workTarget") + proctest.RequireStep(t, retried, "work") if again := requireSingleMarker(t, world.GraphDir); again.ID != marker.ID { t.Fatalf("retry published another marker: %s then %s", marker.ID, again.ID) } @@ -137,9 +137,9 @@ func TestImplementation_WIPStartRetryPublishesOneMarker(t *testing.T) { } } -// Removing a marker that is already gone succeeds: the landing still closes -// the run, and no other marker is touched. -func TestImplementation_LandingRemovesAnAbsentMarkerWithoutError(t *testing.T) { +// Removing a marker that is already gone succeeds: recording the done still +// closes the run, and no other marker is touched. +func TestImplementation_RemovingAnAbsentMarkerSucceeds(t *testing.T) { world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) session := world.Open(t, "wip-absent") serve := startImplementationAtWork(t, session) @@ -154,11 +154,9 @@ func TestImplementation_LandingRemovesAnAbsentMarkerWithoutError(t *testing.T) { proctest.RequireStep(t, serve, "record") doneID := captureDone(t, session, instance) serve = session.Report(t, instance, map[string]any{"doneEntry": doneID}) - proctest.RequireStep(t, serve, "landing") - serve = session.Answer(t, instance, "landing", "landed", nil, "merged") proctest.RequireStep(t, serve, "closeout") if ids := wipMarkerIDs(t, world.GraphDir); len(ids) != 1 || ids[0] != "20260601-130000-someone-else" { - t.Fatalf("markers after landing = %v, want only the other participant's", ids) + t.Fatalf("markers after the done = %v, want only the other participant's", ids) } if _, err := os.Stat(other); err != nil { t.Fatalf("another participant's marker was removed: %v", err) diff --git a/internal/proctest/implementation_test.go b/internal/proctest/implementation_test.go index a15e8662..c461fdd4 100644 --- a/internal/proctest/implementation_test.go +++ b/internal/proctest/implementation_test.go @@ -118,9 +118,9 @@ func entryOnDisk(t *testing.T, graphDir, id string) bool { return true } -// implToSetup drives a fresh instance through contract and baseTarget, +// implToSetup drives a fresh instance through the contract to setup, // logging the anchor read the contract step requires. -func implToSetup(t *testing.T, session *proctest.Session, params map[string]any, baseBranch string) *sdd.WorkflowServe { +func implToSetup(t *testing.T, session *proctest.Session, params map[string]any) *sdd.WorkflowServe { t.Helper() serve := session.Start(t, "implementation", params) proctest.RequireStep(t, serve, "contract") @@ -129,8 +129,6 @@ func implToSetup(t *testing.T, session *proctest.Session, params map[string]any, "contract": "AC1 remaining, AC2 covered by a partial done; ready to build", "widenReport": "searched constraints and prior attempts; nothing beyond the chain", }) - proctest.RequireStep(t, serve, "baseTarget") - serve = session.Report(t, serve.Instance, map[string]any{"baseBranch": baseBranch}) proctest.RequireStep(t, serve, "setup") return serve } @@ -139,15 +137,50 @@ func implToSetup(t *testing.T, session *proctest.Session, params map[string]any, // tracked in-place setup to the working junction. func startImplementationAtWork(t *testing.T, session *proctest.Session) *sdd.WorkflowServe { t.Helper() - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) serve = session.Answer(t, serve.Instance, "setup", "inPlace", map[string]any{"wipDescription": "implement the anchor"}, "in place, small scope") - proctest.RequireStep(t, serve, "workTarget") - serve = session.Report(t, serve.Instance, map[string]any{"workBranch": "main"}) proctest.RequireStep(t, serve, "work") return serve } +// enterWorkBranch is the host branching off after setup: the work branch +// carries the marker just written on base, and the agent declares the branch +// as the session binding (20260923-230855-d-cpt-34w). +func enterWorkBranch(t *testing.T, session *proctest.Session, baseDir, workDir, branch string) { + t.Helper() + for _, id := range wipMarkerIDs(t, baseDir) { + content, err := os.ReadFile(filepath.Join(model.WIPDir(baseDir), id+".md")) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(model.WIPDir(workDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(model.WIPDir(workDir), id+".md"), content, 0o644); err != nil { + t.Fatal(err) + } + } + bindWorkBranch(t, session, branch) +} + +func bindWorkBranch(t *testing.T, session *proctest.Session, branch string) { + t.Helper() + if err := session.WF.BindBranch(t.Context(), session.World.Identity, branch, false); err != nil { + t.Fatal(err) + } +} + +// returnToBase is the host back on base: the agent clears the binding, then +// the work branch's checkout disappears. +func returnToBase(t *testing.T, session *proctest.Session, workBranch string) { + t.Helper() + if err := session.WF.BindBranch(t.Context(), session.World.Identity, "", true); err != nil { + t.Fatal(err) + } + session.World.DropBranch(t, workBranch) +} + // driveCapture runs an already-started capture child through playback and // summary verification, returning the written entry's ID. func driveCapture(t *testing.T, session *proctest.Session, instance string, fields map[string]any) string { @@ -199,20 +232,37 @@ func resumedInstanceServe(t *testing.T, world *proctest.World, sessionID sdd.Ses return nil } -func assertBindingClearSelfGuard(t *testing.T, instructions string) { +func requireInstructions(t *testing.T, unit, instructions string, wants ...string) { t.Helper() - for _, want := range []string{ - "Only if this run actually entered a worktree", - "only after the host has reported a successful landing", - "clear the session branch binding", - "If this run did not enter a worktree or its landing was not successful, make no session branch-binding change", - } { + for _, want := range wants { if !strings.Contains(instructions, want) { - t.Fatalf("closeout instructions missing binding guard %q:\n%s", want, instructions) + t.Fatalf("%s instructions missing %q:\n%s", unit, want, instructions) } } } +// The instruction moments of 20260923-230855-d-cpt-34w: the binding is +// declared after entering the work branch and cleared after returning to base, +// and landing asks for no report. +func assertSetupMoment(t *testing.T, instructions string) { + t.Helper() + requireInstructions(t, "setup", instructions, + "Entering the work branch is host work", + "so the work branch carries it", + "declare it as the session branch binding", + "In-place and quick runs stay where they are and declare nothing", + ) +} + +func assertCloseoutMoment(t *testing.T, instructions string) { + t.Helper() + requireInstructions(t, "closeout", instructions, + "Landing is host work and needs no report here", + "Once the host is back on base, clear the session binding", + "**evaluate**", + ) +} + func TestImplementation_HappyPathTracked(t *testing.T) { world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) session := world.Open(t, "impl-happy") @@ -229,18 +279,15 @@ func TestImplementation_HappyPathTracked(t *testing.T) { "contract": "AC1 remaining, AC2 covered by a partial done; ready to build", "widenReport": "searched constraints and prior attempts; nothing beyond the chain", }) - proctest.RequireStep(t, serve, "baseTarget") - serve = session.Report(t, instance, map[string]any{"baseBranch": "main"}) proctest.RequireStep(t, serve, "setup") + assertSetupMoment(t, serve.Instructions) serve = session.Answer(t, instance, "setup", "inPlace", map[string]any{"wipDescription": "implement the anchor"}, "in place, small scope") - proctest.RequireStep(t, serve, "workTarget") + proctest.RequireStep(t, serve, "work") marker := requireSingleMarker(t, world.GraphDir) if marker.Content != "implement the anchor" { t.Fatalf("marker content = %q, want the wipDescription", marker.Content) } - serve = session.Report(t, instance, map[string]any{"workBranch": "main"}) - proctest.RequireStep(t, serve, "work") // One working-loop cycle: continue self-loops with the running notes. serve = session.Answer(t, instance, "work", "continue", @@ -250,35 +297,25 @@ func TestImplementation_HappyPathTracked(t *testing.T) { serve = session.Answer(t, instance, "work", "conclude", nil, "contract met") proctest.RequireStep(t, serve, "record") - // Recording the done holds the marker through the landing junction. + // The marker goes right after the done is recorded; no landing report. doneID := captureDone(t, session, instance) serve = session.Report(t, instance, map[string]any{"doneEntry": doneID}) - proctest.RequireStep(t, serve, "landing") - if serve.PendingChooser == nil || string(serve.PendingChooser.Kind) != "user" { - t.Fatalf("record should route to the landing user chooser, got %+v", serve.PendingChooser) - } - requireSingleMarker(t, world.GraphDir) - - serve = session.Answer(t, instance, "landing", "landed", nil, "merged successfully") proctest.RequireStep(t, serve, "closeout") requireNoMarkers(t, world.GraphDir) - assertBindingClearSelfGuard(t, serve.Instructions) + assertCloseoutMoment(t, serve.Instructions) serve = session.Answer(t, instance, "closeout", "finish", nil, "done for today") proctest.RequireStatus(t, serve, "completed") } -func TestImplementation_RoutesBaseAndWorkBranchesInEveryMode(t *testing.T) { - tests := []struct { - mode string - workBranch string - }{ - {mode: "inPlace", workBranch: "main"}, - {mode: "branch", workBranch: "feature"}, - {mode: "worktree", workBranch: "feature"}, - } - for _, test := range tests { - t.Run(test.mode, func(t *testing.T) { +// TestImplementation_MarkerFollowsTheCurrentBranchInEveryMode drives every +// tracked mode: setup writes the marker on the session's current branch, the +// work branch the host branches off carries it, and the engine removes it +// there right after the done — base keeps showing the work as taken until the +// merge (20260923-230855-d-cpt-34w). +func TestImplementation_MarkerFollowsTheCurrentBranchInEveryMode(t *testing.T) { + for _, mode := range []string{"inPlace", "branch", "worktree"} { + t.Run(mode, func(t *testing.T) { featureDir := t.TempDir() proctest.WriteEntry(t, featureDir, implAnchorEntry()) proctest.WriteEntry(t, featureDir, implDoneEntry()) @@ -286,104 +323,136 @@ func TestImplementation_RoutesBaseAndWorkBranchesInEveryMode(t *testing.T) { proctest.WithEntries(implAnchorEntry(), implDoneEntry()), proctest.WithBranchDir("feature", featureDir), ) - session := world.Open(t, "impl-routes-"+test.mode) + session := world.Open(t, "impl-routes-"+mode) - serve := session.Start(t, "implementation", map[string]any{"anchor": implAnchorID}) - proctest.RequireStep(t, serve, "contract") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) instance := serve.Instance - session.LogRead(t, "show", []string{implAnchorID}, nil) - serve = session.Report(t, instance, map[string]any{ - "contract": "ready", "widenReport": "constraints checked", - }) - proctest.RequireStep(t, serve, "baseTarget") - if !slices.Contains(serve.Missing, "baseBranch") { - t.Fatalf("baseTarget must require an explicit baseBranch report: missing=%v", serve.Missing) - } - serve = session.Report(t, instance, map[string]any{"baseBranch": "main"}) - proctest.RequireStep(t, serve, "setup") - setupFields := map[string]any{"wipDescription": "route targets"} - if test.mode == "worktree" { - setupFields["worktreeMode"] = "worktree" - } - serve = session.Answer(t, instance, "setup", test.mode, setupFields, test.mode) - proctest.RequireStep(t, serve, "workTarget") - if !slices.Contains(serve.Missing, "workBranch") { - t.Fatalf("workTarget must still require an explicit workBranch report: missing=%v", serve.Missing) - } - for _, want := range []string{ - "current session binding", - "natural candidate and default suggestion", - "report `workBranch` explicitly", - "engine never copies or adopts the binding", - } { - if !strings.Contains(serve.Instructions, want) { - t.Fatalf("workTarget instructions missing %q:\n%s", want, serve.Instructions) - } - } - if test.mode == "worktree" { - for _, want := range []string{ - "after the host has entered that worktree", - "session branch-binding capability", - "does not fill this procedure's state automatically", - } { - if !strings.Contains(serve.Instructions, want) { - t.Fatalf("worktree instructions missing %q:\n%s", want, serve.Instructions) - } - } - } else if strings.Contains(serve.Instructions, "after the host has entered that worktree") { - t.Fatalf("%s workTarget rendered worktree-only declaration:\n%s", test.mode, serve.Instructions) + serve = session.Answer(t, instance, "setup", mode, map[string]any{"wipDescription": "route targets"}, mode) + proctest.RequireStep(t, serve, "work") + if len(serve.Missing) != 0 { + t.Fatalf("the working junction demands a report: missing=%v", serve.Missing) } - // The marker lives on the explicit base branch, never the work branch. requireSingleMarker(t, world.GraphDir) requireNoMarkers(t, featureDir) - serve = session.Report(t, instance, map[string]any{"workBranch": test.workBranch}) - proctest.RequireStep(t, serve, "work") + if mode != "inPlace" { + enterWorkBranch(t, session, world.GraphDir, featureDir, "feature") + } serve = session.Answer(t, instance, "work", "conclude", nil, "mode routing verified") proctest.RequireStep(t, serve, "record") serve = session.Report(t, instance, map[string]any{"doneEntry": implDoneID}) - proctest.RequireStep(t, serve, "landing") - if test.mode == "worktree" { - serve = session.Answer(t, instance, "landing", "defer", nil, "not landed yet") - proctest.RequireStep(t, serve, "landing") - if strings.Contains(serve.Instructions, "clear the session branch binding") { - t.Fatalf("deferred landing rendered clear guidance before landing:\n%s", serve.Instructions) - } - requireSingleMarker(t, world.GraphDir) - } - serve = session.Answer(t, instance, "landing", "landed", nil, "landed successfully") proctest.RequireStep(t, serve, "closeout") - requireNoMarkers(t, world.GraphDir) - assertBindingClearSelfGuard(t, serve.Instructions) + if mode == "inPlace" { + requireNoMarkers(t, world.GraphDir) + return + } + requireNoMarkers(t, featureDir) + requireSingleMarker(t, world.GraphDir) }) } } +// TestImplementation_AbandonAfterSetupRemovesMarker is the abandon path: a +// tracked run dropped at the working junction removes its marker on the +// session's current branch and ends without a done — on base once the host +// returned there and cleared the binding; a quick run ends the same way with +// nothing to remove. +func TestImplementation_AbandonAfterSetupRemovesMarker(t *testing.T) { + t.Run("tracked", func(t *testing.T) { + world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) + session := world.Open(t, "impl-abandon") + serve := startImplementationAtWork(t, session) + instance := serve.Instance + requireInstructions(t, "work", serve.Instructions, "**abandon**", "return it to base and clear the session binding first") + requireSingleMarker(t, world.GraphDir) + + serve = session.Answer(t, instance, "work", "abandon", nil, "drop this run") + proctest.RequireStatus(t, serve, "abandoned") + requireNoMarkers(t, world.GraphDir) + }) + t.Run("from a work branch", func(t *testing.T) { + featureDir := t.TempDir() + proctest.WriteEntry(t, featureDir, implAnchorEntry()) + world := proctest.NewWorld(t, + proctest.WithEntries(implAnchorEntry()), + proctest.WithBranchDir("feature", featureDir), + ) + session := world.Open(t, "impl-abandon-branch") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) + instance := serve.Instance + serve = session.Answer(t, instance, "setup", "branch", map[string]any{"wipDescription": "dropped later"}, "on a branch") + proctest.RequireStep(t, serve, "work") + enterWorkBranch(t, session, world.GraphDir, featureDir, "feature") + + returnToBase(t, session, "feature") + serve = session.Answer(t, instance, "work", "abandon", nil, "drop this run") + proctest.RequireStatus(t, serve, "abandoned") + requireNoMarkers(t, world.GraphDir) + }) + t.Run("quick", func(t *testing.T) { + world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) + session := world.Open(t, "impl-abandon-quick") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) + instance := serve.Instance + serve = session.Answer(t, instance, "setup", "quick", nil, "too small to track") + proctest.RequireStep(t, serve, "work") + + serve = session.Answer(t, instance, "work", "abandon", nil, "drop this run") + proctest.RequireStatus(t, serve, "abandoned") + requireNoMarkers(t, world.GraphDir) + }) +} + +// TestImplementation_StaleBindingCanBeCleared: a session bound to a branch +// whose checkout is gone must still replay, so the binding can be cleared and +// the session resumed. +func TestImplementation_StaleBindingCanBeCleared(t *testing.T) { + featureDir := t.TempDir() + proctest.WriteEntry(t, featureDir, implAnchorEntry()) + world := proctest.NewWorld(t, + proctest.WithEntries(implAnchorEntry()), + proctest.WithBranchDir("feature", featureDir), + ) + session := world.Open(t, "impl-stale") + serve := startImplementationAtWork(t, session) + instance := serve.Instance + bindWorkBranch(t, session, "feature") + world.DropBranch(t, "feature") + + if _, _, err := world.App.ResumeWorkflow(t.Context(), world.Identity, sdd.WorkflowResumeRequest{SessionID: session.ID, ClientName: "impl-stale-resume"}); err == nil { + t.Fatal("resuming a session bound to a branch without a checkout succeeded") + } else if !strings.Contains(err.Error(), `session is bound to branch "feature"`) || !strings.Contains(err.Error(), "clear it") { + t.Fatalf("stale binding error = %v, want the binding named with the clear advice", err) + } + + refreshed, err := world.App.RefreshWorkflow(t.Context(), world.Identity, session.ID) + if err != nil { + t.Fatalf("loading the session to clear its binding: %v", err) + } + if err := refreshed.BindBranch(t.Context(), world.Identity, "", true); err != nil { + t.Fatalf("clearing the stale binding: %v", err) + } + resumed := resumedInstanceServe(t, world, session.ID, "impl-stale-resumed", instance) + proctest.RequireStep(t, resumed, "work") +} + func TestImplementation_QuickSkipsMarker(t *testing.T) { world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry(), implDoneEntry())) session := world.Open(t, "impl-quick") - serve := session.Start(t, "implementation", map[string]any{"anchor": implAnchorID}) - session.LogRead(t, "show", []string{implAnchorID}, nil) + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) instance := serve.Instance - session.Report(t, instance, map[string]any{ - "contract": "one-line fix", - "widenReport": "nothing bears on it", - }) - session.Report(t, instance, map[string]any{"baseBranch": "main"}) serve = session.Answer(t, instance, "setup", "quick", nil, "too small to track") - proctest.RequireStep(t, serve, "workTarget") - requireNoMarkers(t, world.GraphDir) - serve = session.Report(t, instance, map[string]any{"workBranch": "main"}) proctest.RequireStep(t, serve, "work") + requireNoMarkers(t, world.GraphDir) serve = session.Answer(t, instance, "work", "conclude", nil, "fixed") proctest.RequireStep(t, serve, "record") - // No marker was created, so record must bypass the landing junction — a - // route through wipDone would fail loudly on the unset wipMarker. + // No marker was created, so record must bypass the removal — a route + // through wipDone would fail loudly on the unset wipMarker. serve = session.Report(t, instance, map[string]any{"doneEntry": implDoneID}) proctest.RequireStep(t, serve, "closeout") - assertBindingClearSelfGuard(t, serve.Instructions) + assertCloseoutMoment(t, serve.Instructions) requireNoMarkers(t, world.GraphDir) } @@ -391,15 +460,8 @@ func TestImplementation_HoldLoopsBackToSetup(t *testing.T) { world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) session := world.Open(t, "impl-hold") - serve := session.Start(t, "implementation", map[string]any{"anchor": implAnchorID}) - session.LogRead(t, "show", []string{implAnchorID}, nil) + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) instance := serve.Instance - session.Report(t, instance, map[string]any{ - "contract": "AC2 presumes an undecided output format", - "widenReport": "no decision covers the format", - }) - serve = session.Report(t, instance, map[string]any{"baseBranch": "main"}) - proctest.RequireStep(t, serve, "setup") // Hold stashes the capture seed and re-serves setup: the missing decision // is captured as a sub-move, then the user picks a mode. @@ -409,16 +471,14 @@ func TestImplementation_HoldLoopsBackToSetup(t *testing.T) { serve = session.Answer(t, instance, "setup", "inPlace", map[string]any{"wipDescription": "implement with the decided format"}, "decided, go") - proctest.RequireStep(t, serve, "workTarget") - serve = session.Report(t, instance, map[string]any{"workBranch": "main"}) proctest.RequireStep(t, serve, "work") } -// TestImplementation_HoldSeedsCaptureOnBaseBranch is the behavioral half of -// the old dispatch-declaration check for hold: the dispatched capture inherits -// widenReport and captureBranch from baseBranch, so the captured decision -// lands on the base store even before any work branch exists. -func TestImplementation_HoldSeedsCaptureOnBaseBranch(t *testing.T) { +// TestImplementation_HoldCaptureFollowsSessionBinding is the behavioral half +// of the dispatch-declaration check for hold: the dispatched capture inherits +// widenReport and no branch, so the captured decision lands where the session +// binding points, not on a branch the run names. +func TestImplementation_HoldCaptureFollowsSessionBinding(t *testing.T) { featureDir := t.TempDir() proctest.WriteEntry(t, featureDir, implAnchorEntry()) world := proctest.NewWorld(t, @@ -426,8 +486,9 @@ func TestImplementation_HoldSeedsCaptureOnBaseBranch(t *testing.T) { proctest.WithBranchDir("feature", featureDir), ) session := world.Open(t, "impl-hold-seed") + bindWorkBranch(t, session, "feature") - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "feature") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) instance := serve.Instance serve = session.Answer(t, instance, "setup", "hold", nil, "decide the format first") proctest.RequireStep(t, serve, "setup") @@ -446,10 +507,10 @@ func TestImplementation_HoldSeedsCaptureOnBaseBranch(t *testing.T) { "confidence": "medium", }) if !entryOnDisk(t, featureDir, entryID) { - t.Fatalf("hold capture %s should land on the seeded baseBranch store", entryID) + t.Fatalf("hold capture %s should land on the session-bound store", entryID) } if entryOnDisk(t, world.GraphDir, entryID) { - t.Fatalf("hold capture %s leaked onto the default store", entryID) + t.Fatalf("hold capture %s leaked onto the base store", entryID) } } @@ -491,7 +552,11 @@ func TestImplementation_DoneEntryMustResolve(t *testing.T) { } } -func TestImplementation_DoneEntryResolvesAgainstWorkBranch(t *testing.T) { +// TestImplementation_NothingReadsTheWorkBranchAfterItIsGone answers +// 20260902-160151-s-tac-mtv: the done and the marker removal land on the bound +// work branch; once the host is back on base and the work branch's checkout is +// gone, the run still finishes and replays on base. +func TestImplementation_NothingReadsTheWorkBranchAfterItIsGone(t *testing.T) { featureDir := t.TempDir() proctest.WriteEntry(t, featureDir, implAnchorEntry()) world := proctest.NewWorld(t, @@ -500,205 +565,35 @@ func TestImplementation_DoneEntryResolvesAgainstWorkBranch(t *testing.T) { ) session := world.Open(t, "impl-workbranch") - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}) instance := serve.Instance serve = session.Answer(t, instance, "setup", "worktree", - map[string]any{"wipDescription": "target-aware reads", "worktreeMode": "worktree"}, "use a worktree") - proctest.RequireStep(t, serve, "workTarget") - serve = session.Report(t, instance, map[string]any{"workBranch": "feature"}) + map[string]any{"wipDescription": "target-aware reads"}, "use a worktree") proctest.RequireStep(t, serve, "work") + enterWorkBranch(t, session, world.GraphDir, featureDir, "feature") serve = session.Answer(t, instance, "work", "conclude", nil, "contract met") proctest.RequireStep(t, serve, "record") - // The real dispatched capture inherits captureBranch from workBranch, so - // the done signal exists only on the feature store — record's resolution - // must read through the work branch to find it. + // The dispatched capture follows the session binding, so the done exists + // only on the feature store — record's resolution reads through the + // binding to find it. doneID := captureDone(t, session, instance) if !entryOnDisk(t, featureDir, doneID) { - t.Fatalf("done capture %s should land on the work branch store", doneID) + t.Fatalf("done capture %s should land on the bound work branch store", doneID) } if entryOnDisk(t, world.GraphDir, doneID) { t.Fatalf("done capture %s leaked onto the base store", doneID) } serve = session.Report(t, instance, map[string]any{"doneEntry": doneID}) - proctest.RequireStep(t, serve, "landing") - serve = session.Answer(t, instance, "landing", "landed", nil, "landed successfully") proctest.RequireStep(t, serve, "closeout") - requireNoMarkers(t, world.GraphDir) - assertBindingClearSelfGuard(t, serve.Instructions) - for _, want := range []string{ - "session branch-binding capability", - "does not delete the branch or worktree", - } { - if !strings.Contains(serve.Instructions, want) { - t.Fatalf("worktree closeout instructions missing %q:\n%s", want, serve.Instructions) - } - } + requireNoMarkers(t, featureDir) - // Re-attaching replays the stored session through the real load path: the - // worktree choice and the closeout clear guidance must survive. + returnToBase(t, session, "feature") replayed := resumedInstanceServe(t, world, session.ID, "impl-workbranch-replay", instance) proctest.RequireStep(t, replayed, "closeout") - if mode, _ := replayed.Collected["worktreeMode"].(string); mode != "worktree" { - t.Fatalf("replayed worktreeMode = %v, want worktree", replayed.Collected["worktreeMode"]) - } - assertBindingClearSelfGuard(t, replayed.Instructions) -} - -func TestImplementation_WorktreeModeIsScopedToWorktreeChoice(t *testing.T) { - world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) - session := world.Open(t, "impl-scoped-mode") - - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") - instance := serve.Instance - if serve.PendingChooser == nil { - t.Fatal("setup served no chooser") - } - for _, option := range serve.PendingChooser.Options { - hasWorktreeMode := slices.Contains(option.Collect, "worktreeMode") - if option.Choice == "worktree" { - if !hasWorktreeMode { - t.Fatalf("worktree collect = %v; worktreeMode must be required", option.Collect) - } - } else if hasWorktreeMode || slices.Contains(option.Collect, "worktreeMode?") { - t.Fatalf("%s option can write worktreeMode: %v", option.Choice, option.Collect) - } - } - - if _, err := session.AnswerErr(t, instance, "setup", "branch", map[string]any{ - "wipDescription": "branch run", "worktreeMode": "worktree", - }, "use a branch"); err == nil || !strings.Contains(err.Error(), `field "worktreeMode" is not collected by option "branch"`) { - t.Fatalf("branch worktreeMode rejection = %v", err) - } - if _, err := session.AnswerErr(t, instance, "setup", "worktree", map[string]any{ - "wipDescription": "worktree run", - }, "use a worktree"); err == nil || !strings.Contains(err.Error(), `option "worktree" requires field "worktreeMode"`) { - t.Fatalf("missing worktreeMode rejection = %v", err) - } - if _, err := session.AnswerErr(t, instance, "setup", "worktree", map[string]any{ - "wipDescription": "worktree run", "worktreeMode": "", - }, "use a worktree"); err == nil { - t.Fatal("empty worktreeMode marker was accepted") - } -} - -func TestImplementation_PreseededWorktreeModeIsSafeForNonWorktreeModes(t *testing.T) { - tests := []struct { - mode string - setup map[string]any - workBranch string - }{ - { - mode: "inPlace", - setup: map[string]any{"wipDescription": "in-place run"}, - workBranch: "main", - }, - { - mode: "branch", - setup: map[string]any{"wipDescription": "branch run"}, - workBranch: "feature", - }, - { - mode: "quick", - workBranch: "main", - }, - } - - for _, test := range tests { - t.Run(test.mode, func(t *testing.T) { - featureDir := t.TempDir() - proctest.WriteEntry(t, featureDir, implAnchorEntry()) - proctest.WriteEntry(t, featureDir, implDoneEntry()) - world := proctest.NewWorld(t, - proctest.WithEntries(implAnchorEntry(), implDoneEntry()), - proctest.WithBranchDir("feature", featureDir), - ) - session := world.Open(t, "impl-preseeded-"+test.mode) - - serve := implToSetup(t, session, map[string]any{ - "anchor": implAnchorID, - "worktreeMode": "worktree", - }, "main") - instance := serve.Instance - serve = session.Answer(t, instance, "setup", test.mode, test.setup, test.mode) - proctest.RequireStep(t, serve, "workTarget") - if !slices.Contains(serve.Missing, "workBranch") { - t.Fatalf("preseeded %s run must still require an explicit workBranch report: missing=%v", test.mode, serve.Missing) - } - for _, want := range []string{ - "Only if this run actually enters a worktree", - "If this run does not enter a worktree, make no session branch-binding change", - "report `workBranch` explicitly", - "engine never copies or adopts the binding", - } { - if !strings.Contains(serve.Instructions, want) { - t.Fatalf("preseeded %s workTarget instructions missing %q:\n%s", test.mode, want, serve.Instructions) - } - } - - serve = session.Report(t, instance, map[string]any{"workBranch": test.workBranch}) - proctest.RequireStep(t, serve, "work") - serve = session.Answer(t, instance, "work", "conclude", nil, "mode routing verified") - proctest.RequireStep(t, serve, "record") - serve = session.Report(t, instance, map[string]any{"doneEntry": implDoneID}) - if test.mode != "quick" { - proctest.RequireStep(t, serve, "landing") - serve = session.Answer(t, instance, "landing", "landed", nil, "landed successfully") - } - proctest.RequireStep(t, serve, "closeout") - assertBindingClearSelfGuard(t, serve.Instructions) - }) - } -} - -func TestImplementation_WorktreeClearGuidanceSurvivesMarkerSuppression(t *testing.T) { - tests := []struct { - name string - marker any - }{ - {name: "nil", marker: nil}, - {name: "empty", marker: ""}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - featureDir := t.TempDir() - proctest.WriteEntry(t, featureDir, implAnchorEntry()) - proctest.WriteEntry(t, featureDir, implDoneEntry()) - world := proctest.NewWorld(t, - proctest.WithEntries(implAnchorEntry(), implDoneEntry()), - proctest.WithBranchDir("feature", featureDir), - ) - session := world.Open(t, "impl-suppressed-"+test.name) - - serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") - instance := serve.Instance - serve = session.Answer(t, instance, "setup", "worktree", map[string]any{ - "wipDescription": "marker suppression regression", - "worktreeMode": "worktree", - }, "use a worktree") - proctest.RequireStep(t, serve, "workTarget") - serve = session.Report(t, instance, map[string]any{ - "workBranch": "feature", - "worktreeMode": test.marker, - }) - proctest.RequireStep(t, serve, "work") - - serve = session.Answer(t, instance, "work", "conclude", nil, "contract met") - proctest.RequireStep(t, serve, "record") - serve = session.Report(t, instance, map[string]any{"doneEntry": implDoneID}) - proctest.RequireStep(t, serve, "landing") - serve = session.Answer(t, instance, "landing", "landed", nil, "landed successfully") - proctest.RequireStep(t, serve, "closeout") - assertBindingClearSelfGuard(t, serve.Instructions) - - // A re-attach replays the stored session: the suppressed marker - // must not cost the closeout its clear guidance. - replayed := resumedInstanceServe(t, world, session.ID, "impl-suppressed-replay-"+test.name, instance) - proctest.RequireStep(t, replayed, "closeout") - assertBindingClearSelfGuard(t, replayed.Instructions) - }) - } + resumed, _ := world.Resume(t, session.ID, "impl-workbranch-finish") + serve = resumed.Answer(t, instance, "closeout", "finish", nil, "merged") + proctest.RequireStatus(t, serve, "completed") } // TestImplementation_DispatchSeedsChildren is the behavioral port of the old @@ -715,8 +610,6 @@ func TestImplementation_DispatchSeedsChildren(t *testing.T) { proctest.RequireStep(t, serve, "record") doneID := captureDone(t, session, instance) serve = session.Report(t, instance, map[string]any{"doneEntry": doneID}) - proctest.RequireStep(t, serve, "landing") - serve = session.Answer(t, instance, "landing", "landed", nil, "merged successfully") proctest.RequireStep(t, serve, "closeout") serve = session.Answer(t, instance, "closeout", "evaluate", nil, "evaluate it") proctest.RequireStatus(t, serve, "completed") diff --git a/internal/proctest/proctest.go b/internal/proctest/proctest.go index 2122cc59..e078ffaa 100644 --- a/internal/proctest/proctest.go +++ b/internal/proctest/proctest.go @@ -171,14 +171,27 @@ func WithBranchDir(branch, dir string) Option { } // branchTargets acquires per-branch graph stores, falling back to the default -// branch's store for unknown branches. +// branch's store for unknown branches. A branch dropped through +// World.DropBranch fails acquisition the way the local runtime fails a branch +// whose registered checkout is gone. type branchTargets struct { fallback sdd.GraphStore graphs map[string]sdd.GraphStore finalizers []sdd.MutationFinalizer + dropped map[string]bool +} + +func (b branchTargets) checkout(branch string) error { + if b.dropped[branch] { + return fmt.Errorf("sdd: mutation target branch %q must have exactly one registered checkout (found 0)", branch) + } + return nil } func (b branchTargets) Acquire(_ context.Context, target sdd.MutationTarget) (*sdd.AcquiredTarget, error) { + if err := b.checkout(target.Branch); err != nil { + return nil, err + } graph, ok := b.graphs[target.Branch] if !ok { graph = b.fallback @@ -186,12 +199,21 @@ func (b branchTargets) Acquire(_ context.Context, target sdd.MutationTarget) (*s return &sdd.AcquiredTarget{Target: target, Graph: graph, Finalizers: b.finalizers, Release: func() error { return nil }}, nil } +func (b branchTargets) ValidateBranch(_ context.Context, target sdd.MutationTarget) error { + return b.checkout(target.Branch) +} + type branchReadStore struct { sdd.GraphStore targets branchTargets } func (s branchReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + if q.Branch != "" { + if err := s.targets.checkout(q.Branch); err != nil { + return nil, err + } + } graph := s.targets.graphs[q.Branch] if graph == nil { graph = s.GraphStore @@ -207,6 +229,20 @@ type World struct { Identity sdd.RequestIdentity GraphDir string LLM *LLMScript + + dropped map[string]bool +} + +// DropBranch removes a registered branch's checkout: from here every read, +// write and binding declaration on that branch fails as the local runtime +// fails a branch without a registered checkout — the host merged and deleted +// its work branch or worktree. +func (w *World) DropBranch(t *testing.T, branch string) { + t.Helper() + if w.dropped == nil { + t.Fatalf("DropBranch(%q): the world registers no branches (use WithBranchDir)", branch) + } + w.dropped[branch] = true } type accessResolver struct { @@ -270,8 +306,10 @@ func NewWorld(t *testing.T, opts ...Option) *World { Project: sdd.ProjectRef{ID: "proctest"}, DefaultBranch: "main", Graph: graph, LLM: script, Finalizers: cfg.finalizers, } + var dropped map[string]bool if len(cfg.branchDirs) > 0 { - targets := branchTargets{fallback: graph, graphs: map[string]sdd.GraphStore{"main": graph}, finalizers: cfg.finalizers} + dropped = map[string]bool{} + targets := branchTargets{fallback: graph, graphs: map[string]sdd.GraphStore{"main": graph}, finalizers: cfg.finalizers, dropped: dropped} for branch, dir := range cfg.branchDirs { store, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "proctest", GraphDir: dir, Branch: branch}) if err != nil { @@ -281,7 +319,7 @@ func NewWorld(t *testing.T, opts ...Option) *World { } options.Targets = targets options.Graph = branchReadStore{GraphStore: graph, targets: targets} - options.Branches = sdd.BranchValidatorFunc(func(context.Context, sdd.MutationTarget) error { return nil }) + options.Branches = targets } runtime, err := sdd.NewProjectRuntime(options) if err != nil { @@ -293,7 +331,7 @@ func NewWorld(t *testing.T, opts ...Option) *World { if err != nil { t.Fatal(err) } - return &World{App: application, Identity: sdd.RequestIdentity{Subject: "tester"}, GraphDir: cfg.graphDir, LLM: script} + return &World{App: application, Identity: sdd.RequestIdentity{Subject: "tester"}, GraphDir: cfg.graphDir, LLM: script, dropped: dropped} } // Session wraps one workflow session on the world. diff --git a/pkg/application/capture.go b/pkg/application/capture.go index 23750a16..dcdae22d 100644 --- a/pkg/application/capture.go +++ b/pkg/application/capture.go @@ -21,7 +21,7 @@ func (a *Application) PreflightEntry(ctx context.Context, identity RequestIdenti if err != nil { return PreflightEntryResult{}, err } - draft.Target, err = resolveMutationTarget(runtime, draft.Target) + draft.Target, err = resolveMutationTarget(ctx, runtime, draft.Target) if err != nil { return PreflightEntryResult{}, err } @@ -66,7 +66,7 @@ func (a *Application) entryPublicationExists(ctx context.Context, identity Reque if err != nil { return false, err } - target, err = resolveMutationTarget(runtime, target) + target, err = resolveMutationTarget(ctx, runtime, target) if err != nil { return false, err } @@ -112,7 +112,7 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, if _, err := model.IDToRelPath(draft.EntryID); err != nil { return result, fmt.Errorf("recorded entry ID: %w", err) } - draft.Target, err = resolveMutationTarget(runtime, draft.Target) + draft.Target, err = resolveMutationTarget(ctx, runtime, draft.Target) if err != nil { return result, err } diff --git a/pkg/application/document_publication.go b/pkg/application/document_publication.go index 9cc57892..053e89b1 100644 --- a/pkg/application/document_publication.go +++ b/pkg/application/document_publication.go @@ -26,7 +26,7 @@ func (a *Application) readDocument(ctx context.Context, identity RequestIdentity if err != nil { return DocumentPublication{}, err } - target, err = resolveMutationTarget(runtime, target) + target, err = resolveMutationTarget(ctx, runtime, target) if err != nil { return DocumentPublication{}, err } @@ -49,7 +49,7 @@ func (a *Application) lookupDocumentPublication(ctx context.Context, identity Re if err != nil { return DocumentPublication{}, false, err } - target, err = resolveMutationTarget(runtime, target) + target, err = resolveMutationTarget(ctx, runtime, target) if err != nil { return DocumentPublication{}, false, err } @@ -92,7 +92,7 @@ func (a *Application) PublishDocument(ctx context.Context, identity RequestIdent if write.Mutation.LogicalPath == "" || write.Mutation.LogicalPath != filepath.ToSlash(write.Mutation.LogicalPath) || strings.HasPrefix(write.Mutation.LogicalPath, "/") || strings.Contains(write.Mutation.LogicalPath, "..") { return DocumentPublication{}, fmt.Errorf("invalid document path %q", write.Mutation.LogicalPath) } - target, err := resolveMutationTarget(runtime, write.Target) + target, err := resolveMutationTarget(ctx, runtime, write.Target) if err != nil { return DocumentPublication{}, err } diff --git a/pkg/application/runtime.go b/pkg/application/runtime.go index 35a0722b..33528f29 100644 --- a/pkg/application/runtime.go +++ b/pkg/application/runtime.go @@ -24,6 +24,9 @@ type ProjectRuntimeOptions struct { Graph GraphStore Targets TargetAcquirer Branches BranchValidator + // Base derives an unbound session's base branch; nil means the + // configured default. + Base BaseBranchResolver // Embedder and LLM are the two model dependencies, each a pkg/llm port // injected as an instance that arrives already composed — observed, // bounded, and rate-limited by the host's decorators. Routing, deadlines, @@ -79,6 +82,28 @@ func (r *ProjectRuntime) defaultMutationTarget() (MutationTarget, error) { return target, nil } +// baseTarget resolves the target an unbound write goes to: the branch the +// composition derives, else the configured default. +func (r *ProjectRuntime) baseTarget(ctx context.Context) (MutationTarget, BaseSource, error) { + if r.options.Base == nil { + target, err := r.defaultMutationTarget() + return target, BaseFromDefault, err + } + branch, err := r.options.Base.BaseBranch(ctx) + if err != nil { + return MutationTarget{}, "", fmt.Errorf("sdd: deriving the base branch of project %s: %w", r.options.Project.ID, err) + } + if branch == "" { + target, err := r.defaultMutationTarget() + return target, BaseWithoutBranch, err + } + target := MutationTarget{Project: r.options.Project.ID, Branch: branch} + if err := target.Validate(r.options.Project.ID); err != nil { + return MutationTarget{}, "", err + } + return target, BaseFromCheckout, nil +} + func (r *ProjectRuntime) acquire(ctx context.Context, target MutationTarget) (*AcquiredTarget, error) { if err := target.Validate(r.options.Project.ID); err != nil { return nil, markTargetAcquisitionError(target, err) diff --git a/pkg/application/target.go b/pkg/application/target.go index 1967dc6a..f06b9e70 100644 --- a/pkg/application/target.go +++ b/pkg/application/target.go @@ -72,6 +72,23 @@ func (f BranchValidatorFunc) ValidateBranch(ctx context.Context, target Mutation return f(ctx, target) } +// BaseBranchResolver names the branch a composition derives as an unbound +// session's base (20260923-233057-d-cpt-ekd). A local composition answers the +// branch its serving checkout has checked out at the moment of the call; an +// empty answer means it has none, and the configured default branch applies. A composition without one uses the configured default. +type BaseBranchResolver interface { + BaseBranch(context.Context) (string, error) +} + +// BaseSource says where an unbound session's base branch came from. +type BaseSource string + +const ( + BaseFromCheckout BaseSource = "the serving checkout's branch" + BaseWithoutBranch BaseSource = "configured default; the serving checkout has no branch" + BaseFromDefault BaseSource = "configured default" +) + // targetAcquisitionError marks failures from the one shared target-acquisition // boundary without changing their public message or typed cause. Workflow // routing uses the marker to add session-binding provenance only when the @@ -107,7 +124,19 @@ func withSessionBindingTargetError(branch string, fromBinding bool, err error) e if !errors.As(err, &acquisition) || acquisition.target.Branch != branch { return err } - return fmt.Errorf("session is bound to branch %q and acquiring that branch failed; if the binding is stale, re-declare the binding or clear it: %w", branch, err) + return fmt.Errorf("session is bound to branch %q and acquiring that branch failed; check that branch out and retry, or, if the binding is stale, re-declare the binding or clear it: %w", branch, err) +} + +// withBaseTargetError names the remedies when the session's derived base +// branch cannot be acquired: a checkout of it, or a declared branch. An empty +// branch is the base as the failed acquisition resolved it. +func withBaseTargetError(branch string, err error) error { + var acquisition *targetAcquisitionError + if err == nil || !errors.As(err, &acquisition) || (branch != "" && acquisition.target.Branch != branch) { + return err + } + branch = acquisition.target.Branch + return fmt.Errorf("the session's base branch %q has no usable checkout; check that branch out and retry, or declare the branch you work on through the session branch-binding capability, then cancel and redo the write: %w", branch, err) } // FixedTargetAcquirer is a small composition adapter for stores whose one diff --git a/pkg/application/workflow.go b/pkg/application/workflow.go index 611b0772..5a4518ea 100644 --- a/pkg/application/workflow.go +++ b/pkg/application/workflow.go @@ -914,6 +914,11 @@ func (w *WorkflowSession) Framing(ctx context.Context, identity RequestIdentity) fmt.Fprintf(&infoBlock, "Language: %s\n", info.Language) } fmt.Fprintf(&infoBlock, "Search: %s", info.Search) + base, err := w.baseLine() + if err != nil { + return nil, err + } + infoBlock.WriteString(base) if w.branch != "" { fmt.Fprintf(&infoBlock, "\nBranch binding: %s", w.branch) } @@ -932,6 +937,31 @@ func (w *WorkflowSession) Framing(ctx context.Context, identity RequestIdentity) return blocks, nil } +// baseLine names the session's base branch and where it came from, re-derived +// on every framing so a changed base re-serves (20260923-233057-d-cpt-ekd). +// A base without a checkout says so, since every unbound write to it fails. +func (w *WorkflowSession) baseLine() (string, error) { + runtime, err := w.targetRuntime(w.project, AccessRead) + if err != nil { + return "", err + } + if runtime.options.Base == nil && strings.TrimSpace(runtime.options.DefaultBranch) == "" { + // A read-only composition has no branch to name. + return "", nil + } + target, source, err := runtime.baseTarget(w.ctx) + if err != nil { + return "", err + } + line := fmt.Sprintf("\nBase branch: %s (%s)", target.Branch, source) + if source != BaseFromCheckout && runtime.options.Branches != nil { + if err := runtime.options.Branches.ValidateBranch(w.ctx, target); err != nil { + line += fmt.Sprintf("\nNo checkout has the base branch %s: declare the branch you work on before writing.", target.Branch) + } + } + return line, nil +} + // graphHealthBlock renders a compact framing notice of graph-integrity // problems — entry warnings and unreadable (parse-failed) entries — so an // agent opening a session notices that the graph carries warnings. It is @@ -1202,6 +1232,21 @@ func (w *WorkflowSession) ensureShell() error { func (w *WorkflowSession) loadProcedure(canonical string) (*engine.Spec, error) { graph, err := w.graphs.Current() + if err != nil && w.branch != "" { + // A stale binding must not lock the session out: replay resolves the + // procedures the session ran, and clearing or re-declaring the binding + // needs that replay. When the bound branch has no checkout, the specs + // resolve from the session's base, what it reads once the binding is + // cleared; replay's entry-identity check still refuses a procedure + // that changed underneath the session. + var acquisition *targetAcquisitionError + if errors.As(err, &acquisition) && acquisition.target.Branch == w.branch { + var view *materializedGraphView + if view, err = w.graphs.targetView(MutationTarget{Project: w.project}, false); err == nil { + graph = view.snapshot.graph + } + } + } if err != nil { return nil, fmt.Errorf("loading graph: %w", err) } @@ -1333,8 +1378,18 @@ func (g *workflowGraphs) CurrentFor(store *engine.Store) (*model.Graph, error) { } func (g *workflowGraphs) viewFor(store *engine.Store) (*materializedGraphView, error) { - target, fromBinding := g.workflow.effectiveTarget(store) - return g.targetView(target, fromBinding) + target, source := g.workflow.effectiveTargetSource(g.workflow.projectFor(store), store) + view, err := g.targetView(target, source == targetSourceBinding) + if err != nil && source != "" && source != targetSourceBinding { + // The acquisition boundary speaks of a mutation target; a graph read + // that a state field sent to an unavailable branch names the read and + // the field (20260914-180822-d-cpt-9kv). + var acquisition *targetAcquisitionError + if errors.As(err, &acquisition) && acquisition.target.Branch == target.Branch { + return nil, fmt.Errorf("reading the graph on branch %q, chosen by the %s state field, failed: %w", target.Branch, source, err) + } + } + return view, err } func (g *workflowGraphs) targetView(target MutationTarget, fromBinding bool) (*materializedGraphView, error) { diff --git a/pkg/application/workflow_branch_target_integration_test.go b/pkg/application/workflow_branch_target_integration_test.go index 6e3b03a0..797b139e 100644 --- a/pkg/application/workflow_branch_target_integration_test.go +++ b/pkg/application/workflow_branch_target_integration_test.go @@ -30,7 +30,7 @@ func (a workflowBranchTargets) Acquire(_ context.Context, target sdd.MutationTar }, nil } -func TestWorkflowBranchTargetCarriesCaptureReadsThroughImplementationLanding(t *testing.T) { +func TestWorkflowBranchTargetCarriesCaptureReadsThroughImplementationClose(t *testing.T) { const anchorID = "20260717-121000-s-tac-anc" baseDir := t.TempDir() workDir := t.TempDir() @@ -132,14 +132,15 @@ Branch-targeted workflow reads need to follow the written artifact. t.Fatal(err) } implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{ - "contract": "target-aware reads through landing", "widenReport": "anchor inspected", + "contract": "target-aware reads through the closing done", "widenReport": "anchor inspected", }) - implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{"baseBranch": "main"}) implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{ "chooser": "setup", "choice": "worktree", "userWords": "use a worktree", - "fields": map[string]any{"wipDescription": "target-aware workflow reads", "worktreeMode": "worktree"}, + "fields": map[string]any{"wipDescription": "target-aware workflow reads"}, }) - implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{"workBranch": "explicit"}) + if implementation.Step != "work" { + t.Fatalf("implementation step = %q, want work right after setup (no work-branch report)", implementation.Step) + } implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{ "chooser": "work", "choice": "conclude", "userWords": "implementation complete", }) @@ -166,8 +167,10 @@ Branch-targeted workflow reads need to follow the written artifact. if captureServe.Step != "playback" { t.Fatalf("capture step = %q, want playback", captureServe.Step) } - if statement := playbackTargetStatement(t, captureServe); !strings.Contains(statement, "explicit") || !strings.Contains(statement, "captureBranch") { - t.Fatalf("playback target statement = %q, want the explicitly seeded work branch", statement) + // The done follows the session binding — the branch the work is on — + // because the implementation run seeds no branch (20260923-230855-d-cpt-34w). + if statement := playbackTargetStatement(t, captureServe); !strings.Contains(statement, "session branch binding") || strings.Contains(statement, "captureBranch") { + t.Fatalf("playback target statement = %q, want the session binding, not a seeded branch", statement) } captureServe = advanceWorkflow(t, workflow, identity, capture, map[string]any{ "chooser": "playback", "choice": "confirm", "userWords": "confirm", @@ -199,24 +202,27 @@ Branch-targeted workflow reads need to follow the written artifact. t.Fatalf("capture produced = %+v", captureServe.Produced) } implementation = advanceWorkflow(t, workflow, identity, implementation.Instance, map[string]any{"doneEntry": doneEntry}) - if implementation.Step != "landing" { - t.Fatalf("work-branch done should reach landing before merge, got %q", implementation.Step) + if implementation.Step != "closeout" { + t.Fatalf("work-branch done should reach closeout with no landing report, got %q", implementation.Step) } donePath, err := model.IDToRelPath(doneEntry) if err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(explicitDir, donePath)); err != nil { - t.Fatalf("explicit capture-branch done file: %v", err) + if _, err := os.Stat(filepath.Join(workDir, donePath)); err != nil { + t.Fatalf("session-bound work branch done file: %v", err) } if _, err := os.Stat(filepath.Join(baseDir, donePath)); !os.IsNotExist(err) { t.Fatalf("base branch unexpectedly contains done file: %v", err) } - if _, err := os.Stat(filepath.Join(workDir, donePath)); !os.IsNotExist(err) { - t.Fatalf("session-bound branch unexpectedly contains explicit capture file: %v", err) + if _, err := os.Stat(filepath.Join(explicitDir, donePath)); !os.IsNotExist(err) { + t.Fatalf("unbound branch unexpectedly contains the done file: %v", err) } - if got := workflowBranchFileCount(t, filepath.Join(baseDir, "wip")); got != 1 { - t.Fatalf("base WIP markers = %d, want 1", got) + // The session was bound to the work branch at setup, so the marker was + // written there and removed there right after the done + // (20260923-230855-d-cpt-34w); base never held it. + if got := workflowBranchFileCount(t, filepath.Join(baseDir, "wip")); got != 0 { + t.Fatalf("base WIP markers = %d, want 0", got) } if got := workflowBranchFileCount(t, filepath.Join(workDir, "wip")); got != 0 { t.Fatalf("work WIP markers = %d, want 0", got) diff --git a/pkg/application/workflow_capture.go b/pkg/application/workflow_capture.go index 87d0719e..7425c0d9 100644 --- a/pkg/application/workflow_capture.go +++ b/pkg/application/workflow_capture.go @@ -38,6 +38,9 @@ func (w *WorkflowSession) runWorkflowPreflight(ctx *engine.Context) error { } result, err := w.app.PreflightEntry(w.ctx, w.identity, target.Project, w.binding, draft) err = w.withSessionBindingTargetError(err, fromBinding) + if target.Branch == "" { + err = withBaseTargetError("", err) + } var validation *ValidationError if errors.As(err, &validation) { for _, warning := range validation.Warnings { @@ -78,7 +81,12 @@ func (w *WorkflowSession) prepareWorkflowNewEntry(ctx *engine.Context) (map[stri return nil, err } id := model.GenerateIDAt(entryType, draftLayer(layer), suffix, w.app.now()) - return map[string]string{"entryId": id, "project": string(w.instanceProject(ctx.Instance)), "branch": branch}, nil + project := w.instanceProject(ctx.Instance) + _, source := w.effectiveTargetSource(project, ctx.Store) + if source == "" { + source = targetSourceBase + } + return map[string]string{"entryId": id, "project": string(project), "branch": branch, "source": source}, nil } // newEntryPublicationKey is the capture's storage identity: this session, the @@ -226,7 +234,7 @@ func (w *WorkflowSession) capturePreflightState(ctx *engine.Context) (bool, bool if err != nil { return false, false, err } - target, err = runtime.defaultMutationTarget() + target, _, err = runtime.baseTarget(w.ctx) if err != nil { return false, false, err } diff --git a/pkg/application/workflow_document.go b/pkg/application/workflow_document.go index 7dfcb26d..f4a26ef3 100644 --- a/pkg/application/workflow_document.go +++ b/pkg/application/workflow_document.go @@ -99,15 +99,16 @@ func (w *WorkflowSession) prepareWorkflowWIPStart(ctx *engine.Context) (map[stri if !ok { return nil, fmt.Errorf("wipStart: anchor is not set") } - target, err := w.wipTarget(ctx) + values, err := w.wipTarget(ctx) if err != nil { return nil, err } - marker, err := w.app.WIPMarkerID(w.ctx, w.identity, target.Project) + marker, err := w.app.WIPMarkerID(w.ctx, w.identity, ProjectID(values["project"])) if err != nil { return nil, err } - return map[string]string{"markerId": marker, "anchor": anchor, "project": string(target.Project), "branch": target.Branch}, nil + values["markerId"], values["anchor"] = marker, anchor + return values, nil } func (w *WorkflowSession) runWorkflowWIPStart(ctx *engine.Context) error { @@ -119,7 +120,7 @@ func (w *WorkflowSession) runWorkflowWIPStart(ctx *engine.Context) error { MarkerID: marker, EntryID: intent.Values["anchor"], Description: description, }) if err != nil { - return err + return withTargetRemedy(intent, err) } return ctx.Store.WriteEngine("wipMarker", marker) } @@ -130,11 +131,12 @@ func (w *WorkflowSession) prepareWorkflowWIPDone(ctx *engine.Context) (map[strin if !ok { return nil, fmt.Errorf("wipDone: wipMarker is not set") } - target, err := w.wipTarget(ctx) + values, err := w.wipTarget(ctx) if err != nil { return nil, err } - return map[string]string{"markerId": marker, "project": string(target.Project), "branch": target.Branch}, nil + values["markerId"] = marker + return values, nil } func (w *WorkflowSession) runWorkflowWIPDone(ctx *engine.Context) error { @@ -166,7 +168,7 @@ func (w *WorkflowSession) removeWIPMarker(ctx *engine.Context) error { intent := ctx.Intent marker := intent.Values["markerId"] _, err := w.app.FinishWIP(w.ctx, w.identity, w.instanceProject(ctx.Instance), w.binding, intentTarget(intent), w.documentPublicationKey(intent, marker), marker) - return err + return withTargetRemedy(intent, err) } // reportWorkflowWIPEffects reports the marker as present or absent on the diff --git a/pkg/application/workflow_registry.go b/pkg/application/workflow_registry.go index 164fc611..533b16f5 100644 --- a/pkg/application/workflow_registry.go +++ b/pkg/application/workflow_registry.go @@ -287,7 +287,7 @@ func (w *WorkflowSession) registerWorkflowWrites(registry *engine.Registry) erro func (w *WorkflowSession) registerWorkflowWIP(registry *engine.Registry) error { if err := registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "wipStart", Doc: "Creates an exclusive WIP marker for the store's anchor entry on baseBranch, described by wipDescription.", Reads: []string{"anchor", "baseBranch", "wipDescription", "participants"}, Writes: []string{"wipMarker"}}, + Doc: engine.FuncDoc{Name: "wipStart", Doc: "Creates an exclusive WIP marker for the store's anchor entry on the session's current branch, described by wipDescription.", Reads: []string{"anchor", "wipDescription", "participants"}, Writes: []string{"wipMarker"}}, MutatesGraph: true, GraphIndependent: true, Prepare: w.prepareWorkflowWIPStart, @@ -297,7 +297,7 @@ func (w *WorkflowSession) registerWorkflowWIP(registry *engine.Registry) error { return err } if err := registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "wipDone", Doc: "Removes the WIP marker named by the store's wipMarker field from baseBranch.", Reads: []string{"wipMarker", "baseBranch"}, Writes: []string{"wipMarker"}}, + Doc: engine.FuncDoc{Name: "wipDone", Doc: "Removes the WIP marker named by the store's wipMarker field from the session's current branch.", Reads: []string{"wipMarker"}, Writes: []string{"wipMarker"}}, MutatesGraph: true, GraphIndependent: true, Prepare: w.prepareWorkflowWIPDone, @@ -422,7 +422,7 @@ func (w *WorkflowSession) runWorkflowNewEntry(ctx *engine.Context) error { } result, err := w.app.CreateEntry(w.ctx, w.identity, target.Project, w.binding, draft) if err != nil { - return fmt.Errorf("newEntry: %w", err) + return fmt.Errorf("newEntry: %w", withTargetRemedy(ctx.Intent, err)) } w.binding = result.Binding if err := ctx.Store.WriteEngine("entryId", result.EntryID); err != nil { @@ -432,27 +432,35 @@ func (w *WorkflowSession) runWorkflowNewEntry(ctx *engine.Context) error { return w.session.SinkErr() } -// wipTarget is the WIP marker's authority: the instance's project on the -// explicit baseBranch its state names. -func (w *WorkflowSession) wipTarget(ctx *engine.Context) (MutationTarget, error) { - branch, _ := workflowStoreString(ctx.Store, "baseBranch") - if branch == "" { - return MutationTarget{}, fmt.Errorf("WIP write requires an explicit baseBranch") +// wipTarget is the WIP marker's authority: the session's current branch, +// resolved to a concrete branch the intent records with its provenance +// (20260923-230855-d-cpt-34w). +func (w *WorkflowSession) wipTarget(ctx *engine.Context) (map[string]string, error) { + target, source, err := w.concreteEffectiveTarget(ctx.Store) + if err != nil { + return nil, err } - target := MutationTarget{Project: w.instanceProject(ctx.Instance), Branch: branch} if err := w.authorizeTarget(target.Project, AccessWrite); err != nil { - return MutationTarget{}, err + return nil, err } - return target, nil + return map[string]string{"project": string(target.Project), "branch": target.Branch, "source": source}, nil } // workflowBranchFields is the application-owned registry of procedure state // fields carrying branch authority, in precedence order: capture state names -// captureBranch, published entries use resolvedCaptureBranch, implementation -// state names workBranch. A procedure that introduces another branch-bearing -// field must register it here, or its reads and writes silently fall back to -// the session binding. -var workflowBranchFields = [...]string{"captureBranch", "resolvedCaptureBranch", "workBranch"} +// captureBranch, published entries use resolvedCaptureBranch. The +// implementation procedure holds none (20260923-230855-d-cpt-34w). A procedure +// that introduces another branch-bearing field must register it here, or its +// reads and writes silently fall back to the session binding. +var workflowBranchFields = [...]string{"captureBranch", "resolvedCaptureBranch"} + +// targetSourceBinding names the durable session binding as the branch source +// in a target provenance, targetSourceBase the session's derived base; a state +// field names itself; empty means the branch is not yet resolved. +const ( + targetSourceBinding = "binding" + targetSourceBase = "base" +) // effectiveTarget is the sole target precedence rule, shared by graph reads // and graph writes: the project is the instance's (d-cpt-yjc); the branch is @@ -466,6 +474,15 @@ func (w *WorkflowSession) effectiveTarget(store *engine.Store) (MutationTarget, } func (w *WorkflowSession) effectiveTargetFor(project ProjectID, store *engine.Store) (MutationTarget, bool) { + target, source := w.effectiveTargetSource(project, store) + return target, source == targetSourceBinding +} + +// effectiveTargetSource is effectiveTargetFor with the branch's provenance: +// the state field that chose it, targetSourceBinding for the session binding, +// or empty for the configured default. A failed read reports it, so an agent +// sees which field sent the read to a branch that has no checkout. +func (w *WorkflowSession) effectiveTargetSource(project ProjectID, store *engine.Store) (MutationTarget, string) { entryID, _ := workflowStoreString(store, "entryId") for _, field := range workflowBranchFields { // A preflight result does not pin the branch before publication. @@ -473,33 +490,46 @@ func (w *WorkflowSession) effectiveTargetFor(project ProjectID, store *engine.St continue } if branch, _ := workflowStoreString(store, field); branch != "" { - return MutationTarget{Project: project, Branch: branch}, false + return MutationTarget{Project: project, Branch: branch}, field } } if w.branch != "" && project == w.project { - return MutationTarget{Project: project, Branch: w.branch}, true + return MutationTarget{Project: project, Branch: w.branch}, targetSourceBinding } - return MutationTarget{Project: project}, false + return MutationTarget{Project: project}, "" } -// concreteEffectiveTarget resolves the configured default without mutating -// procedure state. The write gate records that default as an engine-owned -// resolvedCaptureBranch only after CreateEntry reports that an artifact was -// actually written. -func (w *WorkflowSession) concreteEffectiveTarget(store *engine.Store) (MutationTarget, bool, bool, error) { - target, fromBinding := w.effectiveTarget(store) +// concreteEffectiveTarget resolves an unbound target to the session's base +// without mutating procedure state, returning the branch's provenance as +// effectiveTargetSource names it, or targetSourceBase. +func (w *WorkflowSession) concreteEffectiveTarget(store *engine.Store) (MutationTarget, string, error) { + project := w.projectFor(store) + target, source := w.effectiveTargetSource(project, store) if target.Branch != "" { - return target, fromBinding, false, nil + return target, source, nil } runtime, err := w.targetRuntime(target.Project, AccessRead) if err != nil { - return MutationTarget{}, false, false, err + return MutationTarget{}, "", err } - target, err = runtime.defaultMutationTarget() + target, _, err = runtime.baseTarget(w.ctx) if err != nil { - return MutationTarget{}, false, false, err + return MutationTarget{}, "", err } - return target, false, true, nil + return target, targetSourceBase, nil +} + +// withTargetRemedy names the remedy when a recorded write's branch cannot be +// acquired, by the provenance the intent recorded (20260923-233057-d-cpt-ekd). +func withTargetRemedy(intent *engine.MutationIntent, err error) error { + branch := intent.Values["branch"] + switch intent.Values["source"] { + case targetSourceBinding: + return withSessionBindingTargetError(branch, true, err) + case targetSourceBase: + return withBaseTargetError(branch, err) + } + return err } func (w *WorkflowSession) withSessionBindingTargetError(err error, fromBinding bool) error { diff --git a/pkg/application/workflow_target_graph_internal_test.go b/pkg/application/workflow_target_graph_internal_test.go index 522af4c6..0cdb167b 100644 --- a/pkg/application/workflow_target_graph_internal_test.go +++ b/pkg/application/workflow_target_graph_internal_test.go @@ -218,12 +218,15 @@ func TestWorkflowEffectiveTargetPrecedenceIsSharedByReadsAndWrites(t *testing.T) {field: "captureBranch", wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, {field: "resolvedCaptureBranch", wantWrite: "main", wantEntry: currentID}, {field: "resolvedCaptureBranch", published: true, wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, - {field: "workBranch", wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, + // workBranch left procedure state (20260914-180822-d-cpt-9kv): a + // stale value in an older session's store no longer outranks the + // binding. + {field: "workBranch", wantWrite: "main", wantEntry: currentID}, {binding: "work", wantRead: "work", wantWrite: "work", wantEntry: workID}, {binding: "work", field: "captureBranch", wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, {binding: "work", field: "resolvedCaptureBranch", wantRead: "work", wantWrite: "work", wantEntry: workID}, {binding: "work", field: "resolvedCaptureBranch", published: true, wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, - {binding: "work", field: "workBranch", wantRead: "explicit", wantWrite: "explicit", wantEntry: explicitID}, + {binding: "work", field: "workBranch", wantRead: "work", wantWrite: "work", wantEntry: workID}, } for _, tt := range tests { name := fmt.Sprintf("binding=%q field=%q published=%v", tt.binding, tt.field, tt.published) @@ -258,18 +261,18 @@ func TestWorkflowEffectiveTargetPrecedenceIsSharedByReadsAndWrites(t *testing.T) if read != wantRead { t.Fatalf("read target = %+v, want %+v", read, wantRead) } - write, writeFromBinding, resolvedDefault, err := workflow.concreteEffectiveTarget(store) + write, source, err := workflow.concreteEffectiveTarget(store) if err != nil { t.Fatal(err) } if write.Branch != tt.wantWrite || write.Project != "example" { t.Fatalf("write target = %+v, want branch %q", write, tt.wantWrite) } - if writeFromBinding != fromBinding { - t.Fatalf("binding provenance differs: read=%v write=%v", fromBinding, writeFromBinding) + if (source == targetSourceBinding) != fromBinding { + t.Fatalf("binding provenance differs: read=%v write source=%q", fromBinding, source) } - if resolvedDefault != (read.Branch == "") { - t.Fatalf("resolvedDefault = %v for read target %+v", resolvedDefault, read) + if (source == targetSourceBase) != (read.Branch == "") { + t.Fatalf("write source = %q for read target %+v", source, read) } if read.Branch != "" && read != write { t.Fatalf("read target %+v and write target %+v disagree", read, write) @@ -368,14 +371,29 @@ func TestWorkflowSessionBindingDriftProvenanceOnlyForBindingTargets(t *testing.T t.Fatalf("binding drift overclaimed checkout state: %v", err) } - for _, field := range []string{"captureBranch", "workBranch"} { - _, explicitErr := (&workflowGraphs{workflow: workflow}).CurrentFor(workflowTargetStore(t, map[string]any{field: "drifted"})) - if explicitErr == nil { - t.Fatalf("%s drift unexpectedly succeeded", field) - } - if strings.Contains(explicitErr.Error(), "session is bound") { - t.Fatalf("%s drift was mislabeled as session binding: %v", field, explicitErr) - } + // A state field that sends a read to an unavailable branch is named with + // the read (20260914-180822-d-cpt-9kv); it is never labeled as the binding. + unbound := &WorkflowSession{ + app: workflow.app, project: "example", identity: workflow.identity, ctx: t.Context(), + } + _, explicitErr := (&workflowGraphs{workflow: unbound}).CurrentFor(workflowTargetStore(t, map[string]any{"captureBranch": "drifted"})) + if explicitErr == nil { + t.Fatal("captureBranch drift unexpectedly succeeded") + } + if strings.Contains(explicitErr.Error(), "session is bound") { + t.Fatalf("captureBranch drift was mislabeled as session binding: %v", explicitErr) + } + if !strings.Contains(explicitErr.Error(), `reading the graph on branch "drifted", chosen by the captureBranch state field`) { + t.Fatalf("captureBranch drift did not name the read and the field: %v", explicitErr) + } + if !errors.Is(explicitErr, driftCause) { + t.Fatalf("captureBranch drift did not preserve original cause: %v", explicitErr) + } + // workBranch left procedure state: a stale value in an old session's store + // is ignored, so the read follows the binding and reports it as such. + _, staleErr := (&workflowGraphs{workflow: workflow}).CurrentFor(workflowTargetStore(t, map[string]any{"workBranch": "elsewhere"})) + if staleErr == nil || !strings.Contains(staleErr.Error(), `session is bound to branch "drifted"`) { + t.Fatalf("stale workBranch did not fall through to the binding: %v", staleErr) } for name, readErr := range map[string]error{ @@ -418,28 +436,55 @@ func TestWorkflowSessionBindingDriftProvenanceOnlyForBindingTargets(t *testing.T } } -func TestWorkflowWIPRequiresExplicitBaseBranchBeforeCallingApplication(t *testing.T) { - workflow := &WorkflowSession{} - registry := engine.NewRegistry() - if err := workflow.registerWorkflowWIP(registry); err != nil { - t.Fatal(err) - } - tests := []struct { - command string - values map[string]any +// TestWorkflowWIPTargetsTheSessionsCurrentBranch: marker writes go to the +// session's current branch, resolved to a concrete branch the intent records +// (20260923-230855-d-cpt-34w). +func TestWorkflowWIPTargetsTheSessionsCurrentBranch(t *testing.T) { + runtime := &ProjectRuntime{options: ProjectRuntimeOptions{ + Project: ProjectRef{ID: "example"}, DefaultBranch: "main", + Graph: workflowTargetGraphStore{snapshot: workflowTargetSnapshot(t, "main-r1", nil)}, + }} + app := &Application{access: workflowTargetAccess{runtime: runtime}} + for _, tt := range []struct { + binding string + wantBranch string }{ - {command: "wipStart", values: map[string]any{"anchor": "20260717-120000-s-tac-wrk"}}, - {command: "wipDone", values: map[string]any{"wipMarker": "20260717-120000-christopher"}}, - } - for _, tt := range tests { - t.Run(tt.command, func(t *testing.T) { - command, ok := registry.Command(tt.command) - if !ok { - t.Fatalf("%s command is not registered", tt.command) + {wantBranch: "main"}, + {binding: "work", wantBranch: "work"}, + } { + t.Run(fmt.Sprintf("binding=%q", tt.binding), func(t *testing.T) { + workflow := &WorkflowSession{ + app: app, project: "example", identity: RequestIdentity{Subject: "christopher"}, ctx: t.Context(), + branch: tt.binding, + } + registry := engine.NewRegistry() + if err := workflow.registerWorkflowWIP(registry); err != nil { + t.Fatal(err) } - _, err := command.Prepare(&engine.Context{Store: workflowTargetStore(t, tt.values)}) - if err == nil || err.Error() != "WIP write requires an explicit baseBranch" { - t.Fatalf("%s error = %v", tt.command, err) + for command, values := range map[string]map[string]any{ + "wipStart": {"anchor": "20260717-120000-s-tac-wrk"}, + "wipDone": {"wipMarker": "20260717-120000-christopher"}, + } { + registered, _ := registry.Command(command) + store := workflowTargetStore(t, nil) + for name, value := range values { + if name == "wipMarker" { + if err := store.WriteEngine(name, value); err != nil { + t.Fatal(err) + } + continue + } + if _, err := store.WriteState(map[string]any{name: value}); err != nil { + t.Fatal(err) + } + } + recorded, err := registered.Prepare(&engine.Context{Store: store}) + if err != nil { + t.Fatalf("%s: %v", command, err) + } + if recorded["branch"] != tt.wantBranch || recorded["project"] != "example" { + t.Fatalf("%s recorded %v, want branch %q", command, recorded, tt.wantBranch) + } } }) } diff --git a/pkg/application/write_api.go b/pkg/application/write_api.go index 3e16fd95..2e517c88 100644 --- a/pkg/application/write_api.go +++ b/pkg/application/write_api.go @@ -147,9 +147,9 @@ func (a *Application) OpenStagedBlob(ctx context.Context, identity RequestIdenti } // resolveMutationTarget completes a target against the runtime it is written -// through: an empty branch means the runtime's configured default, and a named -// project must be the runtime's own. -func resolveMutationTarget(runtime *ProjectRuntime, requested MutationTarget) (MutationTarget, error) { +// through: an empty branch means the runtime's base, and a named project must +// be the runtime's own. +func resolveMutationTarget(ctx context.Context, runtime *ProjectRuntime, requested MutationTarget) (MutationTarget, error) { if requested.Project == "" { requested.Project = runtime.options.Project.ID } @@ -157,7 +157,8 @@ func resolveMutationTarget(runtime *ProjectRuntime, requested MutationTarget) (M if requested.Project != runtime.options.Project.ID { return MutationTarget{}, &ApplicationError{Code: ErrorWriteDenied, Message: "mutation target project must equal the session project"} } - return runtime.defaultMutationTarget() + target, _, err := runtime.baseTarget(ctx) + return target, err } if err := requested.Validate(runtime.options.Project.ID); err != nil { return MutationTarget{}, err diff --git a/pkg/local/branch_read_store.go b/pkg/local/branch_read_store.go new file mode 100644 index 00000000..841e107a --- /dev/null +++ b/pkg/local/branch_read_store.go @@ -0,0 +1,42 @@ +package local + +import ( + "context" + "fmt" + + app "github.com/networkteam/sdd/pkg/application" +) + +// BranchReadStore is the local read source of a served checkout. A read of a +// named branch goes through that branch's registered checkout; an unbound +// read, the session's derived base, reads the serving checkout's own graph. +// When the serving checkout is detached the base is the default branch, read +// through its checkout (20260923-233057-d-cpt-ekd); with no checkout of it +// either, the unbound read stays on the serving checkout so the session still +// opens and its framing can say so, while every unbound write fails. +type BranchReadStore struct { + // GraphStore is the serving checkout's graph; it must support snapshot reads. + app.GraphStore + Branches *GitWorktreeAcquirer + DefaultBranch string +} + +func (s BranchReadStore) AcquireSnapshot(ctx context.Context, q app.SnapshotReadQuery) (*app.AcquiredSnapshot, error) { + if q.Branch == "" { + base, err := s.Branches.BaseBranch(ctx) + if err != nil { + return nil, err + } + if base == "" && s.Branches.ValidateBranch(ctx, app.MutationTarget{Project: s.Branches.Project(), Branch: s.DefaultBranch}) == nil { + q.Branch = s.DefaultBranch + } + } + if q.Branch != "" { + return s.Branches.AcquireSnapshot(ctx, q) + } + reader, ok := s.GraphStore.(app.SnapshotReader) + if !ok { + return nil, fmt.Errorf("sdd: the serving checkout's graph store does not support snapshot reads") + } + return reader.AcquireSnapshot(ctx, q) +} diff --git a/pkg/local/git_target.go b/pkg/local/git_target.go index aad28fe8..c8adba3d 100644 --- a/pkg/local/git_target.go +++ b/pkg/local/git_target.go @@ -3,6 +3,7 @@ package local import ( "bytes" "context" + "errors" "fmt" "os/exec" "path/filepath" @@ -82,6 +83,25 @@ func (a *GitWorktreeAcquirer) AcquireSnapshot(ctx context.Context, q app.Snapsho return a.readFactory(ctx, checkout, q) } +// BaseBranch answers the branch the serving checkout has checked out at the +// moment of the call — the unbound session's base (20260923-233057-d-cpt-ekd). +// It is empty when the checkout has none: a detached HEAD, or a directory +// outside any Git repository. +func (a *GitWorktreeAcquirer) BaseBranch(ctx context.Context) (string, error) { + output, err := a.runGit(ctx, "-C", a.serverCheckout, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && (exit.ExitCode() == 1 || strings.Contains(string(output), "not a git repository")) { + return "", nil + } + return "", fmt.Errorf("sdd: reading the serving checkout's branch: %s (%w)", strings.TrimSpace(string(output)), err) + } + return strings.TrimSpace(string(output)), nil +} + +// Project is the project whose checkouts the acquirer resolves. +func (a *GitWorktreeAcquirer) Project() app.ProjectID { return a.project } + // ValidateBranch applies the live acquisition rule without opening graph // adapters or finalizers. func (a *GitWorktreeAcquirer) ValidateBranch(ctx context.Context, target app.MutationTarget) error { diff --git a/pkg/local/implementation_run_test.go b/pkg/local/implementation_run_test.go new file mode 100644 index 00000000..e26110fe --- /dev/null +++ b/pkg/local/implementation_run_test.go @@ -0,0 +1,243 @@ +package local_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + sdd "github.com/networkteam/sdd/pkg/application" + pkgllm "github.com/networkteam/sdd/pkg/llm" + "github.com/networkteam/sdd/pkg/local" +) + +const runAnchorID = "20260601-120000-d-tac-ref" + +// servedRepository is a Git repository with committed SDD configuration and an +// anchor entry on main, composed the way `sdd serve` composes its checkout. +func servedRepository(t *testing.T) (*gitRepository, *sdd.WorkflowSession, sdd.RequestIdentity) { + t.Helper() + repo := newGitRepository(t) + repo.write(".sdd/config.yaml", "repo_id: example\ngraph_dir: .sdd/graph\ndefault_branch: main\n") + repo.write(".sdd/graph/2026/06/01-120000-d-tac-ref.md", "---\ntype: decision\nkind: directive\nlayer: tactical\nintent: pending\nsummary: A directive the run implements.\n---\n\nA directive the run implements.\n") + repo.git("add", ".sdd") + repo.git("commit", "-m", "test: add the anchor") + repo.write("global-config.yaml", "") + + graph, err := local.NewFilesystemGraphStore(local.FilesystemGraphStoreOptions{Project: "example", GraphDir: filepath.Join(repo.root, ".sdd", "graph")}) + if err != nil { + t.Fatal(err) + } + targets := repo.targets() + runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ + Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", + Graph: local.BranchReadStore{GraphStore: graph, Branches: targets, DefaultBranch: "main"}, + Targets: targets, Branches: targets, Base: targets, + LLM: pkgllm.RunnerFunc(func(_ context.Context, request pkgllm.Request) (pkgllm.Result, error) { + switch request.Purpose { + case pkgllm.PurposePreflight, pkgllm.PurposeWritingGuide: + return pkgllm.Result{Text: `{"findings":[]}`}, nil + case pkgllm.PurposeSummarize: + return pkgllm.Result{Text: "The run delivered the anchor."}, nil + } + return pkgllm.Result{}, fmt.Errorf("unexpected LLM purpose %q", request.Purpose) + }), + }) + if err != nil { + t.Fatal(err) + } + sessions, err := local.NewFilesystemSessionStoreAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + blobs, err := local.NewFilesystemStagedBlobStoreAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: runtimeAccess{runtime: runtime}, Sessions: sessions, StagedBlobs: blobs}) + if err != nil { + t.Fatal(err) + } + identity := sdd.RequestIdentity{Subject: "christopher"} + workflow, _, err := application.OpenWorkflow(t.Context(), identity, "example", sdd.WorkflowOpenRequest{ClientName: "implementation-run"}) + if err != nil { + t.Fatal(err) + } + return repo, workflow, identity +} + +func advance(t *testing.T, workflow *sdd.WorkflowSession, identity sdd.RequestIdentity, instance string, report map[string]any) *sdd.WorkflowServe { + t.Helper() + serve, err := workflow.Advance(t.Context(), identity, sdd.WorkflowAdvanceRequest{Instance: instance, Report: report}) + if err != nil { + t.Fatal(err) + } + return serve +} + +func requireFramingBase(t *testing.T, workflow *sdd.WorkflowSession, identity sdd.RequestIdentity, want string) { + t.Helper() + framing, err := workflow.Framing(t.Context(), identity) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(framing[0], want) { + t.Fatalf("framing info block = %q, want %q", framing[0], want) + } +} + +// markersOn lists the WIP marker files committed on a branch. +func (r *gitRepository) markersOn(branch string) []string { + r.t.Helper() + listing := r.git("ls-tree", "--name-only", branch, ".sdd/graph/wip/") + if listing == "" { + return nil + } + return strings.Split(listing, "\n") +} + +func (r *gitRepository) hasPath(branch, path string) bool { + r.t.Helper() + return r.git("ls-tree", "--name-only", branch, path) != "" +} + +// TestImplementationRunMarkerIsGoneFromMainAfterTheMerge is the fulfilment +// test of 20260923-230855-d-cpt-34w: a tracked run on a two-branch repository +// writes its marker on main at setup, the host branches off, the closing done +// and the marker's removal land on the work branch, and the merge carries both +// to main. In branch mode the serving checkout switches branch, so the derived +// base follows it and nothing is declared (20260923-233057-d-cpt-ekd); in +// worktree mode the agent declares the worktree's branch. +func TestImplementationRunMarkerIsGoneFromMainAfterTheMerge(t *testing.T) { + for _, mode := range []string{"branch", "worktree"} { + t.Run(mode, func(t *testing.T) { + repo, workflow, identity := servedRepository(t) + requireFramingBase(t, workflow, identity, "Base branch: main (the serving checkout's branch)") + + run, err := workflow.Start(t.Context(), identity, sdd.WorkflowStartRequest{Canonical: "implementation", Params: map[string]any{"anchor": runAnchorID}}) + if err != nil { + t.Fatal(err) + } + instance := run.Instance + if err := workflow.LogRead(t.Context(), identity, "show", []string{runAnchorID}, nil); err != nil { + t.Fatal(err) + } + if run = advance(t, workflow, identity, instance, map[string]any{"contract": "deliver the anchor", "widenReport": "anchor inspected"}); run.Step != "setup" { + t.Fatalf("step after the contract = %q, want setup", run.Step) + } + run = advance(t, workflow, identity, instance, map[string]any{ + "chooser": "setup", "choice": mode, "userWords": "go", + "fields": map[string]any{"wipDescription": "deliver the anchor"}, + }) + if run.Step != "work" { + t.Fatalf("step after setup = %q, want work", run.Step) + } + if markers := repo.markersOn("main"); len(markers) != 1 { + t.Fatalf("markers on main after setup = %v, want one", markers) + } + + // Host work: branch off after the marker commit. + switch mode { + case "branch": + repo.git("switch", "-c", "feature") + requireFramingBase(t, workflow, identity, "Base branch: feature (the serving checkout's branch)") + case "worktree": + repo.git("worktree", "add", "-b", "feature", filepath.Join(canonicalTempDir(t), "feature")) + if err := workflow.BindBranch(t.Context(), identity, "feature", false); err != nil { + t.Fatal(err) + } + } + + if run = advance(t, workflow, identity, instance, map[string]any{"chooser": "work", "choice": "conclude", "userWords": "contract met"}); run.Step != "record" { + t.Fatalf("step after conclude = %q, want record", run.Step) + } + capture, err := workflow.Start(t.Context(), identity, sdd.WorkflowStartRequest{Canonical: "capture", Parent: instance}) + if err != nil { + t.Fatal(err) + } + for _, report := range []map[string]any{ + { + "body": "Delivered the anchor directive in commit abc1234.", "entryKind": "done", "layer": "tactical", + "closes": []any{runAnchorID}, "topics": []any{"implementation/engine"}, "confidence": "high", + }, + {"chooser": "playback", "choice": "confirm", "userWords": "confirm"}, + } { + if serve := advance(t, workflow, identity, capture.Instance, report); serve.PendingChooser == nil { + t.Fatalf("capture at %q serves no chooser: %+v", serve.Step, serve) + } + } + serve := advance(t, workflow, identity, capture.Instance, map[string]any{"chooser": "verifySummary", "choice": "faithful", "fields": map[string]any{"fidelityNote": "faithful"}}) + doneID, _ := serve.Produced["entryId"].(string) + if doneID == "" { + t.Fatalf("capture produced = %+v", serve.Produced) + } + run = advance(t, workflow, identity, instance, map[string]any{"doneEntry": doneID}) + if run.Step != "closeout" { + t.Fatalf("step after the done = %q, want closeout", run.Step) + } + donePath := ".sdd/graph/" + strings.Join([]string{doneID[:4], doneID[4:6], doneID[6:8] + doneID[8:]}, "/") + ".md" + if !repo.hasPath("feature", donePath) || repo.hasPath("main", donePath) { + t.Fatalf("the done %s must be committed on feature only", doneID) + } + if markers := repo.markersOn("feature"); len(markers) != 0 { + t.Fatalf("markers on feature after the done = %v, want none", markers) + } + if markers := repo.markersOn("main"); len(markers) != 1 { + t.Fatalf("main must show the work as taken until the merge, markers = %v", markers) + } + + // Host work: back on main, merge. + if mode == "branch" { + repo.git("switch", "main") + } else if err := workflow.BindBranch(t.Context(), identity, "", true); err != nil { + t.Fatal(err) + } + repo.git("merge", "--no-ff", "-m", "merge feature", "feature") + if markers := repo.markersOn("main"); len(markers) != 0 { + t.Fatalf("markers on main after the merge = %v, want none", markers) + } + if !repo.hasPath("main", donePath) { + t.Fatalf("the merge did not carry the done %s to main", doneID) + } + run = advance(t, workflow, identity, instance, map[string]any{"chooser": "closeout", "choice": "finish", "userWords": "merged"}) + if run.Status != "completed" { + t.Fatalf("run status = %q, want completed", run.Status) + } + }) + } +} + +// A detached serving checkout has no branch to derive, so the base is the +// configured default (20260923-233057-d-cpt-ekd). +func TestDetachedServingCheckoutFallsBackToTheDefaultBranch(t *testing.T) { + repo, workflow, identity := servedRepository(t) + targets := repo.targets() + if base, err := targets.BaseBranch(t.Context()); err != nil || base != "main" { + t.Fatalf("base = %q, %v; want main", base, err) + } + repo.git("switch", "--detach") + if base, err := targets.BaseBranch(t.Context()); err != nil || base != "" { + t.Fatalf("detached base = %q, %v; want none", base, err) + } + // No checkout has main any more: the framing says so and names the remedy. + requireFramingBase(t, workflow, identity, "Base branch: main (configured default; the serving checkout has no branch)") + requireFramingBase(t, workflow, identity, "No checkout has the base branch main: declare the branch you work on before writing.") + + // An unbound write fails and names both remedies. + run, err := workflow.Start(t.Context(), identity, sdd.WorkflowStartRequest{Canonical: "implementation", Params: map[string]any{"anchor": runAnchorID}}) + if err != nil { + t.Fatal(err) + } + if err := workflow.LogRead(t.Context(), identity, "show", []string{runAnchorID}, nil); err != nil { + t.Fatal(err) + } + advance(t, workflow, identity, run.Instance, map[string]any{"contract": "deliver the anchor", "widenReport": "anchor inspected"}) + failed := advance(t, workflow, identity, run.Instance, map[string]any{ + "chooser": "setup", "choice": "inPlace", "userWords": "go", + "fields": map[string]any{"wipDescription": "deliver the anchor"}, + }) + if failed.PendingOperation == nil || !strings.Contains(failed.PendingOperation.Error, `the session's base branch "main" has no usable checkout; check that branch out and retry, or declare the branch you work on`) { + t.Fatalf("unbound write on a base without a checkout = %+v, want the remedies named", failed.PendingOperation) + } +} diff --git a/pkg/local/repository_targets_test.go b/pkg/local/repository_targets_test.go index 418265d8..e2b31aa6 100644 --- a/pkg/local/repository_targets_test.go +++ b/pkg/local/repository_targets_test.go @@ -73,7 +73,7 @@ func TestRepositoryTargetPublishesCaptureThroughOneCommit(t *testing.T) { func TestRepositoryTargetsReloadConfiguration(t *testing.T) { repo := newGitRepository(t) repo.write(".sdd/config.yaml", "repo_id: committed\ndefault_branch: main\n") - targets := repo.targets("example") + targets := repo.targets() for _, tt := range []struct { name string localID string @@ -109,9 +109,9 @@ func TestRepositoryTargetsReloadConfiguration(t *testing.T) { } } -func (r *gitRepository) targets(project sdd.ProjectID) *local.GitWorktreeAcquirer { +func (r *gitRepository) targets() *local.GitWorktreeAcquirer { r.t.Helper() - targets, err := local.NewRepositoryTargets(project, r.root, filepath.Join(r.root, "global-config.yaml")) + targets, err := local.NewRepositoryTargets("example", r.root, filepath.Join(r.root, "global-config.yaml")) if err != nil { r.t.Fatal(err) } @@ -126,7 +126,7 @@ func (r *gitRepository) captureApplication(t *testing.T, runner pkgllm.Runner) ( if err != nil { t.Fatal(err) } - targets := r.targets("example") + targets := r.targets() runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: graph, Targets: targets, Branches: targets, LLM: runner, diff --git a/pkg/mcpapp/.snapshots/TestCancellationAfterLostOutcomeReportsPublishedEntry.txt b/pkg/mcpapp/.snapshots/TestCancellationAfterLostOutcomeReportsPublishedEntry.txt index 6879637a..8d6d065b 100644 --- a/pkg/mcpapp/.snapshots/TestCancellationAfterLostOutcomeReportsPublishedEntry.txt +++ b/pkg/mcpapp/.snapshots/TestCancellationAfterLostOutcomeReportsPublishedEntry.txt @@ -13,6 +13,7 @@ cancellation: branch: main entryId: 20260921-120000-s-tac-op1 project: test + source: base goal: put the choice to the user and relay their answer verbatim instance: i_2 instructions: | diff --git a/pkg/mcpapp/.snapshots/TestCancellationReturnsToPlaybackAndReportsAbsentEntry.txt b/pkg/mcpapp/.snapshots/TestCancellationReturnsToPlaybackAndReportsAbsentEntry.txt index 198c34a2..b074a0ec 100644 --- a/pkg/mcpapp/.snapshots/TestCancellationReturnsToPlaybackAndReportsAbsentEntry.txt +++ b/pkg/mcpapp/.snapshots/TestCancellationReturnsToPlaybackAndReportsAbsentEntry.txt @@ -13,6 +13,7 @@ cancellation: branch: main entryId: 20260921-120000-s-tac-op1 project: test + source: base goal: put the choice to the user and relay their answer verbatim instance: i_2 instructions: | diff --git a/pkg/mcpapp/.snapshots/TestFailedOperationServesPendingPositionWithLiveError.txt b/pkg/mcpapp/.snapshots/TestFailedOperationServesPendingPositionWithLiveError.txt index ce9f63f0..ab835b19 100644 --- a/pkg/mcpapp/.snapshots/TestFailedOperationServesPendingPositionWithLiveError.txt +++ b/pkg/mcpapp/.snapshots/TestFailedOperationServesPendingPositionWithLiveError.txt @@ -11,6 +11,7 @@ pending_operation: branch: main entryId: 20260921-120000-s-tac-op1 project: test + source: base procedure: capture project: test session: diff --git a/pkg/mcpapp/.snapshots/TestResumeServesPendingPositionWithoutLiveError.txt b/pkg/mcpapp/.snapshots/TestResumeServesPendingPositionWithoutLiveError.txt index 67ea8dcd..2e906ded 100644 --- a/pkg/mcpapp/.snapshots/TestResumeServesPendingPositionWithoutLiveError.txt +++ b/pkg/mcpapp/.snapshots/TestResumeServesPendingPositionWithoutLiveError.txt @@ -1,6 +1,7 @@ framing: | Local participant: Tester Search: text + Base branch: main (configured default) ## Working principles @@ -52,6 +53,8 @@ open_instances: Your session framing — local participant, configured language, search modes, and recent graph movement — is served alongside this orientation; read it there, not here. + **The branch you work on.** The session framing names the base branch — what the engine reads and writes while no branch is declared. When your work happens on another branch, declare it through the engine's session branch-binding capability; declare again whenever the branch you work on changes, and clear the declaration once you are back on the base. Where the base is where you work, declare nothing. + **Reference facts available before composing or guessing.** Pull the relevant fact in full first: @@ -147,6 +150,7 @@ open_instances: branch: main entryId: 20260921-120000-s-tac-op1 project: test + source: base procedure: capture project: test session: @@ -162,6 +166,7 @@ pending_operation: branch: main entryId: 20260921-120000-s-tac-op1 project: test + source: base project: test session: