diff --git a/CHANGELOG.md b/CHANGELOG.md index f288549..ffae234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ Notable changes to the Swarm plugin. Format follows ## [Unreleased] +### Fixed +- Harvest now reconciles the completed slot's plugin-reported state before + automatic archive. On Herdr's pane-backed path, a successful merge could + previously leave the slot marked working and unnecessarily block cleanup. + Archive still verifies agent state and worktree contents before removal. +- Archive recognizes Herdr 0.8.2's `done` state (an unseen background idle + agent) as settled, instead of refusing cleanup until its tab is focused. + +### Added +- `npm run doctor` checks Node, Git, and the selected Herdr binary without + creating plugin state or contacting a running session; incompatible versions + include an explicit `HERDR_BIN_PATH` remedy. +- `npm run build` checks the manifest and every shell/Node source individually; + `npm run validate` combines these checks with ShellCheck and the test suite. + This avoids the first-file-only behavior of `bash -n scripts/*.sh`. + +### Documented +- The suite website is the canonical Swarm guide, and installation diagnostics + now distinguish prerequisite checks from live workflow verification. + ## [0.3.0] — 2026-08-24 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bb17b7..fa2d5ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,15 +9,22 @@ rules below exist to keep destructive paths guarded. ```sh git clone https://github.com/StructuPath/herdr-swarm herdr plugin link ./herdr-swarm # disk edits stay live -npm test +npm run validate ``` -There is no build step and there are no npm dependencies. `npm test` runs -`node --test` over `tests/`; CI additionally runs `bash -n scripts/*.sh`, +There is no compiled output and there are no npm dependencies. `npm run build` +checks the manifest and parses every shell script and Node module separately. +`npm run doctor` checks local runtime prerequisites without contacting a session. +`npm run validate` runs build checks, ShellCheck, and the tests. `npm test` runs +`node --test` over `tests/`; CI additionally runs a shell syntax command, `shellcheck -x scripts/*.sh` (default severity — the tree is fully clean; keep it that way), and `node scripts/check-manifest.mjs`, on both Linux and macOS. +For manual syntax checks, use `for script in scripts/*.sh; do bash -n "$script" || exit 1; done`. +Passing the glob directly to `bash -n` only checks the first script; later paths +become its arguments. The build command avoids this gap and checks Node syntax too. + ## Test conventions - **One harness.** `tests/harness.mjs` is the single shared harness; extend diff --git a/README.md b/README.md index 6b50ad9..2c660c8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ at a time. Agents commit locally and never push; the orchestrator merges. ![herdr-swarm demo: fan out 3 agents, harvest the winner](assets/herdr-swarm-demo.gif) -**Docs:** the [StructuPath Herdr Plugins wiki](https://github.com/StructuPath/herdr-browser/wiki) +**Docs:** the [StructuPath Herdr Suite guide](https://herdr.structupath.ai/docs/swarm/) is the practical guide to this plugin and its three siblings (Browser, Guard, Conductor). @@ -27,8 +27,9 @@ Conductor). `working` when the slot starts, `idle` once a harvest preview finds the slot finished. Nothing polls in between, so a 0.7.5 slot that finishes on its own still reads `working` until you open harvest. This is cosmetic: - committed work is harvestable regardless, and nothing in the plugin gates - on agent state. + committed work is mergeable regardless. Archive separately requires a + settled agent (`idle`, `done`, or absent) before removing its worktree; + harvest refreshes the completed slot's reported state before auto-archive. - On **0.7.5+**, `pane run` hands the slot's argv to the pane's **shell**, not to `exec` — a preset containing shell metacharacters is interpreted there, unlike on 0.7.4. Presets are your own config, but keep them to a @@ -57,6 +58,23 @@ herdr plugin link ./herdr-swarm npm test ``` +Check the installation before starting agents with `npm run doctor`. It probes +Node, Git, and the selected Herdr binary without opening a session or writing +plugin state. If multiple Herdr installations are present, use +`HERDR_BIN_PATH=/absolute/path/to/herdr npm run doctor` and pass that same +override to plugin scripts. A successful version check is not a live workflow +test; newer-than-tested versions still warn. Confirm that the selected preset's +agent command is installed and authenticated separately. + +Maintainers can run `npm run validate` for manifest/version checks, syntax checks +of **every** shell script and Node module, ShellCheck, and the real-git test +suite. ShellCheck must be installed separately. `npm run build` runs just the +manifest and syntax checks; this interpreted plugin has no compiled output or +npm dependencies. + +See [readiness and live workflow evidence](docs/readiness.md) for the tested +scope and prioritized follow-up work. + ## Quick start The plugin ships five actions (Herdr plugins cannot ship default keybindings — @@ -341,8 +359,7 @@ want a truly clean slate. Plugin logs: ## Publishing -Public and marketplace-listed: the repo carries the `herdr-plugin` topic, so it -auto-lists on the Herdr marketplace. Install with `herdr plugin install +Install from the public repository with `herdr plugin install StructuPath/herdr-swarm`, or `herdr plugin link` a local clone for dev (disk edits stay live). diff --git a/bin/renderer-harvest.mjs b/bin/renderer-harvest.mjs index f04d2c2..808be33 100644 --- a/bin/renderer-harvest.mjs +++ b/bin/renderer-harvest.mjs @@ -363,7 +363,12 @@ export class HarvestRenderer { const r = await this.step("merge", [slot, row.preview.baseSha]); if (r.code === 0) { this.banner = `slot ${slot} merged`; - await this.doArchive(slot); + // Pane-backed slots retain their plugin-reported working state until + // a terminal-state preview reconciles it. Refresh before archive so + // its unchanged settled-agent guard sees the completed harvest. + const settled = await this.step("preview", [slot]); + if (settled.code === 0) await this.doArchive(slot); + else this.banner = `slot ${slot} merged; ${this.lastErrLine(settled)}`; // Re-baseline: every remaining preview must diff and merge against // the NEW base SHA (R7: drift re-checked before every merge). await this.reload(); diff --git a/docs/readiness.md b/docs/readiness.md new file mode 100644 index 0000000..c4a7d95 --- /dev/null +++ b/docs/readiness.md @@ -0,0 +1,53 @@ +# Swarm readiness + +The plugin remains version 0.3.0; fixes on this branch are recorded under +Unreleased in the changelog. The manifest still requires Herdr >=0.7.4. + +## Repeatable validation + +Run `npm run doctor` to check the selected local Node, Git, and Herdr binaries. +Set `HERDR_BIN_PATH` explicitly when more than one Herdr installation exists. +This command neither connects to the running session nor creates plugin state. +Install and authenticate the agent commands selected by your presets separately. + +Run `npm run validate` for source/manifest checks, ShellCheck, and the real-git +test suite. Tests isolate Git configuration and stub Herdr using captured CLI +response shapes. The source-only build has no generated artifact or dependencies. + +## Live workflow evidence — 2026-09-13 + +A dedicated named Herdr 0.8.2 session and throwaway Git repository exercised: + +- Scripted fan-out of a bounded local worker into an actual Herdr worktree/pane. +- A committed contribution, harvest preview, and merge into the checked-out base. +- A second run through `HarvestRenderer.refresh()` and `doMerge()`, including + automatic archive, manifest finalization, and removal of the owned worktree. +- Prune dry-run, which listed the merged branch and deleted nothing. +- A third run aborted through the real CLI: owned panes/worktree removed, + branch kept, manifest archived, and only the original Git worktree remaining. + +The dedicated test server was stopped after verification. + +The first run exposed two cleanup defects: the renderer attempted archive before +refreshing the completed plugin-reported state, and Herdr 0.8.2 reports background +idle agents as `done`. Both now have regression coverage. Working agents remain +ineligible for archive, and ignored-file approvals and ownership checks remain +in force. + +This is bounded 0.8.2 smoke coverage, not certification of every CLI surface. +The broad compatibility baseline remains Herdr 0.7.4/0.7.5, and the existing +newer-than-tested warning is retained. No paid model agent, credentials, external +push, default user session, or production repository was used by the smoke run. + +## Recommended next work + +1. Add opt-in named-session integration coverage for released Herdr versions, + especially agent identity/state and worktree removal. Keep real-git fixture + coverage for drift, conflicts, snapshots, and interrupted-run recovery. +2. Validate each team's setup hook and agent preset against a small task before + increasing slot count. Fresh worktrees lack ignored dependencies and secrets; + setup-hook failure currently warns and still starts the agent. +3. Keep conflict resolution review-first. The resolver-agent document in + `docs/plans/` describes future work, not a shipped feature. +4. Treat `publish` as a branch push for forge review, not automatic PR creation + or merging. The orchestrator remains responsible for the final review. diff --git a/package.json b/package.json index 8fd4955..fb9274f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "type": "module", "engines": { "node": ">=20" }, "scripts": { + "build": "node scripts/check-manifest.mjs && node scripts/check-source.mjs", + "doctor": "bash scripts/doctor.sh", + "lint": "shellcheck -x scripts/*.sh", + "validate": "npm run build && npm run lint && npm test", "test": "node --test" } } diff --git a/scripts/check-source.mjs b/scripts/check-source.mjs new file mode 100755 index 0000000..ea66c69 --- /dev/null +++ b/scripts/check-source.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export function checkSources(root) { + let count = 0; + const errors = []; + for (const directory of ["scripts", "bin"]) { + for (const name of fs.readdirSync(path.join(root, directory)).sort()) { + const extension = path.extname(name); + if (![".sh", ".mjs"].includes(extension)) continue; + const file = path.join(root, directory, name); + const command = extension === ".sh" ? "bash" : process.execPath; + const flag = extension === ".sh" ? "-n" : "--check"; + const result = spawnSync(command, [flag, file], { + encoding: "utf8", + timeout: 10_000, + }); + count += 1; + if (result.status !== 0) { + errors.push(`${directory}/${name}: ${result.error?.message ?? result.stderr}`); + } + } + } + return { count, errors }; +} + +const sourcePath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === sourcePath) { + const result = checkSources(path.resolve(path.dirname(sourcePath), "..")); + for (const error of result.errors) process.stderr.write(`${error}\n`); + if (result.errors.length) process.exitCode = 1; + else process.stdout.write(`Syntax valid: ${result.count} shell scripts and Node modules.\n`); +} diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..14d8b20 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Read-only installation checks; do not resolve/create plugin state or contact +# a running Herdr session. Version probes use the same wrappers as fan-out. +set -uo pipefail +# shellcheck source=scripts/lib.sh +. "$(dirname "$0")/lib.sh" + +failed=0 +if require_node; then + if node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)'; then + printf 'OK Node %s\n' "$(node --version)" + else + echo 'FAIL Node >=20 is required.' >&2 + failed=1 + fi +else + failed=1 +fi + +if git_version="$(git -C "$(dirname "$0")" --version 2>/dev/null)"; then + printf 'OK %s\n' "$git_version" + if ! version_ge "${git_version#git version }" 2.38; then + echo 'WARN Git >=2.38 is recommended for squash-merge detection.' >&2 + fi +else + echo 'FAIL Git is not available on PATH.' >&2 + failed=1 +fi + +printf 'Herdr binary: %s\n' "$(herdr_binary_path)" +if version_gate gated; then + echo "OK Herdr fan-out version requirement (>=0.7.4; newest tested $HERDR_SWARM_MAX_TESTED)." +else + echo 'FAIL Install a supported Herdr CLI or set HERDR_BIN_PATH to its absolute path.' >&2 + failed=1 +fi + +echo 'Agent prerequisites: check your selected preset binaries and authentication before fan-out.' +echo 'This checks local prerequisites only; it does not exercise a Herdr session or start agents.' +exit "$failed" diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 05088e1..4515fad 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -755,7 +755,8 @@ do_archive() { ;; esac # Settled check: spike (a) — the herdr remove verb silently KILLS a live - # agent, so archiving is allowed only when the agent is absent or idle. + # agent, so archiving is allowed only when absent or settled. Herdr 0.8.2 + # reports unseen background idle agents as done (same underlying state). local agents st # shellcheck disable=SC2119 # wrapper takes optional args; none needed here agents="$(herdr_agent_list 2>/dev/null || true)" @@ -771,7 +772,7 @@ do_archive() { }); ' "$SLOT_TERMINAL" "$SLOT_PANE")" case "$st" in - absent | idle) ;; + absent | idle | done) ;; *) echo "herdr-swarm: slot $1 agent is '$st' — archiving would kill it (worktree removal stops live agents, spike (a)); wait for idle or stop it first." >&2 return "$HS_EC_REFUSED" diff --git a/scripts/lib.sh b/scripts/lib.sh index 28e1650..abadc85 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -124,6 +124,11 @@ require_herdr() { fi } +# Diagnostics use the same binary selection as every runtime wrapper. +herdr_binary_path() { + command -v "$HERDR" || printf '%s\n' "$HERDR" +} + # Portable timeout (macOS lacks GNU timeout): poll the child and SIGKILL it # after SECONDS (exit 137). Done in-shell (no background watchdog) so a dying # script can never orphan a sleep that holds the caller's stdout pipe open. diff --git a/tests/check-source.test.mjs b/tests/check-source.test.mjs new file mode 100644 index 0000000..b319b2f --- /dev/null +++ b/tests/check-source.test.mjs @@ -0,0 +1,20 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { mkdtemp } from "./harness.mjs"; +import { checkSources } from "../scripts/check-source.mjs"; + +test("source validation catches invalid later scripts and Node modules without executing them", () => { + const root = mkdtemp("hs-source-"); + fs.mkdirSync(path.join(root, "scripts")); + fs.mkdirSync(path.join(root, "bin")); + fs.writeFileSync(path.join(root, "scripts/a.sh"), "exit 99\n"); + fs.writeFileSync(path.join(root, "scripts/z.sh"), "if then\n"); + fs.writeFileSync(path.join(root, "bin/broken.mjs"), "const = ;\n"); + const result = checkSources(root); + assert.equal(result.count, 3); + assert.equal(result.errors.length, 2); + assert.match(result.errors.join("\n"), /scripts\/z.sh/); + assert.match(result.errors.join("\n"), /bin\/broken.mjs/); +}); diff --git a/tests/doctor.test.mjs b/tests/doctor.test.mjs new file mode 100644 index 0000000..445c995 --- /dev/null +++ b/tests/doctor.test.mjs @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { createHarness } from "./harness.mjs"; + +const h = createHarness(); +h.writeHerdrStub(); + +test("doctor accepts the tested CLI and never opens or changes session/state", () => { + const absentState = path.join(h.stateDir, "untouched"); + const result = h.runScript("doctor.sh", [], h.freshEnv({ + HERDR_PLUGIN_STATE_DIR: absentState, + STUB_HERDR_VERSION: "0.7.5", + })); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /OK Node/); + assert.equal(h.log().trim(), "herdr --version"); + assert.equal(fs.existsSync(absentState), false); +}); + +test("doctor refuses an old CLI and explains the explicit binary override", () => { + const result = h.runScript("doctor.sh", [], h.freshEnv({ STUB_HERDR_VERSION: "0.7.1" })); + assert.equal(result.status, 1); + assert.match(result.stderr, /HERDR_BIN_PATH/); + assert.match(result.stderr, /0.7.4 or newer/); +}); + +test("doctor warns for an untested newer CLI without claiming live compatibility", () => { + const result = h.runScript("doctor.sh", [], h.freshEnv({ STUB_HERDR_VERSION: "0.8.2" })); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /newer than tested/); + assert.match(result.stdout, /does not exercise a Herdr session/); +}); + +test("doctor reports a missing explicit CLI", () => { + const result = h.runScript("doctor.sh", [], h.freshEnv({ HERDR_BIN_PATH: "/does-not-exist/herdr" })); + assert.equal(result.status, 1); + assert.match(result.stderr, /cannot determine herdr version/); +}); diff --git a/tests/harvest.test.mjs b/tests/harvest.test.mjs index 1303105..de6c5cf 100644 --- a/tests/harvest.test.mjs +++ b/tests/harvest.test.mjs @@ -1530,6 +1530,38 @@ test("HarvestRenderer against a real run: preview, drift re-preview + re-baselin ); }); +test("renderer reconciles pane-backed working state before automatic archive", async () => { + const run = mkRun(); + run.env.STUB_HERDR_VERSION = "0.7.5"; + commitIn(run.wt(1), "completed.txt"); + // Mirror the captured agent-list shape, with the plugin's report acting + // as the state source exactly as on the pane-backed Herdr path. + h.writeStub("herdr", ` +echo "herdr $@" >> "$STUB_LOG" +if [ "$1" = "--version" ]; then echo 'herdr 0.7.5'; exit 0; fi +if [ "$1 $2" = 'agent list' ]; then + state=working + if grep -q 'pane report-agent w11:p1 .*--state idle' "$STUB_LOG"; then state=idle; fi + printf '{"result":{"agents":[{"pane_id":"w11:p1","terminal_id":"term_s1","agent_status":"%s"}]}}\\n' "$state" + exit 0 +fi +if [ "$1 $2" = 'pane list' ]; then echo '{"result":{"panes":[]}}'; exit 0; fi +exit 0 +`); + try { + const observed = h.runLib("herdr_agent_list", run.env); + assert.equal(JSON.parse(observed.stdout).result.agents[0].agent_status, "working"); + const r = new HarvestRenderer(run.env); + r.write = () => {}; + await r.refresh(); + await r.doMerge(1); + assert.equal(run.slotRow(1).status, "archived", r.banner); + assert.equal(fs.existsSync(run.wt(1)), false); + } finally { + h.writeHerdrStub(); + } +}); + test("selectSlot routes a user-tree locus through the confirm phase; 'y' merges, anything else cancels", async () => { h.writeHerdrStub(); const run = mkRun(); @@ -1686,39 +1718,41 @@ test("resume scan reports a stale journal (crash before any merge commit) and cl ); }); -test("archive proceeds on an IDLE agent: the herdr verb stops it and the slot archives", () => { - // A live agent matching this slot's terminal id, but idle: unlike - // 'working' (refused above), idle is safe — the herdr remove verb stops - // the idle agent, closes the grouped workspace, and removes the worktree. - h.writeStub( - "herdr", - `echo "herdr $@" >> "$STUB_LOG" -if [ "$1" = "agent" ] && [ "$2" = "list" ]; then - echo '{"id":"cli:agent:list","result":{"agents":[{"agent":"claude","agent_status":"idle","pane_id":"w11:p1","terminal_id":"term_s1","workspace_id":"w11"}],"type":"agent_list"}}' - exit 0 -fi -exit 0`, - ); - const run = mkRun({ status: "merged" }); - const r = step(run, "archive", [1]); - assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); - assert.match(h.log(), /herdr worktree remove --workspace w11 --json/); - assert.doesNotMatch(h.log(), /--force/); - assert.equal(run.slotRow(1).status, "archived"); - assert.ok(!fs.existsSync(run.wt(1)), "worktree gone (git reconcile path)"); - assert.equal( - spawnSync("git", [ - "-C", - run.repo, - "rev-parse", - "--verify", - `refs/heads/${run.branch(1)}`, - ]).status, - 0, - "branch kept — archive never deletes branches", - ); - h.writeHerdrStub(); // restore the default stub for later tests -}); +for (const settledState of ["idle", "done"]) { + test(`archive proceeds on ${settledState} agents: the slot archives and branch survives`, () => { + // A live agent matching this slot's terminal id, but idle: unlike + // 'working' (refused above), idle is safe — the herdr remove verb stops + // the idle agent, closes the grouped workspace, and removes the worktree. + h.writeStub( + "herdr", + `echo "herdr $@" >> "$STUB_LOG" + if [ "$1" = "agent" ] && [ "$2" = "list" ]; then + echo '{"id":"cli:agent:list","result":{"agents":[{"agent":"claude","agent_status":"${settledState}","pane_id":"w11:p1","terminal_id":"term_s1","workspace_id":"w11"}],"type":"agent_list"}}' + exit 0 + fi + exit 0`, + ); + const run = mkRun({ status: "merged" }); + const r = step(run, "archive", [1]); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(h.log(), /herdr worktree remove --workspace w11 --json/); + assert.doesNotMatch(h.log(), /--force/); + assert.equal(run.slotRow(1).status, "archived"); + assert.ok(!fs.existsSync(run.wt(1)), "worktree gone (git reconcile path)"); + assert.equal( + spawnSync("git", [ + "-C", + run.repo, + "rev-parse", + "--verify", + `refs/heads/${run.branch(1)}`, + ]).status, + 0, + "branch kept — archive never deletes branches", + ); + h.writeHerdrStub(); // restore the default stub for later tests + }); +} // ---- Squash-merge detection (deferred follow-up, now shipped) --------------- // A squash-merged slot has no ancestry trail, so the external_merged check