OpenFleet reference implementation: @logicsrc/openfleet, logicsrc fleet, and Claude Code hooks (#185)

* OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet

Ship what docs/openfleet.md describes. The new workspace package holds the
record (write once, never overwrite, 0600), the ledger (append-only JSON
Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet
ceiling, narrowed swarm keys, a merge that never widens, refusals by key),
claiming and deriving exactly as the spec's "Claiming and deriving" and
rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine
rosters into the tree the landing page shows.

logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with
--json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4
outside the caller's subtree, ends nested swarms first, goes through each
member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a
signal for claude-p) and writes one swarm.end per swarm. tree reads claude
agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws
recordless sessions as roster roots of the implicit fleet, and writes
member.end lost for a recorded member its engine no longer lists.

Claude Code takes part through hooks: logicsrc fleet hooks install merges
SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into
~/.claude/settings.json without clobbering it, and logicsrc fleet hook
<Event> runs each one. SessionStart claims, derives or writes a root record
and hands the member its variables through CLAUDE_ENV_FILE; UserPromptSubmit
checks the ceiling with the permission mode the engine reports and writes
member.start, or refuses the first prompt with exit 2 and ceiling.refuse;
PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write
member.end. A hand-started root takes the engine's reported approvals
before member.start, since the command line only guesses them. Hooks
never fail the engine: everything is caught and logged to hooks.log.

The spec and the landing page now say what ships, keep Status 0.1, and
record the two verified Claude Code limits: a background job dispatched from
claude agents gets no launcher environment, and OPENFLEET_* exported at
SessionStart reach the member's tools but not later hooks, so hooks key on
session_id through $OPENFLEET_HOME/sessions/<session_id>.json. PRD 0008
covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the
package before the CLI; README and docs/cli.md list the group.

Tests: 95 in the package (record, ledger merge, every narrower case, the
worked example's claim and derive, the folded tree, hook install
idempotence, each hook handler including the exit-2 refusal and the
PreToolUse deny, every verb with fake deps) and 4 in the CLI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold

