From 5fef2c9d14cd9d831e05157372f4b4c4bad21cfc Mon Sep 17 00:00:00 2001 From: Dave Puglielli Date: Thu, 8 Oct 2026 07:01:04 -0500 Subject: [PATCH] chore - comitatus 1.0.0: codex --no-daemon, default models, launcher outside the herd, fan-out removed, docs refreshed Launch defaults, in up.js KINDS/makeAgent, which every launch path shares: - every Codex launch passes --no-daemon - a bare handle launches on claude-opus-5-5 or gpt-6.1-sol; an explicit model= still wins, and effort is still inherited Herd membership: seed defaults the working lead to the first roster member and refuses a lead outside the roster. A sender left off the roster is named in the seed as an outside launcher that members do not wait on unless their task names it as an approver. Fan-out removed: the fan-out skill, its role files, and the run-workflow verbs (role, fanout, wait-all, state, settled, fan-in, teardown) are deleted, along with the helper's wait and send-wait-read, which duplicated native herdr verbs. The herdr skill carries concise multi-agent rules instead: one writer per tree, evidence-based completion, inspecting partial launches, ordered integration by the target-tree owner, and cleanup that adjudicates dirty and unmerged work rather than sweeping it with --force. Docs: the root README, AGENTS.md, and the comitatus, mantra and vernaculus READMEs and references match the code, with decorative counts removed; historical research and plans carry snapshot notices. Versions: comitatus 1.0.0 (removing a skill is breaking), vernaculus 0.2.1, mantra 0.6.3. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 6 +- .../branches/chore-comitatus-launch-defaults | 2 + .../chore-comitatus-launch-defaults.md | 110 +++ AGENTS.md | 14 +- CLAUDE.md | 5 +- README.md | 143 ++-- comitatus/.claude-plugin/plugin.json | 2 +- comitatus/.codex-plugin/plugin.json | 2 +- comitatus/README.md | 49 +- comitatus/__tests__/fanin.test.js | 730 ------------------ comitatus/__tests__/fanout.test.js | 647 ---------------- comitatus/__tests__/herd-setup.test.js | 41 +- comitatus/__tests__/herd.test.js | 255 +++--- comitatus/__tests__/skill-layout.test.js | 39 + comitatus/__tests__/up.test.js | 82 +- .../hooks/__tests__/herdr-orient.test.js | 86 +-- comitatus/hooks/herdr-orient.js | 50 +- comitatus/package.json | 2 +- comitatus/scripts/herd-setup.js | 27 +- comitatus/skills/fan-out/SKILL.md | 418 ---------- comitatus/skills/fan-out/roles/architect.md | 29 - comitatus/skills/fan-out/roles/hypotheses.md | 16 - comitatus/skills/fan-out/roles/implementer.md | 20 - comitatus/skills/fan-out/roles/mechanic.md | 18 - .../skills/fan-out/roles/orchestrator.md | 67 -- comitatus/skills/fan-out/roles/probe.md | 15 - comitatus/skills/fan-out/roles/reviewer.md | 25 - comitatus/skills/herd-setup/SKILL.md | 2 +- comitatus/skills/herdr/SKILL.md | 68 +- comitatus/skills/herdr/reference/names.md | 7 +- comitatus/skills/herdr/reference/protocol.md | 11 +- .../skills/herdr/scripts/branch-naming.js | 35 - comitatus/skills/herdr/scripts/fanin.js | 403 ---------- comitatus/skills/herdr/scripts/fanout.js | 341 -------- comitatus/skills/herdr/scripts/herd.js | 140 +--- comitatus/skills/herdr/scripts/up.js | 17 +- docs/plans/2026-06-21-comitatus-design.md | 2 + .../2026-06-21-comitatus-implementation.md | 2 + ...6-08-08-cross-model-plugin-architecture.md | 2 + ...09-cross-model-migration-review-round-2.md | 2 + ...09-cross-model-migration-review-round-3.md | 2 + ...2026-08-09-cross-model-migration-review.md | 2 + docs/reviews/fan-out-skill-manifest.md | 2 + .../plans/2026-06-22-comitatus-retro-fixes.md | 2 + .../plans/2026-06-22-herd-up-launcher.md | 2 + .../2026-06-24-herd-permissions-friction.md | 2 + .../specs/2026-06-22-herd-naming-design.md | 2 + .../2026-06-22-herd-up-launcher-design.md | 2 + mantra/.claude-plugin/plugin.json | 2 +- mantra/.codex-plugin/plugin.json | 2 +- mantra/README.md | 10 +- mantra/package.json | 2 +- metadata/skill-catalog.json | 4 - research/comitatus-recommendations.md | 2 + research/herdr-briefing.md | 2 + research/herdr-plugins-catalog.md | 2 + vernaculus/.claude-plugin/plugin.json | 4 +- vernaculus/.codex-plugin/plugin.json | 2 +- vernaculus/package.json | 2 +- vernaculus/references/launch-surface.md | 32 +- .../local-model-delegation-findings.md | 24 +- 61 files changed, 612 insertions(+), 3426 deletions(-) create mode 100644 .claude/branches/chore-comitatus-launch-defaults create mode 100644 .claude/sessions/chore-comitatus-launch-defaults.md delete mode 100644 comitatus/__tests__/fanin.test.js delete mode 100644 comitatus/__tests__/fanout.test.js create mode 100644 comitatus/__tests__/skill-layout.test.js delete mode 100644 comitatus/skills/fan-out/SKILL.md delete mode 100644 comitatus/skills/fan-out/roles/architect.md delete mode 100644 comitatus/skills/fan-out/roles/hypotheses.md delete mode 100644 comitatus/skills/fan-out/roles/implementer.md delete mode 100644 comitatus/skills/fan-out/roles/mechanic.md delete mode 100644 comitatus/skills/fan-out/roles/orchestrator.md delete mode 100644 comitatus/skills/fan-out/roles/probe.md delete mode 100644 comitatus/skills/fan-out/roles/reviewer.md delete mode 100644 comitatus/skills/herdr/scripts/branch-naming.js delete mode 100644 comitatus/skills/herdr/scripts/fanin.js delete mode 100644 comitatus/skills/herdr/scripts/fanout.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a89ee04f..c7111be6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,7 +15,7 @@ "name": "mantra", "source": "./mantra", "description": "Context refresh - periodic re-injection of behavioral guidance to prevent context drift", - "version": "0.6.2" + "version": "0.6.3" }, { "name": "onus", @@ -33,7 +33,7 @@ "name": "comitatus", "source": "./comitatus", "description": "herdr workflows - packages the herdr agent-orchestration skill for claude and codex, auto-injected inside herdr", - "version": "0.14.1" + "version": "1.0.0" }, { "name": "stilus", @@ -51,7 +51,7 @@ "name": "vernaculus", "source": "./vernaculus", "description": "Vernaculus - delegating well-specified coding work to a locally hosted model over MCP", - "version": "0.2.0" + "version": "0.2.1" } ] } diff --git a/.claude/branches/chore-comitatus-launch-defaults b/.claude/branches/chore-comitatus-launch-defaults new file mode 100644 index 00000000..92314b4d --- /dev/null +++ b/.claude/branches/chore-comitatus-launch-defaults @@ -0,0 +1,2 @@ +session: chore-comitatus-launch-defaults.md +status: in-progress diff --git a/.claude/sessions/chore-comitatus-launch-defaults.md b/.claude/sessions/chore-comitatus-launch-defaults.md new file mode 100644 index 00000000..26d43d80 --- /dev/null +++ b/.claude/sessions/chore-comitatus-launch-defaults.md @@ -0,0 +1,110 @@ +# Session: comitatus launch defaults + +## Details +- **Branch**: chore/comitatus-launch-defaults +- **Type**: chore +- **Created**: 2026-10-08 +- **Status**: in-progress + +## Goal +Change comitatus to launch Codex sessions with --no-daemon, default Claude to Opus 5.5 and Codex to gpt-6.1-sol, and keep the launcher out of the spawned herd by default. +Expanded (operator, via jay, 2026-10-08): retire fan-out as a discoverable skill on this branch. herdr owns initialization, membership, role assignment and coordination. +Expanded again (operator chose "Delete on this branch", 2026-10-08): delete the run-workflow layer outright, keep messaging safeguards, seed, launch defaults/readiness and provisioning/setup, and put the shortcomings into concise herdr skill guidance. + +## Approach +Inspect the existing launcher and protocol, verify supported CLI flags and exact model selectors, preserve explicit overrides, and designate a spawned working lead. Update focused tests and owning documentation, validate affected plugin metadata and skill invocation, and bump comitatus once after substantive edits. + +Documentation pass (operator: "ensure all documentation at repo and plugin level is up-to-date"; split agreed with jay, disjoint files in one tree): +- tim: `comitatus/**` docs; `vernaculus/references/**`, scoped to their 0.14.1 snapshot with a "changed in 1.0.0" notice and corrected current-behaviour claims (one vernaculus patch bump); the `AGENTS.md` plugin inventory (plugin list, owning-plugin line, test commands); this session file. +- jay: root `README.md`, plus snapshot notices and links in historical repo research/docs. Historical measurements stay intact. + +## Session Log +- 2026-10-08: Created isolated chore worktree and session; preparing Claude/Codex handoff. +- 2026-10-08 (tim, lead; jay reviewing): every launch path (`up`, `herd.js agent`, `fanout` via `up`) builds args in `up.js` `makeAgent`/`KINDS`, so one change covers all. Codex always gets `--no-daemon` (verified in `codex --help`, 0.161.0). Bare handles default to `claude-opus-5-5` / `gpt-6.1-sol`; explicit `model=` wins; `effort=`/`role=` alone keep the default model; effort is still inherited. Fan-out default selector and codex role files moved from gpt-5.6-sol to gpt-6.1-sol. +- Decision (agreed with jay): `seed` no longer makes the sender the lead. The lead defaults to the first handle in the normalized roster (target prepended if missing); an explicit or default lead outside the roster throws. A sender absent from `--roster` is named in the seed line as an outside launcher (not member, approval hop, or reviewer). Joining is opt-in by listing yourself in `--roster`; `withdraw` is unchanged for that case. +- Blocker resolved: worktree had no node_modules, so `npm run test:comitatus` exited 127 for jay; ran `npm run install:all`. Suite now passes. +- Reconciled with fan-out (jay): the outside launcher is "neither member nor coordinator" and members do not wait on it "unless your task names it as an approver". Fan-out's launcher keeps authorizing fan-in/teardown/completion on explicit operator say-so. Docs now require the launcher to stay in its own workspace, because sync/members infer membership from the workspace. +- Validation: comitatus suite passes; validate:plugins; claude strict validate (marketplace + comitatus); codex 0.147.0 marketplace add + plugin add (0.15.0); live `codex --no-daemon --model gpt-6.1-sol exec` answered; `claude --model claude-opus-5-5 -p` answered; probe-skill comitatus:herdr FIRED on claude and codex. +- Bumped comitatus 0.14.1 -> 0.15.0 (minor: launch defaults change behaviour). Superseded by the scope expansion below. +- Fan-out retirement (operator-authorized on this branch; jay audits): + - Moved `skills/fan-out/SKILL.md` to `skills/herdr/reference/fan-out.md` (frontmatter dropped) and `skills/fan-out/roles/` to `skills/herdr/reference/roles/`. Removed `comitatus:fan-out` from `metadata/skill-catalog.json`. + - "Which position you hold" now assigns positions explicitly: a seeded member keeps its role. A working lead may start a run within its existing scope and becomes that run's launcher; it asks the operator only for missing scope or authority (jay: no blanket new approval gate). + - "Launcher" is a per-run position, not the herd's outside launcher. A working lead starting a run keeps its existing cwd. The stale `up` fetch claim was replaced with the real reason to use `fanout`. + - Hook: roles ride the herdr stable copy (`~/.claude/comitatus/skills/herdr/reference/roles`). The orientation names the packaged absolute roles path on a Codex install or when the stable copy fails. The retired `~/.claude/comitatus/skills/fan-out` is removed only after the herdr copy lands. `DEFAULT_ROLES_DIR=.pipeline/roles` is unchanged, so a repo's own roles still win. + - Added `__tests__/skill-layout.test.js`: only herdr and herd-setup are discoverable, and every relative Markdown link resolves. + - Version: removing an invocable skill is breaking (AGENTS.md), so the single branch bump is 0.14.1 -> 1.0.0. + - Left as historical: `vernaculus/references/local-model-delegation-findings.md`, `docs/`, `tmp/`. +- Probes. Prompt: a seeded working lead tim of tim,jay, launched by sly, describes role assignment and fan-in for two partitions. + - The first Codex run made sly the run launcher and approver. Fixed by the per-run launcher wording. + - Reruns: both hosts FIRED herdr, loaded `reference/fan-out.md`, kept tim as working lead and run launcher, and gave sly no position. Transcripts: `/tmp/tim-probe-codex.txt`, `/tmp/tim-probe-claude-2.txt`. + - Side effect: the source-loaded probe hook reprovisioned `~/.claude/comitatus` from this branch. The installed plugin's hook recopies on its next session start (hash mismatch). +- Validation after expansion: comitatus suite passes (jay: full `npm test` exit 0); validate:plugins; claude strict validate (marketplace + comitatus); codex 0.147 add of comitatus 1.0.0 ships only `herd-setup` and `herdr` skills with roles under `herdr/reference/roles`. + +- Run-workflow deletion (operator decision; jay recommended it with evidence): + - Deleted: `scripts/fanout.js`, `fanin.js`, `branch-naming.js` and their tests; `reference/fan-out.md` and `reference/roles/`. + - Deleted the helper verbs `role`, `fanout`, `wait-all`, `state`, `settled`, `fan-in`, `teardown`, plus `wait` and `send-wait-read`, which duplicated native verbs. `seed --wait` now calls `herdr agent wait --until idle --until done`. + - `herd-setup`: `HELPER_VERBS` trimmed and `GATED_VERBS` removed (no gated verbs remain). The hook drops its roles line and still removes a stale `~/.claude/comitatus/skills/fan-out` once the herdr copy lands. + - herdr SKILL.md, "roles and multi-agent work": + - one writer per tree; + - a timeout is not a failure to deliver (only `undeliverable` proves nothing was sent); + - idle, a wait or a commit is not completion (reply plus criteria and evidence); + - inspect a partial launch before retrying; + - integrate in a stated order from a clean target tree; the target-tree owner resolves or aborts conflicts. + - Cleanup adjudication: inspect dirty, untracked and unmerged work, then decide integrate, preserve or discard, and name each discard. `--force`/`-D` only for an explicit discard. Cleanup recipes no longer default to `--force`/`-D`. + - Removed orphan tests; added tests that the removed verbs are unknown commands, that `seed --wait` uses native wait, and that a stale fan-out copy is removed only after the herdr copy lands. + - The fan-in conflict-ownership backlog item is deleted (`.pipeline/backlog.md` and its item file, both created earlier this session). `fanin.js` is gone, and the ownership rule now lives in the skill. + - Validation: full `npm test` passes (comitatus suite passes); validate:plugins; claude strict validate; codex 0.147 add of 1.0.0 ships skills `herd-setup` and `herdr`, scripts `herd.js` and `up.js`, references `names.md` and `protocol.md`; `herd.js wait` returns "unknown command"; `git diff --check` clean. Behavioural host probes: jay owns them. + +- Final audit (jay) passed on the finished tree: + - comitatus suite passes, validate:plugins and `git diff --check`, all re-run independently by jay; no active references to removed helpers remain in source, dispatch or docs. + - Fresh host probes exited 0 and FIRED herdr on Claude and Codex. Jay read the complete answers, not just the invocation: + - no blind resend, and no completion inferred from idle or a commit; + - a partial launch is inspected before retrying; + - the integration-tree owner handles a conflict; + - useful evidence and changes are preserved, and an authorized failed experiment is explicitly discarded without extra approval; + - sly gets no approval role. + - Transcripts: `/tmp/jay-simplification-claude-full.txt` and `/tmp/jay-simplification-codex-full.txt`. Raw Claude events are in `/tmp/jay-simplification-claude.events.jsonl`. The Codex wrapper output `/tmp/jay-simplification-codex.txt` is truncated; the full answer was recovered from its rollout. + - Optional, harmless: two test comments still describe the moved runbook or fanout/fanin. + +- Doc pass (tim's files): + - `vernaculus/references/launch-surface.md` now opens with a "changed in 1.0.0" notice: default models, `--no-daemon`, removed waits, and naming the model when routing a local Codex provider. Its line citations are pinned to 0.14.1 (`1e4ca45`), and its two current-behaviour claims (bare Codex inherits; the wait defaults) are corrected in place. + - `local-model-delegation-findings.md` is scoped to the snapshot. The "auto-injected" claim is corrected: the hook injects an orientation and the skill loads on invocation. Fan-out is marked removed. + - vernaculus 0.2.0 -> 0.2.1. + - `comitatus/README.md` documents the seed/launcher default and the hook's deletion of a stale fan-out copy. + - `AGENTS.md` lists every marketplace plugin (no count, which is how "six" went stale), with modus and vernaculus in the owning-plugin line and `test:vernaculus` in the test commands. + - `mantra/README.md` intro (jay found it): rules are injected by session hooks, not loaded via native `.claude/rules/`, matching its Features section and `hooks/behavior.js`. mantra 0.6.2 -> 0.6.3. test:mantra and claude strict validate of mantra pass. + - Checks: full `npm test` green; validate:plugins; claude strict validate for the marketplace, comitatus and vernaculus; codex 0.147 add of comitatus 1.0.0 and vernaculus 0.2.1; `git diff --check` clean. + +- Doc pass (jay's files): root `README.md`; `research/comitatus-recommendations.md`, `research/herdr-briefing.md`, `research/herdr-plugins-catalog.md`; `docs/plans/2026-06-21-comitatus-design.md`, `docs/plans/2026-06-21-comitatus-implementation.md`, `docs/plans/2026-08-08-cross-model-plugin-architecture.md`; `docs/superpowers/specs/2026-06-22-herd-up-launcher-design.md`, `docs/superpowers/specs/2026-06-22-herd-naming-design.md`; `docs/superpowers/plans/2026-06-22-herd-up-launcher.md`, `docs/superpowers/plans/2026-06-24-herd-permissions-friction.md`, `docs/superpowers/plans/2026-06-22-comitatus-retro-fixes.md`; `docs/reviews/fan-out-skill-manifest.md`, `docs/reviews/2026-08-09-cross-model-migration-review.md` and its round-2 and round-3 files. + - The root README covers every plugin, Node 24, the Jest/workspace checks, the current comitatus defaults and removed verbs, and hook discovery. It links to the owning guidance rather than duplicating it. + - Historical docs get a snapshot notice linking the current guides; their bodies are unchanged. Jay link-checked them. +- Counts removed at the operator's prompt: `AGENTS.md` no longer states a plugin count, and this log reports "passes" instead of test totals. +- Brittle-claim sweep (operator asked why counts and line numbers persist): + - `mantra/README.md` drops the ungrounded "turn 47", "turn 1 to turn 100" and "~89%" claims and keeps the configured refresh cadence. + - `launch-surface.md` replaces its new current-behaviour `SKILL.md` line citation with a heading link; pinned 0.14.1 citations stay as historical evidence. + - Jay removed decorative totals from the root README. +- Link check on my touched docs: every relative link and anchor resolves except `AGENTS.md` -> `.pipeline/backlog.md`. That link is pre-existing on main, which has no `.pipeline/` directory; per the override the file is created on first use. The one I created was deleted when its only item went moot. Resolved per jay: `AGENTS.md` and `CLAUDE.md` now give the path as literal code and say the index and item directory are created on first use. +- Final whole-tree check on the finished tree (tim): + - `npm test` exits 0 with no failures, and validate:plugins passes. + - Strict Claude validation passes for the marketplace root and for each marketplace plugin, run separately. + - An isolated Codex 0.147 install succeeds for every plugin with a Codex manifest. + - `git diff --check` is clean. + +- Jay's final documentation audit passes: links and anchors in current repository guides, marketplace READMEs and canonical skills resolve; install and skill names match source; historical bodies are unchanged; `git diff --check` is clean. Ready for operator handoff. + +- Live `up` launch (operator asked, 2026-10-08), run from this branch's `herd.js`: + - `up --branch chore/up-live-test --base origin/main --claude ned --codex kit` succeeded and reported `claude-opus-5-5` and `gpt-6.1-sol`. + - The live processes were `claude --model claude-opus-5-5` and `codex --no-daemon --model gpt-6.1-sol`. The panes showed Opus 5.5 and GPT-6.1-Sol, and both agents were idle in the new worktree. + - `seed` sent from tim with roster `ned,kit`: both seeds were observed and named ned as working lead and tim as the outside launcher. Ned (claude) and kit (codex) each replied through the helper; kit used its installed Codex plugin copy (0.14.1) of the helper. Native `herdr agent wait --until idle --until done` returned for both. + - Cleanup by the new rule: no commits ahead of `origin/main`. The only untracked file was memento's auto-created session file for the test branch, discarded as a test-only artifact. After that, `herdr worktree remove` ran without `--force` (`"forced":false`) and `git branch -d` succeeded; the agents are gone. + - Open observation: kit's Codex TUI warned that the installed vernaculus MCP server failed its handshake ("connection closed: initialize response"). + - Not established whether `--no-daemon` is involved: without the shared daemon, codex starts MCP servers itself. + - `codex exec` printed no MCP warning with or without `--no-daemon`, and `~/.codex/log` has no entry, so that comparison is inconclusive. + - To settle it, launch a codex TUI with and without `--no-daemon` and compare the warnings panel (f2). +- Fixed a stale recipe found during the live test: `reference/names.md` piped `herdr agent list` into `node "$H" members`, but the helper never reads stdin and the skill forbids shell variables. It now says `node HERD members`. + +## Next Steps +- Operator decides commit/PR; none is authorized. Bumps on this branch: comitatus 0.14.1 -> 1.0.0, vernaculus 0.2.0 -> 0.2.1, mantra 0.6.2 -> 0.6.3. The comitatus bump covers Codex `--no-daemon`, the default models, the launcher outside the herd, the deletion of the fan-out skill and run-workflow layer, and the new herdr multi-agent and cleanup guidance. +- Reinstall comitatus, vernaculus and mantra to make these versions active. +- Open: whether the vernaculus MCP handshake failure seen in a `--no-daemon` codex TUI depends on `--no-daemon` (see log). +- Side effect: source-loaded probe hooks reprovisioned `~/.claude/comitatus` from this branch. The installed plugin's hook recopies on its next herdr session start (hash mismatch). diff --git a/AGENTS.md b/AGENTS.md index b77ce5d5..2ab032e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file gives coding agents the repository-specific context needed to work saf ## Project overview -Claude Domestique is a Claude Code plugin marketplace. It contains six independently installable plugins: +Claude Domestique is a Claude Code plugin marketplace. Its independently installable plugins are: - `memento`: branch-based session persistence - `mantra`: behavioral rules and context refresh @@ -12,6 +12,8 @@ Claude Domestique is a Claude Code plugin marketplace. It contains six independe - `agent-artifex`: guidance for designing and testing AI services - `comitatus`: herdr orchestration workflows - `stilus`: prose drafting, editing, and review tools +- `modus`: carrying a work item through to working behavior and judging when it is finished +- `vernaculus`: delegating well-specified coding work to a locally hosted model over MCP The repository is an npm workspace using CommonJS and targets Node.js 24. The root `package.json` declares `engines.node` as `>=24`, `.npmrc` sets `engine-strict=true` so an older runtime fails `npm install` rather than warning, `.nvmrc` pins 24 for local shells, and CI tests 24. Nothing verifies an older runtime, so do not reintroduce an older floor without adding a matrix entry for it. @@ -32,12 +34,12 @@ The repository is an npm workspace using CommonJS and targets Node.js 24. The ro - `.agents/skills/`: skills shared across hosts that read the `.agents` convention, currently Codex - `.codex/`: Codex session hooks and the project-level feature flag that enables them -Not every plugin uses every directory. `agent-artifex` and `stilus` are primarily prompt/documentation plugins and currently have no Jest suites. +Not every plugin uses every directory. `agent-artifex`, `stilus`, and `modus` are primarily prompt/documentation plugins and currently have no Jest suites. ## Working principles - Inspect the nearest README, tests, and existing implementation before changing a plugin. -- Keep concepts in their owning plugin. Session lifecycle belongs to `memento`; behavioral and formatting rules to `mantra`; git and work-item behavior to `onus`; herdr orchestration to `comitatus`; AI-service guidance to `agent-artifex`; prose guidance to `stilus`. +- Keep concepts in their owning plugin. Session lifecycle belongs to `memento`; behavioral and formatting rules to `mantra`; git and work-item behavior to `onus`; herdr orchestration to `comitatus`; AI-service guidance to `agent-artifex`; prose guidance to `stilus`; work-item completion to `modus`; local-model delegation to `vernaculus`. - Prefer cross-references to duplicated guidance. Each behavior should have one source of truth. - Treat this repository as self-referential: source files here are not necessarily the installed plugin instances active in the current agent session. Source changes do not take effect until a plugin is rebuilt/reinstalled. - Do not edit generated `memento/lib/shared.js` or `onus/lib/shared.js` directly. Edit `shared/index.js`, then run `npm run build`. @@ -68,6 +70,7 @@ npm run test:mantra npm run test:memento npm run test:onus npm run test:comitatus +npm run test:vernaculus ``` Run the CI-equivalent coverage suites: @@ -328,8 +331,9 @@ bd prime # Refresh Beads context ## Task tracking is on the filesystem, not in beads (current override) By operator instruction, 2026-09-09: **do not file beads.** Work items, todos, and -deferred findings go to [`.pipeline/backlog.md`](.pipeline/backlog.md) and one file -per item under `.pipeline/backlog/`. This overrides "Use `bd` for all task tracking" +deferred findings go to `.pipeline/backlog.md` and one file +per item under `.pipeline/backlog/`; the index and the item directory are created on +first use. This overrides "Use `bd` for all task tracking" in the managed blocks above. Those blocks stay as written, for when the override is lifted. diff --git a/CLAUDE.md b/CLAUDE.md index ef4fb930..7edaf771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,8 +92,9 @@ This section sits outside the managed markers deliberately. Editing the block ab ## Task tracking is on the filesystem, not in beads (current override) By operator instruction, 2026-09-09: **do not file beads.** Work items, todos, and -deferred findings go to [`.pipeline/backlog.md`](.pipeline/backlog.md) and one file -per item under `.pipeline/backlog/`. This overrides "Use `bd` for ALL task tracking" +deferred findings go to `.pipeline/backlog.md` and one file +per item under `.pipeline/backlog/`; the index and the item directory are created on +first use. This overrides "Use `bd` for ALL task tracking" in the managed block above, and the `bd remember` line with it — persistent knowledge goes to the same place. diff --git a/README.md b/README.md index 14d5b82a..de5f5f99 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ Helps developers embody Flexion fundamentals across conversation boundaries: > "I told you. You agreed. You forgot. Repeat." -You've written the perfect CLAUDE.md. Claude reads it. Claude agrees. By turn 47, Claude ignores half of it. **mantra** provides curated behavioral rules that are automatically loaded via Claude Code's native `.claude/rules/` mechanism—ensuring consistent behavior from turn 1 to turn 100. +You've documented your project conventions. Claude reads them, then drifts as the conversation grows. **mantra** injects curated behavioral rules through lifecycle hooks and refreshes them during long sessions. Helps developers embody Flexion fundamentals throughout long sessions: - **Be skeptical and curious** — Keeps Claude questioning assumptions and seeking evidence, not pattern-matching -- **Never compromise on quality** — Maintains consistent standards from turn 1 to turn 100 +- **Never compromise on quality** — Reinforces project standards throughout long sessions - **Listen with humility** — Enforces peer-not-subordinate stance, deferring to evidence over agreement ### [onus](./onus) — Work-Item Automation @@ -49,12 +49,22 @@ Evidence-based workflows for designing, assessing, implementing, and testing MCP ### [comitatus](./comitatus) — herdr Orchestration -Worktree, workspace, pane, and coding-agent orchestration through the herdr CLI. It remains dormant outside a herdr-managed pane. +Agent messaging, herd initialization, and worktree and agent launches through the herdr CLI. Native herdr and Git handle waiting, reading, and integration; the working lead decides when work is complete and what cleanup may discard. The launcher stays outside the spawned herd by default, and a roster member coordinates it. + +Codex launches use `--no-daemon`; default models are `claude-opus-5-5` and `gpt-6.1-sol`, with explicit overrides supported. Comitatus 1.0.0 removes the separate fan-out skill, role files, and run-workflow helpers. See the [comitatus README](./comitatus/README.md) for the supported commands and migration details. It remains dormant outside a herdr-managed pane. ### [stilus](./stilus) — Writing and Review Remove AI slop and review prose for correctness, voice, human readability, and whether the intended point survives. +### [modus](./modus) — Work-Item Guidance + +Rewrite work items for people, or carry an item through implementation and assess its tests, verification, refactoring, and review evidence. See the [plugin's status](./modus/README.md#status) for the limits of the evidence behind that guidance. + +### [vernaculus](./vernaculus) — Local-Model Delegation + +Delegate well-specified coding work to a locally hosted model through MCP. Its skill guides the caller through reviewing, testing, and refining the draft; it requires a running Ollama server and an installed model. + --- ## How They Work Together @@ -69,12 +79,12 @@ External (GitHub/JIRA/Azure DevOps) [memento] ←── "What's next?" lookup │ ▼ read session context - [mantra] ──► native rules auto-loaded + [mantra] ──► rules injected and refreshed through hooks ``` Each plugin works standalone but gains enhanced behavior when used together. -![All three plugins working together](images/plugins-in-action.png) +![Session persistence, rule refresh, and work-item context working together](images/plugins-in-action.png) *Session resumption showing mantra (context refresh counter), onus (issue tracking), and memento (session file) working together. Claude reads the session file and picks up exactly where the previous conversation left off.* @@ -91,6 +101,8 @@ Each plugin works standalone but gains enhanced behavior when used together. | [Node.js](https://nodejs.org/) | 24+ | All | Runtime for hooks and scripts | | [git](https://git-scm.com/) | 2.x | All | Branch detection, commits, session tracking | +Choose the host you use. [Comitatus](./comitatus/README.md#installing) also needs herdr and its agent integrations; [vernaculus](./vernaculus/README.md#requirements) needs Ollama and an installed model. + ### Platform-Specific (onus) When using `/onus:fetch`, Claude will use these tools to retrieve work items: @@ -115,7 +127,7 @@ When using `/onus:fetch`, Claude will use these tools to retrieve work items: ```bash # Check required tools git --version # git version 2.x -node --version # v18.x or higher +node --version # v24.x or higher claude --version # Claude Code 2.x # Check GitHub CLI (optional, for onus with GitHub) @@ -137,6 +149,8 @@ gh auth status # Verify authentication /plugin install agent-artifex@claude-domestique /plugin install comitatus@claude-domestique /plugin install stilus@claude-domestique +/plugin install modus@claude-domestique +/plugin install vernaculus@claude-domestique ``` ### Codex @@ -149,6 +163,8 @@ codex plugin add onus@claude-domestique codex plugin add agent-artifex@claude-domestique codex plugin add comitatus@claude-domestique codex plugin add stilus@claude-domestique +codex plugin add modus@claude-domestique +codex plugin add vernaculus@claude-domestique ``` Codex users must review and trust plugin hooks before hook-enabled plugins can run lifecycle automation. Skills remain available without trusting hooks. @@ -159,17 +175,18 @@ That's it. No initialization required—plugins load automatically once installe ## How Context Injection Works -Mantra, Memento, Onus, and Comitatus use the shared plugin hook convention for zero-config context injection in both hosts: +Mantra, Memento, Onus, and Comitatus inject context through lifecycle hooks in both hosts. Each plugin defines which events it handles; Codex hooks require the trust described above. | Hook | When | What Gets Injected | |------|------|-------------------| -| **SessionStart** | New conversation | Full context: rules, session file, work item details | -| **UserPromptSubmit** | Every prompt | Status line + periodic refresh (every 10 prompts) | +| **SessionStart** | New conversation | Plugin-specific rules, session or work-item context, or herdr orientation | +| **UserPromptSubmit** | Each prompt, for plugins that handle it | Status and context refresh according to that plugin's rules | This means: - **mantra** injects behavioral rules automatically—no copying files to `.claude/rules/` - **memento** creates session files on first prompt for feature branches - **onus** detects issue numbers from branch names and injects work item context +- **comitatus** injects a short orientation when `HERDR_ENV=1`; agents read the herdr skill for its guidance ### Status Line @@ -184,6 +201,8 @@ Each prompt shows plugin status: Claude Code invokes a plugin skill as `/plugin:skill`; Codex invokes it as `$plugin:skill`. The same `SKILL.md` implements both forms. +Selected entry points are listed below. Each plugin's README explains its usage and requirements. + | Plugin | Command | Description | |--------|---------|-------------| | memento | `/memento:start` | Start new work - creates branch and session together | @@ -196,6 +215,14 @@ Claude Code invokes a plugin skill as `/plugin:skill`; Codex invokes it as `$plu | onus | `/onus:close` | Close a work item | | onus | `/onus:commit` | Create a commit with validation and format guidance | | onus | `/onus:pr` | Create a pull request with validation and format guidance | +| agent-artifex | `/agent-artifex:guide` | Choose an AI-service design, implementation, or assessment workflow | +| comitatus | `/comitatus:herdr` | Manage agents, messages, and herds inside herdr | +| comitatus | `/comitatus:herd-setup` | Configure herdr command permissions | +| stilus | `/stilus:deslop` | Edit prose to remove formulaic phrasing | +| stilus | `/stilus:review` | Review finished prose for correctness and clarity | +| modus | `/modus:human-work-item` | Rewrite an unclear item for a person | +| modus | `/modus:agent-work-item` | Implement an item and assess completion evidence | +| vernaculus | `/vernaculus:delegate-to-local-model` | Delegate and verify a local model's coding draft | --- @@ -209,27 +236,33 @@ Bump plugin versions consistently across all config files: node scripts/bump-version.js # Examples: -node scripts/bump-version.js memento patch # 0.1.10 → 0.1.11 -node scripts/bump-version.js mantra minor # 0.1.5 → 0.2.0 +node scripts/bump-version.js memento patch +node scripts/bump-version.js mantra minor ``` -This updates `package.json`, `plugin.json`, and `marketplace.json` atomically. +This updates the plugin's package version when present, its Claude and Codex manifests, and the marketplace entry. Bump each modified plugin once per branch after substantive edits; repository-only documentation does not need a plugin bump. ### Testing -Each plugin has its own test suite: +Run workspace tests and metadata validation from the repository root: ```bash -cd mantra && npm test # Jest -cd memento && npm test # Node test runner -cd onus && npm test # Jest +npm run install:all +npm test +npm run validate:plugins + +# Focused suites +npm run test:comitatus +npm run test:vernaculus ``` +Plugins with JavaScript have Jest suites; skills-only plugins use manifest, reference, and fresh-host invocation checks. See [AGENTS.md](./AGENTS.md#setup-build-and-tests) for the Claude and Codex validation commands and skill probes. + --- ## Shared Conventions -All plugins agree on this mapping: +Issue-based work follows this repository convention: ``` Issue #42 (tracker) @@ -245,81 +278,9 @@ Session: .claude/sessions/issue-feature-42-description.md ## Rules System -mantra injects behavioral rules via hooks—no file copying required. Rules are frontmatter-only markdown files with compact YAML, injected directly into Claude's context. - -### What mantra Provides - -On every session start, mantra injects: - -| Rule | Purpose | -|------|---------| -| `behavior.md` | AI behavior (skeptical-first, evidence-based) | -| `context-format.md` | Context module format spec | -| `format-guide.md` | Compact YAML conventions | -| `test.md` | Testing conventions (TDD workflow) | -| `git.md` | Git workflow (commits, PRs, branches) | -| `sessions.md` | Session management conventions | -| `work-items.md` | Work item integration | - -Rules from sibling plugins (memento, onus) are automatically included when installed together. - -### Creating Custom Rules - -Use `/mantra:make-rule` to create your own rules: - -1. Write a verbose, human-readable markdown file -2. Run `/mantra:make-rule your-guide.md` -3. Claude converts it to token-efficient frontmatter -4. Identify which rules are CRITICAL (used sparingly) -5. Save to `.claude/rules/your-rule.md` - -### Rule File Format - -Each rule file is a **frontmatter-only markdown file**: - -```markdown ---- -# Project conventions - Compact Reference -companion: project-guide.md - -## Git Workflow -branch: WorkItemID-desc | chore/desc -commit: "WorkItemID - verb desc" (HEREDOC format) -test-before: ./gradlew test - -## Testing -pyramid: unit > integration > e2e -run-before-commit: unit-tests -run-before-PR: integration-tests ---- -``` - -No markdown body—just frontmatter containing compact YAML. - -### Emphasis Markers - -Use sparingly for critical rules: - -| Marker | Use When | -|--------|----------| -| `MANDATORY-REREAD:` | Must re-read before specific actions | -| `## SECTION (BLOCKING REQUIREMENT)` | Entire section is non-negotiable | -| `required-before:` | Must happen before an action | -| `never:` | Absolute prohibitions | -| `enforcement:` | If→then trigger rules | - -### File Format - -Rules use compact YAML optimized for Claude: - -| Pattern | Meaning | Example | -|---------|---------|---------| -| `→` | Flow/sequence | `implement → test → commit` | -| `>` | Priority | `unit > integration > e2e` | -| `\|` | Alternatives | `issue/feature-N/desc \| chore/desc` | -| `:` | Key-value | `language: typescript` | +mantra injects compact behavioral rules through hooks in both hosts and includes rules from installed sibling plugins. Companion documents provide detail on demand. -Keep files compact: 10-30 lines, no prose, one fact per line. +The [mantra README](./mantra/README.md#what-gets-injected) owns the rule inventory and format. Use [make-rule](./mantra/skills/make-rule/SKILL.md) to create custom rules. --- diff --git a/comitatus/.claude-plugin/plugin.json b/comitatus/.claude-plugin/plugin.json index 0350209d..e7e3c305 100644 --- a/comitatus/.claude-plugin/plugin.json +++ b/comitatus/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "comitatus", - "version": "0.14.1", + "version": "1.0.0", "description": "herdr workflows - packages the herdr agent-orchestration skill for claude and codex, auto-injected inside herdr", "author": { "name": "Flexion" diff --git a/comitatus/.codex-plugin/plugin.json b/comitatus/.codex-plugin/plugin.json index 9205147d..300e73bf 100644 --- a/comitatus/.codex-plugin/plugin.json +++ b/comitatus/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "comitatus", - "version": "0.14.1", + "version": "1.0.0", "description": "herdr worktree and coding-agent orchestration workflows", "author": { "name": "Flexion" diff --git a/comitatus/README.md b/comitatus/README.md index dd46a658..74a3d200 100644 --- a/comitatus/README.md +++ b/comitatus/README.md @@ -13,7 +13,7 @@ keeps it from drifting when herdr changes. |-------|-----------|------|------------------| | **herdr** | The terminal-native multiplexer binary (`herdr`), installed from [herdr.dev](https://herdr.dev). | The entire command surface and runtime: worktrees, workspaces, tabs, panes, and agents; `agent start/prompt/read/wait`, `worktree`/`tab`/`pane`/`workspace` verbs; agent **detection integrations** (`~/.codex/herdr-agent-state.sh` and friends) that read each agent's status. The authoritative source for flags — run `herdr --help`. | Any notion of a "herd" as a team, the roster/naming convention, or the agent-to-agent messaging protocol. | | **herdr skill** (`comitatus:herdr`) | A curated workflow skill authored under `skills/herdr/`, invoked from inside a herdr pane. | The *conventions layer* herdr has no opinion on: the herd roster + short-handle naming, the `[from reply\|fyi]` messaging protocol, worktree-herd setup patterns, and the `herd.js` composite verbs. Points at `herdr --help` for CLI flags rather than re-teaching them. | The herdr CLI itself (it calls it), and herdr's detection integrations. | -| **comitatus** (this plugin) | The Claude Code plugin that packages and delivers the skill. | Delivery + activation: the SessionStart hook that gates on `HERDR_ENV=1`, injects orientation, and syncs the skill into `~/.codex/skills/herdr/` for codex agents; the `/herd-setup` permission allow-list; the stable helper path. | herdr's binary, its integrations, and the skill's *content* semantics (it ships the files, it doesn't decide what they say). | +| **comitatus** (this plugin) | The Claude Code plugin that packages and delivers the skill. | Delivery + activation: the SessionStart hook that gates on `HERDR_ENV=1`, injects orientation, provisions a stable copy of the skill under `~/.claude/comitatus/`, and reports whether comitatus is installed as a Codex plugin (it never writes into `~/.codex`); the `/herd-setup` permission allow-list; the stable helper path. | herdr's binary, its integrations, and the skill's *content* semantics (it ships the files, it doesn't decide what they say). | **Rule of thumb:** if it is a herdr command or agent-status mechanic, it belongs to herdr; if it is "how a team of agents coordinates," it belongs to the skill; if it @@ -25,20 +25,24 @@ belongs to comitatus. - The `herdr` skill (invoked `comitatus:herdr`) for driving herdr from inside it: worktrees, workspaces, tabs/panes, agents, messaging, and waiting on state — all over the `herdr` CLI. -- The `fan-out` skill (invoked `comitatus:fan-out`) for partitioning a task across - one architect and multiple implementers, with packaged pipeline role files. + It also carries the multi-agent rules: one writer per tree, evidence-based + completion, ordered integration, and cleanup that adjudicates rather than + sweeps. 1.0.0 removed the `comitatus:fan-out` skill, its role files, and its + run verbs; git and the native `herdr` verbs carry that state now. - A SessionStart hook that is silent unless you are inside herdr (`HERDR_ENV=1`). Inside herdr it injects a short orientation and provisions a stable copy of the - `herdr` skill and the `fan-out` role files under `~/.claude/comitatus/`, so the - helper path and `--roles-dir` it prints survive a comitatus update. It writes + `herdr` skill under `~/.claude/comitatus/`, so the helper path it prints + survives a comitatus update. It writes nothing into the Codex home — a codex agent gets the skill by installing the plugin, and the orientation reports whether it has. -- `herd.js`, a Node helper exposing composite verbs (`status`, `members`, `wait`, - `send`, `send-wait-read`, `agent`, `up`) that each run `herdr` themselves — one - static command in place of a pipe or a poll loop. -- The `herd.js` helper's fan-out verbs (`role`, `fanout`, `wait-all`, `state`, - `fan-in`, `teardown`) support the `fan-out` runbook. `state` reads the run's - phase off git refs and stores nothing — there is no run journal to go stale. +- `herd.js`, a Node helper exposing composite verbs that each run `herdr` + themselves: `status`, `members`, `send` (sender stamping, per-recipient + lock, blocked gate), the herd lifecycle `seed`, `broadcast`, `sync`, + `withdraw`, and the launchers `agent` and `up`. Codex launches always pass + `--no-daemon`; a bare handle launches on `claude-opus-5-5` or `gpt-6.1-sol`. + Waiting and reading are native: `herdr agent wait|read`. `seed` names the + first roster member as working lead, and a launcher that leaves itself off + the roster stays outside the herd it starts. - `/herd-setup`, which merges a safe herdr/git allow-list into your settings to cut permission prompts. @@ -167,11 +171,11 @@ with `$(...)`, pipe into `node ...`, or poll in a loop - shapes Claude Code cannot statically analyze, so each one prompts. Two things reduce this to near zero: -1. **Composite verbs.** `herd.js` exposes `status`, `members`, `wait`, `send`, - `send-wait-read`, and `agent` verbs that run `herdr` themselves - one static +1. **Composite verbs.** `herd.js` exposes `status`, `members`, `send`, the herd + lifecycle verbs, and `agent` verbs that run `herdr` themselves - one static command instead of a pipe or a `while` loop. (`up` already does this for - spinning up a whole worktree.) The fan-out verbs `role`, `fanout`, - `wait-all`, and `state` are the same shape. + spinning up a whole worktree.) For waiting, the native + `herdr agent wait --until idle --until done` is already one call. 2. **`/herd-setup`.** Run it once to merge a safe allow-list into your `settings.json` (user scope by default; `--local` or `--project` to change). It allows the safe herdr verbs, `git fetch`, read-only `git status`/`branch`, @@ -192,14 +196,9 @@ machine-specific baked rules out of committed (`--project`) settings. (`herdr worktree remove` *is* allowed: it tears down a herdr worktree, not git history.) Re-running is idempotent and warns on `deny`/`ask` conflicts. -Two helper verbs are **gated** for the same reason: `fan-in` and `teardown`. A -baked rule reads `Bash(node :*)` and so allows whatever that verb -shells out to — the matcher sees the helper invocation, never its children — and -these two reach `git merge`, `git branch -D`, and `worktree remove --force`. -Baking them would route straight around the list above, so they dispatch but -prompt once per call. `herd-setup.js` keeps them in `GATED_VERBS` rather than -`HELPER_VERBS`, and a test holds the union of the two equal to the verbs -`herd.js --help` prints: a verb in neither list is an omission, not a decision. +A test holds `HELPER_VERBS` in `herd-setup.js` equal to the verbs +`herd.js --help` prints, so a verb that dispatches but is not allowed fails the +suite instead of becoming a silent permission prompt. ## Single source of truth @@ -226,6 +225,10 @@ it by hand if you want a clean slate: rm -rf ~/.claude/comitatus ``` +Since 1.0.0 the hook also deletes `~/.claude/comitatus/skills/fan-out`, the copy +of the retired fan-out skill that 0.13 and 0.14 provisioned there. It does so +only after the new herdr copy is in place. + It is staged in a temp dir and swapped into place with an atomic rename, so an agent never reads a half-written skill dir. A run interrupted by a hard kill (SIGKILL) can rarely leave an inert staging dir like diff --git a/comitatus/__tests__/fanin.test.js b/comitatus/__tests__/fanin.test.js deleted file mode 100644 index 1277d587..00000000 --- a/comitatus/__tests__/fanin.test.js +++ /dev/null @@ -1,730 +0,0 @@ -const b = require('../skills/herdr/scripts/fanin.js'); - -// Partition `fanin` of run fanout-comitatus. These are the contract; the module -// ships as a stub whose placeholder returns make every assertion below print the -// value it got. - -const WRITING_GIT = ['merge', 'branch', 'checkout', 'reset', 'push', 'commit', 'rebase', 'switch']; - -function isWrite(call) { - if (call[0] !== 'git') return false; - const verb = call[1]; - if (verb === 'branch') return call.includes('-D') || call.includes('-d') || call.includes('--delete'); - return WRITING_GIT.includes(verb); -} - -// Responses are matched on the joined argv, so a test declares exactly the git -// surface it expects and anything else comes back empty. -function gitHerd(responses = {}, { onCall } = {}) { - const calls = []; - const run = (file, args) => { - calls.push([file, ...args]); - const key = [file, ...args].join(' '); - if (onCall) { - const hit = onCall(file, args); - if (hit !== undefined) return hit; - } - for (const [pattern, resp] of Object.entries(responses)) { - if (key === pattern) { - if (resp instanceof Error) throw resp; - return typeof resp === 'function' ? resp() : resp; - } - } - return ''; - }; - return { run, calls }; -} - -function deps(over = {}) { - return { env: {}, sleep: () => {}, now: () => 0, cwd: '/wt/task-r7', ...over }; -} - -const AGENTS = (status = 'idle') => JSON.stringify({ - result: { - agents: [ - { name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', agent_status: status, cwd: '/wt/task-r7' }, - ], - }, -}); - -// --------------------------------------------------------------------------- -// criterion 10, 11: stateCmd + derivePhase -// --------------------------------------------------------------------------- - -describe('derivePhase', () => { - // The runbook's "What git holds" table, in order. Each row is a fact git - // already answers; the phase is the highest one satisfied. - test.each([ - [{ started: false }, 'absent'], - [{ started: true, manifest: false }, 'started'], - [{ started: true, manifest: true, partitions: [] }, 'partitioned'], - [{ started: true, manifest: true, partitions: [{ name: 'auth', merged: false }] }, 'fanned-out'], - [{ - started: true, manifest: true, - partitions: [{ name: 'auth', merged: true }, { name: 'ui', merged: false }], - }, 'fanned-out'], - [{ - started: true, manifest: true, - partitions: [{ name: 'auth', merged: true }, { name: 'ui', merged: true }], - }, 'fanned-in'], - [{ started: true, manifest: true, partitions: [], logRow: true }, 'finished'], - ])('%j -> %s', (facts, phase) => { - expect(b.derivePhase(facts)).toBe(phase); - }); - - test('PHASES is the ordered vocabulary derivePhase draws from', () => { - expect(b.PHASES).toEqual( - ['absent', 'started', 'partitioned', 'fanned-out', 'fanned-in', 'finished']); - }); -}); - -describe('stateCmd', () => { - test('--run is required', () => { - expect(() => b.stateCmd([], deps({ run: gitHerd().run }))).toThrow(/--run/); - }); - - test('an unstarted run reports absent without inspecting anything else', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': new Error('not a ref'), - }); - expect(b.stateCmd(['--run', 'r7'], deps({ run: herd.run }))) - .toEqual({ run: 'r7', started: false, manifest: false, partitions: [], logRow: false, phase: 'absent' }); - }); - - test('derives the whole table off refs: manifest, per-partition blocked/probe/merged, log row', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git ls-tree -r --name-only task/r7 -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n.pipeline/runs/r7/task.md\n', - 'git branch --list task/r7-* --format=%(refname:short)': 'task/r7-auth\ntask/r7-ui\n', - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': 'task/r7-auth\n', - 'git ls-tree -r --name-only task/r7-auth -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n', - 'git ls-tree -r --name-only task/r7-ui -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n.pipeline/runs/r7/BLOCKED-ui.md\n', - 'git show task/r7:.pipeline/log.md': '| id | type |\n| r0 | feature |\n', - }); - expect(b.stateCmd(['--run', 'r7'], deps({ run: herd.run }))).toEqual({ - run: 'r7', - started: true, - manifest: true, - partitions: [ - { name: 'auth', branch: 'task/r7-auth', merged: true, blocked: false, probe: false }, - { name: 'ui', branch: 'task/r7-ui', merged: false, blocked: true, probe: false }, - ], - logRow: false, - phase: 'fanned-out', - }); - }); - - test('a committed probe-.md on a hypothesis branch is reported as probe', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git ls-tree -r --name-only task/r7 -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - 'git branch --list task/r7-* --format=%(refname:short)': 'task/r7-h1\n', - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': '', - 'git ls-tree -r --name-only task/r7-h1 -- .pipeline/runs/r7': '.pipeline/runs/r7/probe-1.md\n', - 'git show task/r7:.pipeline/log.md': '', - }); - const out = b.stateCmd(['--run', 'r7'], deps({ run: herd.run })); - expect(out.partitions).toEqual([ - { name: 'h1', branch: 'task/r7-h1', merged: false, blocked: false, probe: true }, - ]); - }); - - test('a log.md row for this run reports finished', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git ls-tree -r --name-only task/r7 -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - 'git branch --list task/r7-* --format=%(refname:short)': '', - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': '', - 'git show task/r7:.pipeline/log.md': '| id | type |\n| r7 | feature | 2 |\n', - }); - const out = b.stateCmd(['--run', 'r7'], deps({ run: herd.run })); - expect(out.logRow).toBe(true); - expect(out.phase).toBe('finished'); - }); - - // A verb the operator runs to find out where they are cannot be a verb that - // moves them. Paired with the read assertion so a no-op stub cannot pass it. - test('reads refs and issues no writing git command', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git ls-tree -r --name-only task/r7 -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - 'git branch --list task/r7-* --format=%(refname:short)': '', - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': '', - 'git show task/r7:.pipeline/log.md': '', - }); - b.stateCmd(['--run', 'r7'], deps({ run: herd.run })); - expect(herd.calls).toContainEqual(['git', 'rev-parse', '--verify', '--quiet', 'task/r7']); - expect(herd.calls.filter(isWrite)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// partition `settle` of run orch-selfhost: parseSettled + settledCmd -// -// `wait-all` reports agent status, and an agent reads `idle` between its own -// turns. So a settled STATUS is not a finished partition; the only durable -// evidence is a ref. These tests are the contract for asking git instead. -// --------------------------------------------------------------------------- - -describe('parseSettled', () => { - test('run and partitions in the order given', () => { - expect(b.parseSettled(['--run', 'r7', '--partitions', 'ui,auth'])) - .toEqual({ run: 'r7', partitions: ['ui', 'auth'], taskBranch: 'task/r7', partitionSep: '-' }); - }); - test('--run is required', () => { - expect(() => b.parseSettled(['--partitions', 'auth'])).toThrow(/--run is required/); - }); - test('--partitions is required', () => { - expect(() => b.parseSettled(['--run', 'r7'])).toThrow(/--partitions is required/); - }); - test('an unknown flag throws rather than being ignored', () => { - expect(() => b.parseSettled(['--run', 'r7', '--partitions', 'a', '--force'])) - .toThrow(/unknown flag: --force/); - }); - test('a flag missing its value throws', () => { - expect(() => b.parseSettled(['--run'])).toThrow(/--run is required/); - }); -}); - -describe('settledCmd', () => { - // A commit on the partition branch that the task branch does not have. That - // is what "the implementer finished" looks like in refs. - const SETTLED = { - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git rev-parse --verify --quiet task/r7-auth': 'def456\n', - 'git rev-parse --verify --quiet task/r7-ui': 'fed654\n', - 'git rev-list --count task/r7..task/r7-auth': '2\n', - 'git rev-list --count task/r7..task/r7-ui': '0\n', - 'git ls-tree -r --name-only task/r7-auth -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - 'git ls-tree -r --name-only task/r7-ui -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - }; - - test('a commit ahead of the task branch is done; no commit is still working', () => { - const herd = gitHerd(SETTLED); - expect(b.settledCmd(['--run', 'r7', '--partitions', 'auth,ui'], deps({ run: herd.run }))) - .toEqual([ - { partition: 'auth', branch: 'task/r7-auth', status: 'done', commits: 2, blocked: undefined }, - { partition: 'ui', branch: 'task/r7-ui', status: 'working', commits: 0, blocked: undefined }, - ]); - }); - - // A worker's verdict lives in a commit or a committed BLOCKED file, never in - // a message: blocked is an ANSWER, not a failure to answer, so it must not - // read as `working` and leave the orchestrator waiting on a finished agent. - test('a committed BLOCKED-

.md is blocked, and outranks the commit that carried it', () => { - const herd = gitHerd({ - ...SETTLED, - 'git rev-list --count task/r7..task/r7-ui': '1\n', - 'git ls-tree -r --name-only task/r7-ui -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n.pipeline/runs/r7/BLOCKED-ui.md\n', - }); - const rows = b.settledCmd(['--run', 'r7', '--partitions', 'ui'], deps({ run: herd.run })); - expect(rows).toEqual([ - { partition: 'ui', branch: 'task/r7-ui', status: 'blocked', commits: 1, blocked: 'BLOCKED-ui.md' }, - ]); - }); - - // Another partition's BLOCKED file merged in from the task branch says - // nothing about THIS partition. Only its own name counts. - test('a BLOCKED file naming a different partition does not block this one', () => { - const herd = gitHerd({ - ...SETTLED, - 'git ls-tree -r --name-only task/r7-auth -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n.pipeline/runs/r7/BLOCKED-ui.md\n', - }); - const rows = b.settledCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run })); - expect(rows[0].status).toBe('done'); - expect(rows[0].blocked).toBeUndefined(); - }); - - // Fan-out reports a partition whose worktree failed to come up. Asking about - // it must answer "not done", not crash the poll loop. - test('a partition branch that does not exist yet is working, with a reason', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': 'abc123\n', - 'git rev-parse --verify --quiet task/r7-auth': new Error('not a ref'), - }); - expect(b.settledCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run }))) - .toEqual([ - { partition: 'auth', branch: 'task/r7-auth', status: 'working', commits: 0, reason: 'no branch' }, - ]); - }); - - test('rows come back in the order asked, not alphabetical', () => { - const herd = gitHerd(SETTLED); - const rows = b.settledCmd(['--run', 'r7', '--partitions', 'ui,auth'], deps({ run: herd.run })); - expect(rows.map((r) => r.partition)).toEqual(['ui', 'auth']); - }); - - test('a missing task branch is an error, not a set of working partitions', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet task/r7': new Error('not a ref'), - }); - expect(() => b.settledCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run }))) - .toThrow(/task\/r7/); - }); - - // The verb an orchestrator polls in a loop, sometimes while an implementer is - // mid-commit. Paired with the read assertion so a no-op stub cannot pass it. - test('answers from refs alone and issues no writing git command', () => { - const herd = gitHerd(SETTLED); - b.settledCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run })); - expect(herd.calls).toContainEqual(['git', 'rev-list', '--count', 'task/r7..task/r7-auth']); - expect(herd.calls.filter(isWrite)).toEqual([]); - expect(herd.calls.filter((c) => c[0] !== 'git')).toEqual([]); // no herdr call: status is not the signal - expect(herd.calls.filter((c) => c[1] === 'fetch')).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// run fanout-branch-naming: --task-branch / --partition-sep -// -// Every verb below used to build `task/${run}` itself. Six independent defaults -// is the risk being removed: two of them disagreeing is a silent wrong-branch, -// not an error, so these tests pin the RESOLVED name each verb actually asks -// git about rather than the flag parsing alone. -// -// A throw is not an assertion. Until the flags exist these calls raise -// `unknown flag: --task-branch`, which names nothing and prints no value, so -// `outcome` converts a throw into a value the diff can show. It stays after the -// fix for the same reason: a regression that throws reads as a diff naming the -// message, not as a stack trace. -// --------------------------------------------------------------------------- - -function outcome(fn) { - try { - return fn(); - } catch (error) { - return { threw: error.message }; - } -} - -describe('--task-branch and --partition-sep', () => { - const CONVENTIONS = [ - ['a slashed prefix that is not task/', 'chore/make-new-readme', 'chore/make-new-readme-api'], - ['a ticket number with no slash at all', '76632-create-new-thing', '76632-create-new-thing-api'], - ['the historical default, unchanged', 'task/r7', 'task/r7-api'], - ]; - - test.each(CONVENTIONS)('%s: settled asks git about the right branch', (_label, taskBranch, partitionBranch) => { - const herd = gitHerd({ - [`git rev-parse --verify --quiet ${taskBranch}`]: 'abc123\n', - [`git rev-parse --verify --quiet ${partitionBranch}`]: 'def456\n', - [`git rev-list --count ${taskBranch}..${partitionBranch}`]: '2\n', - [`git ls-tree -r --name-only ${partitionBranch} -- .pipeline/runs/r7`]: '.pipeline/runs/r7/manifest.md\n', - }); - expect(outcome(() => b.settledCmd( - ['--run', 'r7', '--task-branch', taskBranch, '--partitions', 'api'], - deps({ run: herd.run })))).toEqual([ - { partition: 'api', branch: partitionBranch, status: 'done', commits: 2, blocked: undefined }, - ]); - }); - - // The run id names the artifact directory and nothing else. A ticket-numbered - // branch must not force a ticket-numbered .pipeline/runs/. - test('the run id and the branch name are independent', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet 76632-create-new-thing': 'abc123\n', - 'git rev-parse --verify --quiet 76632-create-new-thing-api': 'def456\n', - 'git rev-list --count 76632-create-new-thing..76632-create-new-thing-api': '1\n', - 'git ls-tree -r --name-only 76632-create-new-thing-api -- .pipeline/runs/readme-rewrite': - '.pipeline/runs/readme-rewrite/BLOCKED-api.md\n', - }); - const rows = outcome(() => b.settledCmd( - ['--run', 'readme-rewrite', '--task-branch', '76632-create-new-thing', '--partitions', 'api'], - deps({ run: herd.run }))); - expect(rows).toEqual([{ - partition: 'api', - branch: '76632-create-new-thing-api', - status: 'blocked', - commits: 1, - blocked: 'BLOCKED-api.md', - }]); - }); - - // Verified against git, not assumed: refs are files, so refs/heads/chore/x and - // refs/heads/chore/x/api cannot coexist, and the task branch always exists here. - // $ git branch chore/make-new-readme && git branch chore/make-new-readme/api - // fatal: cannot lock ref 'refs/heads/chore/make-new-readme/api': - // 'refs/heads/chore/make-new-readme' exists - // Refusing at parse time beats failing at the second `worktree create`, which is - // after the first partition already exists. - test.each([ - ['parseSettled', (args) => b.parseSettled(args)], - ['parseFanin', (args) => b.parseFanin(args)], - ['parseTeardown', (args) => b.parseTeardown(args)], - ])('%s rejects a / separator and says why git refuses it', (_name, parse) => { - const args = ['--run', 'r7', '--partitions', 'api', '--partition-sep', '/']; - expect(() => parse(args)).toThrow(/cannot|refs are files|exists/i); - expect(() => parse(args)).toThrow(/\//); - }); - - // Same reviewer finding, carried to the three parsers in this module. It - // matters most here: teardown composes the name it hands to `git branch -D`, - // so a separator that swallows the next flag deletes a branch nobody named. - test.each([ - ['parseSettled', (args) => b.parseSettled(args)], - ['parseFanin', (args) => b.parseFanin(args)], - ['parseTeardown', (args) => b.parseTeardown(args)], - ])('%s rejects a flag-shaped or wordy separator', (_name, parse) => { - for (const sep of ['--dry-run', 'api', 'x-', '']) { - expect(() => parse(['--run', 'r7', '--partitions', 'api', '--partition-sep', sep])) - .toThrow(/--partition-sep/); - } - }); - - test('a flat separator other than - is honoured', () => { - expect(outcome(() => b.parseSettled( - ['--run', 'r7', '--partitions', 'api', '--partition-sep', '--']))) - .toEqual({ run: 'r7', partitions: ['api'], taskBranch: 'task/r7', partitionSep: '--' }); - }); - - test('fan-in refuses when HEAD is not the RESOLVED task branch', () => { - const herd = gitHerd({ - 'git rev-parse --abbrev-ref HEAD': 'task/r7\n', - }); - expect(() => b.faninCmd( - ['--run', 'r7', '--task-branch', 'chore/make-new-readme', '--partitions', 'api'], - deps({ run: herd.run }))) - .toThrow(/chore\/make-new-readme/); - expect(herd.calls.filter(isWrite)).toEqual([]); - }); - - test('fan-in merges the resolved partition branch, not task/-

', () => { - const herd = gitHerd({ - 'git rev-parse --abbrev-ref HEAD': 'chore/make-new-readme\n', - 'git merge --no-edit chore/make-new-readme-api': 'Fast-forward\n', - }, { onCall: (file, args) => (file === 'herdr' ? AGENTS('idle') : undefined) }); - const out = outcome(() => b.faninCmd( - ['--run', 'r7', '--task-branch', 'chore/make-new-readme', '--partitions', 'api'], - deps({ run: herd.run }))); - expect(out).toEqual({ run: 'r7', merged: ['chore/make-new-readme-api'], conflict: undefined }); - }); - - test('teardown plans the resolved partition branch', () => { - const herd = gitHerd({ - 'git branch --merged chore/make-new-readme --list chore/make-new-readme-* --format=%(refname:short)': - 'chore/make-new-readme-api\n', - 'git ls-tree -r --name-only chore/make-new-readme-api -- .pipeline/runs/r7': '', - }, { - onCall: (file) => (file === 'herdr' ? JSON.stringify({ - result: { worktrees: [{ branch: 'chore/make-new-readme-api', path: '/wt/api', open_workspace_id: 'wX' }] }, - }) : undefined), - }); - expect(outcome(() => b.teardownCmd( - ['--run', 'r7', '--task-branch', 'chore/make-new-readme', '--partitions', 'api'], - deps({ run: herd.run })))).toEqual([ - { partition: 'api', branch: 'chore/make-new-readme-api', workspace: 'wX', action: 'planned', reason: undefined }, - ]); - }); -}); - -describe('state under a non-default naming convention', () => { - test('discovers partitions by globbing the resolved task branch', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet 76632-create-new-thing': 'abc123\n', - 'git ls-tree -r --name-only 76632-create-new-thing -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n', - 'git branch --list 76632-create-new-thing-* --format=%(refname:short)': - '76632-create-new-thing-api\n', - 'git branch --merged 76632-create-new-thing --list 76632-create-new-thing-* --format=%(refname:short)': '', - 'git ls-tree -r --name-only 76632-create-new-thing-api -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n', - 'git show 76632-create-new-thing:.pipeline/log.md': '', - }); - const out = outcome(() => b.stateCmd( - ['--run', 'r7', '--task-branch', '76632-create-new-thing'], deps({ run: herd.run }))); - expect(out.partitions).toEqual([{ - name: 'api', - branch: '76632-create-new-thing-api', - merged: false, - blocked: false, - probe: false, - }]); - expect(out.phase).toBe('fanned-out'); - }); - - // `state` is the only verb that FINDS partitions instead of being told them, and - // freer branch names make the glob likelier to catch something a human made. - // `--partitions` constrains it to what the manifest actually declared. - test('--partitions constrains discovery, so a human sibling branch is not a partition', () => { - const herd = gitHerd({ - 'git rev-parse --verify --quiet 76632-create-new-thing': 'abc123\n', - 'git ls-tree -r --name-only 76632-create-new-thing -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n', - 'git branch --list 76632-create-new-thing-* --format=%(refname:short)': - '76632-create-new-thing-api\n76632-create-new-thing-v2\n', - 'git branch --merged 76632-create-new-thing --list 76632-create-new-thing-* --format=%(refname:short)': '', - 'git ls-tree -r --name-only 76632-create-new-thing-api -- .pipeline/runs/r7': '', - 'git ls-tree -r --name-only 76632-create-new-thing-v2 -- .pipeline/runs/r7': '', - 'git show 76632-create-new-thing:.pipeline/log.md': '', - }); - const out = outcome(() => b.stateCmd( - ['--run', 'r7', '--task-branch', '76632-create-new-thing', '--partitions', 'api'], - deps({ run: herd.run }))); - expect(out.partitions.map((p) => p.name)).toEqual(['api']); - }); -}); - -// --------------------------------------------------------------------------- -// criterion 12, 13, 14: faninCmd -// --------------------------------------------------------------------------- - -describe('parseFanin', () => { - test('defaults: settle arch, 300000ms, live run', () => { - expect(b.parseFanin(['--run', 'r7', '--partitions', 'auth,ui'])).toEqual({ - run: 'r7', - partitions: ['auth', 'ui'], - taskBranch: 'task/r7', - partitionSep: '-', - waitHandle: 'arch', - timeout: 300000, - dryRun: false, - }); - }); - - test('--run and --partitions are required', () => { - expect(() => b.parseFanin(['--partitions', 'a'])).toThrow(/--run/); - expect(() => b.parseFanin(['--run', 'r7'])).toThrow(/--partitions/); - }); - - test('--timeout rejects non-finite and negative values', () => { - for (const timeout of ['wat', 'Infinity', '-1']) { - expect(() => b.parseFanin( - ['--run', 'r7', '--partitions', 'auth', '--timeout', timeout])) - .toThrow(/--timeout.*non-negative/); - } - }); -}); - -describe('faninCmd', () => { - const onTaskBranch = { 'git rev-parse --abbrev-ref HEAD': 'task/r7\n' }; - - // `arch` owns the task branch. Merging from a partition worktree is the - // runbook's prohibition, and the branch name is the only check available. - test('refuses when HEAD is not the task branch, naming what it found', () => { - const herd = gitHerd({ 'git rev-parse --abbrev-ref HEAD': 'task/r7-auth\n' }); - expect(() => b.faninCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run }))) - .toThrow(/task\/r7-auth/); - expect(herd.calls.filter(isWrite)).toEqual([]); - }); - - // Merging under a running agent rewrites the tree it is living in - the same - // hazard as diffing under one. - test('settles the architect before the first merge', () => { - const herd = gitHerd({ - ...onTaskBranch, - 'herdr agent list': AGENTS('idle'), - 'git merge --no-edit task/r7-auth': 'Fast-forward\n', - }); - b.faninCmd(['--run', 'r7', '--partitions', 'auth'], deps({ run: herd.run })); - const listAt = herd.calls.findIndex((c) => c[1] === 'agent' && c[2] === 'list'); - const mergeAt = herd.calls.findIndex((c) => c[1] === 'merge'); - expect(listAt).toBeGreaterThanOrEqual(0); - expect(mergeAt).toBeGreaterThan(listAt); - }); - - test('refuses to merge at all when the architect never settles', () => { - let t = 0; - const herd = gitHerd({ - ...onTaskBranch, - 'herdr agent list': AGENTS('working'), - }); - expect(() => b.faninCmd(['--run', 'r7', '--partitions', 'auth', '--timeout', '1000'], - deps({ run: herd.run, now: () => (t += 600) }))).toThrow(/arch/); - expect(herd.calls.filter((c) => c[1] === 'merge')).toEqual([]); - }); - - test('merges in the order given - manifest order, not alphabetical', () => { - const herd = gitHerd({ - ...onTaskBranch, - 'herdr agent list': AGENTS('idle'), - 'git merge --no-edit task/r7-ui': 'Merge made\n', - 'git merge --no-edit task/r7-auth': 'Merge made\n', - }); - const out = b.faninCmd(['--run', 'r7', '--partitions', 'ui,auth'], deps({ run: herd.run })); - expect(out).toEqual({ - run: 'r7', - merged: ['task/r7-ui', 'task/r7-auth'], - conflict: undefined, - }); - expect(herd.calls.filter((c) => c[1] === 'merge').map((c) => c[3])) - .toEqual(['task/r7-ui', 'task/r7-auth']); - }); - - // Merging past a conflict buries which partition caused it, and the conflict - // is left in the tree because resolving it is the implementer's turn. - test('stops at the first conflict, names the partition and the U paths', () => { - const herd = gitHerd({ - ...onTaskBranch, - 'herdr agent list': AGENTS('idle'), - 'git merge --no-edit task/r7-auth': 'Merge made\n', - 'git merge --no-edit task/r7-ui': (() => { - const e = new Error('Command failed'); - e.status = 1; - e.stdout = 'CONFLICT (content): Merge conflict in comitatus/skills/herdr/scripts/herd.js\n'; - return e; - })(), - 'git diff --name-only --diff-filter=U': 'comitatus/skills/herdr/scripts/herd.js\n', - }); - const out = b.faninCmd(['--run', 'r7', '--partitions', 'auth,ui,extra'], deps({ run: herd.run })); - expect(out.merged).toEqual(['task/r7-auth']); - expect(out.conflict).toEqual({ - partition: 'ui', - branch: 'task/r7-ui', - files: ['comitatus/skills/herdr/scripts/herd.js'], - }); - expect(herd.calls.filter((c) => c[1] === 'merge').map((c) => c[3])) - .toEqual(['task/r7-auth', 'task/r7-ui']); - expect(herd.calls.filter((c) => c[1] === 'merge' && c[2] === '--abort')).toEqual([]); - }); - - test('--dry-run reports the planned order and merges nothing', () => { - const herd = gitHerd({ ...onTaskBranch, 'herdr agent list': AGENTS('idle') }); - const out = b.faninCmd(['--run', 'r7', '--partitions', 'auth,ui', '--dry-run'], - deps({ run: herd.run })); - expect(out).toEqual({ - run: 'r7', - plan: ['task/r7-auth', 'task/r7-ui'], - dryRun: true, - }); - expect(herd.calls.filter(isWrite)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// criterion 15, 16, 17: teardownCmd -// --------------------------------------------------------------------------- - -describe('parseTeardown', () => { - test('destruction is opt-in: yes defaults to false', () => { - expect(b.parseTeardown(['--run', 'r7', '--partitions', 'auth,ui'])).toEqual({ - run: 'r7', - partitions: ['auth', 'ui'], - taskBranch: 'task/r7', - partitionSep: '-', - yes: false, - }); - expect(b.parseTeardown(['--run', 'r7', '--partitions', 'auth', '--yes']).yes).toBe(true); - }); - - test('--run and --partitions are required', () => { - expect(() => b.parseTeardown(['--partitions', 'a'])).toThrow(/--run/); - expect(() => b.parseTeardown(['--run', 'r7'])).toThrow(/--partitions/); - }); -}); - -describe('teardownCmd', () => { - const WORKTREES = JSON.stringify({ - result: { - worktrees: [ - { branch: 'task/r7-auth', path: '/wt/task-r7-auth', open_workspace_id: 'wAuth' }, - { branch: 'task/r7-ui', path: '/wt/task-r7-ui', open_workspace_id: 'wUi' }, - ], - }, - }); - const clean = { - 'herdr worktree list --json': WORKTREES, - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': 'task/r7-auth\ntask/r7-ui\n', - 'git -C /wt/task-r7-auth status --porcelain -- .pipeline': '', - 'git -C /wt/task-r7-ui status --porcelain -- .pipeline': '', - 'git ls-tree -r --name-only task/r7-auth -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - 'git ls-tree -r --name-only task/r7-ui -- .pipeline/runs/r7': '.pipeline/runs/r7/manifest.md\n', - }; - - test('without --yes it destroys nothing and returns the plan', () => { - const herd = gitHerd(clean); - const out = b.teardownCmd(['--run', 'r7', '--partitions', 'auth,ui'], deps({ run: herd.run })); - expect(out).toEqual([ - { partition: 'auth', branch: 'task/r7-auth', workspace: 'wAuth', action: 'planned', reason: undefined }, - { partition: 'ui', branch: 'task/r7-ui', workspace: 'wUi', action: 'planned', reason: undefined }, - ]); - expect(herd.calls.filter(isWrite)).toEqual([]); - expect(herd.calls.filter((c) => c[1] === 'worktree' && c[2] === 'remove')).toEqual([]); - }); - - // `worktree remove` does not delete the branch, and a branch still checked - // out cannot be deleted - so remove first, then delete. - test('--yes removes the worktree then deletes the branch, in that order', () => { - const herd = gitHerd({ - ...clean, - 'herdr worktree remove --workspace wAuth --force --json': JSON.stringify({ result: { type: 'ok' } }), - }); - const out = b.teardownCmd(['--run', 'r7', '--partitions', 'auth', '--yes'], - deps({ run: herd.run })); - expect(out).toEqual([ - { partition: 'auth', branch: 'task/r7-auth', workspace: 'wAuth', action: 'removed', reason: undefined }, - ]); - const removeAt = herd.calls.findIndex((c) => c[1] === 'worktree' && c[2] === 'remove'); - const deleteAt = herd.calls.findIndex((c) => c[1] === 'branch' && c.includes('-D')); - expect(herd.calls[removeAt]).toEqual( - ['herdr', 'worktree', 'remove', '--workspace', 'wAuth', '--force', '--json']); - expect(herd.calls[deleteAt]).toEqual(['git', 'branch', '-D', 'task/r7-auth']); - expect(deleteAt).toBeGreaterThan(removeAt); - }); - - // `--force` discards uncommitted files without warning, and BLOCKED-*.md is - // the trial's evidence base. - test('skips a partition with an UNCOMMITTED BLOCKED file, giving the reason', () => { - const herd = gitHerd({ - ...clean, - 'git -C /wt/task-r7-ui status --porcelain -- .pipeline': - '?? .pipeline/runs/r7/BLOCKED-ui.md\n', - }); - const out = b.teardownCmd(['--run', 'r7', '--partitions', 'auth,ui', '--yes'], - deps({ run: herd.run })); - expect(out[1]).toEqual({ - partition: 'ui', - branch: 'task/r7-ui', - workspace: 'wUi', - action: 'skipped', - reason: 'uncommitted BLOCKED-ui.md', - }); - expect(herd.calls.filter((c) => c[1] === 'branch' && c.includes('-D')).map((c) => c[3])) - .toEqual(['task/r7-auth']); - }); - - // Committed is not safe either: `git branch -D` discards the commit, and a - // BLOCKED file committed on a branch about to be deleted is still lost. - test('skips a partition whose BLOCKED file is committed but not merged into the task branch', () => { - const herd = gitHerd({ - ...clean, - 'git branch --merged task/r7 --list task/r7-* --format=%(refname:short)': 'task/r7-auth\n', - 'git ls-tree -r --name-only task/r7-ui -- .pipeline/runs/r7': - '.pipeline/runs/r7/manifest.md\n.pipeline/runs/r7/BLOCKED-ui.md\n', - }); - const out = b.teardownCmd(['--run', 'r7', '--partitions', 'auth,ui', '--yes'], - deps({ run: herd.run })); - expect(out[1]).toEqual({ - partition: 'ui', - branch: 'task/r7-ui', - workspace: 'wUi', - action: 'skipped', - reason: 'unmerged BLOCKED-ui.md', - }); - expect(herd.calls.filter((c) => c[1] === 'worktree' && c[2] === 'remove').map((c) => c[4])) - .toEqual(['wAuth']); - }); - - test('a partition with no live worktree is reported, not silently dropped', () => { - const herd = gitHerd({ - ...clean, - 'herdr worktree list --json': JSON.stringify({ - result: { - worktrees: [{ branch: 'task/r7-auth', path: '/wt/task-r7-auth', open_workspace_id: 'wAuth' }], - }, - }), - }); - const out = b.teardownCmd(['--run', 'r7', '--partitions', 'auth,ui'], deps({ run: herd.run })); - expect(out[1]).toEqual({ - partition: 'ui', - branch: 'task/r7-ui', - workspace: undefined, - action: 'skipped', - reason: 'no live worktree', - }); - }); -}); diff --git a/comitatus/__tests__/fanout.test.js b/comitatus/__tests__/fanout.test.js deleted file mode 100644 index f24f0686..00000000 --- a/comitatus/__tests__/fanout.test.js +++ /dev/null @@ -1,647 +0,0 @@ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const f = require('../skills/herdr/scripts/fanout.js'); - -// Partition `fanout` of run fanout-comitatus. These are the contract; the module -// ships as a stub whose placeholder returns make every assertion below print the -// value it got. - -const TMP = []; -function tmpdir() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fanout-test-')); - TMP.push(dir); - return dir; -} -afterAll(() => { - for (const dir of TMP) fs.rmSync(dir, { recursive: true, force: true }); -}); - -// A worktree that actually has the committed roles directory, because roleCmd -// resolves the role file against the recipient's cwd and the whole point of the -// check is that a role file present only in the operator's checkout is a -// failure the recipient cannot recover from. -function worktreeWithRoles(roles = ['implementer', 'architect']) { - const dir = tmpdir(); - fs.mkdirSync(path.join(dir, '.pipeline', 'roles'), { recursive: true }); - for (const r of roles) { - fs.writeFileSync(path.join(dir, '.pipeline', 'roles', `${r}.md`), `# ${r}\n`); - } - return dir; -} - -function agentList(agents) { - return JSON.stringify({ result: { agents } }); -} - -// Stateful fake: the roster grows as agents launch, so a handle preflight sees -// what a real `herdr agent list` would see at that moment. -function fakeHerd({ agents = [], cwdOf = () => '/wt/task-x' } = {}) { - const roster = agents.slice(); - const calls = []; - let t = 1; - let p = 1; - const run = (file, args) => { - calls.push([file, ...args]); - if (file === 'herdr' && args[0] === 'agent' && args[1] === 'list') return agentList(roster); - if (file === 'herdr' && args[0] === 'worktree' && args[1] === 'list') { - return JSON.stringify({ result: { source: { source_workspace_id: 'wMain' }, worktrees: [] } }); - } - if (file === 'herdr' && args[0] === 'worktree' && args[1] === 'create') { - const branch = args[args.indexOf('--branch') + 1]; - return JSON.stringify({ - result: { - worktree: { path: `/wt/${branch.replace(/\//g, '-')}`, open_workspace_id: `w-${branch}` }, - root_pane: { pane_id: 'wR:p1' }, - tab: { tab_id: 'wR:t1' }, - }, - }); - } - if (file === 'herdr' && args[0] === 'tab' && args[1] === 'create') { - return JSON.stringify({ - result: { tab: { tab_id: `t${++t}` }, root_pane: { pane_id: `pane${++p}` } }, - }); - } - if (file === 'herdr' && args[0] === 'agent' && args[1] === 'start') { - const name = args[2]; - const paneId = args[args.indexOf('--pane') + 1]; - roster.push({ - name, agent: 'codex', pane_id: paneId, workspace_id: 'wX', - agent_status: 'idle', cwd: cwdOf(name), - }); - return JSON.stringify({ result: { agent: { name, pane_id: paneId } } }); - } - return ''; - }; - return { run, calls, roster }; -} - -function deps(over = {}) { - return { - lockDir: tmpdir(), - env: {}, - sleep: () => {}, - now: () => 0, - cwd: '/wt/task-x', - ...over, - }; -} - -// --------------------------------------------------------------------------- -// criterion 1, 2: roleLine -// --------------------------------------------------------------------------- - -describe('roleLine', () => { - test('the architect form: role by path, then $RUN', () => { - expect(f.roleLine({ role: 'architect', run: 'fanout-comitatus' })) - .toBe('read .pipeline/roles/architect.md and follow it. $RUN is fanout-comitatus'); - }); - - test('$PARTITION is appended after $RUN, comma separated', () => { - expect(f.roleLine({ role: 'implementer', run: 'fanout-comitatus', partition: 'auth' })) - .toBe('read .pipeline/roles/implementer.md and follow it. $RUN is fanout-comitatus, $PARTITION is auth'); - }); - - test('$HYPOTHESIS is appended the same way, for the debug path', () => { - expect(f.roleLine({ role: 'probe', run: 'bug-7', hypothesis: '2' })) - .toBe('read .pipeline/roles/probe.md and follow it. $RUN is bug-7, $HYPOTHESIS is 2'); - }); - - test('rolesDir is overridable for a repo that keeps the roles elsewhere', () => { - expect(f.roleLine({ - role: 'architect', run: 'x', rolesDir: 'modus/skills/agent-work-item/roles', - })).toBe('read modus/skills/agent-work-item/roles/architect.md and follow it. $RUN is x'); - }); - - test('the default rolesDir is the runbook\'s .pipeline/roles', () => { - expect(f.DEFAULT_ROLES_DIR).toBe('.pipeline/roles'); - expect(f.roleLine({ role: 'reviewer', run: 'x' })) - .toContain(`read ${f.DEFAULT_ROLES_DIR}/reviewer.md`); - }); - - // A newline in a prompt submits the turn, so a two-line role assignment - // delivers only its first half. Asserted as an exact single-line string - // rather than a newline count, which a placeholder '' would satisfy. - test('every form is exactly one line', () => { - const lines = [ - f.roleLine({ role: 'architect', run: 'x' }), - f.roleLine({ role: 'implementer', run: 'x', partition: 'p' }), - f.roleLine({ role: 'probe', run: 'x', hypothesis: '1' }), - ]; - expect(lines).toEqual([ - 'read .pipeline/roles/architect.md and follow it. $RUN is x', - 'read .pipeline/roles/implementer.md and follow it. $RUN is x, $PARTITION is p', - 'read .pipeline/roles/probe.md and follow it. $RUN is x, $HYPOTHESIS is 1', - ]); - }); - - test('a missing role or run is refused rather than interpolated as undefined', () => { - expect(() => f.roleLine({ run: 'x' })).toThrow(/role/); - expect(() => f.roleLine({ role: 'architect' })).toThrow(/run/i); - }); -}); - -// --------------------------------------------------------------------------- -// criterion 3, 4: roleCmd -// --------------------------------------------------------------------------- - -describe('parseRole', () => { - test('handle is positional; role, run, partition, hypothesis are named', () => { - expect(f.parseRole(['impl1', '--role', 'implementer', '--run', 'r7', - '--partition', 'auth', '--roles-dir', 'roles'])).toEqual({ - handle: 'impl1', - role: 'implementer', - run: 'r7', - partition: 'auth', - hypothesis: undefined, - rolesDir: 'roles', - from: undefined, - }); - }); - - test('--role and --run are both required', () => { - expect(() => f.parseRole(['impl1', '--run', 'r7'])).toThrow(/--role/); - expect(() => f.parseRole(['impl1', '--role', 'implementer'])).toThrow(/--run/); - }); -}); - -describe('roleCmd', () => { - test('sends the composed line with --reply and reports the role and line back', () => { - const cwd = worktreeWithRoles(); - const herd = fakeHerd(); - herd.roster.push({ - name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', - agent_status: 'idle', cwd, - }); - const out = f.roleCmd( - ['impl1', '--role', 'implementer', '--run', 'r7', '--partition', 'auth', '--from', 'arch'], - deps({ run: herd.run })); - - expect(out.role).toBe('implementer'); - expect(out.line) - .toBe('read .pipeline/roles/implementer.md and follow it. $RUN is r7, $PARTITION is auth'); - expect(out.delivery).toBe('observed'); - - const prompt = herd.calls.find((c) => c[1] === 'agent' && c[2] === 'prompt'); - expect(prompt).toBeDefined(); - expect(prompt[4]).toMatch( - /^\[from arch reply #[a-f0-9]+\] read \.pipeline\/roles\/implementer\.md and follow it\. \$RUN is r7, \$PARTITION is auth$/); - }); - - // The runbook's own named failure: a file that exists only in the operator's - // main checkout. The recipient's cwd is the only place worth checking, and it - // is on the agent list already. - test('throws naming the path when the role file is absent from the RECIPIENT cwd', () => { - const cwd = worktreeWithRoles(['architect']); // no implementer.md - const herd = fakeHerd(); - herd.roster.push({ - name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', - agent_status: 'idle', cwd, - }); - expect(() => f.roleCmd(['impl1', '--role', 'implementer', '--run', 'r7', '--from', 'arch'], - deps({ run: herd.run }))) - .toThrow(new RegExp(path.join(cwd, '.pipeline/roles/implementer.md').replace(/[.\\]/g, '\\$&'))); - }); - - test('a role file present in the operator cwd but not the recipient cwd still throws', () => { - const operator = worktreeWithRoles(); - const recipient = tmpdir(); // bare worktree: nothing committed under .pipeline - const herd = fakeHerd(); - herd.roster.push({ - name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', - agent_status: 'idle', cwd: recipient, - }); - expect(() => f.roleCmd(['impl1', '--role', 'implementer', '--run', 'r7', '--from', 'arch'], - deps({ run: herd.run, cwd: operator }))) - .toThrow(/implementer\.md/); - }); - - // Delivered by path. Reading the role file and inlining it would make the - // assignment unauditable and blow past the one-line limit. - test('the role file text is never read into the message', () => { - const cwd = worktreeWithRoles(); - fs.writeFileSync(path.join(cwd, '.pipeline', 'roles', 'implementer.md'), - 'SECRET-ROLE-BODY do not paste me\n'); - const herd = fakeHerd(); - herd.roster.push({ - name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', - agent_status: 'idle', cwd, - }); - const out = f.roleCmd(['impl1', '--role', 'implementer', '--run', 'r7', '--from', 'arch'], - deps({ run: herd.run })); - expect(out.line).toBe('read .pipeline/roles/implementer.md and follow it. $RUN is r7'); - expect(out.line).not.toContain('SECRET-ROLE-BODY'); - }); - - test('an unknown handle throws before any prompt is typed', () => { - const herd = fakeHerd(); - expect(() => f.roleCmd(['nobody', '--role', 'implementer', '--run', 'r7', '--from', 'arch'], - deps({ run: herd.run }))).toThrow(/nobody/); - expect(herd.calls.filter((c) => c[2] === 'prompt')).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// criterion 5, 6, 7: fanoutCmd -// --------------------------------------------------------------------------- - -describe('parseFanout', () => { - test('defaults match the runbook: base task/, codex, implementer, impl tab tag', () => { - expect(f.parseFanout(['--run', 'r7', '--partitions', 'auth,ui'])).toEqual({ - run: 'r7', - partitions: ['auth', 'ui'], - taskBranch: 'task/r7', - partitionSep: '-', - base: 'task/r7', - kind: 'codex', - selector: 'model=gpt-5.6-sol,effort=medium', - role: 'implementer', - roleTag: 'impl', - rolesDir: '.pipeline/roles', - timeout: 45000, - }); - }); - - test('--run and --partitions are required', () => { - expect(() => f.parseFanout(['--partitions', 'a,b'])).toThrow(/--run/); - expect(() => f.parseFanout(['--run', 'r7'])).toThrow(/--partitions/); - }); - - test('a duplicate partition name is refused: two worktrees would collide on one branch', () => { - expect(() => f.parseFanout(['--run', 'r7', '--partitions', 'auth,auth'])) - .toThrow(/duplicate/); - }); - - // run fanout-branch-naming. A throw names nothing and prints no value, so a - // not-yet-supported flag is surfaced as a value the diff can show. - const attempt = (args) => { - try { - return f.parseFanout(args); - } catch (error) { - return { threw: error.message }; - } - }; - - test('--task-branch becomes both the base and the partition branch stem', () => { - const cfg = attempt(['--run', 'r7', '--partitions', 'api', '--task-branch', 'chore/make-new-readme']); - expect(cfg.taskBranch).toBe('chore/make-new-readme'); - expect(cfg.base).toBe('chore/make-new-readme'); - expect(cfg.partitionSep).toBe('-'); - }); - - // --base stays independent: fanning a partition off something other than the - // task branch is still legal, and naming the task branch must not silently - // repoint it. - test('an explicit --base still wins over --task-branch', () => { - const cfg = attempt(['--run', 'r7', '--partitions', 'api', - '--task-branch', 'chore/make-new-readme', '--base', 'origin/main']); - expect(cfg.base).toBe('origin/main'); - expect(cfg.taskBranch).toBe('chore/make-new-readme'); - }); - - test('a / separator is refused with git\'s reason, not accepted and failed later', () => { - const args = ['--run', 'r7', '--partitions', 'api', '--partition-sep', '/']; - expect(() => f.parseFanout(args)).toThrow(/cannot|refs are files|exists/i); - }); - - // Reviewer finding, reproduced: `--partition-sep --base` was accepted verbatim - // and composed `task/r7--baseapi`, because this one flag read args[++i] raw - // while every other flag went through requiredText. - // - // The generic validator is not the fix, and that is why the hole existed: - // requiredText rejects anything matching /^--/, which would reject `--`, a - // separator `parseSettled` is required to accept. So the separator needs its - // own rule - punctuation git allows in a ref, and nothing else. `/` keeps its - // specific git-reason message, so that check runs first. - test.each([ - ['--base', 'a flag name'], - ['api', 'a bare word'], - ['x-', 'punctuation mixed with letters'], - ['', 'empty'], - ])('--partition-sep rejects %s (%s)', (sep) => { - expect(() => f.parseFanout(['--run', 'r7', '--partitions', 'api', '--partition-sep', sep])) - .toThrow(/--partition-sep/); - }); - - test('--partition-sep with no value at all throws instead of silently defaulting', () => { - expect(() => f.parseFanout(['--run', 'r7', '--partitions', 'api', '--partition-sep'])) - .toThrow(/--partition-sep/); - }); - - test.each([['-'], ['--'], ['.'], ['_']])('--partition-sep accepts the flat separator %s', (sep) => { - const cfg = f.parseFanout(['--run', 'r7', '--partitions', 'api', '--partition-sep', sep]); - expect(cfg.partitionSep).toBe(sep); - }); -}); - -describe('fanoutCmd branch naming', () => { - test.each([ - ['chore/make-new-readme', 'chore/make-new-readme-api', 'chore/make-new-readme-ui'], - ['76632-create-new-thing', '76632-create-new-thing-api', '76632-create-new-thing-ui'], - ])('%s: each partition worktree is created off the resolved branch', (taskBranch, apiBranch, uiBranch) => { - const cwd = worktreeWithRoles(); - const herd = fakeHerd({ cwdOf: () => cwd }); - let rows; - try { - rows = f.fanoutCmd(['--run', 'r7', '--partitions', 'api,ui', '--task-branch', taskBranch], - { run: herd.run, env: {}, sleep: () => {}, now: () => 0, cwd }); - } catch (error) { - rows = [{ threw: error.message }]; - } - expect(rows.map((r) => r.branch)).toEqual([apiBranch, uiBranch]); - const created = herd.calls - .filter((c) => c[1] === 'worktree' && c[2] === 'create') - .map((c) => c[c.indexOf('--branch') + 1]); - expect(created).toEqual([apiBranch, uiBranch]); - const bases = herd.calls - .filter((c) => c[1] === 'worktree' && c[2] === 'create') - .map((c) => c[c.indexOf('--base') + 1]); - expect(bases).toEqual([taskBranch, taskBranch]); - }); -}); - -describe('fanoutCmd', () => { - test('one worktree and one implementer per partition, branched off the task branch', () => { - const cwdByHandle = {}; - const herd = fakeHerd({ - agents: [{ - name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', - agent_status: 'idle', cwd: '/wt/task-r7', - }], - cwdOf: (name) => cwdByHandle[name] || worktreeWithRoles(), - }); - const out = f.fanoutCmd(['--run', 'r7', '--partitions', 'auth,ui'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - - expect(out).toHaveLength(2); - expect(out.map((r) => r.partition)).toEqual(['auth', 'ui']); - // Call-signs from the pool, not `impl1`/`impl2`: the handle is the - // addressable identity and stays type- and part-agnostic. `arch` is not in - // the pool, so the first two free names are claimed. - expect(out.map((r) => r.handle)).toEqual(['tim', 'jay']); - expect(out.map((r) => r.branch)).toEqual(['task/r7-auth', 'task/r7-ui']); - - const creates = herd.calls.filter((c) => c[1] === 'worktree' && c[2] === 'create'); - expect(creates).toHaveLength(2); - expect(creates[0]).toEqual(expect.arrayContaining( - ['--branch', 'task/r7-auth', '--base', 'task/r7'])); - expect(creates[1]).toEqual(expect.arrayContaining( - ['--branch', 'task/r7-ui', '--base', 'task/r7'])); - - const starts = herd.calls.filter((c) => c[1] === 'agent' && c[2] === 'start'); - expect(starts.map((c) => c[3])).toEqual(['tim', 'jay']); - expect(starts[0]).toEqual(expect.arrayContaining( - ['--kind', 'codex', '--', '--model', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=medium'])); - - // The part rides on the TAB label, where a human scanning the sidebar sees - // it, and never on the handle. `role=` is label-only: it must not reach the - // agent's own CLI args above. - const labels = herd.calls - .filter((c) => c[1] === 'tab' && c[2] === 'create') - .map((c) => c[c.indexOf('--label') + 1]); - expect(labels).toEqual(['tim-impl ◇', 'jay-impl ◇']); - expect(starts[0]).not.toContain('role=impl'); - }); - - test('each implementer is sent its own $PARTITION role line', () => { - const herd = fakeHerd({ - agents: [{ - name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', - agent_status: 'idle', cwd: '/wt/task-r7', - }], - cwdOf: () => worktreeWithRoles(), - }); - f.fanoutCmd(['--run', 'r7', '--partitions', 'auth,ui'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - - const prompts = herd.calls - .filter((c) => c[1] === 'agent' && c[2] === 'prompt') - .map((c) => c[4].replace(/#[a-f0-9]+/, '#ID')); - expect(prompts).toEqual([ - '[from arch reply #ID] read .pipeline/roles/implementer.md and follow it. $RUN is r7, $PARTITION is auth', - '[from arch reply #ID] read .pipeline/roles/implementer.md and follow it. $RUN is r7, $PARTITION is ui', - ]); - }); - - // "claim each one against a live `herdr agent list`". A handle collision - // surfaces only at agent start, after the tab and the worktree exist, so the - // claim has to happen before the first create. With a pool, a name in use is - // skipped rather than collided with - the live list is the only authority on - // what is free, including handles this run did not launch. - test('a call-sign already in use is skipped, not collided with', () => { - const herd = fakeHerd({ - agents: [ - { name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', agent_status: 'idle', cwd: '/wt/task-r7' }, - { name: 'tim', agent: 'codex', pane_id: 'wZ:p1', workspace_id: 'wZ', agent_status: 'idle', cwd: '/wt/stale' }, - { name: 'sly', agent: 'codex', pane_id: 'wY:p1', workspace_id: 'wY', agent_status: 'idle', cwd: '/wt/other' }, - ], - cwdOf: () => worktreeWithRoles(), - }); - const out = f.fanoutCmd(['--run', 'r7', '--partitions', 'auth,ui'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - expect(out.map((r) => r.handle)).toEqual(['jay', 'gus']); - }); - - // The pool is finite and handles are globally unique, so exhaustion is a real - // end state. It has to land before the first worktree for the same reason a - // collision did: afterwards there are trees and tabs to clean up. - test('an exhausted pool throws before any worktree is created', () => { - const pool = f.handlePool(); - const herd = fakeHerd({ - agents: [ - { name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', agent_status: 'idle', cwd: '/wt/task-r7' }, - ...pool.slice(0, pool.length - 1).map((name, i) => ({ - name, agent: 'codex', pane_id: `w${i}:p1`, workspace_id: `w${i}`, agent_status: 'idle', cwd: '/wt/x', - })), - ], - }); - expect(() => f.fanoutCmd(['--run', 'r7', '--partitions', 'auth,ui'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } }))) - .toThrow(/pool exhausted: need 2, 1 free/); - expect(herd.calls.filter((c) => c[1] === 'worktree' && c[2] === 'create')).toHaveLength(0); - }); - - test('--handle-prefix is refused rather than silently renaming tabs', () => { - expect(() => f.parseFanout(['--run', 'r7', '--partitions', 'auth', '--handle-prefix', 'x'])) - .toThrow(/--handle-prefix is gone.*--role-tag/); - }); - - test('--role-tag names the tab part for a non-implementer fan-out', () => { - const herd = fakeHerd({ - agents: [{ name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', agent_status: 'idle', cwd: '/wt/task-r7' }], - cwdOf: () => worktreeWithRoles(['probe']), - }); - f.fanoutCmd(['--run', 'r7', '--partitions', 'h1', '--role', 'probe', '--role-tag', 'prb'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - const labels = herd.calls - .filter((c) => c[1] === 'tab' && c[2] === 'create') - .map((c) => c[c.indexOf('--label') + 1]); - expect(labels).toEqual(['tim-prb ◇']); - }); - - // A bare --selector is a model id, not key=value. Appending the role tag to - // it verbatim would produce one unparseable model. - test('a bare model selector still launches once the role tag is added', () => { - const herd = fakeHerd({ - agents: [{ name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', agent_status: 'idle', cwd: '/wt/task-r7' }], - cwdOf: () => worktreeWithRoles(), - }); - const out = f.fanoutCmd(['--run', 'r7', '--partitions', 'auth', '--selector', 'gpt-5.6-sol'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - expect(out[0].error).toBeUndefined(); - const start = herd.calls.find((c) => c[1] === 'agent' && c[2] === 'start'); - expect(start).toEqual(expect.arrayContaining(['--model', 'gpt-5.6-sol'])); - }); - - // Width 2 with one dead partition should still leave the operator the other - // one. An exception would cost them a worktree that came up fine. - test('a partition that fails is reported and the rest still launch', () => { - let creates = 0; - const base = fakeHerd({ - agents: [{ - name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', - agent_status: 'idle', cwd: '/wt/task-r7', - }], - cwdOf: () => worktreeWithRoles(), - }); - const run = (file, args) => { - if (file === 'herdr' && args[0] === 'worktree' && args[1] === 'create' && creates++ === 0) { - throw new Error('worktree create: branch_exists'); - } - return base.run(file, args); - }; - const out = f.fanoutCmd(['--run', 'r7', '--partitions', 'auth,ui'], - deps({ run, env: { HERDR_PANE_ID: 'wA:p1' } })); - - expect(out).toHaveLength(2); - expect(out[0].partition).toBe('auth'); - expect(out[0].error).toMatch(/branch_exists/); - expect(out[1].partition).toBe('ui'); - expect(out[1].error).toBeUndefined(); - expect(out[1].delivery).toBe('observed'); - }); - - test('a delivery failure preserves the worktree and agent that were launched', () => { - const recipient = tmpdir(); // launch succeeds, but this worktree has no role file - const herd = fakeHerd({ - agents: [{ - name: 'arch', agent: 'claude', pane_id: 'wA:p1', workspace_id: 'wA', - agent_status: 'idle', cwd: '/wt/task-r7', - }], - cwdOf: () => recipient, - }); - - const [row] = f.fanoutCmd(['--run', 'r7', '--partitions', 'auth'], - deps({ run: herd.run, env: { HERDR_PANE_ID: 'wA:p1' } })); - - expect(row).toEqual(expect.objectContaining({ - partition: 'auth', - handle: 'tim', - branch: 'task/r7-auth', - worktree: { path: '/wt/task-r7-auth', workspace_id: 'w-task/r7-auth' }, - agent: expect.objectContaining({ handle: 'tim', kind: 'codex' }), - error: expect.stringMatching(/implementer\.md/), - })); - }); -}); - -// --------------------------------------------------------------------------- -// criterion 8, 9: waitAllCmd -// --------------------------------------------------------------------------- - -describe('parseWaitAll', () => { - test('handles are one comma list; the runbook\'s defaults are idle,done / 900000', () => { - expect(f.parseWaitAll(['impl1,impl2'])).toEqual({ - handles: ['impl1', 'impl2'], - statuses: ['idle', 'done'], - timeout: 900000, - interval: 2000, - }); - expect(f.DEFAULT_WAIT_TIMEOUT_MS).toBe(900000); - }); - - test('at least one handle is required', () => { - expect(() => f.parseWaitAll([])).toThrow(/handle/); - expect(() => f.parseWaitAll(['--status', 'idle'])).toThrow(/handle/); - }); -}); - -describe('waitAllCmd', () => { - test('all handles settled: a row each, in the order given', () => { - const herd = fakeHerd({ - agents: [ - { name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', agent_status: 'idle', cwd: '/a' }, - { name: 'impl2', agent: 'codex', pane_id: 'w2:p1', workspace_id: 'w2', agent_status: 'done', cwd: '/b' }, - ], - }); - expect(f.waitAllCmd(['impl2,impl1'], deps({ run: herd.run }))).toEqual([ - { handle: 'impl2', status: 'done', settled: true }, - { handle: 'impl1', status: 'idle', settled: true }, - ]); - }); - - // One `agent list` per round for the whole set. N native waits cost N sockets - // and serialise on the slowest handle. - test('one agent list per round, not one wait per handle', () => { - let round = 0; - const statuses = [ - { impl1: 'working', impl2: 'working' }, - { impl1: 'idle', impl2: 'working' }, - { impl1: 'idle', impl2: 'done' }, - ]; - const calls = []; - const run = (file, args) => { - calls.push([file, ...args]); - if (file === 'herdr' && args[0] === 'agent' && args[1] === 'list') { - const s = statuses[Math.min(round++, statuses.length - 1)]; - return agentList(Object.entries(s).map(([name, agent_status]) => ({ - name, agent: 'codex', pane_id: `${name}:p1`, workspace_id: name, agent_status, cwd: '/x', - }))); - } - return ''; - }; - const slept = []; - const out = f.waitAllCmd(['impl1,impl2'], - deps({ run, sleep: (ms) => slept.push(ms), now: () => 0 })); - - expect(out).toEqual([ - { handle: 'impl1', status: 'idle', settled: true }, - { handle: 'impl2', status: 'done', settled: true }, - ]); - expect(calls.filter((c) => c[1] === 'agent' && c[2] === 'list')).toHaveLength(3); - expect(calls.filter((c) => c[1] === 'agent' && c[2] === 'wait')).toHaveLength(0); - expect(slept).toEqual([2000, 2000]); - }); - - // An exception reports the first failure and hides the rest. At width 2 that - // is half the information the operator needs to decide what to re-send. - test('a timeout does not throw and does not suppress the handles that settled', () => { - const herd = fakeHerd({ - agents: [ - { name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', agent_status: 'idle', cwd: '/a' }, - { name: 'impl2', agent: 'codex', pane_id: 'w2:p1', workspace_id: 'w2', agent_status: 'working', cwd: '/b' }, - ], - }); - let t = 0; - const out = f.waitAllCmd(['impl1,impl2', '--timeout', '1000'], - deps({ run: herd.run, now: () => (t += 600) })); - expect(out).toEqual([ - { handle: 'impl1', status: 'idle', settled: true }, - { handle: 'impl2', status: 'working', settled: false }, - ]); - }); - - test('an unknown handle reports undefined status rather than throwing', () => { - const herd = fakeHerd({ - agents: [{ name: 'impl1', agent: 'codex', pane_id: 'w1:p1', workspace_id: 'w1', agent_status: 'idle', cwd: '/a' }], - }); - let t = 0; - const out = f.waitAllCmd(['impl1,ghost', '--timeout', '1000'], - deps({ run: herd.run, now: () => (t += 600) })); - expect(out).toEqual([ - { handle: 'impl1', status: 'idle', settled: true }, - { handle: 'ghost', status: undefined, settled: false }, - ]); - }); -}); diff --git a/comitatus/__tests__/herd-setup.test.js b/comitatus/__tests__/herd-setup.test.js index 66ceab20..93728d39 100644 --- a/comitatus/__tests__/herd-setup.test.js +++ b/comitatus/__tests__/herd-setup.test.js @@ -14,7 +14,7 @@ describe('SAFE_ALLOW', () => { 'Bash(herdr agent send:*)', 'Bash(herdr wait:*)', // removed in herdr 0.7.5 'Bash(git branch:*)', 'Bash(git reset:*)', 'Bash(git checkout:*)', 'Bash(git push:*)', 'Bash(git worktree remove:*)', - 'Bash(git merge:*)']) { // fan-in shells to it; the verb is gated instead + 'Bash(git merge:*)']) { expect(s.SAFE_ALLOW).not.toContain(bad); } }); @@ -25,12 +25,14 @@ describe('bakedHerdRules', () => { const rules = s.bakedHerdRules('/Users/x'); const base = '/Users/x/.claude/comitatus/skills/herdr/scripts/herd.js'; expect(rules).toContain(`Bash(node ${base} send:*)`); - expect(rules).toContain(`Bash(node ${base} wait:*)`); - expect(rules).toContain(`Bash(node ${base} send-wait-read:*)`); expect(rules).toContain(`Bash(node ${base} up:*)`); // the quickstart verb expect(rules).not.toContain(`Bash(node ${base}:*)`); // no blanket expect(rules).not.toContain(`Bash(node ${base} pane:*)`); // verb removed with stdin piping expect(rules).not.toContain(`Bash(node ${base} submit-keys:*)`); // internal to send now + // removed in 1.0.0: native `herdr agent wait|read` and git replace them + for (const gone of ['wait', 'send-wait-read', 'fanout', 'fan-in', 'teardown']) { + expect(rules).not.toContain(`Bash(node ${base} ${gone}:*)`); + } }); test('the herd-lifecycle verbs are allowed too', () => { @@ -44,8 +46,8 @@ describe('bakedHerdRules', () => { // An agent that hits a permission prompt mid-protocol stalls the herd, and a // stalled lead strands everyone downstream. A verb the helper dispatches that // appears in NEITHER list is exactly that trap, so the union must agree with - // usage(). Being in GATED_VERBS is a decision; being in neither is an omission. - test('every dispatchable verb is accounted for as allowed or gated', () => { + // usage(). + test('every dispatchable verb is allowed', () => { const herd = require('../skills/herdr/scripts/herd.js'); const dispatchable = herd.usage() .split('\n') @@ -56,36 +58,9 @@ describe('bakedHerdRules', () => { expect([...s.DISPATCHABLE_VERBS].sort()).toEqual(dispatchable.sort()); }); - // The exception, stated as a test rather than only as a comment. A baked rule - // allows whatever the verb shells out to, and these two reach `git merge`, - // `git branch -D`, and `worktree remove --force` - the commands SAFE_ALLOW - // withholds directly. Baking them would route around that list. - test('the destructive verbs dispatch but are NOT baked into the allowlist', () => { - const rules = s.bakedHerdRules('/Users/x'); - const base = '/Users/x/.claude/comitatus/skills/herdr/scripts/herd.js'; - expect([...s.GATED_VERBS].sort()).toEqual(['fan-in', 'teardown']); - for (const verb of s.GATED_VERBS) { - expect(s.DISPATCHABLE_VERBS).toContain(verb); - expect(s.HELPER_VERBS).not.toContain(verb); - expect(rules).not.toContain(`Bash(node ${base} ${verb}:*)`); - } - }); - - test('the read-only and launch fan-out verbs ARE baked', () => { - const rules = s.bakedHerdRules('/Users/x'); - const base = '/Users/x/.claude/comitatus/skills/herdr/scripts/herd.js'; - // `fanout` is no more privileged than the `up` it calls, and `up` is baked; - // `state` and `settled` only read refs; `role` is a `send` with a composed - // body. `settled` is polled in a loop between an implementer's turns, so a - // prompt on it would stall the very wait it exists to answer. - for (const verb of ['role', 'fanout', 'wait-all', 'state', 'settled']) { - expect(rules).toContain(`Bash(node ${base} ${verb}:*)`); - } - }); - // The skill's verb line is what an agent reads to learn the surface exists. // A verb missing there is invisible in practice however well it is allowed, - // so it advertises the whole dispatch surface - gated verbs included. + // so it advertises the whole dispatch surface. test('SKILL.md advertises exactly the verbs the helper dispatches', () => { const fs = require('fs'); const path = require('path'); diff --git a/comitatus/__tests__/herd.test.js b/comitatus/__tests__/herd.test.js index a80e592a..f9a33f7f 100644 --- a/comitatus/__tests__/herd.test.js +++ b/comitatus/__tests__/herd.test.js @@ -99,85 +99,23 @@ describe('dispatch (self-contained verbs)', () => { } }); - // Registration is the whole point of this arm: the two fan-out modules are - // reachable only through dispatch, and a `case` that was never added makes a - // fully tested verb unreachable while its own suite stays green. - test('the fan-out verbs are routed, not silently unknown', () => { - for (const verb of ['role', 'fanout', 'wait-all', 'state', 'settled', 'fan-in', 'teardown']) { - expect(() => h.dispatch([verb], deps().deps)).not.toThrow(/unknown command/); - } - }); - - test('each fan-out verb reaches its own module, and argument errors come from there', () => { - // Every one of these fails on its own required-argument check, which only - // the module can raise - so the case arm is wired to the right function. - const cases = [ - [['role'], /role needs a handle/], - [['fanout'], /--run is required/], - [['wait-all'], /at least one handle/], - [['state'], /--run is required/], - [['settled'], /--run is required/], - [['fan-in'], /--run is required/], - [['teardown'], /--run is required/], - ]; - for (const [argv, pattern] of cases) { - expect(() => h.dispatch(argv, deps().deps)).toThrow(pattern); + // 1.0.0 removed the run-workflow verbs and the polling waits; native + // `herdr agent wait|read` and git replace them. Unknown, not silently routed. + test('removed verbs are unknown commands', () => { + for (const verb of ['wait', 'send-wait-read', 'role', 'fanout', 'wait-all', + 'state', 'settled', 'fan-in', 'teardown']) { + expect(() => h.dispatch([verb], deps().deps)).toThrow(/unknown command/); } }); - test('state dispatches to the read-only git verb, not to a herdr call', () => { - const calls = []; - const d = { - run: (f, a) => { - calls.push([f, ...a]); - if (f === 'git' && a[0] === 'rev-parse') throw new Error('not a ref'); - return ''; - }, - }; - expect(h.dispatch(['state', '--run', 'r7'], d)).toEqual({ - run: 'r7', started: false, manifest: false, partitions: [], logRow: false, phase: 'absent', - }); - expect(calls).toEqual([['git', 'rev-parse', '--verify', '--quiet', 'task/r7']]); - }); - - // format() decides how a verb prints. The fan-out verbs that return arrays - // return arrays OF OBJECTS, which must not degrade to [object Object]. - test('teardown plan rows format as JSON, not newline-joined objects', () => { - const rows = [{ partition: 'auth', branch: 'task/r7-auth', action: 'planned' }]; + // format() decides how a verb prints. Verbs that return arrays of per- + // recipient results must not degrade to [object Object]. + test('result rows format as JSON, not newline-joined objects', () => { + const rows = [{ handle: 'jay', delivery: 'observed' }]; expect(h.format(rows)).toBe(JSON.stringify(rows)); }); }); -describe('parseWait', () => { - test('defaults: idle, 45s, 1s interval', () => { - expect(h.parseWait(['jay'])).toEqual({ handle: 'jay', statuses: ['idle'], timeout: 45000, interval: 1000 }); - }); - test('comma statuses and overrides', () => { - expect(h.parseWait(['jay', '--status', 'idle,done', '--timeout', '9000', '--interval', '250'])) - .toEqual({ handle: 'jay', statuses: ['idle', 'done'], timeout: 9000, interval: 250 }); - }); -}); - -describe('waitCmd', () => { - test('returns as soon as the status matches one of the set', () => { - const seq = ['working', 'working', 'done']; let i = 0; - const deps = { - run: () => JSON.stringify({ result: { agents: [{ name: 'jay', agent_status: seq[i++] }] } }), - sleep: () => {}, now: () => 0, - }; - expect(h.waitCmd(['jay', '--status', 'idle,done', '--interval', '1'], deps)).toBe('done'); - }); - test('throws on timeout, reporting the last seen status', () => { - let t = 0; - const deps = { - run: () => JSON.stringify({ result: { agents: [{ name: 'jay', agent_status: 'working' }] } }), - sleep: () => {}, now: () => (t += 1000), - }; - expect(() => h.waitCmd(['jay', '--timeout', '1500', '--interval', '1'], deps)) - .toThrow(/wait timeout: jay is working, want idle/); - }); -}); - describe('resolveSelf', () => { const DATA = { result: { agents: [ { name: 'paul', pane_id: 'w1M:p5' }, @@ -439,53 +377,6 @@ describe('sendCmd', () => { }); }); -describe('sendWaitReadCmd', () => { - function runner() { - const calls = []; - const seq = ['working', 'done']; - let i = 0; - const run = (f, a) => { - calls.push([f, ...a]); - if (a[0] === 'agent' && a[1] === 'list') { - return JSON.stringify({ result: { agents: [ - { name: 'gus', agent: 'claude', pane_id: 'w1:p2', agent_status: seq[Math.min(i++, seq.length - 1)] }, - { name: 'sly', agent: 'claude', pane_id: 'w1:p1', focused: true }, - ] } }); - } - if (a[0] === 'pane' && a[1] === 'read') return 'REVIEW: looks good\n'; - return ''; - }; - return { - calls, - deps: { run, sleep: () => {}, now: () => 0, nonce: () => 'n1', lockDir: freshLockDir(), env: {} }, - }; - } - - test('sends, waits for not-working, reads recent, returns the text', () => { - const { calls, deps } = runner(); - const out = h.sendWaitReadCmd(['gus', 'review please', '--reply', '--from', 'sly', '--lines', '50'], deps); - expect(out).toBe('REVIEW: looks good\n'); - expect(calls).toContainEqual( - ['herdr', 'agent', 'prompt', 'gus', '[from sly reply #n1] review please', '--wait', '--until', 'working', '--timeout', '6000']); - expect(calls).toContainEqual(['herdr', 'pane', 'read', 'w1:p2', '--source', 'recent', '--lines', '50']); - }); - - test('an undeliverable send throws instead of waiting on a message never sent', () => { - const { deps } = runner(); - const inner = deps.run; - deps.run = (f, a) => { - if (a[0] === 'agent' && a[1] === 'prompt') { - const e = new Error('refused'); - e.stderr = JSON.stringify({ error: { code: 'agent_not_found' } }); - throw e; - } - return inner(f, a); - }; - expect(() => h.sendWaitReadCmd(['gus', 'hi', '--reply', '--from', 'sly'], deps)) - .toThrow(/undeliverable to gus: agent_not_found/); - }); -}); - // agent verb: tab create -> agent start --kind/--pane (agent takes over the // tab's root pane) -> wait until the agent is ready. describe('agentCmd', () => { @@ -511,12 +402,13 @@ describe('agentCmd', () => { const { run, calls } = runner(); const out = h.agentCmd(['codex', 'jay', '--workspace', 'wR', '--cwd', '/wt/x'], { run }); expect(out).toEqual({ - handle: 'jay', kind: 'codex', model: null, effort: null, pane_id: 'wR:p2', tab: 'wR:t2', + handle: 'jay', kind: 'codex', model: 'gpt-6.1-sol', effort: null, pane_id: 'wR:p2', tab: 'wR:t2', }); expect(calls).toEqual([ ['herdr', 'agent', 'list'], ['herdr', 'tab', 'create', '--workspace', 'wR', '--cwd', '/wt/x', '--label', 'jay ◇', '--no-focus'], - ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000'], + ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000', + '--', '--no-daemon', '--model', 'gpt-6.1-sol'], ['herdr', 'agent', 'wait', 'jay', '--until', 'idle', '--timeout', '45000'], ]); }); @@ -574,7 +466,7 @@ describe('agentCmd', () => { h.agentCmd(['codex', 'jay:effort=high', '--workspace', 'wR', '--cwd', '/wt/x'], { run }); expect(calls).toContainEqual( ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000', - '--', '-c', 'model_reasoning_effort=high']); + '--', '--no-daemon', '--model', 'gpt-6.1-sol', '-c', 'model_reasoning_effort=high']); }); test('the tab label still uses the bare handle, not the selector', () => { @@ -724,6 +616,17 @@ describe('seed', () => { expect(h.seedLine({ ...LINE, lead: 'pip' })).toMatch(/that is you - you drive the work/); }); + // Outside the herd is not the same as outside the approval chain: a fan-out + // launcher still authorizes fan-in and teardown, so the line defers to the task. + test('an outside launcher is named as neither member nor coordinator', () => { + const line = h.seedLine({ ...LINE, launcher: 'sly' }); + expect(line).toMatch(/sly launched this herd and is outside it/); + expect(line).toMatch(/not a member and not its coordinator - the working lead coordinates/); + expect(line).toMatch(/do not wait on sly unless your task names it as an approver/); + expect(line).not.toMatch(/\n/); + expect(h.seedLine(LINE)).not.toMatch(/launched this herd/); + }); + test('it teaches both flags, the roster directives, and the one-line rule', () => { const line = h.seedLine(LINE); expect(line).toMatch(/--reply/); @@ -772,15 +675,83 @@ describe('seed', () => { const out = h.seedCmd(['pip', '--roster', 'pip,jay', '--helper', '/abs/herd.js', '--from', 'jay', '--wait'], deps); expect(out.turn).toBe('finished'); + // native wait, naming both states: bare `agent wait` also matches blocked + expect(calls).toContainEqual(['herdr', 'agent', 'wait', 'pip', '--until', 'idle', '--until', 'done', + '--timeout', '120000']); expect(calls).toContainEqual(['herdr', 'agent', 'read', 'pip', '--source', 'recent', '--lines', '20']); }); + test('--wait reports a timeout when the native wait fails', () => { + const { deps } = seedRunner(); + const run = deps.run; + deps.run = (f, a) => { + if (a[0] === 'agent' && a[1] === 'wait') throw new Error('timeout'); + return run(f, a); + }; + const out = h.seedCmd(['pip', '--roster', 'pip,jay', '--helper', '/abs/herd.js', + '--from', 'jay', '--wait'], deps); + expect(out.turn).toBe('timeout'); + }); + test('a handle missing from the roster is added rather than silently dropped', () => { const { calls, deps } = seedRunner(); h.seedCmd(['pip', '--roster', 'jay', '--helper', '/abs/herd.js', '--from', 'jay'], deps); expect(calls.find((c) => c[2] === 'prompt')[4]).toMatch(/roster: pip, jay/); }); + // The launcher is outside the herd unless it lists itself: the default lead is + // the first roster member, never the sender, and a lead must be a member. + function seedPrompt(args) { + const { calls, deps } = seedRunner(); + h.seedCmd(['pip', ...args, '--helper', '/abs/herd.js'], deps); + return calls.find((c) => c[2] === 'prompt')[4]; + } + + test('a sender left off the roster is an outside launcher, and the lead is the first member', () => { + const line = seedPrompt(['--roster', 'pip,jay', '--from', 'sly']); + expect(line).toMatch(/^\[from sly reply #s1\] you are pip/); + expect(line).toMatch(/roster: pip, jay/); + expect(line).toMatch(/working lead: pip \(that is you/); + expect(line).toMatch(/sly launched this herd and is outside it/); + }); + + test('every member seeded from one roster agrees on the default lead', () => { + const line = seedPrompt(['--roster', 'jay,pip', '--from', 'sly']); + expect(line).toMatch(/working lead: jay;/); + }); + + test('a sender that lists itself has joined: no launcher clause, lead still roster[0]', () => { + const line = seedPrompt(['--roster', 'jay,pip', '--from', 'jay']); + expect(line).toMatch(/working lead: jay;/); + expect(line).not.toMatch(/launched this herd/); + }); + + test('a solo agent seeded alone leads itself', () => { + const line = seedPrompt(['--roster', 'pip', '--from', 'sly']); + expect(line).toMatch(/your teammates: none yet/); + expect(line).toMatch(/working lead: pip \(that is you/); + }); + + test('an omitted target is prepended, so it becomes the default lead', () => { + const line = seedPrompt(['--roster', 'jay', '--from', 'sly']); + expect(line).toMatch(/roster: pip, jay/); + expect(line).toMatch(/working lead: pip \(that is you/); + }); + + test('an explicit --lead that is a member is honored', () => { + expect(seedPrompt(['--roster', 'pip,jay', '--lead', 'jay', '--from', 'sly'])) + .toMatch(/working lead: jay;/); + }); + + test('a --lead outside the roster is refused before anything is sent, the sender included', () => { + for (const lead of ['sly', 'ghost']) { + const { calls, deps } = seedRunner(); + expect(() => h.seedCmd(['pip', '--roster', 'pip,jay', '--lead', lead, '--from', 'sly', + '--helper', '/abs/herd.js'], deps)).toThrow(/is not in the roster/); + expect(calls.some((c) => c[2] === 'prompt')).toBe(false); + } + }); + test('--roster is required: a seed without one produces an agent that cannot address anyone', () => { const { deps } = seedRunner(); expect(() => h.seedCmd(['pip', '--from', 'jay'], deps)).toThrow(/--roster is required/); @@ -1032,43 +1003,13 @@ describe('usage / --help', () => { test('usage no longer documents stdin or piping', () => { expect(h.usage()).not.toMatch(/stdin|pipe/i); }); - test('usage warns that the native agent wait needs one --until per status, not a comma list', () => { - expect(h.usage()).toMatch(/herdr agent wait.*one\b/i); - }); - test('usage documents the model/effort selector and that a bare handle inherits', () => { + test('usage documents the selector, the default models, --no-daemon, and inherited effort', () => { expect(h.usage()).toMatch(/model=/); expect(h.usage()).toMatch(/effort=/); - expect(h.usage()).toMatch(/inherit/i); - }); - test('usage lists every fan-out verb (the done-when for --help)', () => { - const u = h.usage(); - for (const verb of ['role', 'fanout', 'wait-all', 'state', 'settled', 'fan-in', 'teardown']) { - expect(u).toMatch(new RegExp(`^ {2}${verb.replace('-', '\\-')}\\b`, 'm')); - } - }); - // The distinction the verb exists for. A reader who takes `wait-all` for a - // completion check writes the bug this run is fixing, so usage has to say - // which of the two answers off refs. - test('usage says settled answers from refs, not from agent status', () => { - const entry = /^ {2}settled .*(\n {6}.*)*/m.exec(h.usage()); - expect(entry).not.toBeNull(); - expect(entry[0]).toMatch(/--run/); - expect(entry[0]).toMatch(/refs|commit/); - }); - // run fanout-branch-naming. usage() is where an operator learns the surface - // exists, and the whole point of the flag is repositories that do NOT name - // branches task/ - so a usage text that only ever shows task/ tells - // exactly the reader who needs the flag that the kit does not fit them. - test('usage documents --task-branch and stops presenting task/ as the only shape', () => { - const u = h.usage(); - expect(u).toMatch(/--task-branch/); - expect(u).toMatch(/--partition-sep/); - }); - - // The prompt is the cost of the gate; a reader who does not know it is coming - // reads it as a bug and reaches for --force or a blanket allow rule. - test('usage says which verbs /herd-setup deliberately leaves prompting', () => { - expect(h.usage()).toMatch(/NOT baked/); + expect(h.usage()).toMatch(/claude-opus-5-5/); + expect(h.usage()).toMatch(/gpt-6\.1-sol/); + expect(h.usage()).toMatch(/--no-daemon/); + expect(h.usage()).toMatch(/INHERITS effort/); }); }); diff --git a/comitatus/__tests__/skill-layout.test.js b/comitatus/__tests__/skill-layout.test.js new file mode 100644 index 00000000..bda9bfc4 --- /dev/null +++ b/comitatus/__tests__/skill-layout.test.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const SKILLS = path.join(ROOT, 'skills'); + +// 1.0.0 removed the fan-out skill: a second discoverable skill gave a seeded +// member a second, conflicting account of who leads. Only directories with a +// SKILL.md are discoverable by either host. +test('herdr and herd-setup are the only discoverable skills', () => { + const discoverable = fs.readdirSync(SKILLS) + .filter((name) => fs.existsSync(path.join(SKILLS, name, 'SKILL.md'))) + .sort(); + expect(discoverable).toEqual(['herd-setup', 'herdr']); +}); + +function markdownFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = path.join(dir, e.name); + if (e.isDirectory()) return e.name === 'node_modules' ? [] : markdownFiles(p); + return e.name.endsWith('.md') ? [p] : []; + }); +} + +// Moving the runbook and its roles changed every relative link around them. +test('every relative Markdown link in the plugin resolves', () => { + const broken = []; + for (const file of [...markdownFiles(SKILLS), path.join(ROOT, 'README.md')]) { + const text = fs.readFileSync(file, 'utf8'); + for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) { + if (/^[a-z]+:|^#/.test(target)) continue; + const rel = target.split('#')[0]; + if (!fs.existsSync(path.resolve(path.dirname(file), rel))) { + broken.push(`${path.relative(ROOT, file)} -> ${target}`); + } + } + } + expect(broken).toEqual([]); +}); diff --git a/comitatus/__tests__/up.test.js b/comitatus/__tests__/up.test.js index 8694e626..f493f0ac 100644 --- a/comitatus/__tests__/up.test.js +++ b/comitatus/__tests__/up.test.js @@ -1,19 +1,27 @@ const { parseArgs, makeAgent, startAgent, launchAgent, up } = require('../skills/herdr/scripts/up.js'); describe('makeAgent', () => { - // A bare handle means "inherit ambient config". model/effort report null so a - // caller can tell an inherited setting from a chosen one - the kind name is - // NOT an answer to "which model". - test('claude with no selector: ◆ glyph, no extra args, model and effort null', () => { + // A bare handle gets the kind's default model and inherits effort. effort + // reports null so a caller can tell an inherited setting from a passed one - + // the kind name is NOT an answer to "which model". + test('claude with no selector: ◆ glyph, default Opus 5.5 model, effort null', () => { expect(makeAgent('claude', 'sly')).toEqual({ - handle: 'sly', kind: 'claude', glyph: '◆', model: null, effort: null, role: null, extraArgs: [], + handle: 'sly', kind: 'claude', glyph: '◆', model: 'claude-opus-5-5', effort: null, role: null, + extraArgs: ['--model', 'claude-opus-5-5'], }); }); - test('codex with no selector: ◇ glyph, no extra args, model and effort null', () => { + test('codex with no selector: ◇ glyph, --no-daemon, default gpt-6.1-sol model, effort null', () => { expect(makeAgent('codex', 'jay')).toEqual({ - handle: 'jay', kind: 'codex', glyph: '◇', model: null, effort: null, role: null, extraArgs: [], + handle: 'jay', kind: 'codex', glyph: '◇', model: 'gpt-6.1-sol', effort: null, role: null, + extraArgs: ['--no-daemon', '--model', 'gpt-6.1-sol'], }); }); + // Every codex session runs off the shared daemon, whatever else was selected. + test('codex always passes --no-daemon, explicit model and effort included', () => { + expect(makeAgent('codex', 'jay:model=gpt-5.5,effort=high').extraArgs) + .toEqual(['--no-daemon', '--model', 'gpt-5.5', '-c', 'model_reasoning_effort=high']); + expect(makeAgent('claude', 'sly:model=sonnet').extraArgs).not.toContain('--no-daemon'); + }); // The three CLIs differ in SHAPE, not just flag naming, so each kind gets its // own translator. Verified against the installed CLIs' own --help. @@ -24,25 +32,31 @@ describe('makeAgent', () => { }); }); test('codex effort= becomes a -c config override, not a flag', () => { - expect(makeAgent('codex', 'jay:model=gpt-5.6-sol,effort=high')).toEqual({ - handle: 'jay', kind: 'codex', glyph: '◇', model: 'gpt-5.6-sol', effort: 'high', role: null, - extraArgs: ['--model', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=high'], + expect(makeAgent('codex', 'jay:model=gpt-6.1-sol,effort=high')).toEqual({ + handle: 'jay', kind: 'codex', glyph: '◇', model: 'gpt-6.1-sol', effort: 'high', role: null, + extraArgs: ['--no-daemon', '--model', 'gpt-6.1-sol', '-c', 'model_reasoning_effort=high'], }); }); - test('either key alone is enough; the other stays inherited', () => { - expect(makeAgent('claude', 'sly:effort=xhigh').extraArgs).toEqual(['--effort', 'xhigh']); - expect(makeAgent('claude', 'sly:effort=xhigh').model).toBeNull(); - expect(makeAgent('codex', 'jay:model=gpt-5.6-sol').extraArgs).toEqual(['--model', 'gpt-5.6-sol']); - expect(makeAgent('codex', 'jay:model=gpt-5.6-sol').effort).toBeNull(); + // An explicit model replaces the default; effort= alone keeps it. + test('either key alone is enough: effort alone keeps the default model, model alone inherits effort', () => { + expect(makeAgent('claude', 'sly:effort=xhigh').extraArgs) + .toEqual(['--model', 'claude-opus-5-5', '--effort', 'xhigh']); + expect(makeAgent('codex', 'jay:effort=high').extraArgs) + .toEqual(['--no-daemon', '--model', 'gpt-6.1-sol', '-c', 'model_reasoning_effort=high']); + expect(makeAgent('claude', 'sly:model=sonnet').extraArgs).toEqual(['--model', 'sonnet']); + expect(makeAgent('codex', 'jay:model=gpt-5.5').extraArgs).toEqual(['--no-daemon', '--model', 'gpt-5.5']); + expect(makeAgent('codex', 'jay:model=gpt-5.5').effort).toBeNull(); }); // `role=` decorates the TAB so a human can see which part a tab is playing. // It is label-only: it must never reach the agent's own CLI args, and it must // never become part of the handle, which stays the addressable identity. test('role= is a label-only key and does not reach the CLI args', () => { - const a = makeAgent('codex', 'jay:model=gpt-5.6-sol,role=impl'); + const a = makeAgent('codex', 'jay:model=gpt-5.5,role=impl'); expect(a.role).toBe('impl'); expect(a.handle).toBe('jay'); - expect(a.extraArgs).toEqual(['--model', 'gpt-5.6-sol']); + expect(a.extraArgs).toEqual(['--no-daemon', '--model', 'gpt-5.5']); + // role= alone is not a model choice, so the default model still applies + expect(makeAgent('claude', 'sly:role=arch').extraArgs).toEqual(['--model', 'claude-opus-5-5']); }); test('an unsafe role is refused like any other selector value', () => { @@ -282,16 +296,18 @@ describe('startAgent', () => { test('codex model/effort args ride after -- on the start call', () => { const { deps, calls } = runner([ok]); - startAgent(makeAgent('codex', 'jay:model=gpt-5.6-sol,effort=high'), 'wR:p2', '45000', deps); + startAgent(makeAgent('codex', 'jay:model=gpt-6.1-sol,effort=high'), 'wR:p2', '45000', deps); expect(calls[0]).toEqual( ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000', - '--', '--model', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=high']); + '--', '--no-daemon', '--model', 'gpt-6.1-sol', '-c', 'model_reasoning_effort=high']); }); - test('a bare handle passes no -- vector at all', () => { + test('a bare handle passes only its default model after --', () => { const { deps, calls } = runner([ok]); startAgent(makeAgent('claude', 'sly'), 'wR:p2', '45000', deps); - expect(calls[0]).not.toContain('--'); + expect(calls[0]).toEqual( + ['herdr', 'agent', 'start', 'sly', '--kind', 'claude', '--pane', 'wR:p2', '--timeout', '45000', + '--', '--model', 'claude-opus-5-5']); }); }); @@ -299,15 +315,16 @@ describe('launchAgent', () => { // The old report set `model` to the FLAG name ("model":"codex"), which reads // like an answer while carrying nothing about Opus vs Sonnet. The kind is // reported as `kind`; model/effort report what was actually selected. - test('reports kind, and null model/effort when both are inherited', () => { + test('reports kind, the default model, and null effort when effort is inherited', () => { const { run, calls } = fakeRunner(dynMatchers()); const out = launchAgent(makeAgent('codex', 'jay'), { workspace: 'wR', cwd: '/wt/x' }, { run }); expect(out).toEqual({ - handle: 'jay', kind: 'codex', model: null, effort: null, pane_id: 'wR:p2', tab: 'wR:t2', + handle: 'jay', kind: 'codex', model: 'gpt-6.1-sol', effort: null, pane_id: 'wR:p2', tab: 'wR:t2', }); expect(calls).toEqual([ ['herdr', 'tab', 'create', '--workspace', 'wR', '--cwd', '/wt/x', '--label', 'jay ◇', '--no-focus'], - ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000'], + ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p2', '--timeout', '45000', + '--', '--no-daemon', '--model', 'gpt-6.1-sol'], ['herdr', 'agent', 'wait', 'jay', '--until', 'idle', '--timeout', '45000'], ]); }); @@ -350,7 +367,7 @@ describe('up', () => { expect(result).toEqual({ worktree: { path: '/wt/x', workspace_id: 'wR' }, agents: [{ - handle: 'sly', kind: 'claude', model: null, effort: null, pane_id: 'wR:p2', tab: 'wR:t2', + handle: 'sly', kind: 'claude', model: 'claude-opus-5-5', effort: null, pane_id: 'wR:p2', tab: 'wR:t2', }], }); expect(calls).toEqual([ @@ -361,7 +378,8 @@ describe('up', () => { ['herdr', 'worktree', 'create', '--branch', 'chore/x', '--base', 'origin/main', '--no-focus', '--json', '--workspace', 'wMain'], ['herdr', 'tab', 'create', '--workspace', 'wR', '--cwd', '/wt/x', '--label', 'sly ◆', '--no-focus'], - ['herdr', 'agent', 'start', 'sly', '--kind', 'claude', '--pane', 'wR:p2', '--timeout', '45000'], + ['herdr', 'agent', 'start', 'sly', '--kind', 'claude', '--pane', 'wR:p2', '--timeout', '45000', + '--', '--model', 'claude-opus-5-5'], ['herdr', 'agent', 'wait', 'sly', '--until', 'idle', '--timeout', '45000'], ['herdr', 'tab', 'close', 'wR:t1'], // the worktree's original root tab ]); @@ -372,13 +390,14 @@ describe('up', () => { const result = up(['--branch', 'chore/x', '--claude', 'sly', '--codex', 'jay'], { run }); expect(result.agents).toEqual([ - { handle: 'sly', kind: 'claude', model: null, effort: null, pane_id: 'wR:p2', tab: 'wR:t2' }, - { handle: 'jay', kind: 'codex', model: null, effort: null, pane_id: 'wR:p3', tab: 'wR:t3' }, + { handle: 'sly', kind: 'claude', model: 'claude-opus-5-5', effort: null, pane_id: 'wR:p2', tab: 'wR:t2' }, + { handle: 'jay', kind: 'codex', model: 'gpt-6.1-sol', effort: null, pane_id: 'wR:p3', tab: 'wR:t3' }, ]); expect(calls).toContainEqual( ['herdr', 'tab', 'create', '--workspace', 'wR', '--cwd', '/wt/x', '--label', 'jay ◇', '--no-focus']); expect(calls).toContainEqual( - ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p3', '--timeout', '45000']); + ['herdr', 'agent', 'start', 'jay', '--kind', 'codex', '--pane', 'wR:p3', '--timeout', '45000', + '--', '--no-daemon', '--model', 'gpt-6.1-sol']); expect(calls[calls.length - 1]).toEqual(['herdr', 'tab', 'close', 'wR:t1']); }); @@ -421,10 +440,9 @@ describe('up', () => { expect(calls).toContainEqual(['git', 'fetch', 'origin', 'dev']); }); - // The fan-out runbook's own named failure: `fanout` bases every partition - // worktree on `task/`, a branch that exists only locally. An + // A worktree based on a branch that exists only locally (`task/`): an // unconditional `git fetch origin task/` dies on `couldn't find remote - // ref` and no partition worktree is ever created. The base names a remote or + // ref` and the worktree is never created. The base names a remote or // it does not, and only the first case has anything to fetch. const fetches = (calls) => calls.filter((c) => c[0] === 'git' && c[1] === 'fetch'); diff --git a/comitatus/hooks/__tests__/herdr-orient.test.js b/comitatus/hooks/__tests__/herdr-orient.test.js index 13e10be2..c1ffad72 100644 --- a/comitatus/hooks/__tests__/herdr-orient.test.js +++ b/comitatus/hooks/__tests__/herdr-orient.test.js @@ -2,7 +2,6 @@ const path = require('path'); const hook = require('../herdr-orient.js'); const SKILL_DIR = path.resolve(__dirname, '../../skills/herdr'); -const FANOUT_SKILL_DIR = path.resolve(__dirname, '../../skills/fan-out'); const HERD_JS = path.join(SKILL_DIR, 'scripts', 'herd.js'); describe('processSessionStart gating', () => { @@ -76,9 +75,8 @@ describe('buildOrientation', () => { test('lists the composite verbs', () => { const c = hook.buildOrientation('/abs/herd.js'); - for (const v of ['status', 'members', 'wait', 'send', 'send-wait-read', 'agent', 'up']) { - expect(c).toContain(v); - } + expect(c).toContain('status|members|send|seed|broadcast|sync|withdraw|agent|up'); + expect(c).not.toMatch(/send-wait-read|wait-all|fanout|--roles-dir/); }); test('does not steer agents to the unset $CLAUDE_PLUGIN_ROOT', () => { @@ -313,81 +311,31 @@ describe('stable provisioning', () => { }); }); -// The deployed-user case: a repository that carries no roles directory of its -// own. `role` resolves --roles-dir against the RECIPIENT's cwd, so the only -// value that reaches the packaged roles from there is an absolute path - and -// the orientation is where this kit hands out absolute paths. A roles copy that -// is not provisioned, or is provisioned but not named, leaves the deployed -// fan-out runbook with no role files it can reach. -function makeFixtureFanoutSkill(roles = ['architect', 'implementer', 'reviewer']) { - const dir = tmpdir(); - fs.mkdirSync(path.join(dir, 'roles'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'SKILL.md'), '# fan-out\n'); - for (const r of roles) { - fs.writeFileSync(path.join(dir, 'roles', `${r}.md`), `# ${r}\n`); - } - return dir; -} - -describe('fan-out roles are reachable from a deployed install', () => { - test('stableRolesDir builds the fixed path', () => { - expect(hook.stableRolesDir('/h')).toBe(path.join('/h', 'skills', 'fan-out', 'roles')); - }); - - test('provisionStable puts a named skill beside herdr rather than over it', () => { - const home = path.join(tmpdir(), 'stable'); - hook.provisionStable({ skillDir: makeFixtureSkill(), home }); - hook.provisionStable({ skillDir: makeFixtureFanoutSkill(), home, name: 'fan-out' }); - expect(fs.readdirSync(path.join(home, 'skills')).sort()).toEqual(['fan-out', 'herdr']); - expect(fs.existsSync(hook.stableHerdJs(home))).toBe(true); - }); - - test('the orientation names the roles dir and the files are actually there', () => { +// fan-out (0.13-0.14) was a second skill provisioned beside herdr; 1.0.0 +// removed it, so a stable home upgraded in place still carries that copy. +describe('the retired fan-out copy', () => { + test('is removed once the herdr copy lands', () => { const stableHomeDir = path.join(tmpdir(), 'stable'); + const stale = path.join(stableHomeDir, 'skills', 'fan-out', 'roles'); + fs.mkdirSync(stale, { recursive: true }); + fs.writeFileSync(path.join(stale, 'architect.md'), '# old\n'); const r = hook.processSessionStart({ env: { HERDR_ENV: '1' }, skillDir: makeFixtureSkill(), herdJsPath: '/abs/herd.js', codexHome: '/nonexistent', stableHome: stableHomeDir, - fanoutSkillDir: makeFixtureFanoutSkill(), }); - const rolesDir = hook.stableRolesDir(stableHomeDir); - expect(r.hookSpecificOutput.additionalContext).toContain(rolesDir); - expect(r.hookSpecificOutput.additionalContext).toContain('--roles-dir'); - // The path is only worth naming if a role file resolves under it. - expect(fs.readFileSync(path.join(rolesDir, 'architect.md'), 'utf8')).toBe('# architect\n'); - }); - - test('the roles line is omitted rather than pointing at a copy that failed', () => { - const stableHomeDir = path.join(tmpdir(), 'stable'); - const r = hook.processSessionStart({ - env: { HERDR_ENV: '1' }, skillDir: makeFixtureSkill(), herdJsPath: '/abs/herd.js', - codexHome: '/nonexistent', stableHome: stableHomeDir, - fanoutSkillDir: '/definitely/not/here', - }); - expect(r.hookSpecificOutput.additionalContext).not.toContain('--roles-dir'); - expect(r.hookSpecificOutput.additionalContext).not.toContain(hook.stableRolesDir(stableHomeDir)); - // and the herdr half still got its stable path + expect(fs.readdirSync(path.join(stableHomeDir, 'skills'))).toEqual(['herdr']); expect(r.hookSpecificOutput.additionalContext).toContain(hook.stableHerdJs(stableHomeDir)); }); - test('a Codex-cache install provisions no roles copy', () => { + test('is left alone when the herdr copy fails, so nothing is lost for no gain', () => { const stableHomeDir = path.join(tmpdir(), 'stable'); - const r = hook.processSessionStart({ - env: { HERDR_ENV: '1' }, skillDir: makeFixtureSkill(), herdJsPath: '/abs/herd.js', - codexHome: tmpdir(), stableHome: stableHomeDir, - fanoutSkillDir: makeFixtureFanoutSkill(), directCodex: true, + const stale = path.join(stableHomeDir, 'skills', 'fan-out'); + fs.mkdirSync(stale, { recursive: true }); + hook.processSessionStart({ + env: { HERDR_ENV: '1' }, skillDir: '/definitely/not/here', herdJsPath: '/abs/herd.js', + codexHome: '/nonexistent', stableHome: stableHomeDir, }); - expect(fs.existsSync(stableHomeDir)).toBe(false); - expect(r.hookSpecificOutput.additionalContext).not.toContain('--roles-dir'); - }); - - test('the shipped fan-out skill carries every role its SKILL.md names', () => { - const named = fs.readFileSync(path.join(FANOUT_SKILL_DIR, 'SKILL.md'), 'utf8') - .match(/\(roles\/([a-z-]+\.md)\)/g) - .map((m) => m.replace(/^\(roles\//, '').replace(/\)$/, '')); - expect(named.length).toBeGreaterThan(0); - for (const role of new Set(named)) { - expect(fs.existsSync(path.join(FANOUT_SKILL_DIR, 'roles', role))).toBe(true); - } + expect(fs.existsSync(stale)).toBe(true); }); }); diff --git a/comitatus/hooks/herdr-orient.js b/comitatus/hooks/herdr-orient.js index 295326e8..b90b222f 100755 --- a/comitatus/hooks/herdr-orient.js +++ b/comitatus/hooks/herdr-orient.js @@ -8,11 +8,10 @@ const crypto = require('crypto'); const PLUGIN_ROOT = path.resolve(__dirname, '..'); const SKILL_DIR = path.join(PLUGIN_ROOT, 'skills', 'herdr'); -const FANOUT_SKILL_DIR = path.join(PLUGIN_ROOT, 'skills', 'fan-out'); const HERD_JS = path.join(SKILL_DIR, 'scripts', 'herd.js'); const EXCLUDE = new Set(['__tests__', 'node_modules']); -function buildOrientation(herdJsPath, { codexPlugin, rolesDir } = {}) { +function buildOrientation(herdJsPath, { codexPlugin } = {}) { return [ '# herdr (comitatus)', '', @@ -22,26 +21,13 @@ function buildOrientation(herdJsPath, { codexPlugin, rolesDir } = {}) { 'Prefer native `herdr` verbs by handle: `herdr agent prompt|read|get|wait `.', 'The herd.js helper adds the composite verbs (each is self-contained and', 'runs herdr itself - no shell variables):', - ' status|members|wait|send|send-wait-read|agent|up', + ' status|members|send|seed|broadcast|sync|withdraw|agent|up', '', ` node ${herdJsPath} send jay "..." --reply`, '', 'This path is STABLE across comitatus updates; allowlist it once with', '`/herd-setup` and always call it by this absolute path so the permission', 'matcher can match it.', - // Only emitted when the copy is actually on disk. An orientation that named - // a roles directory nothing had written would send `role` at a missing file, - // which is the failure this line exists to prevent. - ...(rolesDir ? [ - '', - 'Fan-out role files (architect, implementer, reviewer, ...) are provisioned', - 'to the same stable location:', - '', - ` ${rolesDir}`, - '', - 'Pass that absolute path as `--roles-dir` to `role` when the repository does', - 'not carry a roles directory of its own.', - ] : []), '', codexPlugin === true ? 'comitatus is installed as a Codex plugin, so codex agents in this herd load the same `herdr` skill from their own plugin install.' @@ -172,11 +158,15 @@ function provisionStable({ skillDir, home, name }) { function stableHome(homedir) { return path.join(homedir, '.claude', 'comitatus'); } function stableHerdJs(home) { return path.join(home, 'skills', 'herdr', 'scripts', 'herd.js'); } -function stableRolesDir(home) { return path.join(home, 'skills', 'fan-out', 'roles'); } +// fan-out (0.13-0.14) was a second skill provisioned beside herdr, and 1.0.0 +// removed it. A leftover tree there is a stale copy nothing names any more. +function removeRetiredFanout(home) { + fs.rmSync(path.join(home, 'skills', 'fan-out'), { recursive: true, force: true }); +} function processSessionStart({ env, skillDir, herdJsPath, codexHome, stableHome: stableHomeDir, - fanoutSkillDir, directCodex = false, + directCodex = false, }) { if (env.HERDR_ENV !== '1') return null; @@ -194,33 +184,22 @@ function processSessionStart({ // fails. Skipped when we are the Codex-installed copy: there is no Claude // permission matcher to keep stable in that case. let helperPath = herdJsPath; - let rolesDir; if (stableHomeDir && !directCodex) { try { provisionStable({ skillDir, home: stableHomeDir }); helperPath = stableHerdJs(stableHomeDir); - } catch { /* keep fallback */ } - - // Provisioned separately from the herdr skill, and separately caught: the - // fan-out roles failing to copy must not cost herdr its stable helper path. - // The packaged roles are the only copy a deployed user has when the - // repository carries no roles of its own, and `role` resolves --roles-dir - // against the recipient's cwd - so without a stable absolute path to name, - // the deployed fan-out runbook has no role files it can reach. - if (fanoutSkillDir) { + // Only once the herdr copy has landed. try { - provisionStable({ skillDir: fanoutSkillDir, home: stableHomeDir, name: 'fan-out' }); - const provisioned = stableRolesDir(stableHomeDir); - if (fs.existsSync(provisioned)) rolesDir = provisioned; - } catch { /* orientation omits the roles line */ } - } + removeRetiredFanout(stableHomeDir); + } catch { /* a stale copy is harmless; nothing names it */ } + } catch { /* keep fallback */ } } return { systemMessage: `📍 Comitatus: herdr${codexPlugin === undefined ? '' : codexPlugin ? ' (codex: installed)' : ' (codex: not installed)'}`, hookSpecificOutput: { hookEventName: 'SessionStart', - additionalContext: buildOrientation(helperPath, { codexPlugin, rolesDir }), + additionalContext: buildOrientation(helperPath, { codexPlugin }), }, }; } @@ -248,7 +227,6 @@ async function main() { herdJsPath: HERD_JS, codexHome, stableHome: stableHome(os.homedir()), - fanoutSkillDir: FANOUT_SKILL_DIR, directCodex: isCodexInstall({ pluginRoot: PLUGIN_ROOT, codexHome }), }); if (result) console.log(JSON.stringify(result)); @@ -267,11 +245,9 @@ module.exports = { resolveCodexHome, stableHome, stableHerdJs, - stableRolesDir, processSessionStart, EXCLUDE, PLUGIN_ROOT, SKILL_DIR, - FANOUT_SKILL_DIR, HERD_JS, }; diff --git a/comitatus/package.json b/comitatus/package.json index ddd63e48..2058a4a7 100644 --- a/comitatus/package.json +++ b/comitatus/package.json @@ -1,6 +1,6 @@ { "name": "@claude-domestique/comitatus", - "version": "0.14.1", + "version": "1.0.0", "description": "herdr orchestration workflows for Claude Code and Codex", "main": "hooks/herdr-orient.js", "scripts": { diff --git a/comitatus/scripts/herd-setup.js b/comitatus/scripts/herd-setup.js index 8da6fdd4..03d9b77c 100644 --- a/comitatus/scripts/herd-setup.js +++ b/comitatus/scripts/herd-setup.js @@ -41,33 +41,15 @@ const SAFE_ALLOW = Object.freeze([ // The herd-lifecycle verbs belong here for the same reason as `send`: an agent // that hits a permission prompt mid-protocol stalls the herd, and a lead that // stalls while seeding or withdrawing strands everyone downstream of it. -// `fanout` is no more privileged than `up`, which it calls; `state` only reads -// refs; `role` is a `send` with a composed body. const HELPER_VERBS = Object.freeze([ - 'status', 'members', 'wait', 'send', 'send-wait-read', + 'status', 'members', 'send', 'seed', 'broadcast', 'sync', 'withdraw', 'agent', 'up', - 'role', 'fanout', 'wait-all', 'state', 'settled', ]); -// Dispatchable, deliberately NOT baked. -// -// A baked rule reads `Bash(node :*)`, which allows whatever that -// verb shells out to - the matcher sees the helper invocation, never its child -// processes. `fan-in` runs `git merge` and `teardown` runs `git branch -D` plus -// `herdr worktree remove --force`, and SAFE_ALLOW withholds `git branch:*`, -// `git checkout:*`, `git reset:*`, and `git push:*` on purpose. Baking these two -// would route an agent straight around that list through the helper. -// -// So they prompt, once per invocation. Nothing downstream stalls on it: these -// are the two steps that destroy work, and neither sits on a path where a -// waiting peer is stranded by the pause - which is the whole reason `send` and -// the seeding verbs are baked and these are not. -const GATED_VERBS = Object.freeze(['fan-in', 'teardown']); - // The full dispatch surface. `herd-setup.test.js` holds this equal to the verbs -// parsed out of `herd.js`'s own usage(), so a verb that dispatches but appears -// in neither list fails the suite rather than becoming an invisible trap. -const DISPATCHABLE_VERBS = Object.freeze([...HELPER_VERBS, ...GATED_VERBS]); +// parsed out of `herd.js`'s own usage(), so a verb that dispatches but is not +// allowed fails the suite rather than becoming an invisible permission trap. +const DISPATCHABLE_VERBS = HELPER_VERBS; function bakedHerdRules(homedir) { const base = stableHerdJs(stableHome(homedir)); @@ -147,7 +129,6 @@ if (require.main === module) main(); module.exports = { SAFE_ALLOW, HELPER_VERBS, - GATED_VERBS, DISPATCHABLE_VERBS, bakedHerdRules, mergeAllow, diff --git a/comitatus/skills/fan-out/SKILL.md b/comitatus/skills/fan-out/SKILL.md deleted file mode 100644 index 00a139af..00000000 --- a/comitatus/skills/fan-out/SKILL.md +++ /dev/null @@ -1,418 +0,0 @@ ---- -name: fan-out -description: >- - Use when coordinating a task as one architect and multiple implementers on - separate herdr branches, with durable run state and ordered fan-in. ---- - -# fan-out - -This is the runbook for partitioning one task across an architect and N -implementers. Load `comitatus:herdr` first: it provides the herdr prerequisite, -helper path, roster protocol, and worktree conventions. This skill supplies the -fan-out workflow and its role files. - -comitatus is the message bus. Git is the state machine. Tests are the contract. -There is no orchestrator *process* — a named agent takes `orchestrator.md` and -drives the run. That is deliberate. - -## Which position you hold - -**Work out which position you are in before acting on anything below.** More -than one agent in a run reads this skill, and they are not interchangeable. - -- **Were you sent a role file** — a message naming `/.md` to - read and follow? Then you are that role, **that file governs**, and this skill - is background for the verbs it names. Do not adopt any other position here. -- **Did you take `architect.md` and then `orchestrator.md`?** You own the task - branch and its worktree. You write there; the restrictions below are not - yours. -- **Did you load this skill yourself to start a run, holding no role file?** - You are the **launcher**, described next. -- **None of the above** — you opened this out of curiosity, or to answer a - question about how fan-out works. Read on, but do not create worktrees or - launch agents for a run nobody asked you to start. - -### The launcher - -The launcher sets a run up and then gets out of the tree. It: - -- creates the task worktree, writes and commits `task.md`, and starts the - architect — all before any other agent is live; -- then **reads, decides, and approves**: answering what `orchestrator.md` - escalates, authorizing `fan-in` and `teardown`, and calling the run finished. - -Once the architect is running, the launcher **does not write to the task -worktree or to any partition worktree** — no edits, no commits, no merges, no -`git` state changes. It takes no role file and owns no branch. Its cwd is the -main checkout, the one tree nobody else is working in. - -That boundary is the rule the topology enforces everywhere else: **one writer -per tree**. An agent's view of its worktree is whatever it last read, so a -launcher that "just fixes one thing" in the task tree is a second writer racing -the orchestrator, and the loser is overwritten silently. If something needs -changing, send it to the agent that owns that tree. - -Before the first run: one command must run the suite and exit non-zero on -failure, both CLIs (Claude Code, Codex) must be installed, and `/herd-setup` -must have been run once on this host — an agent that stops for tool approval -mid-run stalls the fan-out. Start at width 2 and go wider only after several -clean tasks. - -## Role files - -The packaged role copies are in [`roles/`](roles/): -[`architect.md`](roles/architect.md), [`hypotheses.md`](roles/hypotheses.md), -[`implementer.md`](roles/implementer.md), [`mechanic.md`](roles/mechanic.md), -[`orchestrator.md`](roles/orchestrator.md), [`probe.md`](roles/probe.md), and -[`reviewer.md`](roles/reviewer.md). - -In a repository that has `.pipeline/roles`, the helper's default already reads -those and you pass nothing. Everywhere else, **take the roles directory from -your herdr orientation** — the comitatus hook provisions these files next to -the helper and prints the absolute path, the same fixed location that survives -a comitatus update. Pass it as `--roles-dir`: - -```text -node HERD role tim --role implementer --run my-task --partition auth \ - --roles-dir /abs/path/from/your/orientation -``` - -Type the path the orientation gave you, literally. `role` resolves -`--roles-dir` against the **recipient's** cwd, so a relative path means the -recipient's worktree, which is exactly where the packaged roles are not. If -your orientation has no roles line, the copy did not land: read the files out -of this skill directory instead of guessing a plugin cache path, which moves on -every update. - -Deliver role files by path so the recipient reads the exact file; never paste a -role into a prompt. - -## Naming - -Two things get names, and the split matters: - -- **The handle is a bare call-sign** from the pool in the herdr skill's - `reference/names.md` — `tim`, `jay`, `sly`. It is the addressable identity - (`node HERD send tim ...`), it is globally unique across every workspace, and - it carries **no** part and no model. That is what lets you relaunch a member - on another model, or hand it a different role, without renaming the thing - everyone addresses it by. -- **The tab label carries the part**: `- `, so the sidebar - row reads `chore-my-slug · tim-impl ◇`. The glyph is the agent kind (claude - `◆`, codex `◇`, opencode `⬨`). Set it at launch with the `role=` selector key. - -```text -node HERD up --branch --base \ - --claude sly:model=opus,effort=xhigh,role=arch -``` - -`role=` is label-only — it never reaches the agent's CLI args. `fanout` applies -it for you (`--role-tag`, default `impl`) and claims the next free call-signs -from the pool, so you do not name implementers by hand. - -| Part | Tab tag | Role file | -| --- | --- | --- | -| architect | `arch` | `architect.md` | -| implementer | `impl` | `implementer.md` | -| reviewer | `rev` | `reviewer.md` | -| hypotheses | `hyp` | `hypotheses.md` | -| prober | `prb` | `probe.md` | -| mechanic | `mech` | `mechanic.md` | - -Worktree and workspace labels are left at herdr's defaults — it already names a -worktree's workspace after the worktree directory. You assign the handle and -the tab label, nothing else. - -## Who runs what - -| Part | CLI | `--model` | Effort | Why | -| --- | --- | --- | --- | --- | -| Orchestrate, manifest, behavior tests | `claude` | `opus` | `xhigh`, `max` where correctness outweighs cost | Anthropic's recommended level for coding and agentic work, at the highest-leverage point | -| GREEN implementer | `codex` | `gpt-5.6-sol` | `medium`, `high` on high-risk partitions | OpenAI's recommended level for routine development; escalate for ambiguity or expensive mistakes | -| Hypotheses, review of the diff | `claude` | `sonnet` | `high` | Different lineage than the Codex that wrote the code → uncorrelated blind spots, cheaper than the architect | -| Hypothesis prober | `codex` | `gpt-5.6-sol` | `high` | OpenAI escalates for difficult debugging, which is the debug recipe's premise | -| Repo index, summaries, fixtures, lint | `claude` | `sonnet` | `low` | Turning a capable model down beats reaching for a smaller one, and keeps the run on one fewer model | - -Claude effort levels are `low, medium, high, xhigh, max`; Codex has no effort -flag at all, only `-c model_reasoning_effort=`. The helper's selector collapses -both into one form, which is why it is the one to use. - -**Name the model and effort on every launch.** A bare handle inherits whatever -the CLI's ambient config resolves — `~/.codex/config.toml`, or Claude's user, -project, and org-managed settings, the last of which can pin a model you cannot -change interactively. Neither is a choice you made. - -Two vendors cannot give every part a reviewer of a different lineage than both -the architect and the implementer. This table spends its one cross-lineage -boundary on the code — a Claude reviewer against a Codex diff — and accepts -that architect and reviewer share a lineage. The reviewer is a fresh agent -reading only the manifest and the diff, never the architect's reasoning, so a -round is a new sample rather than the same model agreeing with itself. - -## Topology - -Three facts shape every path below. **An agent's cwd is fixed at launch**, so -reassigning means relaunching. **herdr ties one workspace to one worktree**, so -a part that needs its own branch needs its own worktree. **Handles are globally -unique**, so claim each against a live `herdr agent list` and free it with -`herdr workspace close` before reusing it. - -| Part | Worktree | Workspace | Why | -| --- | --- | --- | --- | -| architect | `` | A | owns the task branch | -| reviewer | `` | A, new tab | reads only; needs the tree the architect built | -| implementer | `` | one each | commits to its own branch | -| prober | one per hypothesis, off `` | one each | same reason | -| orchestrator | `` | A — the architect's own pane | needs the task branch for fan-in | -| mechanic | current worktree | current, new tabs | writes to distinct `notes/

