docs: merge path brief, a resolved merge decision lands as a gated local commit (#1610)

Filed at Mos's request after the PRD v1.1 merge. The slice is conductor-only:
push and any stack-role landing authority stay out of scope.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 11:55:35 -05:00
co-authored by Claude Opus 5.5
parent f820fef57a
commit 6f2404e100
@@ -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-<project>-<task>` in the
host's working repository (`<dataRoot>/working/<business>/<project>.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 <decision-id> [--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-<p>-<t>`) 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:
`<work ref> (<task>) is merged to <base> as <sha>; 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.