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.5.2
to
0.6.0
+247
dist/bashcapture.js
// Bash-write capture: attribution for edits made through the Bash tool.
//
// The Edit/Write hooks capture native tool edits, and the Codex hooks capture
// apply_patch envelopes — but agents also write files with heredocs, sed,
// patch, and codegen scripts, and none of that crosses either boundary. Those
// writes stayed unattributed, which starved everything downstream that keys on
// attribution: `commit --mine` had nothing to commit, the raw-git guard's
// dirty-actor census undercounted, and busy multi-agent days could record
// nothing at all.
//
// This closes the gap at the same boundary the guard already hooks: snapshot
// on PreToolUse(Bash), diff on PostToolUse(Bash), attribute the delta to the
// hook's actor. A bash command declares no file list (unlike a patch
// envelope), so discovery diffs the git-dirty set around the call:
//
// baseline (Pre) = dirty paths + content hash, plus pre-content per file
// changed (Post) = paths whose content differs from baseline, plus paths
// that entered or left the dirty set
// before-image = baseline content if the file was dirty, else its HEAD
// blob, else "" for a brand-new file
//
// The delta is INFERRED — Post reads the worktree, so a sibling actor's write
// landing between this call's Pre and Post can ride into this actor's delta
// (the same documented trade-off as Codex capture). Events carry
// `mode: "bash"` so provenance can distinguish inferred capture from
// replay-verified native capture. Everything fails open, and capture skips
// (loudly, via a ledger event) when the pre-call dirty set exceeds the cap.
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { resolve, sep } from "node:path";
import { readAuthorship, readCheckpoint, recordAuthorship, safeAbs } from "./authorship.js";
import { refreshClaims } from "./claims.js";
import { looksBinary } from "./diff.js";
import { changedPaths, headBlob } from "./git.js";
/** Skip capture beyond these pre-call dirty-set bounds. Generous on purpose:
* agents' trees run dirty mid-wave, and a skipped capture is a dark spot the
* census can't see. Skips are recorded so doctor can surface a pattern. */
export const BASH_CAPTURE_MAX_FILES = 200;
export const BASH_CAPTURE_MAX_BYTES = 5 * 1024 * 1024;
function sha(text) {
return createHash("sha256").update(text).digest("hex");
}
/** Read a repo file without following symlinks or escaping the root. */
function readSafe(repoRoot, rel) {
const root = resolve(repoRoot);
const abs = resolve(root, rel);
if (abs !== root && !abs.startsWith(root + sep))
return null;
try {
const st = lstatSync(abs);
if (st.isSymbolicLink() || !st.isFile())
return null;
return readFileSync(abs, "utf8");
}
catch {
return null;
}
}
/** The manifest lives beside the Edit hooks' pre-images, keyed per actor and
* invocation so interleaved calls (or actors) never consume each other's. */
export function baselinePath(store, actor, invocationId) {
const key = createHash("sha256").update(`${actor}bash${invocationId}`).digest("hex").slice(0, 32);
return store.paths.hookSnapshot(`bash-${key}`);
}
/** Manifests whose Post never fired (denied tool, crashed call, session kill)
* would otherwise accumulate forever; sweep anything older than an hour. */
const BASELINE_TTL_MS = 60 * 60 * 1000;
export function sweepStaleBaselines(store) {
try {
const dir = store.paths.hookSnapshotsDir;
if (!existsSync(dir))
return;
const cutoff = Date.now() - BASELINE_TTL_MS;
for (const name of readdirSync(dir)) {
if (!name.startsWith("bash-"))
continue;
try {
if (statSync(`${dir}/${name}`).mtimeMs < cutoff)
rmSync(`${dir}/${name}`, { force: true });
}
catch {
/* another hook may have consumed it */
}
}
}
catch {
/* sweeping is best-effort */
}
}
/**
* Record the pre-call baseline. Called from the Bash PreToolUse hook AFTER the
* guard has decided to allow the command (a denied command has no Post and
* needs no baseline). Returns the baseline for tests; callers ignore it.
*/
export function writeBashBaseline(store, actor, invocationId) {
const repoRoot = store.paths.repoRoot;
const manifest = baselinePath(store, actor, invocationId);
// First call wins on a key collision. Keys collide only under the
// no-tool_use_id fallback (a content hash of the command), where the same
// actor overlapping the IDENTICAL command would otherwise overwrite the
// first call's baseline — its Post would then diff against the wrong
// reference state. Keeping the older baseline leaves the first call's
// capture exact; the second call's writes go dark instead of wrong.
if (existsSync(manifest)) {
try {
return JSON.parse(readFileSync(manifest, "utf8"));
}
catch {
/* unreadable — fall through and rewrite */
}
}
const dirty = changedPaths(repoRoot);
// The authorship sequence at Pre: the Post side's shadow guard needs to know
// which events were recorded DURING the call.
let preSeq;
try {
preSeq = readCheckpoint(store).count + readAuthorship(store).length;
}
catch {
preSeq = undefined; // corrupt checkpoint: capture still works, minus the guard
}
let baseline;
if (dirty.length > BASH_CAPTURE_MAX_FILES) {
baseline = { skipped: `${dirty.length} dirty files exceeds the ${BASH_CAPTURE_MAX_FILES}-file capture cap`, preSeq, files: {} };
}
else {
const files = {};
let bytes = 0;
let capped = false;
for (const path of dirty) {
const content = readSafe(repoRoot, path);
if (content === null) {
// Deleted, unreadable, or a symlink. The sentinel is not a sha256
// output, so no real file content can hash-collide with it.
files[path] = { hash: "missing" };
continue;
}
bytes += content.length;
if (bytes > BASH_CAPTURE_MAX_BYTES) {
capped = true;
break;
}
const st = lstatSafe(repoRoot, path);
const meta = st ? { mtimeMs: st.mtimeMs, size: st.size } : {};
files[path] = looksBinary(content) ? { hash: sha(content), ...meta } : { hash: sha(content), content, ...meta };
}
baseline = capped
? { skipped: `dirty content exceeds the ${Math.round(BASH_CAPTURE_MAX_BYTES / 1024 / 1024)}MB capture cap`, preSeq, files: {} }
: { preSeq, files };
}
try {
mkdirSync(store.paths.hookSnapshotsDir, { recursive: true });
writeFileSync(manifest, JSON.stringify(baseline));
}
catch {
/* fail-open: no baseline, Post no-ops */
}
return baseline;
}
/** lstat a repo file with the same symlink/escape discipline as readSafe. */
function lstatSafe(repoRoot, rel) {
const root = resolve(repoRoot);
const abs = resolve(root, rel);
if (abs !== root && !abs.startsWith(root + sep))
return null;
try {
const st = lstatSync(abs);
return st.isFile() && !st.isSymbolicLink() ? { mtimeMs: st.mtimeMs, size: st.size } : null;
}
catch {
return null;
}
}
/**
* Diff the worktree against the pre-call baseline and attribute what changed.
* Called from the Bash PostToolUse hook. Consumes the baseline.
*/
export function captureBashDelta(store, actor, invocationId) {
const p = baselinePath(store, actor, invocationId);
if (!existsSync(p))
return { captured: [] }; // Pre didn't run or already consumed
let baseline;
try {
baseline = JSON.parse(readFileSync(p, "utf8"));
}
catch {
rmSync(p, { force: true });
return { captured: [] };
}
rmSync(p, { force: true });
sweepStaleBaselines(store); // off the Pre hot path; Post already pays for a diff
if (baseline.skipped)
return { captured: [], skipped: baseline.skipped };
const repoRoot = store.paths.repoRoot;
// Shadow guard: any path attributed by ANOTHER capture boundary during this
// call (a sibling's native Edit, a Codex patch) already has an exact event.
// Recording an inferred bash event on top would land LATER in the log, and
// the ownership fold is latest-wins — the exact attribution would be
// silently shadowed by the guess. Skip those paths: a conservative dark
// spot beats a wrong owner.
const recentlyCaptured = new Set();
if (baseline.preSeq !== undefined) {
try {
for (const ev of readAuthorship(store)) {
if (ev.seq >= baseline.preSeq)
recentlyCaptured.add(ev.path);
}
}
catch {
/* corrupt log tail: proceed without the guard */
}
}
const candidates = new Set([...changedPaths(repoRoot), ...Object.keys(baseline.files)]);
const captured = [];
for (const path of candidates) {
if (recentlyCaptured.has(path))
continue;
if (!safeAbs(repoRoot, path))
continue;
const pre = baseline.files[path];
// Cheap unchanged-check first: same mtime and size as Pre means the call
// never touched the file — skip without reading its content. A no-op
// command over a big dirty tree costs one lstat per file, not one read.
if (pre && pre.mtimeMs !== undefined) {
const st = lstatSafe(repoRoot, path);
if (st && st.mtimeMs === pre.mtimeMs && st.size === pre.size)
continue;
}
const now = readSafe(repoRoot, path);
if (now !== null && looksBinary(now))
continue; // line attribution can't express it
if (pre && pre.content === undefined && pre.hash !== "missing")
continue; // was binary before
const after = now ?? "";
// Before-image ladder: pre-call snapshot for a file that was already
// dirty, HEAD blob for one that was clean, empty for a brand-new file.
const before = pre?.content ?? headBlob(repoRoot, path) ?? "";
if (after === before)
continue; // untouched by this call
if (pre && now !== null && pre.hash === sha(now))
continue; // dirty but unchanged
recordAuthorship(store, { actor, path, oldText: before, newText: after, mode: "bash" });
refreshClaims(store, actor, path, Date.now());
captured.push(path);
}
return { captured: captured.sort() };
}
// Sanity-check granted symbol claims against the file's actual symbols, so a
// typo'd target (`utils.js#formatPirce`) doesn't silently reserve nothing —
// the claimant walks away believing the real function is protected when no
// edit-time check will ever match it.
//
// Warnings, not denials: claiming a symbol you are ABOUT to add is a
// legitimate move (reserve the name before writing the function), so a missing
// symbol can't be an error. The warning tells the actor what Quilt can see and
// suggests a near-miss when one exists.
import { existsSync, readFileSync } from "node:fs";
import { safeAbs } from "./authorship.js";
import { canParse, parseSymbols } from "./symbols.js";
/**
* Warnings for granted symbol claims whose symbol isn't in the file. Quiet for
* whole-file claims, denied claims, files that don't exist yet (creating a file
* is exactly when you'd pre-claim its symbols), and languages Quilt can't parse
* (no symbol list to check against — the claim still works whole-file-wise).
*/
export function verifyClaimTargets(store, results) {
const warnings = [];
for (const r of results) {
if (!r.granted || !r.symbol)
continue;
const abs = safeAbs(store.paths.repoRoot, r.path);
if (!abs || !existsSync(abs) || !canParse(r.path))
continue;
let names;
try {
names = parseSymbols(r.path, readFileSync(abs, "utf8")).map((s) => s.name);
}
catch {
continue; // unreadable/unparseable — nothing to check against
}
if (names.includes(r.symbol))
continue;
const near = closest(r.symbol, names);
warnings.push({
target: `${r.path}#${r.symbol}`,
message: `symbol "${r.symbol}" not found in ${r.path}` +
(near ? ` — did you mean "${near}"?` : "") +
` (claim granted anyway — fine if you're about to add it)`,
});
}
return warnings;
}
/** The nearest existing symbol within a small edit distance, or null. */
function closest(target, names) {
let best = null;
let bestDist = 3; // only suggest genuinely-close names (distance <= 2)
for (const n of names) {
const d = editDistance(target.toLowerCase(), n.toLowerCase(), bestDist);
if (d < bestDist) {
bestDist = d;
best = n;
}
}
return best;
}
/** Levenshtein distance, capped: returns `cap` when the true distance is >= cap. */
function editDistance(a, b, cap) {
if (Math.abs(a.length - b.length) >= cap)
return cap;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
const d = Math.min((prev[j] ?? cap) + 1, (cur[j - 1] ?? cap) + 1, (prev[j - 1] ?? cap) + (a[i - 1] === b[j - 1] ? 0 : 1));
cur[j] = d;
rowMin = Math.min(rowMin, d);
}
if (rowMin >= cap)
return cap;
prev = cur;
}
return Math.min(prev[b.length] ?? cap, cap);
}
// Git interception: the shared `.git/index` is process-global mutable state, so
// raw `git add` / `git commit` / `git reset` from one actor operates on every
// other actor's staging area. With one actor that's harmless; with several it
// is how staged work gets committed under the wrong message or silently
// destroyed. This module guards the raw-git path:
//
// - `quilt hook-bash` (PreToolUse, matcher `Bash`) classifies the command an
// agent is about to run. Index-mutating git is denied ONLY when 2+ actors
// have dirty attributed work — a solo actor's raw git can hurt nobody else.
// - `quilt git -- <args>` is the deliberate escape hatch: it records a ledger
// event, snapshots the index when the command can destroy it, then runs
// real git verbatim.
// - `quilt hook-git-pre-commit` (installed to `.git/hooks/pre-commit`) is the
// backstop for commits that never crossed an agent hook: it refuses a
// staged set spanning 2+ actors' lines (the `git add -A` sweep). It cannot
// catch a commit of a SINGLE actor's staged tree made by someone else —
// raw git carries no committer identity — which is why the Bash hook, where
// identity exists, is the primary guard.
//
// Everything here fails open: a broken guard must never brick a shell or a
// commit. Denials are loud; failures are silent allows.
import { createHash } from "node:crypto";
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { foldedAuthorship } from "./authorship.js";
import { changedPaths, git } from "./git.js";
function str(v) {
return typeof v === "string" ? v : null;
}
/** Normalize a raw Claude Code hook JSON for tool_name "Bash". Null when the
* payload is any other tool or carries no command. */
export function parseBashHookInput(raw) {
if (typeof raw !== "object" || raw === null)
return null;
const o = raw;
if (str(o.tool_name) !== "Bash")
return null;
const input = (o.tool_input ?? {});
const command = str(input.command);
if (!command)
return null;
const invocationId = str(o.tool_use_id) ??
str(o.tool_call_id) ??
str(o.hook_event_id) ??
createHash("sha256").update(command).digest("hex").slice(0, 16);
return {
command,
cwd: str(o.cwd),
sessionId: str(o.session_id),
agentId: str(o.agent_id),
agentType: str(o.agent_type),
invocationId,
};
}
// ---------------------------------------------------------------------------
// Command classification
// ---------------------------------------------------------------------------
/** Shell operators that end one simple command and start another. */
const OPERATORS = new Set(["&&", "||", ";", "|", "&", "\n"]);
/**
* Split a shell command line into simple-command token lists, honoring single
* and double quotes so `echo "git add"` is one token, not a git invocation.
* This is a classifier, not a shell: backticks, `$(...)`, and escapes inside
* words are kept as literal token text. That errs toward seeing MORE git than
* the shell would run (a `git add` inside `$()` still classifies), which for a
* guard is the safe direction.
*/
export function shellSegments(command) {
const segments = [];
let tokens = [];
let word = "";
let quote = null;
const endWord = () => {
if (word !== "")
tokens.push(word);
word = "";
};
const endSegment = () => {
endWord();
if (tokens.length > 0)
segments.push(tokens);
tokens = [];
};
for (let i = 0; i < command.length; i++) {
const c = command[i];
if (quote) {
if (c === "\\" && quote === '"' && i + 1 < command.length) {
// Inside double quotes, backslash-newline is a line continuation and
// emits NOTHING (POSIX); any other escape emits the escaped character.
if (command[i + 1] !== "\n")
word += command[i + 1];
i++;
}
else if (c === quote) {
quote = null;
}
else {
word += c;
}
continue;
}
if (c === "'" || c === '"') {
quote = c;
// A quote opening mid-word (or an empty '' / "") still ends up in the
// same token; the empty string must still count as a word.
if (word === "")
word = "\0EMPTY\0";
continue;
}
if (c === "\\" && i + 1 < command.length) {
// Backslash-newline is a line continuation: elide both characters, so
// `git \<newline> commit` still tokenizes as ["git", "commit"]. Routing
// it through the generic escape would glue the newline into the token
// and hide the subcommand from classification.
if (command[i + 1] === "\n") {
i++;
continue;
}
word += command[++i];
continue;
}
if (c === "\n" || c === ";" || c === "&" || c === "|") {
// Coalesce && and || into one operator; either way the segment ends.
endSegment();
if ((c === "&" || c === "|") && command[i + 1] === c)
i++;
continue;
}
if (c === "(" || c === ")" || c === "`") {
// Subshell and backtick-substitution delimiters: treat as segment
// boundaries so `(git add .)` and `` `git add .` `` still classify.
endSegment();
continue;
}
if (c === " " || c === "\t") {
endWord();
continue;
}
word += c;
}
endSegment();
// Restore explicit empty-string words.
return segments.map((seg) => seg.map((t) => t.replace(/\0EMPTY\0/g, "")));
}
/** git global options that take a separate argument value. */
const GIT_GLOBAL_OPTS_WITH_ARG = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
/** Subcommands that always write the shared index. */
const ALWAYS_MUTATING = new Set(["add", "commit", "reset", "rm", "mv", "update-index", "read-tree"]);
/** Subcommands whose allowed form can still destroy staged state. */
const INDEX_DESTROYING = new Set(["reset", "read-tree"]);
/** stash subcommands that only read stash state. */
const STASH_READ_ONLY = new Set(["list", "show"]);
/** stash subcommands that delete stash entries without touching the index. */
const STASH_DROPPING = new Set(["drop", "clear"]);
/**
* Classify one simple command's tokens. Returns the mutation when this is an
* index-mutating git invocation, null otherwise (not git, or read-only git).
*
* Scope is deliberately the index-mutating set: add, commit, reset, stash,
* rm, mv, update-index, read-tree, `restore --staged`, `apply --cached|--index`,
* and `checkout` only in its pathspec form (`checkout -- <path>` writes index
* and worktree; branch switching is a coordination event, not an index race,
* and stays out of scope).
*/
export function classifyTokens(tokens) {
let i = 0;
// Skip leading env assignments (FOO=bar git ...) and benign wrappers.
while (i < tokens.length) {
const t = tokens[i] ?? "";
if (!(/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || t === "env" || t === "command" || t === "nohup" || t === "time" || t === "sudo"))
break;
i++;
}
const head = tokens[i];
if (head === undefined)
return null;
const base = head.replace(/\\/g, "/").split("/").pop() ?? head;
// A shell wrapper carrying a command string: `sh -c "git add ."`. The whole
// quoted command is one token; classify it recursively so wrapping git in a
// subshell doesn't slip past the guard.
if (base === "sh" || base === "bash" || base === "zsh" || base === "dash") {
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j] ?? "";
if (/^-[a-zA-Z]*c$/.test(t)) {
const inner = tokens[j + 1];
return inner === undefined ? null : classifyCommand(inner);
}
if (!t.startsWith("-"))
break; // a script path, not a -c string
}
return null;
}
// `xargs [flags] git ...`: skip xargs and its leading flags, classify what
// it would run.
if (base === "xargs") {
let j = i + 1;
while (j < tokens.length && (tokens[j] ?? "").startsWith("-"))
j++;
return classifyTokens(tokens.slice(j));
}
if (base !== "git")
return null;
i++;
// Skip git's global options to find the subcommand.
while (i < tokens.length) {
const t = tokens[i] ?? "";
if (!t.startsWith("-"))
break;
if (GIT_GLOBAL_OPTS_WITH_ARG.has(t))
i += 2;
else
i += 1; // -P, --no-pager, --git-dir=x, -c k=v (inline forms)
}
const sub = tokens[i];
if (sub === undefined)
return null;
const rest = tokens.slice(i + 1);
if (sub === "stash") {
// Granular: `stash list`/`show` only read; `drop`/`clear` delete stash
// entries (another actor's parked work) without touching the index;
// everything else (push/pop/apply/save/branch/bare) rewrites the index.
const stashSub = rest[0] ?? "";
if (STASH_READ_ONLY.has(stashSub))
return null;
if (STASH_DROPPING.has(stashSub))
return { sub: `stash ${stashSub}`, destroysIndex: false };
return { sub: stashSub ? `stash ${stashSub}` : "stash", destroysIndex: true };
}
if (ALWAYS_MUTATING.has(sub)) {
// Dry-run previews write nothing. `-n` means dry-run for add/rm/mv, but
// for commit it is --no-verify, so only the long flag exempts commit.
if (rest.includes("--dry-run"))
return null;
if (sub !== "commit" && rest.some((t) => /^-[a-zA-Z]*n/.test(t)))
return null;
return { sub, destroysIndex: INDEX_DESTROYING.has(sub) };
}
if (sub === "restore" && rest.some((t) => t === "--staged" || /^-[a-zA-Z]*S/.test(t))) {
return { sub: "restore --staged", destroysIndex: true };
}
if (sub === "apply" && rest.some((t) => t === "--cached" || t === "--index")) {
return { sub: `apply ${rest.includes("--cached") ? "--cached" : "--index"}`, destroysIndex: false };
}
if (sub === "checkout" && rest.includes("--")) {
return { sub: "checkout -- <paths>", destroysIndex: true };
}
return null;
}
/** First index-mutating git invocation in a command line, or null. */
export function classifyCommand(command) {
for (const seg of shellSegments(command)) {
const m = classifyTokens(seg);
if (m)
return m;
}
return null;
}
// ---------------------------------------------------------------------------
// Dirty-actor census
// ---------------------------------------------------------------------------
/**
* Distinct actors with attributed work on paths git currently sees as dirty.
* Reads ownership + the authorship fold and intersects with `git status` — no
* reconcile, so it is cheap enough for a hook and never takes the store lock.
* The git-status intersection is what keeps stale attribution (from work since
* committed or reverted) from counting: a path that is clean in git cannot
* hold anyone's dirty lines, whatever the state files still say.
*/
export function dirtyActors(store) {
const dirty = new Set(changedPaths(store.paths.repoRoot));
if (dirty.size === 0)
return [];
const actors = new Set();
const ownership = store.readOwnership();
for (const [path, file] of Object.entries(ownership.files)) {
if (!dirty.has(path))
continue;
for (const side of [file.added, file.removed]) {
for (const actor of Object.values(side))
actors.add(actor);
}
}
for (const [path, byKey] of foldedAuthorship(store)) {
if (!dirty.has(path))
continue;
for (const actor of byKey.values())
actors.add(actor);
}
return [...actors].sort();
}
export function attributionCoverage(store) {
const dirtyPaths = changedPaths(store.paths.repoRoot);
const covered = new Set();
const ownership = store.readOwnership();
for (const [path, file] of Object.entries(ownership.files)) {
if (Object.keys(file.added).length > 0 || Object.keys(file.removed).length > 0)
covered.add(path);
}
for (const [path, byKey] of foldedAuthorship(store)) {
if (byKey.size > 0)
covered.add(path);
}
const attributed = dirtyPaths.filter((p) => covered.has(p)).length;
return { dirty: dirtyPaths.length, attributed, registeredActors: store.readActors().length };
}
/** True when the checkout looks multi-agent but attribution is dark enough
* that the dirty-actor census cannot be trusted: several actors registered,
* a substantially dirty tree, and none of it attributed. */
export function captureLooksDark(c) {
return c.registeredActors >= 2 && c.dirty >= 5 && c.attributed === 0;
}
/** The warning surfaced when a mutating git command is allowed only because
* capture is dark. Not a denial: with no attribution, `quilt commit --mine`
* has nothing to commit either, so blocking would trap the actor. */
export function darkCaptureWarning(c) {
return (`Quilt: ${c.dirty} dirty files carry no attribution while ${c.registeredActors} actors are registered here. ` +
`Capture may be dark (edits made via bash scripts are not captured unless the files were claimed first), ` +
`so Quilt cannot tell whose work raw git would sweep up. Run \`quilt doctor\`, and claim files before editing them outside the native edit tools.`);
}
/** The PreToolUse denial text. CLI register: state the stakes, then the safe
* path, then the deliberate override. */
export function denyReason(mutation, actors) {
return (`Quilt: ${actors.length} actors have uncommitted work in this checkout ` +
`(${actors.join(", ")}). Raw \`git ${mutation.sub}\` operates on the SHARED git index: ` +
`it can commit their staged work under your message or destroy their staging. ` +
`Commit your own lines with \`quilt commit --mine -m "<message>"\` (no staging needed). ` +
`If you deliberately need raw git here, run \`quilt git -- ${mutation.sub} ...\` — it is recorded and snapshots the index first.`);
}
// ---------------------------------------------------------------------------
// Index snapshots
// ---------------------------------------------------------------------------
/** Ring size: enough to recover any recent reset without growing unbounded. */
export const SNAPSHOT_RING = 20;
function snapshotsPath(store) {
return join(store.paths.repoRoot, ".quilt", "index-snapshots.jsonl");
}
/**
* Snapshot the current shared index as a tree object before a destructive
* command runs. `git write-tree` persists the tree (and the blobs `git add`
* already wrote) into the object database, so the staging selection survives
* the reset and `git read-tree <sha>` restores it. Returns the tree sha, or
* null when the index cannot be written (e.g. unmerged entries) — the guard
* never blocks on its own bookkeeping.
*/
export function recordIndexSnapshot(store, context) {
try {
const res = git(["write-tree"], { cwd: store.paths.repoRoot, check: false });
if (res.status !== 0)
return null;
const tree = res.stdout.trim();
if (!tree)
return null;
const entry = { ts: new Date().toISOString(), tree, context };
const p = snapshotsPath(store);
// Append-only: concurrent hooks (the exact multi-actor scenario this guard
// exists for) must never lose each other's entries to a read-modify-write.
// The ring bound is enforced on read; the file is compacted only when it
// grows well past the ring, where losing a concurrent OLD entry is
// harmless because only the newest SNAPSHOT_RING entries are ever served.
appendFileSync(p, JSON.stringify(entry) + "\n");
try {
const lines = readFileSync(p, "utf8").split("\n").filter(Boolean);
if (lines.length > SNAPSHOT_RING * 5) {
writeFileSync(p, lines.slice(lines.length - SNAPSHOT_RING).join("\n") + "\n");
}
}
catch {
/* compaction is best-effort */
}
return tree;
}
catch {
return null;
}
}
/** The newest SNAPSHOT_RING snapshots, oldest first. Unparseable lines are skipped. */
export function readIndexSnapshots(store) {
const p = snapshotsPath(store);
if (!existsSync(p))
return [];
const out = [];
for (const line of readFileSync(p, "utf8").split("\n")) {
if (!line.trim())
continue;
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed.tree === "string")
out.push(parsed);
}
catch {
/* skip */
}
}
return out.slice(Math.max(0, out.length - SNAPSHOT_RING));
}
// ---------------------------------------------------------------------------
// Pre-commit backstop
// ---------------------------------------------------------------------------
/** Env var the passthrough sets so its own `git commit` is not re-refused. */
export const PASSTHROUGH_ENV = "QUILT_GIT_PASSTHROUGH";
/** Exit code `hook-git-pre-commit` uses for an actual refusal. The shim honors
* ONLY this code: any other failure — including an older installed quilt that
* does not know the command and exits 1 — reads as "no verdict" and the commit
* proceeds. Without this distinction, version skew between the shim and the
* installed CLI would fail closed and block every commit. */
export const REFUSAL_EXIT = 65;
/**
* Owners of the currently staged set: actor -> staged files carrying that
* actor's attributed lines. Per-file granularity — enough to catch a sweep
* (`git add -A` on a multi-actor tree) without re-deriving per-line hunks at
* commit time.
*/
export function stagedActorSpan(store) {
// -z: NUL-delimited, so non-ASCII paths come back verbatim instead of
// C-quoted and actually match the plain-text keys in ownership/authorship.
const res = git(["diff", "--cached", "--name-only", "--no-renames", "-z"], {
cwd: store.paths.repoRoot,
check: false,
});
if (res.status !== 0)
return new Map();
const staged = new Set(res.stdout.split("\0").filter(Boolean));
if (staged.size === 0)
return new Map();
const byActor = new Map();
const add = (actor, path) => {
(byActor.get(actor) ?? byActor.set(actor, new Set()).get(actor)).add(path);
};
const ownership = store.readOwnership();
for (const [path, file] of Object.entries(ownership.files)) {
if (!staged.has(path))
continue;
for (const side of [file.added, file.removed]) {
for (const actor of Object.values(side))
add(actor, path);
}
}
for (const [path, byKey] of foldedAuthorship(store)) {
if (!staged.has(path))
continue;
for (const actor of byKey.values())
add(actor, path);
}
return new Map([...byActor.entries()].map(([a, files]) => [a, [...files].sort()]));
}
/** The pre-commit refusal text, listing each actor's staged files. */
export function preCommitRefusal(span) {
const lines = [...span.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([actor, files]) => ` ${actor}: ${files.join(", ")}`);
return (`quilt: the staged set spans ${span.size} actors' uncommitted work:\n` +
lines.join("\n") +
"\n" +
"Committing it would land their lines under this commit's message.\n" +
'Commit only your own lines with `quilt commit --mine -m "<message>"`,\n' +
"or run the commit deliberately with `quilt git -- commit ...` (recorded).\n");
}
// ---------------------------------------------------------------------------
// Pre-commit hook installation
// ---------------------------------------------------------------------------
/**
* The directory git will actually consult for hooks. `--git-path hooks`
* resolves both `core.hooksPath` (husky, lefthook, pre-commit framework) and
* worktrees (where `.git` is a file, not a directory) — installing to a
* hardcoded `.git/hooks` in either case would report success while git never
* runs the shim. Falls back to `.git/hooks` only if git itself is unrunnable.
*/
export function hooksDirFor(root) {
const res = git(["rev-parse", "--git-path", "hooks"], { cwd: root, check: false });
const p = res.status === 0 ? res.stdout.trim() : "";
return p ? resolve(root, p) : join(root, ".git", "hooks");
}
/** Marker identifying the shim as Quilt's; bump the version to force reinstall. */
export const PRE_COMMIT_MARKER = "# quilt pre-commit shim v1";
/** A pre-existing non-quilt pre-commit hook is preserved here and chained. */
export const CHAINED_HOOK_NAME = "pre-commit.local";
/**
* The shim `quilt setup` installs at `.git/hooks/pre-commit`. Fail-open by
* construction: when `quilt` is not on PATH (GUI clients with a minimal env)
* the check is skipped rather than failing the commit. A pre-existing hook,
* preserved as `pre-commit.local`, runs after the quilt check passes.
*/
export const PRE_COMMIT_SHIM = `#!/bin/sh
${PRE_COMMIT_MARKER}
# Installed by \`quilt setup\` (re-verified on every run; \`quilt doctor\` checks it).
# Refuses a commit whose staged set spans multiple actors' uncommitted work.
# Only exit ${REFUSAL_EXIT} is a refusal; any other failure (quilt missing, an older
# quilt without this command) must NOT block the commit.
if command -v quilt >/dev/null 2>&1; then
# stderr silenced: an older quilt prints "unknown command" there; the refusal
# itself comes on stdout from the current CLI.
quilt hook-git-pre-commit 2>/dev/null
if [ $? -eq ${REFUSAL_EXIT} ]; then exit 1; fi
fi
hookdir=$(dirname "$0")
if [ -x "$hookdir/${CHAINED_HOOK_NAME}" ]; then
"$hookdir/${CHAINED_HOOK_NAME}" "$@" || exit $?
fi
exit 0
`;
/** Is Quilt's shim (any version) present at the effective pre-commit hook? */
export function preCommitInstalled(root) {
const p = join(hooksDirFor(root), "pre-commit");
if (!existsSync(p))
return false;
try {
return readFileSync(p, "utf8").includes("quilt pre-commit shim");
}
catch {
return false;
}
}
/** Is the CURRENT shim installed? Compares content, not the marker, so any
* shim change redeploys on the next `quilt setup` without a marker bump. */
export function preCommitCurrent(root) {
const p = join(hooksDirFor(root), "pre-commit");
if (!existsSync(p))
return false;
try {
return readFileSync(p, "utf8") === PRE_COMMIT_SHIM;
}
catch {
return false;
}
}
/**
* Install (or re-verify) the pre-commit shim. A pre-existing hook that is not
* Quilt's is preserved as `pre-commit.local` and chained after the check; if
* that name is already taken by something else, nothing is touched and the
* result says so. Idempotent: a current shim is a skip.
*/
export function installPreCommitHook(root, dryRun) {
const hooksDir = hooksDirFor(root);
const hookPath = join(hooksDir, "pre-commit");
const chainedPath = join(hooksDir, CHAINED_HOOK_NAME);
const exists = existsSync(hookPath);
if (exists && preCommitCurrent(root)) {
return { action: "skip", detail: "pre-commit guard already installed" };
}
let action;
let detail;
if (!exists) {
action = "create";
detail = "install the pre-commit guard (multi-actor staged sets are refused)";
}
else if (preCommitInstalled(root)) {
action = "update";
detail = "update the pre-commit guard to the current version";
}
else if (existsSync(chainedPath)) {
return {
action: "skip",
detail: `left untouched — an existing pre-commit hook is present and ${CHAINED_HOOK_NAME} is taken; chain \`quilt hook-git-pre-commit\` into it by hand`,
};
}
else {
action = "update";
detail = `install the pre-commit guard (existing hook preserved as ${CHAINED_HOOK_NAME}, still runs)`;
}
if (dryRun)
return { action, detail };
try {
mkdirSync(hooksDir, { recursive: true });
if (exists && !preCommitInstalled(root) && !existsSync(chainedPath)) {
renameSync(hookPath, chainedPath);
}
writeFileSync(hookPath, PRE_COMMIT_SHIM);
chmodSync(hookPath, 0o755);
return { action, detail };
}
catch (e) {
return { action: "skip", detail: `could not install pre-commit guard (${e.message})` };
}
}
/** Append a guard event to the ledger without throwing. Defaults to the
* passthrough type; pass `type` in the event to record something else. */
export function logGuardEvent(store, event) {
try {
store.appendLedger({ ts: new Date().toISOString(), type: "git.passthrough", ...event });
}
catch {
/* bookkeeping must not block the command */
}
}
+1
-0

