Files
bitvid/tools/fleet/bin/core/ops.mjs
T
2026-10-19 22:58:13 -04:00

580 lines
25 KiB
JavaScript

// Agent-facing fleet operations. The CLIs in bin/ and the stdio MCP server
// both call these. Do not copy this logic into a plugin or a second server.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs';
const STATUSES = ['open', 'in-progress', 'blocked', 'done', 'deferred'];
const SPECIAL = ['owner', 'scheduler', 'interpreter'];
/** Paths a setup commit may stage. Product files are not in this list. */
export const SETUP_PATHS = ['fleet.config.json', 'tools/fleet', 'docs/agents', 'docs/TODO.md', 'fleet', '.gitignore', 'AGENTS.md', '.agents'];
const GITIGNORE_ADDS = new Set(['fleet/inbox/', '.task', '.fleet/']);
const num = (id) => Number(String(id).match(/(\d+)$/)?.[1] ?? 0);
function readJson(file, fallback = null) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
export function backlogDir(c, cwd = process.cwd()) {
const top = tryGit(cwd, 'rev-parse', '--show-toplevel');
const dir = path.join(top.ok ? top.out : c.repo, c.paths.backlog);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function readItems(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => {
const parsed = readJson(path.join(dir, f));
return parsed ?? { id: f, status: 'BROKEN-JSON', area: '?', title: f };
});
}
function itemPath(dir, id) {
if (!/^[\w.-]+$/.test(id || '')) die(`no item ${id}`);
const file = path.join(dir, `${id}.json`);
if (!fs.existsSync(file)) die(`no item ${id}`);
return file;
}
export function listBacklog(c, { area, status, cwd } = {}) {
const items = readItems(backlogDir(c, cwd));
return items.filter((i) => (!area || i.area === area) && (status ? i.status === status : i.status !== 'done'))
.sort((a, b) => String(a.area).localeCompare(String(b.area)) || num(a.id) - num(b.id));
}
export function addBacklog(c, { area, title, id, blockedOn, cwd } = {}) {
if (!area) die('--area required');
if (!title) die('--title required');
const a = getArea(c, area);
const dir = backlogDir(c, cwd);
const items = readItems(dir);
const prefix = a.backlogPrefix ?? a.id;
const itemId = id ?? `${prefix}-${Math.max(0, ...items.filter((i) => String(i.id).startsWith(`${prefix}-`)).map((i) => num(i.id))) + 1}`;
if (fs.existsSync(path.join(dir, `${itemId}.json`))) die(`${itemId} exists`);
const where = cwd || process.cwd();
const head = tryGit(where, 'rev-parse', 'HEAD');
const it = { id: itemId, area: a.id, status: blockedOn ? 'blocked' : 'open', title, blockedOn: blockedOn ?? null, addedOn: today(), filedAt: head.ok ? head.out : null };
writeAtomic(path.join(dir, `${it.id}.json`), JSON.stringify(it, null, 1) + '\n');
return it;
}
export function updateBacklog(c, id, fields = {}, cwd) {
const file = itemPath(backlogDir(c, cwd), id);
const it = readJson(file);
let statusSet = false;
for (const [k, raw] of Object.entries(fields)) {
if (k === 'id') die('id cannot change');
const v = raw === 'null' ? null : raw;
if (k === 'status' && !STATUSES.includes(v)) die(`status must be one of ${STATUSES.join(', ')}`);
if (k === 'area') getArea(c, v);
it[k] = v;
if (k === 'status') statusSet = true;
}
if (it.status !== 'blocked' && statusSet) it.blockedOn = null;
writeAtomic(file, JSON.stringify(it, null, 1) + '\n');
return it;
}
/** Not-done items for an area, in id order. Which one to do is a prompt decision. */
export function nextBacklog(c, area, cwd) {
getArea(c, area);
return readItems(backlogDir(c, cwd))
.filter((i) => i.area === area && i.status !== 'done')
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
/**
* Mark a named item in-progress. The write is atomic. Status and area are not
* refused here: whether a blocked, done, or other area's item should be taken
* is judgment, and the prompt says how to make it.
*/
export function claimBacklog(c, { area, id, cwd } = {}) {
if (!area) die('area required');
getArea(c, area);
if (!id) return { item: null, idle: true, open: nextBacklog(c, area, cwd) };
const dir = backlogDir(c, cwd);
const it = readJson(itemPath(dir, id));
if (it.status !== 'in-progress') {
it.status = 'in-progress';
it.blockedOn = null;
writeAtomic(path.join(dir, `${it.id}.json`), JSON.stringify(it, null, 1) + '\n');
}
return { item: it, idle: false };
}
export function closeBacklog(c, id, cwd) {
const file = itemPath(backlogDir(c, cwd), id);
const it = readJson(file);
it.status = 'done';
it.blockedOn = null;
it.closedOn = today();
writeAtomic(file, JSON.stringify(it, null, 1) + '\n');
return it;
}
function knownParty(c, id) {
if (SPECIAL.includes(id)) return;
getArea(c, id);
}
export function sendMailbox(c, { to, from, body, subject = '' } = {}) {
if (!from) die('--from <your id> required');
knownParty(c, from);
if (!body || !String(body).trim()) die('empty message');
const targets = to === 'all' ? c.areas.map((a) => a.id).filter((id) => id !== from) : [to];
const files = targets.map((t) => { knownParty(c, t); return mailboxSendFile(c.mailbox.dir, t, from, body, subject ?? ''); });
const wakes = targets.filter((t) => t !== from).map((t) => ({ to: t, ...nudgeMail(c, t, from) }));
return Object.assign(files, { wakes });
}
/** Reading takes the messages: they move to the archive, so unread means not yet read. */
export function readMailbox(c, area) {
knownParty(c, area);
const msgs = mailboxReadDir(c.mailbox.dir, area);
for (const m of msgs) {
try { mailboxArchiveDir(c.mailbox.dir, area, m.file); } catch { /* already archived */ }
}
return msgs;
}
export function archiveMailbox(c, area, which) {
knownParty(c, area);
return mailboxArchiveDir(c.mailbox.dir, area, which);
}
function gitignoreIsFleetOnly(repo) {
const st = tryGit(repo, 'status', '--porcelain', '--', '.gitignore');
if (!st.ok || !st.out) return false;
if (st.out.split('\n').every((l) => l.startsWith('??'))) return true;
const diff = tryGit(repo, 'diff', '-U0', '--', '.gitignore');
const lines = (diff.out || '').split('\n').filter((l) => (l.startsWith('+') || l.startsWith('-')) && !l.startsWith('+++') && !l.startsWith('---'));
return lines.length > 0 && lines.every((l) => l.startsWith('+') && GITIGNORE_ADDS.has(l.slice(1)));
}
/**
* Commit only the fleet setup files in the main checkout. Runs in this process
* (fleet init, or the MCP server outside an agent sandbox). A sandboxed git
* commit cannot write the index; this is the path that can. Product files are
* left unstaged.
*/
export function setupCommit(repo, { message = 'fleet init' } = {}) {
if (!tryGit(repo, 'rev-parse', '--git-dir').ok) die(`not a git repo: ${repo}`);
const paths = [];
for (const spec of SETUP_PATHS) {
if (spec === '.gitignore') {
if (gitignoreIsFleetOnly(repo)) paths.push(spec);
continue;
}
const st = tryGit(repo, 'status', '--porcelain', '--', spec);
if (st.ok && st.out) paths.push(spec);
}
if (!paths.length) {
const head = tryGit(repo, 'rev-parse', 'HEAD');
return { sha: head.ok ? head.out : null, committed: [], pending: false };
}
const add = tryGit(repo, 'add', '--', ...paths);
if (!add.ok) return { sha: null, committed: [], pending: true, paths, error: add.err || 'git add failed' };
const email = tryGit(repo, 'config', 'user.email').out;
const args = ['-c', 'core.editor=true', ...(email ? [] : ['-c', 'user.email=fleet-init@localhost', '-c', 'user.name=fleet init']), 'commit', '-m', message, '--', ...paths];
const commit = tryGit(repo, ...args);
if (!commit.ok) return { sha: null, committed: [], pending: true, paths, error: commit.err || commit.out || 'git commit failed' };
return { sha: tryGit(repo, 'rev-parse', 'HEAD').out, committed: paths, pending: false };
}
/** Mail the session manager from the interpreter. The wake is the same short line as any other mail. */
export function tellDispatch(c, { body, from = 'interpreter', subject = '' } = {}) {
const hub = c.areas.find((a) => a.role === 'dispatcher');
if (!hub) die('no session manager (role dispatcher)');
const files = sendMailbox(c, { to: hub.id, from, body, subject });
const wake = (files.wakes || []).find((w) => w.to === hub.id) || { submitted: false, reason: 'no-window' };
return { files: [...files], to: hub.id, from, nudged: Boolean(wake.submitted), wake };
}
export function hearInterpreter(c) {
return readMailbox(c, 'interpreter');
}
const NUDGE_PAUSE_MS = 200;
const NUDGE_SETTLE_MS = 150;
const NUDGE_RETRY_MS = 400;
const IDLE_AFTER_MS = 3000;
const RECOVER_AFTER_MS = 120000;
const BUSY_RE = /esc to interrupt|esc to cancel|ctrl\+c to (?:interrupt|cancel)/i;
export function nudgeLine(from) {
return `You have new fleet mail from ${from}. Read it with mailbox_read.`;
}
function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function tmuxTarget(c, areaId) {
return `fleet-${c.project}`.replace(/[.:]/g, '-') + `:${areaId}`;
}
function tmuxText(args) {
return spawnSync('tmux', args, { encoding: 'utf8' });
}
function capturePane(target) {
const r = tmuxText(['capture-pane', '-p', '-t', target]);
return r.status === 0 ? r.stdout : '';
}
function cursorRow(target, pane) {
const info = tmuxText(['display-message', '-p', '-t', target, '#{cursor_y}']);
const y = Number(String(info.stdout || '').trim());
const rows = String(pane).split('\n');
if (!Number.isInteger(y) || y < 0 || y >= rows.length) return rows[rows.length - 1] || '';
return rows[y] || '';
}
function stillInInput(target, line) {
return cursorRow(target, capturePane(target)).includes(line);
}
/**
* Wake an open tmux window with a short line, not the mail body.
* Codex and Claude treat a fast burst as a paste and swallow Enter, so the
* line is typed, then Enter is sent after a pause, outside that burst.
* If the cursor row still holds the line, Enter is sent once more.
* A pane that shows an interrupt hint is left alone; a later status check can nudge it.
*/
export function nudgeMail(c, areaId, from) {
const target = tmuxTarget(c, areaId);
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { submitted: false, reason: 'no-window' };
const line = nudgeLine(from);
if (BUSY_RE.test(capturePane(target))) return { submitted: false, reason: 'working', line };
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) return { submitted: false, reason: 'send-failed', line };
sleepMs(NUDGE_PAUSE_MS);
tmuxText(['send-keys', '-t', target, 'Enter']);
sleepMs(NUDGE_SETTLE_MS);
if (!stillInInput(target, line)) return { submitted: true, reason: 'submitted', line };
sleepMs(NUDGE_RETRY_MS);
tmuxText(['send-keys', '-t', target, 'Enter']);
sleepMs(NUDGE_SETTLE_MS);
const submitted = !stillInInput(target, line);
return { submitted, reason: submitted ? 'submitted' : 'not-submitted', line };
}
function unreadFiles(c, id) {
const box = path.join(c.mailbox.dir, id);
if (!fs.existsSync(box)) return [];
return fs.readdirSync(box).filter((f) => f.endsWith('.md') && !f.startsWith('.')).sort()
.map((f) => ({ file: f, path: path.join(box, f), mtimeMs: fs.statSync(path.join(box, f)).mtimeMs }));
}
function paneView(c, areaId, idleAfterMs) {
const target = tmuxTarget(c, areaId);
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { window: 'dead', activity: 'unknown' };
const info = tmuxText(['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
const match = String(info.stdout || '').trim().match(/^([01])\s+(\d+)$/);
if (info.status !== 0 || !match) return { window: 'dead', activity: 'unknown' };
const [, dead, stamp] = match;
if (dead === '1') return { window: 'dead', activity: 'unknown' };
const pane = capturePane(target);
const busy = BUSY_RE.test(pane);
const ageMs = Date.now() - Number(stamp) * 1000;
const activity = busy || ageMs < idleAfterMs ? 'working' : 'idle';
return { window: 'alive', activity };
}
function nudgeStatePath(c) {
return path.join(c.stateDir, 'mail-nudges.json');
}
function readNudgeState(c) {
try { return JSON.parse(fs.readFileSync(nudgeStatePath(c), 'utf8')); } catch { return {}; }
}
function senderOf(file) {
try {
const text = fs.readFileSync(file, 'utf8');
return text.match(/^from: (.+)$/m)?.[1]?.trim() || 'fleet';
} catch { return 'fleet'; }
}
/**
* Per-area window, unread count, and a working/idle guess from the pane.
* When an area is idle with mail that has been unread for a while, nudge once.
* That is the only recovery. It runs because someone looked at status, not on a timer.
*/
export function fleetStatus(c, { recoverAfterMs = RECOVER_AFTER_MS, idleAfterMs = IDLE_AFTER_MS } = {}) {
const state = readNudgeState(c);
let stateDirty = false;
const recovered = [];
const areas = c.areas.map((a) => {
const view = paneView(c, a.id, idleAfterMs);
const unread = unreadFiles(c, a.id);
const row = {
id: a.id, role: a.role, harness: a.harness,
window: view.window, unread: unread.length, activity: view.activity,
};
const oldest = unread[0];
const age = oldest ? Date.now() - oldest.mtimeMs : 0;
const already = (state[a.id] || []).includes(oldest?.file);
if (view.window === 'alive' && view.activity === 'idle' && oldest && age >= recoverAfterMs && !already) {
const wake = nudgeMail(c, a.id, senderOf(oldest.path));
state[a.id] = [...(state[a.id] || []), oldest.file];
stateDirty = true;
row.recovered = true;
row.recoverReason = wake.reason;
recovered.push(a.id);
}
return row;
});
if (stateDirty) writeAtomic(nudgeStatePath(c), JSON.stringify(state));
return { areas, recovered, interpreter: { unread: unreadFiles(c, 'interpreter').length } };
}
export function queuePaths(c) {
const state = path.join(c.stateDir, 'landq');
const queue = path.join(state, 'queue');
const done = path.join(state, 'results');
for (const d of [queue, done]) fs.mkdirSync(d, { recursive: true });
return {
state, queue, done,
pid: path.join(state, 'lander.pid'),
inflight: path.join(state, 'inflight.json'),
log: path.join(state, 'landq.log'),
};
}
export { readJson };
export function queued(c) {
const { queue } = queuePaths(c);
return fs.readdirSync(queue).filter((f) => f.endsWith('.json')).sort()
.map((f) => ({ ...readJson(path.join(queue, f)), qfile: f }));
}
export function landerAlive(c) {
return landerIsUp(c);
}
export function submitLanding(c, cwd = process.cwd(), { autostart = true } = {}) {
const branch = git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
if (branch === c.mainBranch || branch === 'HEAD') die(`refusing: submit from your area branch, not ${branch}`);
const warnings = [];
if (git(cwd, 'status', '--porcelain')) warnings.push('uncommitted changes stay in the worktree; only the committed tip is queued');
const sha = git(cwd, 'rev-parse', 'HEAD');
const timeoutMs = Number(c.gates?.timeoutMin) > 0 ? Number(c.gates.timeoutMin) * 60000 : 0;
if (c.remote) {
const fetched = runTimedSync({ cmd: 'git', args: ['-C', cwd, 'fetch', '-q', c.remote, c.mainBranch], timeoutMs });
if (fetched.timedOut) die(`fetch timed out after ${c.gates.timeoutMin} min`);
if (fetched.status !== 0) die(`fetch of ${c.mainBranch} failed: ${(fetched.stderr || fetched.stdout || '').trim() || `exit ${fetched.status}`}`);
const pushed = runTimedSync({ cmd: 'git', args: ['-C', cwd, 'push', '-q', c.remote, `HEAD:refs/heads/${branch}`], timeoutMs });
if (pushed.timedOut) die(`push timed out after ${c.gates.timeoutMin} min`);
if (pushed.status !== 0) die(`push of ${branch} failed: ${(pushed.stderr || pushed.stdout || '').trim() || `exit ${pushed.status}`}`);
}
const files = git(cwd, 'diff', '--name-only', `${mainRef(c)}...HEAD`).split('\n').filter(Boolean);
if (!files.length) die(`nothing to land: ${branch} has no changes against ${mainRef(c)}${warnings.length ? ' (uncommitted changes were not committed)' : ''}`);
const top = git(cwd, 'rev-parse', '--show-toplevel');
const area = c.areas.find((a) => path.resolve(a.worktree) === path.resolve(top))?.id ?? branch;
const entry = { sha, branch, area, files, submittedAt: new Date().toISOString() };
const { queue } = queuePaths(c);
writeAtomic(path.join(queue, `${Date.now()}-${sha.slice(0, 10)}.json`), JSON.stringify(entry, null, 1));
const landerStarted = autostart ? startLander(c, { configPath: c.file }) : 0;
return { ...entry, id: sha, warnings, landerAlive: landerAlive(c), landerStarted, hint: statusHint(sha) };
}
const STATUS_HINT = (sha) => `Submission ${sha}. Go back to work. At your next natural checkpoint, check MCP land_status (id ${sha}) or \`fleet landq status ${sha}\` if you need the result. Waiting is optional. Never infer the lander or the land result from silence.`;
export function statusHint(sha) { return STATUS_HINT(sha); }
const REJECTED = new Set(['conflict', 'failed', 'red', 'refused', 'withdrawn']);
const MAX_WAIT_SEC = 600;
const DEFAULT_WAIT_SEC = 120;
function landerInfo(c) {
const { pid: pidFile } = queuePaths(c);
let rec = null;
try { rec = JSON.parse(fs.readFileSync(pidFile, 'utf8')); } catch { rec = null; }
const pid = Number(rec?.pid) || 0;
const alive = pid > 0 && pidAlive(pid);
return {
alive,
pid: pid || null,
since: typeof rec?.since === 'string' ? rec.since : null,
tickedAt: typeof rec?.tickedAt === 'string' ? rec.tickedAt : null,
};
}
function oneMatch(items, id) {
const exact = items.find((e) => e && e.sha === id);
if (exact) return exact;
const hits = items.filter((e) => e && typeof e.sha === 'string' && e.sha.startsWith(id));
if (hits.length > 1) return { ambiguous: true };
return hits[0] ?? null;
}
/** Durable state of one submission, plus whether the lander process is alive. */
export function readSubmission(c, id) {
if (!id || !/^[0-9a-f]{4,64}$/i.test(id)) die('land status needs a submission id (sha prefix, at least 4 hex characters)');
const { done, inflight } = queuePaths(c);
const lander = landerInfo(c);
const base = {
id,
lander,
hint: lander.alive ? null : 'lander is not running. Start it with: fleet lander start',
};
const doneItems = fs.readdirSync(done).filter((f) => f.endsWith('.json'))
.map((f) => readJson(path.join(done, f))).filter(Boolean);
const finished = oneMatch(doneItems, id);
if (finished?.ambiguous) return { ...base, state: 'unknown', reason: 'ambiguous id; pass a longer sha' };
if (finished) {
const sha = finished.sha;
if (finished.status === 'landed') {
return { ...base, id: sha, state: 'landed', sha, landedSha: finished.mergedAs ?? null, area: finished.area ?? null, branch: finished.branch ?? null, at: finished.at ?? null };
}
const raw = typeof finished.detail === 'string' && finished.detail.trim() ? finished.detail.trim() : (finished.status || 'rejected');
return {
...base, id: sha, state: 'rejected', sha, reason: raw.split('\n')[0],
status: REJECTED.has(finished.status) ? finished.status : (finished.status || 'rejected'),
area: finished.area ?? null, branch: finished.branch ?? null, at: finished.at ?? null,
};
}
const waiting = oneMatch(queued(c), id);
if (waiting?.ambiguous) return { ...base, state: 'unknown', reason: 'ambiguous id; pass a longer sha' };
if (waiting) {
const flying = readJson(inflight, []);
const gating = Array.isArray(flying) && flying.includes(waiting.sha);
return {
...base, id: waiting.sha, state: gating ? 'gating' : 'queued', sha: waiting.sha,
area: waiting.area ?? null, branch: waiting.branch ?? null, submittedAt: waiting.submittedAt ?? null,
};
}
return { ...base, state: 'unknown', reason: 'no submission with that id' };
}
function boundWait(timeoutSec) {
const n = timeoutSec == null || timeoutSec === '' ? DEFAULT_WAIT_SEC : Number(timeoutSec);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_WAIT_SEC;
return Math.min(MAX_WAIT_SEC, n);
}
/**
* One submission's durable state. wait polls until landed, rejected, or unknown,
* or until timeoutSec (default 120, max 600). queued and gating keep polling.
*/
export async function submissionStatus(c, id, { wait = false, timeoutSec } = {}) {
let snap = readSubmission(c, id);
if (!wait || snap.state === 'landed' || snap.state === 'rejected' || snap.state === 'unknown') return snap;
const deadline = Date.now() + boundWait(timeoutSec) * 1000;
while ((snap.state === 'queued' || snap.state === 'gating') && Date.now() < deadline) {
const left = deadline - Date.now();
if (left <= 0) break;
await new Promise((r) => setTimeout(r, Math.min(250, left)));
snap = readSubmission(c, id);
}
if (snap.state === 'queued' || snap.state === 'gating') return { ...snap, timedOut: true };
return snap;
}
function samePath(a, b) {
try { return fs.realpathSync(a) === fs.realpathSync(b); }
catch { return path.resolve(a) === path.resolve(b); }
}
function gitOrDie(cwd, ...args) {
const r = tryGit(cwd, ...args);
if (!r.ok) die(r.err || r.out || `git ${args[0]} failed`);
return r.out;
}
/** Paths from `git status --porcelain -z`, rename/copy recorded as the new name. */
function changedFiles(cwd) {
const out = gitOrDie(cwd, 'status', '--porcelain', '-z');
if (!out) return [];
const parts = out.split('\0');
const files = [];
for (let i = 0; i < parts.length; i++) {
const rec = parts[i];
if (!rec) continue;
const xy = rec.slice(0, 2);
if (xy.includes('R') || xy.includes('C')) files.push(parts[++i]);
else files.push(rec.slice(3));
}
return files.filter(Boolean);
}
/** A path the caller named, relative to the worktree, or a refusal. */
function pathInWorktree(worktree, spec) {
if (typeof spec !== 'string' || !spec.trim()) die('empty path');
const abs = path.resolve(worktree, spec);
const rel = path.relative(worktree, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) die(`refusing: ${spec} is outside the worktree`);
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let realProbe, realWt;
try { realProbe = fs.realpathSync(probe); realWt = fs.realpathSync(worktree); }
catch { die(`refusing: ${spec} is outside the worktree`); }
const relReal = path.relative(realWt, realProbe);
if (relReal.startsWith('..') || path.isAbsolute(relReal)) die(`refusing: ${spec} is outside the worktree`);
return rel.split(path.sep).join('/');
}
/**
* Stage and commit in one area worktree. Runs in this process (the CLI or the
* MCP server), not in a sandboxed agent. Refuses the main checkout, mainBranch,
* a detached HEAD, and any path outside the worktree. doesNotOwn paths warn.
*/
export function commitWork(c, { cwd = process.cwd(), area, message, paths, all = false, submit = false } = {}) {
const msg = typeof message === 'string' ? message.trim() : '';
if (!msg) die('commit needs a message');
const listed = Array.isArray(paths) ? paths : (paths == null || paths === '' ? [] : [paths]);
if (all && listed.length) die('pass paths or all, not both');
if (!all && !listed.length) die('pass paths or all');
const top = gitOrDie(cwd, 'rev-parse', '--show-toplevel');
const branch = gitOrDie(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
if (samePath(top, c.repo)) die(`refusing: ${top} is the main checkout. Commit in the area worktree.`);
if (branch === c.mainBranch || branch === 'HEAD') die(`refusing: commit from your area branch, not ${branch}`);
const areaObj = area
? getArea(c, area)
: c.areas.find((a) => samePath(a.worktree, top));
if (!areaObj) die(`refusing: ${top} is not an area worktree. Pass area, or run from the worktree.`);
if (area && !samePath(areaObj.worktree, top)) die(`refusing: ${top} is not the worktree for ${area} (${areaObj.worktree})`);
const rels = all ? changedFiles(top) : listed.map((p) => pathInWorktree(top, p));
if (!rels.length) die('nothing to commit');
const warnings = [];
for (const rel of rels) {
if (areaObj.doesNotOwn?.length && matchesAny(rel, areaObj.doesNotOwn)) warnings.push(`${rel} matches doesNotOwn for ${areaObj.id}`);
}
if (all) gitOrDie(top, 'add', '-A');
else gitOrDie(top, 'add', '--', ...rels);
gitOrDie(top, '-c', 'core.editor=true', 'commit', '-m', msg, ...(all ? [] : ['--', ...rels]));
const sha = gitOrDie(top, 'rev-parse', 'HEAD');
const result = { sha, area: areaObj.id, branch, warnings };
if (submit) result.landing = submitLanding(c, top);
return result;
}
export function landingStatus(c, prefix) {
const { done } = queuePaths(c);
if (prefix) {
const name = fs.readdirSync(done).find((f) => f.startsWith(prefix));
if (name) return { found: true, phase: 'done', result: readJson(path.join(done, name)) };
const hit = queued(c).find((e) => e.sha.startsWith(prefix));
if (hit) return { found: true, phase: 'queued', result: hit };
return { found: false, prefix };
}
const recent = fs.readdirSync(done).map((f) => readJson(path.join(done, f))).filter(Boolean)
.sort((a, b) => (a.at < b.at ? 1 : -1)).slice(0, 10);
return { found: true, phase: 'overview', landerAlive: landerAlive(c), queue: queued(c), recent };
}
export function areaInfo(c, id) {
const one = (a) => ({
id: a.id, title: a.title, role: a.role, domain: a.domain, provider: a.provider,
tier: a.tier, model: a.model, owns: a.owns, branch: a.branch, worktree: a.worktree,
});
if (id) return one(getArea(c, id));
return c.areas.map(one);
}