.md` paths | - -The rule: **a part that commits to a distinct branch needs its own worktree; a -part that only reads, or writes non-conflicting paths, gets a tab.** At width 2 -that is three worktrees, three workspaces, four agents. - -Where two agents share a worktree they must not write it at the same time. The -architect and reviewer are safe because the reviewer is read-only and waits; -the architect and implementer on the bug-fix path are safe because they are -sequential. Two concurrent writers in one tree is not a configuration this -runbook has. - -## Branch naming - -`--run ` names `.pipeline/runs/`. By default it also names branches: -`task/` for the architect and `task/-` for each implementer. -The run id and task branch are independent: - -```text -node HERD settled --run readme-rewrite \ - --task-branch 76632-create-new-thing --partitions api -``` - -`--task-branch ` and `--partition-sep ` are accepted by `fanout`, -`state`, `settled`, `fan-in`, and `teardown`. They default to `task/` and -`-`; pass the same pair to every verb in a run. Partition branches are -``. - -The separator cannot be `/`: Git refs are files, so a branch cannot also be a -directory. Use a flat separator (`-`, `--`, `.`, `_`). The option is rejected at -parse time before any worktree is created. - -`state` is the only verb that finds partitions rather than receiving them. It -globs `*`; pass `--partitions` when a human-created -sibling branch could otherwise look like a partition. - -**Notation for the rest of this document.** `` is whatever the -consuming project passed to `--task-branch`, and `` is -`