@@ -434,2 +434,3 @@ // The authorship ledger — capture who authored which lines AT THE EDIT.

whole: whole || undefined,
mode: args.mode,
};

@@ -436,0 +437,0 @@ appendFileSync(store.paths.authorshipLog, JSON.stringify(ev) + "\n");

@@ -32,6 +32,27 @@ import { readAuthorship } from "./authorship.js";

return null;
if (file.binary)
return { path: file.path, isNew: file.isNew, isDeleted: file.isDeleted, binary: true, lines: [] };
if (file.binary) {
return {
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
binary: true,
lines: [],
sections: [],
};
}
const events = readAuthorship(store);
const transcriptCache = new Map();
let startLineIndex = 0;
const sections = file.hunks.map((ownedHunk) => {
const section = {
oldStart: ownedHunk.hunk.oldStart,
oldLines: ownedHunk.hunk.oldLines,
newStart: ownedHunk.hunk.newStart,
newLines: ownedHunk.hunk.newLines,
startLineIndex,
lineCount: ownedHunk.lines.length,
};
startLineIndex += section.lineCount;
return section;
});
const lines = file.hunks.flatMap((hunk) => hunk.lines.map((line) => {

@@ -62,3 +83,10 @@ const provenance = line.actors.map((actor) => {

}));
return { path: file.path, isNew: file.isNew, isDeleted: file.isDeleted, binary: false, lines };
return {
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
binary: false,
lines,
sections,
};
}

@@ -10,3 +10,5 @@ // `quilt doctor` — a health check that turns SILENT failure into a visible one.

import { spawn } from "node:child_process";
import { relative } from "node:path";
import { detect, codexHooksTrusted } from "./onboard.js";
import { attributionCoverage, captureLooksDark, hooksDirFor, preCommitCurrent, preCommitInstalled } from "./gitguard.js";
import { readAuthorship, readCheckpoint } from "./authorship.js";

@@ -146,2 +148,29 @@ import { watcherRunning } from "./watch.js";

});
// The raw-git guard: without it, any agent's `git add/commit/reset` operates
// on the shared index across every actor's staging. Two layers, checked
// separately so a re-clone (which wipes .git/hooks) is called out precisely.
checks.push(d.bashGuardWired
? { label: "Raw-git guard", status: "ok", detail: "Bash hook pair (guard + write capture) in .claude/settings.json" }
: {
label: "Raw-git guard",
status: "warn",
detail: "not installed (or missing its capture half)",
hint: "run `quilt setup` — without the pair, raw `git add/commit/reset` can race other actors' staging, and bash-made writes go unattributed",
});
const hooksRel = relative(root, hooksDirFor(root)) || ".git/hooks";
checks.push(preCommitCurrent(root)
? { label: "Pre-commit guard", status: "ok", detail: `quilt shim in ${hooksRel}/pre-commit` }
: preCommitInstalled(root)
? {
label: "Pre-commit guard",
status: "warn",
detail: "an older quilt shim is installed",
hint: "run `quilt setup` — it updates the shim in place",
}
: {
label: "Pre-commit guard",
status: "warn",
detail: "not installed (re-clones wipe .git/hooks)",
hint: "run `quilt setup` — without it, a `git add -A` sweep can commit several actors' work under one message",
});
// Codex: the wiring lives user-globally and Codex SILENTLY SKIPS a newly

