logicsrc/packages/openfleet/src/store.ts
Anthony Ettinger 9ae7ad8962
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>
2026-09-13 10:58:55 +00:00

453 lines
16 KiB
TypeScript

/**
* 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<string, string | undefined>;
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: `<user>@<host>`, 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/<fleet>/members/<member>.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<T>(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<FleetRecord>(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<string, FleetRecord> {
const out = new Map<string, FleetRecord>();
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.<host>.jsonl` copied in from another host.
*/
export function ledgerPaths(homeDir: string, fleet: string): string[] {
const dir = fleetDir(homeDir, fleet);
let names: string[];
try {
names = readdirSync(dir);
} catch {
return [];
}
return names
.filter((name) => /^ledger.*\.jsonl$/.test(name))
.sort()
.map((name) => join(dir, name));
}
/** ISO 8601 UTC with whole seconds, the way the spec's examples read. */
export function isoNow(now: Date = new Date()): string {
return now.toISOString().replace(/\.\d{3}Z$/, "Z");
}
/**
* Append one line to this host's ledger for a fleet. Adds `at`, `fleet` and
* `host`; the caller passes `event` and `by`, because who did it is the one
* thing this module must not guess. Returns the line as written.
*/
export function append(
homeDir: string,
fleet: string,
input: LedgerInput,
opts: { now?: Date; host?: string } = {},
): LedgerLine {
if (!input.by) throw new Error("ledger line needs `by`: sysop or a member id");
if (!input.event) throw new Error("ledger line needs `event`");
const { at: givenAt, host: givenHost, event, by, ...rest } = input as LedgerInput & { event: string; by: string };
// Key order is fixed so every writer's lines read the same way.
const line: LedgerLine = {
at: typeof givenAt === "string" ? givenAt : isoNow(opts.now),
event,
fleet,
host: typeof givenHost === "string" ? givenHost : (opts.host ?? thisHost()),
by,
...rest,
};
const path = ledgerPath(homeDir, fleet);
mkdirPrivate(dirname(path));
appendFileSync(path, `${JSON.stringify(line)}\n`, { encoding: "utf8", mode: FILE_MODE });
try {
if ((statSync(path).mode & 0o777) !== FILE_MODE) chmodSync(path, FILE_MODE);
} catch {
// A ledger we cannot chmod is still a ledger we appended to.
}
return line;
}
// ---------------------------------------------------------------------------
// Once-markers: the lines that must never be written twice
// ---------------------------------------------------------------------------
//
// `member.start`, `member.end` and `swarm.end` are checked in the ledger before
// they are written, but a check followed by an append is not exclusion: the
// engine's hook and the spawner fire at the same moment. So each such line
// takes a marker first, an exclusive create under `fleets/<fleet>/marks/`, and
// the writer that loses the race writes nothing. Both reference writers use
// the same paths, so the rule holds across moshcode and these hooks.
export type OnceEvent = "member.start" | "member.end" | "swarm.end";
export function marksDir(homeDir: string, fleet: string): string {
return join(fleetDir(homeDir, fleet), "marks");
}
/**
* The marker a line takes: `<event>.<id>`, where id is the member or the
* swarm. A `lost` end takes `member.end.<id>.lost` instead, so the engine's
* or the spawner's real end can still supersede it and take the plain one.
*/
export function markName(event: OnceEvent, id: string, lost = false): string {
return `${event}.${id}${lost ? ".lost" : ""}`;
}
export function hasMark(homeDir: string, fleet: string, name: string): boolean {
return existsSync(join(marksDir(homeDir, fleet), name));
}
/** Take a marker with an exclusive create (0600 in a 0700 dir). False when another writer holds it. */
export function claimMark(homeDir: string, fleet: string, name: string): boolean {
const dir = marksDir(homeDir, fleet);
mkdirPrivate(dir);
try {
writeFileSync(join(dir, name), "", { encoding: "utf8", flag: "wx", mode: FILE_MODE });
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
}
}
/**
* Append a line only when its once-marker is free. Null means another writer
* already holds the marker: report "already" and write nothing.
*/
export function appendOnce(
homeDir: string,
fleet: string,
input: LedgerInput,
opts: { now?: Date; host?: string; once: string },
): LedgerLine | null {
if (!claimMark(homeDir, fleet, opts.once)) return null;
return append(homeDir, fleet, input, { now: opts.now, host: opts.host });
}
function parseLines(text: string): LedgerLine[] {
const out: LedgerLine[] = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "") continue;
try {
const parsed = JSON.parse(line) as LedgerLine;
if (parsed && typeof parsed === "object" && typeof parsed.event === "string") out.push(parsed);
} catch {
// One bad line must not hide the rest of the ledger.
}
}
return out;
}
/** Every ledger under a fleet, merged and sorted by `at` (stable, so one file's order holds on ties). */
export function readLedger(homeDir: string, fleet: string): LedgerLine[] {
const lines: LedgerLine[] = [];
for (const path of ledgerPaths(homeDir, fleet)) {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
continue;
}
lines.push(...parseLines(text));
}
return lines.map((line, index) => ({ line, index })).sort((a, b) => {
const byAt = String(a.line.at ?? "").localeCompare(String(b.line.at ?? ""));
return byAt !== 0 ? byAt : a.index - b.index;
}).map((entry) => entry.line);
}
type Match = Partial<Record<keyof LedgerLine, unknown>>;
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<LedgerLine | null>): 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<SessionFile>(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.
}
}