`. They are written as placeholders on purpose: the -`task/` shape is this kit's *default*, not a convention any repository is -obliged to adopt. A repository that names branches `chore/make-new-readme` or -`76632-create-new-thing` passes `--task-branch` once and reads every command -below with its own names substituted. Pass the same `--task-branch` and -`--partition-sep` to every verb in a run — they resolve independently, and two -of them disagreeing is a silent wrong branch rather than an error. - -## Recipe: feature - -1. Stand up the task worktree **first** — `task.md` has to live inside it, or - the architect cannot read a file that exists only in your main checkout: - - ```text - node HERD up --branch --base \ - --claude sly:model=opus,effort=xhigh,role=arch - ``` - - Under the default naming that is `--branch task/`; a project with its own - convention substitutes its own name and passes it to every verb below as - `--task-branch `. The run id and the branch need not match. - -2. In that worktree write `.pipeline/runs//task.md` — type, one-paragraph - goal, observable done-when, risk — commit it, then start the architect: - - ```text - node HERD role sly --role architect --run - ``` - - This is the launcher's last write to this tree. - - Output: `manifest.md` plus failing tests, committed, and then the architect - **stops** — that role ends at the red gate. - -3. Verify the red gate yourself, then hand the same agent its second role: - - ```text - node HERD role sly --role orchestrator --run - ``` - - Run the suite and confirm every new failure is an assertion naming the value - it got, not an import or syntax error. That check is the launcher's, not the - architect's — an architect that grades its own failing tests and proceeds is - what the gate exists to catch. Only once it passes does `sly` become the - orchestrator: same agent, same pane, same worktree, same task branch it - already owns. Everything from here is driven by `sly`, not by you. - -4. Fan out — one worktree, one implementer, and one dispatched role per - partition, in one call: - - ```text - node HERD fanout --run --partitions auth,ui - node HERD wait-all tim,jay - ``` - - Do **not** reach for `up --base ` here. `up` always runs - `git fetch origin `, which fails `couldn't find remote ref` on a task - branch that exists only locally. `fanout` uses `herdr worktree create`, which - takes a local base and does not fetch. - - `wait-all` polls one `agent list` per round, but **status is not the - completion signal**. An agent reads `idle` between its own turns. Confirm - against git with `node HERD settled --run --partitions auth,ui`. - Declaring a run finished off a transient `idle` is the most common way to - lose a partition's work. - -5. Fan in from the task branch, in manifest order: - - ```text - node HERD fan-in --run --partitions auth,ui --wait-handle sly - ``` - - It settles the architect first — merging under a running agent rewrites the - tree that agent is living in. At the first conflict it stops and names the - partition and paths; send those to that partition's implementer and re-wait. - Do not resolve conflicts yourself and do not merge from a partition worktree. - -6. Apply the manifest's `shared_files` — they are the orchestrator's precisely - because no partition may touch them — then run the full suite. - -7. Review from a **fresh** agent on the task worktree, never the architect and - never an implementer: - - ```text - node HERD wait sly --status idle,done --timeout 300000 - git diff ... > .pipeline/runs//diff.patch - node HERD agent claude gus:model=sonnet,effort=high,role=rev \ - --workspace --cwd - node HERD role gus --role reviewer --run - ``` - - Settle the architect before writing the diff: a diff taken while it is still - editing describes a tree that has already moved. Route each finding to the - implementer whose partition owns the file. One escalation past the review, - then stop — reaching the cap does not turn an unresolved finding into a pass. - -8. Append the `.pipeline/log.md` row and commit it. Then tear down: - - ```text - node HERD teardown --run --partitions auth,ui # plans only - node HERD teardown --run --partitions auth,ui --yes - ``` - -## Recipe: bug fix - -Steps 1–2, with the architect writing only the repro test. Then **one** -implementer in the task worktree itself, added as a tab — no partition -worktrees, no fan-in: - -```text -node HERD agent codex tim:model=gpt-5.6-sol,effort=medium,role=impl \ - --workspace --cwd -``` - -These two share a tree, so they run in sequence: wait for the architect to -finish and commit the repro test before starting the implementer. Then steps -6–7. - -## Recipe: debug (cause unknown) - -1. The architect writes the best repro test it can, even if partial. -2. Add a Sonnet agent to the task workspace with `role=hyp` and send it - `hypotheses.md` → `.pipeline/runs//hypotheses.md`. -3. Top 2–3 hypotheses, one worktree and one prober each, off the task branch: - - ```text - node HERD up --branch h --base \ - --codex jay:model=gpt-5.6-sol,effort=high,role=prb - node HERD role jay --role probe --run --hypothesis - ``` - - First CONFIRMED wins. Each prober commits its own `probe-.md` on its own - hypothesis branch, so collect them onto the task branch before closing - anything — a committed file on a branch you are about to delete is still - lost: - - ```text - git cherry-pick h # per hypothesis, REFUTED ones included - ``` - - That record is the only account of what was ruled out. Then close the losing - workspaces to free their handles and delete the hypothesis branches. -4. Continue as a bug fix from the winning worktree. - -## Recipe: analysis - -No new branches, so run it in the worktree you are already in. Add one mechanic -per relevant directory as tabs in the current workspace, each summarizing into -`.pipeline/runs//notes/