@@ -243,2 +272,31 @@ // added hook until the user approves it once in an interactive session —

}
// A LIFETIME capture total says nothing about NOW: a repo can hold 100+
// captured edits from last month while today's dirty tree has zero
// attribution (edits made via bash scripts are invisible to the capture
// hooks unless the files were claimed first). The guard's dirty-actor
// census and `commit --mine` both key on attribution, so dark coverage
// means both are blind — the one state this health check must never let
// read as healthy.
try {
const coverage = attributionCoverage(store);
if (captureLooksDark(coverage)) {
checks.push({
label: "Attribution coverage",
status: "warn",
detail: `${coverage.dirty} dirty files, none attributed, ${coverage.registeredActors} actors registered`,
hint: "capture may be dark — bash-mediated writes (heredocs, sed, scripts) are only attributed when the files were claimed first; the raw-git guard cannot count actors it cannot see",
});
}
else if (coverage.dirty > 0 && coverage.attributed < coverage.dirty) {
checks.push({
label: "Attribution coverage",
status: "info",
detail: `${coverage.attributed} of ${coverage.dirty} dirty files carry attribution`,
hint: "unattributed files are usually human edits or unclaimed bash writes; claim files before editing them outside the native edit tools",
});
}
}
catch {
/* not fatal for a health check */
}
const pid = watcherRunning(store);

