3600 lines
112 KiB
TypeScript
3600 lines
112 KiB
TypeScript
// aidlc-unit.ts - atomic Unit claim registry and checkout-local scope binding.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
import { appendAuditEntry } from "./aidlc-audit.ts";
|
|
import {
|
|
activeIntentUuid,
|
|
activeSpace,
|
|
artifactFilename,
|
|
auditBlockField,
|
|
auditShardName,
|
|
type AuditShardEvent,
|
|
type CachedUnitClaim,
|
|
candidateReviewCoverageProjection,
|
|
clearClaimGeneration,
|
|
clearUnitScopeStamp,
|
|
ensureCloneId,
|
|
errorMessage,
|
|
extractMarkdownSection,
|
|
filterProducesByKind,
|
|
getField,
|
|
humanPresenceGuardDisabled,
|
|
idSuffix,
|
|
invalidateLiveClaimPayloadCache,
|
|
isNonAnswer,
|
|
isReadOnlyEngineProbe,
|
|
isTeamUnitOwnership,
|
|
isoTimestamp,
|
|
loadScopeMetadata,
|
|
loadStageGraphAll,
|
|
parseArgs,
|
|
parseBoltDag,
|
|
readAuditShardEvents,
|
|
readUnitClaimRegistryCache,
|
|
readApplicableTeamUnitScopeStamp,
|
|
readUnitMergeTransaction,
|
|
readStateFile,
|
|
readUnitGateRhythm,
|
|
readUnitScopeStamp,
|
|
parseReviewRecordBytes,
|
|
pairedReviewRecordForCompletion,
|
|
relativeRecordDir,
|
|
REVIEW_RECORDS_DIR,
|
|
reviewRecordDigest,
|
|
type ReviewRecord,
|
|
requireLiveClaimForTeamUnit,
|
|
resolveProjectDir,
|
|
resolveReviewClass,
|
|
selfAttributedDecisionMarker,
|
|
type UnitMergeEvidence,
|
|
type UnitMergeTransaction,
|
|
UNIT_MERGE_DIR,
|
|
type UnitScopeStamp,
|
|
unitGateStatus,
|
|
unitLifecycleSnapshot,
|
|
unitDependencyPath,
|
|
unitMergedReceipts,
|
|
unitMajorConstructionStageSlugs,
|
|
unitParticipantPath,
|
|
unitReleasePendingPath,
|
|
validateUnitName,
|
|
withAuditLock,
|
|
writeUnitClaimRegistryCache,
|
|
writeUnitMergeTransaction,
|
|
writeStateFile,
|
|
writeUnitScopeStamp,
|
|
writeClaimGeneration,
|
|
} from "./aidlc-lib.js";
|
|
import {
|
|
parseTestingContract,
|
|
PLAN_APPROVAL_CHECKPOINT,
|
|
recordedApprovalFingerprint,
|
|
resolveTestingPosture,
|
|
} from "./aidlc-testing-posture.ts";
|
|
|
|
const CLAIM_FILE = ".aidlc-unit-claim.json";
|
|
const ZERO_OID = "0000000000000000000000000000000000000000";
|
|
|
|
interface GitResult {
|
|
ok: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
}
|
|
|
|
function git(projectDir: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
|
|
const result = spawnSync("git", args, {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
env: { ...process.env, ...env },
|
|
});
|
|
return {
|
|
ok: result.status === 0,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
code: result.status ?? 1,
|
|
};
|
|
}
|
|
|
|
function localGit(
|
|
projectDir: string,
|
|
args: string[],
|
|
env?: NodeJS.ProcessEnv,
|
|
): GitResult {
|
|
return git(projectDir, args, {
|
|
GIT_NO_LAZY_FETCH: "1",
|
|
...env,
|
|
});
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
throw new Error(message);
|
|
}
|
|
|
|
export interface UnitClaimPayload {
|
|
version: 1;
|
|
status: "claimed" | "released";
|
|
owner: string;
|
|
space: string;
|
|
intent_uuid: string;
|
|
intent_id8: string;
|
|
unit: string;
|
|
generation: number;
|
|
nonce: string;
|
|
base_oid: string;
|
|
integration_ref: string;
|
|
claim_ref: string;
|
|
predecessor_oid: string | null;
|
|
gate_rhythm: "per-stage" | "unit-end";
|
|
audit_shard?: string;
|
|
candidate_tree_oid?: string;
|
|
candidate_head_oid?: string;
|
|
}
|
|
|
|
export interface UnitClaimView {
|
|
unit: string;
|
|
status: "claimed" | "released";
|
|
owner: string;
|
|
generation: number;
|
|
nonce: string;
|
|
oid: string;
|
|
ref: string;
|
|
observedAt?: string;
|
|
movementObserved?: boolean;
|
|
payload: UnitClaimPayload;
|
|
}
|
|
|
|
export interface UnitClaimOverview {
|
|
claimable: string[];
|
|
claimed: Array<{ unit: string; owner: string; generation: number }>;
|
|
waiting: Array<{ unit: string; blockedBy: string[] }>;
|
|
claims: Map<string, UnitClaimView>;
|
|
warning?: string;
|
|
}
|
|
|
|
function repositoryRemote(projectDir: string): string | null {
|
|
const result = git(projectDir, ["remote"]);
|
|
if (!result.ok) return null;
|
|
const remotes = result.stdout.split(/\r?\n/).map((v) => v.trim()).filter(Boolean);
|
|
if (remotes.length === 0) return null;
|
|
if (remotes.includes("origin")) return "origin";
|
|
if (remotes.length === 1) return remotes[0];
|
|
fail(`Multiple git remotes are configured (${remotes.join(", ")}); configure origin for Unit claims.`);
|
|
}
|
|
|
|
function currentBranch(projectDir: string): string {
|
|
const result = git(projectDir, ["branch", "--show-current"]);
|
|
return result.ok && result.stdout.trim() ? result.stdout.trim() : "main";
|
|
}
|
|
|
|
function affirmedIntegrationBranch(projectDir: string): string | null {
|
|
const memoryRoot = join(
|
|
projectDir,
|
|
"aidlc",
|
|
"spaces",
|
|
activeSpace(projectDir),
|
|
"memory",
|
|
);
|
|
for (const name of ["project.md", "team.md", "org.md"]) {
|
|
try {
|
|
const body = readFileSync(join(memoryRoot, name), "utf-8");
|
|
const section = extractMarkdownSection(body, "## Way of Working");
|
|
const explicit =
|
|
/\b(?:base|integration|merge target)(?: branch)?\b[^`\n]*`([^`]+)`/i.exec(section) ??
|
|
/`([^`]+)`[^.\n]*\b(?:base|integration|merge target)(?: branch)?\b/i.exec(section);
|
|
if (
|
|
explicit &&
|
|
/^(?:main|master|develop|release\/[A-Za-z0-9._/-]+)$/.test(explicit[1])
|
|
) {
|
|
return explicit[1];
|
|
}
|
|
} catch {
|
|
// Try the next narrower/broader method layer.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function integrationBranch(projectDir: string, remote: string | null): string {
|
|
const affirmed = affirmedIntegrationBranch(projectDir);
|
|
if (affirmed) return affirmed;
|
|
if (remote) {
|
|
const cached = git(projectDir, ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`]);
|
|
if (cached.ok && cached.stdout.trim().startsWith(`${remote}/`)) {
|
|
return cached.stdout.trim().slice(remote.length + 1);
|
|
}
|
|
const advertised = git(projectDir, ["ls-remote", "--symref", remote, "HEAD"]);
|
|
const match = advertised.stdout.match(/^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/m);
|
|
if (match) return match[1];
|
|
}
|
|
const current = currentBranch(projectDir);
|
|
const configured = git(projectDir, ["config", `branch.${current}.merge`]);
|
|
if (configured.ok && configured.stdout.trim().startsWith("refs/heads/")) {
|
|
return configured.stdout.trim().slice("refs/heads/".length);
|
|
}
|
|
for (const candidate of ["main", "master", current]) {
|
|
if (
|
|
candidate &&
|
|
git(projectDir, ["rev-parse", "--verify", `refs/heads/${candidate}`]).ok
|
|
) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return current || "main";
|
|
}
|
|
|
|
function fetchIntegration(projectDir: string): {
|
|
remote: string | null;
|
|
branch: string;
|
|
ref: string;
|
|
oid: string;
|
|
} {
|
|
const remote = repositoryRemote(projectDir);
|
|
const branch = integrationBranch(projectDir, remote);
|
|
if (remote) {
|
|
const fetched = git(projectDir, ["fetch", remote, branch]);
|
|
if (!fetched.ok) fail(`git fetch ${remote} ${branch} failed: ${fetched.stderr.trim()}`);
|
|
}
|
|
const ref = remote ? `refs/remotes/${remote}/${branch}` : `refs/heads/${branch}`;
|
|
const oid = git(projectDir, ["rev-parse", "--verify", ref]);
|
|
if (!oid.ok) fail(`Integration ref ${ref} does not exist.`);
|
|
return { remote, branch, ref, oid: oid.stdout.trim() };
|
|
}
|
|
|
|
function claimRef(intentId8: string, unit: string): string {
|
|
return `refs/heads/claim/${intentId8}/${unit}`;
|
|
}
|
|
|
|
function fetchClaimRefs(projectDir: string, remote: string | null): void {
|
|
if (!remote) return;
|
|
const fetched = git(projectDir, [
|
|
"fetch",
|
|
"--no-filter",
|
|
remote,
|
|
"+refs/heads/claim/*:refs/remotes/" + remote + "/claim/*",
|
|
]);
|
|
if (!fetched.ok) {
|
|
fail(`Unit claim registry fetch failed: ${fetched.stderr.trim()}`);
|
|
}
|
|
}
|
|
|
|
function refTip(
|
|
projectDir: string,
|
|
remote: string | null,
|
|
ref: string,
|
|
): string | null {
|
|
if (remote) {
|
|
const found = git(projectDir, ["ls-remote", remote, ref]);
|
|
if (!found.ok) {
|
|
fail(`Unit claim registry read failed: ${found.stderr.trim() || found.stdout.trim()}`);
|
|
}
|
|
const line = found.stdout.trim().split(/\r?\n/).find(Boolean);
|
|
return line ? line.split(/\s+/)[0] : null;
|
|
}
|
|
const found = git(projectDir, ["rev-parse", "--verify", ref]);
|
|
return found.ok ? found.stdout.trim() : null;
|
|
}
|
|
|
|
function readPayload(
|
|
projectDir: string,
|
|
oid: string,
|
|
options: { localOnly?: boolean } = {},
|
|
): UnitClaimPayload | null {
|
|
const shown = options.localOnly
|
|
? localGit(projectDir, ["show", `${oid}:${CLAIM_FILE}`])
|
|
: git(projectDir, ["show", `${oid}:${CLAIM_FILE}`]);
|
|
if (!shown.ok) return null;
|
|
try {
|
|
const parsed = JSON.parse(shown.stdout) as UnitClaimPayload;
|
|
if (
|
|
parsed.version !== 1 ||
|
|
(parsed.status !== "claimed" && parsed.status !== "released") ||
|
|
typeof parsed.unit !== "string" ||
|
|
!Number.isInteger(parsed.generation) ||
|
|
typeof parsed.nonce !== "string" ||
|
|
!/^[0-9a-f]{40}$/.test(parsed.base_oid) ||
|
|
typeof parsed.integration_ref !== "string" ||
|
|
typeof parsed.claim_ref !== "string" ||
|
|
(parsed.audit_shard !== undefined &&
|
|
(typeof parsed.audit_shard !== "string" ||
|
|
!/^[A-Za-z0-9._-]+\.md$/.test(parsed.audit_shard))) ||
|
|
(
|
|
parsed.candidate_tree_oid !== undefined &&
|
|
!/^[0-9a-f]{40}$/.test(parsed.candidate_tree_oid)
|
|
) ||
|
|
(
|
|
parsed.candidate_head_oid !== undefined &&
|
|
!/^[0-9a-f]{40}$/.test(parsed.candidate_head_oid)
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function ensureObject(
|
|
projectDir: string,
|
|
remote: string | null,
|
|
oid: string,
|
|
ref: string,
|
|
): void {
|
|
const hasCommit = (): boolean =>
|
|
localGit(projectDir, ["cat-file", "-e", `${oid}^{commit}`]).ok;
|
|
const payloadBlob = (): string | null => {
|
|
const found = localGit(projectDir, ["rev-parse", `${oid}:${CLAIM_FILE}`]);
|
|
return found.ok && /^[0-9a-f]{40}$/.test(found.stdout.trim())
|
|
? found.stdout.trim()
|
|
: null;
|
|
};
|
|
const hasBlob = (blob: string | null): boolean =>
|
|
blob !== null && localGit(projectDir, ["cat-file", "-e", blob]).ok;
|
|
let blob = payloadBlob();
|
|
if (hasCommit() && hasBlob(blob)) return;
|
|
if (!remote) {
|
|
fail(
|
|
`Unit claim registry payload is unavailable at ${ref}; the local repository may be a partial clone with the claim payload blob missing.`,
|
|
);
|
|
}
|
|
const fetched = git(projectDir, [
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-filter",
|
|
remote,
|
|
ref,
|
|
]);
|
|
if (!fetched.ok) {
|
|
fail(
|
|
`Unit claim registry fetch failed: ${fetched.stderr.trim()} ` +
|
|
`(a partial clone requires a non-filtered fetch of ${ref}).`,
|
|
);
|
|
}
|
|
blob = payloadBlob();
|
|
if (blob && !hasBlob(blob)) {
|
|
const hydrated = git(projectDir, [
|
|
"fetch",
|
|
"--no-tags",
|
|
"--no-filter",
|
|
remote,
|
|
blob,
|
|
]);
|
|
if (!hydrated.ok) {
|
|
fail(
|
|
`Unit claim registry fetch failed: ${hydrated.stderr.trim()} ` +
|
|
`(the partial clone is missing payload blob ${blob} for ${ref}).`,
|
|
);
|
|
}
|
|
}
|
|
if (!hasCommit() || !hasBlob(blob)) {
|
|
fail(
|
|
`Unit claim registry fetch failed: payload at ${ref} is unavailable after a non-filtered fetch; ` +
|
|
"the partial clone is missing the claim payload blob.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function currentClaim(
|
|
projectDir: string,
|
|
remote: string | null,
|
|
ref: string,
|
|
): UnitClaimView | null {
|
|
const oid = refTip(projectDir, remote, ref);
|
|
if (!oid) return null;
|
|
ensureObject(projectDir, remote, oid, ref);
|
|
const payload = readPayload(projectDir, oid, { localOnly: true });
|
|
if (!payload) fail(`Unit claim registry payload is invalid at ${ref}.`);
|
|
return {
|
|
unit: payload.unit,
|
|
status: payload.status,
|
|
owner: payload.owner,
|
|
generation: payload.generation,
|
|
nonce: payload.nonce,
|
|
oid,
|
|
ref,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function createClaimCommit(
|
|
projectDir: string,
|
|
parentOid: string,
|
|
payload: UnitClaimPayload,
|
|
sourceTreeOid?: string,
|
|
additionalParentOid?: string,
|
|
): string {
|
|
const scratch = mkdtempSync(join(tmpdir(), "aidlc-unit-claim-"));
|
|
const index = join(scratch, "index");
|
|
try {
|
|
const env = {
|
|
GIT_INDEX_FILE: index,
|
|
GIT_AUTHOR_NAME: "AI-DLC Unit Claims",
|
|
GIT_AUTHOR_EMAIL: "aidlc-unit@local",
|
|
GIT_COMMITTER_NAME: "AI-DLC Unit Claims",
|
|
GIT_COMMITTER_EMAIL: "aidlc-unit@local",
|
|
};
|
|
const read = git(projectDir, ["read-tree", sourceTreeOid ?? parentOid], env);
|
|
if (!read.ok) fail(`git read-tree failed: ${read.stderr.trim()}`);
|
|
const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], {
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
input: `${JSON.stringify(payload, null, 2)}\n`,
|
|
});
|
|
if (blob.status !== 0) fail(`git hash-object failed: ${(blob.stderr ?? "").trim()}`);
|
|
const update = git(
|
|
projectDir,
|
|
["update-index", "--add", "--cacheinfo", `100644,${(blob.stdout ?? "").trim()},${CLAIM_FILE}`],
|
|
env,
|
|
);
|
|
if (!update.ok) fail(`git update-index failed: ${update.stderr.trim()}`);
|
|
const tree = git(projectDir, ["write-tree"], env);
|
|
if (!tree.ok) fail(`git write-tree failed: ${tree.stderr.trim()}`);
|
|
const commitArgs = [
|
|
"commit-tree",
|
|
tree.stdout.trim(),
|
|
"-p",
|
|
parentOid,
|
|
...(additionalParentOid ? ["-p", additionalParentOid] : []),
|
|
"-m",
|
|
`aidlc unit ${payload.status}: ${payload.unit} generation ${payload.generation}`,
|
|
];
|
|
const commit = git(projectDir, commitArgs, env);
|
|
if (!commit.ok) fail(`git commit-tree failed: ${commit.stderr.trim()}`);
|
|
return commit.stdout.trim();
|
|
} finally {
|
|
rmSync(scratch, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function updateClaimRef(
|
|
projectDir: string,
|
|
remote: string | null,
|
|
ref: string,
|
|
commitOid: string,
|
|
expectedOid: string | null,
|
|
): boolean {
|
|
if (remote) {
|
|
const lease = `--force-with-lease=${ref}:${expectedOid ?? ""}`;
|
|
const pushed = git(projectDir, ["push", remote, lease, `${commitOid}:${ref}`]);
|
|
return pushed.ok;
|
|
}
|
|
const updated = git(projectDir, [
|
|
"update-ref",
|
|
ref,
|
|
commitOid,
|
|
expectedOid ?? ZERO_OID,
|
|
]);
|
|
return updated.ok;
|
|
}
|
|
|
|
function activeIdentity(projectDir: string): {
|
|
space: string;
|
|
intentUuid: string;
|
|
intentId8: string;
|
|
} {
|
|
const space = activeSpace(projectDir);
|
|
const intentUuid = activeIntentUuid(projectDir, space);
|
|
if (!intentUuid) fail("No active intent UUID; switch to the workflow before claiming a Unit.");
|
|
return { space, intentUuid, intentId8: idSuffix(intentUuid) };
|
|
}
|
|
|
|
function stateAtOid(projectDir: string, oid: string): string {
|
|
const relative = relativeRecordDir(projectDir);
|
|
if (!relative) fail("Cannot resolve the active intent record path.");
|
|
const shown = git(projectDir, ["show", `${oid}:${relative}/aidlc-state.md`]);
|
|
if (!shown.ok) fail("The fetched integration ref does not contain the active intent state.");
|
|
return shown.stdout;
|
|
}
|
|
|
|
function dependencyEdgesAtOid(projectDir: string, oid: string): ReturnType<typeof parseBoltDag> {
|
|
const relative = relativeRecordDir(projectDir);
|
|
if (!relative) fail("Cannot resolve the active intent record path.");
|
|
const shown = git(
|
|
projectDir,
|
|
["show", `${oid}:${relative}/inception/units-generation/unit-of-work-dependency.md`],
|
|
);
|
|
if (!shown.ok) fail("The fetched integration ref has no Unit dependency artifact.");
|
|
return parseBoltDag(shown.stdout);
|
|
}
|
|
|
|
function completedUnits(state: string): Set<string> {
|
|
const match = /^## Unit Progress\s*$/m.exec(state);
|
|
if (!match) return new Set();
|
|
const after = match.index + match[0].length;
|
|
const next = /^## /m.exec(state.slice(after));
|
|
const section = state.slice(after, next ? after + next.index : state.length);
|
|
const table = section
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("|"))
|
|
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
|
const header = table.find((cells) => cells[0]?.toLowerCase() === "unit");
|
|
if (!header) return new Set();
|
|
const mergedIndex = header.findIndex(
|
|
(cell) => cell.toLowerCase() === "merged",
|
|
);
|
|
const done = new Set<string>();
|
|
for (const cells of table) {
|
|
if (cells.length !== header.length || cells.length < 4) continue;
|
|
if (
|
|
(cells[0].toLowerCase() === "unit" &&
|
|
cells[1].toLowerCase() === "owner") ||
|
|
cells.every((cell) => /^-+$/.test(cell))
|
|
) {
|
|
continue;
|
|
}
|
|
const workCells = cells.slice(
|
|
2,
|
|
mergedIndex >= 0 ? mergedIndex : undefined,
|
|
);
|
|
if (
|
|
workCells.every((cell) => cell === "[x]") &&
|
|
(mergedIndex < 0 || cells[mergedIndex] === "[x]")
|
|
) {
|
|
done.add(cells[0]);
|
|
}
|
|
}
|
|
return done;
|
|
}
|
|
|
|
function skeletonCompletedAtOid(projectDir: string, oid: string): boolean {
|
|
const relative = relativeRecordDir(projectDir);
|
|
if (!relative) return false;
|
|
const listed = git(projectDir, [
|
|
"ls-tree",
|
|
"-r",
|
|
"--name-only",
|
|
oid,
|
|
`${relative}/audit`,
|
|
]);
|
|
if (!listed.ok) return false;
|
|
const events: Array<{
|
|
event: string;
|
|
names: string;
|
|
timestamp: string;
|
|
skeleton: boolean;
|
|
position: number;
|
|
}> = [];
|
|
let position = 0;
|
|
for (const path of listed.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
const shown = git(projectDir, ["show", `${oid}:${path}`]);
|
|
if (!shown.ok) continue;
|
|
for (const block of shown.stdout.split(/\n---\n/)) {
|
|
const event = /^\*\*Event\*\*:\s*(.+)$/m.exec(block)?.[1]?.trim();
|
|
const names =
|
|
/^\*\*Bolt names\*\*:\s*(.+)$/m.exec(block)?.[1]?.trim() ?? "";
|
|
if (!event) continue;
|
|
events.push({
|
|
event,
|
|
names,
|
|
timestamp: /^\*\*Timestamp\*\*:\s*(.+)$/m.exec(block)?.[1]?.trim() ?? "",
|
|
skeleton: /^\*\*Walking skeleton\*\*:\s*true\s*$/m.test(block),
|
|
position: position++,
|
|
});
|
|
}
|
|
}
|
|
events.sort((a, b) =>
|
|
a.timestamp !== b.timestamp
|
|
? (a.timestamp < b.timestamp ? -1 : 1)
|
|
: a.position - b.position
|
|
);
|
|
const started = new Set<string>();
|
|
for (const event of events) {
|
|
if (event.event === "WORKFLOW_STARTED" || event.event === "STAGE_JUMPED") {
|
|
started.clear();
|
|
continue;
|
|
}
|
|
if (event.event === "BOLT_STARTED" && event.skeleton) {
|
|
started.add(event.names);
|
|
continue;
|
|
}
|
|
if (event.event === "BOLT_COMPLETED" && started.has(event.names)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function openingStatus(
|
|
projectDir: string,
|
|
baseOid: string,
|
|
): { units: string[]; blockers: Map<string, string[]>; completed: Set<string> } {
|
|
const state = stateAtOid(projectDir, baseOid);
|
|
if (!isTeamUnitOwnership(state)) fail("Unit claims require Unit Ownership: team.");
|
|
const parsed = dependencyEdgesAtOid(projectDir, baseOid);
|
|
if (!parsed.ok) fail(`Cannot resolve Unit dependencies: ${parsed.reason}: ${parsed.detail}`);
|
|
const completed = completedUnits(state);
|
|
const scope = getField(state, "Scope") ?? "";
|
|
const skeletonOn = loadScopeMetadata()[scope]?.skeleton === true;
|
|
const skeletonBlocked =
|
|
skeletonOn && !skeletonCompletedAtOid(projectDir, baseOid);
|
|
const blockers = new Map<string, string[]>();
|
|
for (const edge of parsed.units) {
|
|
const waiting = edge.depends_on.filter((dep) => !completed.has(dep));
|
|
if (skeletonBlocked) waiting.unshift("walking-skeleton");
|
|
blockers.set(edge.name, [...new Set(waiting)]);
|
|
}
|
|
return { units: parsed.units.map((edge) => edge.name), blockers, completed };
|
|
}
|
|
|
|
function localOpeningStatus(
|
|
projectDir: string,
|
|
): { units: string[]; blockers: Map<string, string[]>; completed: Set<string> } {
|
|
const state = readStateFile(projectDir);
|
|
const dependencyBody = readFileSync(unitDependencyPath(projectDir), "utf-8");
|
|
return localOpeningStatusFrom(state, dependencyBody);
|
|
}
|
|
|
|
function localOpeningStatusFrom(
|
|
state: string,
|
|
dependencyBody: string,
|
|
): { units: string[]; blockers: Map<string, string[]>; completed: Set<string> } {
|
|
if (!isTeamUnitOwnership(state)) fail("Unit claims require Unit Ownership: team.");
|
|
const parsed = parseBoltDag(dependencyBody);
|
|
if (!parsed.ok) fail(`Cannot resolve Unit dependencies: ${parsed.reason}: ${parsed.detail}`);
|
|
const completed = completedUnits(state);
|
|
const scope = getField(state, "Scope") ?? "";
|
|
const skeletonBlocked =
|
|
loadScopeMetadata()[scope]?.skeleton === true &&
|
|
!completed.has(parsed.units[0]?.name ?? "");
|
|
const blockers = new Map<string, string[]>();
|
|
for (const edge of parsed.units) {
|
|
const waiting = edge.depends_on.filter((dep) => !completed.has(dep));
|
|
if (skeletonBlocked) waiting.unshift("walking-skeleton");
|
|
blockers.set(edge.name, [...new Set(waiting)]);
|
|
}
|
|
return { units: parsed.units.map((edge) => edge.name), blockers, completed };
|
|
}
|
|
|
|
function allClaims(
|
|
projectDir: string,
|
|
remote: string | null,
|
|
intentId8: string,
|
|
units: string[],
|
|
): Map<string, UnitClaimView> {
|
|
fetchClaimRefs(projectDir, remote);
|
|
const claims = new Map<string, UnitClaimView>();
|
|
for (const unit of units) {
|
|
const ref = claimRef(intentId8, unit);
|
|
const claim = currentClaim(projectDir, remote, ref);
|
|
if (claim) claims.set(unit, claim);
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
function buildOverview(
|
|
opening: ReturnType<typeof openingStatus>,
|
|
claims: Map<string, UnitClaimView>,
|
|
warning?: string,
|
|
): UnitClaimOverview {
|
|
const claimable: string[] = [];
|
|
const claimed: Array<{ unit: string; owner: string; generation: number }> = [];
|
|
const waiting: Array<{ unit: string; blockedBy: string[] }> = [];
|
|
for (const unit of opening.units) {
|
|
if (opening.completed.has(unit)) continue;
|
|
const claim = claims.get(unit);
|
|
if (claim?.status === "claimed") {
|
|
claimed.push({ unit, owner: claim.owner, generation: claim.generation });
|
|
continue;
|
|
}
|
|
const blockedBy = opening.blockers.get(unit) ?? [];
|
|
if (blockedBy.length > 0) waiting.push({ unit, blockedBy });
|
|
else claimable.push(unit);
|
|
}
|
|
return { claimable, claimed, waiting, claims, ...(warning ? { warning } : {}) };
|
|
}
|
|
|
|
function cacheClaims(
|
|
projectDir: string,
|
|
claims: Map<string, UnitClaimView>,
|
|
warning?: string,
|
|
): Map<string, UnitClaimView> {
|
|
const identity = activeIdentity(projectDir);
|
|
const priorCache = readUnitClaimRegistryCache(projectDir);
|
|
const cached: Record<string, CachedUnitClaim> = {};
|
|
for (const [unit, claim] of claims) {
|
|
const prior =
|
|
priorCache?.space === identity.space &&
|
|
priorCache.intent_uuid === identity.intentUuid
|
|
? priorCache.claims[unit]
|
|
: undefined;
|
|
const movementObserved =
|
|
prior !== undefined && prior.oid !== claim.oid;
|
|
const observedAt = prior?.observed_at ?? isoTimestamp();
|
|
const nextObservedAt = movementObserved ? isoTimestamp() : observedAt;
|
|
claim.observedAt = observedAt;
|
|
claim.movementObserved = movementObserved;
|
|
cached[unit] = {
|
|
status: claim.status,
|
|
owner: claim.owner,
|
|
generation: claim.generation,
|
|
nonce: claim.nonce,
|
|
ref: claim.ref,
|
|
oid: claim.oid,
|
|
observed_at: nextObservedAt,
|
|
};
|
|
if (claim.status === "claimed") {
|
|
writeClaimGeneration(projectDir, unit, claim.generation);
|
|
} else {
|
|
clearClaimGeneration(projectDir, unit);
|
|
}
|
|
}
|
|
writeUnitClaimRegistryCache(projectDir, {
|
|
version: 1,
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
claims: cached,
|
|
...(warning ? { warning } : {}),
|
|
});
|
|
return claims;
|
|
}
|
|
|
|
interface UnitClaimIdentity {
|
|
space: string;
|
|
intentUuid: string;
|
|
intentId8: string;
|
|
}
|
|
|
|
function cachedClaimsForIdentity(
|
|
projectDir: string,
|
|
identity: UnitClaimIdentity,
|
|
includeCache: boolean,
|
|
): Map<string, UnitClaimView> {
|
|
const cache = readUnitClaimRegistryCache(projectDir);
|
|
const claims = new Map<string, UnitClaimView>();
|
|
if (
|
|
includeCache &&
|
|
cache &&
|
|
cache.space === identity.space &&
|
|
cache.intent_uuid === identity.intentUuid
|
|
) {
|
|
for (const [unit, claim] of Object.entries(cache.claims)) {
|
|
claims.set(unit, {
|
|
unit,
|
|
status: claim.status,
|
|
owner: claim.owner,
|
|
generation: claim.generation,
|
|
nonce: claim.nonce,
|
|
oid: claim.oid,
|
|
ref: claim.ref,
|
|
observedAt: claim.observed_at,
|
|
movementObserved: false,
|
|
payload: {
|
|
version: 1,
|
|
status: claim.status,
|
|
owner: claim.owner,
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
intent_id8: identity.intentId8,
|
|
unit,
|
|
generation: claim.generation,
|
|
nonce: claim.nonce,
|
|
base_oid: "",
|
|
integration_ref: "",
|
|
claim_ref: claim.ref,
|
|
predecessor_oid: null,
|
|
gate_rhythm: "per-stage",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
const refs = localGit(projectDir, [
|
|
"for-each-ref",
|
|
"--format=%(objectname) %(refname)",
|
|
"refs/heads/claim/",
|
|
"refs/remotes/",
|
|
]);
|
|
if (!refs.ok) return claims;
|
|
for (const line of refs.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
const [oid, ref] = line.trim().split(/\s+/, 2);
|
|
if (!oid || !ref) continue;
|
|
if (
|
|
!ref.startsWith(`refs/heads/claim/${identity.intentId8}/`) &&
|
|
!new RegExp(
|
|
`^refs/remotes/[^/]+/claim/${identity.intentId8.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`,
|
|
).test(ref)
|
|
) {
|
|
continue;
|
|
}
|
|
const payload = readPayload(projectDir, oid, { localOnly: true });
|
|
if (!payload || payload.intent_uuid !== identity.intentUuid) {
|
|
throw new Error(
|
|
`Local Unit claim registry payload is unavailable or invalid at ${ref}; ` +
|
|
"fetch that claim ref without a partial-clone filter before retrying.",
|
|
);
|
|
}
|
|
const prior = claims.get(payload.unit);
|
|
if (
|
|
prior &&
|
|
(prior.generation > payload.generation ||
|
|
(prior.generation === payload.generation &&
|
|
(
|
|
prior.oid === oid ||
|
|
(prior.status === "claimed" && payload.status === "released")
|
|
)))
|
|
) {
|
|
continue;
|
|
}
|
|
claims.set(payload.unit, {
|
|
unit: payload.unit,
|
|
status: payload.status,
|
|
owner: payload.owner,
|
|
generation: payload.generation,
|
|
nonce: payload.nonce,
|
|
oid,
|
|
ref: payload.claim_ref,
|
|
observedAt:
|
|
cache?.space === identity.space &&
|
|
cache.intent_uuid === identity.intentUuid
|
|
? cache.claims[payload.unit]?.observed_at
|
|
: undefined,
|
|
movementObserved:
|
|
cache?.space === identity.space &&
|
|
cache.intent_uuid === identity.intentUuid &&
|
|
cache.claims[payload.unit] !== undefined &&
|
|
cache.claims[payload.unit]?.oid !== oid,
|
|
payload,
|
|
});
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
function cachedClaims(projectDir: string): Map<string, UnitClaimView> {
|
|
return cachedClaimsForIdentity(projectDir, activeIdentity(projectDir), true);
|
|
}
|
|
|
|
export function cachedUnitClaimOverview(
|
|
projectDir: string,
|
|
options: { writeCache?: boolean } = {},
|
|
): UnitClaimOverview {
|
|
const pd = resolveProjectDir(projectDir);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) {
|
|
return { claimable: [], claimed: [], waiting: [], claims: new Map() };
|
|
}
|
|
const opening = localOpeningStatus(pd);
|
|
const cache = readUnitClaimRegistryCache(pd);
|
|
let claims = cachedClaims(pd);
|
|
if (options.writeCache !== false && !isReadOnlyEngineProbe()) {
|
|
claims = cacheClaims(pd, claims, cache?.warning);
|
|
}
|
|
return buildOverview(opening, claims, cache?.warning);
|
|
}
|
|
|
|
export function localUnitClaimOverviewForIntent(
|
|
projectDir: string,
|
|
selector: {
|
|
space: string;
|
|
intentUuid: string;
|
|
stateContent: string;
|
|
dependencyBody: string;
|
|
},
|
|
): UnitClaimOverview {
|
|
const pd = resolveProjectDir(projectDir);
|
|
if (!isTeamUnitOwnership(selector.stateContent)) {
|
|
return { claimable: [], claimed: [], waiting: [], claims: new Map() };
|
|
}
|
|
const opening = localOpeningStatusFrom(
|
|
selector.stateContent,
|
|
selector.dependencyBody,
|
|
);
|
|
const claims = cachedClaimsForIdentity(
|
|
pd,
|
|
{
|
|
space: selector.space,
|
|
intentUuid: selector.intentUuid,
|
|
intentId8: idSuffix(selector.intentUuid),
|
|
},
|
|
true,
|
|
);
|
|
return buildOverview(opening, claims);
|
|
}
|
|
|
|
export function unitClaimOverview(projectDir: string): UnitClaimOverview {
|
|
const pd = resolveProjectDir(projectDir);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) {
|
|
return { claimable: [], claimed: [], waiting: [], claims: new Map() };
|
|
}
|
|
const identity = activeIdentity(pd);
|
|
const integration = fetchIntegration(pd);
|
|
const opening = openingStatus(pd, integration.oid);
|
|
const claims = allClaims(pd, integration.remote, identity.intentId8, opening.units);
|
|
const observedClaims = cacheClaims(pd, claims);
|
|
return buildOverview(opening, observedClaims);
|
|
}
|
|
|
|
function updateCachedClaim(
|
|
projectDir: string,
|
|
claim: UnitClaimView,
|
|
): void {
|
|
const claims = cachedClaims(projectDir);
|
|
claims.set(claim.unit, claim);
|
|
cacheClaims(projectDir, claims);
|
|
}
|
|
|
|
function gitOutput(projectDir: string, args: string[], label: string): string {
|
|
const result = git(projectDir, args);
|
|
if (!result.ok) {
|
|
fail(`${label}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function gitPathExistsAt(
|
|
projectDir: string,
|
|
oid: string,
|
|
path: string,
|
|
): boolean {
|
|
return git(projectDir, ["cat-file", "-e", `${oid}:${path}`]).ok;
|
|
}
|
|
|
|
function gitTextAt(
|
|
projectDir: string,
|
|
oid: string,
|
|
path: string,
|
|
): string {
|
|
const shown = git(projectDir, ["show", `${oid}:${path}`]);
|
|
if (!shown.ok) fail(`Pinned candidate is missing ${path}.`);
|
|
return shown.stdout;
|
|
}
|
|
|
|
type CandidateAuditEvent = AuditShardEvent;
|
|
|
|
function candidateAuditEvents(
|
|
projectDir: string,
|
|
oid: string,
|
|
shards: string[],
|
|
): CandidateAuditEvent[] {
|
|
const events = shards.flatMap((path, shardIndex) =>
|
|
gitTextAt(projectDir, oid, path)
|
|
.replace(/\r\n/g, "\n")
|
|
.split(/\n---\n/)
|
|
.filter((block) => block.trim().length > 0)
|
|
.map((block, pos): CandidateAuditEvent | null => {
|
|
const event = auditBlockField(block, "Event");
|
|
if (!event) return null;
|
|
return {
|
|
event,
|
|
block,
|
|
timestamp: auditBlockField(block, "Timestamp") ?? "",
|
|
shard: path,
|
|
shardIndex,
|
|
pos,
|
|
};
|
|
})
|
|
.filter((event): event is CandidateAuditEvent => event !== null)
|
|
);
|
|
return events.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 auditBlockMatchesStage(block: string, stage: string): boolean {
|
|
const gateStages = auditBlockField(block, "Gate Stages");
|
|
if (gateStages) {
|
|
return gateStages
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.includes(stage);
|
|
}
|
|
return auditBlockField(block, "Stage") === stage;
|
|
}
|
|
|
|
function attemptEventMatches(
|
|
event: CandidateAuditEvent,
|
|
unit: string,
|
|
generation: number,
|
|
stage: string,
|
|
): boolean {
|
|
return (
|
|
auditBlockField(event.block, "Unit") === unit &&
|
|
auditBlockField(event.block, "Attempt Generation") ===
|
|
String(generation) &&
|
|
auditBlockMatchesStage(event.block, stage)
|
|
);
|
|
}
|
|
|
|
function candidateReviewFingerprint(
|
|
projectDir: string,
|
|
oid: string,
|
|
recordPrefix: string,
|
|
unit: string,
|
|
stage: {
|
|
slug: string;
|
|
produces?: string[];
|
|
optional_produces?: string[];
|
|
produces_kinds?: Record<string, string[]>;
|
|
},
|
|
unitKind: string | null,
|
|
): string {
|
|
const names = filterProducesByKind(
|
|
stage.produces_kinds,
|
|
[...(stage.produces ?? []), ...(stage.optional_produces ?? [])],
|
|
unitKind,
|
|
);
|
|
const manifest: Array<[string, string]> = names.map((name) => {
|
|
const logicalPath =
|
|
`construction/${unit}/${stage.slug}/${artifactFilename(name)}`;
|
|
const path = `${recordPrefix}/${logicalPath}`;
|
|
if (!gitPathExistsAt(projectDir, oid, path)) {
|
|
return [logicalPath, "missing"];
|
|
}
|
|
const digest = createHash("sha256")
|
|
.update(gitTextAt(projectDir, oid, path))
|
|
.digest("hex");
|
|
return [logicalPath, `sha256:${digest}`];
|
|
});
|
|
manifest.sort(([a], [b]) => a.localeCompare(b));
|
|
return `sha256:${
|
|
createHash("sha256").update(JSON.stringify(manifest)).digest("hex")
|
|
}`;
|
|
}
|
|
|
|
function candidateReviewerReady(
|
|
events: CandidateAuditEvent[],
|
|
projectDir: string,
|
|
oid: string,
|
|
recordPrefix: string,
|
|
unit: string,
|
|
generation: number,
|
|
stage: {
|
|
slug: string;
|
|
reviewer?: string;
|
|
produces?: string[];
|
|
optional_produces?: string[];
|
|
produces_kinds?: Record<string, string[]>;
|
|
},
|
|
unitKind: string | null,
|
|
): boolean {
|
|
const expectedFingerprint = candidateReviewFingerprint(
|
|
projectDir,
|
|
oid,
|
|
recordPrefix,
|
|
unit,
|
|
stage,
|
|
unitKind,
|
|
);
|
|
const completionRecord = (block: string): ReviewRecord | null =>
|
|
pairedReviewRecordForCompletion(projectDir, block, (_projectDir, ref) => {
|
|
const path = `${recordPrefix}/${ref.path}`;
|
|
if (!gitPathExistsAt(projectDir, oid, path)) return null;
|
|
const bytes = gitTextAt(projectDir, oid, path);
|
|
if (reviewRecordDigest(bytes) !== ref.digest) return null;
|
|
return parseReviewRecordBytes(bytes);
|
|
});
|
|
const artifactPrefix = `construction/${unit}/${stage.slug}/`;
|
|
return candidateReviewCoverageProjection(events, {
|
|
unit,
|
|
generation,
|
|
stage: stage.slug,
|
|
reviewer: stage.reviewer,
|
|
artifactPrefix,
|
|
expectedFingerprint,
|
|
completionRecord,
|
|
});
|
|
}
|
|
|
|
function validateCandidateUnitProgress(
|
|
state: string,
|
|
claim: UnitClaimView,
|
|
stages: string[],
|
|
): void {
|
|
const heading = /^## Unit Progress\s*$/m.exec(state);
|
|
if (!heading) fail("Pinned candidate state has no Unit Progress projection.");
|
|
const after = heading.index + heading[0].length;
|
|
const next = /^## /m.exec(state.slice(after));
|
|
const section = state.slice(after, next ? after + next.index : state.length);
|
|
const rows = section
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("|"))
|
|
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
|
const header = rows.find((cells) => cells[0]?.toLowerCase() === "unit");
|
|
if (!header) fail("Pinned candidate Unit Progress projection has no header.");
|
|
const row = rows.find((cells) => cells[0] === claim.unit);
|
|
if (!row || row.length !== header.length) {
|
|
fail(`Pinned candidate Unit Progress has no valid row for "${claim.unit}".`);
|
|
}
|
|
const ownerIndex = header.findIndex((cell) => cell.toLowerCase() === "owner");
|
|
if (ownerIndex < 0 || row[ownerIndex] !== claim.owner) {
|
|
fail(`Pinned candidate Unit Progress owner does not match "${claim.owner}".`);
|
|
}
|
|
for (const stage of stages) {
|
|
const stageIndex = header.indexOf(stage);
|
|
if (stageIndex < 0 || row[stageIndex] !== "[x]") {
|
|
fail(`Pinned candidate Unit Progress stage "${stage}" is not complete.`);
|
|
}
|
|
}
|
|
const gateIndex = header.findIndex((cell) => cell.toLowerCase() === "gate");
|
|
if (gateIndex < 0 || row[gateIndex] !== "[x]") {
|
|
fail("Pinned candidate Unit Progress gate is not approved.");
|
|
}
|
|
}
|
|
|
|
function mainUnitRowMerged(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): boolean {
|
|
const state = readStateFile(projectDir);
|
|
const heading = /^## Unit Progress\s*$/m.exec(state);
|
|
if (!heading) return false;
|
|
const after = heading.index + heading[0].length;
|
|
const next = /^## /m.exec(state.slice(after));
|
|
const rows = state
|
|
.slice(after, next ? after + next.index : state.length)
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("|"))
|
|
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
|
const header = rows.find((cells) => cells[0]?.toLowerCase() === "unit");
|
|
const row = rows.find((cells) => cells[0] === transaction.unit);
|
|
if (!header || !row || row.length !== header.length) return false;
|
|
const expected = [
|
|
"owner",
|
|
...(transaction.live_stage_columns ?? transaction.evidence.stages_expected),
|
|
"gate",
|
|
"merged",
|
|
];
|
|
return expected.every((name) => {
|
|
const index = header.findIndex((cell) => cell.toLowerCase() === name);
|
|
if (index < 0) return false;
|
|
return name === "owner"
|
|
? row[index] === transaction.owner
|
|
: row[index] === "[x]";
|
|
});
|
|
}
|
|
|
|
interface CandidateBoundary {
|
|
changedAudit: string[];
|
|
outsideUnitRecordPaths: string[];
|
|
violations: string[];
|
|
}
|
|
|
|
const TRANSPORTED_ATTEMPT_EVENTS = new Set([
|
|
"UNIT_STARTED",
|
|
"UNIT_PAUSED",
|
|
"UNIT_RESUMED",
|
|
"UNIT_COMPLETED",
|
|
"STAGE_AWAITING_APPROVAL",
|
|
"STAGE_REVISING",
|
|
"GATE_APPROVED",
|
|
"GATE_REJECTED",
|
|
"PLAN_APPROVAL_RECORDED",
|
|
"REVIEW_REQUESTED",
|
|
"REVIEW_COMPLETED",
|
|
]);
|
|
const TRANSPORTED_DATA_EVENTS = new Set([
|
|
"ARTIFACT_CREATED",
|
|
"ARTIFACT_UPDATED",
|
|
]);
|
|
|
|
function isTransportedMainAuthority(event: CandidateAuditEvent): boolean {
|
|
if (
|
|
event.event === "HUMAN_TURN" ||
|
|
event.event === "QUESTION_ANSWERED" ||
|
|
event.event === "SUMMARY_CONFIRMATION_RECORDED" ||
|
|
event.event === "AUTONOMY_MODE_SET" ||
|
|
event.event === "UNIT_MERGED" ||
|
|
event.event.startsWith("MERGE_DISPATCH_")
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
(event.event === "GATE_APPROVED" || event.event === "GATE_REJECTED") &&
|
|
(
|
|
auditBlockField(event.block, "Stage") === "unit-merge" ||
|
|
auditBlockField(event.block, "Gate Scope") === "unit-merge"
|
|
)
|
|
);
|
|
}
|
|
|
|
function transportedAuditViolation(
|
|
event: CandidateAuditEvent,
|
|
unit: string,
|
|
generation: number,
|
|
): string | null {
|
|
if (isTransportedMainAuthority(event)) {
|
|
return `${event.event} is main-authority evidence`;
|
|
}
|
|
if (TRANSPORTED_ATTEMPT_EVENTS.has(event.event)) {
|
|
const eventUnit = auditBlockField(event.block, "Unit");
|
|
const eventGeneration = auditBlockField(
|
|
event.block,
|
|
"Attempt Generation",
|
|
);
|
|
if (eventUnit !== unit) {
|
|
return `${event.event} receipt belongs to Unit ${eventUnit ?? "(unitless)"}`;
|
|
}
|
|
if (eventGeneration !== String(generation)) {
|
|
return (
|
|
`${event.event} receipt belongs to attempt generation ` +
|
|
`${eventGeneration ?? "(missing)"}, expected ${generation}`
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
if (TRANSPORTED_DATA_EVENTS.has(event.event)) {
|
|
const eventUnit = auditBlockField(event.block, "Unit");
|
|
const eventGeneration = auditBlockField(
|
|
event.block,
|
|
"Attempt Generation",
|
|
);
|
|
if (eventUnit !== null && eventUnit !== unit) {
|
|
return `${event.event} row belongs to Unit ${eventUnit}`;
|
|
}
|
|
if (
|
|
eventGeneration !== null &&
|
|
eventGeneration !== String(generation)
|
|
) {
|
|
return `${event.event} row belongs to attempt generation ${eventGeneration}`;
|
|
}
|
|
const file = (auditBlockField(event.block, "File") ?? "")
|
|
.replace(/\\/g, "/");
|
|
const unitArtifactRoot = `construction/${unit}/`;
|
|
if (
|
|
!file.startsWith(unitArtifactRoot) &&
|
|
!file.includes(`/${unitArtifactRoot}`)
|
|
) {
|
|
return `${event.event} row is outside claimed Unit artifacts`;
|
|
}
|
|
return null;
|
|
}
|
|
return `${event.event} is not in the transported Unit evidence allowlist`;
|
|
}
|
|
|
|
function candidateBoundary(
|
|
projectDir: string,
|
|
baseOid: string,
|
|
candidateOid: string,
|
|
recordPrefix: string,
|
|
unit: string,
|
|
generation: number,
|
|
expectedAuditShard?: string,
|
|
): CandidateBoundary {
|
|
const auditRoot = `${recordPrefix}/audit`;
|
|
const auditChanges = gitOutput(
|
|
projectDir,
|
|
[
|
|
"diff",
|
|
"--no-renames",
|
|
"--name-status",
|
|
`${baseOid}..${candidateOid}`,
|
|
"--",
|
|
auditRoot,
|
|
],
|
|
"Cannot compare candidate audit shards",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
const addedAudit = auditChanges
|
|
.filter((line) => line.startsWith("A\t"))
|
|
.map((line) => line.split("\t").at(-1) ?? "")
|
|
.filter(Boolean);
|
|
const changedAudit = addedAudit.filter((path) =>
|
|
isActiveIntentAuditPath(path, recordPrefix)
|
|
);
|
|
const violations = auditChanges
|
|
.filter((line) => !line.startsWith("A\t"))
|
|
.map((line) => `${line.split("\t").at(-1) ?? line} (inherited audit shard)`);
|
|
for (const path of addedAudit.filter((path) =>
|
|
!isActiveIntentAuditPath(path, recordPrefix)
|
|
)) {
|
|
violations.push(`${path} (invalid active-intent audit shard path)`);
|
|
}
|
|
if (changedAudit.length !== 1) {
|
|
violations.push(
|
|
`${auditRoot}/ (expected exactly one newly added team audit shard; found ${changedAudit.length})`,
|
|
);
|
|
}
|
|
if (
|
|
expectedAuditShard &&
|
|
(
|
|
changedAudit.length !== 1 ||
|
|
changedAudit[0] !== `${auditRoot}/${expectedAuditShard}`
|
|
)
|
|
) {
|
|
violations.push(
|
|
`${auditRoot}/ (expected claim-bound audit shard ${expectedAuditShard})`,
|
|
);
|
|
}
|
|
|
|
const changedPaths = gitOutput(
|
|
projectDir,
|
|
["diff", "--no-renames", "--name-only", `${baseOid}..${candidateOid}`],
|
|
"Cannot inspect pinned candidate diff",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
const unitRoot = `${recordPrefix}/construction/${unit}/`;
|
|
const constructionRoot = `${recordPrefix}/construction/`;
|
|
const reviewUnitMarker = `/${REVIEW_RECORDS_DIR}/`;
|
|
const unitRootKey = unitRoot.toLowerCase();
|
|
const constructionRootKey = constructionRoot.toLowerCase();
|
|
const recordPrefixKey = `${recordPrefix}/`.toLowerCase();
|
|
const outsideUnitRecordPaths: string[] = [];
|
|
for (const path of changedPaths) {
|
|
if (isEngineMergeMetadata(projectDir, path, recordPrefix)) continue;
|
|
const pathKey = path.toLowerCase();
|
|
const reviewMarkerIndex = path.indexOf(reviewUnitMarker);
|
|
if (reviewMarkerIndex !== -1) {
|
|
const reviewParts = path.slice(reviewMarkerIndex + reviewUnitMarker.length).split("/");
|
|
if (
|
|
reviewParts.length === 5 &&
|
|
reviewParts[1] === "units" &&
|
|
reviewParts[2] === unit
|
|
) continue;
|
|
}
|
|
if (pathKey.startsWith(unitRootKey)) continue;
|
|
if (changedAudit.includes(path)) continue;
|
|
if (
|
|
pathKey.startsWith(constructionRootKey) ||
|
|
pathKey.startsWith(recordPrefixKey) ||
|
|
pathKey.startsWith("aidlc/")
|
|
) {
|
|
violations.push(`${path} (outside claimed Unit record tree)`);
|
|
continue;
|
|
}
|
|
outsideUnitRecordPaths.push(path);
|
|
}
|
|
|
|
for (const event of candidateAuditEvents(
|
|
projectDir,
|
|
candidateOid,
|
|
changedAudit,
|
|
)) {
|
|
const violation = transportedAuditViolation(event, unit, generation);
|
|
if (violation) {
|
|
violations.push(
|
|
`${event.shard} (${violation})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
changedAudit: [...new Set(changedAudit)].sort(),
|
|
outsideUnitRecordPaths: [...new Set(outsideUnitRecordPaths)].sort(),
|
|
violations: [...new Set(violations)].sort(),
|
|
};
|
|
}
|
|
|
|
function candidateEvidence(
|
|
projectDir: string,
|
|
claim: UnitClaimView,
|
|
candidateBaseOid: string,
|
|
liveIntegrationOid = candidateBaseOid,
|
|
): UnitMergeEvidence {
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
if (!recordPrefix) fail("Cannot resolve the active intent record for candidate evidence.");
|
|
const statePath = `${recordPrefix}/aidlc-state.md`;
|
|
const state = gitTextAt(projectDir, claim.oid, statePath);
|
|
if (!isTeamUnitOwnership(state)) {
|
|
fail("Pinned candidate state does not record Unit Ownership: team.");
|
|
}
|
|
const integrationState = stateAtOid(projectDir, liveIntegrationOid);
|
|
if (!isTeamUnitOwnership(integrationState)) {
|
|
fail("Pinned integration state does not record Unit Ownership: team.");
|
|
}
|
|
const scope = getField(integrationState, "Scope") ?? "";
|
|
const stages = unitMajorConstructionStageSlugs(
|
|
scope,
|
|
integrationState,
|
|
true,
|
|
);
|
|
if (stages.length === 0) fail("Pinned candidate has no active per-unit Construction stages.");
|
|
validateCandidateUnitProgress(state, claim, stages);
|
|
const graph = loadStageGraphAll();
|
|
const baseState = stateAtOid(projectDir, candidateBaseOid);
|
|
const baseStages = unitMajorConstructionStageSlugs(
|
|
getField(baseState, "Scope") ?? "",
|
|
baseState,
|
|
true,
|
|
);
|
|
const baseDag = dependencyEdgesAtOid(projectDir, candidateBaseOid);
|
|
const dag = dependencyEdgesAtOid(projectDir, liveIntegrationOid);
|
|
if (!baseDag.ok) {
|
|
fail(`Pinned candidate base Unit DAG is ${baseDag.reason}: ${baseDag.detail}.`);
|
|
}
|
|
if (!dag.ok) {
|
|
fail(`Pinned integration Unit DAG is ${dag.reason}: ${dag.detail}.`);
|
|
}
|
|
const contract = (
|
|
units: typeof dag.units,
|
|
stageColumns: string[],
|
|
): string => JSON.stringify({
|
|
stage_columns: stageColumns,
|
|
units: units.map((entry) => ({
|
|
name: entry.name,
|
|
kind: entry.kind ?? null,
|
|
depends_on: [...entry.depends_on].sort(),
|
|
})),
|
|
});
|
|
if (contract(baseDag.units, baseStages) !== contract(dag.units, stages)) {
|
|
fail(
|
|
`Unit "${claim.unit}" candidate is based on a stale Construction contract; ` +
|
|
"rebase onto the current integration branch and publish again.",
|
|
);
|
|
}
|
|
const unit = dag.units.find((entry) => entry.name === claim.unit);
|
|
if (!unit) fail(`Pinned integration Unit DAG has no Unit "${claim.unit}".`);
|
|
const unitKind = unit.kind ?? null;
|
|
const forcedMergeJournals = gitOutput(
|
|
projectDir,
|
|
[
|
|
"diff",
|
|
"--name-only",
|
|
`${candidateBaseOid}..${claim.oid}`,
|
|
"--",
|
|
`aidlc/${UNIT_MERGE_DIR}`,
|
|
],
|
|
"Cannot inspect candidate merge journals",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
if (forcedMergeJournals.length > 0) {
|
|
fail(
|
|
`Pinned candidate contains engine merge journals: ${forcedMergeJournals.join(", ")}.`,
|
|
);
|
|
}
|
|
const boundary = candidateBoundary(
|
|
projectDir,
|
|
candidateBaseOid,
|
|
claim.oid,
|
|
recordPrefix,
|
|
claim.unit,
|
|
claim.generation,
|
|
claim.payload.audit_shard,
|
|
);
|
|
if (!claim.payload.audit_shard) {
|
|
fail(
|
|
`Pinned candidate claim has no bound audit shard; release and re-claim Unit "${claim.unit}" before publishing again.`,
|
|
);
|
|
}
|
|
if (boundary.violations.length > 0) {
|
|
fail(
|
|
`Pinned candidate violates claimed Unit ownership at: ${
|
|
boundary.violations.join(", ")
|
|
}.`,
|
|
);
|
|
}
|
|
const changedAudit = boundary.changedAudit;
|
|
const transportedEvents = candidateAuditEvents(
|
|
projectDir,
|
|
claim.oid,
|
|
changedAudit,
|
|
);
|
|
const authorityRows = transportedEvents.filter((event) =>
|
|
TRANSPORTED_ATTEMPT_EVENTS.has(event.event) ||
|
|
TRANSPORTED_DATA_EVENTS.has(event.event)
|
|
);
|
|
const events = authorityRows;
|
|
const stagesCompleted = stages.filter((stage) =>
|
|
unitLifecycleSnapshot(
|
|
projectDir,
|
|
stage,
|
|
authorityRows,
|
|
integrationState,
|
|
{
|
|
artifactFingerprint: (stageNode, unitName) =>
|
|
candidateReviewFingerprint(
|
|
projectDir,
|
|
claim.oid,
|
|
recordPrefix,
|
|
unitName,
|
|
stageNode,
|
|
unitKind,
|
|
),
|
|
},
|
|
).receipts.has(claim.unit)
|
|
);
|
|
const gatesExpected =
|
|
claim.payload.gate_rhythm === "unit-end"
|
|
? [stages[stages.length - 1]]
|
|
: stages;
|
|
const gatesApproved = gatesExpected.filter((stage) =>
|
|
unitGateStatus(
|
|
projectDir,
|
|
stage,
|
|
claim.unit,
|
|
claim.payload.gate_rhythm,
|
|
authorityRows,
|
|
) === "approved"
|
|
);
|
|
const reviewersExpected: string[] = [];
|
|
const reviewersReady: string[] = [];
|
|
const artifactPaths: string[] = [];
|
|
for (const stageSlug of stages) {
|
|
const stage = graph.find((entry) => entry.slug === stageSlug);
|
|
if (!stage) fail(`Pinned candidate references unknown stage "${stageSlug}".`);
|
|
const names = filterProducesByKind(
|
|
stage.produces_kinds,
|
|
stage.produces ?? [],
|
|
unitKind,
|
|
);
|
|
for (const name of names) {
|
|
const path = `${recordPrefix}/construction/${claim.unit}/${stageSlug}/${artifactFilename(name)}`;
|
|
if (!gitPathExistsAt(projectDir, claim.oid, path)) {
|
|
fail(`Pinned candidate is missing required artifact ${path}.`);
|
|
}
|
|
artifactPaths.push(path);
|
|
}
|
|
if (stage.reviewer) {
|
|
const reviewClass = resolveReviewClass(
|
|
stage.review_class ?? "adversarial",
|
|
scope,
|
|
integrationState,
|
|
);
|
|
if (reviewClass !== "none") {
|
|
reviewersExpected.push(stageSlug);
|
|
if (
|
|
candidateReviewerReady(
|
|
events,
|
|
projectDir,
|
|
claim.oid,
|
|
recordPrefix,
|
|
claim.unit,
|
|
claim.generation,
|
|
stage,
|
|
unitKind,
|
|
)
|
|
) {
|
|
reviewersReady.push(stageSlug);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const questionsPath =
|
|
`${recordPrefix}/construction/${claim.unit}/code-generation/code-generation-questions.md`;
|
|
let planFingerprint: string | null = null;
|
|
if (stages.includes("code-generation")) {
|
|
const plan = gitTextAt(
|
|
projectDir,
|
|
claim.oid,
|
|
`${recordPrefix}/construction/${claim.unit}/code-generation/code-generation-plan.md`,
|
|
);
|
|
const instructions = gitTextAt(
|
|
projectDir,
|
|
claim.oid,
|
|
`${recordPrefix}/construction/${claim.unit}/code-generation/unit-test-instructions.md`,
|
|
);
|
|
const questions = gitTextAt(projectDir, claim.oid, questionsPath);
|
|
const fingerprint = recordedApprovalFingerprint(questions);
|
|
const embedded = parseTestingContract(plan);
|
|
const currentContract = resolveTestingPosture(projectDir);
|
|
const approvalEvent = events.findLast((event) =>
|
|
event.event === "PLAN_APPROVAL_RECORDED" &&
|
|
attemptEventMatches(
|
|
event,
|
|
claim.unit,
|
|
claim.generation,
|
|
"code-generation",
|
|
)
|
|
);
|
|
const questionsSha256 = createHash("sha256")
|
|
.update(questions, "utf-8")
|
|
.digest("hex");
|
|
const promptSha256 = createHash("sha256")
|
|
.update(
|
|
`${questions
|
|
.replace(/^\[Answer\]:[ \t]*.*$/gm, "[Answer]:")
|
|
.trimEnd()}\n`,
|
|
"utf-8",
|
|
)
|
|
.digest("hex");
|
|
if (
|
|
fingerprint &&
|
|
instructions.trim().length > 0 &&
|
|
embedded?.contract_sha256 === currentContract.contract_sha256 &&
|
|
approvalEvent &&
|
|
auditBlockField(approvalEvent.block, "Details") === "Approve Plan" &&
|
|
auditBlockField(approvalEvent.block, "Checkpoint") ===
|
|
PLAN_APPROVAL_CHECKPOINT &&
|
|
auditBlockField(approvalEvent.block, "Plan Target") ===
|
|
`unit:${claim.unit}` &&
|
|
auditBlockField(approvalEvent.block, "Intent") ===
|
|
claim.payload.intent_uuid &&
|
|
auditBlockField(approvalEvent.block, "Approval Fingerprint") ===
|
|
fingerprint &&
|
|
auditBlockField(approvalEvent.block, "Questions File") ===
|
|
questionsPath &&
|
|
auditBlockField(approvalEvent.block, "Questions SHA-256") ===
|
|
questionsSha256 &&
|
|
auditBlockField(approvalEvent.block, "Prompt SHA-256") ===
|
|
promptSha256 &&
|
|
(auditBlockField(approvalEvent.block, "Directive Epoch") ?? "").length >
|
|
0 &&
|
|
(auditBlockField(approvalEvent.block, "Run floor") ?? "").length > 0 &&
|
|
(auditBlockField(approvalEvent.block, "Session") ?? "").length > 0 &&
|
|
/^\[Answer\]:\s*A\.\s*Approve Plan\s*$/m.test(questions)
|
|
) {
|
|
planFingerprint = fingerprint;
|
|
}
|
|
}
|
|
const mergeHeld = (getField(state, "Merge-Held") ?? "").trim() === "true";
|
|
const evidence: UnitMergeEvidence = {
|
|
stages_expected: stages,
|
|
stages_completed: stagesCompleted,
|
|
gates_expected: gatesExpected,
|
|
gates_approved: gatesApproved,
|
|
reviewers_expected: reviewersExpected,
|
|
reviewers_ready: reviewersReady,
|
|
plan_fingerprint: planFingerprint,
|
|
artifact_paths: artifactPaths,
|
|
audit_shards: changedAudit,
|
|
outside_unit_record_paths: boundary.outsideUnitRecordPaths,
|
|
merge_held: mergeHeld,
|
|
};
|
|
const incomplete: string[] = [];
|
|
if (stagesCompleted.length !== stages.length) incomplete.push("UNIT_COMPLETED receipts");
|
|
if (gatesApproved.length !== gatesExpected.length) incomplete.push("team gate approvals");
|
|
if (reviewersReady.length !== reviewersExpected.length) incomplete.push("reviewer READY receipts");
|
|
if (stages.includes("code-generation") && !planFingerprint) {
|
|
incomplete.push("Plan Approval fingerprint");
|
|
}
|
|
if (changedAudit.length === 0) {
|
|
incomplete.push("transportable audit shard");
|
|
}
|
|
if (incomplete.length > 0) {
|
|
fail(`Pinned candidate evidence is incomplete: ${incomplete.join(", ")}.`);
|
|
}
|
|
return evidence;
|
|
}
|
|
|
|
function publishUnit(args: string[], projectDir?: string): void {
|
|
const { positional } = parseArgs(args);
|
|
const unit = positional[0];
|
|
if (!unit) fail("Usage: aidlc-unit publish <unit>");
|
|
const pd = resolveProjectDir(projectDir);
|
|
const stamp = requireLiveClaimForTeamUnit(pd, unit);
|
|
if (!stamp) fail("Unit publication requires a scoped team checkout.");
|
|
const dirty = gitOutput(
|
|
pd,
|
|
["status", "--porcelain", "--untracked-files=no"],
|
|
"Cannot inspect candidate worktree",
|
|
);
|
|
if (dirty) fail("Unit publication requires a clean tracked worktree; commit the candidate first.");
|
|
const remote = repositoryRemote(pd);
|
|
const current = currentClaim(pd, remote, stamp.claim_ref);
|
|
if (
|
|
current?.status !== "claimed" ||
|
|
current.nonce !== stamp.nonce ||
|
|
current.generation !== stamp.generation
|
|
) {
|
|
fail(`Unit "${unit}" claim is stale or released; publication refused.`);
|
|
}
|
|
const headOid = gitOutput(pd, ["rev-parse", "HEAD"], "Cannot resolve candidate HEAD");
|
|
const headTree = gitOutput(
|
|
pd,
|
|
["rev-parse", "HEAD^{tree}"],
|
|
"Cannot resolve candidate tree",
|
|
);
|
|
const integration = fetchIntegration(pd);
|
|
const integrationIsAncestor = localGit(
|
|
pd,
|
|
["merge-base", "--is-ancestor", integration.oid, headOid],
|
|
).ok;
|
|
const publicationBaseOid = integrationIsAncestor
|
|
? integration.oid
|
|
: current.payload.base_oid;
|
|
const publicationIntegrationRef = integrationIsAncestor
|
|
? integration.ref
|
|
: current.payload.integration_ref;
|
|
if (
|
|
current.payload.candidate_tree_oid === headTree &&
|
|
current.payload.candidate_head_oid === headOid &&
|
|
current.payload.base_oid === publicationBaseOid &&
|
|
current.payload.integration_ref === publicationIntegrationRef
|
|
) {
|
|
const recoveredStamp = { ...stamp, claim_oid: current.oid };
|
|
finalizeClaimCheckout(pd, recoveredStamp, current);
|
|
console.log(JSON.stringify({
|
|
published: true,
|
|
recovered: true,
|
|
unit,
|
|
candidate_oid: current.oid,
|
|
generation: current.generation,
|
|
}));
|
|
return;
|
|
}
|
|
const payload: UnitClaimPayload = {
|
|
...current.payload,
|
|
predecessor_oid: current.oid,
|
|
base_oid: publicationBaseOid,
|
|
integration_ref: publicationIntegrationRef,
|
|
candidate_tree_oid: headTree,
|
|
candidate_head_oid: headOid,
|
|
};
|
|
const commit = createClaimCommit(
|
|
pd,
|
|
current.oid,
|
|
payload,
|
|
headTree,
|
|
headOid,
|
|
);
|
|
if (!updateClaimRef(pd, remote, stamp.claim_ref, commit, current.oid)) {
|
|
fail(`Unit "${unit}" publication compare-and-swap failed; refresh the claim and retry.`);
|
|
}
|
|
invalidateLiveClaimPayloadCache(pd, unit);
|
|
const published = currentClaim(pd, remote, stamp.claim_ref);
|
|
if (!published || published.oid !== commit || published.nonce !== stamp.nonce) {
|
|
fail(`Unit "${unit}" publication verification failed.`);
|
|
}
|
|
const updatedStamp = { ...stamp, claim_oid: published.oid };
|
|
finalizeClaimCheckout(pd, updatedStamp, published);
|
|
console.log(JSON.stringify({
|
|
published: true,
|
|
unit,
|
|
candidate_oid: published.oid,
|
|
candidate_head_oid: headOid,
|
|
generation: published.generation,
|
|
}));
|
|
}
|
|
|
|
interface PinnedClaimCheck {
|
|
claim: UnitClaimView;
|
|
releasedTombstone?: UnitClaimView;
|
|
}
|
|
|
|
function pinnedClaimFromTransaction(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): UnitClaimView {
|
|
const payload = readPayload(projectDir, transaction.pinned_oid, {
|
|
localOnly: true,
|
|
});
|
|
if (
|
|
payload?.status !== "claimed" ||
|
|
payload.unit !== transaction.unit ||
|
|
payload.intent_uuid !== transaction.intent_uuid ||
|
|
payload.generation !== transaction.generation ||
|
|
payload.nonce !== transaction.nonce ||
|
|
payload.owner !== transaction.owner ||
|
|
payload.base_oid !== transaction.candidate_base_oid ||
|
|
payload.candidate_tree_oid !== transaction.candidate_tree_oid
|
|
) {
|
|
fail(`Pinned Unit "${transaction.unit}" claim payload is unavailable or invalid.`);
|
|
}
|
|
return {
|
|
unit: payload.unit,
|
|
status: payload.status,
|
|
owner: payload.owner,
|
|
generation: payload.generation,
|
|
nonce: payload.nonce,
|
|
oid: transaction.pinned_oid,
|
|
ref: transaction.claim_ref,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function currentPinnedClaim(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
options: { acceptReleasedAttempt?: boolean } = {},
|
|
): PinnedClaimCheck {
|
|
invalidateLiveClaimPayloadCache(projectDir, transaction.unit);
|
|
let current: UnitClaimView | null;
|
|
try {
|
|
current = currentClaim(
|
|
projectDir,
|
|
repositoryRemote(projectDir),
|
|
transaction.claim_ref,
|
|
);
|
|
} catch (error) {
|
|
const message = errorMessage(error);
|
|
if (
|
|
!message.startsWith("Unit claim registry read failed:") &&
|
|
!message.startsWith("Unit claim registry fetch failed:")
|
|
) {
|
|
throw error;
|
|
}
|
|
fail(
|
|
`Unit "${transaction.unit}" claim registry is unavailable (${message}); ` +
|
|
"merge gate and landing fail closed because the pinned attempt may have been tombstoned. Restore registry access and retry.",
|
|
);
|
|
}
|
|
if (
|
|
current?.status === "released" &&
|
|
current.payload.predecessor_oid === transaction.pinned_oid &&
|
|
current.generation === transaction.generation + 1
|
|
) {
|
|
const accepted =
|
|
transaction.released_after_git?.tombstone_oid === current.oid &&
|
|
transaction.released_after_git.tombstone_generation ===
|
|
current.generation;
|
|
if (
|
|
(!options.acceptReleasedAttempt && !accepted) ||
|
|
transaction.status !== "git-landed"
|
|
) {
|
|
fail(
|
|
`Unit "${transaction.unit}" was released after its merge commit landed. ` +
|
|
`Inspect the landed commit, then recover explicitly with aidlc unit land ${transaction.unit} ` +
|
|
'--accept-released-attempt --user-input "<human acknowledgment>".',
|
|
);
|
|
}
|
|
return {
|
|
claim: pinnedClaimFromTransaction(projectDir, transaction),
|
|
releasedTombstone: current,
|
|
};
|
|
}
|
|
if (
|
|
current?.status === "claimed" &&
|
|
current.oid === transaction.pinned_oid &&
|
|
current.generation === transaction.generation &&
|
|
current.nonce === transaction.nonce &&
|
|
current.owner !== transaction.owner
|
|
) {
|
|
fail(
|
|
`Unit "${transaction.unit}" merge journal owner does not match the pinned claim payload owner.`,
|
|
);
|
|
}
|
|
if (
|
|
current?.status !== "claimed" ||
|
|
current.oid !== transaction.pinned_oid ||
|
|
current.generation !== transaction.generation ||
|
|
current.nonce !== transaction.nonce ||
|
|
current.owner !== transaction.owner ||
|
|
current.payload.base_oid !== transaction.candidate_base_oid ||
|
|
current.payload.candidate_tree_oid !== transaction.candidate_tree_oid
|
|
) {
|
|
fail(
|
|
`Unit "${transaction.unit}" claim ref moved or changed attempt after pin; run aidlc-unit pin ${transaction.unit} again.`,
|
|
);
|
|
}
|
|
return { claim: current };
|
|
}
|
|
|
|
function orderedAuditRows(projectDir: string): ReturnType<typeof readAuditShardEvents> {
|
|
return readAuditShardEvents(projectDir).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 mainAuthorityAuditRows(
|
|
projectDir: string,
|
|
): ReturnType<typeof readAuditShardEvents> {
|
|
const mainShard = auditShardName(projectDir);
|
|
return orderedAuditRows(projectDir).filter(
|
|
(row) => basename(row.shard) === mainShard,
|
|
);
|
|
}
|
|
|
|
function assertTransactionIdentity(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): void {
|
|
const identity = activeIdentity(projectDir);
|
|
if (
|
|
transaction.space !== identity.space ||
|
|
transaction.intent_uuid !== identity.intentUuid ||
|
|
transaction.intent_id8 !== identity.intentId8 ||
|
|
transaction.claim_ref !== claimRef(identity.intentId8, transaction.unit)
|
|
) {
|
|
fail(
|
|
`Unit "${transaction.unit}" merge transaction belongs to another active space or intent.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function requireMergeDispatchDecision(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): { strategy: "merge"; target: string } {
|
|
const all = mainAuthorityAuditRows(projectDir);
|
|
if (!transaction.pin_id) {
|
|
fail(`Unit "${transaction.unit}" merge transaction has no pin identity; pin it again.`);
|
|
}
|
|
const relevant = all.filter(
|
|
(row) =>
|
|
(
|
|
row.event === "MERGE_DISPATCH_INVOKED" ||
|
|
row.event === "MERGE_DISPATCH_RETURNED" ||
|
|
row.event === "MERGE_DISPATCH_FALLBACK"
|
|
) &&
|
|
auditBlockField(row.block, "Bolt slug") === transaction.unit &&
|
|
auditBlockField(row.block, "Pinned OID") === transaction.pinned_oid &&
|
|
auditBlockField(row.block, "Attempt Generation") ===
|
|
String(transaction.generation) &&
|
|
auditBlockField(row.block, "Pin Transaction") === transaction.pin_id,
|
|
);
|
|
const invokedIndex = relevant.findLastIndex(
|
|
(row) => row.event === "MERGE_DISPATCH_INVOKED",
|
|
);
|
|
if (invokedIndex < 0) {
|
|
fail(`Unit "${transaction.unit}" merge gate requires MERGE_DISPATCH_INVOKED after pinning.`);
|
|
}
|
|
const terminal = relevant
|
|
.slice(invokedIndex + 1)
|
|
.findLast(
|
|
(row) =>
|
|
row.event === "MERGE_DISPATCH_RETURNED" ||
|
|
row.event === "MERGE_DISPATCH_FALLBACK",
|
|
);
|
|
if (!terminal) {
|
|
fail(`Unit "${transaction.unit}" merge dispatch has no terminal result.`);
|
|
}
|
|
if (!humanPresenceGuardDisabled()) {
|
|
const terminalIndex = all.indexOf(terminal);
|
|
const previousGate = [...all]
|
|
.reverse()
|
|
.find(
|
|
(row) =>
|
|
(row.event === "GATE_APPROVED" || row.event === "GATE_REJECTED") &&
|
|
auditBlockField(row.block, "Stage") !== null,
|
|
);
|
|
const previousGateIndex = previousGate ? all.indexOf(previousGate) : -1;
|
|
const humanFloor = Math.max(terminalIndex, previousGateIndex);
|
|
if (
|
|
terminalIndex < 0 ||
|
|
!all.slice(humanFloor + 1).some((row) => row.event === "HUMAN_TURN")
|
|
) {
|
|
fail(
|
|
`Unit "${transaction.unit}" merge gate requires a typed human turn after merge dispatch.`,
|
|
);
|
|
}
|
|
}
|
|
if (terminal.event === "MERGE_DISPATCH_FALLBACK") {
|
|
return { strategy: "merge", target: transaction.integration_branch };
|
|
}
|
|
const strategy = auditBlockField(terminal.block, "Strategy");
|
|
const target = auditBlockField(terminal.block, "Target branch");
|
|
if (strategy !== "merge") {
|
|
fail(
|
|
`Unit "${transaction.unit}" pinned transaction requires merge strategy; dispatch returned "${strategy ?? "missing"}".`,
|
|
);
|
|
}
|
|
if (!target) fail(`Unit "${transaction.unit}" merge dispatch omitted its target branch.`);
|
|
return { strategy: "merge", target };
|
|
}
|
|
|
|
function mergeGateRecord(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): {
|
|
decision: "approve" | "reject";
|
|
strategy: string | null;
|
|
target: string | null;
|
|
} | null {
|
|
const decisions = mainAuthorityAuditRows(projectDir).filter(
|
|
(row) =>
|
|
(row.event === "GATE_APPROVED" || row.event === "GATE_REJECTED") &&
|
|
auditBlockField(row.block, "Stage") === "unit-merge" &&
|
|
auditBlockField(row.block, "Unit") === transaction.unit &&
|
|
auditBlockField(row.block, "Pinned OID") === transaction.pinned_oid &&
|
|
auditBlockField(row.block, "Attempt Generation") ===
|
|
String(transaction.generation) &&
|
|
auditBlockField(row.block, "Gate Scope") === "unit-merge",
|
|
);
|
|
const latest = decisions.at(-1);
|
|
if (!latest) return null;
|
|
return {
|
|
decision: latest.event === "GATE_APPROVED" ? "approve" : "reject",
|
|
strategy: auditBlockField(latest.block, "Strategy"),
|
|
target: auditBlockField(latest.block, "Target branch"),
|
|
};
|
|
}
|
|
|
|
function pinUnit(args: string[], projectDir?: string): void {
|
|
const { positional } = parseArgs(args);
|
|
const unit = positional[0];
|
|
if (!unit) fail("Usage: aidlc-unit pin <unit>");
|
|
const pd = resolveProjectDir(projectDir);
|
|
if (readApplicableTeamUnitScopeStamp(pd)) {
|
|
fail("Unit pinning must run from an unscoped main checkout.");
|
|
}
|
|
assertMainCheckout(pd);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) fail("Unit pinning requires Unit Ownership: team.");
|
|
const identity = activeIdentity(pd);
|
|
const inFlight = readUnitMergeTransaction(pd, unit);
|
|
if (
|
|
inFlight &&
|
|
inFlight.space === identity.space &&
|
|
inFlight.intent_uuid === identity.intentUuid &&
|
|
["approved", "git-landed", "state-folded", "complete"].includes(
|
|
inFlight.status,
|
|
)
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" already has a ${inFlight.status} merge transaction; recover it with aidlc unit land ${unit} instead of re-pinning.`,
|
|
);
|
|
}
|
|
const integration = fetchIntegration(pd);
|
|
const ref = claimRef(identity.intentId8, unit);
|
|
const claim = currentClaim(pd, integration.remote, ref);
|
|
if (
|
|
claim?.status !== "claimed" ||
|
|
claim.payload.intent_uuid !== identity.intentUuid ||
|
|
!claim.payload.candidate_tree_oid
|
|
) {
|
|
fail(`Unit "${unit}" has no published live candidate to pin.`);
|
|
}
|
|
writeClaimGeneration(pd, unit, claim.generation);
|
|
const evidence = candidateEvidence(
|
|
pd,
|
|
claim,
|
|
claim.payload.base_oid,
|
|
integration.oid,
|
|
);
|
|
const mainBefore = gitOutput(pd, ["rev-parse", "HEAD"], "Cannot resolve main HEAD");
|
|
const transaction: UnitMergeTransaction = {
|
|
version: 1,
|
|
status: "pinned",
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
intent_id8: identity.intentId8,
|
|
unit,
|
|
owner: claim.owner,
|
|
generation: claim.generation,
|
|
nonce: claim.nonce,
|
|
claim_ref: ref,
|
|
pinned_oid: claim.oid,
|
|
candidate_tree_oid: claim.payload.candidate_tree_oid,
|
|
candidate_base_oid: claim.payload.base_oid,
|
|
integration_oid: integration.oid,
|
|
integration_branch: integration.branch,
|
|
main_before_oid: mainBefore,
|
|
pinned_at: isoTimestamp(),
|
|
pin_id: randomUUID(),
|
|
audit_shard: claim.payload.audit_shard,
|
|
evidence,
|
|
};
|
|
updateCachedClaim(pd, claim);
|
|
writeUnitMergeTransaction(pd, transaction);
|
|
console.log(JSON.stringify({
|
|
pinned: true,
|
|
unit,
|
|
pinned_oid: claim.oid,
|
|
pin_id: transaction.pin_id,
|
|
generation: claim.generation,
|
|
evidence,
|
|
gate_required: true,
|
|
merge_dispatch_required: true,
|
|
}));
|
|
}
|
|
|
|
function gateUnitMerge(args: string[], projectDir?: string): void {
|
|
const { positional, flags } = parseArgs(args);
|
|
const unit = positional[0];
|
|
const decision = flags.decision;
|
|
const userInput = flags["user-input"]?.trim();
|
|
if (!unit || (decision !== "approve" && decision !== "reject") || !userInput) {
|
|
fail(
|
|
"Usage: aidlc-unit gate <unit> --decision <approve|reject> --user-input <text>",
|
|
);
|
|
}
|
|
if (
|
|
!humanPresenceGuardDisabled() &&
|
|
isNonAnswer(userInput)
|
|
) {
|
|
fail(
|
|
`Refusing Unit "${unit}" merge approval: --user-input "${userInput}" is cancellation boilerplate, not a human decision.`,
|
|
);
|
|
}
|
|
const approvalAuthorship = humanPresenceGuardDisabled()
|
|
? null
|
|
: selfAttributedDecisionMarker(userInput, "approval");
|
|
if (approvalAuthorship) {
|
|
fail(
|
|
`Refusing Unit "${unit}" merge approval: decision self-attribution blocked ` +
|
|
`(${approvalAuthorship.category}) in --user-input: "${approvalAuthorship.phrase}".`,
|
|
);
|
|
}
|
|
const pd = resolveProjectDir(projectDir);
|
|
const transaction = readUnitMergeTransaction(pd, unit);
|
|
if (!transaction || !["pinned", "approved", "rejected"].includes(transaction.status)) {
|
|
fail(`Unit "${unit}" has no pinned merge transaction.`);
|
|
}
|
|
assertTransactionIdentity(pd, transaction);
|
|
const claim = currentPinnedClaim(pd, transaction).claim;
|
|
const liveIntegration = fetchIntegration(pd);
|
|
const evidence = candidateEvidence(
|
|
pd,
|
|
claim,
|
|
transaction.candidate_base_oid,
|
|
liveIntegration.oid,
|
|
);
|
|
if (JSON.stringify(evidence) !== JSON.stringify(transaction.evidence)) {
|
|
fail(`Unit "${unit}" pinned evidence journal changed; run aidlc-unit pin ${unit} again.`);
|
|
}
|
|
if (transaction.evidence.merge_held) {
|
|
fail(
|
|
`Unit "${unit}" merge is held. Resolve the existing HOLD-MERGE flow in the team checkout, publish, and re-pin.`,
|
|
);
|
|
}
|
|
const existingDecision = mergeGateRecord(pd, transaction)?.decision;
|
|
if (
|
|
(
|
|
(transaction.status === "approved" && decision === "approve") ||
|
|
(transaction.status === "rejected" && decision === "reject")
|
|
) &&
|
|
existingDecision === decision
|
|
) {
|
|
console.log(JSON.stringify({
|
|
gated: true,
|
|
recovered: true,
|
|
unit,
|
|
decision,
|
|
pinned_oid: transaction.pinned_oid,
|
|
}));
|
|
return;
|
|
}
|
|
const dispatch = requireMergeDispatchDecision(pd, transaction);
|
|
appendAuditEntry(
|
|
decision === "approve" ? "GATE_APPROVED" : "GATE_REJECTED",
|
|
{
|
|
Stage: "unit-merge",
|
|
Unit: unit,
|
|
"Pinned OID": transaction.pinned_oid,
|
|
"Attempt Generation": String(transaction.generation),
|
|
"Gate Scope": "unit-merge",
|
|
Strategy: dispatch.strategy,
|
|
"Target branch": dispatch.target,
|
|
"Outside the unit record tree":
|
|
transaction.evidence.outside_unit_record_paths.join(", ") || "none",
|
|
...(decision === "approve"
|
|
? { "User Input": userInput }
|
|
: { Feedback: userInput }),
|
|
},
|
|
pd,
|
|
);
|
|
writeUnitMergeTransaction(pd, {
|
|
...transaction,
|
|
status: decision === "approve" ? "approved" : "rejected",
|
|
decision,
|
|
user_input: userInput,
|
|
target_branch: dispatch.target,
|
|
strategy: dispatch.strategy,
|
|
});
|
|
console.log(JSON.stringify({
|
|
gated: true,
|
|
unit,
|
|
decision,
|
|
pinned_oid: transaction.pinned_oid,
|
|
generation: transaction.generation,
|
|
outside_unit_record_paths:
|
|
transaction.evidence.outside_unit_record_paths,
|
|
outside_unit_record_tree_note:
|
|
"These paths are outside the unit's record tree and require human overlap judgment.",
|
|
}));
|
|
}
|
|
|
|
function isActiveIntentAuditPath(
|
|
path: string,
|
|
recordPrefix: string,
|
|
): boolean {
|
|
const root = `${recordPrefix}/audit/`;
|
|
if (!path.startsWith(root)) return false;
|
|
const leaf = path.slice(root.length);
|
|
return /^[A-Za-z0-9._-]+\.md$/.test(leaf);
|
|
}
|
|
|
|
function isEngineMergeMetadata(
|
|
projectDir: string,
|
|
path: string,
|
|
recordPrefix = relativeRecordDir(projectDir),
|
|
): boolean {
|
|
if (!recordPrefix) return path === CLAIM_FILE;
|
|
const activeIntentPointer = `${dirname(recordPrefix)}/active-intent`;
|
|
return new Set([
|
|
CLAIM_FILE,
|
|
"aidlc/active-space",
|
|
activeIntentPointer,
|
|
`${recordPrefix}/aidlc-state.md`,
|
|
`${recordPrefix}/runtime-graph.json`,
|
|
]).has(path);
|
|
}
|
|
|
|
function conflictFiles(projectDir: string): string[] {
|
|
const listed = git(projectDir, ["diff", "--name-only", "--diff-filter=U"]);
|
|
return listed.ok
|
|
? listed.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function checkpointMainMetadata(
|
|
projectDir: string,
|
|
message: string,
|
|
): { parentOid: string; commitOid?: string } {
|
|
const parentOid = gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot resolve metadata checkpoint parent",
|
|
);
|
|
const statusResult = git(
|
|
projectDir,
|
|
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
);
|
|
if (!statusResult.ok) {
|
|
fail(
|
|
`Cannot inspect main worktree: ${statusResult.stderr.trim() || `exit ${statusResult.code}`}`,
|
|
);
|
|
}
|
|
const status = statusResult.stdout;
|
|
if (!status) return { parentOid };
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
if (!recordPrefix) {
|
|
fail("Cannot resolve active intent metadata for Unit landing.");
|
|
}
|
|
const paths: string[] = [];
|
|
const records = status.split("\u0000");
|
|
for (let i = 0; i < records.length; i++) {
|
|
const record = records[i];
|
|
if (record.length < 4) continue;
|
|
const code = record.slice(0, 2);
|
|
paths.push(record.slice(3));
|
|
if (/[RC]/.test(code) && records[i + 1]) {
|
|
paths.push(records[++i]);
|
|
}
|
|
}
|
|
const unsafe = paths.filter(
|
|
(path) =>
|
|
!isEngineMergeMetadata(projectDir, path, recordPrefix) &&
|
|
path !== `${recordPrefix}/audit/${auditShardName(projectDir)}`,
|
|
);
|
|
if (unsafe.length > 0) {
|
|
fail(
|
|
`Unit landing requires a clean source worktree; commit or stash: ${unsafe.join(", ")}.`,
|
|
);
|
|
}
|
|
const added = git(projectDir, ["add", "-A", "--", ...paths]);
|
|
if (!added.ok) {
|
|
fail(
|
|
`Failed to stage main engine metadata checkpoint: ${added.stderr.trim() || added.stdout.trim()}.`,
|
|
);
|
|
}
|
|
const committed = git(projectDir, [
|
|
"commit",
|
|
"-m",
|
|
message,
|
|
]);
|
|
if (!committed.ok) {
|
|
fail(`Failed to checkpoint main engine metadata: ${committed.stderr.trim()}.`);
|
|
}
|
|
return {
|
|
parentOid,
|
|
commitOid: gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot resolve metadata checkpoint commit",
|
|
),
|
|
};
|
|
}
|
|
|
|
function restoreMainMetadata(
|
|
projectDir: string,
|
|
paths: string[],
|
|
): void {
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
for (const path of paths.filter((path) =>
|
|
isEngineMergeMetadata(projectDir, path, recordPrefix)
|
|
)) {
|
|
if (gitPathExistsAt(projectDir, "HEAD", path)) {
|
|
const restored = git(projectDir, ["checkout", "HEAD", "--", path]);
|
|
if (!restored.ok) fail(`Failed to retain main metadata path ${path}.`);
|
|
const added = git(projectDir, ["add", "--", path]);
|
|
if (!added.ok) fail(`Failed to stage retained metadata path ${path}.`);
|
|
} else {
|
|
git(projectDir, ["rm", "-f", "--cached", "--ignore-unmatch", "--", path]);
|
|
try {
|
|
unlinkSync(join(projectDir, path));
|
|
} catch {
|
|
// Candidate-only metadata may already be absent.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function gitObjectAt(
|
|
projectDir: string,
|
|
oid: string,
|
|
path: string,
|
|
): string | null {
|
|
const result = git(projectDir, ["rev-parse", "--verify", `${oid}:${path}`]);
|
|
return result.ok ? result.stdout.trim() : null;
|
|
}
|
|
|
|
function candidateChangedPaths(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): string[] {
|
|
return gitOutput(
|
|
projectDir,
|
|
[
|
|
"diff",
|
|
"--name-only",
|
|
`${transaction.candidate_base_oid}..${transaction.pinned_oid}`,
|
|
],
|
|
"Cannot inspect pinned candidate diff",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function mergeCommitParents(projectDir: string, commitOid: string): string[] {
|
|
return gitOutput(
|
|
projectDir,
|
|
["show", "-s", "--format=%P", commitOid],
|
|
`Cannot inspect merge commit ${commitOid}`,
|
|
)
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function landedTreeViolations(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
treeish: string,
|
|
mainParent: string,
|
|
): string[] {
|
|
const candidatePaths = candidateChangedPaths(projectDir, transaction);
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
const boundary = recordPrefix
|
|
? candidateBoundary(
|
|
projectDir,
|
|
transaction.candidate_base_oid,
|
|
transaction.pinned_oid,
|
|
recordPrefix,
|
|
transaction.unit,
|
|
transaction.generation,
|
|
transaction.audit_shard,
|
|
)
|
|
: {
|
|
violations: ["active intent record is unavailable"],
|
|
};
|
|
const allowedCommitChanges = new Set(
|
|
candidatePaths.filter((path) =>
|
|
!isEngineMergeMetadata(projectDir, path, recordPrefix)
|
|
),
|
|
);
|
|
const actualCommitChanges = gitOutput(
|
|
projectDir,
|
|
["diff", "--name-only", `${mainParent}..${treeish}`],
|
|
"Cannot inspect landed merge tree",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
const violations = [
|
|
...boundary.violations,
|
|
...actualCommitChanges.filter(
|
|
(path) => !allowedCommitChanges.has(path),
|
|
),
|
|
];
|
|
for (const path of candidatePaths) {
|
|
const actual = gitObjectAt(projectDir, treeish, path);
|
|
const expected = isEngineMergeMetadata(projectDir, path, recordPrefix)
|
|
? gitObjectAt(projectDir, mainParent, path)
|
|
: gitObjectAt(projectDir, transaction.pinned_oid, path);
|
|
if (actual !== expected) violations.push(path);
|
|
}
|
|
return [...new Set(violations)].sort();
|
|
}
|
|
|
|
function assertLandCandidateBoundary(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): void {
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
if (!recordPrefix) {
|
|
fail("Cannot resolve the active intent record for Unit landing.");
|
|
}
|
|
const boundary = candidateBoundary(
|
|
projectDir,
|
|
transaction.candidate_base_oid,
|
|
transaction.pinned_oid,
|
|
recordPrefix,
|
|
transaction.unit,
|
|
transaction.generation,
|
|
transaction.audit_shard,
|
|
);
|
|
if (boundary.violations.length > 0) {
|
|
fail(
|
|
`Unit "${transaction.unit}" landing violates claimed Unit ownership at: ${
|
|
boundary.violations.join(", ")
|
|
}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateLandedMerge(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
commitOid: string,
|
|
target: string,
|
|
): void {
|
|
const branch = gitOutput(
|
|
projectDir,
|
|
["branch", "--show-current"],
|
|
"Cannot resolve landing branch",
|
|
);
|
|
if (branch !== target) {
|
|
fail(`Unit landing expected branch "${target}", found "${branch}".`);
|
|
}
|
|
if (
|
|
!git(
|
|
projectDir,
|
|
["merge-base", "--is-ancestor", commitOid, "HEAD"],
|
|
).ok
|
|
) {
|
|
fail(`Recorded Unit merge commit ${commitOid} is not on ${target}.`);
|
|
}
|
|
const parents = mergeCommitParents(projectDir, commitOid);
|
|
if (parents.length < 2 || parents[1] !== transaction.pinned_oid) {
|
|
fail(
|
|
`Unit "${transaction.unit}" landing is not a controlled merge of pinned OID ${transaction.pinned_oid}.`,
|
|
);
|
|
}
|
|
const mainParent = parents[0];
|
|
const violations = landedTreeViolations(
|
|
projectDir,
|
|
transaction,
|
|
commitOid,
|
|
mainParent,
|
|
);
|
|
if (violations.length > 0) {
|
|
fail(
|
|
`Unit "${transaction.unit}" merge commit violates candidate-exact policy at: ${violations.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function recoveredMergeCommit(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
target: string,
|
|
): string | null {
|
|
const history = git(
|
|
projectDir,
|
|
[
|
|
"log",
|
|
"--first-parent",
|
|
"--format=%H%x00%P",
|
|
`${transaction.main_before_oid}..HEAD`,
|
|
],
|
|
);
|
|
if (!history.ok) return null;
|
|
for (const line of history.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
const [commitOid, parentText = ""] = line.split("\u0000");
|
|
const parents = parentText.split(/\s+/).filter(Boolean);
|
|
if (parents[1] !== transaction.pinned_oid) continue;
|
|
validateLandedMerge(projectDir, transaction, commitOid, target);
|
|
return commitOid;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function metadataCheckpointAtHead(
|
|
projectDir: string,
|
|
unit: string,
|
|
): { parentOid: string; commitOid: string } | null {
|
|
const commitOid = gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot inspect metadata checkpoint",
|
|
);
|
|
const subject = gitOutput(
|
|
projectDir,
|
|
["show", "-s", "--format=%s", commitOid],
|
|
"Cannot inspect metadata checkpoint subject",
|
|
);
|
|
if (subject !== `Checkpoint Unit ${unit} merge gate`) return null;
|
|
const parents = mergeCommitParents(projectDir, commitOid);
|
|
if (parents.length !== 1) return null;
|
|
const paths = gitOutput(
|
|
projectDir,
|
|
["diff", "--name-only", `${parents[0]}..${commitOid}`],
|
|
"Cannot inspect metadata checkpoint paths",
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
const recordPrefix = relativeRecordDir(projectDir);
|
|
if (!recordPrefix) return null;
|
|
if (
|
|
paths.length === 0 ||
|
|
paths.some(
|
|
(path) =>
|
|
!isEngineMergeMetadata(projectDir, path, recordPrefix) &&
|
|
!isActiveIntentAuditPath(path, recordPrefix),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return { parentOid: parents[0], commitOid };
|
|
}
|
|
|
|
function validateMetadataCheckpoint(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): void {
|
|
if (!transaction.checkpoint_commit_oid) return;
|
|
const checkpoint = metadataCheckpointAtHead(projectDir, transaction.unit);
|
|
if (
|
|
!checkpoint ||
|
|
checkpoint.commitOid !== transaction.checkpoint_commit_oid ||
|
|
checkpoint.parentOid !== transaction.checkpoint_parent_oid
|
|
) {
|
|
fail(`Unit "${transaction.unit}" metadata checkpoint is invalid.`);
|
|
}
|
|
}
|
|
|
|
function rollbackMetadataCheckpoint(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): UnitMergeTransaction {
|
|
if (
|
|
transaction.checkpoint_commit_oid &&
|
|
transaction.checkpoint_parent_oid &&
|
|
gitOutput(projectDir, ["rev-parse", "HEAD"], "Cannot inspect checkpoint HEAD") ===
|
|
transaction.checkpoint_commit_oid
|
|
) {
|
|
validateMetadataCheckpoint(projectDir, transaction);
|
|
const reset = git(
|
|
projectDir,
|
|
["reset", "--mixed", transaction.checkpoint_parent_oid],
|
|
);
|
|
if (!reset.ok) {
|
|
fail(`Failed to restore pre-merge metadata checkpoint: ${reset.stderr.trim()}.`);
|
|
}
|
|
}
|
|
return {
|
|
...transaction,
|
|
checkpoint_parent_oid: undefined,
|
|
checkpoint_commit_oid: undefined,
|
|
};
|
|
}
|
|
|
|
function landGit(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
target: string,
|
|
): UnitMergeTransaction {
|
|
assertLandCandidateBoundary(projectDir, transaction);
|
|
if (transaction.git_commit_oid) {
|
|
validateLandedMerge(
|
|
projectDir,
|
|
transaction,
|
|
transaction.git_commit_oid,
|
|
target,
|
|
);
|
|
return transaction;
|
|
}
|
|
const recovered = recoveredMergeCommit(projectDir, transaction, target);
|
|
if (recovered) {
|
|
return {
|
|
...transaction,
|
|
status: "git-landed",
|
|
target_branch: target,
|
|
strategy: "merge",
|
|
git_commit_oid: recovered,
|
|
};
|
|
}
|
|
currentPinnedClaim(projectDir, transaction);
|
|
const branch = gitOutput(
|
|
projectDir,
|
|
["branch", "--show-current"],
|
|
"Cannot resolve main branch",
|
|
);
|
|
if (branch !== target) {
|
|
fail(`Unit landing expected branch "${target}", found "${branch}".`);
|
|
}
|
|
let working = transaction;
|
|
if (!working.checkpoint_commit_oid) {
|
|
const recoveredCheckpoint = metadataCheckpointAtHead(
|
|
projectDir,
|
|
transaction.unit,
|
|
);
|
|
if (recoveredCheckpoint) {
|
|
working = {
|
|
...working,
|
|
checkpoint_parent_oid: recoveredCheckpoint.parentOid,
|
|
checkpoint_commit_oid: recoveredCheckpoint.commitOid,
|
|
};
|
|
writeUnitMergeTransaction(projectDir, working);
|
|
}
|
|
} else {
|
|
validateMetadataCheckpoint(projectDir, working);
|
|
}
|
|
const existingMergeHead = git(
|
|
projectDir,
|
|
["rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
);
|
|
if (!existingMergeHead.ok) {
|
|
if (working.checkpoint_commit_oid) {
|
|
const head = gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot inspect metadata checkpoint",
|
|
);
|
|
if (head !== working.checkpoint_commit_oid) {
|
|
fail("Unit merge metadata checkpoint no longer matches HEAD.");
|
|
}
|
|
} else {
|
|
const checkpoint = checkpointMainMetadata(
|
|
projectDir,
|
|
`Checkpoint Unit ${transaction.unit} merge gate`,
|
|
);
|
|
working = {
|
|
...working,
|
|
checkpoint_parent_oid: checkpoint.parentOid,
|
|
checkpoint_commit_oid: checkpoint.commitOid,
|
|
};
|
|
writeUnitMergeTransaction(projectDir, working);
|
|
}
|
|
} else if (existingMergeHead.stdout.trim() !== transaction.pinned_oid) {
|
|
fail("Another git merge is already in progress.");
|
|
}
|
|
const changed = candidateChangedPaths(projectDir, transaction);
|
|
const merged = existingMergeHead.ok
|
|
? { ok: true, stdout: "", stderr: "", code: 0 }
|
|
: git(
|
|
projectDir,
|
|
["merge", "--no-commit", "--no-ff", transaction.pinned_oid],
|
|
);
|
|
restoreMainMetadata(projectDir, changed);
|
|
const conflicts = conflictFiles(projectDir).filter(
|
|
(path) => !isEngineMergeMetadata(projectDir, path),
|
|
);
|
|
if (conflicts.length > 0) {
|
|
git(projectDir, ["merge", "--abort"]);
|
|
working = rollbackMetadataCheckpoint(projectDir, working);
|
|
writeUnitMergeTransaction(projectDir, {
|
|
...working,
|
|
conflict_files: conflicts,
|
|
});
|
|
fail(`Unit "${transaction.unit}" source conflicts: ${conflicts.join(", ")}.`);
|
|
}
|
|
if (!merged.ok && !git(projectDir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).ok) {
|
|
working = rollbackMetadataCheckpoint(projectDir, working);
|
|
writeUnitMergeTransaction(projectDir, working);
|
|
fail(`Pinned git merge failed: ${merged.stderr.trim() || merged.stdout.trim()}.`);
|
|
}
|
|
const pendingTree = gitOutput(
|
|
projectDir,
|
|
["write-tree"],
|
|
"Cannot materialize pending Unit merge tree",
|
|
);
|
|
const mainParent = gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot resolve pending Unit merge parent",
|
|
);
|
|
const policyViolations = landedTreeViolations(
|
|
projectDir,
|
|
working,
|
|
pendingTree,
|
|
mainParent,
|
|
);
|
|
if (policyViolations.length > 0) {
|
|
git(projectDir, ["merge", "--abort"]);
|
|
working = rollbackMetadataCheckpoint(projectDir, working);
|
|
writeUnitMergeTransaction(projectDir, {
|
|
...working,
|
|
conflict_files: policyViolations,
|
|
});
|
|
fail(
|
|
`Unit "${transaction.unit}" candidate-exact merge policy refused auto-merged paths: ` +
|
|
`${policyViolations.join(", ")}. Rebase onto the current target and republish.`,
|
|
);
|
|
}
|
|
const committed = git(projectDir, [
|
|
"commit",
|
|
"--no-edit",
|
|
"-m",
|
|
`Merge Unit ${transaction.unit} @ ${transaction.pinned_oid.slice(0, 12)}`,
|
|
]);
|
|
if (!committed.ok) {
|
|
git(projectDir, ["merge", "--abort"]);
|
|
working = rollbackMetadataCheckpoint(projectDir, working);
|
|
writeUnitMergeTransaction(projectDir, working);
|
|
fail(`Pinned git merge commit failed: ${committed.stderr.trim()}.`);
|
|
}
|
|
const commitOid = gitOutput(
|
|
projectDir,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot resolve landed merge commit",
|
|
);
|
|
try {
|
|
validateLandedMerge(projectDir, working, commitOid, target);
|
|
} catch (error) {
|
|
const reset = git(projectDir, ["reset", "--hard", mainParent]);
|
|
if (!reset.ok) {
|
|
fail(
|
|
`Unit merge validation failed and rollback also failed: ${errorMessage(error)}; ${reset.stderr.trim()}.`,
|
|
);
|
|
}
|
|
working = rollbackMetadataCheckpoint(projectDir, working);
|
|
writeUnitMergeTransaction(projectDir, working);
|
|
throw error;
|
|
}
|
|
return {
|
|
...working,
|
|
status: "git-landed",
|
|
target_branch: target,
|
|
strategy: "merge",
|
|
git_commit_oid: commitOid,
|
|
conflict_files: undefined,
|
|
};
|
|
}
|
|
|
|
function runStateFold(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
): void {
|
|
const tool = join(dirname(fileURLToPath(import.meta.url)), "aidlc-state.ts");
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
tool,
|
|
"fold-unit-merge",
|
|
"--unit",
|
|
transaction.unit,
|
|
"--pinned-oid",
|
|
transaction.pinned_oid,
|
|
"--generation",
|
|
String(transaction.generation),
|
|
"--project-dir",
|
|
projectDir,
|
|
],
|
|
{
|
|
cwd: projectDir,
|
|
encoding: "utf-8",
|
|
env: {
|
|
...process.env,
|
|
AIDLC_STATE_TRANSITION_OWNER: `unit-merge:${process.pid}`,
|
|
},
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
fail(`Unit state fold failed: ${(result.stderr || result.stdout).trim()}.`);
|
|
}
|
|
}
|
|
|
|
function validateMergeRiskAcknowledgment(
|
|
projectDir: string,
|
|
unit: string,
|
|
userInput: string | undefined,
|
|
): string {
|
|
const answer = userInput?.trim();
|
|
if (!answer) {
|
|
fail(
|
|
`Unit "${unit}" released-attempt recovery requires --user-input <human acknowledgment>.`,
|
|
);
|
|
}
|
|
if (!humanPresenceGuardDisabled() && isNonAnswer(answer)) {
|
|
fail(
|
|
`Refusing Unit "${unit}" released-attempt recovery: --user-input "${answer}" is cancellation boilerplate.`,
|
|
);
|
|
}
|
|
const authorship = humanPresenceGuardDisabled()
|
|
? null
|
|
: selfAttributedDecisionMarker(answer, "approval");
|
|
if (authorship) {
|
|
fail(
|
|
`Refusing Unit "${unit}" released-attempt recovery: decision self-attribution blocked ` +
|
|
`(${authorship.category}) in --user-input: "${authorship.phrase}".`,
|
|
);
|
|
}
|
|
if (!humanPresenceGuardDisabled()) {
|
|
const all = mainAuthorityAuditRows(projectDir);
|
|
const floor = all.findLastIndex(
|
|
(row) =>
|
|
row.event === "GATE_APPROVED" ||
|
|
row.event === "GATE_REJECTED" ||
|
|
(
|
|
row.event === "RECOVERY_COMPLETED" &&
|
|
auditBlockField(row.block, "Recovery") ===
|
|
"unit-merge-released-attempt"
|
|
),
|
|
);
|
|
if (!all.slice(floor + 1).some((row) => row.event === "HUMAN_TURN")) {
|
|
fail(
|
|
`Unit "${unit}" released-attempt recovery requires a fresh typed human turn from main's audit shard.`,
|
|
);
|
|
}
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
function recordReleasedAfterGitAcceptance(
|
|
projectDir: string,
|
|
transaction: UnitMergeTransaction,
|
|
tombstone: UnitClaimView,
|
|
userInput: string,
|
|
): UnitMergeTransaction {
|
|
if (
|
|
transaction.released_after_git?.tombstone_oid === tombstone.oid &&
|
|
transaction.released_after_git.tombstone_generation ===
|
|
tombstone.generation
|
|
) {
|
|
return transaction;
|
|
}
|
|
appendAuditEntry(
|
|
"RECOVERY_COMPLETED",
|
|
{
|
|
Recovery: "unit-merge-released-attempt",
|
|
Unit: transaction.unit,
|
|
"Pinned OID": transaction.pinned_oid,
|
|
"Attempt Generation": String(transaction.generation),
|
|
"Tombstone OID": tombstone.oid,
|
|
"Tombstone Generation": String(tombstone.generation),
|
|
"User Input": userInput,
|
|
Details:
|
|
"Human accepted completion of the already-landed merge after the exact claim attempt was tombstoned.",
|
|
},
|
|
projectDir,
|
|
);
|
|
const updated: UnitMergeTransaction = {
|
|
...transaction,
|
|
released_after_git: {
|
|
accepted_at: isoTimestamp(),
|
|
user_input: userInput,
|
|
tombstone_oid: tombstone.oid,
|
|
tombstone_generation: tombstone.generation,
|
|
},
|
|
};
|
|
writeUnitMergeTransaction(projectDir, updated);
|
|
updateCachedClaim(projectDir, tombstone);
|
|
return updated;
|
|
}
|
|
|
|
function landUnit(args: string[], projectDir?: string): void {
|
|
const { positional, flags } = parseArgs(args);
|
|
const unit = positional[0];
|
|
const step = flags.step ?? "all";
|
|
const acceptReleasedAttempt =
|
|
flags["accept-released-attempt"] === "true";
|
|
if (
|
|
!unit ||
|
|
!["git", "state", "audit", "all"].includes(step) ||
|
|
(flags.strategy !== undefined && flags.strategy !== "merge")
|
|
) {
|
|
fail(
|
|
"Usage: aidlc-unit land <unit> [--step git|state|audit|all] [--target <branch>] " +
|
|
"[--strategy merge] [--accept-released-attempt --user-input <human acknowledgment>]",
|
|
);
|
|
}
|
|
const pd = resolveProjectDir(projectDir);
|
|
assertMainCheckout(pd);
|
|
const existing = readUnitMergeTransaction(pd, unit);
|
|
if (!existing || !["approved", "git-landed", "state-folded", "complete"].includes(existing.status)) {
|
|
fail(`Unit "${unit}" merge is not approved.`);
|
|
}
|
|
let transaction: UnitMergeTransaction = existing;
|
|
let claimCheck: PinnedClaimCheck | null = null;
|
|
assertTransactionIdentity(pd, transaction);
|
|
if (acceptReleasedAttempt && transaction.status !== "git-landed") {
|
|
fail(
|
|
`Unit "${unit}" --accept-released-attempt applies only after the git step landed and before state folding.`,
|
|
);
|
|
}
|
|
if (
|
|
transaction.status === "approved" ||
|
|
transaction.status === "git-landed"
|
|
) {
|
|
claimCheck = currentPinnedClaim(pd, transaction, {
|
|
acceptReleasedAttempt,
|
|
});
|
|
if (
|
|
claimCheck.releasedTombstone &&
|
|
!transaction.released_after_git
|
|
) {
|
|
const userInput = validateMergeRiskAcknowledgment(
|
|
pd,
|
|
unit,
|
|
flags["user-input"],
|
|
);
|
|
transaction = recordReleasedAfterGitAcceptance(
|
|
pd,
|
|
transaction,
|
|
claimCheck.releasedTombstone,
|
|
userInput,
|
|
);
|
|
}
|
|
}
|
|
const gate = mergeGateRecord(pd, transaction);
|
|
if (gate?.decision !== "approve") {
|
|
fail(`Unit "${unit}" has no current approved merge-gate receipt.`);
|
|
}
|
|
if (gate.strategy !== "merge" || !gate.target) {
|
|
fail(`Unit "${unit}" approved merge gate has invalid strategy or target evidence.`);
|
|
}
|
|
const target =
|
|
flags.target ?? gate.target;
|
|
if (
|
|
target !== gate.target
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" merge gate approved target "${gate.target}", not "${target}".`,
|
|
);
|
|
}
|
|
transaction = {
|
|
...transaction,
|
|
target_branch: gate.target,
|
|
strategy: "merge",
|
|
};
|
|
if (transaction.evidence.merge_held) {
|
|
fail(
|
|
`Unit "${unit}" merge is held. Resolve the existing HOLD-MERGE flow in the team checkout, publish, and re-pin.`,
|
|
);
|
|
}
|
|
const runGitStep = step === "git" || step === "all";
|
|
const runStateStep = step === "state" || step === "all";
|
|
const runAuditStep = step === "audit" || step === "all";
|
|
if (step === "state" && transaction.status === "approved") {
|
|
fail(`Unit "${unit}" git step has not landed.`);
|
|
}
|
|
if (
|
|
step === "audit" &&
|
|
!["state-folded", "complete"].includes(transaction.status)
|
|
) {
|
|
fail(`Unit "${unit}" state step has not folded.`);
|
|
}
|
|
if (runGitStep && transaction.status === "approved") {
|
|
if (!claimCheck) {
|
|
fail(`Unit "${unit}" git landing has no current pinned-claim evidence.`);
|
|
}
|
|
const liveIntegration = fetchIntegration(pd);
|
|
const landingBranch = currentBranch(pd);
|
|
if (landingBranch !== target) {
|
|
fail(`Unit landing expected branch "${target}", found "${landingBranch}".`);
|
|
}
|
|
const landingHead = gitOutput(
|
|
pd,
|
|
["rev-parse", "HEAD"],
|
|
"Cannot resolve local landing target",
|
|
);
|
|
if (
|
|
!git(
|
|
pd,
|
|
["merge-base", "--is-ancestor", liveIntegration.oid, landingHead],
|
|
).ok
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" local target "${target}" is stale: fetched ` +
|
|
`${liveIntegration.ref} at ${liveIntegration.oid}, but local HEAD is ` +
|
|
`${landingHead}. Fast-forward or rebase "${target}", then re-run landing.`,
|
|
);
|
|
}
|
|
const liveEvidence = candidateEvidence(
|
|
pd,
|
|
claimCheck.claim,
|
|
transaction.candidate_base_oid,
|
|
landingHead,
|
|
);
|
|
if (JSON.stringify(liveEvidence) !== JSON.stringify(transaction.evidence)) {
|
|
fail(
|
|
`Unit "${unit}" live integration evidence changed after merge approval; ` +
|
|
`run aidlc unit pin ${unit} again and obtain a new merge gate.`,
|
|
);
|
|
}
|
|
transaction = landGit(pd, transaction, target);
|
|
writeUnitMergeTransaction(pd, transaction);
|
|
}
|
|
if (
|
|
runStateStep &&
|
|
["git-landed", "state-folded"].includes(transaction.status)
|
|
) {
|
|
if (!transaction.git_commit_oid) {
|
|
fail(`Unit "${unit}" has no validated git landing commit.`);
|
|
}
|
|
validateLandedMerge(
|
|
pd,
|
|
transaction,
|
|
transaction.git_commit_oid,
|
|
target,
|
|
);
|
|
const liveState = readStateFile(pd);
|
|
const liveScope = getField(liveState, "Scope") ?? "";
|
|
const liveStageColumns = unitMajorConstructionStageSlugs(
|
|
liveScope,
|
|
liveState,
|
|
true,
|
|
);
|
|
if (liveStageColumns.length === 0) {
|
|
fail(`Unit "${unit}" live main state has no active per-unit Construction columns.`);
|
|
}
|
|
transaction = {
|
|
...transaction,
|
|
live_stage_columns: liveStageColumns,
|
|
state_fold_authorized:
|
|
transaction.state_fold_authorized ??
|
|
(
|
|
claimCheck
|
|
? {
|
|
authorized_at: isoTimestamp(),
|
|
mode: claimCheck.releasedTombstone
|
|
? "released-after-git"
|
|
: "live-claim",
|
|
observed_oid:
|
|
claimCheck.releasedTombstone?.oid ??
|
|
claimCheck.claim.oid,
|
|
owner: claimCheck.claim.owner,
|
|
}
|
|
: transaction.state_fold_authorized
|
|
),
|
|
};
|
|
if (!transaction.state_fold_authorized) {
|
|
fail(
|
|
`Unit "${unit}" state fold has no current land-bound claim authorization.`,
|
|
);
|
|
}
|
|
writeUnitMergeTransaction(pd, transaction);
|
|
runStateFold(pd, transaction);
|
|
transaction = { ...transaction, status: "state-folded" };
|
|
writeUnitMergeTransaction(pd, transaction);
|
|
}
|
|
if (runAuditStep && transaction.status !== "complete") {
|
|
if (!unitMergedReceipts(pd).has(unit)) {
|
|
fail(`Unit "${unit}" state fold has no current-generation UNIT_MERGED receipt.`);
|
|
}
|
|
if (!mainUnitRowMerged(pd, transaction)) {
|
|
fail(`Unit "${unit}" state fold has not applied the authoritative merged row.`);
|
|
}
|
|
checkpointMainMetadata(
|
|
pd,
|
|
`Finalize Unit ${unit} merge metadata`,
|
|
);
|
|
transaction = { ...transaction, status: "complete" };
|
|
writeUnitMergeTransaction(pd, transaction);
|
|
}
|
|
console.log(JSON.stringify({
|
|
landed: transaction.status === "complete",
|
|
status: transaction.status,
|
|
unit,
|
|
pinned_oid: transaction.pinned_oid,
|
|
git_commit_oid: transaction.git_commit_oid ?? null,
|
|
}));
|
|
}
|
|
|
|
function assertMainCheckout(projectDir: string): void {
|
|
const top = git(projectDir, ["rev-parse", "--show-toplevel"]);
|
|
const common = git(projectDir, ["rev-parse", "--git-common-dir"]);
|
|
if (!top.ok || !common.ok) fail("Cannot resolve the main git checkout.");
|
|
const topReal = realpathSync(top.stdout.trim());
|
|
const commonAbs = resolve(topReal, common.stdout.trim());
|
|
const mainReal = realpathSync(dirname(commonAbs));
|
|
if (topReal !== mainReal) {
|
|
fail(`This command must run from the unscoped main checkout, not sibling worktree ${topReal}.`);
|
|
}
|
|
}
|
|
|
|
function validateClaimRefUnit(intentId8: string, unit: string): void {
|
|
const ref = claimRef(intentId8, unit);
|
|
const checked = spawnSync("git", ["check-ref-format", ref], {
|
|
encoding: "utf-8",
|
|
});
|
|
if (checked.status !== 0) {
|
|
fail(
|
|
`Unit "${unit}" is not claim-ref safe; avoid '..', '.lock' endings, ` +
|
|
"leading dots, and other git-ref-reserved forms.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function claimOwnerLabel(projectDir: string, supplied?: string): string {
|
|
const owner =
|
|
supplied?.trim() ||
|
|
git(projectDir, ["config", "user.name"]).stdout.trim() ||
|
|
"team";
|
|
if (/[\r\n|]/.test(owner)) {
|
|
fail("Team labels cannot contain pipes or newlines.");
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
function finalizeClaimCheckout(
|
|
projectDir: string,
|
|
stamp: UnitScopeStamp,
|
|
claim: UnitClaimView,
|
|
): void {
|
|
stamp.claim_oid = claim.oid;
|
|
stamp.audit_shard = claim.payload.audit_shard;
|
|
writeUnitScopeStamp(projectDir, stamp);
|
|
writeClaimGeneration(projectDir, stamp.unit, stamp.generation);
|
|
ensureCloneId(projectDir);
|
|
updateCachedClaim(projectDir, claim);
|
|
}
|
|
|
|
interface PendingRelease {
|
|
version: 1;
|
|
status: "pending" | "released";
|
|
intent_uuid: string;
|
|
unit: string;
|
|
claim_generation: number;
|
|
claim_nonce: string;
|
|
release_nonce: string;
|
|
released_generation?: number;
|
|
released_oid?: string;
|
|
}
|
|
|
|
function readPendingRelease(
|
|
projectDir: string,
|
|
unit: string,
|
|
identity: ReturnType<typeof activeIdentity>,
|
|
): PendingRelease | null {
|
|
try {
|
|
const parsed = JSON.parse(
|
|
readFileSync(
|
|
unitReleasePendingPath(
|
|
projectDir,
|
|
unit,
|
|
identity.space,
|
|
identity.intentUuid,
|
|
),
|
|
"utf-8",
|
|
),
|
|
) as Partial<PendingRelease>;
|
|
if (
|
|
parsed.version !== 1 ||
|
|
(parsed.status !== "pending" && parsed.status !== "released") ||
|
|
typeof parsed.intent_uuid !== "string" ||
|
|
typeof parsed.unit !== "string" ||
|
|
!Number.isInteger(parsed.claim_generation) ||
|
|
typeof parsed.claim_nonce !== "string" ||
|
|
typeof parsed.release_nonce !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed as PendingRelease;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writePendingRelease(
|
|
projectDir: string,
|
|
identity: ReturnType<typeof activeIdentity>,
|
|
pending: PendingRelease,
|
|
): void {
|
|
const path = unitReleasePendingPath(
|
|
projectDir,
|
|
pending.unit,
|
|
identity.space,
|
|
identity.intentUuid,
|
|
);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(
|
|
path,
|
|
`${JSON.stringify(pending, null, 2)}\n`,
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
function adoptUnit(args: string[], projectDir?: string): void {
|
|
const { positional } = parseArgs(args);
|
|
const unit = positional[0];
|
|
if (!unit) fail("Usage: aidlc-unit adopt <unit>");
|
|
const unitError = validateUnitName(unit);
|
|
if (unitError) fail(unitError);
|
|
const pd = resolveProjectDir(projectDir);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) fail("Unit adoption requires Unit Ownership: team.");
|
|
const identity = activeIdentity(pd);
|
|
const ref = claimRef(identity.intentId8, unit);
|
|
const branch = gitOutput(
|
|
pd,
|
|
["symbolic-ref", "--quiet", "HEAD"],
|
|
"Unit adoption requires the checked-out local claim branch",
|
|
);
|
|
if (branch !== ref) {
|
|
fail(`Unit adoption requires checked-out branch "${ref}", found "${branch}".`);
|
|
}
|
|
const existing = readUnitScopeStamp(pd);
|
|
if (existing && existing.unit !== unit) {
|
|
fail(`This checkout is already scoped to Unit "${existing.unit}".`);
|
|
}
|
|
const claim = currentClaim(pd, repositoryRemote(pd), ref);
|
|
if (
|
|
claim?.status !== "claimed" ||
|
|
claim.payload.space !== identity.space ||
|
|
claim.payload.intent_uuid !== identity.intentUuid ||
|
|
claim.payload.intent_id8 !== identity.intentId8 ||
|
|
claim.payload.unit !== unit ||
|
|
claim.payload.claim_ref !== ref ||
|
|
!claim.payload.audit_shard ||
|
|
!/^[A-Za-z0-9._-]+\.md$/.test(claim.payload.audit_shard)
|
|
) {
|
|
fail(`Unit "${unit}" checked-out claim identity or audit shard is invalid.`);
|
|
}
|
|
const head = gitOutput(pd, ["rev-parse", "HEAD"], "Cannot resolve checked-out claim");
|
|
if (head !== claim.oid) {
|
|
fail(`Unit adoption requires HEAD at live claim OID ${claim.oid}.`);
|
|
}
|
|
const checkedOut = readPayload(pd, head, { localOnly: true });
|
|
if (
|
|
!checkedOut ||
|
|
checkedOut.nonce !== claim.nonce ||
|
|
checkedOut.generation !== claim.generation ||
|
|
checkedOut.audit_shard !== claim.payload.audit_shard
|
|
) {
|
|
fail(`Unit "${unit}" checked-out claim payload does not match the live claim ref.`);
|
|
}
|
|
const stamp: UnitScopeStamp = {
|
|
version: 1,
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
intent_id8: identity.intentId8,
|
|
unit,
|
|
owner: claim.owner,
|
|
generation: claim.generation,
|
|
nonce: claim.nonce,
|
|
claim_ref: ref,
|
|
claim_oid: claim.oid,
|
|
claimed_from_oid: claim.payload.base_oid,
|
|
integration_ref: claim.payload.integration_ref,
|
|
gate_rhythm: claim.payload.gate_rhythm,
|
|
audit_shard: claim.payload.audit_shard,
|
|
};
|
|
finalizeClaimCheckout(pd, stamp, claim);
|
|
console.log(JSON.stringify({ adopted: true, ...stamp }));
|
|
}
|
|
|
|
function claimUnit(args: string[], projectDir?: string): void {
|
|
const { positional, flags } = parseArgs(args);
|
|
const unit = positional[0];
|
|
if (!unit) fail("Usage: aidlc-unit claim <unit> [--team <label>] [--rhythm per-stage|unit-end]");
|
|
const unitError = validateUnitName(unit);
|
|
if (unitError) fail(unitError);
|
|
if (
|
|
flags.rhythm !== undefined &&
|
|
flags.rhythm !== "per-stage" &&
|
|
flags.rhythm !== "unit-end"
|
|
) {
|
|
fail(`Invalid --rhythm "${flags.rhythm}"; expected per-stage or unit-end.`);
|
|
}
|
|
const pd = resolveProjectDir(projectDir);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) fail("Unit claims require Unit Ownership: team.");
|
|
const identity = activeIdentity(pd);
|
|
validateClaimRefUnit(identity.intentId8, unit);
|
|
const existingStamp = readUnitScopeStamp(pd);
|
|
if (existingStamp) {
|
|
if (existingStamp.unit !== unit) {
|
|
fail(`This checkout is already scoped to Unit "${existingStamp.unit}".`);
|
|
}
|
|
const recovered = currentClaim(
|
|
pd,
|
|
repositoryRemote(pd),
|
|
existingStamp.claim_ref,
|
|
);
|
|
if (
|
|
recovered?.status === "claimed" &&
|
|
recovered.payload.intent_uuid === identity.intentUuid &&
|
|
recovered.unit === unit &&
|
|
recovered.generation === existingStamp.generation &&
|
|
recovered.nonce === existingStamp.nonce
|
|
) {
|
|
finalizeClaimCheckout(pd, existingStamp, recovered);
|
|
console.log(JSON.stringify({ claimed: true, recovered: true, ...existingStamp }));
|
|
return;
|
|
}
|
|
clearUnitScopeStamp(pd);
|
|
}
|
|
const integration = fetchIntegration(pd);
|
|
const opening = openingStatus(pd, integration.oid);
|
|
if (!opening.units.includes(unit)) fail(`Unit "${unit}" is not in the authoritative Unit DAG.`);
|
|
if (opening.completed.has(unit)) {
|
|
fail(`Unit "${unit}" is already complete on the integration ref.`);
|
|
}
|
|
const blockers = opening.blockers.get(unit) ?? [];
|
|
if (blockers.length > 0) {
|
|
const named = blockers.map((dep) =>
|
|
dep === "walking-skeleton"
|
|
? "walking skeleton Bolt is not complete"
|
|
: `${unit} waits on ${dep}`
|
|
);
|
|
fail(named.join("; "));
|
|
}
|
|
const ref = claimRef(identity.intentId8, unit);
|
|
const current = currentClaim(pd, integration.remote, ref);
|
|
if (current?.status === "claimed") {
|
|
fail(`Unit "${unit}" is already claimed by "${current.owner}".`);
|
|
}
|
|
const generation =
|
|
current?.status === "released"
|
|
? current.generation
|
|
: (current?.generation ?? 0) + 1;
|
|
const nonce = randomUUID();
|
|
const owner = claimOwnerLabel(pd, flags.team);
|
|
const auditShard = auditShardName(pd);
|
|
const rhythm =
|
|
flags.rhythm === "unit-end" || flags.rhythm === "per-stage"
|
|
? flags.rhythm
|
|
: readUnitGateRhythm(state);
|
|
const payload: UnitClaimPayload = {
|
|
version: 1,
|
|
status: "claimed",
|
|
owner,
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
intent_id8: identity.intentId8,
|
|
unit,
|
|
generation,
|
|
nonce,
|
|
base_oid: integration.oid,
|
|
integration_ref: integration.ref,
|
|
claim_ref: ref,
|
|
predecessor_oid: current?.oid ?? null,
|
|
gate_rhythm: rhythm,
|
|
audit_shard: auditShard,
|
|
};
|
|
const commit = createClaimCommit(pd, current?.oid ?? integration.oid, payload);
|
|
const stamp: UnitScopeStamp = {
|
|
version: 1,
|
|
space: identity.space,
|
|
intent_uuid: identity.intentUuid,
|
|
intent_id8: identity.intentId8,
|
|
unit,
|
|
owner,
|
|
generation,
|
|
nonce,
|
|
claim_ref: ref,
|
|
claim_oid: commit,
|
|
claimed_from_oid: integration.oid,
|
|
integration_ref: integration.ref,
|
|
gate_rhythm: rhythm,
|
|
audit_shard: auditShard,
|
|
};
|
|
// Persist the intended claim before the CAS. A process killed after the push
|
|
// can validate this nonce on retry and finish without creating a new attempt.
|
|
writeUnitScopeStamp(pd, stamp);
|
|
if (!updateClaimRef(pd, integration.remote, ref, commit, current?.oid ?? null)) {
|
|
clearUnitScopeStamp(pd);
|
|
const winner = currentClaim(pd, integration.remote, ref);
|
|
fail(
|
|
winner?.status === "claimed"
|
|
? `Unit "${unit}" claim lost to "${winner.owner}".`
|
|
: `Unit "${unit}" claim compare-and-swap failed.`,
|
|
);
|
|
}
|
|
invalidateLiveClaimPayloadCache(pd, unit);
|
|
const winner = currentClaim(pd, integration.remote, ref);
|
|
if (!winner || winner.nonce !== nonce) {
|
|
clearUnitScopeStamp(pd);
|
|
fail(`Unit "${unit}" claim verification failed; current holder is "${winner?.owner ?? "unknown"}".`);
|
|
}
|
|
finalizeClaimCheckout(pd, stamp, winner);
|
|
console.log(JSON.stringify({ claimed: true, ...stamp }));
|
|
}
|
|
|
|
function releaseUnit(args: string[], projectDir?: string): void {
|
|
const { positional, flags } = parseArgs(args);
|
|
const unit = positional[0];
|
|
if (!unit) fail("Usage: aidlc-unit release <unit> [--expect-nonce <claim-nonce>]");
|
|
const unitError = validateUnitName(unit);
|
|
if (unitError) fail(unitError);
|
|
const pd = resolveProjectDir(projectDir);
|
|
if (readApplicableTeamUnitScopeStamp(pd)) {
|
|
fail("release must run from an unscoped checkout.");
|
|
}
|
|
assertMainCheckout(pd);
|
|
const state = readStateFile(pd);
|
|
if (!isTeamUnitOwnership(state)) fail("Unit release requires Unit Ownership: team.");
|
|
const identity = activeIdentity(pd);
|
|
const integration = fetchIntegration(pd);
|
|
const ref = claimRef(identity.intentId8, unit);
|
|
const current = currentClaim(pd, integration.remote, ref);
|
|
if (current?.status === "released") {
|
|
const pending = readPendingRelease(pd, unit, identity);
|
|
if (
|
|
pending &&
|
|
(
|
|
pending.intent_uuid !== identity.intentUuid ||
|
|
pending.unit !== unit ||
|
|
pending.release_nonce !== current.nonce ||
|
|
pending.claim_generation + 1 !== current.generation ||
|
|
(
|
|
pending.status === "released" &&
|
|
pending.released_generation !== current.generation
|
|
)
|
|
)
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" release recovery does not match the recorded attempt; ` +
|
|
"refusing to acknowledge a different tombstone.",
|
|
);
|
|
}
|
|
clearClaimGeneration(pd, unit);
|
|
updateCachedClaim(pd, current);
|
|
clearUnitOwner(pd, unit);
|
|
if (pending?.status === "pending") {
|
|
writePendingRelease(pd, identity, {
|
|
...pending,
|
|
status: "released",
|
|
released_generation: current.generation,
|
|
released_oid: current.oid,
|
|
});
|
|
}
|
|
console.log(JSON.stringify({
|
|
released: true,
|
|
recovered: true,
|
|
unit,
|
|
generation: current.generation,
|
|
claim_ref: ref,
|
|
claim_oid: current.oid,
|
|
}));
|
|
return;
|
|
}
|
|
if (current?.status !== "claimed") fail(`Unit "${unit}" has no live claim.`);
|
|
if (flags["expect-nonce"] && flags["expect-nonce"] !== current.nonce) {
|
|
fail(
|
|
`Unit "${unit}" current claim nonce does not match --expect-nonce; refusing release.`,
|
|
);
|
|
}
|
|
const opening = openingStatus(pd, integration.oid);
|
|
if (opening.completed.has(unit)) {
|
|
fail(
|
|
`Unit "${unit}" is already complete/merged on the integration ref; release is refused.`,
|
|
);
|
|
}
|
|
const priorPending = readPendingRelease(pd, unit, identity);
|
|
if (
|
|
priorPending?.status === "released" &&
|
|
!flags["expect-nonce"]
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" was released previously and may now be a successor claim. ` +
|
|
`Re-run with --expect-nonce ${current.nonce} to release this exact attempt.`,
|
|
);
|
|
}
|
|
if (
|
|
priorPending?.status === "pending" &&
|
|
(
|
|
priorPending.intent_uuid !== identity.intentUuid ||
|
|
priorPending.unit !== unit ||
|
|
priorPending.claim_generation !== current.generation ||
|
|
priorPending.claim_nonce !== current.nonce
|
|
)
|
|
) {
|
|
fail(
|
|
`Unit "${unit}" now belongs to a different claim attempt; refusing to ` +
|
|
"tombstone the successor during release recovery.",
|
|
);
|
|
}
|
|
const pending: PendingRelease = priorPending ?? {
|
|
version: 1,
|
|
status: "pending",
|
|
intent_uuid: identity.intentUuid,
|
|
unit,
|
|
claim_generation: current.generation,
|
|
claim_nonce: current.nonce,
|
|
release_nonce: randomUUID(),
|
|
};
|
|
const activePending = pending.status === "pending"
|
|
? pending
|
|
: {
|
|
version: 1 as const,
|
|
status: "pending" as const,
|
|
intent_uuid: identity.intentUuid,
|
|
unit,
|
|
claim_generation: current.generation,
|
|
claim_nonce: current.nonce,
|
|
release_nonce: randomUUID(),
|
|
};
|
|
writePendingRelease(pd, identity, activePending);
|
|
const payload: UnitClaimPayload = {
|
|
...current.payload,
|
|
status: "released",
|
|
owner: "released",
|
|
generation: current.generation + 1,
|
|
nonce: activePending.release_nonce,
|
|
predecessor_oid: current.oid,
|
|
};
|
|
const commit = createClaimCommit(pd, current.oid, payload);
|
|
if (!updateClaimRef(pd, integration.remote, ref, commit, current.oid)) {
|
|
fail(`Unit "${unit}" release compare-and-swap failed.`);
|
|
}
|
|
invalidateLiveClaimPayloadCache(pd, unit);
|
|
const released = currentClaim(pd, integration.remote, ref);
|
|
if (released?.status !== "released" || released.nonce !== payload.nonce) {
|
|
fail(`Unit "${unit}" release verification failed.`);
|
|
}
|
|
clearUnitScopeStamp(pd);
|
|
clearClaimGeneration(pd, unit);
|
|
updateCachedClaim(pd, released);
|
|
clearUnitOwner(pd, unit);
|
|
writePendingRelease(pd, identity, {
|
|
...activePending,
|
|
status: "released",
|
|
released_generation: released.generation,
|
|
released_oid: released.oid,
|
|
});
|
|
console.log(JSON.stringify({
|
|
released: true,
|
|
unit,
|
|
generation: released.generation,
|
|
claim_ref: ref,
|
|
claim_oid: released.oid,
|
|
}));
|
|
}
|
|
|
|
function clearUnitOwner(projectDir: string, unit: string): void {
|
|
withAuditLock(projectDir, () => {
|
|
const stateContent = readStateFile(projectDir);
|
|
const lines = stateContent.split("\n");
|
|
let changed = false;
|
|
const updated = lines.map((line) => {
|
|
if (!line.startsWith("|")) return line;
|
|
const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
|
|
if (
|
|
cells.length < 2 ||
|
|
(cells[0].toLowerCase() === "unit" &&
|
|
cells[1].toLowerCase() === "owner") ||
|
|
cells.every((cell) => /^-+$/.test(cell)) ||
|
|
cells[0] !== unit
|
|
) {
|
|
return line;
|
|
}
|
|
cells[1] = "-";
|
|
changed = true;
|
|
return `| ${cells.join(" | ")} |`;
|
|
}).join("\n");
|
|
if (changed) writeStateFile(projectDir, updated);
|
|
});
|
|
}
|
|
|
|
function participate(projectDir?: string): void {
|
|
const pd = resolveProjectDir(projectDir);
|
|
writeFileSync(unitParticipantPath(pd), "participant\n", "utf-8");
|
|
console.log(JSON.stringify({ participant: true }));
|
|
}
|
|
|
|
let projectDir: string | undefined;
|
|
|
|
export function main(argv: string[]): void {
|
|
const args = [...argv];
|
|
const projectIdx = args.indexOf("--project-dir");
|
|
if (projectIdx !== -1 && projectIdx + 1 < args.length) {
|
|
projectDir = args[projectIdx + 1];
|
|
args.splice(projectIdx, 2);
|
|
}
|
|
const command = args.shift();
|
|
try {
|
|
if (command === "adopt") adoptUnit(args, projectDir);
|
|
else if (command === "claim") claimUnit(args, projectDir);
|
|
else if (command === "release") releaseUnit(args, projectDir);
|
|
else if (command === "participate") participate(projectDir);
|
|
else if (command === "publish") publishUnit(args, projectDir);
|
|
else if (command === "pin") pinUnit(args, projectDir);
|
|
else if (command === "gate") gateUnitMerge(args, projectDir);
|
|
else if (command === "land") landUnit(args, projectDir);
|
|
else if (command === "merge-status") {
|
|
const unit = args[0];
|
|
if (!unit) fail("Usage: aidlc-unit merge-status <unit>");
|
|
console.log(JSON.stringify(
|
|
readUnitMergeTransaction(resolveProjectDir(projectDir), unit),
|
|
null,
|
|
2,
|
|
));
|
|
}
|
|
else if (command === "status") {
|
|
console.log(JSON.stringify(unitClaimOverview(resolveProjectDir(projectDir)), null, 2));
|
|
} else {
|
|
fail(
|
|
"Usage: aidlc-unit <adopt|claim|release|participate|publish|pin|gate|land|merge-status|status> ...",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(JSON.stringify({ error: errorMessage(error) }));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main(process.argv.slice(2));
|
|
}
|