diff --git a/docs/plans/2026-10-14_merge-path-for-approved-work.md b/docs/plans/2026-10-14_merge-path-for-approved-work.md new file mode 100644 index 000000000..ffe22d000 --- /dev/null +++ b/docs/plans/2026-10-14_merge-path-for-approved-work.md @@ -0,0 +1,123 @@ +# Merge path for approved work (2026-10-14) + +Filed by Sage at Mos's request after the PRD v1.1 merge. Gitea #1610. + +## A resolved merge decision lands as a gated local commit + +### Problem + +The Stack can take a task from the PM to a reviewed commit and stop +there. On 2026-10-14: + +- the coder pushed `work/vikunja-19-265` at 8ae5a149 with `push_working`. + That tool writes only `refs/heads/work/vikunja--` in the + host's working repository (`/working//.git`, + `packages/seat/src/working.mjs`); +- the reviewer approved 8ae5a149 in verdict d3f97f9d; +- Jason resolved decision f71c73c9, "squash-merge work/vikunja-19-265 into + refactor", in the CLI. + +No seat could act on that decision, so Mos routed it to Sage by hand. +Sage made the squash patch, checked that its patch-id matched the +reviewed diff, ran the full gate in a worktree, committed f7c8537b3, +pushed it and re-pinned. Jason then told the PM from the Console that +the work had merged. Every approved change would stall the same way. + +`scripts/conductor-apply.sh` doesn't cover this. It takes a headless pi +run record and a path allowlist, not a work ref, a verdict and a +decision. + +### Scope decision + +Sage, as lead, scopes this slice to the conductor. The conductor gets +one command that turns a resolved merge decision into a gated local +commit, and the checks bind that commit to what the reviewer approved +and the human decided. Pushing stays an explicit conductor act +(invariant 8). Giving a stack role landing or push authority would widen +role authority, a policy change that needs Jason. It's out of scope +here and would be its own row if Jason wants it. + +### Owner and reviewer + +- Owner: darkwing, after row 115. +- Reviewer: filbert (decision 130: not a sandbox row; it reads the + working repository and runs git on the host, but it adds nothing a + session can reach). + +### Files owned + +- `scripts/land-work.sh` (new), or a `land` verb in an existing + host-side CLI if that fits better. Record the choice and the reason. +- `scripts/test-land-work.sh` (new), plus fixtures under `tests/` or the + owning package's tests. +- `docs/TOOLS.md`: one entry. +- `docs/plans/CONDUCTOR.md`: a short "landing approved work" section. + +Nothing under `packages/bus/src`, `packages/harness` or `roles/` changes. +If the decision or verdict can't be read without a broker change, stop +and tell Sage. Don't add a verb. + +### What ships + +1. `land-work [--base refactor] [--dry-run]`. It reads, + and never writes, the decision and the verdict through the existing + read-only path the Console and `mosaic` use. It never opens + bus.sqlite for writing. It refuses with a named reason and exit 2 + unless all of these hold: + - the decision is resolved, by a human, with the option that means + merge. The decision names one work ref (`work/vikunja-

-`) and + one target branch (exactly `refactor`; `next` and `main` always + refuse); + - the latest `review.verdict` for that task is `approve` and names a + commit C; + - the work ref in the working repository is at C. A ref that has moved + since the verdict refuses `ref-moved`; + - no later decision on the same work ref withdraws or overrides it. +2. It builds the squash in a detached worktree on the current base. The + reviewed diff is `git diff $(git merge-base base C) C`. It refuses + `base-conflict` if that diff doesn't apply cleanly to the base. After + applying, it refuses `patch-drift` unless `git patch-id --stable` of + the worktree's diff against the base equals that of the reviewed + diff. If the base has moved since the review, the change must still + be byte-for-byte what was reviewed. +3. It refuses `root-file` for any added file at the repository root + (invariant 1). Today's case: 010d30cbe added + `src/parse-requirement-id.mjs` at the root. +4. It runs the full gate in that worktree: every package suite and every + `scripts/test-*.sh`, with pipefail and the output teed to a log. Any + failure refuses `gate-failed` and names the log. Flaky tests + #258/#261/#262/#263 get no special case; the conductor reruns. +5. On a pass it commits once on the base, locally: + - the subject comes from the decision title, or `--subject`; + - the body cites the decision id, verdict id, work ref, C, the base + and the patch-id, and ends with the conductor's attribution line. +6. It prints, and doesn't send, the line for the PM: + ` () is merged to as ; push pending.` + Telling the PM through the bus is a follow-up. Today a human sends it + from the Console. +7. `--dry-run` runs every check and the gate, then prints the commit it + would make. It writes no ref. +8. The worktree, its `node_modules` link and any Docker network named + for it are removed on every path, including refusals. + +### Not in this row + +- Push, merge to `next` or `main`, re-pin or restart. Those stay + explicit acts with their existing authority. The re-pin is still Mos's + window (decision 109). +- Any stack role (PM, coder, reviewer) calling this. +- Telling the PM through the bus. + +### Acceptance + +- Tests use fixture repositories and a fixture decision and verdict + source. Each refusal in item 1 and every named reason in items 2-4 is + exercised. Each test is shown to fail when the check under it is + removed: one mutant per check, listed in the packet. +- A replay of the 19/265 case against a scratch copy of the working + repository at 8ae5a149 and base 1c9a4910d. The resulting tree matches + f7c8537b3's tree, the commit is local only, and `--dry-run` writes no + ref. +- The 19/229 case (010d30cbe) refuses `root-file`. +- Full gate green. Filbert approves on #1610 naming the candidate + manifest. Sage reruns the gate at the landing target.