@@ -245,0 +303,0 @@ checks.push(pid

@@ -22,2 +22,3 @@ import pc from "picocolors";

let changed = false;
let changedLines = 0;
const lineCounts = new Map();

@@ -34,4 +35,6 @@ let unownedLines = 0;

for (const h of f.hunks) {
if (h.hunk.ops.some((o) => o.type !== "eq"))
const hunkChangedLines = h.hunk.ops.filter((o) => o.type !== "eq").length;
if (hunkChangedLines)
changed = true;
changedLines += hunkChangedLines;
for (const a of h.actors) {

@@ -61,2 +64,3 @@ owned = true;

binary: f.binary,
changedLines,
actors: [...lineCounts.entries()]

@@ -63,0 +67,0 @@ .map(([id, lines]) => ({ id, lines }))

@@ -65,2 +65,19 @@ import { spawnSync } from "node:child_process";

}
/** Is `relPath` ignored by git? Covers the repo's .gitignore, .git/info/exclude
* and the user's global excludesfile, so a user who already ignores agent config
* machine-wide is never told to ignore it again. */
export function pathIsIgnored(cwd, relPath) {
return git(["check-ignore", "-q", "--", relPath], { cwd, check: false }).status === 0;
}
/** Is `relPath` in the index? A tracked file is already part of the repo's
* committed surface, so writing to it exposes nothing new. */
export function pathIsTracked(cwd, relPath) {
return git(["ls-files", "--error-unmatch", "--", relPath], { cwd, check: false }).status === 0;
}
/** Does the repo have an `origin` remote? Gates the (slower) visibility probe:
* a repo with no remote can't be published, so there is nothing to look up. */
export function hasOriginRemote(cwd) {
const res = git(["remote", "get-url", "origin"], { cwd, check: false });
return res.status === 0 && res.stdout.trim() !== "";
}
/** Current HEAD commit SHA, or null on an unborn branch (no commits yet). */

@@ -67,0 +84,0 @@ export function headSha(cwd) {

@@ -7,5 +7,7 @@ // Magical onboarding: detect the agent orchestrator in a repo and wire Quilt in

// JSON), we leave it alone and tell the user what to add by hand.
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, resolve, sep } from "node:path";
import { hasOriginRemote, pathIsIgnored, pathIsTracked } from "./git.js";
/** The MCP server entry every agent in the fleet shares. */

@@ -17,2 +19,9 @@ export const QUILT_SERVER = { command: "quilt", args: ["mcp"] };

export const HOOK_POST_COMMAND = "quilt hook-post";
/** The git guard: a PreToolUse hook on the Bash tool that denies raw
* index-mutating git while multiple actors have uncommitted work. */
export const HOOK_BASH_MATCHER = "Bash";
export const HOOK_BASH_COMMAND = "quilt hook-bash";
/** Bash-write capture: the Post half of the pair — diffs the worktree against
* the Pre baseline and attributes the delta to the acting session. */
export const HOOK_BASH_POST_COMMAND = "quilt hook-bash-post";
/** Codex CLI's edit tool — one matcher, since every Codex edit is a patch. */

@@ -36,3 +45,3 @@ export const CODEX_HOOK_MATCHER = "apply_patch";

*/
export const COORDINATION_VERSION = 3;
export const COORDINATION_VERSION = 5;
export const COORDINATION_MARKER = `<!-- quilt:coordination v${COORDINATION_VERSION} -->`;

@@ -58,8 +67,16 @@ /** Closes the block so a future refresh can replace exactly the marked region. */

automatic (each session gets its own id), and every line you edit is
attributed to you as you write it. Claude Code hooks also deny edits into
claimed code. Codex hooks are capture-only, so use claim-aware MCP tools when
you need prevention there.
attributed to you as you write it — native Edit/Write tools AND file writes
made through Bash (heredocs, sed, patch, codegen scripts) alike. Claude Code
hooks also deny edits into claimed code. Codex hooks are capture-only, so
use claim-aware MCP tools when you need prevention there.
- To commit only your lines, run \`quilt commit --mine -m "<message>"\` from
the shell. It works with or without the MCP server, and it leaves everyone
else's uncommitted work untouched. \`quilt status\` shows who owns what.
- Do NOT use raw \`git add\`/\`git commit\`/\`git reset\`: the git index is
SHARED across every actor in this checkout, so raw staging can commit or
destroy other actors' in-flight work. While several actors have uncommitted
work, Quilt denies those commands and a pre-commit check refuses multi-actor
staged sets. \`quilt commit --mine\` needs no staging. If you deliberately
need raw git, \`quilt git -- <args>\` runs it recorded, snapshotting the
index first when the command can destroy staged state.
- The quilt MCP tools (claim, commit_mine, get_status, ...) are an optional

@@ -78,7 +95,6 @@ prevention layer, available when the quilt MCP server is connected and

shared connection cannot tell you apart automatically.
- CLAIM before editing when either applies: (a) you are editing via bash,
scripts, or codegen (nothing captures those; a whole-file claim placed
BEFORE the edit is what binds them to you, and attribution is edit-time,
never retroactive), or (b) you want the code protected from other actors
while you work. Claim WHOLE FILES (\`src/auth.ts\`) or a directory for
- CLAIM before editing when you want the code protected from other actors
while you work (bash-made writes are captured and attributed automatically,
but capture is attribution, not reservation: a claim is what makes other
actors stay off the code). Claim WHOLE FILES (\`src/auth.ts\`) or a directory for
codegen (\`convex/_generated/\`); use \`path#symbol\` only to share one

@@ -135,2 +151,5 @@ file with another actor (pass \`creating: true\` if the symbol does not

const hooksWired = hasSettings && settingsHasQuiltHooks(safeRead(settingsPath));
const bashGuard = hasSettings ? settingsHasBashGuard(safeRead(settingsPath)) : { pre: false, pair: false };
const bashGuardWired = bashGuard.pair;
const bashPreWired = bashGuard.pre;
const codexPresent = existsSync(codexDir());

@@ -154,2 +173,4 @@ const codexWired = codexPresent && codexHooksWiredIn(safeRead(codexHooksPath()));

hooksWired,
bashGuardWired,
bashPreWired,
codexPresent,

@@ -209,2 +230,18 @@ codexWired,

}
/** Is the git guard (Bash-matcher PreToolUse hook) wired in settings content? */
function settingsHasBashGuard(content) {
if (!content)
return { pre: false, pair: false };
try {
const parsed = JSON.parse(content);
const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
if (!isPlainObject(hooks))
return { pre: false, pair: false };
const pre = hookGroupHas(hooks.PreToolUse, HOOK_BASH_COMMAND);
return { pre, pair: pre && hookGroupHas(hooks.PostToolUse, HOOK_BASH_POST_COMMAND) };
}
catch {
return { pre: false, pair: false };
}
}
/** Is the Codex hooks file already carrying both quilt capture hooks? */

@@ -303,2 +340,4 @@ function codexHooksWiredIn(content) {

changed = ensureHookGroup(hooksObj, "PostToolUse", HOOK_POST_COMMAND) || changed;
changed = ensureHookGroup(hooksObj, "PreToolUse", HOOK_BASH_COMMAND, HOOK_BASH_MATCHER) || changed;
changed = ensureHookGroup(hooksObj, "PostToolUse", HOOK_BASH_POST_COMMAND, HOOK_BASH_MATCHER) || changed;
if (!changed)

@@ -456,2 +495,3 @@ return { content: existing ?? "", changed: false };

path: d.mcpJsonPath,
wired: false,
});

@@ -497,2 +537,3 @@ }

path: d.settingsPath,
wired: false,
});

@@ -507,3 +548,11 @@ }

action: d.hasSettings ? "update" : "create",
detail: d.hasSettings ? "add the Edit/Write capture hooks" : "create with the Edit/Write capture hooks",
detail: !d.hasSettings
? "create with the Edit/Write capture hooks and the raw-git guard"
: d.hooksWired && d.bashPreWired
? "add the bash-write capture hook (guard and capture hooks already present)"
: d.hooksWired
? "add the raw-git guard (capture hooks already present)"
: d.bashGuardWired
? "add the Edit/Write capture hooks (raw-git guard already present)"
: "add the Edit/Write capture hooks and the raw-git guard",
content: hooks.content,

@@ -524,2 +573,3 @@ path: d.settingsPath,

path: d.cursorMcpPath,
wired: false,
});

@@ -570,2 +620,3 @@ }

path: codexHooksPath(),
wired: false,
});

@@ -600,1 +651,104 @@ }

}
/**
* Which of a plan's files are NEW to git's view of the repo — untracked, and not
* matched by any ignore rule. These are the files a later `git add -A` would
* sweep into a commit purely because Quilt created them.
*
* A file that is already TRACKED is deliberately excluded: plenty of projects
* commit CLAUDE.md and .mcp.json on purpose, and appending Quilt's snippet to a
* file the repo already publishes exposes nothing that wasn't published already.
* A file already IGNORED is excluded for the same reason, which also covers the
* user whose global excludesfile handles agent config machine-wide.
*
* Paths outside the repo root (Codex's user-global ~/.codex/hooks.json) are
* invisible to git and never reported.
*/
export function newToGit(root, steps) {
const rootPrefix = resolve(root) + sep;
return steps.filter((s) => {
// A malformed config can produce a skip step even though Quilt is not
// present in that file. Do not call it wired or hide it from git.
if (s.wired === false)
return false;
// Files this run writes, AND files a previous run already wired (a "skip"
// step whose file is sitting on disk). The second case is the one that
// matters: a user who reads the warning and then runs `setup --gitignore`
// hits an already-wired repo, where every step is a skip. Judging exposure
// by what THIS run happens to write would no-op exactly when asked to help.
const onDisk = s.content !== undefined || existsSync(s.path);
if (!onDisk)
return false;
if (!resolve(s.path).startsWith(rootPrefix))
return false;
return !pathIsTracked(root, s.file) && !pathIsIgnored(root, s.file);
});
}
/**
* The GitHub visibility of `origin`, or null when it can't be determined without
* bothering the user: no `gh`, not logged in, no origin remote, not a GitHub
* remote, or the call is slow. Null means "say the neutral thing" — a warning
* that only fires when we're SURE the repo is public would leave the common
* no-gh case silent, and one that assumes public would cry wolf on private work.
*
* Set QUILT_NO_GH=1 to skip the probe entirely.
*/
export function repoVisibility(root) {
if (process.env.QUILT_NO_GH === "1")
return null;
if (!hasOriginRemote(root))
return null;
const res = spawnSync("gh", ["repo", "view", "--json", "visibility", "-q", ".visibility"], {
cwd: root,
encoding: "utf8",
timeout: 3000,
});
if (res.error || res.status !== 0)
return null;
const v = res.stdout.trim().toLowerCase();
return v === "public" ? "public" : v === "private" || v === "internal" ? "private" : null;
}
/** Header written above the entries `quilt setup --gitignore` adds. */
export const GITIGNORE_HEADER = "# Local agent config, wired by `quilt setup` (delete to commit and share it)";
/**
* Append `entries` to a .gitignore, additively and idempotently. Entries already
* present as an exact line are normally left alone. Callers that already proved
* a path is not effectively ignored can append the rule at EOF, after any later
* negation. Re-running setup still stays idempotent because git then reports the
* path ignored before this function is called again.
*/
export function mergeGitignore(existing, entries, appendAtEnd = false) {
const base = existing ?? "";
const lines = new Set(base.split("\n").map((l) => l.trim()));
const missing = appendAtEnd ? [...new Set(entries)] : entries.filter((e) => !lines.has(e));
if (missing.length === 0)
return { content: base, changed: false };
const sep = base === "" ? "" : base.endsWith("\n") ? "\n" : "\n\n";
return { content: base + sep + GITIGNORE_HEADER + "\n" + missing.join("\n") + "\n", changed: true };
}
/**
* The .gitignore step for `quilt setup --gitignore`: ignore exactly the files
* this run is introducing to git. Directory-shaped entries (`.claude/settings.json`
* lives under `.claude/`) are ignored by their full path, never by their parent
* directory — ignoring all of `.claude/` would silently swallow whatever else the
* user keeps there.
*/
export function planGitignore(root, exposed) {
if (exposed.length === 0)
return null;
const path = join(root, ".gitignore");
const existing = existsSync(path) ? safeRead(path) : null;
const entries = exposed.map((s) => "/" + s.file);
// `exposed` came from git check-ignore and is not effectively ignored. Append
// at EOF even if an identical earlier rule is cancelled by a later negation.
const merged = mergeGitignore(existing, entries, true);
if (!merged.changed) {
return { file: ".gitignore", action: "skip", detail: "already ignores the wired files", path };
}
return {
file: ".gitignore",
action: existing === null ? "create" : "update",
detail: `keep ${entries.join(", ")} out of git`,
content: merged.content,
path,
};
}
+1
-1
{
"name": "@quilt-dev/cli",
"version": "0.5.2",
"version": "0.6.0",
"mcpName": "io.github.wkoverfield/quilt",

@@ -5,0 +5,0 @@ "description": "Actor-owned patches for Git. Same repo. Many agents. Clean commits.",

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display