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,241 @@
/**
* How a ceiling is read, merged and checked (docs/openfleet.md, "How a ceiling
* is read" and rules 3 to 5).
*
* A fleet's ceiling is whole. A swarm's holds only the keys it narrowed. A
* member's effective ceiling is the fleet's, merged key by key down the swarm
* path, with the latest `fleet.cap` for any swarm on that path applied last.
* Narrower never widens: a key a spawner tried to widen is ignored here and
* refused by the engine that checks it.
*/
import { findEvents, spawnOf } from "./store.js";
import type { Approvals, Ceiling, CeilingKey, LedgerLine, Refusal } from "./types.js";
import { CEILING_KEYS } from "./types.js";
export interface Budget {
amount: number;
unit: string;
}
/** `20 USD`, `1500000 tokens`, or a CAIP-19 asset id after the amount. */
export function parseBudget(value: unknown): Budget | null {
if (typeof value !== "string") return null;
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s+(\S.*)$/);
if (!match) return null;
return { amount: Number(match[1]), unit: match[2].trim() };
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function asHosts(value: unknown): string[] | null {
return Array.isArray(value) && value.every((host) => typeof host === "string") ? (value as string[]) : null;
}
function asTime(value: unknown): number | null {
if (typeof value !== "string") return null;
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : ms;
}
/**
* Is `wanted` within `allowed` for one key? Equal counts as within. An absent
* `allowed` means the spec's default for that key: `approvals` native, `depth`
* 1, and no cap for budget, fan_out, hosts and until.
*/
export function isNarrower(key: CeilingKey, wanted: unknown, allowed: unknown): boolean {
if (wanted === undefined) return true;
switch (key) {
case "approvals":
return wanted === "native" || allowed === "bypass";
case "depth": {
const want = asNumber(wanted);
const cap = allowed === undefined ? 1 : asNumber(allowed);
if (want === null) return false;
if (cap === null) return false;
return want <= cap;
}
case "fan_out": {
if (allowed === undefined) return true;
const want = asNumber(wanted);
const cap = asNumber(allowed);
return want !== null && cap !== null && want <= cap;
}
case "budget": {
if (allowed === undefined) return true;
const want = parseBudget(wanted);
const cap = parseBudget(allowed);
// Budgets in different units are not comparable, so not narrower.
return want !== null && cap !== null && want.unit === cap.unit && want.amount <= cap.amount;
}
case "hosts": {
if (allowed === undefined) return true;
const want = asHosts(wanted);
const cap = asHosts(allowed);
return want !== null && cap !== null && want.every((host) => cap.includes(host));
}
case "until": {
if (allowed === undefined) return true;
const want = asTime(wanted);
const cap = asTime(allowed);
return want !== null && cap !== null && want <= cap;
}
default:
return true;
}
}
/**
* Merge a narrowing into a base ceiling, key by key. A key the narrowing
* would widen keeps the base value: a merged ceiling never widens, whatever a
* ledger line claims. Unknown keys in the narrowing are copied through.
*/
export function mergeCeiling(base: Ceiling, narrowing: Ceiling | undefined): Ceiling {
const out: Ceiling = { ...base };
if (!narrowing || typeof narrowing !== "object") return out;
for (const [key, value] of Object.entries(narrowing)) {
if (value === undefined) continue;
if ((CEILING_KEYS as readonly string[]).includes(key)) {
if (isNarrower(key as CeilingKey, value, base[key])) out[key] = value;
} else {
out[key] = value;
}
}
return out;
}
/**
* A fleet's whole ceiling: the latest `fleet.cap` whose target is the fleet,
* else `fleet.open`, else the implicit fleet's. A line that names no `hosts`
* means the host it was written on (the ceiling table), so the two reference
* readers admit the same members whichever tool opened the fleet.
*/
export function fleetCeiling(lines: LedgerLine[], fleet: string, implicit: Ceiling): Ceiling {
const caps = findEvents(lines, "fleet.cap", { target: fleet });
const opens = findEvents(lines, "fleet.open", { fleet });
const line = caps.length ? caps[caps.length - 1] : opens.length ? opens[opens.length - 1] : null;
if (!line) return { ...implicit };
const ceiling: Ceiling = { ...(line.ceiling ?? {}) };
if (ceiling.hosts === undefined && typeof line.host === "string" && line.host !== "") ceiling.hosts = [line.host];
return ceiling;
}
/** True when the ledger holds no `fleet.open` for this fleet: it is the implicit one. */
export function isImplicitFleet(lines: LedgerLine[], fleet: string): boolean {
return !lines.some((line) => line.event === "fleet.open" && line.fleet === fleet);
}
/** The swarms from the top down to `swarm`, following `parent_swarm` in each `swarm.spawn`. */
export function swarmChain(lines: LedgerLine[], swarm: string | undefined): string[] {
const chain: string[] = [];
const seen = new Set<string>();
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<string, unknown>)[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<string | undefined>): Record<string, number> {
const sums: Record<string, number> = {};
for (const total of totals) {
const parsed = parseBudget(total);
if (!parsed) continue;
sums[parsed.unit] = (sums[parsed.unit] ?? 0) + parsed.amount;
}
return sums;
}
/** `<spent> / <budget>` in the budget's unit, or the sums alone when there is no budget. */
export function formatSpend(spend: Record<string, number>, 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(", ");
}