.md`: - -```text -node HERD agent claude tim:model=sonnet,effort=low,role=mech \ - --workspace --cwd -``` - -Then send the architect the question; it answers reading only that `notes/` -directory plus files it names. - -These mechanics share one tree, so scope each to its own directory and tell it -not to format or lint on this path — `mechanic.md` permits formatting, and a -formatter run by one mechanic rewrites files the others are mid-read. - -## What git holds - -Which step you are on is never stored anywhere; it is read off the repository. - -| State | How git says it | -| --- | --- | -| task started | `` exists | -| partitioned, tests written | `runs//manifest.md` committed on it | -| partition `

` green | `` has a commit and its tests pass | -| partition `

` blocked | `runs//BLOCKED-

.md` committed on it | -| fanned in | `` merged into `` | -| hypothesis ruled out | `runs//probe-.md` committed, REFUTED | -| task finished | `log.md` row committed, partition branches deleted | - -The claim only holds if the artifacts are committed, so commit each as it is -produced. A `.pipeline/` file that exists only in a worktree is not state — -`worktree remove --force` and `workspace close` both discard it silently, and -both are steps in this runbook. `runs//diff.patch` is the exception: it is -regenerable from two refs, so gitignore it. - -`runs//` merges to `main` with the task and stays there. A partition that -blocks twice on the same cause is only visible if the first `BLOCKED` is still -readable, and a hypothesis refuted in task 3 should not be re-probed in task 12. - -The payoff is recovery: close every pane, reboot, come back tomorrow, and -`node HERD state --run ` still answers, because the answer was never in a -terminal. Phase is `absent | started | partitioned | fanned-out | fanned-in | -finished`. - -The one question git cannot answer is whether anyone is still working. That is -liveness, and it lives in `herdr agent list` — never in a file. - -## What crosses the bus - -Messages carry role assignment, conflicts, and questions. They do not carry -state: a worker's verdict goes in a commit or a committed -`BLOCKED-.md`, because a message is not durable and a pane is not a -record. A worker that writes BLOCKED also sends one line saying so, since -nothing polls the filesystem for you. - -On an unattended codex launch, `herdr agent read ` before the first -send. Codex can open on a blocking update menu that herdr still reports as -ready, and the first prompt lands in the menu instead of the composer. - -## Teardown detail - -Teardown plans by default and requires `--yes` to remove anything. Uncommitted -or unmerged `BLOCKED-*.md` and `probe-*.md` evidence causes a partition to be -skipped rather than destroyed — `--force` discards both without warning. -Removal order is fixed: worktree first, then branch, because `remove` does not -delete the branch and a branch still checked out cannot be deleted. diff --git a/comitatus/skills/fan-out/roles/architect.md b/comitatus/skills/fan-out/roles/architect.md deleted file mode 100644 index 13256aed..00000000 --- a/comitatus/skills/fan-out/roles/architect.md +++ /dev/null @@ -1,29 +0,0 @@ -# architect - -Run as: `claude --model opus --effort xhigh` - -Read `.pipeline/runs/$RUN/task.md`. Do not implement anything. - -Everything you write goes under `.pipeline/runs/$RUN/`. Never write to `.pipeline/` directly — those paths are shared across every task and would overwrite another run's record. - -1. Write `.pipeline/runs/$RUN/manifest.md` with: - - **spec**: at most 5 lines - - **criteria**: observable, testable statements - - **partitions**: name, files (exact paths, disjoint across partitions), tests (paths), `depends_on` - - **shared_files**: DI wiring, routing tables, migrations, lockfiles, generated code. Nobody edits these in a worktree. List the change needed; it is applied at fan-in. - - **risk notes**: auth, data, concurrency, money -2. Write failing tests for each partition's criteria. They must fail on an assertion, not on import or syntax. Run them and paste the failing output at the bottom of `runs/$RUN/manifest.md`. -3. If two partitions need the same file, merge them into one partition. Fewer, larger partitions beat conflicts. -4. If the task is ambiguous, write 2-3 options with one line of tradeoff each and stop. Do not pick. - -You own the task branch. Implementers work on partition branches and do not merge; fan-in is yours. - -A test that passes the moment it is written has established nothing. Before handing off, confirm each failure names the assertion that failed and the value it got. - -## You are half the job - -This role ends at the red gate. Commit the manifest and its failing tests, report the failing output, and **stop** — do not fan out, do not launch anyone, do not start implementing. - -You will then be sent `orchestrator.md`: the same agent, the same worktree, the same task branch you already own. That file is what you do with it. Two role files, one agent, in that order — so finishing this one is a pause, not the end of your run. - -Do not promote yourself by reading `orchestrator.md` early. The red gate is verified by whoever sent you here, and an architect that grades its own failing tests and proceeds is the exact failure the gate exists to catch. diff --git a/comitatus/skills/fan-out/roles/hypotheses.md b/comitatus/skills/fan-out/roles/hypotheses.md deleted file mode 100644 index 33307b3b..00000000 --- a/comitatus/skills/fan-out/roles/hypotheses.md +++ /dev/null @@ -1,16 +0,0 @@ -# hypotheses - -Run as: `claude --model sonnet --effort high` - -Input: `.pipeline/runs/$RUN/task.md`, the repro test output, and the files named in the stack trace. - -List at most 5 root-cause hypotheses ranked by likelihood. For each: - -- one line stating it -- one line describing a probe (test, log line, or assertion) that confirms or refutes it in under 5 minutes - -Write them to `.pipeline/runs/$RUN/hypotheses.md` and commit it. Then stop — do not fix anything and do not run the probes. - -A hypothesis whose probe cannot come back REFUTED is not a hypothesis. If you cannot name what would rule it out, drop it. - -Rank by likelihood, not by how easy the probe is. A cheap probe for an unlikely cause wastes a worktree. diff --git a/comitatus/skills/fan-out/roles/implementer.md b/comitatus/skills/fan-out/roles/implementer.md deleted file mode 100644 index ac5036bb..00000000 --- a/comitatus/skills/fan-out/roles/implementer.md +++ /dev/null @@ -1,20 +0,0 @@ -# implementer - -Run as: `codex --model gpt-5.6-sol -c model_reasoning_effort=medium` -High-risk partitions: `-c model_reasoning_effort=high` - -You own partition `$PARTITION` in `.pipeline/runs/$RUN/manifest.md`. - -- Edit only files listed under your partition. Never touch `shared_files` or another partition's files. If you must, stop and write `.pipeline/runs/$RUN/BLOCKED-$PARTITION.md` explaining why, then commit it. -- Make only your partition's tests pass. Do not edit tests except fixtures and factories. -- Run your partition's tests after every change. Max 3 fix cycles; on the 4th failure write BLOCKED with what you tried and stop. -- Smallest diff that passes. No refactoring outside the partition. New dependencies must be named in the commit body. -- When green: commit as `$PARTITION:

