mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 04:43:58 +00:00
* 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>
285 lines
11 KiB
TypeScript
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 };
|
|
}
|