Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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).

Expand Down
7 changes: 6 additions & 1 deletion bin/renderer-harvest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
53 changes: 53 additions & 0 deletions docs/readiness.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
36 changes: 36 additions & 0 deletions scripts/check-source.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
40 changes: 40 additions & 0 deletions scripts/doctor.sh
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 3 additions & 2 deletions scripts/harvest-step.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions scripts/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions tests/check-source.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
40 changes: 40 additions & 0 deletions tests/doctor.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading
Loading