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 |