From 80281ddac2a20fbc651a0d4345cbba8f6de70e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 15:56:28 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20publish=20verb=20=E2=80=94=20PR-bas?= =?UTF-8?q?ed=20harvest=20to=20a=20remote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the plan's deferred 'PR-based harvest' item. harvest-step.sh publish (renderer: p + slot digit) pushes the slot branch to a configured remote — plain same-name push, never --force; non-ff rejections are surfaced, the remote is never clobbered. No new terminal state on purpose: once the forge merge lands and base updates, the next re-preview settles the slot through the existing ancestry and squash-containment detection. Tested against a local bare remote, including the non-fast-forward refusal path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LSffk4LorDDDit18ENESsj --- CHANGELOG.md | 8 +++++ CONCEPTS.md | 7 ++++ README.md | 8 +++++ bin/renderer.mjs | 38 ++++++++++++++++++++-- scripts/harvest-step.sh | 52 +++++++++++++++++++++++++++++- tests/harvest.test.mjs | 71 +++++++++++++++++++++++++++++++++++++++++ tests/renderer.test.mjs | 27 ++++++++++++++++ 7 files changed, 208 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6705bc..9224f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Notable changes to the Swarm plugin. Format follows ## [Unreleased] +### Added +- **PR-based harvest**: the new `publish` verb (`harvest-step.sh publish + `, or `p` + slot digit in the harvest pane) pushes a slot's branch + to a remote (default `origin`, `HERDR_SWARM_PUBLISH_REMOTE` to override) + so review and merge happen on the forge — plain push, never `--force`. + The forge merge is auto-detected by the next re-preview via the existing + ancestry/squash-containment checks, so no new terminal state exists. + ## [0.2.0] — 2026-08-23 ### Added diff --git a/CONCEPTS.md b/CONCEPTS.md index fa3eee0..a5c5ab4 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -54,6 +54,13 @@ at a time, with the user choosing order. Distinct from anything automatic: nothing merges without an explicit per-Slot decision, and the base ref is re-checked for drift before every merge rather than once per session. +### Publish +The PR-based alternative to a local merge: pushing a Slot's branch to a +configured remote (plain push, never force) so review and merge happen on the +forge. Publish deliberately introduces no new terminal state — once the forge +merge lands and base updates, the ordinary preview detection (ancestry or +squash containment) settles the Slot. + ### Locus Where a merge physically executes. Two cases, and the distinction is load-bearing: when the base branch is not checked out anywhere, the merge runs diff --git a/README.md b/README.md index 5f7c5a3..6b50ad9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ add your own, see below): branch-name confirmation; a snapshot ref is written first). - *Conflict or hook failure* — classified distinctly; `s` shells into the merge tree, `a` aborts the merge (`git merge --abort`), `b` backs out. + - *Publish (PR-based harvest)* — `p` then a slot digit pushes that slot's + branch to a remote (default `origin`, override with + `HERDR_SWARM_PUBLISH_REMOTE`) instead of merging locally — plain push, + never `--force`; a non-fast-forward rejection is surfaced, not overridden. + Open the pull request on your forge as usual; once its merge lands and + base updates, the next re-preview auto-detects it (ancestry for merge + commits, tree containment for squashes) and the slot proceeds to archive. + Scriptable as `harvest-step.sh publish `. - *Archive* — after merge/skip, the worktree is removed (branch kept). Recursive ignored-file inventory is byte-safe and requires the exact digest-bound, one-use approval before ignored data can be removed. The diff --git a/bin/renderer.mjs b/bin/renderer.mjs index fc780ef..e717ce5 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -665,13 +665,22 @@ export function renderHarvest(model, cols = 80) { for (const f of ph.files ?? []) lines.push(` ${sanitizeText(f)}`); lines.push(`${ESC}[2m [y]archive anyway [n]keep the worktree${ESC}[0m`); break; + case "publish-pick": + lines.push( + ` PUBLISH: push which slot's branch to the remote (plain push, never force)?`, + ); + lines.push( + ` The forge merge is auto-detected on a later re-preview once base updates.`, + ); + lines.push(`${ESC}[2m [1-9]slot [Esc]cancel${ESC}[0m`); + break; default: { // Any row still carrying a journal wedges every merge (sequencer_scan // / the merge verb's own refusal), so the escape hatch has to be // reachable from the resting phase — not only from conflict. const j = (model.rows ?? []).find((r) => r.journal); lines.push( - `${ESC}[2m 1-9:select slot (merge/prompt) r:re-preview${ + `${ESC}[2m 1-9:select slot (merge/prompt) p:publish to remote r:re-preview${ j ? ` a:abort stale merge (slot ${j.slot})` : "" } q:quit${ESC}[0m`, ); @@ -1108,6 +1117,28 @@ export class HarvestRenderer { this.paint(); } break; + case "publish-pick": + if (ch >= "1" && ch <= "9") { + const slot = Number(ch); + this.phase = { name: "list" }; + if (!this.rows.find((r) => r.slot === slot)) { + this.banner = `no slot ${slot} in this run`; + this.paint(); + break; + } + const r = await this.step("publish", [slot]); + if (r.code === 0) { + const [, remote, sha] = r.out.published?.[0] ?? []; + this.banner = `slot ${slot} published to ${remote ?? "remote"} (${String(sha ?? "").slice(0, 10)})`; + } else { + this.banner = this.lastErrLine(r); + } + await this.reload(); + } else if (ch === "b" || ch === "\x1b") { + this.phase = { name: "list" }; + this.paint(); + } + break; case "ignored": if (ch === "y" || ch === "Y") { const slot = ph.slot; @@ -1128,7 +1159,10 @@ export class HarvestRenderer { break; default: if (ch >= "1" && ch <= "9") await this.selectSlot(Number(ch)); - else if (ch === "r") { + else if (ch === "p") { + this.phase = { name: "publish-pick" }; + this.paint(); + } else if (ch === "r") { this.banner = ""; await this.reload(); } else if (ch === "a") { diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 41893ff..76db283 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -10,7 +10,8 @@ # # Verbs: preview | commit-wip | snapshot | # discard | skip | merge | -# resume [complete ] | archive | abort-merge +# resume [complete ] | archive | abort-merge | +# publish # # Output protocol: machine-readable "keyvalue…" lines on stdout, human # messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on @@ -26,6 +27,8 @@ # guard) # HERDR_SWARM_CLEANUP_APPROVAL archive: exact one-use JSON approval # emitted by the ignored inventory preview +# HERDR_SWARM_PUBLISH_REMOTE publish: remote to push the slot branch +# to (default: origin) # HERDR_SWARM_HARVEST_WT_NO_HOOKS=1 disable repo hooks in the plugin-owned # harvest worktree ONLY (hook-policy KTD: # fresh worktrees lack node_modules, so @@ -679,6 +682,49 @@ do_resume() { return 0 } +# do_publish: PR-based harvest — hand the slot's committed work to the forge +# instead of merging locally. A plain same-name push of the slot branch to the +# configured remote, NEVER --force: a rejected non-fast-forward means the +# remote branch moved under someone else's hands, which needs a human, not a +# flag. Publish mutates no local ref and touches no worktree, so it composes +# with the rest of the flow: once the forge merge lands and base is updated, +# the next preview detects it (ancestry for merge commits, tree containment +# for squashes) and the slot proceeds to archive as usual — no new terminal +# state exists on purpose. +do_publish() { + read_slot "$1" || return $? + local remote="${HERDR_SWARM_PUBLISH_REMOTE:-origin}" tip out patch + if ! git -C "$REPO_ROOT" remote get-url "$remote" >/dev/null 2>&1; then + echo "herdr-swarm: remote '$remote' is not configured in this repository — add it, or point HERDR_SWARM_PUBLISH_REMOTE at the remote to publish to." >&2 + return "$HS_EC_REFUSED" + fi + tip="$(git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/heads/$SLOT_BRANCH")" || { + echo "herdr-swarm: slot $1 has no branch to publish." >&2 + return "$HS_EC_REFUSED" + } + if [ "$tip" = "$FORK_SHA" ]; then + echo "herdr-swarm: slot $1 has no commits past the fork point — nothing to publish." >&2 + return "$HS_EC_REFUSED" + fi + # Uncommitted work never travels; say so rather than silently publishing + # half a slot (commit-WIP first to include it). + if [ -n "$SLOT_PATH" ] && [ -d "$SLOT_PATH" ] && + [ -n "$(git -C "$SLOT_PATH" status --porcelain 2>/dev/null)" ]; then + echo "herdr-swarm: note — slot $1 has uncommitted work; only committed work is published. Commit-WIP first to include it." >&2 + fi + if ! out="$(git -C "$REPO_ROOT" push "$remote" "refs/heads/$SLOT_BRANCH:refs/heads/$SLOT_BRANCH" 2>&1)"; then + printf '%s\n' "$out" >&2 + echo "herdr-swarm: publish of slot $1 to '$remote' was rejected — nothing was force-pushed; resolve the refusal above and retry." >&2 + return "$HS_EC_REFUSED" + fi + patch="$(node -e ' + const [remote, sha] = process.argv.slice(1); + process.stdout.write(JSON.stringify({ published: { remote, sha } })); + ' "$remote" "$tip")" || return 1 + manifest_update_slot "$1" "$patch" || return 1 + printf 'published\t%s\t%s\t%s\n' "$1" "$remote" "$tip" +} + do_archive() { read_slot "$1" || return $? case "$SLOT_STATUS" in @@ -909,6 +955,10 @@ abort-merge) require_slot_arg "${1-}" || exit 1 do_abort_merge "$1" ;; +publish) + require_slot_arg "${1-}" || exit 1 + do_publish "$1" + ;; *) echo "herdr-swarm: unknown harvest verb '$VERB'" >&2 exit 1 diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index e3f9b1a..b841008 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -1757,3 +1757,74 @@ test("preview never squash-detects a slot that still has unlanded commits", () = assert.match(r.stdout, /state\tclean/); assert.equal(run.slotRow(1).status, "running", "not marked merged"); }); + +// ---- Publish (PR-based harvest, deferred follow-up now shipped) ------------- +// A local bare repository stands in for the forge; publish is a plain push, +// so file:// semantics are exactly the wire semantics that matter (ff vs +// non-ff rejection). + +function addBareRemote(run) { + const bare = path.join(mkdtemp("hs-remote-"), "origin.git"); + h.git(run.repo, "init", "--bare", bare); + h.git(run.repo, "remote", "add", "origin", bare); + return bare; +} + +test("publish pushes the slot branch to the remote and records it, never with force", () => { + h.writeHerdrStub(); + const run = mkRun(); + const bare = addBareRemote(run); + const tip = commitIn(run.wt(1), "feat.txt", "publishable\n", "slot work"); + const r = step(run, "publish", [1]); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, new RegExp(`published\t1\torigin\t${tip}`)); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + tip, + "remote branch is at the slot tip", + ); + assert.deepEqual(run.slotRow(1).published, { remote: "origin", sha: tip }); + // Fast-forward re-publish after more work is fine. + const tip2 = commitIn(run.wt(1), "more.txt", "more\n", "more work"); + const r2 = step(run, "publish", [1]); + assert.equal(r2.status, 0, `${r2.stdout}\n${r2.stderr}`); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + tip2, + ); +}); + +test("publish refuses: missing remote, empty slot, and non-fast-forward — remote never clobbered", () => { + h.writeHerdrStub(); + // No remote configured at all. + const run = mkRun(); + commitIn(run.wt(1), "a.txt", "a\n", "work"); + let r = step(run, "publish", [1]); + assert.equal(r.status, EC.REFUSED); + assert.match(r.stderr, /remote 'origin' is not configured/); + // Nothing past the fork point. + const run2 = mkRun(); + addBareRemote(run2); + r = step(run2, "publish", [1]); + assert.equal(r.status, EC.REFUSED); + assert.match(r.stderr, /no commits past the fork point/); + // Non-fast-forward: remote holds history the local branch no longer has. + const run3 = mkRun(); + const bare3 = addBareRemote(run3); + const first = commitIn(run3.wt(1), "one.txt", "one\n", "first"); + assert.equal(step(run3, "publish", [1]).status, 0); + // Rewrite the slot branch: back to the fork, different commit. + h.git(run3.wt(1), "reset", "--hard", run3.fork); + commitIn(run3.wt(1), "two.txt", "two\n", "rewritten"); + r = step(run3, "publish", [1]); + assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /rejected|failed/i); + assert.equal( + h.git(run3.repo, "ls-remote", bare3, `refs/heads/${run3.branch(1)}`) + .stdout.split("\t")[0], + first, + "remote branch untouched — publish never forces", + ); +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 0f5a5e2..d32c2f7 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -757,3 +757,30 @@ test("agent-supplied state text cannot smuggle escapes into any rendered view", assert.ok(!resume.includes("\x1b]"), `escape survived the resume view:\n${resume}`); assert.ok(!resume.includes("\x07")); }); + +test("publish flow: p opens the picker, a digit routes through step('publish'), Esc cancels", async () => { + const r = mkHarvest(); + r.rows = [{ slot: 1, label: "s1", branch: "b", status: "running", preview: { state: "clean", dirty: 0 } }]; + await r.onKey("p"); + assert.equal(r.phase.name, "publish-pick"); + await r.onKey("1"); + assert.deepEqual(r.calls, [["publish", 1]], "routed through step(), not raw git"); + assert.equal(r.phase.name, "list"); + // Esc cancels without a verb. + await r.onKey("p"); + await r.onKey("\x1b"); + assert.equal(r.phase.name, "list"); + assert.deepEqual(r.calls, [["publish", 1]], "cancel runs nothing"); + // A digit with no matching slot runs nothing and says so. + await r.onKey("p"); + await r.onKey("7"); + assert.deepEqual(r.calls, [["publish", 1]]); + assert.match(r.banner, /no slot 7/); +}); + +test("publish-pick phase renders its prompt and the list footer advertises p", () => { + const model = { runInfo: { run_id: "r1", base_ref: "refs/heads/main" }, rows: [], phase: { name: "publish-pick" } }; + assert.match(renderHarvest(model, 100), /PUBLISH: push which slot/); + const list = renderHarvest({ ...model, phase: { name: "list" } }, 120); + assert.match(list, /p:publish to remote/); +}); From 53d5a5214196b37f8cc3ccc7e8dcd990f1ba51bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:04:03 +0000 Subject: [PATCH 2/3] fix(publish): require fork-point ancestry and push the audited SHA Review flagged two bounded gaps: a rewritten slot branch could publish history this run never audited, and the branch could move between the checks and the push. Publish now refuses when the recorded fork point is no longer an ancestor of the tip (ancestry, the same authority prune uses), and pushes the audited SHA itself rather than the branch name so a mid-flight commit cannot ride along. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LSffk4LorDDDit18ENESsj --- scripts/harvest-step.sh | 12 +++++++++++- tests/harvest.test.mjs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 76db283..6c2ccc4 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -706,13 +706,23 @@ do_publish() { echo "herdr-swarm: slot $1 has no commits past the fork point — nothing to publish." >&2 return "$HS_EC_REFUSED" fi + # The branch must still contain the recorded fork point: a rewritten slot + # branch (reset onto foreign history) would otherwise publish commits this + # run never audited. Same authority prune uses — ancestry, not bookkeeping. + if ! git -C "$REPO_ROOT" merge-base --is-ancestor "$FORK_SHA" "$tip"; then + echo "herdr-swarm: slot $1 branch no longer contains the recorded fork point $FORK_SHA — its history was rewritten; publish refused." >&2 + return "$HS_EC_REFUSED" + fi # Uncommitted work never travels; say so rather than silently publishing # half a slot (commit-WIP first to include it). if [ -n "$SLOT_PATH" ] && [ -d "$SLOT_PATH" ] && [ -n "$(git -C "$SLOT_PATH" status --porcelain 2>/dev/null)" ]; then echo "herdr-swarm: note — slot $1 has uncommitted work; only committed work is published. Commit-WIP first to include it." >&2 fi - if ! out="$(git -C "$REPO_ROOT" push "$remote" "refs/heads/$SLOT_BRANCH:refs/heads/$SLOT_BRANCH" 2>&1)"; then + # Push the AUDITED SHA, not the branch name: if the agent commits again + # between the checks above and the push, the remote still receives exactly + # the tip that passed them (the branch-name form would race). + if ! out="$(git -C "$REPO_ROOT" push "$remote" "$tip:refs/heads/$SLOT_BRANCH" 2>&1)"; then printf '%s\n' "$out" >&2 echo "herdr-swarm: publish of slot $1 to '$remote' was rejected — nothing was force-pushed; resolve the refusal above and retry." >&2 return "$HS_EC_REFUSED" diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index b841008..b89757b 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -1828,3 +1828,20 @@ test("publish refuses: missing remote, empty slot, and non-fast-forward — remo "remote branch untouched — publish never forces", ); }); + +test("publish refuses a slot branch whose rewritten history lost the fork point", () => { + h.writeHerdrStub(); + const run = mkRun(); + addBareRemote(run); + // Rebuild the slot branch on an orphan root: commits exist, but the + // recorded fork point is no longer in its history. + h.git(run.wt(1), "checkout", "-q", "--orphan", "rebuilt"); + fs.writeFileSync(path.join(run.wt(1), "alien.txt"), "foreign history\n"); + h.git(run.wt(1), "add", "alien.txt"); + h.git(run.wt(1), "commit", "-q", "-m", "alien root"); + h.git(run.wt(1), "branch", "-f", run.branch(1)); + h.git(run.wt(1), "checkout", "-q", run.branch(1)); + const r = step(run, "publish", [1]); + assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /no longer contains the recorded fork point/); +}); From fa51800aea6ae9fe0e735bc823570127b0249661 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:08:24 +0000 Subject: [PATCH 3/3] test(publish): prove the audited tip survives a mid-flight branch advance Review asked for coverage of the capture-to-push window. A new seam (HERDR_SWARM_TEST_PUBLISH_READY_FILE / _PAUSE_BEFORE_PUBLISH, same pattern as the other crash-window seams) holds the verb between tip validation and push while the test commits to the branch; the remote must receive exactly the audited SHA and the manifest must record it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LSffk4LorDDDit18ENESsj --- scripts/harvest-step.sh | 7 ++++++- tests/harvest.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 6c2ccc4..05088e1 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -721,7 +721,12 @@ do_publish() { fi # Push the AUDITED SHA, not the branch name: if the agent commits again # between the checks above and the push, the remote still receives exactly - # the tip that passed them (the branch-name form would race). + # the tip that passed them (the branch-name form would race). The seam + # below holds the verb in that window so the race is testable. + if [ -n "${HERDR_SWARM_TEST_PUBLISH_READY_FILE:-}" ]; then : >"$HERDR_SWARM_TEST_PUBLISH_READY_FILE"; fi + if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH:-}" ]; then + sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH" + fi if ! out="$(git -C "$REPO_ROOT" push "$remote" "$tip:refs/heads/$SLOT_BRANCH" 2>&1)"; then printf '%s\n' "$out" >&2 echo "herdr-swarm: publish of slot $1 to '$remote' was rejected — nothing was force-pushed; resolve the refusal above and retry." >&2 diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index b89757b..1303105 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -1845,3 +1845,29 @@ test("publish refuses a slot branch whose rewritten history lost the fork point" assert.equal(r.status, EC.REFUSED, `${r.stdout}\n${r.stderr}`); assert.match(r.stderr, /no longer contains the recorded fork point/); }); + +test("publish pushes the audited tip even when the branch advances mid-flight", async () => { + h.writeHerdrStub(); + const run = mkRun(); + const bare = addBareRemote(run); + const audited = commitIn(run.wt(1), "one.txt", "one\n", "audited work"); + const ready = path.join(h.stateDir, "publish-ready"); + const done = stepAsync(run, "publish", [1], { + HERDR_SWARM_TEST_PUBLISH_READY_FILE: ready, + HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH: "2", + }); + // The verb has captured and validated its tip once the ready file exists; + // advance the branch inside the capture-to-push window. + await until(() => fs.existsSync(ready)); + const racer = commitIn(run.wt(1), "two.txt", "two\n", "raced in"); + const r = await done; + assert.equal(r.code, 0, `${r.out}\n${r.err}`); + assert.notEqual(racer, audited); + assert.equal( + h.git(run.repo, "ls-remote", bare, `refs/heads/${run.branch(1)}`) + .stdout.split("\t")[0], + audited, + "the remote received exactly the audited tip, not the mid-flight commit", + ); + assert.deepEqual(run.slotRow(1).published, { remote: "origin", sha: audited }); +});