`. Body: files touched, anything a reviewer should look at. - -Do not merge. Do not touch the task branch. Fan-in belongs to the architect. - -Commit the BLOCKED file. Uncommitted it is not state: the worktree is removed at teardown and takes the only record of your failure with it. - -Committing is still not the whole signal — nothing polls the filesystem. Also send one line to the lead saying you blocked and naming the file. - -A test you made pass by narrowing its assertion has not passed. If the only way through was to weaken the test, that is a BLOCKED, not a green. diff --git a/comitatus/skills/fan-out/roles/mechanic.md b/comitatus/skills/fan-out/roles/mechanic.md deleted file mode 100644 index 44872801..00000000 --- a/comitatus/skills/fan-out/roles/mechanic.md +++ /dev/null @@ -1,18 +0,0 @@ -# mechanic - -Run as: `claude --model sonnet --effort low` - -Mechanical work only. Never change behavior. - -Allowed: - -- summarize a directory into `.pipeline/runs/$RUN/notes/.md`, at most 200 words (purpose, entry points, key types, how it is tested) -- generate fixtures or factories copying an existing pattern in the repo -- format and lint -- write a commit message from a diff - -Output the artifact and nothing else. - -Where several mechanics run in one worktree, stay inside the directory you were given and do not format or lint. A formatter rewrites files the others are mid-read. - -If the task requires a judgment — which of two patterns is right, whether a test is adequate, what a function should do — it is not mechanical. Say so in one line and stop rather than guessing. diff --git a/comitatus/skills/fan-out/roles/orchestrator.md b/comitatus/skills/fan-out/roles/orchestrator.md deleted file mode 100644 index 44c7d74b..00000000 --- a/comitatus/skills/fan-out/roles/orchestrator.md +++ /dev/null @@ -1,67 +0,0 @@ -# orchestrator - -Run as: `claude --model opus --effort xhigh` - -You drive the run. You do not do the work. - -Taken by the same agent that just finished `architect.md`, in the task worktree, once -the manifest is committed and its tests fail on assertions. You already own the task -branch; this role is what you do with it. - -`$RUN` is the task id. `H` is the herd helper path from your herdr orientation — call -it by that absolute path. - -## The loop - -1. **Read the state before acting.** `node H state --run $RUN` reports the phase off - git refs. Never assume the phase from what you remember doing; a run you resume - after a compaction or a restart has whatever git says it has. -2. **Fan out.** `node H fanout --run $RUN --partitions ` — one worktree and one - codex implementer per partition, each sent the implementer role by path. Read the - result rows: a row carrying both `worktree` and `error` means the agent is live but - never got its role, so re-send it rather than creating anything. -3. **Wait.** `node H wait-all impl,impl` in one call. Then confirm against git, - not against status: a partition is done when its branch has a commit or a committed - `BLOCKED-

