Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

2542 lines
96 KiB
TypeScript

// aidlc-log.ts — Interaction audit helper
//
// Records DECISION_RECORDED (before AskUserQuestion), QUESTION_ANSWERED
// (after ordinary answers), SUMMARY_CONFIRMATION_RECORDED (the reserved,
// human-backed pre-generation receipt), and REVIEW_REQUESTED / REVIEW_COMPLETED
// (the §12a reviewer step). Orchestrator-callable; state tool doesn't own these
// because they fire per-question / per-review, not per state transition.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { appendAuditEntry, appendAuditEntryUnlocked } from "./aidlc-audit.ts";
import {
assertNoSymlinkInChainOrThrow,
auditBlockField,
checkSummaryConfirmationEvidence,
claimAttemptFields,
clearSummaryAuthorization,
isPerUnitStage,
SUMMARY_AUTHORIZATION_FIELD,
SUMMARY_EVIDENCE_EVENTS,
type SummaryAuthorization,
summaryAttemptFloors,
summaryAttemptIdentity,
summaryAuthorizationId,
removeRecordFileNoFollow,
summaryAuthorizationRelativePath,
summaryAuthorizationTargetOrThrow,
writeRecordFileNoFollow,
writeSummaryAuthorization,
emitError,
errorMessage,
evaluateGuardRefusal,
eventMatchesClaimAttempt,
formatReceivedReply,
freshReviewReceipts,
filterProducesByKind,
getField,
guardAttemptState,
guardRefusalOutput,
humanAuthorityState,
harnessDir,
hookExecutionRecoveryText,
hookLiveness,
holdsAuditLock,
humanActedSinceLastAnswer,
humanPresenceGuardDisabled,
isAutonomousConstructionDecision,
legacyReviewAppendixEchoFields,
isAutonomousSwarmStage,
loadStageGraphAll,
isNonAnswer,
isoTimestamp,
latestPipelineLinkArtifactMtime,
parseCheckboxes,
parseReviewSection,
pipelineAttemptStartedAt,
pipelineLinkEvidence,
pipelineLinks,
pendingReviewRequestStatus,
readAllAuditShards,
recordAcceptedChanges,
governedChangeControl,
readAuditShardEvents,
planApprovalChallengeRelativePath,
readRegularFileNoFollowOrThrow,
readStateFile,
readUnitSourceManifest,
recordDir,
relativeRecordDir,
recoveryGuidance,
requestChangesResetIsExecutable,
reviewAppendedAfterRequest,
reviewArtifactSnapshot,
reviewAttemptId,
reviewDraftRelativePath,
reviewRecordDigest,
reviewRecordRelativePath,
reviewRequestArtifactsCurrent,
REVIEW_RECORD_MAX_BYTES,
resolveBoltDag,
reviewAttemptAccounting,
reviewAttemptEventMatchesCurrentClaim,
reviewAttemptWindow,
serializeReviewRecord,
resolveProjectDir,
resolveWorkflowSelection,
resolveReviewClass,
selfAttributedDecisionMarker,
SUMMARY_CONFIRMATION_CHECKPOINT,
SUMMARY_CONFIRMATION_HASH_SCOPE,
summaryConfirmationAnswer,
summaryConfirmationContentHash,
stateFilePath,
teamUnitGateStatus,
toPosix,
unattendedHumanPresenceHint,
unitSourceFingerprint,
UNBINDABLE_FINGERPRINT,
validateLiveUnitScope,
validateReviewAppendix,
withAuditLock,
workspaceSourceState,
writeUnitSourceSnapshot,
} from "./aidlc-lib.js";
import type {
GuardAttemptState,
GuardRefusal,
TeamUnitGateResolution,
PlanApprovalRuntimeChallenge,
ReviewClass,
ReviewRecord,
ReviewVerdict,
} from "./aidlc-lib.js";
import {
authorizingPlanApprovalOverrideRequest,
codeGenerationPlanApprovalQuestionEvidence,
type CodeGenerationTarget,
PLAN_APPROVAL_CHECKPOINT,
PLAN_APPROVAL_OVERRIDE_HUMAN_ONLY,
PlanApprovalOverrideHumanOnlyError,
type PlanApprovalOverrideReceiptResult,
PlanApprovalSourceDriftError,
PlanApprovalUnbindableError,
recordPlanApprovalChallenge,
recordPlanApprovalOverrideReceipt,
recordPlanApprovalReceipt,
} from "./aidlc-testing-posture.js";
import { runtimeHarnessName } from "./aidlc-runtime-paths.ts";
// Resolve the project dir AND assert that an active workflow exists before any
// audit emit. WHY: aidlc-log is orchestrator-called per-question and threads no
// --intent/--space, so it relies on default intent resolution. On a fresh shell
// (pre-creation) or a >1-intent workspace with no active-intent cursor, that
// resolution yields null and stateFilePath()/auditFilePath() collapse to the
// BARE space record root (aidlc/spaces/<space>/intents/). Emitting there would
// drop an audit shard DIRECTLY into the bare intents root and break the "no
// aidlc-state.md / no audit/ ever lives directly in the bare intents root"
// invariant (aidlc-lib.ts). Existence of the resolved state file is the same
// "is there an active workflow" signal every other emitter guards on — the
// hooks via `if (!existsSync(stateFilePath(...)))` no-op, emitError() via the
// same check. aidlc-log is the lone emitter that was missing it; mirror the
// clean-error idiom (orchestrator-called → a missing workflow is a misuse, not
// a routine no-op).
function resolveActiveProjectDir(explicit?: string): string {
const pd = resolveProjectDir(explicit);
if (!existsSync(stateFilePath(pd))) {
error(
'No active workflow is selected, so this interaction cannot be recorded. Start one by describing what to build (/aidlc "build the auth service"), or switch to an existing one with /aidlc intent <name>.'
);
}
return pd;
}
// handleAnswer emits inside a withAuditLock section (classification and
// emission share one snapshot); appendAuditEntry acquires the OS lock itself,
// so route held-lock emits through the unlocked variant (the aidlc-state.ts
// idiom) to avoid self-deadlocking on the lock dir we already hold.
function emitAudit(
pd: string,
eventType: string,
fields: Record<string, string>,
intent?: string,
space?: string,
): void {
if (holdsAuditLock(pd, intent, space)) {
appendAuditEntryUnlocked(eventType, fields, pd, intent, space);
return;
}
appendAuditEntry(eventType, fields, pd, intent, space);
}
// --- Flag parsing ---
function parseFlags(
args: string[]
): { positional: string[]; flags: Record<string, string> } {
const positional: string[] = [];
const flags: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
if (a === "--single" || a === "--retry-pending" || a === "--stage-level") {
flags[a.slice(2)] = "true";
continue;
}
if (i + 1 >= args.length) {
error(`${a} expects a value, got end of arguments.`);
}
const val = args[i + 1];
if (val.startsWith("--")) {
error(`${a} expects a value, got another flag: "${val}". Did you forget the value?`);
}
flags[a.slice(2)] = val;
i++;
} else {
positional.push(a);
}
}
return { positional, flags };
}
// Whether anything sits at the path, symlink included, without following it.
function lstatExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch {
return false;
}
}
function summaryQuestionEvidence(
pd: string,
flags: Record<string, string>,
expectedAnswer: string,
): { relativePath: string; sha256: string } {
const supplied = flags["questions-file"];
if (!supplied) {
error(
"Summary confirmation requires --questions-file <path> so the receipt can bind to the reviewed answers.",
);
}
const absolute = resolve(pd, supplied);
const root = recordDir(pd);
if (
root === null ||
(absolute !== root && !absolute.startsWith(`${root}${sep}`))
) {
error(
`Summary confirmation questions file must be inside the active intent record: ${supplied}`,
);
}
if (!absolute.endsWith("-questions.md") || !existsSync(absolute)) {
error(`Summary confirmation questions file does not exist: ${supplied}`);
}
const body = readFileSync(absolute, "utf-8");
if (summaryConfirmationAnswer(body) !== expectedAnswer) {
const rendered = expectedAnswer || "a blank value";
error(
`Summary confirmation section in ${supplied} must contain exactly one ` +
`\`[Answer]:\` line with ${rendered} before this command runs.`,
);
}
let sha256: string;
try {
sha256 = summaryConfirmationContentHash(body);
} catch (e) {
error(
`Summary confirmation questions file ${supplied} is invalid: ${errorMessage(e)}.`,
);
}
return {
relativePath: toPosix(relative(pd, absolute)),
sha256,
};
}
function planApprovalTarget(flags: Record<string, string>): CodeGenerationTarget {
const unit = flags.unit?.trim();
const stageLevel = flags["stage-level"] === "true";
if (unit && stageLevel) {
error("Plan Approval accepts exactly one of --unit <unit> or --stage-level.");
}
if (unit) return { unit };
if (stageLevel) return { unit: null };
error("Plan Approval requires exactly one of --unit <unit> or --stage-level.");
}
function planApprovalFields(
evidence: ReturnType<typeof codeGenerationPlanApprovalQuestionEvidence>,
): Record<string, string> {
return {
Checkpoint: PLAN_APPROVAL_CHECKPOINT,
"Plan Target": evidence.authority.targetId,
Intent: evidence.authority.intentId,
"Directive Epoch": evidence.authority.directiveEpoch,
"Run floor": evidence.authority.runFloor,
"Approval Fingerprint": evidence.fingerprint,
"Questions File": evidence.questionsRelativePath,
"Questions SHA-256": evidence.questionsSha256,
"Prompt SHA-256": evidence.promptSha256,
};
}
// --- Subcommand: decision ---
// Usage: aidlc-log decision --stage <slug> --decision <text> [--options <csv>]
// [--rationale <text>] [--checkpoint summary-confirmation
// --questions-file <path> [--unit <unit>] [--single]]
//
// Fires BEFORE AskUserQuestion, recording what options will be shown.
function handleDecision(args: string[]): void {
const { flags } = parseFlags(args);
if (!flags.stage) error("Missing --stage <slug>");
if (!flags.decision) error("Missing --decision <text>");
if (
flags.checkpoint !== undefined &&
flags.checkpoint !== "summary-confirmation" &&
flags.checkpoint !== "plan-approval"
) {
error(
`Unknown --checkpoint "${flags.checkpoint}". Accepted: summary-confirmation, plan-approval`,
);
}
const pd = resolveActiveProjectDir(projectDir);
// Plan Approval is answered by the hooks: the human-turn hook records the
// response the receipt pairs with. When heartbeats show the workflow advanced
// after the hooks last fired (the doctor's own staleness test and slack), the
// challenge could never be answered, so it is refused before it is minted.
if (flags.checkpoint === "plan-approval") {
const liveness = hookLiveness(pd);
if (liveness.stale) {
error(
"hooks are not firing in this session: hooks last fired " +
`${liveness.newestHeartbeat?.timestampRaw}, but the workflow last advanced ` +
`${liveness.newestStageOrGateEvent?.timestampRaw}. No Plan Approval challenge was ` +
"minted because the human's answer is recorded by the hooks. " +
hookExecutionRecoveryText(runtimeHarnessName(pd, harnessDir())),
);
}
}
if (flags.unit) validateLiveUnitScope(pd, flags.unit);
const summaryEvidence =
flags.checkpoint === "summary-confirmation"
? summaryQuestionEvidence(pd, flags, "")
: null;
// The plan-approval checkpoint reads Change Control inside the evidence, only
// when the source the plan was written against has moved; that read traces a
// memory edit and raises an invalid memory value as its own error.
const planEvidence =
flags.checkpoint === "plan-approval"
? codeGenerationPlanApprovalQuestionEvidence(
pd,
planApprovalTarget(flags),
flags["questions-file"] ?? "",
"",
)
: null;
// The evidence recorded any accepted drift (row first, re-baseline second);
// its human lines ride on this command's JSON.
const changeNotices = planEvidence ? [...planEvidence.changeNotices] : [];
const fields: Record<string, string> = {
Stage: flags.stage,
Decision: flags.decision,
};
if (flags.options) {
fields.Options =
planEvidence &&
(
flags["hash-option-labels"] === "true" ||
flags["legacy-directive-options"] === "true"
)
? "[protected exact choices]"
: flags.options;
}
if (flags.rationale) fields.Rationale = flags.rationale;
if (flags.checkpoint === "summary-confirmation") {
fields.Checkpoint = SUMMARY_CONFIRMATION_CHECKPOINT;
fields["Questions File"] = summaryEvidence!.relativePath;
}
if (planEvidence) Object.assign(fields, planApprovalFields(planEvidence));
if (planEvidence) {
const session = flags.session?.trim();
if (!session) {
error(
"Plan Approval requires --session <id> from the invoking SessionStart context.",
);
}
fields.Session = session;
}
if (flags.unit) {
fields.Unit = flags.unit;
Object.assign(fields, claimAttemptFields(pd, flags.unit));
}
if (flags.single === "true") fields.Workflow = `single-stage:${flags.stage}`;
try {
emitAudit(pd, "DECISION_RECORDED", fields);
} catch (e) {
error(`Audit emission failed: ${errorMessage(e)}`);
}
// The challenge is the half a later answer must pair with. Printing its id
// and file lets a conductor see that a re-run decision replaced it (and so
// orphaned an answer the human already gave) instead of discovering that at
// the receipt.
let challenge: PlanApprovalRuntimeChallenge | null = null;
if (planEvidence) {
try {
const options = (flags.options ?? "")
.split(",")
.map((option) => option.trim())
.filter(Boolean);
if (options.length !== 2) {
error("Plan Approval decision requires exactly two offered options");
}
challenge = recordPlanApprovalChallenge(
pd,
planEvidence,
fields.Session,
[options[0], options[1]],
flags["exact-option-labels"] === "true",
flags["hash-option-labels"] === "true",
flags["legacy-directive-options"] === "true",
);
} catch (e) {
error(`Plan Approval challenge creation failed: ${errorMessage(e)}`);
}
}
console.log(
JSON.stringify({
emitted: "DECISION_RECORDED",
stage: flags.stage,
...(challenge !== null
? {
challengeId: challenge.challengeId,
challengeFile: planApprovalChallengeRelativePath(pd, challenge.session),
}
: {}),
...(changeNotices.length > 0 ? { change_notices: changeNotices } : {}),
})
);
}
// --- Subcommand: answer ---
// Usage: aidlc-log answer --stage <slug> --details <text>
// [--checkpoint summary-confirmation --questions-file <path>
// [--unit <unit>] [--single]]
//
// Fires AFTER the user answers a question.
// An answer at an open approval gate belongs to a non-gate question only when
// the audit stream proves that question was asked: a DECISION_RECORDED for this
// stage after the current STAGE_AWAITING_APPROVAL, with no later
// QUESTION_ANSWERED. This structural signal handles arbitrary user wording and
// avoids guessing from gate-option words that may also begin substantive
// answers. Caller holds the audit lock, so this snapshot cannot race an emit.
function hasPendingDecisionAtGate(pd: string, stage: string): boolean {
const audit = readAllAuditShards(pd);
if (audit.length === 0) return false;
const relevant = new Set([
"STAGE_AWAITING_APPROVAL",
"DECISION_RECORDED",
"QUESTION_ANSWERED",
"SUMMARY_CONFIRMATION_RECORDED",
]);
const events = audit
.replace(/\r\n/g, "\n")
.split(/\n---\n/)
.map((block, position) => ({
event: auditBlockField(block, "Event") ?? "",
stage: auditBlockField(block, "Stage"),
timestamp: auditBlockField(block, "Timestamp") ?? "",
position,
}))
.filter((event) => relevant.has(event.event))
.sort((a, b) => {
if (a.timestamp !== b.timestamp) {
return a.timestamp < b.timestamp ? -1 : 1;
}
return a.position - b.position;
});
const gateOpen = events.findLastIndex(
(event) =>
event.event === "STAGE_AWAITING_APPROVAL" && event.stage === stage,
);
if (gateOpen === -1) return false;
let pending = false;
for (const event of events.slice(gateOpen + 1)) {
if (event.stage !== stage) continue;
if (event.event === "DECISION_RECORDED") {
pending = true;
} else if (
event.event === "QUESTION_ANSWERED" ||
event.event === "SUMMARY_CONFIRMATION_RECORDED"
) {
pending = false;
}
}
return pending;
}
function pendingSummaryDecision(
pd: string,
stage: string,
unit: string | undefined,
workflow: string | undefined,
questionsFile: string,
): { pending: boolean; humanAfterDecision: boolean; ambiguity?: string } {
const entries = readAuditShardEvents(pd).filter((entry) => {
if (entry.event === "HUMAN_TURN") return true;
if (entry.event === "STAGE_COMPLETED") {
return (
auditBlockField(entry.block, "Stage") === stage &&
(auditBlockField(entry.block, "Workflow") ?? undefined) === workflow
);
}
if (
entry.event !== "DECISION_RECORDED" &&
entry.event !== "SUMMARY_CONFIRMATION_RECORDED"
) {
return false;
}
const matching =
auditBlockField(entry.block, "Stage") === stage &&
auditBlockField(entry.block, "Checkpoint") ===
SUMMARY_CONFIRMATION_CHECKPOINT &&
(auditBlockField(entry.block, "Unit") ?? undefined) === unit &&
(auditBlockField(entry.block, "Workflow") ?? undefined) === workflow &&
auditBlockField(entry.block, "Questions File") === questionsFile;
return matching && (
unit === undefined ||
eventMatchesClaimAttempt(pd, entry.block, unit)
);
});
if (entries.length === 0) {
return { pending: false, humanAfterDecision: false };
}
type Entry = (typeof entries)[number];
const follows = (candidate: Entry, boundary: Entry): true | false | null => {
if (candidate.shard === boundary.shard) {
return candidate.pos > boundary.pos;
}
if (candidate.timestamp !== boundary.timestamp) {
return candidate.timestamp > boundary.timestamp;
}
return null;
};
const latestFrontier = (candidates: Entry[]): Entry[] => {
const byShard = new Map<string, Entry>();
for (const entry of candidates) {
const previous = byShard.get(entry.shard);
if (!previous || entry.pos > previous.pos) byShard.set(entry.shard, entry);
}
const latestTimestamp = [...byShard.values()].reduce(
(latest, entry) =>
entry.timestamp > latest ? entry.timestamp : latest,
"",
);
return [...byShard.values()].filter(
(entry) => entry.timestamp === latestTimestamp,
);
};
const floors = latestFrontier(
entries.filter((entry) => entry.event === "STAGE_COMPLETED"),
);
const afterFloor = (entry: Entry): true | false | null => {
if (floors.length === 0) return true;
const relations = floors.map((floor) => follows(entry, floor));
if (relations.every((relation) => relation === true)) return true;
if (relations.some((relation) => relation === false)) return false;
return null;
};
const actions = entries.filter((entry) =>
entry.event === "DECISION_RECORDED" ||
entry.event === "SUMMARY_CONFIRMATION_RECORDED"
);
const orderedActions = actions.filter((entry) => afterFloor(entry) === true);
const latestActions = latestFrontier(orderedActions);
const latestActionTimestamp = latestActions[0]?.timestamp;
const unorderedActions = actions.filter((entry) => afterFloor(entry) === null);
if (
unorderedActions.some((entry) =>
latestActionTimestamp === undefined ||
entry.timestamp >= latestActionTimestamp
)
) {
return {
pending: false,
humanAfterDecision: false,
ambiguity: floors[0]?.timestamp ?? unorderedActions[0].timestamp,
};
}
if (latestActions.length === 0 || latestActionTimestamp === undefined) {
return { pending: false, humanAfterDecision: false };
}
const latestKinds = new Set(
latestActions.map((entry) => entry.event),
);
if (latestKinds.size > 1) {
return {
pending: false,
humanAfterDecision: false,
ambiguity: latestActionTimestamp,
};
}
if (!latestKinds.has("DECISION_RECORDED")) {
return { pending: false, humanAfterDecision: false };
}
const humans = entries.filter((entry) => entry.event === "HUMAN_TURN");
const humanRelations = humans.map((human) =>
latestActions.map((decision) => follows(human, decision))
);
if (
humanRelations.some((relations) =>
relations.every((relation) => relation === true)
)
) {
return { pending: true, humanAfterDecision: true };
}
if (
humanRelations.some((relations) =>
!relations.some((relation) => relation === false) &&
relations.some((relation) => relation === null)
)
) {
return {
pending: false,
humanAfterDecision: false,
ambiguity: latestActionTimestamp,
};
}
return { pending: true, humanAfterDecision: false };
}
function handleAnswer(args: string[]): void {
const { flags } = parseFlags(args);
if (!flags.stage) error("Missing --stage <slug>");
if (!flags.details) error("Missing --details <text>");
if (
flags.checkpoint !== undefined &&
flags.checkpoint !== "summary-confirmation" &&
flags.checkpoint !== "plan-approval"
) {
error(
`Unknown --checkpoint "${flags.checkpoint}". Accepted: summary-confirmation, plan-approval`,
);
}
const summaryCheckpoint = flags.checkpoint === "summary-confirmation";
const planCheckpoint = flags.checkpoint === "plan-approval";
if (
summaryCheckpoint &&
flags.details !== "Looks correct" &&
flags.details !== "Request changes"
) {
error(
`Cannot record the summary choice because reply ${formatReceivedReply(flags.details)} ` +
'did not match an offered option. Present "Looks correct" and ' +
'"Request changes". Re-present those choices and wait for the human to choose one.',
);
}
if (
planCheckpoint &&
flags.details !== "Approve Plan" &&
flags.details !== "Request Changes"
) {
error(
`Refusing to record Plan Approval: received reply ${formatReceivedReply(flags.details)}. ` +
'Valid choices are "Approve Plan" or "Request Changes".',
);
}
// Break-glass (human only). The flag alone authorizes nothing: half A is the
// typed phrase the human-turn hook recorded for this session, checked below
// under the lock. Here only the shape is validated.
const overrideReason = flags.override?.trim() ?? null;
if (overrideReason !== null) {
if (!planCheckpoint) {
error("--override applies only to --checkpoint plan-approval.");
}
if (overrideReason.length === 0) {
error(
"Plan Approval override requires a nonblank --override reason: the reason the " +
"human typed after `Override Plan Approval:`, verbatim.",
);
}
if (flags.details !== "Approve Plan") {
error('Plan Approval override applies only to --details "Approve Plan".');
}
}
// A cancelled/dismissed/auto-resolved question widget is not an answer.
// Some harnesses return a completed-looking object for a dismissed question.
if (isNonAnswer(flags.details)) {
error(
`Cannot record reply ${formatReceivedReply(flags.details)} because it represents a ` +
`dismissed question, not a human answer. Re-present the question and wait for a real ` +
`response before trying again.`,
);
}
const pd = resolveActiveProjectDir(projectDir);
if (flags.unit) validateLiveUnitScope(pd, flags.unit);
const summaryEvidence = summaryCheckpoint
? summaryQuestionEvidence(pd, flags, flags.details)
: null;
let planEvidence: ReturnType<
typeof codeGenerationPlanApprovalQuestionEvidence
> | null = null;
const fields: Record<string, string> = {
Stage: flags.stage,
Details: flags.details,
};
if (summaryCheckpoint) {
fields.Checkpoint = SUMMARY_CONFIRMATION_CHECKPOINT;
fields["Questions File"] = summaryEvidence!.relativePath;
fields["Questions SHA-256"] = summaryEvidence!.sha256;
fields["Hash Scope"] = SUMMARY_CONFIRMATION_HASH_SCOPE;
}
if (flags.unit) {
fields.Unit = flags.unit;
Object.assign(fields, claimAttemptFields(pd, flags.unit));
}
if (flags.single === "true") fields.Workflow = `single-stage:${flags.stage}`;
if (planCheckpoint) {
const session = flags.session?.trim();
if (!session) {
error(
"Plan Approval requires --session <id> from the invoking SessionStart context.",
);
}
fields.Session = session;
// Half A of the break-glass pairing is checked before anything else is
// read and before any lock is held: without the human's typed request the
// only answer is the human-only guidance, whatever else the plan or its
// answer look like. The intent binding and the consumption happen again
// inside the receipt transaction, where the evidence names the intent.
if (
overrideReason !== null &&
authorizingPlanApprovalOverrideRequest(pd, session, overrideReason, null) === null
) {
error(PLAN_APPROVAL_OVERRIDE_HUMAN_ONLY);
}
}
// Classification and emission run under ONE audit lock: a concurrent
// gate-start (itself locked) cannot flip the stage to [?] between the
// checkbox read below and the QUESTION_ANSWERED append, which would
// re-create the answer-consumes-the-turn deadlock this branch prevents.
// appendAuditEntry / emitError re-acquire reentrantly (per-pd depth).
withAuditLock(pd, () => {
if (planCheckpoint) {
planEvidence = codeGenerationPlanApprovalQuestionEvidence(
pd,
planApprovalTarget(flags),
flags["questions-file"] ?? "",
flags.details as "Approve Plan" | "Request Changes",
{ breakGlass: overrideReason !== null },
);
Object.assign(fields, planApprovalFields(planEvidence));
}
// Human-presence gate (ledger-event design): the interview answer is
// a human-judgement event, so require a HUMAN_TURN appended AFTER the last
// QUESTION_ANSWERED (ledger order) before recording another. The prior
// QUESTION_ANSWERED is the "since" boundary (its own consume-once: one human turn
// logs one answer), so no separate marker/consume step is needed. Autonomy
// carve-out FIRST (Construction swarm/Bolt answers are not human), then the scoped
// test off-switch. Fail-open when no ledger exists (presence not tracked yet).
const content = existsSync(stateFilePath(pd))
? readFileSync(stateFilePath(pd), "utf-8")
: null;
const stageNode = loadStageGraphAll().find((stage) => stage.slug === flags.stage);
const autonomousDecision = isAutonomousConstructionDecision(content, stageNode?.phase);
const workflow =
flags.single === "true" ? `single-stage:${flags.stage}` : undefined;
// Authorship floor (issue 742): the same interview answer the conductor
// wrote for itself. isNonAnswer above rejects a DISMISSED widget; this
// rejects a self-attributed one ("A. Nothing to add - CONDUCTOR DEFAULT,
// session unattended"), which the presence check below cannot catch because
// a human is in the session, just not at this question. Autonomous
// Construction is exempt for ordinary answers. Summary confirmation remains
// a human-backed checkpoint below: its fresh-turn requirement is not waived
// by Construction autonomy even though its text is one of two exact strings.
const answerAuthorship =
autonomousDecision || humanPresenceGuardDisabled()
? null
: selfAttributedDecisionMarker(flags.details, "answer");
if (answerAuthorship) {
error(
`Cannot record this answer for "${flags.stage}" because --details says it was ` +
`chosen by the assistant (${answerAuthorship.category}: "${answerAuthorship.phrase}"). ` +
`This question must be answered by the human. Re-present it and wait for their reply.`,
);
}
if (summaryCheckpoint) {
const pending = pendingSummaryDecision(
pd,
flags.stage,
flags.unit,
workflow,
summaryEvidence!.relativePath,
);
if (pending.ambiguity !== undefined) {
error(
"Refusing to record summary confirmation: matching prompt, response, " +
`or run-boundary events share audit Timestamp "${pending.ambiguity}" ` +
"across different shards, so a human response after this prompt cannot " +
"be proven. Present a fresh summary prompt after that second, end the " +
"turn, and record the human's new response.",
);
}
if (!pending.pending) {
error(
"Cannot record the summary choice because no matching unanswered summary question " +
"exists for this stage and work item. Record the question before presenting it, then " +
"wait for the human's choice.",
);
}
if (
!humanPresenceGuardDisabled() &&
(!pending.humanAfterDecision || !humanActedSinceLastAnswer(pd))
) {
error(
"Cannot record the summary choice because no human reply has arrived after this "
+ "question, or that turn was already used by another decision. End the turn, "
+ `wait for the human's choice, then try again.${unattendedHumanPresenceHint()}`,
);
}
// The confirmation authorizes the outputs generated from it. Mint the
// authorization id from the attempt, the scope, and the confirmed content
// (identical confirmations mint the same id; changed answers a new one),
// record it on the receipt, and make it the active authorization for the
// scope so every later write of a stage output is stamped with it. A
// "Request changes" reply withdraws the active authorization instead.
const unitMajor =
stageNode !== undefined &&
isPerUnitStage(stageNode) &&
getField(content ?? "", "Construction Iteration")?.trim() === "unit-major";
const floors = summaryAttemptFloors(
readAuditShardEvents(pd).filter((entry) => SUMMARY_EVIDENCE_EVENTS.has(entry.event)),
flags.stage,
workflow,
unitMajor,
);
const authorization: SummaryAuthorization = {
version: 1,
id: "",
stage: flags.stage,
unit: flags.unit ?? null,
workflow: workflow ?? null,
attempt: summaryAttemptIdentity(floors),
questions_file: summaryEvidence!.relativePath,
questions_sha256: summaryEvidence!.sha256,
choice: flags.details,
recorded_at: isoTimestamp(),
};
authorization.id = summaryAuthorizationId({
attempt: authorization.attempt,
stage: authorization.stage,
unit: authorization.unit,
workflow: authorization.workflow,
questionsFile: authorization.questions_file,
questionsSha256: authorization.questions_sha256,
choice: authorization.choice,
});
const positive = flags.details === "Looks correct";
if (positive) fields[SUMMARY_AUTHORIZATION_FIELD] = authorization.id;
// Registry first, receipt second, both under the audit lock. A registry
// that cannot be written (a redirected `.aidlc-summary-authorization`, a
// file where the stage directory belongs, a full disk) refuses the answer
// BEFORE any receipt exists, so the pending question and the human's turn
// are still there for a retry once the cause is fixed. If the receipt
// then fails to append, the registry is restored to what it was, so no
// authorization exists without the receipt that minted it.
let previousRegistry: Buffer | null = null;
try {
const target = summaryAuthorizationTargetOrThrow(pd, authorization.stage, authorization.unit);
previousRegistry = lstatExists(target)
? readRegularFileNoFollowOrThrow(target, "summary authorization", 64 * 1024)
: null;
if (positive) {
writeSummaryAuthorization(pd, authorization);
} else {
clearSummaryAuthorization(pd, authorization.stage, authorization.unit);
}
} catch (e) {
error(
`Cannot record the summary choice: its authorization record could not be ` +
`saved (${errorMessage(e)}). Nothing was recorded; the pending question is ` +
"still answerable. Repair the record directory and run this command again.",
);
}
try {
emitAudit(pd, "SUMMARY_CONFIRMATION_RECORDED", fields);
} catch (e) {
// Restore the registry to its exact prior bytes (or absence) so no
// authorization exists without the receipt that minted it. A restore
// that itself fails is part of the report: the registry is then in a
// state the human must know about before retrying.
let rollback = "";
try {
const registryRelative = summaryAuthorizationRelativePath(
authorization.stage,
authorization.unit,
);
if (previousRegistry === null) {
removeRecordFileNoFollow(recordDir(pd) as string, registryRelative);
} else {
writeRecordFileNoFollow(recordDir(pd) as string, registryRelative, previousRegistry);
}
} catch (restoreError) {
rollback =
` The authorization record could not be restored either (${errorMessage(restoreError)}); ` +
`remove ${summaryAuthorizationRelativePath(authorization.stage, authorization.unit)} ` +
"under the intent record before retrying.";
}
error(`Audit emission failed: ${errorMessage(e)}.${rollback}`);
}
console.log(
JSON.stringify({
emitted: "SUMMARY_CONFIRMATION_RECORDED",
checkpoint: "summary-confirmation",
stage: flags.stage,
...(positive ? { summary_authorization_id: authorization.id } : {}),
}),
);
return;
}
if (planCheckpoint && overrideReason !== null) {
// Half B of the break-glass pairing. Autonomous Construction has no human
// at the keyboard, so nobody could have typed the phrase.
if (autonomousDecision) {
error(
"Plan Approval override is refused under autonomous Construction mode: " +
"the break-glass phrase must be typed by a human in an attended session.",
);
}
// Effects, in order: the typed request is re-validated against the
// evidence's intent and consumed inside the receipt transaction; the
// normal path is attempted; when it refuses, the PLAN_APPROVAL_OVERRIDDEN
// row is appended BEFORE the override receipt is written (a ledger that
// cannot take the row leaves no receipt and spends no request); then the
// receipt; then PLAN_APPROVAL_RECORDED with Override: yes.
let recorded: PlanApprovalOverrideReceiptResult;
try {
recorded = recordPlanApprovalOverrideReceipt(
pd,
planEvidence!,
fields.Session,
overrideReason,
(failedChecks) => {
emitAudit(pd, "PLAN_APPROVAL_OVERRIDDEN", {
Stage: flags.stage,
Reason: overrideReason,
"Failed Checks": failedChecks.join(" | "),
Session: fields.Session,
Unit: flags.unit ?? "stage-level",
Fingerprint: planEvidence!.fingerprint,
});
},
);
} catch (e) {
if (e instanceof PlanApprovalOverrideHumanOnlyError) error(e.message);
error(`Refusing to record Plan Approval override: ${errorMessage(e)}`);
}
const changeNotices = [...planEvidence!.changeNotices, ...recorded.changeNotices];
if (recorded.overridden) fields.Override = "yes";
try {
emitAudit(pd, "PLAN_APPROVAL_RECORDED", fields);
} catch (e) {
error(`Audit emission failed: ${errorMessage(e)}`);
}
console.log(
JSON.stringify({
emitted: "PLAN_APPROVAL_RECORDED",
checkpoint: "plan-approval",
stage: flags.stage,
override: recorded.overridden,
...(recorded.overridden
? { failed_checks: recorded.failedChecks }
: { note: "the normal receipt path succeeded, so the override was not needed and nothing was overridden" }),
...(changeNotices.length > 0 ? { change_notices: changeNotices } : {}),
}),
);
return;
}
if (planCheckpoint) {
// The evidence recorded any accepted drift before the receipt is
// certified; certification may accept and record a later move of its own.
const changeNotices = [...planEvidence!.changeNotices];
try {
const recorded = recordPlanApprovalReceipt(
pd,
planEvidence!,
fields.Session,
flags.details as "Approve Plan" | "Request Changes",
);
changeNotices.push(...recorded.changeNotices);
} catch (e) {
if (e instanceof PlanApprovalSourceDriftError) {
console.error(JSON.stringify({ remedy: e.remedy }));
error(e.message);
}
if (e instanceof PlanApprovalUnbindableError) {
console.error(JSON.stringify({ code: e.code, remedies: e.remedies }));
error(e.message);
}
error(`Refusing to record Plan Approval: ${errorMessage(e)}`);
}
try {
if (flags.details === "Approve Plan") {
emitAudit(pd, "PLAN_APPROVAL_RECORDED", fields);
} else {
emitAudit(pd, "QUESTION_ANSWERED", fields);
}
} catch (e) {
error(`Audit emission failed: ${errorMessage(e)}`);
}
console.log(
JSON.stringify({
emitted:
flags.details === "Approve Plan"
? "PLAN_APPROVAL_RECORDED"
: "QUESTION_ANSWERED",
checkpoint: "plan-approval",
stage: flags.stage,
...(changeNotices.length > 0 ? { change_notices: changeNotices } : {}),
}),
);
return;
}
// Approval choices are lifecycle transitions, not interview answers. A
// conductor may nevertheless route an approval through `answer` before
// `report`; emitting QUESTION_ANSWERED here would consume the same
// HUMAN_TURN that approval needs. When the target stage is at [?] and no
// unresolved non-gate decision was recorded after the gate opened,
// acknowledge without emitting so the report command can commit the gate.
// The human-presence requirement is NOT waived: a redundant answer with no
// fresh HUMAN_TURN refuses, so a fabricated `answer && report rejected`
// chain (reject carries no presence guard of its own) breaks at the answer.
const targetAtApprovalGate =
content !== null &&
parseCheckboxes(content).some(
(checkbox) =>
checkbox.slug === flags.stage &&
checkbox.state === "awaiting-approval",
);
const pendingDecision =
targetAtApprovalGate && hasPendingDecisionAtGate(pd, flags.stage);
if (targetAtApprovalGate && !pendingDecision) {
if (
!autonomousDecision &&
!humanPresenceGuardDisabled() &&
!humanActedSinceLastAnswer(pd)
) {
error(
"Cannot record this approval choice because no new human reply has arrived. "
+ "After the human types their choice, use aidlc-orchestrate.ts report --result "
+ "approved or rejected; do not use aidlc-log.ts answer for an approval."
+ unattendedHumanPresenceHint(),
);
}
console.log(
JSON.stringify({
skipped: "QUESTION_ANSWERED",
stage: flags.stage,
reason: "approval-gate-report-owned",
}),
);
return;
}
if (autonomousDecision) {
// autonomous Construction: no human presence required
} else if (humanPresenceGuardDisabled()) {
// scoped test off-switch
} else if (!humanActedSinceLastAnswer(pd)) {
error(
"Cannot record this answer because no new human reply has arrived for the question. "
+ "Wait for the human to type an answer, then try again."
+ unattendedHumanPresenceHint(),
);
}
try {
emitAudit(pd, "QUESTION_ANSWERED", fields);
} catch (e) {
error(`Audit emission failed: ${errorMessage(e)}`);
}
console.log(
JSON.stringify({ emitted: "QUESTION_ANSWERED", stage: flags.stage })
);
});
}
// --- Subcommand: link ---
// Usage:
// aidlc-log link --stage <slug> --link <agent> [--repo <repo>]
// [--artifact <path>] [--single]
// → PIPELINE_LINK_COMPLETED
//
// The receipt is emitted only after a declared pipeline link returns. Ordering,
// duplicate prevention, and attempt freshness are checked under the audit lock
// so two concurrent conductors cannot advance the same chain.
function handleLink(args: string[]): void {
const { flags } = parseFlags(args);
if (!flags.stage) error("Missing --stage <slug>");
if (!flags.link) error("Missing --link <agent>");
if (flags.intent || flags.space) {
error(
"The link command does not accept --intent/--space selectors. Switch to the target workspace first.",
);
}
const pd = resolveActiveProjectDir(projectDir);
const selection = resolveWorkflowSelection(pd);
const space = selection.space;
const intent = selection.intent;
if (!intent) {
error("Cannot resolve the active intent for pipeline link logging.");
}
const singleRun = flags.single === "true";
try {
withAuditLock(pd, () => {
const node = loadStageGraphAll().find((stage) => stage.slug === flags.stage);
if (node?.mode !== "pipeline") {
throw new Error(
`Cannot record pipeline link: stage "${flags.stage}" is not mode: pipeline.`,
);
}
const links = pipelineLinks(node);
const index = links.indexOf(flags.link);
if (index === -1) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": "${flags.link}" is not in its declared lead/support chain (${links.join(", ")}).`,
);
}
const evidence = pipelineLinkEvidence(pd, node, { singleRun });
if (evidence.repos.length > 0) {
if (!flags.repo) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": this intent records repository identity; pass --repo <repo>.`,
);
}
if (!evidence.repos.includes(flags.repo)) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": repo "${flags.repo}" is not registered for this intent (${evidence.repos.join(", ")}).`,
);
}
} else if (flags.repo) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": this intent has no registered repo identity; omit --repo.`,
);
}
const repo = flags.repo ?? null;
if (evidence.receipts.some((receipt) =>
receipt.link === flags.link && receipt.repo === repo
)) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": link "${flags.link}"` +
`${repo ? ` for repo "${repo}"` : ""} already completed this attempt.`,
);
}
if (index > 0) {
const previous = links[index - 1];
const previousCompleted = evidence.receipts.some((receipt) =>
receipt.link === previous && receipt.repo === repo
);
if (!previousCompleted) {
throw new Error(
`Cannot record pipeline link for "${flags.stage}": "${flags.link}" is out of order; ` +
`position ${index + 1}/${links.length} requires current-attempt receipt for "${previous}"` +
`${repo ? ` in repo "${repo}"` : ""}.`,
);
}
}
const fields: Record<string, string> = {
Stage: flags.stage,
Link: flags.link,
Position: `${index + 1}/${links.length}`,
};
if (
flags.stage === "reverse-engineering" &&
flags.link === node.lead_agent
) {
if (!flags.artifact) {
throw new Error(
'Cannot record reverse-engineering developer link: pass --artifact "<record>/inception/reverse-engineering/developer-scan[-<repo>].md".',
);
}
const root = recordDir(pd);
if (root === null) {
throw new Error(
"Cannot record reverse-engineering developer link: active intent record is unavailable.",
);
}
const expected = join(
root,
"inception",
"reverse-engineering",
repo ? `developer-scan-${repo}.md` : "developer-scan.md",
);
const artifact = resolve(pd, flags.artifact);
if (artifact !== expected) {
throw new Error(
`Cannot record reverse-engineering developer link: --artifact must resolve to ${toPosix(relative(pd, expected))}.`,
);
}
if (!existsSync(artifact)) {
throw new Error(
`Cannot record reverse-engineering developer link: handoff file does not exist: ${flags.artifact}.`,
);
}
let artifactBytes: Buffer;
let artifactMtimeMs: number;
try {
const guardedArtifact = assertNoSymlinkInChainOrThrow(
realpathSync(pd),
relative(pd, artifact),
);
const snapshot = readRegularFileNoFollowOrThrow(
guardedArtifact,
"reverse-engineering developer handoff",
undefined,
guardedArtifact,
true,
);
artifactBytes = snapshot.bytes;
artifactMtimeMs = snapshot.mtimeMs;
} catch (error) {
throw new Error(
`Cannot record reverse-engineering developer link: handoff file must be a regular file with no symlink path components (${errorMessage(error)}).`,
);
}
const attemptStartedAt = pipelineAttemptStartedAt(
pd,
flags.stage,
{ singleRun },
);
if (
attemptStartedAt === "" ||
artifactMtimeMs < Date.parse(attemptStartedAt)
) {
throw new Error(
`Cannot record reverse-engineering developer link: ${toPosix(relative(pd, artifact))} was not written in the current stage attempt.`,
);
}
const previousMtime = latestPipelineLinkArtifactMtime(
pd,
flags.stage,
flags.link,
repo,
{ singleRun },
);
if (
previousMtime !== null &&
artifactMtimeMs <= previousMtime
) {
throw new Error(
`Cannot record reverse-engineering developer link: ${toPosix(relative(pd, artifact))} was not rewritten after its prior pipeline receipt.`,
);
}
const digest = createHash("sha256")
.update(artifactBytes)
.digest("hex");
fields["Artifact Path"] = toPosix(relative(pd, artifact));
fields["Artifact SHA256"] = `sha256:${digest}`;
fields["Artifact Mtime Ms"] = String(artifactMtimeMs);
}
if (repo) fields.Repo = repo;
if (singleRun) fields.Workflow = `single-stage:${flags.stage}`;
emitAudit(pd, "PIPELINE_LINK_COMPLETED", fields, intent, space);
}, intent, space);
} catch (e) {
error(errorMessage(e));
}
console.log(JSON.stringify({
emitted: "PIPELINE_LINK_COMPLETED",
stage: flags.stage,
link: flags.link,
...(flags.repo ? { repo: flags.repo } : {}),
...(singleRun ? { single: true } : {}),
}));
}
// --- Subcommand: review ---
// Usage:
// aidlc-log review --stage <slug> --reviewer <agent> [--unit <u>] --iteration <n>
// → REVIEW_REQUESTED (fires when the conductor dispatches the reviewer)
// aidlc-log review --stage <slug> --reviewer <agent> [--unit <u>] --iteration <n> --verdict <READY|NOT-READY>
// → REVIEW_COMPLETED (fires when the conductor reads the reviewer's verdict)
//
// The §12a reviewer step is otherwise prose-driven; these tool-actor rows make
// it observable and let the engine enforce that a reviewer-bearing stage cannot
// be approved without a terminal REVIEW_COMPLETED (see verifyReviewerPrecondition
// in aidlc-state.ts). On a per-unit Construction stage the reviewer fires once
// PER UNIT, so pass --unit; the approve guard requires one review per unit.
const VALID_VERDICTS = new Set(["READY", "NOT-READY"]);
function reviewBudgetMessage(stage: string, ordinal: number, budget: number): string {
return (
`Cannot request review pass ${ordinal} for "${stage}" because this stage allows ` +
`${budget} review pass${budget === 1 ? "" : "es"}. ` +
(budget === 1
? "Do not ask the reviewer again; include the findings in the approval summary for the human."
: "Present the unresolved findings at the approval gate for the human instead of starting another review.")
);
}
export function reviewRecoverySpentMessage(
stage: string,
guidance: string | null,
autonomousBolt?: {
unit: string;
slug: string | null;
batch: string | null;
},
requestChangesResetValid = true,
): string {
const prefix =
`Cannot start another review for "${stage}": the one recovery review was ` +
"already used, and this stage's output document changed again afterward. ";
if (autonomousBolt) {
const slug = autonomousBolt.slug ?? autonomousBolt.unit;
const batch = autonomousBolt.batch
? ` batch ${autonomousBolt.batch}`
: " the current batch";
return (
prefix +
`Do not put autonomous Unit "${autonomousBolt.unit}" in --claimed and do ` +
"not run finalize or merge it. Halt and ask the human whether to restart " +
`the Bolt attempt. On an approved retry, return to the main workspace, run ` +
`\`aidlc-bolt.ts abort --name "${autonomousBolt.unit}" --slug "${slug}" ` +
`--reason "stale review recovery exhausted" --discard\`, then rerun the ` +
`current \`aidlc-swarm.ts prepare\` step for Unit "${autonomousBolt.unit}" in` +
`${batch} with the original base/repo arguments. The fresh Bolt attempt ` +
"restores one review allowance without claiming convergence. Do not " +
"record a Request Changes decision on the human's behalf."
);
}
return (
prefix +
(guidance ?? "") +
(requestChangesResetValid
? " Only a human Request Changes decision resets the review attempt; do not " +
"record that rejection on the human's behalf."
: "")
);
}
function reviewRecoveryGuidance(
projectDir: string,
stateContent: string,
stage: string,
unit?: string,
teamGate?: TeamUnitGateResolution,
): string {
try {
return recoveryGuidance(projectDir, stateContent, stage, {
...(unit ? { unit } : {}),
...(teamGate ? { teamGate } : {}),
});
} catch {
return (
`Restart this stage cleanly with /aidlc --stage ${stage}, then confirm ` +
"its summary and review the finished output again."
);
}
}
function reviewSummaryEvidenceMessage(stage: string, message: string): string {
const cause = message.replace(
/^Refusing to (?:complete|continue) "[^"]+"(?: for unit "[^"]+")?:\s*/,
"",
);
return `Cannot start review for "${stage}": ${cause}`;
}
function reviewRecoveryAlreadyRequestedMessage(
stage: string,
iteration: number,
guidance: string,
requestChangesResetValid: boolean,
): string {
return (
`Cannot request another recovery review for "${stage}" because one already exists ` +
`in this review attempt. If the reviewer has not returned, retry iteration ${iteration} ` +
`with --retry-pending. If its verdict was recorded, ${guidance}` +
(requestChangesResetValid
? " Only a human Request Changes decision resets the review attempt; do not record " +
"that rejection on the human's behalf."
: "")
);
}
class ReviewRefusal extends Error {}
function refuseReview(message: string): never {
throw new ReviewRefusal(message);
}
function refuseReviewGuard(
projectDir: string,
refusal: GuardRefusal,
attempt: GuardAttemptState,
resources: string[] = [],
): never {
refuseReview(guardRefusalOutput(projectDir, refusal, attempt, resources));
}
function handleReview(args: string[]): void {
const { flags } = parseFlags(args);
if (!flags.stage) error("Missing --stage <slug>");
if (!flags.reviewer) error("Missing --reviewer <agent>");
if (flags.intent || flags.space) {
error(
"The review command does not accept --intent/--space selectors. Switch to the target workspace first.",
);
}
const pd = resolveActiveProjectDir(projectDir);
if (flags.unit) validateLiveUnitScope(pd, flags.unit);
const selection = resolveWorkflowSelection(pd);
const space = selection.space;
const intent = selection.intent;
if (!intent) {
error("Cannot resolve the active intent for review logging.");
}
const fields: Record<string, string> = {
Stage: flags.stage,
Reviewer: flags.reviewer,
};
if (flags.unit) {
fields.Unit = flags.unit;
Object.assign(fields, claimAttemptFields(pd, flags.unit));
}
if (flags.single === "true") fields.Workflow = `single-stage:${flags.stage}`;
const retryPending = flags["retry-pending"] === "true";
const loadContext = (
scanReceipts: boolean,
enforceAdmissibility = true,
) => {
const state = readStateFile(pd, intent, space);
const node = loadStageGraphAll().find((stage) => stage.slug === flags.stage);
if (!node?.reviewer) {
refuseReview(`Cannot record review: stage "${flags.stage}" has no declared reviewer.`);
}
if (!node.review_artifact) {
refuseReview(
`Cannot record review: stage "${flags.stage}" has no declared review_artifact.`,
);
}
if (flags.reviewer !== node.reviewer) {
refuseReview(
`Cannot record review for "${flags.stage}": reviewer "${flags.reviewer}" ` +
`does not match the declared reviewer "${node.reviewer}".`,
);
}
if (flags.unit && node.for_each !== "unit-of-work") {
refuseReview(`Stage "${flags.stage}" is not per-unit; remove --unit.`);
}
const autonomousCandidate =
flags.unit !== undefined && isAutonomousSwarmStage(pd, state, node);
const unitResolution =
node.for_each === "unit-of-work" ? resolveBoltDag(pd, intent, space) : null;
const attemptWindow = reviewAttemptWindow(pd, state, node);
const attempt = reviewAttemptAccounting(
pd,
attemptWindow,
state,
node,
flags.reviewer,
flags.unit,
fields.Workflow,
{
eventFilter: (row) =>
reviewAttemptEventMatchesCurrentClaim(
pd,
state,
flags.unit,
row,
),
},
);
const mergedBoltUnits = attemptWindow.mergedBoltUnits;
if (enforceAdmissibility && flags.unit) {
const resolution = unitResolution ?? resolveBoltDag(pd, intent, space);
if (resolution.state === "malformed") {
refuseReview(
`Cannot record review for "${flags.stage}" unit "${flags.unit}": the authoritative ` +
`unit DAG is ${resolution.reason} (${resolution.detail}). Fix ` +
"unit-of-work-dependency.md before recording a per-unit review.",
);
}
if (
resolution.state === "none" &&
!attempt.boltStarted &&
!attemptWindow.mergedBoltUnits.has(flags.unit)
) {
refuseReview(
`Cannot record review for "${flags.stage}" unit "${flags.unit}": no authoritative ` +
"unit DAG exists and no matching active or merged Bolt attempt was found. Run " +
`\`aidlc-bolt.ts start --name "${flags.unit}" --batch 1\` before retrying this ` +
"per-unit review, or remove --unit and record a stage-level no-DAG review.",
);
}
if (resolution.state === "ok" && !resolution.units.includes(flags.unit)) {
refuseReview(
`Cannot record review for "${flags.stage}" unit "${flags.unit}": it is not present ` +
`in the authoritative unit DAG (${resolution.units.join(", ")}).`,
);
}
if (
resolution.state === "ok" &&
filterProducesByKind(
node.produces_kinds,
node.produces ?? [],
resolution.unitKinds?.get(flags.unit) ?? null,
).length === 0
) {
refuseReview(
`Cannot record review for "${flags.stage}": unit "${flags.unit}" has no applicable required outputs for its kind.`,
);
}
}
const declared = node.review_class ?? "adversarial";
if (attempt.ambiguity !== null) {
refuseReview(
`Cannot record review for "${flags.stage}": ${attempt.ambiguity} makes the current review attempt chronology ambiguous. Record a fresh stage/jump boundary, then request the review again.`,
);
}
let reviewClass: ReviewClass | null = null;
let budget: number | null = null;
if (autonomousCandidate && attempt.boltStarted) {
reviewClass = declared;
budget =
reviewClass === "advisory"
? 1
: node.reviewer_max_iterations ?? 2;
} else {
try {
reviewClass = resolveReviewClass(
declared,
getField(state, "Scope") ?? "",
state,
);
if (reviewClass === "none") budget = 0;
else if (reviewClass === "advisory") budget = 1;
else budget = node.reviewer_max_iterations ?? 2;
} catch {
// Class resolution fails open; ordinal enforcement remains active.
}
}
// Receipt freshness is needed only while minting REVIEW_REQUESTED (to
// classify bounded stale-receipt recovery). REVIEW_COMPLETED consumes only
// node + attempt and then performs its one authoritative source-state walk
// while stamping; scanning here would double-walk on every re-review.
const receipts =
!scanReceipts || reviewClass === null
? null
: freshReviewReceipts(pd, state, node, {
reviewClass,
attemptWindow,
selection: { intent, space },
});
const requireRequiredArtifacts =
process.env.AIDLC_SKIP_ARTIFACT_GUARD !== "1" &&
!(
unitResolution !== null &&
unitResolution.state !== "ok" &&
flags.unit === undefined &&
mergedBoltUnits.size === 0
);
return {
state,
node,
attempt,
budget,
receipts,
autonomousCandidate,
requireRequiredArtifacts,
unitResolution,
mergedBoltUnits,
};
};
const stampRequestedSourceBinding = (
node: ReturnType<typeof loadStageGraphAll>[number],
): void => {
if (!node.workspace_requires) return;
const sourceState = workspaceSourceState(pd, intent, space);
fields["Source Fingerprint"] =
sourceState?.fingerprint ?? UNBINDABLE_FINGERPRINT;
const bindsUnitSource =
flags.unit !== undefined &&
node.for_each === "unit-of-work" &&
flags.single !== "true";
if (!bindsUnitSource) return;
const manifest = readUnitSourceManifest(
pd,
flags.stage as string,
flags.unit as string,
);
if (!manifest.ok) {
const manifestPath = `${relativeRecordDir(pd, intent, space) ?? "aidlc"}/construction/${flags.unit}/${flags.stage}/source-manifest.json`;
refuseReview(
`Cannot record REVIEW_REQUESTED for "${flags.stage}": unit "${flags.unit}" has no valid source manifest at ` +
`${manifestPath} (${manifest.reason}). Write the manifest listing every application-source path ` +
"the reviewer will inspect, then dispatch the review.",
);
}
fields["Unit Source Fingerprint"] =
sourceState === null
? UNBINDABLE_FINGERPRINT
: writeUnitSourceSnapshot(
pd,
flags.stage as string,
flags.unit as string,
sourceState.listing,
manifest,
manifest.rawBytesSha256,
);
};
const mintReviewRequestId = (): string =>
`review:${randomBytes(16).toString("hex")}`;
// The reviewer writes its review into this slot; the verdict consumes it into
// the record. Project-relative so the conductor can hand it to the reviewer.
const reviewSlot = (
floor: string,
iteration: number,
): {
draftRelative: string;
draftRelativeToRecord: string;
recordRelative: string;
} => {
const record = recordDir(pd);
if (record === null) refuseReview("Cannot resolve the active intent record.");
const attemptId = reviewAttemptId(floor);
const draft = reviewDraftRelativePath(flags.stage as string, flags.unit, attemptId, iteration);
return {
draftRelative: toPosix(relative(pd, join(record, ...draft.split("/")))),
draftRelativeToRecord: draft,
recordRelative: reviewRecordRelativePath(flags.stage as string, flags.unit, attemptId, iteration),
};
};
// REVIEW_REQUESTED owns its ordinal: require a positive integer, count prior
// requests in the current attempt, and append under the same lock. This closes
// duplicate/missing-label bypasses and makes concurrent requests serialize.
if (flags.verdict === undefined) {
if (!flags.iteration || !/^[1-9][0-9]*$/.test(flags.iteration)) {
error("Starting a review requires --iteration <positive integer>.");
}
const iteration = Number(flags.iteration);
fields.Iteration = flags.iteration;
let retried = false;
let upgraded = false;
let recovery: "stale-receipt" | undefined;
let requestId: string | null = null;
let reviewFile: string | null = null;
const requestChangeNotices: string[] = [];
// Open the reviewer's slot for this request: any draft an earlier dispatch of
// the same iteration left behind is not this dispatch's review.
const openReviewDraftSlot = (floor: string): void => {
const slot = reviewSlot(floor, iteration);
// Never through a symlinked `.aidlc-reviews`: a redirected slot is not
// this record's, so the request refuses instead of clearing a path
// outside the intent record.
try {
removeRecordFileNoFollow(recordDir(pd) as string, slot.draftRelativeToRecord);
} catch (e) {
refuseReview(
`Cannot start review for "${flags.stage}": the review slot ` +
`${slot.draftRelativeToRecord} cannot be opened (${errorMessage(e)}).`,
);
}
reviewFile = slot.draftRelative;
};
try {
withAuditLock(pd, () => {
const {
state,
node,
attempt,
budget,
receipts,
autonomousCandidate,
requireRequiredArtifacts,
unitResolution,
mergedBoltUnits,
} = loadContext(true, !retryPending);
const pendingStatus = pendingReviewRequestStatus(
pd,
node,
flags.unit,
attempt,
{
requireRequiredArtifacts,
boltDag: unitResolution ?? undefined,
mergedBoltUnits,
single: flags.single === "true",
},
);
const teamGate = teamUnitGateStatus(
pd,
state,
node.slug,
flags.unit,
);
// The review request is a governed Change Control checkpoint when the
// receipt scan or the summary check met an input change after an
// approval and read the setting: resolve it again here as the mutating
// caller (an invalid memory value is its own error; a memory edit that
// moved it is recorded), then record what they accepted under relaxed.
// The human line rides on this command's JSON.
const summaryEvidence = checkSummaryConfirmationEvidence(pd, node, {
stateContent: state,
unit: flags.unit,
workflow: fields.Workflow,
selection: { intent, space },
});
if (receipts?.changeControlRead || summaryEvidence.changeControlRead) {
governedChangeControl(pd, state, { intent, space });
requestChangeNotices.push(
...recordAcceptedChanges(
pd,
[
...(receipts?.acceptedChanges ?? []),
...(summaryEvidence.ok ? summaryEvidence.acceptedChanges ?? [] : []),
],
{ intent, space },
),
);
}
if (!summaryEvidence.ok) {
const message = reviewSummaryEvidenceMessage(
flags.stage,
summaryEvidence.message,
);
const guardAttempt = guardAttemptState(pd, state, node, {
...(flags.unit ? { unit: flags.unit } : {}),
...(receipts ? { receipts } : {}),
summaryCoverage: summaryEvidence.summaryCoverage,
reviewBudget: budget,
pendingStatus,
accounting: attempt,
}).attempt;
const evaluated = evaluateGuardRefusal({
code: summaryEvidence.refusal?.code ?? "SUMMARY_EVIDENCE_INVALID",
blockedAction: "review-request",
stage: flags.stage,
...(flags.unit ? { unit: flags.unit } : {}),
stateContent: state,
invariant:
summaryEvidence.refusal?.invariant ??
"A review starts only after current human-backed summary authorization.",
userMessage: message,
attempt: guardAttempt,
humanAuthority: humanAuthorityState(pd),
...(teamGate ? { teamGate } : {}),
});
const refusal = summaryEvidence.refusal === undefined
? evaluated
: {
...summaryEvidence.refusal,
blockedAction: "review-request",
state: evaluated.state,
userMessage: message,
remedies: evaluated.remedies,
};
refuseReviewGuard(pd, refusal, guardAttempt);
}
const expected = attempt.requestCount + 1;
const sameSourceRecoveryScope =
receipts?.newestSourceUnit === (flags.unit ?? null);
const sourceScopeStale =
sameSourceRecoveryScope && receipts?.sourceStale === true;
const artifactScopeStale =
receipts !== null &&
(flags.unit
? receipts.unitStale.has(flags.unit)
: receipts.stageStale);
const scopeStale =
process.env.AIDLC_SKIP_SOURCE_FRESHNESS !== "1" &&
fields.Workflow === undefined &&
receipts !== null &&
(sourceScopeStale || artifactScopeStale);
const sourceRecoverySpent =
sourceScopeStale &&
(receipts?.sourceRecoverySpent === true ||
receipts?.sourceStaleProgress?.recoverySpent === true);
const recoverySpent =
attempt.recoverySpent || sourceRecoverySpent;
const refuseAttemptGuard = (
code: string,
invariant: string,
message: string,
): never => {
const guardAttempt = guardAttemptState(pd, state, node, {
...(flags.unit ? { unit: flags.unit } : {}),
...(receipts ? { receipts } : {}),
reviewBudget: budget,
pendingStatus,
accounting: attempt,
}).attempt;
const autonomousBolt =
autonomousCandidate && attempt.boltStarted && flags.unit
? {
unit: flags.unit,
slug: attempt.boltSlug,
batch: attempt.boltBatch,
}
: undefined;
const refusal = evaluateGuardRefusal({
code,
blockedAction: "review-request",
stage: flags.stage,
...(flags.unit ? { unit: flags.unit } : {}),
stateContent: state,
invariant,
userMessage: message,
attempt: guardAttempt,
humanAuthority: humanAuthorityState(pd),
...(teamGate ? { teamGate } : {}),
...(autonomousBolt ? { autonomousBolt } : {}),
});
refuseReviewGuard(pd, refusal, guardAttempt, [
`source:${receipts?.newestSourceFingerprint ?? "none"}`,
`artifact-stale:${artifactScopeStale}`,
]);
};
if (retryPending) {
const pendingRequest = attempt.pendingRequests.get(iteration);
if (!pendingRequest) {
if (scopeStale) {
if (recoverySpent) {
const message = reviewRecoverySpentMessage(
flags.stage,
autonomousCandidate && attempt.boltStarted
? null
: reviewRecoveryGuidance(
pd,
state,
flags.stage,
flags.unit,
teamGate,
),
autonomousCandidate && attempt.boltStarted && flags.unit
? {
unit: flags.unit,
slug: attempt.boltSlug,
batch: attempt.boltBatch,
}
: undefined,
requestChangesResetIsExecutable(
state,
flags.stage,
teamGate,
),
);
refuseAttemptGuard(
"REVIEW_RECOVERY_SPENT",
"The stale-receipt recovery slot is single-use within an attempt.",
message,
);
}
const unitArg = flags.unit ? ` --unit "${flags.unit}"` : "";
refuseReview(
`Cannot retry the prior review for "${flags.stage}" because it completed ` +
"before the stage output or project source changed. Start the one recovery " +
`pass with \`aidlc-log.ts review --stage "${flags.stage}" ` +
`--reviewer "${flags.reviewer}"${unitArg} --iteration ${expected}\`.`,
);
}
if (recoverySpent) {
const message = reviewRecoveryAlreadyRequestedMessage(
flags.stage,
attempt.recoveryIteration ?? iteration,
reviewRecoveryGuidance(
pd,
state,
flags.stage,
flags.unit,
teamGate,
),
requestChangesResetIsExecutable(
state,
flags.stage,
teamGate,
),
);
refuseAttemptGuard(
"REVIEW_RECOVERY_ALREADY_REQUESTED",
"Only one stale-receipt recovery request exists in an attempt.",
message,
);
}
refuseReview(
`Cannot retry review iteration ${iteration} for "${flags.stage}" because no ` +
`pending request with that number exists. Start the expected review pass instead.`,
);
}
const requestBinding = pendingRequest.binding;
if (requestBinding === null) {
refuseReview(
`Refusing review retry for "${flags.stage}": the original ` +
`REVIEW_REQUESTED iteration ${iteration} has no valid request ` +
"binding, so its authority cannot be recovered by rebaselining.",
);
}
if (pendingRequest.retried) {
refuseReview(
`Refusing review retry for "${flags.stage}": REVIEW_REQUESTED ` +
`iteration ${iteration} already used its one pending-request retry. ` +
"Do not dispatch it again; record the bounded incomplete-review " +
"NOT-READY fallback or start the next permitted review iteration.",
);
}
const snapshot = reviewArtifactSnapshot(pd, node, flags.unit, {
requireRequiredArtifacts,
boltDag: unitResolution ?? undefined,
mergedBoltUnits,
});
if (snapshot === null) {
refuseReview(
`Cannot retry review for "${flags.stage}": the declared artifact set ` +
"could not be captured as one stable snapshot. Restore regular " +
"artifact files and retry.",
);
}
if (
!reviewRequestArtifactsCurrent(requestBinding, snapshot) &&
!reviewAppendedAfterRequest(requestBinding, snapshot)
) {
refuseReview(
`Refusing review retry for "${flags.stage}": declared artifacts no ` +
`longer match the bytes from REVIEW_REQUESTED iteration ${iteration}. ` +
"A retry re-dispatches that exact request and cannot rebaseline changed " +
"content. Restore the requested artifact bytes before retrying.",
);
}
// A request written before review records, or before source binding on
// a workspace-writing stage, is modernized by this one retry: it gains a
// request id and the source fingerprints, keeps its artifact fingerprint,
// and is marked so the ledger shows the upgrade.
let legacyUpgrade =
requestBinding.requestId === null ||
(node.workspace_requires &&
requestBinding.sourceFingerprint === null) ||
(node.workspace_requires &&
flags.unit !== undefined &&
node.for_each === "unit-of-work" &&
flags.single !== "true" &&
requestBinding.unitSourceFingerprint === null);
if (node.workspace_requires) {
stampRequestedSourceBinding(node);
const currentSource = fields["Source Fingerprint"];
if (
requestBinding.sourceFingerprint !== null &&
currentSource !== requestBinding.sourceFingerprint
) {
refuseReview(
`Refusing review retry for "${flags.stage}": workspace source no ` +
`longer matches REVIEW_REQUESTED iteration ${iteration}. A retry ` +
"cannot rebaseline source changed while review was pending.",
);
}
const currentUnitSource = fields["Unit Source Fingerprint"];
if (
requestBinding.unitSourceFingerprint !== null &&
currentUnitSource !== requestBinding.unitSourceFingerprint
) {
refuseReview(
`Refusing review retry for "${flags.stage}": unit source or ` +
`source-manifest.json no longer matches REVIEW_REQUESTED ` +
`iteration ${iteration}. A retry cannot rebaseline changed unit source.`,
);
}
}
if (
requestBinding.legacyAppendix?.priorAppendix === true &&
requestBinding.legacyAppendix.challenge === null
) {
legacyUpgrade = true;
}
requestId = requestBinding.requestId ?? mintReviewRequestId();
fields.Retry = "pending-request";
fields["Artifact Fingerprint"] = requestBinding.artifactFingerprint;
fields["Request Id"] = requestId;
if (requestBinding.legacyAppendix !== null) {
// The retry pairs with the original request under the legacy matcher;
// echo its appendix binding unchanged.
Object.assign(
fields,
legacyReviewAppendixEchoFields(requestBinding.legacyAppendix),
);
}
if (requestBinding.sourceFingerprint !== null) {
fields["Source Fingerprint"] = requestBinding.sourceFingerprint;
}
if (requestBinding.unitSourceFingerprint !== null) {
fields["Unit Source Fingerprint"] =
requestBinding.unitSourceFingerprint;
}
if (requestBinding.recoveryCause !== null) {
fields["Recovery Cause"] = requestBinding.recoveryCause;
}
if (legacyUpgrade) {
fields.Upgrade = "legacy-request";
upgraded = true;
}
openReviewDraftSlot(attempt.floor);
emitAudit(pd, "REVIEW_REQUESTED", fields, intent, space);
retried = true;
return;
}
const recoveryEligible =
budget !== null &&
scopeStale &&
attempt.pendingIterations.size === 0 &&
!recoverySpent;
if (scopeStale && recoverySpent) {
const message = reviewRecoverySpentMessage(
flags.stage,
autonomousCandidate && attempt.boltStarted
? null
: reviewRecoveryGuidance(
pd,
state,
flags.stage,
flags.unit,
teamGate,
),
autonomousCandidate && attempt.boltStarted && flags.unit
? {
unit: flags.unit,
slug: attempt.boltSlug,
batch: attempt.boltBatch,
}
: undefined,
requestChangesResetIsExecutable(
state,
flags.stage,
teamGate,
),
);
refuseAttemptGuard(
"REVIEW_RECOVERY_SPENT",
"The stale-receipt recovery slot is single-use within an attempt.",
message,
);
}
if (recoverySpent) {
const message = reviewRecoveryAlreadyRequestedMessage(
flags.stage,
attempt.recoveryIteration ?? iteration,
reviewRecoveryGuidance(
pd,
state,
flags.stage,
flags.unit,
teamGate,
),
requestChangesResetIsExecutable(
state,
flags.stage,
teamGate,
),
);
refuseAttemptGuard(
"REVIEW_RECOVERY_ALREADY_REQUESTED",
"Only one stale-receipt recovery request exists in an attempt.",
message,
);
}
if (!recoveryEligible && budget !== null && iteration > budget) {
refuseAttemptGuard(
"REVIEW_BUDGET_EXHAUSTED",
"Review requests do not exceed the configured attempt budget.",
reviewBudgetMessage(flags.stage, iteration, budget),
);
}
if (!recoveryEligible && budget !== null && expected > budget) {
refuseAttemptGuard(
"REVIEW_BUDGET_EXHAUSTED",
"Review requests do not exceed the configured attempt budget.",
reviewBudgetMessage(flags.stage, expected, budget),
);
}
if (attempt.pendingIterations.size > 0) {
const pending = [...attempt.pendingIterations].sort((a, b) => a - b);
refuseAttemptGuard(
"REVIEW_VERDICT_PENDING",
"A review request receives its verdict before another request starts.",
`Cannot start another review for "${flags.stage}" because iteration ` +
`${pending.join(", ")} is still waiting for a verdict. Record that verdict, or ` +
"repeat the same iteration with --retry-pending if the reviewer did not run.",
);
}
if (iteration !== expected) {
refuseReview(
`Cannot start review iteration ${iteration} for "${flags.stage}" because the next ` +
`iteration is ${expected}. Retry with --iteration ${expected}.`,
);
}
if (recoveryEligible) {
fields.Recovery = "stale-receipt";
fields["Recovery Cause"] =
artifactScopeStale && sourceScopeStale
? "artifact+source"
: artifactScopeStale
? "artifact"
: "source";
recovery = "stale-receipt";
}
const snapshot = reviewArtifactSnapshot(pd, node, flags.unit, {
requireRequiredArtifacts,
boltDag: unitResolution ?? undefined,
mergedBoltUnits,
});
if (snapshot === null) {
refuseReview(
`Cannot start review for "${flags.stage}": a required output document ` +
"is missing or unreadable. Create every required output document " +
"for this stage, then retry the review.",
);
}
fields["Artifact Fingerprint"] = snapshot.fingerprint;
requestId = mintReviewRequestId();
fields["Request Id"] = requestId;
stampRequestedSourceBinding(node);
openReviewDraftSlot(attempt.floor);
emitAudit(pd, "REVIEW_REQUESTED", fields, intent, space);
}, intent, space);
} catch (e) {
if (e instanceof ReviewRefusal) error(e.message);
error(`Audit emission failed: ${errorMessage(e)}`);
}
console.log(JSON.stringify({
emitted: "REVIEW_REQUESTED",
stage: flags.stage,
...(retried ? { retry: "pending-request" } : {}),
...(upgraded ? { upgrade: "legacy-request" } : {}),
...(recovery ? { recovery } : {}),
requestId,
reviewFile,
...(requestChangeNotices.length > 0 ? { change_notices: requestChangeNotices } : {}),
}));
return;
}
if (retryPending) {
error("--retry-pending cannot be combined with --verdict.");
}
if (!flags.iteration || !/^[1-9][0-9]*$/.test(flags.iteration)) {
error("Recording a review verdict requires --iteration <positive integer>.");
}
const iteration = Number(flags.iteration);
fields.Iteration = flags.iteration;
const verdict = flags.verdict.toUpperCase();
if (!VALID_VERDICTS.has(verdict)) {
error(
`Unknown --verdict "${flags.verdict}". Accepted: ${[...VALID_VERDICTS].join(", ")}.`
);
}
fields.Verdict = verdict;
const reviewFileFlag = flags["review-file"];
let recordPath: string | null = null;
try {
withAuditLock(pd, () => {
const {
node,
attempt,
requireRequiredArtifacts,
unitResolution,
mergedBoltUnits,
} = loadContext(false, false);
const pendingRequest = attempt.pendingRequests.get(iteration);
if (!pendingRequest) {
refuseReview(
`Cannot record a verdict for review iteration ${iteration} on "${flags.stage}" ` +
`because no pending request with that number exists. Start or retry that review first.`,
);
}
const requestBinding = pendingRequest.binding;
if (requestBinding === null) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": the matching REVIEW_REQUESTED ` +
`iteration ${iteration} has no valid request binding. Its authority ` +
"cannot be recovered by retrying or rebaselining; start a fresh review attempt.",
);
}
const snapshot = reviewArtifactSnapshot(pd, node, flags.unit, {
requireRequiredArtifacts,
boltDag: unitResolution ?? undefined,
mergedBoltUnits,
});
if (snapshot === null) {
refuseReview(
`Cannot record review for "${flags.stage}": the declared artifact set ` +
"changed during the snapshot or is no longer a set of regular files.",
);
}
const bindsUnitSource =
node.workspace_requires === true &&
flags.unit !== undefined &&
node.for_each === "unit-of-work" &&
flags.single !== "true";
const manifest = bindsUnitSource
? readUnitSourceManifest(pd, flags.stage, flags.unit as string)
: null;
if (manifest?.ok === false) {
const manifestPath = `${relativeRecordDir(pd, intent, space) ?? "aidlc"}/construction/${flags.unit}/${flags.stage}/source-manifest.json`;
refuseReview(
`Cannot record review for "${flags.stage}": unit "${flags.unit}" has no valid source manifest at ` +
`${manifestPath} (${manifest.reason}). Write the manifest listing every application-source path ` +
"this unit created or modified, including shell- or generator-written files, then request and " +
"record the review again.",
);
}
const slot = reviewSlot(attempt.floor, iteration);
const legacy = requestBinding.legacyAppendix;
// Deprecated input path: a reviewer that still appends `## Review` to
// the artifact (see reviewAppendedAfterRequest). Read, never written to;
// the validated section is copied into the completion's review record.
const appendedAfterRequest = reviewAppendedAfterRequest(requestBinding, snapshot);
// The reviewer writes a review, never the artifact: the bytes the reviewer
// was dispatched on must be the bytes on disk now. A legacy request is
// compared against the body before any embedded appendix, which is what
// it fingerprinted.
if (
!reviewRequestArtifactsCurrent(requestBinding, snapshot) &&
!appendedAfterRequest
) {
refuseReview(
`Cannot record the verdict for "${flags.stage}" because ` +
`its output documents changed after review iteration ${iteration} started. ` +
"Restore the bytes the reviewer was dispatched on and re-run that exact " +
"iteration; --retry-pending cannot rebaseline changed content.",
);
}
// The review file is read the way the record will be read back: no
// symlinked container or leaf, no hardlink, no oversize file. A slot
// draft that is absent is an incomplete review; one that is anything but
// a plain file is refused, never silently treated as missing.
let body: Buffer | null = null;
try {
if (reviewFileFlag !== undefined) {
// An explicit review file must live inside the active intent record,
// where the reviewer's slot lives, reached through no symlink: a
// path outside it is not the reviewer's output.
const recordRoot = realpathSync(recordDir(pd) as string);
const relativeToRecord = toPosix(relative(recordRoot, resolve(pd, reviewFileFlag)));
if (
relativeToRecord === "" ||
relativeToRecord === ".." ||
relativeToRecord.startsWith("../") ||
isAbsolute(relativeToRecord)
) {
throw new Error("the path is outside the active intent record");
}
body = readRegularFileNoFollowOrThrow(
assertNoSymlinkInChainOrThrow(recordRoot, relativeToRecord),
"review file",
REVIEW_RECORD_MAX_BYTES,
);
} else {
const target = assertNoSymlinkInChainOrThrow(
realpathSync(recordDir(pd) as string),
slot.draftRelativeToRecord,
);
if (lstatExists(target)) {
body = readRegularFileNoFollowOrThrow(target, "review file", REVIEW_RECORD_MAX_BYTES);
}
}
} catch (readError) {
refuseReview(
`Cannot record review for "${flags.stage}": the review file ` +
`${reviewFileFlag ?? slot.draftRelative} is not a plain readable file ` +
`(${errorMessage(readError)}).`,
);
}
if (body !== null && snapshot.appendix.length > 0 && appendedAfterRequest) {
refuseReview(
`Cannot record the verdict for "${flags.stage}": a \`## Review\` section was ` +
`appended to the reviewed artifact after review iteration ${iteration} started ` +
"and a review file was also written. The review file is the review; remove the " +
"appended section so the artifact carries the bytes the reviewer was dispatched on.",
);
}
const incompleteFallback =
body === null &&
!appendedAfterRequest &&
pendingRequest.retried &&
verdict === "NOT-READY";
const embeddedLegacy = body === null && !incompleteFallback && appendedAfterRequest;
if (body === null && !incompleteFallback && !embeddedLegacy) {
refuseReview(
`Cannot record review for "${flags.stage}": no review was written for ` +
`iteration ${iteration}. The reviewer writes its review to ` +
`${slot.draftRelative} (or pass --review-file <path>); a retried ` +
"incomplete attempt records --verdict NOT-READY without a review.",
);
}
const reviewBytes = body ?? snapshot.appendix;
if (!incompleteFallback) {
const validity = validateReviewAppendix(reviewBytes, {
verdict: verdict as ReviewVerdict,
reviewer: flags.reviewer,
iteration,
reviewChallenge: embeddedLegacy ? legacy?.challenge ?? null : null,
standalone: body !== null,
});
if (!validity.valid) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": ${validity.reason}.`,
);
}
}
fields["Request Fingerprint"] = requestBinding.artifactFingerprint;
fields["Artifact Fingerprint"] = snapshot.fingerprint;
if (requestBinding.requestId !== null) {
fields["Request Id"] = requestBinding.requestId;
}
if (legacy !== null) {
Object.assign(fields, legacyReviewAppendixEchoFields(legacy));
}
// Bind the terminal receipt to the workspace source state the reviewer
// inspected. Only workspace-writing stages carry this binding. A newly
// unbindable receipt records that explicitly so completion fails closed;
// only genuinely legacy fieldless receipts keep migration behavior.
let sourceFingerprint: string | null = null;
let unitFingerprint: string | null = null;
if (node.workspace_requires) {
const sourceState = workspaceSourceState(pd, intent, space);
sourceFingerprint = sourceState?.fingerprint ?? UNBINDABLE_FINGERPRINT;
if (requestBinding.sourceFingerprint === null) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": the matching REVIEW_REQUESTED ` +
`iteration ${iteration} has no source fingerprint. Modernize that exact ` +
"request with --retry-pending before recording the verdict.",
);
}
if (sourceFingerprint !== requestBinding.sourceFingerprint) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": workspace source changed after ` +
`REVIEW_REQUESTED iteration ${iteration}. Restore the requested source state ` +
"and re-dispatch the reviewer.",
);
}
fields["Request Source Fingerprint"] = sourceFingerprint;
fields["Source Fingerprint"] = sourceFingerprint;
if (bindsUnitSource) {
unitFingerprint =
sourceState === null || manifest?.ok !== true
? UNBINDABLE_FINGERPRINT
: unitSourceFingerprint(
sourceState.listing,
manifest,
manifest.rawBytesSha256,
);
if (requestBinding.unitSourceFingerprint === null) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": the matching REVIEW_REQUESTED ` +
`iteration ${iteration} has no unit source fingerprint. Modernize that exact ` +
"request with --retry-pending before recording the verdict.",
);
}
if (unitFingerprint !== requestBinding.unitSourceFingerprint) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": unit source or source-manifest.json ` +
`changed after REVIEW_REQUESTED iteration ${iteration}. Restore the requested ` +
"unit source state and re-dispatch the reviewer.",
);
}
fields["Unit Source Fingerprint"] = unitFingerprint;
if (sourceState !== null && manifest?.ok === true) {
writeUnitSourceSnapshot(
pd,
flags.stage,
flags.unit as string,
sourceState.listing,
manifest,
manifest.rawBytesSha256,
);
}
}
}
// Every record-era completion writes the review record named by its row.
// A review-file completion stores its validated body, the tolerated
// appended form stores the validated appendix, and the bounded incomplete
// NOT-READY fallback stores an empty body with no findings.
const artifactKey = snapshot.reviewArtifact;
const recordBody = incompleteFallback ? Buffer.alloc(0) : reviewBytes;
let findings: ReturnType<typeof parseReviewSection>["findings"] = [];
if (!incompleteFallback) {
try {
findings = parseReviewSection(
recordBody.toString("utf-8"),
artifactKey,
flags.unit,
).findings;
} catch (parseError) {
refuseReview(
`Refusing REVIEW_COMPLETED for "${flags.stage}": ${errorMessage(parseError)}.`,
);
}
}
const record: ReviewRecord = {
version: 1,
stage: flags.stage,
unit: flags.unit ?? null,
workflow: fields.Workflow ?? null,
attempt: reviewAttemptId(attempt.floor),
iteration,
reviewer: flags.reviewer,
verdict: verdict as ReviewVerdict,
request_id: requestBinding.requestId,
request_challenge: legacy?.challenge ?? null,
artifact_fingerprint: snapshot.fingerprint,
source_fingerprint: sourceFingerprint,
unit_source_fingerprint: unitFingerprint,
findings: findings.map((finding) => ({
id: finding.id,
severity: finding.severity,
location: finding.location,
finding: finding.finding,
required_action: finding.requiredAction,
status: finding.status,
})),
body: recordBody.toString("utf-8"),
recorded_at: isoTimestamp(),
};
const serialized = serializeReviewRecord(record);
try {
writeRecordFileNoFollow(
recordDir(pd) as string,
slot.recordRelative,
serialized,
);
} catch (e) {
refuseReview(
`Cannot record the verdict for "${flags.stage}": the review record ` +
`${slot.recordRelative} cannot be written (${errorMessage(e)}).`,
);
}
fields["Review Record"] = slot.recordRelative;
fields["Review Record Digest"] = reviewRecordDigest(serialized);
recordPath = slot.recordRelative;
emitAudit(pd, "REVIEW_COMPLETED", fields, intent, space);
// The draft was the reviewer's input; the record now holds it. The
// chain was verified when the draft was read, so this cannot redirect.
if (body !== null && reviewFileFlag === undefined) {
removeRecordFileNoFollow(recordDir(pd) as string, slot.draftRelativeToRecord);
}
}, intent, space);
} catch (e) {
if (e instanceof ReviewRefusal) error(e.message);
error(`Audit emission failed: ${errorMessage(e)}`);
}
console.log(JSON.stringify({
emitted: "REVIEW_COMPLETED",
stage: flags.stage,
...(recordPath !== null ? { reviewRecord: recordPath } : {}),
}));
}
// --- CLI entry point ---
let projectDir: string | undefined;
export function main(argv: string[]): void {
const rawArgs = argv;
// Extract --project-dir
const filteredArgs: string[] = [];
for (let i = 0; i < rawArgs.length; i++) {
if (rawArgs[i] === "--project-dir" && i + 1 < rawArgs.length) {
projectDir = rawArgs[i + 1];
i++;
} else {
filteredArgs.push(rawArgs[i]);
}
}
const subcommand = filteredArgs[0];
try {
switch (subcommand) {
case "decision":
handleDecision(filteredArgs.slice(1));
break;
case "answer":
handleAnswer(filteredArgs.slice(1));
break;
case "link":
handleLink(filteredArgs.slice(1));
break;
case "review":
handleReview(filteredArgs.slice(1));
break;
default:
error(`Unknown subcommand: ${subcommand}. Valid: decision, answer, link, review`);
}
} catch (e) {
// A Plan Approval source-drift refusal is the human sentence; the
// conductor's remedy (which command reopens approval) rides beside it. An
// unbindable source boundary rides its ordered remedies the same way, repair
// first and break glass last.
if (e instanceof PlanApprovalSourceDriftError) {
console.error(JSON.stringify({ remedy: e.remedy }));
}
if (e instanceof PlanApprovalUnbindableError) {
console.error(JSON.stringify({ code: e.code, remedies: e.remedies }));
}
error(errorMessage(e));
}
}
// --- Utility ---
function error(msg: string): never {
const pd = resolveProjectDir(projectDir);
const command = `aidlc-log ${process.argv.slice(2).join(" ")}`.trim();
emitError(pd, "aidlc-log", command, msg);
}
if (import.meta.main) {
main(process.argv.slice(2));
}