logicsrc/packages/openfleet/src/hooks-install.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

285 lines
11 KiB
TypeScript

/**
* 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), and only that code is passed on. Any other
* failure (a crash, a missing build, an older `logicsrc` on PATH with no
* `fleet`) would otherwise show as an error on every prompt, so it becomes 0.
* SessionStart's stdout is the member's context line, so nothing there is
* redirected.
*/
export function hookCommand(event: HookEvent): string {
if (event === "UserPromptSubmit") return `${GUARD} || exit 0; logicsrc fleet hook ${event}; rc=$?; [ "$rc" -eq 2 ] && exit 2; exit 0`;
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<string, unknown>;
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<string, unknown> {
const hooks = settings.hooks;
if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) return hooks as Record<string, unknown>;
const fresh: Record<string, unknown> = {};
settings.hooks = fresh;
return fresh;
}
function groupsOf(hooks: Record<string, unknown>, event: string): Group[] {
if (!Array.isArray(hooks[event])) hooks[event] = [];
return hooks[event] as Group[];
}
function entryFor(spec: HookSpec): Record<string, unknown> {
return { type: "command", command: spec.command, ...(spec.timeout !== undefined ? { timeout: spec.timeout } : {}) };
}
function sameEntry(entry: unknown, spec: HookSpec): boolean {
const e = entry as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>) : {};
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<string>([
...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 };
}