.md`. An agent reading `idle` between its own turns is not a finished - agent, and this is the single most common way a run is declared complete early. -4. **Fan in.** `node H fan-in --run $RUN --partitions ` in manifest order. On a - conflict it stops and names the partition and the unmerged paths; send that - partition's implementer the conflict and re-wait. Do not resolve it yourself. -5. **Apply the shared files.** These are yours — the manifest listed them because no - partition may touch them. Then run the full suite. -6. **Review.** Add one fresh agent to your workspace, never yourself and never an - implementer, and send it `reviewer.md`. Route each finding to the implementer whose - partition owns the file. The reviewer names defects; implementers write fixes; you - carry messages. Re-merge and re-run the suite after fixes land. -7. **Record and tear down.** Append the `log.md` row and commit it. Then - `node H teardown --run $RUN` to see the plan, and `--yes` to execute. - -## What you never do - -Do not write production code, tests, or fixes. Do not resolve a conflict, grade a -partition, or decide a finding is not worth acting on. If you find yourself editing a -file inside a partition, you have stopped orchestrating. - -Do not merge from a partition worktree. Fan-in happens on the task branch, in your -worktree. - -## Stop and ask the operator - -Two verbs are deliberately not pre-authorized, because baking them would grant -`git merge`, `git branch -D`, and `herdr worktree remove --force` through a helper -rule the allowlist otherwise refuses: - -- `fan-in` — you will be prompted. Say what you are about to merge and wait. -- `teardown` — same, and it defaults to a plan. Never pass `--yes` without the - operator saying so in this run. - -Also stop and ask when: a partition returns BLOCKED and the manifest was wrong rather -than the worker; the reviewer's finding contradicts a criterion; or the same partition -blocks twice on the same cause. Those are decisions, not steps. - -## Reporting - -One line to the operator at each phase boundary — fanned out, all partitions settled, -fanned in, suite green, review clean or N findings routed, torn down. Include the test -counts you actually ran, not the counts you expect. A phase you did not verify is a -phase you report as unverified. diff --git a/comitatus/skills/fan-out/roles/probe.md b/comitatus/skills/fan-out/roles/probe.md deleted file mode 100644 index 3748ba38..00000000 --- a/comitatus/skills/fan-out/roles/probe.md +++ /dev/null @@ -1,15 +0,0 @@ -# probe - -Run as: `codex --model gpt-5.6-sol -c model_reasoning_effort=high` - -Take hypothesis `$HYPOTHESIS` from `.pipeline/runs/$RUN/hypotheses.md`. - -Write the probe. Run it. Write the result to `.pipeline/runs/$RUN/probe-$HYPOTHESIS.md` as `CONFIRMED` or `REFUTED` with the evidence, and commit it. - -Commit a REFUTED result too. Losing hypothesis worktrees get closed, and an uncommitted result is discarded with them — leaving no account of what was ruled out, so the next pass re-probes it. - -Do not fix anything. Stop. - -A probe that could not run is neither CONFIRMED nor REFUTED. Write `COULD NOT RUN` and name what stopped it — a missing fixture, an unreachable service, a test that errors on import. Reporting REFUTED for a probe that never executed sends the search down the wrong branch and nothing downstream can tell the difference. - -The evidence is the output, not your reading of it. Paste what the probe printed. diff --git a/comitatus/skills/fan-out/roles/reviewer.md b/comitatus/skills/fan-out/roles/reviewer.md deleted file mode 100644 index 1bf65a74..00000000 --- a/comitatus/skills/fan-out/roles/reviewer.md +++ /dev/null @@ -1,25 +0,0 @@ -# reviewer - -Run as: `claude --model sonnet --effort high` - -Input: `.pipeline/runs/$RUN/manifest.md` and `.pipeline/runs/$RUN/diff.patch`. Read nothing else. - -Report only the following, one line each, most severe first. Output `NONE` if nothing applies. - -1. Files changed that are not in the owning partition's file list. -2. Tests weakened, deleted, skipped, or made tautological. -3. Concurrency, transactional, security, or input-validation defects in the diff. Cite the line. -4. For bug fixes: does the change address the cause or the symptom? Cite the line. -5. Behavior added that no criterion asked for. - -No praise, no restating the diff, no style comments. - -`NONE` is an expected answer, not a failure to find anything. Say it plainly when it is true. - -Every finding names something specific: the file outside the partition, the weakened assertion, the line with the defect. General dissatisfaction is not a finding. - -Do not propose replacement wording or edit either input. Name the defect; the implementer writes the fix. - -A finding needs a supported reason to act. It does not need an executable reproduction — an architectural defect can be real — but a reviewer's preferred implementation is not automatically an obligation. - -One escalation past this review, then stop. Reaching the cap does not turn an unresolved finding into a pass; an open finding at that point goes to a person. diff --git a/comitatus/skills/herd-setup/SKILL.md b/comitatus/skills/herd-setup/SKILL.md index 1bde48ac..3d5f7772 100644 --- a/comitatus/skills/herd-setup/SKILL.md +++ b/comitatus/skills/herd-setup/SKILL.md @@ -37,7 +37,7 @@ Add `--local` for `~/.claude/settings.local.json` (machine, gitignored), or - `git fetch`, read-only `git status`/`git branch`. - One rule **per helper verb** at the stable path, e.g. `Bash(node /.claude/comitatus/skills/herdr/scripts/herd.js send:*)` - - so `status`/`members`/`wait`/`send`/`send-wait-read`/`agent`/`up` run + so `status`/`members`/`send`/`seed`/`broadcast`/`sync`/`withdraw`/`agent`/`up` run prompt-free, but ONLY when you call the helper by the **absolute path** shown in your herdr orientation (shell variables and relative paths defeat the permission matcher). diff --git a/comitatus/skills/herdr/SKILL.md b/comitatus/skills/herdr/SKILL.md index 1790dced..6878bf4e 100644 --- a/comitatus/skills/herdr/SKILL.md +++ b/comitatus/skills/herdr/SKILL.md @@ -24,9 +24,7 @@ commands in this skill are deliberately one command per line. copy the literal h the shell still parses the command you type. in PowerShell, single quotes keep a message body literal while double quotes expand `$variables`; in `cmd.exe`, use double quotes and escape its metacharacters. `` in the examples means one argument quoted for the shell currently running - it is not a promise that one quoting form is portable between shells. keep protocol messages on one physical line; do not use POSIX `\`, PowerShell backticks, or cmd carets to continue them. -helper verbs: `status | members | wait | send | send-wait-read | seed | broadcast | sync | withdraw | agent | up | role | fanout | wait-all | state | settled | fan-in | teardown`. - -`fan-in` and `teardown` are the two verbs `/herd-setup` does **not** pre-authorize, because they reach `git merge`, `git branch -D`, and `worktree remove --force`. Expect one permission prompt each; nothing is waiting on you at either step. +helper verbs: `status | members | send | seed | broadcast | sync | withdraw | agent | up`. waiting, reading, and git are native: use `herdr agent wait|read` and `git` directly. The message and membership contract is in [reference/protocol.md](reference/protocol.md). @@ -42,33 +40,35 @@ node HERD up --branch chore/my-slug --base origin/main --claude sly --codex jay ```json { "worktree": { "path": "…", "workspace_id": "wR" }, - "agents": [ { "handle": "sly", "kind": "claude", "model": null, "effort": null, + "agents": [ { "handle": "sly", "kind": "claude", "model": "claude-opus-5-5", "effort": null, "pane_id": "wR:p3", "tab": "wR:t2" } ] } ``` -`kind` is the integration; `model` and `effort` are what was actually **selected**, and `null` means **inherited** - so a launch on a model you chose is visibly distinct from one on whatever the ambient config happened to resolve. Do not read `kind` as an answer to "which model is it on". +`kind` is the integration; `model` and `effort` are what was actually **passed** - an explicit selector or the kind's default model - and `null` means **inherited** from the CLI's ambient config. Do not read `kind` as an answer to "which model is it on". -the `git fetch` happens only when `--base` names a configured remote. `origin/main` does; `task/my-slug` does not - a slash is not what makes a base remote-tracking, and a base with no slash is a local ref. so a **local-only base creates the worktree with no fetch attempted**, which is what lets `fanout` branch each partition off a task branch that was never pushed. +the `git fetch` happens only when `--base` names a configured remote. `origin/main` does; `task/my-slug` does not - a slash is not what makes a base remote-tracking, and a base with no slash is a local ref. so a **local-only base creates the worktree with no fetch attempted**, so a worktree can branch off a task branch that was never pushed. | flag | runs | glyph | |---|---|---| | `--claude [:]` | `claude` | ◆ | -| `--codex [:]` | `codex` | ◇ | +| `--codex [:]` | `codex --no-daemon` | ◇ | | `--opencode :` | `opencode -m ` | ⬨ | ### where model and effort come from -**a bare handle inherits both from the CLI's own ambient config** - nothing is passed, so whatever that CLI resolves at startup is what you get: +**a bare handle gets the kind's default model and inherits effort** from the CLI's own ambient config: -- **claude** - user/project/**managed** settings. org-managed settings can *pin* a model the user cannot change interactively (`/model` reports e.g. "Managed settings pins Sonnet 5"), which makes the launch flag the only available lever. -- **codex** - `config.toml` under the Codex home (`model`, `model_reasoning_effort`). +- **claude** - model `claude-opus-5-5`; effort from user/project/**managed** settings. org-managed settings can *pin* a model the user cannot change interactively (`/model` reports e.g. "Managed settings pins Sonnet 5"), which makes the launch flag the only available lever. +- **codex** - model `gpt-6.1-sol`; effort from `config.toml` under the Codex home (`model_reasoning_effort`). every codex launch also passes `--no-daemon`, so the session runs without the shared background app-server even when one is already running. - **opencode** - no default worth guessing, so its model is the one **required** selector. +an explicit `model=` always replaces the default; `effort=` or `role=` alone does not, so `jay:effort=high` still launches on `gpt-6.1-sol`. + the optional `:` overrides that per agent, as `key=value` pairs: | selector | claude | codex | opencode | |---|---|---|---| -| `model=` | `--model ` | `--model ` | `-m ` (required) | +| `model=` | `--model ` (default `claude-opus-5-5`) | `--model ` (default `gpt-6.1-sol`) | `-m ` (required) | | `effort=` | `--effort ` | `-c model_reasoning_effort=` | **unsupported - refused** | claude takes an alias (`opus`, `sonnet`, `fable`) or a full name (`claude-fable-5`), and its effort levels are `low, medium, high, xhigh, max`. codex has no effort *flag* - only the generic `-c =` config override - which is why the two kinds cannot share one flag pair. @@ -179,7 +179,7 @@ node HERD agent codex jay --workspace wR --cwd node HERD agent claude nell:model=opus,effort=high --workspace wR --cwd ``` -it runs the primitives for you: `tab create` (decorated label) -> `agent start --kind --pane --timeout ` (the agent takes over the tab's root shell pane, so there is no leftover shell to close; the handle is assigned **at launch** - no detect-then-rename) -> `agent wait --until idle` (ready to seed). the trailing `-- ` on `agent start` is the only channel for per-agent program args, so the `handle[:]` model/effort selectors ride there - same syntax and same defaults-are-inherited rule as `up` (see [where model and effort come from](#where-model-and-effort-come-from)), because both share one `makeAgent`. `--timeout` alone only guarantees interactive readiness, so the explicit `--until idle` wait is what confirms the status the caller depends on. +it runs the primitives for you: `tab create` (decorated label) -> `agent start --kind --pane --timeout ` (the agent takes over the tab's root shell pane, so there is no leftover shell to close; the handle is assigned **at launch** - no detect-then-rename) -> `agent wait --until idle` (ready to seed). the trailing `-- ` on `agent start` is the only channel for per-agent program args, so the `handle[:]` model/effort selectors ride there - same syntax and same defaults as `up` (see [where model and effort come from](#where-model-and-effort-come-from)), because both share one `makeAgent`. `--timeout` alone only guarantees interactive readiness, so the explicit `--until idle` wait is what confirms the status the caller depends on. ### 4. change a handle @@ -212,11 +212,11 @@ herdr workspace close wR ### 7. remove a worktree -removes the git worktree, its directory, the workspace, and all its tabs/agents - then delete the branch separately: +removes the git worktree, its directory, the workspace, and all its tabs/agents - then delete the branch separately. adjudicate first (see [cleanup](#cleanup-adjudicates-it-does-not-sweep)). leave `--force` off: `git worktree remove` refuses a dirty tree without it, and `git branch -d` refuses an unmerged branch where `-D` would not: ```text -herdr worktree remove --workspace wR --force --json -git branch -D chore/my-slug +herdr worktree remove --workspace wR --json +git branch -d chore/my-slug ``` `worktree remove` does not delete the branch; the second command does. @@ -234,10 +234,11 @@ node HERD send sly "review the diff on this branch; jay is cross-checking" --fyi Take `` from `herdr worktree list --json`. The first command removes the worktree, its directory, its workspace, and every tab and agent in that workspace. +Adjudicate its work first; add `--force` / `-D` only for what you decided to discard. ```text -herdr worktree remove --workspace --force --json -git branch -D chore/review-x +herdr worktree remove --workspace --json +git branch -d chore/review-x ``` ### assign a herd to a worktree @@ -249,22 +250,31 @@ step 1 (create the worktree) then step 3 per agent - or just `up`. the herd is s an agent's **cwd is fixed at launch** - you cannot re-cwd a running agent. reassigning = **relaunching** on the new worktree. handles are preserved; conversation + protocol seeding are **not**. handles must be unique, so the old herd comes down first: Closing the old workspace frees its handles. The final two commands are optional cleanup -for the old worktree and branch after the new herd launches successfully. +for the old worktree and branch after the new herd launches successfully, subject to the +same adjudication. ```text herdr workspace close node HERD up --branch chore/new-slug --base origin/main --claude sly --codex jay -git worktree remove --force -git branch -D chore/old-slug +git worktree remove +git branch -d chore/old-slug ``` after a reassign the relaunched agents are **cold**: re-seed the protocol + roster (see below). -## fan-out +## roles and multi-agent work + +this skill owns herd initialization, membership, and who does what: `seed` assigns each member its handle, roster and working lead, and the working lead coordinates. a small task needs nothing more - a working lead who also implements, plus an independent verifier given the requirements and the diff and test evidence. add a dedicated agent per part only when coordination or verification warrants one. comitatus ships no run workflow or role files; git and the native verbs carry the state. what follows are the rules that keep that safe: -For the architect/implementer runbook, branch naming, role delivery, and ordered -fan-in, use the [`fan-out` skill](../fan-out/SKILL.md). It builds on this skill's -herdr prerequisite, helper path, roster protocol, and worktree conventions. +- **one writer per tree.** an agent writes only in its own worktree (cwd is fixed at launch). anything another tree needs goes to that tree's owner as a message. +- **a timeout is not a failure to deliver.** a wait can time out after the text was submitted, and neither silence nor a missing echo in the pane proves it was lost. only an `undeliverable` result (or `agent_blocked`) establishes that no input was sent; after `accepted` or `observed`, do not resend blind - a resend duplicates the turn. +- **idle, a finished wait, or a commit is not completion.** an agent reads `idle` between its own turns, `agent prompt --wait` can match a turn that was already running, and an interim commit is not a finished one. done means the agent's reply plus the stated criteria checked - tests run, evidence read. +- **inspect a partial launch before retrying.** a failed `up` or `agent` can leave a worktree, workspace, tab, or a running agent holding the handle. look (`herdr worktree list`, `herdr agent list`) and remove or reuse what is there; never relaunch blind. +- **integrate in a stated order, in the target tree, by its owner.** start from a clean target tree and index - `git merge --abort` cannot always restore uncommitted changes that predate the merge. merge branches one at a time in the order you named. on a conflict, the owner of the tree being merged into resolves it or runs `git merge --abort`, so the tree is clean before anything is handed back. the branch's author fixes its own branch in its own tree. + +### cleanup adjudicates; it does not sweep + +before removing a worktree or deleting a branch, inspect what would be lost: `git -C status --short` (dirty and untracked files) and `git log ..` (commits not merged). decide each piece - **integrate** it, **preserve** it (keep the branch, or commit or push it where you already have that authority), or **discard** it. say what you are discarding and why, in one line, to whoever owns the work. `--force` and `git branch -D` are for that explicit discard, within the authority you already have; they are not the default. discarding a failed experiment you were authorized to throw away needs no extra approval - it needs to be named. ## agent-to-agent protocol (from/to) @@ -288,7 +298,7 @@ node HERD send jay "" --reply the helper reports mechanical evidence, and the `[from jay]` reply landing in your pane is the end-to-end confirmation. Silence is not proof of loss. For a message that expects **no** reply (`[herd ...]`, a one-way note), the helper serializes the send; it does not make the message durable: ```text -node HERD wait jay --status idle,done --timeout 30000 +herdr agent wait jay --until idle --until done --timeout 30000 ``` For delivery purposes, either `idle` or `done` means the recipient is free to receive. @@ -313,7 +323,9 @@ two complementary triggers: on receiving `[herd +H]`/`[herd -H]`: update your roster **idempotently**; do **not** reply and do **not** re-broadcast (every member detects independently; re-broadcasting causes O(N²) storms). -**lead withdrawal:** the lead may seed the herd, announce the active coordinator, send the withdrawal directive with `withdraw`, and then leave. Remaining members continue without waiting for the lead; the lead is not a required approval or review hop. +**the launcher stays out by default:** the agent that launches and seeds a herd is not a member of it unless it lists itself in `--roster`. `seed` defaults the working lead to the first handle in the roster - so pass the same `--roster`, lead first, to every seed - and refuses a `--lead` that is not in the roster, because a lead the members cannot see strands the herd. a sender left off the roster is named in the seed line as an outside launcher - neither member nor coordinator - so members do not wait on it unless their task names it as an approver. the seed reply still comes back to the launcher; that is the proof of seeding, not membership. **keep the launcher in its own workspace** (`up` already puts each herd in a fresh one): `sync`, `members`, `broadcast` and `withdraw` infer membership from the workspace, so a launcher co-located with the herd is counted whatever the roster says. + +**lead withdrawal:** a launcher that *did* join - by listing itself in `--roster` - leaves with `withdraw --lead `: it hands off to the working lead first, then sends the withdrawal directive. Remaining members continue without waiting for it; it is not a required approval or review hop. add sequence: seed the newcomer with the roster, then `[herd +new]`. remove sequence: `[herd -gone]`, then close the agent's tab (steps 6-7). @@ -342,13 +354,13 @@ the native wait command does not accept the helper's comma-list syntax. - **fetch before `worktree create`** - `--base origin/main` is the local ref; it is stale until you `git fetch origin main`. - **`agent prompt` types AND submits** - `herdr agent prompt "..."` is the native one-call send (kind-aware: it handles codex's double-Enter). `node HERD send` wraps it to stamp the sender, serialize the recipient, and report the three-valued `delivery` result. -- **`--status` comma lists are helper-only** - `node HERD wait jay --status idle,done` accepts a set (it polls `agent list` itself); the native `herdr agent wait --until ` needs one `--until` per state (repeat the flag). +- **native `agent wait` takes one `--until` per state** - repeat the flag (`--until idle --until done`). without `--until` it also matches `blocked`, so name the states you mean. - **`agent start` needs an existing shell pane** - it takes `--kind --pane ` and takes that pane over; it does **not** spawn its own. the helper/`up` hand it a fresh tab's root pane. `--timeout` waits only for interactive readiness, not for `idle` - wait on `idle` explicitly after. - **a fresh codex pane can read "ready" behind an update modal** - codex may open on a blocking `Update available! -> ` menu. `agent start` succeeds and herdr reports the pane interactively ready, but that modal owns the keyboard, so the first prompt - a seed line included - lands in the menu instead of the composer. the `blocked` send gate does **not** save you here: herdr detects the pane as ready, not `blocked`, so `send` types anyway and reports a normal delivery. on an unattended codex launch, `agent read ` first and clear the menu (`herdr agent send-keys "2"` to skip) before the first send. observed on codex 0.147.0 prompting to 0.149.0. - **install the managed opencode status integration** - herdr 0.9.0 ships the current integration, but it is not active until `herdr integration install opencode` installs it in the user's opencode plugin directory. without it, status waits on opencode panes are unreliable. - **only the home repo's worktree tree nests; plain workspaces don't** - a `workspace create --cwd` workspace gets no repo association and floats ungrouped, even at a repo root. give each herd agent a *tab* in the herd's one workspace; make the herd a worktree to nest it under the repo. - **workspace label vs tab truncation** - rows render ` · `; the long worktree-name label can truncate the tab label in the collapsed sidebar; the handle reappears on focus/widen. that label is herdr's default - don't rename it. -- **`worktree remove` keeps the branch** - it removes the worktree, directory, workspace, and all tabs/agents, but `git branch -D` is separate. +- **`worktree remove` keeps the branch** - it removes the worktree, directory, workspace, and all tabs/agents, but deleting the branch (`git branch -d`) is separate. - **`open` reattaches, `create` makes new branches** - existing branch with no worktree -> `git worktree add` first, then `open` (home repo only). - **one repo per herdr session** - `worktree create` always builds in the session's home repo regardless of cwd (verified). a second repo needs a second `herdr --session `. - **`worktree create`/`open` run from the repo's main-checkout workspace** - from a linked worktree they error `linked_worktree_source`; pass `--workspace ` or run from there. diff --git a/comitatus/skills/herdr/reference/names.md b/comitatus/skills/herdr/reference/names.md index 4358eb9b..61ac57da 100644 --- a/comitatus/skills/herdr/reference/names.md +++ b/comitatus/skills/herdr/reference/names.md @@ -17,11 +17,12 @@ so this file is just the member call-sign pool. short, phonetically distinct call-signs, type-agnostic, each claimed as the **next unused** entry - check live state and skip any already in use: -```bash -: "${H:?set H from the herdr orientation line before piping herdr JSON into node}" -herdr agent list | node "$H" members # taken member handles +```text +node HERD members # taken member handles, across every workspace ``` +`HERD` is the absolute helper path from your herdr orientation. + constraints: - **member handles are globally unique - herdr enforces it.** a duplicate `agent start` is diff --git a/comitatus/skills/herdr/reference/protocol.md b/comitatus/skills/herdr/reference/protocol.md index 43d8a693..0523fefa 100644 --- a/comitatus/skills/herdr/reference/protocol.md +++ b/comitatus/skills/herdr/reference/protocol.md @@ -59,9 +59,18 @@ scoped to the OS user: uid on POSIX, a hash of the Windows account name and home on Windows. Cleanup uses Node's bounded retries for transient Windows directory-removal errors; it never steals a lock merely because its timestamp is old. +## Launcher outside the herd + +By default the launcher that seeds a herd is not a member of it. The working lead defaults +to the first handle in the roster and must be a roster member; a sender absent from the +roster is named in each seed as an outside launcher, neither member nor coordinator, that +members do not wait on unless their task names it as an approver. A launcher joins only by +listing itself in the roster. Membership is inferred from the workspace, so an outside +launcher must run from a different workspace than the herd. + ## Lead withdrawal -A lead may seed the herd and then withdraw. The handoff is explicit: identify the active +A launcher that joined the herd may withdraw. The handoff is explicit: identify the active coordinator, send the handoff first, send `[herd -self]` to each member, and only then close the lead's pane. Remaining members own the work and must not wait for the departed lead. If no coordinator remains, diff --git a/comitatus/skills/herdr/scripts/branch-naming.js b/comitatus/skills/herdr/scripts/branch-naming.js deleted file mode 100644 index a0ee6aba..00000000 --- a/comitatus/skills/herdr/scripts/branch-naming.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -const DEFAULT_PARTITION_SEP = '-'; - -function partitionSeparator(value) { - if (value === undefined || value === '') throw new Error('--partition-sep needs a value'); - const separator = String(value); - if (separator.includes('/')) { - throw new Error('--partition-sep cannot contain "/": git refs are files, and the task branch already exists'); - } - if (!/^[-._]+$/.test(separator)) { - throw new Error('--partition-sep must contain only "-", ".", or "_"'); - } - return separator; -} - -function resolveBranchNaming({ run, taskBranch, partitionSep } = {}) { - if (!run) throw new Error('--run is required'); - const resolvedTaskBranch = taskBranch === undefined ? `task/${run}` : taskBranch; - if (!resolvedTaskBranch) throw new Error('--task-branch is required'); - const resolvedPartitionSep = partitionSeparator( - partitionSep === undefined ? DEFAULT_PARTITION_SEP : partitionSep); - return { taskBranch: resolvedTaskBranch, partitionSep: resolvedPartitionSep }; -} - -function partitionBranch({ taskBranch, partitionSep }, partition) { - return `${taskBranch}${partitionSep}${partition}`; -} - -module.exports = { - DEFAULT_PARTITION_SEP, - partitionSeparator, - resolveBranchNaming, - partitionBranch, -}; diff --git a/comitatus/skills/herdr/scripts/fanin.js b/comitatus/skills/herdr/scripts/fanin.js deleted file mode 100644 index 046fcbfe..00000000 --- a/comitatus/skills/herdr/scripts/fanin.js +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// STUB — partition `fanin` of run fanout-comitatus. Every function is exported -// with a correctly-typed placeholder return so the contract tests in -// __tests__/fanin.test.js fail on an assertion that prints what it got, rather -// than on a missing module. Replace each body; do not change a signature -// without changing the test that names it. -// -// This module owns the git side of fan-out-trial.md. Git is the state machine in -// the literal sense: which step a run is on is never stored anywhere, it is read -// off the refs. `state` therefore derives its whole answer on every call and -// persists nothing — a cached copy would be the run journal the runbook's "do -// not build yet" list refuses. -// -// Nothing here reads stdin. Every git and herdr invocation goes through -// deps.run so a test can assert the exact argv. -// -// Require ./herd.js LAZILY (for waitCmd), inside the function that needs it: -// herd.js requires this module back from its dispatch. - -const { - partitionSeparator, - resolveBranchNaming, - partitionBranch, -} = require('./branch-naming.js'); - -const PHASES = Object.freeze( - ['absent', 'started', 'partitioned', 'fanned-out', 'fanned-in', 'finished']); - -function lines(value) { - return String(value || '').split('\n').map((line) => line.trim()).filter(Boolean); -} - -function required(value, flag) { - if (!value) throw new Error(`${flag} is required`); - return value; -} - -function positiveNumber(value, flag) { - const number = Number(required(value, flag)); - if (!Number.isFinite(number) || number < 0) { - throw new Error(`${flag} must be a non-negative number`); - } - return number; -} - -function parsePartitions(value) { - return required(value, '--partitions').split(',').map((part) => part.trim()).filter(Boolean); -} - -function regexpEscape(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// state --run -// eslint-disable-next-line no-unused-vars -function parseState(args) { - const out = { - run: undefined, - taskBranch: undefined, - partitionSep: undefined, - partitions: undefined, - }; - for (let i = 0; i < args.length; i++) { - const flag = args[i]; - const value = () => args[++i]; - if (flag === '--run') out.run = value(); - else if (flag === '--task-branch') out.taskBranch = value(); - else if (flag === '--partition-sep') out.partitionSep = partitionSeparator(value()); - else if (flag === '--partitions') out.partitions = parsePartitions(value()); - else throw new Error(`unknown flag: ${flag}`); - } - return { ...out, ...resolveBranchNaming(out) }; -} - -// Read-only by construction. `stateCmd` must issue no merge, branch -D, -// checkout, reset, or push: a verb the operator runs to find out where they are -// cannot be a verb that moves them. -// eslint-disable-next-line no-unused-vars -function stateCmd(args, deps) { - const cfg = parseState(args); - const { taskBranch } = cfg; - try { - deps.run('git', ['rev-parse', '--verify', '--quiet', taskBranch]); - } catch { - const absent = { - run: cfg.run, - started: false, - manifest: false, - partitions: [], - logRow: false, - }; - return { ...absent, phase: derivePhase(absent) }; - } - - const runPath = `.pipeline/runs/${cfg.run}`; - const taskFiles = lines(deps.run( - 'git', ['ls-tree', '-r', '--name-only', taskBranch, '--', runPath])); - const manifest = taskFiles.includes(`${runPath}/manifest.md`); - const branchPattern = partitionBranch(cfg, '*'); - let branches = lines(deps.run( - 'git', ['branch', '--list', branchPattern, '--format=%(refname:short)'])); - if (cfg.partitions) { - const allowed = new Set(cfg.partitions.map((partition) => partitionBranch(cfg, partition))); - branches = branches.filter((branch) => allowed.has(branch)); - } - const merged = new Set(lines(deps.run( - 'git', ['branch', '--merged', taskBranch, '--list', branchPattern, '--format=%(refname:short)']))); - const branchPrefix = partitionBranch(cfg, ''); - const partitions = branches.map((branch) => { - const branchFiles = lines(deps.run( - 'git', ['ls-tree', '-r', '--name-only', branch, '--', runPath])); - return { - name: branch.slice(branchPrefix.length), - branch, - merged: merged.has(branch), - blocked: branchFiles.some((file) => /\/BLOCKED-[^/]+\.md$/.test(file)), - probe: branchFiles.some((file) => /\/probe-[^/]+\.md$/.test(file)), - }; - }); - let log = ''; - try { - log = deps.run('git', ['show', `${taskBranch}:.pipeline/log.md`]); - } catch { - // A task may be active before the cumulative log exists. - } - const row = new RegExp(`^\\|\\s*${regexpEscape(cfg.run)}\\s*\\|`, 'm'); - const facts = { - run: cfg.run, - started: true, - manifest, - partitions, - logRow: row.test(String(log || '')), - }; - return { ...facts, phase: derivePhase(facts) }; -} - -// The single derived answer to "which step am I on", from the facts stateCmd -// already collected. Separate so it is testable without a git mock. -// eslint-disable-next-line no-unused-vars -function derivePhase(facts) { - if (!facts.started) return PHASES[0]; - if (!facts.manifest) return PHASES[1]; - if (facts.logRow) return PHASES[5]; - if (!facts.partitions || facts.partitions.length === 0) return PHASES[2]; - if (facts.partitions.every((partition) => partition.merged)) return PHASES[4]; - return PHASES[3]; -} - -// STUB — partition `settle` of run orch-selfhost. `parseSettled` and -// `settledCmd` below are placeholders with the right return TYPES so the -// contract tests in __tests__/fanin.test.js fail on an assertion that prints -// what they got, rather than on a missing export. Replace both bodies. -// -// settled --run --partitions a,b -// -// The question `wait-all` cannot answer. An agent reads `idle` between its own -// turns, so a set of idle handles is not a set of finished partitions — it is -// the single most common way a run is declared complete early. This asks git -// instead: a commit on the partition branch, or a committed BLOCKED-

.md, is -// durable evidence; a status is a sample of a pane. -// -// Read-only by the same rule as stateCmd, and for a stronger reason: this verb -// is what a caller polls in a loop, so it must be safe to call at any moment -// during a partition's own commit. -// eslint-disable-next-line no-unused-vars -function parseSettled(args) { - const out = { - run: undefined, - partitions: undefined, - taskBranch: undefined, - partitionSep: undefined, - }; - for (let i = 0; i < args.length; i++) { - const flag = args[i]; - const value = () => args[++i]; - if (flag === '--run') out.run = value(); - else if (flag === '--partitions') out.partitions = parsePartitions(value()); - else if (flag === '--task-branch') out.taskBranch = value(); - else if (flag === '--partition-sep') out.partitionSep = partitionSeparator(value()); - else throw new Error(`unknown flag: ${flag}`); - } - const naming = resolveBranchNaming(out); - if (!out.partitions) throw new Error('--partitions is required'); - return { ...out, ...naming }; -} - -// eslint-disable-next-line no-unused-vars -function settledCmd(args, deps) { - const cfg = parseSettled(args); - const { taskBranch } = cfg; - try { - deps.run('git', ['rev-parse', '--verify', '--quiet', taskBranch]); - } catch { - throw new Error(`task branch does not exist: ${taskBranch}`); - } - - const runPath = `.pipeline/runs/${cfg.run}`; - return cfg.partitions.map((partition) => { - const branch = partitionBranch(cfg, partition); - try { - deps.run('git', ['rev-parse', '--verify', '--quiet', branch]); - } catch { - return { partition, branch, status: 'working', commits: 0, reason: 'no branch' }; - } - - const commits = Number(String( - deps.run('git', ['rev-list', '--count', `${taskBranch}..${branch}`])).trim()); - const branchFiles = lines(deps.run( - 'git', ['ls-tree', '-r', '--name-only', branch, '--', runPath])); - const blocked = `BLOCKED-${partition}.md`; - const isBlocked = branchFiles.includes(`${runPath}/${blocked}`); - return { - partition, - branch, - status: isBlocked ? 'blocked' : commits > 0 ? 'done' : 'working', - commits, - blocked: isBlocked ? blocked : undefined, - }; - }); -} - -// fan-in --run --partitions a,b [--wait-handle arch] [--timeout ms] [--dry-run] -// eslint-disable-next-line no-unused-vars -function parseFanin(args) { - const out = { - run: undefined, - partitions: undefined, - taskBranch: undefined, - partitionSep: undefined, - waitHandle: 'arch', - timeout: 300000, - dryRun: false, - }; - for (let i = 0; i < args.length; i++) { - const flag = args[i]; - const value = () => { - const next = args[++i]; - if (next === undefined) throw new Error(`missing value for ${flag}`); - return next; - }; - if (flag === '--run') out.run = value(); - else if (flag === '--partitions') out.partitions = parsePartitions(value()); - else if (flag === '--task-branch') out.taskBranch = value(); - else if (flag === '--partition-sep') out.partitionSep = partitionSeparator(value()); - else if (flag === '--wait-handle') out.waitHandle = value(); - else if (flag === '--timeout') out.timeout = positiveNumber(value(), flag); - else if (flag === '--dry-run') out.dryRun = true; - else throw new Error(`unknown flag: ${flag}`); - } - const naming = resolveBranchNaming(out); - if (!out.partitions) throw new Error('--partitions is required'); - return { ...out, ...naming }; -} - -// Two refusals before the first merge, both from the runbook: -// - HEAD must be task/. `arch` owns the task branch and merging from a -// partition worktree is prohibited. -// - `arch` must be settled. Merging under a running agent rewrites the tree it -// is living in — the same hazard as diffing under one. -// -// On the first conflict: stop, leave the merge in place for the implementer to -// resolve, and report the conflicted paths. Later partitions are not attempted; -// merging past a conflict buries which partition caused it. -// eslint-disable-next-line no-unused-vars -function faninCmd(args, deps) { - const cfg = parseFanin(args); - const { taskBranch } = cfg; - const currentBranch = String( - deps.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); - if (currentBranch !== taskBranch) { - throw new Error(`fan-in must run on ${taskBranch}; found ${currentBranch || '(detached HEAD)'}`); - } - - const { waitCmd } = require('./herd.js'); - try { - waitCmd([cfg.waitHandle, '--status', 'idle,done', '--timeout', String(cfg.timeout)], deps); - } catch (error) { - throw new Error(`cannot fan in while ${cfg.waitHandle} is unsettled: ${error.message}`); - } - - const plan = cfg.partitions.map((partition) => partitionBranch(cfg, partition)); - if (cfg.dryRun) return { run: cfg.run, plan, dryRun: true }; - - const merged = []; - for (let i = 0; i < cfg.partitions.length; i++) { - const partition = cfg.partitions[i]; - const branch = plan[i]; - try { - deps.run('git', ['merge', '--no-edit', branch]); - merged.push(branch); - } catch (error) { - const files = lines(deps.run('git', ['diff', '--name-only', '--diff-filter=U'])); - if (files.length === 0) throw error; - return { - run: cfg.run, - merged, - conflict: { partition, branch, files }, - }; - } - } - return { run: cfg.run, merged, conflict: undefined }; -} - -// teardown --run --partitions a,b [--yes] -// eslint-disable-next-line no-unused-vars -function parseTeardown(args) { - const out = { - run: undefined, - partitions: undefined, - taskBranch: undefined, - partitionSep: undefined, - yes: false, - }; - for (let i = 0; i < args.length; i++) { - const flag = args[i]; - const value = () => { - const next = args[++i]; - if (next === undefined) throw new Error(`missing value for ${flag}`); - return next; - }; - if (flag === '--run') out.run = value(); - else if (flag === '--partitions') out.partitions = parsePartitions(value()); - else if (flag === '--task-branch') out.taskBranch = value(); - else if (flag === '--partition-sep') out.partitionSep = partitionSeparator(value()); - else if (flag === '--yes') out.yes = true; - else throw new Error(`unknown flag: ${flag}`); - } - const naming = resolveBranchNaming(out); - if (!out.partitions) throw new Error('--partitions is required'); - return { ...out, ...naming }; -} - -// Destroys nothing without --yes. `herdr worktree remove --force` discards -// uncommitted files silently and `git branch -D` discards commits, and -// BLOCKED-*.md and probe-*.md are the trial's whole evidence base — a partition -// holding one that is uncommitted, or committed but not yet merged into the task -// branch, is skipped with a reason rather than destroyed. -// -// Order matters: remove the worktree, THEN delete the branch. `worktree remove` -// does not delete the branch, and a branch still checked out cannot be deleted. -// eslint-disable-next-line no-unused-vars -function teardownCmd(args, deps) { - const cfg = parseTeardown(args); - const { taskBranch } = cfg; - const data = JSON.parse(deps.run('herdr', ['worktree', 'list', '--json'])); - const worktrees = (data && data.result && data.result.worktrees) || []; - const branchPattern = partitionBranch(cfg, '*'); - const merged = new Set(lines(deps.run( - 'git', ['branch', '--merged', taskBranch, '--list', branchPattern, '--format=%(refname:short)']))); - const runPath = `.pipeline/runs/${cfg.run}`; - - return cfg.partitions.map((partition) => { - const branch = partitionBranch(cfg, partition); - const worktree = worktrees.find((item) => item && item.branch === branch); - const workspace = worktree && worktree.open_workspace_id; - const result = { partition, branch, workspace, action: 'skipped', reason: undefined }; - if (!worktree || !workspace) { - result.reason = 'no live worktree'; - return result; - } - - const dirty = lines(deps.run( - 'git', ['-C', worktree.path, 'status', '--porcelain', '--', '.pipeline'])); - const dirtyBlocked = dirty.map((line) => line.slice(3).trim()) - .find((file) => /(^|\/)BLOCKED-[^/]+\.md$/.test(file)); - if (dirtyBlocked) { - result.reason = `uncommitted ${dirtyBlocked.split('/').pop()}`; - return result; - } - - const branchFiles = lines(deps.run( - 'git', ['ls-tree', '-r', '--name-only', branch, '--', runPath])); - const blocked = branchFiles.find((file) => /\/BLOCKED-[^/]+\.md$/.test(file)); - if (blocked && !merged.has(branch)) { - result.reason = `unmerged ${blocked.split('/').pop()}`; - return result; - } - - if (!cfg.yes) { - result.action = 'planned'; - return result; - } - deps.run('herdr', ['worktree', 'remove', '--workspace', workspace, '--force', '--json']); - deps.run('git', ['branch', '-D', branch]); - result.action = 'removed'; - return result; - }); -} - -module.exports = { - PHASES, - parseState, - stateCmd, - derivePhase, - parseSettled, - settledCmd, - parseFanin, - faninCmd, - parseTeardown, - teardownCmd, -}; diff --git a/comitatus/skills/herdr/scripts/fanout.js b/comitatus/skills/herdr/scripts/fanout.js deleted file mode 100644 index 0f49710d..00000000 --- a/comitatus/skills/herdr/scripts/fanout.js +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { - partitionSeparator, - resolveBranchNaming, - partitionBranch, -} = require('./branch-naming.js'); - -// This module owns the launch/message side of fan-out-trial.md: composing a role -// line, standing up one worktree+implementer per partition, and observing them -// all settle. The git side (state, fan-in, teardown) is fanin.js. -// -// Like every verb in herd.js, nothing here reads stdin and nothing takes a shell -// variable: each function fetches the herdr state it needs through deps.run. -// -// Require ./herd.js and ./up.js LAZILY, inside the function that needs them. -// herd.js requires this module back from its dispatch, and a top-level cycle -// leaves whichever side loaded second holding a half-initialised exports object. - -// These are the runbook's own defaults, not preferences. -const DEFAULT_ROLES_DIR = '.pipeline/roles'; -const NAMES_MD = path.resolve(__dirname, '..', 'reference', 'names.md'); -const DEFAULT_WAIT_TIMEOUT_MS = 900000; -const DEFAULT_WAIT_INTERVAL_MS = 2000; - -function requiredText(value, name) { - if (value === undefined || value === '' || /^--/.test(String(value))) { - throw new Error(`${name} needs a value`); - } - const text = String(value); - if (/\r|\n/.test(text)) throw new Error(`${name} must be one line`); - return text; -} - -function positiveNumber(value, name) { - const number = Number(requiredText(value, name)); - if (!Number.isFinite(number) || number < 0) { - throw new Error(`${name} must be a non-negative number`); - } - return number; -} - -function agents(data) { - return (data && data.result && data.result.agents) || []; -} - -// The call-sign pool is read from reference/names.md rather than copied into -// this file. That document is what a human is told to claim handles from, and -// a second copy here would drift from it silently - the reader and the tool -// would disagree about the roster with nothing to catch it. -// -// The pool is the first fenced block with no info string; the `bash` block -// above it is an example, not names. -function handlePool(readFile = fs.readFileSync) { - let text; - try { - text = String(readFile(NAMES_MD, 'utf8')); - } catch (error) { - throw new Error(`cannot read the handle pool at ${NAMES_MD}: ${error.message}`); - } - // Scanned line by line, tracking fence state. A regex over the whole file - // reads the CLOSING fence of the ```bash example as an opener and returns its - // prose as names. - let info = null; // info string of the fence we are inside, else null - let body = []; - for (const line of text.split('\n')) { - const fence = /^```(.*)$/.exec(line); - if (fence && info === null) { - info = fence[1].trim(); - body = []; - } else if (fence) { - if (info === '') { - const names = body.join(' ').split(/\s+/).filter((w) => /^[a-z][a-z0-9]*$/.test(w)); - if (names.length > 0) return names; - } - info = null; - } else if (info !== null) { - body.push(line); - } - } - throw new Error(`no handle pool found in ${NAMES_MD}`); -} - -// Claim the next unused call-signs. Handles are globally unique across every -// workspace (herdr rejects a duplicate `agent start` with agent_name_taken), so -// the live list is the only authority on what is free - not a counter, and not -// what this run launched. -function claimHandles(count, taken, pool = handlePool()) { - const free = pool.filter((name) => !taken.has(name)); - if (free.length < count) { - throw new Error( - `handle pool exhausted: need ${count}, ${free.length} free of ${pool.length} in ${NAMES_MD}`); - } - return free.slice(0, count); -} - -function fetchAgents(deps) { - const herd = require('./herd.js'); - return herd.fetchAgents(deps); -} - -// One line, always: a newline in a prompt submits the turn, so a two-line role -// assignment delivers half of itself. The role file is named by PATH and never -// read here — pasting its text is what makes a role assignment unauditable. -function roleLine({ role, run, partition, hypothesis, rolesDir } = {}) { - role = requiredText(role, 'role'); - run = requiredText(run, 'run'); - rolesDir = rolesDir === undefined ? DEFAULT_ROLES_DIR : requiredText(rolesDir, 'rolesDir'); - const parts = [`read ${path.posix.join(rolesDir, `${role}.md`)} and follow it. $RUN is ${run}`]; - if (partition !== undefined) { - parts.push(`$PARTITION is ${requiredText(partition, 'partition')}`); - } - if (hypothesis !== undefined) { - parts.push(`$HYPOTHESIS is ${requiredText(hypothesis, 'hypothesis')}`); - } - return parts.join(', '); -} - -// role --role --run [--partition p] [--hypothesis n] -// [--roles-dir d] [--from self] -function parseRole(args) { - const out = { - handle: args[0], - role: undefined, - run: undefined, - partition: undefined, - hypothesis: undefined, - rolesDir: DEFAULT_ROLES_DIR, - from: undefined, - }; - if (!out.handle || out.handle.startsWith('--')) throw new Error('role needs a handle'); - for (let i = 1; i < args.length; i++) { - const flag = args[i]; - const need = () => requiredText(args[++i], flag); - if (flag === '--role') out.role = need(); - else if (flag === '--run') out.run = need(); - else if (flag === '--partition') out.partition = need(); - else if (flag === '--hypothesis') out.hypothesis = need(); - else if (flag === '--roles-dir') out.rolesDir = need(); - else if (flag === '--from') out.from = need(); - else throw new Error(`unknown flag: ${flag}`); - } - if (!out.role) throw new Error('--role is required'); - if (!out.run) throw new Error('--run is required'); - return out; -} - -// Resolves the role file against the RECIPIENT's cwd from `herdr agent list`, -// not the sender's. The runbook's own named failure is a task file that exists -// only in the operator's main checkout, and the recipient's cwd is the only -// place that can be checked from here. -function roleCmd(args, deps) { - const cfg = parseRole(args); - const target = agents(fetchAgents(deps)).find((agent) => agent && agent.name === cfg.handle); - if (!target) throw new Error(`no agent: ${cfg.handle}`); - const rolePath = path.resolve(target.cwd, cfg.rolesDir, `${cfg.role}.md`); - if (!fs.existsSync(rolePath)) throw new Error(`role file not found in recipient cwd: ${rolePath}`); - - const line = roleLine(cfg); - const sendArgs = [cfg.handle, line, '--reply']; - if (cfg.from) sendArgs.push('--from', cfg.from); - const { sendCmd } = require('./herd.js'); - return { ...sendCmd(sendArgs, deps), role: cfg.role, line }; -} - -// fanout --run --partitions a,b [--base task/] [--kind codex] -// [--selector model=..,effort=..] [--role implementer] -// [--role-tag impl] [--roles-dir d] [--timeout ms] -function parseFanout(args) { - const out = { - run: undefined, - partitions: undefined, - taskBranch: undefined, - partitionSep: undefined, - base: undefined, - kind: 'codex', - selector: 'model=gpt-5.6-sol,effort=medium', - role: 'implementer', - roleTag: 'impl', - rolesDir: DEFAULT_ROLES_DIR, - timeout: 45000, - }; - for (let i = 0; i < args.length; i++) { - const flag = args[i]; - const need = () => requiredText(args[++i], flag); - if (flag === '--run') out.run = need(); - else if (flag === '--partitions') out.partitions = need().split(',').filter(Boolean); - else if (flag === '--task-branch') out.taskBranch = need(); - else if (flag === '--partition-sep') out.partitionSep = partitionSeparator(args[++i]); - else if (flag === '--base') out.base = need(); - else if (flag === '--kind') out.kind = need(); - else if (flag === '--selector') out.selector = need(); - else if (flag === '--role') out.role = need(); - else if (flag === '--role-tag') out.roleTag = need(); - else if (flag === '--handle-prefix') { - // Removed rather than aliased: it minted `impl1`/`impl2`, which the - // handle pool replaces. Silently mapping it onto --role-tag would name - // tabs after a flag whose whole meaning was the handle. - throw new Error('--handle-prefix is gone: handles come from the call-sign pool; use --role-tag to name the tab part'); - } - else if (flag === '--roles-dir') out.rolesDir = need(); - else if (flag === '--timeout') out.timeout = positiveNumber(args[++i], flag); - else throw new Error(`unknown flag: ${flag}`); - } - if (!out.run) throw new Error('--run is required'); - if (!out.partitions || out.partitions.length === 0) throw new Error('--partitions is required'); - const duplicate = out.partitions.find((partition, i) => out.partitions.indexOf(partition) !== i); - if (duplicate) throw new Error(`duplicate partition: ${duplicate}`); - const naming = resolveBranchNaming(out); - out.base = out.base || naming.taskBranch; - return { ...out, ...naming }; -} - -// Sequential per partition on purpose: `herdr worktree create` resolves its -// source workspace from the invocation cwd, and two concurrent creates off one -// base have not been shown safe. Width shows up as parallelism in waitAllCmd. -// -// A partition that fails does not abort the rest. One taken handle or one dead -// codex should not cost the operator the partitions that would have come up. -function fanoutCmd(args, deps) { - const cfg = parseFanout(args); - // Claimed against the live list BEFORE the first worktree: a collision - // surfaces only at `agent start`, by which point the tab and the tree exist. - const taken = new Set(agents(fetchAgents(deps)).map((agent) => agent && agent.name).filter(Boolean)); - const claimed = claimHandles(cfg.partitions.length, taken, cfg.pool || handlePool()); - const requests = cfg.partitions.map((partition, i) => ({ - partition, - handle: claimed[i], - branch: partitionBranch(cfg, partition), - })); - - // `--selector` accepts a bare model (`gpt-5.6-sol`) as well as key=value - // form. Appending `,role=` to a bare model would produce one unparseable - // model id, so normalise to keys first. - const selector = /^\w+=/.test(cfg.selector) - ? `${cfg.selector},role=${cfg.roleTag}` - : `model=${cfg.selector},role=${cfg.roleTag}`; - - const { up } = require('./up.js'); - return requests.map((request) => { - let launched; - try { - const launch = up([ - '--branch', request.branch, - '--base', cfg.base, - `--${cfg.kind}`, `${request.handle}:${selector}`, - '--timeout', String(cfg.timeout), - ], deps); - launched = { - ...request, - worktree: launch.worktree, - agent: launch.agents[0], - }; - const delivery = roleCmd([ - request.handle, - '--role', cfg.role, - '--run', cfg.run, - '--partition', request.partition, - '--roles-dir', cfg.rolesDir, - ], deps); - return { - ...launched, - delivery: delivery.delivery, - }; - } catch (error) { - return { ...(launched || request), error: error.message }; - } - }); -} - -// wait-all [--status idle,done] [--timeout ms] [--interval ms] -function parseWaitAll(args) { - const out = { - handles: undefined, - statuses: ['idle', 'done'], - timeout: DEFAULT_WAIT_TIMEOUT_MS, - interval: DEFAULT_WAIT_INTERVAL_MS, - }; - let i = 0; - if (args[0] && !args[0].startsWith('--')) { - out.handles = args[0].split(',').filter(Boolean); - i = 1; - } - for (; i < args.length; i++) { - const flag = args[i]; - if (flag === '--status') { - out.statuses = requiredText(args[++i], flag).split(',').filter(Boolean); - } else if (flag === '--timeout') { - out.timeout = positiveNumber(args[++i], flag); - } else if (flag === '--interval') { - out.interval = positiveNumber(args[++i], flag); - } else { - throw new Error(`unknown flag: ${flag}`); - } - } - if (!out.handles || out.handles.length === 0) throw new Error('wait-all needs at least one handle'); - return out; -} - -// ONE `herdr agent list` per round for the whole set, not one wait per handle: -// N waits cost N sockets and serialise on the slowest handle. Never throws on a -// timeout — the operator needs to see which handles settled and which did not, -// and an exception reports only the first failure. -function waitAllCmd(args, deps) { - const cfg = parseWaitAll(args); - const now = deps.now || Date.now; - const sleep = deps.sleep || ((ms) => { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms)); - }); - const deadline = now() + cfg.timeout; - let rows; - for (;;) { - const byHandle = new Map(agents(fetchAgents(deps)).map((agent) => [agent.name, agent])); - rows = cfg.handles.map((handle) => { - const agent = byHandle.get(handle); - const status = agent && agent.agent_status; - return { handle, status, settled: cfg.statuses.includes(status) }; - }); - if (rows.every(({ settled }) => settled) || now() >= deadline) return rows; - sleep(cfg.interval); - } -} - -module.exports = { - DEFAULT_ROLES_DIR, - DEFAULT_WAIT_TIMEOUT_MS, - DEFAULT_WAIT_INTERVAL_MS, - NAMES_MD, - handlePool, - claimHandles, - roleLine, - parseRole, - roleCmd, - parseFanout, - fanoutCmd, - parseWaitAll, - waitAllCmd, -}; diff --git a/comitatus/skills/herdr/scripts/herd.js b/comitatus/skills/herdr/scripts/herd.js index 385ee060..4d4d860b 100755 --- a/comitatus/skills/herdr/scripts/herd.js +++ b/comitatus/skills/herdr/scripts/herd.js @@ -52,34 +52,6 @@ function defaultSleep(ms) { function sleeper(deps) { return (deps && deps.sleep) || defaultSleep; } function clock(deps) { return (deps && deps.now) || Date.now; } -function parseWait(args) { - const out = { handle: args[0], statuses: ['idle'], timeout: 45000, interval: 1000 }; - for (let i = 1; i < args.length; i++) { - const v = () => args[++i]; - if (args[i] === '--status') out.statuses = v().split(',').filter(Boolean); - else if (args[i] === '--timeout') out.timeout = Number(v()); - else if (args[i] === '--interval') out.interval = Number(v()); - } - return out; -} - -// Comma-status OR ("idle,done") is why this exists: the native -// `herdr wait agent-status` takes exactly one status. -function waitCmd(args, deps) { - const cfg = parseWait(args); - const now = clock(deps); - const sleep = sleeper(deps); - const deadline = now() + cfg.timeout; - for (;;) { - const st = status(fetchAgents(deps), cfg.handle); - if (cfg.statuses.includes(st)) return st; - if (now() >= deadline) { - throw new Error(`wait timeout: ${cfg.handle} is ${st}, want ${cfg.statuses.join(',')}`); - } - sleep(cfg.interval); - } -} - function resolveSelf(data, override, env = process.env) { if (override) return override; // The executing pane is the identity source: focus is a global that drifts @@ -403,20 +375,6 @@ function sendCmd(args, deps) { }; } -function sendWaitReadCmd(args, deps) { - const handle = args[0]; - const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; }; - const timeout = String(Number(opt('--timeout', '60000'))); - const lines = String(Number(opt('--lines', '40'))); - - const sent = sendCmd(args, deps); // parses handle/message/--reply/--fyi/--from itself - if (sent.delivery === 'undeliverable') { - throw new Error(`undeliverable to ${handle}: ${sent.reason}`); - } - waitCmd([handle, '--status', 'idle,done', '--timeout', timeout], deps); - return deps.run('herdr', ['pane', 'read', sent.pane, '--source', 'recent', '--lines', lines]); -} - // --------------------------------------------------------------------------- // herd lifecycle: seed, broadcast, roster sync, withdraw // --------------------------------------------------------------------------- @@ -498,6 +456,7 @@ function seedLine(cfg) { `you are ${cfg.handle}, a member of a herd working on ${cfg.cwd}`, `roster: ${cfg.roster.join(', ')} (your teammates: ${peers.join(', ') || 'none yet'})`, `working lead: ${cfg.lead}${cfg.lead === cfg.handle ? ' (that is you - you drive the work)' : ''}`, + cfg.launcher ? `${cfg.launcher} launched this herd and is outside it: not a member and not its coordinator - the working lead coordinates, so do not wait on ${cfg.launcher} unless your task names it as an approver` : undefined, `PROTOCOL: message a teammate with exactly ONE line by RUNNING this as a real shell command, never printing it: node ${shellPath(cfg.helper, cfg.platform)} send "" --reply (use --fyi instead when no reply is wanted)`, 'an incoming "[from X reply]" needs a one-line answer back to X; "[from X fyi]" needs no answer and no ack; "[herd +H]" and "[herd -H]" are roster updates you apply idempotently without replying or rebroadcasting', 'a "#id" in the header is a delivery id - ignore it, except that the same id arriving twice is a duplicate to ignore', @@ -516,11 +475,22 @@ function seedCmd(args, deps) { const data = fetchAgents(deps); const target = findAgent(data, handle); if (!target) throw new Error(`no agent: ${handle}`); - const lead = opt('--lead') || resolveSelf(data, opt('--from'), deps.env) || roster[0]; + // The launcher stays OUT of the herd it seeds unless it opts in by listing + // itself in --roster. The lead, defaulted or explicit, must be a roster + // member: a lead outside the roster - the sender included - would coordinate + // a herd that cannot see it, and every member would wait on an agent the + // roster says is not there. + const self = resolveSelf(data, opt('--from'), deps.env); + const herd = roster.includes(handle) ? roster : [handle, ...roster]; + const lead = opt('--lead') || herd[0]; + if (!herd.includes(lead)) { + throw new Error(`--lead ${lead} is not in the roster (${herd.join(', ')}); a lead must be a member - add it to --roster to join`); + } const line = seedLine({ handle, - roster: roster.includes(handle) ? roster : [handle, ...roster], + roster: herd, lead, + launcher: self && !herd.includes(self) ? self : undefined, cwd: opt('--cwd', target.cwd || '(this worktree)'), helper: helperFor(target.agent, opt('--helper')), brief: opt('--brief'), @@ -537,9 +507,10 @@ function seedCmd(args, deps) { // the sender's own pane, which this process cannot poll. Announce // `[herd +]` to the rest of the herd after the reply arrives, not // after this returns. + // Native wait, both states named: without --until it also matches `blocked`. const timeout = String(Number(opt('--timeout', '120000'))); try { - waitCmd([handle, '--status', 'idle,done', '--timeout', timeout], deps); + deps.run('herdr', ['agent', 'wait', handle, '--until', 'idle', '--until', 'done', '--timeout', timeout]); } catch { return { ...sent, turn: 'timeout' }; } @@ -685,9 +656,6 @@ function usage() { 'verbs are self-contained - each runs herdr itself:', ' status agent_status', ' members [--workspace ] handles, optionally per workspace', - ' wait [--status a,b] [--timeout ms] [--interval ms]', - ' poll agent list until status matches; a single-call comma set', - ' (idle,done) - the native `herdr agent wait` needs one --until each', ' send [--reply|--fyi] [--from ] [--force]', ' one sender at a time per recipient (per-recipient lock); reports', ' delivery: observed (turn start or id echoed) | accepted (submitted,', @@ -695,9 +663,10 @@ function usage() { ' --force is an UNSAFE override of the blocked gate: it types into a', ' recipient whose modal has keyboard focus, so the text can land in a', ' permission dialog instead of the composer. Diagnostics only.', - ' send-wait-read [--timeout ms] [--lines n]', ' seed --roster a,b,c [--lead h] [--brief s] [--helper p] [--wait]', ' one-line cold-agent orientation: handle, roster, lead, protocol.', + ' the lead defaults to the first roster member and must be in the', + ' roster; a sender left off --roster is named as an outside launcher.', ' --wait blocks until it finishes a turn; its REPLY is the real proof', ' broadcast [--workspace ws] [--exclude a,b] [--reply|--fyi]', ' sync --roster a,b,c [--workspace ws] [--dry-run]', @@ -707,57 +676,18 @@ function usage() { ' withdraw --lead [--workspace ws] [--note s]', ' hand off to the working lead, then announce your own departure', ' agent [:] --workspace --cwd

[--timeout ms] [--label s]', - ' kind is claude|codex|opencode. a bare handle INHERITS the model and', - ' effort from that CLI\'s ambient config (managed settings for claude,', - ' ~/.codex/config.toml for codex); the selector overrides per agent:', + ' kind is claude|codex|opencode. a bare handle gets the default model', + ' (claude-opus-5-5 for claude, gpt-6.1-sol for codex) and INHERITS effort', + ' from that CLI\'s ambient config; codex always runs --no-daemon. the', + ' selector overrides per agent:', ' nell:model=opus,effort=high claude --model/--effort', ' jay:effort=high codex -c model_reasoning_effort=high', ' bob:ollama/qwen2.5:7b opencode -m (model REQUIRED here)', ' a bare suffix is a model, so handle:opus == handle:model=opus. keys', ' are named, not positional, because opencode models contain colons.', ' opencode has no effort selector and refuses effort= rather than', - ' dropping it. the result reports model/effort as null when inherited.', + ' dropping it. the result reports effort as null when inherited.', ' up [...] one-shot worktree + herd launcher', - '', - 'fan-out verbs - the mechanical steps of the fan-out runbook:', - ' role --role --run [--partition p] [--hypothesis n] [--roles-dir d]', - ' deliver a role BY PATH: "read /.md and follow it. $RUN', - ' is , $PARTITION is

". resolved against the RECIPIENT cwd from the', - ' agent list, so a role file present only in your own checkout fails here', - ' instead of silently arriving as an unreadable path', - ' fanout --run --partitions a,b [--task-branch ] [--partition-sep ]', - ' [--base ] [--kind codex]', - ' [--selector model=..,effort=..] [--role r] [--role-tag p] [--timeout ms]', - ' one worktree + one agent per partition off the task branch, each sent its own', - ' $PARTITION role line. handles are the next free call-signs from the pool in', - ' reference/names.md, claimed against the live agent list BEFORE the first', - ' worktree, because a collision surfaces only at agent start - after the tab', - ' and the tree exist. --role-tag decorates each TAB as - so the', - ' sidebar shows the part; the handle itself stays a bare call-sign. a partition', - ' that fails is reported and the rest still launch', - ' wait-all [--status idle,done] [--timeout ms] [--interval ms]', - ' one `agent list` per round for the whole set, not one wait per handle. a', - ' timeout returns a row per handle rather than throwing, so a stuck handle', - ' does not hide the ones that settled', - ' state --run [--task-branch ] [--partition-sep ] [--partitions a,b]', - ' which step the run is on, read off git refs', - ' absent|started|partitioned|fanned-out|fanned-in|finished, plus per-branch', - ' merged/blocked/probe. derived on every call and stored nowhere - git is', - ' the state machine, so there is no run journal to go stale', - ' settled --run --partitions a,b [--task-branch ] [--partition-sep ]', - ' partition completion from branch commits or committed BLOCKED files;', - ' reads git refs, never transient agent status', - ' fan-in --run --partitions a,b [--task-branch ] [--partition-sep ]', - ' [--wait-handle arch] [--timeout ms] [--dry-run]', - ' refuses unless HEAD is the task branch and the architect is settled, then merges', - ' in the order given (manifest order). stops at the FIRST conflict with its', - ' --diff-filter=U paths and leaves the merge in the tree to resolve.', - ' NOT baked by /herd-setup - it runs `git merge`, so it prompts', - ' teardown --run --partitions a,b [--task-branch ] [--partition-sep ] [--yes]', - ' `worktree remove --force` then `git branch -D`, in that order, per', - ' partition. plans only without --yes, and skips a partition whose BLOCKED', - ' file is uncommitted or committed-but-unmerged - --force discards both', - ' without warning. NOT baked by /herd-setup - it destroys work, so it prompts', ].join('\n'); } @@ -771,12 +701,8 @@ function dispatch(argv, deps) { const i = rest.indexOf('--workspace'); return members(fetchAgents(deps), i >= 0 ? rest[i + 1] : undefined); } - case 'wait': - return waitCmd(rest, deps); case 'send': return sendCmd(rest, deps); - case 'send-wait-read': - return sendWaitReadCmd(rest, deps); case 'seed': return seedCmd(rest, deps); case 'broadcast': @@ -787,23 +713,6 @@ function dispatch(argv, deps) { return withdrawCmd(rest, deps); case 'agent': return agentCmd(rest, deps); - // Lazy, like `agent` and `defaultDeps` above: both modules require this one - // back for sendCmd/waitCmd, and a top-level require either way would leave - // whichever side loaded second holding a half-initialised exports object. - case 'role': - return require('./fanout.js').roleCmd(rest, deps); - case 'fanout': - return require('./fanout.js').fanoutCmd(rest, deps); - case 'wait-all': - return require('./fanout.js').waitAllCmd(rest, deps); - case 'state': - return require('./fanin.js').stateCmd(rest, deps); - case 'settled': - return require('./fanin.js').settledCmd(rest, deps); - case 'fan-in': - return require('./fanin.js').faninCmd(rest, deps); - case 'teardown': - return require('./fanin.js').teardownCmd(rest, deps); default: throw new Error(`unknown command: ${cmd}`); } @@ -859,8 +768,6 @@ module.exports = { status, members, fetchAgents, - parseWait, - waitCmd, resolveSelf, stampPrefix, idIn, @@ -875,7 +782,6 @@ module.exports = { echoSeen, parseSend, sendCmd, - sendWaitReadCmd, seedLine, seedCmd, codexHelper, diff --git a/comitatus/skills/herdr/scripts/up.js b/comitatus/skills/herdr/scripts/up.js index b836243f..c6913e43 100644 --- a/comitatus/skills/herdr/scripts/up.js +++ b/comitatus/skills/herdr/scripts/up.js @@ -16,10 +16,16 @@ const { execFileSync } = require('child_process'); // verified against the installed CLI's own --help, not assumed. // Keyed by agent KIND, deliberately not "model": conflating the two is what // made the old report claim `"model":"claude"`. +// +// `defaultModel` is what a launch gets when the selector names none. Pinning it +// here, rather than inheriting ambient config, means every herd member starts +// on a known model unless the caller chose otherwise; an explicit `model=` +// always wins. Effort has no default and is still inherited. const KINDS = { claude: { glyph: '◆', kind: 'claude', + defaultModel: 'claude-opus-5-5', extraArgs: ({ model, effort }) => [ ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), @@ -28,11 +34,17 @@ const KINDS = { codex: { glyph: '◇', kind: 'codex', + defaultModel: 'gpt-6.1-sol', // No effort flag exists; `-c =` overrides one // ~/.codex/config.toml value. The value is parsed as TOML, and a bare word // that fails to parse is used as a literal string - which is what a level // like `high` relies on. + // + // `--no-daemon` on every launch: run without the shared background + // app-server even when one is already running, so a herd member's session + // does not depend on a daemon some other codex started. extraArgs: ({ model, effort }) => [ + '--no-daemon', ...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort=${effort}`] : []), ], @@ -69,8 +81,8 @@ const SELECTORS = { // handle:model:effort. Named keys are order-independent and extensible, and // they attach per agent, so two agents of one kind can differ in one launch. // -// A bare handle means "inherit whatever the CLI's ambient config resolves" and -// reports both settings as null - never as the kind name. +// A bare handle gets the kind's `defaultModel` (see KINDS) and inherits effort +// from the CLI's ambient config, reporting it as null - never as the kind name. function parseSelector(kind, spec) { const text = String(spec); const colon = text.indexOf(':'); @@ -106,6 +118,7 @@ function makeAgent(kind, spec) { const def = KINDS[kind]; if (!def) throw new Error(`unknown agent kind: ${kind} (want ${Object.keys(KINDS).join('/')})`); const sel = parseSelector(kind, spec); + if (!sel.model && def.defaultModel) sel.model = def.defaultModel; if (!sel.handle) throw new Error(`--${kind} needs a (got "${spec}")`); if (kind === 'opencode') { // opencode alone requires a model: it has no ambient default to inherit. diff --git a/docs/plans/2026-06-21-comitatus-design.md b/docs/plans/2026-06-21-comitatus-design.md index 36e7d0bd..0b662745 100644 --- a/docs/plans/2026-06-21-comitatus-design.md +++ b/docs/plans/2026-06-21-comitatus-design.md @@ -1,5 +1,7 @@ # Design: comitatus Plugin +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../comitatus/README.md). + **Date:** 2026-06-21 **Status:** Design **Branch:** chore/add-comitatus-plugin (proposed) diff --git a/docs/plans/2026-06-21-comitatus-implementation.md b/docs/plans/2026-06-21-comitatus-implementation.md index edaa95aa..14d41faa 100644 --- a/docs/plans/2026-06-21-comitatus-implementation.md +++ b/docs/plans/2026-06-21-comitatus-implementation.md @@ -1,5 +1,7 @@ # comitatus Plugin Implementation Plan +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../comitatus/README.md). + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `comitatus`, a fifth claude-domestique plugin that packages the herdr agent-orchestration skill for claude, ships a Node roster/state helper, and auto-provisions the same skill into codex's skill path when running inside herdr. diff --git a/docs/plans/2026-08-08-cross-model-plugin-architecture.md b/docs/plans/2026-08-08-cross-model-plugin-architecture.md index 8ebfa9d8..4a2b3460 100644 --- a/docs/plans/2026-08-08-cross-model-plugin-architecture.md +++ b/docs/plans/2026-08-08-cross-model-plugin-architecture.md @@ -1,5 +1,7 @@ # Cross-model plugin architecture and migration plan +> Historical migration record for the versions named below. See the [repository README](../../README.md) for current installation and validation, and the [comitatus README](../../comitatus/README.md) for current herd commands and conventions. + **Date:** 2026-08-08 **Status:** Complete — Phases 0–5 implemented; authenticated model-level smoke testing remains an operational release check diff --git a/docs/reviews/2026-08-09-cross-model-migration-review-round-2.md b/docs/reviews/2026-08-09-cross-model-migration-review-round-2.md index 0bcb1d8d..c9de1ec2 100644 --- a/docs/reviews/2026-08-09-cross-model-migration-review-round-2.md +++ b/docs/reviews/2026-08-09-cross-model-migration-review-round-2.md @@ -1,5 +1,7 @@ # Review round 2: cross-model plugin migration (Phase 0 + Phase 1) +> Historical migration record for the versions named below. See the [repository README](../../README.md) for current installation and validation, and the [comitatus README](../../comitatus/README.md) for current herd commands and conventions. + **Date:** 2026-08-09 **Reviewer:** Claude (Opus 5) **Branch:** `chore/make-all-plugins-available-to-claude-and-codex` diff --git a/docs/reviews/2026-08-09-cross-model-migration-review-round-3.md b/docs/reviews/2026-08-09-cross-model-migration-review-round-3.md index b9917c77..ef30ed1d 100644 --- a/docs/reviews/2026-08-09-cross-model-migration-review-round-3.md +++ b/docs/reviews/2026-08-09-cross-model-migration-review-round-3.md @@ -1,5 +1,7 @@ # Review round 3: cross-model plugin migration (Phase 0 + Phase 1) +> Historical migration record for the versions named below. See the [repository README](../../README.md) for current installation and validation, and the [comitatus README](../../comitatus/README.md) for current herd commands and conventions. + **Date:** 2026-08-09 **Reviewer:** Claude (Opus 5) **Branch:** `chore/make-all-plugins-available-to-claude-and-codex` diff --git a/docs/reviews/2026-08-09-cross-model-migration-review.md b/docs/reviews/2026-08-09-cross-model-migration-review.md index ba5e9454..f663450d 100644 --- a/docs/reviews/2026-08-09-cross-model-migration-review.md +++ b/docs/reviews/2026-08-09-cross-model-migration-review.md @@ -1,5 +1,7 @@ # Review: cross-model plugin migration (Phase 0 + Phase 1) +> Historical migration record for the versions named below. See the [repository README](../../README.md) for current installation and validation, and the [comitatus README](../../comitatus/README.md) for current herd commands and conventions. + **Date:** 2026-08-09 **Reviewer:** Claude (Opus 5) **Branch:** `chore/make-all-plugins-available-to-claude-and-codex` diff --git a/docs/reviews/fan-out-skill-manifest.md b/docs/reviews/fan-out-skill-manifest.md index ccd999f2..ac41f0c6 100644 --- a/docs/reviews/fan-out-skill-manifest.md +++ b/docs/reviews/fan-out-skill-manifest.md @@ -1,5 +1,7 @@ # fan-out skill review manifest +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../comitatus/README.md). + ## review scope - Base: `7a1deeb` (after the Modus-purpose refactor merge) diff --git a/docs/superpowers/plans/2026-06-22-comitatus-retro-fixes.md b/docs/superpowers/plans/2026-06-22-comitatus-retro-fixes.md index f8c39c89..524eda7b 100644 --- a/docs/superpowers/plans/2026-06-22-comitatus-retro-fixes.md +++ b/docs/superpowers/plans/2026-06-22-comitatus-retro-fixes.md @@ -1,5 +1,7 @@ # comitatus Retro Fixes Implementation Plan +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../../comitatus/README.md). + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix the comitatus-ownable bug and behavior issues surfaced by a live herdr-session retro: the broken `herd.js` helper-path recipe, plus three documentation clarifications. diff --git a/docs/superpowers/plans/2026-06-22-herd-up-launcher.md b/docs/superpowers/plans/2026-06-22-herd-up-launcher.md index c11b59bc..d3a08506 100644 --- a/docs/superpowers/plans/2026-06-22-herd-up-launcher.md +++ b/docs/superpowers/plans/2026-06-22-herd-up-launcher.md @@ -1,5 +1,7 @@ # `up` Launcher Implementation Plan +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../../comitatus/README.md). + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `node "$H" up …` helper that creates a worktree and launches an N-agent herd in a single command, collapsing the multi-step `$()`-threaded recipe (5–9 permission prompts) into one. diff --git a/docs/superpowers/plans/2026-06-24-herd-permissions-friction.md b/docs/superpowers/plans/2026-06-24-herd-permissions-friction.md index 7c012788..0adfb843 100644 --- a/docs/superpowers/plans/2026-06-24-herd-permissions-friction.md +++ b/docs/superpowers/plans/2026-06-24-herd-permissions-friction.md @@ -1,5 +1,7 @@ # herdr Permissions Friction Reduction Implementation Plan +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../../comitatus/README.md). + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Cut Claude Code permission prompts for herdr workflows by (a) adding composite `herd` wrapper verbs that self-exec herdr (no pipes, loops, or `$H` variable), (b) provisioning the helper at a stable, version-independent path so an allow-rule survives upgrades, (c) shipping a `/herd-setup` command that bakes a SAFE, per-verb allowlist, and (d) tightening the agent-to-agent protocol so every message states whether a reply is required. diff --git a/docs/superpowers/specs/2026-06-22-herd-naming-design.md b/docs/superpowers/specs/2026-06-22-herd-naming-design.md index 8a9f664e..4476459b 100644 --- a/docs/superpowers/specs/2026-06-22-herd-naming-design.md +++ b/docs/superpowers/specs/2026-06-22-herd-naming-design.md @@ -1,5 +1,7 @@ # Herd naming: descriptive labels over collective nouns +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../../comitatus/README.md). + - **Date:** 2026-06-22 - **Status:** Accepted - **Plugin:** comitatus (`skills/herdr`) diff --git a/docs/superpowers/specs/2026-06-22-herd-up-launcher-design.md b/docs/superpowers/specs/2026-06-22-herd-up-launcher-design.md index 44590404..eea42067 100644 --- a/docs/superpowers/specs/2026-06-22-herd-up-launcher-design.md +++ b/docs/superpowers/specs/2026-06-22-herd-up-launcher-design.md @@ -1,5 +1,7 @@ # `up` — one-shot worktree + herd launcher +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../../../comitatus/README.md). + **Date:** 2026-06-22 **Plugin:** comitatus (herdr skill) **Status:** approved design diff --git a/mantra/.claude-plugin/plugin.json b/mantra/.claude-plugin/plugin.json index a4419483..58f9b8be 100644 --- a/mantra/.claude-plugin/plugin.json +++ b/mantra/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mantra", - "version": "0.6.2", + "version": "0.6.3", "description": "Behavioral skills for coding agents - critical assessment, evidence-based debugging, and rule authoring", "author": { "name": "David Puglielli" diff --git a/mantra/.codex-plugin/plugin.json b/mantra/.codex-plugin/plugin.json index 1e5f5c23..ef1336c4 100644 --- a/mantra/.codex-plugin/plugin.json +++ b/mantra/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mantra", - "version": "0.6.2", + "version": "0.6.3", "description": "Behavioral skills for critical assessment, evidence-based debugging, and rule authoring", "author": { "name": "David Puglielli" diff --git a/mantra/README.md b/mantra/README.md index c84677b0..b0767d5d 100644 --- a/mantra/README.md +++ b/mantra/README.md @@ -4,22 +4,22 @@ Behavioral rules plugin for Claude Code and Codex sessions. -Claude is brilliant. Claude is helpful. Claude also has the memory of a goldfish in a context window. You've written the perfect CLAUDE.md. You've carefully documented your project conventions. Claude reads it. Claude agrees. Claude then proceeds to ignore half of it by turn 47. +Claude is brilliant. Claude is helpful. Claude also has the memory of a goldfish in a context window. You've written the perfect CLAUDE.md. You've carefully documented your project conventions. Claude reads it. Claude agrees. Claude then drifts away from it as the session grows. -**mantra** solves this by providing curated behavioral rules that are automatically loaded via Claude Code's native `.claude/rules/` mechanism—ensuring consistent behavior from turn 1 to turn 100. +**mantra** solves this by providing curated behavioral rules that are injected automatically by session hooks in Claude Code and Codex, and re-injected periodically, so they stay in context as a session grows. ### Flexion Fundamentals mantra helps developers embody Flexion fundamentals throughout long sessions: - **Be skeptical and curious** — Keeps Claude questioning assumptions and seeking evidence, not pattern-matching -- **Never compromise on quality** — Maintains consistent standards from turn 1 to turn 100 +- **Never compromise on quality** — Keeps standards consistent across a long session - **Listen with humility** — Enforces peer-not-subordinate stance, deferring to evidence over agreement ## Features - **Zero config** - Rules injected automatically via hooks, no setup required -- **Token efficient** - Compact YAML frontmatter (~89% token reduction vs prose) +- **Token efficient** - Compact YAML frontmatter rather than prose - **Status indicator** - Shows rules loaded and context freshness on every prompt - **Curated rules** - Ships with behavior, testing, git, and format conventions - **Periodic refresh** - Re-injects rules every 10 prompts to prevent drift @@ -199,7 +199,7 @@ Mantra isn't just about remembering project conventions. It's about **overriding ```bash npm install -npm test # Run Jest tests (28 specs) +npm test # Run Jest tests ``` ## Why "mantra"? diff --git a/mantra/package.json b/mantra/package.json index c8cbf32e..64e2d249 100644 --- a/mantra/package.json +++ b/mantra/package.json @@ -1,6 +1,6 @@ { "name": "@claude-domestique/mantra", - "version": "0.6.2", + "version": "0.6.3", "description": "Behavioral rules for Claude Code and Codex sessions, auto-injected through hooks", "main": "index.js", "scripts": { diff --git a/metadata/skill-catalog.json b/metadata/skill-catalog.json index f6feb7f5..c6d5e140 100644 --- a/metadata/skill-catalog.json +++ b/metadata/skill-catalog.json @@ -24,10 +24,6 @@ "name": "agent-artifex:learn", "classification": "public" }, - { - "name": "comitatus:fan-out", - "classification": "public" - }, { "name": "comitatus:herd-setup", "classification": "public" diff --git a/research/comitatus-recommendations.md b/research/comitatus-recommendations.md index 6bb13469..e6ef593e 100644 --- a/research/comitatus-recommendations.md +++ b/research/comitatus-recommendations.md @@ -1,5 +1,7 @@ # comitatus × herdr: first-principles review and recommendation +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../comitatus/README.md). + Written 2026-07-02 after full review of herdr docs + local 0.7.1 CLI verification (see `herdr-briefing.md`) and the plugin ecosystem (see `herdr-plugins-catalog.md`). Current comitatus at 0.4.2. ## Design constraints (from the operator, 2026-07-02 — FIRM) diff --git a/research/herdr-briefing.md b/research/herdr-briefing.md index 7a82b8f8..85ce0ed1 100644 --- a/research/herdr-briefing.md +++ b/research/herdr-briefing.md @@ -1,5 +1,7 @@ # herdr capability briefing (docs + local verification) +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../comitatus/README.md). + Sources: herdr.dev/docs (concepts, agents, session-state, configuration, socket-api, plugins, marketplace, quick-start), verified against the locally installed `herdr 0.7.1` CLI on 2026-07-02. Items marked **(verified local)** were confirmed against the real binary; everything else is from docs. ## Object model diff --git a/research/herdr-plugins-catalog.md b/research/herdr-plugins-catalog.md index eb2c6472..2af470a1 100644 --- a/research/herdr-plugins-catalog.md +++ b/research/herdr-plugins-catalog.md @@ -1,5 +1,7 @@ # herdr plugin ecosystem — assessment against comitatus goals +> Historical snapshot of the versions and decisions named below. Current commands, launch defaults, and herd conventions are documented in the [comitatus README](../comitatus/README.md). + Source: GitHub topic `herdr-plugin` (the marketplace index), queried 2026-07-02. 52 repos found; the ones relevant to our goals are graded below. None are vetted by herdr — review before installing. Our goals: (1) reliable agent-to-agent workflow with few permission interruptions, (2) worktree + herd lifecycle automation, (3) operator visibility/control, (4) less comitatus code. diff --git a/vernaculus/.claude-plugin/plugin.json b/vernaculus/.claude-plugin/plugin.json index 6d1b1bdd..c6c2dcee 100644 --- a/vernaculus/.claude-plugin/plugin.json +++ b/vernaculus/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vernaculus", - "version": "0.2.0", - "description": "Vernaculus \u2014 delegating well-specified coding work to a locally hosted model over MCP, with the brief/generate/verify/diagnose/refine loop the measurements say it needs", + "version": "0.2.1", + "description": "Vernaculus — delegating well-specified coding work to a locally hosted model over MCP, with the brief/generate/verify/diagnose/refine loop the measurements say it needs", "author": { "name": "Flexion" }, diff --git a/vernaculus/.codex-plugin/plugin.json b/vernaculus/.codex-plugin/plugin.json index 57caabb7..144034c1 100644 --- a/vernaculus/.codex-plugin/plugin.json +++ b/vernaculus/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vernaculus", - "version": "0.2.0", + "version": "0.2.1", "description": "Delegate well-specified coding work to a locally hosted model over MCP, with a diagnose-and-refine loop", "author": { "name": "Flexion", diff --git a/vernaculus/package.json b/vernaculus/package.json index 2aab9f5d..b557d2f8 100644 --- a/vernaculus/package.json +++ b/vernaculus/package.json @@ -1,6 +1,6 @@ { "name": "@claude-domestique/vernaculus", - "version": "0.2.0", + "version": "0.2.1", "description": "Delegate well-specified coding work to a locally hosted model over MCP", "main": "mcp/server.js", "scripts": { diff --git a/vernaculus/references/launch-surface.md b/vernaculus/references/launch-surface.md index 27b17a32..e93bfe41 100644 --- a/vernaculus/references/launch-surface.md +++ b/vernaculus/references/launch-surface.md @@ -1,8 +1,24 @@ # Mechanical local-model launch surface -Scope: the current `comitatus` launcher and the installed CLIs. I inspected +Scope: the `comitatus` 0.14.1 launcher and the installed CLIs. I inspected `herdr` 0.9.0, OpenCode 1.18.30, Claude Code 2.1.270, and Codex CLI 0.154.0. -I did not launch an agent or change an integration. +I did not launch an agent or change an integration. Line citations refer to +comitatus 0.14.1 (commit `1e4ca45`). + +**Changed in comitatus 1.0.0.** Read these first; the sections below are the +0.14.1 snapshot: + +- A bare `--claude ` now launches with `--model claude-opus-5-5`, and a + bare `--codex ` with `--model gpt-6.1-sol`. An explicit `model=` + replaces the default. To use a model your ambient configuration routes, such + as a local Codex provider, name it: `--codex ned:model=qwen2.5:7b`. +- Every Codex launch passes `--no-daemon`. +- The helper's `wait` and `send-wait-read` verbs are gone. Use native + `herdr agent wait --until idle --until done`. `seed --wait` calls + that, with its 120-second default. + +Current behaviour is documented in +[`comitatus/skills/herdr/SKILL.md`](../../comitatus/skills/herdr/SKILL.md). ## A. What `--opencode :` does today @@ -100,10 +116,11 @@ also defines `oss_provider`, `model_provider`, and There are two mechanical routes today: -- Configure a local provider in user-level Codex configuration, then launch a - bare `--codex ` or add only the model selector. The helper deliberately - inherits ambient Codex configuration - (`comitatus/skills/herdr/SKILL.md:59-80`). Official documentation says +- Configure a local provider in user-level Codex configuration, then launch with + only the model selector. At 0.14.1 a bare `--codex ` also worked, + because the helper inherited ambient Codex configuration. Since 1.0.0 a bare + handle passes `--model gpt-6.1-sol`, so name the local model + ([where model and effort come from](../../comitatus/skills/herdr/SKILL.md#where-model-and-effort-come-from)). Official documentation says project-local `.codex/config.toml` cannot override provider routing, so this must be machine/user configuration. - Bypass the comitatus helper and use native herdr's argv tail, for example @@ -162,7 +179,8 @@ tab only after every agent launches (`comitatus/skills/herdr/scripts/up.js:291-3 treats a timeout or `agent_prompt_stalled` as unconfirmed submission and does not resend it. - Completion waits are separate and fixed by helper defaults: 45 seconds for - `wait`, 60 seconds for `send-wait-read`, and 120 seconds for `seed --wait` + `wait`, 60 seconds for `send-wait-read` (both removed in 1.0.0 in favour of + native `herdr agent wait`), and 120 seconds for `seed --wait` (`comitatus/skills/herdr/scripts/herd.js:55-80`, `comitatus/skills/herdr/scripts/herd.js:406-417`, `comitatus/skills/herdr/scripts/herd.js:533-550`). A slow local inference can diff --git a/vernaculus/references/local-model-delegation-findings.md b/vernaculus/references/local-model-delegation-findings.md index 254c99f8..2c767e40 100644 --- a/vernaculus/references/local-model-delegation-findings.md +++ b/vernaculus/references/local-model-delegation-findings.md @@ -1,7 +1,10 @@ # Local model integration — findings and options Exploratory. Nothing here is implemented. Mechanical launch-surface detail is in -[`launch-surface.md`](launch-surface.md) and is not repeated. +[`launch-surface.md`](launch-surface.md) and is not repeated. Measurements and +line citations are a snapshot of comitatus 0.14.1. 1.0.0 removed the fan-out +skill and its role files and changed the launch defaults; see the notice at the +top of `launch-surface.md`. ## The question has two different shapes @@ -101,14 +104,17 @@ lands on the reviewer. tokens; the local tag pins `num_ctx` to **32768**, which is the practical figure on this hardware, not an arbitrary one. -Against that budget, `comitatus/skills/herdr/SKILL.md` is about 8k tokens and is -*auto-injected* whenever `HERDR_ENV=1` (`comitatus/hooks/herdr-orient.js:180`). -A fan-out participant loads `fan-out/SKILL.md` and a role file on top. A peer -local model therefore spends something like a quarter to a third of its window -on orientation before it reads a single repository file — and the fan-out -orchestrator role is explicitly written as a loop that *survives compaction* -(`comitatus/skills/fan-out/roles/orchestrator.md:13-33`), which assumes a model -that can be compacted and resume, not one that ran out of room. +Against that budget, `comitatus/skills/herdr/SKILL.md` was about 8k tokens at +0.14.1. The SessionStart hook injects only a short orientation whenever +`HERDR_ENV=1` and points at the skill (`comitatus/hooks/herdr-orient.js`); the +skill itself loads when the agent invokes it, which a herd member is told to +do. At 0.14.1 a fan-out participant also loaded `fan-out/SKILL.md` and a role +file. A peer local model therefore spent something like a quarter to a third of +its window on orientation before it read a single repository file. The fan-out +orchestrator role was written as a loop that *survives compaction*, which +assumes a model that can be compacted and resume, not one that ran out of room. +comitatus 1.0.0 deleted the fan-out skill and its roles, so only the herdr skill +remains in that budget. Note also that `qwen3:30b-32k` is the base Qwen3 instruct tag, not Qwen3-Coder. There is no `qwen3-coder:30b` on this machine, so any config naming that tag