The review of the reference implementation against moshcode found the two
readers disagreeing on the same files. This round applies the shared
rulings so both sides read a ledger the same way.

Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the
effective ceiling from the ledger on every read. The latest fleet-target
fleet.cap (else fleet.open, else the implicit fleet's) replaces the copy in
a record, so a sysop's widening cap reaches running members; then each
swarm.spawn narrowing down the path, then swarm caps last. In the implicit
fleet a parentless record's own approvals enters at the root; a ceiling a
writer left without the key is never read as native, and startMember fills
it with the engine's word while the record is unclaimed. A fleet.open or
cap with no hosts means the host it was written on (R23).

Once-markers (R-G, R28): member.start, member.end and swarm.end each take
an exclusive create under fleets/<fleet>/marks/<event>.<id> before the
append; a lost end takes <id>.lost so a real end can still supersede it.
The hooks let a real end follow a lost line (R9).

tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member
past its effective until with state timeout and the members of a swarm or
fleet at its budget with state budget, then writes swarm.end for each swarm
touched once it is complete. An agent's tree stops nothing. lost is written
only for a member its engine's roster can hold: a claude-code background job
(8-hex member or session) or a moshcode pane, never an interactive session
claude agents does not list (R-E, R3, R14). A nested swarm is drawn under
the member that spawned it and its row shows the effective ceiling (R25).

stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every
member and every nested swarm has an end line that counts; an engine that
will not end a member leaves it without an end line and the verb exits
non-zero. claude stop takes the job id: the member of a background job,
else the first eight characters of a session UUID; an interactive session
with no job id cannot be stopped and the tool says so. cap on a swarm
refuses a key that would widen. A derived claude-code job is named by its
job id and carries no pid.

Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a
derived record's guessed approvals corrected at UserPromptSubmit), R32
(the UserPromptSubmit hook passes only exit 2 through), R31 (package
README), R36 (rule 13 says the launcher test is unimplemented in 0.1),
docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet
tests, 93 CLI tests, contract green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* openfleet hooks: no member.end for a member that never started

A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

* logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-13 03:58:55 -07:00 • committed by GitHub
parent 519d13c3d6
commit 9ae7ad8962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 6355 additions and 20 deletions

View file

@ -0,0 +1,136 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { HOOK_EVENTS, hookCommand, hookSpecs, hooksStatus, installHooks, isOurs, removeHooks, settingsFile } from "./hooks-install.js";
import { cleanup, tempHome } from "./test-helpers.js";
describe("hook commands", () => {
it("guard every event so a box without logicsrc stays silent, and let only UserPromptSubmit be heard", () => {
expect(hookCommand("SessionStart")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook SessionStart; exit 0");
expect(hookCommand("Stop")).toBe("command -v logicsrc >/dev/null 2>&1 && logicsrc fleet hook Stop; exit 0");
// Only the deliberate 2 (a refused start) reaches the engine; a crash or an older logicsrc on PATH is swallowed.
expect(hookCommand("UserPromptSubmit")).toBe('command -v logicsrc >/dev/null 2>&1 || exit 0; logicsrc fleet hook UserPromptSubmit; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0');
expect(hookCommand("SessionStart")).not.toContain(">/dev/null 2>&1 && logicsrc fleet hook SessionStart >/dev/null");
const specs = hookSpecs();
expect(specs.map((spec) => spec.event)).toEqual([...HOOK_EVENTS]);
expect(specs.find((spec) => spec.event === "PreToolUse")?.matcher).toBe("Edit|Write|MultiEdit|NotebookEdit");
expect(specs.find((spec) => spec.event === "SessionEnd")?.timeout).toBe(20);
expect(specs.filter((spec) => spec.event !== "PreToolUse").every((spec) => spec.matcher === undefined)).toBe(true);
});
it("recognises ours by the command text alone", () => {
expect(isOurs({ type: "command", command: hookCommand("Stop") })).toBe(true);
expect(isOurs({ type: "command", command: "moshcode herd report x done" })).toBe(false);
expect(isOurs(null)).toBe(false);
});
it("resolves the settings file from HOME or CLAUDE_CONFIG_DIR", () => {
expect(settingsFile({ HOME: "/home/x" })).toBe("/home/x/.claude/settings.json");
expect(settingsFile({ HOME: "/home/x", CLAUDE_CONFIG_DIR: "/cfg" })).toBe("/cfg/settings.json");
});
});
describe("install, status, remove over a settings file", () => {
let dir: string;
let file: string;
beforeEach(() => {
dir = tempHome();
mkdirSync(join(dir, ".claude"));
file = join(dir, ".claude", "settings.json");
});
afterEach(() => cleanup(dir));
it("creates the file with five matcher-less groups (PreToolUse with its matcher), 0600, then is idempotent", () => {
const first = installHooks(file);
expect(first.ok).toBe(true);
expect(first.written).toBe(5);
expect(first.changes.every((change) => change.change === "added")).toBe(true);
expect(statSync(file).mode & 0o777).toBe(0o600);
const settings = JSON.parse(readFileSync(file, "utf8"));
for (const event of HOOK_EVENTS) expect(settings.hooks[event].length).toBe(1);
expect(settings.hooks.Stop[0]).toEqual({ hooks: [{ type: "command", command: hookCommand("Stop") }] });
expect(settings.hooks.PreToolUse[0]).toEqual({ matcher: "Edit|Write|MultiEdit|NotebookEdit", hooks: [{ type: "command", command: hookCommand("PreToolUse") }] });
expect(settings.hooks.SessionEnd[0].hooks[0]).toEqual({ type: "command", command: hookCommand("SessionEnd"), timeout: 20 });
const before = readFileSync(file, "utf8");
const second = installHooks(file);
expect(second.written).toBe(0);
expect(second.changes.every((change) => change.change === "unchanged")).toBe(true);
expect(readFileSync(file, "utf8")).toBe(before);
const status = hooksStatus(file);
expect(status).toMatchObject({ file, present: true, readable: true, installed: true, partial: false });
expect(status.events.every((event) => event.installed && event.current)).toBe(true);
});
it("merges into an existing file, keeps everyone else's hooks and settings, and preserves the mode", () => {
writeFileSync(
file,
JSON.stringify({
model: "opus",
hooks: {
Stop: [{ hooks: [{ type: "command", command: "moshcode herd report \"$MOSHCODE_HERD_NAME\" done; exit 0" }] }],
Notification: [{ hooks: [{ type: "command", command: "say hi" }] }],
},
}),
);
chmodSync(file, 0o644);
const result = installHooks(file);
expect(result.ok).toBe(true);
expect(statSync(file).mode & 0o777).toBe(0o644);
const settings = JSON.parse(readFileSync(file, "utf8"));
expect(settings.model).toBe("opus");
expect(settings.hooks.Notification).toEqual([{ hooks: [{ type: "command", command: "say hi" }] }]);
expect(settings.hooks.Stop.length).toBe(2);
expect(settings.hooks.Stop[0].hooks[0].command).toContain("moshcode herd report");
expect(settings.hooks.Stop[1].hooks[0].command).toBe(hookCommand("Stop"));
const removed = removeHooks(file);
expect(removed).toMatchObject({ ok: true, removed: 5 });
const after = JSON.parse(readFileSync(file, "utf8"));
expect(after).toEqual({
model: "opus",
hooks: {
Stop: [{ hooks: [{ type: "command", command: "moshcode herd report \"$MOSHCODE_HERD_NAME\" done; exit 0" }] }],
Notification: [{ hooks: [{ type: "command", command: "say hi" }] }],
},
});
expect(removeHooks(file)).toMatchObject({ ok: true, removed: 0 });
});
it("replaces an older text of ours rather than firing twice, and status says so before", () => {
writeFileSync(file, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "logicsrc fleet hook Stop >/dev/null 2>&1; exit 0" }] }] } }));
const status = hooksStatus(file);
expect(status.partial).toBe(true);
expect(status.events.find((event) => event.event === "Stop")).toEqual({ event: "Stop", installed: true, current: false });
const result = installHooks(file);
expect(result.changes.find((change) => change.event === "Stop")?.change).toBe("updated");
const settings = JSON.parse(readFileSync(file, "utf8"));
expect(settings.hooks.Stop).toEqual([{ hooks: [{ type: "command", command: hookCommand("Stop") }] }]);
});
it("removes ours from any event, drops empty structure only when it made it, and leaves a missing file missing", () => {
installHooks(file);
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, "utf8")), hooks: { ...JSON.parse(readFileSync(file, "utf8")).hooks, SubagentStop: [{ hooks: [{ type: "command", command: "logicsrc fleet hook SubagentStop; exit 0" }] }] } }));
const removed = removeHooks(file);
expect(removed.removed).toBe(6);
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({});
const missing = join(dir, "nowhere", "settings.json");
expect(removeHooks(missing)).toMatchObject({ ok: true, removed: 0 });
expect(existsSync(missing)).toBe(false);
expect(hooksStatus(missing)).toMatchObject({ present: false, readable: true, installed: false, partial: false });
});
it("refuses to merge into a file it cannot parse, and a dry run writes nothing", () => {
writeFileSync(file, "{ not json");
const result = installHooks(file);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/not valid JSON/);
expect(readFileSync(file, "utf8")).toBe("{ not json");
expect(hooksStatus(file).readable).toBe(false);
writeFileSync(file, "{}");
const dry = installHooks(file, { dryRun: true });
expect(dry.written).toBe(5);
expect(readFileSync(file, "utf8")).toBe("{}");
});
});