Sign In

@quilt-dev/cli

Package Overview
Dependencies
Maintainers
1
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@quilt-dev/cli - npm Package Compare versions

Comparing version
0.1.0
to
0.3.0
+383
dist/authorship.js
// 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);
}
// `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 };
}
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";
}
// 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 });
}
// 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();
}
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) {

@@ -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

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;
}

@@ -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

@@ -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 };
}

@@ -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);
}

@@ -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`);
}
}
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 };

@@ -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) {

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);

@@ -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`));

{
"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

![Two coding agents share one checkout: they claim different functions without contending, one gets a heads-up that something it depends on is changing, and each commits only its own work.](examples/demo.gif)
Same edits, two agents, one repo. Plain git vs Quilt:
![Without Quilt, the first agent's commit absorbs the other's work and the history credits one author; with Quilt, each change is committed by the agent that made it.](examples/contrast.gif)
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 @@