Skip to content
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
# v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Set up Node.js
# v4.0.3
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b
with:
node-version: 20
- name: Run tests
run: npm test
- name: Validate shell syntax
run: bash -n scripts/*.sh
- name: Validate plugin manifest
run: node scripts/check-manifest.mjs
75 changes: 63 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ Conductor).
warning, never a refusal.
- **git >= 2.38** recommended (relies on `git worktree`, three-arg
`git update-ref` compare-and-swap, and `git merge-base --is-ancestor`).
- **Node.js >= 20** on your PATH (manifest handling and the pane renderers).
- **Node.js >= 20** on your PATH (manifest handling, manifest validation, and
pane renderers). No Python runtime or third-party parser is required.
- macOS or Linux.

## Install
Expand Down Expand Up @@ -88,13 +89,17 @@ 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.
- *Archive* — after merge/skip, the worktree is removed (branch kept); an
inventory of ignored files that removal would silently delete is shown
first and requires acknowledgment.
- *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
same guard covers plugin-owned detached merge worktrees during swap,
resume, and merge-abort cleanup.
4. **Abort** (`structupath.swarm.abort`) — abandon the run, mid-flight or
post-crash: stops agents, closes swarm panes/workspaces, removes clean
swarm worktrees, keeps everything questionable, prints a summary. Branches
are never deleted by abort.
post-crash: stops agents, closes swarm panes/workspaces, removes only exact
verified resources, keeps dirty/ignored/unresolved resources, and prints a
summary. Ignored cleanup is preview/apply, described below. Abort exits
nonzero unless every slot update succeeds and the exact completed archive
exists. Branches are never deleted by abort.
5. **Prune** (`structupath.swarm.prune`) — dry-run listing of fully-merged
`swarm/*` branches, discard-snapshot backup refs, and archived run
manifests. Deletion is gated per resource class, because the classes are
Expand All @@ -118,7 +123,7 @@ exactly one prompt; anything you leave unset still prompts, so interactive use i
unchanged.

| Variable | Replaces |
|---|---|
| --- | --- |
| `HERDR_SWARM_SLOTS` | slot count (same cap check, raise with `HERDR_SWARM_MAX_SLOTS`) |
| `HERDR_SWARM_PRESETS` | comma-separated preset names, one per slot; a single name applies to all slots |
| `HERDR_SWARM_TASK_FILE` | path to a file whose contents become the shared task |
Expand Down Expand Up @@ -172,12 +177,57 @@ scripts directly rather than through `herdr plugin action invoke` (which
forwards no environment):

| Capability | Scriptable path |
|---|---|
| --- | --- |
| Fan out | `scripts/fanout-pane.sh` with the variables above (zero-TTY) |
| Harvest | `scripts/harvest-step.sh <verb>` — a verb CLI with typed exit codes and `key<TAB>value` stdout |
| Abort | `scripts/abort.sh` — a zero-TTY action, env-gated |
| Prune | `scripts/prune.sh` — a zero-TTY action, dry run by default, env-gated per resource class |

Harvest, Status, and Abort resolve the active generation by physical Git
repository, not by the current Herdr workspace filename. An explicit
`HERDR_PLUGIN_CONTEXT_JSON.workspace_cwd` is authoritative; any legacy
workspace-named manifest must match the exact generation selected under that
repository's lock or the operation refuses without mutation. Reopening the
same repository under another workspace ID therefore reaches the same run.
The resolution refuses rather than choosing when multiple live manifests, a
conflicting workspace hint, or an invalid active index exists.

#### Scripted ignored-file cleanup

Ignored-only work is **kept by default**. Cleanup is an exact two-step
preview/apply protocol; a generic yes/ack variable never authorizes deletion.
The relevant variables are:

| Variable | Meaning |
| --- | --- |
| `HERDR_SWARM_ABORT_PREVIEW=yes` | read-only Abort preview; closes/removes/updates nothing |
| `HERDR_SWARM_CLEANUP_OPERATION_ID=<safe-id>` | stable caller-chosen preview operation ID; Abort derives one resource ID per slot |
| `HERDR_SWARM_CLEANUP_APPROVAL='<json>'` | exact `cleanup_approval` JSON emitted by preview; bound to resource type, repository, run, slot, physical path, generation/HEAD, operation, and inventory digest |

Example:

```sh
HERDR_SWARM_ABORT_PREVIEW=yes \
HERDR_SWARM_CLEANUP_OPERATION_ID=abort-review-1 \
bash scripts/abort.sh

# Copy one cleanup_approval JSON line from the preview, inspect every
# ignored_json line, then apply exactly that one resource approval:
HERDR_SWARM_CLEANUP_APPROVAL='{"approved":true,"...":"exact preview fields"}' \
bash scripts/abort.sh
```

Apply immediately rechecks resource identity and recursively recomputes the
NUL-delimited ignored inventory. Any changed path, HEAD, registration,
generation, digest, symlink, duplicate owner, stale approval, or already-used
operation refuses removal. If several resources contain ignored data, repeat
preview/apply for each emitted approval. `harvest-step.sh archive` uses the
same output protocol (exit 37 when approval is required); detached merge
cleanup may emit an approval after the base swap lands and retains its exact
journaled worktree until approved. Retry `harvest-step.sh resume` for a
Harvest cleanup, or retry Abort with the exact approval Abort emitted; only a
verified removal clears the journal and permits terminal slot/run archival.

### Keybinding

```toml
Expand Down Expand Up @@ -255,9 +305,10 @@ starts the agent anyway; output lands in the plugin state dir.
- **Squash merges are invisible.** A slot you squash-merged yourself still
shows as pending and will conflict on re-merge — skip it by hand. Fast-
forward and plain external merges *are* auto-detected via ancestry.
- **Ignored files sit outside every safety net** — not in WIP commits, not in
discard snapshots, not protected by no-`--force` removal. The archive-time
ignored-file inventory prompt is the only guard.
- **Ignored files are not in commits or discard snapshots.** Every slot or
detached-harvest worktree removal recursively inventories them and keeps the
resource by default. Deletion requires the exact one-use preview approval;
changed inventories refuse and must be previewed again.
- **Closing the parent repo workspace kills swarm agents silently** (the
worktree workspaces are grouped under it). Committed work survives and
stays harvestable; uncommitted editor state in the agent does not.
Expand Down
98 changes: 73 additions & 25 deletions bin/renderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,14 @@ export function renderStatus(rows, cols = 80) {
const counts = `${r.committed ?? "-"}/${r.uncommitted ?? "-"}`;
// state comes from herdr's agent_status — externally controlled text on
// the same footing as a branch name, so it sanitizes like one.
const line = ` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(counts, 8)}${pad(
sanitizeText(r.label ?? ""),
14,
)}${pad(sanitizeText(r.branch ?? ""), 28)}${sanitizeText(r.path ?? "-")}`.slice(
0,
cols,
);
const line =
` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(counts, 8)}${pad(
sanitizeText(r.label ?? ""),
14,
)}${pad(sanitizeText(r.branch ?? ""), 28)}${sanitizeText(r.path ?? "-")}`.slice(
0,
cols,
);
lines.push(
r.state === "blocked" ? `${ESC}[7m${ESC}[31m${line}${ESC}[0m` : line,
);
Expand Down Expand Up @@ -322,7 +323,10 @@ export class Renderer {
try {
// Three-dot range against the recorded fork SHA — see diffRange().
facts.committed = parseDiffStat(
await this.git(["diff", "--stat", diffRange(manifest.fork_sha)], row.path),
await this.git(
["diff", "--stat", diffRange(manifest.fork_sha)],
row.path,
),
);
} catch {
facts.committed = null;
Expand Down Expand Up @@ -379,7 +383,8 @@ export class Renderer {
gitFacts[row.slot] = factsList[i];
});
this.rows = sortSlots(reconcileSlots(m.slots, agents, gitFacts));
this.banner = agents === null ? "agent list unavailable — states shown as unknown" : "";
this.banner =
agents === null ? "agent list unavailable — states shown as unknown" : "";
this.paint();
}

Expand Down Expand Up @@ -559,7 +564,8 @@ export function renderHarvest(model, cols = 80) {
)}`
: " herdr-swarm harvest";
lines.push(`${ESC}[7m${title.slice(0, cols)}${ESC}[0m`);
if (model.banner) lines.push(` ! ${sanitizeText(model.banner)}`.slice(0, cols));
if (model.banner)
lines.push(` ! ${sanitizeText(model.banner)}`.slice(0, cols));
lines.push(
` ${pad("#", 3)}${pad("state", 17)}${pad("dirty", 7)}${pad("label", 14)}branch`,
);
Expand All @@ -570,7 +576,10 @@ export function renderHarvest(model, cols = 80) {
const line = ` ${pad(r.slot, 3)}${pad(sanitizeText(state ?? "?"), 17)}${pad(
p?.dirty ?? "-",
7,
)}${pad(sanitizeText(r.label ?? ""), 14)}${sanitizeText(r.branch ?? "")}`.slice(0, cols);
)}${pad(sanitizeText(r.label ?? ""), 14)}${sanitizeText(r.branch ?? "")}`.slice(
0,
cols,
);
// Dirty and error rows need the user before any merge can happen —
// same "loud" treatment blocked gets in status mode.
lines.push(
Expand All @@ -589,7 +598,9 @@ export function renderHarvest(model, cols = 80) {
lines.push(
` RESUME: slot ${o.slot} has a completed but un-swapped merge commit ${sanitizeText(String(o.sha)).slice(0, 10)}.`,
);
lines.push(`${ESC}[2m [y]complete the swap [n]leave it journaled${ESC}[0m`);
lines.push(
`${ESC}[2m [y]complete the swap [n]leave it journaled${ESC}[0m`,
);
break;
}
case "stale": {
Expand Down Expand Up @@ -618,7 +629,9 @@ export function renderHarvest(model, cols = 80) {
lines.push(
` DISCARD slot ${ph.slot}: a snapshot ref is written first, but this deletes uncommitted work.`,
);
lines.push(` Type the slot branch name to confirm, Enter to submit, Esc to cancel:`);
lines.push(
` Type the slot branch name to confirm, Enter to submit, Esc to cancel:`,
);
lines.push(` > ${sanitizeText(ph.typed)}`);
break;
case "confirm-user":
Expand All @@ -628,7 +641,9 @@ export function renderHarvest(model, cols = 80) {
lines.push(
` The tree was verified clean and will be re-verified at merge time.`,
);
lines.push(`${ESC}[2m [y]merge in my tree [any other key]cancel${ESC}[0m`);
lines.push(
`${ESC}[2m [y]merge in my tree [any other key]cancel${ESC}[0m`,
);
break;
case "conflict": {
lines.push(
Expand Down Expand Up @@ -773,7 +788,11 @@ export class HarvestRenderer {
return;
}
const m = parsed.manifest;
this.runInfo = { run_id: m.run_id, base_ref: m.base_ref, repo_root: m.repo_root };
this.runInfo = {
run_id: m.run_id,
base_ref: m.base_ref,
repo_root: m.repo_root,
};
const rows = [];
// Sequential on purpose: every preview verb takes the per-repo mutation
// lock, so concurrency here would only contend on that lock.
Expand Down Expand Up @@ -854,17 +873,24 @@ export class HarvestRenderer {
}
}

async doArchive(slot, ack = false) {
async doArchive(slot, approval = null) {
const r = await this.step(
"archive",
[slot],
ack ? { HERDR_SWARM_ACK_IGNORED: "1" } : {},
approval ? { HERDR_SWARM_CLEANUP_APPROVAL: approval } : {},
);
if (r.code === STEP_EC.IGNORED) {
this.phase = {
name: "ignored",
slot,
files: (r.out.ignored ?? []).map((v) => v[0]),
approval: r.out.cleanup_approval?.[0]?.[0] ?? null,
files: (r.out.ignored_json ?? []).map((v) => {
try {
return JSON.parse(v[0]);
} catch {
return v[0];
}
}),
};
this.paint();
} else if (r.code === STEP_EC.DIRTY) {
Expand Down Expand Up @@ -999,7 +1025,9 @@ export class HarvestRenderer {
if (ch === "y" || ch === "Y") {
const r = await this.step("resume", ["complete", offer.slot]);
this.banner =
r.code === 0 ? `slot ${offer.slot} swap completed` : this.lastErrLine(r);
r.code === 0
? `slot ${offer.slot} swap completed`
: this.lastErrLine(r);
}
if (ph.idx + 1 < ph.offers.length) {
this.phase = { ...ph, idx: ph.idx + 1 };
Expand Down Expand Up @@ -1030,12 +1058,16 @@ export class HarvestRenderer {
case "dirty":
if (ch === "w") {
const r = await this.step("commit-wip", [ph.slot]);
this.banner = r.code === 0 ? `slot ${ph.slot} committed as WIP` : this.lastErrLine(r);
this.banner =
r.code === 0
? `slot ${ph.slot} committed as WIP`
: this.lastErrLine(r);
this.phase = { name: "list" };
await this.reload();
} else if (ch === "s") {
const r = await this.step("skip", [ph.slot]);
this.banner = r.code === 0 ? `slot ${ph.slot} skipped` : this.lastErrLine(r);
this.banner =
r.code === 0 ? `slot ${ph.slot} skipped` : this.lastErrLine(r);
this.phase = { name: "list" };
await this.reload();
} else if (ch === "d") {
Expand All @@ -1062,7 +1094,9 @@ export class HarvestRenderer {
} else if (ch === "a") {
const r = await this.step("abort-merge", [ph.slot]);
this.banner =
r.code === 0 ? `slot ${ph.slot} merge aborted` : this.lastErrLine(r);
r.code === 0
? `slot ${ph.slot} merge aborted`
: this.lastErrLine(r);
this.phase = { name: "list" };
await this.reload();
} else if (ch === "b" || ch === "\x1b") {
Expand All @@ -1075,8 +1109,14 @@ export class HarvestRenderer {
case "ignored":
if (ch === "y" || ch === "Y") {
const slot = ph.slot;
const approval = ph.approval;
this.phase = { name: "list" };
await this.doArchive(slot, true);
if (!approval) {
this.banner = "cleanup approval missing — re-preview required";
this.paint();
break;
}
await this.doArchive(slot, approval);
await this.reload();
} else {
this.banner = `slot ${ph.slot} kept — worktree not removed`;
Expand Down Expand Up @@ -1107,7 +1147,12 @@ export class HarvestRenderer {
screen() {
const cols = process.stdout.columns || 80;
return renderHarvest(
{ runInfo: this.runInfo, banner: this.banner, phase: this.phase, rows: this.rows },
{
runInfo: this.runInfo,
banner: this.banner,
phase: this.phase,
rows: this.rows,
},
cols,
);
}
Expand Down Expand Up @@ -1179,7 +1224,10 @@ export class HarvestRenderer {
const dangling = r.out.resume_dangling ?? [];
if (dangling.length) {
this.banner = `DANGLING merge commit(s): ${dangling
.map((v) => `slot ${v[0]} @ ${String(v[1]).slice(0, 10)} (kept in ${v[2]})`)
.map(
(v) =>
`slot ${v[0]} @ ${String(v[1]).slice(0, 10)} (kept in ${v[2]})`,
)
.join("; ")}`;
}
if (offers.length) this.phase = { name: "resume", offers, idx: 0 };
Expand Down
Loading
Loading