From 4ceaaaaa1fc0811ef7a296b1618e9d915bd5befb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:06:11 +0000 Subject: [PATCH 1/4] OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet Ship what docs/openfleet.md describes. The new workspace package holds the record (write once, never overwrite, 0600), the ledger (append-only JSON Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet ceiling, narrowed swarm keys, a merge that never widens, refusals by key), claiming and deriving exactly as the spec's "Claiming and deriving" and rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine rosters into the tree the landing page shows. logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with --json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4 outside the caller's subtree, ends nested swarms first, goes through each member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a signal for claude-p) and writes one swarm.end per swarm. tree reads claude agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws recordless sessions as roster roots of the implicit fleet, and writes member.end lost for a recorded member its engine no longer lists. Claude Code takes part through hooks: logicsrc fleet hooks install merges SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into ~/.claude/settings.json without clobbering it, and logicsrc fleet hook runs each one. SessionStart claims, derives or writes a root record 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 first prompt with exit 2 and ceiling.refuse; PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write member.end. A hand-started root takes the engine's reported approvals before member.start, since the command line only guesses them. Hooks never fail the engine: everything is caught and logged to hooks.log. The spec and the landing page now say what ships, keep Status 0.1, and record the two verified Claude Code limits: a background job dispatched from claude agents gets no launcher environment, and OPENFLEET_* exported at SessionStart reach the member's tools but not later hooks, so hooks key on session_id through $OPENFLEET_HOME/sessions/.json. PRD 0008 covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the package before the CLI; README and docs/cli.md list the group. Tests: 95 in the package (record, ledger merge, every narrower case, the worked example's claim and derive, the folded tree, hook install idempotence, each hook handler including the exit-2 refusal and the PreToolUse deny, every verb with fake deps) and 4 in the CLI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- README.md | 2 + apps/logicsrc-web/src/app/openfleet/page.tsx | 14 +- docs/cli.md | 1 + docs/openfleet.md | 12 +- package-lock.json | 18 +- package.json | 4 +- packages/cli/package.json | 3 +- packages/cli/src/fleet.test.ts | 99 +++ packages/cli/src/fleet.ts | 26 + packages/cli/src/index.ts | 2 + packages/openfleet/package.json | 44 ++ packages/openfleet/src/ceiling.test.ts | 167 ++++ packages/openfleet/src/ceiling.ts | 237 ++++++ packages/openfleet/src/commands.test.ts | 361 +++++++++ packages/openfleet/src/commands.ts | 716 ++++++++++++++++++ packages/openfleet/src/context.test.ts | 328 ++++++++ packages/openfleet/src/context.ts | 438 +++++++++++ packages/openfleet/src/fold.test.ts | 126 +++ packages/openfleet/src/fold.ts | 389 ++++++++++ packages/openfleet/src/hooks-install.test.ts | 135 ++++ packages/openfleet/src/hooks-install.ts | 283 +++++++ packages/openfleet/src/hooks.test.ts | 307 ++++++++ packages/openfleet/src/hooks.ts | 461 +++++++++++ packages/openfleet/src/index.ts | 18 + packages/openfleet/src/rosters.ts | 189 +++++ packages/openfleet/src/store.test.ts | 173 +++++ packages/openfleet/src/store.ts | 396 ++++++++++ packages/openfleet/src/swarm.test.ts | 27 + packages/openfleet/src/swarm.ts | 47 ++ packages/openfleet/src/test-helpers.ts | 126 +++ packages/openfleet/src/types.ts | 233 ++++++ packages/openfleet/tsconfig.json | 9 + ...0008-openfleet-reference-implementation.md | 133 ++++ prd/README.md | 1 + 34 files changed, 5510 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/fleet.test.ts create mode 100644 packages/cli/src/fleet.ts create mode 100644 packages/openfleet/package.json create mode 100644 packages/openfleet/src/ceiling.test.ts create mode 100644 packages/openfleet/src/ceiling.ts create mode 100644 packages/openfleet/src/commands.test.ts create mode 100644 packages/openfleet/src/commands.ts create mode 100644 packages/openfleet/src/context.test.ts create mode 100644 packages/openfleet/src/context.ts create mode 100644 packages/openfleet/src/fold.test.ts create mode 100644 packages/openfleet/src/fold.ts create mode 100644 packages/openfleet/src/hooks-install.test.ts create mode 100644 packages/openfleet/src/hooks-install.ts create mode 100644 packages/openfleet/src/hooks.test.ts create mode 100644 packages/openfleet/src/hooks.ts create mode 100644 packages/openfleet/src/index.ts create mode 100644 packages/openfleet/src/rosters.ts create mode 100644 packages/openfleet/src/store.test.ts create mode 100644 packages/openfleet/src/store.ts create mode 100644 packages/openfleet/src/swarm.test.ts create mode 100644 packages/openfleet/src/swarm.ts create mode 100644 packages/openfleet/src/test-helpers.ts create mode 100644 packages/openfleet/src/types.ts create mode 100644 packages/openfleet/tsconfig.json create mode 100644 prd/0008-openfleet-reference-implementation.md 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..e51d7ee 100644 --- a/apps/logicsrc-web/src/app/openfleet/page.tsx +++ b/apps/logicsrc-web/src/app/openfleet/page.tsx @@ -54,7 +54,7 @@ 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."] ]; @@ -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..c212ad3 100644 --- a/docs/openfleet.md +++ b/docs/openfleet.md @@ -166,7 +166,7 @@ Five verbs, over `$OPENFLEET_HOME`. Two are the sysop's alone. The test is the e | `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 @@ -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 member its engine no longer lists. `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/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..f8d55b4 --- /dev/null +++ b/packages/openfleet/src/ceiling.test.ts @@ -0,0 +1,167 @@ +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 } })]; + expect(fleetCeiling(opened, "f", implicit)).toEqual({ approvals: "bypass", depth: 2 }); + 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 } }), + ]; + expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3 }); + }); + + 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..2d85177 --- /dev/null +++ b/packages/openfleet/src/ceiling.ts @@ -0,0 +1,237 @@ +/** + * 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. + */ +export function fleetCeiling(lines: LedgerLine[], fleet: string, implicit: Ceiling): Ceiling { + const caps = findEvents(lines, "fleet.cap", { target: fleet }); + if (caps.length) return { ...(caps[caps.length - 1].ceiling ?? {}) }; + const opens = findEvents(lines, "fleet.open", { fleet }); + if (opens.length) return { ...(opens[opens.length - 1].ceiling ?? {}) }; + return { ...implicit }; +} + +/** 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..1636598 --- /dev/null +++ b/packages/openfleet/src/commands.test.ts @@ -0,0 +1,361 @@ +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); + }); +}); + +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); + }); +}); + +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("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..a26331a --- /dev/null +++ b/packages/openfleet/src/commands.ts @@ -0,0 +1,716 @@ +/** + * `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 { isNarrower, parseBudget, parseUntil } from "./ceiling.js"; +import { endMember, memberCeiling } 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 { defaultRosters, realExec, type Exec } from "./rosters.js"; +import { + append, + claimedBy, + endOf, + findEvents, + fleetDir, + home as homeOf, + implicitFleet, + isoNow, + listFleets, + 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, Rosters, Tree } from "./types.js"; + + +/** Exit codes: 0 ok, 1 usage, 2 invalid input, 3 not found, 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; +} + +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 }; +} + +/** Claude Code's roster keys jobs by the first eight characters of the session id. */ +function claudeHandle(handle: string): string { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(handle) ? handle.slice(0, 8) : handle; +} + +async function stopThroughEngine(deps: Deps, record: Partial & { member: string }): Promise { + const engine = record.engine; + const handle = record.session ?? record.member; + if (engine === "claude-code") { + const result = await deps.exec("claude", ["stop", claudeHandle(handle)]); + return result.code === 0 ? null : `claude stop ${claudeHandle(handle)} 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 path sees it: its record when there is one, else what its member.start said. */ +function memberFacts(home: string, fleet: string, lines: LedgerLine[], member: string): (Partial & { member: string }) | null { + const record = readRecords(home, fleet).get(member); + if (record) return record; + const start = claimedBy(lines, member); + if (!start) return null; + return { member, engine: start.engine, session: start.session, swarm: start.swarm, parent: start.parent, host: start.host }; +} + +async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, rows: Row[]): 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, note: problem }); + return; + } + endMember(ctx.home, fleet, member, { state: "stopped", by, now: ctx.deps.now(), host: ctx.host }, facts.swarm); + rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state: "stopped" }); +} + +/** 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]; +} + +/** 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 { + let 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); + const members = membersOfSwarm(ctx.home, fleet, lines, swarm); + for (const member of members) await stopMember(ctx, fleet, member, by, rows); + lines = readLedger(ctx.home, fleet); + if (swarmEndOf(lines, swarm)) { + rows.push({ kind: "swarm", swarm, state: String(swarmEndOf(lines, swarm)?.state), ended: false, note: "already ended" }); + return; + } + 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"; + append(ctx.home, fleet, { event: "swarm.end", by, swarm, state }, { now: ctx.deps.now(), host: ctx.host }); + rows.push({ kind: "swarm", swarm, state, ended: true }); +} + +/** 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) => + row.kind === "member" + ? `${row.stopped ? "stopped" : "skipped"} ${row.member}${row.engine ? ` ${row.engine}` : ""}${row.state ? ` ${row.state}` : ""}${row.note ? ` (${row.note})` : ""}` + : `${row.ended ? "ended" : "left"} swarm ${row.swarm} ${row.state}${row.note ? ` (${row.note})` : ""}`, + ); +} + +// --------------------------------------------------------------------------- +// 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); + } + 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()] : membersOfSwarm(ctx.home, fleet, lines, target).concat( + findEvents(lines, "swarm.spawn").filter((spawn) => swarmAncestry(lines, String(spawn.swarm)).some((s) => s.swarm === target)).flatMap((spawn) => membersOfSwarm(ctx.home, fleet, lines, String(spawn.swarm))), + ); + for (const member of new Set(inScope)) { + if (!claimedBy(lines, member) || endOf(lines, member)) continue; + const record = records.get(member); + const start = claimedBy(lines, member)!; + const facts: FleetRecord = record ?? { openfleet: "0.1", fleet, sysop: "", member, swarm: start.swarm, parent: start.parent, depth: start.depth, engine: start.engine, host: start.host, approvals: start.approvals }; + 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); + } + 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.") + .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 } : {}) }); + // A recorded member its engine no longer lists, with no end line, is lost (verb table, tree). + const by = deps.env.OPENFLEET_MEMBER ?? "sysop"; + 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, () => renderTree(tree, { host: ctx.host })); + }), + ); + + 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); + 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); + 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; + 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..94ee71a --- /dev/null +++ b/packages/openfleet/src/context.test.ts @@ -0,0 +1,328 @@ +import { existsSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { claimOrDerive, context, endMember, memberCeiling, startMember } from "./context.js"; +import { append, findEvents, readLedger, readRecord, recordPath, writeCurrent, writeRecord } from "./store.js"; +import { DEV, FLEET, PIECE_1, ROOT, cleanup, envFor, seedWorkedExample, tempHome } from "./test-helpers.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"] }); + }); +}); + +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("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("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("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..07820ef --- /dev/null +++ b/packages/openfleet/src/context.ts @@ -0,0 +1,438 @@ +/** + * 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, mergeCeiling, rootApprovals, swarmChain } from "./ceiling.js"; +import { nextSwarmOfOne } from "./swarm.js"; +import { + append, + claimedBy, + endOf, + findEvents, + home as homeOf, + implicitFleet, + isoNow, + 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. The record's own `ceiling` is + * what it was started under; the latest `fleet.cap` for its fleet and for any + * swarm on its path wins over that copy (rule 7). A record with no ceiling + * gets the fleet's, with the root's approvals entering at the root in the + * implicit fleet, merged down its swarm path. + */ +export function memberCeiling(homeDir: string, lines: LedgerLine[], record: FleetRecord, implicit: ImplicitFleet): Ceiling { + const chain = swarmChain(lines, record.swarm); + let base: Ceiling; + if (record.ceiling && typeof record.ceiling === "object") { + base = { ...record.ceiling }; + } else { + base = fleetCeiling(lines, record.fleet, implicit.ceiling); + if (isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record)); + base = effectiveCeiling(base, lines, chain); + } + const fleetCaps = findEvents(lines, "fleet.cap", { target: record.fleet }); + if (fleetCaps.length) base = mergeCeiling(base, fleetCaps[fleetCaps.length - 1].ceiling); + for (const swarm of chain) { + const caps = findEvents(lines, "fleet.cap", { target: swarm }); + if (caps.length) base = mergeCeiling(base, caps[caps.length - 1].ceiling); + } + return base; +} + +// --------------------------------------------------------------------------- +// 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; + const 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 narrowing: Ceiling | 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; + narrowing = namedSpawn.ceiling; + } 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 }], + }; + } + + const parentCeiling = memberCeiling(home, lines, parent, implicit); + const ceiling = mergeCeiling(parentCeiling, narrowing); + 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, + ...(session.pid !== undefined ? { 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; nothing was written. */ + already: LedgerLine | null; + /** The record as it stands: rewritten when a hand-started root's real approvals differed from the guess. */ + record: FleetRecord; +} + +/** + * Claim a record: check the effective ceiling, then write `member.start` with + * `by` the member itself. 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 }, +): 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). The starter may have guessed those before the engine said; + // the engine's word replaces the guess while the record is still unclaimed. + const handStartedRoot = !record.parent && !record.orphan && isImplicitFleet(lines, record.fleet); + if (handStartedRoot && record.approvals !== facts.approvals) { + record = { ...record, approvals: facts.approvals, ceiling: { ...(record.ceiling ?? {}), 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 = append( + 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 }, + ); + 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; 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. + */ +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 ended = append( + 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 }, + ); + + let swarmEnded: LedgerLine | null = null; + if (swarm) { + const spawn = spawnOf(lines, swarm); + const ofOne = spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member; + if (ofOne && !swarmEndOf(lines, swarm)) { + swarmEnded = append( + homeDir, + fleet, + { event: "swarm.end", by: facts.by, swarm, state: facts.state, ...(facts.summary !== undefined ? { summary: facts.summary } : {}) }, + { now, host: facts.host }, + ); + } + } + 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..4022524 --- /dev/null +++ b/packages/openfleet/src/fold.test.ts @@ -0,0 +1,126 @@ +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); + expect(hand.swarms.map((swarm) => swarm.swarm)).toEqual(["inner-0503"]); + expect(hand.swarms[0].members[0]).toMatchObject({ member: "inner-0503-1", spend: "5 USD", depth: 1, parent: "hand-0501-1" }); + expect(node.spend).toEqual({ USD: 5, tokens: 1000 }); + + 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 +1000 tokens, 5 USD/); + expect(text).toMatch(/├─ stray +claude-code +unclaimed {2}\[orphan\]/); + expect(text).toMatch(/└─ inner-0503-1 \(i1\) +moshcode\/kimi +working {2}5 USD/); + }); +}); diff --git a/packages/openfleet/src/fold.ts b/packages/openfleet/src/fold.ts new file mode 100644 index 0000000..f7a0390 --- /dev/null +++ b/packages/openfleet/src/fold.ts @@ -0,0 +1,389 @@ +/** + * 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 { fleetCeiling, formatSpend, isImplicitFleet, sumSpend } from "./ceiling.js"; +import { fleetSysop } from "./context.js"; +import { rosterFor } 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 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 : {}, + 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 row = matchRow(roster, [id, record?.session, start?.session]); + const covering = rosterFor(engine); + 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 } : covering && roster.readable.has(covering) ? { 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, swarms under their parent swarm or spawner, the rest at the top. + 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 parentSwarm = swarm.parent_swarm ? swarms.get(swarm.parent_swarm) : undefined; + if (parentSwarm && parentSwarm !== swarm) { + parentSwarm.swarms.push(swarm); + continue; + } + const spawner = members.get(swarm.by); + if (spawner) spawner.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 ceiling = fleetCeiling(lines, fleet, implicit.ceiling); + 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; + const size = node.ceiling.fan_out !== undefined ? `${count}/${String(node.ceiling.fan_out)} members` : `${count} member${count === 1 ? "" : "s"}`; + const rest = [...(node.state ? [node.state] : node.ceiling.until ? [`until ${hhmm(String(node.ceiling.until))}`] : [])]; + const spend = formatSpend(node.spend, typeof node.ceiling.budget === "string" ? node.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..dba030f --- /dev/null +++ b/packages/openfleet/src/hooks-install.test.ts @@ -0,0 +1,135 @@ +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"); + expect(hookCommand("UserPromptSubmit")).toBe("command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit"); + 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..04fcff2 --- /dev/null +++ b/packages/openfleet/src/hooks-install.ts @@ -0,0 +1,283 @@ +/** + * 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), so its guard is `|| exit 0` and the exit + * code is the handler's own. 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}`; + 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..99c8f4b --- /dev/null +++ b/packages/openfleet/src/hooks.test.ts @@ -0,0 +1,307 @@ +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 { findEvents, readLedger, readRecord, readSession, recordPath, writeCurrent, append } 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("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" }); + }); +}); + +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..fede990 --- /dev/null +++ b/packages/openfleet/src/hooks.ts @@ -0,0 +1,461 @@ +/** + * 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(); + const result = startMember(homeDir, record, { sessionId, approvals, env, now: io.now(), host: io.host ?? implicit.host, implicit }); + 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; + if (endOf(readLedger(homeDir, record.fleet), record.member)) 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); + if (endOf(lines, record.member)) 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..6cafff6 --- /dev/null +++ b/packages/openfleet/src/rosters.ts @@ -0,0 +1,189 @@ +/** + * 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"; + if (engine?.startsWith("moshcode/")) return "moshcode"; + return null; +} diff --git a/packages/openfleet/src/store.test.ts b/packages/openfleet/src/store.test.ts new file mode 100644 index 0000000..43c29ee --- /dev/null +++ b/packages/openfleet/src/store.test.ts @@ -0,0 +1,173 @@ +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, + claimedBy, + endOf, + hasEvent, + home, + implicitFleet, + ledgerPaths, + listFleets, + 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("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..52e8801 --- /dev/null +++ b/packages/openfleet/src/store.ts @@ -0,0 +1,396 @@ +/** + * 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; +} + +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..187f9b2 --- /dev/null +++ b/packages/openfleet/src/types.ts @@ -0,0 +1,233 @@ +/** + * 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; + ceiling: 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..5279245 --- /dev/null +++ b/prd/0008-openfleet-reference-implementation.md @@ -0,0 +1,133 @@ +--- +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; every verb takes `--json`. +- 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 member its engine no longer lists. +- 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. +- `hasEvent` before a write is a check, not a lock. Two writers racing on one + swarm can still produce two `swarm.end` lines. 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 | From ce8fc7c153ce9b669f8b35ac0261a264671c6e4d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:45:30 +0000 Subject: [PATCH 2/4] OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold The review of the reference implementation against moshcode found the two readers disagreeing on the same files. This round applies the shared rulings so both sides read a ledger the same way. Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the effective ceiling 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, so a sysop's widening cap reaches running members; 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; a ceiling a writer left without the key is never read as native, and startMember fills it with the engine's word while the record is unclaimed. A fleet.open or cap with no hosts means the host it was written on (R23). Once-markers (R-G, R28): member.start, member.end and swarm.end each take an exclusive create under fleets//marks/. before the append; a lost end takes .lost so a real end can still supersede it. The hooks let a real end follow a lost line (R9). tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member past its effective until with state timeout and the members of a swarm or fleet at its budget with state budget, then writes swarm.end for each swarm touched once it is complete. An agent's tree stops nothing. lost is written only for a member its engine's roster can hold: a claude-code background job (8-hex member or session) or a moshcode pane, never an interactive session claude agents does not list (R-E, R3, R14). A nested swarm is drawn under the member that spawned it and its row shows the effective ceiling (R25). stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every member and every nested swarm has an end line that counts; an engine that will not end a member leaves it without an end line and the verb exits non-zero. claude stop takes the job id: the member of a background job, else the first eight characters of a session UUID; an interactive session with no job id cannot be stopped and the tool says so. cap on a swarm refuses a key that would widen. A derived claude-code job is named by its job id and carries no pid. Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a derived record's guessed approvals corrected at UserPromptSubmit), R32 (the UserPromptSubmit hook passes only exit 2 through), R31 (package README), R36 (rule 13 says the launcher test is unimplemented in 0.1), docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet tests, 93 CLI tests, contract green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- apps/logicsrc-web/src/app/openfleet/page.tsx | 2 +- docs/openfleet.md | 8 +- packages/openfleet/README.md | 116 ++++++++ packages/openfleet/src/ceiling.test.ts | 15 +- packages/openfleet/src/ceiling.ts | 12 +- packages/openfleet/src/commands.test.ts | 124 +++++++++ packages/openfleet/src/commands.ts | 259 ++++++++++++++---- packages/openfleet/src/context.test.ts | 139 +++++++++- packages/openfleet/src/context.ts | 128 +++++---- packages/openfleet/src/fold.test.ts | 47 +++- packages/openfleet/src/fold.ts | 34 ++- packages/openfleet/src/hooks-install.test.ts | 3 +- packages/openfleet/src/hooks-install.ts | 10 +- packages/openfleet/src/hooks.test.ts | 38 ++- packages/openfleet/src/hooks.ts | 12 +- packages/openfleet/src/rosters.ts | 37 ++- packages/openfleet/src/store.test.ts | 22 ++ packages/openfleet/src/store.ts | 57 ++++ packages/openfleet/src/types.ts | 7 + ...0008-openfleet-reference-implementation.md | 29 +- 20 files changed, 961 insertions(+), 138 deletions(-) create mode 100644 packages/openfleet/README.md diff --git a/apps/logicsrc-web/src/app/openfleet/page.tsx b/apps/logicsrc-web/src/app/openfleet/page.tsx index e51d7ee..e295473 100644 --- a/apps/logicsrc-web/src/app/openfleet/page.tsx +++ b/apps/logicsrc-web/src/app/openfleet/page.tsx @@ -61,7 +61,7 @@ const STEPS: Array<[string, string]> = [ 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"] ]; diff --git a/docs/openfleet.md b/docs/openfleet.md index c212ad3..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,7 +162,7 @@ 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`. | @@ -182,7 +182,7 @@ The reference sysop tool is `logicsrc fleet`, in `@logicsrc/openfleet` 0.1.0 (lo 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. @@ -206,7 +206,7 @@ Three ship: `logicsrc fleet` in `@logicsrc/openfleet` 0.1.0 (logicsrc CLI 0.3.0) **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: `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 member its engine no longer lists. `logicsrc fleet hooks install|remove|status` and `logicsrc fleet hook ` are the Claude Code side above. Every verb takes `--json`. +**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 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/src/ceiling.test.ts b/packages/openfleet/src/ceiling.test.ts index f8d55b4..a8dfdf4 100644 --- a/packages/openfleet/src/ceiling.test.ts +++ b/packages/openfleet/src/ceiling.test.ts @@ -84,15 +84,24 @@ describe("the fleet's ceiling and the path down", () => { expect(fleetCeiling([], "f", implicit)).toEqual(implicit); expect(isImplicitFleet([], "f")).toBe(true); const opened = [line({ event: "fleet.open", fleet: "f", ceiling: { approvals: "bypass", depth: 2 } })]; - expect(fleetCeiling(opened, "f", implicit)).toEqual({ 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 } }), + line({ event: "fleet.cap", target: "f", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }), ]; - expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3 }); + 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", () => { diff --git a/packages/openfleet/src/ceiling.ts b/packages/openfleet/src/ceiling.ts index 2d85177..4aaf73c 100644 --- a/packages/openfleet/src/ceiling.ts +++ b/packages/openfleet/src/ceiling.ts @@ -108,14 +108,18 @@ export function mergeCeiling(base: Ceiling, narrowing: Ceiling | undefined): Cei /** * A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet, - * else `fleet.open`, else the implicit fleet's. + * 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 }); - if (caps.length) return { ...(caps[caps.length - 1].ceiling ?? {}) }; const opens = findEvents(lines, "fleet.open", { fleet }); - if (opens.length) return { ...(opens[opens.length - 1].ceiling ?? {}) }; - return { ...implicit }; + 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. */ diff --git a/packages/openfleet/src/commands.test.ts b/packages/openfleet/src/commands.test.ts index 1636598..a83a643 100644 --- a/packages/openfleet/src/commands.test.ts +++ b/packages/openfleet/src/commands.test.ts @@ -139,6 +139,20 @@ describe("cap", () => { 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", () => { @@ -174,6 +188,77 @@ describe("tree", () => { 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", () => { @@ -236,6 +321,45 @@ describe("stop", () => { 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); diff --git a/packages/openfleet/src/commands.ts b/packages/openfleet/src/commands.ts index a26331a..1aa460f 100644 --- a/packages/openfleet/src/commands.ts +++ b/packages/openfleet/src/commands.ts @@ -11,14 +11,15 @@ import { existsSync } from "node:fs"; import type { Command } from "commander"; -import { isNarrower, parseBudget, parseUntil } from "./ceiling.js"; -import { endMember, memberCeiling } from "./context.js"; +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 { defaultRosters, realExec, type Exec } from "./rosters.js"; +import { claudeJobId, defaultRosters, realExec, type Exec } from "./rosters.js"; import { append, + appendOnce, claimedBy, endOf, findEvents, @@ -27,6 +28,7 @@ import { implicitFleet, isoNow, listFleets, + markName, readLedger, readRecords, spawnOf, @@ -37,10 +39,11 @@ import { type ImplicitFleet, } from "./store.js"; import { slug } from "./swarm.js"; -import type { Approvals, Ceiling, EndState, FleetRecord, LedgerLine, Rosters, Tree } from "./types.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, 4 refused (the human-only verbs and stop outside reach). */ +/** 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 { @@ -210,6 +213,8 @@ interface StopRow { 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 { @@ -240,17 +245,15 @@ function report(rows: Row[]): { members: Omit[]; swarms: Omit & { member: string }): Promise { const engine = record.engine; const handle = record.session ?? record.member; if (engine === "claude-code") { - const result = await deps.exec("claude", ["stop", claudeHandle(handle)]); - return result.code === 0 ? null : `claude stop ${claudeHandle(handle)} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`; + // `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]); @@ -273,16 +276,38 @@ async function stopThroughEngine(deps: Deps, record: Partial & { me return engine ? `no way to stop engine ${engine}` : `member ${record.member} names no engine`; } -/** A member as the stop path sees it: its record when there is one, else what its member.start said. */ -function memberFacts(home: string, fleet: string, lines: LedgerLine[], member: string): (Partial & { member: string }) | null { - const record = readRecords(home, fleet).get(member); +/** + * 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 { member, engine: start.engine, session: start.session, swarm: start.swarm, parent: start.parent, host: start.host }; + 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 } : {}), + }; } -async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, rows: Row[]): Promise { +/** + * 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) { @@ -300,11 +325,21 @@ async function stopMember(ctx: Ctx, fleet: string, member: string, by: string, r } const problem = await stopThroughEngine(ctx.deps, facts); if (problem) { - rows.push({ kind: "member", member, engine: facts.engine, stopped: false, note: problem }); + rows.push({ kind: "member", member, engine: facts.engine, stopped: false, error: problem }); return; } - endMember(ctx.home, fleet, member, { state: "stopped", by, now: ctx.deps.now(), host: ctx.host }, facts.swarm); - rows.push({ kind: "member", member, engine: facts.engine, stopped: true, state: "stopped" }); + 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. */ @@ -316,18 +351,37 @@ function membersOfSwarm(home: string, fleet: string, lines: LedgerLine[], swarm: return [...out]; } -/** 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 { - let lines = readLedger(ctx.home, fleet); +/** 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)); - for (const child of nested) await stopSwarm(ctx, fleet, child, by, rows); - const members = membersOfSwarm(ctx.home, fleet, lines, swarm); - for (const member of members) await stopMember(ctx, fleet, member, by, rows); - lines = readLedger(ctx.home, fleet); - if (swarmEndOf(lines, swarm)) { - rows.push({ kind: "swarm", swarm, state: String(swarmEndOf(lines, swarm)?.state), ended: false, note: "already ended" }); + 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); @@ -336,10 +390,23 @@ async function stopSwarm(ctx: Ctx, fleet: string, swarm: string, by: string, row return; } const state: EndState = started.length ? (swarmEndState(ends) ?? "stopped") : "stopped"; - append(ctx.home, fleet, { event: "swarm.end", by, swarm, state }, { now: ctx.deps.now(), host: ctx.host }); + 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[] = []; @@ -375,11 +442,97 @@ function findMember(home: string, fleets: string[], member: string): string | nu } function stopText(rows: Row[]): string[] { - return rows.map((row) => - row.kind === "member" - ? `${row.stopped ? "stopped" : "skipped"} ${row.member}${row.engine ? ` ${row.engine}` : ""}${row.state ? ` ${row.state}` : ""}${row.note ? ` (${row.note})` : ""}` - : `${row.ended ? "ended" : "left"} swarm ${row.swarm} ${row.state}${row.note ? ` (${row.note})` : ""}`, - ); + 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; + } + } } // --------------------------------------------------------------------------- @@ -494,6 +647,14 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = 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 }); @@ -501,14 +662,12 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = const rows: Row[] = []; const lines = readLedger(ctx.home, fleet); const records = readRecords(ctx.home, fleet); - const inScope = kind === "fleet" ? [...records.keys()] : membersOfSwarm(ctx.home, fleet, lines, target).concat( - findEvents(lines, "swarm.spawn").filter((spawn) => swarmAncestry(lines, String(spawn.swarm)).some((s) => s.swarm === target)).flatMap((spawn) => membersOfSwarm(ctx.home, fleet, lines, String(spawn.swarm))), - ); + const inScope = kind === "fleet" ? [...records.keys()] : membersUnderSwarm(ctx.home, fleet, lines, target); for (const member of new Set(inScope)) { - if (!claimedBy(lines, member) || endOf(lines, member)) continue; - const record = records.get(member); - const start = claimedBy(lines, member)!; - const facts: FleetRecord = record ?? { openfleet: "0.1", fleet, sysop: "", member, swarm: start.swarm, parent: start.parent, depth: start.depth, engine: start.engine, host: start.host, approvals: start.approvals }; + 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 = @@ -518,6 +677,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = !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), @@ -530,15 +690,19 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = .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.") + .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 } : {}) }); - // A recorded member its engine no longer lists, with no end line, is lost (verb table, tree). - const by = deps.env.OPENFLEET_MEMBER ?? "sysop"; + 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; @@ -548,7 +712,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = node.ended = result.ended.at; } } - emit(ctx, tree, () => renderTree(tree, { host: ctx.host })); + emit(ctx, { ...tree, enforced: report(rows) }, () => [renderTree(tree, { host: ctx.host }), ...stopText(rows)]); }), ); @@ -575,6 +739,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = 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; } @@ -585,6 +750,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = 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; } @@ -598,6 +764,7 @@ export function registerOpenFleetCommands(cmd: Command, partial: Partial = } 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)); }), ); diff --git a/packages/openfleet/src/context.test.ts b/packages/openfleet/src/context.test.ts index 94ee71a..1bc2d5e 100644 --- a/packages/openfleet/src/context.test.ts +++ b/packages/openfleet/src/context.test.ts @@ -1,8 +1,9 @@ import { existsSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { claimOrDerive, context, endMember, memberCeiling, startMember } from "./context.js"; -import { append, findEvents, readLedger, readRecord, recordPath, writeCurrent, writeRecord } from "./store.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"); @@ -182,6 +183,41 @@ describe("deriving under the root: the planner's swarm of one", () => { 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", () => { @@ -229,6 +265,41 @@ describe("root and orphan records", () => { 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"); @@ -274,6 +345,50 @@ describe("ending", () => { 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"); @@ -318,6 +433,28 @@ describe("context and the effective ceiling", () => { 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; diff --git a/packages/openfleet/src/context.ts b/packages/openfleet/src/context.ts index 07820ef..4625989 100644 --- a/packages/openfleet/src/context.ts +++ b/packages/openfleet/src/context.ts @@ -9,16 +9,19 @@ * make the same decision from the same ledger. */ -import { checkCeiling, effectiveCeiling, fleetCeiling, isImplicitFleet, mergeCeiling, rootApprovals, swarmChain } from "./ceiling.js"; +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, @@ -105,29 +108,21 @@ export function rootOf(homeDir: string, lines: LedgerLine[], record: FleetRecord } /** - * The effective ceiling a member is under now. The record's own `ceiling` is - * what it was started under; the latest `fleet.cap` for its fleet and for any - * swarm on its path wins over that copy (rule 7). A record with no ceiling - * gets the fleet's, with the root's approvals entering at the root in the - * implicit fleet, merged down its swarm path. + * 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 chain = swarmChain(lines, record.swarm); - let base: Ceiling; - if (record.ceiling && typeof record.ceiling === "object") { - base = { ...record.ceiling }; - } else { - base = fleetCeiling(lines, record.fleet, implicit.ceiling); - if (isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record)); - base = effectiveCeiling(base, lines, chain); - } - const fleetCaps = findEvents(lines, "fleet.cap", { target: record.fleet }); - if (fleetCaps.length) base = mergeCeiling(base, fleetCaps[fleetCaps.length - 1].ceiling); - for (const swarm of chain) { - const caps = findEvents(lines, "fleet.cap", { target: swarm }); - if (caps.length) base = mergeCeiling(base, caps[caps.length - 1].ceiling); - } - return base; + 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)); } // --------------------------------------------------------------------------- @@ -201,20 +196,19 @@ function derive( 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.id; + // 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 narrowing: Ceiling | 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; - narrowing = namedSpawn.ceiling; } else { const existing = findEvents(lines, "swarm.spawn").map((line) => String(line.swarm ?? "")); swarm = nextSwarmOfOne(parent.member, existing); @@ -230,8 +224,9 @@ function derive( }; } - const parentCeiling = memberCeiling(home, lines, parent, implicit); - const ceiling = mergeCeiling(parentCeiling, narrowing); + // 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( @@ -256,7 +251,8 @@ function derive( ...(task !== undefined ? { task } : {}), depth, engine: session.engine, - ...(session.pid !== undefined ? { session: String(session.pid) } : {}), + // 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), @@ -309,23 +305,37 @@ function root(opts: { home: string; env: Env; now: Date; host: string; implicit: export interface StartResult { started: LedgerLine | null; refused: { refusal: Refusal; line: LedgerLine } | null; - /** The record was already claimed; nothing was written. */ + /** + * 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 a hand-started root's real approvals differed from the guess. */ + /** 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. 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. + * `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 }, + 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(); @@ -336,11 +346,19 @@ export function startMember( 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). The starter may have guessed those before the engine said; - // the engine's word replaces the guess while the record is still unclaimed. + // 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 = { ...record, approvals: facts.approvals, ceiling: { ...(record.ceiling ?? {}), approvals: facts.approvals } }; + 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); } @@ -357,7 +375,7 @@ export function startMember( return { started: null, refused: { refusal, line }, already: null, record }; } - const started = append( + const started = appendOnce( homeDir, record.fleet, { @@ -373,8 +391,9 @@ export function startMember( approvals: facts.approvals, ...(record.piece ? { piece: record.piece } : {}), }, - { now, host }, + { 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 }; } @@ -391,22 +410,27 @@ export interface EndFacts { export interface EndResult { ended: LedgerLine | null; swarmEnded: LedgerLine | null; - /** An end line already counted; nothing was written. */ + /** 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. + * 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 = append( + const ended = appendOnce( homeDir, fleet, { @@ -418,19 +442,27 @@ export function endMember(homeDir: string, fleet: string, member: string, facts: ...(facts.total !== undefined ? { total: facts.total } : {}), ...(facts.links !== undefined ? { links: facts.links } : {}), }, - { now, host: facts.host }, + { 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); - const ofOne = spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member; + // 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 = append( + swarmEnded = appendOnce( homeDir, fleet, { event: "swarm.end", by: facts.by, swarm, state: facts.state, ...(facts.summary !== undefined ? { summary: facts.summary } : {}) }, - { now, host: facts.host }, + { now, host: facts.host, once: markName("swarm.end", swarm) }, ); } } diff --git a/packages/openfleet/src/fold.test.ts b/packages/openfleet/src/fold.test.ts index 4022524..6833d06 100644 --- a/packages/openfleet/src/fold.test.ts +++ b/packages/openfleet/src/fold.test.ts @@ -113,14 +113,55 @@ describe("fold: opened fleets, nesting, spend and orphans", () => { const hand = node.swarms[0]; expect(hand).toMatchObject({ swarm: "hand-0501", by: "sysop", spend: { USD: 5, tokens: 1000 } }); expect(hand.members.length).toBe(1); - expect(hand.swarms.map((swarm) => swarm.swarm)).toEqual(["inner-0503"]); - expect(hand.swarms[0].members[0]).toMatchObject({ member: "inner-0503-1", spend: "5 USD", depth: 1, parent: "hand-0501-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 +1000 tokens, 5 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 index f7a0390..9a62a30 100644 --- a/packages/openfleet/src/fold.ts +++ b/packages/openfleet/src/fold.ts @@ -6,9 +6,9 @@ * place a member with no record exists. */ -import { fleetCeiling, formatSpend, isImplicitFleet, sumSpend } from "./ceiling.js"; +import { effectiveCeiling, fleetCeiling, formatSpend, isImplicitFleet, sumSpend, swarmChain } from "./ceiling.js"; import { fleetSysop } from "./context.js"; -import { rosterFor } from "./rosters.js"; +import { rosterFor, rosterHolds } from "./rosters.js"; import { claimedBy, endOf, @@ -89,6 +89,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost } } + const ceiling = fleetCeiling(lines, fleet, implicit.ceiling); const pieceOf = new Map(); const swarms = new Map(); for (const spawn of findEvents(lines, "swarm.spawn")) { @@ -100,6 +101,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost 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 } : {}) } : {}), @@ -119,8 +121,11 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost 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; @@ -136,7 +141,7 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost approvals, ...(owns ? { owns } : {}), ...(record?.orphan ? { orphan: true } : {}), - ...(row ? { alive: true } : covering && roster.readable.has(covering) ? { alive: false } : {}), + ...(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 } : {}), @@ -151,7 +156,9 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost 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, swarms under their parent swarm or spawner, the rest at the top. + // 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; @@ -160,13 +167,13 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost } const fleetSwarms: SwarmNode[] = []; for (const swarm of swarms.values()) { - const parentSwarm = swarm.parent_swarm ? swarms.get(swarm.parent_swarm) : undefined; - if (parentSwarm && parentSwarm !== swarm) { - parentSwarm.swarms.push(swarm); + const spawner = members.get(swarm.by); + if (spawner) { + spawner.swarms.push(swarm); continue; } - const spawner = members.get(swarm.by); - if (spawner) spawner.swarms.push(swarm); + 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. @@ -178,7 +185,6 @@ function foldFleet(homeDir: string, fleet: string, implicit: ImplicitFleet, rost for (const swarm of fleetSwarms) sumSwarm(swarm); for (const root of roots) for (const swarm of root.swarms) sumSwarm(swarm); - const ceiling = fleetCeiling(lines, fleet, implicit.ceiling); const implicitHere = isImplicitFleet(lines, fleet); return { fleet, @@ -292,9 +298,11 @@ function memberRow(node: MemberNode, prefix: string, host: string): Row { function swarmRow(node: SwarmNode, prefix: string): Row { const count = node.members.length; - const size = node.ceiling.fan_out !== undefined ? `${count}/${String(node.ceiling.fan_out)} members` : `${count} member${count === 1 ? "" : "s"}`; - const rest = [...(node.state ? [node.state] : node.ceiling.until ? [`until ${hhmm(String(node.ceiling.until))}`] : [])]; - const spend = formatSpend(node.spend, typeof node.ceiling.budget === "string" ? node.ceiling.budget : undefined); + // 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(" ") }; } diff --git a/packages/openfleet/src/hooks-install.test.ts b/packages/openfleet/src/hooks-install.test.ts index dba030f..5807313 100644 --- a/packages/openfleet/src/hooks-install.test.ts +++ b/packages/openfleet/src/hooks-install.test.ts @@ -8,7 +8,8 @@ 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"); - expect(hookCommand("UserPromptSubmit")).toBe("command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit"); + // 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]); diff --git a/packages/openfleet/src/hooks-install.ts b/packages/openfleet/src/hooks-install.ts index 04fcff2..d29fcc8 100644 --- a/packages/openfleet/src/hooks-install.ts +++ b/packages/openfleet/src/hooks-install.ts @@ -37,12 +37,14 @@ const GUARD = "command -v logicsrc >/dev/null 2>&1"; * * 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), so its guard is `|| exit 0` and the exit - * code is the handler's own. SessionStart's stdout is the member's context - * line, so nothing there is redirected. + * 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}`; + 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`; } diff --git a/packages/openfleet/src/hooks.test.ts b/packages/openfleet/src/hooks.test.ts index 99c8f4b..9d0bf5c 100644 --- a/packages/openfleet/src/hooks.test.ts +++ b/packages/openfleet/src/hooks.test.ts @@ -3,7 +3,7 @@ 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 { findEvents, readLedger, readRecord, readSession, recordPath, writeCurrent, append } from "./store.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"; @@ -284,6 +284,42 @@ describe("Stop and SessionEnd", () => { 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", () => { diff --git a/packages/openfleet/src/hooks.ts b/packages/openfleet/src/hooks.ts index fede990..5841d0d 100644 --- a/packages/openfleet/src/hooks.ts +++ b/packages/openfleet/src/hooks.ts @@ -279,7 +279,9 @@ export function handleUserPromptSubmit(payload: Payload, io: HookIo): HookResult const approvals: Approvals = payload.permission_mode === "bypassPermissions" ? "bypass" : "native"; const implicit = io.implicit ?? implicitFleet(); - const result = startMember(homeDir, record, { sessionId, approvals, env, now: io.now(), host: io.host ?? implicit.host, implicit }); + // 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 } }); @@ -368,7 +370,9 @@ export function handleStop(payload: Payload, io: HookIo): HookResult { if (!jobDir || !session.record) return OK; if (!Array.isArray(payload.background_tasks) || payload.background_tasks.length > 0) return OK; const record = session.record; - if (endOf(readLedger(homeDir, record.fleet), 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(readLedger(homeDir, record.fleet), record.member); + if (existing && existing.state !== "lost") return OK; const state = stateJson(jobDir); endMember( homeDir, @@ -398,8 +402,8 @@ export function handleSessionEnd(payload: Payload, io: HookIo): HookResult { // 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); - if (endOf(lines, record.member)) return OK; + const existing = endOf(readLedger(homeDir, record.fleet), record.member); + if (existing && existing.state !== "lost") return OK; const jobDir = ownJobDir(env, sessionId); const state = jobDir ? stateJson(jobDir) : null; endMember( diff --git a/packages/openfleet/src/rosters.ts b/packages/openfleet/src/rosters.ts index 6cafff6..12f8d17 100644 --- a/packages/openfleet/src/rosters.ts +++ b/packages/openfleet/src/rosters.ts @@ -184,6 +184,41 @@ export function defaultRosters(exec: Exec = realExec, env: Env = process.env): R /** 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"; - if (engine?.startsWith("moshcode/")) return "moshcode"; + // 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 index 43c29ee..c3b97ef 100644 --- a/packages/openfleet/src/store.test.ts +++ b/packages/openfleet/src/store.test.ts @@ -4,13 +4,17 @@ 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, @@ -160,6 +164,24 @@ describe("the ledger", () => { 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"); diff --git a/packages/openfleet/src/store.ts b/packages/openfleet/src/store.ts index 52e8801..bd6cea9 100644 --- a/packages/openfleet/src/store.ts +++ b/packages/openfleet/src/store.ts @@ -236,6 +236,63 @@ export function append( 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")) { diff --git a/packages/openfleet/src/types.ts b/packages/openfleet/src/types.ts index 187f9b2..e5de817 100644 --- a/packages/openfleet/src/types.ts +++ b/packages/openfleet/src/types.ts @@ -178,7 +178,14 @@ export interface SwarmNode { 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; diff --git a/prd/0008-openfleet-reference-implementation.md b/prd/0008-openfleet-reference-implementation.md index 5279245..d44df60 100644 --- a/prd/0008-openfleet-reference-implementation.md +++ b/prd/0008-openfleet-reference-implementation.md @@ -74,7 +74,21 @@ the files exist and something writes them. - 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; every verb takes `--json`. + `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. @@ -86,7 +100,9 @@ the files exist and something writes them. - 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 member its engine no longer lists. + 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 @@ -129,5 +145,10 @@ None. It is the reference implementation of an open standard. - 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. -- `hasEvent` before a write is a check, not a lock. Two writers racing on one - swarm can still produce two `swarm.end` lines. +- 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. From 85608e5540831829d2326e81d0349333ecff16b2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:49:46 +0000 Subject: [PATCH 3/4] openfleet hooks: no member.end for a member that never started A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- packages/openfleet/src/hooks.test.ts | 11 +++++++++++ packages/openfleet/src/hooks.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/openfleet/src/hooks.test.ts b/packages/openfleet/src/hooks.test.ts index 9d0bf5c..f165051 100644 --- a/packages/openfleet/src/hooks.test.ts +++ b/packages/openfleet/src/hooks.test.ts @@ -186,6 +186,17 @@ describe("UserPromptSubmit", () => { 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); diff --git a/packages/openfleet/src/hooks.ts b/packages/openfleet/src/hooks.ts index 5841d0d..0620bcf 100644 --- a/packages/openfleet/src/hooks.ts +++ b/packages/openfleet/src/hooks.ts @@ -370,8 +370,11 @@ export function handleStop(payload: Payload, io: HookIo): HookResult { 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(readLedger(homeDir, record.fleet), record.member); + const existing = endOf(lines, record.member); if (existing && existing.state !== "lost") return OK; const state = stateJson(jobDir); endMember( @@ -402,7 +405,10 @@ export function handleSessionEnd(payload: Payload, io: HookIo): HookResult { // 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 existing = endOf(readLedger(homeDir, record.fleet), record.member); + 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; From ad6523d472339845ebcf3698a056d18614bc87fb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 10:52:59 +0000 Subject: [PATCH 4/4] logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --- packages/logicsrc-mcp/src/standards.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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[] };