diff --git a/README.md b/README.md
index a989608..7dff4f9 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ packages/
opencontext OpenContext reference implementation (resolver, scopes, bundles, adapters)
openontology OpenOntology reference engine (entities, claims, queries, change sets)
openprd OpenPRD reference implementation (numbered PRDs, lifecycle, task bridge)
+ openfleet OpenFleet reference implementation (record, ledger, sysop verbs, Claude Code hooks)
logicsrc-mcp @profullstack/logicsrc-mcp standards MCP server
sdk SDK contract types and helpers
tui terminal UI
@@ -165,6 +166,7 @@ logicsrc vault … # OpenCreds (also `opencreds`)
logicsrc prd … # OpenPRD
logicsrc ontology … # OpenOntology
logicsrc context … # OpenContext (also `opencontext`)
+logicsrc fleet … # OpenFleet: open, cap, tree, stop, log, hooks install
logicsrc openmcp … # OpenMCP: relays, find, call, add, probe, serve (also `openmcp`)
logicsrc openspec … # import, export, change; any other word is OpenSpec.dev's own CLI (init, list, validate, archive, show)
logicsrc mcp # the LogicSRC MCP server over stdio (also `logicsrc-mcp`)
diff --git a/apps/logicsrc-web/src/app/openfleet/page.tsx b/apps/logicsrc-web/src/app/openfleet/page.tsx
index 1f3c5a8..e295473 100644
--- a/apps/logicsrc-web/src/app/openfleet/page.tsx
+++ b/apps/logicsrc-web/src/app/openfleet/page.tsx
@@ -54,14 +54,14 @@ const STEPS: Array<[string, string]> = [
["1. A human opens a fleet", "Or does nothing, and gets the implicit fleet user@host with a ceiling of depth 1 and no fleet-level approvals: each session the human starts by hand runs its subtree under the approvals it was started with. Only a human opens a fleet or sets its ceiling. A process that carries OPENFLEET_MEMBER is an agent, and the tool refuses it."],
["2. An agent spawns a swarm", "It mints a swarm id, writes swarm.spawn to the fleet's ledger with the task, the ceiling it narrowed to, and one piece per member with the paths that member owns, as data. It never widens the ceiling it inherited."],
["3. The starter writes the record, the session claims it", "Before each member starts: one JSON file under ~/.openfleet, its path in OPENFLEET_RECORD, copies of fleet, member and swarm in three more variables. The session that starts under an unclaimed record claims it by writing member.start. One that inherits an already claimed record derives its own child record, so the leak becomes a parent pointer."],
- ["4. Everyone reads the same tree", "claude agents, moshcode herd ps and logicsrc fleet would fold the ledger and the records into one tree: fleet, swarms, members, state, spend against budget, and a mark on every member running with approvals bypassed."],
+ ["4. Everyone reads the same tree", "logicsrc fleet tree and moshcode fleet tree fold the ledger and the records into one tree: fleet, swarms, members, state, spend against budget, and a mark on every member running with approvals bypassed. The engine rosters, claude agents and moshcode herd ps, add liveness and the members that have no record."],
["5. The sysop stops or caps as one unit", "stop on a swarm ends every member through its own engine and writes one swarm.end. cap narrows a fleet or a running swarm, and anything already above the new ceiling is stopped. log says afterwards what happened and who did it."]
];
const VERBS: Array<[string, string, string]> = [
["open", "sysop only", "mint a fleet, name the human, set the ceiling, write fleet.open"],
["cap", "sysop only", "set a fleet's ceiling or narrow a running swarm's; stop whatever is now above it"],
- ["tree", "anyone", "the fleet as a tree, from the ledger and the records, with liveness and recordless members from the engine rosters it can read"],
+ ["tree", "anyone", "the fleet as a tree, from the ledger and the records, with liveness and recordless members from the engine rosters it can read; run by the sysop it also stops what is past its deadline or over its budget"],
["stop", "within reach", "end a member, a swarm, or a whole fleet as one unit; an agent reaches only what it spawned"],
["log", "anyone", "the ledger for a fleet, a swarm or a member: what happened, who did it, what it cost"]
];
@@ -197,9 +197,13 @@ export default function OpenFleetPage(): ReactNode {
Five verbs
- Proposed for logicsrc fleet, for Claude Code's{" "}
- claude agents and for moshcode fleet,
- each able to stop the members its own engine runs. None ships yet.
+ logicsrc fleet ships them in{" "}
+ @logicsrc/openfleet 0.1.0 (logicsrc CLI 0.3.0) and{" "}
+ moshcode fleet in moshcode 0.99.0, each able to stop the
+ members its own engine runs. Claude Code joins through{" "}
+ logicsrc fleet hooks install, which makes every session a
+ recorded member; its own claude agents view does not offer the
+ verbs yet.
@@ -250,7 +254,7 @@ export default function OpenFleetPage(): ReactNode {
Specification: the terms, the record, the
environment, claiming and deriving, the ledger's eight events, five verbs, fifteen
- rules, the worked example, and what Claude Code and moshcode would each add
+ rules, the worked example, and what Claude Code and moshcode each add
ASDLC: a swarm is one fan-out made durable;{" "}
diff --git a/docs/cli.md b/docs/cli.md
index 46fbb12..7b021e7 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -35,6 +35,7 @@ accounts
social
email
credentials
+fleet
openspec
plugins
tui
diff --git a/docs/openfleet.md b/docs/openfleet.md
index 6a39253..6a62269 100644
--- a/docs/openfleet.md
+++ b/docs/openfleet.md
@@ -150,7 +150,7 @@ The three lines that should have recorded the worked example's first piece, from
| `swarm.spawn` | `swarm`, `parent_swarm`, `task`, `ceiling`, `pieces` | A spawner started a swarm: its id; `parent_swarm`, the swarm of the spawner's own record, absent when the spawner is a root member or the sysop; the task in the spawner's words; the keys it narrowed; and one piece per member it minted, `{ "member", "title", "owns" }`, with the paths that member owns as data. Written before the first member starts. `by` is the spawner: a member id, or `sysop` for a swarm the human started by hand. |
| `member.start` | `member`, `session`, `swarm`, `parent`, `depth`, `engine`, `host`, `cwd`, `approvals`, `piece` | A member began, and its record is claimed. Written by the session itself with `by` its own member id, or by the starter, with `by` the starter, for an engine that cannot write it. `session` is the engine's own id when the record has none. The row a sysop tool draws in the tree; `approvals: bypass` is the mark it shows. |
| `member.spend` | `member`, `amount`, `total` | A member spent something: `amount` since the last line and `total` so far, as ` ` or ` tokens`, in the engine's own numbers. Written by an engine that can count, at intervals or at the end. Summed per swarm and per fleet against `ceiling.budget`. |
-| `member.end` | `member`, `state`, `summary`, `total`, `links` | A member finished. `state` is `done`, `failed`, `stopped`, `budget`, `timeout` or `lost`. `by` says who ended it: `sysop`, the spawner, or the member itself. `summary` is the member's closing summary when it wrote one; `total` its final spend; `links` the PRs and URLs it produced. `lost` is what a sysop tool writes for a member whose engine no longer lists it and that has no end line from any writer, with `by` `sysop` when the tool runs with no `OPENFLEET_MEMBER` and that member otherwise. A member has one end line that counts: the first written, except `lost`, which the engine's or the spawner's own `member.end` supersedes whenever it arrives. A session or tool that finds an end line for a member writes none, unless that line is `lost`, which the engine's or the spawner's own `member.end` may follow and supersede; `stop` on an ended member writes nothing. |
+| `member.end` | `member`, `state`, `summary`, `total`, `links` | A member finished. `state` is `done`, `failed`, `stopped`, `budget`, `timeout` or `lost`. `by` says who ended it: `sysop`, the spawner, or the member itself. `summary` is the member's closing summary when it wrote one; `total` its final spend; `links` the PRs and URLs it produced. `lost` is what a sysop tool writes for a member whose engine's roster can hold it and no longer lists it, and that has no end line from any writer: a Claude Code background job, a moshcode pane. An interactive or `-p` Claude Code session is never in `claude agents`, so no roster marks it lost. `by` is `sysop` when the tool runs with no `OPENFLEET_MEMBER` and that member otherwise. A member has one end line that counts: the first written, except `lost`, which the engine's or the spawner's own `member.end` supersedes whenever it arrives. A session or tool that finds an end line for a member writes none, unless that line is `lost`, which the engine's or the spawner's own `member.end` may follow and supersede; `stop` on an ended member writes nothing. |
| `swarm.end` | `swarm`, `state`, `summary`, `verdict` | A swarm ended as one unit: every member and every nested swarm under it has an end line at or before this one. `state` is `done` when every member ended `done`, else the first of `failed`, `stopped`, `budget`, `timeout` found among its members' end lines. `summary` is the spawner's synthesis, its closing summary of every member's summary, when it has one; `verdict` the verify result when it ran. One `swarm.end` per swarm ended, never two for the same swarm: a writer checks the ledger first and writes it only when none exists, and a synthesis that arrives after one goes into the spawner's own `member.end` `summary`. |
| `ceiling.refuse` | `member`, `action`, `key`, `wanted`, `allowed` | An engine or tool refused something because it would exceed the ceiling: `action` is `start` or `spawn`; `key` names the ceiling key; `wanted` and `allowed` say the two values. `member` is the id the refused record names when one exists, else absent; `by` is the spawner: that record's `parent`, else the caller's `OPENFLEET_MEMBER`, else the refused record's own `member` when it carries `orphan`. This is how a sysop finds out what an agent tried. |
@@ -162,11 +162,11 @@ Five verbs, over `$OPENFLEET_HOME`. Two are the sysop's alone. The test is the e
|---|---|---|
| `open` | sysop only | Creates a fleet: mints the id, records the sysop, sets the ceiling from flags (`--approvals native\|bypass --budget "20 USD" --depth 2 --fan-out 4 --hosts dev,netcup --until 2h`), writes `fleet.open`, writes the id to `current`, prints it. Refuses when `OPENFLEET_MEMBER` is set. |
| `cap` | sysop only | Sets the whole ceiling of a fleet, or narrows a running swarm's, and writes `fleet.cap`. Members already above the new ceiling, a `bypass` member under a now-`native` ceiling, a member on a now-forbidden host, are stopped by the tool, each with `member.end` state `stopped`. Refuses when `OPENFLEET_MEMBER` is set. An agent narrows only at spawn time, in the `swarm.spawn` it writes. |
-| `tree` | anyone | Renders one fleet, or every fleet on this host, as a tree: fleet, its swarms, each swarm's members and nested swarms, with state, engine, host, depth, spend against budget, and a mark on every member whose approvals is `bypass`. Built from the ledger and the records. When an engine's roster is readable, sessions it lists that have no record are drawn as root members of the implicit fleet and marked as coming from the roster, and a recorded member the roster no longer lists gets `member.end` state `lost`. An agent calls it on its own fleet to learn its siblings. |
+| `tree` | anyone | Renders one fleet, or every fleet on this host, as a tree: fleet, its swarms, each swarm's members and nested swarms, with state, engine, host, depth, spend against budget, and a mark on every member whose approvals is `bypass`. Built from the ledger and the records. When an engine's roster is readable, sessions it lists that have no record are drawn as root members of the implicit fleet and marked as coming from the roster, and a recorded member the roster can hold and no longer lists (a background job, a pane; never an interactive session the roster does not list) gets `member.end` state `lost`. Run by the sysop, it enforces rule 6: a working member past its effective `until` is stopped through its engine and ends `timeout`, and a swarm or fleet whose summed `member.spend` has reached its budget has its members stopped, each ending `budget`, then its `swarm.end` when the swarm is complete. An agent calls it on its own fleet to learn its siblings; it stops nothing. |
| `stop` | anyone, within reach | Ends a member, a swarm, or everything in a fleet (`--fleet`) as one unit. For a swarm: nested swarms first, each with its own `swarm.end`, then the target's members through each member's own engine, then the target's `swarm.end`. An agent may stop only a swarm it spawned or a member under such a swarm; `--fleet`, an ancestor, or a sibling's swarm refuses when `OPENFLEET_MEMBER` is set. |
| `log` | anyone | Reads the ledger for a fleet, a swarm or a member: what happened, in order, who did it, what each member spent, how each ended, what was refused and why. `--since`, `--member`, `--swarm`, `--json`. |
-The reference sysop tool is `logicsrc fleet`. FleetView (`claude agents`) and `moshcode fleet` would offer the same five verbs over the same files, each able to stop the members its own engine runs. None of the three ships yet.
+The reference sysop tool is `logicsrc fleet`, in `@logicsrc/openfleet` 0.1.0 (logicsrc CLI 0.3.0). `moshcode fleet` offers the same five verbs over the same files from moshcode 0.99.0, and stops the members its engine runs. FleetView (`claude agents`) does not offer them yet; Claude Code takes part through the hooks `logicsrc fleet hooks install` writes, and `logicsrc fleet` stops its jobs through `claude stop`.
## Rules
@@ -182,7 +182,7 @@ The reference sysop tool is `logicsrc fleet`. FleetView (`claude agents`) and `m
10. The ledger is append-only, one JSON object per line, one file per fleet per host. Every line carries `at`, `event`, `fleet`, `host` and `by`; `by` is `sysop` or a member id; a tool never writes `sysop` for an action an agent took.
11. `stop` on a swarm ends nested swarms first, each with its own `swarm.end`, then the target's members through their own engines, then writes the target's `swarm.end`: one `swarm.end` per swarm ended, never two for the same swarm, so a writer checks the ledger first.
12. A member with no record is a root member of the implicit fleet `@` of the account that started it, never an error. The implicit fleet's ceiling is depth 1 and hosts that host, with no fleet-level `approvals`; each root member's subtree runs under the approvals that root was started with, read from the engine's roster: Claude Code's `respawnFlags`, moshcode's `args`.
-13. A session started with no `OPENFLEET_RECORD` but with an engine's child marker (`CLAUDE_JOB_DIR`, `CLAUDE_CODE_CHILD_SESSION`, `MOSHCODE_HERD_NAME`) in the environment the engine was invoked with, before it sets its own session variables, was started by something that dropped its record. For a `--bg` job the launching `claude` process makes the test, not the daemon it starts. The engine writes a root record with `orphan: true`, then checks approvals before claiming it: a refusal writes `ceiling.refuse` with `member` and `by` the record's own `member` and leaves the record unclaimed, unless `current` names an opened fleet whose ceiling says `bypass`; otherwise it claims the record with `by` its own member id, and the tree marks the row. Under one account the environment test is a convention and the ledger is the audit; nothing in 0.1 stops a process from unsetting a variable.
+13. A session started with no `OPENFLEET_RECORD` but with an engine's child marker (`CLAUDE_JOB_DIR`, `CLAUDE_CODE_CHILD_SESSION`, `MOSHCODE_HERD_NAME`) in the environment the engine was invoked with, before it sets its own session variables, was started by something that dropped its record. For a `--bg` job the launching `claude` process makes the test, not the daemon it starts. In 0.1 no launcher makes that test yet: a background job dispatched with no launcher record is a clean root of the implicit fleet. The engine writes a root record with `orphan: true`, then checks approvals before claiming it: a refusal writes `ceiling.refuse` with `member` and `by` the record's own `member` and leaves the record unclaimed, unless `current` names an opened fleet whose ceiling says `bypass`; otherwise it claims the record with `by` its own member id, and the tree marks the row. Under one account the environment test is a convention and the ledger is the audit; nothing in 0.1 stops a process from unsetting a variable.
14. A sysop tool renders every recorded member from records and the ledger. Members with no record exist only in an engine's roster, and the tool reads the rosters it can (`claude agents --json`, `~/.moshcode/herd/sessions.json`) to draw them and to add liveness. No roster is required for a recorded member.
15. A record and a ledger never hold a credential. Unknown keys are kept.
@@ -200,13 +200,13 @@ Had `460a4502` been started with approvals `native`, its subtree's ceiling would
## Reference implementations
-None ships yet. What each tool adds, from what it records today:
+Three ship: `logicsrc fleet` in `@logicsrc/openfleet` 0.1.0 (logicsrc CLI 0.3.0), Claude Code through `logicsrc fleet hooks install`, and moshcode 0.99.0, which writes records in `moshcode swarm` and offers `moshcode fleet`. What each does, on top of what it records:
-**Claude Code.** Resolve the fleet from `OPENFLEET_FLEET`, else `$OPENFLEET_HOME/current`, else the implicit `@`. On every start, interactive, `--bg`, `-p`, or dispatched from `claude agents`: when `OPENFLEET_RECORD` names an unclaimed record, claim it; when it names a claimed one, derive a child record and claim that; when it is unset, write a root record, with `orphan: true` when the environment the `claude` command was invoked with carries a child marker; for a `--bg` job the launching process makes that test, not the daemon. A root record written with no opened fleet carries `ceiling: { approvals, depth: 1, hosts: [host] }` from its own flags, or `approvals: native` when the record carries `orphan`. Set `approvals` to `bypass` whenever the flags carry `--permission-mode bypassPermissions` or `--dangerously-skip-permissions`; refuse the start with `ceiling.refuse` when the ceiling says no, before any `member.start`; set the four variables below `OPENFLEET_HOME` in the job environment beside `CLAUDE_JOB_DIR`. Add the record's `fleet`, `swarm`, `parent`, `depth` and `approvals` to `~/.claude/jobs//state.json` and to the `claude agents --json` rows, so the roster stops being flat. Append `member.spend` from the job's token count at intervals, and `member.end` when the job first goes terminal, or when an interactive or `-p` session exits, or on SIGHUP when it can, with `summary` from the job's final output, or its SUMMARY section when the prompt asked for one, and `links` from the state file's `children` key, which holds the links a job produced and not child jobs. FleetView groups rows by fleet and swarm, marks `bypass` members, sums spend against budget, and offers `stop` on a swarm and `cap`. The in-process Agent and Workflow tools stay as they are: not members. One thing is unverified: whether the `claude agents` view passes its environment to the daemon it starts. If it does not, the view passes the four variables itself, and the fallback is a root member of the implicit fleet.
+**Claude Code.** Ships as hooks. `logicsrc fleet hooks install` merges five entries into `~/.claude/settings.json` and never clobbers what is there: SessionStart resolves the record and hands the member its variables, UserPromptSubmit checks the ceiling with the permission mode the engine reports and writes `member.start` or refuses the first prompt, PreToolUse denies an Edit or Write outside `piece.owns`, Stop and SessionEnd write `member.end`. What the hooks do is what this paragraph asks of the engine. Resolve the fleet from `OPENFLEET_FLEET`, else `$OPENFLEET_HOME/current`, else the implicit `@`. On every start, interactive, `--bg`, `-p`, or dispatched from `claude agents`: when `OPENFLEET_RECORD` names an unclaimed record, claim it; when it names a claimed one, derive a child record and claim that; when it is unset, write a root record, with `orphan: true` when the environment the `claude` command was invoked with carries a child marker; for a `--bg` job the launching process makes that test, not the daemon. A root record written with no opened fleet carries `ceiling: { approvals, depth: 1, hosts: [host] }` from its own flags, or `approvals: native` when the record carries `orphan`. Set `approvals` to `bypass` whenever the flags carry `--permission-mode bypassPermissions` or `--dangerously-skip-permissions`; refuse the start with `ceiling.refuse` when the ceiling says no, before any `member.start`; set the four variables below `OPENFLEET_HOME` in the job environment beside `CLAUDE_JOB_DIR`. Still Claude Code's own to add: the record's `fleet`, `swarm`, `parent`, `depth` and `approvals` in `~/.claude/jobs//state.json` and in the `claude agents --json` rows, so the roster stops being flat; `member.spend` from the job's token count at intervals; and a FleetView that groups rows by fleet and swarm, marks `bypass` members, sums spend against budget, and offers `stop` on a swarm and `cap`. Until then the hooks write `member.end` when a background job first goes terminal, or when an interactive or `-p` session exits, with `summary` from the closing message, or its SUMMARY section when the prompt asked for one, `total` from the job's token count, and `links` from the state file's `children` key, which holds the links a job produced and not child jobs. The in-process Agent and Workflow tools stay as they are: not members. Two limits were verified on Claude Code 2.1.270 and shape the hooks. A background job dispatched from `claude agents` runs in a spare the daemon forked before any launcher existed, so no launcher environment reaches it: it is a root member of the implicit fleet unless a launcher writes its record and the job finds that record by another channel than the environment. And `OPENFLEET_*` exported at SessionStart reach the member's tools through `CLAUDE_ENV_FILE` but not later hooks, so the hooks key on `session_id` through `$OPENFLEET_HOME/sessions/.json`, a file of the implementation and not of this spec.
-**moshcode.** In `moshcode swarm`: mint the swarm id before the plan call, and run the planner with no `OPENFLEET_SWARM`, since its `swarm.spawn` does not exist until the plan returns; extend the planner's reply to `[{ "title", "prompt", "files" }]` and store `files` as `piece.owns`, so "do not touch bye.sh" becomes data moshcode can check instead of prose it never parses; write `swarm.spawn` with one piece per pane, member ids `-`, and the narrowing from `--agents` (`fan_out`) and `--timeout` (`until`); name each pane after its member id and write one unclaimed record per pane with `session` the pane's tmux target; add the four variables to the pane's environment line beside `MOSHCODE_HERD_NAME` and `MOSHCODE_HERD_DIR`, and keep them when deleting `ANTHROPIC_API_KEY` and `CLAUDE_CODE_SESSION_ID`. A `claude` pane claims its own record. For codex, deepseek and kimi panes moshcode writes `member.start` from the herd ledger's `submit` event, since nothing else in the pane writes a record. Record `approvals: bypass` truthfully: today `sessions.json` says `agent: false` while the pane runs `claude --dangerously-skip-permissions`. Refuse that flag with `ceiling.refuse` unless the ceiling says `bypass`. At the end, write `member.end` from the herd `end` event for every pane whose session has not written its own by then, then `swarm.end` with the synthesis as `summary` and the `--verify` result as `verdict`, then the default kill; `--keep` leaves members running and writes neither `member.end` nor `swarm.end`. When moshcode itself runs inside a member, the swarm's parent is that member. `moshcode fleet open|cap|tree|stop|log` is the sysop tool for this engine, with `herd ps` grouped by fleet and swarm.
+**moshcode.** From 0.99.0, `moshcode swarm` writes the record and the ledger, and `moshcode fleet` is the sysop tool for its engine. What that means, in `moshcode swarm`: mint the swarm id before the plan call, and run the planner with no `OPENFLEET_SWARM`, since its `swarm.spawn` does not exist until the plan returns; extend the planner's reply to `[{ "title", "prompt", "files" }]` and store `files` as `piece.owns`, so "do not touch bye.sh" becomes data moshcode can check instead of prose it never parses; write `swarm.spawn` with one piece per pane, member ids `-`, and the narrowing from `--agents` (`fan_out`) and `--timeout` (`until`); name each pane after its member id and write one unclaimed record per pane with `session` the pane's tmux target; add the four variables to the pane's environment line beside `MOSHCODE_HERD_NAME` and `MOSHCODE_HERD_DIR`, and keep them when deleting `ANTHROPIC_API_KEY` and `CLAUDE_CODE_SESSION_ID`. A `claude` pane claims its own record. For codex, deepseek and kimi panes moshcode writes `member.start` from the herd ledger's `submit` event, since nothing else in the pane writes a record. Record `approvals: bypass` truthfully: today `sessions.json` says `agent: false` while the pane runs `claude --dangerously-skip-permissions`. Refuse that flag with `ceiling.refuse` unless the ceiling says `bypass`. At the end, write `member.end` from the herd `end` event for every pane whose session has not written its own by then, then `swarm.end` with the synthesis as `summary` and the `--verify` result as `verdict`, then the default kill; `--keep` leaves members running and writes neither `member.end` nor `swarm.end`. When moshcode itself runs inside a member, the swarm's parent is that member. `moshcode fleet open|cap|tree|stop|log` is the sysop tool for this engine, with `herd ps` grouped by fleet and swarm.
-**logicsrc.** `logicsrc fleet open|cap|tree|stop|log`, the engine-neutral sysop tool that folds any `$OPENFLEET_HOME` into one tree and stops a member through the engine its record names.
+**logicsrc.** `logicsrc fleet open|cap|tree|stop|log`, the engine-neutral sysop tool that folds any `$OPENFLEET_HOME` into one tree and stops a member through the engine its record names: `claude stop` for `claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for `tmux`, a signal to the pid for `claude-p`. Ships in `@logicsrc/openfleet` 0.1.0 with the logicsrc CLI 0.3.0. `tree` reads `claude agents --json --all` and `~/.moshcode/herd/sessions.json` for liveness and for members with no record, and writes `member.end` state `lost` for a recorded background job or pane its engine's roster can hold and no longer lists; an interactive or `-p` `claude` session, which `claude agents` never lists, is never marked lost. Run by the sysop, `tree` also enforces rule 6: a member past its effective `until` is stopped through its engine and ends `timeout`, a swarm or fleet whose summed `member.spend` has reached its budget has its members stopped, each ending `budget`, and each swarm touched gets its `swarm.end` once it is complete. `stop` on a claude-code member calls `claude stop` with the job id: the member id of a background job, else the first eight characters of the record's session when that is a session UUID; an interactive session with no job id cannot be stopped by the tool, which says so. Every `member.start`, `member.end` and `swarm.end` the tool or the hooks write takes a once-marker first, an exclusive create of `$OPENFLEET_HOME/fleets//marks/.` (`member.end..lost` for a `lost` line, so a real end can still follow it and take the plain one); a writer that finds the marker taken writes nothing and reports "already". moshcode uses the same paths, so the two writers never double a line. `logicsrc fleet hooks install|remove|status` and `logicsrc fleet hook ` are the Claude Code side above. Every verb takes `--json`.
## What is deliberately absent
@@ -258,7 +258,7 @@ None ships yet. What each tool adds, from what it records today:
| Version | Date | Change |
|---|---|---|
-| 0.1 | 2026-09-13 | First publication: fleet and swarm, the record, the environment, claiming and deriving, the ledger's eight events, five sysop verbs, fifteen rules, the worked example, what Claude Code and moshcode would each add. |
+| 0.1 | 2026-09-13 | First publication: fleet and swarm, the record, the environment, claiming and deriving, the ledger's eight events, five sysop verbs, fifteen rules, the worked example, what Claude Code and moshcode each add. Same day: `logicsrc fleet` and the Claude Code hooks ship in `@logicsrc/openfleet` 0.1.0, `moshcode swarm` and `moshcode fleet` in moshcode 0.99.0. |
## License
diff --git a/package-lock.json b/package-lock.json
index b7bceb6..d96c792 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2678,6 +2678,10 @@
"resolved": "packages/opencreds",
"link": true
},
+ "node_modules/@logicsrc/openfleet": {
+ "resolved": "packages/openfleet",
+ "link": true
+ },
"node_modules/@logicsrc/openmcp": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@logicsrc/openmcp/-/openmcp-0.3.1.tgz",
@@ -8947,12 +8951,13 @@
},
"packages/cli": {
"name": "@logicsrc/cli",
- "version": "0.2.1",
+ "version": "0.3.0",
"dependencies": {
"@fission-ai/openspec": "^1.13.0",
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/opencontext": "file:../opencontext",
"@logicsrc/opencreds": "file:../opencreds",
+ "@logicsrc/openfleet": "file:../openfleet",
"@logicsrc/openmcp": "^0.3.1",
"@logicsrc/openontology": "file:../openontology",
"@logicsrc/openprd": "file:../openprd",
@@ -9047,6 +9052,17 @@
"vitest": "^4.0.8"
}
},
+ "packages/openfleet": {
+ "name": "@logicsrc/openfleet",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.2"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8"
+ }
+ },
"packages/openontology": {
"name": "@logicsrc/openontology",
"version": "0.1.0",
diff --git a/package.json b/package.json
index 57d7799..c7d93a7 100644
--- a/package.json
+++ b/package.json
@@ -12,14 +12,14 @@
"apps/*"
],
"scripts": {
- "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
+ "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/openfleet run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
"start": "npm --workspace @logicsrc/web run start",
"test": "npm run test --workspaces --if-present",
"check": "npm run build && npm run test",
"schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures",
"test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract",
"test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e",
- "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/cli run build"
+ "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/openfleet run build && npm --workspace @logicsrc/cli run build"
},
"devDependencies": {
"@types/node": "^24.10.1",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3484500..cbedb6b 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@logicsrc/cli",
- "version": "0.2.1",
+ "version": "0.3.0",
"description": "LogicSRC CLI: every LogicSRC standard and tool as one command.",
"type": "module",
"main": "./dist/index.js",
@@ -18,6 +18,7 @@
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/opencontext": "file:../opencontext",
"@logicsrc/opencreds": "file:../opencreds",
+ "@logicsrc/openfleet": "file:../openfleet",
"@logicsrc/openmcp": "^0.3.1",
"@logicsrc/openontology": "file:../openontology",
"@logicsrc/openprd": "file:../openprd",
diff --git a/packages/cli/src/fleet.test.ts b/packages/cli/src/fleet.test.ts
new file mode 100644
index 0000000..439620b
--- /dev/null
+++ b/packages/cli/src/fleet.test.ts
@@ -0,0 +1,99 @@
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Command } from "commander";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Deps } from "@logicsrc/openfleet/commands";
+import { registerFleetCommands } from "./fleet.js";
+
+/** A program shaped like the real one: positional options on, no process.exit. */
+function program(): Command {
+ const p = new Command();
+ p.name("logicsrc").enablePositionalOptions().exitOverride();
+ return p;
+}
+
+const OPENFLEET_KEYS = ["OPENFLEET_HOME", "OPENFLEET_RECORD", "OPENFLEET_FLEET", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"];
+
+describe("logicsrc fleet", () => {
+ let home: string;
+ let saved: Record;
+ let out: string[];
+ let err: string[];
+ let deps: Partial;
+
+ beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "logicsrc-fleet-"));
+ saved = Object.fromEntries(OPENFLEET_KEYS.map((key) => [key, process.env[key]]));
+ out = [];
+ err = [];
+ deps = {
+ env: { OPENFLEET_HOME: home, HOME: home },
+ now: () => new Date("2026-09-13T06:00:00Z"),
+ exec: async () => ({ code: 0, stdout: "", stderr: "" }),
+ kill: () => undefined,
+ rosters: {},
+ stdin: async () => "",
+ write: (line) => out.push(line),
+ error: (line) => err.push(line),
+ stdout: (text) => out.push(text),
+ stderr: (text) => err.push(text),
+ host: "dev",
+ user: "anthony",
+ };
+ });
+
+ afterEach(() => {
+ for (const key of OPENFLEET_KEYS) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+ rmSync(home, { recursive: true, force: true });
+ process.exitCode = 0;
+ });
+
+ it("registers the group with the five verbs and the hook verbs", () => {
+ const p = program();
+ registerFleetCommands(p, deps);
+ const fleet = p.commands.find((command) => command.name() === "fleet");
+ expect(fleet).toBeDefined();
+ expect(fleet!.commands.map((command) => command.name()).sort()).toEqual(["cap", "hook", "hooks", "log", "open", "stop", "tree"]);
+ expect(fleet!.description()).toContain("OpenFleet");
+ });
+
+ it("opens a fleet, then shows it in the tree and the log, over the injected home", async () => {
+ const p = program();
+ registerFleetCommands(p, deps);
+ await p.parseAsync(["node", "logicsrc", "fleet", "open", "--approvals", "bypass", "--depth", "2", "--json"]);
+ expect(process.exitCode).toBe(0);
+ expect(JSON.parse(out[0])).toMatchObject({ fleet: "fleet-20260913", sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, hosts: ["dev"] } });
+ expect(readFileSync(join(home, "current"), "utf8")).toBe("fleet-20260913\n");
+
+ const tree = program();
+ registerFleetCommands(tree, deps);
+ await tree.parseAsync(["node", "logicsrc", "fleet", "tree"]);
+ expect(out[1]).toBe("fleet-20260913 (fleet, sysop anthony@dev, approvals bypass, depth 2, hosts dev)");
+
+ const log = program();
+ registerFleetCommands(log, deps);
+ await log.parseAsync(["node", "logicsrc", "fleet", "log", "--json"]);
+ expect(JSON.parse(out[2])).toMatchObject({ event: "fleet.open", by: "sysop", fleet: "fleet-20260913", host: "dev" });
+ });
+
+ it("refuses the sysop's verbs with exit 4 when the process is an agent", async () => {
+ const p = program();
+ registerFleetCommands(p, { ...deps, env: { ...deps.env, OPENFLEET_MEMBER: "create-two-0541-1" } });
+ await p.parseAsync(["node", "logicsrc", "fleet", "open"]);
+ expect(process.exitCode).toBe(4);
+ expect(err[0]).toContain("OPENFLEET_MEMBER=create-two-0541-1");
+ expect(out).toEqual([]);
+ });
+
+ it("inspects the hooks of a settings file given by flag, never the real one", async () => {
+ const file = join(home, "settings.json");
+ const p = program();
+ registerFleetCommands(p, deps);
+ await p.parseAsync(["node", "logicsrc", "fleet", "hooks", "status", "--settings-file", file, "--json"]);
+ expect(JSON.parse(out[0])).toMatchObject({ file, present: false, installed: false });
+ });
+});
diff --git a/packages/cli/src/fleet.ts b/packages/cli/src/fleet.ts
new file mode 100644
index 0000000..e7dd44b
--- /dev/null
+++ b/packages/cli/src/fleet.ts
@@ -0,0 +1,26 @@
+import type { Command } from "commander";
+import { registerOpenFleetCommands, type Deps } from "@logicsrc/openfleet/commands";
+
+/**
+ * `logicsrc fleet …`
+ *
+ * The commands themselves live in `@logicsrc/openfleet`, the same package that
+ * holds the record, the ledger and the Claude Code hooks, so the sysop tool
+ * and the engine side read the same files through the same code. The spec
+ * treats the five verbs (open, cap, tree, stop, log) and their refusals as a
+ * conformance surface; keeping them in one place is how they stay one contract.
+ *
+ * `deps` is injectable so the umbrella tests drive the group with a fake
+ * clock, environment, roster and process runner, and never touch
+ * `~/.openfleet` or a real engine.
+ */
+export function registerFleetCommands(program: Command, deps: Partial = {}): void {
+ const fleet = program
+ .command("fleet")
+ .description(
+ "OpenFleet: agents under a human. open, cap, tree, stop and log over $OPENFLEET_HOME, " +
+ "plus the Claude Code hooks (`hooks install`) that make every session a recorded member.",
+ );
+
+ registerOpenFleetCommands(fleet, deps);
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 8e74012..ec15422 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -36,6 +36,7 @@ import { parsePositiveInteger } from "./numeric-options.js";
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
import { registerOpenContextCommands } from "./context.js";
import { registerOpenCredsCommands } from "./creds.js";
+import { registerFleetCommands } from "./fleet.js";
import { registerOntologyCommands } from "./ontology.js";
import { registerPrdCommands } from "./prd.js";
import { registerOpenMcpCommands } from "./openmcp.js";
@@ -1057,6 +1058,7 @@ registerOpenContextCommands(program);
registerOpenCredsCommands(program);
registerOntologyCommands(program);
registerPrdCommands(program);
+registerFleetCommands(program);
registerOpenMcpCommands(program);
registerMcpCommands(program);
// Every other word under `logicsrc openspec` is OpenSpec.dev's own CLI.
diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts
index d070c74..e58ab1d 100644
--- a/packages/logicsrc-mcp/src/standards.test.ts
+++ b/packages/logicsrc-mcp/src/standards.test.ts
@@ -203,7 +203,7 @@ describe("MCP: OpenPRD", () => {
it("reports the next free id and the allowed lifecycle moves", async () => {
const client = await connect();
// Asserted against the live prd/ directory, so this advances with every PRD added.
- expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0008");
+ expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0009");
const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } });
const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] };
diff --git a/packages/openfleet/README.md b/packages/openfleet/README.md
new file mode 100644
index 0000000..2ec0a6b
--- /dev/null
+++ b/packages/openfleet/README.md
@@ -0,0 +1,116 @@
+# @logicsrc/openfleet
+
+Reference implementation of [OpenFleet](https://logicsrc.com/docs/openfleet),
+the record an agent session carries about where it sits: which human answers
+for it, who spawned it, for what task, at what depth, and under what ceiling.
+A fleet is every agent session one human, its sysop, is answerable for. A
+swarm is the set of sessions one spawner starts to do one task.
+
+This package holds the record, the ledger, the ceiling rules, claiming and
+deriving, the folded tree, the five sysop verbs, and the Claude Code hooks.
+moshcode writes the same files from `moshcode swarm` and reads them with
+`moshcode fleet`; one tree shows both engines.
+
+## Install
+
+```bash
+npm install -g @logicsrc/cli
+logicsrc fleet --help
+```
+
+The verbs live in this package and the umbrella CLI wraps them as
+`logicsrc fleet`. To use the library directly:
+
+```bash
+npm install @logicsrc/openfleet
+```
+
+## The files
+
+Everything lives under `$OPENFLEET_HOME`, default `~/.openfleet`:
+
+| Path | What |
+| --- | --- |
+| `fleets//members/.json` | One record per member, written before it starts, never changed after `member.start`. |
+| `fleets//ledger.jsonl` | The append-only ledger for this host. `ledger..jsonl` copies from other hosts are merged by `at`. |
+| `fleets//marks/.` | Once-markers for `member.start`, `member.end` and `swarm.end`, so two writers never double a line. |
+| `current` | The fleet the account's next root member joins. Absent means the implicit `@`. |
+| `sessions/.json` | The Claude Code hooks' own lookup, not part of the spec. |
+
+Files are 0600 and directories 0700.
+
+## The five verbs
+
+```bash
+# Sysop only: mint a fleet, set its whole ceiling, write fleet.open and current.
+logicsrc fleet open team --approvals bypass --budget "20 USD" --depth 2 --fan-out 4 --hosts dev,netcup --until 2h
+
+# Sysop only: set a fleet's whole ceiling, or narrow a running swarm's. Members now above it are stopped.
+logicsrc fleet cap team-20260913 --approvals native
+logicsrc fleet cap create-two-0541 --fan-out 2
+
+# Anyone: the tree, from the ledger, the records and the engine rosters. Run by the sysop it also
+# stops what is past its deadline or over its budget (rule 6) and marks lost what its engine no longer lists.
+logicsrc fleet tree
+logicsrc fleet tree anthony@dev --json
+
+# Anyone, within reach: end a member, a swarm (nested swarms first), or a whole fleet through each member's engine.
+logicsrc fleet stop create-two-0541
+logicsrc fleet stop team-20260913 --fleet
+
+# Anyone: the ledger, in order, with who did it.
+logicsrc fleet log --swarm create-two-0541
+logicsrc fleet log --since 2h --json
+```
+
+Every verb takes `--json`. `open` and `cap` refuse with exit 4 when the
+process carries `OPENFLEET_MEMBER`: that process is an agent. `stop` refuses
+outside the caller's own subtree the same way. An engine that will not end a
+member leaves it without an end line and the verb exits 3.
+
+`stop` goes through the member's own engine: `claude stop ` for
+`claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for
+`tmux`, a signal to the pid for `claude-p`.
+
+## Claude Code hooks
+
+```bash
+logicsrc fleet hooks install # merges five entries into ~/.claude/settings.json, never clobbers
+logicsrc fleet hooks status
+logicsrc fleet hooks remove # takes out only ours
+```
+
+With the hooks installed every Claude Code session becomes a recorded member:
+SessionStart claims the record `OPENFLEET_RECORD` names, derives a child under
+a claimed one, or writes a root record; UserPromptSubmit checks the ceiling
+with the permission mode the engine reports and writes `member.start`, or
+refuses the first prompt with exit 2; PreToolUse denies an Edit or Write
+outside `piece.owns`; Stop and SessionEnd write `member.end`. A hook never
+fails the engine. `logicsrc fleet hook ` is the entry point the
+settings file calls.
+
+## Environment
+
+| Variable | Meaning |
+| --- | --- |
+| `OPENFLEET_HOME` | The root directory. Default `~/.openfleet`. |
+| `OPENFLEET_RECORD` | The absolute path of this member's record. |
+| `OPENFLEET_FLEET` | A copy of the record's `fleet`; in the sysop's shell, the fleet new roots join. |
+| `OPENFLEET_MEMBER` | A copy of the record's `member`. Present means this process is an agent. |
+| `OPENFLEET_SWARM` | A copy of the record's `swarm`, when it carries one. |
+
+## Library
+
+```js
+import { context, claimOrDerive, startMember, endMember, fold, renderTree, readLedger } from "@logicsrc/openfleet";
+import { registerOpenFleetCommands } from "@logicsrc/openfleet/commands";
+```
+
+`context(env)` answers which fleet and which member a process is. `claimOrDerive`
+is the engine-side rule from the spec's "Claiming and deriving". `fold` builds
+the tree `tree` renders. `registerOpenFleetCommands(command, deps)` mounts the
+verbs on a commander command with every world-touching dependency injectable.
+
+## License
+
+MIT. The specification text is CC BY 4.0.
diff --git a/packages/openfleet/package.json b/packages/openfleet/package.json
new file mode 100644
index 0000000..0e0c0f3
--- /dev/null
+++ b/packages/openfleet/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@logicsrc/openfleet",
+ "version": "0.1.0",
+ "description": "Reference implementation of the OpenFleet standard: the record an agent session carries about who spawned it, for what and under what ceiling, the ledger its sysop reads, and the `logicsrc fleet` sysop verbs.",
+ "license": "MIT",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": "./dist/index.js",
+ "./commands": "./dist/commands.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/profullstack/logicsrc.git",
+ "directory": "packages/openfleet"
+ },
+ "homepage": "https://logicsrc.com/docs/openfleet",
+ "keywords": [
+ "logicsrc",
+ "openfleet",
+ "agents",
+ "fleet",
+ "swarm",
+ "standards",
+ "cli"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "test": "vitest run src"
+ },
+ "dependencies": {
+ "commander": "^14.0.2"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/packages/openfleet/src/ceiling.test.ts b/packages/openfleet/src/ceiling.test.ts
new file mode 100644
index 0000000..a8dfdf4
--- /dev/null
+++ b/packages/openfleet/src/ceiling.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkCeiling,
+ describeRefusal,
+ effectiveCeiling,
+ fleetCeiling,
+ formatSpend,
+ isImplicitFleet,
+ isNarrower,
+ mergeCeiling,
+ parseBudget,
+ parseUntil,
+ rootApprovals,
+ sumSpend,
+ swarmChain,
+} from "./ceiling.js";
+import type { LedgerLine } from "./types.js";
+
+const line = (partial: Partial & { event: string }): LedgerLine => ({ at: "2026-09-13T05:00:00Z", fleet: "f", host: "dev", by: "sysop", ...partial });
+
+describe("narrower", () => {
+ it("approvals: native under bypass, never bypass under native or under nothing", () => {
+ expect(isNarrower("approvals", "native", "bypass")).toBe(true);
+ expect(isNarrower("approvals", "bypass", "bypass")).toBe(true);
+ expect(isNarrower("approvals", "native", "native")).toBe(true);
+ expect(isNarrower("approvals", "native", undefined)).toBe(true);
+ expect(isNarrower("approvals", "bypass", "native")).toBe(false);
+ expect(isNarrower("approvals", "bypass", undefined)).toBe(false);
+ });
+
+ it("depth and fan_out: smaller or equal; depth absent means 1, fan_out absent means uncapped", () => {
+ expect(isNarrower("depth", 1, 2)).toBe(true);
+ expect(isNarrower("depth", 2, 2)).toBe(true);
+ expect(isNarrower("depth", 3, 2)).toBe(false);
+ expect(isNarrower("depth", 1, undefined)).toBe(true);
+ expect(isNarrower("depth", 2, undefined)).toBe(false);
+ expect(isNarrower("fan_out", 4, 8)).toBe(true);
+ expect(isNarrower("fan_out", 9, 8)).toBe(false);
+ expect(isNarrower("fan_out", 99, undefined)).toBe(true);
+ });
+
+ it("budget: smaller or equal in the same unit, never across units, uncapped when absent", () => {
+ expect(isNarrower("budget", "10 USD", "20 USD")).toBe(true);
+ expect(isNarrower("budget", "20 USD", "20 USD")).toBe(true);
+ expect(isNarrower("budget", "30 USD", "20 USD")).toBe(false);
+ expect(isNarrower("budget", "10 USD", "500000 tokens")).toBe(false);
+ expect(isNarrower("budget", "10 USD", undefined)).toBe(true);
+ expect(isNarrower("budget", "lots", "20 USD")).toBe(false);
+ });
+
+ it("hosts: a subset; until: earlier or equal", () => {
+ expect(isNarrower("hosts", ["dev"], ["dev", "netcup"])).toBe(true);
+ expect(isNarrower("hosts", ["dev", "netcup"], ["dev"])).toBe(false);
+ expect(isNarrower("hosts", ["dev"], undefined)).toBe(true);
+ expect(isNarrower("until", "2026-09-13T06:00:00Z", "2026-09-13T06:11:01Z")).toBe(true);
+ expect(isNarrower("until", "2026-09-13T06:11:01Z", "2026-09-13T06:11:01Z")).toBe(true);
+ expect(isNarrower("until", "2026-09-13T07:00:00Z", "2026-09-13T06:11:01Z")).toBe(false);
+ expect(isNarrower("until", "2026-09-13T07:00:00Z", undefined)).toBe(true);
+ });
+});
+
+describe("merge", () => {
+ const base = { approvals: "bypass" as const, depth: 2, fan_out: 8, hosts: ["dev", "netcup"], budget: "20 USD", until: "2026-09-13T08:00:00Z" };
+
+ it("takes every narrowed key and inherits the rest", () => {
+ expect(mergeCeiling(base, { fan_out: 4, until: "2026-09-13T06:11:01Z" })).toEqual({ ...base, fan_out: 4, until: "2026-09-13T06:11:01Z" });
+ expect(mergeCeiling(base, undefined)).toEqual(base);
+ });
+
+ it("never widens: a key a spawner tried to widen keeps the base value", () => {
+ expect(mergeCeiling({ approvals: "native", depth: 1 }, { approvals: "bypass", depth: 3, fan_out: 2 })).toEqual({ approvals: "native", depth: 1, fan_out: 2 });
+ expect(mergeCeiling(base, { hosts: ["dev", "netcup", "other"], budget: "500 USD" })).toEqual(base);
+ });
+
+ it("copies unknown keys through", () => {
+ expect(mergeCeiling({ depth: 1 }, { scopes: ["fleet:read"] })).toEqual({ depth: 1, scopes: ["fleet:read"] });
+ });
+});
+
+describe("the fleet's ceiling and the path down", () => {
+ const implicit = { depth: 1, hosts: ["dev"] };
+
+ it("is the implicit one with no lines, fleet.open once opened, and the latest fleet.cap for the fleet after that", () => {
+ expect(fleetCeiling([], "f", implicit)).toEqual(implicit);
+ expect(isImplicitFleet([], "f")).toBe(true);
+ const opened = [line({ event: "fleet.open", fleet: "f", ceiling: { approvals: "bypass", depth: 2 } })];
+ // hosts absent means the host the line was written on.
+ expect(fleetCeiling(opened, "f", implicit)).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev"] });
+ expect(isImplicitFleet(opened, "f")).toBe(false);
+ const capped = [
+ ...opened,
+ line({ event: "fleet.cap", target: "f", ceiling: { approvals: "native", depth: 1 } }),
+ line({ event: "fleet.cap", target: "some-swarm", ceiling: { depth: 0 } }),
+ line({ event: "fleet.cap", target: "f", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }),
+ ];
+ expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] });
+ });
+
+ it("a fleet opened with an empty ceiling admits members on its own host only", () => {
+ const opened = [line({ event: "fleet.open", fleet: "f", host: "dev", ceiling: {} })];
+ const ceiling = fleetCeiling(opened, "f", implicit);
+ expect(ceiling).toEqual({ hosts: ["dev"] });
+ expect(checkCeiling({ hosts: ["dev"] }, ceiling)).toBeNull();
+ expect(checkCeiling({ hosts: ["netcup"] }, ceiling)).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] });
+ });
+
+ it("follows parent_swarm to the top and merges spawn narrowings first, then swarm caps last", () => {
+ const lines = [
+ line({ event: "swarm.spawn", by: "root", swarm: "outer", ceiling: { fan_out: 4, until: "2026-09-13T07:00:00Z" } }),
+ line({ event: "swarm.spawn", by: "outer-1", swarm: "inner", parent_swarm: "outer", ceiling: { fan_out: 2, depth: 9 } }),
+ line({ event: "fleet.cap", target: "outer", ceiling: { until: "2026-09-13T06:00:00Z" } }),
+ ];
+ expect(swarmChain(lines, "inner")).toEqual(["outer", "inner"]);
+ expect(swarmChain(lines, undefined)).toEqual([]);
+ const base = { approvals: "bypass" as const, depth: 2, hosts: ["dev"] };
+ expect(effectiveCeiling(base, lines, swarmChain(lines, "inner"))).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:00:00Z" });
+ });
+
+ it("a root supplies its approvals to its subtree in the implicit fleet, native when orphan", () => {
+ expect(rootApprovals({ approvals: "bypass" })).toBe("bypass");
+ expect(rootApprovals({ approvals: "bypass", orphan: true })).toBe("native");
+ expect(rootApprovals({})).toBe("native");
+ expect(rootApprovals(null)).toBe("native");
+ });
+});
+
+describe("checkCeiling", () => {
+ const allowed = { approvals: "bypass" as const, depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" };
+
+ it("passes what is within", () => {
+ expect(checkCeiling({ approvals: "bypass", depth: 1, fan_out: 2, hosts: ["dev"], until: "2026-09-13T05:41:12Z" }, allowed)).toBeNull();
+ expect(checkCeiling({}, allowed)).toBeNull();
+ });
+
+ it("names the first key exceeded, in the order approvals, depth, fan_out, hosts, until", () => {
+ expect(checkCeiling({ approvals: "bypass" }, { approvals: "native" })).toEqual({ key: "approvals", wanted: "bypass", allowed: "native" });
+ expect(checkCeiling({ approvals: "bypass" }, {})).toEqual({ key: "approvals", wanted: "bypass", allowed: "native" });
+ expect(checkCeiling({ depth: 2 }, allowed)).toEqual({ key: "depth", wanted: 2, allowed: 1 });
+ expect(checkCeiling({ depth: 2 }, {})).toEqual({ key: "depth", wanted: 2, allowed: 1 });
+ expect(checkCeiling({ fan_out: 5 }, allowed)).toEqual({ key: "fan_out", wanted: 5, allowed: 4 });
+ expect(checkCeiling({ hosts: ["netcup"] }, allowed)).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] });
+ expect(checkCeiling({ until: "2026-09-13T06:30:00Z" }, allowed)).toEqual({ key: "until", wanted: "2026-09-13T06:30:00Z", allowed: "2026-09-13T06:11:01Z" });
+ expect(checkCeiling({ approvals: "bypass", depth: 5 }, { approvals: "native", depth: 1 })?.key).toBe("approvals");
+ });
+
+ it("describes a refusal for a human", () => {
+ expect(describeRefusal({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] })).toBe("ceiling refuses hosts: wanted netcup, allowed dev");
+ expect(describeRefusal({ key: "fan_out", wanted: 5, allowed: null })).toBe("ceiling refuses fan_out: wanted 5, allowed none");
+ });
+});
+
+describe("budgets, deadlines and spend", () => {
+ it("parses ", () => {
+ expect(parseBudget("20 USD")).toEqual({ amount: 20, unit: "USD" });
+ expect(parseBudget("1500000 tokens")).toEqual({ amount: 1500000, unit: "tokens" });
+ expect(parseBudget("0.5 eip155:1/slip44:60")).toEqual({ amount: 0.5, unit: "eip155:1/slip44:60" });
+ expect(parseBudget("20")).toBeNull();
+ expect(parseBudget(undefined)).toBeNull();
+ });
+
+ it("reads --until as a duration from now or an ISO time", () => {
+ const now = new Date("2026-09-13T05:41:01Z");
+ expect(parseUntil("2h", now)).toBe("2026-09-13T07:41:01Z");
+ expect(parseUntil("30m", now)).toBe("2026-09-13T06:11:01Z");
+ expect(parseUntil("2026-09-13T06:11:01Z", now)).toBe("2026-09-13T06:11:01Z");
+ expect(parseUntil("soon", now)).toBeNull();
+ });
+
+ it("sums spend per unit and shows it against the budget's unit", () => {
+ const sums = sumSpend(["10 USD", "5 USD", "1000 tokens", undefined, "junk"]);
+ expect(sums).toEqual({ USD: 15, tokens: 1000 });
+ expect(formatSpend(sums, "20 USD")).toBe("15/20 USD");
+ expect(formatSpend(sums)).toBe("15 USD, 1000 tokens");
+ expect(formatSpend({})).toBe("");
+ });
+});
diff --git a/packages/openfleet/src/ceiling.ts b/packages/openfleet/src/ceiling.ts
new file mode 100644
index 0000000..4aaf73c
--- /dev/null
+++ b/packages/openfleet/src/ceiling.ts
@@ -0,0 +1,241 @@
+/**
+ * How a ceiling is read, merged and checked (docs/openfleet.md, "How a ceiling
+ * is read" and rules 3 to 5).
+ *
+ * A fleet's ceiling is whole. A swarm's holds only the keys it narrowed. A
+ * member's effective ceiling is the fleet's, merged key by key down the swarm
+ * path, with the latest `fleet.cap` for any swarm on that path applied last.
+ * Narrower never widens: a key a spawner tried to widen is ignored here and
+ * refused by the engine that checks it.
+ */
+
+import { findEvents, spawnOf } from "./store.js";
+import type { Approvals, Ceiling, CeilingKey, LedgerLine, Refusal } from "./types.js";
+import { CEILING_KEYS } from "./types.js";
+
+export interface Budget {
+ amount: number;
+ unit: string;
+}
+
+/** `20 USD`, `1500000 tokens`, or a CAIP-19 asset id after the amount. */
+export function parseBudget(value: unknown): Budget | null {
+ if (typeof value !== "string") return null;
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\S.*)$/);
+ if (!match) return null;
+ return { amount: Number(match[1]), unit: match[2].trim() };
+}
+
+function asNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function asHosts(value: unknown): string[] | null {
+ return Array.isArray(value) && value.every((host) => typeof host === "string") ? (value as string[]) : null;
+}
+
+function asTime(value: unknown): number | null {
+ if (typeof value !== "string") return null;
+ const ms = Date.parse(value);
+ return Number.isNaN(ms) ? null : ms;
+}
+
+/**
+ * Is `wanted` within `allowed` for one key? Equal counts as within. An absent
+ * `allowed` means the spec's default for that key: `approvals` native, `depth`
+ * 1, and no cap for budget, fan_out, hosts and until.
+ */
+export function isNarrower(key: CeilingKey, wanted: unknown, allowed: unknown): boolean {
+ if (wanted === undefined) return true;
+ switch (key) {
+ case "approvals":
+ return wanted === "native" || allowed === "bypass";
+ case "depth": {
+ const want = asNumber(wanted);
+ const cap = allowed === undefined ? 1 : asNumber(allowed);
+ if (want === null) return false;
+ if (cap === null) return false;
+ return want <= cap;
+ }
+ case "fan_out": {
+ if (allowed === undefined) return true;
+ const want = asNumber(wanted);
+ const cap = asNumber(allowed);
+ return want !== null && cap !== null && want <= cap;
+ }
+ case "budget": {
+ if (allowed === undefined) return true;
+ const want = parseBudget(wanted);
+ const cap = parseBudget(allowed);
+ // Budgets in different units are not comparable, so not narrower.
+ return want !== null && cap !== null && want.unit === cap.unit && want.amount <= cap.amount;
+ }
+ case "hosts": {
+ if (allowed === undefined) return true;
+ const want = asHosts(wanted);
+ const cap = asHosts(allowed);
+ return want !== null && cap !== null && want.every((host) => cap.includes(host));
+ }
+ case "until": {
+ if (allowed === undefined) return true;
+ const want = asTime(wanted);
+ const cap = asTime(allowed);
+ return want !== null && cap !== null && want <= cap;
+ }
+ default:
+ return true;
+ }
+}
+
+/**
+ * Merge a narrowing into a base ceiling, key by key. A key the narrowing
+ * would widen keeps the base value: a merged ceiling never widens, whatever a
+ * ledger line claims. Unknown keys in the narrowing are copied through.
+ */
+export function mergeCeiling(base: Ceiling, narrowing: Ceiling | undefined): Ceiling {
+ const out: Ceiling = { ...base };
+ if (!narrowing || typeof narrowing !== "object") return out;
+ for (const [key, value] of Object.entries(narrowing)) {
+ if (value === undefined) continue;
+ if ((CEILING_KEYS as readonly string[]).includes(key)) {
+ if (isNarrower(key as CeilingKey, value, base[key])) out[key] = value;
+ } else {
+ out[key] = value;
+ }
+ }
+ return out;
+}
+
+/**
+ * A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet,
+ * else `fleet.open`, else the implicit fleet's. A line that names no `hosts`
+ * means the host it was written on (the ceiling table), so the two reference
+ * readers admit the same members whichever tool opened the fleet.
+ */
+export function fleetCeiling(lines: LedgerLine[], fleet: string, implicit: Ceiling): Ceiling {
+ const caps = findEvents(lines, "fleet.cap", { target: fleet });
+ const opens = findEvents(lines, "fleet.open", { fleet });
+ const line = caps.length ? caps[caps.length - 1] : opens.length ? opens[opens.length - 1] : null;
+ if (!line) return { ...implicit };
+ const ceiling: Ceiling = { ...(line.ceiling ?? {}) };
+ if (ceiling.hosts === undefined && typeof line.host === "string" && line.host !== "") ceiling.hosts = [line.host];
+ return ceiling;
+}
+
+/** True when the ledger holds no `fleet.open` for this fleet: it is the implicit one. */
+export function isImplicitFleet(lines: LedgerLine[], fleet: string): boolean {
+ return !lines.some((line) => line.event === "fleet.open" && line.fleet === fleet);
+}
+
+/** The swarms from the top down to `swarm`, following `parent_swarm` in each `swarm.spawn`. */
+export function swarmChain(lines: LedgerLine[], swarm: string | undefined): string[] {
+ const chain: string[] = [];
+ const seen = new Set();
+ let current = swarm;
+ while (current && !seen.has(current)) {
+ seen.add(current);
+ chain.unshift(current);
+ const spawn = spawnOf(lines, current);
+ current = spawn?.parent_swarm;
+ }
+ return chain;
+}
+
+/**
+ * The effective ceiling at the bottom of a swarm path: `base` merged with each
+ * `swarm.spawn` narrowing on the way down, then the latest `fleet.cap` for
+ * each swarm on the path, applied last so the sysop's word wins over what a
+ * spawner wrote.
+ */
+export function effectiveCeiling(base: Ceiling, lines: LedgerLine[], chain: string[]): Ceiling {
+ let ceiling: Ceiling = { ...base };
+ for (const swarm of chain) {
+ const spawn = spawnOf(lines, swarm);
+ if (spawn?.ceiling) ceiling = mergeCeiling(ceiling, spawn.ceiling);
+ }
+ for (const swarm of chain) {
+ const caps = findEvents(lines, "fleet.cap", { target: swarm });
+ if (caps.length) ceiling = mergeCeiling(ceiling, caps[caps.length - 1].ceiling);
+ }
+ return ceiling;
+}
+
+/** The approvals a root supplies to its subtree in the implicit fleet: its own, or native when orphan. */
+export function rootApprovals(root: { approvals?: Approvals; orphan?: boolean } | null | undefined): Approvals {
+ if (!root) return "native";
+ if (root.orphan) return "native";
+ return root.approvals === "bypass" ? "bypass" : "native";
+}
+
+/** What a start or a spawn wants, checked key by key against what is allowed. */
+export interface Wanted {
+ approvals?: Approvals;
+ depth?: number;
+ fan_out?: number;
+ hosts?: string[];
+ /** For a start: the time it starts. For a spawn: the deadline it asks for. */
+ until?: string;
+}
+
+const CHECK_ORDER: CeilingKey[] = ["approvals", "depth", "fan_out", "hosts", "until"];
+
+/**
+ * The first key `wanted` exceeds in `allowed`, or null when everything is
+ * within the ceiling. Budget is not checked here: it is a sum of spend, not a
+ * property of a start (rule 6).
+ */
+export function checkCeiling(wanted: Wanted, allowed: Ceiling): Refusal | null {
+ for (const key of CHECK_ORDER) {
+ const want = (wanted as Record)[key];
+ if (want === undefined) continue;
+ if (!isNarrower(key, want, allowed[key])) {
+ const shown = allowed[key] ?? (key === "approvals" ? "native" : key === "depth" ? 1 : null);
+ return { key, wanted: want, allowed: shown };
+ }
+ }
+ return null;
+}
+
+export function describeRefusal(refusal: Refusal): string {
+ const show = (value: unknown) => (Array.isArray(value) ? value.join(",") : value === null ? "none" : String(value));
+ return `ceiling refuses ${refusal.key}: wanted ${show(refusal.wanted)}, allowed ${show(refusal.allowed)}`;
+}
+
+const DURATION = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i;
+
+/**
+ * `--until 2h` is a duration from now; `--until 2026-09-13T06:11:01Z` is a
+ * time. Either way the ceiling stores ISO 8601 UTC.
+ */
+export function parseUntil(value: string, now: Date = new Date()): string | null {
+ const trimmed = value.trim();
+ const duration = trimmed.match(DURATION);
+ if (duration) {
+ const n = Number(duration[1]);
+ const unit = duration[2].toLowerCase();
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
+ return new Date(now.getTime() + n * factor).toISOString().replace(/\.\d{3}Z$/, "Z");
+ }
+ const ms = Date.parse(trimmed);
+ if (Number.isNaN(ms)) return null;
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+/** Sum `member.spend` totals per unit. Totals in units that do not parse are skipped. */
+export function sumSpend(totals: Array): Record {
+ const sums: Record = {};
+ for (const total of totals) {
+ const parsed = parseBudget(total);
+ if (!parsed) continue;
+ sums[parsed.unit] = (sums[parsed.unit] ?? 0) + parsed.amount;
+ }
+ return sums;
+}
+
+/** ` / ` in the budget's unit, or the sums alone when there is no budget. */
+export function formatSpend(spend: Record, budget?: string): string {
+ const cap = parseBudget(budget);
+ if (cap) return `${spend[cap.unit] ?? 0}/${cap.amount} ${cap.unit}`;
+ const parts = Object.entries(spend).map(([unit, amount]) => `${amount} ${unit}`);
+ return parts.join(", ");
+}
diff --git a/packages/openfleet/src/commands.test.ts b/packages/openfleet/src/commands.test.ts
new file mode 100644
index 0000000..a83a643
--- /dev/null
+++ b/packages/openfleet/src/commands.test.ts
@@ -0,0 +1,485 @@
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { Command } from "commander";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { EXIT, registerOpenFleetCommands, type Deps } from "./commands.js";
+import { startMember } from "./context.js";
+import { append, findEvents, readCurrent, readLedger, recordPath, writeRecord } from "./store.js";
+import { DEV, FLEET, PIECE_1, PIECE_2, ROOT, cleanup, envFor, seedWorkedExample, seedWorkedExampleToEnd, tempHome } from "./test-helpers.js";
+import type { RosterRow } from "./types.js";
+
+const NOW = new Date("2026-09-13T06:00:00Z");
+
+interface Harness {
+ run: (...argv: string[]) => Promise;
+ out: string[];
+ err: string[];
+ raw: string[];
+ execs: string[][];
+ kills: number[];
+ deps: Deps;
+}
+
+function harness(home: string, extra: Partial = {}, env: Record = {}): Harness {
+ const out: string[] = [];
+ const err: string[] = [];
+ const raw: string[] = [];
+ const execs: string[][] = [];
+ const kills: number[] = [];
+ const deps: Deps = {
+ env: envFor(home, env),
+ now: () => NOW,
+ exec: async (file, args) => {
+ execs.push([file, ...args]);
+ return { code: 0, stdout: "", stderr: "" };
+ },
+ kill: (pid) => {
+ kills.push(pid);
+ },
+ rosters: {},
+ stdin: async () => "",
+ write: (line) => out.push(line),
+ error: (line) => err.push(line),
+ stdout: (text) => raw.push(text),
+ stderr: (text) => raw.push(text),
+ host: "dev",
+ user: "anthony",
+ ...extra,
+ };
+ const run = async (...argv: string[]): Promise => {
+ const program = new Command();
+ program.name("logicsrc").enablePositionalOptions().exitOverride();
+ const fleet = program.command("fleet");
+ registerOpenFleetCommands(fleet, deps);
+ process.exitCode = 0;
+ await program.parseAsync(["node", "logicsrc", "fleet", ...argv]);
+ const code = Number(process.exitCode ?? 0);
+ process.exitCode = 0;
+ return code;
+ };
+ return { run, out, err, raw, execs, kills, deps };
+}
+
+describe("open", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("mints -, writes fleet.open by sysop with the ceiling from flags, writes current, prints the id", async () => {
+ const h = harness(home);
+ expect(await h.run("open", "team", "--approvals", "bypass", "--budget", "20 USD", "--depth", "2", "--fan-out", "4", "--hosts", "dev,netcup", "--until", "2h")).toBe(0);
+ expect(h.out).toEqual(["team-20260913"]);
+ expect(readCurrent(home)).toBe("team-20260913");
+ const lines = readLedger(home, "team-20260913");
+ expect(lines).toEqual([
+ { at: "2026-09-13T06:00:00Z", event: "fleet.open", fleet: "team-20260913", host: "dev", by: "sysop", sysop: "anthony@dev", ceiling: { approvals: "bypass", budget: "20 USD", depth: 2, fan_out: 4, hosts: ["dev", "netcup"], until: "2026-09-13T08:00:00Z" } },
+ ]);
+ // A second fleet the same day gets a suffix; a bare open uses "fleet" and this host.
+ expect(await h.run("open", "team", "--json")).toBe(0);
+ expect(JSON.parse(h.out[1])).toMatchObject({ fleet: "team-20260913-2", sysop: "anthony@dev", ceiling: { hosts: ["dev"] } });
+ expect(await h.run("open", "--sysop", "https://anthony.example/profile.md")).toBe(0);
+ expect(h.out[2]).toBe("fleet-20260913");
+ expect(readLedger(home, "fleet-20260913")[0].sysop).toBe("https://anthony.example/profile.md");
+ });
+
+ it("refuses with exit 4 when the caller carries OPENFLEET_MEMBER, and rejects bad flags with exit 2", async () => {
+ const agent = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
+ expect(await agent.run("open")).toBe(EXIT.REFUSED);
+ expect(agent.err[0]).toMatch(/sysop's.*OPENFLEET_MEMBER=create-two-0541-1/);
+ expect(existsSync(join(home, "fleets"))).toBe(false);
+ const h = harness(home);
+ expect(await h.run("open", "--approvals", "sometimes")).toBe(EXIT.INVALID);
+ expect(await h.run("open", "--budget", "20")).toBe(EXIT.INVALID);
+ expect(await h.run("open", "--depth", "-1")).toBe(EXIT.INVALID);
+ expect(await h.run("open", "--until", "later")).toBe(EXIT.INVALID);
+ expect(existsSync(join(home, "fleets"))).toBe(false);
+ });
+});
+
+describe("cap", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExampleToEnd(home);
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("narrows a running swarm and stops the bypass member now above the native ceiling, through its engine", async () => {
+ const h = harness(home);
+ expect(await h.run("cap", "create-two-0541", "--approvals", "native")).toBe(0);
+ const lines = readLedger(home, FLEET);
+ expect(findEvents(lines, "fleet.cap")[0]).toMatchObject({ by: "sysop", target: "create-two-0541", ceiling: { approvals: "native" } });
+ expect(h.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]);
+ expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
+ expect(h.out[0]).toBe('capped swarm create-two-0541: {"approvals":"native"}');
+ expect(h.out[1]).toBe("stopped create-two-0541-2 moshcode/claude stopped");
+ // The already-done member was left alone, and the root, outside the swarm, was not touched.
+ expect(findEvents(lines, "member.end", { member: "create-two-0541-1" }).length).toBe(1);
+ expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]);
+ });
+
+ it("sets a fleet's whole ceiling, refuses an agent, and says when the target does not exist", async () => {
+ const h = harness(home);
+ expect(await h.run("cap", FLEET, "--approvals", "native", "--json")).toBe(0);
+ const result = JSON.parse(h.out[0]);
+ expect(result).toMatchObject({ target: FLEET, kind: "fleet", ceiling: { approvals: "native", hosts: ["dev"] } });
+ // The root ran with bypass; under a native fleet ceiling it is stopped through claude.
+ expect(h.execs).toContainEqual(["claude", "stop", "460a4502"]);
+ expect(result.stopped.map((row: { member: string }) => row.member)).toContain("460a4502");
+ expect(await h.run("cap", "nope", "--depth", "1")).toBe(EXIT.NOT_FOUND);
+ expect(await h.run("cap", "create-two-0541")).toBe(EXIT.INVALID);
+ const agent = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" });
+ expect(await agent.run("cap", "create-two-0541", "--depth", "1")).toBe(EXIT.REFUSED);
+ });
+
+ it("refuses a cap that would widen a swarm before writing anything: a cap on a swarm only narrows", async () => {
+ const h = harness(home);
+ expect(await h.run("cap", "create-two-0541", "--fan-out", "8")).toBe(EXIT.INVALID);
+ expect(h.err[0]).toBe("cap on swarm create-two-0541 never widens: fan_out 8 is not within 4");
+ expect(await h.run("cap", "create-two-0541", "--depth", "2")).toBe(EXIT.INVALID);
+ expect(await h.run("cap", "create-two-0541", "--until", "2026-09-13T07:00:00Z")).toBe(EXIT.INVALID);
+ expect(await h.run("cap", "create-two-0541", "--hosts", "dev,netcup")).toBe(EXIT.INVALID);
+ expect(findEvents(readLedger(home, FLEET), "fleet.cap")).toEqual([]);
+ expect(h.execs).toEqual([]);
+ // Equal or narrower lands.
+ expect(await h.run("cap", "create-two-0541", "--fan-out", "2", "--until", "2026-09-13T06:05:00Z")).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "fleet.cap")[0]).toMatchObject({ target: "create-two-0541", ceiling: { fan_out: 2, until: "2026-09-13T06:05:00Z" } });
+ });
+});
+
+describe("tree", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExampleToEnd(home);
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("prints the tree, or JSON, and writes member.end lost for a recorded member its engine no longer lists", async () => {
+ const h = harness(home);
+ expect(await h.run("tree")).toBe(0);
+ expect(h.out[0].split("\n")[0]).toBe("anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev)");
+ expect(h.out[0]).toContain("swarm create-two-0541");
+ expect(await h.run("tree", "--json")).toBe(0);
+ expect(JSON.parse(h.out[1]).fleets[0].roots[0].member).toBe("460a4502");
+ expect(await h.run("tree", "nope")).toBe(EXIT.NOT_FOUND);
+
+ const moshcode = async (): Promise => [];
+ const rostered = harness(home, { rosters: { moshcode } });
+ expect(await rostered.run("tree")).toBe(0);
+ expect(rostered.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +lost/);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "lost" });
+ // As an agent, the lost line is by that agent.
+ const asAgent = harness(home, { rosters: { claude: async () => [] } }, { OPENFLEET_MEMBER: "create-two-0541-1" });
+ expect(await asAgent.run("tree")).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "create-two-0541-1", state: "lost" });
+ // --no-roster reads nothing and writes nothing.
+ const quiet = harness(home, { rosters: { claude: async () => { throw new Error("should not be read"); } } });
+ expect(await quiet.run("tree", "--no-roster")).toBe(0);
+ });
+
+ it("never marks an interactive claude session lost: the roster cannot hold it", async () => {
+ const interactive = "68aca9c1-1111-4222-8333-444455556666";
+ append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: interactive, member: interactive, session: interactive, depth: 0, engine: "claude-code", cwd: "/x", approvals: "native" }, { host: "dev" });
+ const h = harness(home, { rosters: { claude: async () => [] } });
+ expect(await h.run("tree")).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: interactive })).toEqual([]);
+ expect(h.out[0]).toMatch(new RegExp(`${interactive} +claude-code +working$`, "m"));
+ // The background job beside it, same roster, is lost.
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "lost" });
+ });
+
+ it("enforces a deadline: a working member past its effective until is stopped through its engine and ends timeout, then its swarm ends", async () => {
+ // The worked example's swarm runs until 06:11:01Z; the clock reads 06:30.
+ const late = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") });
+ expect(await late.run("tree")).toBe(0);
+ expect(late.execs).toEqual([["moshcode", "herd", "kill", "create-two-0541-2"]]);
+ const lines = readLedger(home, FLEET);
+ expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ by: "sysop", state: "timeout", at: "2026-09-13T06:30:00Z" });
+ expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ by: "sysop", state: "timeout" });
+ // The root has no deadline and is left alone.
+ expect(findEvents(lines, "member.end", { member: "460a4502" })).toEqual([]);
+ expect(late.out[0]).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +timeout/);
+ expect(late.out[0]).toMatch(/swarm create-two-0541 +"create two \.\.\." +2\/4 members +timeout/);
+ expect(late.out.slice(1)).toEqual(["stopped create-two-0541-2 moshcode/claude timeout", "ended swarm create-two-0541 timeout"]);
+ // A second run finds everything ended and writes nothing more.
+ const again = harness(home, { now: () => new Date("2026-09-13T06:31:00Z") });
+ expect(await again.run("tree", "--json")).toBe(0);
+ expect(again.execs).toEqual([]);
+ expect(JSON.parse(again.out[0]).enforced).toEqual({ members: [], swarms: [] });
+ expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1);
+ });
+
+ it("leaves enforcement to the sysop: an agent's tree stops nothing", async () => {
+ const agent = harness(home, { now: () => new Date("2026-09-13T06:30:00Z") }, { OPENFLEET_MEMBER: "460a4502" });
+ expect(await agent.run("tree")).toBe(0);
+ expect(agent.execs).toEqual([]);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]);
+ });
+
+ it("enforces a budget: a swarm whose spend has reached its budget has its members stopped with budget, siblings outside it untouched", async () => {
+ const fleet = "team-20260913";
+ append(home, fleet, { at: "2026-09-13T05:00:00Z", event: "fleet.open", by: "sysop", fleet, sysop: "anthony@dev", ceiling: { approvals: "bypass", depth: 2, hosts: ["dev"], budget: "20 USD" } }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:01:00Z", event: "swarm.spawn", by: "sysop", swarm: "hand-0501", task: "by hand", ceiling: { budget: "5 USD" }, pieces: [{ member: "hand-0501-1" }, { member: "hand-0501-2" }] }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "hand-0501-1", member: "hand-0501-1", session: "hand-0501-1", swarm: "hand-0501", depth: 0, engine: "moshcode/codex", approvals: "native" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:02:01Z", event: "member.start", by: "hand-0501-2", member: "hand-0501-2", session: "hand-0501-2", swarm: "hand-0501", depth: 0, engine: "moshcode/kimi", approvals: "native" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:03:00Z", event: "member.start", by: "aaaa0001", member: "aaaa0001", session: "aaaa0001", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:05:00Z", event: "member.spend", by: "hand-0501-1", member: "hand-0501-1", amount: "3 USD", total: "3 USD" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:06:00Z", event: "member.spend", by: "hand-0501-2", member: "hand-0501-2", amount: "2 USD", total: "2 USD" }, { host: "dev" });
+ const h = harness(home);
+ expect(await h.run("tree", fleet)).toBe(0);
+ expect(h.execs).toEqual([
+ ["moshcode", "herd", "kill", "hand-0501-1"],
+ ["moshcode", "herd", "kill", "hand-0501-2"],
+ ]);
+ const lines = readLedger(home, fleet);
+ expect(findEvents(lines, "member.end").map((line) => [line.member, line.state, line.by])).toEqual([
+ ["hand-0501-1", "budget", "sysop"],
+ ["hand-0501-2", "budget", "sysop"],
+ ]);
+ expect(findEvents(lines, "swarm.end", { swarm: "hand-0501" })[0]).toMatchObject({ state: "budget", by: "sysop" });
+ expect(h.out[0]).toMatch(/swarm hand-0501 +"by hand" +2 members +budget {2}5\/5 USD/);
+ expect(h.out[0]).toMatch(/aaaa0001 +claude-code +working$/m);
+ // The fleet's own budget: raise the spend past 20 USD and the root goes too.
+ append(home, fleet, { at: "2026-09-13T06:00:01Z", event: "member.spend", by: "aaaa0001", member: "aaaa0001", amount: "15 USD", total: "15 USD" }, { host: "dev" });
+ const over = harness(home, { now: () => new Date("2026-09-13T06:01:00Z") });
+ expect(await over.run("tree", fleet, "--json")).toBe(0);
+ expect(over.execs).toEqual([["claude", "stop", "aaaa0001"]]);
+ expect(findEvents(readLedger(home, fleet), "member.end", { member: "aaaa0001" })[0]).toMatchObject({ state: "budget" });
+ expect(JSON.parse(over.out[0]).enforced.members).toEqual([{ member: "aaaa0001", engine: "claude-code", stopped: true, state: "budget" }]);
+ });
+});
+
+describe("stop", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExampleToEnd(home);
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("ends a swarm as one unit: nested swarms first, members through their engines, then one swarm.end", async () => {
+ // A nested swarm spawned by the second piece, with a claude-p member and a tmux member.
+ append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "swarm.spawn", by: "create-two-0541-2", swarm: "nested-0550", parent_swarm: "create-two-0541", task: "nested", ceiling: {}, pieces: [{ member: "nested-0550-1" }, { member: "nested-0550-2" }] }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:50:01Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "31337", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "claude-p", approvals: "native" }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-2", member: "nested-0550-2", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" });
+ const h = harness(home);
+ expect(await h.run("stop", "create-two-0541")).toBe(0);
+ expect(h.kills).toEqual([31337]);
+ expect(h.execs).toEqual([
+ ["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"],
+ ["moshcode", "herd", "kill", "create-two-0541-2"],
+ ]);
+ const lines = readLedger(home, FLEET);
+ const events = lines.filter((line) => Date.parse(line.at) >= NOW.getTime()).map((line) => `${line.event} ${line.member ?? line.swarm} ${line.state} ${line.by}`);
+ expect(events).toEqual([
+ "member.end nested-0550-1 stopped sysop",
+ "member.end nested-0550-2 stopped sysop",
+ "swarm.end nested-0550 stopped sysop",
+ "member.end create-two-0541-2 stopped sysop",
+ "swarm.end create-two-0541 stopped sysop",
+ ]);
+ expect(h.out).toEqual([
+ "stopped nested-0550-1 claude-p stopped",
+ "stopped nested-0550-2 tmux stopped",
+ "ended swarm nested-0550 stopped",
+ "skipped create-two-0541-1 claude-code done (already ended)",
+ "stopped create-two-0541-2 moshcode/claude stopped",
+ "ended swarm create-two-0541 stopped",
+ ]);
+ // Stopping again touches nothing: one swarm.end per swarm, no end line for an ended member.
+ const again = harness(home);
+ expect(await again.run("stop", "create-two-0541", "--json")).toBe(0);
+ expect(again.execs).toEqual([]);
+ expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "create-two-0541" }).length).toBe(1);
+ expect(JSON.parse(again.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: false, note: "already ended" }, { swarm: "create-two-0541", state: "stopped", ended: false, note: "already ended" }]);
+ });
+
+ it("stops one member through claude, by the caller, and reports an engine that says no", async () => {
+ const h = harness(home);
+ expect(await h.run("stop", "460a4502")).toBe(0);
+ expect(h.execs).toEqual([["claude", "stop", "460a4502"]]);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "460a4502" })[0]).toMatchObject({ by: "sysop", state: "stopped" });
+ const failing = harness(home, { exec: async () => ({ code: 1, stdout: "", stderr: "no such session" }) });
+ expect(await failing.run("stop", "create-two-0541-2")).toBe(EXIT.NOT_FOUND);
+ expect(failing.out[0]).toContain("no such session");
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })).toEqual([]);
+ expect(await h.run("stop", "nobody")).toBe(EXIT.NOT_FOUND);
+ });
+
+ it("stops a claude-code member by its job id: the member of a background job, else the first eight characters of a session UUID, and says when there is none", async () => {
+ const uuid = "68aca9c1-1111-4222-8333-444455556666";
+ append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "member.start", by: "piece-x", member: "piece-x", session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:51:00Z", event: "member.start", by: uuid, member: uuid, session: uuid, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ const h = harness(home);
+ expect(await h.run("stop", "piece-x")).toBe(0);
+ expect(h.execs).toEqual([["claude", "stop", "68aca9c1"]]);
+ const interactive = harness(home);
+ expect(await interactive.run("stop", uuid, "--json")).toBe(EXIT.NOT_FOUND);
+ expect(interactive.execs).toEqual([]);
+ expect(JSON.parse(interactive.out[0]).members[0]).toMatchObject({ member: uuid, stopped: false, error: expect.stringContaining("claude stop cannot end it") });
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: uuid })).toEqual([]);
+ });
+
+ it("writes no swarm.end while a member's engine would not stop, or a nested swarm has no swarm.end, and exits non-zero", async () => {
+ append(home, FLEET, { at: "2026-09-13T05:50:00Z", event: "swarm.spawn", by: "create-two-0541-2", swarm: "nested-0550", parent_swarm: "create-two-0541", task: "nested", ceiling: {}, pieces: [{ member: "nested-0550-1" }] }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:50:02Z", event: "member.start", by: "nested-0550-1", member: "nested-0550-1", session: "%7", swarm: "nested-0550", parent: "create-two-0541-2", depth: 2, engine: "tmux", approvals: "native" }, { host: "dev" });
+ // tmux says no; moshcode says yes.
+ const h = harness(home, { exec: async (file) => (file === "tmux" ? { code: 1, stdout: "", stderr: "can't find pane: %7" } : { code: 0, stdout: "", stderr: "" }) });
+ expect(await h.run("stop", "create-two-0541")).toBe(EXIT.NOT_FOUND);
+ const lines = readLedger(home, FLEET);
+ expect(findEvents(lines, "member.end", { member: "nested-0550-1" })).toEqual([]);
+ expect(findEvents(lines, "swarm.end", { swarm: "nested-0550" })).toEqual([]);
+ expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })).toEqual([]);
+ expect(findEvents(lines, "member.end", { member: "create-two-0541-2" })[0]).toMatchObject({ state: "stopped" });
+ expect(h.out).toEqual([
+ "skipped nested-0550-1 tmux (tmux kill-pane -t %7 exited 1: can't find pane: %7)",
+ "left swarm nested-0550 open (no end line yet for nested-0550-1)",
+ "skipped create-two-0541-1 claude-code done (already ended)",
+ "stopped create-two-0541-2 moshcode/claude stopped",
+ "left swarm create-two-0541 open (no swarm.end yet for nested nested-0550)",
+ ]);
+ // Once the pane can be ended, the nested swarm and then the target end, each once.
+ const later = harness(home);
+ expect(await later.run("stop", "create-two-0541", "--json")).toBe(0);
+ expect(later.execs).toEqual([["tmux", "-L", "moshcode", "kill-pane", "-t", "%7"]]);
+ expect(JSON.parse(later.out[0]).swarms).toEqual([{ swarm: "nested-0550", state: "stopped", ended: true }, { swarm: "create-two-0541", state: "stopped", ended: true }]);
+ });
+
+ it("lets an agent stop only the swarm it spawned and what sits under it", async () => {
+ const spawner = harness(home, {}, { OPENFLEET_MEMBER: "460a4502" });
+ expect(await spawner.run("stop", "create-two-0541-2")).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-2" })[0].by).toBe("460a4502");
+ const sibling = harness(home, {}, { OPENFLEET_MEMBER: "create-two-0541-1" });
+ expect(await sibling.run("stop", "create-two-0541")).toBe(EXIT.REFUSED);
+ expect(sibling.err[0]).toMatch(/outside the subtree create-two-0541-1 spawned/);
+ expect(await sibling.run("stop", "460a4502")).toBe(EXIT.REFUSED);
+ expect(await sibling.run("stop", FLEET, "--fleet")).toBe(EXIT.REFUSED);
+ expect(sibling.execs).toEqual([]);
+ });
+
+ it("--fleet stops every swarm and every root", async () => {
+ const h = harness(home);
+ expect(await h.run("stop", FLEET, "--fleet")).toBe(0);
+ expect(h.execs).toEqual([
+ ["moshcode", "herd", "kill", "create-two-0541-2"],
+ ["claude", "stop", "460a4502"],
+ ]);
+ const lines = readLedger(home, FLEET);
+ expect(findEvents(lines, "swarm.end", { swarm: "create-two-0541" })[0]).toMatchObject({ state: "stopped" });
+ expect(findEvents(lines, "member.end", { member: "460a4502" })[0]).toMatchObject({ state: "stopped", by: "sysop" });
+ });
+});
+
+describe("log", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExampleToEnd(home);
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("prints one line per event, or JSON Lines, filtered by member, swarm and since", async () => {
+ const h = harness(home);
+ expect(await h.run("log")).toBe(0);
+ expect(h.out.length).toBe(9);
+ expect(h.out[0]).toMatch(/^2026-09-13T04:55:01Z {2}member.start {4}460a4502 +member 460a4502 engine claude-code approvals bypass$/);
+ expect(h.out[8]).toMatch(/^2026-09-13T05:41:36Z {2}member.end {6}create-two-0541-1 +member create-two-0541-1 done Created hello.sh, mode -rwxrwxr-x, prints hello.$/);
+ expect(h.out[5]).toContain('swarm create-two-0541 task create two ... pieces 2 narrowed {"fan_out":4,"until":"2026-09-13T06:11:01Z"}');
+
+ const json = harness(home);
+ expect(await json.run("log", FLEET, "--json", "--member", "create-two-0541-1")).toBe(0);
+ expect(json.out.map((line) => JSON.parse(line).event)).toEqual(["member.start", "member.end"]);
+
+ const swarm = harness(home);
+ expect(await swarm.run("log", "--swarm", "create-two-0541")).toBe(0);
+ expect(swarm.out.map((line) => line.split(/ {2}/)[1].trim())).toEqual(["swarm.spawn", "member.start", "member.start", "member.end"]);
+
+ const since = harness(home);
+ expect(await since.run("log", "--since", "2026-09-13T05:41:30Z")).toBe(0);
+ expect(since.out.length).toBe(1);
+ expect(await since.run("log", "--since", "1h")).toBe(0);
+ expect(since.out.length).toBe(1 + 8);
+ expect(await since.run("log", "--since", "whenever")).toBe(EXIT.INVALID);
+ expect(await since.run("log", "nope")).toBe(EXIT.NOT_FOUND);
+ const empty = harness(tempHome());
+ expect(await empty.run("log")).toBe(0);
+ expect(empty.out).toEqual(["(no events)"]);
+ });
+});
+
+describe("hooks and hook", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => {
+ cleanup(home);
+ process.exitCode = 0;
+ });
+
+ it("installs, reports and removes over --settings-file, never the real one", async () => {
+ const file = join(home, "settings.json");
+ const h = harness(home);
+ expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
+ expect(h.out[0]).toBe(`${file}: not installed`);
+ expect(await h.run("hooks", "install", "--settings-file", file)).toBe(0);
+ expect(h.out[6]).toBe(`wrote 5 hooks in ${file}`);
+ expect(JSON.parse(readFileSync(file, "utf8")).hooks.SessionStart[0].hooks[0].command).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0");
+ expect(await h.run("hooks", "install", "--settings-file", file, "--json")).toBe(0);
+ expect(JSON.parse(h.out[12]).written).toBe(0);
+ expect(await h.run("hooks", "status", "--settings-file", file)).toBe(0);
+ expect(h.out[13]).toBe(`${file}: installed`);
+ expect(await h.run("hooks", "remove", "--settings-file", file)).toBe(0);
+ expect(h.out[19]).toBe(`removed 5 hooks from ${file}`);
+ // With HOME pointed at the temp dir, the default file lands there too.
+ expect(await h.run("hooks", "install", "--dry-run")).toBe(0);
+ expect(h.out[20]).toBe(`would write 5 hooks in ${join(home, ".claude", "settings.json")}`);
+ expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
+ });
+
+ it("runs a hook over stdin and writes its verdict raw", async () => {
+ seedWorkedExample(home);
+ const sessionId = "68aca9c1-1111-4222-8333-444455556666";
+ const start = harness(home, {
+ stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", source: "startup" }),
+ hookIo: { readProcCmdline: () => ["claude"], readProcEnviron: () => ({}), appendFile: () => undefined },
+ }, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1"), CLAUDE_PID: "1" });
+ expect(await start.run("hook", "SessionStart")).toBe(0);
+ expect(start.raw).toEqual(["OpenFleet: you are member create-two-0541-1 of fleet anthony@dev, piece create hello.sh bash, owns hello.sh\n"]);
+ const deny = harness(home, { stdin: async () => JSON.stringify({ session_id: sessionId, cwd: "/x", tool_name: "Write", tool_input: { file_path: "bye.sh" } }) });
+ expect(await deny.run("hook", "PreToolUse")).toBe(0);
+ expect(JSON.parse(deny.raw[0]).hookSpecificOutput.permissionDecision).toBe("deny");
+ // A refused start comes back as exit 2 with the reason on stderr.
+ writeRecord(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
+ startMember(home, { ...ROOT, member: "native-root", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } }, { sessionId: "native-root", approvals: "native", now: NOW, host: "dev", implicit: DEV });
+ const child = "cafe0000-1111-4222-8333-444455556666";
+ const refused = harness(home, {
+ stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", source: "startup" }),
+ hookIo: { readProcCmdline: () => ["claude", "--dangerously-skip-permissions"], readProcEnviron: () => ({}), appendFile: () => undefined },
+ }, { OPENFLEET_RECORD: recordPath(home, FLEET, "native-root"), CLAUDE_PID: "1" });
+ expect(await refused.run("hook", "SessionStart")).toBe(0);
+ expect(refused.raw[0]).toBe("OpenFleet: start refused, ceiling refuses approvals: wanted bypass, allowed native\n");
+ const prompt = harness(home, { stdin: async () => JSON.stringify({ session_id: child, cwd: "/x", permission_mode: "bypassPermissions", prompt: "go" }) });
+ expect(await prompt.run("hook", "UserPromptSubmit")).toBe(2);
+ expect(prompt.raw[0]).toBe("OpenFleet refused the start: ceiling refuses approvals: wanted bypass, allowed native\n");
+ void PIECE_2;
+ });
+});
diff --git a/packages/openfleet/src/commands.ts b/packages/openfleet/src/commands.ts
new file mode 100644
index 0000000..1aa460f
--- /dev/null
+++ b/packages/openfleet/src/commands.ts
@@ -0,0 +1,883 @@
+/**
+ * `logicsrc fleet`: the sysop's five verbs over `$OPENFLEET_HOME`, plus the
+ * Claude Code hook entry points.
+ *
+ * Every dependency that touches the world (environment, clock, processes,
+ * rosters, stdin, stdout) is injectable so the verbs can be tested without a
+ * filesystem outside a temp dir and without a single process. Exit codes are
+ * set on `process.exitCode`, never through `process.exit`, so a caller that
+ * embeds the group keeps control.
+ */
+
+import { existsSync } from "node:fs";
+import type { Command } from "commander";
+import { effectiveCeiling, fleetCeiling, isImplicitFleet, isNarrower, parseBudget, parseUntil, rootApprovals, swarmChain } from "./ceiling.js";
+import { endMember, memberCeiling, rootOf } from "./context.js";
+import { flattenMembers, flattenSwarms, fold, renderTree } from "./fold.js";
+import { hooksStatus, installHooks, removeHooks, settingsFile } from "./hooks-install.js";
+import { realHookIo, runHook, type HookIo } from "./hooks.js";
+import { claudeJobId, defaultRosters, realExec, type Exec } from "./rosters.js";
+import {
+ append,
+ appendOnce,
+ claimedBy,
+ endOf,
+ findEvents,
+ fleetDir,
+ home as homeOf,
+ implicitFleet,
+ isoNow,
+ listFleets,
+ markName,
+ readLedger,
+ readRecords,
+ spawnOf,
+ swarmEndOf,
+ swarmEndState,
+ writeCurrent,
+ type Env,
+ type ImplicitFleet,
+} from "./store.js";
+import { slug } from "./swarm.js";
+import type { Approvals, Ceiling, EndState, FleetRecord, LedgerLine, MemberNode, Rosters, SwarmNode, Tree } from "./types.js";
+import { CEILING_KEYS, OPENFLEET_VERSION } from "./types.js";
+
+
+/** Exit codes: 0 ok, 1 usage, 2 invalid input, 3 not found or an engine that would not end a member, 4 refused (the human-only verbs and stop outside reach). */
+export const EXIT = { OK: 0, USAGE: 1, INVALID: 2, NOT_FOUND: 3, REFUSED: 4 } as const;
+
+export interface Deps {
+ env: Env;
+ now: () => Date;
+ exec: Exec;
+ kill: (pid: number, signal?: NodeJS.Signals) => void;
+ rosters: Rosters;
+ stdin: () => Promise;
+ write: (line: string) => void;
+ error: (line: string) => void;
+ /** Raw writers for the hook entry point, whose stdout is a contract (a deny decision, a context line). */
+ stdout: (text: string) => void;
+ stderr: (text: string) => void;
+ host?: string;
+ user?: string;
+ hookIo?: Partial;
+}
+
+function readStdin(): Promise {
+ return new Promise((resolve) => {
+ if (process.stdin.isTTY) {
+ resolve("");
+ return;
+ }
+ let text = "";
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (chunk) => {
+ text += chunk;
+ });
+ process.stdin.on("end", () => resolve(text));
+ process.stdin.on("error", () => resolve(text));
+ });
+}
+
+export function realDeps(): Deps {
+ return {
+ env: process.env,
+ now: () => new Date(),
+ exec: realExec,
+ kill: (pid, signal) => process.kill(pid, signal ?? "SIGTERM"),
+ rosters: defaultRosters(realExec, process.env),
+ stdin: readStdin,
+ write: (line) => console.log(line),
+ error: (line) => console.error(line),
+ stdout: (text) => process.stdout.write(text),
+ stderr: (text) => process.stderr.write(text),
+ };
+}
+
+class CliError extends Error {
+ constructor(
+ message: string,
+ readonly code: number,
+ ) {
+ super(message);
+ }
+}
+
+function fail(message: string, code: number): never {
+ throw new CliError(message, code);
+}
+
+async function run(deps: Deps, body: () => Promise): Promise {
+ try {
+ await body();
+ } catch (error) {
+ const code = error instanceof CliError ? error.code : EXIT.USAGE;
+ deps.error((error as Error).message);
+ process.exitCode = code;
+ }
+}
+
+interface Ctx {
+ deps: Deps;
+ home: string;
+ implicit: ImplicitFleet;
+ host: string;
+ json: boolean;
+}
+
+function ctxOf(command: Command, deps: Deps, opts: { json?: boolean }): Ctx {
+ const globals = command.optsWithGlobals<{ home?: string }>();
+ const implicit = implicitFleet({ user: deps.user, host: deps.host });
+ return { deps, home: globals.home ?? homeOf(deps.env), implicit, host: deps.host ?? implicit.host, json: opts.json === true };
+}
+
+/** The human-only test (rule 2): a process that carries OPENFLEET_MEMBER is an agent. */
+function sysopOnly(verb: string, deps: Deps): void {
+ const member = deps.env.OPENFLEET_MEMBER;
+ if (member) fail(`${verb} is the sysop's: this process carries OPENFLEET_MEMBER=${member}, so it is an agent`, EXIT.REFUSED);
+}
+
+interface CeilingFlags {
+ approvals?: string;
+ budget?: string;
+ depth?: string;
+ fanOut?: string;
+ hosts?: string;
+ until?: string;
+}
+
+function ceilingFrom(flags: CeilingFlags, now: Date): Ceiling {
+ const ceiling: Ceiling = {};
+ if (flags.approvals !== undefined) {
+ if (flags.approvals !== "native" && flags.approvals !== "bypass") fail(`--approvals must be native or bypass, not ${flags.approvals}`, EXIT.INVALID);
+ ceiling.approvals = flags.approvals;
+ }
+ if (flags.budget !== undefined) {
+ if (!parseBudget(flags.budget)) fail(`--budget must read " ", like "20 USD" or "500000 tokens", not "${flags.budget}"`, EXIT.INVALID);
+ ceiling.budget = flags.budget.trim();
+ }
+ if (flags.depth !== undefined) {
+ const depth = Number(flags.depth);
+ if (!Number.isInteger(depth) || depth < 0) fail(`--depth must be a whole number, not ${flags.depth}`, EXIT.INVALID);
+ ceiling.depth = depth;
+ }
+ if (flags.fanOut !== undefined) {
+ const fanOut = Number(flags.fanOut);
+ if (!Number.isInteger(fanOut) || fanOut < 1) fail(`--fan-out must be a whole number of at least 1, not ${flags.fanOut}`, EXIT.INVALID);
+ ceiling.fan_out = fanOut;
+ }
+ if (flags.hosts !== undefined) {
+ const hosts = flags.hosts.split(",").map((host) => host.trim()).filter((host) => host !== "");
+ if (hosts.length === 0) fail("--hosts needs at least one hostname", EXIT.INVALID);
+ ceiling.hosts = hosts;
+ }
+ if (flags.until !== undefined) {
+ const until = parseUntil(flags.until, now);
+ if (!until) fail(`--until must be a duration like 2h or an ISO 8601 time, not ${flags.until}`, EXIT.INVALID);
+ ceiling.until = until;
+ }
+ return ceiling;
+}
+
+function addCeilingFlags(command: Command): Command {
+ return command
+ .option("--approvals ", "native or bypass: the most a member below may run with")
+ .option("--budget ", 'total spend allowed, "20 USD" or "500000 tokens"')
+ .option("--depth ", "the deepest member allowed (1: roots may spawn, their members may not)")
+ .option("--fan-out ", "the most members one swarm may hold")
+ .option("--hosts ", "hostnames members may run on")
+ .option("--until ", "a duration from now (2h) or an ISO 8601 time after which everything is stopped");
+}
+
+function yyyymmdd(now: Date): string {
+ return now.toISOString().slice(0, 10).replace(/-/g, "");
+}
+
+function emit(ctx: Ctx, data: unknown, text: () => string | string[]): void {
+ if (ctx.json) {
+ ctx.deps.write(JSON.stringify(data, null, 2));
+ return;
+ }
+ const lines = text();
+ for (const line of Array.isArray(lines) ? lines : [lines]) ctx.deps.write(line);
+}
+
+// ---------------------------------------------------------------------------
+// Stopping members through their engines
+// ---------------------------------------------------------------------------
+
+interface StopRow {
+ kind: "member";
+ member: string;
+ engine?: string;
+ stopped: boolean;
+ state?: string;
+ note?: string;
+ /** The engine would not end the member: no end line was written and the verb exits non-zero. */
+ error?: string;
+}
+
+interface SwarmRow {
+ kind: "swarm";
+ swarm: string;
+ state: string;
+ ended: boolean;
+ note?: string;
+}
+
+/** What `stop` and `cap` did, in the order it happened. */
+type Row = StopRow | SwarmRow;
+
+function report(rows: Row[]): { members: Omit[]; swarms: Omit[] } {
+ const members: Omit[] = [];
+ const swarms: Omit[] = [];
+ for (const row of rows) {
+ if (row.kind === "member") {
+ const { kind, ...rest } = row;
+ void kind;
+ members.push(rest);
+ } else {
+ const { kind, ...rest } = row;
+ void kind;
+ swarms.push(rest);
+ }
+ }
+ return { members, swarms };
+}
+
+async function stopThroughEngine(deps: Deps, record: Partial & { member: string }): Promise {
+ const engine = record.engine;
+ const handle = record.session ?? record.member;
+ if (engine === "claude-code") {
+ // `claude stop` takes a job id: the member of a background job, else the first eight characters of a session UUID.
+ const jobId = claudeJobId(record.member, record.session);
+ if (!jobId) return `member ${record.member} is an interactive claude session with no job id: claude stop cannot end it, close the session instead`;
+ const result = await deps.exec("claude", ["stop", jobId]);
+ return result.code === 0 ? null : `claude stop ${jobId} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`;
+ }
+ if (typeof engine === "string" && engine.startsWith("moshcode/")) {
+ const result = await deps.exec("moshcode", ["herd", "kill", handle]);
+ return result.code === 0 ? null : `moshcode herd kill ${handle} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`;
+ }
+ if (engine === "tmux") {
+ const result = await deps.exec("tmux", ["-L", "moshcode", "kill-pane", "-t", handle]);
+ return result.code === 0 ? null : `tmux kill-pane -t ${handle} exited ${result.code}: ${result.stderr.trim()}`;
+ }
+ if (engine === "claude-p") {
+ const pid = Number(record.session);
+ if (!Number.isInteger(pid) || pid <= 0) return `claude-p member ${record.member} has no pid in its record`;
+ try {
+ deps.kill(pid, "SIGTERM");
+ return null;
+ } catch (error) {
+ return `kill ${pid}: ${(error as Error).message}`;
+ }
+ }
+ return engine ? `no way to stop engine ${engine}` : `member ${record.member} names no engine`;
+}
+
+/**
+ * A member as the stop and ceiling paths see it: its record when there is
+ * one, else a record shaped from what its `member.start` said (it may live on
+ * another host, whose ledger was copied in without its record files).
+ */
+function memberFacts(home: string, fleet: string, lines: LedgerLine[], member: string, records?: Map): FleetRecord | null {
+ const record = (records ?? readRecords(home, fleet)).get(member);
+ if (record) return record;
+ const start = claimedBy(lines, member);
+ if (!start) return null;
+ return {
+ openfleet: OPENFLEET_VERSION,
+ fleet,
+ sysop: "",
+ member,
+ ...(start.parent ? { parent: start.parent } : {}),
+ ...(start.swarm ? { swarm: start.swarm } : {}),
+ ...(start.depth !== undefined ? { depth: start.depth } : {}),
+ ...(start.engine ? { engine: start.engine } : {}),
+ ...(start.session ? { session: start.session } : {}),
+ ...(start.host ? { host: start.host } : {}),
+ ...(start.approvals ? { approvals: start.approvals } : {}),
+ };
+}
+
+/**
+ * End one member through its engine, then write its `member.end` with the
+ * state given (`stopped` for the verbs, `timeout` or `budget` for rule 6). An
+ * engine that says no leaves the member without an end line and the row
+ * carries the error, so the verb can exit non-zero.
+ */
+async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, rows: Row[], state: EndState = "stopped"): Promise {
+ const lines = readLedger(ctx.home, fleet);
+ const facts = memberFacts(ctx.home, fleet, lines, member);
+ if (!facts) {
+ rows.push({ kind: "member", member, stopped: false, note: "no record and no member.start" });
+ return;
+ }
+ const end = endOf(lines, member);
+ if (end && end.state !== "lost") {
+ rows.push({ kind: "member", member, engine: facts.engine, stopped: false, state: String(end.state), note: "already ended" });
+ return;
+ }
+ if (!claimedBy(lines, member)) {
+ rows.push({ kind: "member", member, engine: facts.engine, stopped: false, state: "unclaimed", note: "never started" });
+ return;
+ }
+ const problem = await stopThroughEngine(ctx.deps, facts);
+ if (problem) {
+ rows.push({ kind: "member", member, engine: facts.engine, stopped: false, error: problem });
+ return;
+ }
+ const result = endMember(ctx.home, fleet, member, { state, by, now: ctx.deps.now(), host: ctx.host }, facts.swarm);
+ if (!result.ended) {
+ // The engine ended it and wrote its own line between our check and our write; that line counts.
+ rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state: String(result.already?.state ?? state), note: "ended meanwhile" });
+ return;
+ }
+ rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state });
+}
+
+/** An engine that would not end a member fails the verb: the row says why and the exit code says so. */
+function exitOnEngineFailure(rows: Row[]): void {
+ if (rows.some((row) => row.kind === "member" && row.error !== undefined)) process.exitCode = EXIT.NOT_FOUND;
+}
+
+/** Members of a swarm: records and starts that name it, plus pieces its spawn minted. */
+function membersOfSwarm(home: string, fleet: string, lines: LedgerLine[], swarm: string): string[] {
+ const out = new Set();
+ for (const record of readRecords(home, fleet).values()) if (record.swarm === swarm) out.add(record.member);
+ for (const line of findEvents(lines, "member.start", { swarm })) if (typeof line.member === "string") out.add(line.member);
+ for (const piece of spawnOf(lines, swarm)?.pieces ?? []) if (typeof piece?.member === "string") out.add(piece.member);
+ return [...out];
+}
+
+/** Members of a swarm and of every swarm nested under it. */
+function membersUnderSwarm(home: string, fleet: string, lines: LedgerLine[], swarm: string): string[] {
+ const out = new Set(membersOfSwarm(home, fleet, lines, swarm));
+ for (const spawn of findEvents(lines, "swarm.spawn")) {
+ const id = String(spawn.swarm ?? "");
+ if (id === "" || id === swarm) continue;
+ if (swarmAncestry(lines, id).some((line) => line.swarm === swarm)) for (const member of membersOfSwarm(home, fleet, lines, id)) out.add(member);
+ }
+ return [...out];
+}
+
+/**
+ * One `swarm.end` per swarm, written only once every nested swarm has its
+ * own and every started member has an end line that counts (rule 11). A
+ * member whose engine would not stop has none, so its swarm stays open and
+ * the row says why. The line goes through its once-marker.
+ */
+function endSwarmIfComplete(ctx: Ctx, fleet: string, swarm: string, by: string, rows: Row[]): void {
+ const lines = readLedger(ctx.home, fleet);
+ const existing = swarmEndOf(lines, swarm);
+ if (existing) {
+ rows.push({ kind: "swarm", swarm, state: String(existing.state), ended: false, note: "already ended" });
+ return;
+ }
+ const nested = findEvents(lines, "swarm.spawn", { parent_swarm: swarm }).map((line) => String(line.swarm));
+ const openNested = nested.filter((child) => !swarmEndOf(lines, child));
+ if (openNested.length) {
+ rows.push({ kind: "swarm", swarm, state: "open", ended: false, note: `no swarm.end yet for nested ${openNested.join(", ")}` });
+ return;
+ }
+ const members = membersOfSwarm(ctx.home, fleet, lines, swarm);
+ const started = members.filter((member) => claimedBy(lines, member));
+ const ends = started.map((member) => endOf(lines, member));
+ const missing = started.filter((_, index) => ends[index] === null);
+ if (missing.length) {
+ rows.push({ kind: "swarm", swarm, state: "open", ended: false, note: `no end line yet for ${missing.join(", ")}` });
+ return;
+ }
+ const state: EndState = started.length ? (swarmEndState(ends) ?? "stopped") : "stopped";
+ const line = appendOnce(ctx.home, fleet, { event: "swarm.end", by, swarm, state }, { now: ctx.deps.now(), host: ctx.host, once: markName("swarm.end", swarm) });
+ if (!line) {
+ rows.push({ kind: "swarm", swarm, state, ended: false, note: "already ended" });
+ return;
+ }
+ rows.push({ kind: "swarm", swarm, state, ended: true });
+}
+
+/** Rule 11: nested swarms first, then the members through their engines, then one swarm.end. */
+async function stopSwarm(ctx: Ctx, fleet: string, swarm: string, by: string, rows: Row[]): Promise {
+ const lines = readLedger(ctx.home, fleet);
+ const nested = findEvents(lines, "swarm.spawn", { parent_swarm: swarm }).map((line) => String(line.swarm));
+ for (const child of nested) await stopSwarm(ctx, fleet, child, by, rows);
+ for (const member of membersOfSwarm(ctx.home, fleet, lines, swarm)) await stopMember(ctx, fleet, member, by, rows);
+ endSwarmIfComplete(ctx, fleet, swarm, by, rows);
+}
+
+/** The swarms from `swarm` up to the top, by `parent_swarm`. */
+function swarmAncestry(lines: LedgerLine[], swarm: string): LedgerLine[] {
+ const out: LedgerLine[] = [];
+ const seen = new Set();
+ let current: string | undefined = swarm;
+ while (current && !seen.has(current)) {
+ seen.add(current);
+ const spawn = spawnOf(lines, current);
+ if (!spawn) break;
+ out.push(spawn);
+ current = spawn.parent_swarm;
+ }
+ return out;
+}
+
+/** An agent reaches only the swarms it spawned and what sits under them. */
+function withinReach(lines: LedgerLine[], swarm: string | undefined, caller: string): boolean {
+ if (!swarm) return false;
+ return swarmAncestry(lines, swarm).some((spawn) => spawn.by === caller);
+}
+
+function findSwarm(home: string, fleets: string[], swarm: string): string | null {
+ for (const fleet of fleets) if (spawnOf(readLedger(home, fleet), swarm)) return fleet;
+ return null;
+}
+
+function findMember(home: string, fleets: string[], member: string): string | null {
+ for (const fleet of fleets) {
+ if (readRecords(home, fleet).has(member)) return fleet;
+ if (claimedBy(readLedger(home, fleet), member)) return fleet;
+ }
+ return null;
+}
+
+function stopText(rows: Row[]): string[] {
+ return rows.map((row) => {
+ if (row.kind === "member") {
+ const why = row.error ?? row.note;
+ return `${row.stopped ? "stopped" : "skipped"} ${row.member}${row.engine ? ` ${row.engine}` : ""}${row.state ? ` ${row.state}` : ""}${why ? ` (${why})` : ""}`;
+ }
+ return `${row.ended ? "ended" : "left"} swarm ${row.swarm} ${row.state}${row.note ? ` (${row.note})` : ""}`;
+ });
+}
+
+/**
+ * A swarm's effective ceiling now: the fleet's whole ceiling, the spawner's
+ * root approvals entering at the root in the implicit fleet, merged down the
+ * swarm path with the latest caps last. A swarm the sysop started by hand in
+ * the implicit fleet has no one approvals: each member supplies its own.
+ */
+function swarmCeiling(ctx: Ctx, lines: LedgerLine[], fleet: string, swarm: string): Ceiling {
+ const base = fleetCeiling(lines, fleet, ctx.implicit.ceiling);
+ if (base.approvals === undefined && isImplicitFleet(lines, fleet)) {
+ const spawn = spawnOf(lines, swarm);
+ const spawner = spawn && spawn.by !== "sysop" ? memberFacts(ctx.home, fleet, lines, spawn.by) : null;
+ if (spawner) base.approvals = rootApprovals(rootOf(ctx.home, lines, spawner));
+ }
+ return effectiveCeiling(base, lines, swarmChain(lines, swarm));
+}
+
+function showValue(value: unknown): string {
+ return Array.isArray(value) ? value.join(",") : value === undefined || value === null ? "none" : String(value);
+}
+
+/** Has spend in the budget's unit reached the budget? Other units are shown, not summed (open question, 0.1). */
+function overBudget(budget: unknown, spend: Record): boolean {
+ const cap = parseBudget(budget);
+ return cap !== null && (spend[cap.unit] ?? 0) >= cap.amount;
+}
+
+/**
+ * Rule 6, run by the sysop's own `tree`: a working member whose effective
+ * ceiling `until` has passed is stopped through its engine and ends
+ * `timeout`; a fleet or swarm whose summed `member.spend` in the budget's
+ * unit has reached its budget has every working member under it stopped,
+ * each ending `budget`. Then each swarm touched gets its `swarm.end` when it
+ * is complete, nested first. A member the roster already says is gone is left
+ * to the lost pass; a member whose engine says no keeps its row and is tried
+ * again next time. The tree nodes are updated in place so the render shows
+ * what was done.
+ */
+async function enforce(ctx: Ctx, tree: Tree, by: string, rows: Row[]): Promise {
+ const now = ctx.deps.now();
+ const working = flattenMembers(tree).filter(({ member }) => member.state === "working" && !member.roster && member.alive !== false);
+ for (const fleet of tree.fleets) {
+ const lines = readLedger(ctx.home, fleet.fleet);
+ const records = readRecords(ctx.home, fleet.fleet);
+ const mine = working.filter((entry) => entry.fleet === fleet.fleet).map((entry) => entry.member);
+ const swarms = new Map(flattenSwarms(tree).filter((entry) => entry.fleet === fleet.fleet).map((entry) => [entry.swarm.swarm, entry.swarm]));
+ const touched = new Set();
+
+ const stopAs = async (node: MemberNode, state: EndState): Promise => {
+ if (node.state !== "working") return;
+ await stopMember(ctx, fleet.fleet, node.member, by, rows, state);
+ const row = rows[rows.length - 1];
+ if (row.kind === "member" && row.stopped) {
+ node.state = state;
+ node.ended = isoNow(now);
+ }
+ if (node.swarm) touched.add(node.swarm);
+ };
+
+ if (overBudget(fleet.ceiling.budget, fleet.spend)) for (const node of mine) await stopAs(node, "budget");
+ for (const swarm of swarms.values()) {
+ if (!overBudget((swarm.effective ?? swarm.ceiling).budget, swarm.spend)) continue;
+ const under = new Set(membersUnderSwarm(ctx.home, fleet.fleet, lines, swarm.swarm));
+ for (const node of mine) if (under.has(node.member)) await stopAs(node, "budget");
+ touched.add(swarm.swarm);
+ }
+ for (const node of mine) {
+ if (node.state !== "working") continue;
+ const facts = memberFacts(ctx.home, fleet.fleet, lines, node.member, records);
+ if (!facts) continue;
+ const allowed = memberCeiling(ctx.home, lines, facts, ctx.implicit);
+ if (allowed.until !== undefined && !isNarrower("until", isoNow(now), allowed.until)) await stopAs(node, "timeout");
+ }
+
+ // Nested swarms end before the swarms that hold them.
+ const ordered = [...touched].sort((a, b) => swarmAncestry(lines, b).length - swarmAncestry(lines, a).length);
+ for (const swarm of ordered) {
+ endSwarmIfComplete(ctx, fleet.fleet, swarm, by, rows);
+ const row = rows[rows.length - 1];
+ const node = swarms.get(swarm);
+ if (node && row.kind === "swarm" && row.ended) node.state = row.state as EndState;
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// log rendering
+// ---------------------------------------------------------------------------
+
+function short(value: unknown, max = 72): string {
+ const text = typeof value === "string" ? value : JSON.stringify(value);
+ if (text === undefined) return "";
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
+}
+
+function describeLine(line: LedgerLine): string {
+ const c = (ceiling: unknown) => (ceiling && typeof ceiling === "object" ? JSON.stringify(ceiling) : "");
+ switch (line.event) {
+ case "fleet.open":
+ return `fleet ${line.fleet} sysop ${String(line.sysop ?? "")} ceiling ${c(line.ceiling)}`;
+ case "fleet.cap":
+ return `target ${String(line.target ?? "")} ceiling ${c(line.ceiling)}`;
+ case "swarm.spawn":
+ return `swarm ${String(line.swarm ?? "")} task ${short(line.task ?? "", 48)} pieces ${Array.isArray(line.pieces) ? line.pieces.length : 0}${line.ceiling && Object.keys(line.ceiling).length ? ` narrowed ${c(line.ceiling)}` : ""}`;
+ case "member.start":
+ return `member ${String(line.member ?? "")}${line.session && line.session !== line.member ? ` session ${String(line.session)}` : ""}${line.swarm ? ` swarm ${String(line.swarm)}` : ""} engine ${String(line.engine ?? "?")} approvals ${String(line.approvals ?? "native")}`;
+ case "member.spend":
+ return `member ${String(line.member ?? "")} amount ${String(line.amount ?? "")} total ${String(line.total ?? "")}`;
+ case "member.end":
+ return `member ${String(line.member ?? "")} ${String(line.state ?? "")}${line.total ? ` total ${String(line.total)}` : ""}${line.summary ? ` ${short(line.summary, 60)}` : ""}`;
+ case "swarm.end":
+ return `swarm ${String(line.swarm ?? "")} ${String(line.state ?? "")}${line.summary ? ` ${short(line.summary, 60)}` : ""}`;
+ case "ceiling.refuse":
+ return `${line.member ? `member ${String(line.member)} ` : ""}${String(line.action ?? "")} refused on ${String(line.key ?? "")}: wanted ${short(line.wanted)}, allowed ${short(line.allowed)}`;
+ default: {
+ const { at, event, fleet, host, by, ...rest } = line;
+ void at;
+ void event;
+ void fleet;
+ void host;
+ void by;
+ return short(rest);
+ }
+ }
+}
+
+function sinceOf(value: string | undefined, now: Date): number | null {
+ if (!value) return null;
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i);
+ if (match) {
+ const n = Number(match[1]);
+ const unit = match[2].toLowerCase();
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
+ return now.getTime() - n * factor;
+ }
+ const ms = Date.parse(value);
+ return Number.isNaN(ms) ? Number.NaN : ms;
+}
+
+// ---------------------------------------------------------------------------
+// Registration
+// ---------------------------------------------------------------------------
+
+export function registerOpenFleetCommands(cmd: Command, partial: Partial = {}): void {
+ const deps: Deps = { ...realDeps(), ...partial };
+ cmd.option("--home ", "the OpenFleet home (default $OPENFLEET_HOME, else ~/.openfleet)");
+
+ addCeilingFlags(
+ cmd
+ .command("open")
+ .argument("[name]", "a short name for the fleet; the id is -", "fleet")
+ .option("--sysop ", "the human answerable for the fleet: an OpenProfile.md URL, or user@host")
+ .option("--json", "print the fleet as JSON")
+ .description("Open a fleet: mint its id, name the sysop, set the whole ceiling, write fleet.open and current. Sysop only."),
+ ).action(async (name: string, opts: CeilingFlags & { sysop?: string; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ sysopOnly("open", deps);
+ const ctx = ctxOf(command, deps, opts);
+ const now = deps.now();
+ const ceiling = ceilingFrom(opts, now);
+ if (!ceiling.hosts) ceiling.hosts = [ctx.host];
+ const base = `${slug(name, 40)}-${yyyymmdd(now)}`;
+ let fleet = base;
+ for (let n = 2; existsSync(fleetDir(ctx.home, fleet)); n += 1) fleet = `${base}-${n}`;
+ const sysop = opts.sysop ?? ctx.implicit.sysop;
+ const line = append(ctx.home, fleet, { event: "fleet.open", by: "sysop", fleet, sysop, ceiling }, { now, host: ctx.host });
+ writeCurrent(ctx.home, fleet);
+ emit(ctx, { fleet, sysop, ceiling, at: line.at, home: ctx.home }, () => fleet);
+ }),
+ );
+
+ addCeilingFlags(
+ cmd
+ .command("cap")
+ .argument("", "a fleet id, or a running swarm's id")
+ .option("--fleet ", "the fleet a swarm target belongs to, when the id is not unique")
+ .option("--json", "print what was written and what was stopped as JSON")
+ .description("Set a fleet's whole ceiling or narrow a running swarm's; stop whatever is now above it. Sysop only."),
+ ).action(async (target: string, opts: CeilingFlags & { fleet?: string; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ sysopOnly("cap", deps);
+ const ctx = ctxOf(command, deps, opts);
+ const now = deps.now();
+ const ceiling = ceilingFrom(opts, now);
+ const fleets = listFleets(ctx.home);
+ let fleet: string;
+ let kind: "fleet" | "swarm";
+ if (fleets.includes(target) || target === ctx.implicit.id) {
+ fleet = target;
+ kind = "fleet";
+ if (!ceiling.hosts) ceiling.hosts = [ctx.host];
+ } else {
+ const found = opts.fleet ? (spawnOf(readLedger(ctx.home, opts.fleet), target) ? opts.fleet : null) : findSwarm(ctx.home, fleets, target);
+ if (!found) fail(`no fleet or swarm named ${target} under ${ctx.home}`, EXIT.NOT_FOUND);
+ fleet = found;
+ kind = "swarm";
+ if (Object.keys(ceiling).length === 0) fail("cap on a swarm needs at least one key to narrow", EXIT.INVALID);
+ // Rule 3: a cap on a swarm only narrows. A key that would widen is refused before anything is written,
+ // so the two reference readers, one of which honours what the ledger says, never disagree on the swarm.
+ const current = swarmCeiling(ctx, readLedger(ctx.home, fleet), fleet, target);
+ for (const key of CEILING_KEYS) {
+ if (ceiling[key] === undefined || isNarrower(key, ceiling[key], current[key])) continue;
+ const allowed = current[key] ?? (key === "approvals" ? "native" : key === "depth" ? 1 : undefined);
+ fail(`cap on swarm ${target} never widens: ${key} ${showValue(ceiling[key])} is not within ${showValue(allowed)}`, EXIT.INVALID);
+ }
+ }
+ const line = append(ctx.home, fleet, { event: "fleet.cap", by: "sysop", target, ceiling }, { now, host: ctx.host });
+
+ // Members already above the new ceiling are stopped, each with member.end state stopped.
+ const rows: Row[] = [];
+ const lines = readLedger(ctx.home, fleet);
+ const records = readRecords(ctx.home, fleet);
+ const inScope = kind === "fleet" ? [...records.keys()] : membersUnderSwarm(ctx.home, fleet, lines, target);
+ for (const member of new Set(inScope)) {
+ const start = claimedBy(lines, member);
+ if (!start || endOf(lines, member)) continue;
+ const facts = memberFacts(ctx.home, fleet, lines, member, records);
+ if (!facts) continue;
+ const allowed = memberCeiling(ctx.home, lines, facts, ctx.implicit);
+ const approvals: Approvals = (start.approvals ?? facts.approvals) === "bypass" ? "bypass" : "native";
+ const above =
+ !isNarrower("approvals", approvals, allowed.approvals) ||
+ !isNarrower("depth", facts.depth ?? 0, allowed.depth) ||
+ !isNarrower("hosts", [facts.host ?? ctx.host], allowed.hosts) ||
+ !isNarrower("until", isoNow(now), allowed.until);
+ if (above) await stopMember(ctx, fleet, member, "sysop", rows);
+ }
+ exitOnEngineFailure(rows);
+ emit(ctx, { target, kind, fleet, ceiling, at: line.at, stopped: report(rows).members }, () => [
+ `capped ${kind} ${target}: ${JSON.stringify(ceiling)}`,
+ ...stopText(rows),
+ ]);
+ }),
+ );
+
+ cmd
+ .command("tree")
+ .argument("[fleet]", "one fleet; default every fleet under the home")
+ .option("--no-roster", "do not read the engine rosters (claude agents, moshcode herd)")
+ .option("--json", "print the tree as JSON")
+ .description("Render a fleet, or every fleet on this host, as a tree: swarms, members, state, engine, spend and a mark on every bypass member. Run by the sysop, it also stops what is past its deadline or over its budget.")
+ .action(async (fleet: string | undefined, opts: { roster?: boolean; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ if (fleet && !listFleets(ctx.home).includes(fleet) && fleet !== ctx.implicit.id) fail(`no fleet named ${fleet} under ${ctx.home}`, EXIT.NOT_FOUND);
+ const rosters = opts.roster === false ? {} : deps.rosters;
+ const tree: Tree = await fold(ctx.home, rosters, { implicit: ctx.implicit, host: ctx.host, ...(fleet ? { fleet } : {}) });
+ const caller = deps.env.OPENFLEET_MEMBER;
+ const by = caller ?? "sysop";
+ // Rule 6 is the sysop's to enforce: an agent's tree renders and marks, it stops nothing outside its subtree (rule 2).
+ const rows: Row[] = [];
+ if (!caller) await enforce(ctx, tree, by, rows);
+ // A recorded member its engine's roster can hold and no longer lists, with no end line, is lost (verb table, tree).
+ for (const entry of flattenMembers(tree)) {
+ const node = entry.member;
+ if (node.roster || node.state !== "working" || node.alive !== false) continue;
+ const result = endMember(ctx.home, entry.fleet, node.member, { state: "lost", by, now: deps.now(), host: ctx.host }, node.swarm);
+ if (result.ended) {
+ node.state = "lost";
+ node.ended = result.ended.at;
+ }
+ }
+ emit(ctx, { ...tree, enforced: report(rows) }, () => [renderTree(tree, { host: ctx.host }), ...stopText(rows)]);
+ }),
+ );
+
+ cmd
+ .command("stop")
+ .argument("", "a member id, a swarm id, or with --fleet a fleet id")
+ .option("--fleet", "the target is a fleet: stop everything in it")
+ .option("--json", "print what was stopped as JSON")
+ .description("End a member, a swarm, or everything in a fleet as one unit, through each member's own engine. An agent reaches only what it spawned.")
+ .action(async (target: string, opts: { fleet?: boolean; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ const caller = deps.env.OPENFLEET_MEMBER;
+ const by = caller ?? "sysop";
+ const fleets = listFleets(ctx.home);
+ const rows: Row[] = [];
+
+ if (opts.fleet) {
+ if (caller) fail(`stop --fleet is the sysop's: this process carries OPENFLEET_MEMBER=${caller}`, EXIT.REFUSED);
+ if (!fleets.includes(target)) fail(`no fleet named ${target} under ${ctx.home}`, EXIT.NOT_FOUND);
+ const tree = await fold(ctx.home, {}, { implicit: ctx.implicit, host: ctx.host, fleet: target });
+ // Top-level swarms first (each ends its nested ones), then the roots themselves.
+ for (const entry of flattenSwarms(tree)) {
+ if (swarmAncestry(readLedger(ctx.home, target), entry.swarm.swarm).length === 1) await stopSwarm(ctx, target, entry.swarm.swarm, by, rows);
+ }
+ for (const entry of flattenMembers(tree)) if (!entry.swarm) await stopMember(ctx, target, entry.member.member, by, rows);
+ exitOnEngineFailure(rows);
+ emit(ctx, { target, kind: "fleet", ...report(rows) }, () => stopText(rows));
+ return;
+ }
+
+ const swarmFleet = findSwarm(ctx.home, fleets, target);
+ if (swarmFleet) {
+ if (caller && !withinReach(readLedger(ctx.home, swarmFleet), target, caller)) {
+ fail(`stop refuses: swarm ${target} is outside the subtree ${caller} spawned`, EXIT.REFUSED);
+ }
+ await stopSwarm(ctx, swarmFleet, target, by, rows);
+ exitOnEngineFailure(rows);
+ emit(ctx, { target, kind: "swarm", fleet: swarmFleet, ...report(rows) }, () => stopText(rows));
+ return;
+ }
+
+ const memberFleet = findMember(ctx.home, fleets, target);
+ if (!memberFleet) fail(`no swarm or member named ${target} under ${ctx.home}`, EXIT.NOT_FOUND);
+ const lines = readLedger(ctx.home, memberFleet);
+ const facts = memberFacts(ctx.home, memberFleet, lines, target);
+ if (caller && !withinReach(lines, facts?.swarm, caller)) {
+ fail(`stop refuses: member ${target} is outside the subtree ${caller} spawned`, EXIT.REFUSED);
+ }
+ await stopMember(ctx, memberFleet, target, by, rows);
+ if (rows.some((row) => row.kind === "member" && !row.stopped && row.note && row.note !== "already ended")) process.exitCode = EXIT.NOT_FOUND;
+ exitOnEngineFailure(rows);
+ emit(ctx, { target, kind: "member", fleet: memberFleet, ...report(rows) }, () => stopText(rows));
+ }),
+ );
+
+ cmd
+ .command("log")
+ .argument("[fleet]", "one fleet; default every fleet under the home")
+ .option("--since ", "only lines at or after a time, or within a duration (2h)")
+ .option("--member ", "only lines about one member")
+ .option("--swarm ", "only lines about one swarm and its members")
+ .option("--json", "print JSON Lines, one event per line")
+ .description("Read the ledger for a fleet, a swarm or a member: what happened, in order, who did it, what was spent, how each ended, what was refused.")
+ .action(async (fleet: string | undefined, opts: { since?: string; member?: string; swarm?: string; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ const fleets = fleet ? [fleet] : listFleets(ctx.home);
+ if (fleet && !listFleets(ctx.home).includes(fleet)) fail(`no fleet named ${fleet} under ${ctx.home}`, EXIT.NOT_FOUND);
+ const since = sinceOf(opts.since, deps.now());
+ if (since !== null && Number.isNaN(since)) fail(`--since must be a duration like 2h or an ISO 8601 time, not ${opts.since}`, EXIT.INVALID);
+ let lines: LedgerLine[] = [];
+ for (const name of fleets) {
+ const all = readLedger(ctx.home, name);
+ const swarmMembers = opts.swarm ? new Set(membersOfSwarm(ctx.home, name, all, opts.swarm)) : null;
+ lines.push(
+ ...all.filter((line) => {
+ if (since !== null && Date.parse(line.at) < since) return false;
+ if (opts.member && line.member !== opts.member && line.by !== opts.member) return false;
+ if (opts.swarm && line.swarm !== opts.swarm && line.target !== opts.swarm && !(typeof line.member === "string" && swarmMembers?.has(line.member))) return false;
+ return true;
+ }),
+ );
+ }
+ lines = lines.sort((a, b) => a.at.localeCompare(b.at));
+ if (ctx.json) {
+ for (const line of lines) deps.write(JSON.stringify(line));
+ return;
+ }
+ if (lines.length === 0) {
+ deps.write("(no events)");
+ return;
+ }
+ const byWidth = Math.max(...lines.map((line) => line.by.length));
+ for (const line of lines) deps.write(`${line.at} ${line.event.padEnd(14)} ${line.by.padEnd(byWidth)} ${describeLine(line)}`);
+ }),
+ );
+
+ cmd
+ .command("hook")
+ .argument("", "SessionStart, UserPromptSubmit, PreToolUse, Stop or SessionEnd")
+ .description("Run one Claude Code hook over the JSON on stdin. Installed by `hooks install`; never fails the engine.")
+ .action(async (event: string, _opts: unknown, command: Command) => {
+ const globals = command.optsWithGlobals<{ home?: string }>();
+ const env: Env = globals.home ? { ...deps.env, OPENFLEET_HOME: globals.home } : deps.env;
+ const implicit = implicitFleet({ user: deps.user, host: deps.host });
+ const io: HookIo = { ...realHookIo(env), now: deps.now, implicit, host: deps.host ?? implicit.host, ...deps.hookIo, env };
+ let text = "";
+ try {
+ text = await deps.stdin();
+ } catch {
+ text = "";
+ }
+ const result = runHook(event, text, io);
+ if (result.stdout) deps.stdout(result.stdout);
+ if (result.stderr) deps.stderr(result.stderr);
+ process.exitCode = result.exit;
+ });
+
+ const hooks = cmd.command("hooks").description("Install, remove or inspect the Claude Code hooks in ~/.claude/settings.json (merged, never clobbered).");
+ const fileOf = (opts: { settingsFile?: string }) => opts.settingsFile ?? settingsFile(deps.env);
+ hooks
+ .command("install")
+ .option("--settings-file ", "the settings file to merge into (default ~/.claude/settings.json)")
+ .option("--dry-run", "compute the change and write nothing")
+ .option("--json", "print the result as JSON")
+ .description("Add the five OpenFleet hooks, replacing older copies of ours and touching nothing else.")
+ .action(async (opts: { settingsFile?: string; dryRun?: boolean; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ const result = installHooks(fileOf(opts), { dryRun: opts.dryRun });
+ if (!result.ok) fail(result.error ?? "install failed", EXIT.INVALID);
+ emit(ctx, result, () => [
+ `${result.written ? `${opts.dryRun ? "would write" : "wrote"} ${result.written} hook${result.written === 1 ? "" : "s"}` : "already installed"} in ${result.file}`,
+ ...result.changes.map((change) => ` ${change.event.padEnd(17)} ${change.change}`),
+ ]);
+ }),
+ );
+ hooks
+ .command("remove")
+ .option("--settings-file ", "the settings file to edit (default ~/.claude/settings.json)")
+ .option("--dry-run", "compute the change and write nothing")
+ .option("--json", "print the result as JSON")
+ .description("Take the OpenFleet hooks back out, leaving every other hook and setting as it was.")
+ .action(async (opts: { settingsFile?: string; dryRun?: boolean; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ const result = removeHooks(fileOf(opts), { dryRun: opts.dryRun });
+ if (!result.ok) fail(result.error ?? "remove failed", EXIT.INVALID);
+ emit(ctx, result, () => `${opts.dryRun ? "would remove" : "removed"} ${result.removed} hook${result.removed === 1 ? "" : "s"} from ${result.file}`);
+ }),
+ );
+ hooks
+ .command("status")
+ .option("--settings-file ", "the settings file to inspect (default ~/.claude/settings.json)")
+ .option("--json", "print the status as JSON")
+ .description("Say which OpenFleet hooks are installed and whether they carry this version's command text.")
+ .action(async (opts: { settingsFile?: string; json?: boolean }, command: Command) =>
+ run(deps, async () => {
+ const ctx = ctxOf(command, deps, opts);
+ const status = hooksStatus(fileOf(opts));
+ emit(ctx, status, () => [
+ `${status.file}: ${!status.readable ? `unreadable (${status.error})` : status.installed ? "installed" : status.partial ? "partially installed" : "not installed"}`,
+ ...status.events.map((event) => ` ${event.event.padEnd(17)} ${event.installed ? (event.current ? "installed" : "installed, older text") : "missing"}`),
+ ]);
+ }),
+ );
+}
diff --git a/packages/openfleet/src/context.test.ts b/packages/openfleet/src/context.test.ts
new file mode 100644
index 0000000..1bc2d5e
--- /dev/null
+++ b/packages/openfleet/src/context.test.ts
@@ -0,0 +1,465 @@
+import { existsSync } from "node:fs";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { claimOrDerive, context, endMember, memberCeiling, startMember } from "./context.js";
+import { append, claimMark, findEvents, hasMark, markName, readLedger, readRecord, recordPath, writeCurrent, writeRecord } from "./store.js";
+import { DEV, FLEET, PIECE_1, ROOT, cleanup, envFor, seedWorkedExample, tempHome } from "./test-helpers.js";
+import type { FleetRecord } from "./types.js";
+
+const NOW = new Date("2026-09-13T05:41:12Z");
+
+describe("claiming: the worked example's first piece", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExample(home);
+ });
+ afterEach(() => cleanup(home));
+
+ it("claims the unclaimed record OPENFLEET_RECORD names, writing nothing yet", () => {
+ const env = envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") });
+ const before = readLedger(home, FLEET).length;
+ const resolution = claimOrDerive({ home, env, now: NOW, host: "dev", implicit: DEV, session: { id: "172ffd83-d296-485b-a89d-1d7cdd5abbc9", engine: "claude-code", cwd: "/x", approvals: "bypass" } });
+ expect(resolution.kind).toBe("claim");
+ if (resolution.kind !== "claim") throw new Error("unreachable");
+ expect(resolution.record.member).toBe("create-two-0541-1");
+ expect(resolution.session).toBe("172ffd83-d296-485b-a89d-1d7cdd5abbc9");
+ expect(readLedger(home, FLEET).length).toBe(before);
+ });
+
+ it("then member.start is the spec's line: by the member, session the job id, piece as data", () => {
+ const result = startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.refused).toBeNull();
+ expect(result.started).toEqual({
+ at: "2026-09-13T05:41:12Z",
+ event: "member.start",
+ fleet: "anthony@dev",
+ host: "dev",
+ by: "create-two-0541-1",
+ member: "create-two-0541-1",
+ session: "172ffd83",
+ swarm: "create-two-0541",
+ parent: "460a4502",
+ depth: 1,
+ engine: "claude-code",
+ cwd: "/home/anthony/.claude/jobs/460a4502/tmp/swarm-live",
+ approvals: "bypass",
+ piece: { title: "create hello.sh bash", owns: ["hello.sh"] },
+ });
+ const again = startMember(home, PIECE_1, { sessionId: "other", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(again.already?.session).toBe("172ffd83");
+ expect(again.started).toBeNull();
+ expect(findEvents(readLedger(home, FLEET), "member.start", { member: "create-two-0541-1" }).length).toBe(1);
+ });
+
+ it("keeps the record's own session when it has one: the tmux target moshcode wrote", () => {
+ const piece2 = readRecord(recordPath(home, FLEET, "create-two-0541-2"))!;
+ const result = startMember(home, piece2, { sessionId: "a-claude-session-id", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.started?.session).toBe("create-two-0541-2");
+ });
+
+ it("a claimed record makes the next session derive instead of running as that member", () => {
+ startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ const env = envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") });
+ const resolution = claimOrDerive({ home, env, now: NOW, host: "dev", implicit: DEV, session: { id: "child-session", engine: "claude-p", cwd: "/x", pid: 777, approvals: "native" } });
+ // Depth 2 under a ceiling of depth 1: refused, on depth, by the parent.
+ expect(resolution.kind).toBe("refused");
+ if (resolution.kind !== "refused") throw new Error("unreachable");
+ expect(resolution.refusal.key).toBe("depth");
+ expect(resolution.line).toMatchObject({ event: "ceiling.refuse", by: "create-two-0541-1", member: "child-session", action: "start", key: "depth", wanted: 2, allowed: 1 });
+ expect(existsSync(recordPath(home, FLEET, "child-session"))).toBe(false);
+ });
+});
+
+describe("deriving under the root: the planner's swarm of one", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExample(home);
+ });
+ afterEach(() => cleanup(home));
+
+ const rootEnv = (home: string) => envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502") });
+
+ it("mints -, writes swarm.spawn by the parent with the command line as task, then the child record", () => {
+ const resolution = claimOrDerive({
+ home,
+ env: rootEnv(home),
+ now: new Date("2026-09-13T05:40:50Z"),
+ host: "dev",
+ implicit: DEV,
+ session: { id: "plan-session", engine: "claude-p", cwd: "/home/anthony", pid: 4242, command: 'claude -p "Split the task below"', approvals: "bypass" },
+ });
+ expect(resolution.kind).toBe("derive");
+ if (resolution.kind !== "derive") throw new Error("unreachable");
+ // 460a4502-1 already exists in the seed, so the next is -2.
+ expect(resolution.spawned).toMatchObject({ event: "swarm.spawn", by: "460a4502", swarm: "460a4502-2", task: 'claude -p "Split the task below"', ceiling: {}, pieces: [{ member: "plan-session" }] });
+ expect(resolution.spawned).not.toHaveProperty("parent_swarm");
+ expect(resolution.record).toEqual({
+ openfleet: "0.1",
+ fleet: FLEET,
+ sysop: FLEET,
+ member: "plan-session",
+ parent: "460a4502",
+ swarm: "460a4502-2",
+ task: 'claude -p "Split the task below"',
+ depth: 1,
+ engine: "claude-p",
+ session: "4242",
+ host: "dev",
+ cwd: "/home/anthony",
+ started: "2026-09-13T05:40:50Z",
+ approvals: "bypass",
+ ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] },
+ });
+ expect(readRecord(resolution.recordPath)).toEqual(resolution.record);
+ const lines = readLedger(home, FLEET);
+ const spawnAt = lines.findIndex((line) => line.event === "swarm.spawn" && line.swarm === "460a4502-2");
+ expect(spawnAt).toBeGreaterThan(-1);
+ });
+
+ it("joins the swarm OPENFLEET_SWARM names when the parent spawned it, taking its task and narrowing, no piece", () => {
+ const resolution = claimOrDerive({
+ home,
+ env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), OPENFLEET_SWARM: "create-two-0541" }),
+ now: NOW,
+ host: "dev",
+ implicit: DEV,
+ session: { id: "joiner", engine: "claude-p", cwd: "/x", pid: 9, approvals: "bypass" },
+ });
+ expect(resolution.kind).toBe("derive");
+ if (resolution.kind !== "derive") throw new Error("unreachable");
+ expect(resolution.spawned).toBeNull();
+ expect(resolution.record).toMatchObject({ member: "joiner", parent: "460a4502", swarm: "create-two-0541", task: "create two ...", depth: 1 });
+ expect(resolution.record).not.toHaveProperty("piece");
+ expect(resolution.record.ceiling).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"], fan_out: 4, until: "2026-09-13T06:11:01Z" });
+ });
+
+ it("ignores OPENFLEET_SWARM that names a swarm someone else spawned, and derives a swarm of one instead", () => {
+ append(home, FLEET, { event: "swarm.spawn", by: "someone-else", swarm: "theirs-0600", task: "t", ceiling: {}, pieces: [] }, { host: "dev" });
+ const resolution = claimOrDerive({
+ home,
+ env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), OPENFLEET_SWARM: "theirs-0600" }),
+ now: NOW,
+ host: "dev",
+ implicit: DEV,
+ session: { id: "s", engine: "claude-p", cwd: "/x", approvals: "native" },
+ });
+ expect(resolution.kind).toBe("derive");
+ if (resolution.kind !== "derive") throw new Error("unreachable");
+ expect(resolution.record.swarm).toBe("460a4502-2");
+ });
+
+ it("refuses bypass under a native root before writing anything, with by the parent and member the would-be id", () => {
+ writeRecord(home, { ...ROOT, member: "aaaa1111", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
+ append(home, FLEET, { event: "member.start", by: "aaaa1111", member: "aaaa1111", session: "aaaa1111", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ const before = readLedger(home, FLEET).length;
+ const resolution = claimOrDerive({
+ home,
+ env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "aaaa1111") }),
+ now: NOW,
+ host: "dev",
+ implicit: DEV,
+ session: { id: "wants-bypass", engine: "claude-p", cwd: "/x", approvals: "bypass" },
+ });
+ expect(resolution.kind).toBe("refused");
+ if (resolution.kind !== "refused") throw new Error("unreachable");
+ expect(resolution.line).toMatchObject({ event: "ceiling.refuse", by: "aaaa1111", member: "wants-bypass", action: "start", key: "approvals", wanted: "bypass", allowed: "native" });
+ const lines = readLedger(home, FLEET);
+ expect(lines.length).toBe(before + 1);
+ expect(findEvents(lines, "swarm.spawn", { by: "aaaa1111" })).toEqual([]);
+ expect(existsSync(recordPath(home, FLEET, "wants-bypass"))).toBe(false);
+ });
+
+ it("refuses a host the ceiling does not list", () => {
+ const resolution = claimOrDerive({
+ home,
+ env: rootEnv(home),
+ now: NOW,
+ host: "netcup",
+ implicit: DEV,
+ session: { id: "remote", engine: "claude-p", cwd: "/x", approvals: "native" },
+ });
+ expect(resolution.kind).toBe("refused");
+ if (resolution.kind !== "refused") throw new Error("unreachable");
+ expect(resolution.refusal).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] });
+ });
+
+ it("names a derived claude-code background job by its job id and gives it no pid: claude stop takes the job id", () => {
+ const resolution = claimOrDerive({
+ home,
+ env: rootEnv(home),
+ now: NOW,
+ host: "dev",
+ implicit: DEV,
+ session: { id: "abcd1234-0000-4000-8000-000000000009", member: "abcd1234", engine: "claude-code", cwd: "/x", pid: 5150, command: "claude --bg", approvals: "bypass" },
+ });
+ expect(resolution.kind).toBe("derive");
+ if (resolution.kind !== "derive") throw new Error("unreachable");
+ expect(resolution.record.member).toBe("abcd1234");
+ expect(resolution.record).not.toHaveProperty("session");
+ expect(resolution.spawned?.pieces).toEqual([{ member: "abcd1234" }]);
+ });
+
+ it("corrects a derived record's approvals to the engine's word at start, so the record, the check and member.start agree", () => {
+ // SessionStart guessed native from the command line; the permission mode says bypass, which the bypass root allows.
+ const derived = claimOrDerive({ home, env: rootEnv(home), now: NOW, host: "dev", implicit: DEV, session: { id: "guessed", engine: "claude-p", cwd: "/x", pid: 1, approvals: "native" } });
+ if (derived.kind !== "derive") throw new Error("unreachable");
+ expect(derived.record.approvals).toBe("native");
+ const result = startMember(home, derived.record, { sessionId: "guessed", approvals: "bypass", now: NOW, host: "dev", implicit: DEV, derived: true });
+ expect(result.started?.approvals).toBe("bypass");
+ expect(result.record.approvals).toBe("bypass");
+ expect(readRecord(recordPath(home, FLEET, "guessed"))?.approvals).toBe("bypass");
+ // The ceiling still rules: under a native root the same correction is refused, and the record stays unclaimed.
+ writeRecord(home, { ...ROOT, member: "bbbb2222", approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
+ append(home, FLEET, { event: "member.start", by: "bbbb2222", member: "bbbb2222", session: "bbbb2222", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ const under = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "bbbb2222") }), now: NOW, host: "dev", implicit: DEV, session: { id: "guessed-2", engine: "claude-p", cwd: "/x", approvals: "native" } });
+ if (under.kind !== "derive") throw new Error("unreachable");
+ const refused = startMember(home, under.record, { sessionId: "guessed-2", approvals: "bypass", now: NOW, host: "dev", implicit: DEV, derived: true });
+ expect(refused.refused?.refusal.key).toBe("approvals");
+ expect(refused.started).toBeNull();
+ });
+});
+
+describe("root and orphan records", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("writes a root record in the implicit fleet under its own approvals, depth 1, this host", () => {
+ const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "460a4502-full-session-id", member: "460a4502", engine: "claude-code", cwd: "/home/anthony", approvals: "bypass" } });
+ expect(resolution.kind).toBe("root");
+ if (resolution.kind !== "root") throw new Error("unreachable");
+ expect(resolution.existed).toBe(false);
+ expect(resolution.record).toEqual({
+ openfleet: "0.1",
+ fleet: FLEET,
+ sysop: FLEET,
+ member: "460a4502",
+ depth: 0,
+ engine: "claude-code",
+ host: "dev",
+ cwd: "/home/anthony",
+ started: "2026-09-13T05:41:12Z",
+ approvals: "bypass",
+ ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] },
+ });
+ const again = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "460a4502-full-session-id", member: "460a4502", engine: "claude-code", cwd: "/elsewhere", approvals: "native" } });
+ expect(again.kind).toBe("root");
+ if (again.kind !== "root") throw new Error("unreachable");
+ expect(again.existed).toBe(true);
+ expect(again.record.cwd).toBe("/home/anthony");
+ });
+
+ it("a claude -p root carries its pid as session, the handle it is stopped by", () => {
+ const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "p-session", engine: "claude-p", cwd: "/x", pid: 31337, approvals: "native" } });
+ if (resolution.kind !== "root") throw new Error("unreachable");
+ expect(resolution.record.session).toBe("31337");
+ });
+
+ it("starting a root never refuses in the implicit fleet: the root supplies its own approvals", () => {
+ const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "r", engine: "claude-code", cwd: "/x", approvals: "bypass" } });
+ if (resolution.kind !== "root") throw new Error("unreachable");
+ const result = startMember(home, resolution.record, { sessionId: "r", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.started?.approvals).toBe("bypass");
+ });
+
+ it("starts a parentless bypass member whose writer left approvals out of the ceiling: the record's own approvals rule, and the engine fills the key", () => {
+ // The record moshcode writes for a swarm the sysop runs by hand: no parent, depth 0, bypass, a ceiling with no approvals key.
+ append(home, FLEET, { at: "2026-09-13T05:41:00Z", event: "swarm.spawn", by: "sysop", swarm: "hello-0541", task: "say hello", ceiling: { fan_out: 2, until: "2026-09-13T06:11:00Z" }, pieces: [{ member: "hello-0541-1", title: "hello" }] }, { host: "dev" });
+ const record: FleetRecord = {
+ openfleet: "0.1",
+ fleet: FLEET,
+ sysop: FLEET,
+ member: "hello-0541-1",
+ swarm: "hello-0541",
+ task: "say hello",
+ piece: { title: "hello" },
+ depth: 0,
+ engine: "moshcode/claude",
+ session: "hello-0541-1",
+ host: "dev",
+ cwd: "/x",
+ started: "2026-09-13T05:41:00Z",
+ approvals: "bypass",
+ ceiling: { depth: 1, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:11:00Z" },
+ };
+ writeRecord(home, record);
+ // The reader never takes an absent key for native on a parentless record in the implicit fleet.
+ expect(memberCeiling(home, readLedger(home, FLEET), record, DEV).approvals).toBe("bypass");
+ const result = startMember(home, record, { sessionId: "pane", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.refused).toBeNull();
+ expect(result.started).toMatchObject({ member: "hello-0541-1", session: "hello-0541-1", approvals: "bypass", depth: 0, swarm: "hello-0541" });
+ expect(result.record.ceiling).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:11:00Z" });
+ expect(readRecord(recordPath(home, FLEET, "hello-0541-1"))?.ceiling?.approvals).toBe("bypass");
+ // A root with no ceiling at all gets the implicit root ceiling written whole.
+ writeRecord(home, { openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "thin-root", approvals: "bypass" });
+ const thin = startMember(home, { openfleet: "0.1", fleet: FLEET, sysop: FLEET, member: "thin-root", approvals: "bypass" }, { sessionId: "thin-root", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(thin.started?.approvals).toBe("bypass");
+ expect(readRecord(recordPath(home, FLEET, "thin-root"))?.ceiling).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"] });
+ });
+
+ it("an orphan root gets ceiling approvals native and is refused when it runs with bypass, by its own member", () => {
+ const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "orphan-1", engine: "claude-p", cwd: "/x", approvals: "bypass", orphan: true } });
+ if (resolution.kind !== "root") throw new Error("unreachable");
+ expect(resolution.record.orphan).toBe(true);
+ expect(resolution.record.ceiling).toEqual({ approvals: "native", depth: 1, hosts: ["dev"] });
+ const result = startMember(home, resolution.record, { sessionId: "orphan-1", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.started).toBeNull();
+ expect(result.refused?.line).toMatchObject({ event: "ceiling.refuse", by: "orphan-1", member: "orphan-1", action: "start", key: "approvals", wanted: "bypass", allowed: "native" });
+ const native = startMember(home, resolution.record, { sessionId: "orphan-1", approvals: "native", now: NOW, host: "dev", implicit: DEV });
+ expect(native.started?.approvals).toBe("native");
+ });
+
+ it("an opened fleet named by current gives a root the fleet's whole ceiling; bypass under a native fleet is refused with by sysop", () => {
+ append(home, "team-20260913", { event: "fleet.open", by: "sysop", fleet: "team-20260913", sysop: "https://anthony.example/profile.md", ceiling: { depth: 2, hosts: ["dev"] } }, { host: "dev" });
+ writeCurrent(home, "team-20260913");
+ const resolution = claimOrDerive({ home, env: envFor(home), now: NOW, host: "dev", implicit: DEV, session: { id: "r2", engine: "claude-code", cwd: "/x", approvals: "bypass" } });
+ if (resolution.kind !== "root") throw new Error("unreachable");
+ expect(resolution.record).toMatchObject({ fleet: "team-20260913", sysop: "https://anthony.example/profile.md", ceiling: { depth: 2, hosts: ["dev"] } });
+ const result = startMember(home, resolution.record, { sessionId: "r2", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(result.refused?.line).toMatchObject({ by: "sysop", key: "approvals", wanted: "bypass", allowed: "native" });
+ // OPENFLEET_FLEET in the sysop's shell overrides current.
+ const other = claimOrDerive({ home, env: envFor(home, { OPENFLEET_FLEET: "elsewhere" }), now: NOW, host: "dev", implicit: DEV, session: { id: "r3", engine: "claude-code", cwd: "/x", approvals: "native" } });
+ if (other.kind !== "root") throw new Error("unreachable");
+ expect(other.record.fleet).toBe("elsewhere");
+ });
+});
+
+describe("ending", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExample(home);
+ });
+ afterEach(() => cleanup(home));
+
+ it("writes one member.end that counts, and a swarm.end only for a swarm of one", () => {
+ startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ const first = endMember(home, FLEET, "create-two-0541-1", { state: "done", by: "create-two-0541-1", summary: "Created hello.sh", now: new Date("2026-09-13T05:41:36Z"), host: "dev" }, "create-two-0541");
+ expect(first.ended).toMatchObject({ event: "member.end", by: "create-two-0541-1", member: "create-two-0541-1", state: "done", summary: "Created hello.sh" });
+ expect(first.swarmEnded).toBeNull();
+ const second = endMember(home, FLEET, "create-two-0541-1", { state: "stopped", by: "sysop" }, "create-two-0541");
+ expect(second.ended).toBeNull();
+ expect(second.already?.state).toBe("done");
+ });
+
+ it("leaves a spawner's one-piece swarm for the spawner to end", () => {
+ // A swarm moshcode wrote with a single piece is not a swarm of one the
+ // engine minted: its id is not -. The member ends itself only.
+ append(home, FLEET, { event: "swarm.spawn", by: "460a4502", swarm: "gate-two-1030", task: "gate", ceiling: {}, pieces: [{ member: "gate-two-1030-1", title: "one" }] }, { now: NOW, host: "dev" });
+ writeRecord(home, { ...PIECE_1, member: "gate-two-1030-1", swarm: "gate-two-1030", piece: { title: "one" } });
+ startMember(home, { ...PIECE_1, member: "gate-two-1030-1", swarm: "gate-two-1030", piece: { title: "one" } }, { sessionId: "s1", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ const ended = endMember(home, FLEET, "gate-two-1030-1", { state: "done", by: "gate-two-1030-1", now: NOW, host: "dev" }, "gate-two-1030");
+ expect(ended.ended?.state).toBe("done");
+ expect(ended.swarmEnded).toBeNull();
+ expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: "gate-two-1030" }).length).toBe(0);
+ });
+
+ it("writes nothing when another writer holds the once-marker: member.start, member.end, swarm.end", () => {
+ // The spawner took the member.start marker a moment ago; its line is not in the ledger yet.
+ claimMark(home, FLEET, markName("member.start", "create-two-0541-1"));
+ const before = readLedger(home, FLEET).length;
+ const start = startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(start).toMatchObject({ started: null, refused: null, already: null });
+ expect(readLedger(home, FLEET).length).toBe(before);
+ // Once its line lands, the ledger check answers first.
+ append(home, FLEET, { event: "member.start", by: "460a4502", member: "create-two-0541-1", session: "172ffd83", depth: 1, engine: "claude-code", approvals: "bypass" }, { now: NOW, host: "dev" });
+ expect(startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV }).already?.by).toBe("460a4502");
+ // The same for the end: a held plain marker means a real end is in flight, so neither a second real end nor a lost one is written.
+ claimMark(home, FLEET, markName("member.end", "create-two-0541-1"));
+ expect(endMember(home, FLEET, "create-two-0541-1", { state: "done", by: "create-two-0541-1", now: NOW, host: "dev" }, "create-two-0541").ended).toBeNull();
+ expect(endMember(home, FLEET, "create-two-0541-1", { state: "lost", by: "sysop", now: NOW, host: "dev" }, "create-two-0541").ended).toBeNull();
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: "create-two-0541-1" })).toEqual([]);
+ // A lost end takes its own marker, so a real end after it still lands and takes the plain one.
+ startMember(home, { ...PIECE_1, member: "create-two-0541-2", session: "create-two-0541-2" }, { sessionId: "s2", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ expect(endMember(home, FLEET, "create-two-0541-2", { state: "lost", by: "sysop", now: NOW, host: "dev" }, "create-two-0541").ended?.state).toBe("lost");
+ expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2", true))).toBe(true);
+ expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2"))).toBe(false);
+ expect(endMember(home, FLEET, "create-two-0541-2", { state: "done", by: "create-two-0541-2", now: NOW, host: "dev" }, "create-two-0541").ended?.state).toBe("done");
+ expect(hasMark(home, FLEET, markName("member.end", "create-two-0541-2"))).toBe(true);
+ // A swarm.end marker held elsewhere keeps a derived swarm of one from ending twice.
+ const derived = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502") }), now: NOW, host: "dev", implicit: DEV, session: { id: "p9", engine: "claude-p", cwd: "/x", approvals: "native" } });
+ if (derived.kind !== "derive") throw new Error("unreachable");
+ startMember(home, derived.record, { sessionId: "p9", approvals: "native", now: NOW, host: "dev", implicit: DEV });
+ claimMark(home, FLEET, markName("swarm.end", derived.record.swarm!));
+ const ended = endMember(home, FLEET, "p9", { state: "done", by: "p9", now: NOW, host: "dev" }, derived.record.swarm);
+ expect(ended.ended?.state).toBe("done");
+ expect(ended.swarmEnded).toBeNull();
+ });
+
+ it("lets a real end supersede lost, and ends a derived swarm of one with the member", () => {
+ const derived = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502") }), now: NOW, host: "dev", implicit: DEV, session: { id: "p2", engine: "claude-p", cwd: "/x", approvals: "native" } });
+ if (derived.kind !== "derive") throw new Error("unreachable");
+ startMember(home, derived.record, { sessionId: "p2", approvals: "native", now: NOW, host: "dev", implicit: DEV });
+ const lost = endMember(home, FLEET, "p2", { state: "lost", by: "sysop", now: NOW, host: "dev" }, derived.record.swarm);
+ expect(lost.ended?.state).toBe("lost");
+ expect(lost.swarmEnded?.state).toBe("lost");
+ const real = endMember(home, FLEET, "p2", { state: "done", by: "p2", summary: "plan", now: NOW, host: "dev" }, derived.record.swarm);
+ expect(real.ended?.state).toBe("done");
+ // One swarm.end per swarm: the lost one stands.
+ expect(real.swarmEnded).toBeNull();
+ expect(findEvents(readLedger(home, FLEET), "swarm.end", { swarm: derived.record.swarm }).length).toBe(1);
+ });
+});
+
+describe("context and the effective ceiling", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExample(home);
+ });
+ afterEach(() => cleanup(home));
+
+ it("reads the own record first, then OPENFLEET_FLEET, then current, then the implicit fleet", () => {
+ const own = context(envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1"), OPENFLEET_MEMBER: "create-two-0541-1", OPENFLEET_SWARM: "create-two-0541" }), { implicit: DEV });
+ expect(own).toMatchObject({ home, fleet: FLEET, sysop: FLEET, implicit: true, member: "create-two-0541-1", swarm: "create-two-0541", host: "dev" });
+ expect(own.record?.member).toBe("create-two-0541-1");
+ expect(own.ceiling).toEqual({ approvals: "bypass", depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" });
+
+ expect(context(envFor(home, { OPENFLEET_FLEET: "named" }), { implicit: DEV })).toMatchObject({ fleet: "named", member: null, record: null });
+ writeCurrent(home, "from-current");
+ expect(context(envFor(home), { implicit: DEV }).fleet).toBe("from-current");
+ cleanup(home);
+ home = tempHome();
+ expect(context(envFor(home), { implicit: DEV })).toMatchObject({ fleet: FLEET, sysop: FLEET, implicit: true, ceiling: { depth: 1, hosts: ["dev"] } });
+ });
+
+ it("lets the latest fleet.cap for a swarm win over the ceiling copied into a record", () => {
+ const lines = readLedger(home, FLEET);
+ expect(memberCeiling(home, lines, PIECE_1, DEV)).toEqual(PIECE_1.ceiling);
+ append(home, FLEET, { event: "fleet.cap", by: "sysop", target: "create-two-0541", ceiling: { approvals: "native", until: "2026-09-13T06:00:00Z" } }, { host: "dev" });
+ expect(memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV)).toEqual({ ...PIECE_1.ceiling, approvals: "native", until: "2026-09-13T06:00:00Z" });
+ });
+
+ it("lets a fleet-target cap widen past the ceiling copied into a record: the copy is a snapshot, not an input", () => {
+ // The sysop raises the implicit fleet to depth 2 so its members may spawn.
+ append(home, FLEET, { event: "fleet.cap", by: "sysop", target: FLEET, ceiling: { depth: 2, hosts: ["dev", "netcup"] } }, { host: "dev" });
+ const allowed = memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV);
+ // Root approvals still enter at the root when the cap names none (the implicit fleet has no fleet-level approvals).
+ expect(allowed).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev", "netcup"], fan_out: 4, until: "2026-09-13T06:11:01Z" });
+ // A claude -p under the claimed piece is now depth 2, within the raised ceiling: derived, not refused.
+ startMember(home, PIECE_1, { sessionId: "172ffd83", approvals: "bypass", now: NOW, host: "dev", implicit: DEV });
+ const child = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") }), now: NOW, host: "netcup", implicit: DEV, session: { id: "grandchild", engine: "claude-p", cwd: "/x", pid: 7, approvals: "bypass" } });
+ expect(child.kind).toBe("derive");
+ if (child.kind !== "derive") throw new Error("unreachable");
+ expect(child.record).toMatchObject({ depth: 2, host: "netcup", ceiling: { depth: 2, approvals: "bypass" } });
+ // A cap on the implicit fleet that names approvals applies to every root's subtree.
+ append(home, FLEET, { event: "fleet.cap", by: "sysop", target: FLEET, ceiling: { approvals: "native", depth: 2, hosts: ["dev"] } }, { host: "dev" });
+ expect(memberCeiling(home, readLedger(home, FLEET), PIECE_1, DEV).approvals).toBe("native");
+ // And the ceiling of the swarm the derive joined includes that swarm's own cap, applied last.
+ append(home, FLEET, { event: "fleet.cap", by: "sysop", target: "create-two-0541", ceiling: { fan_out: 1 } }, { host: "dev" });
+ const joined = claimOrDerive({ home, env: envFor(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), OPENFLEET_SWARM: "create-two-0541" }), now: NOW, host: "dev", implicit: DEV, session: { id: "joiner-2", engine: "claude-p", cwd: "/x", pid: 8, approvals: "native" } });
+ if (joined.kind !== "derive") throw new Error("unreachable");
+ expect(joined.record.ceiling).toEqual({ approvals: "native", depth: 2, hosts: ["dev"], fan_out: 1, until: "2026-09-13T06:11:01Z" });
+ });
+
+ it("computes a ceiling for a record that carries none: the fleet's, the root's approvals, the swarm path", () => {
+ const { ceiling: _dropped, ...thin } = PIECE_1;
+ void _dropped;
+ expect(memberCeiling(home, readLedger(home, FLEET), thin, DEV)).toEqual({ approvals: "bypass", depth: 1, hosts: ["dev"], fan_out: 4, until: "2026-09-13T06:11:01Z" });
+ // Whose root cannot be found reads as native.
+ expect(memberCeiling(home, readLedger(home, FLEET), { ...thin, parent: "nobody" }, DEV).approvals).toBe("native");
+ });
+});
diff --git a/packages/openfleet/src/context.ts b/packages/openfleet/src/context.ts
new file mode 100644
index 0000000..4625989
--- /dev/null
+++ b/packages/openfleet/src/context.ts
@@ -0,0 +1,470 @@
+/**
+ * Where a process sits, and how a session claims or derives its record.
+ *
+ * `context` answers "which fleet, which member" from the environment the way
+ * the spec orders it: the record `OPENFLEET_RECORD` names, else the fleet
+ * `OPENFLEET_FLEET` names, else `$OPENFLEET_HOME/current`, else the implicit
+ * fleet. `claimOrDerive` is the engine-side rule from "Claiming and deriving"
+ * and rule 13, written once so the Claude Code hooks and any other starter
+ * make the same decision from the same ledger.
+ */
+
+import { checkCeiling, effectiveCeiling, fleetCeiling, isImplicitFleet, rootApprovals, swarmChain } from "./ceiling.js";
+import { nextSwarmOfOne } from "./swarm.js";
+import {
+ append,
+ appendOnce,
+ claimedBy,
+ endOf,
+ findEvents,
+ hasMark,
+ home as homeOf,
+ implicitFleet,
+ isoNow,
+ markName,
+ readCurrent,
+ readLedger,
+ readRecord,
+ recordPath,
+ replaceUnclaimedRecord,
+ spawnOf,
+ swarmEndOf,
+ writeRecord,
+ type Env,
+ type ImplicitFleet,
+} from "./store.js";
+import type { Approvals, Ceiling, EndState, FleetRecord, LedgerInput, LedgerLine, Refusal } from "./types.js";
+import { OPENFLEET_VERSION } from "./types.js";
+
+export interface Context {
+ home: string;
+ fleet: string;
+ sysop: string;
+ /** No `fleet.open` in the ledger: the fleet is the implicit `@`. */
+ implicit: boolean;
+ record: FleetRecord | null;
+ recordPath: string | null;
+ /** `OPENFLEET_MEMBER`, else the record's member. Present means this process is an agent. */
+ member: string | null;
+ swarm: string | null;
+ host: string;
+ ceiling: Ceiling;
+}
+
+export interface ContextOptions {
+ implicit?: ImplicitFleet;
+ host?: string;
+}
+
+/** The sysop an opened fleet's `fleet.open` names, or null for the implicit fleet. */
+export function fleetSysop(lines: LedgerLine[], fleet: string): string | null {
+ const opens = findEvents(lines, "fleet.open", { fleet });
+ const sysop = opens.length ? opens[opens.length - 1].sysop : undefined;
+ return typeof sysop === "string" ? sysop : null;
+}
+
+export function context(env: Env = process.env, opts: ContextOptions = {}): Context {
+ const implicit = opts.implicit ?? implicitFleet();
+ const host = opts.host ?? implicit.host;
+ const home = homeOf(env);
+ let record: FleetRecord | null = null;
+ let path: string | null = null;
+ if (env.OPENFLEET_RECORD) {
+ record = readRecord(env.OPENFLEET_RECORD);
+ if (record) path = env.OPENFLEET_RECORD;
+ }
+ const fleet = record?.fleet ?? env.OPENFLEET_FLEET ?? readCurrent(home) ?? implicit.id;
+ const lines = readLedger(home, fleet);
+ const implicitHere = isImplicitFleet(lines, fleet);
+ const sysop = record?.sysop ?? fleetSysop(lines, fleet) ?? implicit.sysop;
+ const member = env.OPENFLEET_MEMBER ?? record?.member ?? null;
+ const swarm = env.OPENFLEET_SWARM ?? record?.swarm ?? null;
+ const ceiling = record ? memberCeiling(home, lines, record, implicit) : fleetCeiling(lines, fleet, implicit.ceiling);
+ return { home, fleet, sysop, implicit: implicitHere, record, recordPath: path, member, swarm, host, ceiling };
+}
+
+/**
+ * The root above a record, following `parent` through record files, then
+ * `member.start` lines when a record is missing (it may live on another host).
+ * Null when the chain breaks, which reads as approvals native.
+ */
+export function rootOf(homeDir: string, lines: LedgerLine[], record: FleetRecord): { approvals?: Approvals; orphan?: boolean; member: string } | null {
+ let current: { approvals?: Approvals; orphan?: boolean; member: string; parent?: string } = record;
+ const seen = new Set([record.member]);
+ while (current.parent) {
+ const parent = current.parent;
+ if (seen.has(parent)) return null;
+ seen.add(parent);
+ const file = readRecord(recordPath(homeDir, record.fleet, parent));
+ if (file) {
+ current = file;
+ continue;
+ }
+ const start = claimedBy(lines, parent);
+ if (!start) return null;
+ current = { member: parent, approvals: start.approvals, parent: start.parent };
+ }
+ return current;
+}
+
+/**
+ * The effective ceiling a member is under now, rebuilt from the ledger every
+ * time. The record's own `ceiling` is a snapshot of what it was started
+ * under and never an input here: the latest `fleet.cap` for its fleet wins
+ * over that copy (rule 7), widening included, so the sysop can raise a fleet
+ * and have its running members read the new ceiling. The order is the spec's:
+ * the fleet's whole ceiling (latest fleet-target cap, else `fleet.open`, else
+ * the implicit fleet's), with the root's own approvals entering at the root
+ * in the implicit fleet when no cap names one, then each `swarm.spawn`
+ * narrowing down the member's path, then the latest cap on any swarm on that
+ * path, applied last. A merge never widens.
+ */
+export function memberCeiling(homeDir: string, lines: LedgerLine[], record: FleetRecord, implicit: ImplicitFleet): Ceiling {
+ const base = fleetCeiling(lines, record.fleet, implicit.ceiling);
+ if (base.approvals === undefined && isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record));
+ return effectiveCeiling(base, lines, swarmChain(lines, record.swarm));
+}
+
+// ---------------------------------------------------------------------------
+// Claiming and deriving
+// ---------------------------------------------------------------------------
+
+export interface SessionFacts {
+ /** The engine's own id for this session. */
+ id: string;
+ engine: string;
+ cwd: string;
+ /** The process id, the handle a `claude -p` is stopped by. */
+ pid?: number;
+ /** The command line the session was started with; the task of a swarm of one. */
+ command?: string;
+ approvals: Approvals;
+ /** The member id a root record uses when it differs from `id`: a Claude Code job id. */
+ member?: string;
+ /** For a root: the engine's child marker was present in the invoking environment (rule 13). */
+ orphan?: boolean;
+}
+
+export interface StartOptions {
+ home: string;
+ env: Env;
+ now?: Date;
+ host?: string;
+ implicit?: ImplicitFleet;
+ session: SessionFacts;
+}
+
+export type Resolution =
+ | { kind: "claim"; record: FleetRecord; recordPath: string; session: string }
+ | { kind: "derive"; record: FleetRecord; recordPath: string; parent: FleetRecord; spawned: LedgerLine | null }
+ | { kind: "root"; record: FleetRecord; recordPath: string; existed: boolean }
+ | { kind: "refused"; refusal: Refusal; line: LedgerLine; parent: FleetRecord; member: string };
+
+/**
+ * Resolve the record a starting session runs as.
+ *
+ * - `OPENFLEET_RECORD` names an unclaimed record: claim it (the caller writes
+ * `member.start` once it knows the approvals for certain).
+ * - It names a claimed record: derive a child record, joining the swarm
+ * `OPENFLEET_SWARM` names when its `swarm.spawn` was written by the claimed
+ * member, else a swarm of one. The merged ceiling is checked before anything
+ * is written; a refusal writes `ceiling.refuse` and nothing else.
+ * - It is unset: write a root record, `orphan: true` when the session's
+ * invoking environment carried a child marker.
+ */
+export function claimOrDerive(opts: StartOptions): Resolution {
+ const now = opts.now ?? new Date();
+ const implicit = opts.implicit ?? implicitFleet();
+ const host = opts.host ?? implicit.host;
+ const { home, env, session } = opts;
+
+ const inherited = env.OPENFLEET_RECORD ? readRecord(env.OPENFLEET_RECORD) : null;
+ if (inherited && env.OPENFLEET_RECORD) {
+ const lines = readLedger(home, inherited.fleet);
+ if (!claimedBy(lines, inherited.member)) {
+ return { kind: "claim", record: inherited, recordPath: env.OPENFLEET_RECORD, session: inherited.session ?? session.id };
+ }
+ return derive(inherited, lines, { home, env, now, host, implicit, session });
+ }
+
+ return root({ home, env, now, host, implicit, session });
+}
+
+function derive(
+ parent: FleetRecord,
+ lines: LedgerLine[],
+ opts: { home: string; env: Env; now: Date; host: string; implicit: ImplicitFleet; session: SessionFacts },
+): Resolution {
+ const { home, env, now, host, implicit, session } = opts;
+ // A background job's member is its job id, as for a root; otherwise the engine's session id.
+ const member = session.member ?? session.id;
+ const depth = (parent.depth ?? 0) + 1;
+
+ // The swarm the child joins, when the parent spawned the one the environment names.
+ let swarm: string;
+ let task: string | undefined;
+ let spawnToWrite: LedgerInput | null = null;
+ const named = env.OPENFLEET_SWARM;
+ const namedSpawn = named && named !== parent.swarm ? spawnOf(lines, named) : null;
+ if (named && namedSpawn && namedSpawn.by === parent.member) {
+ swarm = named;
+ task = namedSpawn.task;
+ } else {
+ const existing = findEvents(lines, "swarm.spawn").map((line) => String(line.swarm ?? ""));
+ swarm = nextSwarmOfOne(parent.member, existing);
+ task = session.command ?? session.engine;
+ spawnToWrite = {
+ event: "swarm.spawn",
+ by: parent.member,
+ swarm,
+ ...(parent.swarm ? { parent_swarm: parent.swarm } : {}),
+ task,
+ ceiling: {},
+ pieces: [{ member }],
+ };
+ }
+
+ // The parent's effective ceiling, then the joined swarm's own narrowing and
+ // any cap on it. A swarm of one has no `swarm.spawn` yet and narrows nothing.
+ const ceiling = effectiveCeiling(memberCeiling(home, lines, parent, implicit), lines, [swarm]);
+ const refusal = checkCeiling({ approvals: session.approvals, depth, hosts: [host], until: isoNow(now) }, ceiling);
+ if (refusal) {
+ const line = append(
+ home,
+ parent.fleet,
+ { event: "ceiling.refuse", by: parent.member, member, action: "start", key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed },
+ { now, host },
+ );
+ return { kind: "refused", refusal, line, parent, member };
+ }
+
+ let spawned: LedgerLine | null = null;
+ if (spawnToWrite) spawned = append(home, parent.fleet, spawnToWrite, { now, host });
+
+ const record: FleetRecord = {
+ openfleet: OPENFLEET_VERSION,
+ fleet: parent.fleet,
+ sysop: parent.sysop,
+ member,
+ parent: parent.member,
+ swarm,
+ ...(task !== undefined ? { task } : {}),
+ depth,
+ engine: session.engine,
+ // Only a `claude -p` is stopped by pid; a Claude Code job is stopped by its job id, which is its member.
+ ...(session.pid !== undefined && session.engine === "claude-p" ? { session: String(session.pid) } : {}),
+ host,
+ cwd: session.cwd,
+ started: isoNow(now),
+ approvals: session.approvals,
+ ceiling,
+ };
+ const path = writeRecord(home, record);
+ return { kind: "derive", record, recordPath: path, parent, spawned };
+}
+
+function root(opts: { home: string; env: Env; now: Date; host: string; implicit: ImplicitFleet; session: SessionFacts }): Resolution {
+ const { home, env, now, host, implicit, session } = opts;
+ const member = session.member ?? session.id;
+ const fleet = env.OPENFLEET_FLEET ?? readCurrent(home) ?? implicit.id;
+ const path = recordPath(home, fleet, member);
+ const existing = readRecord(path);
+ if (existing) return { kind: "root", record: existing, recordPath: path, existed: true };
+
+ const lines = readLedger(home, fleet);
+ const implicitHere = isImplicitFleet(lines, fleet);
+ const sysop = fleetSysop(lines, fleet) ?? implicit.sysop;
+ const orphan = session.orphan === true;
+ const ceiling: Ceiling = implicitHere
+ ? { approvals: orphan ? "native" : session.approvals, depth: 1, hosts: [host] }
+ : fleetCeiling(lines, fleet, implicit.ceiling);
+
+ const record: FleetRecord = {
+ openfleet: OPENFLEET_VERSION,
+ fleet,
+ sysop,
+ member,
+ ...(orphan ? { orphan: true } : {}),
+ depth: 0,
+ engine: session.engine,
+ ...(session.pid !== undefined && session.engine === "claude-p" ? { session: String(session.pid) } : {}),
+ host,
+ cwd: session.cwd,
+ started: isoNow(now),
+ approvals: session.approvals,
+ ceiling,
+ };
+ writeRecord(home, record);
+ return { kind: "root", record, recordPath: path, existed: false };
+}
+
+// ---------------------------------------------------------------------------
+// member.start and member.end
+// ---------------------------------------------------------------------------
+
+export interface StartResult {
+ started: LedgerLine | null;
+ refused: { refusal: Refusal; line: LedgerLine } | null;
+ /**
+ * The record was already claimed, or another writer took the `member.start`
+ * marker first; nothing was written. Null beside a null `started` and
+ * `refused` means the marker was taken and the line is not visible yet.
+ */
+ already: LedgerLine | null;
+ /** The record as it stands: rewritten when the engine's word on approvals replaced the starter's guess. */
+ record: FleetRecord;
+}
+
+/**
+ * Claim a record: check the effective ceiling, then write `member.start` with
+ * `by` the member itself, under the once-marker so a spawner writing the same
+ * line on the member's behalf cannot double it. A refusal writes
+ * `ceiling.refuse` instead, with `by` the record's parent, else the caller's
+ * `OPENFLEET_MEMBER`, else the record's own member when it carries `orphan`,
+ * else `sysop` for a root the human started by hand.
+ */
+export function startMember(
+ homeDir: string,
+ given: FleetRecord,
+ facts: {
+ sessionId: string;
+ approvals: Approvals;
+ env?: Env;
+ now?: Date;
+ host?: string;
+ implicit?: ImplicitFleet;
+ /** The record was derived by this engine at SessionStart from a guess at approvals. */
+ derived?: boolean;
+ },
+): StartResult {
+ const now = facts.now ?? new Date();
+ const implicit = facts.implicit ?? implicitFleet();
+ let record = given;
+ const host = facts.host ?? record.host ?? implicit.host;
+ const lines = readLedger(homeDir, record.fleet);
+ const already = claimedBy(lines, record.member);
+ if (already) return { started: null, refused: null, already, record };
+
+ // A root the sysop started by hand runs under the approvals it was started
+ // with (rule 12), and its record's ceiling must carry them: in the implicit
+ // fleet there is no fleet-level approvals, each root supplies its own. The
+ // starter may have guessed, or left the key out; the engine's word fills
+ // the record while it is still unclaimed. A record this engine derived at
+ // SessionStart from a guess is corrected the same way, so the record, the
+ // check and the `member.start` agree.
+ const handStartedRoot = !record.parent && !record.orphan && isImplicitFleet(lines, record.fleet);
+ if (handStartedRoot && (record.approvals !== facts.approvals || record.ceiling?.approvals === undefined)) {
+ const ceiling: Ceiling = record.ceiling ? { ...record.ceiling, approvals: facts.approvals } : { approvals: facts.approvals, depth: 1, hosts: [host] };
+ record = { ...record, approvals: facts.approvals, ceiling };
+ replaceUnclaimedRecord(homeDir, record);
+ } else if (facts.derived && record.approvals !== facts.approvals) {
+ record = { ...record, approvals: facts.approvals };
+ replaceUnclaimedRecord(homeDir, record);
+ }
+
+ const allowed = memberCeiling(homeDir, lines, record, implicit);
+ const refusal = checkCeiling({ approvals: facts.approvals, depth: record.depth ?? 0, hosts: [host], until: isoNow(now) }, allowed);
+ if (refusal) {
+ const by = record.parent ?? facts.env?.OPENFLEET_MEMBER ?? (record.orphan ? record.member : "sysop");
+ const line = append(
+ homeDir,
+ record.fleet,
+ { event: "ceiling.refuse", by, member: record.member, action: "start", key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed },
+ { now, host },
+ );
+ return { started: null, refused: { refusal, line }, already: null, record };
+ }
+
+ const started = appendOnce(
+ homeDir,
+ record.fleet,
+ {
+ event: "member.start",
+ by: record.member,
+ member: record.member,
+ session: record.session ?? facts.sessionId,
+ ...(record.swarm ? { swarm: record.swarm } : {}),
+ ...(record.parent ? { parent: record.parent } : {}),
+ depth: record.depth ?? 0,
+ ...(record.engine ? { engine: record.engine } : {}),
+ ...(record.cwd ? { cwd: record.cwd } : {}),
+ approvals: facts.approvals,
+ ...(record.piece ? { piece: record.piece } : {}),
+ },
+ { now, host, once: markName("member.start", record.member) },
+ );
+ if (!started) return { started: null, refused: null, already: claimedBy(readLedger(homeDir, record.fleet), record.member), record };
+ return { started, refused: null, already: null, record };
+}
+
+export interface EndFacts {
+ state: EndState;
+ by: string;
+ summary?: string;
+ total?: string;
+ links?: unknown[];
+ now?: Date;
+ host?: string;
+}
+
+export interface EndResult {
+ ended: LedgerLine | null;
+ swarmEnded: LedgerLine | null;
+ /** An end line already counted, or another writer holds the marker; nothing was written. */
+ already: LedgerLine | null;
+}
+
+/**
+ * End a member: one `member.end` that counts (a `lost` line may be superseded
+ * by a real one, anything else stands), then, for a swarm of one the engine
+ * derived, that swarm's `swarm.end` with the same state. Every line goes
+ * through its once-marker; a `lost` end takes `member.end..lost` so the
+ * real end can still follow it and take the plain marker.
+ */
+export function endMember(homeDir: string, fleet: string, member: string, facts: EndFacts, swarm?: string): EndResult {
+ const now = facts.now ?? new Date();
+ const lines = readLedger(homeDir, fleet);
+ const existing = endOf(lines, member);
+ if (existing && !(existing.state === "lost" && facts.state !== "lost")) return { ended: null, swarmEnded: null, already: existing };
+ const lost = facts.state === "lost";
+ // A real end in flight (marker taken, line not yet visible) beats a lost one.
+ if (lost && hasMark(homeDir, fleet, markName("member.end", member))) return { ended: null, swarmEnded: null, already: existing };
+
+ const ended = appendOnce(
+ homeDir,
+ fleet,
+ {
+ event: "member.end",
+ by: facts.by,
+ member,
+ state: facts.state,
+ ...(facts.summary !== undefined ? { summary: facts.summary } : {}),
+ ...(facts.total !== undefined ? { total: facts.total } : {}),
+ ...(facts.links !== undefined ? { links: facts.links } : {}),
+ },
+ { now, host: facts.host, once: markName("member.end", member, lost) },
+ );
+ if (!ended) return { ended: null, swarmEnded: null, already: endOf(readLedger(homeDir, fleet), member) };
+
+ let swarmEnded: LedgerLine | null = null;
+ if (swarm) {
+ const spawn = spawnOf(lines, swarm);
+ // Only a swarm of one the engine itself minted ends with its member: the
+ // spawner is a member (never the sysop) and the id is -. A
+ // one-piece swarm a spawner such as moshcode wrote is that spawner's to end.
+ const minted =
+ typeof spawn?.by === "string" &&
+ spawn.by !== "sysop" &&
+ new RegExp(`^${spawn.by.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`).test(swarm);
+ const ofOne = minted && spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member;
+ if (ofOne && !swarmEndOf(lines, swarm)) {
+ swarmEnded = appendOnce(
+ homeDir,
+ fleet,
+ { event: "swarm.end", by: facts.by, swarm, state: facts.state, ...(facts.summary !== undefined ? { summary: facts.summary } : {}) },
+ { now, host: facts.host, once: markName("swarm.end", swarm) },
+ );
+ }
+ }
+ return { ended, swarmEnded, already: null };
+}
diff --git a/packages/openfleet/src/fold.test.ts b/packages/openfleet/src/fold.test.ts
new file mode 100644
index 0000000..6833d06
--- /dev/null
+++ b/packages/openfleet/src/fold.test.ts
@@ -0,0 +1,167 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { flattenMembers, flattenSwarms, fold, renderTree } from "./fold.js";
+import { append, writeRecord } from "./store.js";
+import { DEV, FLEET, PLANNER, ROOT, cleanup, seedWorkedExampleToEnd, tempHome } from "./test-helpers.js";
+import type { RosterRow } from "./types.js";
+
+describe("fold: the worked example", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExampleToEnd(home);
+ });
+ afterEach(() => cleanup(home));
+
+ it("builds the implicit fleet, the root, the planner's swarm of one and the two-piece swarm", async () => {
+ const tree = await fold(home, {}, { implicit: DEV, host: "dev" });
+ expect(tree.fleets.length).toBe(1);
+ const fleet = tree.fleets[0];
+ expect(fleet).toMatchObject({ fleet: FLEET, sysop: FLEET, implicit: true, ceiling: { depth: 1, hosts: ["dev"] }, swarms: [] });
+ expect(fleet.roots.length).toBe(1);
+ const root = fleet.roots[0];
+ expect(root).toMatchObject({ member: "460a4502", engine: "claude-code", depth: 0, state: "working", approvals: "bypass", host: "dev" });
+ expect(root.alive).toBeUndefined();
+ expect(root.swarms.map((swarm) => swarm.swarm)).toEqual(["460a4502-1", "create-two-0541"]);
+
+ const planner = root.swarms[0];
+ expect(planner).toMatchObject({ by: "460a4502", state: "done", ceiling: {} });
+ expect(planner.members.map((member) => [member.member, member.session, member.engine, member.state])).toEqual([[PLANNER, "4242", "claude-p", "done"]]);
+
+ const swarm = root.swarms[1];
+ expect(swarm).toMatchObject({ task: "create two ...", by: "460a4502", ceiling: { fan_out: 4, until: "2026-09-13T06:11:01Z" } });
+ expect(swarm.state).toBeUndefined();
+ expect(swarm.members.map((member) => ({ member: member.member, session: member.session, title: member.title, engine: member.engine, state: member.state, approvals: member.approvals, owns: member.owns, depth: member.depth }))).toEqual([
+ { member: "create-two-0541-1", session: "172ffd83", title: "create hello.sh bash", engine: "claude-code", state: "done", approvals: "bypass", owns: ["hello.sh"], depth: 1 },
+ { member: "create-two-0541-2", session: "create-two-0541-2", title: "create bye.sh bash", engine: "moshcode/claude", state: "working", approvals: "bypass", owns: ["bye.sh"], depth: 1 },
+ ]);
+ expect(swarm.members[0].summary).toBe("Created hello.sh, mode -rwxrwxr-x, prints hello.");
+ expect(flattenMembers(tree).map((entry) => entry.member.member)).toEqual(["460a4502", PLANNER, "create-two-0541-1", "create-two-0541-2"]);
+ expect(flattenSwarms(tree).map((entry) => entry.swarm.swarm)).toEqual(["460a4502-1", "create-two-0541"]);
+ });
+
+ it("renders the landing page's tree", async () => {
+ const tree = await fold(home, {}, { implicit: DEV, host: "dev" });
+ const text = renderTree(tree, { host: "dev" });
+ const lines = text.split("\n");
+ expect(lines[0]).toBe("anthony@dev (implicit fleet, sysop anthony@dev, depth 1, hosts dev)");
+ expect(lines[1]).toMatch(/^└─ 460a4502 +claude-code +working {2}\[bypass\]$/);
+ expect(lines[2]).toMatch(/^ {3}├─ swarm 460a4502-1 +claude -p "Split the task below into at most 4 \.\.\." +1 member +done$/);
+ expect(lines[3]).toMatch(/^ {3}│ {2}└─ 9f1c2d3e-0000-4000-8000-000000000001 \(4242\) +claude-p +done {2}\[bypass\]$/);
+ expect(lines[4]).toMatch(/^ {3}└─ swarm create-two-0541 +"create two \.\.\." +2\/4 members +until 06:11$/);
+ expect(lines[5]).toMatch(/^ {6}├─ create-two-0541-1 \(172ffd83\) +create hello.sh bash +claude-code +done {2}\[bypass\] {2}owns hello.sh$/);
+ expect(lines[6]).toMatch(/^ {6}└─ create-two-0541-2 +create bye.sh bash +moshcode\/claude +working {2}\[bypass\] {2}owns bye.sh$/);
+ expect(lines.length).toBe(7);
+ expect(renderTree({ fleets: [] })).toBe("(no fleets)");
+ });
+
+ it("joins the rosters: liveness for recorded members, a name for a root, and recordless sessions as roster roots", async () => {
+ const claude = async (): Promise => [
+ { engine: "claude-code", id: "460a4502", sessionId: "460a4502-aaaa-bbbb-cccc-dddddddddddd", name: "ultracode mode documentation", state: "working", approvals: "bypass" },
+ { engine: "claude-code", id: "b9fc0f52", sessionId: "b9fc0f52-322b-452e-8523-77945ab1d777", name: "meta card screenshots stream", state: "working", approvals: "native" },
+ ];
+ const moshcode = async (): Promise => [];
+ const tree = await fold(home, { claude, moshcode }, { implicit: DEV, host: "dev" });
+ const fleet = tree.fleets[0];
+ const root = fleet.roots.find((node) => node.member === "460a4502")!;
+ expect(root.alive).toBe(true);
+ expect(root.title).toBe("ultracode mode documentation");
+ const rosterOnly = fleet.roots.find((node) => node.member === "b9fc0f52")!;
+ expect(rosterOnly).toMatchObject({ roster: true, alive: true, state: "working", approvals: "native", title: "meta card screenshots stream", depth: 0, engine: "claude-code" });
+ // moshcode's roster was readable and lists nothing: the pane is gone.
+ const pane = root.swarms[1].members.find((node) => node.member === "create-two-0541-2")!;
+ expect(pane.alive).toBe(false);
+ // 172ffd83 claimed its record and is not in the roster: gone too. The planner ended, so liveness is moot but reported.
+ expect(root.swarms[1].members[0].alive).toBe(false);
+ const text = renderTree(tree, { host: "dev" });
+ expect(text).toMatch(/^├─ 460a4502 +ultracode mode documentation +claude-code +working {2}\[bypass\]$/m);
+ expect(text).toMatch(/^└─ b9fc0f52 \(b9fc0f52-322b-452e-8523-77945ab1d777\) +meta card screenshots stream +claude-code +working {2}\[roster\]$/m);
+ expect(text).toMatch(/create-two-0541-2 +create bye.sh bash +moshcode\/claude +working {2}\[bypass\] {2}\[gone\]/);
+ });
+
+ it("ignores a roster that cannot be read, and reads only one fleet when asked", async () => {
+ const tree = await fold(home, { claude: async () => null, moshcode: async () => { throw new Error("no manifest"); } }, { implicit: DEV, host: "dev", fleet: FLEET });
+ expect(tree.fleets[0].roots[0].alive).toBeUndefined();
+ const none = await fold(home, {}, { implicit: DEV, host: "dev", fleet: "other" });
+ expect(none.fleets).toEqual([{ fleet: "other", sysop: FLEET, implicit: true, ceiling: { depth: 1, hosts: ["dev"] }, roots: [], swarms: [], spend: {} }]);
+ });
+});
+
+describe("fold: opened fleets, nesting, spend and orphans", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("puts a sysop-started swarm at the fleet level, nests by parent_swarm, sums spend against budget, marks orphans", async () => {
+ const fleet = "team-20260913";
+ append(home, fleet, { at: "2026-09-13T05:00:00Z", event: "fleet.open", by: "sysop", fleet, sysop: "https://anthony.example/profile.md", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD" } }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:01:00Z", event: "swarm.spawn", by: "sysop", swarm: "hand-0501", task: "by hand", ceiling: { fan_out: 2 }, pieces: [{ member: "hand-0501-1", title: "one" }] }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "hand-0501-1", member: "hand-0501-1", session: "h1", swarm: "hand-0501", depth: 0, engine: "moshcode/codex", approvals: "native" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:03:00Z", event: "swarm.spawn", by: "hand-0501-1", swarm: "inner-0503", parent_swarm: "hand-0501", task: "nested", ceiling: {}, pieces: [{ member: "inner-0503-1" }] }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:04:00Z", event: "member.start", by: "inner-0503-1", member: "inner-0503-1", session: "i1", swarm: "inner-0503", parent: "hand-0501-1", depth: 1, engine: "moshcode/kimi", approvals: "native" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:05:00Z", event: "member.spend", by: "inner-0503-1", member: "inner-0503-1", amount: "3 USD", total: "3 USD" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:06:00Z", event: "member.spend", by: "inner-0503-1", member: "inner-0503-1", amount: "2 USD", total: "5 USD" }, { host: "dev" });
+ append(home, fleet, { at: "2026-09-13T05:06:00Z", event: "member.spend", by: "hand-0501-1", member: "hand-0501-1", amount: "1000 tokens", total: "1000 tokens" }, { host: "dev" });
+ writeRecord(home, { ...ROOT, fleet, sysop: "https://anthony.example/profile.md", member: "stray", orphan: true, approvals: "native", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
+
+ const tree = await fold(home, {}, { implicit: DEV, host: "dev" });
+ const node = tree.fleets[0];
+ expect(node).toMatchObject({ fleet, sysop: "https://anthony.example/profile.md", implicit: false, ceiling: { approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD" } });
+ expect(node.roots.map((root) => [root.member, root.state, root.orphan])).toEqual([["stray", "unclaimed", true]]);
+ expect(node.swarms.length).toBe(1);
+ const hand = node.swarms[0];
+ expect(hand).toMatchObject({ swarm: "hand-0501", by: "sysop", spend: { USD: 5, tokens: 1000 } });
+ expect(hand.members.length).toBe(1);
+ // The nested swarm sits under the member that spawned it, the row that says who, not under the parent swarm.
+ expect(hand.swarms).toEqual([]);
+ const spawner = hand.members[0];
+ expect(spawner.swarms.map((swarm) => swarm.swarm)).toEqual(["inner-0503"]);
+ expect(spawner.swarms[0].members[0]).toMatchObject({ member: "inner-0503-1", spend: "5 USD", depth: 1, parent: "hand-0501-1" });
+ // A swarm carries what it narrowed and what it runs under: the fleet's budget reaches the row.
+ expect(hand.ceiling).toEqual({ fan_out: 2 });
+ expect(hand.effective).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD", fan_out: 2 });
+ expect(spawner.swarms[0].effective).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev"], budget: "20 USD", fan_out: 2 });
+ expect(node.spend).toEqual({ USD: 5, tokens: 1000 });
+ expect(flattenSwarms(tree).map((entry) => [entry.swarm.swarm, entry.parentMember])).toEqual([["hand-0501", null], ["inner-0503", "hand-0501-1"]]);
+
+ const text = renderTree(tree, { host: "dev" });
+ expect(text.split("\n")[0]).toBe("team-20260913 (fleet, sysop https://anthony.example/profile.md, approvals bypass, depth 3, hosts dev, budget 20 USD, spent 5/20 USD)");
+ expect(text).toMatch(/└─ swarm hand-0501 +"by hand" +1\/2 members +5\/20 USD/);
+ expect(text).toMatch(/├─ stray +claude-code +unclaimed {2}\[orphan\]/);
+ // The nested swarm inherits fan_out 2 and the fleet's budget.
+ expect(text).toMatch(/└─ swarm inner-0503 +"nested" +1\/2 members +5\/20 USD/);
+ expect(text).toMatch(/└─ inner-0503-1 \(i1\) +moshcode\/kimi +working {2}5 USD/);
+ });
+});
+
+describe("fold: which members a roster can hold", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("never reads an interactive claude session as gone, and reads a background job or a pane as gone when its roster is readable and silent", async () => {
+ const interactive = "68aca9c1-1111-4222-8333-444455556666";
+ // An interactive root: the member is the session UUID, there is no job id, and `claude agents` never lists it.
+ append(home, FLEET, { at: "2026-09-13T05:00:00Z", event: "member.start", by: interactive, member: interactive, session: interactive, depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ // A background job: an 8-hex member the roster can hold.
+ append(home, FLEET, { at: "2026-09-13T05:01:00Z", event: "member.start", by: "b9fc0f52", member: "b9fc0f52", session: "b9fc0f52", depth: 0, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ // A claimed piece whose session is a job id.
+ append(home, FLEET, { at: "2026-09-13T05:02:00Z", event: "member.start", by: "piece-0502-1", member: "piece-0502-1", session: "172ffd83", depth: 1, engine: "claude-code", approvals: "native" }, { host: "dev" });
+ // A tmux pane moshcode started: in the herd manifest under its member id.
+ append(home, FLEET, { at: "2026-09-13T05:03:00Z", event: "member.start", by: "piece-0502-2", member: "piece-0502-2", session: "%7", depth: 1, engine: "tmux", approvals: "native" }, { host: "dev" });
+ // A claude -p: no roster holds it.
+ append(home, FLEET, { at: "2026-09-13T05:04:00Z", event: "member.start", by: "p1", member: "p1", session: "31337", depth: 1, engine: "claude-p", approvals: "native" }, { host: "dev" });
+ const tree = await fold(home, { claude: async () => [], moshcode: async () => [] }, { implicit: DEV, host: "dev" });
+ const alive = Object.fromEntries(tree.fleets[0].roots.map((node) => [node.member, node.alive]));
+ expect(alive).toEqual({ [interactive]: undefined, b9fc0f52: false, "piece-0502-1": false, "piece-0502-2": false, p1: undefined });
+ const text = renderTree(tree, { host: "dev" });
+ expect(text).not.toMatch(new RegExp(`${interactive}.*\\[gone\\]`));
+ expect(text).toMatch(/b9fc0f52 +claude-code +working {2}\[gone\]/);
+ // With the rosters unreadable nothing is gone.
+ const blind = await fold(home, { claude: async () => null, moshcode: async () => null }, { implicit: DEV, host: "dev" });
+ expect(blind.fleets[0].roots.every((node) => node.alive === undefined)).toBe(true);
+ });
+});
diff --git a/packages/openfleet/src/fold.ts b/packages/openfleet/src/fold.ts
new file mode 100644
index 0000000..9a62a30
--- /dev/null
+++ b/packages/openfleet/src/fold.ts
@@ -0,0 +1,397 @@
+/**
+ * Fold `$OPENFLEET_HOME` into one tree: fleets, their root members, their
+ * swarms (nested), each swarm's members, with state, engine, host, depth,
+ * spend against budget and a mark on every `bypass` member. Built from the
+ * ledger and the records; an engine's roster adds liveness and is the only
+ * place a member with no record exists.
+ */
+
+import { effectiveCeiling, fleetCeiling, formatSpend, isImplicitFleet, sumSpend, swarmChain } from "./ceiling.js";
+import { fleetSysop } from "./context.js";
+import { rosterFor, rosterHolds } from "./rosters.js";
+import {
+ claimedBy,
+ endOf,
+ findEvents,
+ implicitFleet,
+ latestSpend,
+ listFleets,
+ readLedger,
+ readRecords,
+ swarmEndOf,
+ type ImplicitFleet,
+} from "./store.js";
+import type { Approvals, EndState, FleetNode, LedgerLine, MemberNode, RosterRow, Rosters, SwarmNode, Tree } from "./types.js";
+
+export interface FoldOptions {
+ implicit?: ImplicitFleet;
+ host?: string;
+ /** Only this fleet. */
+ fleet?: string;
+}
+
+interface RosterState {
+ rows: RosterRow[];
+ /** Roster names that were readable, so an unmatched member of that engine is gone rather than unknown. */
+ readable: Set;
+ consumed: Set;
+}
+
+async function readRosters(rosters: Rosters): Promise {
+ const state: RosterState = { rows: [], readable: new Set(), consumed: new Set() };
+ for (const name of ["claude", "moshcode"] as const) {
+ const read = rosters[name];
+ if (!read) continue;
+ let rows: RosterRow[] | null = null;
+ try {
+ rows = await read();
+ } catch {
+ rows = null;
+ }
+ if (!rows) continue;
+ state.readable.add(name);
+ state.rows.push(...rows);
+ }
+ return state;
+}
+
+function matchRow(state: RosterState, ids: Array): RosterRow | null {
+ const wanted = new Set(ids.filter((id): id is string => typeof id === "string" && id !== ""));
+ if (wanted.size === 0) return null;
+ for (const row of state.rows) {
+ if (state.consumed.has(row)) continue;
+ if (wanted.has(row.id) || (row.sessionId !== undefined && wanted.has(row.sessionId)) || (row.member !== undefined && wanted.has(row.member))) {
+ state.consumed.add(row);
+ return row;
+ }
+ }
+ return null;
+}
+
+function spendOf(members: MemberNode[], swarms: SwarmNode[]): Record {
+ const sums = sumSpend(members.map((member) => member.spend));
+ for (const swarm of swarms) {
+ for (const [unit, amount] of Object.entries(swarm.spend)) sums[unit] = (sums[unit] ?? 0) + amount;
+ }
+ return sums;
+}
+
+function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, roster: RosterState, host: string): FleetNode {
+ const lines = readLedger(homeDir, fleet);
+ const records = readRecords(homeDir, fleet);
+
+ // Every member the ledger or the records know: records, starts, and pieces minted in a spawn.
+ const memberIds = new Set(records.keys());
+ for (const line of lines) {
+ if (line.event === "member.start" && typeof line.member === "string") memberIds.add(line.member);
+ if (line.event === "swarm.spawn" && Array.isArray(line.pieces)) {
+ for (const piece of line.pieces) if (typeof piece?.member === "string") memberIds.add(piece.member);
+ }
+ }
+
+ const ceiling = fleetCeiling(lines, fleet, implicit.ceiling);
+ const pieceOf = new Map();
+ const swarms = new Map();
+ for (const spawn of findEvents(lines, "swarm.spawn")) {
+ if (typeof spawn.swarm !== "string") continue;
+ const end = swarmEndOf(lines, spawn.swarm);
+ swarms.set(spawn.swarm, {
+ swarm: spawn.swarm,
+ ...(typeof spawn.task === "string" ? { task: spawn.task } : {}),
+ by: spawn.by,
+ ...(typeof spawn.parent_swarm === "string" ? { parent_swarm: spawn.parent_swarm } : {}),
+ ceiling: spawn.ceiling && typeof spawn.ceiling === "object" ? spawn.ceiling : {},
+ effective: effectiveCeiling(ceiling, lines, swarmChain(lines, spawn.swarm)),
+ members: [],
+ swarms: [],
+ ...(end ? { state: end.state as EndState, ...(typeof end.summary === "string" ? { summary: end.summary } : {}) } : {}),
+ spend: {},
+ });
+ for (const piece of spawn.pieces ?? []) {
+ if (typeof piece?.member === "string") {
+ pieceOf.set(piece.member, { swarm: spawn.swarm, title: piece.title, owns: piece.owns, task: spawn.task });
+ }
+ }
+ }
+
+ const members = new Map();
+ for (const id of memberIds) {
+ const record = records.get(id) ?? null;
+ const start = claimedBy(lines, id);
+ const end = endOf(lines, id);
+ const piece = pieceOf.get(id);
+ const engine = record?.engine ?? start?.engine;
+ const session = record?.session ?? start?.session;
+ const row = matchRow(roster, [id, record?.session, start?.session]);
+ const covering = rosterFor(engine);
+ // Not listed means gone only for a member the roster can hold: a claude-code background job, a moshcode pane.
+ const gone = covering !== null && roster.readable.has(covering) && rosterHolds(engine, id, session);
+ const approvals: Approvals = (start?.approvals ?? record?.approvals) === "bypass" ? "bypass" : "native";
+ const owns = record?.piece?.owns ?? piece?.owns;
+ const title = record?.piece?.title ?? piece?.title ?? row?.name;
+ const node: MemberNode = {
+ member: id,
+ ...(record?.session ?? start?.session ? { session: record?.session ?? start?.session } : {}),
+ ...(title ? { title } : {}),
+ ...(record?.task ?? piece?.task ? { task: record?.task ?? piece?.task } : {}),
+ ...(engine ?? row?.engine ? { engine: engine ?? row?.engine } : {}),
+ host: record?.host ?? start?.host ?? host,
+ depth: record?.depth ?? start?.depth ?? 0,
+ state: end ? (end.state as EndState) : start ? "working" : "unclaimed",
+ approvals,
+ ...(owns ? { owns } : {}),
+ ...(record?.orphan ? { orphan: true } : {}),
+ ...(row ? { alive: true } : gone ? { alive: false } : {}),
+ ...(latestSpend(lines, id) ? { spend: latestSpend(lines, id) } : {}),
+ ...(start ? { started: start.at } : record?.started ? { started: record.started } : {}),
+ ...(end ? { ended: end.at } : {}),
+ ...(typeof end?.summary === "string" ? { summary: end.summary } : {}),
+ ...(record?.parent ?? start?.parent ? { parent: record?.parent ?? start?.parent } : {}),
+ ...(record?.swarm ?? start?.swarm ?? piece?.swarm ? { swarm: record?.swarm ?? start?.swarm ?? piece?.swarm } : {}),
+ swarms: [],
+ };
+ members.set(id, node);
+ }
+
+ const byStart = (a: { started?: string; member?: string; swarm?: string }, b: typeof a) =>
+ String(a.started ?? "").localeCompare(String(b.started ?? "")) || String(a.member ?? a.swarm).localeCompare(String(b.member ?? b.swarm));
+
+ // Members into swarms. A swarm goes under the member that spawned it when
+ // that member is in this tree (the row that says who), else under its
+ // parent swarm, else at the fleet level: the sysop's, started by hand.
+ const roots: MemberNode[] = [];
+ for (const node of [...members.values()].sort(byStart)) {
+ const swarm = node.swarm ? swarms.get(node.swarm) : undefined;
+ if (swarm) swarm.members.push(node);
+ else roots.push(node);
+ }
+ const fleetSwarms: SwarmNode[] = [];
+ for (const swarm of swarms.values()) {
+ const spawner = members.get(swarm.by);
+ if (spawner) {
+ spawner.swarms.push(swarm);
+ continue;
+ }
+ const parentSwarm = swarm.parent_swarm ? swarms.get(swarm.parent_swarm) : undefined;
+ if (parentSwarm && parentSwarm !== swarm) parentSwarm.swarms.push(swarm);
+ else fleetSwarms.push(swarm);
+ }
+ // Spend rolls up from the leaves: a swarm's total is its members' plus every swarm under them.
+ const sumSwarm = (swarm: SwarmNode): void => {
+ for (const nested of swarm.swarms) sumSwarm(nested);
+ for (const member of swarm.members) for (const nested of member.swarms) sumSwarm(nested);
+ swarm.spend = spendOf(swarm.members, [...swarm.swarms, ...swarm.members.flatMap((member) => member.swarms)]);
+ };
+ for (const swarm of fleetSwarms) sumSwarm(swarm);
+ for (const root of roots) for (const swarm of root.swarms) sumSwarm(swarm);
+
+ const implicitHere = isImplicitFleet(lines, fleet);
+ return {
+ fleet,
+ sysop: fleetSysop(lines, fleet) ?? implicit.sysop,
+ implicit: implicitHere,
+ ceiling,
+ roots,
+ swarms: fleetSwarms,
+ spend: spendOf(roots, [...fleetSwarms, ...roots.flatMap((root) => root.swarms)]),
+ };
+}
+
+/** Fold every fleet under `home` (or one) into a tree, joining the rosters given. */
+export async function fold(homeDir: string, rosters: Rosters = {}, opts: FoldOptions = {}): Promise {
+ const implicit = opts.implicit ?? implicitFleet();
+ const host = opts.host ?? implicit.host;
+ const roster = await readRosters(rosters);
+ const names = opts.fleet ? [opts.fleet] : listFleets(homeDir);
+ const fleets = names.map((fleet) => foldFleet(homeDir, fleet, implicit, roster, host));
+
+ // Sessions an engine lists that have no record are root members of the implicit fleet, marked as roster rows.
+ const leftovers = roster.rows.filter((row) => !roster.consumed.has(row));
+ if (leftovers.length && (!opts.fleet || opts.fleet === implicit.id)) {
+ let node = fleets.find((fleet) => fleet.fleet === implicit.id);
+ if (!node) {
+ node = { fleet: implicit.id, sysop: implicit.sysop, implicit: true, ceiling: { ...implicit.ceiling }, roots: [], swarms: [], spend: {} };
+ fleets.push(node);
+ }
+ for (const row of leftovers) {
+ node.roots.push({
+ member: row.id,
+ ...(row.sessionId ? { session: row.sessionId } : {}),
+ ...(row.name ? { title: row.name } : {}),
+ engine: row.engine,
+ host,
+ depth: 0,
+ state: rowState(row.state),
+ approvals: row.approvals ?? "native",
+ roster: true,
+ alive: true,
+ ...(row.startedAt ? { started: row.startedAt } : {}),
+ swarms: [],
+ });
+ }
+ }
+
+ fleets.sort((a, b) => Number(b.implicit) - Number(a.implicit) || a.fleet.localeCompare(b.fleet));
+ return { fleets };
+}
+
+function rowState(state: string | undefined): MemberNode["state"] {
+ if (state === "done" || state === "failed" || state === "stopped") return state;
+ return "working";
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+interface Row {
+ prefix: string;
+ label: string;
+ title: string;
+ /** The engine for a member row, the member count for a swarm row. */
+ engine: string;
+ rest: string;
+}
+
+function hhmm(iso: string | undefined): string {
+ if (!iso) return "";
+ const ms = Date.parse(iso);
+ if (Number.isNaN(ms)) return iso;
+ const date = new Date(ms);
+ return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}`;
+}
+
+function quote(task: string | undefined, max = 60): string {
+ if (!task) return "";
+ const text = task.length > max ? `${task.slice(0, max - 4).trimEnd()} ...` : task;
+ // A command line that carries its own quotes reads better bare.
+ return text.includes('"') ? text : `"${text}"`;
+}
+
+export function describeCeiling(ceiling: Record): string {
+ const parts: string[] = [];
+ if (ceiling.approvals) parts.push(`approvals ${String(ceiling.approvals)}`);
+ if (ceiling.depth !== undefined) parts.push(`depth ${String(ceiling.depth)}`);
+ if (ceiling.fan_out !== undefined) parts.push(`fan-out ${String(ceiling.fan_out)}`);
+ if (Array.isArray(ceiling.hosts)) parts.push(`hosts ${ceiling.hosts.join(",")}`);
+ if (ceiling.budget) parts.push(`budget ${String(ceiling.budget)}`);
+ if (ceiling.until) parts.push(`until ${hhmm(String(ceiling.until))}`);
+ return parts.join(", ");
+}
+
+function memberRow(node: MemberNode, prefix: string, host: string): Row {
+ const label = node.session && node.session !== node.member ? `${node.member} (${node.session})` : node.member;
+ const marks: string[] = [];
+ if (node.approvals === "bypass") marks.push("[bypass]");
+ if (node.orphan) marks.push("[orphan]");
+ if (node.roster) marks.push("[roster]");
+ if (node.alive === false && node.state === "working") marks.push("[gone]");
+ const rest = [
+ node.state,
+ ...marks,
+ ...(node.owns && node.owns.length ? [`owns ${node.owns.join(",")}`] : []),
+ ...(node.host && node.host !== host ? [`@${node.host}`] : []),
+ ...(node.spend ? [node.spend] : []),
+ ].join(" ");
+ return { prefix, label, title: node.title ?? "", engine: node.engine ?? "?", rest };
+}
+
+function swarmRow(node: SwarmNode, prefix: string): Row {
+ const count = node.members.length;
+ // The row shows what the swarm runs under, inherited keys included, not only what its spawner narrowed.
+ const ceiling = node.effective ?? node.ceiling;
+ const size = ceiling.fan_out !== undefined ? `${count}/${String(ceiling.fan_out)} members` : `${count} member${count === 1 ? "" : "s"}`;
+ const rest = [...(node.state ? [node.state] : ceiling.until ? [`until ${hhmm(String(ceiling.until))}`] : [])];
+ const spend = formatSpend(node.spend, typeof ceiling.budget === "string" ? ceiling.budget : undefined);
+ if (spend) rest.push(spend);
+ return { prefix, label: `swarm ${node.swarm}`, title: quote(node.task), engine: size, rest: rest.join(" ") };
+}
+
+function walk(rows: Row[], items: Array<{ kind: "member"; node: MemberNode } | { kind: "swarm"; node: SwarmNode }>, indent: string, host: string): void {
+ items.forEach((item, index) => {
+ const last = index === items.length - 1;
+ const branch = last ? "└─ " : "├─ ";
+ const childIndent = `${indent}${last ? " " : "│ "}`;
+ if (item.kind === "member") {
+ rows.push(memberRow(item.node, `${indent}${branch}`, host));
+ walk(rows, item.node.swarms.map((node) => ({ kind: "swarm" as const, node })), childIndent, host);
+ } else {
+ rows.push(swarmRow(item.node, `${indent}${branch}`));
+ walk(
+ rows,
+ [
+ ...item.node.members.map((node) => ({ kind: "member" as const, node })),
+ ...item.node.swarms.map((node) => ({ kind: "swarm" as const, node })),
+ ],
+ childIndent,
+ host,
+ );
+ }
+ });
+}
+
+/** The tree as plain text, one fleet after another, columns aligned per fleet. */
+export function renderTree(tree: Tree, opts: { host?: string } = {}): string {
+ const host = opts.host ?? implicitFleet().host;
+ const out: string[] = [];
+ if (tree.fleets.length === 0) return "(no fleets)";
+ for (const fleet of tree.fleets) {
+ const ceiling = describeCeiling(fleet.ceiling);
+ const spend = formatSpend(fleet.spend, typeof fleet.ceiling.budget === "string" ? fleet.ceiling.budget : undefined);
+ out.push(`${fleet.fleet} (${fleet.implicit ? "implicit fleet" : "fleet"}, sysop ${fleet.sysop}${ceiling ? `, ${ceiling}` : ""}${spend ? `, spent ${spend}` : ""})`);
+ const rows: Row[] = [];
+ walk(
+ rows,
+ [
+ ...fleet.roots.map((node) => ({ kind: "member" as const, node })),
+ ...fleet.swarms.map((node) => ({ kind: "swarm" as const, node })),
+ ],
+ "",
+ host,
+ );
+ const labelWidth = Math.max(0, ...rows.map((row) => row.prefix.length + row.label.length));
+ const titleWidth = Math.max(0, ...rows.map((row) => row.title.length));
+ const engineWidth = Math.max(0, ...rows.map((row) => row.engine.length));
+ for (const row of rows) {
+ const head = `${row.prefix}${row.label}`.padEnd(labelWidth);
+ const title = titleWidth ? ` ${row.title.padEnd(titleWidth)}` : "";
+ out.push(`${head}${title} ${row.engine.padEnd(engineWidth)} ${row.rest}`.trimEnd());
+ }
+ }
+ return out.join("\n");
+}
+
+/** Every member in a tree, depth first, with the swarm it sits in. */
+export function flattenMembers(tree: Tree): Array<{ fleet: string; member: MemberNode; swarm: SwarmNode | null }> {
+ const out: Array<{ fleet: string; member: MemberNode; swarm: SwarmNode | null }> = [];
+ const visitSwarm = (fleet: string, swarm: SwarmNode): void => {
+ for (const member of swarm.members) {
+ out.push({ fleet, member, swarm });
+ for (const nested of member.swarms) visitSwarm(fleet, nested);
+ }
+ for (const nested of swarm.swarms) visitSwarm(fleet, nested);
+ };
+ for (const fleet of tree.fleets) {
+ for (const root of fleet.roots) {
+ out.push({ fleet: fleet.fleet, member: root, swarm: null });
+ for (const swarm of root.swarms) visitSwarm(fleet.fleet, swarm);
+ }
+ for (const swarm of fleet.swarms) visitSwarm(fleet.fleet, swarm);
+ }
+ return out;
+}
+
+/** Every swarm in a tree, parents before children. */
+export function flattenSwarms(tree: Tree): Array<{ fleet: string; swarm: SwarmNode; parentMember: string | null }> {
+ const out: Array<{ fleet: string; swarm: SwarmNode; parentMember: string | null }> = [];
+ const visitSwarm = (fleet: string, swarm: SwarmNode, parentMember: string | null): void => {
+ out.push({ fleet, swarm, parentMember });
+ for (const member of swarm.members) for (const nested of member.swarms) visitSwarm(fleet, nested, member.member);
+ for (const nested of swarm.swarms) visitSwarm(fleet, nested, parentMember);
+ };
+ for (const fleet of tree.fleets) {
+ for (const root of fleet.roots) for (const swarm of root.swarms) visitSwarm(fleet.fleet, swarm, root.member);
+ for (const swarm of fleet.swarms) visitSwarm(fleet.fleet, swarm, null);
+ }
+ return out;
+}
diff --git a/packages/openfleet/src/hooks-install.test.ts b/packages/openfleet/src/hooks-install.test.ts
new file mode 100644
index 0000000..5807313
--- /dev/null
+++ b/packages/openfleet/src/hooks-install.test.ts
@@ -0,0 +1,136 @@
+import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { HOOK_EVENTS, hookCommand, hookSpecs, hooksStatus, installHooks, isOurs, removeHooks, settingsFile } from "./hooks-install.js";
+import { cleanup, tempHome } from "./test-helpers.js";
+
+describe("hook commands", () => {
+ it("guard every event so a box without logicsrc stays silent, and let only UserPromptSubmit be heard", () => {
+ expect(hookCommand("SessionStart")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0");
+ expect(hookCommand("Stop")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook Stop; exit 0");
+ // Only the deliberate 2 (a refused start) reaches the engine; a crash or an older logicsrc on PATH is swallowed.
+ expect(hookCommand("UserPromptSubmit")).toBe('command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0');
+ expect(hookCommand("SessionStart")).not.toContain(">/dev/null 2>&1 && logicsrc fleet hook SessionStart >/dev/null");
+ const specs = hookSpecs();
+ expect(specs.map((spec) => spec.event)).toEqual([...HOOK_EVENTS]);
+ expect(specs.find((spec) => spec.event === "PreToolUse")?.matcher).toBe("Edit|Write|MultiEdit|NotebookEdit");
+ expect(specs.find((spec) => spec.event === "SessionEnd")?.timeout).toBe(20);
+ expect(specs.filter((spec) => spec.event !== "PreToolUse").every((spec) => spec.matcher === undefined)).toBe(true);
+ });
+
+ it("recognises ours by the command text alone", () => {
+ expect(isOurs({ type: "command", command: hookCommand("Stop") })).toBe(true);
+ expect(isOurs({ type: "command", command: "moshcode herd report x done" })).toBe(false);
+ expect(isOurs(null)).toBe(false);
+ });
+
+ it("resolves the settings file from HOME or CLAUDE_CONFIG_DIR", () => {
+ expect(settingsFile({ HOME: "/home/x" })).toBe("/home/x/.claude/settings.json");
+ expect(settingsFile({ HOME: "/home/x", CLAUDE_CONFIG_DIR: "/cfg" })).toBe("/cfg/settings.json");
+ });
+});
+
+describe("install, status, remove over a settings file", () => {
+ let dir: string;
+ let file: string;
+ beforeEach(() => {
+ dir = tempHome();
+ mkdirSync(join(dir, ".claude"));
+ file = join(dir, ".claude", "settings.json");
+ });
+ afterEach(() => cleanup(dir));
+
+ it("creates the file with five matcher-less groups (PreToolUse with its matcher), 0600, then is idempotent", () => {
+ const first = installHooks(file);
+ expect(first.ok).toBe(true);
+ expect(first.written).toBe(5);
+ expect(first.changes.every((change) => change.change === "added")).toBe(true);
+ expect(statSync(file).mode & 0o777).toBe(0o600);
+ const settings = JSON.parse(readFileSync(file, "utf8"));
+ for (const event of HOOK_EVENTS) expect(settings.hooks[event].length).toBe(1);
+ expect(settings.hooks.Stop[0]).toEqual({ hooks: [{ type: "command", command: hookCommand("Stop") }] });
+ expect(settings.hooks.PreToolUse[0]).toEqual({ matcher: "Edit|Write|MultiEdit|NotebookEdit", hooks: [{ type: "command", command: hookCommand("PreToolUse") }] });
+ expect(settings.hooks.SessionEnd[0].hooks[0]).toEqual({ type: "command", command: hookCommand("SessionEnd"), timeout: 20 });
+
+ const before = readFileSync(file, "utf8");
+ const second = installHooks(file);
+ expect(second.written).toBe(0);
+ expect(second.changes.every((change) => change.change === "unchanged")).toBe(true);
+ expect(readFileSync(file, "utf8")).toBe(before);
+
+ const status = hooksStatus(file);
+ expect(status).toMatchObject({ file, present: true, readable: true, installed: true, partial: false });
+ expect(status.events.every((event) => event.installed && event.current)).toBe(true);
+ });
+
+ it("merges into an existing file, keeps everyone else's hooks and settings, and preserves the mode", () => {
+ writeFileSync(
+ file,
+ JSON.stringify({
+ model: "opus",
+ hooks: {
+ Stop: [{ hooks: [{ type: "command", command: "moshcode herd report \"$MOSHCODE_HERD_NAME\" done; exit 0" }] }],
+ Notification: [{ hooks: [{ type: "command", command: "say hi" }] }],
+ },
+ }),
+ );
+ chmodSync(file, 0o644);
+ const result = installHooks(file);
+ expect(result.ok).toBe(true);
+ expect(statSync(file).mode & 0o777).toBe(0o644);
+ const settings = JSON.parse(readFileSync(file, "utf8"));
+ expect(settings.model).toBe("opus");
+ expect(settings.hooks.Notification).toEqual([{ hooks: [{ type: "command", command: "say hi" }] }]);
+ expect(settings.hooks.Stop.length).toBe(2);
+ expect(settings.hooks.Stop[0].hooks[0].command).toContain("moshcode herd report");
+ expect(settings.hooks.Stop[1].hooks[0].command).toBe(hookCommand("Stop"));
+
+ const removed = removeHooks(file);
+ expect(removed).toMatchObject({ ok: true, removed: 5 });
+ const after = JSON.parse(readFileSync(file, "utf8"));
+ expect(after).toEqual({
+ model: "opus",
+ hooks: {
+ Stop: [{ hooks: [{ type: "command", command: "moshcode herd report \"$MOSHCODE_HERD_NAME\" done; exit 0" }] }],
+ Notification: [{ hooks: [{ type: "command", command: "say hi" }] }],
+ },
+ });
+ expect(removeHooks(file)).toMatchObject({ ok: true, removed: 0 });
+ });
+
+ it("replaces an older text of ours rather than firing twice, and status says so before", () => {
+ writeFileSync(file, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "logicsrc fleet hook Stop >/dev/null 2>&1; exit 0" }] }] } }));
+ const status = hooksStatus(file);
+ expect(status.partial).toBe(true);
+ expect(status.events.find((event) => event.event === "Stop")).toEqual({ event: "Stop", installed: true, current: false });
+ const result = installHooks(file);
+ expect(result.changes.find((change) => change.event === "Stop")?.change).toBe("updated");
+ const settings = JSON.parse(readFileSync(file, "utf8"));
+ expect(settings.hooks.Stop).toEqual([{ hooks: [{ type: "command", command: hookCommand("Stop") }] }]);
+ });
+
+ it("removes ours from any event, drops empty structure only when it made it, and leaves a missing file missing", () => {
+ installHooks(file);
+ writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, "utf8")), hooks: { ...JSON.parse(readFileSync(file, "utf8")).hooks, SubagentStop: [{ hooks: [{ type: "command", command: "logicsrc fleet hook SubagentStop; exit 0" }] }] } }));
+ const removed = removeHooks(file);
+ expect(removed.removed).toBe(6);
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({});
+ const missing = join(dir, "nowhere", "settings.json");
+ expect(removeHooks(missing)).toMatchObject({ ok: true, removed: 0 });
+ expect(existsSync(missing)).toBe(false);
+ expect(hooksStatus(missing)).toMatchObject({ present: false, readable: true, installed: false, partial: false });
+ });
+
+ it("refuses to merge into a file it cannot parse, and a dry run writes nothing", () => {
+ writeFileSync(file, "{ not json");
+ const result = installHooks(file);
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/not valid JSON/);
+ expect(readFileSync(file, "utf8")).toBe("{ not json");
+ expect(hooksStatus(file).readable).toBe(false);
+ writeFileSync(file, "{}");
+ const dry = installHooks(file, { dryRun: true });
+ expect(dry.written).toBe(5);
+ expect(readFileSync(file, "utf8")).toBe("{}");
+ });
+});
diff --git a/packages/openfleet/src/hooks-install.ts b/packages/openfleet/src/hooks-install.ts
new file mode 100644
index 0000000..d29fcc8
--- /dev/null
+++ b/packages/openfleet/src/hooks-install.ts
@@ -0,0 +1,285 @@
+/**
+ * Claude Code hooks: install, remove, status, over `~/.claude/settings.json`.
+ *
+ * Three rules, copied from moshcode's herd hooks because they are about being
+ * a good guest in someone else's config file:
+ *
+ * MERGE, NEVER CLOBBER. The file is the user's and holds their other hooks.
+ * Install extends it; remove takes out only entries whose command is ours.
+ *
+ * A HOOK MUST NEVER BREAK AN ENGINE. Every command is guarded so a box with
+ * no `logicsrc` on PATH gets silence, not a failing hook on every turn.
+ *
+ * OURS IS MATCHED BY ITS TEXT. The settings schema belongs to the engine; a
+ * marker key we invent is one it may reject. `logicsrc fleet hook` in the
+ * command is marker enough.
+ */
+
+import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import type { Env } from "./store.js";
+
+export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Stop", "SessionEnd"] as const;
+export type HookEvent = (typeof HOOK_EVENTS)[number];
+
+export interface HookSpec {
+ event: HookEvent;
+ command: string;
+ matcher?: string;
+ timeout?: number;
+}
+
+const GUARD = "command -v logicsrc >/dev/null 2>&1";
+
+/**
+ * The shell command one hook runs.
+ *
+ * Every event but one ends in `; exit 0`: whatever happened, the engine
+ * carries on. UserPromptSubmit is the one hook that must be heard: a start the
+ * ceiling refuses exits 2 (rule 5), and only that code is passed on. Any other
+ * failure (a crash, a missing build, an older `logicsrc` on PATH with no
+ * `fleet`) would otherwise show as an error on every prompt, so it becomes 0.
+ * SessionStart's stdout is the member's context line, so nothing there is
+ * redirected.
+ */
+export function hookCommand(event: HookEvent): string {
+ if (event === "UserPromptSubmit") return `${GUARD} || exit 0; logicsrc fleet hook ${event}; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0`;
+ return `${GUARD} && logicsrc fleet hook ${event}; exit 0`;
+}
+
+export function hookSpecs(): HookSpec[] {
+ return [
+ { event: "SessionStart", command: hookCommand("SessionStart") },
+ { event: "UserPromptSubmit", command: hookCommand("UserPromptSubmit") },
+ { event: "PreToolUse", command: hookCommand("PreToolUse"), matcher: "Edit|Write|MultiEdit|NotebookEdit" },
+ { event: "Stop", command: hookCommand("Stop") },
+ // SessionEnd hooks share a 1.5 s budget unless one names a longer timeout.
+ { event: "SessionEnd", command: hookCommand("SessionEnd"), timeout: 20 },
+ ];
+}
+
+export function isOurs(entry: unknown): boolean {
+ const command = (entry as { command?: unknown } | null)?.command;
+ return typeof command === "string" && /\blogicsrc fleet hook\b/.test(command);
+}
+
+/** `~/.claude/settings.json`, from `$HOME` so tests can point it elsewhere. */
+export function settingsFile(env: Env = process.env): string {
+ const base = env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim() !== "" ? env.CLAUDE_CONFIG_DIR : join(env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir(), ".claude");
+ return join(base, "settings.json");
+}
+
+const SETTINGS_MODE = 0o600;
+
+type Json = Record;
+
+interface ReadResult {
+ ok: boolean;
+ present: boolean;
+ data: Json;
+ error?: string;
+}
+
+export function readSettings(file: string): ReadResult {
+ let text: string;
+ try {
+ text = readFileSync(file, "utf8");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, present: false, data: {} };
+ return { ok: false, present: true, data: {}, error: String((error as Error).message) };
+ }
+ if (!text.trim()) return { ok: true, present: true, data: {} };
+ try {
+ const parsed = JSON.parse(text);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return { ok: false, present: true, data: {}, error: `${file} is not a JSON object` };
+ }
+ return { ok: true, present: true, data: parsed as Json };
+ } catch (error) {
+ // A settings file we cannot parse is one we cannot merge into; overwriting
+ // it would take every other hook and preference with us.
+ return { ok: false, present: true, data: {}, error: `${file} is not valid JSON (${(error as Error).message}): fix it and re-run` };
+ }
+}
+
+function existingMode(file: string): number {
+ try {
+ return statSync(file).mode & 0o777;
+ } catch {
+ return SETTINGS_MODE;
+ }
+}
+
+export function writeSettings(file: string, data: Json, mode: number): void {
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
+ // Write-then-rename: a crash mid-write on the engine's own settings file
+ // would otherwise leave it truncated.
+ const tmp = `${file}.logicsrc-${process.pid}`;
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode });
+ renameSync(tmp, file);
+}
+
+interface Group {
+ matcher?: string;
+ hooks?: unknown[];
+ [key: string]: unknown;
+}
+
+function hooksOf(settings: Json): Record {
+ const hooks = settings.hooks;
+ if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) return hooks as Record;
+ const fresh: Record = {};
+ settings.hooks = fresh;
+ return fresh;
+}
+
+function groupsOf(hooks: Record, event: string): Group[] {
+ if (!Array.isArray(hooks[event])) hooks[event] = [];
+ return hooks[event] as Group[];
+}
+
+function entryFor(spec: HookSpec): Record {
+ return { type: "command", command: spec.command, ...(spec.timeout !== undefined ? { timeout: spec.timeout } : {}) };
+}
+
+function sameEntry(entry: unknown, spec: HookSpec): boolean {
+ const e = entry as Record | null;
+ return !!e && e.command === spec.command && (e.timeout ?? undefined) === spec.timeout;
+}
+
+type Change = "added" | "updated" | "unchanged";
+
+/** Add or refresh our entry for one event, replacing an older text of ours rather than firing twice. */
+function mergeEvent(settings: Json, spec: HookSpec): Change {
+ const groups = groupsOf(hooksOf(settings), spec.event);
+ for (const group of groups) {
+ if (!Array.isArray(group?.hooks)) continue;
+ const at = group.hooks.findIndex(isOurs);
+ if (at < 0) continue;
+ const onlyOurs = group.hooks.length === 1;
+ const matcherOk = spec.matcher === undefined ? true : group.matcher === spec.matcher;
+ if (sameEntry(group.hooks[at], spec) && matcherOk) return "unchanged";
+ group.hooks[at] = entryFor(spec);
+ // The matcher is the group's; only touch it when the group is ours alone.
+ if (onlyOurs) {
+ if (spec.matcher !== undefined) group.matcher = spec.matcher;
+ else delete group.matcher;
+ }
+ return "updated";
+ }
+ groups.push({ ...(spec.matcher !== undefined ? { matcher: spec.matcher } : {}), hooks: [entryFor(spec)] });
+ return "added";
+}
+
+/** Take our entries out of one event, leaving structure we did not create alone. */
+function pruneEvent(settings: Json, event: string): number {
+ const hooks = settings.hooks;
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return 0;
+ const table = hooks as Record;
+ if (!Array.isArray(table[event])) return 0;
+ let removed = 0;
+ const kept: Group[] = [];
+ for (const group of table[event] as Group[]) {
+ if (!Array.isArray(group?.hooks)) {
+ kept.push(group);
+ continue;
+ }
+ const before = group.hooks.length;
+ const remaining = group.hooks.filter((entry) => !isOurs(entry));
+ removed += before - remaining.length;
+ // A group that held only our hook goes with it; one that held someone else's stays.
+ if (remaining.length === 0 && before > 0) continue;
+ kept.push({ ...group, hooks: remaining });
+ }
+ if (kept.length) table[event] = kept;
+ else delete table[event];
+ if (Object.keys(table).length === 0) delete settings.hooks;
+ return removed;
+}
+
+export interface EventStatus {
+ event: HookEvent;
+ installed: boolean;
+ /** Installed with exactly the text this version writes. */
+ current: boolean;
+}
+
+export interface HooksStatus {
+ file: string;
+ present: boolean;
+ readable: boolean;
+ installed: boolean;
+ partial: boolean;
+ events: EventStatus[];
+ error?: string;
+}
+
+export function hooksStatus(file: string): HooksStatus {
+ const read = readSettings(file);
+ const specs = hookSpecs();
+ if (!read.ok) {
+ return { file, present: read.present, readable: false, installed: false, partial: false, events: specs.map((spec) => ({ event: spec.event, installed: false, current: false })), error: read.error };
+ }
+ const hooks = read.data.hooks && typeof read.data.hooks === "object" ? (read.data.hooks as Record) : {};
+ const events = specs.map((spec) => {
+ const groups = Array.isArray(hooks[spec.event]) ? (hooks[spec.event] as Group[]) : [];
+ const found = groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [])).filter(isOurs);
+ return { event: spec.event, installed: found.length > 0, current: found.some((entry) => sameEntry(entry, spec)) };
+ });
+ const all = events.every((event) => event.installed && event.current);
+ return { file, present: read.present, readable: true, installed: all, partial: events.some((event) => event.installed) && !all, events };
+}
+
+export interface InstallResult {
+ ok: boolean;
+ file: string;
+ changes: Array<{ event: HookEvent; change: Change }>;
+ written: number;
+ error?: string;
+}
+
+export function installHooks(file: string, opts: { dryRun?: boolean } = {}): InstallResult {
+ const read = readSettings(file);
+ if (!read.ok) return { ok: false, file, changes: [], written: 0, error: read.error };
+ const settings = read.data;
+ const changes = hookSpecs().map((spec) => ({ event: spec.event, change: mergeEvent(settings, spec) }));
+ const written = changes.filter((change) => change.change !== "unchanged").length;
+ if (!opts.dryRun && written > 0) {
+ try {
+ writeSettings(file, settings, read.present ? existingMode(file) : SETTINGS_MODE);
+ } catch (error) {
+ return { ok: false, file, changes, written: 0, error: String((error as Error).message) };
+ }
+ }
+ return { ok: true, file, changes, written };
+}
+
+export interface RemoveResult {
+ ok: boolean;
+ file: string;
+ removed: number;
+ error?: string;
+}
+
+/** Take them out again. Only ever removes commands this module wrote, from any event that holds one. */
+export function removeHooks(file: string, opts: { dryRun?: boolean } = {}): RemoveResult {
+ const read = readSettings(file);
+ if (!read.ok) return { ok: false, file, removed: 0, error: read.error };
+ if (!read.present) return { ok: true, file, removed: 0 };
+ const settings = read.data;
+ const events = new Set([
+ ...HOOK_EVENTS,
+ ...Object.keys(settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? (settings.hooks as Json) : {}),
+ ]);
+ let removed = 0;
+ for (const event of events) removed += pruneEvent(settings, event);
+ if (!opts.dryRun && removed > 0) {
+ try {
+ writeSettings(file, settings, existingMode(file));
+ } catch (error) {
+ return { ok: false, file, removed: 0, error: String((error as Error).message) };
+ }
+ }
+ return { ok: true, file, removed };
+}
diff --git a/packages/openfleet/src/hooks.test.ts b/packages/openfleet/src/hooks.test.ts
new file mode 100644
index 0000000..f165051
--- /dev/null
+++ b/packages/openfleet/src/hooks.test.ts
@@ -0,0 +1,354 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { startMember } from "./context.js";
+import { contextLine, exportLines, ownJobDir, ownsPath, runHook, summaryOf, type HookIo } from "./hooks.js";
+import { append, endOf, findEvents, readLedger, readRecord, readSession, recordPath, writeCurrent } from "./store.js";
+import { DEV, FLEET, PIECE_1, cleanup, envFor, seedWorkedExample, tempHome } from "./test-helpers.js";
+import type { Env } from "./store.js";
+
+const SESSION = "68aca9c1-1111-4222-8333-444455556666";
+const NOW = new Date("2026-09-13T05:41:12Z");
+
+interface Fake {
+ io: HookIo;
+ appended: Array<[string, string]>;
+ envFile: string;
+}
+
+function fake(home: string, env: Env = {}, opts: { environ?: Record; cmdline?: string[] } = {}): Fake {
+ const envFile = join(home, "env.sh");
+ const appended: Array<[string, string]> = [];
+ const io: HookIo = {
+ env: envFor(home, { CLAUDE_PID: "4242", CLAUDE_ENV_FILE: envFile, CLAUDE_CODE_ENTRYPOINT: "cli", ...env }),
+ now: () => NOW,
+ host: "dev",
+ implicit: DEV,
+ readProcEnviron: () => opts.environ ?? { PATH: "/usr/bin" },
+ readProcCmdline: () => opts.cmdline ?? ["claude"],
+ appendFile: (path, text) => appended.push([path, text]),
+ };
+ return { io, appended, envFile };
+}
+
+const payload = (extra: Record) => JSON.stringify({ session_id: SESSION, cwd: "/home/anthony/work", transcript_path: "/t.jsonl", ...extra });
+
+describe("helpers", () => {
+ it("knows its own job dir, the SUMMARY section, the export lines and the context line", () => {
+ expect(ownJobDir({ CLAUDE_JOB_DIR: "/home/a/.claude/jobs/68aca9c1" }, SESSION)).toBe("/home/a/.claude/jobs/68aca9c1");
+ expect(ownJobDir({ CLAUDE_JOB_DIR: "/home/a/.claude/jobs/172ffd83" }, SESSION)).toBeNull();
+ expect(ownJobDir({}, SESSION)).toBeNull();
+ expect(summaryOf("Did things.\n\nSUMMARY:\nCreated hello.sh.\nDone.")).toBe("SUMMARY:\nCreated hello.sh.\nDone.");
+ expect(summaryOf("x".repeat(600))?.length).toBe(500);
+ expect(summaryOf("")).toBeUndefined();
+ expect(summaryOf(null)).toBeUndefined();
+ expect(exportLines("/h", { record: null, recordPath: "/h/fleets/f/members/m.json", member: "m", fleet: "f", swarm: "s w", last_message: null })).toBe(
+ "export OPENFLEET_HOME='/h'\nexport OPENFLEET_RECORD='/h/fleets/f/members/m.json'\nexport OPENFLEET_FLEET='f'\nexport OPENFLEET_MEMBER='m'\nexport OPENFLEET_SWARM='s w'\n",
+ );
+ expect(contextLine(PIECE_1)).toBe("OpenFleet: you are member create-two-0541-1 of fleet anthony@dev, piece create hello.sh bash, owns hello.sh");
+ expect(contextLine({ openfleet: "0.1", fleet: "f", sysop: "s", member: "m" })).toBe("OpenFleet: you are member m of fleet f");
+ });
+
+ it("matches piece.owns as files, directories and globs", () => {
+ expect(ownsPath(["hello.sh"], "hello.sh")).toBe(true);
+ expect(ownsPath(["hello.sh"], "bye.sh")).toBe(false);
+ expect(ownsPath(["src/"], "src/a/b.ts")).toBe(true);
+ expect(ownsPath(["src"], "srcs/a.ts")).toBe(false);
+ expect(ownsPath(["*.md"], "README.md")).toBe(true);
+ expect(ownsPath(["*.md"], "docs/x.md")).toBe(false);
+ expect(ownsPath(["docs/**/*.md"], "docs/a/b/c.md")).toBe(true);
+ expect(ownsPath(["./packages/openfleet/"], "packages/openfleet/src/x.ts")).toBe(true);
+ });
+});
+
+describe("SessionStart", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("writes a root record for a hand-started session, exports the variables, and prints the context line", () => {
+ const { io, appended, envFile } = fake(home, {}, { cmdline: ["claude", "--permission-mode", "bypassPermissions"] });
+ const result = runHook("SessionStart", payload({ source: "startup" }), io);
+ expect(result).toEqual({ exit: 0, stdout: `OpenFleet: you are member ${SESSION} of fleet anthony@dev\n`, stderr: "" });
+ const record = readRecord(recordPath(home, FLEET, SESSION));
+ expect(record).toMatchObject({ member: SESSION, depth: 0, engine: "claude-code", host: "dev", cwd: "/home/anthony/work", approvals: "bypass", ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] } });
+ expect(record).not.toHaveProperty("orphan");
+ expect(appended).toEqual([[envFile, `export OPENFLEET_HOME='${home}'\nexport OPENFLEET_RECORD='${recordPath(home, FLEET, SESSION)}'\nexport OPENFLEET_FLEET='anthony@dev'\nexport OPENFLEET_MEMBER='${SESSION}'\n`]]);
+ const session = readSession(home, SESSION);
+ expect(session).toMatchObject({ member: SESSION, fleet: FLEET, swarm: null, last_message: null, kind: "root", approvals: "bypass" });
+ expect(readLedger(home, FLEET)).toEqual([]);
+ });
+
+ it("re-exports for a resume of a known session and writes nothing new", () => {
+ const { io } = fake(home);
+ runHook("SessionStart", payload({ source: "startup" }), io);
+ const second = fake(home);
+ const result = runHook("SessionStart", payload({ source: "resume" }), second.io);
+ expect(result.stdout).toContain(`you are member ${SESSION}`);
+ expect(second.appended.length).toBe(1);
+ expect(second.appended[0][1]).toContain("OPENFLEET_MEMBER");
+ });
+
+ it("skips a subagent and a payload without a session id", () => {
+ const { io, appended } = fake(home);
+ expect(runHook("SessionStart", payload({ source: "startup", agent_id: "sub-1" }), io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ expect(runHook("SessionStart", "{}", io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ expect(appended).toEqual([]);
+ expect(existsSync(join(home, "fleets"))).toBe(false);
+ });
+
+ it("claims the record OPENFLEET_RECORD names and tells the member its piece", () => {
+ seedWorkedExample(home);
+ const { io, appended } = fake(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") });
+ const result = runHook("SessionStart", payload({ source: "startup" }), io);
+ expect(result.stdout).toBe("OpenFleet: you are member create-two-0541-1 of fleet anthony@dev, piece create hello.sh bash, owns hello.sh\n");
+ expect(appended[0][1]).toContain("export OPENFLEET_SWARM='create-two-0541'");
+ expect(appended[0][1]).toContain("export OPENFLEET_MEMBER='create-two-0541-1'");
+ expect(readSession(home, SESSION)).toMatchObject({ member: "create-two-0541-1", swarm: "create-two-0541", kind: "claim" });
+ });
+
+ it("derives under a claimed record, and reports a refusal without a record", () => {
+ seedWorkedExample(home);
+ const derived = fake(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), CLAUDE_CODE_ENTRYPOINT: "sdk-cli" }, { cmdline: ["claude", "-p", "plan it"] });
+ const result = runHook("SessionStart", payload({ source: "startup" }), derived.io);
+ expect(result.stdout).toBe(`OpenFleet: you are member ${SESSION} of fleet anthony@dev\n`);
+ const record = readRecord(recordPath(home, FLEET, SESSION));
+ expect(record).toMatchObject({ parent: "460a4502", swarm: "460a4502-2", task: "claude -p plan it", depth: 1, engine: "claude-p", session: "4242", approvals: "native" });
+ expect(findEvents(readLedger(home, FLEET), "swarm.spawn", { swarm: "460a4502-2" })[0]).toMatchObject({ by: "460a4502", pieces: [{ member: SESSION }] });
+
+ // Now claim that derived record as a child, and start something beneath it: depth 2 is refused.
+ startMember(home, record!, { sessionId: SESSION, approvals: "native", now: NOW, host: "dev", implicit: DEV });
+ const grandchild = "deadbeef-0000-4000-8000-000000000002";
+ const refused = fake(home, { OPENFLEET_RECORD: recordPath(home, FLEET, SESSION) });
+ const out = runHook("SessionStart", payload({ source: "startup", session_id: grandchild }), refused.io);
+ expect(out.stdout).toBe("OpenFleet: start refused, ceiling refuses depth: wanted 2, allowed 1\n");
+ expect(refused.appended).toEqual([]);
+ expect(existsSync(recordPath(home, FLEET, grandchild))).toBe(false);
+ expect(readSession(home, grandchild)?.refused).toEqual({ key: "depth", reason: "ceiling refuses depth: wanted 2, allowed 1" });
+ expect(readFileSync(join(home, "hooks.log"), "utf8")).toContain("refused");
+ const blocked = runHook("UserPromptSubmit", payload({ session_id: grandchild, permission_mode: "auto", prompt: "hi" }), refused.io);
+ expect(blocked).toEqual({ exit: 2, stdout: "", stderr: "OpenFleet refused the start: ceiling refuses depth: wanted 2, allowed 1\n" });
+ });
+
+ it("marks an orphan when the invoking environment carried a child marker", () => {
+ const { io } = fake(home, {}, { environ: { CLAUDE_JOB_DIR: "/home/anthony/.claude/jobs/172ffd83", CLAUDE_CODE_CHILD_SESSION: "1" }, cmdline: ["claude", "--dangerously-skip-permissions"] });
+ runHook("SessionStart", payload({ source: "startup" }), io);
+ const record = readRecord(recordPath(home, FLEET, SESSION));
+ expect(record).toMatchObject({ orphan: true, approvals: "bypass", ceiling: { approvals: "native", depth: 1, hosts: ["dev"] } });
+ });
+
+ it("names a background job by its job id and reads approvals from state.json respawnFlags, with no orphan test", () => {
+ const jobId = SESSION.slice(0, 8);
+ const jobDir = join(home, "jobs", jobId);
+ mkdirSync(jobDir, { recursive: true });
+ writeFileSync(join(jobDir, "state.json"), JSON.stringify({ respawnFlags: ["--agent", "claude", "--permission-mode", "bypassPermissions"], tokens: 801101, children: [{ id: "179", href: "https://github.com/profullstack/logicsrc/pull/179", kind: "pr" }] }));
+ const { io } = fake(home, { CLAUDE_JOB_DIR: jobDir }, { environ: { CLAUDE_JOB_DIR: "/somewhere/else" } });
+ const result = runHook("SessionStart", payload({ source: "startup" }), io);
+ expect(result.stdout).toBe(`OpenFleet: you are member ${jobId} of fleet anthony@dev\n`);
+ const record = readRecord(recordPath(home, FLEET, jobId));
+ expect(record).toMatchObject({ member: jobId, engine: "claude-code", approvals: "bypass", depth: 0 });
+ expect(record).not.toHaveProperty("orphan");
+ expect(record).not.toHaveProperty("session");
+ });
+});
+
+describe("UserPromptSubmit", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("writes member.start once, with approvals from the permission mode the engine reports", () => {
+ const { io } = fake(home);
+ runHook("SessionStart", payload({ source: "startup" }), io);
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "go" }), fake(home).io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ const starts = findEvents(readLedger(home, FLEET), "member.start", { member: SESSION });
+ expect(starts.length).toBe(1);
+ expect(starts[0]).toMatchObject({ by: SESSION, session: SESSION, depth: 0, engine: "claude-code", cwd: "/home/anthony/work", approvals: "bypass" });
+ runHook("UserPromptSubmit", payload({ permission_mode: "auto", prompt: "again" }), fake(home).io);
+ expect(findEvents(readLedger(home, FLEET), "member.start", { member: SESSION }).length).toBe(1);
+ expect(readSession(home, SESSION)?.approvals).toBe("bypass");
+ });
+
+ it("refuses the first prompt with exit 2 and ceiling.refuse when bypass runs under a native fleet", () => {
+ append(home, "team-20260913", { event: "fleet.open", by: "sysop", fleet: "team-20260913", sysop: FLEET, ceiling: { depth: 2, hosts: ["dev"] } }, { host: "dev" });
+ writeCurrent(home, "team-20260913");
+ runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
+ const result = runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "go" }), fake(home).io);
+ expect(result).toEqual({ exit: 2, stdout: "", stderr: "OpenFleet refused the start: ceiling refuses approvals: wanted bypass, allowed native\n" });
+ const lines = readLedger(home, "team-20260913");
+ expect(findEvents(lines, "ceiling.refuse")[0]).toMatchObject({ by: "sysop", member: SESSION, action: "start", key: "approvals", wanted: "bypass", allowed: "native" });
+ expect(findEvents(lines, "member.start")).toEqual([]);
+ // Every later prompt of that session is refused the same way.
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "still" }), fake(home).io).exit).toBe(2);
+ });
+
+ it("writes no end line for a member whose start was refused", () => {
+ append(home, "team-20260913", { event: "fleet.open", by: "sysop", fleet: "team-20260913", sysop: FLEET, ceiling: { depth: 2, hosts: ["dev"] } }, { host: "dev" });
+ writeCurrent(home, "team-20260913");
+ runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "go" }), fake(home).io).exit).toBe(2);
+ // The session still winds down through Stop and SessionEnd; a member that never started has no end.
+ runHook("Stop", payload({ permission_mode: "bypassPermissions", last_assistant_message: "blocked", background_tasks: [] }), fake(home).io);
+ runHook("SessionEnd", payload({ reason: "other" }), fake(home).io);
+ expect(findEvents(readLedger(home, "team-20260913"), "member.end")).toEqual([]);
+ });
+
+ it("does nothing for a session it never saw, or a subagent", () => {
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home).io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "auto", agent_id: "x" }), fake(home).io).exit).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.start")).toEqual([]);
+ });
+});
+
+describe("PreToolUse", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ seedWorkedExample(home);
+ runHook("SessionStart", payload({ source: "startup" }), fake(home, { OPENFLEET_RECORD: recordPath(home, FLEET, "create-two-0541-1") }).io);
+ });
+ afterEach(() => cleanup(home));
+
+ it("denies an edit outside piece.owns, resolved against the record's cwd, and says nothing inside it", () => {
+ const io = fake(home).io;
+ const cwd = PIECE_1.cwd!;
+ expect(runHook("PreToolUse", payload({ tool_name: "Write", tool_input: { file_path: join(cwd, "hello.sh") } }), io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ expect(runHook("PreToolUse", payload({ tool_name: "Edit", tool_input: { file_path: "hello.sh" } }), io).stdout).toBe("");
+ const denied = runHook("PreToolUse", payload({ tool_name: "Write", tool_input: { file_path: join(cwd, "bye.sh") } }), io);
+ expect(denied.exit).toBe(0);
+ expect(JSON.parse(denied.stdout)).toEqual({
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "outside piece.owns: bye.sh (member create-two-0541-1 owns hello.sh)" },
+ });
+ const away = runHook("PreToolUse", payload({ tool_name: "Write", tool_input: { file_path: "/etc/passwd" } }), io);
+ expect(JSON.parse(away.stdout).hookSpecificOutput.permissionDecision).toBe("deny");
+ expect(runHook("PreToolUse", payload({ tool_name: "Bash", tool_input: { command: "rm bye.sh" } }), io).stdout).toBe("");
+ });
+
+ it("says nothing for a member with no piece", () => {
+ const other = "aaaaaaaa-0000-4000-8000-000000000000";
+ runHook("SessionStart", payload({ source: "startup", session_id: other }), fake(home).io);
+ expect(runHook("PreToolUse", payload({ session_id: other, tool_name: "Write", tool_input: { file_path: "/anywhere" } }), fake(home).io).stdout).toBe("");
+ });
+});
+
+describe("Stop and SessionEnd", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ function bgJob(): { jobDir: string; jobId: string } {
+ const jobId = SESSION.slice(0, 8);
+ const jobDir = join(home, "jobs", jobId);
+ mkdirSync(jobDir, { recursive: true });
+ writeFileSync(join(jobDir, "state.json"), JSON.stringify({ respawnFlags: [], tokens: 801101, children: [{ id: "179", href: "https://github.com/profullstack/logicsrc/pull/179", kind: "pr" }, { id: "x", kind: "note" }] }));
+ return { jobDir, jobId };
+ }
+
+ it("a background job ends done at its first idle Stop, with the SUMMARY section, links and total", () => {
+ const { jobDir, jobId } = bgJob();
+ const env = { CLAUDE_JOB_DIR: jobDir };
+ runHook("SessionStart", payload({ source: "startup" }), fake(home, env).io);
+ runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home, env).io);
+ const busy = runHook("Stop", payload({ last_assistant_message: "working on it", background_tasks: [{ id: "t" }] }), fake(home, env).io);
+ expect(busy.exit).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.end")).toEqual([]);
+ expect(readSession(home, SESSION)?.last_message).toBe("working on it");
+ runHook("Stop", payload({ last_assistant_message: "All done.\n\nSUMMARY: created hello.sh and opened a PR.", background_tasks: [] }), fake(home, env).io);
+ const ends = findEvents(readLedger(home, FLEET), "member.end", { member: jobId });
+ expect(ends.length).toBe(1);
+ expect(ends[0]).toMatchObject({ by: jobId, state: "done", summary: "SUMMARY: created hello.sh and opened a PR.", total: "801101 tokens", links: ["https://github.com/profullstack/logicsrc/pull/179"] });
+ runHook("Stop", payload({ last_assistant_message: "later turn", background_tasks: [] }), fake(home, env).io);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: jobId }).length).toBe(1);
+ });
+
+ it("an interactive or -p session only stores its message at Stop and ends at SessionEnd, for a real exit", () => {
+ runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
+ runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home).io);
+ runHook("Stop", payload({ last_assistant_message: "ok", background_tasks: [] }), fake(home).io);
+ expect(findEvents(readLedger(home, FLEET), "member.end")).toEqual([]);
+ expect(runHook("SessionEnd", payload({ reason: "clear" }), fake(home).io).exit).toBe(0);
+ expect(findEvents(readLedger(home, FLEET), "member.end")).toEqual([]);
+ runHook("SessionEnd", payload({ reason: "other" }), fake(home).io);
+ const ends = findEvents(readLedger(home, FLEET), "member.end", { member: SESSION });
+ expect(ends.length).toBe(1);
+ expect(ends[0]).toMatchObject({ by: SESSION, state: "done", summary: "ok" });
+ expect(ends[0]).not.toHaveProperty("total");
+ runHook("SessionEnd", payload({ reason: "prompt_input_exit" }), fake(home).io);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: SESSION }).length).toBe(1);
+ });
+
+ it("ends a derived swarm of one with the member", () => {
+ seedWorkedExample(home);
+ const env = { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
+ runHook("SessionStart", payload({ source: "startup" }), fake(home, env).io);
+ runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions" }), fake(home, env).io);
+ runHook("Stop", payload({ last_assistant_message: "the plan", background_tasks: [] }), fake(home, env).io);
+ runHook("SessionEnd", payload({ reason: "other" }), fake(home, env).io);
+ const lines = readLedger(home, FLEET);
+ expect(findEvents(lines, "member.end", { member: SESSION })[0]).toMatchObject({ state: "done", summary: "the plan" });
+ expect(findEvents(lines, "swarm.end", { swarm: "460a4502-2" })[0]).toMatchObject({ by: SESSION, state: "done", summary: "the plan" });
+ });
+
+ it("supersedes a lost line a sysop tool wrote with the engine's own end, at SessionEnd and at a job's idle Stop", () => {
+ runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
+ runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home).io);
+ runHook("Stop", payload({ last_assistant_message: "finished", background_tasks: [] }), fake(home).io);
+ append(home, FLEET, { event: "member.end", by: "sysop", member: SESSION, state: "lost" }, { now: NOW, host: "dev" });
+ runHook("SessionEnd", payload({ reason: "other" }), fake(home).io);
+ const ends = findEvents(readLedger(home, FLEET), "member.end", { member: SESSION });
+ expect(ends.map((line) => line.state)).toEqual(["lost", "done"]);
+ expect(ends[1]).toMatchObject({ by: SESSION, summary: "finished" });
+ expect(endOf(readLedger(home, FLEET), SESSION)?.state).toBe("done");
+
+ const { jobDir, jobId } = bgJob();
+ const env = { CLAUDE_JOB_DIR: jobDir };
+ const job = "68aca9c1-2222-4222-8333-444455556666";
+ runHook("SessionStart", payload({ source: "startup", session_id: job }), fake(home, env).io);
+ runHook("UserPromptSubmit", payload({ session_id: job, permission_mode: "auto" }), fake(home, env).io);
+ append(home, FLEET, { event: "member.end", by: "sysop", member: jobId, state: "lost" }, { now: NOW, host: "dev" });
+ runHook("Stop", payload({ session_id: job, last_assistant_message: "SUMMARY: shipped.", background_tasks: [] }), fake(home, env).io);
+ expect(endOf(readLedger(home, FLEET), jobId)).toMatchObject({ state: "done", summary: "SUMMARY: shipped.", total: "801101 tokens" });
+ // A real end that already stands is never followed by another.
+ runHook("Stop", payload({ session_id: job, last_assistant_message: "again", background_tasks: [] }), fake(home, env).io);
+ expect(findEvents(readLedger(home, FLEET), "member.end", { member: jobId }).length).toBe(2);
+ });
+
+ it("corrects a derived record's guessed approvals with the permission mode the engine reports", () => {
+ seedWorkedExample(home);
+ // The command line said nothing about permissions, so SessionStart guessed native; the engine then says bypass.
+ const env = { OPENFLEET_RECORD: recordPath(home, FLEET, "460a4502"), CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
+ runHook("SessionStart", payload({ source: "startup" }), fake(home, env, { cmdline: ["claude", "-p", "plan it"] }).io);
+ expect(readRecord(recordPath(home, FLEET, SESSION))?.approvals).toBe("native");
+ expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions" }), fake(home, env).io).exit).toBe(0);
+ expect(readRecord(recordPath(home, FLEET, SESSION))?.approvals).toBe("bypass");
+ expect(findEvents(readLedger(home, FLEET), "member.start", { member: SESSION })[0]?.approvals).toBe("bypass");
+ expect(readSession(home, SESSION)?.approvals).toBe("bypass");
+ });
+});
+
+describe("never failing the engine", () => {
+ let home: string;
+ beforeEach(() => {
+ home = tempHome();
+ });
+ afterEach(() => cleanup(home));
+
+ it("logs a bad payload, an unknown event and a handler that throws, and exits 0", () => {
+ const { io } = fake(home);
+ expect(runHook("SessionStart", "{{{", io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ expect(runHook("Notification", payload({}), io)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ const broken: HookIo = { ...io, readProcCmdline: () => { throw new Error("proc exploded"); } };
+ expect(runHook("SessionStart", payload({ source: "startup" }), broken)).toEqual({ exit: 0, stdout: "", stderr: "" });
+ const log = readFileSync(join(home, "hooks.log"), "utf8");
+ expect(log).toContain("bad payload");
+ expect(log).toContain("Notification: no such hook");
+ expect(log).toContain("proc exploded");
+ });
+});
diff --git a/packages/openfleet/src/hooks.ts b/packages/openfleet/src/hooks.ts
new file mode 100644
index 0000000..0620bcf
--- /dev/null
+++ b/packages/openfleet/src/hooks.ts
@@ -0,0 +1,471 @@
+/**
+ * The Claude Code side of OpenFleet, run from the engine's own hooks.
+ *
+ * Five hooks, one handler each. SessionStart resolves the record (claim,
+ * derive, root, orphan) and hands the member its variables through
+ * CLAUDE_ENV_FILE; UserPromptSubmit checks the ceiling with the permission
+ * mode the engine reports and writes member.start or refuses the start;
+ * PreToolUse keeps an edit inside piece.owns; Stop and SessionEnd write
+ * member.end. Exported variables reach the member's tools but not later hook
+ * processes, so every hook after the first finds its member through
+ * `$OPENFLEET_HOME/sessions/.json`.
+ *
+ * A hook must never throw at the engine: everything is caught, logged to
+ * `$OPENFLEET_HOME/hooks.log`, and exits 0, except the one deliberate exit 2
+ * that refuses a start.
+ */
+
+import { appendFileSync, readFileSync } from "node:fs";
+import { basename, isAbsolute, join, relative, resolve } from "node:path";
+import { describeRefusal } from "./ceiling.js";
+import { claimOrDerive, endMember, startMember } from "./context.js";
+import { approvalsFromFlags, claudeHome } from "./rosters.js";
+import {
+ claimedBy,
+ endOf,
+ home as homeOf,
+ implicitFleet,
+ logHook,
+ readJson,
+ readLedger,
+ readSession,
+ writeSession,
+ type Env,
+ type ImplicitFleet,
+ type SessionFile,
+} from "./store.js";
+import type { Approvals, FleetRecord } from "./types.js";
+
+export interface HookIo {
+ env: Env;
+ now: () => Date;
+ host?: string;
+ implicit?: ImplicitFleet;
+ /** `/proc//environ` as a map, the environment the engine was invoked with; null when unreadable. */
+ readProcEnviron: (pid: string) => Record | null;
+ /** `/proc//cmdline` as argv; null when unreadable. */
+ readProcCmdline: (pid: string) => string[] | null;
+ /** Append to `$CLAUDE_ENV_FILE`. */
+ appendFile: (path: string, text: string) => void;
+}
+
+export interface HookResult {
+ exit: 0 | 2;
+ stdout: string;
+ stderr: string;
+}
+
+const OK: HookResult = { exit: 0, stdout: "", stderr: "" };
+
+function parseNulList(buffer: Buffer): string[] {
+ return buffer.toString("utf8").split("\0").filter((part) => part !== "");
+}
+
+export function realHookIo(env: Env = process.env): HookIo {
+ return {
+ env,
+ now: () => new Date(),
+ readProcEnviron: (pid) => {
+ try {
+ const out: Record = {};
+ for (const part of parseNulList(readFileSync(`/proc/${pid}/environ`))) {
+ const at = part.indexOf("=");
+ if (at > 0) out[part.slice(0, at)] = part.slice(at + 1);
+ }
+ return out;
+ } catch {
+ return null;
+ }
+ },
+ readProcCmdline: (pid) => {
+ try {
+ return parseNulList(readFileSync(`/proc/${pid}/cmdline`));
+ } catch {
+ return null;
+ }
+ },
+ appendFile: (path, text) => appendFileSync(path, text, "utf8"),
+ };
+}
+
+interface Payload {
+ session_id?: string;
+ cwd?: string;
+ transcript_path?: string;
+ source?: string;
+ agent_id?: string;
+ permission_mode?: string;
+ prompt?: string;
+ tool_name?: string;
+ tool_input?: { file_path?: string; notebook_path?: string; [key: string]: unknown };
+ last_assistant_message?: string;
+ background_tasks?: unknown[];
+ reason?: string;
+ [key: string]: unknown;
+}
+
+function parsePayload(text: string): Payload {
+ if (!text.trim()) return {};
+ const parsed = JSON.parse(text);
+ return parsed && typeof parsed === "object" ? (parsed as Payload) : {};
+}
+
+/** The engine's child markers (rule 13), in the environment the engine was invoked with. */
+export const CHILD_MARKERS = ["CLAUDE_JOB_DIR", "CLAUDE_CODE_CHILD_SESSION", "MOSHCODE_HERD_NAME"] as const;
+
+/** `CLAUDE_JOB_DIR` is this session's own when its basename is the session id's first eight characters. */
+export function ownJobDir(env: Env, sessionId: string): string | null {
+ const dir = env.CLAUDE_JOB_DIR;
+ if (!dir || !sessionId) return null;
+ return basename(dir) === sessionId.slice(0, 8) ? dir : null;
+}
+
+/** The `SUMMARY:` section of a closing message when it wrote one, else its last 500 characters. */
+export function summaryOf(text: string | null | undefined): string | undefined {
+ if (typeof text !== "string" || text.trim() === "") return undefined;
+ const at = text.search(/^\s*SUMMARY:/m);
+ if (at >= 0) return text.slice(at).trim();
+ const trimmed = text.trim();
+ return trimmed.length > 500 ? trimmed.slice(-500) : trimmed;
+}
+
+function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`;
+}
+
+/** The `export` lines a SessionStart hook hands the member through CLAUDE_ENV_FILE. */
+export function exportLines(homeDir: string, session: SessionFile): string {
+ const vars: Array<[string, string | null | undefined]> = [
+ ["OPENFLEET_HOME", homeDir],
+ ["OPENFLEET_RECORD", session.recordPath],
+ ["OPENFLEET_FLEET", session.fleet],
+ ["OPENFLEET_MEMBER", session.member],
+ ["OPENFLEET_SWARM", session.swarm],
+ ];
+ return vars
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "")
+ .map(([name, value]) => `export ${name}=${shellQuote(value)}\n`)
+ .join("");
+}
+
+export function contextLine(record: FleetRecord): string {
+ let line = `OpenFleet: you are member ${record.member} of fleet ${record.fleet}`;
+ if (record.piece?.title) line += `, piece ${record.piece.title}`;
+ if (record.piece?.owns?.length) line += `, owns ${record.piece.owns.join(", ")}`;
+ return line;
+}
+
+function stateJson(jobDir: string): { respawnFlags?: unknown; tokens?: unknown; children?: unknown } | null {
+ return readJson(join(jobDir, "state.json"));
+}
+
+function linksFrom(state: { children?: unknown } | null): unknown[] | undefined {
+ if (!state || !Array.isArray(state.children)) return undefined;
+ const links = state.children
+ .map((child) => (child && typeof child === "object" ? (child as { href?: unknown }).href : undefined))
+ .filter((href): href is string => typeof href === "string");
+ return links.length ? links : undefined;
+}
+
+function totalFrom(state: { tokens?: unknown } | null): string | undefined {
+ return state && typeof state.tokens === "number" && Number.isFinite(state.tokens) ? `${state.tokens} tokens` : undefined;
+}
+
+// ---------------------------------------------------------------------------
+// SessionStart
+// ---------------------------------------------------------------------------
+
+export function handleSessionStart(payload: Payload, io: HookIo): HookResult {
+ if (payload.agent_id) return OK;
+ const sessionId = payload.session_id;
+ if (!sessionId) return OK;
+ const env = io.env;
+ const homeDir = homeOf(env);
+ const implicit = io.implicit ?? implicitFleet();
+ const host = io.host ?? implicit.host;
+
+ const existing = readSession(homeDir, sessionId);
+ if (existing) {
+ // A resume, a respawn, or a repeat: the member is the same one, keyed by session id.
+ if (existing.refused) return { exit: 0, stdout: `OpenFleet: start refused, ${existing.refused.reason}\n`, stderr: "" };
+ if (env.CLAUDE_ENV_FILE) io.appendFile(env.CLAUDE_ENV_FILE, exportLines(homeDir, existing));
+ return { exit: 0, stdout: existing.record ? `${contextLine(existing.record)}\n` : "", stderr: "" };
+ }
+
+ const jobDir = ownJobDir(env, sessionId);
+ const bg = jobDir !== null;
+ const engine = env.CLAUDE_CODE_ENTRYPOINT === "sdk-cli" ? "claude-p" : "claude-code";
+ const pid = env.CLAUDE_PID;
+
+ let approvals: Approvals = "native";
+ if (bg) approvals = approvalsFromFlags(stateJson(jobDir)?.respawnFlags);
+ else if (pid) approvals = approvalsFromFlags(io.readProcCmdline(pid) ?? []);
+
+ let orphan = false;
+ if (!bg && pid) {
+ const invoked = io.readProcEnviron(pid);
+ if (invoked) orphan = CHILD_MARKERS.some((marker) => typeof invoked[marker] === "string" && invoked[marker] !== "");
+ }
+
+ const cmdline = pid ? io.readProcCmdline(pid) : null;
+ const command = cmdline && cmdline.length ? cmdline.join(" ") : "claude";
+ const pidNumber = pid && /^\d+$/.test(pid) ? Number(pid) : undefined;
+
+ const resolution = claimOrDerive({
+ home: homeDir,
+ env,
+ now: io.now(),
+ host,
+ implicit,
+ session: {
+ id: sessionId,
+ engine,
+ cwd: payload.cwd ?? process.cwd(),
+ ...(pidNumber !== undefined ? { pid: pidNumber } : {}),
+ command,
+ approvals,
+ ...(bg ? { member: basename(jobDir) } : {}),
+ orphan,
+ },
+ });
+
+ if (resolution.kind === "refused") {
+ const reason = describeRefusal(resolution.refusal);
+ writeSession(homeDir, sessionId, {
+ record: null,
+ recordPath: null,
+ member: resolution.member,
+ fleet: resolution.parent.fleet,
+ swarm: null,
+ last_message: null,
+ refused: { key: resolution.refusal.key, reason },
+ });
+ logHook(homeDir, `SessionStart ${sessionId}: refused, ${reason}`, io.now());
+ return { exit: 0, stdout: `OpenFleet: start refused, ${reason}\n`, stderr: "" };
+ }
+
+ const record = resolution.record;
+ const session: SessionFile = {
+ record,
+ recordPath: resolution.recordPath,
+ member: record.member,
+ fleet: record.fleet,
+ swarm: record.swarm ?? null,
+ last_message: null,
+ kind: resolution.kind,
+ approvals,
+ };
+ writeSession(homeDir, sessionId, session);
+ if (env.CLAUDE_ENV_FILE) io.appendFile(env.CLAUDE_ENV_FILE, exportLines(homeDir, session));
+ return { exit: 0, stdout: `${contextLine(record)}\n`, stderr: "" };
+}
+
+// ---------------------------------------------------------------------------
+// UserPromptSubmit
+// ---------------------------------------------------------------------------
+
+export function handleUserPromptSubmit(payload: Payload, io: HookIo): HookResult {
+ if (payload.agent_id) return OK;
+ const sessionId = payload.session_id;
+ if (!sessionId) return OK;
+ const env = io.env;
+ const homeDir = homeOf(env);
+ const session = readSession(homeDir, sessionId);
+ if (!session) return OK;
+ if (session.refused) return { exit: 2, stdout: "", stderr: `OpenFleet refused the start: ${session.refused.reason}\n` };
+ if (!session.record) return OK;
+ const record = session.record;
+ if (claimedBy(readLedger(homeDir, record.fleet), record.member)) return OK;
+
+ const approvals: Approvals = payload.permission_mode === "bypassPermissions" ? "bypass" : "native";
+ const implicit = io.implicit ?? implicitFleet();
+ // A record this engine derived at SessionStart guessed approvals from the command line; the permission mode is the engine's word.
+ const derived = session.kind === "derive";
+ const result = startMember(homeDir, record, { sessionId, approvals, env, now: io.now(), host: io.host ?? implicit.host, implicit, derived });
+ if (result.refused) {
+ const reason = describeRefusal(result.refused.refusal);
+ writeSession(homeDir, sessionId, { ...session, refused: { key: result.refused.refusal.key, reason } });
+ logHook(homeDir, `UserPromptSubmit ${sessionId}: refused, ${reason}`, io.now());
+ return { exit: 2, stdout: "", stderr: `OpenFleet refused the start: ${reason}\n` };
+ }
+ if (result.started) writeSession(homeDir, sessionId, { ...session, record: result.record, approvals });
+ return OK;
+}
+
+// ---------------------------------------------------------------------------
+// PreToolUse
+// ---------------------------------------------------------------------------
+
+function globToRegExp(pattern: string): RegExp {
+ let out = "";
+ for (let i = 0; i < pattern.length; i += 1) {
+ const ch = pattern[i];
+ if (ch === "*") {
+ if (pattern[i + 1] === "*") {
+ out += ".*";
+ i += 1;
+ if (pattern[i + 1] === "/") i += 1;
+ } else {
+ out += "[^/]*";
+ }
+ } else if (ch === "?") {
+ out += "[^/]";
+ } else {
+ out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
+ }
+ }
+ return new RegExp(`^${out}$`);
+}
+
+/** Does a path relative to the record's cwd fall under one `piece.owns` entry? A directory owns what is under it. */
+export function ownsPath(owns: string[], rel: string): boolean {
+ const target = rel.replace(/\\/g, "/");
+ for (const raw of owns) {
+ const pattern = String(raw).replace(/^\.\//, "").replace(/\/+$/, "");
+ if (pattern === "") continue;
+ if (/[*?]/.test(pattern)) {
+ if (globToRegExp(pattern).test(target)) return true;
+ continue;
+ }
+ if (target === pattern || target.startsWith(`${pattern}/`)) return true;
+ }
+ return false;
+}
+
+export function handlePreToolUse(payload: Payload, io: HookIo): HookResult {
+ const sessionId = payload.session_id;
+ if (!sessionId) return OK;
+ const homeDir = homeOf(io.env);
+ const session = readSession(homeDir, sessionId);
+ const owns = session?.record?.piece?.owns;
+ if (!session?.record || !Array.isArray(owns) || owns.length === 0) return OK;
+ const filePath = payload.tool_input?.file_path ?? payload.tool_input?.notebook_path;
+ if (typeof filePath !== "string" || filePath === "") return OK;
+ const cwd = session.record.cwd ?? payload.cwd ?? process.cwd();
+ const rel = relative(cwd, resolve(cwd, filePath));
+ const outside = rel === "" || rel.startsWith("..") || isAbsolute(rel) || !ownsPath(owns, rel);
+ if (!outside) return OK;
+ const reason = `outside piece.owns: ${rel || filePath} (member ${session.record.member} owns ${owns.join(", ")})`;
+ const decision = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } };
+ return { exit: 0, stdout: `${JSON.stringify(decision)}\n`, stderr: "" };
+}
+
+// ---------------------------------------------------------------------------
+// Stop and SessionEnd
+// ---------------------------------------------------------------------------
+
+export function handleStop(payload: Payload, io: HookIo): HookResult {
+ if (payload.agent_id) return OK;
+ const sessionId = payload.session_id;
+ if (!sessionId) return OK;
+ const env = io.env;
+ const homeDir = homeOf(env);
+ const session = readSession(homeDir, sessionId);
+ if (!session) return OK;
+ if (typeof payload.last_assistant_message === "string") {
+ session.last_message = payload.last_assistant_message;
+ writeSession(homeDir, sessionId, session);
+ }
+ const jobDir = ownJobDir(env, sessionId);
+ if (!jobDir || !session.record) return OK;
+ if (!Array.isArray(payload.background_tasks) || payload.background_tasks.length > 0) return OK;
+ const record = session.record;
+ const lines = readLedger(homeDir, record.fleet);
+ // A member that never started (its first prompt was refused, or none came) has no end line.
+ if (!claimedBy(lines, record.member)) return OK;
+ // A `lost` line a sysop tool wrote is superseded by the engine's own end; anything else stands.
+ const existing = endOf(lines, record.member);
+ if (existing && existing.state !== "lost") return OK;
+ const state = stateJson(jobDir);
+ endMember(
+ homeDir,
+ record.fleet,
+ record.member,
+ {
+ state: "done",
+ by: record.member,
+ summary: summaryOf(session.last_message),
+ links: linksFrom(state),
+ total: totalFrom(state),
+ now: io.now(),
+ host: io.host ?? record.host,
+ },
+ record.swarm,
+ );
+ return OK;
+}
+
+export function handleSessionEnd(payload: Payload, io: HookIo): HookResult {
+ const sessionId = payload.session_id;
+ if (!sessionId) return OK;
+ const env = io.env;
+ const homeDir = homeOf(env);
+ const session = readSession(homeDir, sessionId);
+ if (!session?.record) return OK;
+ // clear, resume and logout hand the same work to another session; only a real exit ends the member.
+ if (payload.reason !== undefined && payload.reason !== "other" && payload.reason !== "prompt_input_exit") return OK;
+ const record = session.record;
+ const lines = readLedger(homeDir, record.fleet);
+ // A member that never started (its first prompt was refused, or none came) has no end line.
+ if (!claimedBy(lines, record.member)) return OK;
+ const existing = endOf(lines, record.member);
+ if (existing && existing.state !== "lost") return OK;
+ const jobDir = ownJobDir(env, sessionId);
+ const state = jobDir ? stateJson(jobDir) : null;
+ endMember(
+ homeDir,
+ record.fleet,
+ record.member,
+ {
+ state: "done",
+ by: record.member,
+ summary: summaryOf(session.last_message),
+ links: linksFrom(state),
+ total: totalFrom(state),
+ now: io.now(),
+ host: io.host ?? record.host,
+ },
+ record.swarm,
+ );
+ return OK;
+}
+
+// ---------------------------------------------------------------------------
+// Dispatch
+// ---------------------------------------------------------------------------
+
+const HANDLERS: Record HookResult> = {
+ SessionStart: handleSessionStart,
+ UserPromptSubmit: handleUserPromptSubmit,
+ PreToolUse: handlePreToolUse,
+ Stop: handleStop,
+ SessionEnd: handleSessionEnd,
+};
+
+/** Run one hook over the JSON Claude Code wrote to stdin. Never throws. */
+export function runHook(event: string, payloadText: string, io: HookIo): HookResult {
+ const homeDir = homeOf(io.env);
+ const handler = HANDLERS[event];
+ if (!handler) {
+ logHook(homeDir, `${event}: no such hook`, io.now());
+ return OK;
+ }
+ let payload: Payload;
+ try {
+ payload = parsePayload(payloadText);
+ } catch (error) {
+ logHook(homeDir, `${event}: bad payload: ${(error as Error).message}`, io.now());
+ return OK;
+ }
+ try {
+ return handler(payload, io);
+ } catch (error) {
+ logHook(homeDir, `${event} ${payload.session_id ?? "?"}: ${(error as Error).stack ?? String(error)}`, io.now());
+ return OK;
+ }
+}
+
+/** Where a job's state lives, for callers that want the same path this module reads. */
+export function jobStatePath(env: Env, jobId: string): string {
+ return join(claudeHome(env), "jobs", jobId, "state.json");
+}
diff --git a/packages/openfleet/src/index.ts b/packages/openfleet/src/index.ts
new file mode 100644
index 0000000..3e6a501
--- /dev/null
+++ b/packages/openfleet/src/index.ts
@@ -0,0 +1,18 @@
+/**
+ * @logicsrc/openfleet: the OpenFleet 0.1 reference implementation.
+ *
+ * The record a member carries, the ledger its sysop reads, claiming and
+ * deriving, the ceiling rules, the tree, the Claude Code hooks, and the
+ * `logicsrc fleet` verbs (exported separately as `./commands`).
+ * https://logicsrc.com/docs/openfleet
+ */
+
+export * from "./types.js";
+export * from "./store.js";
+export * from "./ceiling.js";
+export * from "./swarm.js";
+export * from "./context.js";
+export * from "./rosters.js";
+export * from "./fold.js";
+export * from "./hooks-install.js";
+export * from "./hooks.js";
diff --git a/packages/openfleet/src/rosters.ts b/packages/openfleet/src/rosters.ts
new file mode 100644
index 0000000..12f8d17
--- /dev/null
+++ b/packages/openfleet/src/rosters.ts
@@ -0,0 +1,224 @@
+/**
+ * The engine rosters a sysop tool can read (rule 14): Claude Code's
+ * `claude agents --json --all` and moshcode's `~/.moshcode/herd/sessions.json`.
+ * They add liveness to recorded members and are the only place a member with
+ * no record exists. Neither is required; a roster that cannot be read is null
+ * and the tree says nothing about liveness for that engine.
+ */
+
+import { execFile } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { Env } from "./store.js";
+import { readJson } from "./store.js";
+import type { Approvals, RosterRow, Rosters } from "./types.js";
+
+export interface ExecResult {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+/** Run a program with an argv array, never a shell string. */
+export type Exec = (file: string, args: string[], opts?: { timeoutMs?: number }) => Promise;
+
+export const realExec: Exec = (file, args, opts = {}) =>
+ new Promise((resolve) => {
+ execFile(file, args, { timeout: opts.timeoutMs ?? 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
+ const code = error ? ((error as NodeJS.ErrnoException & { code?: unknown }).code as number | string | undefined) : 0;
+ resolve({
+ code: typeof code === "number" ? code : error ? 1 : 0,
+ stdout: String(stdout ?? ""),
+ stderr: String(stderr ?? "") + (error && typeof code !== "number" ? `\n${error.message}` : ""),
+ });
+ });
+ });
+
+function homeDirOf(env: Env): string {
+ return env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir();
+}
+
+/** Where Claude Code keeps its jobs: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
+export function claudeHome(env: Env = process.env): string {
+ return env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim() !== "" ? env.CLAUDE_CONFIG_DIR : join(homeDirOf(env), ".claude");
+}
+
+/** Claude Code's own flags for a job, kept through respawn: the approvals a recordless root ran with (rule 12). */
+export function approvalsFromFlags(flags: unknown): Approvals {
+ if (!Array.isArray(flags)) return "native";
+ const list = flags.map(String);
+ if (list.includes("--dangerously-skip-permissions")) return "bypass";
+ const at = list.indexOf("--permission-mode");
+ if (at >= 0 && list[at + 1] === "bypassPermissions") return "bypass";
+ if (list.some((flag) => flag === "--permission-mode=bypassPermissions")) return "bypass";
+ return "native";
+}
+
+function isoFromMs(value: unknown): string | undefined {
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
+ return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+interface ClaudeRow {
+ pid?: number;
+ id?: string;
+ cwd?: string;
+ kind?: string;
+ startedAt?: number;
+ sessionId?: string;
+ name?: string;
+ status?: string;
+ state?: string;
+}
+
+/** `claude agents --json --all`, or null when the CLI is missing or says no. */
+export async function claudeRoster(exec: Exec, env: Env = process.env): Promise {
+ let result: ExecResult;
+ try {
+ result = await exec("claude", ["agents", "--json", "--all"], { timeoutMs: 10_000 });
+ } catch {
+ return null;
+ }
+ if (result.code !== 0) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(result.stdout);
+ } catch {
+ return null;
+ }
+ const rows: unknown[] = Array.isArray(parsed)
+ ? parsed
+ : parsed && typeof parsed === "object"
+ ? (Object.values(parsed as Record).find(Array.isArray) as unknown[] | undefined) ?? []
+ : [];
+ const out: RosterRow[] = [];
+ for (const raw of rows) {
+ const row = raw as ClaudeRow;
+ if (!row || typeof row.id !== "string") continue;
+ const state = readJson<{ respawnFlags?: unknown }>(join(claudeHome(env), "jobs", row.id, "state.json"));
+ out.push({
+ engine: "claude-code",
+ id: row.id,
+ ...(row.sessionId ? { sessionId: row.sessionId } : {}),
+ ...(row.name ? { name: row.name } : {}),
+ ...(row.cwd ? { cwd: row.cwd } : {}),
+ ...(row.state ? { state: row.state } : {}),
+ approvals: approvalsFromFlags(state?.respawnFlags),
+ ...(isoFromMs(row.startedAt) ? { startedAt: isoFromMs(row.startedAt) } : {}),
+ ...(typeof row.pid === "number" ? { pid: row.pid } : {}),
+ });
+ }
+ return out;
+}
+
+/** The flags moshcode's engines use to skip their own approval prompts. */
+const BYPASS_FLAGS = ["--dangerously-skip-permissions", "--dangerously-bypass-approvals-and-sandbox", "--yolo", "--turbo"];
+
+export function carriesBypass(args: unknown): boolean {
+ return Array.isArray(args) && args.some((arg) => BYPASS_FLAGS.includes(String(arg)));
+}
+
+/** `~/.moshcode/herd/sessions.json` (or `$MOSHCODE_HERD_DIR/sessions.json`). */
+export function moshcodeManifestPath(env: Env = process.env): string {
+ const dir = env.MOSHCODE_HERD_DIR && env.MOSHCODE_HERD_DIR.trim() !== "" ? env.MOSHCODE_HERD_DIR : join(homeDirOf(env), ".moshcode", "herd");
+ return join(dir, "sessions.json");
+}
+
+interface MoshcodeMeta {
+ engine?: string;
+ args?: unknown;
+ cwd?: string;
+ created?: number;
+ agent?: boolean;
+ herd?: string;
+ fleet?: string;
+ swarm?: string;
+ member?: string;
+ approvals?: string;
+}
+
+export async function moshcodeRoster(env: Env = process.env): Promise {
+ let text: string;
+ try {
+ text = readFileSync(moshcodeManifestPath(env), "utf8");
+ } catch {
+ return null;
+ }
+ let parsed: { sessions?: Record };
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return null;
+ }
+ const sessions = parsed && typeof parsed === "object" && parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
+ return Object.entries(sessions).map(([name, meta]) => {
+ const approvals: Approvals =
+ meta.approvals === "bypass" || meta.approvals === "native"
+ ? meta.approvals
+ : meta.agent === true || carriesBypass(meta.args)
+ ? "bypass"
+ : "native";
+ return {
+ engine: `moshcode/${meta.engine ?? "unknown"}`,
+ id: name,
+ name,
+ ...(meta.cwd ? { cwd: meta.cwd } : {}),
+ approvals,
+ ...(isoFromMs(meta.created) ? { startedAt: isoFromMs(meta.created) } : {}),
+ ...(meta.fleet ? { fleet: meta.fleet } : {}),
+ ...(meta.swarm ? { swarm: meta.swarm } : {}),
+ ...(meta.member ? { member: meta.member } : {}),
+ };
+ });
+}
+
+/** Both rosters, each reading the real thing. */
+export function defaultRosters(exec: Exec = realExec, env: Env = process.env): Rosters {
+ return {
+ claude: () => claudeRoster(exec, env),
+ moshcode: () => moshcodeRoster(env),
+ };
+}
+
+/** Which roster answers for an engine string, so "not listed" can mean "gone" rather than "unknown". */
+export function rosterFor(engine: string | undefined): keyof Rosters | null {
+ if (engine === "claude-code") return "claude";
+ // moshcode names every pane it starts, tmux ones included, in its herd manifest.
+ if (engine?.startsWith("moshcode/") || engine === "tmux") return "moshcode";
+ return null;
+}
+
+/** A Claude Code job id: the first eight hex characters of its session id. */
+export const JOB_ID_RE = /^[0-9a-f]{8}$/i;
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Can this engine's roster hold the member at all? `claude agents` lists
+ * background jobs only, so an interactive or `-p` session (a UUID member with
+ * no job id) is never in it and its absence says nothing. moshcode's manifest
+ * holds every pane it started. Only a member the roster can hold is "gone"
+ * when the roster no longer lists it.
+ */
+export function rosterHolds(engine: string | undefined, member: string, session: string | undefined): boolean {
+ const roster = rosterFor(engine);
+ if (roster === null) return false;
+ if (roster === "moshcode") return true;
+ return JOB_ID_RE.test(member) || (typeof session === "string" && JOB_ID_RE.test(session));
+}
+
+/**
+ * The job id `claude stop` takes for a claude-code member: the member id of a
+ * background job, else the first eight characters of the record's session
+ * when that is a session UUID. Null for an interactive session with no job
+ * id, which the tool cannot stop. A session equal to the member is the
+ * engine's own id echoed back in `member.start`, not a job handle.
+ */
+export function claudeJobId(member: string, session: string | undefined): string | null {
+ if (JOB_ID_RE.test(member)) return member;
+ if (typeof session !== "string" || session === member) return null;
+ if (JOB_ID_RE.test(session)) return session;
+ if (UUID_RE.test(session)) return session.slice(0, 8);
+ return null;
+}
diff --git a/packages/openfleet/src/store.test.ts b/packages/openfleet/src/store.test.ts
new file mode 100644
index 0000000..c3b97ef
--- /dev/null
+++ b/packages/openfleet/src/store.test.ts
@@ -0,0 +1,195 @@
+import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ RecordExistsError,
+ append,
+ appendOnce,
+ claimMark,
+ claimedBy,
+ endOf,
+ hasEvent,
+ hasMark,
+ home,
+ implicitFleet,
+ ledgerPaths,
+ listFleets,
+ markName,
+ readCurrent,
+ readLedger,
+ readRecord,
+ readRecords,
+ readSession,
+ recordPath,
+ swarmEndState,
+ writeCurrent,
+ writeRecord,
+ writeSession,
+} from "./store.js";
+import { FLEET, ROOT, cleanup, snapshotEnv, tempHome } from "./test-helpers.js";
+
+describe("home and the implicit fleet", () => {
+ let restore: () => void;
+ beforeEach(() => {
+ restore = snapshotEnv();
+ });
+ afterEach(() => restore());
+
+ it("is $OPENFLEET_HOME, else ~/.openfleet", () => {
+ expect(home({ OPENFLEET_HOME: "/x/fleet" })).toBe("/x/fleet");
+ expect(home({ HOME: "/home/someone" })).toBe("/home/someone/.openfleet");
+ expect(home({ OPENFLEET_HOME: " ", HOME: "/home/someone" })).toBe("/home/someone/.openfleet");
+ });
+
+ it("names the implicit fleet @ with depth 1, that host, and no approvals", () => {
+ const fleet = implicitFleet({ user: "anthony", host: "dev" });
+ expect(fleet.id).toBe("anthony@dev");
+ expect(fleet.sysop).toBe("anthony@dev");
+ expect(fleet.ceiling).toEqual({ depth: 1, hosts: ["dev"] });
+ expect("approvals" in fleet.ceiling).toBe(false);
+ });
+});
+
+describe("the record", () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = tempHome();
+ });
+ afterEach(() => cleanup(dir));
+
+ it("writes once under fleets//members/.json, 0600 in 0700 dirs, and reads back with unknown keys kept", () => {
+ const path = writeRecord(dir, { ...ROOT, extra: { kept: true } });
+ expect(path).toBe(recordPath(dir, FLEET, "460a4502"));
+ expect(path).toBe(join(dir, "fleets", FLEET, "members", "460a4502.json"));
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+ expect(statSync(join(dir, "fleets", FLEET, "members")).mode & 0o777).toBe(0o700);
+ expect(readRecord(path)).toEqual({ ...ROOT, extra: { kept: true } });
+ });
+
+ it("refuses to overwrite: a record never changes after it is written", () => {
+ writeRecord(dir, ROOT);
+ expect(() => writeRecord(dir, { ...ROOT, approvals: "native" })).toThrow(RecordExistsError);
+ expect(readRecord(recordPath(dir, FLEET, ROOT.member))?.approvals).toBe("bypass");
+ });
+
+ it("returns null for a missing record, a non-JSON file, or an object without fleet and member", () => {
+ expect(readRecord(join(dir, "nope.json"))).toBeNull();
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, "bad.json"), "{not json");
+ writeFileSync(join(dir, "thin.json"), JSON.stringify({ openfleet: "0.1" }));
+ expect(readRecord(join(dir, "bad.json"))).toBeNull();
+ expect(readRecord(join(dir, "thin.json"))).toBeNull();
+ });
+
+ it("lists every record of a fleet by member id, and every fleet with a directory", () => {
+ writeRecord(dir, ROOT);
+ writeRecord(dir, { ...ROOT, member: "b" });
+ expect([...readRecords(dir, FLEET).keys()].sort()).toEqual(["460a4502", "b"]);
+ expect(listFleets(dir)).toEqual([FLEET]);
+ expect(listFleets(join(dir, "missing"))).toEqual([]);
+ });
+});
+
+describe("the ledger", () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = tempHome();
+ });
+ afterEach(() => cleanup(dir));
+
+ it("appends one JSON line with at, event, fleet, host and by, in that order, at mode 0600", () => {
+ const line = append(dir, FLEET, { event: "fleet.open", by: "sysop", sysop: FLEET, ceiling: { depth: 2 } }, { now: new Date("2026-09-13T05:41:01.500Z"), host: "dev" });
+ expect(line).toEqual({ at: "2026-09-13T05:41:01Z", event: "fleet.open", fleet: FLEET, host: "dev", by: "sysop", sysop: FLEET, ceiling: { depth: 2 } });
+ const path = join(dir, "fleets", FLEET, "ledger.jsonl");
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+ expect(readFileSync(path, "utf8")).toBe(`${JSON.stringify(line)}\n`);
+ expect(Object.keys(JSON.parse(readFileSync(path, "utf8")))).toEqual(["at", "event", "fleet", "host", "by", "sysop", "ceiling"]);
+ });
+
+ it("refuses a line with no by or no event: who did it is never guessed", () => {
+ expect(() => append(dir, FLEET, { event: "member.end", by: "" })).toThrow(/by/);
+ expect(() => append(dir, FLEET, { event: "", by: "sysop" })).toThrow(/event/);
+ });
+
+ it("merges every ledger*.jsonl under a fleet, sorted by at, keeping file order on ties and skipping bad lines", () => {
+ append(dir, FLEET, { at: "2026-09-13T05:41:12Z", event: "member.start", by: "a", member: "a" }, { host: "dev" });
+ append(dir, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev" });
+ const remote = join(dir, "fleets", FLEET, "ledger.netcup.jsonl");
+ writeFileSync(
+ remote,
+ [
+ JSON.stringify({ at: "2026-09-13T05:41:20Z", event: "member.start", fleet: FLEET, host: "netcup", by: "b", member: "b" }),
+ "this is not json",
+ JSON.stringify({ at: "2026-09-13T05:41:36Z", event: "member.end", fleet: FLEET, host: "netcup", by: "b", member: "b", state: "done" }),
+ "",
+ ].join("\n"),
+ );
+ expect(ledgerPaths(dir, FLEET).map((path) => path.split("/").pop())).toEqual(["ledger.jsonl", "ledger.netcup.jsonl"]);
+ const lines = readLedger(dir, FLEET);
+ expect(lines.map((line) => `${line.at} ${line.event} ${line.by}`)).toEqual([
+ "2026-09-13T05:41:12Z member.start a",
+ "2026-09-13T05:41:20Z member.start b",
+ "2026-09-13T05:41:36Z member.end a",
+ "2026-09-13T05:41:36Z member.end b",
+ ]);
+ expect(readLedger(dir, "no-such-fleet")).toEqual([]);
+ });
+
+ it("answers claimed, ended and hasEvent from the lines", () => {
+ append(dir, FLEET, { at: "2026-09-13T05:41:12Z", event: "member.start", by: "a", member: "a", session: "s1" }, { host: "dev" });
+ const lines = readLedger(dir, FLEET);
+ expect(claimedBy(lines, "a")?.session).toBe("s1");
+ expect(claimedBy(lines, "b")).toBeNull();
+ expect(hasEvent(lines, "member.start", { member: "a" })).toBe(true);
+ expect(hasEvent(lines, "member.end", { member: "a" })).toBe(false);
+ expect(endOf(lines, "a")).toBeNull();
+ });
+
+ it("counts the first end line, except lost, which a real end supersedes", () => {
+ append(dir, FLEET, { at: "2026-09-13T05:42:00Z", event: "member.end", by: "sysop", member: "a", state: "lost" }, { host: "dev" });
+ expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("lost");
+ append(dir, FLEET, { at: "2026-09-13T05:43:00Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev" });
+ expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("done");
+ append(dir, FLEET, { at: "2026-09-13T05:44:00Z", event: "member.end", by: "sysop", member: "a", state: "stopped" }, { host: "dev" });
+ expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("done");
+ });
+
+ it("derives a swarm's end state: done when all done, else the first of failed, stopped, budget, timeout", () => {
+ const end = (state: string) => ({ at: "", event: "member.end", fleet: FLEET, host: "dev", by: "x", state });
+ expect(swarmEndState([end("done"), end("done")])).toBe("done");
+ expect(swarmEndState([end("done"), end("timeout"), end("stopped")])).toBe("stopped");
+ expect(swarmEndState([end("budget"), end("failed")])).toBe("failed");
+ expect(swarmEndState([end("done"), end("timeout")])).toBe("timeout");
+ expect(swarmEndState([end("done"), null])).toBeNull();
+ expect(swarmEndState([end("lost")])).toBe("failed");
+ });
+
+ it("takes a once-marker with an exclusive create, so a racing second writer writes nothing", () => {
+ expect(markName("member.start", "a")).toBe("member.start.a");
+ expect(markName("member.end", "a", true)).toBe("member.end.a.lost");
+ expect(markName("swarm.end", "s-1")).toBe("swarm.end.s-1");
+ expect(hasMark(dir, FLEET, "member.start.a")).toBe(false);
+ expect(claimMark(dir, FLEET, "member.start.a")).toBe(true);
+ expect(claimMark(dir, FLEET, "member.start.a")).toBe(false);
+ expect(hasMark(dir, FLEET, "member.start.a")).toBe(true);
+ const path = join(dir, "fleets", FLEET, "marks", "member.start.a");
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+ expect(statSync(join(dir, "fleets", FLEET, "marks")).mode & 0o777).toBe(0o700);
+ const first = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev", once: markName("member.end", "a") });
+ expect(first?.state).toBe("done");
+ const second = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:37Z", event: "member.end", by: "sysop", member: "a", state: "stopped" }, { host: "dev", once: markName("member.end", "a") });
+ expect(second).toBeNull();
+ expect(readLedger(dir, FLEET).filter((line) => line.event === "member.end").length).toBe(1);
+ });
+
+ it("keeps current and the per-session file under the home", () => {
+ expect(readCurrent(dir)).toBeNull();
+ writeCurrent(dir, "fleet-20260913");
+ expect(readCurrent(dir)).toBe("fleet-20260913");
+ expect(readSession(dir, "abc")).toBeNull();
+ writeSession(dir, "abc", { record: null, recordPath: null, member: null, fleet: FLEET, swarm: null, last_message: "hi" });
+ expect(readSession(dir, "abc")?.last_message).toBe("hi");
+ expect(existsSync(join(dir, "sessions", "abc.json"))).toBe(true);
+ expect(statSync(join(dir, "sessions", "abc.json")).mode & 0o777).toBe(0o600);
+ });
+});
diff --git a/packages/openfleet/src/store.ts b/packages/openfleet/src/store.ts
new file mode 100644
index 0000000..bd6cea9
--- /dev/null
+++ b/packages/openfleet/src/store.ts
@@ -0,0 +1,453 @@
+/**
+ * Where the record and the ledger live, and how they are read and written.
+ *
+ * Everything takes an explicit `home` so tests point at a temp directory and
+ * two writers on one box (this package and moshcode) resolve the same paths
+ * from the same variable. Files are 0600 and directories 0700: a record names
+ * a working directory and a task, and a ledger says what every agent under one
+ * account did, so neither is anyone else's business.
+ */
+
+import {
+ appendFileSync,
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ renameSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir, hostname, userInfo } from "node:os";
+import { basename, dirname, join } from "node:path";
+import type { Ceiling, EndState, FleetRecord, LedgerInput, LedgerLine } from "./types.js";
+
+export type Env = Record;
+
+const FILE_MODE = 0o600;
+const DIR_MODE = 0o700;
+
+/** `$OPENFLEET_HOME`, default `~/.openfleet`. */
+export function home(env: Env = process.env): string {
+ const fromEnv = env.OPENFLEET_HOME;
+ if (fromEnv && fromEnv.trim() !== "") return fromEnv;
+ return join(env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir(), ".openfleet");
+}
+
+/** This machine's hostname, the `host` every line carries. */
+export function thisHost(): string {
+ return hostname();
+}
+
+/** The account running this process, for the implicit fleet id. */
+export function thisUser(): string {
+ try {
+ return userInfo().username;
+ } catch {
+ return process.env.USER ?? process.env.LOGNAME ?? "user";
+ }
+}
+
+export interface ImplicitFleet {
+ id: string;
+ sysop: string;
+ ceiling: Ceiling;
+ host: string;
+}
+
+/**
+ * The fleet a member belongs to when none was opened: `@`, sysop
+ * the same, ceiling depth 1 and this host, and no fleet-level `approvals`
+ * (each hand-started root supplies its own).
+ */
+export function implicitFleet(opts: { user?: string; host?: string } = {}): ImplicitFleet {
+ const user = opts.user ?? thisUser();
+ const host = opts.host ?? thisHost();
+ const id = `${user}@${host}`;
+ return { id, sysop: id, ceiling: { depth: 1, hosts: [host] }, host };
+}
+
+export function fleetDir(homeDir: string, fleet: string): string {
+ return join(homeDir, "fleets", fleet);
+}
+
+export function membersDir(homeDir: string, fleet: string): string {
+ return join(fleetDir(homeDir, fleet), "members");
+}
+
+/** `$OPENFLEET_HOME/fleets//members/.json`. */
+export function recordPath(homeDir: string, fleet: string, member: string): string {
+ return join(membersDir(homeDir, fleet), `${member}.json`);
+}
+
+/** The ledger this host writes. */
+export function ledgerPath(homeDir: string, fleet: string): string {
+ return join(fleetDir(homeDir, fleet), "ledger.jsonl");
+}
+
+function mkdirPrivate(dir: string): void {
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE });
+}
+
+function writePrivate(path: string, body: string): void {
+ mkdirPrivate(dirname(path));
+ // Write-then-rename so a reader never sees half a record.
+ const tmp = `${path}.${process.pid}.tmp`;
+ writeFileSync(tmp, body, { encoding: "utf8", mode: FILE_MODE });
+ renameSync(tmp, path);
+}
+
+export function readJson(path: string): T | null {
+ let text: string;
+ try {
+ text = readFileSync(path, "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ return null;
+ }
+}
+
+/** The record at a path, or null when there is none or it is not JSON. */
+export function readRecord(path: string): FleetRecord | null {
+ const parsed = readJson(path);
+ if (!parsed || typeof parsed !== "object") return null;
+ if (typeof parsed.fleet !== "string" || typeof parsed.member !== "string") return null;
+ return parsed;
+}
+
+export class RecordExistsError extends Error {
+ constructor(readonly path: string) {
+ super(`record already exists: ${path}`);
+ }
+}
+
+/**
+ * Write a member's record. Refuses to overwrite: a record is written once,
+ * before the member starts, and never changes after `member.start` (rule 7).
+ * Returns the path written.
+ */
+export function writeRecord(homeDir: string, record: FleetRecord): string {
+ const path = recordPath(homeDir, record.fleet, record.member);
+ if (existsSync(path)) throw new RecordExistsError(path);
+ writePrivate(path, `${JSON.stringify(record, null, 2)}\n`);
+ return path;
+}
+
+/**
+ * Rewrite a record that is still unclaimed. The caller has checked the ledger:
+ * a record never changes after `member.start`, but before it the starter may
+ * learn something it guessed (a hand-started root's real approvals).
+ */
+export function replaceUnclaimedRecord(homeDir: string, record: FleetRecord): string {
+ const path = recordPath(homeDir, record.fleet, record.member);
+ writePrivate(path, `${JSON.stringify(record, null, 2)}\n`);
+ return path;
+}
+
+/** Every record under a fleet, by member id. */
+export function readRecords(homeDir: string, fleet: string): Map {
+ const out = new Map();
+ const dir = membersDir(homeDir, fleet);
+ let names: string[];
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return out;
+ }
+ for (const name of names.sort()) {
+ if (!name.endsWith(".json")) continue;
+ const record = readRecord(join(dir, name));
+ if (record) out.set(record.member, record);
+ }
+ return out;
+}
+
+/** The fleets that have a directory under `home`. */
+export function listFleets(homeDir: string): string[] {
+ try {
+ return readdirSync(join(homeDir, "fleets"), { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Every `ledger*.jsonl` under a fleet: this host's `ledger.jsonl` plus any
+ * `ledger..jsonl` copied in from another host.
+ */
+export function ledgerPaths(homeDir: string, fleet: string): string[] {
+ const dir = fleetDir(homeDir, fleet);
+ let names: string[];
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return [];
+ }
+ return names
+ .filter((name) => /^ledger.*\.jsonl$/.test(name))
+ .sort()
+ .map((name) => join(dir, name));
+}
+
+/** ISO 8601 UTC with whole seconds, the way the spec's examples read. */
+export function isoNow(now: Date = new Date()): string {
+ return now.toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+/**
+ * Append one line to this host's ledger for a fleet. Adds `at`, `fleet` and
+ * `host`; the caller passes `event` and `by`, because who did it is the one
+ * thing this module must not guess. Returns the line as written.
+ */
+export function append(
+ homeDir: string,
+ fleet: string,
+ input: LedgerInput,
+ opts: { now?: Date; host?: string } = {},
+): LedgerLine {
+ if (!input.by) throw new Error("ledger line needs `by`: sysop or a member id");
+ if (!input.event) throw new Error("ledger line needs `event`");
+ const { at: givenAt, host: givenHost, event, by, ...rest } = input as LedgerInput & { event: string; by: string };
+ // Key order is fixed so every writer's lines read the same way.
+ const line: LedgerLine = {
+ at: typeof givenAt === "string" ? givenAt : isoNow(opts.now),
+ event,
+ fleet,
+ host: typeof givenHost === "string" ? givenHost : (opts.host ?? thisHost()),
+ by,
+ ...rest,
+ };
+ const path = ledgerPath(homeDir, fleet);
+ mkdirPrivate(dirname(path));
+ appendFileSync(path, `${JSON.stringify(line)}\n`, { encoding: "utf8", mode: FILE_MODE });
+ try {
+ if ((statSync(path).mode & 0o777) !== FILE_MODE) chmodSync(path, FILE_MODE);
+ } catch {
+ // A ledger we cannot chmod is still a ledger we appended to.
+ }
+ return line;
+}
+
+// ---------------------------------------------------------------------------
+// Once-markers: the lines that must never be written twice
+// ---------------------------------------------------------------------------
+//
+// `member.start`, `member.end` and `swarm.end` are checked in the ledger before
+// they are written, but a check followed by an append is not exclusion: the
+// engine's hook and the spawner fire at the same moment. So each such line
+// takes a marker first, an exclusive create under `fleets//marks/`, and
+// the writer that loses the race writes nothing. Both reference writers use
+// the same paths, so the rule holds across moshcode and these hooks.
+
+export type OnceEvent = "member.start" | "member.end" | "swarm.end";
+
+export function marksDir(homeDir: string, fleet: string): string {
+ return join(fleetDir(homeDir, fleet), "marks");
+}
+
+/**
+ * The marker a line takes: `.`, where id is the member or the
+ * swarm. A `lost` end takes `member.end..lost` instead, so the engine's
+ * or the spawner's real end can still supersede it and take the plain one.
+ */
+export function markName(event: OnceEvent, id: string, lost = false): string {
+ return `${event}.${id}${lost ? ".lost" : ""}`;
+}
+
+export function hasMark(homeDir: string, fleet: string, name: string): boolean {
+ return existsSync(join(marksDir(homeDir, fleet), name));
+}
+
+/** Take a marker with an exclusive create (0600 in a 0700 dir). False when another writer holds it. */
+export function claimMark(homeDir: string, fleet: string, name: string): boolean {
+ const dir = marksDir(homeDir, fleet);
+ mkdirPrivate(dir);
+ try {
+ writeFileSync(join(dir, name), "", { encoding: "utf8", flag: "wx", mode: FILE_MODE });
+ return true;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
+ throw error;
+ }
+}
+
+/**
+ * Append a line only when its once-marker is free. Null means another writer
+ * already holds the marker: report "already" and write nothing.
+ */
+export function appendOnce(
+ homeDir: string,
+ fleet: string,
+ input: LedgerInput,
+ opts: { now?: Date; host?: string; once: string },
+): LedgerLine | null {
+ if (!claimMark(homeDir, fleet, opts.once)) return null;
+ return append(homeDir, fleet, input, { now: opts.now, host: opts.host });
+}
+
+function parseLines(text: string): LedgerLine[] {
+ const out: LedgerLine[] = [];
+ for (const raw of text.split("\n")) {
+ const line = raw.trim();
+ if (line === "") continue;
+ try {
+ const parsed = JSON.parse(line) as LedgerLine;
+ if (parsed && typeof parsed === "object" && typeof parsed.event === "string") out.push(parsed);
+ } catch {
+ // One bad line must not hide the rest of the ledger.
+ }
+ }
+ return out;
+}
+
+/** Every ledger under a fleet, merged and sorted by `at` (stable, so one file's order holds on ties). */
+export function readLedger(homeDir: string, fleet: string): LedgerLine[] {
+ const lines: LedgerLine[] = [];
+ for (const path of ledgerPaths(homeDir, fleet)) {
+ let text: string;
+ try {
+ text = readFileSync(path, "utf8");
+ } catch {
+ continue;
+ }
+ lines.push(...parseLines(text));
+ }
+ return lines.map((line, index) => ({ line, index })).sort((a, b) => {
+ const byAt = String(a.line.at ?? "").localeCompare(String(b.line.at ?? ""));
+ return byAt !== 0 ? byAt : a.index - b.index;
+ }).map((entry) => entry.line);
+}
+
+type Match = Partial>;
+
+function matches(line: LedgerLine, match: Match): boolean {
+ for (const [key, value] of Object.entries(match)) {
+ if (value === undefined) continue;
+ if (line[key] !== value) return false;
+ }
+ return true;
+}
+
+/** The lines of one event whose keys match, in ledger order. */
+export function findEvents(lines: LedgerLine[], event: string, match: Match = {}): LedgerLine[] {
+ return lines.filter((line) => line.event === event && matches(line, match));
+}
+
+export function hasEvent(lines: LedgerLine[], event: string, match: Match = {}): boolean {
+ return lines.some((line) => line.event === event && matches(line, match));
+}
+
+/** The `member.start` that claimed a record, or null when the record is unclaimed. */
+export function claimedBy(lines: LedgerLine[], member: string): LedgerLine | null {
+ return findEvents(lines, "member.start", { member })[0] ?? null;
+}
+
+/**
+ * The end line that counts for a member: the first written, except `lost`,
+ * which a later real end supersedes. Null when the member has not ended.
+ */
+export function endOf(lines: LedgerLine[], member: string): LedgerLine | null {
+ const ends = findEvents(lines, "member.end", { member });
+ if (ends.length === 0) return null;
+ const real = ends.find((line) => line.state !== "lost");
+ return real ?? ends[0];
+}
+
+/** The swarm.end for a swarm, or null: one per swarm, never two. */
+export function swarmEndOf(lines: LedgerLine[], swarm: string): LedgerLine | null {
+ return findEvents(lines, "swarm.end", { swarm })[0] ?? null;
+}
+
+export function spawnOf(lines: LedgerLine[], swarm: string): LedgerLine | null {
+ return findEvents(lines, "swarm.spawn", { swarm })[0] ?? null;
+}
+
+/** Latest `member.spend` total per member under a list of members. */
+export function latestSpend(lines: LedgerLine[], member: string): string | undefined {
+ const spends = findEvents(lines, "member.spend", { member });
+ return spends.length ? spends[spends.length - 1].total : undefined;
+}
+
+/**
+ * The state a swarm ends with, from its members' end lines: `done` when every
+ * member ended `done`, else the first of failed, stopped, budget, timeout found.
+ * Null when a member has no end line yet.
+ */
+export function swarmEndState(ends: Array): EndState | null {
+ if (ends.some((end) => end === null)) return null;
+ const states = ends.map((end) => String(end!.state));
+ if (states.every((state) => state === "done")) return "done";
+ for (const candidate of ["failed", "stopped", "budget", "timeout"] as const) {
+ if (states.includes(candidate)) return candidate;
+ }
+ // Only `lost` members remain: nothing real to report, the closest truth is failed.
+ return "failed";
+}
+
+/** `$OPENFLEET_HOME/current`: the fleet the account's next root member joins. */
+export function currentPath(homeDir: string): string {
+ return join(homeDir, "current");
+}
+
+export function readCurrent(homeDir: string): string | null {
+ try {
+ const value = readFileSync(currentPath(homeDir), "utf8").trim();
+ return value === "" ? null : value;
+ } catch {
+ return null;
+ }
+}
+
+export function writeCurrent(homeDir: string, fleet: string): void {
+ writePrivate(currentPath(homeDir), `${fleet}\n`);
+}
+
+// ---------------------------------------------------------------------------
+// The per-session file the Claude Code hooks key on
+// ---------------------------------------------------------------------------
+//
+// Not part of the spec. `OPENFLEET_*` exported at SessionStart reach the
+// member's tools but not later hook processes, so every hook after the first
+// looks its member up by session id here.
+
+export interface SessionFile {
+ record: FleetRecord | null;
+ recordPath: string | null;
+ member: string | null;
+ fleet: string;
+ swarm: string | null;
+ last_message: string | null;
+ /** Set when the start was refused at the ceiling, with the reason to repeat. */
+ refused?: { key: string; reason: string } | null;
+ [key: string]: unknown;
+}
+
+export function sessionPath(homeDir: string, sessionId: string): string {
+ return join(homeDir, "sessions", `${basename(sessionId)}.json`);
+}
+
+export function readSession(homeDir: string, sessionId: string): SessionFile | null {
+ return readJson(sessionPath(homeDir, sessionId));
+}
+
+export function writeSession(homeDir: string, sessionId: string, session: SessionFile): void {
+ writePrivate(sessionPath(homeDir, sessionId), `${JSON.stringify(session, null, 2)}\n`);
+}
+
+/** `$OPENFLEET_HOME/hooks.log`: where a hook says what went wrong, since it may not tell the engine. */
+export function logHook(homeDir: string, message: string, now: Date = new Date()): void {
+ try {
+ mkdirPrivate(homeDir);
+ appendFileSync(join(homeDir, "hooks.log"), `${isoNow(now)} ${message}\n`, { encoding: "utf8", mode: FILE_MODE });
+ } catch {
+ // Logging must never be the thing that fails.
+ }
+}
diff --git a/packages/openfleet/src/swarm.test.ts b/packages/openfleet/src/swarm.test.ts
new file mode 100644
index 0000000..2edcb9b
--- /dev/null
+++ b/packages/openfleet/src/swarm.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { NAME_RE, memberId, nextSwarmOfOne, slug, swarmId } from "./swarm.js";
+
+describe("swarm ids", () => {
+ it("mints -: the spec's create-two-0541", () => {
+ expect(swarmId("create two ...", new Date("2026-09-13T05:41:01Z"))).toBe("create-two-0541");
+ expect(swarmId("Ship the OpenFleet reference implementation, then promote it", new Date("2026-09-13T23:59:59Z"))).toBe("ship-the-openfleet-refe-2359");
+ });
+
+ it("slugs to a leading letter, lowercase, at most 23 characters, never empty", () => {
+ expect(slug("2024 report")).toBe("report");
+ expect(slug("!!!")).toBe("swarm");
+ expect(slug("a".repeat(40)).length).toBe(23);
+ expect(slug("abc-def-ghi-jkl-mno-pqr-stu")).toBe("abc-def-ghi-jkl-mno-pqr");
+ });
+
+ it("member ids stay valid moshcode pane names up to sixteen members", () => {
+ const swarm = swarmId("x".repeat(30), new Date("2026-09-13T05:41:01Z"));
+ for (let n = 1; n <= 16; n += 1) expect(memberId(swarm, n)).toMatch(NAME_RE);
+ });
+
+ it("mints a swarm of one as - past the ones already minted", () => {
+ expect(nextSwarmOfOne("460a4502", [])).toBe("460a4502-1");
+ expect(nextSwarmOfOne("460a4502", ["460a4502-1", "create-two-0541", "460a4502-3"])).toBe("460a4502-4");
+ expect(nextSwarmOfOne("a.b", ["a.b-2", "axb-9"])).toBe("a.b-3");
+ });
+});
diff --git a/packages/openfleet/src/swarm.ts b/packages/openfleet/src/swarm.ts
new file mode 100644
index 0000000..2af6f10
--- /dev/null
+++ b/packages/openfleet/src/swarm.ts
@@ -0,0 +1,47 @@
+/**
+ * Swarm and member ids.
+ *
+ * A swarm id is a short slug of the task and the UTC minute it was minted:
+ * `create-two-0541`. Member ids in a swarm are `-`. Both double as
+ * moshcode pane names, which must match `^[a-z][a-z0-9_-]{0,31}$`, so the slug
+ * is capped at 23 characters and starts with a letter: 23 + 5 for the minute
+ * + 3 for `-16` stays inside 32.
+ */
+
+export const NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
+
+export const SLUG_MAX = 23;
+
+/** Lowercase, dashes for anything else, leading letter, at most `max` characters, never empty. */
+export function slug(text: string, max = SLUG_MAX): string {
+ let out = String(text ?? "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^[^a-z]+/, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ if (out.length > max) out = out.slice(0, max).replace(/-+$/, "");
+ return out || "swarm";
+}
+
+/** `-`, e.g. `create-two-0541` for "create two ..." minted at 05:41Z. */
+export function swarmId(task: string, now: Date = new Date()): string {
+ const hh = String(now.getUTCHours()).padStart(2, "0");
+ const mm = String(now.getUTCMinutes()).padStart(2, "0");
+ return `${slug(task)}-${hh}${mm}`;
+}
+
+export function memberId(swarm: string, n: number): string {
+ return `${swarm}-${n}`;
+}
+
+/** `-` for a swarm of one, with `n` one past the swarms already minted under that parent. */
+export function nextSwarmOfOne(parent: string, existingSwarms: string[]): string {
+ const pattern = new RegExp(`^${parent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d+)$`);
+ let max = 0;
+ for (const swarm of existingSwarms) {
+ const match = swarm.match(pattern);
+ if (match) max = Math.max(max, Number(match[1]));
+ }
+ return `${parent}-${max + 1}`;
+}
diff --git a/packages/openfleet/src/test-helpers.ts b/packages/openfleet/src/test-helpers.ts
new file mode 100644
index 0000000..9f337ce
--- /dev/null
+++ b/packages/openfleet/src/test-helpers.ts
@@ -0,0 +1,126 @@
+/**
+ * Shared fixtures for the tests: a temp home, the implicit fleet of the
+ * spec's worked example (`anthony@dev`), and the ledger and records that
+ * morning should have produced. Excluded from the build.
+ */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { append, implicitFleet, writeRecord, type Env, type ImplicitFleet } from "./store.js";
+import type { FleetRecord } from "./types.js";
+
+export const DEV: ImplicitFleet = implicitFleet({ user: "anthony", host: "dev" });
+export const FLEET = DEV.id;
+
+export function tempHome(): string {
+ return mkdtempSync(join(tmpdir(), "openfleet-test-"));
+}
+
+export function cleanup(dir: string): void {
+ rmSync(dir, { recursive: true, force: true });
+}
+
+const OPENFLEET_KEYS = ["OPENFLEET_HOME", "OPENFLEET_RECORD", "OPENFLEET_FLEET", "OPENFLEET_MEMBER", "OPENFLEET_SWARM"] as const;
+
+/** Snapshot the OPENFLEET_* keys of process.env and hand back a restore, so one test's environment never leaks into another file's. */
+export function snapshotEnv(): () => void {
+ const saved: Record = {};
+ for (const key of OPENFLEET_KEYS) saved[key] = process.env[key];
+ return () => {
+ for (const key of OPENFLEET_KEYS) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+ };
+}
+
+/** A clean environment for a test: a home and nothing else OpenFleet knows. */
+export function envFor(home: string, extra: Env = {}): Env {
+ return { OPENFLEET_HOME: home, HOME: home, ...extra };
+}
+
+export const ROOT: FleetRecord = {
+ openfleet: "0.1",
+ fleet: FLEET,
+ sysop: FLEET,
+ member: "460a4502",
+ depth: 0,
+ engine: "claude-code",
+ host: "dev",
+ cwd: "/home/anthony",
+ started: "2026-09-13T04:55:00Z",
+ approvals: "bypass",
+ ceiling: { approvals: "bypass", depth: 1, hosts: ["dev"] },
+};
+
+export const PIECE_CEILING = { approvals: "bypass" as const, depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" };
+
+export const PIECE_1: FleetRecord = {
+ openfleet: "0.1",
+ fleet: FLEET,
+ sysop: FLEET,
+ member: "create-two-0541-1",
+ parent: "460a4502",
+ swarm: "create-two-0541",
+ task: "create two ...",
+ piece: { title: "create hello.sh bash", owns: ["hello.sh"] },
+ depth: 1,
+ engine: "claude-code",
+ host: "dev",
+ cwd: "/home/anthony/.claude/jobs/460a4502/tmp/swarm-live",
+ started: "2026-09-13T05:41:01Z",
+ approvals: "bypass",
+ ceiling: { ...PIECE_CEILING },
+};
+
+export const PIECE_2: FleetRecord = {
+ ...PIECE_1,
+ member: "create-two-0541-2",
+ piece: { title: "create bye.sh bash", owns: ["bye.sh"] },
+ engine: "moshcode/claude",
+ session: "create-two-0541-2",
+};
+
+export const PLANNER = "9f1c2d3e-0000-4000-8000-000000000001";
+
+/**
+ * The morning up to the moment the two pieces are written and unclaimed:
+ * the root, its planner swarm of one (done), and `swarm.spawn` for
+ * create-two-0541 with both records on disk.
+ */
+export function seedWorkedExample(home: string): void {
+ writeRecord(home, ROOT);
+ append(home, FLEET, { at: "2026-09-13T04:55:01Z", event: "member.start", by: "460a4502", member: "460a4502", session: "460a4502", depth: 0, engine: "claude-code", cwd: "/home/anthony", approvals: "bypass" }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:40:50Z", event: "swarm.spawn", by: "460a4502", swarm: "460a4502-1", task: 'claude -p "Split the task below into at most 4 ..."', ceiling: {}, pieces: [{ member: PLANNER }] }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:40:51Z", event: "member.start", by: PLANNER, member: PLANNER, session: "4242", swarm: "460a4502-1", parent: "460a4502", depth: 1, engine: "claude-p", approvals: "bypass" }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:40:59Z", event: "member.end", by: PLANNER, member: PLANNER, state: "done" }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:40:59Z", event: "swarm.end", by: PLANNER, swarm: "460a4502-1", state: "done" }, { host: "dev" });
+ append(
+ home,
+ FLEET,
+ {
+ at: "2026-09-13T05:41:01Z",
+ event: "swarm.spawn",
+ by: "460a4502",
+ swarm: "create-two-0541",
+ task: "create two ...",
+ ceiling: { fan_out: 4, until: "2026-09-13T06:11:01Z" },
+ pieces: [
+ { member: "create-two-0541-1", title: "create hello.sh bash", owns: ["hello.sh"] },
+ { member: "create-two-0541-2", title: "create bye.sh bash", owns: ["bye.sh"] },
+ ],
+ },
+ { host: "dev" },
+ );
+ writeRecord(home, PIECE_1);
+ writeRecord(home, PIECE_2);
+}
+
+/** The rest of the morning: both pieces claimed, the first done. */
+export function seedWorkedExampleToEnd(home: string): void {
+ seedWorkedExample(home);
+ append(home, FLEET, { at: "2026-09-13T05:41:12Z", event: "member.start", by: "create-two-0541-1", member: "create-two-0541-1", session: "172ffd83", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "claude-code", cwd: PIECE_1.cwd, approvals: "bypass", piece: PIECE_1.piece }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:41:13Z", event: "member.start", by: "create-two-0541-2", member: "create-two-0541-2", session: "create-two-0541-2", swarm: "create-two-0541", parent: "460a4502", depth: 1, engine: "moshcode/claude", cwd: PIECE_2.cwd, approvals: "bypass", piece: PIECE_2.piece }, { host: "dev" });
+ append(home, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "create-two-0541-1", member: "create-two-0541-1", state: "done", summary: "Created hello.sh, mode -rwxrwxr-x, prints hello." }, { host: "dev" });
+}
diff --git a/packages/openfleet/src/types.ts b/packages/openfleet/src/types.ts
new file mode 100644
index 0000000..e5de817
--- /dev/null
+++ b/packages/openfleet/src/types.ts
@@ -0,0 +1,240 @@
+/**
+ * The shapes OpenFleet 0.1 defines: the record a member carries, the ceiling,
+ * and the eight ledger events. Names and keys follow docs/openfleet.md to the
+ * letter, because moshcode and Claude Code write the same files and the two
+ * writers must agree byte for byte.
+ */
+
+export const OPENFLEET_VERSION = "0.1";
+
+export type Approvals = "native" | "bypass";
+
+/** The programs that run a member and can end it. Lowercase, as the spec lists them. */
+export type Engine =
+ | "claude-code"
+ | "moshcode/claude"
+ | "moshcode/codex"
+ | "moshcode/deepseek"
+ | "moshcode/kimi"
+ | "claude-p"
+ | "tmux";
+
+export const ENGINES: readonly Engine[] = [
+ "claude-code",
+ "moshcode/claude",
+ "moshcode/codex",
+ "moshcode/deepseek",
+ "moshcode/kimi",
+ "claude-p",
+ "tmux",
+];
+
+/** The most a fleet, a swarm or a member may do. Every key optional; absent means the spec's default. */
+export interface Ceiling {
+ approvals?: Approvals;
+ /** ` ` or ` tokens`. */
+ budget?: string;
+ depth?: number;
+ fan_out?: number;
+ hosts?: string[];
+ /** ISO 8601 UTC. */
+ until?: string;
+ [key: string]: unknown;
+}
+
+export const CEILING_KEYS = ["approvals", "budget", "depth", "fan_out", "hosts", "until"] as const;
+export type CeilingKey = (typeof CEILING_KEYS)[number];
+
+export interface Piece {
+ title?: string;
+ owns?: string[];
+ [key: string]: unknown;
+}
+
+/** One JSON object per member. Unknown keys are kept. */
+export interface FleetRecord {
+ openfleet: string;
+ fleet: string;
+ sysop: string;
+ member: string;
+ parent?: string;
+ orphan?: boolean;
+ swarm?: string;
+ task?: string;
+ piece?: Piece;
+ depth?: number;
+ engine?: Engine | string;
+ session?: string;
+ host?: string;
+ cwd?: string;
+ started?: string;
+ approvals?: Approvals;
+ ceiling?: Ceiling;
+ [key: string]: unknown;
+}
+
+export type EndState = "done" | "failed" | "stopped" | "budget" | "timeout" | "lost";
+
+export const END_STATES: readonly EndState[] = ["done", "failed", "stopped", "budget", "timeout", "lost"];
+
+export type EventName =
+ | "fleet.open"
+ | "fleet.cap"
+ | "swarm.spawn"
+ | "member.start"
+ | "member.spend"
+ | "member.end"
+ | "swarm.end"
+ | "ceiling.refuse";
+
+/** One piece of a swarm, as written in `swarm.spawn`. */
+export interface SpawnPiece {
+ member: string;
+ title?: string;
+ owns?: string[];
+ [key: string]: unknown;
+}
+
+/** The keys every ledger line carries, then the event's own. */
+export interface LedgerLine {
+ at: string;
+ event: EventName | string;
+ fleet: string;
+ host: string;
+ /** `sysop`, or a member id. */
+ by: string;
+ sysop?: string;
+ ceiling?: Ceiling;
+ target?: string;
+ swarm?: string;
+ parent_swarm?: string;
+ task?: string;
+ pieces?: SpawnPiece[];
+ member?: string;
+ session?: string;
+ parent?: string;
+ depth?: number;
+ engine?: string;
+ cwd?: string;
+ approvals?: Approvals;
+ piece?: Piece;
+ amount?: string;
+ total?: string;
+ state?: EndState | string;
+ summary?: string;
+ links?: unknown[];
+ verdict?: unknown;
+ action?: "start" | "spawn" | string;
+ key?: string;
+ wanted?: unknown;
+ allowed?: unknown;
+ [key: string]: unknown;
+}
+
+/** What the caller passes to `append`: everything but `at`, `fleet` and `host`. */
+export type LedgerInput = { event: EventName | string; by: string; at?: string; host?: string; [key: string]: unknown };
+
+/** A refusal from `checkCeiling`: which key, what was wanted, what was allowed. */
+export interface Refusal {
+ key: CeilingKey;
+ wanted: unknown;
+ allowed: unknown;
+}
+
+// ---------------------------------------------------------------------------
+// The tree `fold` builds and `tree` renders
+// ---------------------------------------------------------------------------
+
+export type MemberState = "unclaimed" | "working" | EndState;
+
+export interface MemberNode {
+ member: string;
+ session?: string;
+ title?: string;
+ task?: string;
+ engine?: string;
+ host?: string;
+ depth: number;
+ state: MemberState;
+ approvals: Approvals;
+ owns?: string[];
+ orphan?: boolean;
+ /** True when the row exists only in an engine's roster, with no record and no ledger line. */
+ roster?: boolean;
+ /** Liveness from the engine's roster: true listed, false not listed, undefined when no roster was readable. */
+ alive?: boolean;
+ /** Latest `member.spend` total, as written. */
+ spend?: string;
+ started?: string;
+ ended?: string;
+ summary?: string;
+ parent?: string;
+ swarm?: string;
+ swarms: SwarmNode[];
+}
+
+export interface SwarmNode {
+ swarm: string;
+ task?: string;
+ by: string;
+ parent_swarm?: string;
+ /** The keys the spawner narrowed, as written in `swarm.spawn`. */
+ ceiling: Ceiling;
+ /**
+ * The swarm's effective ceiling: the fleet's merged down the swarm path
+ * with the latest caps last. In the implicit fleet `approvals` enters at
+ * each root and is not resolved here; fan_out, budget and until are.
+ */
+ effective?: Ceiling;
+ members: MemberNode[];
+ swarms: SwarmNode[];
+ state?: EndState;
+ summary?: string;
+ /** Sums of `member.spend` totals under this swarm, one entry per unit. */
+ spend: Record;
+}
+
+export interface FleetNode {
+ fleet: string;
+ sysop: string;
+ implicit: boolean;
+ ceiling: Ceiling;
+ roots: MemberNode[];
+ /** Swarms the sysop started by hand: `by: "sysop"` with no parent swarm. */
+ swarms: SwarmNode[];
+ spend: Record;
+}
+
+export interface Tree {
+ fleets: FleetNode[];
+}
+
+// ---------------------------------------------------------------------------
+// Engine rosters
+// ---------------------------------------------------------------------------
+
+/** One row of an engine's own roster, normalised across engines. */
+export interface RosterRow {
+ engine: Engine | string;
+ /** The engine's own id for the session: a job id, a pane name. */
+ id: string;
+ /** A second id the engine knows, when it has one: Claude Code's full session id. */
+ sessionId?: string;
+ name?: string;
+ cwd?: string;
+ state?: string;
+ approvals?: Approvals;
+ startedAt?: string;
+ /** What the engine's roster says about fleet placement, when it says anything. */
+ fleet?: string;
+ swarm?: string;
+ member?: string;
+ pid?: number;
+}
+
+export interface Rosters {
+ /** `claude agents --json --all`, when the CLI is present. */
+ claude?: () => Promise;
+ /** `~/.moshcode/herd/sessions.json`, when it exists. */
+ moshcode?: () => Promise;
+}
diff --git a/packages/openfleet/tsconfig.json b/packages/openfleet/tsconfig.json
new file mode 100644
index 0000000..c6bc9db
--- /dev/null
+++ b/packages/openfleet/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.test.ts", "src/test-helpers.ts"]
+}
diff --git a/prd/0008-openfleet-reference-implementation.md b/prd/0008-openfleet-reference-implementation.md
new file mode 100644
index 0000000..d44df60
--- /dev/null
+++ b/prd/0008-openfleet-reference-implementation.md
@@ -0,0 +1,154 @@
+---
+openprd: "0.3"
+id: "0008"
+title: "Ship the OpenFleet reference implementation"
+status: Draft
+authors:
+ - anthony@profullstack.com
+created: 2026-09-13
+updated: 2026-09-13
+repo: profullstack/logicsrc
+discussion:
+implementation: packages/openfleet
+tags:
+ - openfleet
+ - agents
+ - fleet
+ - swarm
+ - claude-code
+ - moshcode
+ - cli
+supersedes:
+superseded-by:
+---
+
+## Problem
+
+The OpenFleet specification (docs/openfleet.md) was published on 2026-09-13 from
+one incident: a Claude Code background job asked moshcode to split a task across
+two agents, and afterwards nobody, human or agent, could say who had started
+either worker, why, under what ceiling, or with whose approval. The spec names
+the record, the ledger and five sysop verbs, and named `logicsrc fleet` as the
+reference sysop tool. It shipped with the line "None of the three ships yet".
+A standard nothing implements is prose; the incident repeats every morning until
+the files exist and something writes them.
+
+## Goals
+
+- A human can open a fleet, set its ceiling, see every agent session under them
+ as one tree, stop a swarm as one unit, and read afterwards what happened and
+ who did it, from one command: `logicsrc fleet`.
+- Every Claude Code session on a box with the hooks installed becomes a
+ recorded member: it claims the record its starter wrote, or derives its own,
+ or is a root member of the implicit fleet, and it refuses to run above the
+ ceiling it was started under.
+- moshcode and Claude Code write the same files the same way, so one tree
+ shows both engines' members without either reading the other's roster.
+
+## Non-Goals
+
+- No orchestration: splitting a task, choosing an engine, verifying, and
+ synthesising stay in `moshcode swarm` and `@logicsrc/agentswarm`.
+- No change to Claude Code itself. The engine side ships as hooks over its own
+ settings file; FleetView grouping and `member.spend` at intervals wait on the
+ engine.
+- No network surface, no signed ledger lines, no freeze, no `adopt`: the 0.2
+ questions stay open.
+
+## Users
+
+- The sysop: one developer answerable for every agent session on their box,
+ who wants to see the tree and stop the wrong part of it.
+- An agent that spawns a swarm and wants its children to know who they are and
+ what they own.
+- A later session inspecting a member's record to answer the five questions the
+ incident could not.
+
+## Requirements
+
+- R1 [P0] `@logicsrc/openfleet` 0.1.0: the record (write once, never
+ overwrite), the ledger (append-only, 0600, merged across `ledger*.jsonl` by
+ `at`), the ceiling rules (whole fleet ceiling, narrowed swarm keys, merge
+ that never widens, refusal by key), claim and derive exactly as the spec's
+ "Claiming and deriving", and `fold` into the tree the landing page shows.
+- R2 [P0] `logicsrc fleet open|cap|tree|stop|log` with the spec's flags;
+ `open` and `cap` exit 4 when `OPENFLEET_MEMBER` is set; `stop` exits 4
+ outside the caller's subtree; `stop` ends nested swarms first and writes one
+ `swarm.end` per swarm, only once every member and every nested swarm has an
+ end line that counts, and exits non-zero when an engine would not end a
+ member; `cap` on a swarm refuses a key that would widen; every verb takes
+ `--json`.
+- R2a [P0] Rule 6 lives in `tree`, run by the sysop: a working member past
+ its effective `until` is stopped through its engine and ends `timeout`; a
+ swarm or fleet whose summed `member.spend` in the budget's unit has reached
+ its budget has its members stopped, each ending `budget`; each swarm touched
+ gets its `swarm.end` when complete. An agent's `tree` stops nothing.
+- R2b [P0] The effective ceiling is rebuilt from the ledger on every read:
+ the latest fleet-target `fleet.cap` (else `fleet.open`, else the implicit
+ fleet's) replaces the copy in a record, widening included; then each
+ `swarm.spawn` narrowing down the path, then swarm caps last. In the
+ implicit fleet a parentless record's own `approvals` enters at the root, and
+ the engine fills a ceiling a writer left without the key.
+- R3 [P0] `stop` goes through the member's own engine: `claude stop` for
+ `claude-code`, `moshcode herd kill` for `moshcode/*`, `tmux kill-pane` for
+ `tmux`, a signal for `claude-p`. Never a shell string.
+- R4 [P0] Claude Code hooks: `logicsrc fleet hook ` for SessionStart,
+ UserPromptSubmit, PreToolUse, Stop and SessionEnd, and `logicsrc fleet hooks
+ install|remove|status` that merges into `~/.claude/settings.json` and never
+ clobbers it. A hook never fails the engine; a refused start exits 2 before
+ any `member.start`.
+- R5 [P1] `tree` reads `claude agents --json --all` and
+ `~/.moshcode/herd/sessions.json` when it can, draws recordless sessions as
+ roots of the implicit fleet, and writes `member.end` state `lost` for a
+ recorded background job or pane its engine's roster can hold and no longer
+ lists. `claude agents` lists background jobs only, so an interactive or `-p`
+ session (a UUID member with no job id) is never marked lost by it.
+- R6 [P1] The spec and the landing page say what ships, keep `Status: 0.1`,
+ and record the two verified Claude Code limits (no launcher environment
+ reaches a dispatched background job; exported variables reach tools but not
+ later hooks).
+- R7 [P1] Tests cover record and ledger IO, every narrower case, the worked
+ example's claim and derive, the folded tree, hook install idempotence, and
+ each hook handler, including the exit-2 refusal and the PreToolUse deny.
+
+## UX Notes
+
+`logicsrc fleet tree` prints the tree the landing page shows: fleet header,
+root members, swarms nested under their spawner, members with engine, state,
+`[bypass]`, `owns`. `log` prints one line per event, oldest first, with who did
+it. Refusals name the key, what was wanted and what was allowed.
+
+## Tech Stack
+
+TypeScript, NodeNext, commander 14, vitest 4. No workspace dependencies beyond
+the CLI's `file:../openfleet` link. Node 18+ (the installer's floor), so the
+tree is plain text rather than a TUI.
+
+## Monetization
+
+None. It is the reference implementation of an open standard.
+
+## Success Metrics
+
+- The worked example's morning can be replayed against a temp home and
+ `logicsrc fleet tree` prints the tree the spec shows.
+- A Claude Code session started with the hooks installed appears in
+ `logicsrc fleet tree` with the right approvals mark without anyone editing a
+ file by hand.
+
+## Risks & Open Questions
+
+- A background job dispatched from `claude agents` gets no launcher
+ environment, so a launcher that wants it in a swarm must write its record and
+ pass the path another way (a `--settings` hook command, or a lookup by the
+ job's cwd and intent). Until then it is a root of the implicit fleet.
+- User-level hooks fire for every `claude -p` a tool makes, so each becomes a
+ swarm of one and, at depth 1 in the implicit fleet, is refused on depth. The
+ spec lists this as an open question; the hooks enforce the letter of it.
+- A ledger check before a write is not exclusion, so `member.start`,
+ `member.end` and `swarm.end` each take a once-marker first: an exclusive
+ create of `fleets//marks/.` (`.lost` suffixed for a lost
+ end, so a real end can still supersede it). moshcode uses the same paths.
+ A marker taken by a writer that then crashed before appending leaves the
+ line unwritten until someone clears the marker by hand; 0.1 accepts that
+ over a doubled audit line.
diff --git a/prd/README.md b/prd/README.md
index 36bc957..2cf5adf 100644
--- a/prd/README.md
+++ b/prd/README.md
@@ -18,3 +18,4 @@ Status lives in each file's front-matter and is the source of truth:
| [0005](./0005-add-logicsrc-openswarm-spec.md) | Add the LogicSRC OpenSwarm specification family | Draft | openswarm, ipfile, ipdb, ipaudio, ipvideo, iplive, bittorrent, x402, c0mpute |
| [0006](./0006-add-pay2seed-spec.md) | Add pay2seed, paid2seed, pay2stream and paid2stream to the OpenSwarm family | Draft | openswarm, pay2seed, paid2seed, pay2stream, paid2stream, iplive, hls, ipfile, ippay, ipdb, bittorrent, torlink, bittorrented, c0mpute |
| [0007](./0007-add-tech-stack-and-monetization-to-openprd.md) | Add Tech Stack and Monetization sections to OpenPRD | Draft | openprd, standards, monetization |
+| [0008](./0008-openfleet-reference-implementation.md) | Ship the OpenFleet reference implementation | Draft | openfleet, agents, fleet, swarm, claude-code, moshcode, cli |