3215 lines
102 KiB
TypeScript
3215 lines
102 KiB
TypeScript
// aidlc-worktree.ts — Construction-phase worktree primitive.
|
|
//
|
|
// Five subcommands: create, merge, discard, list, verify. Audit-first
|
|
// (audit-of-intent semantics — see docs/reference/12-state-machine.md
|
|
// § Audit-first atomicity). The orchestrator dispatches aidlc-pipeline-deploy-agent
|
|
// to read team practices, the agent invokes this tool with resolved flags,
|
|
// then the orchestrator calls `verify` as a deterministic post-dispatch
|
|
// backstop confirming the audit event landed.
|
|
//
|
|
// Sibling-worktree rejection: rejects calls from inside a non-main worktree
|
|
// to avoid the dev-worktree-vs-bolt-worktree clash. Run from the main repo
|
|
// checkout.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { appendAuditEntry } from "./aidlc-audit.ts";
|
|
import {
|
|
auditBlockField,
|
|
boltSlugForUnit,
|
|
currentSwarmSourceOpeningFingerprint,
|
|
currentSwarmSourceMergeChain,
|
|
discoverSiblingRepos,
|
|
emitError,
|
|
errorMessage,
|
|
findAllEvents,
|
|
getField,
|
|
gitCommitSourceListing,
|
|
intentsDir,
|
|
isValidRepoName,
|
|
latestMainWorkflowStageRunFloorForProject,
|
|
listIntentDirs,
|
|
listSpaces,
|
|
parseSourceListing,
|
|
readAllAuditShards,
|
|
readAuditShardEvents,
|
|
readStateFile,
|
|
relativeRecordDir,
|
|
repoDir,
|
|
reviewedSourceRefPrefix,
|
|
resolveAuditWorktreePath,
|
|
resolveBoltDag,
|
|
resolveConstructionRepo,
|
|
resolveProjectDir,
|
|
serializeSourceListing,
|
|
sourceListingEntriesEqual,
|
|
sourceListingSha256,
|
|
type WorkspaceSourceState,
|
|
UNBINDABLE_FINGERPRINT,
|
|
validateUnitName,
|
|
workspaceSourceFailureSuffix,
|
|
workspaceSourceFingerprint,
|
|
workspaceSourceExclusionPathspecs,
|
|
workspaceSourcePathIsExcluded,
|
|
workspaceSourceState,
|
|
worktreePath,
|
|
worktreeStateFilePath,
|
|
writeFileAtomic,
|
|
} from "./aidlc-lib.js";
|
|
|
|
// kebab-case slug shape: lowercase letter, then lowercase letters / digits /
|
|
// hyphens. Mirrors stage-schema.ts:95+:101 — the codebase already duplicates
|
|
// this regex across conceptual domains; a one-line constant beats a cross-
|
|
// module import for a tool-local check.
|
|
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
|
const VALID_STRATEGIES = new Set(["squash", "merge", "rebase"]);
|
|
const WORKTREE_META_FILENAME = "worktree-meta.json";
|
|
const WORKTREE_BASE_LISTING_FILENAME = "base-source-listing.tsv";
|
|
const VALID_VERIFY_EVENTS = new Set([
|
|
"WORKTREE_CREATED",
|
|
"WORKTREE_MERGED",
|
|
"WORKTREE_DISCARDED",
|
|
]);
|
|
|
|
// --- Flag parsing (mirrors aidlc-bolt.ts:30-46) ---
|
|
|
|
function parseFlags(args: string[]): Record<string, string> {
|
|
const flags: Record<string, string> = {};
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (!a.startsWith("--")) continue;
|
|
if (i + 1 >= args.length) {
|
|
error(`${a} expects a value, got end of arguments.`);
|
|
}
|
|
const val = args[i + 1];
|
|
if (val.startsWith("--")) {
|
|
error(`${a} expects a value, got another flag: "${val}". Did you forget the value?`);
|
|
}
|
|
flags[a.slice(2)] = val;
|
|
i++;
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
// --- Audit emit shorthand ---
|
|
|
|
function emitAudit(
|
|
pd: string,
|
|
eventType: string,
|
|
fields: Record<string, string>,
|
|
intent?: string,
|
|
space?: string
|
|
): string {
|
|
const result = appendAuditEntry(eventType, fields, pd, intent, space);
|
|
return result.timestamp;
|
|
}
|
|
|
|
// --- Git invocation ---
|
|
|
|
interface GitResult {
|
|
ok: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
}
|
|
|
|
function runGit(args: string[], cwd?: string): GitResult {
|
|
const r = spawnSync("git", args, {
|
|
cwd,
|
|
encoding: "utf-8",
|
|
env: { ...process.env, EDITOR: process.env.EDITOR ?? "false" },
|
|
});
|
|
return {
|
|
ok: r.status === 0,
|
|
stdout: (r.stdout ?? "").toString(),
|
|
stderr: (r.stderr ?? "").toString(),
|
|
code: r.status ?? 1,
|
|
};
|
|
}
|
|
|
|
interface RetainedSourceRef {
|
|
ref: string;
|
|
oid: string;
|
|
}
|
|
|
|
function retainedSourceRefs(repoCwd: string, slug: string): RetainedSourceRef[] | null {
|
|
const prefix = reviewedSourceRefPrefix(slug);
|
|
const listed = runGit(
|
|
["for-each-ref", "--format=%(refname)%09%(objectname)", prefix],
|
|
repoCwd,
|
|
);
|
|
if (!listed.ok) return null;
|
|
const refs: RetainedSourceRef[] = [];
|
|
for (const line of listed.stdout.split(/\r?\n/)) {
|
|
if (!line) continue;
|
|
const tab = line.indexOf("\t");
|
|
if (tab === -1) return null;
|
|
const ref = line.slice(0, tab);
|
|
const oid = line.slice(tab + 1);
|
|
if (!ref.startsWith(prefix) || !/^[0-9a-f]{40,64}$/.test(oid)) return null;
|
|
refs.push({ ref, oid });
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function recordedWorktreeSelector(
|
|
pd: string,
|
|
slug: string,
|
|
): {
|
|
intent?: string;
|
|
space?: string;
|
|
repoSelector?: string | null;
|
|
} | null {
|
|
const path = join(worktreePath(pd, slug), ".aidlc", WORKTREE_META_FILENAME);
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
intentRecord?: unknown;
|
|
repoSelector?: unknown;
|
|
};
|
|
const matched =
|
|
typeof parsed.intentRecord === "string"
|
|
? /^aidlc\/spaces\/([^/]+)\/intents\/([^/]+)$/.exec(
|
|
parsed.intentRecord,
|
|
)
|
|
: null;
|
|
if (
|
|
"repoSelector" in parsed &&
|
|
parsed.repoSelector !== null &&
|
|
typeof parsed.repoSelector !== "string"
|
|
) {
|
|
return matched === null
|
|
? null
|
|
: { space: matched[1], intent: matched[2] };
|
|
}
|
|
if (matched === null && !("repoSelector" in parsed)) return null;
|
|
return {
|
|
...(matched === null
|
|
? {}
|
|
: {
|
|
space: matched[1],
|
|
intent: matched[2],
|
|
}),
|
|
...("repoSelector" in parsed
|
|
? { repoSelector: parsed.repoSelector as string | null }
|
|
: {}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Compare-and-delete each ref: if another process moved one after enumeration,
|
|
// preserve it and report a cleanup failure instead of deleting newer evidence.
|
|
function deleteRetainedSourceRefs(repoCwd: string, refs: RetainedSourceRef[]): string | null {
|
|
for (const retained of refs) {
|
|
const deleted = runGit(["update-ref", "-d", retained.ref, retained.oid], repoCwd);
|
|
if (!deleted.ok) {
|
|
return deleted.stderr.trim() || deleted.stdout.trim() || `cannot delete ${retained.ref}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Sibling-worktree detection ---
|
|
//
|
|
// `aidlc-worktree` must run from the main repo checkout, not from a sibling
|
|
// worktree (e.g. `.claude/worktrees/<dev>/`). The main checkout is the
|
|
// directory whose `.git` is the same as `git rev-parse --git-common-dir`'s
|
|
// parent. macOS symlinks `/var → /private/var`, so canonicalise both sides
|
|
// via `realpathSync` before comparing.
|
|
//
|
|
// P7 (multi-repo): the guard is RE-ANCHORED to the TARGET repo's checkout. When
|
|
// `--repo <name>` selects a sibling repo, `repoCwd` is that repo dir and every
|
|
// git probe runs there — so "must run from the main checkout" is evaluated against
|
|
// the sibling repo, not the (non-git) workspace root. Absent `--repo` (legacy
|
|
// single-repo), `repoCwd` is the projectDir and the behaviour is unchanged.
|
|
function assertNotSiblingWorktree(repoCwd?: string): void {
|
|
const top = runGit(["rev-parse", "--show-toplevel"], repoCwd);
|
|
if (!top.ok) {
|
|
error("Not a git repository (or any of the parent directories).");
|
|
}
|
|
const cwdTop = canonicalise(top.stdout.trim());
|
|
|
|
const common = runGit(["rev-parse", "--git-common-dir"], repoCwd);
|
|
if (!common.ok) {
|
|
error("Cannot resolve git common dir.");
|
|
}
|
|
const commonRaw = common.stdout.trim();
|
|
const commonAbs = resolve(cwdTop, commonRaw);
|
|
const mainCheckout = canonicalise(dirname(commonAbs));
|
|
|
|
if (cwdTop !== mainCheckout) {
|
|
error(
|
|
`aidlc-worktree must run from the main repo checkout, not from a sibling worktree at ${cwdTop}. Bolt worktrees are siblings of the main checkout, not nested.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function canonicalise(p: string): string {
|
|
try {
|
|
return realpathSync(p);
|
|
} catch {
|
|
return p;
|
|
}
|
|
}
|
|
|
|
function gitCommonDirRealpath(cwd: string): string | null {
|
|
const top = runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
const common = runGit(["rev-parse", "--git-common-dir"], cwd);
|
|
if (!top.ok || !common.ok) return null;
|
|
try {
|
|
return realpathSync(resolve(top.stdout.trim(), common.stdout.trim()));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function pathKey(p: string): string {
|
|
const normalised = canonicalise(resolve(p)).replace(/\\/g, "/");
|
|
return process.platform === "win32" ? normalised.toLowerCase() : normalised;
|
|
}
|
|
|
|
function gitCommonDirHash(path: string): string {
|
|
return createHash("sha256").update(pathKey(path)).digest("hex");
|
|
}
|
|
|
|
function auditWorktreePath(pd: string, path: string): string {
|
|
return relative(pd, path).replaceAll("\\", "/");
|
|
}
|
|
|
|
// --- Validation helpers ---
|
|
|
|
function validateSlug(slug: string | undefined): string {
|
|
if (!slug) error("Missing --slug <slug>");
|
|
if (!SLUG_RE.test(slug)) {
|
|
error(
|
|
`Invalid --slug: "${slug}". Must be kebab-case (lowercase letter then [a-z0-9-]).`
|
|
);
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function validateStrategy(strategy: string | undefined): string {
|
|
if (!strategy) error("Missing --strategy <squash|merge|rebase>");
|
|
if (!VALID_STRATEGIES.has(strategy)) {
|
|
error(
|
|
`Invalid --strategy: "${strategy}". Must be one of: squash, merge, rebase.`
|
|
);
|
|
}
|
|
return strategy;
|
|
}
|
|
|
|
// Resolve the cwd every git op in a construction handler must run in (P7). With
|
|
// `--repo <name>` it is the sibling repo dir; absent it the lone recorded repo is
|
|
// inferred (or the projectDir for a legacy single-repo intent). A disambiguation
|
|
// failure (multi-repo intent without --repo, or an out-of-set name) errors before
|
|
// any audit emit. `flags.intent`/`flags.space` select the intent whose repo set is
|
|
// consulted (same selector the audit emit threads).
|
|
function resolveRepoCwd(
|
|
pd: string,
|
|
flags: Record<string, string>,
|
|
slug: string,
|
|
): string {
|
|
return resolveRepoTarget(pd, flags, slug).cwd;
|
|
}
|
|
|
|
function resolveRepoTarget(
|
|
pd: string,
|
|
flags: Record<string, string>,
|
|
slug: string,
|
|
): { cwd: string; repo: string | null } {
|
|
try {
|
|
return resolveConstructionRepo(pd, flags.repo, flags.intent, flags.space);
|
|
} catch (e) {
|
|
errorWithSlug(slug, errorMessage(e));
|
|
}
|
|
}
|
|
|
|
// --- Subcommand: create ---
|
|
//
|
|
// Usage: aidlc-worktree create --slug <slug> --base <branch> [--repo <name>]
|
|
// [--intent <dir>] [--space <name>]
|
|
//
|
|
// --repo (P7): the sibling repo to fork the worktree inside (a multi-repo intent
|
|
// requires it; a single-repo intent infers the lone repo; a legacy intent with no
|
|
// recorded repos runs in the projectDir, today's behaviour).
|
|
function rawBaseSourceListing(
|
|
repoCwd: string,
|
|
baseCommit: string,
|
|
carriesWorkspaceShell: boolean,
|
|
): { serialized: string; hash: string } | null {
|
|
// This is captured once at worktree creation, so bind the live external
|
|
// target bytes that the opening review baseline actually sees.
|
|
const listing = gitCommitSourceListing(
|
|
repoCwd,
|
|
baseCommit,
|
|
carriesWorkspaceShell,
|
|
true,
|
|
);
|
|
if (listing === null) return null;
|
|
const serialized = serializeSourceListing(listing);
|
|
if (parseSourceListing(serialized) === null) return null;
|
|
return { serialized, hash: `sha256:${sourceListingSha256(serialized)}` };
|
|
}
|
|
|
|
function handleCreate(args: string[]): void {
|
|
const flags = parseFlags(args);
|
|
const slug = validateSlug(flags.slug);
|
|
if (!flags.base) errorWithSlug(slug, "Missing --base <branch>");
|
|
const swarmValues = [
|
|
flags["swarm-unit"],
|
|
flags["swarm-batch"],
|
|
flags["swarm-stage"],
|
|
flags["swarm-floor"],
|
|
];
|
|
const hasSwarmValue = swarmValues.some((value) => value !== undefined);
|
|
if (hasSwarmValue && swarmValues.some((value) => value === undefined)) {
|
|
errorWithSlug(
|
|
slug,
|
|
"Swarm worktree creation requires --swarm-unit, --swarm-batch, --swarm-stage, and --swarm-floor together",
|
|
);
|
|
}
|
|
const swarm =
|
|
hasSwarmValue
|
|
? {
|
|
unit: flags["swarm-unit"],
|
|
batch: flags["swarm-batch"],
|
|
stage: flags["swarm-stage"],
|
|
floor: flags["swarm-floor"],
|
|
}
|
|
: null;
|
|
if (swarm !== null) {
|
|
const unitError = validateUnitName(swarm.unit);
|
|
if (unitError !== null) errorWithSlug(slug, unitError);
|
|
if (boltSlugForUnit(swarm.unit) !== slug) {
|
|
errorWithSlug(
|
|
slug,
|
|
`Swarm unit ${JSON.stringify(swarm.unit)} does not map to Bolt slug ${JSON.stringify(slug)}`,
|
|
);
|
|
}
|
|
if (!/^[1-9][0-9]*$/.test(swarm.batch)) {
|
|
errorWithSlug(slug, "Swarm batch must be a positive integer");
|
|
}
|
|
if (!swarm.stage || !swarm.floor) {
|
|
errorWithSlug(slug, "Swarm stage and run floor must be non-empty");
|
|
}
|
|
}
|
|
|
|
const pd = resolveProjectDir(projectDir);
|
|
const intentRecord = relativeRecordDir(pd, flags.intent, flags.space);
|
|
// P7: anchor every git op to the target sibling repo (or the projectDir for a
|
|
// legacy single-repo intent). The guard is evaluated against that same checkout.
|
|
const repoTarget = resolveRepoTarget(pd, flags, slug);
|
|
const repoCwd = repoTarget.cwd;
|
|
const creatingGitCommonDir = gitCommonDirRealpath(repoCwd);
|
|
if (creatingGitCommonDir === null) {
|
|
errorWithSlug(slug, "Cannot resolve the creating repository common dir.");
|
|
}
|
|
assertNotSiblingWorktree(repoCwd);
|
|
|
|
// Pre-audit checks: every failure here exits without emitting.
|
|
const baseExists = runGit(["rev-parse", "--verify", flags.base], repoCwd);
|
|
if (!baseExists.ok) {
|
|
errorWithSlug(slug, `Base branch does not exist locally: ${flags.base}`);
|
|
}
|
|
// Resolve the exact commit object before emitting or creating anything. The
|
|
// durable per-worktree metadata lets swarm finalize read the fork point even
|
|
// if the human-readable base branch moves later.
|
|
const baseCommitResult = runGit(
|
|
["rev-parse", "--verify", `${flags.base}^{commit}`],
|
|
repoCwd,
|
|
);
|
|
if (!baseCommitResult.ok) {
|
|
errorWithSlug(slug, `Base branch does not resolve to a commit: ${flags.base}`);
|
|
}
|
|
const baseCommit = baseCommitResult.stdout.trim();
|
|
if (!/^[0-9a-f]{40,64}$/.test(baseCommit)) {
|
|
errorWithSlug(slug, `Base branch resolved to an invalid commit id: ${flags.base}`);
|
|
}
|
|
const rawBase = rawBaseSourceListing(
|
|
repoCwd,
|
|
baseCommit,
|
|
repoTarget.repo === null,
|
|
);
|
|
if (rawBase === null) {
|
|
errorWithSlug(slug, `Base source listing could not be computed for: ${flags.base}`);
|
|
}
|
|
|
|
const wtPath = worktreePath(pd, slug);
|
|
if (existsSync(wtPath)) {
|
|
errorWithSlug(
|
|
slug,
|
|
`Worktree directory already exists: ${wtPath}. If BOLT_COMPLETED was recorded ` +
|
|
"without AUDIT_MERGED, finish the existing Bolt complete/merge and audit-merge " +
|
|
"path instead of creating another worktree.",
|
|
);
|
|
}
|
|
|
|
const branchName = `bolt-${slug}`;
|
|
const branchExists = runGit(["rev-parse", "--verify", `refs/heads/${branchName}`], repoCwd);
|
|
if (branchExists.ok) {
|
|
errorWithSlug(slug, `Branch already exists: ${branchName}`);
|
|
}
|
|
|
|
// Audit-first: emit BEFORE git so a kill-9 between emit and git surfaces
|
|
// as "phantom WORKTREE_CREATED" reconciled by doctor (audit-of-intent
|
|
// semantics — see docs/reference/12-state-machine.md).
|
|
let auditTs: string;
|
|
try {
|
|
auditTs = emitAudit(pd, "WORKTREE_CREATED", {
|
|
"Bolt slug": slug,
|
|
"Worktree path": auditWorktreePath(pd, wtPath),
|
|
"Branch name": branchName,
|
|
"Base branch": flags.base,
|
|
"Base commit": baseCommit,
|
|
"Base Source Listing": rawBase.hash,
|
|
Repo: repoTarget.repo ?? "-",
|
|
...(intentRecord ? { "Intent record": intentRecord } : {}),
|
|
...(swarm
|
|
? {
|
|
"Swarm Unit": swarm.unit,
|
|
"Swarm Batch": swarm.batch,
|
|
"Swarm Stage": swarm.stage,
|
|
"Swarm Run floor": swarm.floor,
|
|
}
|
|
: {}),
|
|
}, flags.intent, flags.space);
|
|
} catch (e) {
|
|
errorWithSlug(slug, `Audit emission failed: ${errorMessage(e)}`);
|
|
}
|
|
|
|
// Create from the immutable object just attested above. Keeping flags.base
|
|
// here would allow a concurrently-moved branch to fork a different tree than
|
|
// WORKTREE_CREATED/worktree-meta.json record.
|
|
const add = runGit(["worktree", "add", wtPath, "-b", branchName, baseCommit], repoCwd);
|
|
if (!add.ok) {
|
|
if (
|
|
existsSync(wtPath) ||
|
|
repositoryRegistersBoltWorktree(pd, repoCwd, slug)
|
|
) {
|
|
runGit(["worktree", "remove", "--force", wtPath], repoCwd);
|
|
}
|
|
const leakedBranch = runGit(
|
|
["rev-parse", "--verify", `refs/heads/${branchName}`],
|
|
repoCwd,
|
|
);
|
|
if (leakedBranch.ok && leakedBranch.stdout.trim() === baseCommit) {
|
|
runGit(
|
|
["update-ref", "-d", `refs/heads/${branchName}`, baseCommit],
|
|
repoCwd,
|
|
);
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
`git worktree add failed: ${add.stderr.trim() || add.stdout.trim() || `exit ${add.code}`}`
|
|
);
|
|
}
|
|
|
|
const metaPath = join(wtPath, ".aidlc", WORKTREE_META_FILENAME);
|
|
const listingPath = join(wtPath, ".aidlc", WORKTREE_BASE_LISTING_FILENAME);
|
|
try {
|
|
mkdirSync(dirname(metaPath), { recursive: true });
|
|
writeFileAtomic(listingPath, rawBase.serialized);
|
|
writeFileAtomic(
|
|
metaPath,
|
|
`${JSON.stringify(
|
|
{
|
|
version: 1,
|
|
boltSlug: slug,
|
|
baseBranch: flags.base,
|
|
baseCommit,
|
|
baseSourceListing: rawBase.hash,
|
|
repoSelector: repoTarget.repo,
|
|
gitCommonDirHash: gitCommonDirHash(creatingGitCommonDir),
|
|
...(intentRecord ? { intentRecord } : {}),
|
|
...(swarm
|
|
? {
|
|
swarmUnit: swarm.unit,
|
|
swarmBatch: swarm.batch,
|
|
swarmStage: swarm.stage,
|
|
swarmFloor: swarm.floor,
|
|
}
|
|
: {}),
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
} catch (e) {
|
|
errorWithSlug(slug, `Worktree metadata write failed: ${errorMessage(e)}`);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: "WORKTREE_CREATED",
|
|
slug,
|
|
worktree_path: wtPath,
|
|
branch: branchName,
|
|
base: flags.base,
|
|
base_commit: baseCommit,
|
|
base_source_listing: rawBase.hash,
|
|
audit_timestamp: auditTs,
|
|
})
|
|
);
|
|
}
|
|
|
|
// --- Subcommand: merge ---
|
|
//
|
|
// Usage:
|
|
// aidlc-worktree merge --slug <slug> --target <branch> --strategy <squash|merge|rebase>
|
|
// [--message <msg>] [--repo <name>] [--intent <dir>] [--space <name>]
|
|
//
|
|
// --repo (P7): the sibling repo the merge lands in — same resolution as `create`.
|
|
// Refuse a source merge whose worktree no longer holds the bytes that
|
|
// converged. Reads the newest SWARM_UNIT_CONVERGED for this unit; a Bolt that
|
|
// never went through the swarm has none and passes straight through, and a
|
|
// convergence row from before this field existed carries no fingerprint and
|
|
// keeps the pre-existing behaviour. Off-switch: AIDLC_SKIP_SOURCE_FRESHNESS=1.
|
|
interface BoundConvergedSourceRecord {
|
|
kind: "bound";
|
|
fingerprint: string;
|
|
commit: string;
|
|
unit: string;
|
|
batch: string;
|
|
stage: string;
|
|
floor: string;
|
|
}
|
|
|
|
interface BypassedConvergedSourceRecord {
|
|
kind: "bypass";
|
|
unit: string;
|
|
batch: string;
|
|
stage: string;
|
|
floor: string;
|
|
}
|
|
|
|
type ConvergedSourceRecord =
|
|
| BoundConvergedSourceRecord
|
|
| BypassedConvergedSourceRecord;
|
|
|
|
function convergedUnitName(
|
|
pd: string,
|
|
slug: string,
|
|
intent?: string,
|
|
space?: string,
|
|
): string {
|
|
const resolution = resolveBoltDag(pd, intent, space);
|
|
if (resolution.state !== "ok") return slug;
|
|
const match = resolution.units.find((unit) => boltSlugForUnit(unit) === slug);
|
|
return match ?? slug;
|
|
}
|
|
|
|
type WorktreeAuditRow = ReturnType<typeof readAuditShardEvents>[number];
|
|
|
|
interface ScopedWorktreeAuditRow extends WorktreeAuditRow {
|
|
authorityIntent?: string;
|
|
authoritySpace: string;
|
|
}
|
|
|
|
function sortWorktreeAuditRows(rows: WorktreeAuditRow[]): WorktreeAuditRow[] {
|
|
return rows.sort((a, b) => {
|
|
if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp ? -1 : 1;
|
|
if (a.shardIndex !== b.shardIndex) return a.shardIndex - b.shardIndex;
|
|
return a.pos - b.pos;
|
|
});
|
|
}
|
|
|
|
function latestUnambiguousRow(
|
|
slug: string,
|
|
label: string,
|
|
rows: WorktreeAuditRow[],
|
|
identity: (row: WorktreeAuditRow) => string,
|
|
): WorktreeAuditRow | null {
|
|
if (rows.length === 0) return null;
|
|
sortWorktreeAuditRows(rows);
|
|
const latestTimestamp = rows.at(-1)?.timestamp as string;
|
|
const latest = rows.filter((row) => row.timestamp === latestTimestamp);
|
|
if (
|
|
new Set(latest.map((row) => row.shard)).size > 1 &&
|
|
new Set(latest.map(identity)).size !== 1
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: same-second cross-shard ${label} authority is ambiguous`,
|
|
);
|
|
}
|
|
return latest.at(-1) ?? null;
|
|
}
|
|
|
|
function allWorktreeAuditRows(
|
|
pd: string,
|
|
): { rows: ScopedWorktreeAuditRow[]; unreadableShards: string[] } {
|
|
const rows: ScopedWorktreeAuditRow[] = [];
|
|
const unreadable = new Set<string>();
|
|
const seenRows = new Set<string>();
|
|
for (const { name: space } of listSpaces(pd)) {
|
|
const intentNames = new Set(listIntentDirs(pd, space));
|
|
try {
|
|
for (const entry of readdirSync(intentsDir(pd, space), {
|
|
withFileTypes: true,
|
|
})) {
|
|
if (
|
|
entry.isDirectory() &&
|
|
existsSync(join(intentsDir(pd, space), entry.name, "audit"))
|
|
) {
|
|
intentNames.add(entry.name);
|
|
}
|
|
}
|
|
} catch {
|
|
// The explicit-space read below reports an unreadable aggregate root.
|
|
}
|
|
for (const intent of [undefined, ...[...intentNames].sort()]) {
|
|
const failures: string[] = [];
|
|
const selectedRows = readAuditShardEvents(pd, intent, space, failures);
|
|
for (const failure of failures) unreadable.add(failure);
|
|
for (const row of selectedRows) {
|
|
const key = `${row.shard}\0${row.pos}`;
|
|
if (seenRows.has(key)) continue;
|
|
seenRows.add(key);
|
|
const shardRelative = relative(intentsDir(pd, space), row.shard)
|
|
.replaceAll("\\", "/");
|
|
const parts = shardRelative.split("/");
|
|
rows.push({
|
|
...row,
|
|
authoritySpace: space,
|
|
...(parts.length >= 3 &&
|
|
parts[0] !== ".." &&
|
|
parts[1] === "audit"
|
|
? { authorityIntent: parts[0] }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { rows, unreadableShards: [...unreadable].sort() };
|
|
}
|
|
|
|
function repoSelectorKey(repo: string | null): string {
|
|
return repo ?? "\0root";
|
|
}
|
|
|
|
function repoSelectorLabel(repo: string | null): string {
|
|
return repo ?? "-";
|
|
}
|
|
|
|
interface RepositoryBoltEvidence {
|
|
branch: boolean;
|
|
durable: boolean;
|
|
registered: boolean;
|
|
retained: boolean;
|
|
}
|
|
|
|
function repositoryRegistersBoltWorktree(
|
|
pd: string,
|
|
repoCwd: string,
|
|
slug: string,
|
|
): boolean {
|
|
const listed = runGit(["worktree", "list", "--porcelain"], repoCwd);
|
|
if (!listed.ok) return false;
|
|
const branchName = `bolt-${slug}`;
|
|
const expectedPath = pathKey(worktreePath(pd, slug));
|
|
for (const line of listed.stdout.split(/\r?\n/)) {
|
|
if (
|
|
(line.startsWith("worktree ") &&
|
|
pathKey(line.slice("worktree ".length)) === expectedPath) ||
|
|
line === `branch refs/heads/${branchName}`
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function repositoryBoltEvidence(
|
|
pd: string,
|
|
repo: string | null,
|
|
slug: string,
|
|
): RepositoryBoltEvidence {
|
|
const cwd = repo === null ? pd : repoDir(pd, repo);
|
|
if (!runGit(["rev-parse", "--git-dir"], cwd).ok) {
|
|
return {
|
|
branch: false,
|
|
durable: false,
|
|
registered: false,
|
|
retained: false,
|
|
};
|
|
}
|
|
const branchName = `bolt-${slug}`;
|
|
const branch = runGit(
|
|
["rev-parse", "--verify", `refs/heads/${branchName}`],
|
|
cwd,
|
|
).ok;
|
|
const retained = retainedSourceRefs(cwd, slug);
|
|
const retainedPresent = retained !== null && retained.length > 0;
|
|
const registered = repositoryRegistersBoltWorktree(pd, cwd, slug);
|
|
return {
|
|
branch,
|
|
durable: branch || retainedPresent || registered,
|
|
registered,
|
|
retained: retainedPresent,
|
|
};
|
|
}
|
|
|
|
interface DiscardCreationAuthority {
|
|
evidenceExists: boolean;
|
|
intent?: string;
|
|
repo?: string | null;
|
|
space?: string;
|
|
}
|
|
|
|
function discardCreationAuthority(
|
|
pd: string,
|
|
slug: string,
|
|
recorded: ReturnType<typeof recordedWorktreeSelector>,
|
|
explicitRepo?: string,
|
|
): DiscardCreationAuthority {
|
|
const audit = allWorktreeAuditRows(pd);
|
|
const creationRows = audit.rows.filter(
|
|
(row) =>
|
|
row.event === "WORKTREE_CREATED" &&
|
|
auditBlockField(row.block, "Bolt slug") === slug,
|
|
);
|
|
const selectors = new Map<string, string | null>();
|
|
selectors.set(repoSelectorKey(null), null);
|
|
for (const repo of discoverSiblingRepos(pd)) {
|
|
selectors.set(repoSelectorKey(repo), repo);
|
|
}
|
|
for (const row of creationRows) {
|
|
const field = auditBlockField(row.block, "Repo");
|
|
if (field === "-") {
|
|
selectors.set(repoSelectorKey(null), null);
|
|
} else if (field !== null && isValidRepoName(field)) {
|
|
selectors.set(repoSelectorKey(field), field);
|
|
}
|
|
}
|
|
if (recorded?.repoSelector !== undefined) {
|
|
selectors.set(
|
|
repoSelectorKey(recorded.repoSelector),
|
|
recorded.repoSelector,
|
|
);
|
|
}
|
|
if (explicitRepo !== undefined && isValidRepoName(explicitRepo)) {
|
|
selectors.set(repoSelectorKey(explicitRepo), explicitRepo);
|
|
}
|
|
|
|
const evidence = new Map<string, RepositoryBoltEvidence>();
|
|
for (const [key, repo] of selectors) {
|
|
evidence.set(key, repositoryBoltEvidence(pd, repo, slug));
|
|
}
|
|
const evidenceExists =
|
|
existsSync(worktreePath(pd, slug)) ||
|
|
[...evidence.values()].some((value) => value.durable);
|
|
if (!evidenceExists) return { evidenceExists: false };
|
|
|
|
const wellShapedRows = creationRows.filter((row) => {
|
|
const recordedPath = auditBlockField(row.block, "Worktree path");
|
|
const branch = auditBlockField(row.block, "Branch name");
|
|
return (
|
|
recordedPath !== null &&
|
|
pathKey(resolveAuditWorktreePath(pd, recordedPath)) ===
|
|
pathKey(worktreePath(pd, slug)) &&
|
|
branch === `bolt-${slug}`
|
|
);
|
|
});
|
|
const rowsByTimestamp = new Map<string, ScopedWorktreeAuditRow[]>();
|
|
for (const row of wellShapedRows) {
|
|
const current = rowsByTimestamp.get(row.timestamp) ?? [];
|
|
current.push(row);
|
|
rowsByTimestamp.set(row.timestamp, current);
|
|
}
|
|
for (const tied of rowsByTimestamp.values()) {
|
|
const durableRepos = new Set<string>();
|
|
const shards = new Set<string>();
|
|
for (const row of tied) {
|
|
const field = auditBlockField(row.block, "Repo");
|
|
const repo =
|
|
field === "-"
|
|
? null
|
|
: field !== null && isValidRepoName(field)
|
|
? field
|
|
: undefined;
|
|
if (
|
|
repo !== undefined &&
|
|
evidence.get(repoSelectorKey(repo))?.durable === true
|
|
) {
|
|
durableRepos.add(repoSelectorKey(repo));
|
|
shards.add(row.shard);
|
|
}
|
|
}
|
|
if (durableRepos.size > 1 && shards.size > 1) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to discard: same-second cross-shard WORKTREE_CREATED repository authority is ambiguous",
|
|
);
|
|
}
|
|
}
|
|
const corroborated = wellShapedRows.filter((row) => {
|
|
const field = auditBlockField(row.block, "Repo");
|
|
const repo =
|
|
field === "-"
|
|
? null
|
|
: field !== null && isValidRepoName(field)
|
|
? field
|
|
: undefined;
|
|
return (
|
|
repo !== undefined &&
|
|
evidence.get(repoSelectorKey(repo))?.durable === true
|
|
);
|
|
});
|
|
const corroboratedRepos = new Map<string, string | null>();
|
|
for (const row of corroborated) {
|
|
const field = auditBlockField(row.block, "Repo") as string;
|
|
const repo = field === "-" ? null : field;
|
|
corroboratedRepos.set(repoSelectorKey(repo), repo);
|
|
}
|
|
if (corroboratedRepos.size > 1) {
|
|
const labels = [...corroboratedRepos.values()]
|
|
.map(repoSelectorLabel)
|
|
.sort()
|
|
.map((value) => JSON.stringify(value))
|
|
.join(", ");
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: corroborated WORKTREE_CREATED rows disagree on the creating repository (${labels})`,
|
|
);
|
|
}
|
|
|
|
let authority: ScopedWorktreeAuditRow | undefined;
|
|
if (corroborated.length > 0) {
|
|
authority = [...corroborated].sort((a, b) => {
|
|
if (a.timestamp !== b.timestamp) {
|
|
return a.timestamp < b.timestamp ? -1 : 1;
|
|
}
|
|
if (a.shard !== b.shard) return a.shard < b.shard ? -1 : 1;
|
|
return a.pos - b.pos;
|
|
}).at(-1);
|
|
}
|
|
const repoField =
|
|
authority === undefined
|
|
? null
|
|
: auditBlockField(authority.block, "Repo");
|
|
const auditRepo =
|
|
repoField === "-"
|
|
? null
|
|
: repoField !== null && isValidRepoName(repoField)
|
|
? repoField
|
|
: undefined;
|
|
const hasRepoRow = creationRows.some(
|
|
(row) => auditBlockField(row.block, "Repo") !== null,
|
|
);
|
|
const retainedEvidence = [...evidence.values()].some(
|
|
(value) => value.retained,
|
|
);
|
|
const durableRepoEntries = [...evidence.entries()].filter(
|
|
([, value]) => value.durable,
|
|
);
|
|
const durableRepos = durableRepoEntries.map(([key]) => key);
|
|
const readableCreationRepos = [
|
|
...new Set(
|
|
creationRows
|
|
.map((row) => auditBlockField(row.block, "Repo"))
|
|
.filter((repo): repo is string => repo !== null)
|
|
.map((repo) => (repo === "-" ? null : repo))
|
|
.filter(
|
|
(repo): repo is string | null =>
|
|
repo === null || isValidRepoName(repo),
|
|
),
|
|
),
|
|
];
|
|
const modernAuthority =
|
|
recorded?.repoSelector !== undefined ||
|
|
hasRepoRow ||
|
|
retainedEvidence ||
|
|
audit.unreadableShards.length > 0;
|
|
if (
|
|
auditRepo === undefined &&
|
|
modernAuthority
|
|
) {
|
|
if (audit.unreadableShards.length > 0) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to discard: durable Bolt evidence exists, but WORKTREE_CREATED Repo authority is unreadable; restore readable audit shards and retry",
|
|
);
|
|
}
|
|
if (readableCreationRepos.length > 0) {
|
|
const repos = readableCreationRepos
|
|
.map(repoSelectorLabel)
|
|
.sort()
|
|
.map((repo) => JSON.stringify(repo))
|
|
.join(", ");
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: a readable WORKTREE_CREATED record names ${repos}, but no worktree registration, bolt-${slug} branch, or retained reviewed-source ref remains there to corroborate it. If this Bolt was already discarded, this is expected; otherwise inspect ${repos} for the bolt-${slug} branch`,
|
|
);
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to discard: durable Bolt evidence exists, but no corroborated WORKTREE_CREATED Repo authority is available",
|
|
);
|
|
}
|
|
if (auditRepo === undefined && !modernAuthority) {
|
|
if (durableRepos.length > 1) {
|
|
const repos = durableRepoEntries
|
|
.map(([key]) => repoSelectorLabel(selectors.get(key) ?? null))
|
|
.sort();
|
|
const selectedKey =
|
|
explicitRepo === undefined
|
|
? undefined
|
|
: repoSelectorKey(explicitRepo);
|
|
const selectedEvidence =
|
|
selectedKey === undefined
|
|
? undefined
|
|
: evidence.get(selectedKey);
|
|
const creatingCandidates = durableRepoEntries.filter(
|
|
([, value]) => value.registered || value.retained,
|
|
);
|
|
if (
|
|
selectedKey === undefined ||
|
|
selectedEvidence === undefined ||
|
|
!selectedEvidence.durable ||
|
|
creatingCandidates.length !== 1 ||
|
|
creatingCandidates[0][0] !== selectedKey
|
|
) {
|
|
const labels = repos.map((repo) => JSON.stringify(repo)).join(", ");
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: pre-upgrade Bolt evidence spans repositories ${labels} without WORKTREE_CREATED Repo authority. Delete the stray bolt-${slug} branch in the repository that is not the creating one, then retry with --repo <creating-repo>`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
explicitRepo !== undefined &&
|
|
durableRepos.length === 1 &&
|
|
durableRepos[0] !== repoSelectorKey(explicitRepo)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: selected repository ${JSON.stringify(explicitRepo)} contradicts the only repository carrying pre-upgrade Bolt evidence`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
auditRepo !== undefined &&
|
|
recorded?.repoSelector !== undefined &&
|
|
recorded.repoSelector !== auditRepo
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: worktree metadata repository ${JSON.stringify(repoSelectorLabel(recorded.repoSelector))} does not match corroborated WORKTREE_CREATED repository ${JSON.stringify(repoSelectorLabel(auditRepo))}`,
|
|
);
|
|
}
|
|
return {
|
|
evidenceExists: true,
|
|
...(auditRepo === undefined
|
|
? {}
|
|
: {
|
|
repo: auditRepo,
|
|
space: authority?.authoritySpace,
|
|
...(authority?.authorityIntent === undefined
|
|
? {}
|
|
: { intent: authority.authorityIntent }),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function rowAfter(candidate: WorktreeAuditRow, boundary: WorktreeAuditRow): boolean {
|
|
if (candidate.timestamp !== boundary.timestamp) {
|
|
return candidate.timestamp > boundary.timestamp;
|
|
}
|
|
return candidate.shard === boundary.shard && candidate.pos > boundary.pos;
|
|
}
|
|
|
|
function convergedSourceRecord(
|
|
pd: string,
|
|
slug: string,
|
|
repoCwd: string,
|
|
intent?: string,
|
|
space?: string,
|
|
selectedRepo: string | null = null,
|
|
): ConvergedSourceRecord | null {
|
|
const wtPath = worktreePath(pd, slug);
|
|
let worktreeMeta: {
|
|
boltSlug: string;
|
|
baseCommit: string;
|
|
baseSourceListing: string;
|
|
intentRecord?: string;
|
|
swarmUnit?: string;
|
|
swarmBatch?: string;
|
|
swarmStage?: string;
|
|
swarmFloor?: string;
|
|
repoSelector?: string | null;
|
|
gitCommonDir?: string;
|
|
gitCommonDirHash?: string;
|
|
} | null = null;
|
|
const metaPath = join(wtPath, ".aidlc", WORKTREE_META_FILENAME);
|
|
if (existsSync(metaPath)) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
} catch {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current worktree metadata is malformed",
|
|
);
|
|
}
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
Array.isArray(parsed) ||
|
|
(parsed as Record<string, unknown>).boltSlug !== slug ||
|
|
typeof (parsed as Record<string, unknown>).baseCommit !== "string" ||
|
|
typeof (parsed as Record<string, unknown>).baseSourceListing !== "string"
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current worktree metadata is incomplete",
|
|
);
|
|
}
|
|
worktreeMeta = {
|
|
boltSlug: slug,
|
|
baseCommit: (parsed as Record<string, string>).baseCommit,
|
|
baseSourceListing: (parsed as Record<string, string>).baseSourceListing,
|
|
...(typeof (parsed as Record<string, unknown>).intentRecord === "string"
|
|
? { intentRecord: (parsed as Record<string, string>).intentRecord }
|
|
: {}),
|
|
...("repoSelector" in (parsed as Record<string, unknown>)
|
|
? {
|
|
repoSelector: (parsed as Record<string, unknown>)
|
|
.repoSelector as string | null,
|
|
}
|
|
: {}),
|
|
...(typeof (parsed as Record<string, unknown>).gitCommonDir === "string"
|
|
? { gitCommonDir: (parsed as Record<string, string>).gitCommonDir }
|
|
: {}),
|
|
...(typeof (parsed as Record<string, unknown>).gitCommonDirHash === "string"
|
|
? {
|
|
gitCommonDirHash: (parsed as Record<string, string>)
|
|
.gitCommonDirHash,
|
|
}
|
|
: {}),
|
|
...(typeof (parsed as Record<string, unknown>).swarmUnit === "string"
|
|
? {
|
|
swarmUnit: (parsed as Record<string, string>).swarmUnit,
|
|
swarmBatch: (parsed as Record<string, string>).swarmBatch,
|
|
swarmStage: (parsed as Record<string, string>).swarmStage,
|
|
swarmFloor: (parsed as Record<string, string>).swarmFloor,
|
|
}
|
|
: {}),
|
|
};
|
|
const swarmMetaFields = [
|
|
worktreeMeta.swarmUnit,
|
|
worktreeMeta.swarmBatch,
|
|
worktreeMeta.swarmStage,
|
|
worktreeMeta.swarmFloor,
|
|
];
|
|
const swarmMetaCount = swarmMetaFields.filter(
|
|
(value) => value !== undefined,
|
|
).length;
|
|
if (
|
|
"intentRecord" in worktreeMeta &&
|
|
(!worktreeMeta.intentRecord ||
|
|
worktreeMeta.intentRecord !== relativeRecordDir(pd, intent, space))
|
|
) {
|
|
const expected = recordedWorktreeSelector(pd, slug);
|
|
const recovery =
|
|
typeof expected?.space === "string" &&
|
|
typeof expected.intent === "string"
|
|
? ` Retry with --space ${expected.space} --intent ${expected.intent}.`
|
|
: "";
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: selected intent ${JSON.stringify(relativeRecordDir(pd, intent, space))} does not match worktree provenance ${JSON.stringify(worktreeMeta.intentRecord)}.${recovery}`,
|
|
);
|
|
}
|
|
if (
|
|
(swarmMetaCount !== 0 && swarmMetaCount !== swarmMetaFields.length) ||
|
|
(worktreeMeta.swarmUnit !== undefined &&
|
|
(validateUnitName(worktreeMeta.swarmUnit) !== null ||
|
|
boltSlugForUnit(worktreeMeta.swarmUnit) !== slug ||
|
|
!/^[1-9][0-9]*$/.test(worktreeMeta.swarmBatch ?? "") ||
|
|
!worktreeMeta.swarmStage ||
|
|
!worktreeMeta.swarmFloor))
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current worktree metadata selector/swarm provenance is invalid",
|
|
);
|
|
}
|
|
}
|
|
const retained = retainedSourceRefs(repoCwd, slug);
|
|
if (retained === null) {
|
|
errorWithSlug(slug, "refusing to merge: reviewed-source ref enumeration failed");
|
|
}
|
|
let requiresSwarmAuthority =
|
|
retained.length > 0 || worktreeMeta?.swarmUnit !== undefined;
|
|
let rows: WorktreeAuditRow[];
|
|
try {
|
|
rows = readAuditShardEvents(pd, intent, space);
|
|
} catch {
|
|
if (requiresSwarmAuthority) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current modern worktree audit authority is unreadable",
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const creation = latestUnambiguousRow(
|
|
slug,
|
|
"WORKTREE_CREATED",
|
|
rows.filter(
|
|
(row) =>
|
|
row.event === "WORKTREE_CREATED" &&
|
|
auditBlockField(row.block, "Bolt slug") === slug &&
|
|
(() => {
|
|
const recordedPath = auditBlockField(row.block, "Worktree path");
|
|
return (
|
|
recordedPath !== null &&
|
|
pathKey(resolveAuditWorktreePath(pd, recordedPath)) ===
|
|
pathKey(wtPath)
|
|
);
|
|
})(),
|
|
),
|
|
(row) =>
|
|
[
|
|
auditBlockField(row.block, "Base commit") ?? "",
|
|
auditBlockField(row.block, "Base Source Listing") ?? "",
|
|
auditBlockField(row.block, "Branch name") ?? "",
|
|
].join("\0"),
|
|
);
|
|
if (creation === null) {
|
|
if (requiresSwarmAuthority) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current modern worktree has no WORKTREE_CREATED authority",
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
const creationSwarmFields = [
|
|
auditBlockField(creation.block, "Swarm Unit"),
|
|
auditBlockField(creation.block, "Swarm Batch"),
|
|
auditBlockField(creation.block, "Swarm Stage"),
|
|
auditBlockField(creation.block, "Swarm Run floor"),
|
|
];
|
|
const creationSwarmCount = creationSwarmFields.filter(
|
|
(value) => value !== null,
|
|
).length;
|
|
if (creationSwarmCount !== 0 && creationSwarmCount !== creationSwarmFields.length) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: WORKTREE_CREATED carries incomplete swarm provenance",
|
|
);
|
|
}
|
|
const creationSwarmUnit = creationSwarmFields[0] ?? undefined;
|
|
const creationSwarmBatch = creationSwarmFields[1] ?? undefined;
|
|
const creationSwarmStage = creationSwarmFields[2] ?? undefined;
|
|
const creationSwarmFloor = creationSwarmFields[3] ?? undefined;
|
|
if (
|
|
creationSwarmUnit !== undefined &&
|
|
(validateUnitName(creationSwarmUnit) !== null ||
|
|
boltSlugForUnit(creationSwarmUnit) !== slug ||
|
|
!/^[1-9][0-9]*$/.test(creationSwarmBatch ?? "") ||
|
|
!creationSwarmStage ||
|
|
!creationSwarmFloor)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: WORKTREE_CREATED swarm provenance is invalid",
|
|
);
|
|
}
|
|
requiresSwarmAuthority ||= creationSwarmUnit !== undefined;
|
|
|
|
const mappedUnits = new Set<string>();
|
|
for (const row of rows) {
|
|
if (!rowAfter(row, creation)) continue;
|
|
if (row.event === "SWARM_STARTED") {
|
|
for (const unit of (auditBlockField(row.block, "Unit names") ?? "")
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)) {
|
|
if (boltSlugForUnit(unit) === slug) mappedUnits.add(unit);
|
|
}
|
|
} else if (row.event === "SWARM_UNIT_CONVERGED") {
|
|
const unit = auditBlockField(row.block, "Unit name");
|
|
if (unit && boltSlugForUnit(unit) === slug) mappedUnits.add(unit);
|
|
}
|
|
}
|
|
if (mappedUnits.size > 1) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: multiple swarm Units map to Bolt slug "${slug}"`,
|
|
);
|
|
}
|
|
requiresSwarmAuthority ||= mappedUnits.size > 0;
|
|
const creationBaseCommit = auditBlockField(creation.block, "Base commit");
|
|
const creationBaseListing = auditBlockField(
|
|
creation.block,
|
|
"Base Source Listing",
|
|
);
|
|
const creationModern =
|
|
creationBaseCommit !== null || creationBaseListing !== null;
|
|
const durableModernWorktree = worktreeMeta !== null || retained.length > 0;
|
|
const modernWorktree = durableModernWorktree || creationModern;
|
|
if (modernWorktree && (!creationBaseCommit || !creationBaseListing)) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: durable modern worktree evidence is inconsistent with the current WORKTREE_CREATED source attestation",
|
|
);
|
|
}
|
|
|
|
if (modernWorktree) {
|
|
if (worktreeMeta === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current worktree metadata is missing",
|
|
);
|
|
}
|
|
if (
|
|
!("repoSelector" in worktreeMeta) ||
|
|
(
|
|
typeof worktreeMeta.gitCommonDir !== "string" &&
|
|
typeof worktreeMeta.gitCommonDirHash !== "string"
|
|
)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: durable modern worktree evidence lacks creating-repository binding; restart the Bolt attempt",
|
|
);
|
|
}
|
|
const selectedCommonDir = gitCommonDirRealpath(repoCwd);
|
|
const worktreeCommonDir = gitCommonDirRealpath(wtPath);
|
|
const metadataCommonDirHash =
|
|
typeof worktreeMeta.gitCommonDirHash === "string" &&
|
|
/^[0-9a-f]{64}$/.test(worktreeMeta.gitCommonDirHash)
|
|
? worktreeMeta.gitCommonDirHash
|
|
: typeof worktreeMeta.gitCommonDir === "string"
|
|
? gitCommonDirHash(worktreeMeta.gitCommonDir)
|
|
: null;
|
|
if (worktreeMeta.repoSelector !== selectedRepo) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: selected repository ${JSON.stringify(selectedRepo ?? "-")} does not match creating repository ${JSON.stringify(worktreeMeta.repoSelector ?? "-")}; retry with the creating --repo selector`,
|
|
);
|
|
}
|
|
if (selectedCommonDir === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: could not resolve the selected checkout git directory for repository ${JSON.stringify(selectedRepo ?? "-")} after partial cleanup; restore the selected checkout registration, then retry`,
|
|
);
|
|
}
|
|
if (worktreeCommonDir === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: could not resolve the Bolt worktree git directory after partial cleanup; restore the worktree registration or discard and restart the Bolt attempt",
|
|
);
|
|
}
|
|
if (
|
|
metadataCommonDirHash === null ||
|
|
metadataCommonDirHash !== gitCommonDirHash(selectedCommonDir) ||
|
|
metadataCommonDirHash !== gitCommonDirHash(worktreeCommonDir)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: recorded creating-repository provenance does not match the selected repository or Bolt worktree; restore the original repository/worktree binding or discard and restart the Bolt attempt",
|
|
);
|
|
}
|
|
if (
|
|
auditBlockField(creation.block, "Repo") !==
|
|
(worktreeMeta.repoSelector ?? "-")
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: WORKTREE_CREATED repository selector does not match worktree metadata",
|
|
);
|
|
}
|
|
if (
|
|
worktreeMeta.baseCommit !== creationBaseCommit ||
|
|
worktreeMeta.baseSourceListing !== creationBaseListing ||
|
|
(worktreeMeta.intentRecord !== undefined &&
|
|
auditBlockField(creation.block, "Intent record") !==
|
|
worktreeMeta.intentRecord) ||
|
|
(worktreeMeta.swarmUnit !== creationSwarmUnit ||
|
|
worktreeMeta.swarmBatch !== creationSwarmBatch ||
|
|
worktreeMeta.swarmStage !== creationSwarmStage ||
|
|
worktreeMeta.swarmFloor !== creationSwarmFloor)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current worktree metadata does not match WORKTREE_CREATED",
|
|
);
|
|
}
|
|
}
|
|
const unitName =
|
|
worktreeMeta?.swarmUnit ??
|
|
creationSwarmUnit ??
|
|
mappedUnits.values().next().value ??
|
|
convergedUnitName(pd, slug, intent, space);
|
|
|
|
const boltStart = latestUnambiguousRow(
|
|
slug,
|
|
"BOLT_STARTED",
|
|
rows.filter(
|
|
(row) =>
|
|
row.event === "BOLT_STARTED" &&
|
|
auditBlockField(row.block, "Bolt slug") === slug &&
|
|
rowAfter(row, creation),
|
|
),
|
|
(row) =>
|
|
[
|
|
auditBlockField(row.block, "Batch number") ?? "",
|
|
auditBlockField(row.block, "Base commit") ?? "",
|
|
auditBlockField(row.block, "Base Source Listing") ?? "",
|
|
].join("\0"),
|
|
);
|
|
if (boltStart === null) {
|
|
if (requiresSwarmAuthority) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current modern worktree has no BOLT_STARTED authority",
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
const batch = auditBlockField(boltStart.block, "Batch number");
|
|
const boltUnit = auditBlockField(boltStart.block, "Bolt names");
|
|
if (!batch || !/^[1-9][0-9]*$/.test(batch)) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current BOLT_STARTED has no valid batch number",
|
|
);
|
|
}
|
|
if (
|
|
requiresSwarmAuthority &&
|
|
(!boltUnit ||
|
|
boltUnit.includes(",") ||
|
|
boltSlugForUnit(boltUnit) !== slug ||
|
|
boltUnit !== unitName ||
|
|
(worktreeMeta?.swarmBatch !== undefined &&
|
|
worktreeMeta.swarmBatch !== batch))
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current BOLT_STARTED does not match swarm worktree provenance",
|
|
);
|
|
}
|
|
if (
|
|
modernWorktree &&
|
|
(auditBlockField(boltStart.block, "Base commit") !== creationBaseCommit ||
|
|
auditBlockField(boltStart.block, "Base Source Listing") !==
|
|
creationBaseListing)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current BOLT_STARTED does not match WORKTREE_CREATED",
|
|
);
|
|
}
|
|
|
|
const swarmStart = latestUnambiguousRow(
|
|
slug,
|
|
"SWARM_STARTED",
|
|
rows.filter(
|
|
(row) =>
|
|
row.event === "SWARM_STARTED" &&
|
|
auditBlockField(row.block, "Batch number") === batch &&
|
|
(auditBlockField(row.block, "Unit names") ?? "")
|
|
.split(",")
|
|
.map((unit) => unit.trim())
|
|
.includes(unitName) &&
|
|
rowAfter(row, creation) &&
|
|
rowAfter(row, boltStart),
|
|
),
|
|
(row) =>
|
|
[
|
|
auditBlockField(row.block, "Stage") ?? "",
|
|
auditBlockField(row.block, "Run floor") ?? "",
|
|
auditBlockField(row.block, "Unit names") ?? "",
|
|
].join("\0"),
|
|
);
|
|
if (swarmStart === null) {
|
|
if (requiresSwarmAuthority) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current autonomous worktree has no SWARM_STARTED authority",
|
|
);
|
|
}
|
|
return null; // ordinary non-swarm Bolt
|
|
}
|
|
const stage = auditBlockField(swarmStart.block, "Stage");
|
|
const floor = auditBlockField(swarmStart.block, "Run floor");
|
|
if (!stage || !floor) {
|
|
if (!modernWorktree) return null; // pre-binding swarm migration
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current modern SWARM_STARTED lacks Stage/Run floor authority",
|
|
);
|
|
}
|
|
if (
|
|
worktreeMeta?.swarmStage !== undefined &&
|
|
(worktreeMeta.swarmStage !== stage ||
|
|
worktreeMeta.swarmFloor !== floor ||
|
|
worktreeMeta.swarmBatch !== batch)
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current SWARM_STARTED does not match worktree provenance",
|
|
);
|
|
}
|
|
let selectedState: string;
|
|
try {
|
|
selectedState = readStateFile(pd, intent, space);
|
|
} catch {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: current swarm state is unavailable",
|
|
);
|
|
}
|
|
const currentStage = getField(selectedState, "Current Stage")?.trim();
|
|
const currentFloor = latestMainWorkflowStageRunFloorForProject(
|
|
pd,
|
|
stage,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
intent,
|
|
space,
|
|
);
|
|
if (currentStage !== stage || currentFloor !== floor) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: this convergence belongs to a stale stage attempt",
|
|
);
|
|
}
|
|
|
|
const latest = latestUnambiguousRow(
|
|
slug,
|
|
"SWARM_UNIT_CONVERGED",
|
|
rows.filter(
|
|
(row) =>
|
|
row.event === "SWARM_UNIT_CONVERGED" &&
|
|
auditBlockField(row.block, "Unit name") === unitName &&
|
|
auditBlockField(row.block, "Batch number") === batch &&
|
|
auditBlockField(row.block, "Stage") === stage &&
|
|
auditBlockField(row.block, "Run floor") === floor &&
|
|
rowAfter(row, swarmStart),
|
|
),
|
|
(row) =>
|
|
[
|
|
auditBlockField(row.block, "Source Fingerprint") ?? "",
|
|
auditBlockField(row.block, "Source Commit") ?? "",
|
|
auditBlockField(row.block, "Source Freshness Bypass") ?? "",
|
|
].join("\0"),
|
|
);
|
|
if (latest === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: the current swarm worktree has no correlated convergence authority; rerun finalize",
|
|
);
|
|
}
|
|
|
|
const bypass =
|
|
auditBlockField(latest.block, "Source Freshness Bypass") ?? undefined;
|
|
const fingerprint =
|
|
auditBlockField(latest.block, "Source Fingerprint") ?? undefined;
|
|
const commit = auditBlockField(latest.block, "Source Commit") ?? undefined;
|
|
if (bypass !== undefined) {
|
|
if (bypass !== "true") {
|
|
errorWithSlug(slug, `refusing to merge: invalid Source Freshness Bypass marker "${bypass}"`);
|
|
}
|
|
if (fingerprint || commit) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: convergence mixes bypass and bound source authority",
|
|
);
|
|
}
|
|
if (process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1") {
|
|
return { kind: "bypass", unit: unitName, batch, stage, floor };
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: this convergence was finalized with source freshness bypassed; ` +
|
|
`retry this merge with AIDLC_SKIP_SOURCE_FRESHNESS=1, or run ` +
|
|
`'aidlc-worktree discard --slug ${slug}' and redo the unit from prepare through review/finalize`,
|
|
);
|
|
}
|
|
if (process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1") return null;
|
|
if (!fingerprint || !commit) {
|
|
if (!modernWorktree && !fingerprint && !commit) return null;
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: the current modern convergence row lacks complete immutable source authority; rerun finalize",
|
|
);
|
|
}
|
|
if (fingerprint === UNBINDABLE_FINGERPRINT) {
|
|
errorWithSlug(slug, "refusing to merge: this convergence receipt is unbindable; re-run review and finalize with Git available");
|
|
}
|
|
if (!/^[0-9a-f]{40,64}$/.test(fingerprint) || !/^[0-9a-f]{40,64}$/.test(commit)) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: the current convergence source authority is malformed",
|
|
);
|
|
}
|
|
if (!creationBaseCommit) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: bound convergence has no immutable worktree base commit authority",
|
|
);
|
|
}
|
|
return {
|
|
kind: "bound",
|
|
fingerprint,
|
|
commit,
|
|
unit: unitName,
|
|
batch,
|
|
stage,
|
|
floor,
|
|
};
|
|
}
|
|
|
|
function assertConvergedSourceUnchanged(
|
|
slug: string,
|
|
wtPath: string,
|
|
record: ConvergedSourceRecord | null,
|
|
): string | null {
|
|
if (!record || record.kind === "bypass") return null;
|
|
const current = workspaceSourceFingerprint(wtPath);
|
|
if (current === null || current !== record.fingerprint) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the worktree source no longer matches the state this unit ` +
|
|
`converged with (source-fingerprint mismatch). Re-run the swarm's convergence ` +
|
|
`check for "${slug}" against the current worktree, or discard the worktree.`,
|
|
);
|
|
}
|
|
const object = runGit(["cat-file", "-e", `${record.commit}^{commit}`], wtPath);
|
|
if (!object.ok) {
|
|
errorWithSlug(slug, `refusing to merge: reviewed Source Commit ${record.commit} is unavailable`);
|
|
}
|
|
return record.commit;
|
|
}
|
|
|
|
function sourceListingsEqual(
|
|
left: ReadonlyMap<string, string>,
|
|
right: ReadonlyMap<string, string>,
|
|
): boolean {
|
|
if (left.size !== right.size) return false;
|
|
for (const [key, value] of left) {
|
|
if (!sourceListingEntriesEqual(right.get(key), value)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function changedSourceListingKeys(
|
|
left: ReadonlyMap<string, string>,
|
|
right: ReadonlyMap<string, string>,
|
|
): Set<string> {
|
|
const changed = new Set<string>();
|
|
for (const [key, value] of left) {
|
|
if (!sourceListingEntriesEqual(right.get(key), value)) changed.add(key);
|
|
}
|
|
for (const key of right.keys()) {
|
|
if (!left.has(key)) changed.add(key);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function changedSourceListingPaths(
|
|
left: ReadonlyMap<string, string>,
|
|
right: ReadonlyMap<string, string>,
|
|
): string[] {
|
|
return [...changedSourceListingKeys(left, right)]
|
|
.sort()
|
|
.slice(0, 10)
|
|
.map((key) => {
|
|
const separator = key.indexOf("\0");
|
|
const repo = separator === -1 ? "" : key.slice(0, separator);
|
|
const path = separator === -1 ? key : key.slice(separator + 1);
|
|
return repo ? `${repo}/${path}` : path;
|
|
});
|
|
}
|
|
|
|
function assertAggregateSourceBeforeMerge(
|
|
pd: string,
|
|
slug: string,
|
|
record: ConvergedSourceRecord | null,
|
|
intent?: string,
|
|
space?: string,
|
|
): {
|
|
state: WorkspaceSourceState;
|
|
openingFingerprint: string;
|
|
} | null {
|
|
if (!record || record.kind === "bypass") return null;
|
|
const current = workspaceSourceState(pd, intent, space);
|
|
if (current === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the main checkout source aggregate is unbindable${workspaceSourceFailureSuffix()}`,
|
|
);
|
|
}
|
|
const chain = currentSwarmSourceMergeChain(
|
|
pd,
|
|
record.stage,
|
|
intent,
|
|
space,
|
|
);
|
|
if (chain.state === "invalid") {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the current swarm source-merge chain is invalid (${chain.reason})`,
|
|
);
|
|
}
|
|
if (chain.state === "ready") {
|
|
if (chain.units.has(record.unit)) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: unit "${record.unit}" already has current-attempt source-merge authority`,
|
|
);
|
|
}
|
|
if (current.fingerprint !== chain.fingerprint) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: the main checkout source changed after the previous reviewed-source merge",
|
|
);
|
|
}
|
|
return {
|
|
state: current,
|
|
openingFingerprint: chain.fingerprint,
|
|
};
|
|
}
|
|
|
|
const opening = currentSwarmSourceOpeningFingerprint(
|
|
pd,
|
|
record.stage,
|
|
intent,
|
|
space,
|
|
);
|
|
if (opening.state === "invalid") {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the current stage has no verifiable predecessor for the first aggregate link (${opening.reason})`,
|
|
);
|
|
}
|
|
if (
|
|
opening.source === "stage-baseline" &&
|
|
opening.listing !== undefined &&
|
|
!sourceListingsEqual(current.listing, opening.listing)
|
|
) {
|
|
const changed = changedSourceListingPaths(
|
|
current.listing,
|
|
opening.listing,
|
|
);
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the main checkout source changed since the stage-entry baseline (${changed.join(", ") || "unknown paths"})`,
|
|
);
|
|
}
|
|
if (
|
|
opening.source === "prior-accepted" &&
|
|
current.fingerprint !== opening.fingerprint
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: the main checkout source does not match the prior attempt's final reviewed aggregate",
|
|
);
|
|
}
|
|
return {
|
|
state: current,
|
|
openingFingerprint: opening.fingerprint,
|
|
};
|
|
}
|
|
|
|
function expectedAggregateAfterCommit(
|
|
before: ReadonlyMap<string, string>,
|
|
priorCommittedRepo: ReadonlyMap<string, string>,
|
|
landedCommittedRepo: ReadonlyMap<string, string>,
|
|
repo: string | null,
|
|
): Map<string, string> {
|
|
const expected = new Map(before);
|
|
for (const key of changedSourceListingKeys(
|
|
priorCommittedRepo,
|
|
landedCommittedRepo,
|
|
)) {
|
|
const aggregateKey = `${repo ?? ""}${key}`;
|
|
const landed = landedCommittedRepo.get(key);
|
|
if (landed === undefined) expected.delete(aggregateKey);
|
|
else expected.set(aggregateKey, landed);
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
function stagedTreeOid(repoCwd: string): string | null {
|
|
const tree = runGit(["write-tree"], repoCwd);
|
|
const oid = tree.ok ? tree.stdout.trim() : "";
|
|
return /^[0-9a-f]{40,64}$/.test(oid) ? oid : null;
|
|
}
|
|
|
|
function assertLandedMergeCommit(
|
|
slug: string,
|
|
repoCwd: string,
|
|
priorHead: string,
|
|
commitSha: string,
|
|
expectedTree: string,
|
|
expectedSecondParent: string | null | undefined,
|
|
): void {
|
|
const parent = runGit(["rev-parse", `${commitSha}^1`], repoCwd);
|
|
const tree = runGit(["rev-parse", `${commitSha}^{tree}`], repoCwd);
|
|
const secondParent = runGit(["rev-parse", `${commitSha}^2`], repoCwd);
|
|
const secondParentMatches =
|
|
expectedSecondParent === undefined ||
|
|
(expectedSecondParent === null
|
|
? !secondParent.ok
|
|
: secondParent.ok &&
|
|
secondParent.stdout.trim() === expectedSecondParent);
|
|
if (
|
|
!parent.ok ||
|
|
parent.stdout.trim() !== priorHead ||
|
|
!tree.ok ||
|
|
tree.stdout.trim() !== expectedTree ||
|
|
!secondParentMatches
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`[merge-succeeded:${commitSha}] unexpected commit or tree change landed during the source merge; no SWARM_SOURCE_MERGED authority was emitted. Do not retry this merge. Preserve the worktree and restart the stage attempt.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderSourcePathKeys(keys: Iterable<string>): string {
|
|
return [...keys]
|
|
.sort()
|
|
.slice(0, 10)
|
|
.map((key) => {
|
|
const separator = key.indexOf("\0");
|
|
const repo = separator === -1 ? "" : key.slice(0, separator);
|
|
const path = separator === -1 ? key : key.slice(separator + 1);
|
|
return repo ? `${repo}/${path}` : path;
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
interface MergedSwarmAuthority {
|
|
unit: string;
|
|
batch: string;
|
|
stage: string;
|
|
floor: string;
|
|
sourceCommit: string;
|
|
mergeCommit: string;
|
|
repo: string | null;
|
|
space: string;
|
|
intent?: string;
|
|
}
|
|
|
|
interface SwarmWorktreeIdentity {
|
|
unit: string;
|
|
batch: string;
|
|
stage: string;
|
|
floor: string;
|
|
}
|
|
|
|
function currentSwarmWorktreeIdentity(
|
|
pd: string,
|
|
slug: string,
|
|
): SwarmWorktreeIdentity | null | undefined {
|
|
const path = join(worktreePath(pd, slug), ".aidlc", WORKTREE_META_FILENAME);
|
|
if (!existsSync(path)) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const unit = parsed.swarmUnit;
|
|
const batch = parsed.swarmBatch;
|
|
const stage = parsed.swarmStage;
|
|
const floor = parsed.swarmFloor;
|
|
return (
|
|
typeof unit === "string" &&
|
|
boltSlugForUnit(unit) === slug &&
|
|
typeof batch === "string" &&
|
|
/^[1-9][0-9]*$/.test(batch) &&
|
|
typeof stage === "string" &&
|
|
stage.length > 0 &&
|
|
typeof floor === "string" &&
|
|
floor.length > 0
|
|
)
|
|
? { unit, batch, stage, floor }
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mergedSwarmCleanupAuthority(
|
|
pd: string,
|
|
slug: string,
|
|
target: string,
|
|
explicitRepo?: string,
|
|
intent?: string,
|
|
space?: string,
|
|
identity?: SwarmWorktreeIdentity,
|
|
): MergedSwarmAuthority | null {
|
|
const audit = allWorktreeAuditRows(pd);
|
|
const candidates = new Map<
|
|
string,
|
|
{
|
|
authority: MergedSwarmAuthority;
|
|
cleanupEvidence: boolean;
|
|
order: number;
|
|
}
|
|
>();
|
|
let matchedSlugAuthority = false;
|
|
for (let order = 0; order < audit.rows.length; order++) {
|
|
const row = audit.rows[order];
|
|
if (row.event !== "SWARM_SOURCE_MERGED") continue;
|
|
const unit = auditBlockField(row.block, "Unit name");
|
|
if (unit === null || boltSlugForUnit(unit) !== slug) continue;
|
|
if (space !== undefined && row.authoritySpace !== space) continue;
|
|
if (intent !== undefined && row.authorityIntent !== intent) continue;
|
|
const repoField = auditBlockField(row.block, "Repo");
|
|
const repo =
|
|
repoField === "-"
|
|
? null
|
|
: repoField !== null && isValidRepoName(repoField)
|
|
? repoField
|
|
: undefined;
|
|
if (repo === undefined) continue;
|
|
if (explicitRepo !== undefined && repo !== explicitRepo) continue;
|
|
const batch = auditBlockField(row.block, "Batch number");
|
|
const stage = auditBlockField(row.block, "Stage");
|
|
const floor = auditBlockField(row.block, "Run floor");
|
|
const sourceCommit = auditBlockField(row.block, "Source Commit");
|
|
const mergeCommit = auditBlockField(row.block, "Merge commit");
|
|
if (
|
|
!batch ||
|
|
!/^[1-9][0-9]*$/.test(batch) ||
|
|
!stage ||
|
|
!floor ||
|
|
!sourceCommit ||
|
|
!/^[0-9a-f]{40,64}$/.test(sourceCommit) ||
|
|
!mergeCommit ||
|
|
!/^[0-9a-f]{40,64}$/.test(mergeCommit)
|
|
) continue;
|
|
if (
|
|
identity !== undefined &&
|
|
(unit !== identity.unit ||
|
|
batch !== identity.batch ||
|
|
stage !== identity.stage ||
|
|
floor !== identity.floor)
|
|
) continue;
|
|
matchedSlugAuthority = true;
|
|
const convergence = audit.rows.find(
|
|
(candidate) =>
|
|
candidate.authoritySpace === row.authoritySpace &&
|
|
candidate.authorityIntent === row.authorityIntent &&
|
|
candidate.event === "SWARM_UNIT_CONVERGED" &&
|
|
auditBlockField(candidate.block, "Batch number") === batch &&
|
|
auditBlockField(candidate.block, "Unit name") === unit &&
|
|
auditBlockField(candidate.block, "Stage") === stage &&
|
|
auditBlockField(candidate.block, "Run floor") === floor &&
|
|
auditBlockField(candidate.block, "Source Commit") === sourceCommit &&
|
|
rowAfter(row, candidate),
|
|
);
|
|
if (!convergence) continue;
|
|
const repoCwd = repo === null ? pd : repoDir(pd, repo);
|
|
if (
|
|
!runGit(["cat-file", "-e", `${sourceCommit}^{commit}`], repoCwd).ok ||
|
|
!runGit(["merge-base", "--is-ancestor", mergeCommit, target], repoCwd).ok
|
|
) continue;
|
|
const branch = runGit(
|
|
["rev-parse", "--verify", `refs/heads/bolt-${slug}`],
|
|
repoCwd,
|
|
);
|
|
const retained = retainedSourceRefs(repoCwd, slug);
|
|
const cleanupEvidence =
|
|
(branch.ok && branch.stdout.trim() === sourceCommit) ||
|
|
(retained?.some((entry) => entry.oid === sourceCommit) ?? false);
|
|
const authority: MergedSwarmAuthority = {
|
|
unit,
|
|
batch,
|
|
stage,
|
|
floor,
|
|
sourceCommit,
|
|
mergeCommit,
|
|
repo,
|
|
space: row.authoritySpace,
|
|
...(row.authorityIntent === undefined
|
|
? {}
|
|
: { intent: row.authorityIntent }),
|
|
};
|
|
candidates.set(
|
|
[
|
|
authority.space,
|
|
authority.intent ?? "",
|
|
authority.repo ?? "",
|
|
authority.unit,
|
|
authority.batch,
|
|
authority.stage,
|
|
authority.floor,
|
|
authority.sourceCommit,
|
|
authority.mergeCommit,
|
|
].join("\0"),
|
|
{ authority, cleanupEvidence, order },
|
|
);
|
|
}
|
|
const values = [...candidates.values()];
|
|
const evidenced = values.filter((candidate) => candidate.cleanupEvidence);
|
|
if (evidenced.length > 1) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing cleanup-only merge: multiple durable SWARM_SOURCE_MERGED authorities match this Bolt slug; retry with the original --space, --intent, and --repo selectors",
|
|
);
|
|
}
|
|
const selected =
|
|
evidenced.length === 1
|
|
? evidenced[0]
|
|
: values.sort((a, b) => a.order - b.order).at(-1);
|
|
const authority = selected?.authority;
|
|
if (authority !== undefined) return authority;
|
|
if (matchedSlugAuthority) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing cleanup-only merge: no matching durable source authority is reachable from target ${target}; restore the original target or pass the exact creating selectors`,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function reconcileMergedSwarmCleanup(
|
|
pd: string,
|
|
slug: string,
|
|
repoCwd: string,
|
|
target: string,
|
|
authority: MergedSwarmAuthority | null,
|
|
): boolean {
|
|
if (authority === null) return false;
|
|
const cleanupTag = `[merge-succeeded:${authority.mergeCommit}]`;
|
|
|
|
const wtPath = worktreePath(pd, slug);
|
|
const branchName = `bolt-${slug}`;
|
|
const dirExists = existsSync(wtPath);
|
|
const registered = repositoryRegistersBoltWorktree(pd, repoCwd, slug);
|
|
if (dirExists || registered) {
|
|
if (!dirExists) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} worktree registration remains but its checkout directory is missing; restore the registration or prune it before retrying cleanup`,
|
|
);
|
|
}
|
|
const align = runGit(["reset", "--hard", authority.sourceCommit], wtPath);
|
|
if (!align.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only source alignment failed: ${align.stderr.trim() || `exit ${align.code}`}`,
|
|
);
|
|
}
|
|
const removed = runGit(["worktree", "remove", "--force", wtPath], repoCwd);
|
|
if (!removed.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only worktree remove failed: ${removed.stderr.trim() || `exit ${removed.code}`}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const branch = runGit(
|
|
["rev-parse", "--verify", `refs/heads/${branchName}`],
|
|
repoCwd,
|
|
);
|
|
if (branch.ok) {
|
|
const branchOid = branch.stdout.trim();
|
|
if (branchOid !== authority.sourceCommit) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only branch ${branchName} moved after source landing; preserve it for inspection`,
|
|
);
|
|
}
|
|
const deleted = runGit(
|
|
["update-ref", "-d", `refs/heads/${branchName}`, branchOid],
|
|
repoCwd,
|
|
);
|
|
if (!deleted.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only branch deletion failed: ${deleted.stderr.trim() || `exit ${deleted.code}`}`,
|
|
);
|
|
}
|
|
}
|
|
const retained = retainedSourceRefs(repoCwd, slug);
|
|
if (retained === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only reviewed-source ref enumeration failed`,
|
|
);
|
|
}
|
|
const refCleanupError = deleteRetainedSourceRefs(repoCwd, retained);
|
|
if (refCleanupError) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cleanup-only reviewed-source ref deletion failed: ${refCleanupError}`,
|
|
);
|
|
}
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: null,
|
|
slug,
|
|
worktree_path: wtPath,
|
|
target,
|
|
source_authority: authority.mergeCommit,
|
|
cleanup_reconciled: true,
|
|
}),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function refuseConfiguredMergeDrivers(
|
|
slug: string,
|
|
repoCwd: string,
|
|
record: ConvergedSourceRecord | null,
|
|
): void {
|
|
if (
|
|
record?.kind !== "bound" ||
|
|
process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1"
|
|
) {
|
|
return;
|
|
}
|
|
const configured = runGit(
|
|
[
|
|
"config",
|
|
"-z",
|
|
"--name-only",
|
|
"--get-regexp",
|
|
"^merge\\..*\\.driver$",
|
|
],
|
|
repoCwd,
|
|
);
|
|
if (!configured.ok && configured.code === 1) return;
|
|
if (!configured.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: cannot inspect effective repository merge-driver configuration",
|
|
);
|
|
}
|
|
const keys = [
|
|
...new Set(
|
|
configured.stdout
|
|
.split("\0")
|
|
.map((key) => key.trim())
|
|
.filter(Boolean),
|
|
),
|
|
].sort();
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: repository merge-driver configuration is present (${keys.join(", ")}); remove the merge.<name>.driver configuration, or retry with AIDLC_SKIP_SOURCE_FRESHNESS=1`,
|
|
);
|
|
}
|
|
|
|
function refuseConfiguredCheckoutFilters(
|
|
slug: string,
|
|
repoCwd: string,
|
|
record: ConvergedSourceRecord | null,
|
|
): void {
|
|
if (
|
|
record?.kind !== "bound" ||
|
|
process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1"
|
|
) {
|
|
return;
|
|
}
|
|
const configured = runGit(
|
|
[
|
|
"config",
|
|
"-z",
|
|
"--name-only",
|
|
"--get-regexp",
|
|
"^filter\\..*\\.(smudge|process)$",
|
|
],
|
|
repoCwd,
|
|
);
|
|
if (!configured.ok && configured.code === 1) return;
|
|
if (!configured.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to merge: cannot inspect effective repository checkout-filter configuration",
|
|
);
|
|
}
|
|
const keys = [
|
|
...new Set(
|
|
configured.stdout
|
|
.split("\0")
|
|
.map((key) => key.trim())
|
|
.filter(Boolean),
|
|
),
|
|
].sort();
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: repository checkout-filter configuration is present (${keys.join(", ")}); remove the filter.<name>.smudge/process configuration, or retry with AIDLC_SKIP_SOURCE_FRESHNESS=1`,
|
|
);
|
|
}
|
|
|
|
function handleMerge(args: string[]): void {
|
|
const flags = parseFlags(args);
|
|
const slug = validateSlug(flags.slug);
|
|
if (!flags.target) errorWithSlug(slug, "Missing --target <branch>");
|
|
const strategy = validateStrategy(flags.strategy);
|
|
const message = flags.message ?? `Bolt ${slug}`;
|
|
|
|
const pd = resolveProjectDir(projectDir);
|
|
const recorded = recordedWorktreeSelector(pd, slug);
|
|
if (flags.intent === undefined && flags.space === undefined) {
|
|
if (
|
|
recorded !== null &&
|
|
recorded.intent !== undefined &&
|
|
recorded.space !== undefined
|
|
) {
|
|
flags.intent = recorded.intent;
|
|
flags.space = recorded.space;
|
|
}
|
|
}
|
|
if (
|
|
flags.repo === undefined &&
|
|
typeof recorded?.repoSelector === "string"
|
|
) {
|
|
flags.repo = recorded.repoSelector;
|
|
}
|
|
const worktreeIdentity = currentSwarmWorktreeIdentity(pd, slug);
|
|
const cleanupAuthority =
|
|
worktreeIdentity === null
|
|
? null
|
|
: mergedSwarmCleanupAuthority(
|
|
pd,
|
|
slug,
|
|
flags.target,
|
|
flags.repo,
|
|
flags.intent,
|
|
flags.space,
|
|
worktreeIdentity,
|
|
);
|
|
if (recorded === null && cleanupAuthority !== null) {
|
|
flags.space = cleanupAuthority.space;
|
|
if (cleanupAuthority.intent !== undefined) {
|
|
flags.intent = cleanupAuthority.intent;
|
|
}
|
|
if (cleanupAuthority.repo !== null) {
|
|
flags.repo = cleanupAuthority.repo;
|
|
}
|
|
}
|
|
// P7: anchor every git op to the target sibling repo. The merge runs IN that
|
|
// repo's main checkout (squash/merge/ff/commit/worktree-remove/branch-D); the
|
|
// rebase still runs in the worktree (wtPath). Legacy single-repo → repoCwd=pd.
|
|
const repoTarget = resolveRepoTarget(pd, flags, slug);
|
|
const repoCwd = repoTarget.cwd;
|
|
assertNotSiblingWorktree(repoCwd);
|
|
if (
|
|
reconcileMergedSwarmCleanup(
|
|
pd,
|
|
slug,
|
|
repoCwd,
|
|
flags.target,
|
|
cleanupAuthority,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Defensive HEAD check: the caller must have <target> checked out at the repo cwd.
|
|
const head = runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoCwd);
|
|
if (!head.ok) {
|
|
errorWithSlug(slug, "Cannot resolve HEAD.");
|
|
}
|
|
const actual = head.stdout.trim();
|
|
if (actual === "HEAD") {
|
|
errorWithSlug(
|
|
slug,
|
|
`expected branch ${flags.target}, found detached HEAD`
|
|
);
|
|
}
|
|
if (actual !== flags.target) {
|
|
errorWithSlug(
|
|
slug,
|
|
`expected branch ${flags.target}, found ${actual}`
|
|
);
|
|
}
|
|
|
|
const wtPath = worktreePath(pd, slug);
|
|
const branchName = `bolt-${slug}`;
|
|
const sourceRecord = convergedSourceRecord(
|
|
pd,
|
|
slug,
|
|
repoCwd,
|
|
flags.intent,
|
|
flags.space,
|
|
repoTarget.repo,
|
|
);
|
|
const frameworkPathspecs = workspaceSourceExclusionPathspecs(wtPath);
|
|
if (frameworkPathspecs === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
"cannot resolve the Bolt worktree source role from its metadata",
|
|
);
|
|
}
|
|
const aggregateBefore = assertAggregateSourceBeforeMerge(
|
|
pd,
|
|
slug,
|
|
sourceRecord,
|
|
flags.intent,
|
|
flags.space,
|
|
);
|
|
if (sourceRecord?.kind === "bound" && strategy === "rebase") {
|
|
errorWithSlug(
|
|
slug,
|
|
"refusing to rebase a source-bound convergence: rebase before review/finalize, then merge the immutable reviewed commit",
|
|
);
|
|
}
|
|
if (sourceRecord?.kind === "bypass") {
|
|
const applicationStatus = runGit([
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--untracked-files=all",
|
|
"--ignored=matching",
|
|
], wtPath);
|
|
if (!applicationStatus.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`cannot inspect bypassed application source: ${applicationStatus.stderr.trim() || `exit ${applicationStatus.code}`}`,
|
|
);
|
|
}
|
|
const applicationLines = applicationStatus.stdout
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.filter((line) => {
|
|
const path = line.slice(3);
|
|
return workspaceSourcePathIsExcluded(wtPath, path) !== true;
|
|
});
|
|
if (applicationLines.length > 0) {
|
|
const detail = applicationLines.join(", ");
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to merge: the bypassed Bolt has uncommitted or ignored application paths not represented by its branch (${detail}); commit, remove, or discard those paths before retrying`,
|
|
);
|
|
}
|
|
}
|
|
refuseConfiguredMergeDrivers(slug, repoCwd, sourceRecord);
|
|
refuseConfiguredCheckoutFilters(slug, repoCwd, sourceRecord);
|
|
|
|
// Rebase requires a remote for <target>. The remote-existence check is
|
|
// a pre-audit guard (no state change). The actual `git fetch` is post-
|
|
// audit because fetch mutates remote-tracking refs — running it before
|
|
// the audit emit would leave a kill-9 window where refs moved without
|
|
// a corresponding audit row.
|
|
let rebaseRemote = "";
|
|
if (strategy === "rebase") {
|
|
const remote = runGit(["config", `branch.${flags.target}.remote`], repoCwd);
|
|
if (!remote.ok || !remote.stdout.trim()) {
|
|
errorWithSlug(
|
|
slug,
|
|
`rebase strategy requires a remote for ${flags.target}; got none`
|
|
);
|
|
}
|
|
rebaseRemote = remote.stdout.trim();
|
|
}
|
|
|
|
// Audit-first: emit BEFORE any state-mutating git command (including the
|
|
// rebase pre-fetch).
|
|
let auditTs: string;
|
|
try {
|
|
auditTs = emitAudit(pd, "WORKTREE_MERGED", {
|
|
"Bolt slug": slug,
|
|
"Worktree path": auditWorktreePath(pd, wtPath),
|
|
"Target branch": flags.target,
|
|
Strategy: strategy,
|
|
}, flags.intent, flags.space);
|
|
} catch (e) {
|
|
errorWithSlug(slug, `Audit emission failed: ${errorMessage(e)}`);
|
|
}
|
|
|
|
if (strategy === "rebase") {
|
|
const fetch = runGit(["fetch", rebaseRemote], wtPath);
|
|
if (!fetch.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`git fetch failed: ${fetch.stderr.trim() || fetch.stdout.trim() || `exit ${fetch.code}`}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// This is the last guard before source mutation. The convergence selector is
|
|
// the requested intent/space, and the returned target is an immutable commit
|
|
// object rather than the movable bolt-<slug> branch.
|
|
let mergeTarget = assertConvergedSourceUnchanged(slug, wtPath, sourceRecord) ?? branchName;
|
|
let bypassBranchOid = "";
|
|
if (sourceRecord?.kind === "bypass" && strategy !== "rebase") {
|
|
const branchOid = runGit(["rev-parse", `${branchName}^{commit}`], repoCwd);
|
|
if (!branchOid.ok || !branchOid.stdout.trim()) {
|
|
errorWithSlug(slug, "cannot resolve the bypassed Bolt branch commit");
|
|
}
|
|
bypassBranchOid = branchOid.stdout.trim();
|
|
mergeTarget = bypassBranchOid;
|
|
}
|
|
|
|
let commitSha = "";
|
|
const priorTargetHead = currentSha(repoCwd);
|
|
const disabledHooksPath = join(
|
|
tmpdir(),
|
|
`aidlc-disabled-hooks-${process.pid}-${randomUUID()}`,
|
|
).replaceAll("\\", "/");
|
|
const mutationArgs = (args: string[]): string[] =>
|
|
sourceRecord?.kind === "bound"
|
|
? ["-c", `core.hooksPath=${disabledHooksPath}`, ...args]
|
|
: args;
|
|
// conflictCwd records which checkout the conflicting state lives in:
|
|
// squash/merge run in the target repo's main checkout (cwd = repoCwd), rebase
|
|
// runs in the worktree (cwd = wtPath). For conflict-file enumeration, we query
|
|
// `git diff --name-only --diff-filter=U` in the SAME cwd so the index reflects
|
|
// the real conflict. (P7: repoCwd is the sibling repo, or the projectDir for a
|
|
// legacy single-repo intent — squash/merge default to it now, not the caller's cwd.)
|
|
let conflictCwd: string | undefined = repoCwd;
|
|
let conflictHit = false;
|
|
switch (strategy) {
|
|
case "squash": {
|
|
const m = runGit(
|
|
mutationArgs(["merge", "--squash", "--no-verify", mergeTarget]),
|
|
repoCwd,
|
|
);
|
|
if (!m.ok) {
|
|
if (isConflict(m)) {
|
|
conflictHit = true;
|
|
break;
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
`git merge --squash failed: ${m.stderr.trim() || `exit ${m.code}`}`
|
|
);
|
|
}
|
|
const expectedTree = stagedTreeOid(repoCwd);
|
|
if (expectedTree === null) {
|
|
errorWithSlug(slug, "cannot resolve the staged squash merge tree");
|
|
}
|
|
const c = runGit(
|
|
mutationArgs(["commit", "--no-verify", "-m", message]),
|
|
repoCwd,
|
|
);
|
|
if (!c.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`git commit failed: ${c.stderr.trim() || `exit ${c.code}`}`
|
|
);
|
|
}
|
|
commitSha = currentSha(repoCwd);
|
|
assertLandedMergeCommit(
|
|
slug,
|
|
repoCwd,
|
|
priorTargetHead,
|
|
commitSha,
|
|
expectedTree,
|
|
null,
|
|
);
|
|
break;
|
|
}
|
|
case "merge": {
|
|
const m = runGit(
|
|
mutationArgs([
|
|
"merge",
|
|
"--no-ff",
|
|
"--no-commit",
|
|
"--no-verify",
|
|
mergeTarget,
|
|
]),
|
|
repoCwd,
|
|
);
|
|
if (!m.ok) {
|
|
if (isConflict(m)) {
|
|
conflictHit = true;
|
|
break;
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
`git merge --no-ff failed: ${m.stderr.trim() || `exit ${m.code}`}`
|
|
);
|
|
}
|
|
const expectedTree = stagedTreeOid(repoCwd);
|
|
if (expectedTree === null) {
|
|
errorWithSlug(slug, "cannot resolve the staged merge tree");
|
|
}
|
|
const c = runGit(
|
|
mutationArgs([
|
|
"commit",
|
|
"--no-verify",
|
|
"-m",
|
|
`Merge bolt ${slug}`,
|
|
]),
|
|
repoCwd,
|
|
);
|
|
if (!c.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`git commit failed: ${c.stderr.trim() || `exit ${c.code}`}`,
|
|
);
|
|
}
|
|
commitSha = currentSha(repoCwd);
|
|
assertLandedMergeCommit(
|
|
slug,
|
|
repoCwd,
|
|
priorTargetHead,
|
|
commitSha,
|
|
expectedTree,
|
|
sourceRecord?.kind === "bound"
|
|
? sourceRecord.commit
|
|
: undefined,
|
|
);
|
|
break;
|
|
}
|
|
case "rebase": {
|
|
const r = runGit(["rebase", flags.target], wtPath);
|
|
if (!r.ok) {
|
|
if (isConflict(r)) {
|
|
conflictHit = true;
|
|
conflictCwd = wtPath;
|
|
break;
|
|
}
|
|
errorWithSlug(
|
|
slug,
|
|
`git rebase failed: ${r.stderr.trim() || `exit ${r.code}`}`
|
|
);
|
|
}
|
|
const ffTarget =
|
|
sourceRecord?.kind === "bypass"
|
|
? currentSha(wtPath)
|
|
: mergeTarget;
|
|
if (sourceRecord?.kind === "bypass") bypassBranchOid = ffTarget;
|
|
const ff = runGit(["merge", "--ff-only", ffTarget], repoCwd);
|
|
if (!ff.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`git merge --ff-only failed: ${ff.stderr.trim() || `exit ${ff.code}`}`
|
|
);
|
|
}
|
|
commitSha = currentSha(repoCwd);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (conflictHit) {
|
|
const files = listConflictFiles(conflictCwd);
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
status: "conflict",
|
|
slug,
|
|
worktree_path: wtPath,
|
|
conflict_files: files,
|
|
detail: `Merge produced conflicts in worktree at ${wtPath}. Worktree preserved for inspection.`,
|
|
})}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Cleanup: remove worktree + delete branch. The merge commit at
|
|
// <commitSha> is now permanent on <target> — failures here leave an
|
|
// orphan worktree directory and/or branch but DO NOT roll back the
|
|
// merge. Tag the error message with [merge-succeeded:<sha>] so the
|
|
// ERROR_LOGGED row carries enough state for doctor to tell
|
|
// "merge failed entirely" from "merge landed, cleanup orphan remains"
|
|
// — these need different recovery actions.
|
|
const cleanupTag = `[merge-succeeded:${commitSha}]`;
|
|
if (sourceRecord?.kind === "bound") {
|
|
const aggregateAfter = workspaceSourceState(pd, flags.intent, flags.space);
|
|
if (aggregateBefore === null || aggregateAfter === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cannot bind the post-merge main-checkout source aggregate; worktree and retained source commit preserved`,
|
|
);
|
|
}
|
|
const priorCommittedRepo = gitCommitSourceListing(
|
|
repoCwd,
|
|
priorTargetHead,
|
|
repoTarget.repo === null,
|
|
);
|
|
const landedCommittedRepo = gitCommitSourceListing(
|
|
repoCwd,
|
|
commitSha,
|
|
repoTarget.repo === null,
|
|
);
|
|
if (priorCommittedRepo === null || landedCommittedRepo === null) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cannot reconstruct the committed source delta; no SWARM_SOURCE_MERGED authority was emitted. Do not retry this merge. Preserve the worktree and restart the stage attempt.`,
|
|
);
|
|
}
|
|
const expectedAfter = expectedAggregateAfterCommit(
|
|
aggregateBefore.state.listing,
|
|
priorCommittedRepo,
|
|
landedCommittedRepo,
|
|
repoTarget.repo,
|
|
);
|
|
const mismatchedEntries = [
|
|
...changedSourceListingKeys(expectedAfter, aggregateAfter.listing),
|
|
];
|
|
if (mismatchedEntries.length > 0) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} post-merge source does not match landed merge commit ${commitSha} (${renderSourcePathKeys(mismatchedEntries) || "unknown paths"}); no SWARM_SOURCE_MERGED authority was emitted. Do not retry this merge. Preserve the worktree and restart the stage attempt.`,
|
|
);
|
|
}
|
|
if (currentSha(repoCwd) !== commitSha) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} target HEAD changed after the source merge result was verified; no SWARM_SOURCE_MERGED authority was emitted. Do not retry this merge. Preserve the worktree and restart the stage attempt.`,
|
|
);
|
|
}
|
|
try {
|
|
emitAudit(
|
|
pd,
|
|
"SWARM_SOURCE_MERGED",
|
|
{
|
|
"Batch number": sourceRecord.batch,
|
|
"Unit name": sourceRecord.unit,
|
|
Stage: sourceRecord.stage,
|
|
"Run floor": sourceRecord.floor,
|
|
"Previous Source Fingerprint": aggregateBefore.openingFingerprint,
|
|
"Source Fingerprint": aggregateAfter.fingerprint,
|
|
"Source Commit": sourceRecord.commit,
|
|
"Merge commit": commitSha,
|
|
Repo: repoTarget.repo ?? "-",
|
|
},
|
|
flags.intent,
|
|
flags.space,
|
|
);
|
|
} catch (e) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} post-merge source authority emission failed (${errorMessage(e)}); the source merge landed but no aggregate receipt exists. Do not retry this merge. Preserve the worktree and restart the stage attempt, or use AIDLC_SKIP_SOURCE_FRESHNESS=1 only with explicit human approval.`,
|
|
);
|
|
}
|
|
}
|
|
if (sourceRecord?.kind === "bypass") {
|
|
const currentBranchOid = runGit(["rev-parse", `${branchName}^{commit}`], repoCwd);
|
|
if (
|
|
!bypassBranchOid ||
|
|
!currentBranchOid.ok ||
|
|
currentBranchOid.stdout.trim() !== bypassBranchOid
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} bypassed Bolt branch changed during the merge; worktree and branch preserved`,
|
|
);
|
|
}
|
|
}
|
|
// A swarm snapshot does not move the Bolt branch, so reviewed application
|
|
// files may still be modified/untracked in this disposable checkout. Once
|
|
// that immutable source has landed, align the checkout to it before forced
|
|
// removal.
|
|
if (sourceRecord?.kind === "bound") {
|
|
const align = runGit(["reset", "--hard", mergeTarget], wtPath);
|
|
if (!align.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} reviewed-source cleanup reset failed: ${align.stderr.trim() || `exit ${align.code}`}`,
|
|
);
|
|
}
|
|
} else if (sourceRecord?.kind === "bypass") {
|
|
// Finalization writes framework metadata into the Bolt even when source
|
|
// freshness is bypassed. Remove only that known residue so ordinary
|
|
// worktree removal can still protect uncommitted application source.
|
|
for (const frameworkPath of frameworkPathspecs) {
|
|
const tracked = runGit(["ls-files", "-z", "--", frameworkPath], wtPath);
|
|
if (!tracked.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} bypass cleanup path enumeration failed: ${tracked.stderr.trim() || `exit ${tracked.code}`}`,
|
|
);
|
|
}
|
|
if (tracked.stdout.length === 0) continue;
|
|
const restore = runGit(
|
|
["checkout", "--force", "HEAD", "--", frameworkPath],
|
|
wtPath,
|
|
);
|
|
if (!restore.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} bypass cleanup reset failed: ${restore.stderr.trim() || `exit ${restore.code}`}`,
|
|
);
|
|
}
|
|
}
|
|
const clean = runGit(
|
|
["clean", "-ffdx", "--", ...frameworkPathspecs],
|
|
wtPath,
|
|
);
|
|
if (!clean.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} bypass cleanup failed: ${clean.stderr.trim() || `exit ${clean.code}`}`,
|
|
);
|
|
}
|
|
const remainingApplicationStatus = runGit([
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--untracked-files=all",
|
|
"--ignored=matching",
|
|
], wtPath);
|
|
if (!remainingApplicationStatus.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} cannot recheck bypassed application source: ${remainingApplicationStatus.stderr.trim() || `exit ${remainingApplicationStatus.code}`}`,
|
|
);
|
|
}
|
|
const remainingApplicationLines = remainingApplicationStatus.stdout
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.filter((line) => {
|
|
const path = line.slice(3);
|
|
return workspaceSourcePathIsExcluded(wtPath, path) !== true;
|
|
});
|
|
if (remainingApplicationLines.length > 0) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} application source changed during the bypassed merge; worktree preserved`,
|
|
);
|
|
}
|
|
} else {
|
|
// Ordinary Bolts also carry framework-owned runtime attestations under the
|
|
// root .aidlc/ shell. Git treats that ignored directory as one cleanable
|
|
// entry, so remove the shell as a unit; non-forced worktree removal below
|
|
// must continue to protect every application path.
|
|
const clean = runGit(
|
|
["clean", "-ffdx", "--", ...frameworkPathspecs],
|
|
wtPath,
|
|
);
|
|
if (!clean.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} ordinary Bolt metadata cleanup failed: ${clean.stderr.trim() || `exit ${clean.code}`}`,
|
|
);
|
|
}
|
|
}
|
|
// A raw-byte snapshot can remain permanently "modified" under its own lossy
|
|
// clean filter even after reset (Git re-cleans the raw index blob for status).
|
|
// The successful hard reset to the immutable source above authorizes forced
|
|
// removal of that bound checkout. Bypassed and ordinary Bolt cleanup remains
|
|
// non-forced so application source cannot be discarded silently.
|
|
const rm = runGit(
|
|
sourceRecord?.kind === "bound"
|
|
? ["worktree", "remove", "--force", wtPath]
|
|
: ["worktree", "remove", wtPath],
|
|
repoCwd,
|
|
);
|
|
if (!rm.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} worktree remove failed: ${rm.stderr.trim() || `exit ${rm.code}`}`
|
|
);
|
|
}
|
|
const del =
|
|
sourceRecord?.kind === "bypass"
|
|
? runGit(
|
|
["update-ref", "-d", `refs/heads/${branchName}`, bypassBranchOid],
|
|
repoCwd,
|
|
)
|
|
: runGit(["branch", "-D", branchName], repoCwd);
|
|
if (!del.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`${cleanupTag} branch -D ${branchName} failed: ${del.stderr.trim() || `exit ${del.code}`}`
|
|
);
|
|
}
|
|
const retained = retainedSourceRefs(repoCwd, slug);
|
|
if (retained === null) {
|
|
errorWithSlug(slug, `${cleanupTag} reviewed-source ref enumeration failed`);
|
|
}
|
|
const refCleanupError = deleteRetainedSourceRefs(repoCwd, retained);
|
|
if (refCleanupError) {
|
|
errorWithSlug(slug, `${cleanupTag} reviewed-source ref cleanup failed: ${refCleanupError}`);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: "WORKTREE_MERGED",
|
|
slug,
|
|
worktree_path: wtPath,
|
|
target: flags.target,
|
|
strategy,
|
|
commit_sha: commitSha,
|
|
audit_timestamp: auditTs,
|
|
})
|
|
);
|
|
}
|
|
|
|
function currentSha(cwd?: string): string {
|
|
const r = runGit(["rev-parse", "HEAD"], cwd);
|
|
return r.ok ? r.stdout.trim() : "";
|
|
}
|
|
|
|
function isConflict(r: GitResult): boolean {
|
|
// Anchor on git's canonical CONFLICT marker prefix. The previous
|
|
// permissive form (`/conflict/i` etc.) false-positived on stdout that
|
|
// happened to contain the substring "conflict" — including unrelated
|
|
// hint text in future git releases.
|
|
const blob = `${r.stdout}\n${r.stderr}`;
|
|
return /^CONFLICT \(/m.test(blob);
|
|
}
|
|
|
|
function listConflictFiles(cwd?: string): string[] {
|
|
// `git diff --name-only --diff-filter=U` enumerates unmerged paths in
|
|
// the index. Deterministic across all conflict shapes (content, rename/
|
|
// rename, modify/delete) — beats parsing git's prose stderr, which has
|
|
// varied across git releases.
|
|
const r = runGit(["diff", "--name-only", "--diff-filter=U"], cwd);
|
|
if (!r.ok) return [];
|
|
return r.stdout
|
|
.split(/\r?\n/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
}
|
|
|
|
// --- Subcommand: discard ---
|
|
//
|
|
// Usage: aidlc-worktree discard --slug <slug> [--repo <name>]
|
|
// [--intent <dir>] [--space <name>]
|
|
//
|
|
// --repo (P7): the sibling repo the worktree was forked in — same resolution as
|
|
// `create`. Idempotent: if neither directory nor branch exists, succeeds silently
|
|
// without re-emitting audit.
|
|
function handleDiscard(args: string[]): void {
|
|
const flags = parseFlags(args);
|
|
const slug = validateSlug(flags.slug);
|
|
const pd = resolveProjectDir(projectDir);
|
|
const intentWasExplicit =
|
|
flags.intent !== undefined || flags.space !== undefined;
|
|
const recorded = recordedWorktreeSelector(pd, slug);
|
|
if (
|
|
flags.intent === undefined &&
|
|
flags.space === undefined &&
|
|
recorded?.intent !== undefined &&
|
|
recorded.space !== undefined
|
|
) {
|
|
flags.intent = recorded.intent;
|
|
flags.space = recorded.space;
|
|
}
|
|
const authority = discardCreationAuthority(
|
|
pd,
|
|
slug,
|
|
recorded,
|
|
flags.repo,
|
|
);
|
|
const wtPath = worktreePath(pd, slug);
|
|
if (!authority.evidenceExists) {
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: null,
|
|
slug,
|
|
worktree_path: wtPath,
|
|
reason: "already-discarded",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (authority.space !== undefined) {
|
|
const authorityRecord =
|
|
authority.intent === undefined
|
|
? null
|
|
: `aidlc/spaces/${authority.space}/intents/${authority.intent}`;
|
|
if (
|
|
intentWasExplicit &&
|
|
relativeRecordDir(pd, flags.intent, flags.space) !== authorityRecord
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: selected intent ${JSON.stringify(relativeRecordDir(pd, flags.intent, flags.space))} does not match creating intent ${JSON.stringify(authorityRecord)}`,
|
|
);
|
|
}
|
|
if (!intentWasExplicit && authority.intent !== undefined) {
|
|
flags.intent = authority.intent;
|
|
flags.space = authority.space;
|
|
}
|
|
}
|
|
const creatingRepo =
|
|
authority.repo !== undefined
|
|
? authority.repo
|
|
: recorded?.repoSelector;
|
|
if (flags.repo === undefined && typeof creatingRepo === "string") {
|
|
flags.repo = creatingRepo;
|
|
}
|
|
if (
|
|
flags.repo !== undefined &&
|
|
creatingRepo !== undefined &&
|
|
flags.repo !== creatingRepo
|
|
) {
|
|
errorWithSlug(
|
|
slug,
|
|
`refusing to discard: selected repository ${JSON.stringify(flags.repo)} does not match creating repository ${JSON.stringify(creatingRepo ?? "-")}`,
|
|
);
|
|
}
|
|
// P7: anchor every git op to the target sibling repo (or projectDir for legacy).
|
|
const repoCwd =
|
|
creatingRepo === null ? pd : resolveRepoCwd(pd, flags, slug);
|
|
assertNotSiblingWorktree(repoCwd);
|
|
|
|
const branchName = `bolt-${slug}`;
|
|
const dirExists = existsSync(wtPath);
|
|
const registered = repositoryRegistersBoltWorktree(pd, repoCwd, slug);
|
|
const branchExists = runGit([
|
|
"rev-parse",
|
|
"--verify",
|
|
`refs/heads/${branchName}`,
|
|
], repoCwd).ok;
|
|
const retained = retainedSourceRefs(repoCwd, slug);
|
|
if (retained === null) {
|
|
errorWithSlug(slug, "reviewed-source ref enumeration failed");
|
|
}
|
|
|
|
if (!dirExists && !branchExists && retained.length === 0) {
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: null,
|
|
slug,
|
|
worktree_path: wtPath,
|
|
reason: "already-discarded",
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
let auditTs: string;
|
|
try {
|
|
auditTs = emitAudit(pd, "WORKTREE_DISCARDED", {
|
|
"Bolt slug": slug,
|
|
"Worktree path": auditWorktreePath(pd, wtPath),
|
|
Reason: "agent-discard",
|
|
}, flags.intent, flags.space);
|
|
} catch (e) {
|
|
errorWithSlug(slug, `Audit emission failed: ${errorMessage(e)}`);
|
|
}
|
|
|
|
if (dirExists || registered) {
|
|
const rm = runGit(["worktree", "remove", "--force", wtPath], repoCwd);
|
|
if (!rm.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`git worktree remove failed: ${rm.stderr.trim() || `exit ${rm.code}`}`
|
|
);
|
|
}
|
|
}
|
|
if (branchExists) {
|
|
const del = runGit(["branch", "-D", branchName], repoCwd);
|
|
if (!del.ok) {
|
|
errorWithSlug(
|
|
slug,
|
|
`branch -D ${branchName} failed: ${del.stderr.trim() || `exit ${del.code}`}`
|
|
);
|
|
}
|
|
}
|
|
const refCleanupError = deleteRetainedSourceRefs(repoCwd, retained);
|
|
if (refCleanupError) {
|
|
errorWithSlug(slug, `reviewed-source ref cleanup failed: ${refCleanupError}`);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
emitted: "WORKTREE_DISCARDED",
|
|
slug,
|
|
worktree_path: wtPath,
|
|
reason: "agent-discard",
|
|
audit_timestamp: auditTs,
|
|
})
|
|
);
|
|
}
|
|
|
|
// --- Subcommand: list ---
|
|
//
|
|
// Usage: aidlc-worktree list
|
|
//
|
|
// Filters `git worktree list --porcelain` output to entries that are AIDLC
|
|
// Bolt worktrees: parent path is `<projectDir>/.aidlc/worktrees/` AND the
|
|
// basename starts with `bolt-`. Both conditions are required so an
|
|
// unrelated worktree someone happens to name `bolt-other` outside our
|
|
// namespace doesn't masquerade as a Bolt. Read-only — no audit emission.
|
|
function handleList(_args: string[]): void {
|
|
// No assertNotSiblingWorktree here — list is read-only and useful from
|
|
// anywhere. Run from current cwd's git context.
|
|
const pd = resolveProjectDir(projectDir);
|
|
const boltsDir = pathKey(resolve(pd, ".aidlc", "worktrees"));
|
|
|
|
const r = runGit(["worktree", "list", "--porcelain"]);
|
|
if (!r.ok) {
|
|
error(`git worktree list failed: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
}
|
|
|
|
interface WT {
|
|
path: string;
|
|
branch: string;
|
|
}
|
|
// Type guard — a Partial<WT> with .path defined narrows to WT since
|
|
// branch defaults to "" at construction (the "worktree " branch below).
|
|
function isCompleteWT(p: Partial<WT>): p is WT {
|
|
return p.path !== undefined;
|
|
}
|
|
const all: WT[] = [];
|
|
let cur: Partial<WT> = {};
|
|
for (const line of r.stdout.split(/\r?\n/)) {
|
|
if (line.startsWith("worktree ")) {
|
|
if (isCompleteWT(cur)) all.push({ ...cur, branch: cur.branch ?? "" });
|
|
cur = { path: line.slice("worktree ".length), branch: "" };
|
|
} else if (line.startsWith("branch ")) {
|
|
cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
} else if (line === "") {
|
|
if (isCompleteWT(cur)) {
|
|
all.push({ ...cur, branch: cur.branch ?? "" });
|
|
cur = {};
|
|
}
|
|
}
|
|
}
|
|
if (isCompleteWT(cur)) all.push({ ...cur, branch: cur.branch ?? "" });
|
|
|
|
const bolts = all
|
|
.filter((w) => {
|
|
const base = w.path.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
|
if (!base.startsWith("bolt-")) return false;
|
|
// Require parent to be the framework-owned bolts directory.
|
|
const parent = pathKey(dirname(w.path));
|
|
return parent === boltsDir;
|
|
})
|
|
.map((w) => ({
|
|
slug: (w.path.split(/[\\/]/).filter(Boolean).pop() ?? "").slice("bolt-".length),
|
|
worktree_path: w.path,
|
|
branch: w.branch,
|
|
}));
|
|
|
|
console.log(JSON.stringify({ worktrees: bolts }));
|
|
}
|
|
|
|
// --- Subcommand: verify ---
|
|
//
|
|
// Usage: aidlc-worktree verify --event <WORKTREE_*> --slug <slug>
|
|
// [--max-age-seconds <n>]
|
|
//
|
|
// Greps `aidlc-docs/audit.md` for the most recent block matching both
|
|
// `**Event**: <event>` and `**Bolt slug**: <slug>`. Read-only — no audit
|
|
// emission. The orchestrator's deterministic post-dispatch backstop.
|
|
function handleVerify(args: string[]): void {
|
|
const flags = parseFlags(args);
|
|
if (!flags.event) error("Missing --event <WORKTREE_CREATED|WORKTREE_MERGED|WORKTREE_DISCARDED>");
|
|
if (!VALID_VERIFY_EVENTS.has(flags.event)) {
|
|
error(
|
|
`Invalid --event: "${flags.event}". Must be one of: WORKTREE_CREATED, WORKTREE_MERGED, WORKTREE_DISCARDED.`
|
|
);
|
|
}
|
|
const slug = validateSlug(flags.slug);
|
|
const maxAge = flags["max-age-seconds"]
|
|
? Number(flags["max-age-seconds"])
|
|
: 60;
|
|
if (!Number.isFinite(maxAge) || maxAge < 0) {
|
|
error(`Invalid --max-age-seconds: "${flags["max-age-seconds"]}".`);
|
|
}
|
|
|
|
const pd = resolveProjectDir(projectDir);
|
|
// Read across every per-clone audit shard (single shard in the common case).
|
|
const audit = readAllAuditShards(pd, flags.intent, flags.space);
|
|
if (audit.length === 0) {
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
verified: false,
|
|
event: flags.event,
|
|
slug,
|
|
reason: "absent",
|
|
})}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const match = findLatestEvent(audit, flags.event, slug);
|
|
if (!match) {
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
verified: false,
|
|
event: flags.event,
|
|
slug,
|
|
reason: "absent",
|
|
})}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const ageMs = Date.now() - new Date(match.timestamp).getTime();
|
|
if (ageMs > maxAge * 1000) {
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
verified: false,
|
|
event: flags.event,
|
|
slug,
|
|
reason: `stale (last seen ${match.timestamp})`,
|
|
})}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
verified: true,
|
|
event: flags.event,
|
|
slug,
|
|
audit_timestamp: match.timestamp,
|
|
})
|
|
);
|
|
}
|
|
|
|
// --- Subcommand: info ---
|
|
//
|
|
// Usage: aidlc-worktree info --slug <slug>
|
|
//
|
|
// Reads the most-recent WORKTREE_CREATED audit block for `slug`, parses the
|
|
// `Worktree path` and `Branch name` fields, emits JSON to stdout, exits 0.
|
|
// On miss or malformed-block, prints an error to stderr and exits non-zero.
|
|
//
|
|
// The halt-and-ask flow calls this to interpolate the worktree path and
|
|
// branch name into the AskUserQuestion prompt body. Schema pinned in
|
|
// `knowledge/aidlc-shared/worktree-info-schema.md`.
|
|
function handleInfo(args: string[]): void {
|
|
const flags = parseFlags(args);
|
|
const slug = validateSlug(flags.slug);
|
|
|
|
const pd = resolveProjectDir(projectDir);
|
|
// Read across every per-clone audit shard (single shard in the common case).
|
|
const audit = readAllAuditShards(pd, flags.intent, flags.space);
|
|
if (audit.length === 0) {
|
|
process.stderr.write(
|
|
`error: no WORKTREE_CREATED audit entry for slug ${slug} (audit log absent)\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const match = findLatestEvent(audit, "WORKTREE_CREATED", slug);
|
|
if (!match) {
|
|
process.stderr.write(
|
|
`error: no WORKTREE_CREATED audit entry for slug ${slug}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const pathMatch = match.block.match(/^\*\*Worktree path\*\*:\s*(.+?)\s*$/m);
|
|
const branchMatch = match.block.match(/^\*\*Branch name\*\*:\s*(.+?)\s*$/m);
|
|
if (!pathMatch || !branchMatch) {
|
|
process.stderr.write(
|
|
`error: malformed WORKTREE_CREATED block at ${match.timestamp} (missing Worktree path or Branch name field)\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read the per-Bolt forked state file for the Merge-Held marker if present.
|
|
// Absence of the file or the field both resolve to merge_held=false — the
|
|
// resume-path check is "do not dispatch a merge that's actively held",
|
|
// not "every Bolt has had its hold state explicitly initialised".
|
|
const resolvedWorktreePath = resolveAuditWorktreePath(pd, pathMatch[1]);
|
|
let mergeHeld = false;
|
|
const wtStatePath = worktreeStateFilePath(resolvedWorktreePath);
|
|
if (existsSync(wtStatePath)) {
|
|
const wtContent = readFileSync(wtStatePath, "utf-8");
|
|
mergeHeld = getField(wtContent, "Merge-Held") === "true";
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
slug,
|
|
path: resolvedWorktreePath,
|
|
branch_name: branchMatch[1],
|
|
audit_timestamp: match.timestamp,
|
|
merge_held: mergeHeld,
|
|
})
|
|
);
|
|
}
|
|
|
|
interface AuditMatch {
|
|
timestamp: string;
|
|
block: string;
|
|
}
|
|
|
|
function findLatestEvent(
|
|
audit: string,
|
|
event: string,
|
|
slug: string
|
|
): AuditMatch | null {
|
|
// Select the CHRONOLOGICALLY-newest matching block (max **Timestamp**), NOT
|
|
// the last block by buffer position. The audit string is a readAllAuditShards
|
|
// glob-merge that concatenates per-clone shards in FILENAME (lexical) order,
|
|
// so it is NOT time-ordered across shards — a buffer-position "last match
|
|
// wins" walk could return an OLDER block from a lexically-later shard (e.g.
|
|
// `worktree verify --max-age-seconds` reporting a fresh worktree STALE, or
|
|
// `worktree info` returning a stale path/branch). Delegate to findAllEvents,
|
|
// which CRLF-normalizes before splitting and sorts ascending by ISO-8601
|
|
// timestamp with a buffer-position tiebreak — the SAME ordering fix the other
|
|
// readers (findAllEvents / buildWorkflowHeader / hasStageAuditEvent) already
|
|
// use — then take the last (newest) match. Returns null on no match.
|
|
const matches = findAllEvents(audit, event, slug);
|
|
if (matches.length === 0) return null;
|
|
const newest = matches[matches.length - 1];
|
|
return { timestamp: newest.timestamp, block: newest.block };
|
|
}
|
|
|
|
// --- CLI entry point ---
|
|
|
|
let projectDir: string | undefined;
|
|
|
|
export function main(argv: string[]): void {
|
|
const rawArgs = argv;
|
|
|
|
const filteredArgs: string[] = [];
|
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
if (rawArgs[i] === "--project-dir" && i + 1 < rawArgs.length) {
|
|
projectDir = rawArgs[i + 1];
|
|
i++;
|
|
} else {
|
|
filteredArgs.push(rawArgs[i]);
|
|
}
|
|
}
|
|
|
|
const subcommand = filteredArgs[0];
|
|
|
|
try {
|
|
switch (subcommand) {
|
|
case "create":
|
|
handleCreate(filteredArgs.slice(1));
|
|
break;
|
|
case "merge":
|
|
handleMerge(filteredArgs.slice(1));
|
|
break;
|
|
case "discard":
|
|
handleDiscard(filteredArgs.slice(1));
|
|
break;
|
|
case "list":
|
|
handleList(filteredArgs.slice(1));
|
|
break;
|
|
case "verify":
|
|
handleVerify(filteredArgs.slice(1));
|
|
break;
|
|
case "info":
|
|
handleInfo(filteredArgs.slice(1));
|
|
break;
|
|
default:
|
|
error(
|
|
`Unknown subcommand: ${subcommand}. Valid: create, merge, discard, list, verify, info`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
error(errorMessage(e));
|
|
}
|
|
}
|
|
|
|
// errorWithSlug — emits ERROR_LOGGED via emitError with `[slug=<slug>]`
|
|
// prepended to the message so doctor's regex `\[slug=([a-z0-9-]+)\]` can
|
|
// correlate the error with the affected Bolt without re-engineering
|
|
// emitError's field set.
|
|
function errorWithSlug(slug: string, msg: string): never {
|
|
error(`[slug=${slug}] ${msg}`);
|
|
}
|
|
|
|
function error(msg: string): never {
|
|
const pd = resolveProjectDir(projectDir);
|
|
const command = `aidlc-worktree ${process.argv.slice(2).join(" ")}`.trim();
|
|
emitError(pd, "aidlc-worktree", command, msg);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main(process.argv.slice(2));
|
|
}
|