@quilt-dev/cli
Advanced tools
| // The authorship ledger — capture who authored which lines AT THE EDIT. | ||
| // | ||
| // The core insight (see design/authorship-capture.md): the OS records that bytes | ||
| // changed, never which agent changed them. So instead of inferring authorship | ||
| // later from reconcile timing (lossy, races), we record it the instant an edit | ||
| // happens, from the tool-call payload (old -> new), which carries the actor's | ||
| // identity and brackets the exact byte change. Each edit appends one immutable | ||
| // event; ownership is a replay of the log. This is the v0.3 substrate. | ||
| import { appendFileSync, existsSync, lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | ||
| import { createHash } from "node:crypto"; | ||
| import { resolve, sep } from "node:path"; | ||
| import { lineDiff } from "./diff.js"; | ||
| import { parseSymbols, ownKey, symbolLocator } from "./symbols.js"; | ||
| import { claimHeldByOther } from "./claims.js"; | ||
| /** | ||
| * Resolve a repo-relative path to an absolute one, refusing anything that escapes | ||
| * the repo (`../` traversal, absolute paths) or is a symlink — `path` is actor- | ||
| * controlled, so a write must never land outside the working tree. Mirrors the | ||
| * guard claims.ts/engine.ts apply to reads. Returns null if disallowed. Shared | ||
| * with the native-edit hooks (hooks.ts) so both apply the identical guard. | ||
| */ | ||
| export function safeAbs(repoRoot, relPath) { | ||
| const root = resolve(repoRoot); | ||
| const abs = resolve(root, relPath); | ||
| if (abs !== root && !abs.startsWith(root + sep)) | ||
| return null; | ||
| try { | ||
| if (lstatSync(abs).isSymbolicLink()) | ||
| return null; // never write through a symlink | ||
| } | ||
| catch { | ||
| /* file doesn't exist yet — fine for a create */ | ||
| } | ||
| return abs; | ||
| } | ||
| /** The symbol names an edit touches, then whether another actor holds any of them. */ | ||
| function checkHeld(store, actor, path, before, idx, oldString) { | ||
| const startLine = before.slice(0, idx).split("\n").length; // 1-based start of the match | ||
| const endLine = startLine + oldString.split("\n").length - 1; | ||
| const touched = parseSymbols(path, before) | ||
| .filter((s) => !(s.endLine < startLine || s.startLine > endLine)) | ||
| .map((s) => s.name); | ||
| return heldDenial(claimHeldByOther(store, actor, path, touched, Date.now())); | ||
| } | ||
| /** Shape a `claimHeldByOther` hit into the shared EditDenied return, or null if clear. */ | ||
| function heldDenial(held) { | ||
| if (!held) | ||
| return null; | ||
| return { ok: false, error: `held by ${held.holder}`, heldBy: held.holder, holderIntent: held.intent }; | ||
| } | ||
| /** | ||
| * Prevention check for an `old_string` edit, given the file's current content. | ||
| * Returns an EditDenied if another actor holds the touched symbol(s), else null | ||
| * (including when the old_string can't be located — that's not a claim problem). | ||
| * Shared by the MCP `quilt_edit` tool and the native-Edit hook so both prevent | ||
| * identically. | ||
| */ | ||
| export function checkHeldEdit(store, actor, path, before, oldString) { | ||
| const idx = before.indexOf(oldString); | ||
| if (idx === -1) | ||
| return null; | ||
| return checkHeld(store, actor, path, before, idx, oldString); | ||
| } | ||
| /** | ||
| * Prevention check for a whole-file write. Considers symbols in BOTH the new | ||
| * content and the existing file (pass its content, or null if new), so | ||
| * overwriting a claimed symbol away is still denied. Shared by `quilt_write` | ||
| * and the native-Write hook. | ||
| */ | ||
| export function checkHeldWrite(store, actor, path, content, existing) { | ||
| const symbols = new Set(parseSymbols(path, content).map((s) => s.name)); | ||
| if (existing !== null) | ||
| for (const s of parseSymbols(path, existing)) | ||
| symbols.add(s.name); | ||
| return heldDenial(claimHeldByOther(store, actor, path, [...symbols], Date.now())); | ||
| } | ||
| function splitLines(s) { | ||
| const out = s.split("\n"); | ||
| if (out.length && out[out.length - 1] === "") | ||
| out.pop(); | ||
| return out; | ||
| } | ||
| function sha(s) { | ||
| return createHash("sha256").update(s).digest("hex").slice(0, 16); | ||
| } | ||
| /** Read the full append-only ledger (chronological). */ | ||
| export function readAuthorship(store) { | ||
| const p = store.paths.authorshipLog; | ||
| if (!existsSync(p)) | ||
| return []; | ||
| return readFileSync(p, "utf8") | ||
| .split("\n") | ||
| .filter((l) => l.trim()) | ||
| .map((l) => JSON.parse(l)); | ||
| } | ||
| /** | ||
| * Fold a run of events into an existing `path -> (ownKey -> actor)` map, later | ||
| * event winning for the same key. The one place the fold rule lives, so the | ||
| * log-fold and the checkpoint-fold can't drift. | ||
| * | ||
| * Keys are `symbol\0text` (event.addedKeys/removedKeys), so an added line is | ||
| * owned under its symbol scope and a removal deletes exactly that line's entry — | ||
| * an identical line in another symbol has a different key and is untouched. That | ||
| * lets compaction prune removed lines instead of accumulating stale entries. | ||
| */ | ||
| function foldEvents(byPath, events) { | ||
| for (const ev of events) { | ||
| let m = byPath.get(ev.path); | ||
| if (!m) | ||
| byPath.set(ev.path, (m = new Map())); | ||
| // Legacy events (pre-symbol-keying) have no addedKeys — fall back to a | ||
| // bare-text key (empty symbol scope), matching how top-level lines key. | ||
| const addedKeys = ev.addedKeys ?? ev.added.map((t) => ownKey("", t)); | ||
| for (const key of addedKeys) | ||
| m.set(key, ev.actor); | ||
| // A captured removal drops the line's ownership — the line is gone from the | ||
| // file as of this event. Events are appended in real order under the store | ||
| // lock, so a later re-add (which re-sets the key) always wins over an earlier | ||
| // removal, and vice versa; no owner-guard is needed or correct here (a removal | ||
| // by another actor still means the line is gone). | ||
| for (const key of ev.removedKeys ?? []) | ||
| m.delete(key); | ||
| } | ||
| } | ||
| /** Compact once the un-folded log passes this many events. */ | ||
| export const COMPACT_THRESHOLD = 1000; | ||
| /** | ||
| * Read the checkpoint. Absent → an empty checkpoint (nothing compacted yet). But | ||
| * a checkpoint that EXISTS and won't parse is fatal: the log was truncated after | ||
| * it was written, so it's the only record of that compacted authorship. Returning | ||
| * empty would silently misattribute every historical line, so we throw loudly | ||
| * instead — a corrupt checkpoint is a stop-and-look, not something to paper over. | ||
| */ | ||
| export function readCheckpoint(store) { | ||
| const p = store.paths.authorshipCheckpoint; | ||
| if (!existsSync(p)) | ||
| return { count: 0, ownership: {} }; | ||
| let cp; | ||
| try { | ||
| cp = JSON.parse(readFileSync(p, "utf8")); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`quilt: authorship checkpoint is corrupt (${p}) — compacted authorship history can't be read. ` + | ||
| `Restore it from backup, or delete it to continue without that history. (${e.message})`); | ||
| } | ||
| return { count: cp.count ?? 0, ownership: cp.ownership ?? {} }; | ||
| } | ||
| /** | ||
| * The authoritative line-ownership reconcile attributes from: the checkpoint's | ||
| * fold plus the un-compacted log tail on top (later wins). Reading the checkpoint | ||
| * instead of re-folding all of history keeps reconcile cheap on long-lived repos. | ||
| */ | ||
| export function foldedAuthorship(store, log = readAuthorship(store)) { | ||
| const cp = readCheckpoint(store); | ||
| const byPath = new Map(); | ||
| for (const [path, lines] of Object.entries(cp.ownership)) { | ||
| byPath.set(path, new Map(Object.entries(lines))); | ||
| } | ||
| foldEvents(byPath, log); | ||
| return byPath; | ||
| } | ||
| /** | ||
| * Who REMOVED each line, per the ledger: `path -> removedKey -> latest actor`. | ||
| * The mirror of foldedAuthorship for the removed side, so reconcile can attribute | ||
| * a captured removal to its recorded author instead of to whoever happened to | ||
| * reconcile first (which is what let a committer's `commit --mine` swallow another | ||
| * actor's line removal when no reconcile ran between their edits). A pure fold — | ||
| * pass the log the caller already read (reconcile reads it once for both folds). | ||
| * | ||
| * Log-only by design, NOT checkpoint-backed: a removed line is never "un-removed", | ||
| * so a checkpoint of removal attribution would grow without bound and defeat | ||
| * compaction. Removals in the current working diff are recent and uncommitted, so | ||
| * their events are in the log tail. The only gap is a removal still uncommitted | ||
| * after compaction (~1000 events), which degrades to inference — the pre-fix | ||
| * behavior, safe (never a wrong ledger answer, just no ledger answer). | ||
| */ | ||
| export function foldedRemovals(log) { | ||
| const byPath = new Map(); | ||
| for (const ev of log) { | ||
| if (!ev.removedKeys?.length) | ||
| continue; | ||
| let m = byPath.get(ev.path); | ||
| if (!m) | ||
| byPath.set(ev.path, (m = new Map())); | ||
| for (const key of ev.removedKeys) | ||
| m.set(key, ev.actor); // later removal wins | ||
| } | ||
| return byPath; | ||
| } | ||
| /** Fold the current log into the checkpoint and truncate the log. NOT locked — | ||
| * call only while holding the store lock. Writing the checkpoint atomically | ||
| * BEFORE truncating means a crash in between just leaves the log to be re-folded | ||
| * (idempotent: re-setting a line to the same actor is a no-op), never lost. */ | ||
| function compactLocked(store) { | ||
| const events = readAuthorship(store); | ||
| if (events.length === 0) | ||
| return; | ||
| const cp = readCheckpoint(store); | ||
| const byPath = new Map(); | ||
| for (const [path, lines] of Object.entries(cp.ownership)) | ||
| byPath.set(path, new Map(Object.entries(lines))); | ||
| foldEvents(byPath, events); | ||
| const ownership = {}; | ||
| for (const [path, m] of byPath) | ||
| ownership[path] = Object.fromEntries(m); | ||
| const next = { count: cp.count + events.length, ownership }; | ||
| const tmp = store.paths.authorshipCheckpoint + ".tmp"; | ||
| writeFileSync(tmp, JSON.stringify(next)); | ||
| renameSync(tmp, store.paths.authorshipCheckpoint); // atomic | ||
| writeFileSync(store.paths.authorshipLog, ""); // truncate only after the checkpoint is durable | ||
| } | ||
| /** Compact the ledger (fold the log into the checkpoint, truncate). Locked — the | ||
| * explicit entry point (recordAuthorship compacts inline under its own lock). For | ||
| * tests and a future `quilt compact` maintenance command. */ | ||
| export function compactAuthorship(store) { | ||
| store.withLock(() => compactLocked(store)); | ||
| } | ||
| /** The genuinely-added and removed lines for an old->new payload. */ | ||
| export function computeDelta(oldText, newText) { | ||
| const ops = lineDiff(oldText, newText); | ||
| return { | ||
| added: ops.filter((o) => o.type === "add").map((o) => o.text), | ||
| removed: ops.filter((o) => o.type === "del").map((o) => o.text), | ||
| }; | ||
| } | ||
| /** | ||
| * The delta plus each line's symbol-qualified ownership key. Added lines take | ||
| * their scope from the post-image (`newText`), removed lines from the pre-image | ||
| * (`oldText`) — each is where that line physically lives — so the fold keys the | ||
| * same way reconcile does. A whole write is all-adds against the new content. | ||
| */ | ||
| function keyedDelta(path, oldText, newText, whole) { | ||
| if (whole) { | ||
| const loc = symbolLocator(path, newText); | ||
| const added = splitLines(newText); | ||
| return { added, removed: [], addedKeys: added.map((t, i) => ownKey(loc(i + 1), t)), removedKeys: [] }; | ||
| } | ||
| const addLoc = symbolLocator(path, newText); | ||
| const delLoc = symbolLocator(path, oldText); | ||
| const added = []; | ||
| const removed = []; | ||
| const addedKeys = []; | ||
| const removedKeys = []; | ||
| let newLine = 0; | ||
| let oldLine = 0; | ||
| for (const op of lineDiff(oldText, newText)) { | ||
| if (op.type === "eq") { | ||
| newLine++; | ||
| oldLine++; | ||
| } | ||
| else if (op.type === "add") { | ||
| newLine++; | ||
| added.push(op.text); | ||
| addedKeys.push(ownKey(addLoc(newLine), op.text)); | ||
| } | ||
| else { | ||
| oldLine++; | ||
| removed.push(op.text); | ||
| removedKeys.push(ownKey(delLoc(oldLine), op.text)); | ||
| } | ||
| } | ||
| return { added, removed, addedKeys, removedKeys }; | ||
| } | ||
| /** | ||
| * Append one authorship event, derived from the edit payload. The seq is the | ||
| * current event count (assigned under the lock so concurrent appends stay | ||
| * ordered). Returns the event. | ||
| */ | ||
| export function recordAuthorship(store, args) { | ||
| const { actor, path, oldText, newText, intent, whole } = args; | ||
| return store.withLock(() => { | ||
| const events = readAuthorship(store); | ||
| const { added, removed, addedKeys, removedKeys } = keyedDelta(path, oldText, newText, !!whole); | ||
| const ev = { | ||
| // seq spans the compacted history too, so it stays monotonic after a | ||
| // truncation resets the log to empty. | ||
| seq: readCheckpoint(store).count + events.length, | ||
| ts: new Date().toISOString(), | ||
| actor, | ||
| path, | ||
| added, | ||
| removed, | ||
| addedKeys, | ||
| removedKeys: removedKeys.length ? removedKeys : undefined, | ||
| anchor: whole ? null : args.anchor ?? null, | ||
| preHash: whole ? null : sha(oldText), | ||
| intent: intent?.trim() ? intent.trim() : undefined, | ||
| whole: whole || undefined, | ||
| }; | ||
| appendFileSync(store.paths.authorshipLog, JSON.stringify(ev) + "\n"); | ||
| // Keep the log bounded: once it's grown past the threshold, fold it into the | ||
| // checkpoint and truncate. Same lock, so the fold sees exactly what we wrote. | ||
| if (events.length + 1 >= COMPACT_THRESHOLD) | ||
| compactLocked(store); | ||
| return ev; | ||
| }); | ||
| } | ||
| /** | ||
| * The surviving anchor line for an `old_string` edit (the last complete line | ||
| * before the match), or null if the string can't be located. Used by the hook, | ||
| * which — unlike applyAndRecordEdit — doesn't already hold the match offset. | ||
| */ | ||
| export function anchorForEdit(before, oldString) { | ||
| const idx = before.indexOf(oldString); | ||
| return idx === -1 ? null : lineBefore(before, idx); | ||
| } | ||
| /** The last complete line of `text` before offset `idx` (the surviving anchor). */ | ||
| function lineBefore(text, idx) { | ||
| const head = text.slice(0, idx).split("\n"); | ||
| // head's last element is the partial line where the match starts (or "" at a | ||
| // line boundary); the element before it is the last complete preceding line. | ||
| return head.length >= 2 ? head[head.length - 2] ?? null : null; | ||
| } | ||
| /** | ||
| * Apply an `old_string` -> `new_string` edit to a file and capture authorship in | ||
| * one step — exactly what the `quilt_edit` MCP tool does. The write is atomic | ||
| * (temp + rename) so a crash never leaves a half-written file. Returns the event, | ||
| * or an error string if the old_string can't be located. | ||
| */ | ||
| export function applyAndRecordEdit(store, args) { | ||
| const abs = safeAbs(store.paths.repoRoot, args.path); | ||
| if (!abs) | ||
| return { ok: false, error: "path escapes the repository" }; | ||
| if (!existsSync(abs)) | ||
| return { ok: false, error: `file not found: ${args.path}` }; | ||
| const before = readFileSync(abs, "utf8"); | ||
| const idx = before.indexOf(args.oldString); | ||
| if (idx === -1) | ||
| return { ok: false, error: "old_string not found in file" }; | ||
| if (before.indexOf(args.oldString, idx + 1) !== -1) { | ||
| return { ok: false, error: "old_string is not unique; include more context" }; | ||
| } | ||
| // PREVENTION: if another actor holds the symbol(s) this edit touches, deny the | ||
| // write before any bytes change and hand back their intent — the earliest | ||
| // possible encounter point (earlier than commit), so the agent resolves in-band. | ||
| const denied = checkHeldEdit(store, args.actor, args.path, before, args.oldString); | ||
| if (denied) | ||
| return denied; | ||
| const after = before.slice(0, idx) + args.newString + before.slice(idx + args.oldString.length); | ||
| atomicWrite(abs, after); | ||
| // Diff the FULL before->after content (computed in-memory from the bytes this | ||
| // actor read — never a disk re-read, so a sibling's concurrent write can't taint | ||
| // it). This yields whole-line adds/removes that match how ownership is keyed, | ||
| // unlike the partial old_string/new_string fragments. | ||
| const event = recordAuthorship(store, { | ||
| actor: args.actor, | ||
| path: args.path, | ||
| oldText: before, | ||
| newText: after, | ||
| intent: args.intent, | ||
| anchor: lineBefore(before, idx), | ||
| }); | ||
| return { ok: true, event }; | ||
| } | ||
| /** Whole-file write/create with authorship capture (the `quilt_write` tool). */ | ||
| export function applyAndRecordWrite(store, args) { | ||
| const abs = safeAbs(store.paths.repoRoot, args.path); | ||
| if (!abs) | ||
| return { ok: false, error: "path escapes the repository" }; | ||
| // A whole-file write collides with any other actor's claim on this path. Check | ||
| // symbols in BOTH the new content and the existing file — overwriting a file in | ||
| // a way that removes a claimed symbol must still be denied (else it silently | ||
| // deletes the held code). | ||
| const existing = existsSync(abs) ? readFileSync(abs, "utf8") : null; | ||
| const denied = checkHeldWrite(store, args.actor, args.path, args.content, existing); | ||
| if (denied) | ||
| return denied; | ||
| atomicWrite(abs, args.content); | ||
| const event = recordAuthorship(store, { | ||
| actor: args.actor, | ||
| path: args.path, | ||
| oldText: "", | ||
| newText: args.content, | ||
| intent: args.intent, | ||
| whole: true, | ||
| }); | ||
| return { ok: true, event }; | ||
| } | ||
| function atomicWrite(abs, content) { | ||
| const tmp = abs + ".quilt-tmp"; | ||
| writeFileSync(tmp, content); | ||
| renameSync(tmp, abs); | ||
| } |
+132
| // `quilt doctor` — a health check that turns SILENT failure into a visible one. | ||
| // | ||
| // Quilt's hooks fail open: if capture ever stops working (QUILT_ACTOR unset, a | ||
| // hook not wired, an orchestrator change), edits quietly fall back to best-effort | ||
| // inference and nothing tells the user. This surfaces that: it reports whether | ||
| // the wiring is in place, whether identity is set, and — the key signal — how | ||
| // many edits have actually been captured. "0 edits recorded despite uncommitted | ||
| // changes" is the tell that capture isn't flowing. | ||
| import { detect } from "./onboard.js"; | ||
| import { readAuthorship, readCheckpoint } from "./authorship.js"; | ||
| import { watcherRunning } from "./watch.js"; | ||
| import { changedPaths } from "./git.js"; | ||
| import { openEscalations } from "./outcomes.js"; | ||
| /** | ||
| * Diagnose Quilt's health in a repo. Pure except for reading state + git; returns | ||
| * a structured report the CLI renders. `actorEnv` is the caller's QUILT_ACTOR. | ||
| */ | ||
| export function diagnose(store, opts = {}) { | ||
| const checks = []; | ||
| const root = store.paths.repoRoot; | ||
| if (!store.initialized) { | ||
| checks.push({ | ||
| label: "Quilt", | ||
| status: "fail", | ||
| detail: "not initialized in this repo", | ||
| hint: "run `quilt setup` (wires everything) or `quilt init`", | ||
| }); | ||
| return finish(checks, 0); | ||
| } | ||
| checks.push({ label: "Quilt", status: "ok", detail: "initialized (.quilt/)" }); | ||
| const d = detect(root); | ||
| if (d.orchestrator) { | ||
| checks.push({ label: "Orchestrator", status: "ok", detail: `detected ${d.orchestrator}` }); | ||
| } | ||
| else { | ||
| checks.push({ label: "Orchestrator", status: "info", detail: "none detected", hint: "run `quilt setup` to wire one in" }); | ||
| } | ||
| checks.push(d.quiltWired | ||
| ? { label: "MCP server", status: "ok", detail: "quilt server in .mcp.json" } | ||
| : { label: "MCP server", status: "warn", detail: "not in .mcp.json", hint: "run `quilt setup` — agents reach Quilt over MCP" }); | ||
| checks.push(d.hooksWired | ||
| ? { label: "Capture hooks", status: "ok", detail: "Edit/Write hooks in .claude/settings.json" } | ||
| : { | ||
| label: "Capture hooks", | ||
| status: "warn", | ||
| detail: "not installed", | ||
| hint: "run `quilt setup` — without them, native edits aren't captured", | ||
| }); | ||
| // Identity. The doctor usually runs in the human's shell, where QUILT_ACTOR is | ||
| // legitimately unset — so this is informational, not a warning. The point is to | ||
| // remind that each AGENT process needs its own id or the hooks capture nothing. | ||
| const actor = opts.actorEnv?.trim(); | ||
| checks.push(actor | ||
| ? { label: "Identity", status: "ok", detail: `QUILT_ACTOR=${actor}` } | ||
| : { | ||
| label: "Identity", | ||
| status: "info", | ||
| detail: "QUILT_ACTOR not set in this shell", | ||
| hint: "expected for you; each agent process needs its own QUILT_ACTOR for the hooks to attribute it", | ||
| }); | ||
| // Capture health — the core signal. Reading the checkpoint THROWS on a corrupt | ||
| // one (by design elsewhere), but a health tool must never crash — that's the | ||
| // exact case it should report — so catch it and surface it as a failed check. | ||
| let events; | ||
| let total; | ||
| try { | ||
| events = readAuthorship(store); | ||
| total = readCheckpoint(store).count + events.length; | ||
| } | ||
| catch (e) { | ||
| checks.push({ | ||
| label: "Capture", | ||
| status: "fail", | ||
| detail: "authorship state is unreadable", | ||
| hint: `${e.message.replace(/^quilt:\s*/, "")}`, | ||
| }); | ||
| return finish(checks, 0); | ||
| } | ||
| if (total > 0) { | ||
| const last = events.at(-1); | ||
| checks.push({ | ||
| label: "Capture", | ||
| status: "ok", | ||
| detail: `${total} edit${total === 1 ? "" : "s"} recorded${last ? ` (latest by ${last.actor})` : ""}`, | ||
| }); | ||
| } | ||
| else { | ||
| let changed = 0; | ||
| try { | ||
| changed = changedPaths(root).length; | ||
| } | ||
| catch { | ||
| /* not fatal for a health check */ | ||
| } | ||
| // Warn only in an AGENT shell (QUILT_ACTOR set): there, uncommitted changes | ||
| // with nothing captured means this agent's edits aren't flowing. In a human | ||
| // shell — including right after `quilt setup`, whose own config files show as | ||
| // uncommitted — that's expected, so stay at info rather than cry wolf. | ||
| checks.push(changed > 0 && d.hooksWired && actor | ||
| ? { | ||
| label: "Capture", | ||
| status: "warn", | ||
| detail: `0 edits recorded, but ${changed} file${changed === 1 ? " has" : "s have"} uncommitted changes`, | ||
| hint: "your edits aren't being captured — is this process's QUILT_ACTOR set? the hooks fail open silently", | ||
| } | ||
| : { label: "Capture", status: "info", detail: "0 edits recorded yet" }); | ||
| } | ||
| const pid = watcherRunning(store); | ||
| checks.push(pid | ||
| ? { label: "Live view", status: "ok", detail: `quilt watch running (pid ${pid})` } | ||
| : { | ||
| label: "Live view", | ||
| status: "info", | ||
| detail: "quilt watch not running", | ||
| hint: "fleet/status refresh only when you run a quilt command; `quilt watch` keeps them live", | ||
| }); | ||
| const esc = openEscalations(store); | ||
| if (esc.length > 0) { | ||
| checks.push({ | ||
| label: "Needs you", | ||
| status: "warn", | ||
| detail: `${esc.length} escalation${esc.length === 1 ? "" : "s"} awaiting a human`, | ||
| hint: "`quilt fleet` shows them", | ||
| }); | ||
| } | ||
| return finish(checks, total); | ||
| } | ||
| function finish(checks, captureCount) { | ||
| const hasFail = checks.some((c) => c.status === "fail"); | ||
| const hasWarn = checks.some((c) => c.status === "warn"); | ||
| return { checks, verdict: hasFail ? "not-ready" : hasWarn ? "warnings" : "healthy", captureCount }; | ||
| } |
+196
| import pc from "picocolors"; | ||
| import { buildModel } from "./engine.js"; | ||
| import { listClaims, listBlocks, claimLabel } from "./claims.js"; | ||
| import { openEscalations, resolutions } from "./outcomes.js"; | ||
| import { dependencyWarnings, formatWarning } from "./push.js"; | ||
| /** Compute the current fleet view. Read-only. */ | ||
| export function fleetSnapshot(store, now) { | ||
| const model = buildModel(store, null); // read-only: no active actor, no reconcile | ||
| const claims = listClaims(store, now); | ||
| const known = store.readActors(); | ||
| const filesByActor = new Map(); | ||
| const unattributed = new Set(); | ||
| const overlaps = []; | ||
| for (const f of model.files) { | ||
| let owned = false; | ||
| let changed = false; | ||
| // An overlap = a hunk owned by more than one actor (engine marks it | ||
| // "shared"). The engine further tags each shared hunk: "adjacent" (different | ||
| // lines sharing a hunk — commits cleanly) or "contended" (a same-line | ||
| // overwrite or identical-line clash). A file is contended if any shared hunk | ||
| // is, so a real clash is never hidden behind benign adjacency. | ||
| const overlapActors = new Set(); | ||
| let overlapLines = 0; | ||
| let contended = false; | ||
| for (const h of f.hunks) { | ||
| if (h.hunk.ops.some((o) => o.type !== "eq")) | ||
| changed = true; | ||
| for (const a of h.actors) { | ||
| owned = true; | ||
| (filesByActor.get(a) ?? filesByActor.set(a, new Set()).get(a)).add(f.path); | ||
| } | ||
| if (h.ownership === "shared") { | ||
| for (const a of h.actors) | ||
| overlapActors.add(a); | ||
| overlapLines += h.hunk.ops.filter((o) => o.type !== "eq").length; | ||
| if (h.overlap === "contended") | ||
| contended = true; | ||
| } | ||
| } | ||
| if (changed && !owned) | ||
| unattributed.add(f.path); | ||
| if (overlapActors.size) { | ||
| overlaps.push({ | ||
| path: f.path, | ||
| actors: [...overlapActors].sort(), | ||
| lines: overlapLines, | ||
| kind: contended ? "contended" : "adjacent", | ||
| }); | ||
| } | ||
| } | ||
| const claimsByActor = new Map(); | ||
| for (const c of claims) { | ||
| (claimsByActor.get(c.actor) ?? claimsByActor.set(c.actor, []).get(c.actor)).push(claimLabel(c)); | ||
| } | ||
| const typeOf = new Map(known.map((a) => [a.id, a.type])); | ||
| const ids = new Set([ | ||
| ...known.map((a) => a.id), | ||
| ...filesByActor.keys(), | ||
| ...claimsByActor.keys(), | ||
| ]); | ||
| const actors = [...ids].sort().map((id) => ({ | ||
| id, | ||
| type: typeOf.get(id) ?? "agent", | ||
| claims: (claimsByActor.get(id) ?? []).sort(), | ||
| files: [...(filesByActor.get(id) ?? [])].sort(), | ||
| })); | ||
| const blocked = listBlocks(store, now) | ||
| .map((b) => ({ | ||
| actor: b.actor, | ||
| target: b.symbol ? `${b.path}#${b.symbol}` : b.path, | ||
| holder: b.holder, | ||
| holderIntent: b.holderIntent, | ||
| })) | ||
| .sort((a, b) => a.actor.localeCompare(b.actor) || a.target.localeCompare(b.target)); | ||
| // Dependency heads-up across the whole fleet (each actor's warnings, deduped). | ||
| const seen = new Set(); | ||
| const warnings = []; | ||
| for (const a of actors) { | ||
| for (const w of dependencyWarnings(store, a.id, now)) { | ||
| const key = `${w.yourSymbol}->${w.heldTarget}@${w.heldBy}`; | ||
| if (seen.has(key)) | ||
| continue; | ||
| seen.add(key); | ||
| warnings.push(w); | ||
| } | ||
| } | ||
| const clobbers = store | ||
| .readClobbers() | ||
| .clobbers.filter((c) => !c.restored) | ||
| .map((c) => ({ path: c.path, byActor: c.byActor, victimActor: c.victimActor })); | ||
| return { | ||
| actors, | ||
| overlaps, | ||
| blocked, | ||
| clobbers, | ||
| needsYou: openEscalations(store), | ||
| sewn: resolutions(store).slice(0, 5), | ||
| dependencyWarnings: warnings, | ||
| unattributed: [...unattributed].sort(), | ||
| }; | ||
| } | ||
| /** Render the fleet view as a glanceable terminal dashboard. */ | ||
| export function renderFleet(view, headLabel) { | ||
| const out = []; | ||
| const clashes = view.overlaps.filter((o) => o.kind === "contended").length + view.clobbers.length; | ||
| const counts = `${view.actors.length} actor${view.actors.length === 1 ? "" : "s"}` + | ||
| `, ${view.needsYou.length} needs-you` + | ||
| `, ${clashes} clash${clashes === 1 ? "" : "es"}` + | ||
| `, ${view.blocked.length} blocked`; | ||
| out.push(`${pc.bold("Quilt")} ${pc.dim("· fleet")} ${pc.dim(headLabel)} ${pc.dim(counts)}\n`); | ||
| // The engineer's action list goes first: clashes the agents couldn't sew. | ||
| if (view.needsYou.length) { | ||
| out.push(pc.bold(pc.yellow(" Needs you")) + pc.dim(" (agents couldn't reconcile these — your call)")); | ||
| for (const o of view.needsYou) { | ||
| out.push(" " + pc.yellow("⚑ ") + pc.bold(o.target) + | ||
| (o.note ? pc.dim(` ${o.note}`) : "") + pc.dim(` (raised by ${o.actor})`)); | ||
| } | ||
| out.push(pc.dim(" clear with: quilt resolve <target>")); | ||
| out.push(""); | ||
| } | ||
| out.push(pc.bold(" Actors")); | ||
| if (view.actors.length === 0) { | ||
| out.push(pc.dim(" (no actors yet)")); | ||
| } | ||
| else { | ||
| for (const a of view.actors) { | ||
| const active = a.claims.length > 0 || a.files.length > 0; | ||
| const dot = active ? pc.green("●") : pc.dim("○"); | ||
| const work = a.files.length | ||
| ? a.files.join(", ") | ||
| : pc.dim(a.claims.length ? "reserved, not yet edited" : "idle"); | ||
| out.push(` ${dot} ${pc.bold(a.id)} ${pc.dim(`(${a.type})`)} ${work}`); | ||
| if (a.claims.length) | ||
| out.push(pc.dim(` claims: ${a.claims.join(", ")}`)); | ||
| } | ||
| } | ||
| out.push(""); | ||
| if (view.blocked.length) { | ||
| out.push(pc.bold(pc.red(" Blocked"))); | ||
| for (const b of view.blocked) { | ||
| const held = b.holderIntent ? `held by ${b.holder}: ${b.holderIntent}` : `held by ${b.holder}`; | ||
| out.push(" " + pc.red("⛔ ") + `${pc.bold(b.actor)} waiting on ${b.target} ${pc.dim(`(${held})`)}`); | ||
| } | ||
| out.push(""); | ||
| } | ||
| if (view.dependencyWarnings.length) { | ||
| out.push(pc.bold(pc.yellow(" Dependency heads-up"))); | ||
| for (const w of view.dependencyWarnings) | ||
| out.push(" " + pc.yellow("⚠ ") + formatWarning(w)); | ||
| out.push(""); | ||
| } | ||
| const contended = view.overlaps.filter((o) => o.kind === "contended"); | ||
| const adjacent = view.overlaps.filter((o) => o.kind === "adjacent"); | ||
| const fmtOverlap = (c) => `${c.path} ${pc.dim(c.actors.join(", "))} ${pc.dim(`(${c.lines} line${c.lines === 1 ? "" : "s"})`)}`; | ||
| if (view.clobbers.length) { | ||
| out.push(pc.bold(pc.red(" Overwrite preserved")) + pc.dim(" (one actor replaced another's lines — both saved)")); | ||
| for (const c of view.clobbers) { | ||
| out.push(" " + pc.red("⚠ ") + `${c.path} ${pc.dim(`${c.byActor} overwrote ${c.victimActor}`)}`); | ||
| } | ||
| out.push(pc.dim(" recover with: quilt restore <path>")); | ||
| out.push(""); | ||
| } | ||
| if (!view.overlaps.length) { | ||
| if (!view.clobbers.length) | ||
| out.push(pc.dim(" Overlaps: none\n")); | ||
| } | ||
| else { | ||
| if (contended.length) { | ||
| out.push(pc.bold(pc.red(" Same-line clash")) + pc.dim(" (two actors changed the same line — review)")); | ||
| for (const c of contended) | ||
| out.push(" " + pc.red("⚠ ") + fmtOverlap(c)); | ||
| out.push(pc.dim(" recover overwritten work: quilt restore <path> · back out an actor: quilt undo <actor>")); | ||
| out.push(""); | ||
| } | ||
| if (adjacent.length) { | ||
| out.push(pc.bold(pc.dim(" Working close")) + pc.dim(" (different lines in one region — commits cleanly)")); | ||
| for (const c of adjacent) | ||
| out.push(" " + pc.dim("· " + fmtOverlap(c))); | ||
| out.push(""); | ||
| } | ||
| } | ||
| if (view.sewn.length) { | ||
| out.push(pc.dim(pc.bold(" Sewn by agents")) + pc.dim(" (recent — agents reconciled these themselves)")); | ||
| for (const o of view.sewn) { | ||
| out.push(pc.dim(` ✓ ${o.target}${o.note ? ` ${o.note}` : ""} (${o.actor})`)); | ||
| } | ||
| out.push(""); | ||
| } | ||
| if (view.unattributed.length) { | ||
| out.push(pc.dim(pc.bold(" Unattributed changes"))); | ||
| for (const p of view.unattributed) | ||
| out.push(pc.dim(` ${p}`)); | ||
| out.push(""); | ||
| } | ||
| return out.join("\n") + "\n"; | ||
| } |
+170
| // Native-tool capture: a Pre/Post Claude-Code hook pair that gives agents | ||
| // capture + prevention on the built-in Edit / Write / MultiEdit tools with ZERO | ||
| // protocol. Agents edit normally; Quilt records who authored which lines and | ||
| // denies writes into code another actor holds. The MCP quilt_edit / quilt_write | ||
| // tools stay the fallback for runtimes without hooks. | ||
| // | ||
| // Why a PAIR of hooks: a PostToolUse hook has the edit payload but not the file's | ||
| // pre-edit content, so it can't compute the full-line delta that ownership keys | ||
| // on (it would only see the old_string/new_string fragments). So: | ||
| // - PreToolUse snapshots the `before` content AND runs the claim check (deny a | ||
| // write into held code — prevention at the earliest point, before any bytes | ||
| // change). | ||
| // - PostToolUse diffs that snapshot against the now-written file to get the | ||
| // real delta, and appends the authorship event. | ||
| // The snapshot is keyed by actor+path, which is race-free: one agent runs its | ||
| // tool calls sequentially (Pre → write → Post), and two agents editing the same | ||
| // file get different keys, so neither reads the other's snapshot. | ||
| import { createHash } from "node:crypto"; | ||
| import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { anchorForEdit, checkHeldEdit, checkHeldWrite, recordAuthorship, safeAbs } from "./authorship.js"; | ||
| function str(v) { | ||
| return typeof v === "string" ? v : null; | ||
| } | ||
| /** | ||
| * Normalize the raw Claude-Code hook JSON into a HookInput. Accepts both field | ||
| * spellings — the current tool schema uses `old_string`/`new_string`/`content`, | ||
| * but we also accept `old_str`/`new_str`/`file_text` so a schema rename can't | ||
| * silently turn capture into a no-op. Returns null if there's no usable payload. | ||
| */ | ||
| export function parseHookInput(raw) { | ||
| if (typeof raw !== "object" || raw === null) | ||
| return null; | ||
| const o = raw; | ||
| const tool = str(o.tool_name); | ||
| if (!tool) | ||
| return null; | ||
| const input = (o.tool_input ?? {}); | ||
| const path = str(input.file_path); | ||
| const oneEdit = (e) => { | ||
| const oldString = str(e.old_string) ?? str(e.old_str); | ||
| const newString = str(e.new_string) ?? str(e.new_str); | ||
| if (oldString === null || newString === null) | ||
| return null; | ||
| return { oldString, newString }; | ||
| }; | ||
| const edits = []; | ||
| if (Array.isArray(input.edits)) { | ||
| for (const e of input.edits) { | ||
| if (typeof e === "object" && e !== null) { | ||
| const parsed = oneEdit(e); | ||
| if (parsed) | ||
| edits.push(parsed); | ||
| } | ||
| } | ||
| } | ||
| else { | ||
| const single = oneEdit(input); | ||
| if (single) | ||
| edits.push(single); | ||
| } | ||
| const content = str(input.content) ?? str(input.file_text); | ||
| return { tool, path, edits, content }; | ||
| } | ||
| function snapshotKey(actor, path) { | ||
| // 128 bits of sha256 — ample to avoid collisions for a transient scratch file. | ||
| return createHash("sha256").update(`${actor}\0${path}`).digest("hex").slice(0, 32); | ||
| } | ||
| function snapshotPath(store, actor, path) { | ||
| return store.paths.hookSnapshot(snapshotKey(actor, path)); | ||
| } | ||
| /** The pre-write content of a path (empty string for a not-yet-created file). */ | ||
| function readBefore(abs) { | ||
| return existsSync(abs) ? readFileSync(abs, "utf8") : ""; | ||
| } | ||
| /** | ||
| * PreToolUse: snapshot the file's pre-edit content for the Post hook, and run the | ||
| * prevention claim-check. Denies (blocks the tool) when another actor holds the | ||
| * code the write would touch, handing back their intent so the agent can resolve | ||
| * in-band. A no-op allow when there's no path or no edit payload. | ||
| */ | ||
| export function runHookPre(store, actor, input) { | ||
| if (!input.path) | ||
| return { deny: false }; | ||
| const abs = safeAbs(store.paths.repoRoot, input.path); | ||
| if (!abs) | ||
| return { deny: false }; // outside the repo or a symlink — not Quilt's to police | ||
| const before = readBefore(abs); | ||
| let denied = null; | ||
| if (input.content !== null && input.edits.length === 0) { | ||
| // Whole-file Write (existing content is null for a not-yet-created file). | ||
| denied = checkHeldWrite(store, actor, input.path, input.content, existsSync(abs) ? before : null); | ||
| } | ||
| else { | ||
| // Edit / MultiEdit — any held edit denies the whole call. | ||
| for (const e of input.edits) { | ||
| denied = checkHeldEdit(store, actor, input.path, before, e.oldString); | ||
| if (denied) | ||
| break; | ||
| } | ||
| } | ||
| if (denied) { | ||
| return { | ||
| deny: true, | ||
| reason: `Quilt: ${input.path} is held by ${denied.heldBy}` + | ||
| (denied.holderIntent ? ` (${denied.holderIntent})` : "") + | ||
| `. They are mid-change. If they're already doing your change, drop yours; ` + | ||
| `if it's compatible, adapt around it; if your goals are genuinely opposed, ` + | ||
| `escalate instead of overwriting.`, | ||
| }; | ||
| } | ||
| // Allowed → stash the before-image so Post can compute the real delta. Only | ||
| // when there's actually a payload to capture: an unrecognized tool (or a | ||
| // widened matcher) yields no edits and null content, and must not leave a | ||
| // snapshot that Post would turn into a zero-delta event. | ||
| if (input.content !== null || input.edits.length > 0) { | ||
| mkdirSync(store.paths.hookSnapshotsDir, { recursive: true }); | ||
| writeFileSync(snapshotPath(store, actor, input.path), before); | ||
| } | ||
| return { deny: false }; | ||
| } | ||
| /** Replay the edit payload against the pre-image IN MEMORY, mirroring what the | ||
| * native tool wrote — the first occurrence of each old_string, applied in order. | ||
| * This matches Claude Code's own Edit/MultiEdit semantics (first occurrence, and | ||
| * a non-unique old_string is rejected before the hook fires), so the location we | ||
| * find here is the one the tool actually changed. Reconstructing `after` this way | ||
| * (rather than re-reading the written file) is what makes capture race-free: a | ||
| * sibling's concurrent write to the same file can't leak into this actor's | ||
| * recorded delta. */ | ||
| function replayEdits(before, edits) { | ||
| let after = before; | ||
| for (const e of edits) { | ||
| const idx = after.indexOf(e.oldString); | ||
| if (idx === -1) | ||
| continue; // couldn't locate — skip, don't fabricate a change | ||
| after = after.slice(0, idx) + e.newString + after.slice(idx + e.oldString.length); | ||
| } | ||
| return after; | ||
| } | ||
| /** | ||
| * PostToolUse: read the stashed pre-image, reconstruct the post-image in memory | ||
| * from the edit payload, compute the full before→after delta, and append the | ||
| * authorship event. No-op if there's no snapshot (Pre didn't run, or the write | ||
| * was denied). Consumes the snapshot so it can't be reused. | ||
| */ | ||
| export function runHookPost(store, actor, input) { | ||
| if (!input.path) | ||
| return; | ||
| if (!safeAbs(store.paths.repoRoot, input.path)) | ||
| return; | ||
| const snap = snapshotPath(store, actor, input.path); | ||
| if (!existsSync(snap)) | ||
| return; // nothing captured for this call | ||
| const before = readFileSync(snap, "utf8"); | ||
| if (input.content !== null && input.edits.length === 0) { | ||
| // Whole-file write — mirror applyAndRecordWrite exactly: whole:true treats the | ||
| // content as fresh (added = every line, removed = none), so oldText is ignored. | ||
| // Passing "" keeps both capture paths recording overwrites identically. | ||
| recordAuthorship(store, { actor, path: input.path, oldText: "", newText: input.content, whole: true }); | ||
| } | ||
| else { | ||
| // Edit / MultiEdit — diff the pre-image against the in-memory post-image, so | ||
| // adds/removes are whole lines matching how ownership is keyed. Anchor only | ||
| // for a single edit (unambiguous location); MultiEdit leaves it null. | ||
| const after = replayEdits(before, input.edits); | ||
| const only = input.edits.length === 1 ? input.edits[0] : undefined; | ||
| const anchor = only ? anchorForEdit(before, only.oldString) : null; | ||
| recordAuthorship(store, { actor, path: input.path, oldText: before, newText: after, anchor }); | ||
| } | ||
| rmSync(snap, { force: true }); | ||
| } |
+294
| // Magical onboarding: detect the agent orchestrator in a repo and wire Quilt in | ||
| // as the shared MCP server, plus drop a coordination snippet into CLAUDE.md. | ||
| // | ||
| // Everything here is idempotent and non-destructive: existing config is parsed | ||
| // and merged, never clobbered. If a file can't be safely merged (e.g. malformed | ||
| // JSON), we leave it alone and tell the user what to add by hand. | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| /** The MCP server entry every agent in the fleet shares. */ | ||
| export const QUILT_SERVER = { command: "quilt", args: ["mcp"] }; | ||
| /** The native Edit/Write/MultiEdit tools the capture hooks intercept. */ | ||
| export const HOOK_MATCHER = "Edit|Write|MultiEdit"; | ||
| export const HOOK_PRE_COMMAND = "quilt hook-pre"; | ||
| export const HOOK_POST_COMMAND = "quilt hook-post"; | ||
| /** Marker so the CLAUDE.md snippet is added at most once. */ | ||
| export const COORDINATION_MARKER = "<!-- quilt:coordination -->"; | ||
| /** The coordination instructions appended to CLAUDE.md. */ | ||
| export const COORDINATION_BLOCK = `${COORDINATION_MARKER} | ||
| ## Coordinating with other agents (Quilt) | ||
| You share this checkout with other agents. Coordinate through Quilt: | ||
| - Pick a stable id for yourself — your role or task name (e.g. \`auth-agent\`). | ||
| Use that exact id as \`actor\` on every Quilt call. | ||
| - Before you edit a file, \`claim\` what you're about to change | ||
| (\`path#symbol\`, e.g. \`src/auth.ts#login\`). Pass a short | ||
| intent too — the why (your ticket/task) — which is shown to anyone you block. | ||
| - If your claim is denied, another agent holds that code and is mid-change. The | ||
| response carries their holderIntent (what they are doing). Use it instead of | ||
| forcing your change through: if they are already doing your change, drop yours; | ||
| if it is compatible, adapt around it; if your goals are genuinely opposed (you | ||
| each need the same line to be different things), do NOT overwrite them — | ||
| escalate the target with a reason naming both intents, and move on. A human | ||
| decides. | ||
| - When you reconcile a clash yourself (merge both intents, or adapt), resolve the | ||
| target with a short note so the decision is recorded. | ||
| - The claim response may include \`dependencyWarnings\`: a function you depend on | ||
| is being changed by another agent. Account for it. | ||
| - When your change is ready, \`commit_mine\` with your id. It commits only your | ||
| lines and leaves everyone else's work untouched.`; | ||
| /** Inspect a repo root for orchestrator config and whether Quilt is wired in. */ | ||
| export function detect(root) { | ||
| const mcpJsonPath = join(root, ".mcp.json"); | ||
| const claudeMdPath = join(root, "CLAUDE.md"); | ||
| const settingsPath = join(root, ".claude", "settings.json"); | ||
| const hasMcpJson = existsSync(mcpJsonPath); | ||
| const hasClaudeMd = existsSync(claudeMdPath); | ||
| const hasSettings = existsSync(settingsPath); | ||
| const hasClaudeDir = existsSync(join(root, ".claude")); | ||
| const hasCursorDir = existsSync(join(root, ".cursor")); | ||
| const hasAgentsMd = existsSync(join(root, "AGENTS.md")); | ||
| const orchestrator = hasClaudeDir || hasClaudeMd || hasMcpJson | ||
| ? "Claude Code" | ||
| : hasCursorDir | ||
| ? "Cursor" | ||
| : hasAgentsMd | ||
| ? "agents (AGENTS.md)" | ||
| : null; | ||
| const quiltWired = hasMcpJson && mcpServersHasQuilt(safeRead(mcpJsonPath)); | ||
| const coordinationPresent = hasClaudeMd && (safeRead(claudeMdPath) ?? "").includes(COORDINATION_MARKER); | ||
| const hooksWired = hasSettings && settingsHasQuiltHooks(safeRead(settingsPath)); | ||
| return { | ||
| mcpJsonPath, | ||
| claudeMdPath, | ||
| settingsPath, | ||
| hasMcpJson, | ||
| hasClaudeMd, | ||
| hasSettings, | ||
| orchestrator, | ||
| quiltWired, | ||
| coordinationPresent, | ||
| hooksWired, | ||
| }; | ||
| } | ||
| function isPlainObject(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| function safeRead(path) { | ||
| try { | ||
| return readFileSync(path, "utf8"); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| function mcpServersHasQuilt(content) { | ||
| if (!content) | ||
| return false; | ||
| try { | ||
| const parsed = JSON.parse(content); | ||
| return Boolean(parsed?.mcpServers?.quilt); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** Does a hook event's array already contain a group running `command`? */ | ||
| function hookGroupHas(list, command) { | ||
| if (!Array.isArray(list)) | ||
| return false; | ||
| for (const group of list) { | ||
| const hooks = isPlainObject(group) ? group.hooks : undefined; | ||
| if (Array.isArray(hooks)) { | ||
| for (const h of hooks) | ||
| if (isPlainObject(h) && h.command === command) | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| function settingsHasQuiltHooks(content) { | ||
| if (!content) | ||
| return false; | ||
| try { | ||
| const parsed = JSON.parse(content); | ||
| const hooks = isPlainObject(parsed) ? parsed.hooks : undefined; | ||
| if (!isPlainObject(hooks)) | ||
| return false; | ||
| return hookGroupHas(hooks.PreToolUse, HOOK_PRE_COMMAND) && hookGroupHas(hooks.PostToolUse, HOOK_POST_COMMAND); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** Ensure a hook event array holds a group running `command`; returns true if it added one. */ | ||
| function ensureHookGroup(hooks, event, command) { | ||
| if (hookGroupHas(hooks[event], command)) | ||
| return false; | ||
| const arr = Array.isArray(hooks[event]) ? hooks[event] : []; | ||
| arr.push({ matcher: HOOK_MATCHER, hooks: [{ type: "command", command }] }); | ||
| hooks[event] = arr; | ||
| return true; | ||
| } | ||
| /** | ||
| * Add the `quilt` server to an `.mcp.json`. Creates the file content if absent | ||
| * (existing === null), no-ops if quilt is already present, and refuses to touch | ||
| * a file that isn't valid JSON (returns an error instead of clobbering it). | ||
| */ | ||
| export function mergeMcpServers(existing) { | ||
| if (existing === null || existing.trim() === "") { | ||
| return { | ||
| content: JSON.stringify({ mcpServers: { quilt: QUILT_SERVER } }, null, 2) + "\n", | ||
| changed: true, | ||
| }; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(existing); | ||
| } | ||
| catch { | ||
| return { content: existing, changed: false, error: "not valid JSON" }; | ||
| } | ||
| if (!isPlainObject(parsed)) { | ||
| return { content: existing, changed: false, error: "not a JSON object" }; | ||
| } | ||
| const obj = parsed; | ||
| const servers = obj.mcpServers; | ||
| // Refuse to touch a file whose mcpServers isn't an object — assigning to a | ||
| // string/number throws, and an array would silently drop our entry. | ||
| if (servers !== undefined && !isPlainObject(servers)) { | ||
| return { content: existing, changed: false, error: "mcpServers is not an object" }; | ||
| } | ||
| const map = servers ?? {}; | ||
| if (map.quilt) | ||
| return { content: existing, changed: false }; | ||
| map.quilt = { ...QUILT_SERVER }; | ||
| obj.mcpServers = map; | ||
| return { content: JSON.stringify(obj, null, 2) + "\n", changed: true }; | ||
| } | ||
| /** | ||
| * Add the Quilt capture hooks (PreToolUse + PostToolUse on Edit/Write/MultiEdit) | ||
| * to a `.claude/settings.json`. Creates the content if absent, no-ops if both | ||
| * hooks are already present, and refuses to touch a file that isn't valid JSON | ||
| * or whose `hooks` shape can't be safely merged (returns an error, unchanged). | ||
| */ | ||
| export function mergeHookSettings(existing) { | ||
| let parsed = {}; | ||
| if (existing !== null && existing.trim() !== "") { | ||
| try { | ||
| parsed = JSON.parse(existing); | ||
| } | ||
| catch { | ||
| return { content: existing, changed: false, error: "not valid JSON" }; | ||
| } | ||
| if (!isPlainObject(parsed)) { | ||
| return { content: existing, changed: false, error: "not a JSON object" }; | ||
| } | ||
| } | ||
| const obj = parsed; | ||
| const hooks = obj.hooks; | ||
| if (hooks !== undefined && !isPlainObject(hooks)) { | ||
| return { content: existing ?? "", changed: false, error: "hooks is not an object" }; | ||
| } | ||
| const hooksObj = hooks ?? {}; | ||
| // A pre-existing event value that isn't an array can't be merged safely. | ||
| for (const event of ["PreToolUse", "PostToolUse"]) { | ||
| if (hooksObj[event] !== undefined && !Array.isArray(hooksObj[event])) { | ||
| return { content: existing ?? "", changed: false, error: `hooks.${event} is not an array` }; | ||
| } | ||
| } | ||
| let changed = ensureHookGroup(hooksObj, "PreToolUse", HOOK_PRE_COMMAND); | ||
| changed = ensureHookGroup(hooksObj, "PostToolUse", HOOK_POST_COMMAND) || changed; | ||
| if (!changed) | ||
| return { content: existing ?? "", changed: false }; | ||
| obj.hooks = hooksObj; | ||
| return { content: JSON.stringify(obj, null, 2) + "\n", changed: true }; | ||
| } | ||
| /** | ||
| * Append the coordination snippet to CLAUDE.md. No-ops if the marker is already | ||
| * present; otherwise appends with a blank-line separator. | ||
| */ | ||
| export function appendCoordination(existing) { | ||
| const base = existing ?? ""; | ||
| if (base.includes(COORDINATION_MARKER)) | ||
| return { content: base, changed: false }; | ||
| const sep = base === "" ? "" : base.endsWith("\n") ? "\n" : "\n\n"; | ||
| return { content: base + sep + COORDINATION_BLOCK + "\n", changed: true }; | ||
| } | ||
| /** Compute the setup plan for a repo without writing anything. */ | ||
| export function planSetup(root) { | ||
| const d = detect(root); | ||
| const steps = []; | ||
| const mcpExisting = d.hasMcpJson ? safeRead(d.mcpJsonPath) : null; | ||
| const mcp = mergeMcpServers(mcpExisting); | ||
| if (mcp.error) { | ||
| steps.push({ | ||
| file: ".mcp.json", | ||
| action: "skip", | ||
| detail: `left untouched (${mcp.error}) — add the "quilt" server by hand`, | ||
| path: d.mcpJsonPath, | ||
| }); | ||
| } | ||
| else if (!mcp.changed) { | ||
| steps.push({ file: ".mcp.json", action: "skip", detail: "quilt server already present", path: d.mcpJsonPath }); | ||
| } | ||
| else { | ||
| steps.push({ | ||
| file: ".mcp.json", | ||
| action: d.hasMcpJson ? "update" : "create", | ||
| detail: d.hasMcpJson ? "add the quilt MCP server" : "create with the quilt MCP server", | ||
| content: mcp.content, | ||
| path: d.mcpJsonPath, | ||
| }); | ||
| } | ||
| const mdExisting = d.hasClaudeMd ? safeRead(d.claudeMdPath) : null; | ||
| const md = appendCoordination(mdExisting); | ||
| if (!md.changed) { | ||
| steps.push({ file: "CLAUDE.md", action: "skip", detail: "coordination snippet already present", path: d.claudeMdPath }); | ||
| } | ||
| else { | ||
| steps.push({ | ||
| file: "CLAUDE.md", | ||
| action: d.hasClaudeMd ? "update" : "create", | ||
| detail: d.hasClaudeMd ? "append the coordination snippet" : "create with the coordination snippet", | ||
| content: md.content, | ||
| path: d.claudeMdPath, | ||
| }); | ||
| } | ||
| const settingsExisting = d.hasSettings ? safeRead(d.settingsPath) : null; | ||
| const hooks = mergeHookSettings(settingsExisting); | ||
| if (hooks.error) { | ||
| steps.push({ | ||
| file: ".claude/settings.json", | ||
| action: "skip", | ||
| detail: `left untouched (${hooks.error}) — add the quilt hooks by hand`, | ||
| path: d.settingsPath, | ||
| }); | ||
| } | ||
| else if (!hooks.changed) { | ||
| steps.push({ file: ".claude/settings.json", action: "skip", detail: "capture hooks already present", path: d.settingsPath }); | ||
| } | ||
| else { | ||
| steps.push({ | ||
| file: ".claude/settings.json", | ||
| action: d.hasSettings ? "update" : "create", | ||
| detail: d.hasSettings ? "add the Edit/Write capture hooks" : "create with the Edit/Write capture hooks", | ||
| content: hooks.content, | ||
| path: d.settingsPath, | ||
| }); | ||
| } | ||
| return steps; | ||
| } | ||
| /** Apply a plan's create/update steps to disk. Returns the steps actually written. */ | ||
| export function applySetup(steps) { | ||
| const written = []; | ||
| for (const step of steps) { | ||
| if (step.action === "skip" || step.content === undefined) | ||
| continue; | ||
| mkdirSync(dirname(step.path), { recursive: true }); // .claude/ may not exist yet | ||
| writeFileSync(step.path, step.content); | ||
| written.push(step); | ||
| } | ||
| return written; | ||
| } |
| // Collision outcomes — how a clash was handled. | ||
| // | ||
| // When the fleet's own agents resolve collisions (Quilt is the substrate, not the | ||
| // resolver), two things have to be visible to the engineer: the clashes an agent | ||
| // could NOT reconcile and kicked up for a human ("escalated" → Needs you), and a | ||
| // trail of the ones an agent sewed itself ("resolved" → audit). The latest | ||
| // outcome per target wins: an escalation stays open until a later resolution | ||
| // closes it. | ||
| import { randomUUID } from "node:crypto"; | ||
| /** Normalize a target the same way claims do, so `./a#f` and `a#f` agree. */ | ||
| export function normalizeTarget(raw) { | ||
| const hash = raw.indexOf("#"); | ||
| const path = (hash === -1 ? raw : raw.slice(0, hash)).replace(/^\.\/+/, "").replace(/\/+$/, ""); | ||
| if (hash === -1) | ||
| return path; | ||
| const symbol = raw.slice(hash + 1).trim(); | ||
| return symbol ? `${path}#${symbol}` : path; | ||
| } | ||
| /** Record a collision outcome and return it. */ | ||
| export function recordOutcome(store, kind, actor, rawTarget, note, nowIso) { | ||
| return store.withLock(() => { | ||
| const file = store.readOutcomes(); | ||
| const outcome = { | ||
| id: randomUUID().slice(0, 12), | ||
| target: normalizeTarget(rawTarget), | ||
| kind, | ||
| actor, | ||
| note: note?.trim() ? note.trim() : undefined, | ||
| ts: nowIso, | ||
| }; | ||
| file.outcomes.push(outcome); | ||
| store.writeOutcomes(file); | ||
| return outcome; | ||
| }); | ||
| } | ||
| /** The latest outcome recorded for each target (insertion order = chronological). */ | ||
| function latestByTarget(outcomes) { | ||
| const latest = new Map(); | ||
| for (const o of outcomes) | ||
| latest.set(o.target, o); // later entries overwrite | ||
| return latest; | ||
| } | ||
| /** | ||
| * Open escalations — targets whose most recent outcome is an escalation (no | ||
| * resolution has closed it yet). These are the "Needs you" items. | ||
| */ | ||
| export function openEscalations(store) { | ||
| const latest = latestByTarget(store.readOutcomes().outcomes); | ||
| return [...latest.values()] | ||
| .filter((o) => o.kind === "escalated") | ||
| .sort((a, b) => a.target.localeCompare(b.target)); | ||
| } | ||
| /** Every resolution recorded (the audit trail), most recent first. */ | ||
| export function resolutions(store) { | ||
| return store | ||
| .readOutcomes() | ||
| .outcomes.filter((o) => o.kind === "resolved") | ||
| .reverse(); | ||
| } |
+96
| import { isTrivialLine, lineDiff, splitLines } from "./diff.js"; | ||
| import { symbolLocator, opKeyer } from "./symbols.js"; | ||
| /** | ||
| * Reconstruct a file with one actor's uncommitted changes BACKED OUT, keeping | ||
| * everyone else's: omit the actor's added lines, restore the head lines it | ||
| * removed, and leave every other actor's (and unclaimed) changes exactly as they | ||
| * are in the working tree. This is the inverse of commit.ts's buildOwnedText — | ||
| * "everyone except this actor" instead of "only this actor" — and it's what lets | ||
| * you back out one rogue agent's work from a shared checkout without touching the | ||
| * others'. Trivial structural lines (braces) follow their change run, same as the | ||
| * commit path. | ||
| */ | ||
| function buildWithoutActor(path, headText, worktreeText, owned, actor) { | ||
| const ops = lineDiff(headText ?? "", worktreeText ?? ""); | ||
| // Same keying as commit/reconcile: adds by worktree scope, removals by HEAD. | ||
| const keyOf = opKeyer(symbolLocator(path, worktreeText ?? ""), symbolLocator(path, headText ?? "")); | ||
| const out = []; | ||
| let reverted = 0; | ||
| let lastFromWorktree = false; | ||
| let blockRevert = false; // trivial lines inherit their run's revert decision | ||
| for (const op of ops) { | ||
| const key = keyOf(op); // every op, so the line cursor stays aligned | ||
| if (op.type === "eq") { | ||
| out.push(op.text); | ||
| lastFromWorktree = false; | ||
| blockRevert = false; | ||
| continue; | ||
| } | ||
| let revert; | ||
| if (isTrivialLine(op.text)) { | ||
| revert = blockRevert; | ||
| } | ||
| else { | ||
| const owner = op.type === "add" ? owned?.added[key] : owned?.removed[key]; | ||
| revert = owner === actor; // back out ONLY this actor's lines | ||
| blockRevert = revert; | ||
| } | ||
| if (op.type === "add") { | ||
| if (revert) { | ||
| reverted++; // drop the actor's added line | ||
| } | ||
| else { | ||
| out.push(op.text); // keep another actor's / unclaimed add | ||
| lastFromWorktree = true; | ||
| } | ||
| } | ||
| else { | ||
| if (revert) { | ||
| out.push(op.text); // restore the head line the actor removed | ||
| reverted++; | ||
| lastFromWorktree = false; | ||
| } | ||
| // else: another actor's / unclaimed removal — leave it removed (omit head line) | ||
| } | ||
| } | ||
| if (reverted === 0) | ||
| return { text: worktreeText, reverted: 0 }; | ||
| // Undoing a file the actor CREATED (no head) whose every line was theirs: the | ||
| // file should cease to exist, not be left as an empty stub. | ||
| if (headText === null && out.length === 0) | ||
| return { text: null, reverted }; | ||
| if (worktreeText === null && out.length === 0) | ||
| return { text: null, reverted }; | ||
| const headFinal = headText === null ? true : splitLines(headText).finalNewline; | ||
| const wtFinal = worktreeText === null ? true : splitLines(worktreeText).finalNewline; | ||
| const finalNL = lastFromWorktree ? wtFinal : headFinal; | ||
| const text = out.length === 0 ? "" : out.join("\n") + (finalNL ? "\n" : ""); | ||
| return { text, reverted }; | ||
| } | ||
| /** | ||
| * Plan backing out an actor's uncommitted working-tree changes. Pure: computes | ||
| * the new content per file without writing anything (the caller writes, or | ||
| * previews on --dry-run). | ||
| */ | ||
| export function planUndo(model, ownership, actor) { | ||
| const files = []; | ||
| const skippedBinary = []; | ||
| let totalReverted = 0; | ||
| for (const file of model.files) { | ||
| const owns = ownership.files[file.path] && | ||
| (Object.values(ownership.files[file.path].added).includes(actor) || | ||
| Object.values(ownership.files[file.path].removed).includes(actor)); | ||
| if (!owns) | ||
| continue; | ||
| if (file.binary) { | ||
| skippedBinary.push(file.path); | ||
| continue; | ||
| } | ||
| const built = buildWithoutActor(file.path, file.oldText, file.newText, ownership.files[file.path], actor); | ||
| if (built.reverted === 0) | ||
| continue; | ||
| files.push({ path: file.path, text: built.text, reverted: built.reverted }); | ||
| totalReverted += built.reverted; | ||
| } | ||
| return { actor, files, totalReverted, skippedBinary }; | ||
| } |
+75
-6
| import { resolve, sep } from "node:path"; | ||
| /** How long a claim is held before it auto-expires (refreshed on each claim). */ | ||
| export const CLAIM_TTL_MS = 10 * 60 * 1000; | ||
| /** How long a recorded denial lingers without a retry. Short — a block is news. */ | ||
| export const BLOCK_TTL_MS = 90 * 1000; | ||
| /** | ||
@@ -54,6 +56,9 @@ * True if `p` resolves outside `repoRoot` (absolute path or `../` traversal). | ||
| */ | ||
| export function acquireClaims(store, actorId, sessionId, rawPaths, now) { | ||
| export function acquireClaims(store, actorId, sessionId, rawPaths, now, intent) { | ||
| const cleanIntent = intent?.trim() ? intent.trim() : undefined; | ||
| return store.withLock(() => { | ||
| const file = store.readClaims(); | ||
| file.claims = active(file.claims, now); | ||
| file.blocks = (file.blocks ?? []).filter((b) => b.expiresAt > now); | ||
| const sameTarget = (b, t) => b.actor === actorId && b.path === t.path && b.symbol === t.symbol; | ||
| const results = []; | ||
@@ -70,5 +75,31 @@ for (const raw of rawPaths) { | ||
| if (conflict) { | ||
| results.push({ ...target, granted: false, holder: conflict.actor }); | ||
| results.push({ | ||
| ...target, | ||
| granted: false, | ||
| holder: conflict.actor, | ||
| holderIntent: conflict.intent, | ||
| }); | ||
| // Record the denial so the fleet view can show who's blocked on whom, | ||
| // carrying the holder's intent so the block explains itself. | ||
| const prior = file.blocks.find((b) => sameTarget(b, target)); | ||
| if (prior) { | ||
| prior.holder = conflict.actor; | ||
| prior.holderIntent = conflict.intent; | ||
| prior.expiresAt = now + BLOCK_TTL_MS; | ||
| } | ||
| else { | ||
| file.blocks.push({ | ||
| path: target.path, | ||
| symbol: target.symbol, | ||
| actor: actorId, | ||
| holder: conflict.actor, | ||
| holderIntent: conflict.intent, | ||
| blockedAt: new Date(now).toISOString(), | ||
| expiresAt: now + BLOCK_TTL_MS, | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| // Granted: this actor is no longer blocked on this target. | ||
| file.blocks = file.blocks.filter((b) => !sameTarget(b, target)); | ||
| const own = file.claims.find((c) => c.actor === actorId && | ||
@@ -80,2 +111,4 @@ c.path === target.path && | ||
| own.session = sessionId; | ||
| if (cleanIntent !== undefined) | ||
| own.intent = cleanIntent; | ||
| } | ||
@@ -90,2 +123,3 @@ else { | ||
| expiresAt: now + CLAIM_TTL_MS, | ||
| intent: cleanIntent, | ||
| }); | ||
@@ -109,10 +143,15 @@ } | ||
| const before = file.claims.length; | ||
| const matchesTarget = (path, symbol) => targets === null || | ||
| targets.some((t) => t.path === path && (t.symbol === undefined || t.symbol === symbol)); | ||
| file.claims = file.claims.filter((c) => { | ||
| if (c.actor !== actorId) | ||
| return true; | ||
| if (targets === null) | ||
| return false; // release all of this actor's claims | ||
| return !targets.some((t) => t.path === c.path && | ||
| (t.symbol === undefined || t.symbol === c.symbol)); | ||
| return !matchesTarget(c.path, c.symbol); | ||
| }); | ||
| // Releasing a claim resolves any block where this actor was the holder, so | ||
| // drop those now rather than waiting for them to time out (a re-acquire | ||
| // inside the TTL would otherwise make a resolved block reappear). | ||
| if (file.blocks?.length) { | ||
| file.blocks = file.blocks.filter((b) => !(b.holder === actorId && matchesTarget(b.path, b.symbol))); | ||
| } | ||
| store.writeClaims(file); | ||
@@ -126,2 +165,32 @@ return before - file.claims.length; | ||
| } | ||
| /** | ||
| * Is some OTHER actor holding a claim that an edit to `path` touching `symbols` | ||
| * would collide with? A whole-file claim by another actor collides with any edit; | ||
| * a symbol claim collides only when the edit touches that symbol. Returns the | ||
| * holder (and their intent) of the first such claim, or null if the edit is free. | ||
| * This is the edit-time prevention oracle: deny the write before bytes change. | ||
| */ | ||
| export function claimHeldByOther(store, actorId, rawPath, symbols, now) { | ||
| const path = parseTarget(rawPath).path; | ||
| for (const c of listClaims(store, now)) { | ||
| if (c.actor === actorId || c.path !== path) | ||
| continue; | ||
| if (c.symbol === undefined || symbols.includes(c.symbol)) { | ||
| return { holder: c.actor, intent: c.intent }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Active claim denials — who is blocked on whom. Only surfaced while the denial | ||
| * is fresh AND the holder still holds an overlapping claim (a block whose holder | ||
| * has released is no longer real, so it's dropped). | ||
| */ | ||
| export function listBlocks(store, now) { | ||
| const file = store.readClaims(); | ||
| const claims = active(file.claims, now); | ||
| return (file.blocks ?? []) | ||
| .filter((b) => b.expiresAt > now) | ||
| .filter((b) => claims.some((c) => c.actor === b.holder && overlaps({ path: b.path, symbol: b.symbol }, c))); | ||
| } | ||
| /** Display label for a claim, e.g. `utils.js#formatPrice` or `utils.js`. */ | ||
@@ -128,0 +197,0 @@ export function claimLabel(c) { |
+337
-14
@@ -13,2 +13,4 @@ #!/usr/bin/env node | ||
| import { dependencyWarnings, formatWarning } from "./push.js"; | ||
| import { fleetSnapshot, renderFleet } from "./fleet.js"; | ||
| import { planUndo } from "./undo.js"; | ||
| import { selectOwned, commitSelection } from "./commit.js"; | ||
@@ -19,3 +21,7 @@ import { renderStatus, renderPreview } from "./render.js"; | ||
| import { acquireClaims, releaseClaims, listClaims, claimLabel } from "./claims.js"; | ||
| import { recordOutcome } from "./outcomes.js"; | ||
| import { runMcpServer } from "./mcp.js"; | ||
| import { diagnose } from "./doctor.js"; | ||
| import { parseHookInput, runHookPre, runHookPost } from "./hooks.js"; | ||
| import { detect, planSetup, applySetup } from "./onboard.js"; | ||
| // Exit quietly when output is piped into a process that closes early | ||
@@ -50,2 +56,50 @@ // (e.g. `quilt preview | head`) instead of crashing with EPIPE. The MCP command | ||
| } | ||
| /** Read all of stdin as a string (for the hook commands' JSON payload). */ | ||
| function readStdin() { | ||
| return new Promise((resolve) => { | ||
| let data = ""; | ||
| process.stdin.setEncoding("utf8"); | ||
| process.stdin.on("data", (c) => (data += c)); | ||
| process.stdin.on("end", () => resolve(data)); | ||
| process.stdin.on("error", () => resolve(data)); | ||
| }); | ||
| } | ||
| /** | ||
| * Resolve the actor a hook acts as: QUILT_ACTOR (per-subagent identity, the | ||
| * load-bearing signal for a shared checkout) falls back to the active session's | ||
| * actor (single-agent case). Registers a first-seen actor so it shows up in the | ||
| * fleet. Returns null when identity is unknown — the hook then no-ops rather than | ||
| * guess (it can't tell self from other without an id). | ||
| */ | ||
| function hookActor(store) { | ||
| const id = process.env.QUILT_ACTOR || activeContext(store).actorId; | ||
| if (!id) | ||
| return null; | ||
| if (!store.findActor(id)) { | ||
| store.upsertActor({ id, type: "agent", displayName: id.split("/").pop() ?? id, createdAt: nowIso() }); | ||
| } | ||
| return id; | ||
| } | ||
| /** Initialize Quilt's .quilt/ store. Returns false if it already existed. */ | ||
| function doInit(root) { | ||
| const store = new Store(root); | ||
| if (store.initialized) | ||
| return false; | ||
| store.ensureDirs(); | ||
| const config = { version: 1, createdAt: nowIso() }; | ||
| store.writeConfig(config); | ||
| store.writeObserved({ files: {} }); | ||
| store.writeOwnership({ files: {}, conflicts: {} }); | ||
| store.appendLedger({ ts: nowIso(), type: "repo.initialized", repoRoot: root }); | ||
| return true; | ||
| } | ||
| /** Print one setup step (create/update/skip) for `quilt setup`. */ | ||
| function printSetupStep(step, dryRun) { | ||
| if (step.action === "skip") { | ||
| process.stdout.write(pc.dim(` • ${step.file}: ${step.detail}\n`)); | ||
| return; | ||
| } | ||
| const verb = dryRun ? pc.cyan(" would ") : pc.green(" ✓ "); | ||
| process.stdout.write(verb + `${step.action} ${step.file} — ${step.detail}\n`); | ||
| } | ||
| /** Print active advisory claims below a status view. */ | ||
@@ -67,3 +121,3 @@ function printClaims(store) { | ||
| return; | ||
| process.stdout.write(pc.red(pc.bold(" Collisions caught (work preserved):\n"))); | ||
| process.stdout.write(pc.red(pc.bold(" Overwrite preserved (work saved):\n"))); | ||
| for (const c of open) { | ||
@@ -78,3 +132,3 @@ process.stdout.write(` ${c.path} ${pc.dim(`${c.byActor} overwrote ${c.victimActor}`)}\n`); | ||
| .description("Actor-owned patches for Git. Same repo. Many agents. Clean commits.") | ||
| .version("0.1.0"); | ||
| .version("0.3.0"); | ||
| program | ||
@@ -85,15 +139,65 @@ .command("init") | ||
| const root = findRepo(); | ||
| const created = doInit(root); | ||
| if (!created) { | ||
| process.stdout.write(pc.dim("Quilt already initialized at .quilt/\n")); | ||
| } | ||
| else { | ||
| process.stdout.write(pc.green("✓ ") + "Quilt initialized.\n" + | ||
| pc.dim(" Next: quilt start --actor <id> --type agent\n")); | ||
| } | ||
| // If this looks like an agent-orchestrated repo, point at one-step wiring. | ||
| const d = detect(root); | ||
| if (d.orchestrator && !(d.quiltWired && d.coordinationPresent)) { | ||
| process.stdout.write("\n" + | ||
| pc.cyan("→ ") + | ||
| `${d.orchestrator} detected. Wire the fleet up with ` + | ||
| pc.bold("quilt setup") + | ||
| pc.dim(" (adds the shared MCP server + coordination snippet).\n")); | ||
| } | ||
| }); | ||
| program | ||
| .command("setup") | ||
| .description("Wire Quilt into this repo's agent orchestrator (.mcp.json + CLAUDE.md + capture hooks)") | ||
| .option("--dry-run", "show what would change without writing") | ||
| .action((opts) => { | ||
| const root = findRepo(); | ||
| const store = new Store(root); | ||
| if (store.initialized) { | ||
| process.stdout.write(pc.dim("Quilt already initialized at .quilt/\n")); | ||
| const dryRun = Boolean(opts.dryRun); | ||
| const initNeeded = !store.initialized; | ||
| if (initNeeded && !dryRun) | ||
| doInit(root); | ||
| const d = detect(root); | ||
| const steps = planSetup(root); | ||
| const willChange = steps.some((s) => s.action !== "skip"); | ||
| if (d.orchestrator) { | ||
| process.stdout.write(pc.dim(`Detected ${d.orchestrator}.\n`)); | ||
| } | ||
| else { | ||
| process.stdout.write(pc.dim("No orchestrator config detected — wiring up for Claude Code (.mcp.json + CLAUDE.md + hooks).\n")); | ||
| } | ||
| if (dryRun) { | ||
| if (initNeeded) { | ||
| process.stdout.write(pc.cyan(" would ") + "initialize Quilt (.quilt/)\n"); | ||
| } | ||
| for (const s of steps) | ||
| printSetupStep(s, true); | ||
| process.stdout.write("\n" + pc.dim(willChange || initNeeded ? "Run `quilt setup` to apply.\n" : "Already wired — nothing to do.\n")); | ||
| return; | ||
| } | ||
| store.ensureDirs(); | ||
| const config = { version: 1, createdAt: nowIso() }; | ||
| store.writeConfig(config); | ||
| store.writeObserved({ files: {} }); | ||
| store.writeOwnership({ files: {}, conflicts: {} }); | ||
| store.appendLedger({ ts: nowIso(), type: "repo.initialized", repoRoot: root }); | ||
| process.stdout.write(pc.green("✓ ") + "Quilt initialized.\n" + | ||
| pc.dim(" Next: quilt start --actor <id> --type agent\n")); | ||
| const written = applySetup(steps); | ||
| if (initNeeded) | ||
| process.stdout.write(pc.green("✓ ") + "initialized Quilt (.quilt/)\n"); | ||
| for (const s of steps) | ||
| printSetupStep(s, false); | ||
| if (written.length === 0 && !initNeeded) { | ||
| process.stdout.write("\n" + pc.green("✓ ") + "Already wired up. Your fleet is ready.\n"); | ||
| } | ||
| else { | ||
| process.stdout.write("\n" + | ||
| pc.green("✓ ") + | ||
| "Quilt is wired in. Each agent: claim before editing, commit_mine when done.\n" + | ||
| pc.dim(" Give each agent process its own QUILT_ACTOR so the capture hooks can\n") + | ||
| pc.dim(" tell them apart — without it, native edits aren't attributed.\n") + | ||
| pc.dim(" Run `quilt doctor` to confirm capture is flowing. See docs/orchestrators.md.\n")); | ||
| } | ||
| }); | ||
@@ -179,2 +283,97 @@ program | ||
| program | ||
| .command("undo") | ||
| .description("Back out one actor's uncommitted changes from the working tree, keeping everyone else's") | ||
| .argument("<actor>", "the actor whose uncommitted changes to revert") | ||
| .option("--dry-run", "show what would be reverted without changing any files") | ||
| .action((actor, opts) => { | ||
| const store = requireStore(); | ||
| const ctx = activeContext(store); | ||
| reconcile(store, ctx.actorId); | ||
| const model = buildModel(store, ctx.actorId); | ||
| const plan = planUndo(model, store.readOwnership(), actor); | ||
| if (plan.files.length === 0 && plan.skippedBinary.length === 0) { | ||
| process.stdout.write(pc.dim(`No attributed uncommitted changes owned by ${actor}.\n`)); | ||
| return; | ||
| } | ||
| if (opts.dryRun) { | ||
| process.stdout.write(pc.bold(`Would back out ${plan.totalReverted} line-change(s) by ${actor}:\n`)); | ||
| for (const f of plan.files) { | ||
| const what = f.text === null ? "delete" : `${f.reverted} line${f.reverted === 1 ? "" : "s"}`; | ||
| process.stdout.write(` ${f.path} ${pc.dim(`(${what})`)}\n`); | ||
| } | ||
| for (const p of plan.skippedBinary) { | ||
| process.stdout.write(pc.dim(` ${p} (binary — can't line-revert)\n`)); | ||
| } | ||
| return; | ||
| } | ||
| const repoRoot = store.paths.repoRoot; | ||
| for (const f of plan.files) { | ||
| const abs = resolve(repoRoot, f.path); | ||
| if (f.text === null) { | ||
| rmSync(abs, { force: true }); | ||
| } | ||
| else { | ||
| mkdirSync(dirname(abs), { recursive: true }); | ||
| writeFileSync(abs, f.text); | ||
| } | ||
| } | ||
| // Absorb the undo so the next reconcile doesn't re-attribute it, and drop the | ||
| // actor's now-gone ownership. Other actors' entries are left intact. | ||
| store.withLock(() => { | ||
| const obs = store.readObserved(); | ||
| const own = store.readOwnership(); | ||
| for (const f of plan.files) { | ||
| obs.files[f.path] = f.text; | ||
| const fo = own.files[f.path]; | ||
| if (fo) { | ||
| for (const k of Object.keys(fo.added)) | ||
| if (fo.added[k] === actor) | ||
| delete fo.added[k]; | ||
| for (const k of Object.keys(fo.removed)) | ||
| if (fo.removed[k] === actor) | ||
| delete fo.removed[k]; | ||
| } | ||
| } | ||
| store.writeObserved(obs); | ||
| store.writeOwnership(own); | ||
| }); | ||
| process.stdout.write(pc.green("✓ ") + | ||
| `Backed out ${plan.totalReverted} line-change(s) by ${actor} across ${plan.files.length} file(s). ` + | ||
| "Other actors' work is untouched.\n"); | ||
| for (const p of plan.skippedBinary) { | ||
| process.stdout.write(pc.dim(` skipped binary (can't line-revert): ${p}\n`)); | ||
| } | ||
| }); | ||
| program | ||
| .command("fleet") | ||
| .description("Mission control: a live view of the fleet — who's working, claims, conflicts") | ||
| .option("--json", "emit the fleet view as JSON") | ||
| .option("--watch", "refresh the view live until Ctrl-C") | ||
| .action((opts) => { | ||
| const store = requireStore(); | ||
| const headLabel = shortHead(store.paths.repoRoot); | ||
| if (opts.json) { | ||
| process.stdout.write(JSON.stringify(fleetSnapshot(store, Date.now()), null, 2) + "\n"); | ||
| return; | ||
| } | ||
| const draw = () => { | ||
| const view = renderFleet(fleetSnapshot(store, Date.now()), headLabel); | ||
| if (opts.watch) | ||
| process.stdout.write("\x1b[2J\x1b[H"); // clear + home | ||
| process.stdout.write(view); | ||
| }; | ||
| draw(); | ||
| if (!opts.watch) | ||
| return; | ||
| process.stdout.write(pc.dim(" (live — Ctrl-C to stop)\n")); | ||
| const timer = setInterval(draw, 1000); | ||
| const stop = () => { | ||
| clearInterval(timer); | ||
| process.stdout.write("\n"); | ||
| process.exit(0); | ||
| }; | ||
| process.once("SIGINT", stop); | ||
| process.once("SIGTERM", stop); | ||
| }); | ||
| program | ||
| .command("mine") | ||
@@ -210,3 +409,3 @@ .description("Summarize the changes you own") | ||
| .command("conflicts") | ||
| .description("Show overlapping changes claimed by multiple actors") | ||
| .description("Show shared changes: same-line clashes (contended) vs adjacent edits that commit cleanly") | ||
| .option("--json", "emit stable JSON for agents") | ||
@@ -395,2 +594,3 @@ .action((opts) => { | ||
| .option("--json", "emit JSON") | ||
| .option("--intent <text>", "a short why for this claim, shown to anyone it blocks") | ||
| .action((paths, opts) => { | ||
@@ -418,3 +618,3 @@ const store = requireStore(); | ||
| fail("no active actor. Run `quilt start --actor <id>` first."); | ||
| const results = acquireClaims(store, ctx.actorId, ctx.session?.id ?? null, paths, Date.now()); | ||
| const results = acquireClaims(store, ctx.actorId, ctx.session?.id ?? null, paths, Date.now(), opts.intent); | ||
| // Push-awareness: warn if anything just claimed depends on a symbol another | ||
@@ -435,2 +635,7 @@ // actor is currently changing, so the actor learns at reservation time. | ||
| process.stdout.write(pc.red(" ✗ denied ") + `${target} ${pc.dim(`(${why})`)}\n`); | ||
| // Hand the blocked actor the holder's intent so it can resolve the | ||
| // collision instead of just waiting. | ||
| if (r.holderIntent) { | ||
| process.stdout.write(pc.dim(` ${r.holder} is: ${r.holderIntent}\n`)); | ||
| } | ||
| } | ||
@@ -458,2 +663,60 @@ } | ||
| program | ||
| .command("escalate") | ||
| .description("Flag a collision you can't reconcile for a human — shows under 'Needs you'") | ||
| .argument("<target>", "the clash, e.g. pool.js#maxConnections") | ||
| .option("--reason <text>", "why it needs a human (e.g. the opposed intents)") | ||
| .action((target, opts) => { | ||
| const store = requireStore(); | ||
| const ctx = activeContext(store); | ||
| const actor = ctx.actorId ?? "unknown"; | ||
| const o = recordOutcome(store, "escalated", actor, target, opts.reason, nowIso()); | ||
| store.appendLedger({ ts: o.ts, type: "collision.escalated", target: o.target, actorId: actor }); | ||
| process.stdout.write(pc.yellow("⚑ ") + `escalated ${pc.bold(o.target)} for review` + | ||
| (o.note ? pc.dim(` — ${o.note}`) : "") + "\n"); | ||
| }); | ||
| program | ||
| .command("resolve") | ||
| .description("Mark a collision as sewn/handled — closes its 'Needs you' flag and records the trail") | ||
| .argument("<target>", "the clash that was resolved, e.g. pool.js#maxConnections") | ||
| .option("--note <text>", "what was done to reconcile it") | ||
| .action((target, opts) => { | ||
| const store = requireStore(); | ||
| const ctx = activeContext(store); | ||
| const actor = ctx.actorId ?? "unknown"; | ||
| const o = recordOutcome(store, "resolved", actor, target, opts.note, nowIso()); | ||
| store.appendLedger({ ts: o.ts, type: "collision.resolved", target: o.target, actorId: actor }); | ||
| process.stdout.write(pc.green("✓ ") + `resolved ${pc.bold(o.target)}` + | ||
| (o.note ? pc.dim(` — ${o.note}`) : "") + "\n"); | ||
| }); | ||
| program | ||
| .command("doctor") | ||
| .description("Check Quilt's health here: wiring, identity, and whether capture is actually flowing") | ||
| .option("--json", "output the report as JSON") | ||
| .action((opts) => { | ||
| // Not requireStore: doctor should run pre-init and REPORT that, not error. | ||
| const store = new Store(findRepo()); | ||
| const report = diagnose(store, { actorEnv: process.env.QUILT_ACTOR }); | ||
| if (opts.json) { | ||
| process.stdout.write(JSON.stringify(report, null, 2) + "\n"); | ||
| return; | ||
| } | ||
| const glyph = (s) => s === "ok" ? pc.green("✓") : s === "warn" ? pc.yellow("!") : s === "fail" ? pc.red("✗") : pc.dim("·"); | ||
| process.stdout.write(pc.bold("quilt doctor") + "\n\n"); | ||
| for (const c of report.checks) { | ||
| process.stdout.write(` ${glyph(c.status)} ${pc.bold(c.label)} ${c.detail}\n`); | ||
| if (c.hint) | ||
| process.stdout.write(` ${pc.dim("→ " + c.hint)}\n`); | ||
| } | ||
| const verdict = report.verdict === "healthy" | ||
| ? pc.green("healthy") | ||
| : report.verdict === "warnings" | ||
| ? pc.yellow("wired, with warnings") | ||
| : pc.red("not ready — see the checks above"); | ||
| process.stdout.write("\n" + pc.dim("Verdict: ") + verdict + "\n"); | ||
| // Non-zero on not-ready so `quilt doctor` is usable as a CI/scripting gate. | ||
| // Warnings stay 0 (advisory), matching the convention of linters. | ||
| if (report.verdict === "not-ready") | ||
| process.exitCode = 1; | ||
| }); | ||
| program | ||
| .command("mcp") | ||
@@ -472,3 +735,63 @@ .description("Run the Quilt MCP server (stdio) for agent integration") | ||
| }); | ||
| // A Quilt-initialized store for the current repo, or null when there's no repo | ||
| // or Quilt isn't set up. The hook commands stay fail-open: any problem → no-op. | ||
| function hookStore() { | ||
| try { | ||
| const root = repoRoot(process.cwd()); | ||
| if (!root) | ||
| return null; | ||
| const store = new Store(root); | ||
| return store.initialized ? store : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| program | ||
| .command("hook-pre") | ||
| .description("PreToolUse hook: snapshot + prevention for native Edit/Write/MultiEdit (reads JSON on stdin)") | ||
| .action(async () => { | ||
| // Fail-open: a hook must never block or crash an agent's edit on our account. | ||
| try { | ||
| const store = hookStore(); | ||
| const input = store && parseHookInput(JSON.parse(await readStdin())); | ||
| const actor = store && hookActor(store); | ||
| if (store && input && actor) { | ||
| const decision = runHookPre(store, actor, input); | ||
| if (decision.deny) { | ||
| // Claude Code's PreToolUse deny format: this JSON on stdout (exit 0) | ||
| // blocks the tool call and shows `permissionDecisionReason` to the | ||
| // agent. Allowing is the default — emit nothing. | ||
| process.stdout.write(JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: "PreToolUse", | ||
| permissionDecision: "deny", | ||
| permissionDecisionReason: decision.reason, | ||
| }, | ||
| }) + "\n"); | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| /* fail-open: allow the edit */ | ||
| } | ||
| process.exit(0); | ||
| }); | ||
| program | ||
| .command("hook-post") | ||
| .description("PostToolUse hook: capture authorship of a native Edit/Write/MultiEdit (reads JSON on stdin)") | ||
| .action(async () => { | ||
| try { | ||
| const store = hookStore(); | ||
| const input = store && parseHookInput(JSON.parse(await readStdin())); | ||
| const actor = store && hookActor(store); | ||
| if (store && input && actor) | ||
| runHookPost(store, actor, input); | ||
| } | ||
| catch { | ||
| /* fail-open: skip capture */ | ||
| } | ||
| process.exit(0); | ||
| }); | ||
| program | ||
| .command("whoami") | ||
@@ -475,0 +798,0 @@ .description("Show the active actor and session") |
+8
-7
@@ -6,3 +6,3 @@ import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; | ||
| import { buildHunks, isTrivialLine, lineDiff, renderPatch, splitLines, } from "./diff.js"; | ||
| import { hunkChangedLines, } from "./engine.js"; | ||
| import { symbolLocator, opKeyer } from "./symbols.js"; | ||
| /** git mode for a working-tree file: 100755 if executable, else 100644. */ | ||
@@ -26,4 +26,7 @@ function worktreeMode(repoRoot, relPath) { | ||
| */ | ||
| function buildOwnedText(headText, worktreeText, owned, actor, includeUnclaimed) { | ||
| function buildOwnedText(path, headText, worktreeText, owned, actor, includeUnclaimed) { | ||
| const ops = lineDiff(headText ?? "", worktreeText ?? ""); | ||
| // Key each line the same way reconcile did: adds by their symbol scope in the | ||
| // worktree, removals by their scope in HEAD. | ||
| const keyOf = opKeyer(symbolLocator(path, worktreeText ?? ""), symbolLocator(path, headText ?? "")); | ||
| const out = []; | ||
@@ -38,2 +41,3 @@ let added = 0; | ||
| for (const op of ops) { | ||
| const key = keyOf(op); // every op, so the line cursor stays aligned | ||
| if (op.type === "eq") { | ||
@@ -50,3 +54,3 @@ out.push(op.text); | ||
| else { | ||
| const owner = op.type === "add" ? owned?.added[op.text] : owned?.removed[op.text]; | ||
| const owner = op.type === "add" ? owned?.added[key] : owned?.removed[key]; | ||
| if (owner === actor) | ||
@@ -111,3 +115,3 @@ include = true; | ||
| continue; | ||
| const built = buildOwnedText(file.oldText, file.newText, ownership.files[file.path], actor, opts.includeMixed ?? false); | ||
| const built = buildOwnedText(file.path, file.oldText, file.newText, ownership.files[file.path], actor, opts.includeMixed ?? false); | ||
| if (built.hasUnclaimed) | ||
@@ -231,5 +235,2 @@ hasMixed = true; | ||
| } | ||
| export function fileHunkLines(file) { | ||
| return file.hunks.reduce((n, h) => n + hunkChangedLines(h.hunk), 0); | ||
| } | ||
| /** | ||
@@ -236,0 +237,0 @@ * Detects an in-progress merge/rebase/cherry-pick/revert. Committing on top of a |
+155
-36
| import { lstatSync, readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { resolve, sep } from "node:path"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { changedPaths, headBlob } from "./git.js"; | ||
| import { buildHunks, isTrivialLine, lineDiff, looksBinary, splitLines, MAX_LCS_CELLS, } from "./diff.js"; | ||
| import { parseSymbols } from "./symbols.js"; | ||
| import { changedPaths, headBlobs } from "./git.js"; | ||
| import { buildHunks, isTrivialLine, lineDiff, looksBinary, MAX_LCS_CELLS, } from "./diff.js"; | ||
| import { parseSymbols, ownKey, keyText, symbolLocator, opKeyer } from "./symbols.js"; | ||
| import { foldedAuthorship, foldedRemovals, readAuthorship } from "./authorship.js"; | ||
| function readWorktree(repoRoot, relPath) { | ||
| const abs = join(repoRoot, relPath); | ||
| // Defense in depth: paths come from .quilt/ JSON (ownership/observed) which is | ||
| // normally Quilt-written, but a hand-edited file could inject `../` — never | ||
| // read a path that resolves outside the repo, and never follow a symlink. | ||
| const root = resolve(repoRoot); | ||
| const abs = resolve(root, relPath); | ||
| if (abs !== root && !abs.startsWith(root + sep)) | ||
| return null; | ||
| try { | ||
@@ -56,11 +63,18 @@ // lstat (not stat) so a symlink is never followed — Quilt must not read or | ||
| } | ||
| function changedLineSets(oldText, newText) { | ||
| /** The ownership KEYS (symbol scope + text) added/removed between two texts, for | ||
| * pruning ownership down to lines still in the diff. Keyed the same way reconcile | ||
| * records them: adds scope to the new side, removes to the old side. */ | ||
| function changedLineSets(path, oldText, newText) { | ||
| const ops = lineDiff(oldText ?? "", newText ?? ""); | ||
| const keyOf = opKeyer(symbolLocator(path, newText ?? ""), symbolLocator(path, oldText ?? "")); | ||
| const added = new Set(); | ||
| const removed = new Set(); | ||
| for (const op of ops) { | ||
| const key = keyOf(op); | ||
| if (key === null) | ||
| continue; | ||
| if (op.type === "add") | ||
| added.add(op.text); | ||
| else if (op.type === "del") | ||
| removed.add(op.text); | ||
| added.add(key); | ||
| else | ||
| removed.add(key); | ||
| } | ||
@@ -84,2 +98,14 @@ return { added, removed }; | ||
| let clobbersChanged = false; | ||
| // Authoritative authorship from the capture-at-edit ledger (checkpoint + log | ||
| // tail). The ledger is the PRIMARY attribution source: any line it has an | ||
| // author for wins. The content-key inference below is the FALLBACK FLOOR — it | ||
| // only decides lines the ledger never captured (e.g. a raw bash/sed write). | ||
| // Empty when nothing was captured, so the whole overlay no-ops on that path. | ||
| // Read the log once and feed both folds (added-ownership and removal-author). | ||
| const authorshipLog = readAuthorship(store); | ||
| const ledgerOwn = foldedAuthorship(store, authorshipLog); | ||
| // ...and who removed each line, so a captured removal is attributed to its | ||
| // recorded author too (not just whoever reconciled first) — else a commit can | ||
| // include deleting another actor's line when no reconcile ran between edits. | ||
| const removalOwn = foldedRemovals(authorshipLog); | ||
| // Files reserved by OTHER actors. We skip them entirely this pass: don't | ||
@@ -110,6 +136,10 @@ // attribute, don't advance the observed snapshot, don't prune. A claim | ||
| } | ||
| for (const path of relevantPaths(store)) { | ||
| // Read every relevant file's HEAD content in one batched git call up front, | ||
| // instead of a subprocess per path inside the loop (the reconcile hot path). | ||
| const paths = relevantPaths(store); | ||
| const headByPath = headBlobs(repoRoot, paths); | ||
| for (const path of paths) { | ||
| if (wholeFileClaimed.has(path)) | ||
| continue; | ||
| const head = headBlob(repoRoot, path); | ||
| const head = headByPath.get(path) ?? null; | ||
| const current = readWorktree(repoRoot, path); | ||
@@ -143,2 +173,7 @@ // Lines inside symbols another actor has claimed are off-limits for | ||
| const conflicts = ownership.conflicts; | ||
| // Symbol scope for the ownership key: added lines live in `current`, removed | ||
| // lines in `baseline`. Keying `symbol\0text` keeps identical text in two | ||
| // different functions from collapsing to one owner (or one false conflict). | ||
| const addLoc = symbolLocator(path, current ?? ""); | ||
| const delLoc = symbolLocator(path, baseline ?? ""); | ||
| // Clobber detection: the active actor is removing lines that ANOTHER actor | ||
@@ -163,3 +198,9 @@ // owns (uncommitted). Preserve the victim's pre-clobber content so it can | ||
| continue; | ||
| const owner = file.added[op.text]; | ||
| // Whose line is being deleted? Prefer the authoritative ledger author — | ||
| // it knows the true author even when this actor's reconcile hasn't yet | ||
| // overlaid it onto file.added, so a captured-but-unreconciled line still | ||
| // names the right clobber victim. The victim added the line (keyed by its | ||
| // scope), so look it up by the same symbol-qualified key. | ||
| const delKey = ownKey(delLoc(bLine), op.text); | ||
| const owner = ledgerOwn.get(path)?.get(delKey) ?? file.added[delKey]; | ||
| if (owner && owner !== activeActorId) { | ||
@@ -222,12 +263,13 @@ const sample = victims.get(owner) ?? []; | ||
| const map = op.type === "add" ? file.added : file.removed; | ||
| const existing = map[op.text]; | ||
| const key = op.type === "add" ? ownKey(addLoc(curLine), op.text) : ownKey(delLoc(baseLine), op.text); | ||
| const existing = map[key]; | ||
| if (existing && existing !== activeActorId) { | ||
| const fileConflicts = (conflicts[path] ??= {}); | ||
| const list = fileConflicts[op.text] ?? [existing]; | ||
| const list = fileConflicts[key] ?? [existing]; | ||
| if (!list.includes(activeActorId)) | ||
| list.push(activeActorId); | ||
| fileConflicts[op.text] = list; | ||
| fileConflicts[key] = list; | ||
| } | ||
| else if (!existing) { | ||
| map[op.text] = activeActorId; | ||
| map[key] = activeActorId; | ||
| } | ||
@@ -243,3 +285,3 @@ } | ||
| // Prune ownership/conflicts for lines no longer in the working diff. | ||
| const { added, removed } = changedLineSets(head, current); | ||
| const { added, removed } = changedLineSets(path, head, current); | ||
| const file = ownership.files[path]; | ||
@@ -270,2 +312,35 @@ if (file) { | ||
| } | ||
| // Ledger overlay: the ledger is authoritative, so a captured line is | ||
| // attributed to its RECORDED author, replacing whatever the inference floor | ||
| // above guessed (this is the fix for "whoever reconciled first owns it"). | ||
| // Applies to lines present in the current diff; un-captured lines keep their | ||
| // inferred owner — inference is the fallback floor. Added and removed sides | ||
| // are overlaid symmetrically so neither depends on reconcile timing. | ||
| const ledgerForPath = ledgerOwn.get(path); | ||
| if (ledgerForPath) { | ||
| const f = (ownership.files[path] ??= { added: {}, removed: {} }); | ||
| for (const [key, actor] of ledgerForPath) { | ||
| if (!added.has(key) || isTrivialLine(keyText(key))) | ||
| continue; | ||
| f.added[key] = actor; | ||
| if (ownership.conflicts[path]?.[key]) | ||
| delete ownership.conflicts[path][key]; | ||
| } | ||
| } | ||
| // Same key-mismatch caveat as the added overlay (see symbols.ts#opKeyer): a | ||
| // removed line's key here scopes to HEAD's symbol, the ledger's to the edit's | ||
| // baseline — they diverge only if the enclosing function was renamed between | ||
| // the two, in which case this silently no-ops and the line keeps its inferred | ||
| // owner (benign, rare). | ||
| const removalForPath = removalOwn.get(path); | ||
| if (removalForPath) { | ||
| const f = (ownership.files[path] ??= { added: {}, removed: {} }); | ||
| for (const [key, actor] of removalForPath) { | ||
| if (!removed.has(key) || isTrivialLine(keyText(key))) | ||
| continue; | ||
| f.removed[key] = actor; | ||
| if (ownership.conflicts[path]?.[key]) | ||
| delete ownership.conflicts[path][key]; | ||
| } | ||
| } | ||
| } | ||
@@ -286,3 +361,46 @@ store.writeOwnership(ownership); | ||
| } | ||
| function classifyHunk(hunk, path, ownership, activeActorId) { | ||
| /** | ||
| * Decide whether a shared hunk is a benign adjacency or a real same-line clash. | ||
| * | ||
| * Within a change region (a run of del/add ops bounded by equal context) the | ||
| * diff emits all deletes then all adds, so delete[i] and add[i] describe the | ||
| * same position — a replacement. If a replacement's deleted line is owned by one | ||
| * actor and its added line by another, that's an overwrite: someone's line was | ||
| * replaced by someone else's. Adjacent edits, by contrast, pair each actor's own | ||
| * delete with its own add, so no cross-owner pair appears. | ||
| */ | ||
| function hunkOverlap(hunk, file, conflicted, keyOf) { | ||
| if (conflicted) | ||
| return "contended"; // identical line added/removed by two actors | ||
| let delOwners = []; | ||
| let addOwners = []; | ||
| let contended = false; | ||
| const flushRegion = () => { | ||
| const n = Math.min(delOwners.length, addOwners.length); | ||
| for (let i = 0; i < n; i++) { | ||
| const d = delOwners[i]; | ||
| const a = addOwners[i]; | ||
| if (d && a && d !== a) | ||
| contended = true; // a line was replaced by another actor's | ||
| } | ||
| delOwners = []; | ||
| addOwners = []; | ||
| }; | ||
| for (const op of hunk.ops) { | ||
| const key = keyOf(op); // call for EVERY op so the line cursor stays aligned | ||
| if (op.type === "eq") { | ||
| flushRegion(); | ||
| continue; | ||
| } | ||
| if (isTrivialLine(op.text)) | ||
| continue; | ||
| if (op.type === "del") | ||
| delOwners.push(file?.removed[key]); | ||
| else | ||
| addOwners.push(file?.added[key]); | ||
| } | ||
| flushRegion(); | ||
| return contended ? "contended" : "adjacent"; | ||
| } | ||
| function classifyHunk(hunk, path, ownership, activeActorId, addLoc, delLoc) { | ||
| const file = ownership.files[path]; | ||
@@ -293,3 +411,7 @@ const fileConflicts = ownership.conflicts[path] ?? {}; | ||
| let conflicted = false; | ||
| // A fresh keyer per hunk, started at the hunk's line offsets. Called on every | ||
| // op (incl. eq/trivial) so the keys line up with what reconcile recorded. | ||
| const keyOf = opKeyer(addLoc, delLoc, hunk.newStart, hunk.oldStart); | ||
| for (const op of hunk.ops) { | ||
| const key = keyOf(op); | ||
| if (op.type === "eq") | ||
@@ -302,3 +424,3 @@ continue; | ||
| const map = op.type === "add" ? file?.added : file?.removed; | ||
| const owner = map?.[op.text]; | ||
| const owner = map?.[key]; | ||
| if (owner) | ||
@@ -308,5 +430,5 @@ owners.add(owner); | ||
| unowned = true; | ||
| if (fileConflicts[op.text]) { | ||
| if (fileConflicts[key]) { | ||
| conflicted = true; | ||
| for (const a of fileConflicts[op.text]) | ||
| for (const a of fileConflicts[key]) | ||
| owners.add(a); | ||
@@ -330,3 +452,6 @@ } | ||
| } | ||
| return { hunk, ownership: ownership_, actors, conflicted }; | ||
| const overlap = ownership_ === "shared" | ||
| ? hunkOverlap(hunk, file, conflicted, opKeyer(addLoc, delLoc, hunk.newStart, hunk.oldStart)) | ||
| : undefined; | ||
| return { hunk, ownership: ownership_, actors, conflicted, overlap }; | ||
| } | ||
@@ -338,4 +463,6 @@ /** Build the read-only worktree model used by status / mine / preview / commit. */ | ||
| const files = []; | ||
| for (const path of changedPaths(repoRoot)) { | ||
| const head = headBlob(repoRoot, path); | ||
| const paths = changedPaths(repoRoot); | ||
| const headByPath = headBlobs(repoRoot, paths); | ||
| for (const path of paths) { | ||
| const head = headByPath.get(path) ?? null; | ||
| const current = readWorktree(repoRoot, path); | ||
@@ -357,5 +484,8 @@ if (head === current) | ||
| if (!binary) { | ||
| // Symbol scopes for keying: adds live in `current`, removals in `head`. | ||
| const addLoc = symbolLocator(path, current ?? ""); | ||
| const delLoc = symbolLocator(path, head ?? ""); | ||
| const ops = lineDiff(head ?? "", current ?? ""); | ||
| for (const hunk of buildHunks(ops)) { | ||
| model.hunks.push(classifyHunk(hunk, path, ownership, activeActorId)); | ||
| model.hunks.push(classifyHunk(hunk, path, ownership, activeActorId, addLoc, delLoc)); | ||
| } | ||
@@ -367,12 +497,1 @@ } | ||
| } | ||
| /** Count changed (add/del) lines in a hunk. */ | ||
| export function hunkChangedLines(hunk) { | ||
| return hunk.ops.filter((o) => o.type !== "eq").length; | ||
| } | ||
| /** Convenience: does this file have any hunk owned by the active actor? */ | ||
| export function fileHasMine(file) { | ||
| return file.hunks.some((h) => h.ownership === "mine" || h.ownership === "mixed"); | ||
| } | ||
| export function isFinalNewline(text) { | ||
| return text === null ? true : splitLines(text).finalNewline; | ||
| } |
+59
-11
@@ -6,6 +6,12 @@ import { spawnSync } from "node:child_process"; | ||
| */ | ||
| /** spawnSync is set to `encoding: "buffer"`, which it would (wrongly) try to use | ||
| * to encode a string stdin — so coerce, letting callers pass a string or Buffer | ||
| * as the `input` type promises. */ | ||
| function toBufferInput(input) { | ||
| return typeof input === "string" ? Buffer.from(input, "utf8") : input; | ||
| } | ||
| export function git(args, opts) { | ||
| const res = spawnSync("git", args, { | ||
| cwd: opts.cwd, | ||
| input: opts.input, | ||
| input: toBufferInput(opts.input), | ||
| encoding: "buffer", | ||
@@ -30,3 +36,3 @@ env: { ...process.env, ...opts.env }, | ||
| cwd: opts.cwd, | ||
| input: opts.input, | ||
| input: toBufferInput(opts.input), | ||
| encoding: "buffer", | ||
@@ -87,2 +93,50 @@ env: { ...process.env, ...opts.env }, | ||
| /** | ||
| * Read many files' HEAD content in ONE `git cat-file --batch` instead of a | ||
| * subprocess per path — the reconcile hot path (see bench/authorship/LATENCY.md). | ||
| * Returns `path -> content` (utf8), or `null` for a path absent at HEAD (a new | ||
| * file) or that doesn't resolve to a blob. Order-correlated: cat-file emits one | ||
| * record per input line in order, so responses map back to `paths` by index. | ||
| * Parsed byte-wise off the raw buffer so blob sizes stay correct for any bytes. | ||
| */ | ||
| export function headBlobs(cwd, paths) { | ||
| const result = new Map(); | ||
| if (paths.length === 0) | ||
| return result; | ||
| const sha = headSha(cwd); | ||
| if (!sha) { | ||
| for (const p of paths) | ||
| result.set(p, null); // unborn branch: nothing at HEAD | ||
| return result; | ||
| } | ||
| const input = paths.map((p) => `HEAD:${p}`).join("\n") + "\n"; | ||
| const out = gitBytes(["cat-file", "--batch"], { cwd, input }); | ||
| let off = 0; | ||
| for (const p of paths) { | ||
| const nl = out.indexOf(0x0a, off); // end of this record's header line | ||
| if (nl === -1) { | ||
| result.set(p, null); // truncated/short output — treat as absent | ||
| continue; | ||
| } | ||
| const header = out.toString("utf8", off, nl); | ||
| off = nl + 1; | ||
| const tokens = header.split(" "); | ||
| // Missing object: "<input> missing" (the input path may contain spaces, but | ||
| // the final token is always "missing"). Found blob: "<oid> blob <size>". | ||
| if (tokens[tokens.length - 1] === "missing" || tokens[tokens.length - 2] !== "blob") { | ||
| result.set(p, null); | ||
| if (tokens[tokens.length - 1] !== "missing") { | ||
| // A non-blob (e.g. a tree) still has <size> bytes of body to skip over. | ||
| const size = Number(tokens[tokens.length - 1]); | ||
| if (Number.isFinite(size)) | ||
| off += size + 1; | ||
| } | ||
| continue; | ||
| } | ||
| const size = Number(tokens[tokens.length - 1]); | ||
| result.set(p, out.toString("utf8", off, off + size)); | ||
| off += size + 1; // skip the body and its trailing LF | ||
| } | ||
| return result; | ||
| } | ||
| /** | ||
| * Paths that differ between HEAD and the working tree (tracked + untracked), | ||
@@ -105,3 +159,5 @@ * relative to the repo root. Uses NUL-delimited porcelain for safety. | ||
| let path = entry.slice(3); | ||
| // Renames carry "old -> new"; porcelain -z puts old path in the next field. | ||
| // Defensive: `--no-renames` above makes git emit a rename as delete+add, so | ||
| // `R` shouldn't appear — but if it ever does, porcelain -z puts the old path | ||
| // in the next NUL field, so consume it rather than treat it as a real path. | ||
| if (status[0] === "R" || status[1] === "R") { | ||
@@ -116,10 +172,2 @@ i++; // consume the old-path field | ||
| } | ||
| /** True if the path is tracked by git at HEAD or the index. */ | ||
| export function isTracked(cwd, relPath) { | ||
| const res = git(["ls-files", "--error-unmatch", "--", relPath], { | ||
| cwd, | ||
| check: false, | ||
| }); | ||
| return res.status === 0; | ||
| } | ||
| /** | ||
@@ -126,0 +174,0 @@ * The git file mode of a path at HEAD (e.g. "100644", "100755", "120000" for a |
+8
-4
@@ -22,2 +22,3 @@ function hunkAddRemove(ops) { | ||
| conflicted: h.conflicted, | ||
| ...(h.overlap ? { overlap: h.overlap } : {}), | ||
| added, | ||
@@ -70,15 +71,18 @@ removed, | ||
| for (const file of model.files) { | ||
| const conflicted = file.hunks.filter((h) => h.conflicted || h.ownership === "shared"); | ||
| if (conflicted.length === 0) | ||
| const shared = file.hunks.filter((h) => h.ownership === "shared"); | ||
| if (shared.length === 0) | ||
| continue; | ||
| const actors = new Set(); | ||
| let lines = 0; | ||
| for (const h of conflicted) { | ||
| let contended = false; | ||
| for (const h of shared) { | ||
| for (const a of h.actors) | ||
| actors.add(a); | ||
| lines += h.hunk.ops.filter((o) => o.type !== "eq").length; | ||
| if (h.overlap === "contended") | ||
| contended = true; | ||
| } | ||
| out.push({ path: file.path, actors: [...actors], lines }); | ||
| out.push({ path: file.path, actors: [...actors], lines, kind: contended ? "contended" : "adjacent" }); | ||
| } | ||
| return { conflicts: out }; | ||
| } |
+137
-33
@@ -10,8 +10,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { acquireClaims, releaseClaims, listClaims } from "./claims.js"; | ||
| import { recordOutcome, openEscalations } from "./outcomes.js"; | ||
| import { applyAndRecordEdit, applyAndRecordWrite } from "./authorship.js"; | ||
| import { dependencyWarnings } from "./push.js"; | ||
| /** | ||
| * The Quilt MCP server (stdio). Each coding agent runs its own instance with its | ||
| * own actor identity, so attribution is precise per-agent. The intended agent | ||
| * loop: start_session → (get_status → claim → edit → commit_mine). NOTHING is | ||
| * written to stdout except the JSON-RPC transport. | ||
| * The Quilt MCP server (stdio). Attribution is per-agent. Two ways to identify: | ||
| * an agent can pin one identity (its own server via QUILT_ACTOR, or a single | ||
| * start_session call), OR — when several subagents share ONE server (Claude | ||
| * Code / Codex spawning a fleet) — each tool call passes its own `actor`, so the | ||
| * shared process attributes every subagent correctly. Intended loop: | ||
| * (start_session?) → get_status → claim → edit → commit_mine. NOTHING is written | ||
| * to stdout except the JSON-RPC transport. | ||
| */ | ||
@@ -31,6 +36,28 @@ export async function runMcpServer(store) { | ||
| } | ||
| const requireActor = () => { | ||
| if (!active) | ||
| throw new Error("no active actor — call start_session first"); | ||
| return active.actorId; | ||
| /** | ||
| * Resolve who a tool call acts as. Precedence: an explicit per-call `actor` | ||
| * argument > the env/start_session `active` actor. The per-call form is what | ||
| * lets ONE shared server (e.g. Claude Code or Codex running several subagents | ||
| * against one `quilt mcp` process) attribute each subagent correctly — there | ||
| * is no single global "active" agent to clobber. An actor named for the first | ||
| * time is auto-registered, so a subagent can just pass its id without a | ||
| * separate start_session. | ||
| */ | ||
| const resolveActor = (explicit, required) => { | ||
| const id = explicit ?? active?.actorId ?? null; | ||
| if (!id) { | ||
| if (required) { | ||
| throw new Error("no actor — pass `actor`, or call start_session first"); | ||
| } | ||
| return null; | ||
| } | ||
| if (!store.findActor(id)) { | ||
| store.upsertActor({ | ||
| id, | ||
| type: "agent", | ||
| displayName: id.split("/").pop() ?? id, | ||
| createdAt: nowIso(), | ||
| }); | ||
| } | ||
| return id; | ||
| }; | ||
@@ -40,5 +67,10 @@ const ok = (data) => ({ | ||
| }); | ||
| const server = new McpServer({ name: "quilt", version: "0.0.1" }); | ||
| // Reusable optional per-call actor field for every actor-scoped tool. | ||
| const actorArg = z | ||
| .string() | ||
| .optional() | ||
| .describe("actor id to act as. Required when several agents share one server (each subagent passes its own id, e.g. its role/task name); optional if identity is pinned via start_session or QUILT_ACTOR."); | ||
| const server = new McpServer({ name: "quilt", version: "0.3.0" }); | ||
| server.registerTool("start_session", { | ||
| description: "Identify the calling agent as an actor and start a session in this repo. Call this first.", | ||
| description: "Register an actor and start a session in this repo, pinning this server to that identity. Optional: if several agents share one server, skip this and pass `actor` on each call instead.", | ||
| inputSchema: { | ||
@@ -85,5 +117,5 @@ actor: z.string().describe("actor id, e.g. wilson/codex-auth"), | ||
| description: "Show who owns which working-tree changes, plus caught collisions and active claims. Call before editing and before committing.", | ||
| inputSchema: {}, | ||
| }, async () => { | ||
| const actorId = active?.actorId ?? null; | ||
| inputSchema: { actor: actorArg }, | ||
| }, async ({ actor }) => { | ||
| const actorId = resolveActor(actor, false); | ||
| reconcile(store, actorId); | ||
@@ -95,6 +127,11 @@ const model = buildModel(store, actorId); | ||
| claims: listClaims(store, Date.now()), | ||
| // Push-awareness at the orient step: a symbol this actor already claimed | ||
| // depends on one another actor is changing. Mirrors `quilt status --json`. | ||
| dependencyWarnings: actorId ? dependencyWarnings(store, actorId, Date.now()) : [], | ||
| // Collisions an agent kicked up for a human and not yet resolved. | ||
| needsYou: openEscalations(store), | ||
| }); | ||
| }); | ||
| server.registerTool("get_my_changes", { description: "Summarize the changes you own.", inputSchema: {} }, async () => { | ||
| const actorId = requireActor(); | ||
| server.registerTool("get_my_changes", { description: "Summarize the changes you own.", inputSchema: { actor: actorArg } }, async ({ actor }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| reconcile(store, actorId); | ||
@@ -106,5 +143,5 @@ const model = buildModel(store, actorId); | ||
| description: "Show overlapping/shared changes and collisions that were caught.", | ||
| inputSchema: {}, | ||
| }, async () => { | ||
| const actorId = active?.actorId ?? null; | ||
| inputSchema: { actor: actorArg }, | ||
| }, async ({ actor }) => { | ||
| const actorId = resolveActor(actor, false); | ||
| reconcile(store, actorId); | ||
@@ -122,5 +159,5 @@ const model = buildModel(store, actorId); | ||
| description: "Preview the exact patch commit_mine would create.", | ||
| inputSchema: { includeUnclaimed: z.boolean().optional() }, | ||
| }, async ({ includeUnclaimed }) => { | ||
| const actorId = requireActor(); | ||
| inputSchema: { actor: actorArg, includeUnclaimed: z.boolean().optional() }, | ||
| }, async ({ actor, includeUnclaimed }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| reconcile(store, actorId); | ||
@@ -139,10 +176,10 @@ const model = buildModel(store, actorId); | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| message: z.string(), | ||
| includeUnclaimed: z.boolean().optional(), | ||
| }, | ||
| }, async ({ message, includeUnclaimed }) => { | ||
| const actorId = requireActor(); | ||
| }, async ({ actor: actorIn, message, includeUnclaimed }) => { | ||
| const actorId = resolveActor(actorIn, true); | ||
| // resolveActor registered the actor if it was first-seen, so it exists. | ||
| const actor = store.findActor(actorId); | ||
| if (!actor) | ||
| throw new Error(`unknown actor ${actorId}`); | ||
| reconcile(store, actorId); | ||
@@ -170,7 +207,12 @@ const model = buildModel(store, actorId); | ||
| server.registerTool("claim", { | ||
| description: "Reserve files BEFORE you edit them. Use `path#symbol` (e.g. utils.js#formatPrice) to reserve just one function/class so others can edit other parts of the same file in parallel; use a bare path to reserve the whole file. A denied target is held by another actor — edit something else or coordinate.", | ||
| inputSchema: { paths: z.array(z.string()) }, | ||
| }, async ({ paths }) => { | ||
| const actorId = requireActor(); | ||
| const results = acquireClaims(store, actorId, active?.session?.id ?? null, paths, Date.now()); | ||
| description: "Reserve files BEFORE you edit them. Use `path#symbol` (e.g. utils.js#formatPrice) to reserve just one function/class so others can edit other parts of the same file in parallel; use a bare path to reserve the whole file. Pass a short `intent` (the why) so an actor you block can resolve the collision from it. A denied target is held by another actor — its `holderIntent` tells you what they're doing, so reconcile from that instead of just waiting.", | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| paths: z.array(z.string()), | ||
| intent: z.string().optional().describe("a short why for this claim, e.g. the ticket/task"), | ||
| }, | ||
| }, async ({ actor, paths, intent }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| const sessionId = active?.actorId === actorId ? active?.session?.id ?? null : null; | ||
| const results = acquireClaims(store, actorId, sessionId, paths, Date.now(), intent); | ||
| // Push-awareness at reservation time: tell the agent if anything it just | ||
@@ -182,5 +224,5 @@ // claimed depends on a symbol another actor is currently changing. | ||
| description: "Release your claims on the given paths. Omit `paths` to release ALL of yours; an empty array releases none.", | ||
| inputSchema: { paths: z.array(z.string()).optional() }, | ||
| }, async ({ paths }) => { | ||
| const actorId = requireActor(); | ||
| inputSchema: { actor: actorArg, paths: z.array(z.string()).optional() }, | ||
| }, async ({ actor, paths }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| // Omitting `paths` releases everything; an explicit empty array is a no-op | ||
@@ -191,4 +233,66 @@ // (so a programmatic empty list never accidentally drops all claims). | ||
| }); | ||
| server.registerTool("escalate", { | ||
| description: "Flag a collision you CANNOT reconcile (e.g. two opposed intents on the same line) for a human. Use this instead of forcing a change through when a denied claim's holderIntent conflicts with yours. It shows up under 'Needs you' until resolved.", | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| target: z.string().describe("the clash, e.g. pool.js#maxConnections"), | ||
| reason: z.string().optional().describe("why it needs a human — name the opposed intents"), | ||
| }, | ||
| }, async ({ actor, target, reason }) => { | ||
| const actorId = resolveActor(actor, false) ?? "unknown"; | ||
| const o = recordOutcome(store, "escalated", actorId, target, reason, new Date().toISOString()); | ||
| store.appendLedger({ ts: o.ts, type: "collision.escalated", target: o.target, actorId }); | ||
| return ok({ escalated: o }); | ||
| }); | ||
| server.registerTool("resolve", { | ||
| description: "Mark a collision as sewn/handled after you reconciled it — closes its 'Needs you' flag and records the audit trail. Use after you've merged or adapted so the work accounts for both intents.", | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| target: z.string().describe("the clash that was resolved, e.g. pool.js#maxConnections"), | ||
| note: z.string().optional().describe("what you did to reconcile it"), | ||
| }, | ||
| }, async ({ actor, target, note }) => { | ||
| const actorId = resolveActor(actor, false) ?? "unknown"; | ||
| const o = recordOutcome(store, "resolved", actorId, target, note, new Date().toISOString()); | ||
| store.appendLedger({ ts: o.ts, type: "collision.resolved", target: o.target, actorId }); | ||
| return ok({ resolved: o }); | ||
| }); | ||
| server.registerTool("quilt_edit", { | ||
| description: "Edit a file through Quilt instead of your raw editor. Replaces the unique `old_string` with `new_string` and records WHO authored the change at the moment of the edit — so attribution is exact even when several agents share this checkout, with no claims or reconcile guesswork. Pass `why` (your ticket/task). Prefer this over a plain file edit when coordinating a fleet.", | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| path: z.string().describe("repo-relative file path"), | ||
| old_string: z.string().describe("the exact text to replace (must be unique in the file)"), | ||
| new_string: z.string().describe("the replacement text"), | ||
| why: z.string().optional().describe("a short why for this edit, e.g. the ticket/task"), | ||
| }, | ||
| }, async ({ actor, path, old_string, new_string, why }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| const r = applyAndRecordEdit(store, { actor: actorId, path, oldString: old_string, newString: new_string, intent: why }); | ||
| if (!r.ok) { | ||
| return ok("heldBy" in r | ||
| ? { applied: false, denied: true, heldBy: r.heldBy, holderIntent: r.holderIntent, | ||
| guidance: "Another agent holds this code. Use their intent: if they're already doing your change, drop it; if compatible, edit elsewhere; if genuinely opposed, escalate." } | ||
| : { applied: false, error: r.error }); | ||
| } | ||
| return ok({ applied: true, captured: r.event }); | ||
| }); | ||
| server.registerTool("quilt_write", { | ||
| description: "Write a whole file (create or overwrite) through Quilt, recording you as the author of its contents at write time. Use for new files. Pass `why`.", | ||
| inputSchema: { | ||
| actor: actorArg, | ||
| path: z.string().describe("repo-relative file path"), | ||
| content: z.string().describe("full file contents"), | ||
| why: z.string().optional(), | ||
| }, | ||
| }, async ({ actor, path, content, why }) => { | ||
| const actorId = resolveActor(actor, true); | ||
| const r = applyAndRecordWrite(store, { actor: actorId, path, content, intent: why }); | ||
| if (!r.ok) { | ||
| return ok("heldBy" in r ? { applied: false, denied: true, heldBy: r.heldBy, holderIntent: r.holderIntent } : { applied: false, error: r.error }); | ||
| } | ||
| return ok({ applied: true, captured: r.event }); | ||
| }); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } |
+21
-0
@@ -56,2 +56,23 @@ import { join } from "node:path"; | ||
| } | ||
| /** Collision outcomes: escalations (needs a human) and resolutions (audit). */ | ||
| get outcomes() { | ||
| return join(this.dir, "outcomes.json"); | ||
| } | ||
| /** Append-only authorship log: one event per captured edit (the ledger). */ | ||
| get authorshipLog() { | ||
| return join(this.dir, "authorship.log"); | ||
| } | ||
| /** Compacted fold of old authorship events (line-ownership), so the log stays | ||
| * bounded — reconcile reads this checkpoint plus the un-compacted log tail. */ | ||
| get authorshipCheckpoint() { | ||
| return join(this.dir, "authorship.checkpoint.json"); | ||
| } | ||
| /** Directory for pre→post hook snapshots (the pre-edit file content). */ | ||
| get hookSnapshotsDir() { | ||
| return join(this.dir, "hooks"); | ||
| } | ||
| /** A single pre→post snapshot file, keyed by a hash of actor+path. */ | ||
| hookSnapshot(key) { | ||
| return join(this.hookSnapshotsDir, `${key}.blob`); | ||
| } | ||
| } |
+6
-6
| import pc from "picocolors"; | ||
| import { hunkChangedLines, } from "./engine.js"; | ||
| function plural(n, word) { | ||
@@ -100,12 +99,14 @@ return `${n} ${word}${n === 1 ? "" : "s"}`; | ||
| const actors = new Set(); | ||
| let conflicted = false; | ||
| let contended = false; | ||
| for (const h of file.hunks) { | ||
| for (const a of h.actors) | ||
| actors.add(a); | ||
| if (h.conflicted) | ||
| conflicted = true; | ||
| if (h.overlap === "contended") | ||
| contended = true; | ||
| } | ||
| out.push(` ${file.path} ${fileLineSummary(file)}`); | ||
| out.push(` ${pc.dim("touched by:")} ${[...actors].join(", ") || pc.dim("unknown")}` + | ||
| (conflicted ? pc.red(" status: conflict") : pc.yellow(" status: needs review"))); | ||
| (contended | ||
| ? pc.red(" status: same-line clash — review") | ||
| : pc.dim(" status: adjacent edits — commits cleanly"))); | ||
| } | ||
@@ -159,2 +160,1 @@ out.push(""); | ||
| } | ||
| export { hunkChangedLines }; |
+12
-3
@@ -134,2 +134,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, openSync, closeSync, statSync, rmSync, } from "node:fs"; | ||
| } | ||
| // --- collision outcomes (escalations + resolutions) --- | ||
| readOutcomes() { | ||
| return readJson(this.paths.outcomes, { outcomes: [] }); | ||
| } | ||
| writeOutcomes(file) { | ||
| writeJson(this.paths.outcomes, file); | ||
| } | ||
| // --- ledger --- | ||
@@ -142,5 +149,7 @@ appendLedger(event) { | ||
| * processes can't interleave read-modify-write of ownership/observed state | ||
| * and lose each other's claims. The lock auto-expires after 10s (a crashed | ||
| * process never wedges the repo); after ~5s of contention we proceed anyway | ||
| * rather than block a developer's command indefinitely. | ||
| * and lose each other's claims. We never steal the lock from a *live* holder | ||
| * on a timer; it's reclaimed only when the holding process is gone (pid not | ||
| * alive) or as an absolute backstop when the lockfile is very old (>120s, guards | ||
| * against pid reuse). If a live holder keeps it past ~30s we throw rather than | ||
| * run unlocked and risk corrupting state. | ||
| */ | ||
@@ -147,0 +156,0 @@ withLock(fn) { |
+242
-15
| import { createRequire } from "node:module"; | ||
| import Parser from "web-tree-sitter"; | ||
| const require = createRequire(import.meta.url); | ||
| const isJsFamily = (g) => g === "javascript" || g === "typescript" || g === "tsx"; | ||
| // Extension -> grammar. tree-sitter-javascript also parses JSX; .tsx needs the | ||
@@ -15,2 +16,14 @@ // dedicated tsx grammar (the plain typescript grammar rejects JSX). | ||
| tsx: "tsx", | ||
| py: "python", | ||
| go: "go", | ||
| rs: "rust", | ||
| java: "java", | ||
| rb: "ruby", | ||
| c: "c", | ||
| h: "c", | ||
| cpp: "cpp", | ||
| cc: "cpp", | ||
| cxx: "cpp", | ||
| hpp: "cpp", | ||
| hh: "cpp", | ||
| }; | ||
@@ -115,3 +128,142 @@ /** Loaded parsers, one per grammar, populated by initSymbols(). */ | ||
| } | ||
| // Top-level declaration node types -> kind, for the non-JS grammars. Each grammar | ||
| // names a function/class/type its own way; these are the common, high-value ones. | ||
| const LANG_KINDS = { | ||
| python: { function_definition: "function", class_definition: "class" }, | ||
| go: { function_declaration: "function", method_declaration: "function" }, | ||
| rust: { | ||
| function_item: "function", | ||
| struct_item: "class", | ||
| enum_item: "class", | ||
| trait_item: "class", | ||
| }, | ||
| java: { | ||
| class_declaration: "class", | ||
| interface_declaration: "class", | ||
| enum_declaration: "class", | ||
| record_declaration: "class", | ||
| annotation_type_declaration: "class", | ||
| }, | ||
| ruby: { | ||
| method: "function", | ||
| singleton_method: "function", | ||
| class: "class", | ||
| module: "class", | ||
| }, | ||
| // C/C++ function_definition is handled specially (name lives in the declarator). | ||
| c: { struct_specifier: "class", union_specifier: "class", enum_specifier: "class", type_definition: "value" }, | ||
| cpp: { | ||
| class_specifier: "class", | ||
| struct_specifier: "class", | ||
| union_specifier: "class", | ||
| enum_specifier: "class", | ||
| type_definition: "value", | ||
| }, | ||
| }; | ||
| // Per-grammar call-site shape for the dependency graph (push-awareness): the AST | ||
| // node type for a call and the field holding the callee name. Grammars not listed | ||
| // use the C-family default (`call_expression` / `function`). | ||
| const CALL_SPEC = { | ||
| python: { node: "call", field: "function" }, | ||
| ruby: { node: "call", field: "method" }, | ||
| java: { node: "method_invocation", field: "name" }, | ||
| }; | ||
| /** | ||
| * Extract a C/C++ function name from a `function_definition`. The name lives | ||
| * inside the declarator, possibly wrapped in pointer/reference/parenthesized | ||
| * declarators (`int *foo()`), so dig down to the innermost named declarator. | ||
| */ | ||
| // Step into a declarator's inner declarator. pointer_declarator carries a named | ||
| // `declarator` field; reference_declarator / parenthesized_declarator expose | ||
| // their child only as an unnamed-positional named child, so fall back to that. | ||
| function declaratorChild(d) { | ||
| return d.childForFieldName("declarator") ?? d.namedChildren[0] ?? null; | ||
| } | ||
| function cFunctionName(node) { | ||
| let d = declaratorChild(node); | ||
| while (d && d.type !== "function_declarator") | ||
| d = declaratorChild(d); | ||
| let name = d?.childForFieldName("declarator") ?? null; | ||
| while (name && name.childForFieldName("declarator")) | ||
| name = name.childForFieldName("declarator"); | ||
| if (!name) | ||
| return null; | ||
| // Accept a plain or C++-qualified identifier; reject anything exotic (e.g. a | ||
| // function returning a function pointer leaves a garbled declarator) so it | ||
| // degrades to a whole-file claim rather than an unusable symbol name. | ||
| return /^[A-Za-z_~]\w*(::~?[A-Za-z_]\w*)*$/.test(name.text) ? name.text : null; | ||
| } | ||
| function lineRange(node) { | ||
| return { startLine: node.startPosition.row + 1, endLine: node.endPosition.row + 1 }; | ||
| } | ||
| /** Extract top-level symbols for Python / Go / Rust from one top-level node. */ | ||
| function collectLang(top, out, grammar) { | ||
| // Python: `@deco`-wrapped def/class — unwrap, but keep the decorator span. | ||
| if (grammar === "python" && top.type === "decorated_definition") { | ||
| const inner = top.namedChildren.find((c) => c.type === "function_definition" || c.type === "class_definition"); | ||
| const name = inner?.childForFieldName("name"); | ||
| if (inner && name) { | ||
| out.push({ | ||
| name: name.text, | ||
| kind: inner.type === "class_definition" ? "class" : "function", | ||
| ...lineRange(top), | ||
| }); | ||
| } | ||
| return; | ||
| } | ||
| // Go: `type ( ... )` / `type Foo struct{}` / `type Foo = Bar` wrap one or more | ||
| // type_spec (definitions) or type_alias (aliases). | ||
| if (grammar === "go" && top.type === "type_declaration") { | ||
| for (const spec of top.namedChildren) { | ||
| if (spec.type !== "type_spec" && spec.type !== "type_alias") | ||
| continue; | ||
| const name = spec.childForFieldName("name"); | ||
| if (!name) | ||
| continue; | ||
| const t = spec.childForFieldName("type"); | ||
| const kind = spec.type === "type_spec" && t && (t.type === "struct_type" || t.type === "interface_type") | ||
| ? "class" | ||
| : "value"; | ||
| out.push({ name: name.text, kind, ...lineRange(spec) }); | ||
| } | ||
| return; | ||
| } | ||
| // C / C++ free functions and methods: the name is buried in the declarator. | ||
| if ((grammar === "c" || grammar === "cpp") && top.type === "function_definition") { | ||
| const fname = cFunctionName(top); | ||
| if (fname) | ||
| out.push({ name: fname, kind: "function", ...lineRange(top) }); | ||
| return; | ||
| } | ||
| // C / C++ typedef may name several aliases: `typedef int foo, bar;`. Surface | ||
| // each cleanly-named alias; skip complex declarators (e.g. `typedef int *p`). | ||
| if ((grammar === "c" || grammar === "cpp") && top.type === "type_definition") { | ||
| for (const decl of top.childrenForFieldName("declarator")) { | ||
| if (/^[A-Za-z_]\w*$/.test(decl.text)) { | ||
| out.push({ name: decl.text, kind: "value", ...lineRange(top) }); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| const kind = LANG_KINDS[grammar]?.[top.type]; | ||
| if (!kind) | ||
| return; | ||
| // Most grammars expose the symbol name via the "name" field; C typedefs put it | ||
| // in "declarator" (`typedef struct {...} Foo`). | ||
| const name = top.childForFieldName("name") ?? top.childForFieldName("declarator"); | ||
| if (name && !name.text.includes("\n")) | ||
| out.push({ name: name.text, kind, ...lineRange(top) }); | ||
| } | ||
| /** Top-level symbols of a parsed file, dispatching to the right grammar's rules. */ | ||
| function collectAll(root, grammar) { | ||
| const symbols = []; | ||
| for (const top of root.namedChildren) { | ||
| if (isJsFamily(grammar)) | ||
| collect(top, symbols); | ||
| else | ||
| collectLang(top, symbols, grammar); | ||
| } | ||
| return symbols; | ||
| } | ||
| /** | ||
| * Parse `content` and hand the root node to `fn`, then free the tree. The Tree | ||
@@ -136,3 +288,3 @@ * lives in the wasm heap and is reclaimed only by an explicit delete() (JS GC | ||
| tree = parser.parse(content); | ||
| return fn(tree.rootNode); | ||
| return fn(tree.rootNode, grammar); | ||
| } | ||
@@ -155,10 +307,82 @@ catch { | ||
| export function parseSymbols(path, content) { | ||
| return withTree(path, content, [], (root) => { | ||
| const symbols = []; | ||
| for (const top of root.namedChildren) | ||
| collect(top, symbols); | ||
| return symbols; | ||
| }); | ||
| return withTree(path, content, [], (root, grammar) => collectAll(root, grammar)); | ||
| } | ||
| /** Separates the symbol scope from the line text in an ownership key. NUL can't | ||
| * appear in a source line, so it's an unambiguous delimiter. */ | ||
| export const OWN_KEY_SEP = "\u0000"; | ||
| /** | ||
| * The ownership key for a line: its enclosing symbol scope plus the line text. | ||
| * Keying on `symbol\0text` instead of bare `text` stops identical lines in | ||
| * different symbols (e.g. ` return null;` in two functions) from collapsing to | ||
| * one owner. Top-level lines use an empty scope, so they key by text as before. | ||
| */ | ||
| export function ownKey(symbol, text) { | ||
| return symbol + OWN_KEY_SEP + text; | ||
| } | ||
| /** The line text back out of an ownership key (drops the symbol scope). */ | ||
| export function keyText(key) { | ||
| const i = key.indexOf(OWN_KEY_SEP); | ||
| return i === -1 ? key : key.slice(i + 1); | ||
| } | ||
| /** | ||
| * Build a line-number -> enclosing-symbol lookup for a file's content, so callers | ||
| * can compute ownership keys while walking a diff. Parses once; the returned | ||
| * function maps a 1-based line number to the innermost symbol containing it (a | ||
| * method beats its class — smallest span wins, ties broken by document order), | ||
| * or "" when the line is top-level or the file doesn't parse. Empty content or an | ||
| * unsupported language yields "" for every line, i.e. plain text keying — a safe | ||
| * degrade. | ||
| */ | ||
| export function symbolLocator(path, content) { | ||
| const symbols = parseSymbols(path, content); | ||
| if (symbols.length === 0) | ||
| return () => ""; | ||
| return (line) => { | ||
| let best = ""; | ||
| let bestSpan = Infinity; | ||
| for (const s of symbols) { | ||
| if (line >= s.startLine && line <= s.endLine) { | ||
| const span = s.endLine - s.startLine; // innermost = smallest span | ||
| if (span < bestSpan) { | ||
| best = s.name; | ||
| bestSpan = span; | ||
| } | ||
| } | ||
| } | ||
| return best; | ||
| }; | ||
| } | ||
| /** | ||
| * A stateful helper for walking a line diff and producing each op's ownership | ||
| * key. `addLoc`/`delLoc` are symbol locators for the new and old sides; pass the | ||
| * hunk's `newStart`/`oldStart` (default 1 for a whole-file diff). Call it on | ||
| * EVERY op in order: `eq` advances both cursors and returns null; `add`/`del` | ||
| * return the line's `symbol\0text` key. Added lines scope to the new side (where | ||
| * they live), removed lines to the old side. | ||
| * | ||
| * Consistency note: reconcile keys a removed line from its scope in the | ||
| * last-observed baseline, while commit/undo/fleet key it from HEAD. These match | ||
| * unless the enclosing function was RENAMED between HEAD and the baseline (a rare | ||
| * uncommitted-rename case); if they diverge the removal is treated as unclaimed | ||
| * (benign — never misattributed or lost). Added lines have no such split (every | ||
| * reader scopes them from the shared working tree). | ||
| */ | ||
| export function opKeyer(addLoc, delLoc, newStart = 1, oldStart = 1) { | ||
| let newLine = newStart - 1; | ||
| let oldLine = oldStart - 1; | ||
| return (op) => { | ||
| if (op.type === "eq") { | ||
| newLine++; | ||
| oldLine++; | ||
| return null; | ||
| } | ||
| if (op.type === "add") { | ||
| newLine++; | ||
| return ownKey(addLoc(newLine), op.text); | ||
| } | ||
| oldLine++; | ||
| return ownKey(delLoc(oldLine), op.text); | ||
| }; | ||
| } | ||
| /** | ||
| * Dependency graph: maps each top-level symbol name to the set of names it | ||
@@ -173,6 +397,4 @@ * references in its body — function-call callees and type references. Targets | ||
| export function symbolReferences(path, content) { | ||
| return withTree(path, content, new Map(), (root) => { | ||
| const symbols = []; | ||
| for (const top of root.namedChildren) | ||
| collect(top, symbols); | ||
| return withTree(path, content, new Map(), (root, grammar) => { | ||
| const symbols = collectAll(root, grammar); | ||
| const refs = new Map(); | ||
@@ -191,5 +413,10 @@ // Find the top-level symbol whose line range encloses a given 1-based row. | ||
| }; | ||
| // Call-expression callees: `foo(...)` -> reference to `foo`. | ||
| for (const call of root.descendantsOfType("call_expression")) { | ||
| const callee = call.childForFieldName("function"); | ||
| // Call callees: `foo(...)` -> reference to `foo`. Each grammar names the call | ||
| // node and the callee field differently, so dispatch on both. | ||
| const { node: callNode, field: calleeField } = CALL_SPEC[grammar] ?? { | ||
| node: "call_expression", | ||
| field: "function", | ||
| }; | ||
| for (const call of root.descendantsOfType(callNode)) { | ||
| const callee = call.childForFieldName(calleeField); | ||
| if (callee && callee.type === "identifier") { | ||
@@ -199,3 +426,3 @@ add(enclosing(callee.startPosition.row + 1), callee.text); | ||
| } | ||
| // Type references: `: Foo`, `extends Foo`, etc. | ||
| // Type references: `: Foo`, `extends Foo`, Rust `type_identifier`, etc. | ||
| for (const t of root.descendantsOfType("type_identifier")) { | ||
@@ -202,0 +429,0 @@ add(enclosing(t.startPosition.row + 1), t.text); |
+1
-1
@@ -82,3 +82,3 @@ import { existsSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; | ||
| printed.add(c.id); | ||
| process.stdout.write(pc.red(" ⚠ collision ") + | ||
| process.stdout.write(pc.red(" ⚠ overwrite ") + | ||
| `${pc.bold(c.byActor)} overwrote ${pc.bold(c.victimActor)}'s edits in ${c.path}. ` + | ||
@@ -85,0 +85,0 @@ pc.dim(`both saved, run: quilt restore ${c.path}\n`)); |
+1
-1
| { | ||
| "name": "@quilt-dev/cli", | ||
| "version": "0.1.0", | ||
| "version": "0.3.0", | ||
| "description": "Actor-owned patches for Git. Same repo. Many agents. Clean commits.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+121
-25
@@ -11,2 +11,8 @@ # Quilt | ||
|  | ||
| Same edits, two agents, one repo. Plain git vs Quilt: | ||
|  | ||
| Everyone else ran *toward* isolation: a worktree per agent, a branch per agent, | ||
@@ -18,8 +24,8 @@ reconcile at PR time. That trades one mess for another. You get `node_modules`, | ||
| The bet is that parallelism comes from **coordination and visibility**, not | ||
| isolation and blindness. Agents claim the symbols they're about to touch, see | ||
| each other's uncommitted work, get a heads-up when a function they depend on is | ||
| changing, and each commits only its own hunks. Quilt is a cooperative protocol, | ||
| like Git itself, and it keeps Git as the source of truth: every commit it makes | ||
| is an ordinary Git commit. | ||
| Parallelism comes from **coordination and visibility**, not isolation and | ||
| blindness. Agents claim the code they're about to touch, see each other's | ||
| uncommitted work, get a heads-up when something they depend on is changing, and | ||
| each commits only its own changes. Quilt is a cooperative protocol, like Git | ||
| itself, and it keeps Git as the source of truth: every commit it makes is an | ||
| ordinary Git commit. | ||
@@ -47,3 +53,4 @@ ### See it in 20 seconds | ||
| agents editing different functions never contend. Powered by tree-sitter | ||
| (JS/TS/TSX today), with whole-file claims for everything else. | ||
| (JavaScript, TypeScript, JSX/TSX, Python, Go, Rust, Java, Ruby, C, C++), with whole-file claims for | ||
| everything else. | ||
| - **Push-awareness.** When you claim a symbol that depends on a function another | ||
@@ -54,4 +61,12 @@ actor is changing, Quilt warns you at claim time so the cascade is never a | ||
| `commit --mine` commits only yours even when they share a hunk. | ||
| - **Conflict surfacing.** Overlapping edits are flagged, not silently committed; | ||
| pre-existing or generated changes stay unattributed. | ||
| - **Conflict surfacing.** Overlapping edits are flagged, not silently committed, | ||
| and Quilt tells a real same-line clash apart from two agents working on | ||
| different lines that merely share a hunk — so the alarm means something. | ||
| Pre-existing or generated changes stay unattributed. | ||
| - **Self-sewing collisions.** A claim carries a short `intent`; a blocked agent | ||
| gets the holder's intent and resolves most collisions itself — dropping a | ||
| redundant change or adapting. Only genuinely opposed work is `escalate`d to a | ||
| human. `quilt fleet` splits it into **Needs you** and **Sewn by agents**. Quilt | ||
| never calls an LLM — it hands your agents the context and records what they | ||
| decide. | ||
| - **Preview-first `commit --mine`.** See the exact patch before anything moves. | ||
@@ -70,2 +85,29 @@ - **Preserves other actors' work.** Committing yours leaves everyone else's | ||
| ## Why not worktrees? | ||
| A worktree (or branch, or clone) per agent is the usual answer, and for fully | ||
| independent tasks it works. But isolation has costs that grow with the number of | ||
| agents: | ||
| - **Setup tax.** Every worktree needs its own install, build, and environment — | ||
| `node_modules`, `.env`, build caches — duplicated N times. | ||
| - **Blindness.** Agents can't see each other's uncommitted work, so they find | ||
| out they collided or broke a shared dependency at merge time, after the work | ||
| is already done. | ||
| - **Merge tax.** Reconciliation happens at the end, when the branches have | ||
| diverged the most. | ||
| The deeper issue is that worktrees isolate; they don't coordinate. When agents | ||
| are working in the same codebase, you usually want the opposite — for them to | ||
| see each other and account for each other as they go. Quilt keeps everyone in | ||
| one checkout and coordinates continuously: claims stop collisions before they | ||
| happen, shared visibility lets an agent adapt to what another is doing, and | ||
| `commit --mine` keeps each actor's history clean without a checkout per agent. | ||
| Worktrees still make sense for genuinely independent, long-running work, or when | ||
| you want hard OS-level isolation. Quilt is for agents working the same code at | ||
| the same time. The two aren't mutually exclusive. | ||
| --- | ||
| ## Install | ||
@@ -128,7 +170,12 @@ | ||
| | `quilt init` | Initialize `.quilt/` in the repo. | | ||
| | `quilt setup [--dry-run]` | Wire Quilt into the repo's orchestrator: add the shared MCP server to `.mcp.json`, the coordination snippet to `CLAUDE.md`, and the native-edit capture hooks to `.claude/settings.json` (idempotent). | | ||
| | `quilt start --actor <id> [--type human\|agent\|bot] [--name <n>] [--email <e>]` | Start a session for an actor. | | ||
| | `quilt watch` | Watch the tree: attribute edits live and catch collisions. | | ||
| | `quilt fleet [--json] [--watch]` | Mission control: every actor, their claims, overlaps, and collisions in one view. | | ||
| | `quilt status [--json]` | Show who owns which working-tree changes. | | ||
| | `quilt mine [--json]` | Summarize the changes you own. | | ||
| | `quilt conflicts [--json]` | Show overlapping/shared changes. | | ||
| | `quilt conflicts [--json]` | Show shared changes: same-line clashes vs adjacent edits that commit cleanly. | | ||
| | `quilt undo <actor> [--dry-run]` | Back out one actor's uncommitted changes, leaving everyone else's untouched. | | ||
| | `quilt escalate <target> [--reason]` | Flag a collision agents can't reconcile for a human (shows under "Needs you"). | | ||
| | `quilt resolve <target> [--note]` | Mark a collision sewn/handled — clears its "Needs you" flag, records the trail. | | ||
| | `quilt restore [path] [--json]` | List or recover work overwritten by another actor. | | ||
@@ -140,2 +187,3 @@ | `quilt preview --mine [--json] [--include-unclaimed]` | Print the exact patch `commit --mine` would create. | | ||
| | `quilt mcp` | Run the MCP server (stdio) for agent integration. | | ||
| | `quilt doctor [--json]` | Health check: is Quilt wired, is identity set, and is capture actually flowing? | | ||
| | `quilt whoami` | Show the active actor/session. | | ||
@@ -185,3 +233,3 @@ | `quilt end` | End the active session. | | ||
| ```jsonc | ||
| // .mcp.json (or your agent's MCP config) | ||
| // .mcp.json — single-agent form: one server pinned to one identity. | ||
| { | ||
@@ -194,16 +242,28 @@ "mcpServers": { | ||
| For a **fleet**, drop the `env` and run one shared server — each subagent passes | ||
| its own `actor` per call instead, so there's no single identity to clobber (see | ||
| [docs/orchestrators.md](docs/orchestrators.md)). `quilt setup` wires this for you. | ||
| Tools: `start_session`, `get_status`, `get_my_changes`, `get_conflicts`, | ||
| `preview_mine`, `commit_mine`, `claim`, `release`. The intended loop: | ||
| `preview_mine`, `commit_mine`, `claim`, `release`, `escalate`, `resolve`, and | ||
| `quilt_edit` / `quilt_write` (the capture-and-prevent edit tools — the fallback | ||
| for runtimes without the native-edit hooks). The intended loop: | ||
| ```txt | ||
| # fleet (per-call actor — no session needed): | ||
| claim(actor, symbols) → …edit… → commit_mine(actor) | ||
| # single agent (pinned session): | ||
| start_session → get_status → claim(symbols) → …edit… → commit_mine | ||
| ``` | ||
| `claim` adds **advisory prevention** on top of detect-and-preserve: a symbol | ||
| already claimed by another actor is denied, so a well-behaved agent edits | ||
| something else. The `claim` and `get_conflicts` responses also carry | ||
| A fleet of subagents shares one server and passes its own `actor` on each call, | ||
| so no `start_session` is needed — an id registers on first use. `claim` adds | ||
| **advisory prevention** on top of detect-and-preserve: a symbol already claimed | ||
| by another actor is denied, so a well-behaved agent edits something else. The | ||
| `claim`, `get_status`, and `get_conflicts` responses all carry | ||
| **`dependencyWarnings`**: push-awareness, so the moment an agent reserves a | ||
| symbol it learns whether a function it depends on is being changed by someone | ||
| else (see below). An agent that skips claiming but still drives Quilt as itself | ||
| is still caught by collision detection. | ||
| symbol — or just orients with `get_status` — it learns whether a function it | ||
| depends on is being changed by someone else (see below). An agent that skips | ||
| claiming but still drives Quilt as itself is still caught by collision detection. | ||
@@ -217,2 +277,13 @@ Quilt is a **cooperative protocol**, like Git: it coordinates the agents that | ||
| **Running a fleet of subagents?** Run `quilt setup` — it detects your | ||
| orchestrator and wires in the shared MCP server, the coordination snippet, and | ||
| the native-edit capture hooks in one step. On Claude Code the hooks let agents | ||
| use the built-in `Edit`/`Write` tools normally while Quilt records each change's | ||
| author and blocks a write into code another agent holds — no protocol to follow | ||
| (each agent just carries its own id in `QUILT_ACTOR`). One shared `quilt mcp` | ||
| server attributes the whole fleet the explicit way too — each subagent passes its | ||
| own `actor` per call, so there's no single identity to clobber. See | ||
| [docs/orchestrators.md](docs/orchestrators.md) for the details and the Codex / | ||
| Cursor / Aider variants. | ||
| ## Push-awareness: dependents hear about changes | ||
@@ -281,8 +352,24 @@ | ||
| - Attribution keys on **line content** (blank lines and lone braces/punctuation | ||
| are ignored so they don't false-conflict). Two actors adding the same | ||
| *substantive* line in different places can still be flagged as overlapping; | ||
| conservative by design. | ||
| - Symbol parsing covers **JS/TS/TSX** (tree-sitter). Other languages fall back to | ||
| whole-file claims and line-level attribution. | ||
| Quilt is designed to fail **safe**: every limitation below degrades to | ||
| best-effort attribution or a surfaced warning — it never silently loses or | ||
| corrupts your work. | ||
| - **Capture is best-effort, and it can go quiet.** The native-edit hooks fail | ||
| open (they must never block an agent's edit), so if capture stops — an agent | ||
| without a `QUILT_ACTOR`, a Claude Code update that changes the hook payload, an | ||
| unparseable file — edits fall back to Quilt's line-inference instead of the | ||
| precise ledger. Nothing is lost, but attribution gets coarser. Run | ||
| **`quilt doctor`** to confirm capture is actually flowing (it reports how many | ||
| edits have been recorded). | ||
| - **Identity is per-process.** The hooks attribute an edit to the `QUILT_ACTOR` | ||
| of the process that made it. Give each agent its own; several sub-agents inside | ||
| **one** process share one id, so for that topology use the MCP `quilt_edit` / | ||
| per-call-`actor` path instead of the hooks. | ||
| - **Attribution keys on symbol + line content** (blank lines and lone | ||
| braces/punctuation are ignored so they don't false-conflict). Identical lines in | ||
| *different* functions are kept distinct; two identical lines in the *same* | ||
| function can still collapse — rare, and conservative by design. | ||
| - Symbol parsing covers **JavaScript, TypeScript, JSX/TSX, Python, Go, Rust, Java, Ruby, C, and C++** | ||
| (tree-sitter). Other languages fall back to whole-file claims and line-level | ||
| attribution. | ||
| - Push-awareness is **advisory and name-based**; a cross-file reference to a | ||
@@ -293,3 +380,9 @@ same-named symbol can false-positive. Import resolution is a future refinement. | ||
| - POSIX-first. CRLF / `core.autocrlf` repos on Windows aren't handled yet. | ||
| - The fleet/status view reflects the **last reconcile**, not live state, unless | ||
| `quilt watch` is running. | ||
| **On the roadmap:** import-resolution for push-awareness; pruning of the older | ||
| state logs (the authorship log already compacts; `ledger.jsonl` and preserved | ||
| clobber snapshots still grow); more languages; Windows/CRLF support. | ||
| --- | ||
@@ -310,3 +403,6 @@ | ||
| watcher.pid # pidfile for a running `quilt watch` | ||
| ledger.jsonl # append-only event log | ||
| ledger.jsonl # append-only event log (sessions, claims, clobbers, …) | ||
| authorship.log # captured edits — who authored which lines (the ledger) | ||
| authorship.checkpoint.json # compacted fold of old authorship events | ||
| hooks/ # pre→post hook snapshots (pre-edit file content) | ||
| ``` | ||
@@ -313,0 +409,0 @@ |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
233694
88.99%26
36.84%5015
81.64%415
30.09%19
46.15%