From 0761ffa97c660642052e3e92e675be82625ea24c Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Sat, 3 Oct 2026 21:35:40 -0600 Subject: [PATCH] feat(swarm): per-slot tasks from one file, with a conflict hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripted fan-out could only give every slot the same task; different tasks per slot needed a human at the prompts. HERDR_SWARM_TASK_FILE now takes `` marker lines: text before the first marker is a shared preamble, each section is that slot's own task, and the highest section sets the slot count when HERDR_SWARM_SLOTS is unset. Sections that don't fit the run, duplicates, near-miss markers (which would silently merge two slots' work), and slots with no task refuse before anything is created; markers inside code fences are text; the file is snapshotted once so a concurrent rewrite cannot mis-split it. Before creating anything, fan-out also compares each slot's own text and warns when slots given different tasks name the same tracked file or a file inside a directory the other names — the merges likely to conflict. Shared preambles and best-of-N runs are never flagged; it is a hint, not a gate, and says so when it had to skip. An adversarial review found nothing blocking; its six findings (silent mis-splits and silent skips) are fixed here with tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ README.md | 50 +++++++++++- scripts/fanout-pane.sh | 77 +++++++++++++++--- scripts/tasks.mjs | 173 +++++++++++++++++++++++++++++++++++++++++ tests/fanout.test.mjs | 121 ++++++++++++++++++++++++++++ tests/tasks.test.mjs | 95 ++++++++++++++++++++++ 6 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 scripts/tasks.mjs create mode 100644 tests/tasks.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a3679d6..f90fd88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ Notable changes to the Swarm plugin. Format follows agent) as settled, instead of refusing cleanup until its tab is focused. ### Added +- Per-slot tasks without a TTY: `` marker lines split + `HERDR_SWARM_TASK_FILE` into per-slot sections after a shared preamble. + The highest section sets the slot count when `HERDR_SWARM_SLOTS` is unset, + and sections that don't fit the run refuse before anything is created. + Fan-out also warns when slots given different tasks name the same tracked + file or directory, since their merges may conflict. This applies to + interactive overrides too. - Slot environment: fan-out clones the repo's ignored `node_modules` (or a `clone-paths` list) into each worktree copy-on-write (`cp -c` / `--reflink`; skipped, not fully copied, where unsupported), before `setup.sh`. Each slot diff --git a/README.md b/README.md index 21cafcd..80b99b8 100644 --- a/README.md +++ b/README.md @@ -193,12 +193,58 @@ Notes: `HERDR_SWARM_DETRITUS_ACK_UNMERGED=yes` is the explicit opt-in, exactly as prune gates its destructive classes separately. `rename` keeps the work under `swarm-kept/` and always succeeds. -- **Per-slot task overrides stay interactive-only.** Fan out once per distinct - task instead; there is no env encoding for N free-form multi-line prompts. +- **Per-slot tasks go in the task file.** See below. - **Nothing blocks on a read.** With any of these variables set and stdin not a terminal, a missing piece is a loud refusal naming the variable — never a process waiting forever for input nobody will type. +### Different tasks per slot + +To give slots different work, split `HERDR_SWARM_TASK_FILE` with marker lines. +Text before the first marker is shared context every slot receives: + +```markdown +The repo uses pnpm. Keep the public API unchanged. + + +Add retry-with-backoff to `src/http/client.ts`. + + +Write the v3 migration guide in docs/migration.md. +``` + +- Slot N's task is the preamble, then its own section. Markers never reach the + agent, and they're HTML comments, so the file still renders cleanly as + markdown. +- With no `HERDR_SWARM_SLOTS`, the highest section number sets the slot count. + With it, extra slots get the preamble alone, and fan-out notes each slot + that did. +- The fan-out is **refused, before anything is created**, if a section + exceeds the slot count, a slot number appears twice, or a slot is left with + no task at all. It's also refused on any comment line that *looks* like a + marker but doesn't parse (``). Kept as text, that + line would silently hand one slot two slots' work. +- Inside ```` ``` ```` or `~~~` fenced code blocks, marker lines are plain + text, so a brief can show the syntax as an example. +- The file is read once, into a snapshot, so rewriting it during fan-out + can't mis-split it. +- A file with no markers is one shared task, as before. Interactive runs keep + the per-slot override prompt. + +**Conflict hint.** Before creating anything, fan-out compares each slot's own +task: its section, or its override when interactive. When two slots were given +*different* tasks that name the same tracked file, or a file inside a +directory the other names, it warns: + +```text +herdr-swarm: WARNING slots 1 and 2 were given different tasks that both name src/api/client.ts — their merges may conflict. +``` + +Only paths that exist in git count, so ordinary prose never matches. Shared +preamble text and best-of-N runs (the same task in every slot) are never +flagged. It's a hint, not a gate: the fan-out proceeds. If the repo can't be +listed, the hint says it was skipped rather than staying silent. + ### Can an agent drive the whole plugin? Yes — all four mutating capabilities are scriptable, all by calling the diff --git a/scripts/fanout-pane.sh b/scripts/fanout-pane.sh index ee7fac1..d565cc3 100755 --- a/scripts/fanout-pane.sh +++ b/scripts/fanout-pane.sh @@ -91,7 +91,10 @@ read_task() { # HERDR_SWARM_TASK_FILE file whose contents become the shared task — a FILE, # not a var, because the task is normally multi-line # and the stdin protocol's lone "." terminator has no -# environment equivalent +# environment equivalent. `` +# marker lines split it into per-slot sections after +# a shared preamble (scripts/tasks.mjs); with no +# HERDR_SWARM_SLOTS the highest section sets N # HERDR_SWARM_TASK single-line shared task; the file wins if both are set # HERDR_SWARM_DETRITUS delete | rename | abort — replaces the d/r/q prompt # HERDR_SWARM_DETRITUS_ACK_UNMERGED=yes @@ -100,10 +103,8 @@ read_task() { # leftovers REFUSES: the P0 guard is not bypassable # just because nobody is at the keyboard. # -# Per-slot task overrides stay interactive-only. Encoding N free-form multi-line -# prompts into the environment buys nothing a caller cannot get by fanning out -# once per distinct task, and every encoding scheme reintroduces the terminator -# problem HERDR_SWARM_TASK_FILE exists to dodge. +# Per-slot tasks without a TTY go through slot sections in HERDR_SWARM_TASK_FILE +# — one file, so the terminator problem it exists to dodge never comes back. SCRIPTED=0 if [ -n "${HERDR_SWARM_SLOTS:-}${HERDR_SWARM_PRESETS:-}${HERDR_SWARM_TASK:-}${HERDR_SWARM_TASK_FILE:-}${HERDR_SWARM_DETRITUS:-}" ]; then SCRIPTED=1 @@ -436,11 +437,35 @@ fi # --- Collect inputs (nothing is created until every input is validated) ----- +# Task scratch (the task-file snapshot, per-slot splits, conflict-hint inputs) +# lives in the 0700 state dir and goes on exit. The EXIT trap extends — never +# replaces — the lock-release trap set above. +task_scratch="$(mktemp -d "$(state_dir)/tasks.XXXXXX")" || fatal 1 "herdr-swarm: could not create task scratch space." +trap 'rm -rf "$task_scratch"; release_lock "$MUTATION_LOCK"' EXIT + +# The task file is read ONCE, into a snapshot every later step uses: a file +# still being written when fan-out starts must not be counted from one +# version and split from another. A task file with per-slot sections +# (scripts/tasks.mjs) implies its own slot count when none was given. +task_sections=0 +task_source="" +if [ -n "${HERDR_SWARM_TASK_FILE:-}" ]; then + { [ -f "$HERDR_SWARM_TASK_FILE" ] && [ -r "$HERDR_SWARM_TASK_FILE" ]; } || + fatal 1 "herdr-swarm: HERDR_SWARM_TASK_FILE '$HERDR_SWARM_TASK_FILE' is not a readable file." + task_source="$task_scratch/brief.md" + cp "$HERDR_SWARM_TASK_FILE" "$task_source" || fatal 1 "herdr-swarm: could not read HERDR_SWARM_TASK_FILE '$HERDR_SWARM_TASK_FILE'." + task_sections="$(node "$PLUGIN_ROOT/scripts/tasks.mjs" max "$task_source")" || fatal 1 "herdr-swarm: HERDR_SWARM_TASK_FILE has an invalid slot section (see above)." +fi + if [ -n "${HERDR_SWARM_SLOTS:-}" ]; then # No re-prompt loop here: a scripted caller cannot fix a typo mid-run, so a # bad count is a refusal with the cap check's own message above it. n="$HERDR_SWARM_SLOTS" preflight_check_slot_cap "$n" || fatal $? "herdr-swarm: HERDR_SWARM_SLOTS='$n' refused." +elif [ "$task_sections" -gt 0 ]; then + n="$task_sections" + preflight_check_slot_cap "$n" || fatal $? "herdr-swarm: the task file's highest slot section ($n) is over the slot cap." + echo "herdr-swarm: $n slots, from the task file's slot sections." else require_var HERDR_SWARM_SLOTS "the slot count" while :; do @@ -501,8 +526,8 @@ fi if [ -n "${HERDR_SWARM_TASK_FILE:-}" ]; then # The file wins over HERDR_SWARM_TASK on purpose: it is the channel that can # carry a real multi-line brief, so a caller that set both meant this one. - [ -f "$HERDR_SWARM_TASK_FILE" ] || fatal 1 "herdr-swarm: HERDR_SWARM_TASK_FILE '$HERDR_SWARM_TASK_FILE' is not a readable file." - shared_task="$(cat "$HERDR_SWARM_TASK_FILE")" || fatal 1 "herdr-swarm: could not read HERDR_SWARM_TASK_FILE '$HERDR_SWARM_TASK_FILE'." + # Read from the snapshot taken (and validated) before the slot count. + shared_task="$(cat "$task_source")" || fatal 1 "herdr-swarm: could not read HERDR_SWARM_TASK_FILE '$HERDR_SWARM_TASK_FILE'." # Command substitution ate the trailing newline; restore it so the task file # has the same shape it gets from the interactive reader (body, then footer). shared_task="$shared_task"$'\n' @@ -518,11 +543,26 @@ fi trimmed="${shared_task//[[:space:]]/}" [ -n "$trimmed" ] || fatal 1 "herdr-swarm: empty task prompt — nothing to hand the agents." +# Per-slot split of a sectioned task file, plus each slot's OWN text for the +# conflict hint below. +if [ "$task_sections" -gt 0 ]; then + split_out="$(node "$PLUGIN_ROOT/scripts/tasks.mjs" split "$task_source" "$n" "$task_scratch")" || + fatal 1 "herdr-swarm: HERDR_SWARM_TASK_FILE's slot sections do not fit this run (see above)." + while IFS=$'\t' read -r kind s; do + [ "$kind" = preamble_only ] || continue + echo "herdr-swarm: note — slot $s has no section of its own; it gets the shared preamble alone." >&2 + done <<<"$split_out" +fi + for ((i = 1; i <= n; i++)); do + if [ "$task_sections" -gt 0 ]; then + slot_tasks[i]="$(cat "$task_scratch/slot-$i.md")"$'\n' + continue + fi if [ "$TASK_FROM_ENV" -eq 1 ]; then - # Per-slot overrides are interactive-only (see the env block up top): - # the shared task arrived from the environment, so there is no prompt - # session to elaborate it in. + # Per-slot overrides from the environment come through slot sections in + # HERDR_SWARM_TASK_FILE (handled above); a plain shared task has no + # prompt session to elaborate it in. slot_tasks[i]="$shared_task" continue fi @@ -537,6 +577,23 @@ for ((i = 1; i <= n; i++)); do esac done +# Conflict hint, before anything is created: slots given DIFFERENT work that +# name the same tracked path are likely to conflict at harvest. Compares each +# slot's own text (its section, or its whole task when unsectioned), so a +# shared preamble — or best-of-N on one task — is never flagged. Warns only. +focus_files=() +for ((i = 1; i <= n; i++)); do + if [ "$task_sections" -eq 0 ]; then printf '%s' "${slot_tasks[i]}" >"$task_scratch/slot-$i.focus.md"; fi + focus_files+=("$task_scratch/slot-$i.focus.md") +done +while IFS=$'\t' read -r kind a b overlap_path; do + case "$kind" in + overlap) echo "herdr-swarm: WARNING slots $a and $b were given different tasks that both name $overlap_path — their merges may conflict." >&2 ;; + # Never silent: an empty hint must not read as "no conflicts". + skipped) echo "herdr-swarm: note — conflict hint skipped ($a)." >&2 ;; + esac +done < <(node "$PLUGIN_ROOT/scripts/tasks.mjs" overlaps "$SWARM_REPO" "${focus_files[@]}" 2>/dev/null) + # Every slot's binary is checked BEFORE any create: a missing binary found # after worktree create would strand a half-built run (R3/R4). preflight_check_argv "${slot_argvs[@]}" || fatal $? diff --git a/scripts/tasks.mjs b/scripts/tasks.mjs new file mode 100644 index 0000000..6250530 --- /dev/null +++ b/scripts/tasks.mjs @@ -0,0 +1,173 @@ +// tasks.mjs — per-slot task files and fan-out conflict hints. +// +// A task file may carry one section per slot, each opened by a marker line +// +// +// +// (an HTML comment: invisible when the brief is rendered as markdown, and not +// something a normal brief contains by accident). Text before the first +// marker is a shared preamble every slot receives; slot N's task is the +// preamble followed by its own section. A file with no markers is one shared +// task, exactly as before. +// +// node tasks.mjs max +// highest section number, or 0 when the file has no markers +// node tasks.mjs split +// writes /slot-.md (the task) and slot-.focus.md (the +// slot's OWN text — its section, or the whole task when unsectioned) for +// i = 1..n; refuses duplicate/out-of-range sections, near-miss markers, +// and slots left with no task at all; prints `preamble_onlyi` for +// slots that got the shared preamble alone. Markers inside ``` / ~~~ +// fences are text. +// node tasks.mjs overlaps ... +// one `overlapabpath` line per pair of slots whose +// distinct tasks name the same tracked path (or a path inside a tracked +// directory the other names), or one `skippedwhy` when the repo +// cannot be listed. A hint for the operator, not a gate. +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const MARKER = /^\s*\s*$/; +// Anything that LOOKS like a marker but does not parse as one. Silently +// keeping it as text would hand one slot two slots' work (and could shrink +// the slot count when it was the highest marker), so it is a refusal. +const NEAR_MARKER = /`); + } + if (m) { + const slot = Number(m[1]); + if (!Number.isSafeInteger(slot) || slot < 1) throw new Error(`invalid slot marker '${line.trim()}' — slots start at 1`); + if (sections.has(slot)) throw new Error(`slot ${slot} has two sections`); + current = []; + sections.set(slot, current); + continue; + } + (current ?? preamble).push(line); + } + const join = (lines) => lines.join("\n").replace(/^\s*\n/, "").replace(/\s+$/, ""); + return { preamble: join(preamble), sections: new Map([...sections].map(([k, v]) => [k, join(v)])) }; +} + +export function splitTasks(text, n) { + const { preamble, sections } = parseSections(text); + if (sections.size === 0) { + const whole = text.replace(/\s+$/, ""); + return Array.from({ length: n }, () => ({ task: whole, focus: whole })); + } + for (const slot of sections.keys()) { + if (slot > n) throw new Error(`the task file has a section for slot ${slot}, but the run has ${n} slot(s)`); + } + return Array.from({ length: n }, (_, i) => { + const own = sections.get(i + 1) ?? ""; + const task = [preamble, own].filter((part) => part !== "").join("\n\n"); + if (task.replace(/\s/g, "") === "") throw new Error(`slot ${i + 1} has no task — give it a section, or put shared text before the first marker`); + // Allowed (a preamble can be the whole brief for extra slots), but in a + // sectioned file it is often a missing section — callers say so. + return { task, focus: own, preambleOnly: own.replace(/\s/g, "") === "" }; + }); +} + +// Words that look like paths: anything in backticks, plus bare words that +// contain a slash or end in a file extension. Surrounding punctuation and a +// leading ./ are stripped. Only tokens that are real tracked paths (or +// directories of tracked files) survive, which keeps prose from matching. +export function mentionedPaths(text, tracked) { + const raw = [...text.matchAll(/`([^`\n]+)`/g)].map((m) => m[1]); + for (const word of text.split(/\s+/)) if (/\/|\.[A-Za-z0-9]{1,8}[)\].,;:'"]*$/.test(word)) raw.push(word); + const found = new Set(); + for (let token of raw) { + token = token.trim().replace(/^[("'[<]+/, "").replace(/[)\]>"',.;:!?]+$/, "").replace(/^\.\//, "").replace(/\/+$/, ""); + if (token && (tracked.files.has(token) || tracked.dirs.has(token))) found.add(token); + } + return found; +} + +// Throws when git cannot list the repo (or the listing is over 256 MiB): +// the caller reports the hint as skipped instead of implying "no conflicts". +export function trackedPaths(repo) { + const r = spawnSync("git", ["-C", repo, "ls-files", "-z"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(r.error?.code === "ENOBUFS" ? "the tracked-file list is too large" : "git ls-files failed"); + const files = new Set(r.stdout.split("\0").filter(Boolean)); + const dirs = new Set(); + for (const f of files) { + const parts = f.split("/"); + for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/")); + } + return { files, dirs }; +} + +const within = (p, q) => p === q || q.startsWith(`${p}/`); + +// Pairs of slots whose focus texts differ and name overlapping paths. Slots +// with identical focus text (best-of-N on one task) are expected to overlap. +export function findOverlaps(focusTexts, tracked) { + const mentions = focusTexts.map((t) => mentionedPaths(t, tracked)); + const out = []; + for (let a = 0; a < focusTexts.length; a++) { + for (let b = a + 1; b < focusTexts.length; b++) { + if (focusTexts[a] === focusTexts[b]) continue; + const hits = new Set(); + for (const p of mentions[a]) for (const q of mentions[b]) { + if (within(p, q)) hits.add(q); + else if (within(q, p)) hits.add(p); + } + for (const p of [...hits].sort()) out.push({ a: a + 1, b: b + 1, path: p }); + } + } + return out; +} + +function main(cmd, args) { + if (cmd === "max") { + const { sections } = parseSections(fs.readFileSync(args[0], "utf8")); + process.stdout.write(`${sections.size ? Math.max(...sections.keys()) : 0}\n`); + } else if (cmd === "split") { + const [file, n, outdir] = args; + const tasks = splitTasks(fs.readFileSync(file, "utf8"), Number(n)); + tasks.forEach(({ task, focus, preambleOnly }, i) => { + fs.writeFileSync(path.join(outdir, `slot-${i + 1}.md`), `${task}\n`); + fs.writeFileSync(path.join(outdir, `slot-${i + 1}.focus.md`), focus); + if (preambleOnly) process.stdout.write(`preamble_only\t${i + 1}\n`); + }); + } else if (cmd === "overlaps") { + const [repo, ...files] = args; + let tracked; + try { + tracked = trackedPaths(repo); + } catch (error) { + process.stdout.write(`skipped\t${error.message}\n`); + return; + } + for (const o of findOverlaps(files.map((f) => fs.readFileSync(f, "utf8")), tracked)) { + process.stdout.write(`overlap\t${o.a}\t${o.b}\t${o.path}\n`); + } + } else { + throw new Error(`unknown command '${cmd}'`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(process.argv[2], process.argv.slice(3)); + } catch (error) { + process.stderr.write(`herdr-swarm: task file: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/tests/fanout.test.mjs b/tests/fanout.test.mjs index 7deb99e..8d051d5 100644 --- a/tests/fanout.test.mjs +++ b/tests/fanout.test.mjs @@ -927,6 +927,127 @@ test("fan-out targets the workspace's repo, not the process cwd", () => { // started. These tests pin the closing of that gap, including the part that // must NOT close: the unmerged-work guard. +// --- per-slot tasks from one file (roadmap item 5) --- + +function taskFile(text) { + const f = path.join(mkdtemp("hs-brief-"), "brief.md"); + fs.writeFileSync(f, text); + return f; +} + +// A repo whose tracked files the conflict hint can match against. +function setupWithSources(extraEnv) { + const ctx = setup(extraEnv); + fs.mkdirSync(path.join(ctx.repo, "src", "api"), { recursive: true }); + fs.writeFileSync(path.join(ctx.repo, "src", "api", "client.ts"), "export {};\n"); + fs.writeFileSync(path.join(ctx.repo, "src", "ui.ts"), "export {};\n"); + git(ctx.repo, "add", "src"); + git(ctx.repo, "commit", "-q", "-m", "sources"); + return ctx; +} + +test("env-driven: a sectioned task file gives each slot its own task and sets the slot count", () => { + const brief = taskFile([ + "Shared context: the repo uses pnpm.", + "", + "", + "Add retries to the HTTP client.", + "", + "Write the migration guide.", + "", + "Benchmark cold start.", + "", + ].join("\n")); + const { repo, env } = setup({ HERDR_SWARM_TASK_FILE: brief, HERDR_SWARM_PRESETS: "claude" }); + const r = runPaneNoStdin(env, repo); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /3 slots, from the task file's slot sections/); + const tasks = readManifest().slots.map((s) => fs.readFileSync(path.join(s.path, ".swarm-task.md"), "utf8")); + assert.equal(tasks.length, 3); + for (const [i, own] of ["Add retries", "Write the migration guide", "Benchmark cold start"].entries()) { + assert.match(tasks[i], /^Shared context: the repo uses pnpm\.\n\n/); + assert.match(tasks[i], new RegExp(own)); + for (const other of ["Add retries", "Write the migration guide", "Benchmark cold start"].filter((o) => o !== own)) + assert.doesNotMatch(tasks[i], new RegExp(other), `slot ${i + 1} must not see another slot's task`); + assert.match(tasks[i], /## Standing instructions/, "the footer still follows"); + assert.doesNotMatch(tasks[i], /swarm-slot:/, "markers never reach the agent"); + } + assert.doesNotMatch(r.stderr, /may conflict/); +}); + +test("env-driven: sections that do not fit the slot count refuse before anything is created", () => { + for (const [slots, text, why] of [ + ["2", "\nx\n", /section for slot 3, but the run has 2/], + ["2", "\nonly one\n", /slot 2 has no task/], + [undefined, "\na\n\nb\n", /slot 1 has two sections/], + ]) { + const { repo, env } = setup({ HERDR_SWARM_TASK_FILE: taskFile(text), HERDR_SWARM_PRESETS: "claude", ...(slots ? { HERDR_SWARM_SLOTS: slots } : {}) }); + const r = runPaneNoStdin(env, repo); + assert.notEqual(r.status, 0); + assert.match(r.stderr, why); + assert.doesNotMatch(log(), /worktree create/, "nothing was created"); + } +}); + +test("a preamble lets more slots than sections share it", () => { + const { repo, env } = setup({ + HERDR_SWARM_TASK_FILE: taskFile("Everyone: keep the API stable.\n\nAlso add retries.\n"), + HERDR_SWARM_SLOTS: "2", + HERDR_SWARM_PRESETS: "claude", + }); + const r = runPaneNoStdin(env, repo); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + const [one, two] = readManifest().slots.map((s) => fs.readFileSync(path.join(s.path, ".swarm-task.md"), "utf8")); + assert.match(one, /keep the API stable\.\n\nAlso add retries\./); + assert.match(two, /^Everyone: keep the API stable\.\n/); + assert.doesNotMatch(two, /retries/); + assert.match(r.stderr, /slot 2 has no section of its own; it gets the shared preamble alone/); +}); + +test("a near-miss marker or an unreadable task file refuses before anything is created, with the right reason", () => { + { + const { repo, env } = setup({ HERDR_SWARM_TASK_FILE: taskFile("\nA\n\nB\n"), HERDR_SWARM_PRESETS: "claude" }); + const r = runPaneNoStdin(env, repo); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /line 3 looks like a slot marker but is not one/); + } + { + const brief = taskFile("Do it.\n"); + fs.chmodSync(brief, 0o000); + const { repo, env } = setup({ HERDR_SWARM_TASK_FILE: brief, HERDR_SWARM_SLOTS: "1", HERDR_SWARM_PRESETS: "claude" }); + const r = runPaneNoStdin(env, repo); + fs.chmodSync(brief, 0o600); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /is not a readable file/); + assert.doesNotMatch(r.stderr, /invalid slot section/); + } + assert.doesNotMatch(log(), /worktree create/, "nothing was created"); +}); + +test("different tasks naming the same tracked path get a conflict warning; best-of-N does not", () => { + const brief = taskFile("\nAdd retries in `src/api/client.ts`.\n\nRework src/api auth.\n\nRestyle src/ui.ts.\n"); + { + const { repo, env } = setupWithSources({ HERDR_SWARM_TASK_FILE: brief, HERDR_SWARM_PRESETS: "claude" }); + const r = runPaneNoStdin(env, repo); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /WARNING slots 1 and 2 were given different tasks that both name src\/api\/client\.ts/); + assert.doesNotMatch(r.stderr, /slots (1|2) and 3/); + } + { + const { repo, env } = setupWithSources({ HERDR_SWARM_TASK: "Add retries in src/api/client.ts", HERDR_SWARM_SLOTS: "3", HERDR_SWARM_PRESETS: "claude" }); + const r = runPaneNoStdin(env, repo); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.doesNotMatch(r.stderr, /may conflict/, "the same task on every slot is best-of-N, not a conflict"); + } +}); + +test("interactive overrides get the same conflict hint", () => { + const { repo, env } = setupWithSources(); + const r = runPane(lines(["2", "", "", "Shared: improve src/ui.ts", ".", "y", "Rewrite src/api/client.ts", ".", "y", "Add tests for src/api/client.ts", "."]), env, repo); + assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /slots 1 and 2 were given different tasks that both name src\/api\/client\.ts/); +}); + test("env-driven: SLOTS+PRESETS+TASK fan out with no stdin at all", () => { const { repo, wtRoot, env } = setup({ HERDR_SWARM_SLOTS: "2", diff --git a/tests/tasks.test.mjs b/tests/tasks.test.mjs new file mode 100644 index 0000000..0d44879 --- /dev/null +++ b/tests/tasks.test.mjs @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseSections, splitTasks, mentionedPaths, findOverlaps } from "../scripts/tasks.mjs"; + +const tracked = { + files: new Set(["src/api/client.ts", "src/api/retry.ts", "src/ui/button.tsx", "README.md", "package.json"]), + dirs: new Set(["src", "src/api", "src/ui"]), +}; + +test("an unmarked file is one shared task for every slot", () => { + const tasks = splitTasks("Do the thing.\nCarefully.\n\n", 3); + assert.deepEqual(tasks, Array(3).fill({ task: "Do the thing.\nCarefully.", focus: "Do the thing.\nCarefully." })); +}); + +test("sections follow a shared preamble; a slot without one gets the preamble", () => { + const text = "Repo uses pnpm.\n\n\nAdd retries.\n\nFix the button.\n"; + assert.deepEqual(parseSections(text).preamble, "Repo uses pnpm."); + assert.deepEqual(splitTasks(text, 3), [ + { task: "Repo uses pnpm.\n\nAdd retries.", focus: "Add retries.", preambleOnly: false }, + { task: "Repo uses pnpm.", focus: "", preambleOnly: true }, + { task: "Repo uses pnpm.\n\nFix the button.", focus: "Fix the button.", preambleOnly: false }, + ]); +}); + +test("section errors are refusals, never guesses", () => { + assert.throws(() => parseSections("\nx"), /slots start at 1/); + assert.throws(() => parseSections("\na\n\nb"), /slot 2 has two sections/); + assert.throws(() => splitTasks("\nx", 3), /section for slot 4, but the run has 3/); + assert.throws(() => splitTasks("\nonly one", 2), /slot 2 has no task/); +}); + +test("a marker inside prose or a fenced code block is not a marker", () => { + assert.equal(parseSections("Explain how `` works.\n").sections.size, 0); + const doc = [ + "", + "Document the syntax:", + "```markdown", + "", + "", + "```", + "~~~", + "", + "~~~", + "", + "Second task.", + ].join("\n"); + const { sections } = parseSections(doc); + assert.deepEqual([...sections.keys()], [1, 2]); + assert.match(sections.get(1), /```markdown\n/, "the example survives verbatim"); +}); + +test("a near-miss marker is refused by line, never kept as text", () => { + for (const typo of ["", "", "", "", ""]) { + assert.throws(() => parseSections(`\nA\n${typo}\nB\n`), /line 3 looks like a slot marker but is not one/, typo); + } +}); + +test("slots that get the preamble alone are flagged", () => { + const tasks = splitTasks("Shared.\n\nOwn.\n\n \n", 3); + assert.deepEqual(tasks.map((t) => t.preambleOnly), [false, true, true]); +}); + +test("overlaps CLI says 'skipped' when the repo cannot be listed", async () => { + const { spawnSync } = await import("node:child_process"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hs-notrepo-")); + const focus = path.join(dir, "f.md"); + fs.writeFileSync(focus, "src/a.ts"); + const r = spawnSync("node", [new URL("../scripts/tasks.mjs", import.meta.url).pathname, "overlaps", dir, focus, focus], { encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^skipped\tgit ls-files failed$/m); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("only real tracked paths count as mentions", () => { + const found = mentionedPaths("Touch `src/api/client.ts`, then ./src/ui/button.tsx. Read README.md (and docs/missing.md). Mind src/api/", tracked); + assert.deepEqual([...found].sort(), ["README.md", "src/api", "src/api/client.ts", "src/ui/button.tsx"]); + assert.deepEqual([...mentionedPaths("Refactor the api client and/or the retry logic", tracked)], []); +}); + +test("overlaps: same file, or a file inside a directory the other slot names", () => { + const overlaps = findOverlaps([ + "Add retries in `src/api/retry.ts`.", + "Restructure src/api for the new auth.", + "Restyle `src/ui/button.tsx`.", + ], tracked); + assert.deepEqual(overlaps, [{ a: 1, b: 2, path: "src/api/retry.ts" }]); +}); + +test("identical tasks (best-of-N) and empty focus never warn", () => { + assert.deepEqual(findOverlaps(Array(3).fill("Fix `src/api/client.ts`."), tracked), []); + assert.deepEqual(findOverlaps(["Fix `src/api/client.ts`.", ""], tracked), []); +});