7450 lines
276 KiB
TypeScript
7450 lines
276 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import {
|
||
existsSync,
|
||
mkdirSync,
|
||
readFileSync,
|
||
readdirSync,
|
||
realpathSync,
|
||
statSync,
|
||
unlinkSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import {
|
||
basename,
|
||
dirname,
|
||
isAbsolute,
|
||
join,
|
||
relative,
|
||
resolve as resolvePath,
|
||
sep,
|
||
} from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { appendAuditEntry, appendAuditEntryUnlocked } from "./aidlc-audit.ts";
|
||
import {
|
||
acceptedRiskDispositionField,
|
||
rejectedFindingDispositionField,
|
||
REVIEW_FINDING_DISPOSITIONS_FIELD,
|
||
} from "./aidlc-review-brief.ts";
|
||
import {
|
||
activeIntent,
|
||
activeIntentUuid,
|
||
activeSpace,
|
||
activeUnitCheckpoint,
|
||
auditBlockField,
|
||
auditShardName,
|
||
appendSlug,
|
||
appendUnderHeading,
|
||
artifactFilename,
|
||
BLOCKING_SENSOR_OVERRIDE_CHOICE,
|
||
BLOCKING_SENSOR_OVERRIDE_DECISION,
|
||
BLOCKING_SENSOR_OVERRIDE_OPTIONS,
|
||
type CheckboxState,
|
||
checkSummaryConfirmationEvidence,
|
||
type AcceptedChange,
|
||
governedChangeControl,
|
||
recordAcceptedChanges,
|
||
resolveChangeControl,
|
||
claimAttemptFields,
|
||
codekbDir,
|
||
codekbRepoName,
|
||
codekbStoreIsCurrent,
|
||
countCheckboxes,
|
||
currentSwarmSourceMergeChain,
|
||
currentSwarmAttemptObligations,
|
||
effectiveUnitGateRhythm,
|
||
emitError,
|
||
errorMessage,
|
||
evaluateGuardRefusal,
|
||
eventMatchesClaimAttempt,
|
||
extractMarkdownSection,
|
||
filterProducesByKind,
|
||
findStageBySlug,
|
||
findAllEvents,
|
||
firstInScopeStageOfPhase,
|
||
formatReceivedReply,
|
||
freshReviewReceipts,
|
||
getField,
|
||
guardRecoveryFeedbackStatus,
|
||
selectedGuardRecoveryRemedyAction,
|
||
type GuardAttemptState,
|
||
type StageEntry,
|
||
type GuardRefusal,
|
||
guardRefusalOutput,
|
||
guardAttemptState,
|
||
humanAuthorityState,
|
||
harnessDir,
|
||
hasUnsafeSingleLineCharacter,
|
||
holdsAuditLock,
|
||
humanActedSinceGate,
|
||
humanPresenceGuardDisabled,
|
||
unattendedHumanPresenceHint,
|
||
intentRepos,
|
||
isAutonomousConstructionDecision,
|
||
isAutonomousMode,
|
||
isAutonomousSwarmStage,
|
||
isTeamUnitOwnership,
|
||
isNonAnswer,
|
||
isRequestChangesChoice,
|
||
isRegularFile,
|
||
isoTimestamp,
|
||
KNOWN_CODEKB_STAGES,
|
||
latestMainWorkflowStageRunFloorForProject,
|
||
loadScopeMapping,
|
||
nextInScopeStage,
|
||
PHASE_NUMBERS,
|
||
PHASES,
|
||
parseCheckboxes,
|
||
parseMemoryEntries,
|
||
parseRefsList,
|
||
parseStateStageSuffixes,
|
||
pipelineLinkEvidence,
|
||
producesArtifactFile,
|
||
readAllAuditShards,
|
||
readApplicableTeamUnitScopeStamp,
|
||
readAuditShardEvents,
|
||
readStateFile,
|
||
ROUTE_CHECK_ENV,
|
||
readUnitMergeTransaction,
|
||
readUnitGateRhythm,
|
||
readUnitScopeStamp,
|
||
recordDir,
|
||
recoveryGuidance,
|
||
requestChangesResetIsExecutable,
|
||
relativeCodekbDir,
|
||
relativeMemoryPath,
|
||
relativeRecordDir,
|
||
removeField,
|
||
removeSlug,
|
||
replaceSection,
|
||
selfAttributedDecisionMarker,
|
||
resolveBoltDag,
|
||
requireLiveClaimForTeamUnit,
|
||
reviewArtifactFingerprint,
|
||
reviewerGateGuardDisabled,
|
||
resolveReviewClass,
|
||
resolveProjectFlag,
|
||
resolveWorkflowSelection,
|
||
sourceClaimCovers,
|
||
sourceBaselineAuditFields,
|
||
sourceListingEntriesEqual,
|
||
resolveProjectDir,
|
||
resolveStage,
|
||
setCheckbox,
|
||
setField,
|
||
setFieldStrict,
|
||
setOrInsertField,
|
||
setPhaseProgress,
|
||
singleStageAttemptIsOpen,
|
||
stagesInScope,
|
||
swarmConvergedUnits,
|
||
teamUnitGateStatus,
|
||
unitCompletedReceipts,
|
||
unitGateStatus,
|
||
unitMajorConstructionStageSlugs,
|
||
unitParkedPath,
|
||
type UnitGateScope,
|
||
updateIntentStatus,
|
||
usesStageLevelPerUnitArtifacts,
|
||
validateLiveUnitScope,
|
||
validateUnitName,
|
||
validScopes,
|
||
withAuditLock,
|
||
worktreeDocsDir,
|
||
worktreePath,
|
||
worktreeStateFilePath,
|
||
workspaceSourceState,
|
||
writeStateFile,
|
||
writeUnitScopeStamp,
|
||
writeFileAtomic,
|
||
} from "./aidlc-lib.js";
|
||
import { memoryDirFor } from "./aidlc-graph.ts";
|
||
import { inspectRequiredArtifactInstances } from "./aidlc-artifact-resolution.ts";
|
||
import { compiledExecutable } from "./aidlc-runtime-paths.ts";
|
||
import {
|
||
stageValidationAuditFields,
|
||
VALIDATION_WARNING_FIELD,
|
||
} from "./aidlc-validity.ts";
|
||
import {
|
||
stageUsageAuditFields,
|
||
workflowUsageAuditFields,
|
||
} from "./aidlc-usage.ts";
|
||
import { deriveTeamUnitProgressModel } from "./aidlc-orchestrate.ts";
|
||
|
||
// All valid checkbox states (lib.ts adds [?] awaiting-approval and [R] revising)
|
||
const VALID_CHECKBOX_STATES: CheckboxState[] = [
|
||
"pending",
|
||
"in-progress",
|
||
"awaiting-approval",
|
||
"revising",
|
||
"completed",
|
||
"skipped",
|
||
];
|
||
|
||
function isCheckboxState(s: string): s is CheckboxState {
|
||
return (VALID_CHECKBOX_STATES as readonly string[]).includes(s);
|
||
}
|
||
|
||
// Top-level dirs the artifact guard treats as "not source code" - the whole
|
||
// `aidlc/` workspace tree holds the per-intent records + planning artifacts +
|
||
// memory + codekb, the harness dirs hold the framework, .git is VCS. (On v2 the
|
||
// flat `aidlc-docs/` root is gone - every record lives under aidlc/spaces/...,
|
||
// so skipping `aidlc` skips all planning docs.) Used by workspaceHasSourceFile
|
||
// (the top-level dir skip) and isNonDocPath (the git first-segment skip).
|
||
// Declared at module top (not beside verifyStageArtifacts) because the command
|
||
// dispatch runs at top level: a const declared lower in the file would be in
|
||
// its temporal dead zone when an approve/advance dispatch calls the guard.
|
||
const HARNESS_DOC_DIRS = new Set([
|
||
"aidlc",
|
||
".claude",
|
||
".kiro",
|
||
".codex",
|
||
".opencode",
|
||
".aidlc",
|
||
".cursor",
|
||
".git",
|
||
]);
|
||
|
||
// KNOWN_CODEKB_STAGES now lives in aidlc-lib.ts (imported above) beside the
|
||
// shared produces-artifact matchers, so the review-freeze hook and this tool
|
||
// agree on which stages use the codekb layout. Imports are hoisted, so the
|
||
// TDZ concern that once kept a local copy here does not apply.
|
||
|
||
// --- Audit emission helper ---
|
||
// Uses the throw-on-error appendAuditEntry (not handleAppend which writes JSON to stdout).
|
||
// Caller wraps in try/catch; a thrown exception is the signal that audit failed and
|
||
// the state write should not proceed.
|
||
//
|
||
// Lock-aware: when the caller is mid-transaction inside a withAuditLock (the
|
||
// C2b lost-update wrapping — every RMW handler below holds the lock across
|
||
// read→decide→emit→write), this process already owns the OS lock. Routing
|
||
// through appendAuditEntry (which calls the NON-reentrant acquireAuditLock)
|
||
// would self-deadlock and burn the 5s retry budget before throwing, so detect
|
||
// the held lock and use the unlocked append variant instead — exactly how
|
||
// handleFork/handleMerge emit (appendAuditEntryUnlocked) and how emitError
|
||
// branches in aidlc-lib.ts. Outside a held lock (no current caller, but kept
|
||
// safe for any future bare-emit site) it takes its own lock as before.
|
||
function emitAudit(
|
||
projectDir: string,
|
||
eventType: string,
|
||
fields: Record<string, string>
|
||
): string {
|
||
if (holdsAuditLock(projectDir)) {
|
||
return appendAuditEntryUnlocked(eventType, fields, projectDir).timestamp;
|
||
}
|
||
return appendAuditEntry(eventType, fields, projectDir).timestamp;
|
||
}
|
||
|
||
// Per-stage token/cost rollup fields for STAGE_COMPLETED / WORKFLOW_COMPLETED.
|
||
// Wraps aidlc-usage's ledger-read helper (a ledger read only: NO transcript
|
||
// I/O) in a try/catch so any failure returns {} (no fields). Usage must NEVER
|
||
// block, delay, or break a completion event, so the caller computes this into a
|
||
// const ABOVE the withAuditLock(...) call and only merges the resulting strings
|
||
// into the emit `fields`. "Before emitAudit" is NOT far enough - emitAudit runs
|
||
// UNDER the held lock (see emitAudit above), so a ledger read there would still
|
||
// happen inside the lock; computing it before the lock opens keeps the
|
||
// completion path lock-clean. When no ledger exists (Kiro/Codex/opencode, or a
|
||
// Claude session that never folded) this returns {} and the completion event is
|
||
// unchanged.
|
||
function stageRollupFields(pd: string, stageSlug: string): Record<string, string> {
|
||
try {
|
||
return stageUsageAuditFields(pd, stageSlug);
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function workflowRollupFields(pd: string): Record<string, string> {
|
||
try {
|
||
return workflowUsageAuditFields(pd);
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function auditField(block: string, fieldName: string): string | null {
|
||
const prefix = `**${fieldName}**:`;
|
||
for (const line of block.split("\n")) {
|
||
if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function hasStageAuditEvent(
|
||
projectDir: string,
|
||
eventType: string,
|
||
stageSlug: string
|
||
): boolean {
|
||
// Read across every per-clone audit shard (one in the common single-clone /
|
||
// flat-legacy case; the glob-merge matters only when concurrent clones append
|
||
// to the same intent). readAllAuditShards returns "" when no shard exists.
|
||
const audit = readAllAuditShards(projectDir);
|
||
if (audit.length === 0) return false;
|
||
const workflowStarts = findAllEvents(audit, "WORKFLOW_STARTED");
|
||
const since = workflowStarts.length > 0
|
||
? workflowStarts[workflowStarts.length - 1].timestamp
|
||
: "";
|
||
return findAllEvents(audit, eventType).some((ev) => {
|
||
if (since && ev.timestamp < since) return false;
|
||
// Rows committed by a `--single` stage-runner run carry a synthetic
|
||
// `Workflow: single-stage:<slug>` id and belong to no main workflow —
|
||
// they must never satisfy a main-workflow dedup check (a single run's
|
||
// STAGE_COMPLETED would otherwise suppress the main workflow's own
|
||
// emission for the same slug). Main-workflow rows carry no Workflow field.
|
||
if (auditField(ev.block, "Workflow")?.startsWith("single-stage:")) {
|
||
return false;
|
||
}
|
||
return auditField(ev.block, "Stage") === stageSlug;
|
||
});
|
||
}
|
||
|
||
function hasTeamAttemptStageAuditEvent(
|
||
projectDir: string,
|
||
eventType: string,
|
||
stageSlug: string,
|
||
): boolean {
|
||
const rows = 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;
|
||
});
|
||
let floor = -1;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (
|
||
rows[i].event !== "WORKFLOW_STARTED" &&
|
||
rows[i].event !== "STAGE_JUMPED"
|
||
) {
|
||
continue;
|
||
}
|
||
floor = i;
|
||
if (
|
||
rows.some(
|
||
(row) =>
|
||
row.timestamp === rows[i].timestamp &&
|
||
row.shard !== rows[i].shard,
|
||
)
|
||
) {
|
||
while (
|
||
floor + 1 < rows.length &&
|
||
rows[floor + 1].timestamp === rows[i].timestamp
|
||
) {
|
||
floor++;
|
||
}
|
||
}
|
||
}
|
||
return rows.slice(floor + 1).some(
|
||
(row) =>
|
||
row.event === eventType &&
|
||
auditBlockField(row.block, "Stage") === stageSlug &&
|
||
!auditBlockField(row.block, "Workflow")?.startsWith("single-stage:"),
|
||
);
|
||
}
|
||
|
||
interface OrderedAuditEvent {
|
||
event: string;
|
||
block: string;
|
||
timestamp: string;
|
||
position: number;
|
||
}
|
||
|
||
// Audit rows are sharded by clone, so readAllAuditShards() concatenation order
|
||
// is not chronological. Build one ordered main-workflow stream for attempt-
|
||
// scoped recovery checks.
|
||
function orderedMainWorkflowAudit(projectDir: string): OrderedAuditEvent[] {
|
||
const audit = readAllAuditShards(projectDir);
|
||
if (audit.length === 0) return [];
|
||
const events = audit
|
||
.replace(/\r\n/g, "\n")
|
||
.split(/\n---\n/)
|
||
.map((block, position): OrderedAuditEvent | null => {
|
||
const event = auditField(block, "Event");
|
||
if (!event) return null;
|
||
if (auditField(block, "Workflow")?.startsWith("single-stage:")) return null;
|
||
return {
|
||
event,
|
||
block,
|
||
timestamp: auditField(block, "Timestamp") ?? "",
|
||
position,
|
||
};
|
||
})
|
||
.filter((event): event is OrderedAuditEvent => event !== null)
|
||
.sort((a, b) => {
|
||
if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp ? -1 : 1;
|
||
return a.position - b.position;
|
||
});
|
||
const workflowStart = events.findLastIndex(
|
||
(event) => event.event === "WORKFLOW_STARTED",
|
||
);
|
||
return workflowStart === -1 ? events : events.slice(workflowStart);
|
||
}
|
||
|
||
// Return the audit rows emitted after this stage's skip in its CURRENT attempt.
|
||
// A later STAGE_STARTED for the same slug starts a fresh attempt and invalidates
|
||
// all prior skip dedup evidence. This lets a backward jump skip the stage again
|
||
// while still recovering an interrupted [S] transition without duplicate rows.
|
||
function currentRoutedSkipAuditTail(
|
||
projectDir: string,
|
||
stageSlug: string,
|
||
): OrderedAuditEvent[] | null {
|
||
const events = orderedMainWorkflowAudit(projectDir);
|
||
let latestBoundary = -1;
|
||
let latestBoundaryWasSkip = false;
|
||
for (let i = 0; i < events.length; i++) {
|
||
const event = events[i];
|
||
if (auditField(event.block, "Stage") !== stageSlug) continue;
|
||
if (event.event === "STAGE_STARTED") {
|
||
latestBoundary = i;
|
||
latestBoundaryWasSkip = false;
|
||
} else if (event.event === "STAGE_SKIPPED") {
|
||
latestBoundary = i;
|
||
latestBoundaryWasSkip = true;
|
||
}
|
||
}
|
||
return latestBoundaryWasSkip ? events.slice(latestBoundary + 1) : null;
|
||
}
|
||
|
||
function auditTailHasFields(
|
||
events: OrderedAuditEvent[],
|
||
eventType: string,
|
||
fields: Record<string, string>,
|
||
): boolean {
|
||
return events.some(
|
||
(event) =>
|
||
event.event === eventType &&
|
||
Object.entries(fields).every(
|
||
([field, value]) => auditField(event.block, field) === value,
|
||
),
|
||
);
|
||
}
|
||
|
||
// producesArtifactFile / producesArtifactUnit / freshReviewReceipts moved to
|
||
// aidlc-lib.ts: the review-freeze PreToolUse hook shares the SAME scan so its
|
||
// write-freeze window and this tool's completion precondition can never
|
||
// diverge (a divergence would block writes the engine accepts, or miss writes
|
||
// the engine refuses). Imported above.
|
||
|
||
// The gate-revision backstop predicate (the reconciliation half of the
|
||
// forwarding-reliability gap). TRUE when the human demonstrably revised the
|
||
// stage's artifact at an OPEN gate but no `reject` verb was ever recorded, so
|
||
// `approve` should backfill the missing GATE_REJECTED + STAGE_REVISING pair
|
||
// rather than silently under-record the revision (leaving Revision Count 0 and
|
||
// no audit pair for a revision the user actually saw happen).
|
||
//
|
||
// Chronological interleave of six event types across every shard (Timestamp,
|
||
// then buffer position as the tiebreak): the SAME sort idiom humanActedSinceGate
|
||
// uses (aidlc-lib.ts). readAllAuditShards concatenates per-clone shards in
|
||
// FILENAME order, which is NOT time order, so a raw-position scan could misrank
|
||
// an older event living in a lexically-later shard. findAllEvents is NOT usable
|
||
// here: it filters ONE event type per call, and this predicate needs one
|
||
// interleaved ordering across all six to reason about "after the gate opened".
|
||
//
|
||
// The four conjuncts, all required:
|
||
// 1. an anchor exists: the LAST ORGANIC (non-Recovered) STAGE_AWAITING_APPROVAL
|
||
// for this slug, or, when the stage was (re)started after it / it never
|
||
// happened, the LAST STAGE_STARTED for this slug (the current stage run's
|
||
// boundary). Recovered=true gate rows are NEVER the anchor: report
|
||
// synthesizes one right before approve when the conductor skipped
|
||
// gate-start, so its timestamp postdates the human turns and revision
|
||
// writes the predicate needs inside the window, AND
|
||
// 2. no GATE_REJECTED for this slug after that anchor (a recorded reject means
|
||
// the verb already ran, nothing to backfill), AND
|
||
// 3. at least one HUMAN_TURN after the anchor (the human responded at the
|
||
// gate), AND
|
||
// 4. at least one ARTIFACT_CREATED/ARTIFACT_UPDATED to a declared produces file
|
||
// AFTER the FIRST post-anchor HUMAN_TURN.
|
||
//
|
||
// The HUMAN_TURN pivot in conjunct 4 is load-bearing: the reviewer appends its
|
||
// `## Review` section to the stage's review_artifact BEFORE the human responds at the
|
||
// gate (stage-protocol-reviewer.md §12a), firing an ARTIFACT_UPDATED on a produces file.
|
||
// Anchoring the artifact window at the first post-anchor human turn (not the gate
|
||
// open) excludes that legitimate pre-response append, so the reviewer's edit is
|
||
// never mistaken for a human-driven revision.
|
||
//
|
||
// When the anchor is the STAGE_STARTED fallback (no organic gate row for this
|
||
// run), one extra conjunct applies: a produces-file write must ALSO exist
|
||
// BETWEEN the anchor and the first post-anchor HUMAN_TURN. Without a recorded
|
||
// gate-open, "the artifact already existed when the human weighed in" is the
|
||
// evidence separating a gate revision from ordinary production: mid-stage
|
||
// coaching (human speaks BEFORE any write, conductor then produces) must not
|
||
// bump Revision Count. An SAA anchor needs no such guard - production precedes
|
||
// gate-open by construction there.
|
||
//
|
||
// Fail-open everywhere (empty ledger, no anchor, no post-anchor human turn ->
|
||
// false): the backstop only ever ADDS a reject it can prove happened; when the
|
||
// evidence is absent it does nothing and the normal approve proceeds. Codekb
|
||
// stages are covered via producesArtifactFile's codekb arm (their produces live
|
||
// under codekb/<repo>/ with no <slug> subdir; the audit-logger hook logs those
|
||
// writes). A non-empty intent repo set scopes that evidence to its own repos; an
|
||
// empty legacy set retains the any-repo fallback described at the matcher. They
|
||
// were previously excluded outright, which - combined with the hook not logging
|
||
// codekb paths at all - left a revised-then-approved reverse-engineering gate
|
||
// with Revision Count 0 and no GATE_REJECTED row.
|
||
function unrecordedRevisionSinceGateOpen(
|
||
pd: string,
|
||
stage: { slug: string; produces?: string[] }
|
||
): boolean {
|
||
const audit = readAllAuditShards(pd);
|
||
if (audit.length === 0) return false; // no ledger -> nothing to reconcile
|
||
const recordedRepos = new Set(intentRepos(pd));
|
||
const RELEVANT = new Set([
|
||
"STAGE_AWAITING_APPROVAL",
|
||
"STAGE_STARTED",
|
||
"GATE_REJECTED",
|
||
"HUMAN_TURN",
|
||
"ARTIFACT_CREATED",
|
||
"ARTIFACT_UPDATED",
|
||
]);
|
||
const blocks = audit.replace(/\r\n/g, "\n").split(/\n---\n/);
|
||
const events: {
|
||
ts: string;
|
||
pos: number;
|
||
event: string;
|
||
stage: string | null;
|
||
file: string | null;
|
||
recovered: boolean;
|
||
}[] = [];
|
||
for (let i = 0; i < blocks.length; i++) {
|
||
const ev = auditField(blocks[i], "Event");
|
||
if (!ev || !RELEVANT.has(ev)) continue;
|
||
events.push({
|
||
ts: auditField(blocks[i], "Timestamp") ?? "",
|
||
pos: i,
|
||
event: ev,
|
||
stage: auditField(blocks[i], "Stage"),
|
||
file: auditField(blocks[i], "File"),
|
||
recovered: auditField(blocks[i], "Recovered") === "true",
|
||
});
|
||
}
|
||
events.sort((a, b) => {
|
||
if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1;
|
||
return a.pos - b.pos;
|
||
});
|
||
// 1. Anchor: the LAST ORGANIC gate-open for this slug, else the LAST
|
||
// STAGE_STARTED for it, whichever is later. A Recovered=true gate row is
|
||
// report's own approve-time backfill: it postdates the human turns and the
|
||
// revision this predicate is looking for, so anchoring on it empties the
|
||
// window and produces the false negative on the skip-everything flow (the
|
||
// common shape of the bug). The stage-start fallback bounds the window to
|
||
// the current run when the conductor never opened the gate at all.
|
||
let anchor = -1;
|
||
let anchorIsGateOpen = false;
|
||
for (let i = 0; i < events.length; i++) {
|
||
if (events[i].stage !== stage.slug) continue;
|
||
if (events[i].event === "STAGE_AWAITING_APPROVAL" && !events[i].recovered) {
|
||
anchor = i;
|
||
anchorIsGateOpen = true;
|
||
} else if (events[i].event === "STAGE_STARTED") {
|
||
anchor = i;
|
||
anchorIsGateOpen = false;
|
||
}
|
||
}
|
||
if (anchor === -1) return false;
|
||
// 2 + 3 in one pass after the anchor: any recorded reject for this slug means
|
||
// the verb ran (return false); otherwise capture the FIRST human turn as the
|
||
// artifact-window pivot. For the stage-start fallback anchor, also require a
|
||
// produces write BEFORE that pivot (see the function comment): without a
|
||
// recorded gate-open, an artifact that predates the human's response is the
|
||
// evidence the human was reacting to produced work rather than coaching a
|
||
// stage that had produced nothing yet.
|
||
let firstHuman = -1;
|
||
let wroteBeforeHuman = false;
|
||
for (let i = anchor + 1; i < events.length; i++) {
|
||
const e = events[i];
|
||
if (e.event === "GATE_REJECTED" && e.stage === stage.slug) {
|
||
return false;
|
||
}
|
||
if (firstHuman === -1) {
|
||
if (e.event === "HUMAN_TURN") {
|
||
firstHuman = i;
|
||
} else if (
|
||
(e.event === "ARTIFACT_CREATED" || e.event === "ARTIFACT_UPDATED") &&
|
||
e.file !== null &&
|
||
producesArtifactFile(stage, e.file, recordedRepos)
|
||
) {
|
||
wroteBeforeHuman = true;
|
||
}
|
||
}
|
||
}
|
||
if (firstHuman === -1) return false;
|
||
if (!anchorIsGateOpen && !wroteBeforeHuman) return false;
|
||
// 4. A produces-file artifact write after the first post-anchor human turn.
|
||
for (let i = firstHuman + 1; i < events.length; i++) {
|
||
const e = events[i];
|
||
if (
|
||
(e.event === "ARTIFACT_CREATED" || e.event === "ARTIFACT_UPDATED") &&
|
||
e.file !== null &&
|
||
producesArtifactFile(stage, e.file, recordedRepos)
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// --- Slug + small helpers (used by fork/merge handlers below; declared
|
||
// before main() so they're initialised before dispatch fires) ---
|
||
|
||
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
||
|
||
function validateSlug(slug: string | undefined): string {
|
||
if (!slug) errorWithSlug("(missing)", `Missing --slug <slug>`);
|
||
if (!SLUG_RE.test(slug)) {
|
||
errorWithSlug(slug, `Invalid --slug: "${slug}". Must be kebab-case (lowercase letter then [a-z0-9-]).`);
|
||
}
|
||
return slug;
|
||
}
|
||
|
||
function errorWithSlug(slug: string, msg: string): never {
|
||
error(`[slug=${slug}] ${msg}`);
|
||
}
|
||
|
||
function sha256(buf: string): string {
|
||
return createHash("sha256").update(buf).digest("hex");
|
||
}
|
||
|
||
function parseFlags(args: string[]): Record<string, string> {
|
||
const flags: Record<string, string> = {};
|
||
for (let i = 0; i < args.length; i++) {
|
||
const a = args[i];
|
||
if (a.startsWith("--") && i + 1 < args.length) {
|
||
flags[a.slice(2)] = args[i + 1];
|
||
i++;
|
||
}
|
||
}
|
||
return flags;
|
||
}
|
||
|
||
// --- CLI entry point ---
|
||
|
||
// The engine's in-process guard preflight runs the same admission checks as a
|
||
// question, not a transition; while it runs, Change Control observes and leaves
|
||
// the ledger and stdout alone (see observeAcceptedChanges). Declared at module
|
||
// top for the same temporal-dead-zone reason as HARNESS_DOC_DIRS.
|
||
let changeControlPreflight = false;
|
||
|
||
let projectDir: string | undefined;
|
||
|
||
// Active per-intent lock context for the in-transaction error path. handleFork/
|
||
// handleMerge resolve their intent and hold a PER-INTENT audit lock across the
|
||
// whole transaction (withAuditLock(pd, fn, resolvedIntent, space)). When an
|
||
// errorWithSlug fires mid-transaction it routes through error() -> emitError,
|
||
// whose holdsAuditLock probe must key the SAME per-intent bucket the caller
|
||
// holds — a bare holdsAuditLock(pd) keys the __workspace__ sentinel, returns
|
||
// false mid per-intent transaction, and takes emitError's 5s blocking-acquire
|
||
// branch writing ERROR_LOGGED to the wrong bucket. These mirror the resolved
|
||
// intent+space into error() so emitError keys lock==write. Set immediately
|
||
// before the lock, cleared after; on the happy path no error fires and they are
|
||
// harmless. All OTHER handlers lock the sentinel bucket and leave these unset
|
||
// (undefined), so error() keys the sentinel for them — correct.
|
||
let lockIntent: string | undefined;
|
||
let lockSpace: string | undefined;
|
||
let stateSessionOverride: string | undefined;
|
||
|
||
// A guard refused. Carries the typed refusal so main() can render it as the
|
||
// guard-recovery ask, and so the router, calling the same admission function
|
||
// in-process, gets the same refusal without a rendering step in between.
|
||
class StateGuardRefusalError extends Error {
|
||
constructor(
|
||
readonly refusal: GuardRefusal,
|
||
readonly attempt: GuardAttemptState,
|
||
readonly resourceFingerprints: string[],
|
||
) {
|
||
super(refusal.userMessage);
|
||
}
|
||
}
|
||
|
||
// Every other refusal this tool makes. error() throws it instead of exiting on
|
||
// the spot, so the admission functions the router shares with the handlers are
|
||
// ordinary functions: a structural refusal unwinds to main() for the CLI, and
|
||
// the router treats it as "cannot decide" and fails open to the real command.
|
||
class StateCommandError extends Error {}
|
||
|
||
// A serial verb refused by a live wave must leave the audit unchanged too.
|
||
// Unwind the transaction lock before reporting this error without ERROR_LOGGED.
|
||
class UnitWaveRouteRefusalError extends StateCommandError {}
|
||
|
||
export function main(argv: string[]): void {
|
||
const args = [...argv];
|
||
|
||
// Extract --project-dir flag
|
||
const pdIdx = args.indexOf("--project-dir");
|
||
if (pdIdx !== -1 && pdIdx + 1 < args.length) {
|
||
projectDir = args[pdIdx + 1];
|
||
args.splice(pdIdx, 2);
|
||
}
|
||
stateSessionOverride =
|
||
resolveWorkflowSelection(resolveProjectDir(projectDir)).sessionId ?? undefined;
|
||
|
||
const subcommand = args[0];
|
||
|
||
// Lifecycle transitions and generic state writes are engine-owned. The
|
||
// orchestrator binds its child marker to its own PID; this process accepts it
|
||
// only when it names the actual parent. A copied static token cannot bypass
|
||
// report's stage pinning, evidence checks, and idempotency.
|
||
const engineOwnedTransitions = new Set([
|
||
"set",
|
||
"checkbox",
|
||
"advance",
|
||
"finalize",
|
||
"complete-workflow",
|
||
"gate-start",
|
||
"approve",
|
||
"reject",
|
||
"revise",
|
||
"skip",
|
||
"park",
|
||
"refresh-unit-progress",
|
||
"sync-unit-scope-stage",
|
||
"fold-unit-merge",
|
||
]);
|
||
if (
|
||
subcommand &&
|
||
engineOwnedTransitions.has(subcommand) &&
|
||
process.env.AIDLC_STATE_TRANSITION_OWNER !== `orchestrate:${process.ppid}` &&
|
||
!(
|
||
subcommand === "fold-unit-merge" &&
|
||
process.env.AIDLC_STATE_TRANSITION_OWNER === `unit-merge:${process.ppid}`
|
||
) &&
|
||
process.env.AIDLC_ALLOW_DIRECT_STATE_TRANSITIONS !== "1"
|
||
) {
|
||
exitWithError(
|
||
`Stage status cannot be changed with aidlc-state.ts ${subcommand} because that bypasses ` +
|
||
"the workflow's completion and approval checks. Use aidlc-orchestrate.ts report " +
|
||
"--stage <slug> --result " +
|
||
"<awaiting-approval|approved|rejected|revised|completed|skipped>; use " +
|
||
"aidlc-orchestrate.ts park to pause, and next/jump to move through the workflow.",
|
||
);
|
||
}
|
||
|
||
try {
|
||
switch (subcommand) {
|
||
case "get":
|
||
handleGet(args.slice(1));
|
||
break;
|
||
case "set":
|
||
handleSet(args.slice(1));
|
||
break;
|
||
case "set-skeleton-stance":
|
||
handleSetSkeletonStance(args.slice(1));
|
||
break;
|
||
case "set-construction-iteration":
|
||
handleSetConstructionIteration(args.slice(1));
|
||
break;
|
||
case "set-unit-ownership":
|
||
handleSetUnitOwnership(args.slice(1));
|
||
break;
|
||
case "set-unit-gate-rhythm":
|
||
handleSetUnitGateRhythm(args.slice(1));
|
||
break;
|
||
case "refresh-unit-progress":
|
||
handleRefreshUnitProgress(args.slice(1));
|
||
break;
|
||
case "sync-unit-scope-stage":
|
||
handleSyncUnitScopeStage(args.slice(1));
|
||
break;
|
||
case "fold-unit-merge":
|
||
handleFoldUnitMerge(args.slice(1));
|
||
break;
|
||
case "checkbox":
|
||
handleCheckbox(args.slice(1));
|
||
break;
|
||
case "count":
|
||
handleCount(args.slice(1));
|
||
break;
|
||
case "advance":
|
||
handleAdvance(args.slice(1));
|
||
break;
|
||
case "finalize":
|
||
handleFinalize(args.slice(1));
|
||
break;
|
||
case "complete-workflow":
|
||
handleCompleteWorkflow(args.slice(1));
|
||
break;
|
||
case "gate-start":
|
||
handleGateStart(args.slice(1));
|
||
break;
|
||
case "approve":
|
||
handleApprove(args.slice(1));
|
||
break;
|
||
case "reject":
|
||
handleReject(args.slice(1));
|
||
break;
|
||
case "revise":
|
||
handleRevise(args.slice(1));
|
||
break;
|
||
case "skip":
|
||
handleSkip(args.slice(1));
|
||
break;
|
||
case "resume":
|
||
handleResume(args.slice(1));
|
||
break;
|
||
case "acknowledge-compaction":
|
||
handleAcknowledgeCompaction(args.slice(1));
|
||
break;
|
||
case "reuse-artifact":
|
||
handleReuseArtifact(args.slice(1));
|
||
break;
|
||
case "lookup":
|
||
handleLookup(args.slice(1));
|
||
break;
|
||
case "practices-event":
|
||
handlePracticesEvent(args.slice(1));
|
||
break;
|
||
case "practices-promote":
|
||
handlePracticesPromote(args.slice(1));
|
||
break;
|
||
case "fork":
|
||
handleFork(args.slice(1));
|
||
break;
|
||
case "merge":
|
||
handleMerge(args.slice(1));
|
||
break;
|
||
case "unit":
|
||
handleUnit(args.slice(1));
|
||
break;
|
||
case "park":
|
||
handlePark(args.slice(1));
|
||
break;
|
||
case "unpark":
|
||
handleUnpark(args.slice(1));
|
||
break;
|
||
default:
|
||
error(
|
||
`Unknown subcommand: ${subcommand}. Valid: get, set, set-skeleton-stance, set-construction-iteration, set-unit-ownership, set-unit-gate-rhythm, refresh-unit-progress, sync-unit-scope-stage, fold-unit-merge, checkbox, count, advance, finalize, complete-workflow, gate-start, approve, reject, revise, skip, resume, acknowledge-compaction, reuse-artifact, lookup, practices-event, practices-promote, fork, merge, unit, park, unpark`
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (e instanceof UnitWaveRouteRefusalError) {
|
||
console.error(JSON.stringify({ error: e.message }));
|
||
process.exit(1);
|
||
}
|
||
if (e instanceof StateGuardRefusalError) {
|
||
const pd = resolveProjectDir(projectDir);
|
||
exitWithError(
|
||
guardRefusalOutput(
|
||
pd,
|
||
e.refusal,
|
||
e.attempt,
|
||
e.resourceFingerprints,
|
||
),
|
||
);
|
||
}
|
||
exitWithError(errorMessage(e));
|
||
}
|
||
}
|
||
|
||
if (import.meta.main) {
|
||
main(process.argv.slice(2));
|
||
}
|
||
|
||
// --- Subcommand handlers ---
|
||
|
||
function handleGet(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts get <field>");
|
||
const field = args.join(" ");
|
||
const pd = resolveProjectDir(projectDir);
|
||
const content = readStateFile(pd);
|
||
const value = getField(content, field);
|
||
if (value === null) {
|
||
error(`Field not found: ${field}`);
|
||
}
|
||
console.log(value);
|
||
}
|
||
|
||
function handleSet(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts set <field=value> ...");
|
||
const pd = resolveProjectDir(projectDir);
|
||
// C2b lost-update safety: hold the audit lock across read→decide→write so
|
||
// two concurrent `set`s of different fields can't clobber each other (A reads
|
||
// V1, B reads V1, A writes V2, B writes V1.5 → A's field lost). The +1/-1
|
||
// increment forms are especially exposed — they read-modify a counter.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
for (const pair of args) {
|
||
const eqIdx = pair.indexOf("=");
|
||
if (eqIdx <= 0) error(`Invalid field=value pair: ${pair}`);
|
||
const field = pair.slice(0, eqIdx);
|
||
let value = pair.slice(eqIdx + 1);
|
||
|
||
// Special values
|
||
if (value === "NOW") {
|
||
value = isoTimestamp();
|
||
} else if (value === "+1") {
|
||
const current = getField(content, field);
|
||
const num = current ? parseInt(current, 10) : 0;
|
||
value = String(num + 1);
|
||
} else if (value === "-1") {
|
||
const current = getField(content, field);
|
||
const num = current ? parseInt(current, 10) : 0;
|
||
value = String(Math.max(0, num - 1));
|
||
}
|
||
|
||
content = setField(content, field, value);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ updated: true, fields: args.length }));
|
||
});
|
||
}
|
||
|
||
// set-skeleton-stance <on|off|scope-dependent> — record the conductor's
|
||
// classified walking-skeleton stance (the classify round-trip). The
|
||
// `Skeleton Stance` field is runtime metadata (like Revision Count): it is NOT
|
||
// in the base state template, so we use setOrInsertField to update-if-present /
|
||
// insert-under-`## Runtime State`-if-absent (mirrors aidlc-bolt.ts's Merge-Held
|
||
// pattern for a runtime-only field). No audit row — the stance is metadata the
|
||
// next `aidlc-orchestrate next` reads to resolve the deferred Construction
|
||
// Bolt-1 gate, not a state-machine transition; it rides no event, exactly like
|
||
// `set` itself. The orchestration engine shells out to THIS subcommand rather
|
||
// than writing state itself (the engine writes nothing).
|
||
function handleSetSkeletonStance(args: string[]): void {
|
||
// Declared inside the handler: `main()` is invoked at module load before a
|
||
// module-level const further down would initialise (TDZ), so the value set
|
||
// lives here, where it is reached only when the subcommand runs.
|
||
const skeletonStanceValues = ["on", "off", "scope-dependent"];
|
||
if (args.length < 1) {
|
||
error(
|
||
`Usage: aidlc-state.ts set-skeleton-stance <${skeletonStanceValues.join("|")}>`,
|
||
);
|
||
}
|
||
const stance = args[0];
|
||
if (!skeletonStanceValues.includes(stance)) {
|
||
error(
|
||
`Invalid skeleton stance "${stance}". Valid: ${skeletonStanceValues.join(", ")}.`,
|
||
);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
// C2b lost-update safety: read→write under one lock (a concurrent `set` of an
|
||
// unrelated field must not lose this stance write, nor vice versa).
|
||
withAuditLock(pd, () => {
|
||
const content = readStateFile(pd);
|
||
const updated = setOrInsertField(
|
||
content,
|
||
"## Runtime State",
|
||
"Skeleton Stance",
|
||
stance,
|
||
);
|
||
writeStateFile(pd, updated);
|
||
console.log(JSON.stringify({ updated: true, skeleton_stance: stance }));
|
||
});
|
||
}
|
||
|
||
// set-construction-iteration <unit-major|stage-major>: record how the per-unit
|
||
// construction stages (design + code-generation) iterate over units.
|
||
// `Construction Iteration` is runtime metadata
|
||
// (like Skeleton Stance): it is NOT in the base state template, so we use
|
||
// setOrInsertField to update-if-present / insert-under-`## Runtime State`-if-absent.
|
||
// No audit row: the field is metadata the next `aidlc-orchestrate next` reads to
|
||
// pick the (stage, unit) walk order, not a state-machine transition; it rides no
|
||
// event, exactly like `set` and `set-skeleton-stance`. The classify round-trip is
|
||
// initiated by the delivery-planning stage prose (or set directly by a human); the
|
||
// engine writes nothing itself.
|
||
function handleSetConstructionIteration(args: string[]): void {
|
||
// Declared inside the handler for the same TDZ reason as skeleton stance:
|
||
// main() runs at module load before a module-level const would initialise.
|
||
const constructionIterationValues = ["unit-major", "stage-major"];
|
||
if (args.length < 1) {
|
||
error(
|
||
`Usage: aidlc-state.ts set-construction-iteration <${constructionIterationValues.join("|")}>`,
|
||
);
|
||
}
|
||
const value = args[0];
|
||
if (!constructionIterationValues.includes(value)) {
|
||
error(
|
||
`Invalid construction iteration "${value}". Valid: ${constructionIterationValues.join(", ")}.`,
|
||
);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
// Lost-update safety: read-then-write under one lock, as for the stance write.
|
||
withAuditLock(pd, () => {
|
||
const content = readStateFile(pd);
|
||
if (value !== "unit-major" && isTeamUnitOwnership(content)) {
|
||
error(
|
||
"Construction Iteration cannot leave unit-major while Unit Ownership is team. " +
|
||
"Set unit ownership to solo first.",
|
||
);
|
||
}
|
||
const updated = setOrInsertField(
|
||
content,
|
||
"## Runtime State",
|
||
"Construction Iteration",
|
||
value,
|
||
);
|
||
writeStateFile(pd, updated);
|
||
console.log(JSON.stringify({ updated: true, construction_iteration: value }));
|
||
});
|
||
}
|
||
|
||
function unitProgressSectionRange(
|
||
content: string,
|
||
): { start: number; end: number } | null {
|
||
const match = /^## Unit Progress\r?$/m.exec(content);
|
||
if (!match) return null;
|
||
const afterHeading = match.index + match[0].length;
|
||
const next = /^## /m.exec(content.slice(afterHeading));
|
||
return {
|
||
start: match.index,
|
||
end: next ? afterHeading + next.index : content.length,
|
||
};
|
||
}
|
||
|
||
function removeUnitProgressSection(content: string): string {
|
||
const range = unitProgressSectionRange(content);
|
||
if (!range) return content;
|
||
return `${content.slice(0, range.start)}${content.slice(range.end)}`;
|
||
}
|
||
|
||
function upsertUnitProgressSection(content: string, section: string): string {
|
||
const normalized = `${section.trimEnd()}\n\n`;
|
||
const range = unitProgressSectionRange(content);
|
||
if (range) {
|
||
return `${content.slice(0, range.start)}${normalized}${content.slice(range.end)}`;
|
||
}
|
||
const currentStatus = /^## Current Status$/m;
|
||
if (currentStatus.test(content)) {
|
||
return content.replace(currentStatus, `${normalized}## Current Status`);
|
||
}
|
||
return `${content.trimEnd()}\n\n${normalized}`;
|
||
}
|
||
|
||
function currentUnitProgressSection(content: string): string {
|
||
const range = unitProgressSectionRange(content);
|
||
if (!range) refuseUnitProgress("State has no Unit Progress section.");
|
||
return content.slice(range.start, range.end).trimEnd();
|
||
}
|
||
|
||
function hasCurrentTeamUnitActivity(pd: string, content: string): boolean {
|
||
if (!isTeamUnitOwnership(content)) return false;
|
||
const rows = readAuditShardEvents(pd).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;
|
||
});
|
||
let floor = -1;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (
|
||
rows[i].event === "WORKFLOW_STARTED" ||
|
||
rows[i].event === "STAGE_JUMPED"
|
||
) {
|
||
floor = i;
|
||
if (
|
||
rows.some(
|
||
(row) =>
|
||
row.timestamp === rows[i].timestamp &&
|
||
row.shard !== rows[i].shard,
|
||
)
|
||
) {
|
||
while (
|
||
floor + 1 < rows.length &&
|
||
rows[floor + 1].timestamp === rows[i].timestamp
|
||
) {
|
||
floor++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let latestOwnership = -1;
|
||
for (let i = floor + 1; i < rows.length; i++) {
|
||
if (rows[i].event === "UNIT_OWNERSHIP_SET") latestOwnership = i;
|
||
}
|
||
let teamMode =
|
||
latestOwnership === -1 ||
|
||
auditBlockField(rows[latestOwnership].block, "Mode") === "team";
|
||
const activityEvents = new Set(
|
||
"UNIT_STARTED,UNIT_PAUSED,UNIT_RESUMED,UNIT_COMPLETED,STAGE_AWAITING_APPROVAL,STAGE_REVISING,GATE_APPROVED,GATE_REJECTED".split(
|
||
",",
|
||
),
|
||
);
|
||
for (let i = Math.max(floor, latestOwnership) + 1; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
if (row.event === "UNIT_OWNERSHIP_SET") {
|
||
teamMode = auditBlockField(row.block, "Mode") === "team";
|
||
continue;
|
||
}
|
||
if (
|
||
teamMode &&
|
||
activityEvents.has(row.event) &&
|
||
auditBlockField(row.block, "Unit") !== null
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function handleSetUnitOwnership(args: string[]): void {
|
||
const values = ["team", "solo"];
|
||
if (args.length < 1) {
|
||
error(`Usage: aidlc-state.ts set-unit-ownership <${values.join("|")}>`);
|
||
}
|
||
const value = args[0];
|
||
if (!values.includes(value)) {
|
||
error(`Invalid unit ownership "${value}". Valid: ${values.join(", ")}.`);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const current = getField(content, "Unit Ownership")?.trim() || "solo";
|
||
if (value !== current && hasCurrentTeamUnitActivity(pd, content)) {
|
||
error(
|
||
`Unit ownership is frozen after team Unit activity in the current workflow attempt (current: ${current}).`,
|
||
);
|
||
}
|
||
if (
|
||
value === "team" &&
|
||
getField(content, "Construction Iteration")?.trim() !== "unit-major"
|
||
) {
|
||
error(
|
||
'Unit ownership "team" requires Construction Iteration: unit-major. ' +
|
||
"Run aidlc-state.ts set-construction-iteration unit-major first.",
|
||
);
|
||
}
|
||
if (value === "team") {
|
||
const repos = intentRepos(pd);
|
||
if (repos.length > 0) {
|
||
error(
|
||
`Unit ownership "team" is not supported for intents with recorded workspace repos ` +
|
||
`(${repos.join(", ")}). Team-owned Unit claims require the workspace root itself ` +
|
||
"to be the source Git repository.",
|
||
);
|
||
}
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "none") {
|
||
error(
|
||
'Unit ownership "team" requires a non-empty authoritative unit DAG.',
|
||
);
|
||
}
|
||
if (resolution.state === "malformed") {
|
||
error(
|
||
`Unit ownership "team" requires a valid authoritative unit DAG: ${resolution.reason}: ${resolution.detail}`,
|
||
);
|
||
}
|
||
if (resolution.units.length === 0) {
|
||
error(
|
||
'Unit ownership "team" requires a non-empty authoritative unit DAG.',
|
||
);
|
||
}
|
||
}
|
||
content = setOrInsertField(
|
||
content,
|
||
"## Runtime State",
|
||
"Unit Ownership",
|
||
value,
|
||
);
|
||
if (value === "solo") content = removeUnitProgressSection(content);
|
||
emitAudit(pd, "UNIT_OWNERSHIP_SET", { Mode: value });
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ updated: true, unit_ownership: value }));
|
||
});
|
||
}
|
||
|
||
function handleSetUnitGateRhythm(args: string[]): void {
|
||
const values = ["per-stage", "unit-end"];
|
||
if (args.length < 1) {
|
||
error(`Usage: aidlc-state.ts set-unit-gate-rhythm <${values.join("|")}>`);
|
||
}
|
||
const value = args[0];
|
||
if (!values.includes(value)) {
|
||
error(`Invalid unit gate rhythm "${value}". Valid: ${values.join(", ")}.`);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
if (!isTeamUnitOwnership(content)) {
|
||
error(
|
||
"Unit gate rhythm requires Unit Ownership: team. " +
|
||
"Run aidlc-state.ts set-unit-ownership team first.",
|
||
);
|
||
}
|
||
const current = readUnitGateRhythm(content);
|
||
if (value !== current && hasCurrentTeamUnitActivity(pd, content)) {
|
||
error(
|
||
`Unit gate rhythm is frozen after team Unit activity in the current workflow attempt (current: ${current}).`,
|
||
);
|
||
}
|
||
content = setOrInsertField(
|
||
content,
|
||
"## Runtime State",
|
||
"Unit Gate Rhythm",
|
||
value,
|
||
);
|
||
emitAudit(pd, "UNIT_GATE_RHYTHM_SET", { Rhythm: value });
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ updated: true, unit_gate_rhythm: value }));
|
||
});
|
||
}
|
||
|
||
interface UnitProgressRefreshPayload {
|
||
section: string;
|
||
stage_states: Record<string, CheckboxState>;
|
||
}
|
||
|
||
function refuseUnitProgress(msg: string): never {
|
||
console.error(JSON.stringify({ error: msg }));
|
||
process.exit(1);
|
||
}
|
||
|
||
function parseUnitProgressTable(section: string): {
|
||
preface: string[];
|
||
header: string[];
|
||
separator: string[];
|
||
rows: Map<string, string[]>;
|
||
order: string[];
|
||
} {
|
||
const lines = section.split(/\r?\n/);
|
||
const firstTableLine = lines.findIndex((line) => line.startsWith("|"));
|
||
if (firstTableLine < 0) refuseUnitProgress("Invalid Unit Progress table.");
|
||
const table = lines.slice(firstTableLine).map((line) =>
|
||
line.split("|").slice(1, -1).map((cell) => cell.trim())
|
||
);
|
||
if (
|
||
table.length < 2 ||
|
||
table[0][0]?.toLowerCase() !== "unit" ||
|
||
table[1].length !== table[0].length
|
||
) {
|
||
refuseUnitProgress("Invalid Unit Progress table header.");
|
||
}
|
||
const header = table[0];
|
||
const separator = table[1];
|
||
const rows = new Map<string, string[]>();
|
||
const order: string[] = [];
|
||
for (const cells of table.slice(2)) {
|
||
if (
|
||
cells.length !== header.length ||
|
||
cells.every((cell) => /^-+$/.test(cell))
|
||
) {
|
||
continue;
|
||
}
|
||
rows.set(cells[0], cells);
|
||
order.push(cells[0]);
|
||
}
|
||
return {
|
||
preface: lines.slice(0, firstTableLine),
|
||
header,
|
||
separator,
|
||
rows,
|
||
order,
|
||
};
|
||
}
|
||
|
||
function foldUnitProgressSection(
|
||
currentSection: string,
|
||
authoritativeSection: string,
|
||
unit: string,
|
||
): string {
|
||
const current = parseUnitProgressTable(currentSection);
|
||
const authoritative = parseUnitProgressTable(authoritativeSection);
|
||
const target = authoritative.rows.get(unit);
|
||
if (!target) {
|
||
refuseUnitProgress(`Authoritative Unit Progress has no row for "${unit}".`);
|
||
}
|
||
const currentIndex = new Map(
|
||
current.header.map((name, index) => [name, index]),
|
||
);
|
||
const rows = authoritative.order.map((rowUnit) => {
|
||
if (rowUnit === unit) return `| ${target.join(" | ")} |`;
|
||
const before = current.rows.get(rowUnit);
|
||
const derived = authoritative.rows.get(rowUnit);
|
||
if (!before || !derived) {
|
||
refuseUnitProgress(
|
||
`Cannot preserve non-target Unit Progress row "${rowUnit}".`,
|
||
);
|
||
}
|
||
const preserved = authoritative.header.map((name, index) => {
|
||
const oldIndex = currentIndex.get(name);
|
||
return oldIndex === undefined ? derived[index] : before[oldIndex];
|
||
});
|
||
return `| ${preserved.join(" | ")} |`;
|
||
});
|
||
return [
|
||
...authoritative.preface,
|
||
`| ${authoritative.header.join(" | ")} |`,
|
||
`| ${authoritative.separator.join(" | ")} |`,
|
||
...rows,
|
||
].join("\n");
|
||
}
|
||
|
||
function handleRefreshUnitProgress(
|
||
args: string[],
|
||
quiet = false,
|
||
foldUnit?: string,
|
||
foldOwner?: string,
|
||
afterWrite?: () => void,
|
||
): void {
|
||
const payloadRaw = getFlagValue(args, "--payload");
|
||
if (!payloadRaw) {
|
||
refuseUnitProgress(
|
||
"Usage: aidlc-state.ts refresh-unit-progress --payload <base64url-json>",
|
||
);
|
||
}
|
||
let payload: UnitProgressRefreshPayload;
|
||
try {
|
||
payload = JSON.parse(
|
||
Buffer.from(payloadRaw, "base64url").toString("utf-8"),
|
||
) as UnitProgressRefreshPayload;
|
||
} catch (e) {
|
||
refuseUnitProgress(`Invalid unit progress payload: ${errorMessage(e)}`);
|
||
}
|
||
if (
|
||
typeof payload.section !== "string" ||
|
||
!payload.section.startsWith("## Unit Progress\n") ||
|
||
payload.stage_states === null ||
|
||
typeof payload.stage_states !== "object"
|
||
) {
|
||
refuseUnitProgress("Invalid unit progress payload shape.");
|
||
}
|
||
const sectionTail = payload.section.split("\n").slice(1).join("\n");
|
||
if (
|
||
/^#{1,6}\s/m.test(sectionTail) ||
|
||
/^- \*\*[^*]+\*\*:/m.test(sectionTail)
|
||
) {
|
||
refuseUnitProgress(
|
||
"Invalid unit progress payload: embedded headings and state field lines are forbidden.",
|
||
);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const before = content;
|
||
if (!isTeamUnitOwnership(content)) {
|
||
refuseUnitProgress(
|
||
"Refusing Unit Progress refresh: Unit Ownership is not team.",
|
||
);
|
||
}
|
||
if (getField(content, "Construction Iteration")?.trim() !== "unit-major") {
|
||
refuseUnitProgress(
|
||
"Refusing Unit Progress refresh: Construction Iteration must be unit-major.",
|
||
);
|
||
}
|
||
let authoritative: ReturnType<typeof deriveTeamUnitProgressModel>;
|
||
try {
|
||
authoritative = deriveTeamUnitProgressModel(
|
||
pd,
|
||
content,
|
||
undefined,
|
||
foldUnit ? new Set([foldUnit]) : undefined,
|
||
foldUnit && foldOwner
|
||
? { ownerOverrides: new Map([[foldUnit, foldOwner]]) }
|
||
: undefined,
|
||
);
|
||
} catch (e) {
|
||
refuseUnitProgress(
|
||
`Refusing Unit Progress refresh: ${errorMessage(e)}`,
|
||
);
|
||
}
|
||
const suppliedStates = Object.entries(payload.stage_states).sort(
|
||
([a], [b]) => a.localeCompare(b),
|
||
);
|
||
const authoritativeStates = Object.entries(authoritative.stageStates).sort(
|
||
([a], [b]) => a.localeCompare(b),
|
||
);
|
||
if (
|
||
payload.section !== authoritative.section ||
|
||
JSON.stringify(suppliedStates) !== JSON.stringify(authoritativeStates)
|
||
) {
|
||
refuseUnitProgress(
|
||
"Refusing Unit Progress refresh: payload does not match authoritative DAG/artifact/receipt/gate evidence.",
|
||
);
|
||
}
|
||
const section = foldUnit
|
||
? foldUnitProgressSection(
|
||
currentUnitProgressSection(content),
|
||
authoritative.section,
|
||
foldUnit,
|
||
)
|
||
: authoritative.section;
|
||
content = upsertUnitProgressSection(content, section);
|
||
const stageEntries = Object.entries(authoritative.stageStates);
|
||
const completedNow: string[] = [];
|
||
for (const [slug, state] of stageEntries) {
|
||
if (!isCheckboxState(state)) {
|
||
refuseUnitProgress(
|
||
`Invalid derived checkbox state for "${slug}": ${state}`,
|
||
);
|
||
}
|
||
const stage = findStageBySlug(slug);
|
||
if (
|
||
stage?.phase !== "construction" ||
|
||
stage.for_each !== "unit-of-work"
|
||
) {
|
||
refuseUnitProgress(`Invalid Unit Progress stage: ${slug}`);
|
||
}
|
||
content = setCheckbox(content, slug, state);
|
||
if (
|
||
state === "completed" &&
|
||
(
|
||
getSlugState(before, slug) !== "completed" ||
|
||
!hasStageAuditEvent(pd, "STAGE_COMPLETED", slug)
|
||
)
|
||
) {
|
||
completedNow.push(slug);
|
||
}
|
||
}
|
||
|
||
const allBlockStagesComplete =
|
||
stageEntries.length > 0 &&
|
||
stageEntries.every(([, state]) => state === "completed");
|
||
const blockSlugs = stageEntries.map(([slug]) => slug);
|
||
const currentSlug = getField(content, "Current Stage") ?? "";
|
||
let started: string | null = null;
|
||
let phaseBoundary = false;
|
||
let workflowCompleted = false;
|
||
let completedFinalStage: NonNullable<ReturnType<typeof findStageBySlug>> | null =
|
||
null;
|
||
if (allBlockStagesComplete && blockSlugs.includes(currentSlug)) {
|
||
const finalSlug = blockSlugs[blockSlugs.length - 1];
|
||
const workflowScope = getField(content, "Scope") ?? "";
|
||
const finalStage = findStageBySlug(finalSlug);
|
||
const next = nextInScopeStage(finalSlug, workflowScope, content);
|
||
if (finalStage && next) {
|
||
const nextAfter = nextInScopeStage(next.slug, workflowScope, content);
|
||
const timestamp = isoTimestamp();
|
||
phaseBoundary = finalStage.phase !== next.phase;
|
||
content = setCheckbox(content, next.slug, "in-progress");
|
||
content = setField(content, "Current Stage", next.slug);
|
||
content = setField(content, "Lifecycle Phase", next.phase.toUpperCase());
|
||
content = setField(content, "Next Stage", nextAfter?.slug ?? "none");
|
||
content = setField(content, "In Progress", next.slug);
|
||
content = setField(content, "Active Agent", next.lead_agent);
|
||
content = setField(content, "Status", "Running");
|
||
content = setField(content, "Last Updated", timestamp);
|
||
content = setField(content, "Last Completed Stage", finalSlug);
|
||
content = setField(content, "Next Action", `Execute ${next.name}`);
|
||
if (phaseBoundary) {
|
||
content = setPhaseProgress(content, finalStage.phase, "Verified");
|
||
content = setPhaseProgress(content, next.phase, "Active");
|
||
}
|
||
started = next.slug;
|
||
} else if (
|
||
finalStage &&
|
||
getField(before, "Status") !== "Completed"
|
||
) {
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Current Stage", finalSlug);
|
||
content = setField(content, "Status", "Completed");
|
||
content = setField(content, "Last Updated", timestamp);
|
||
content = setField(content, "Last Completed Stage", finalSlug);
|
||
content = setField(content, "In Progress", "none");
|
||
content = setField(content, "Next Stage", "none");
|
||
content = setField(content, "Next Action", "Workflow complete");
|
||
content = setPhaseProgress(content, finalStage.phase, "Verified");
|
||
workflowCompleted = true;
|
||
completedFinalStage = finalStage;
|
||
}
|
||
}
|
||
content = setField(
|
||
content,
|
||
"Completed",
|
||
String(countCheckboxes(content, "completed")),
|
||
);
|
||
try {
|
||
for (const slug of completedNow) {
|
||
const stage = findStageBySlug(slug);
|
||
if (!hasTeamAttemptStageAuditEvent(pd, "STAGE_STARTED", slug)) {
|
||
emitAudit(pd, "STAGE_STARTED", {
|
||
Stage: slug,
|
||
Agent: stage?.lead_agent ?? "",
|
||
});
|
||
}
|
||
emitAudit(pd, "STAGE_COMPLETED", {
|
||
Stage: slug,
|
||
Details: `Stage ${stage?.name ?? slug} completed from team Unit Progress`,
|
||
});
|
||
}
|
||
if (started) {
|
||
const next = findStageBySlug(started);
|
||
if (phaseBoundary) {
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": "construction",
|
||
"To phase": next?.phase ?? "",
|
||
"Stages completed": String(countCheckboxes(content, "completed")),
|
||
});
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": `construction → ${next?.phase ?? ""}`,
|
||
});
|
||
emitAudit(pd, "PHASE_STARTED", {
|
||
Phase: next?.phase ?? "",
|
||
Scope: getField(content, "Scope") ?? "",
|
||
});
|
||
}
|
||
emitAudit(pd, "STAGE_STARTED", {
|
||
Stage: started,
|
||
Agent: next?.lead_agent ?? "",
|
||
});
|
||
}
|
||
if (workflowCompleted && completedFinalStage) {
|
||
const scope = getField(content, "Scope") ?? "";
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": completedFinalStage.phase,
|
||
"To phase": "(end)",
|
||
"Stages completed": String(countCheckboxes(content, "completed")),
|
||
});
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": `${completedFinalStage.phase} → end`,
|
||
});
|
||
emitAudit(pd, "WORKFLOW_COMPLETED", {
|
||
Scope: scope,
|
||
Details:
|
||
`Scope: ${scope}, ${countCheckboxes(content, "completed")} stages completed`,
|
||
...workflowRollupFields(pd),
|
||
});
|
||
}
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
if (workflowCompleted) {
|
||
const completedIntentDir = activeIntent(pd);
|
||
if (completedIntentDir) {
|
||
updateIntentStatus(pd, completedIntentDir, "complete");
|
||
}
|
||
}
|
||
afterWrite?.();
|
||
if (!quiet) {
|
||
console.log(JSON.stringify({
|
||
refreshed: true,
|
||
completed: completedNow,
|
||
started,
|
||
workflow_completed: workflowCompleted,
|
||
}));
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleFoldUnitMerge(args: string[]): void {
|
||
const unit = getFlagValue(args, "--unit");
|
||
const pinnedOid = getFlagValue(args, "--pinned-oid");
|
||
const generationRaw = getFlagValue(args, "--generation");
|
||
if (!unit || !pinnedOid || !generationRaw) {
|
||
error(
|
||
"Usage: aidlc-state.ts fold-unit-merge --unit <unit> --pinned-oid <oid> --generation <n>",
|
||
);
|
||
}
|
||
const generation = Number(generationRaw);
|
||
if (!Number.isInteger(generation) || generation < 1) {
|
||
error("--generation must be a positive integer.");
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
const existing = readUnitMergeTransaction(pd, unit);
|
||
if (
|
||
!existing ||
|
||
existing.pinned_oid !== pinnedOid ||
|
||
existing.generation !== generation ||
|
||
!existing.git_commit_oid ||
|
||
!["git-landed", "state-folded", "complete"].includes(existing.status)
|
||
) {
|
||
error(`No git-landed Unit merge transaction matches "${unit}" at ${pinnedOid}.`);
|
||
}
|
||
const transaction = existing;
|
||
const mergeCommitOid = transaction.git_commit_oid!;
|
||
const space = activeSpace(pd);
|
||
const intentUuid = activeIntentUuid(pd, space);
|
||
if (
|
||
!intentUuid ||
|
||
transaction.space !== space ||
|
||
transaction.intent_uuid !== intentUuid
|
||
) {
|
||
error(`Unit "${unit}" merge transaction belongs to another active space or intent.`);
|
||
}
|
||
const ancestor = spawnSync(
|
||
"git",
|
||
["merge-base", "--is-ancestor", mergeCommitOid, "HEAD"],
|
||
{ cwd: pd, encoding: "utf-8" },
|
||
);
|
||
if (ancestor.status !== 0) {
|
||
error(
|
||
`Unit "${unit}" merge commit ${mergeCommitOid} is not present on main.`,
|
||
);
|
||
}
|
||
const parents = spawnSync(
|
||
"git",
|
||
["show", "-s", "--format=%P", mergeCommitOid],
|
||
{ cwd: pd, encoding: "utf-8" },
|
||
);
|
||
if (
|
||
parents.status !== 0 ||
|
||
parents.stdout.trim().split(/\s+/)[1] !== pinnedOid
|
||
) {
|
||
error(`Unit "${unit}" has no controlled merge commit for pinned OID ${pinnedOid}.`);
|
||
}
|
||
const mainShard = auditShardName(pd);
|
||
const mainRows = readAuditShardEvents(pd)
|
||
.filter((row) => basename(row.shard) === mainShard)
|
||
.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;
|
||
});
|
||
const gate = mainRows
|
||
.filter(
|
||
(row) =>
|
||
(row.event === "GATE_APPROVED" || row.event === "GATE_REJECTED") &&
|
||
auditBlockField(row.block, "Stage") === "unit-merge" &&
|
||
auditBlockField(row.block, "Unit") === unit &&
|
||
auditBlockField(row.block, "Pinned OID") === pinnedOid &&
|
||
auditBlockField(row.block, "Attempt Generation") ===
|
||
String(generation) &&
|
||
auditBlockField(row.block, "Gate Scope") === "unit-merge",
|
||
)
|
||
.at(-1);
|
||
if (gate?.event !== "GATE_APPROVED") {
|
||
error(`Unit "${unit}" has no current approved merge-gate receipt.`);
|
||
}
|
||
const gateStrategy = auditBlockField(gate.block, "Strategy");
|
||
const gateTarget = auditBlockField(gate.block, "Target branch");
|
||
const branch = spawnSync("git", ["branch", "--show-current"], {
|
||
cwd: pd,
|
||
encoding: "utf-8",
|
||
});
|
||
if (
|
||
gateStrategy !== "merge" ||
|
||
!gateTarget ||
|
||
branch.status !== 0 ||
|
||
branch.stdout.trim() !== gateTarget ||
|
||
transaction.target_branch !== gateTarget
|
||
) {
|
||
error(`Unit "${unit}" merge-gate strategy or target does not match main.`);
|
||
}
|
||
const authorization = transaction.state_fold_authorized;
|
||
if (!authorization) {
|
||
error(
|
||
`Unit "${unit}" state fold has no land-bound claim authorization.`,
|
||
);
|
||
}
|
||
if (authorization.owner !== transaction.owner) {
|
||
error(
|
||
`Unit "${unit}" merge journal owner does not match the pinned claim owner.`,
|
||
);
|
||
}
|
||
const liveAuthorized =
|
||
authorization.mode === "live-claim" &&
|
||
authorization.observed_oid === pinnedOid &&
|
||
eventMatchesClaimAttempt(
|
||
pd,
|
||
`**Unit**: ${unit}\n**Attempt Generation**: ${generation}\n`,
|
||
unit,
|
||
);
|
||
const releasedAuthorized =
|
||
authorization.mode === "released-after-git" &&
|
||
transaction.released_after_git !== undefined &&
|
||
authorization.observed_oid ===
|
||
transaction.released_after_git.tombstone_oid &&
|
||
transaction.released_after_git.tombstone_generation === generation + 1 &&
|
||
mainRows.some(
|
||
(row) =>
|
||
row.event === "RECOVERY_COMPLETED" &&
|
||
auditBlockField(row.block, "Recovery") ===
|
||
"unit-merge-released-attempt" &&
|
||
auditBlockField(row.block, "Unit") === unit &&
|
||
auditBlockField(row.block, "Pinned OID") === pinnedOid &&
|
||
auditBlockField(row.block, "Attempt Generation") ===
|
||
String(generation) &&
|
||
auditBlockField(row.block, "Tombstone OID") ===
|
||
transaction.released_after_git?.tombstone_oid &&
|
||
auditBlockField(row.block, "Tombstone Generation") ===
|
||
String(
|
||
transaction.released_after_git?.tombstone_generation,
|
||
) &&
|
||
auditBlockField(row.block, "User Input") ===
|
||
transaction.released_after_git?.user_input,
|
||
);
|
||
if (!liveAuthorized && !releasedAuthorized) {
|
||
error(`Unit "${unit}" merge attempt generation is no longer current.`);
|
||
}
|
||
|
||
const liveState = readStateFile(pd);
|
||
if (!isTeamUnitOwnership(liveState)) {
|
||
error("Unit merge folding requires Unit Ownership: team.");
|
||
}
|
||
const model = deriveTeamUnitProgressModel(
|
||
pd,
|
||
liveState,
|
||
undefined,
|
||
new Set([unit]),
|
||
{
|
||
ownerOverrides: new Map([[unit, authorization.owner]]),
|
||
},
|
||
);
|
||
const payload = Buffer.from(
|
||
JSON.stringify({
|
||
section: model.section,
|
||
stage_states: model.stageStates,
|
||
}),
|
||
"utf-8",
|
||
).toString("base64url");
|
||
handleRefreshUnitProgress(
|
||
["--payload", payload],
|
||
true,
|
||
unit,
|
||
authorization.owner,
|
||
() => {
|
||
const alreadyMerged = readAuditShardEvents(pd).some(
|
||
(row) =>
|
||
row.event === "UNIT_MERGED" &&
|
||
auditBlockField(row.block, "Unit") === unit &&
|
||
auditBlockField(row.block, "Pinned OID") === pinnedOid &&
|
||
auditBlockField(row.block, "Attempt Generation") ===
|
||
String(generation),
|
||
);
|
||
if (!alreadyMerged) {
|
||
appendAuditEntryUnlocked(
|
||
"UNIT_MERGED",
|
||
{
|
||
Unit: unit,
|
||
Owner: authorization.owner,
|
||
"Pinned OID": pinnedOid,
|
||
"Merge commit OID": mergeCommitOid,
|
||
"Attempt Generation": String(generation),
|
||
},
|
||
pd,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
console.log(JSON.stringify({
|
||
folded: true,
|
||
unit,
|
||
pinned_oid: pinnedOid,
|
||
generation,
|
||
}));
|
||
}
|
||
|
||
function handleSyncUnitScopeStage(args: string[]): void {
|
||
const slug = args[0];
|
||
const unit = getFlagValue(args.slice(1), "--unit");
|
||
if (!slug || !unit) {
|
||
error("Usage: aidlc-state.ts sync-unit-scope-stage <stage> --unit <unit>");
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const stamp = validateLiveUnitScope(pd, unit);
|
||
if (!stamp) error("Scoped Unit stage sync requires a live Unit claim.");
|
||
const stage = findStageBySlug(slug);
|
||
if (
|
||
stage?.phase !== "construction" ||
|
||
stage.for_each !== "unit-of-work"
|
||
) {
|
||
error(`Stage "${slug}" is not a per-unit Construction stage.`);
|
||
}
|
||
content = setField(content, "Current Stage", slug);
|
||
content = setField(content, "Lifecycle Phase", "CONSTRUCTION");
|
||
content = setField(content, "In Progress", slug);
|
||
content = setField(content, "Active Agent", stage.lead_agent);
|
||
if (getSlugState(content, slug) !== "completed") {
|
||
content = setCheckbox(content, slug, "in-progress");
|
||
}
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
synced: true,
|
||
stage: slug,
|
||
unit,
|
||
generation: stamp.generation,
|
||
}));
|
||
});
|
||
}
|
||
|
||
// park - persist a `Parked` runtime field so the next `aidlc-orchestrate next`
|
||
// emits a terminal `parked` directive and the Stop hook lets the turn end
|
||
// (issue #367: a clean multi-session exit, so the agent never rubber-stamps
|
||
// stages to reach `done`). `Parked` and `Parked At Stage` are runtime-only
|
||
// fields (like Skeleton Stance) inserted under `## Runtime State`. Refuses a
|
||
// completed workflow (nothing to park). Emits WORKFLOW_PARKED - a recorded
|
||
// state event, audit-first under the lock.
|
||
//
|
||
// AUTONOMY GUARD (issue #365, salvaged from the suspend branch): an unattended
|
||
// autonomous Construction run must never park, so the tool refuses `park`
|
||
// outright under `Construction Autonomy Mode: autonomous`. This is
|
||
// defence-in-depth beside the Stop hook's identical guard: the hook protects
|
||
// the unattended turn-end path, this tool refusal protects a direct/scripted
|
||
// `aidlc-state.ts park` invocation in an autonomous run. (#365's suspend
|
||
// mechanism had no first-class tool verb a swarm could call, so it could guard
|
||
// hook-side only; park's `aidlc-state.ts park` is directly invocable, so the
|
||
// tool refusal closes a path #365 did not have.)
|
||
function handlePark(_args: string[]): void {
|
||
const pd = resolveProjectDir(projectDir);
|
||
const initialContent = readStateFile(pd);
|
||
if (
|
||
getField(initialContent, "Construction Autonomy Mode")?.trim() ===
|
||
"autonomous"
|
||
) {
|
||
error(
|
||
"Refusing to park: Construction Autonomy Mode is autonomous. An unattended " +
|
||
"autonomous run has no human to resume it and must keep moving - do not park it.",
|
||
);
|
||
}
|
||
const scopeStamp = validateLiveUnitScope(pd);
|
||
if (scopeStamp) {
|
||
writeFileSync(
|
||
unitParkedPath(pd),
|
||
`${JSON.stringify({
|
||
unit: scopeStamp.unit,
|
||
generation: scopeStamp.generation,
|
||
parked_at: isoTimestamp(),
|
||
})}\n`,
|
||
"utf-8",
|
||
);
|
||
process.stderr.write(
|
||
`[aidlc] warning: parked Unit "${scopeStamp.unit}" locally from its checkout stamp; ` +
|
||
"claim liveness was not required and will be rechecked at the next claim-sensitive boundary.\n",
|
||
);
|
||
console.log(JSON.stringify({
|
||
parked: true,
|
||
unit: scopeStamp.unit,
|
||
checkout_local: true,
|
||
}));
|
||
return;
|
||
}
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const status = getField(content, "Status");
|
||
if (status === "Completed") {
|
||
error("Workflow is already Completed - nothing to park.");
|
||
}
|
||
const currentSlug = getField(content, "Current Stage") ?? "";
|
||
if (currentSlug.length === 0) {
|
||
error("State file has no Current Stage - cannot park.");
|
||
}
|
||
const timestamp = isoTimestamp();
|
||
emitAudit(pd, "WORKFLOW_PARKED", {
|
||
Stage: currentSlug,
|
||
});
|
||
content = setOrInsertField(content, "## Runtime State", "Parked", timestamp);
|
||
content = setOrInsertField(content, "## Runtime State", "Parked At Stage", currentSlug);
|
||
content = setField(content, "Last Updated", timestamp);
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ parked: true, stage: currentSlug, timestamp }));
|
||
});
|
||
}
|
||
|
||
// unpark - clear the `Parked` / `Parked At Stage` fields on explicit re-entry
|
||
// (the resume flow calls this), so subsequent plain `next` calls no longer
|
||
// emit `parked`. Idempotent: clearing absent fields is a no-op.
|
||
function handleUnpark(_args: string[]): void {
|
||
const pd = resolveProjectDir(projectDir);
|
||
const scopeStamp = readApplicableTeamUnitScopeStamp(pd);
|
||
if (scopeStamp) {
|
||
const wasParked = existsSync(unitParkedPath(pd));
|
||
try {
|
||
unlinkSync(unitParkedPath(pd));
|
||
} catch {
|
||
// Idempotent.
|
||
}
|
||
console.log(JSON.stringify({
|
||
unparked: true,
|
||
was_parked: wasParked,
|
||
checkout_local: true,
|
||
}));
|
||
return;
|
||
}
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const wasParked = (getField(content, "Parked") ?? "").trim().length > 0;
|
||
// Remove both runtime markers (no-op if absent - unpark is idempotent).
|
||
content = removeField(content, "Parked");
|
||
content = removeField(content, "Parked At Stage");
|
||
if (wasParked) {
|
||
const ts = isoTimestamp();
|
||
emitAudit(pd, "WORKFLOW_UNPARKED", {});
|
||
content = setField(content, "Last Updated", ts);
|
||
}
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ unparked: true, was_parked: wasParked }));
|
||
});
|
||
}
|
||
|
||
// unit <start|pause|resume|complete> --stage <slug> --unit <name>
|
||
// [--reason <text>] [--next-action <text>] [--wave]
|
||
//
|
||
// Unit-of-work lifecycle receipts for INLINE per-unit Construction stages
|
||
// (for_each: unit-of-work, mode: inline). The engine's coverage walk
|
||
// (aidlc-orchestrate.ts unitCovered) treats UNIT_COMPLETED as the completion
|
||
// signal and artifact existence as the evidence checked HERE, at emit time —
|
||
// so a paused or partially-written unit can never be mistaken for a finished
|
||
// one just because its files exist. UNIT_PAUSED requires --reason and
|
||
// --next-action so a resumed session (or another machine) lands on the exact
|
||
// checkpoint; the runtime `Active Unit` / `Unit State` fields mirror the
|
||
// latest receipt for cheap status reads. The autonomous swarm keeps its own
|
||
// SWARM_UNIT_* ledger (aidlc-swarm.ts) — this verb is the interactive twin.
|
||
//
|
||
// Single-active-unit invariant: `start` refuses while another unit of the
|
||
// same stage is non-terminal (started/resumed/paused without a later
|
||
// UNIT_COMPLETED), so resume/restart races cannot create two active units.
|
||
// `resume` refuses unless the named unit is the currently-paused one.
|
||
function handleUnit(args: string[]): void {
|
||
const action = args[0];
|
||
const VALID_UNIT_ACTIONS = new Set(["start", "pause", "resume", "complete"]);
|
||
if (!action || !VALID_UNIT_ACTIONS.has(action)) {
|
||
error(
|
||
`Usage: aidlc-state.ts unit <start|pause|resume|complete> --stage <slug> --unit <name> [--reason <text>] [--next-action <text>] [--wave]`,
|
||
);
|
||
}
|
||
const rest = args.slice(1);
|
||
const slug = getFlagValue(rest, "--stage");
|
||
const unit = getFlagValue(rest, "--unit");
|
||
const rawReason = getFlagValue(rest, "--reason");
|
||
const rawNextAction = getFlagValue(rest, "--next-action");
|
||
const waveMode = rest.includes("--wave");
|
||
const reason = rawReason?.trim();
|
||
const nextAction = rawNextAction?.trim();
|
||
if (!slug) error("Missing --stage <slug>");
|
||
if (!unit) error("Missing --unit <name>");
|
||
const unitNameError = validateUnitName(unit);
|
||
if (unitNameError) error(unitNameError);
|
||
validateStateLineValue("--reason", rawReason);
|
||
validateStateLineValue("--next-action", rawNextAction);
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
if (stage.for_each !== "unit-of-work") {
|
||
error(`Stage "${slug}" is not per-unit (for_each: unit-of-work); unit receipts do not apply.`);
|
||
}
|
||
if (action === "pause") {
|
||
if (!reason) error("unit pause requires --reason <text> (why the unit stopped).");
|
||
if (!nextAction) error("unit pause requires --next-action <text> (the exact next step on resume).");
|
||
}
|
||
if (waveMode && action !== "complete") {
|
||
error("unit --wave is supported only with the complete action.");
|
||
}
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
validateLiveUnitScope(pd, unit);
|
||
// One lock across read→validate→emit→write (the C2b idiom): the checkpoint
|
||
// read and the receipt append must see one ledger snapshot, or two racing
|
||
// `unit start` calls could both pass the single-active-unit check.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
const scope = getField(content, "Scope");
|
||
if (usesStageLevelPerUnitArtifacts(scope, content)) {
|
||
error(
|
||
`Refusing unit ${action} for "${unit}": the effective workflow plan skips ` +
|
||
"Units Generation, so this stage runs once at stage level.",
|
||
);
|
||
}
|
||
|
||
// Only an engine-eligible autonomous swarm owns SWARM_UNIT_* bookkeeping.
|
||
// The autonomy grant persists across backward jumps, where inline per-unit
|
||
// stages still need this interactive lifecycle ledger.
|
||
if (autonomousSwarmOwnsStage(stage, content)) {
|
||
error(
|
||
`Refusing unit ${action}: Construction Autonomy Mode is autonomous. The swarm referee ` +
|
||
"owns per-unit bookkeeping (SWARM_UNIT_* receipts); interactive unit receipts apply " +
|
||
"only when the engine routes the stage inline.",
|
||
);
|
||
}
|
||
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "malformed") {
|
||
error(
|
||
`Refusing unit ${action}: the authoritative unit DAG is ${resolution.reason} ` +
|
||
`(${resolution.detail}). Fix unit-of-work-dependency.md first.`,
|
||
);
|
||
}
|
||
if (resolution.state !== "ok" || !resolution.units.includes(unit)) {
|
||
error(
|
||
`Refusing unit ${action} for "${unit}": it is not in the authoritative unit DAG.`,
|
||
);
|
||
}
|
||
|
||
const checkpoint = activeUnitCheckpoint(pd, slug);
|
||
|
||
// Consult the live route before any serial receipt can change it. A fresh
|
||
// wave has no completion receipt yet, and old wave receipts can remain
|
||
// after an explicit switch to unit-major, so ledger mode is insufficient.
|
||
// The route check is read-only and shares this lock's ledger snapshot.
|
||
const routed = action === "complete"
|
||
? {}
|
||
: readEngineUnitDirective(pd, slug, unit, action);
|
||
if (
|
||
routed.kind === "run-stage" &&
|
||
routed.stage === slug &&
|
||
routed.wave
|
||
) {
|
||
throw new UnitWaveRouteRefusalError(
|
||
`Refusing unit ${action} for "${unit}" of "${slug}": a wave is active for this stage ` +
|
||
"(the orchestration engine currently routes it as a batch). " +
|
||
"A wave has no single active unit — every entry settles through " +
|
||
`\`aidlc-state.ts unit complete --wave --stage ${slug} --unit <name>\`. A bare ` +
|
||
`\`${action}\` would append a serial receipt and ` +
|
||
"drop the deterministic batch path for every remaining unit of the stage. " +
|
||
"Use `unit complete --wave` instead.",
|
||
);
|
||
}
|
||
|
||
if (waveMode) {
|
||
if (checkpoint) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${slug}": serial unit ` +
|
||
`"${checkpoint.unit}" is ${checkpoint.state}. Complete or resume that checkpoint first.`,
|
||
);
|
||
}
|
||
requireEngineRoutedWaveUnit(pd, slug, unit);
|
||
} else if (action === "start") {
|
||
if (checkpoint && checkpoint.unit !== unit) {
|
||
error(
|
||
`Refusing to start unit "${unit}" for "${slug}": unit "${checkpoint.unit}" is ${checkpoint.state}` +
|
||
`${checkpoint.reason ? ` (reason: ${checkpoint.reason})` : ""}. ` +
|
||
`${checkpoint.state === "paused" ? `Resume it (aidlc-state.ts unit resume --stage ${slug} --unit ${checkpoint.unit}) or complete it first.` : "Complete it first."} ` +
|
||
"One active unit at a time.",
|
||
);
|
||
}
|
||
if (checkpoint && checkpoint.unit === unit) {
|
||
// Idempotent re-entry on the same unit: a crashed conductor may re-run
|
||
// start after resume; acknowledge without a duplicate receipt.
|
||
console.log(JSON.stringify({ unit, stage: slug, state: checkpoint.state, already_active: true }));
|
||
return;
|
||
}
|
||
requireEngineRoutedUnit(routed, slug, unit);
|
||
} else if (action === "pause" || action === "complete") {
|
||
if (!checkpoint || checkpoint.unit !== unit) {
|
||
error(
|
||
`Refusing to ${action} unit "${unit}" for "${slug}": it is not the active unit` +
|
||
`${checkpoint ? ` (active: "${checkpoint.unit}", ${checkpoint.state})` : " (no unit is active — start it first)"}.`,
|
||
);
|
||
}
|
||
if (action === "complete" && checkpoint.state === "paused") {
|
||
error(
|
||
`Refusing to complete unit "${unit}" for "${slug}": it is paused` +
|
||
`${checkpoint.reason ? ` (reason: ${checkpoint.reason})` : ""}. Resume it first ` +
|
||
`(aidlc-state.ts unit resume --stage ${slug} --unit ${unit}); a paused unit's work is not done.`,
|
||
);
|
||
}
|
||
} else if (action === "resume") {
|
||
if (!checkpoint || checkpoint.unit !== unit || checkpoint.state !== "paused") {
|
||
error(
|
||
`Refusing to resume unit "${unit}" for "${slug}": it is not the paused unit` +
|
||
`${checkpoint ? ` (active: "${checkpoint.unit}", ${checkpoint.state})` : " (no unit is active)"}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// UNIT_COMPLETED is receipt-plus-evidence: the receipt commits only when
|
||
// every applicable required artifact for THIS unit exists on disk. This is
|
||
// the claim-1 inversion — the artifact walk moved from "is the transition"
|
||
// to "is checked by the transition".
|
||
if (action === "complete" && !artifactGuardDisabled()) {
|
||
const missing = missingUnitArtifacts(pd, stage, unit);
|
||
if (missing.length > 0) {
|
||
error(
|
||
`Refusing to complete unit "${unit}" for "${slug}": required artifacts are missing on disk ` +
|
||
`(${missing.join(", ")}). Write the unit's artifacts before completing it.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const memoryEntries =
|
||
action === "complete" && waveMode
|
||
? fanInWaveUnitMemory(pd, slug, unit)
|
||
: 0;
|
||
const waveFingerprint =
|
||
action === "complete" && waveMode
|
||
? reviewArtifactFingerprint(pd, stage, unit, {
|
||
requireRequiredArtifacts: true,
|
||
})
|
||
: null;
|
||
if (action === "complete" && waveMode && waveFingerprint === null) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${slug}": the final artifact fingerprint could not be computed.`,
|
||
);
|
||
}
|
||
|
||
let eventType: string;
|
||
if (action === "start") eventType = "UNIT_STARTED";
|
||
else if (action === "pause") eventType = "UNIT_PAUSED";
|
||
else if (action === "resume") eventType = "UNIT_RESUMED";
|
||
else eventType = "UNIT_COMPLETED";
|
||
const fields: Record<string, string> = {
|
||
Stage: slug,
|
||
Unit: unit,
|
||
"Run floor": latestMainWorkflowStageRunFloorForProject(
|
||
pd,
|
||
slug,
|
||
getField(content, "Construction Iteration")?.trim() === "unit-major",
|
||
isTeamUnitOwnership(content) ? unit : undefined,
|
||
),
|
||
...claimAttemptFields(pd, unit),
|
||
...(waveMode
|
||
? {
|
||
Mode: "wave",
|
||
"Wave memory entries": String(memoryEntries),
|
||
"Artifact Fingerprint": waveFingerprint ?? "",
|
||
}
|
||
: {}),
|
||
};
|
||
if (reason) fields.Reason = reason;
|
||
if (nextAction) fields["Next Action"] = nextAction;
|
||
|
||
try {
|
||
emitAudit(pd, eventType, fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
// Mirror the latest checkpoint into runtime state for cheap status reads
|
||
// (audit stays the source of truth — these fields are a cache, exactly like
|
||
// Parked / Parked At Stage).
|
||
const timestamp = isoTimestamp();
|
||
if (action === "complete") {
|
||
content = removeField(content, "Active Unit");
|
||
content = removeField(content, "Unit State");
|
||
content = removeField(content, "Unit Pause Reason");
|
||
content = removeField(content, "Unit Next Action");
|
||
} else {
|
||
content = setOrInsertField(content, "## Runtime State", "Active Unit", unit);
|
||
content = setOrInsertField(
|
||
content,
|
||
"## Runtime State",
|
||
"Unit State",
|
||
action === "pause" ? "paused" : "in-progress",
|
||
);
|
||
if (action === "pause") {
|
||
content = setOrInsertField(content, "## Runtime State", "Unit Pause Reason", reason ?? "");
|
||
content = setOrInsertField(content, "## Runtime State", "Unit Next Action", nextAction ?? "");
|
||
} else {
|
||
content = removeField(content, "Unit Pause Reason");
|
||
content = removeField(content, "Unit Next Action");
|
||
}
|
||
}
|
||
content = setField(content, "Last Updated", timestamp);
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
emitted: eventType,
|
||
stage: slug,
|
||
unit,
|
||
timestamp,
|
||
...(waveMode ? { wave: true, memory_entries: memoryEntries } : {}),
|
||
}));
|
||
});
|
||
}
|
||
|
||
function validateStateLineValue(label: string, value: string | undefined): void {
|
||
if (value !== undefined && hasUnsafeSingleLineCharacter(value)) {
|
||
error(`${label} must be printable text on one physical line.`);
|
||
}
|
||
}
|
||
|
||
interface EngineUnitDirective {
|
||
kind?: unknown;
|
||
stage?: unknown;
|
||
unit?: unknown;
|
||
wave?: unknown;
|
||
}
|
||
|
||
function readEngineUnitDirective(
|
||
pd: string,
|
||
stage: string,
|
||
unit: string,
|
||
action: string,
|
||
): EngineUnitDirective {
|
||
const executable = compiledExecutable();
|
||
let subargs = ["next", "--project-dir", pd];
|
||
let directive: unknown = null;
|
||
for (let attempts = 0; attempts < 1_000; attempts++) {
|
||
const command = executable
|
||
? [executable, "engine", "orchestrate", ...subargs]
|
||
: [
|
||
process.execPath,
|
||
fileURLToPath(new URL("./aidlc-orchestrate.ts", import.meta.url)),
|
||
...subargs,
|
||
];
|
||
const result = spawnSync(command[0], command.slice(1), {
|
||
cwd: pd,
|
||
encoding: "utf-8",
|
||
env: {
|
||
...process.env,
|
||
AIDLC_PROJECT_DIR: pd,
|
||
[ROUTE_CHECK_ENV]: "1",
|
||
...(stateSessionOverride
|
||
? { AIDLC_SESSION_OVERRIDE: stateSessionOverride }
|
||
: {}),
|
||
},
|
||
timeout: 30_000,
|
||
});
|
||
if (result.status !== 0) {
|
||
error(
|
||
`Refusing to ${action} unit "${unit}" for "${stage}": the orchestration engine could not resolve ` +
|
||
`the current routed unit (${(result.stderr ?? "").trim() || "no diagnostic"}).`,
|
||
);
|
||
}
|
||
try {
|
||
directive = JSON.parse((result.stdout ?? "").trim());
|
||
} catch {
|
||
error(
|
||
`Refusing to ${action} unit "${unit}" for "${stage}": the orchestration engine returned an ` +
|
||
"unparseable directive.",
|
||
);
|
||
}
|
||
const transport =
|
||
directive !== null && typeof directive === "object"
|
||
? directive as { kind?: unknown; continue_token?: unknown }
|
||
: {};
|
||
if (transport.kind !== "load-steering") break;
|
||
if (
|
||
typeof transport.continue_token !== "string" ||
|
||
transport.continue_token.length === 0
|
||
) {
|
||
error(
|
||
`Refusing to ${action} unit "${unit}" for "${stage}": the engine's steering directive ` +
|
||
"did not include a continuation token.",
|
||
);
|
||
}
|
||
subargs = ["continue", transport.continue_token, "--project-dir", pd];
|
||
}
|
||
return directive !== null && typeof directive === "object"
|
||
? directive as EngineUnitDirective
|
||
: {};
|
||
}
|
||
|
||
function requireEngineRoutedUnit(
|
||
routed: EngineUnitDirective,
|
||
stage: string,
|
||
unit: string,
|
||
): void {
|
||
if (
|
||
routed.kind !== "run-stage" ||
|
||
routed.stage !== stage ||
|
||
routed.unit !== unit
|
||
) {
|
||
const expected =
|
||
routed.kind === "run-stage" &&
|
||
typeof routed.stage === "string" &&
|
||
typeof routed.unit === "string"
|
||
? `"${routed.stage}"/"${routed.unit}"`
|
||
: `a ${String(routed.kind ?? "non-run-stage")} directive`;
|
||
error(
|
||
`Refusing to start unit "${unit}" for "${stage}": the engine currently routes ${expected}. ` +
|
||
"Run the exact directive.stage/directive.unit pair returned by aidlc-orchestrate.ts next.",
|
||
);
|
||
}
|
||
}
|
||
|
||
function requireEngineRoutedWaveUnit(
|
||
pd: string,
|
||
stage: string,
|
||
unit: string,
|
||
): void {
|
||
const executable = compiledExecutable();
|
||
let subargs = ["next", "--project-dir", pd];
|
||
let directive: unknown = null;
|
||
for (let attempts = 0; attempts < 1_000; attempts++) {
|
||
const command = executable
|
||
? [executable, "engine", "orchestrate", ...subargs]
|
||
: [
|
||
process.execPath,
|
||
fileURLToPath(new URL("./aidlc-orchestrate.ts", import.meta.url)),
|
||
...subargs,
|
||
];
|
||
const result = spawnSync(command[0], command.slice(1), {
|
||
cwd: pd,
|
||
encoding: "utf-8",
|
||
env: {
|
||
...process.env,
|
||
AIDLC_PROJECT_DIR: pd,
|
||
[ROUTE_CHECK_ENV]: "1",
|
||
...(stateSessionOverride
|
||
? { AIDLC_SESSION_OVERRIDE: stateSessionOverride }
|
||
: {}),
|
||
},
|
||
timeout: 30_000,
|
||
});
|
||
if (result.status !== 0) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${stage}": the orchestration ` +
|
||
`engine could not resolve the current wave (${(result.stderr ?? "").trim() || "no diagnostic"}).`,
|
||
);
|
||
}
|
||
try {
|
||
directive = JSON.parse((result.stdout ?? "").trim());
|
||
} catch {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${stage}": the orchestration ` +
|
||
"engine returned an unparseable directive.",
|
||
);
|
||
}
|
||
const transport =
|
||
directive !== null && typeof directive === "object"
|
||
? directive as { kind?: unknown; continue_token?: unknown }
|
||
: {};
|
||
if (transport.kind !== "load-steering") break;
|
||
if (
|
||
typeof transport.continue_token !== "string" ||
|
||
transport.continue_token.length === 0
|
||
) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${stage}": the engine's ` +
|
||
"steering directive did not include a continuation token.",
|
||
);
|
||
}
|
||
subargs = ["continue", transport.continue_token, "--project-dir", pd];
|
||
}
|
||
|
||
const routed =
|
||
directive !== null && typeof directive === "object"
|
||
? directive as {
|
||
kind?: unknown;
|
||
stage?: unknown;
|
||
wave?: {
|
||
entries?: Array<{
|
||
unit?: unknown;
|
||
build_required?: unknown;
|
||
completion_required?: unknown;
|
||
review_state?: unknown;
|
||
}>;
|
||
};
|
||
}
|
||
: {};
|
||
const entry = routed.wave?.entries?.find((candidate) => candidate.unit === unit);
|
||
const reviewSettled =
|
||
entry?.review_state === "READY" ||
|
||
entry?.review_state === "NOT-READY" ||
|
||
entry?.review_state === "not-required";
|
||
if (
|
||
routed.kind !== "run-stage" ||
|
||
routed.stage !== stage ||
|
||
!entry ||
|
||
entry.build_required !== false ||
|
||
entry.completion_required !== true ||
|
||
!reviewSettled
|
||
) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${stage}": the engine does ` +
|
||
"not currently expose that entry as build-complete, review-settled, and awaiting its completion receipt.",
|
||
);
|
||
}
|
||
}
|
||
|
||
function fanInWaveUnitMemory(pd: string, stage: string, unit: string): number {
|
||
const headings = [
|
||
"Interpretations",
|
||
"Deviations",
|
||
"Tradeoffs",
|
||
"Open questions",
|
||
] as const;
|
||
const rec = recordDir(pd);
|
||
if (rec === null) {
|
||
error(
|
||
`Refusing wave completion for unit "${unit}" of "${stage}": no active intent record directory exists.`,
|
||
);
|
||
}
|
||
const unitPath = join(rec, "construction", unit, stage, "memory.md");
|
||
const parentPath = join(rec, "construction", stage, "memory.md");
|
||
const unitContent = existsSync(unitPath) ? readFileSync(unitPath, "utf-8") : "";
|
||
const entries = parseMemoryEntries(unitContent);
|
||
|
||
let parentContent = existsSync(parentPath)
|
||
? readFileSync(parentPath, "utf-8")
|
||
: `${headings.map((heading) => `## ${heading}\n`).join("\n")}\n`;
|
||
for (const heading of headings) {
|
||
if (!new RegExp(`^## ${heading}$`, "m").test(parentContent)) {
|
||
parentContent = `${parentContent.trimEnd()}\n\n## ${heading}\n`;
|
||
}
|
||
}
|
||
|
||
let added = 0;
|
||
for (const entry of entries) {
|
||
const digest = createHash("sha256")
|
||
.update(`${stage}\0${unit}\0${entry.heading}\0${entry.raw}`, "utf-8")
|
||
.digest("hex");
|
||
const marker = `<!-- aidlc-wave-memory:${unit}:${digest} -->`;
|
||
if (parentContent.includes(marker)) continue;
|
||
parentContent = appendUnderHeading(
|
||
parentContent,
|
||
`## ${entry.heading}`,
|
||
`\n${entry.raw}\n${marker}\n`,
|
||
);
|
||
added++;
|
||
}
|
||
|
||
mkdirSync(dirname(parentPath), { recursive: true });
|
||
if (!existsSync(parentPath) || added > 0) {
|
||
writeFileAtomic(parentPath, parentContent.endsWith("\n") ? parentContent : `${parentContent}\n`);
|
||
}
|
||
return added;
|
||
}
|
||
|
||
// The unit's missing REQUIRED artifacts (kind-filtered like the engine's
|
||
// unitCovered): resolved under <record>/construction/<unit>/<slug>/<name>.md.
|
||
// Returns [] when everything applicable exists. Kind filtering reads the
|
||
// bolt_dag the same way the engine does; with no readable dag the FULL
|
||
// required list applies (fail strict, like unitCovered's kinds=null path).
|
||
function missingUnitArtifacts(
|
||
pd: string,
|
||
stage: { slug: string; produces?: string[]; produces_kinds?: Record<string, string[]> },
|
||
unit: string,
|
||
): string[] {
|
||
const rec = recordDir(pd);
|
||
if (rec === null) return stage.produces ?? ["<no record dir>"];
|
||
let required = stage.produces ?? [];
|
||
if (stage.produces_kinds !== undefined) {
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "ok" && resolution.unitKinds !== null) {
|
||
required = filterProducesByKind(
|
||
stage.produces_kinds,
|
||
required,
|
||
resolution.unitKinds.get(unit) ?? null,
|
||
);
|
||
}
|
||
}
|
||
const missing: string[] = [];
|
||
for (const name of required) {
|
||
const p = join(rec, "construction", unit, stage.slug, artifactFilename(name));
|
||
if (!isRegularFile(p)) missing.push(name);
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
function handleCheckbox(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts checkbox <slug=state> ...");
|
||
const pd = resolveProjectDir(projectDir);
|
||
|
||
// Parse + validate args BEFORE taking the lock — pure input checks that
|
||
// touch no shared state, so they fail fast without holding the lock.
|
||
const changes: Array<{ slug: string; state: CheckboxState }> = [];
|
||
for (const pair of args) {
|
||
const eqIdx = pair.indexOf("=");
|
||
if (eqIdx <= 0) error(`Invalid slug=state pair: ${pair}`);
|
||
const slug = pair.slice(0, eqIdx);
|
||
const stateStr = pair.slice(eqIdx + 1);
|
||
if (!isCheckboxState(stateStr)) {
|
||
error(`Invalid state: ${stateStr}. Valid: ${VALID_CHECKBOX_STATES.join(", ")}`);
|
||
}
|
||
changes.push({ slug, state: stateStr });
|
||
}
|
||
|
||
// C2b lost-update safety: read→apply→count→write under one lock so the
|
||
// Completed counter resync sees a consistent snapshot (a concurrent checkbox
|
||
// flip between our read and write would otherwise desync the count).
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
for (const { slug, state } of changes) {
|
||
content = setCheckbox(content, slug, state);
|
||
}
|
||
|
||
// Sync Completed counter to actual [x] count
|
||
const completedCount = countCheckboxes(content, "completed");
|
||
content = setField(content, "Completed", String(completedCount));
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ updated: true, checkboxes: changes.length, completed_count: completedCount }));
|
||
});
|
||
}
|
||
|
||
function handleCount(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts count <state>");
|
||
const stateStr = args[0];
|
||
if (!isCheckboxState(stateStr)) {
|
||
error(`Invalid state: ${stateStr}. Valid: ${VALID_CHECKBOX_STATES.join(", ")}`);
|
||
}
|
||
const pd = resolveProjectDir(projectDir);
|
||
const content = readStateFile(pd);
|
||
console.log(countCheckboxes(content, stateStr));
|
||
}
|
||
|
||
// --- Stage-completion artifact guard (issue #366) ---------------------------
|
||
//
|
||
// The state machine's transitions were purely ceremonial: approve/advance
|
||
// marked a stage [x] without verifying ANY work landed on disk, so an agent
|
||
// could rubber-stamp all 33 stages (gate-start->approve, or pure advance) with
|
||
// zero artifacts. This guard makes a forward stage-completion CONTINGENT on
|
||
// evidence of work - the same principle the swarm referee already applies at
|
||
// the merge gate (aidlc-swarm.ts finalize is authoritative, so a red unit
|
||
// cannot merge even if the conductor lies).
|
||
//
|
||
// It lives in aidlc-state.ts because that is the ONE seam every transition
|
||
// passes through: the issue's repro calls `aidlc-state.ts approve/advance`
|
||
// directly, so a guard only in orchestrate's `report` dispatcher is bypassable.
|
||
//
|
||
// V2 PATH RE-AUTHOR (workspace refactor #429): the flat `aidlc-docs/<phase>/<slug>/`
|
||
// layout is gone - a stage's produces[] artifacts now live under the ACTIVE
|
||
// intent's per-intent record dir (`aidlc/spaces/<space>/intents/<slug>-<id8>/
|
||
// <phase>/<stage>/`), per-unit Construction artifacts under that record's
|
||
// `construction/<unit>/<stage>/`, and codekb stages (reverse-engineering) under
|
||
// the space-level `aidlc/spaces/<space>/codekb/<repo>/`. This guard resolves
|
||
// against those live seams (recordDir / codekbDir), mirroring
|
||
// resolveArtifactPath in aidlc-orchestrate.ts so the two cannot drift on shape.
|
||
//
|
||
// Two layers:
|
||
// 1. produces-existence - a stage that declares produces[] must have at least
|
||
// one of them on disk. Empty-produces stages (init phase) are exempt.
|
||
// 2. workspace_requires - a code-producing stage (frontmatter flag) must also
|
||
// have a real file OUTSIDE the aidlc/ workspace tree and the harness dir.
|
||
// Catches the code-generation case where only the two markdown produces[]
|
||
// docs were written but no actual source code (issue #366 Update 2).
|
||
//
|
||
// Bypass: AIDLC_SKIP_ARTIFACT_GUARD=1 (env, set by the test runner for synthetic
|
||
// tiers that drive transitions against bare fixtures).
|
||
// (KNOWN_CODEKB_STAGES is declared at module top alongside HARNESS_DOC_DIRS to
|
||
// dodge the TDZ - the dispatch that calls this guard runs at module load.)
|
||
|
||
function artifactGuardDisabled(): boolean {
|
||
return resolveProjectFlag("AIDLC_SKIP_ARTIFACT_GUARD") === "1";
|
||
}
|
||
|
||
// Mirrors both aidlc-orchestrate.ts isAutonomousSwarmCandidate and the
|
||
// unit-major suppression at eligibleAutonomousSwarmBatches. Under unit-major
|
||
// the WALK owns per-unit work inline and needs the interactive lifecycle
|
||
// ledger, so its `unit start` must not be refused as swarm-owned. A missing
|
||
// scope fails closed for a subagent stage: without it, this tool cannot prove
|
||
// that the stage is the non-swarm skeleton gate.
|
||
function autonomousSwarmOwnsStage(
|
||
stage: { slug: string; phase: string; for_each?: string; mode?: string },
|
||
stateContent: string,
|
||
): boolean {
|
||
if (stage.phase !== "construction") return false;
|
||
if (stage.for_each !== "unit-of-work" || stage.mode !== "subagent") return false;
|
||
if (!isAutonomousMode(stateContent)) return false;
|
||
if (getField(stateContent, "Construction Iteration")?.trim() === "unit-major") {
|
||
return false;
|
||
}
|
||
const scope = getField(stateContent, "Scope");
|
||
if (!scope) return true;
|
||
if (usesStageLevelPerUnitArtifacts(scope, stateContent)) return false;
|
||
const first = firstInScopeStageOfPhase("construction", scope);
|
||
return first === null || first.slug !== stage.slug;
|
||
}
|
||
|
||
// Settled-autonomous-swarm exemption, mirroring isSettledAutonomousSwarm in
|
||
// aidlc-orchestrate.ts (the report path's disk-backed-guard exemption). Finalize
|
||
// copies reviewed per-unit record artifacts into main, but the current-attempt
|
||
// convergence plus source-merge ledger remains the authoritative complete-set
|
||
// signal and avoids reconstructing a cross-Unit verdict from file presence. The
|
||
// exemption is granted only when EVERY unit of a valid DAG has a convergence
|
||
// row from the CURRENT stage attempt (rows before the latest main-workflow
|
||
// STAGE_STARTED for this slug are a prior run's). Anything ambiguous - not the
|
||
// swarm build stage, autonomy not granted, DAG absent/malformed, any
|
||
// unconverged unit - fails closed and leaves the guard exactly as strict as
|
||
// before. Duplicated rather than imported: state.ts is the dependency floor
|
||
// (orchestrate imports nothing from it and it must not import orchestrate).
|
||
function isSettledSwarmForArtifactGuard(
|
||
pd: string,
|
||
stage: { slug: string; phase: string; for_each?: string; mode?: string },
|
||
stateContent: string,
|
||
action: ReviewerPreconditionAction = "complete",
|
||
): boolean {
|
||
if (getField(stateContent, "Construction Iteration")?.trim() === "unit-major") {
|
||
return false;
|
||
}
|
||
if (!isAutonomousSwarmStage(pd, stateContent, stage)) return false;
|
||
const units = currentAttemptSwarmUnits(pd, stage.slug, action);
|
||
if (units === null || units.length === 0) return false;
|
||
// Shared attempt-scoped read (aidlc-lib.ts): a row counts only when its
|
||
// Stage names this slug AND its Run floor equals the current attempt's
|
||
// floor, so stale-attempt and cross-stage rows never satisfy the guard.
|
||
const converged = swarmConvergedUnits(pd, stage.slug);
|
||
return units.every((unit) => converged.has(unit));
|
||
}
|
||
|
||
function settledSwarmForArtifactGuardOrError(
|
||
pd: string,
|
||
stage: { slug: string; phase: string; for_each?: string; mode?: string },
|
||
stateContent: string,
|
||
action: ReviewerPreconditionAction,
|
||
): boolean {
|
||
try {
|
||
return isSettledSwarmForArtifactGuard(pd, stage, stateContent, action);
|
||
} catch (e) {
|
||
// A refusal the probe itself raised (a structural problem it named, or a
|
||
// guard it hit) is the answer, not an unexpected failure to wrap.
|
||
if (e instanceof StateCommandError || e instanceof StateGuardRefusalError) {
|
||
throw e;
|
||
}
|
||
error(
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: the settled-swarm probe failed unexpectedly ` +
|
||
`(${errorMessage(e)}). Restore readable state, audit, and Unit DAG evidence before retrying.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function currentAttemptSwarmUnits(
|
||
pd: string,
|
||
stageSlug: string,
|
||
action: ReviewerPreconditionAction,
|
||
): string[] | null {
|
||
const obligations = currentSwarmAttemptObligations(pd, stageSlug);
|
||
if (obligations.state === "invalid") {
|
||
error(
|
||
`${reviewerPreconditionPrefix(stageSlug, action)}: current swarm Unit obligations are invalid ` +
|
||
`(${obligations.reason}). Restore unit-of-work-dependency.md or restart the stage attempt.`,
|
||
);
|
||
}
|
||
const resolution = resolveBoltDag(pd);
|
||
if (obligations.state === "none") {
|
||
return resolution.state === "ok" ? resolution.units : null;
|
||
}
|
||
if (resolution.state !== "ok") {
|
||
error(
|
||
`${reviewerPreconditionPrefix(stageSlug, action)}: the current Unit DAG cannot be compared with ` +
|
||
`the attempt-bound Unit obligations. Restore unit-of-work-dependency.md or restart the stage attempt.`,
|
||
);
|
||
}
|
||
const live = new Set(resolution.units);
|
||
const missing = [...obligations.units].filter((unit) => !live.has(unit)).sort();
|
||
const added = resolution.units
|
||
.filter((unit) => !obligations.units.has(unit))
|
||
.sort();
|
||
if (missing.length > 0 || added.length > 0) {
|
||
const detail = [
|
||
missing.length > 0 ? `missing attempt Units: ${missing.join(", ")}` : "",
|
||
added.length > 0 ? `added Units: ${added.join(", ")}` : "",
|
||
].filter(Boolean).join("; ");
|
||
error(
|
||
`${reviewerPreconditionPrefix(stageSlug, action)}: the Unit DAG changed during the current swarm attempt ` +
|
||
`(${detail}). Restore unit-of-work-dependency.md to the attempt-bound Unit set or restart the stage attempt.`,
|
||
);
|
||
}
|
||
return [...obligations.units];
|
||
}
|
||
|
||
function verifySettledSwarmSourceBinding(
|
||
pd: string,
|
||
stage: { slug: string; workspace_requires?: boolean },
|
||
action: ReviewerPreconditionAction,
|
||
): void {
|
||
if (
|
||
stage.workspace_requires !== true ||
|
||
process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1"
|
||
) {
|
||
return;
|
||
}
|
||
const units = currentAttemptSwarmUnits(pd, stage.slug, action);
|
||
if (units === null) {
|
||
error(
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: its settled swarm Unit obligations cannot be resolved for the current attempt.`,
|
||
);
|
||
}
|
||
const chain = currentSwarmSourceMergeChain(pd, stage.slug);
|
||
if (chain.state === "none") {
|
||
error(
|
||
`Refusing to complete "${stage.slug}": every Bolt converged, but no current-attempt post-merge main-checkout source binding exists. Merge each reviewed source commit before approval.`,
|
||
);
|
||
}
|
||
if (chain.state === "invalid") {
|
||
error(
|
||
`Refusing to complete "${stage.slug}": its post-merge main-checkout source binding chain is invalid (${chain.reason}). Re-run the affected source merge or restart the Bolt attempt.`,
|
||
);
|
||
}
|
||
const missing = units.filter((unit) => !chain.units.has(unit));
|
||
if (missing.length > 0) {
|
||
error(
|
||
`Refusing to complete "${stage.slug}": ${missing.length} converged unit(s) have no current-attempt post-merge source binding (${missing.join(", ")}). Merge every reviewed source commit before approval.`,
|
||
);
|
||
}
|
||
const current = workspaceSourceState(pd);
|
||
if (current === null || current.fingerprint !== chain.fingerprint) {
|
||
error(
|
||
`Refusing to complete "${stage.slug}": the main checkout source no longer matches the final reviewed swarm merge (source-fingerprint mismatch). Revert the unreviewed edit or restart and re-review the affected Bolt.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function priorAcceptedSourceFields(
|
||
pd: string,
|
||
stage: { slug: string; workspace_requires?: boolean },
|
||
): Record<string, string> {
|
||
if (stage.workspace_requires !== true) return {};
|
||
const chain = currentSwarmSourceMergeChain(pd, stage.slug);
|
||
return chain.state === "ready"
|
||
? { "Prior Accepted Source Fingerprint": chain.fingerprint }
|
||
: {};
|
||
}
|
||
|
||
// Deterministic off-switch for the approve-time gate-revision backstop (mirrors
|
||
// artifactGuardDisabled above). The suite sets this globally so no existing
|
||
// approve/reject test changes behaviour; the dedicated backstop test clears it
|
||
// to exercise the real reconciliation.
|
||
function revisionBackstopDisabled(): boolean {
|
||
return resolveProjectFlag("AIDLC_SKIP_REVISION_BACKSTOP") === "1";
|
||
}
|
||
|
||
// Resolve the directories a stage's produces[] artifacts would live under,
|
||
// mirroring aidlc-orchestrate.ts's resolveArtifactPath against the v2 per-intent
|
||
// seams. Three placement classes:
|
||
// - codekb (reverse-engineering): the produces live DIRECTLY under each
|
||
// registered repo dir beneath the space-level codekb root (no <slug> subdir
|
||
// - see the codekb arm of resolveArtifactPath).
|
||
// - per-unit Construction (for_each: unit-of-work): the {unit} segment is
|
||
// unknown at approve/advance time, so we glob every
|
||
// <record>/construction/<unit>/<slug>/ instead of resolving one.
|
||
// - everything else: <record>/<phase>/<slug>/.
|
||
// Returns [] when no active intent record resolves (recordDir null) - a stage
|
||
// that declares produces then vacuously fails the existence check, which is the
|
||
// correct refusal (there is no record to have written them to).
|
||
function producesDirsForStage(
|
||
pd: string,
|
||
stage: { slug: string; phase: string; for_each?: string }
|
||
): string[] {
|
||
if (KNOWN_CODEKB_STAGES.has(stage.slug)) {
|
||
const repos = intentRepos(pd);
|
||
const resolved = repos.length > 0 ? repos : [codekbRepoName(pd)];
|
||
return resolved.map((repo) => codekbDir(pd, repo));
|
||
}
|
||
const rec = recordDir(pd);
|
||
if (rec === null) return [];
|
||
const perUnit = stage.for_each === "unit-of-work";
|
||
if (perUnit) {
|
||
const stateContent = readStateFile(pd);
|
||
const scope = getField(stateContent, "Scope");
|
||
if (usesStageLevelPerUnitArtifacts(scope, stateContent)) {
|
||
return [join(rec, "construction", stage.slug)];
|
||
}
|
||
const ctorRoot = join(rec, "construction");
|
||
if (!existsSync(ctorRoot)) return [];
|
||
const dirs: string[] = [];
|
||
for (const unit of readdirSync(ctorRoot)) {
|
||
const d = join(ctorRoot, unit, stage.slug);
|
||
if (existsSync(d)) dirs.push(d);
|
||
}
|
||
return dirs;
|
||
}
|
||
return [join(rec, stage.phase, stage.slug)];
|
||
}
|
||
|
||
// True when at least one declared produces[] artifact exists on disk under the
|
||
// stage's resolved directory. A stage with empty produces[] vacuously passes.
|
||
//
|
||
// Unit-kind all-vacuous exemption: a per-unit stage carrying a produces_kinds
|
||
// map can legitimately owe ZERO artifacts across ALL units (e.g. a workflow of
|
||
// only packaging units on functional-design). No unit ever wrote a per-unit
|
||
// dir, so the ANY-exists glob below is empty and would refuse the approval the
|
||
// engine just presented. When a bolt_dag with unit kinds exists AND every unit
|
||
// in the dag filters to an empty required set, return true (the stage does not
|
||
// apply to any unit). Any unit owing any artifact leaves the ANY-exists check
|
||
// exactly as strict as today.
|
||
function producesArtifactsExist(
|
||
pd: string,
|
||
stage: { slug: string; phase: string; for_each?: string; produces?: string[]; produces_kinds?: Record<string, string[]> }
|
||
): boolean {
|
||
const produces = stage.produces ?? [];
|
||
if (produces.length === 0) return true; // nothing declared -> nothing to verify
|
||
if (stage.for_each === "unit-of-work" && stage.produces_kinds !== undefined) {
|
||
const stateContent = readStateFile(pd);
|
||
const scope = getField(stateContent, "Scope");
|
||
if (!usesStageLevelPerUnitArtifacts(scope, stateContent)) {
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "ok" && resolution.unitKinds !== null) {
|
||
const allVacuous = resolution.units.every(
|
||
(u) =>
|
||
filterProducesByKind(
|
||
stage.produces_kinds,
|
||
produces,
|
||
resolution.unitKinds?.get(u) ?? null,
|
||
).length === 0,
|
||
);
|
||
if (allVacuous) return true;
|
||
}
|
||
}
|
||
}
|
||
const dirs = producesDirsForStage(pd, stage);
|
||
if (KNOWN_CODEKB_STAGES.has(stage.slug)) {
|
||
return dirs.length > 0 && dirs.every((dir) =>
|
||
produces.every((name) =>
|
||
isRegularFile(join(dir, artifactFilename(name)))
|
||
)
|
||
);
|
||
}
|
||
for (const dir of dirs) {
|
||
for (const name of produces) {
|
||
if (isRegularFile(join(dir, artifactFilename(name)))) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
interface SensorFireVerdict {
|
||
fire_id: string;
|
||
sensor_id: string;
|
||
stage: string;
|
||
output_path: string;
|
||
result: "passed" | "failed" | "budget-override";
|
||
detail_path: string | null;
|
||
note?: string;
|
||
}
|
||
|
||
interface BlockingSensorIssue {
|
||
sensorId: string;
|
||
outputPath: string;
|
||
detailPath: string | null;
|
||
reason: string;
|
||
}
|
||
|
||
interface GateSensorEvaluation {
|
||
issues: BlockingSensorIssue[];
|
||
fingerprints: Map<string, string>;
|
||
}
|
||
|
||
function pathIsWithin(root: string, candidate: string): boolean {
|
||
const rel = relative(root, candidate);
|
||
return rel === "" ||
|
||
(rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
||
}
|
||
|
||
// Resolve every existing declared deliverable, reusing the same stage directory
|
||
// placement as producesArtifactsExist. --artifacts may name resolved paths or
|
||
// filenames; it supplements the graph walk for optional/custom emitters but
|
||
// cannot introduce an undeclared deliverable or escape a canonical produce dir.
|
||
function existingDeclaredArtifactPaths(
|
||
pd: string,
|
||
stage: {
|
||
slug: string;
|
||
phase: string;
|
||
for_each?: string;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
},
|
||
artifacts?: string,
|
||
): string[] {
|
||
const names = [
|
||
...(stage.produces ?? []),
|
||
...(stage.optional_produces ?? []),
|
||
];
|
||
const declaredFilenames = new Set(names.map(artifactFilename));
|
||
const dirs = producesDirsForStage(pd, stage);
|
||
const projectRoot = realpathSync(pd);
|
||
const roots = dirs.flatMap((dir) => {
|
||
if (!existsSync(dir)) return [];
|
||
let canonical: string;
|
||
try {
|
||
canonical = realpathSync(dir);
|
||
if (!statSync(canonical).isDirectory()) return [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
if (!pathIsWithin(projectRoot, canonical)) {
|
||
error(
|
||
`Refusing gate sensor dispatch for "${stage.slug}": resolved produce directory ` +
|
||
`"${dir}" escapes the project root after canonicalization.`,
|
||
);
|
||
}
|
||
return [canonical];
|
||
});
|
||
const found = new Set<string>();
|
||
const addIfDeclaredFile = (candidate: string): void => {
|
||
if (!declaredFilenames.has(basename(candidate)) || !existsSync(candidate)) {
|
||
return;
|
||
}
|
||
let canonical: string;
|
||
try {
|
||
canonical = realpathSync(candidate);
|
||
if (!statSync(canonical).isFile()) return;
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!roots.some((root) => pathIsWithin(root, canonical))) {
|
||
error(
|
||
`Refusing gate sensor artifact "${candidate}" for "${stage.slug}": ` +
|
||
"the canonical path is outside the stage's resolved produce directories.",
|
||
);
|
||
}
|
||
found.add(canonical);
|
||
};
|
||
|
||
for (const dir of dirs) {
|
||
for (const filename of declaredFilenames) {
|
||
addIfDeclaredFile(join(dir, filename));
|
||
}
|
||
}
|
||
|
||
for (const token of (artifacts ?? "").split(",")) {
|
||
const value = token.trim();
|
||
if (!value) continue;
|
||
const variants = declaredFilenames.has(basename(value))
|
||
? [value]
|
||
: [value, artifactFilename(value)];
|
||
for (const variant of variants) {
|
||
if (
|
||
isAbsolute(variant) ||
|
||
variant.includes("/") ||
|
||
variant.includes("\\")
|
||
) {
|
||
addIfDeclaredFile(
|
||
isAbsolute(variant) ? variant : resolvePath(pd, variant),
|
||
);
|
||
}
|
||
for (const dir of dirs) addIfDeclaredFile(join(dir, variant));
|
||
}
|
||
}
|
||
|
||
return [...found];
|
||
}
|
||
|
||
function parseSensorFireVerdict(stdout: string): SensorFireVerdict | null {
|
||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
try {
|
||
const value = JSON.parse(lines[i]) as Partial<SensorFireVerdict>;
|
||
if (
|
||
typeof value.fire_id === "string" &&
|
||
typeof value.sensor_id === "string" &&
|
||
typeof value.stage === "string" &&
|
||
typeof value.output_path === "string" &&
|
||
(value.result === "passed" ||
|
||
value.result === "failed" ||
|
||
value.result === "budget-override") &&
|
||
(value.detail_path === null || typeof value.detail_path === "string") &&
|
||
(value.note === undefined || typeof value.note === "string")
|
||
) {
|
||
return value as SensorFireVerdict;
|
||
}
|
||
} catch {
|
||
// Keep scanning in case a wrapper wrote a banner before the JSON verdict.
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function gateSensorDispatchTimeoutMs(): number | undefined {
|
||
const raw = process.env.AIDLC_GATE_SENSOR_DISPATCH_TIMEOUT_MS;
|
||
if (!raw) return undefined;
|
||
const parsed = Number.parseInt(raw, 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||
}
|
||
|
||
function diagnosticSnippet(value: string | null | undefined): string {
|
||
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 240);
|
||
}
|
||
|
||
function gateSensorMatchesOutput(
|
||
sensor: NonNullable<
|
||
NonNullable<ReturnType<typeof findStageBySlug>>["sensors_applicable"]
|
||
>[number],
|
||
outputPath: string,
|
||
): boolean {
|
||
return sensor.matches === undefined ||
|
||
new Bun.Glob(sensor.matches).match(outputPath.replace(/\\/g, "/"));
|
||
}
|
||
|
||
function artifactFingerprint(path: string): string | null {
|
||
try {
|
||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function fireGateSensors(
|
||
pd: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
artifacts?: string,
|
||
): GateSensorEvaluation {
|
||
const paths = existingDeclaredArtifactPaths(pd, stage, artifacts);
|
||
const issues: BlockingSensorIssue[] = [];
|
||
const fingerprints = new Map<string, string>();
|
||
if (paths.length === 0) return { issues, fingerprints };
|
||
|
||
const sensors = (stage.sensors_applicable ?? []).filter((sensor) =>
|
||
sensor.fire_on === "gate"
|
||
);
|
||
for (const sensor of sensors) {
|
||
if (sensor.default_severity !== "blocking") continue;
|
||
for (const outputPath of paths) {
|
||
if (!gateSensorMatchesOutput(sensor, outputPath) || fingerprints.has(outputPath)) {
|
||
continue;
|
||
}
|
||
const fingerprint = artifactFingerprint(outputPath);
|
||
if (fingerprint === null) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason: "artifact became unreadable before sensor dispatch",
|
||
});
|
||
continue;
|
||
}
|
||
fingerprints.set(outputPath, fingerprint);
|
||
}
|
||
}
|
||
|
||
const executable = compiledExecutable();
|
||
const sensorTool = fileURLToPath(new URL("./aidlc-sensor.ts", import.meta.url));
|
||
for (const sensor of sensors) {
|
||
for (const outputPath of paths) {
|
||
if (!gateSensorMatchesOutput(sensor, outputPath)) continue;
|
||
const expectedFingerprint = fingerprints.get(outputPath);
|
||
if (
|
||
sensor.default_severity === "blocking" &&
|
||
(
|
||
expectedFingerprint === undefined ||
|
||
artifactFingerprint(outputPath) !== expectedFingerprint
|
||
)
|
||
) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason: "artifact changed before sensor dispatch",
|
||
});
|
||
continue;
|
||
}
|
||
const args = [
|
||
"fire",
|
||
sensor.id,
|
||
"--stage",
|
||
stage.slug,
|
||
"--output-path",
|
||
outputPath,
|
||
];
|
||
const command = executable
|
||
? [executable, "sensor", ...args]
|
||
: [process.execPath, sensorTool, ...args];
|
||
const result = spawnSync(command[0], command.slice(1), {
|
||
cwd: pd,
|
||
encoding: "utf-8",
|
||
timeout: gateSensorDispatchTimeoutMs(),
|
||
env: {
|
||
...process.env,
|
||
AIDLC_PROJECT_DIR: pd,
|
||
CLAUDE_PROJECT_DIR: pd,
|
||
},
|
||
});
|
||
if (sensor.default_severity !== "blocking") continue;
|
||
if (artifactFingerprint(outputPath) !== expectedFingerprint) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason: "artifact changed during sensor evaluation",
|
||
});
|
||
continue;
|
||
}
|
||
if (result.error) {
|
||
const code = (result.error as NodeJS.ErrnoException).code ?? "unknown";
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason:
|
||
code === "ETIMEDOUT"
|
||
? "dispatcher timeout"
|
||
: `dispatcher spawn failed (${code})`,
|
||
});
|
||
continue;
|
||
}
|
||
if (result.status !== 0) {
|
||
const stderr = diagnosticSnippet(result.stderr);
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason:
|
||
result.status === null
|
||
? `dispatcher terminated by ${result.signal ?? "unknown signal"}`
|
||
: `dispatcher exited ${result.status}${stderr ? `: ${stderr}` : ""}`,
|
||
});
|
||
continue;
|
||
}
|
||
const verdict = parseSensorFireVerdict(result.stdout ?? "");
|
||
if (verdict === null) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason: "dispatcher returned no valid verdict",
|
||
});
|
||
continue;
|
||
}
|
||
let verdictOutputPath: string | null = null;
|
||
try {
|
||
verdictOutputPath = realpathSync(
|
||
isAbsolute(verdict.output_path)
|
||
? verdict.output_path
|
||
: resolvePath(pd, verdict.output_path),
|
||
);
|
||
} catch {
|
||
// The exact output file is already known to exist; an unresolvable
|
||
// verdict path is invalid evaluation evidence.
|
||
}
|
||
if (
|
||
verdict.sensor_id !== sensor.id ||
|
||
verdict.stage !== stage.slug ||
|
||
verdictOutputPath !== outputPath
|
||
) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath,
|
||
detailPath: null,
|
||
reason: "dispatcher verdict identity did not match the requested fire",
|
||
});
|
||
continue;
|
||
}
|
||
if (verdict.result === "failed" && verdict.detail_path !== null) {
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath: verdict.output_path,
|
||
detailPath: verdict.detail_path,
|
||
reason: "reported findings",
|
||
});
|
||
continue;
|
||
}
|
||
if (verdict.result === "passed" && verdict.note === undefined) continue;
|
||
issues.push({
|
||
sensorId: sensor.id,
|
||
outputPath: verdict.output_path,
|
||
detailPath: verdict.detail_path,
|
||
reason:
|
||
verdict.result === "passed"
|
||
? verdict.note ?? "invalid passed verdict"
|
||
: verdict.result === "budget-override"
|
||
? "sensor execution exceeded its budget"
|
||
: "failed verdict omitted its detail path",
|
||
});
|
||
}
|
||
}
|
||
return { issues, fingerprints };
|
||
}
|
||
|
||
function verifyGateSensorArtifactsUnchanged(
|
||
slug: string,
|
||
evaluation: GateSensorEvaluation,
|
||
): void {
|
||
for (const [outputPath, fingerprint] of evaluation.fingerprints) {
|
||
if (artifactFingerprint(outputPath) === fingerprint) continue;
|
||
error(
|
||
`Refusing to open the gate for "${slug}": blocking sensor artifact ` +
|
||
`"${outputPath}" changed after evaluation. Retry so every blocking sensor ` +
|
||
"checks the bytes that enter the gate.",
|
||
);
|
||
}
|
||
}
|
||
|
||
function auditEventIsAfter(
|
||
later: ReturnType<typeof readAuditShardEvents>[number],
|
||
earlier: ReturnType<typeof readAuditShardEvents>[number],
|
||
): boolean {
|
||
if (later.shard === earlier.shard) return later.pos > earlier.pos;
|
||
return later.timestamp > earlier.timestamp;
|
||
}
|
||
|
||
function hasBlockingSensorOverrideAuthorization(
|
||
pd: string,
|
||
slug: string,
|
||
): boolean {
|
||
const rows = readAuditShardEvents(pd);
|
||
const stageRows = rows.filter((row) => {
|
||
const workflow = auditField(row.block, "Workflow");
|
||
return auditField(row.block, "Stage") === slug &&
|
||
!workflow?.startsWith("single-stage:");
|
||
});
|
||
const boundaries = stageRows.filter((row) =>
|
||
row.event === "STAGE_STARTED" ||
|
||
row.event === "STAGE_AWAITING_APPROVAL" ||
|
||
row.event === "GATE_REJECTED"
|
||
);
|
||
const interactions = stageRows.filter((row) =>
|
||
row.event === "DECISION_RECORDED" ||
|
||
row.event === "QUESTION_ANSWERED"
|
||
);
|
||
const answers = interactions.filter((row) =>
|
||
row.event === "QUESTION_ANSWERED" &&
|
||
auditField(row.block, "Details") === BLOCKING_SENSOR_OVERRIDE_CHOICE
|
||
);
|
||
|
||
for (const answer of answers) {
|
||
if (!boundaries.every((boundary) => auditEventIsAfter(answer, boundary))) {
|
||
continue;
|
||
}
|
||
if (
|
||
!interactions.every((row) =>
|
||
row === answer || auditEventIsAfter(answer, row)
|
||
)
|
||
) {
|
||
continue;
|
||
}
|
||
const decision = interactions.findLast((row) => {
|
||
if (
|
||
row.event !== "DECISION_RECORDED" ||
|
||
row.shard !== answer.shard ||
|
||
row.pos >= answer.pos ||
|
||
auditField(row.block, "Decision") !== BLOCKING_SENSOR_OVERRIDE_DECISION
|
||
) {
|
||
return false;
|
||
}
|
||
const options = (auditField(row.block, "Options") ?? "")
|
||
.split(",")
|
||
.map((option) => option.trim());
|
||
return BLOCKING_SENSOR_OVERRIDE_OPTIONS.every((option) =>
|
||
options.includes(option)
|
||
);
|
||
});
|
||
if (!decision) continue;
|
||
const humanTurn = rows.some((row) =>
|
||
row.event === "HUMAN_TURN" &&
|
||
row.shard === answer.shard &&
|
||
row.pos > decision.pos &&
|
||
row.pos < answer.pos
|
||
);
|
||
if (humanTurn) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function enforceBlockingGateSensors(
|
||
pd: string,
|
||
stateContent: string,
|
||
slug: string,
|
||
reportResult: "awaiting-approval" | "revised",
|
||
issues: BlockingSensorIssue[],
|
||
overrideRequested: boolean,
|
||
overrideInput?: string,
|
||
): void {
|
||
if (issues.length === 0) return;
|
||
const sensorIds = [...new Set(issues.map((issue) => issue.sensorId))];
|
||
const detailPaths = issues.flatMap((issue) =>
|
||
issue.detailPath === null ? [] : [issue.detailPath]
|
||
);
|
||
const reasons = issues.map(
|
||
(issue) => `${issue.sensorId}@${issue.outputPath}: ${issue.reason}`,
|
||
);
|
||
if (overrideRequested) {
|
||
if (isAutonomousMode(stateContent)) {
|
||
error(
|
||
`Refusing blocking sensor override for "${slug}": Construction Autonomy Mode ` +
|
||
"is autonomous. Unattended runs must halt on blocking sensor findings or " +
|
||
"unavailable evaluations.",
|
||
);
|
||
}
|
||
if (overrideInput?.trim() !== BLOCKING_SENSOR_OVERRIDE_CHOICE) {
|
||
error(
|
||
`Refusing blocking sensor override for "${slug}": --user-input must be the ` +
|
||
`exact offered choice "${BLOCKING_SENSOR_OVERRIDE_CHOICE}".`,
|
||
);
|
||
}
|
||
if (!hasBlockingSensorOverrideAuthorization(pd, slug)) {
|
||
error(
|
||
`Refusing blocking sensor override for "${slug}": no fresh authorization receipt ` +
|
||
`proves that "${BLOCKING_SENSOR_OVERRIDE_CHOICE}" was offered and selected after ` +
|
||
"a HUMAN_TURN. Record the decision and exact answer through aidlc-log.ts, then retry.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
error(
|
||
`Blocking gate sensor evaluation did not pass for "${slug}". Sensors: ` +
|
||
`${sensorIds.join(", ")}. Detail paths: ${detailPaths.join(", ") || "none"}. ` +
|
||
`Reasons: ${reasons.join("; ")}. Fix the findings and retry, or first run ` +
|
||
`bun ${harnessDir()}/tools/aidlc-log.ts decision --stage ${slug} ` +
|
||
`--decision "${BLOCKING_SENSOR_OVERRIDE_DECISION}" --options ` +
|
||
`"${BLOCKING_SENSOR_OVERRIDE_OPTIONS.join(",")}", present those choices, and ` +
|
||
`after the human selects "${BLOCKING_SENSOR_OVERRIDE_CHOICE}" record it with ` +
|
||
`aidlc-log.ts answer. Then retry: bun ${harnessDir()}/tools/aidlc-orchestrate.ts ` +
|
||
`report --stage ${slug} --result ${reportResult} --override-blocking-sensors ` +
|
||
`--user-input "${BLOCKING_SENSOR_OVERRIDE_CHOICE}". Autonomous mode cannot override.`,
|
||
);
|
||
}
|
||
|
||
function addBlockingSensorOverrideFields(
|
||
fields: Record<string, string>,
|
||
issues: BlockingSensorIssue[],
|
||
overrideRequested: boolean,
|
||
): void {
|
||
if (issues.length === 0 || !overrideRequested) return;
|
||
fields["Blocking Sensor Override"] = "true";
|
||
fields["Blocking Sensor IDs"] = [
|
||
...new Set(issues.map((issue) => issue.sensorId)),
|
||
].join(",");
|
||
const detailPaths = issues.flatMap((issue) =>
|
||
issue.detailPath === null ? [] : [issue.detailPath]
|
||
);
|
||
if (detailPaths.length > 0) {
|
||
fields["Blocking Sensor Detail Paths"] = detailPaths.join(",");
|
||
}
|
||
fields["Blocking Sensor Reasons"] = issues
|
||
.map((issue) => `${issue.sensorId}: ${issue.reason}`)
|
||
.join("; ");
|
||
}
|
||
|
||
function verifyGateOpeningGuards(
|
||
pd: string,
|
||
content: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
): void {
|
||
verifyStageArtifacts(pd, stage, "present-approval-gate");
|
||
verifySummaryConfirmationPrecondition(pd, content, stage);
|
||
verifyPipelineLinkPrecondition(pd, stage);
|
||
if (!reviewerGateGuardDisabled()) {
|
||
verifyReviewerPrecondition(pd, content, stage, "present-approval-gate");
|
||
}
|
||
}
|
||
|
||
// True when any non-doc file exists in the workspace - a file outside the
|
||
// aidlc/ workspace tree and the harness dirs. Bounded shallow walk (one level
|
||
// into each top-level dir is enough to detect src/<file>); avoids a full
|
||
// recursive scan.
|
||
function workspaceHasSourceFile(pd: string): boolean {
|
||
let entries: string[];
|
||
try {
|
||
entries = readdirSync(pd);
|
||
} catch {
|
||
return false;
|
||
}
|
||
for (const entry of entries) {
|
||
if (HARNESS_DOC_DIRS.has(entry)) continue;
|
||
const p = join(pd, entry);
|
||
let st: ReturnType<typeof statSync>;
|
||
try {
|
||
st = statSync(p);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (st.isFile()) return true; // a file at workspace root counts
|
||
if (st.isDirectory()) {
|
||
// Any file anywhere beneath a non-harness top-level dir (e.g. src/).
|
||
try {
|
||
if (dirHasFile(p)) return true;
|
||
} catch {
|
||
/* unreadable dir - skip */
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Recursive existence probe: does this directory contain any file? Short-
|
||
// circuits on the first file found.
|
||
function dirHasFile(dir: string): boolean {
|
||
for (const entry of readdirSync(dir)) {
|
||
const p = join(dir, entry);
|
||
const st = statSync(p);
|
||
if (st.isFile()) return true;
|
||
if (st.isDirectory() && dirHasFile(p)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// A git-reported path (status --porcelain or diff --name-only output) counts as
|
||
// "source work" when its FIRST segment is not a harness/doc dir - i.e. it is a
|
||
// real workspace file (src/..., a root file), not an aidlc/ planning doc or
|
||
// framework file. Mirrors HARNESS_DOC_DIRS, the same set the FS walk skips.
|
||
function isNonDocPath(p: string): boolean {
|
||
const rel = p.trim().replace(/^"|"$/g, ""); // git -z not used; strip any quoting
|
||
if (rel.length === 0) return false;
|
||
const firstSeg = rel.split("/")[0];
|
||
return !HARNESS_DOC_DIRS.has(firstSeg);
|
||
}
|
||
|
||
// Run git in the workspace, fail-safe: returns null on any spawn/exec problem so
|
||
// callers fall back to the filesystem check rather than trapping.
|
||
function git(pd: string, args: string[]): string | null {
|
||
try {
|
||
const r = spawnSync("git", args, {
|
||
cwd: pd,
|
||
encoding: "utf-8",
|
||
timeout: 30_000,
|
||
});
|
||
if (r.status !== 0 || typeof r.stdout !== "string") return null;
|
||
return r.stdout;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// True when `pd` is inside a git work tree. (`--is-inside-work-tree` prints
|
||
// "true"/"false"; a non-repo exits non-zero -> git() returns null -> false.)
|
||
function isGitRepo(pd: string): boolean {
|
||
return git(pd, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
|
||
}
|
||
|
||
// Git-aware "did this workspace get real source work?" signal (issue #366
|
||
// Update 3). Distinguishes "code produced this session" from a brownfield repo's
|
||
// pre-existing src/ - which the bare filesystem check cannot. True when EITHER:
|
||
// 1. the working tree has an uncommitted/untracked non-doc change
|
||
// (`git status --porcelain`), OR
|
||
// 2. the last commit touched a non-doc path (`git diff --name-only HEAD~1 HEAD`)
|
||
// - so commit-then-approve (clean tree) still passes, closing Update 3's
|
||
// clean-working-tree false-block.
|
||
// Returns null (NOT false) on any git error or a HEAD~1 miss (a single-commit or
|
||
// 0-commit repo has no parent to diff), so the caller falls back to the
|
||
// filesystem check rather than wrongly refusing a greenfield first commit. A
|
||
// resolved HEAD~1 whose last commit is doc-only returns false (a real
|
||
// "no recent code", e.g. a brownfield clean tree), so the guard still refuses.
|
||
function gitHasSourceWork(pd: string): boolean | null {
|
||
const porcelain = git(pd, ["status", "--porcelain"]);
|
||
if (porcelain === null) return null;
|
||
// `XY <path>` per line; renames are `orig -> new` (take the new path).
|
||
for (const line of porcelain.split("\n")) {
|
||
if (line.trim().length === 0) continue;
|
||
const pathPart = line.slice(3);
|
||
const candidate = pathPart.includes(" -> ")
|
||
? pathPart.split(" -> ")[1]
|
||
: pathPart;
|
||
if (isNonDocPath(candidate)) return true;
|
||
}
|
||
// Clean (or doc-only) working tree - check whether the LAST commit added code,
|
||
// covering the commit-then-approve pattern. HEAD~1 is absent on the very first
|
||
// commit; that diff errors -> git() returns null.
|
||
const lastCommit = git(pd, ["diff", "--name-only", "HEAD~1", "HEAD"]);
|
||
if (lastCommit !== null) {
|
||
for (const line of lastCommit.split("\n")) {
|
||
if (isNonDocPath(line)) return true;
|
||
}
|
||
// HEAD~1 resolved and the last commit was doc-only: a definitive "no recent
|
||
// code" (e.g. a brownfield repo whose src/ predates this session), so return
|
||
// false to refuse - the FS fallback would wrongly pass on the pre-existing
|
||
// src/.
|
||
return false;
|
||
}
|
||
// HEAD~1 did NOT resolve (a single-commit repo has no parent): we could not
|
||
// inspect the last commit at all, so this is the documented "0-commit / HEAD~1
|
||
// miss" case - return null (NOT false) so the caller falls back to the
|
||
// filesystem probe rather than false-refusing a greenfield first-commit whose
|
||
// sole commit holds the source.
|
||
return null;
|
||
}
|
||
|
||
// The workspace_requires signal: git-aware when the workspace is a git repo
|
||
// (precise - tells session-produced code from a brownfield baseline), else the
|
||
// filesystem-existence fallback (shell-free, reliable in non-git workspaces and
|
||
// the test fixtures). Fail-open: a git error falls back to the FS check.
|
||
function workspaceHasWork(pd: string): boolean {
|
||
if (isGitRepo(pd)) {
|
||
const gitVerdict = gitHasSourceWork(pd);
|
||
if (gitVerdict !== null) return gitVerdict;
|
||
}
|
||
return workspaceHasSourceFile(pd);
|
||
}
|
||
|
||
// The guard itself. Called from approve/advance/finalize/complete-workflow
|
||
// BEFORE any state mutation, so a refusal (error() -> process.exit) leaves state
|
||
// untouched. `stage` is the StageEntry being completed. No-op when bypass active.
|
||
function verifyStageArtifacts(
|
||
pd: string,
|
||
stage: { slug: string; name: string; phase: string; for_each?: string; mode?: string; produces?: string[]; produces_kinds?: Record<string, string[]>; workspace_requires?: boolean },
|
||
action: ReviewerPreconditionAction = "complete",
|
||
): void {
|
||
if (artifactGuardDisabled()) return;
|
||
|
||
// A settled autonomous swarm proved its work through the referee's per-unit
|
||
// convergence and source-merge ledger. Reviewed records now land in main, but
|
||
// file presence alone cannot replace that complete-set authority.
|
||
let settledSwarm = false;
|
||
let stateContent: string | null = null;
|
||
try {
|
||
stateContent = readStateFile(pd);
|
||
} catch {
|
||
// No readable state file: not a swarm settle; stay strict.
|
||
}
|
||
if (stateContent !== null) {
|
||
settledSwarm = settledSwarmForArtifactGuardOrError(
|
||
pd,
|
||
stage,
|
||
stateContent,
|
||
action,
|
||
);
|
||
}
|
||
if (settledSwarm) return;
|
||
|
||
if (!producesArtifactsExist(pd, stage)) {
|
||
const message =
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: none of its declared artifacts exist ` +
|
||
`under the intent's record directory. The stage protocol requires ${stage.name} ` +
|
||
`to produce output before the gate. Produce the artifacts before completing. ` +
|
||
`(declared: ${(stage.produces ?? []).join(", ") || "none"})`;
|
||
refuseStateGuard(pd, stateContent ?? readStateFile(pd), stage, {
|
||
code: "REQUIRED_ARTIFACTS_MISSING",
|
||
blockedAction: action,
|
||
invariant: "Every applicable required output exists before certification.",
|
||
userMessage: message,
|
||
});
|
||
}
|
||
|
||
if (stage.workspace_requires && !workspaceHasWork(pd)) {
|
||
const message =
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: it is a code-producing stage ` +
|
||
`(workspace_requires) but no source work is evident outside the aidlc/ ` +
|
||
`workspace tree. In a git workspace this means no uncommitted change and no ` +
|
||
`code in the last commit; otherwise no source file exists. Planning docs alone ` +
|
||
`do not satisfy ${stage.name} - write the code to the workspace.`;
|
||
refuseStateGuard(pd, stateContent ?? readStateFile(pd), stage, {
|
||
code: "REQUIRED_SOURCE_WORK_MISSING",
|
||
blockedAction: action,
|
||
invariant: "A workspace-producing stage contains application source work.",
|
||
userMessage: message,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Change Control at a governed checkpoint of this tool. The shared read-only
|
||
// checks (the summary evidence, the receipt scan) read the setting only when
|
||
// they meet an input change after an approval, and say so; they fall back to
|
||
// strict on a resolution failure. When one has read it, this tool resolves it
|
||
// again as the mutating caller: an invalid memory `Mode:` value is then the
|
||
// validation error naming the file and the allowed values, and a transition
|
||
// records a memory edit that moved the value. Then it writes the CHANGE_ACCEPTED
|
||
// rows for the changes the check accepted under `relaxed` and prints the
|
||
// one-line sentences as their own JSON line so the engine's report carries them
|
||
// onto its directive and a direct caller sees them the same way. The engine's
|
||
// in-process preflight runs the same admission checks as a question, not a
|
||
// transition, so it resolves without writing and observes nothing.
|
||
function observeChangeControl(
|
||
pd: string,
|
||
content: string,
|
||
checked: { changeControlRead?: boolean; acceptedChanges?: AcceptedChange[] },
|
||
): void {
|
||
const selection = resolveWorkflowSelection(pd);
|
||
const changeControlSelection = {
|
||
intent: selection.intent ?? undefined,
|
||
space: selection.space,
|
||
};
|
||
if (!checked.changeControlRead) return;
|
||
if (changeControlPreflight) {
|
||
resolveChangeControl(pd, content, { selection: changeControlSelection });
|
||
return;
|
||
}
|
||
governedChangeControl(pd, content, changeControlSelection);
|
||
const notices = recordAcceptedChanges(
|
||
pd,
|
||
checked.acceptedChanges ?? [],
|
||
changeControlSelection,
|
||
);
|
||
if (notices.length > 0) console.log(JSON.stringify({ change_notices: notices }));
|
||
}
|
||
|
||
function verifySummaryConfirmationPrecondition(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
name: string;
|
||
phase: string;
|
||
outputs?: string;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
for_each?: string;
|
||
summary_confirmation?: "required" | "if-present";
|
||
},
|
||
): void {
|
||
const evidence = checkSummaryConfirmationEvidence(pd, stage, {
|
||
stateContent: content,
|
||
});
|
||
observeChangeControl(pd, content, evidence);
|
||
if (evidence.ok) return;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: evidence.refusal?.code ?? "SUMMARY_EVIDENCE_INVALID",
|
||
blockedAction: "summary-confirmation",
|
||
invariant:
|
||
evidence.refusal?.invariant ??
|
||
"Generated outputs descend from a current human-backed summary confirmation.",
|
||
userMessage: evidence.message,
|
||
summaryCoverage: evidence.summaryCoverage,
|
||
});
|
||
}
|
||
|
||
// --- Reviewer precondition (§12a / RFC Track 1) -----------------------------
|
||
//
|
||
// A stage that declares a `reviewer` cannot open its approval gate or complete
|
||
// until the reviewer step actually ran — proven by a terminal REVIEW_COMPLETED
|
||
// row (written by the tool actor `aidlc-log.ts review --verdict`). Hard on the
|
||
// review HAVING HAPPENED, soft on the verdict (a NOT-READY-after-cap still lets
|
||
// the human approve).
|
||
//
|
||
// This lives beside the artifact guard in both gate-opening handlers and all
|
||
// four completing handlers, not in orchestrate's report: direct recovery calls
|
||
// must not bypass it (issues #366 and #551).
|
||
//
|
||
// The audit read is FLOORED (mirrors swarmConvergedUnits / hasStageAuditEvent):
|
||
// only REVIEW_COMPLETED rows recorded AFTER the stage's latest STAGE_STARTED,
|
||
// any later GATE_REJECTED, and the latest relevant produces[] write count.
|
||
// Per-unit artifact writes invalidate only that unit's receipt. Without these
|
||
// floors a stale review from a prior stage-run, before a reject/revise, or
|
||
// before an artifact edit would clear the gate for work nobody re-reviewed.
|
||
//
|
||
// The row must match BOTH Stage AND Reviewer (a row naming the wrong reviewer —
|
||
// a typo, or the conductor self-certifying — must not satisfy it). On per-unit
|
||
// stages (for_each: unit-of-work) one review per stage is not enough: the
|
||
// Review accounting is per Unit, so EVERY unit must carry its own terminal review.
|
||
//
|
||
type ReviewerPreconditionAction = "complete" | "present-approval-gate";
|
||
|
||
function reviewerPreconditionPrefix(
|
||
slug: string,
|
||
action: ReviewerPreconditionAction,
|
||
): string {
|
||
return action === "present-approval-gate"
|
||
? `Cannot present "${slug}" for approval`
|
||
: `Cannot complete "${slug}"`;
|
||
}
|
||
|
||
function refuseStateGuard(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
phase?: string;
|
||
for_each?: string;
|
||
reviewer?: string;
|
||
review_artifact?: string;
|
||
reviewer_max_iterations?: number;
|
||
review_class?: "adversarial" | "advisory";
|
||
workspace_requires?: boolean;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
},
|
||
input: {
|
||
code: string;
|
||
blockedAction: string;
|
||
invariant: string;
|
||
userMessage: string;
|
||
unit?: string;
|
||
receipts?: ReturnType<typeof freshReviewReceipts>;
|
||
summaryCoverage?: "current" | "stale" | "missing";
|
||
autonomousBolt?: {
|
||
unit: string;
|
||
slug: string | null;
|
||
batch: string | null;
|
||
};
|
||
},
|
||
): never {
|
||
const snapshot = guardAttemptState(pd, content, stage, {
|
||
...(input.unit ? { unit: input.unit } : {}),
|
||
...(input.receipts ? { receipts: input.receipts } : {}),
|
||
...(input.summaryCoverage ? { summaryCoverage: input.summaryCoverage } : {}),
|
||
});
|
||
const teamGate = teamUnitGateStatus(pd, content, stage.slug, input.unit);
|
||
const refusal = evaluateGuardRefusal({
|
||
code: input.code,
|
||
blockedAction: input.blockedAction,
|
||
stage: stage.slug,
|
||
...(input.unit ? { unit: input.unit } : {}),
|
||
stateContent: content,
|
||
invariant: input.invariant,
|
||
userMessage: input.userMessage,
|
||
attempt: snapshot.attempt,
|
||
humanAuthority: humanAuthorityState(pd),
|
||
...(teamGate ? { teamGate } : {}),
|
||
...(input.autonomousBolt ? { autonomousBolt: input.autonomousBolt } : {}),
|
||
});
|
||
throw new StateGuardRefusalError(refusal, snapshot.attempt, snapshot.resources);
|
||
}
|
||
|
||
function verifyReviewerPrecondition(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
name: string;
|
||
phase: string;
|
||
for_each?: string;
|
||
mode?: string;
|
||
reviewer?: string;
|
||
reviewer_max_iterations?: number;
|
||
review_class?: "adversarial" | "advisory";
|
||
workspace_requires?: boolean;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
},
|
||
action: ReviewerPreconditionAction = "complete",
|
||
requireReceiptExistence = true,
|
||
): void {
|
||
if (!stage.reviewer) return; // stage declares no reviewer — nothing to enforce
|
||
|
||
// Interactive directives omit the reviewer when the effective class resolves
|
||
// to `none`; their completion path must use that same resolution or it asks
|
||
// for a receipt the conductor was explicitly told not to create. Autonomous
|
||
// swarm stages are the exception: their declared reviewer is the only
|
||
// pre-merge verification inside each Bolt, so caps/overrides do not silence
|
||
// the receipt requirement there.
|
||
const autonomousSwarm = isAutonomousSwarmStage(pd, content, stage);
|
||
const reviewClass = autonomousSwarm
|
||
? stage.review_class ?? "adversarial"
|
||
: resolveReviewClass(
|
||
stage.review_class ?? "adversarial",
|
||
getField(content, "Scope") ?? "",
|
||
content,
|
||
);
|
||
if (reviewClass === "none") {
|
||
return;
|
||
}
|
||
|
||
const reviewer = stage.reviewer;
|
||
|
||
// The fresh-receipt scan lives in aidlc-lib.ts (freshReviewReceipts) so the
|
||
// review-freeze PreToolUse hook and this precondition read the SAME window:
|
||
// event interleave (timestamp, buffer-position tiebreak), the stage-agnostic
|
||
// WORKFLOW_STARTED/STAGE_JUMPED floor, the unit-major STAGE_STARTED skip,
|
||
// and per-unit write invalidation are all documented there.
|
||
// This precondition is a governed Change Control checkpoint when the scan met
|
||
// reviewed content that changed after a terminal receipt: the setting is then
|
||
// resolved here (an invalid memory value errors; a memory edit that moved it
|
||
// is recorded), the rows for what the scan accepted under relaxed are written,
|
||
// and the human is told once.
|
||
const receipts = freshReviewReceipts(pd, content, stage, { reviewClass });
|
||
observeChangeControl(pd, content, receipts);
|
||
const perUnit =
|
||
stage.for_each === "unit-of-work" &&
|
||
!usesStageLevelPerUnitArtifacts(getField(content, "Scope"), content);
|
||
const pendingRecoveryUnits = Array.from(receipts.unitPending)
|
||
.filter(([, pending]) => pending.recovery)
|
||
.map(([unit]) => unit);
|
||
const verificationFailedUnits = Array.from(receipts.unitPending)
|
||
.filter(([, pending]) => pending.verificationFailed === true)
|
||
.map(([unit]) => unit);
|
||
if (
|
||
receipts.stagePending?.verificationFailed === true ||
|
||
verificationFailedUnits.length > 0
|
||
) {
|
||
const unit = verificationFailedUnits.length === 1
|
||
? verificationFailedUnits[0]
|
||
: undefined;
|
||
const scope = verificationFailedUnits.length > 0
|
||
? ` for Unit${verificationFailedUnits.length === 1 ? "" : "s"} ${verificationFailedUnits.join(", ")}`
|
||
: "";
|
||
const iteration = unit === undefined
|
||
? receipts.stagePending?.iteration ?? 1
|
||
: receipts.unitPending.get(unit)?.iteration ?? 1;
|
||
const unitFlag = unit === undefined ? "" : ` --unit ${unit}`;
|
||
const message =
|
||
`${reviewerPreconditionPrefix(stage.slug, action)} because the recorded review${scope} ` +
|
||
"could not be verified and the review must be run again. Retry the pending " +
|
||
`request with \`aidlc-log.ts review --stage ${stage.slug}${unitFlag} --reviewer ` +
|
||
`${reviewer} --iteration ${iteration} --retry-pending\`, then record the verdict ` +
|
||
"with the same command plus `--verdict <READY|NOT-READY>`.";
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_EVIDENCE_MISSING",
|
||
blockedAction: action,
|
||
invariant: "A recorded review remains available and matches its completion.",
|
||
userMessage: message,
|
||
receipts,
|
||
unit,
|
||
});
|
||
}
|
||
if (receipts.stagePending?.recovery || pendingRecoveryUnits.length > 0) {
|
||
const scope =
|
||
pendingRecoveryUnits.length > 0
|
||
? ` for Unit${pendingRecoveryUnits.length === 1 ? "" : "s"} ${pendingRecoveryUnits.join(", ")}`
|
||
: "";
|
||
const message =
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: the recovery review${scope} ` +
|
||
"is still in progress. Finish that review and record its result before " +
|
||
"presenting the gate or completing the stage.";
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_RECOVERY_PENDING",
|
||
blockedAction: action,
|
||
invariant: "A pending recovery request receives its matching verdict.",
|
||
userMessage: message,
|
||
receipts,
|
||
unit:
|
||
pendingRecoveryUnits.length === 1
|
||
? pendingRecoveryUnits[0]
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
// Source-state equality composes with v2's bounded stale-receipt recovery.
|
||
// The workspace-global newest-receipt reconciliation remains the outer
|
||
// boundary; freshReviewReceipts additionally validates each modern unit
|
||
// binding and applies newest-fresh-claimant shielding per path.
|
||
const sourceFreshnessOff =
|
||
process.env.AIDLC_SKIP_SOURCE_FRESHNESS === "1";
|
||
const settledSwarm =
|
||
perUnit &&
|
||
settledSwarmForArtifactGuardOrError(
|
||
pd,
|
||
stage,
|
||
content,
|
||
action,
|
||
);
|
||
// A tightly bounded reconciliation handles the one intentional exception to
|
||
// the global outer boundary: after an unclaimed addition is reverted, the
|
||
// current baseline delta can be fully covered by fresh modern unit bindings
|
||
// even though the newest workspace-global receipt saw the transient file.
|
||
// Any legacy/missing/stale unit evidence or remaining unclaimed baseline delta
|
||
// keeps the normal global-first refusal.
|
||
let baselineChanged: Set<string> | null = null;
|
||
let baselineUnclaimed: string[] | null = null;
|
||
if (
|
||
stage.workspace_requires === true &&
|
||
receipts.sourceBaseline.state === "ready" &&
|
||
receipts.currentSourceListing !== null
|
||
) {
|
||
baselineChanged = new Set<string>();
|
||
const baseline = receipts.sourceBaseline.listing;
|
||
for (const [pathKey, oid] of baseline) {
|
||
if (
|
||
!sourceListingEntriesEqual(
|
||
receipts.currentSourceListing.get(pathKey),
|
||
oid,
|
||
)
|
||
) {
|
||
baselineChanged.add(pathKey);
|
||
}
|
||
}
|
||
for (const pathKey of receipts.currentSourceListing.keys()) {
|
||
if (!baseline.has(pathKey)) baselineChanged.add(pathKey);
|
||
}
|
||
const claimModels = [...receipts.freshUnitClaims.values()];
|
||
baselineUnclaimed = [...baselineChanged]
|
||
.filter((pathKey) => !claimModels.some((claims) => sourceClaimCovers(pathKey, claims)))
|
||
.sort();
|
||
}
|
||
const resolutionForReconciliation = perUnit ? resolveBoltDag(pd) : null;
|
||
const applicableReconciliationUnits =
|
||
resolutionForReconciliation?.state === "ok"
|
||
? resolutionForReconciliation.units.filter(
|
||
(unit) =>
|
||
filterProducesByKind(
|
||
stage.produces_kinds,
|
||
stage.produces ?? [],
|
||
resolutionForReconciliation.unitKinds?.get(unit) ?? null,
|
||
).length > 0,
|
||
)
|
||
: [];
|
||
const baselineReversionReconciled =
|
||
receipts.sourceStale &&
|
||
perUnit &&
|
||
applicableReconciliationUnits.length > 0 &&
|
||
applicableReconciliationUnits.every(
|
||
(unit) =>
|
||
receipts.unitVerdicts.has(unit) &&
|
||
receipts.freshUnitClaims.has(unit) &&
|
||
!receipts.unitStale.has(unit),
|
||
) &&
|
||
baselineUnclaimed?.length === 0;
|
||
const staleSource =
|
||
stage.workspace_requires === true &&
|
||
!sourceFreshnessOff &&
|
||
!settledSwarm &&
|
||
receipts.sourceStale &&
|
||
!baselineReversionReconciled;
|
||
if (staleSource) {
|
||
staleSourcePreconditionError(
|
||
pd,
|
||
content,
|
||
stage,
|
||
reviewer,
|
||
receipts,
|
||
action,
|
||
);
|
||
}
|
||
|
||
if (settledSwarm) {
|
||
verifySettledSwarmSourceBinding(pd, stage, action);
|
||
return;
|
||
}
|
||
|
||
// Already-[x] recovery skips only existence/cardinality and therefore every
|
||
// attribution check that depends on a complete fresh-unit claim union. The
|
||
// modern global binding was still compared above, preserving crash recovery.
|
||
if (!requireReceiptExistence) return;
|
||
|
||
const sawStageReview = receipts.stageVerdict !== null;
|
||
const reviewedUnits = new Set(receipts.unitVerdicts.keys());
|
||
|
||
if (!perUnit) {
|
||
if (!sawStageReview) {
|
||
if (receipts.stageStale) {
|
||
staleReviewPreconditionError(
|
||
pd,
|
||
content,
|
||
stage,
|
||
reviewer,
|
||
receipts,
|
||
action,
|
||
);
|
||
}
|
||
reviewerPreconditionError(pd, content, stage, reviewer, receipts, action);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "malformed") {
|
||
error(
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: its per-unit review set cannot be ` +
|
||
`resolved because unit-of-work-dependency.md is ${resolution.reason} ` +
|
||
`(${resolution.detail}). Fix the fenced units block before ` +
|
||
`${action === "complete" ? "completing" : "presenting the approval gate"}.`,
|
||
);
|
||
}
|
||
let reviewUnits: string[];
|
||
const noDagObserved = resolution.state === "none";
|
||
if (noDagObserved) {
|
||
if (receipts.mergedBoltUnits.size === 0) {
|
||
if (!sawStageReview) {
|
||
reviewerPreconditionError(pd, content, stage, reviewer, receipts, action);
|
||
}
|
||
return;
|
||
}
|
||
if (sawStageReview) return;
|
||
reviewUnits = [...receipts.mergedBoltUnits].sort();
|
||
} else if (resolution.units.length === 0) {
|
||
if (!sawStageReview) {
|
||
reviewerPreconditionError(pd, content, stage, reviewer, receipts, action);
|
||
}
|
||
return;
|
||
} else {
|
||
// A kind-pruned unit with no applicable produces[] never receives a stage
|
||
// directive, so it cannot owe a review. If every unit is vacuous, no
|
||
// stage-level fallback review is required.
|
||
const produces = stage.produces ?? [];
|
||
reviewUnits = resolution.units.filter(
|
||
(unit) =>
|
||
filterProducesByKind(
|
||
stage.produces_kinds,
|
||
produces,
|
||
resolution.unitKinds?.get(unit) ?? null,
|
||
).length > 0,
|
||
);
|
||
}
|
||
if (reviewUnits.length === 0) return;
|
||
|
||
const missing = reviewUnits.filter((u) => !reviewedUnits.has(u));
|
||
if (missing.length > 0) {
|
||
if (noDagObserved) {
|
||
const requestCommands = missing.flatMap((unit) => {
|
||
if (
|
||
receipts.unitStaleProgress.get(unit)?.recoverySpent === true
|
||
) {
|
||
return [];
|
||
}
|
||
const pending = receipts.unitPending.get(unit);
|
||
const iteration =
|
||
pending?.iteration ??
|
||
receipts.unitStaleProgress.get(unit)?.nextIteration ??
|
||
(receipts.unitIterations.get(unit) ?? 0) + 1;
|
||
const retry =
|
||
pending?.state === "retry-required" ? " --retry-pending" : "";
|
||
return [
|
||
`\`aidlc-log.ts review --stage ${stage.slug} --unit ${unit} ` +
|
||
`--reviewer ${reviewer} --iteration ${iteration}${retry}\``,
|
||
];
|
||
});
|
||
const recoverySpent = missing.filter(
|
||
(unit) =>
|
||
receipts.unitStaleProgress.get(unit)?.recoverySpent === true,
|
||
);
|
||
const guidance =
|
||
requestCommands.length > 0
|
||
? `Run ${requestCommands.join(", then ")} to request the missing ` +
|
||
"per-unit review, then record each verdict with the same command " +
|
||
"plus `--verdict <READY|NOT-READY>`."
|
||
: "";
|
||
const exhausted =
|
||
recoverySpent.length > 0
|
||
? ` Recovery was already spent for ${recoverySpent.join(", ")}. ` +
|
||
recoveryGuidance(pd, content, stage.slug) +
|
||
(requestChangesResetIsExecutable(content, stage.slug)
|
||
? " Only a human Request Changes decision resets that review attempt."
|
||
: "")
|
||
: "";
|
||
error(
|
||
`${reviewerPreconditionPrefix(stage.slug, action)}: merged Bolt ` +
|
||
`unit${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ` +
|
||
`${missing.length === 1 ? "has" : "have"} no fresh recorded review. ` +
|
||
guidance +
|
||
exhausted,
|
||
);
|
||
}
|
||
const stale = missing.filter((unit) => receipts.unitStale.has(unit));
|
||
const neverReviewed = missing.filter((unit) => !receipts.unitStale.has(unit));
|
||
const recoveryAvailable = stale.filter(
|
||
(unit) => receipts.unitStaleProgress.get(unit)?.recoverySpent !== true,
|
||
);
|
||
const recoverySpent = stale.filter(
|
||
(unit) => receipts.unitStaleProgress.get(unit)?.recoverySpent === true,
|
||
);
|
||
const guidance: string[] = [];
|
||
if (recoveryAvailable.length > 0) {
|
||
guidance.push(
|
||
`For invalidated units with recovery available (${recoveryAvailable.join(", ")}), ` +
|
||
`run \`aidlc-log.ts review --stage ${stage.slug} --unit <unit> --reviewer ` +
|
||
`${reviewer} --iteration <next ordinal>\`, then record the verdict with ` +
|
||
`the same command plus \`--verdict <READY|NOT-READY>\` and stop editing ` +
|
||
`this stage's output documents, that unit's source-manifest.json, and `
|
||
+ `that unit's claimed source paths.`,
|
||
);
|
||
}
|
||
if (recoverySpent.length > 0) {
|
||
guidance.push(
|
||
autonomousSwarm
|
||
? `For autonomous units whose recovery was already spent (${recoverySpent.join(", ")}), ` +
|
||
`do not put them in --claimed or finalize/merge them. Halt and ask the ` +
|
||
`human whether to restart each Bolt; on approval abort/discard the old ` +
|
||
`Bolt and rerun the current swarm prepare step so the fresh Bolt ` +
|
||
`attempt restores one review allowance.`
|
||
: `For units whose recovery was already spent (${recoverySpent.join(", ")}), ` +
|
||
`the one recovery review was already used and their output changed ` +
|
||
`again. ${recoveryGuidance(pd, content, stage.slug)}` +
|
||
(requestChangesResetIsExecutable(content, stage.slug)
|
||
? " Only a human Request Changes decision resets the review attempt; " +
|
||
"do not record that rejection on the human's behalf."
|
||
: ""),
|
||
);
|
||
}
|
||
if (neverReviewed.length > 0) {
|
||
guidance.push(
|
||
`For never-reviewed units (${neverReviewed.join(", ")}), run the normal ` +
|
||
`\`aidlc-log.ts review --stage ${stage.slug} --unit <unit> --reviewer ` +
|
||
`${reviewer} --iteration <next ordinal>\` request and record its verdict.`,
|
||
);
|
||
}
|
||
error(
|
||
`${reviewerPreconditionPrefix(stage.slug, action)} because ${missing.length} of ` +
|
||
`${reviewUnits.length} applicable units do not have a current review from ${reviewer} ` +
|
||
`(${missing.join(", ")}). Changed after review: ` +
|
||
`${stale.length > 0 ? stale.join(", ") : "none"}. Not yet reviewed: ` +
|
||
`${neverReviewed.length > 0 ? neverReviewed.join(", ") : "none"}. ` +
|
||
guidance.join(" ")
|
||
);
|
||
}
|
||
|
||
const attributionApplies =
|
||
stage.workspace_requires === true && !sourceFreshnessOff && !settledSwarm;
|
||
if (!attributionApplies) return;
|
||
|
||
if (
|
||
receipts.sourceBaseline.state === "unbindable" ||
|
||
receipts.sourceBaseline.state === "invalid"
|
||
) {
|
||
error(
|
||
`Refusing to complete "${stage.slug}": the stage's source baseline snapshot is missing, ` +
|
||
`inconsistent with other modern source-binding evidence, or does not match its recorded hash, ` +
|
||
`so unclaimed source changes cannot be verified. Re-enter the stage ` +
|
||
`(a stage jump records a fresh baseline) or set AIDLC_SKIP_SOURCE_FRESHNESS=1 to bypass ` +
|
||
`deterministically.`,
|
||
);
|
||
}
|
||
if (
|
||
receipts.sourceBaseline.state === "ready" &&
|
||
receipts.currentSourceListing !== null
|
||
) {
|
||
const unclaimed = baselineUnclaimed ?? [];
|
||
if (unclaimed.length > 0) {
|
||
const rendered = unclaimed.slice(0, 10).map((key) => {
|
||
const separator = key.indexOf("\0");
|
||
const repo = key.slice(0, separator);
|
||
const path = key.slice(separator + 1);
|
||
return repo ? `${repo}/${path}` : path;
|
||
});
|
||
const more = unclaimed.length > 10 ? ` … and ${unclaimed.length - 10} more` : "";
|
||
error(
|
||
`Refusing to complete "${stage.slug}": ${unclaimed.length} application-source path(s) changed during this stage run ` +
|
||
`that no reviewed unit's source manifest claims (${rendered.join(", ")}${more}). Add each path to the owning ` +
|
||
`unit's source-manifest.json and record that unit's one bounded stale-receipt recovery review ` +
|
||
`(aidlc-log.ts review --stage ${stage.slug} --unit <unit> --reviewer ${reviewer} --iteration <next ordinal>, ` +
|
||
`then --verdict <READY|NOT-READY>), or revert the change. Unclaimed source changes fail closed (RFC #662).`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function staleSourcePreconditionError(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
phase: string;
|
||
for_each?: string;
|
||
reviewer?: string;
|
||
review_artifact?: string;
|
||
reviewer_max_iterations?: number;
|
||
review_class?: "adversarial" | "advisory";
|
||
workspace_requires?: boolean;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
},
|
||
reviewer: string,
|
||
receipts: ReturnType<typeof freshReviewReceipts>,
|
||
action: ReviewerPreconditionAction,
|
||
): never {
|
||
const slug = stage.slug;
|
||
const recoverySpent =
|
||
receipts.sourceStaleProgress?.recoverySpent === true;
|
||
const reason = receipts.sourceStaleReason;
|
||
if (reason === "boundary-unbindable") {
|
||
const message =
|
||
`Refusing to complete "${slug}": the reviewed source boundary could not be ` +
|
||
"fingerprinted, so this is not evidence that application source changed. " +
|
||
"Repair .aidlc-source-paths.json or the workspace source boundary, then " +
|
||
`record a fresh review by ${reviewer}. ${recoveryGuidance(pd, content, slug)}` +
|
||
(recoverySpent && requestChangesResetIsExecutable(content, slug)
|
||
? " Only a human Request Changes decision resets the review attempt; do not " +
|
||
"record that rejection on the human's behalf."
|
||
: "");
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "SOURCE_BOUNDARY_UNBINDABLE",
|
||
blockedAction: action,
|
||
invariant: "Reviewed source is bound to a reproducible workspace boundary.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
if (recoverySpent) {
|
||
const message =
|
||
`Refusing to complete "${slug}": the workspace source changed again after ` +
|
||
`the one recovery review by ${reviewer} (source-fingerprint mismatch). ` +
|
||
`${recoveryGuidance(pd, content, slug)} ` +
|
||
(requestChangesResetIsExecutable(content, slug)
|
||
? "Only a human Request Changes decision resets the review attempt; do not " +
|
||
"record that rejection on the human's behalf."
|
||
: "");
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "SOURCE_RECOVERY_SPENT",
|
||
blockedAction: action,
|
||
invariant: "The current source remains covered by the bounded review attempt.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
const message =
|
||
`Cannot complete "${slug}" because the project source changed after ${reviewer} ` +
|
||
`reviewed it. Ask ${reviewer} to review the current source once more and record ` +
|
||
`the verdict, or revert the source change, then try again.`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "SOURCE_REVIEW_STALE",
|
||
blockedAction: action,
|
||
invariant: "The current source remains covered by reviewer evidence.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
|
||
function verifyPipelineLinkPrecondition(
|
||
pd: string,
|
||
stage: {
|
||
slug: string;
|
||
name: string;
|
||
mode?: string;
|
||
lead_agent: string;
|
||
support_agents: string[];
|
||
},
|
||
): void {
|
||
if (
|
||
stage.mode !== "pipeline" ||
|
||
process.env.AIDLC_DISABLE_ENSEMBLE_EVIDENCE === "1"
|
||
) {
|
||
return;
|
||
}
|
||
const evidence = pipelineLinkEvidence(pd, stage);
|
||
if (evidence.missing.length === 0) return;
|
||
const missing = evidence.missing.map(({ link, repo }) =>
|
||
repo ? `${repo}:${link}` : link
|
||
);
|
||
const message =
|
||
`Cannot complete "${stage.slug}" because these pipeline handoffs have not been ` +
|
||
`recorded for the current run: ${missing.join(", ")}. Run aidlc-log.ts link after ` +
|
||
`each agent returns` +
|
||
`${evidence.repos.length > 0 ? " with --repo <repo>" : ""}, or set ` +
|
||
`AIDLC_DISABLE_ENSEMBLE_EVIDENCE=1 only to recover a legitimately-run in-flight pipeline.`;
|
||
const content = readStateFile(pd);
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "PIPELINE_EVIDENCE_MISSING",
|
||
blockedAction: "complete",
|
||
invariant: "Every required pipeline handoff is recorded in the current attempt.",
|
||
userMessage: message,
|
||
});
|
||
}
|
||
|
||
function staleReviewPreconditionError(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
phase: string;
|
||
for_each?: string;
|
||
reviewer?: string;
|
||
review_artifact?: string;
|
||
reviewer_max_iterations?: number;
|
||
review_class?: "adversarial" | "advisory";
|
||
workspace_requires?: boolean;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
},
|
||
reviewer: string,
|
||
receipts: ReturnType<typeof freshReviewReceipts>,
|
||
action: ReviewerPreconditionAction = "complete",
|
||
): never {
|
||
const slug = stage.slug;
|
||
const recoverySpent =
|
||
receipts.stageStaleProgress?.recoverySpent === true;
|
||
if (recoverySpent) {
|
||
const message =
|
||
`${reviewerPreconditionPrefix(slug, action)}: this stage's output document ` +
|
||
`changed again after the one recovery review by ${reviewer}. ` +
|
||
recoveryGuidance(pd, content, slug) +
|
||
(requestChangesResetIsExecutable(content, slug)
|
||
? " Only a human Request Changes decision resets the review attempt; do not " +
|
||
"record that rejection on the human's behalf."
|
||
: "");
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "ARTIFACT_RECOVERY_SPENT",
|
||
blockedAction: action,
|
||
invariant: "The current output remains covered by the bounded review attempt.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
const message =
|
||
`${reviewerPreconditionPrefix(slug, action)} because an output document changed after ` +
|
||
`${reviewer} reviewed it. Run ` +
|
||
`one recovery review pass with \`aidlc-log.ts review --stage ${slug} ` +
|
||
`--reviewer ${reviewer} --iteration <next ordinal>\`, then record the verdict ` +
|
||
`with the same command plus \`--verdict <READY|NOT-READY>\`. After that ` +
|
||
`review, stop editing this stage's output documents. If the recovery pass was already ` +
|
||
`spent, present the situation to the human at the approval gate; a human ` +
|
||
`Request Changes decision resets the review attempt. Do not record a rejection ` +
|
||
`on the human's behalf.`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "ARTIFACT_REVIEW_STALE",
|
||
blockedAction: action,
|
||
invariant: "The current output remains covered by reviewer evidence.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
|
||
function reviewerPreconditionError(
|
||
pd: string,
|
||
content: string,
|
||
stage: {
|
||
slug: string;
|
||
phase: string;
|
||
for_each?: string;
|
||
reviewer?: string;
|
||
review_artifact?: string;
|
||
reviewer_max_iterations?: number;
|
||
review_class?: "adversarial" | "advisory";
|
||
workspace_requires?: boolean;
|
||
produces?: string[];
|
||
optional_produces?: string[];
|
||
produces_kinds?: Record<string, string[]>;
|
||
},
|
||
reviewer: string,
|
||
receipts: ReturnType<typeof freshReviewReceipts>,
|
||
action: ReviewerPreconditionAction = "complete",
|
||
): never {
|
||
const slug = stage.slug;
|
||
const pending = receipts.stagePending;
|
||
if (pending?.state === "retry-required") {
|
||
const message =
|
||
`${reviewerPreconditionPrefix(slug, action)} because the review request is still ` +
|
||
"waiting for a verifiable recorded result. Run " +
|
||
`\`aidlc-log.ts review --stage ${slug} --reviewer ${reviewer} --iteration ` +
|
||
`${pending.iteration} --retry-pending\`, then record the verdict with the same ` +
|
||
"command plus `--verdict <READY|NOT-READY>`.";
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_EVIDENCE_MISSING",
|
||
blockedAction: action,
|
||
invariant: "A pending review request receives a verifiable recorded result.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
if (action === "present-approval-gate") {
|
||
const message =
|
||
`Cannot present "${slug}" for approval because ${reviewer} has not reviewed the ` +
|
||
`current output. Apply any fixes first, then request the review with ` +
|
||
`\`aidlc-log.ts review --stage ${slug} --reviewer ${reviewer} --iteration ` +
|
||
`<next ordinal>\` and record its verdict with the same command plus ` +
|
||
`\`--verdict <READY|NOT-READY>\`. After recording the verdict, do not edit ` +
|
||
`this stage's output documents; include suggestions from a READY review in the ` +
|
||
`approval summary instead.`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_EVIDENCE_MISSING",
|
||
blockedAction: action,
|
||
invariant: "Reviewer-bearing stages have current terminal review evidence.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
const message =
|
||
`Cannot complete "${slug}" because ${reviewer} has not reviewed the current output. ` +
|
||
`Apply any fixes first, then request the review with \`aidlc-log.ts review --stage ` +
|
||
`${slug} --reviewer ${reviewer} --iteration <next ordinal>\` and record its verdict ` +
|
||
`with the same command plus \`--verdict <READY|NOT-READY>\`. After recording the ` +
|
||
`verdict, do not edit this stage's output documents; include suggestions from a ` +
|
||
`READY review in the approval summary instead.`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_EVIDENCE_MISSING",
|
||
blockedAction: action,
|
||
invariant: "Reviewer-bearing stages have current terminal review evidence.",
|
||
userMessage: message,
|
||
receipts,
|
||
});
|
||
}
|
||
|
||
function reviewRecoverySpentInCurrentAttempt(
|
||
pd: string,
|
||
content: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
): boolean {
|
||
if (!stage.reviewer) return false;
|
||
const autonomousSwarm = isAutonomousSwarmStage(pd, content, stage);
|
||
const reviewClass = autonomousSwarm
|
||
? stage.review_class ?? "adversarial"
|
||
: resolveReviewClass(
|
||
stage.review_class ?? "adversarial",
|
||
getField(content, "Scope") ?? "",
|
||
content,
|
||
);
|
||
if (reviewClass === "none") return false;
|
||
const receipts = freshReviewReceipts(pd, content, stage, { reviewClass });
|
||
if (receipts.sourceRecoverySpent) return true;
|
||
if (receipts.sourceStaleProgress?.recoverySpent === true) return true;
|
||
if (receipts.stageStaleProgress?.recoverySpent === true) return true;
|
||
for (const progress of receipts.unitStaleProgress.values()) {
|
||
if (progress.recoverySpent) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function handleAdvance(
|
||
args: string[],
|
||
inheritedValidationWarning?: string,
|
||
): void {
|
||
// Keep only the positional <completed-slug> [<next-slug>]; any flags are
|
||
// filtered out so they are not misread as the next slug.
|
||
const positional = args.filter((a) => !a.startsWith("--"));
|
||
if (positional.length < 1)
|
||
error("Usage: aidlc-state.ts advance <completed-slug> [<next-slug>]");
|
||
const completedSlug = positional[0];
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// Per-stage token/cost rollup - computed BEFORE withAuditLock opens (a ledger
|
||
// read, never transcript I/O, and try/caught so it never blocks a completion).
|
||
// "Before emitAudit" is not far enough: emitAudit runs UNDER the held lock, so
|
||
// the read must happen above the lock. Merged into STAGE_COMPLETED's fields
|
||
// inside the arrow below. {} when no ledger exists (non-Claude harness, or a
|
||
// Claude session that never folded) - the event is then unchanged.
|
||
const usageFields = stageRollupFields(pd, completedSlug);
|
||
// C2b lost-update safety: the whole read→decide→emit-audit→write critical
|
||
// section runs under one audit lock so the next-stage derivation, the 5 audit
|
||
// rows, and the state write all commit atomically against a single snapshot
|
||
// (decide-inside-lock). emitAudit detects the held lock and uses the unlocked
|
||
// append variant, so audit + state land together (audit-first). The replay
|
||
// guard's early `return` exits the arrow cleanly; the lock releases in
|
||
// withAuditLock's finally.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
// Look up stage data
|
||
const completedStage = findStageBySlug(completedSlug);
|
||
if (!completedStage) error(`Unknown stage: ${completedSlug}`);
|
||
|
||
// Scope is authoritative for deriving next stage — refuse silent "feature"
|
||
// fallback when the state file is missing or corrupted. Adversarial finding.
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) {
|
||
error(
|
||
`State file has no Scope field. Refusing to advance — fix the state file first.`
|
||
);
|
||
}
|
||
if (!validScopes().has(scope)) {
|
||
error(
|
||
`State file has invalid Scope "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
|
||
// Slug validation — `advance <slug>` is a post-gate-approval transition.
|
||
// The caller must have just finished <completedSlug>. Silently accepting
|
||
// any slug (even ones unrelated to the current state) would mutate
|
||
// unrelated stages and emit bogus events.
|
||
//
|
||
// Accept two shapes cleanly:
|
||
// 1. completedSlug matches `Current Stage` (normal post-approve flow);
|
||
// 2. completedSlug is already `[x]` (idempotent replay / approve-first).
|
||
// Anything else errors.
|
||
const completedCbBefore = parseCheckboxes(content).find(
|
||
(c) => c.slug === completedSlug
|
||
);
|
||
const currentStageField = getField(content, "Current Stage");
|
||
const matchesCurrent = completedSlug === currentStageField;
|
||
const alreadyMarkedCompleted = completedCbBefore?.state === "completed";
|
||
const stageCompletedAlreadyAudited =
|
||
alreadyMarkedCompleted && hasStageAuditEvent(pd, "STAGE_COMPLETED", completedSlug);
|
||
if (!matchesCurrent && !alreadyMarkedCompleted) {
|
||
error(
|
||
`Cannot advance "${completedSlug}": Current Stage is "${currentStageField}" and "${completedSlug}" is ${
|
||
completedCbBefore?.state ?? "unknown"
|
||
}. Pass the slug that's actually active, or use 'skip' / 'complete-workflow'.`
|
||
);
|
||
}
|
||
|
||
// If next-slug was not provided, derive it from the scope AND state file.
|
||
// The state file's EXECUTE/SKIP suffix (set by handleInit with Greenfield
|
||
// overrides) and per-stage checkbox state take precedence over the
|
||
// scope-mapping.json defaults.
|
||
let nextSlug: string;
|
||
if (positional.length >= 2) {
|
||
nextSlug = positional[1];
|
||
// Validate the caller-supplied next slug is in scope AND not already
|
||
// SKIP-stamped in the state file. Symmetric with single-arg form.
|
||
const stateOverrides = parseStateStageSuffixes(content);
|
||
const nextAction =
|
||
stateOverrides.get(nextSlug) ??
|
||
loadScopeMapping()[scope]?.stages[nextSlug];
|
||
if (nextAction === "SKIP") {
|
||
error(
|
||
`Cannot advance to "${nextSlug}": stage is SKIP for scope "${scope}" (or state file). Pick the next EXECUTE stage or use 'skip'.`
|
||
);
|
||
}
|
||
} else {
|
||
const next = nextInScopeStage(completedSlug, scope, content);
|
||
if (!next) {
|
||
error(
|
||
`No next in-scope stage after "${completedSlug}" for scope "${scope}". ` +
|
||
`Use 'complete-workflow' if this was the final stage.`
|
||
);
|
||
}
|
||
nextSlug = next.slug;
|
||
}
|
||
const nextStage = findStageBySlug(nextSlug);
|
||
if (!nextStage) error(`Unknown stage: ${nextSlug}`);
|
||
|
||
// Idempotency guard — if completedSlug is already [x] AND nextSlug has
|
||
// already left pending with Current Stage pointing at it, this is a replay.
|
||
// Skip the whole emission block and exit cleanly, rather than doubling
|
||
// STAGE_STARTED / PHASE_COMPLETED / PHASE_VERIFIED / PHASE_STARTED.
|
||
// Adversarial finding: the previous alreadyMarkedCompleted guard only
|
||
// suppressed STAGE_COMPLETED; phase events still doubled.
|
||
// The next stage counts as already-started in ANY of its post-start gate
|
||
// states — in-progress, awaiting-approval, revising. Matching only
|
||
// in-progress let a stale replay demote a gate-held `[?]`/`[R]` next stage
|
||
// back to `[-]` and re-emit STAGE_STARTED.
|
||
const nextCbBefore = parseCheckboxes(content).find(
|
||
(c) => c.slug === nextSlug
|
||
);
|
||
const nextAlreadyStarted =
|
||
nextCbBefore?.state === "in-progress" ||
|
||
nextCbBefore?.state === "awaiting-approval" ||
|
||
nextCbBefore?.state === "revising";
|
||
const isReplay =
|
||
alreadyMarkedCompleted &&
|
||
stageCompletedAlreadyAudited &&
|
||
nextAlreadyStarted &&
|
||
currentStageField === nextSlug;
|
||
if (isReplay) {
|
||
console.log(
|
||
JSON.stringify({
|
||
completed: completedSlug,
|
||
started: nextSlug,
|
||
replay: true,
|
||
timestamp: isoTimestamp(),
|
||
})
|
||
);
|
||
return;
|
||
}
|
||
|
||
// A true replay above is already fully applied and remains idempotent. A
|
||
// crash-window partial approval does not satisfy all replay predicates, so it
|
||
// still reaches the shared admission chain (reviewer evidence always; the
|
||
// artifact, summary, and pipeline guards when THIS advance is the completion).
|
||
admitStageAction(pd, content, completedStage, {
|
||
action: "complete",
|
||
entrypoint: "advance",
|
||
});
|
||
|
||
// Detect phase boundary (for PHASE_COMPLETED/VERIFIED/STARTED emissions)
|
||
const crossesPhaseBoundary = completedStage.phase !== nextStage.phase;
|
||
|
||
// 1. Mark completed-slug → [x] (idempotent)
|
||
content = setCheckbox(content, completedSlug, "completed");
|
||
|
||
// 2. Mark next-slug → [-]
|
||
content = setCheckbox(content, nextSlug, "in-progress");
|
||
|
||
// 3. Update fields
|
||
const nextAfterNext = nextInScopeStage(nextSlug, scope, content);
|
||
const timestamp = isoTimestamp();
|
||
|
||
content = setField(content, "Current Stage", nextStage.slug);
|
||
content = setField(content, "Lifecycle Phase", nextStage.phase.toUpperCase());
|
||
content = setField(content, "Next Stage", nextAfterNext ? nextAfterNext.slug : "none");
|
||
content = setField(content, "In Progress", nextStage.slug);
|
||
content = setField(content, "Active Agent", nextStage.lead_agent);
|
||
content = setField(content, "Status", "Running");
|
||
content = setField(content, "Last Updated", timestamp);
|
||
content = setField(content, "Last Completed Stage", completedSlug);
|
||
content = setField(content, "Next Action", `Execute ${nextStage.name}`);
|
||
|
||
// Sync Completed counter to actual [x] count
|
||
const completedCount = countCheckboxes(content, "completed");
|
||
content = setField(content, "Completed", String(completedCount));
|
||
|
||
// Phase Progress rows mirror the boundary events emitted below: the
|
||
// completed phase's row flips to Verified and the entered phase's to Active,
|
||
// in the same state write. Display-only (routing reads Lifecycle Phase and
|
||
// the checkboxes), but without the flip the section holds its initial values
|
||
// forever and contradicts the checkboxes underneath it.
|
||
if (crossesPhaseBoundary) {
|
||
content = setPhaseProgress(content, completedStage.phase, "Verified");
|
||
content = setPhaseProgress(content, nextStage.phase, "Active");
|
||
}
|
||
|
||
const validationFields =
|
||
!alreadyMarkedCompleted || !stageCompletedAlreadyAudited
|
||
? stageValidationAuditFields(pd, completedStage, content)
|
||
: {};
|
||
const validationWarning =
|
||
inheritedValidationWarning ?? validationFields[VALIDATION_WARNING_FIELD];
|
||
|
||
// 4. Atomic audit emission — audit-first, then state write.
|
||
// If audit fails, throw before touching state (writeStateFile below is skipped).
|
||
try {
|
||
// Emit STAGE_COMPLETED only if approve didn't already emit it.
|
||
if (!alreadyMarkedCompleted || !stageCompletedAlreadyAudited) {
|
||
emitAudit(pd, "STAGE_COMPLETED", {
|
||
Stage: completedSlug,
|
||
...validationFields,
|
||
Details: `Stage ${completedStage.name} completed`,
|
||
...usageFields,
|
||
});
|
||
}
|
||
if (crossesPhaseBoundary) {
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": completedStage.phase,
|
||
"To phase": nextStage.phase,
|
||
"Stages completed": String(completedCount),
|
||
});
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": `${completedStage.phase} → ${nextStage.phase}`,
|
||
});
|
||
emitAudit(pd, "PHASE_STARTED", {
|
||
Phase: nextStage.phase,
|
||
Scope: scope,
|
||
});
|
||
}
|
||
emitAudit(pd, "STAGE_STARTED", {
|
||
Stage: nextSlug,
|
||
Agent: nextStage.lead_agent,
|
||
...(nextStage.workspace_requires
|
||
? sourceBaselineAuditFields(pd, nextSlug)
|
||
: {}),
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
completed: completedSlug,
|
||
started: nextSlug,
|
||
phase: nextStage.phase.toUpperCase(),
|
||
phase_boundary: crossesPhaseBoundary,
|
||
completed_count: completedCount,
|
||
next_after: nextAfterNext ? nextAfterNext.slug : null,
|
||
already_completed: alreadyMarkedCompleted,
|
||
memory_path: relativeMemoryPath(nextStage.phase, nextStage.slug, relativeRecordDir(pd)),
|
||
timestamp,
|
||
...(validationWarning ? { warnings: [validationWarning] } : {}),
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
function handleFinalize(args: string[]): void {
|
||
// Keep <completed-slug> positional; any flags are filtered out.
|
||
const positional = args.filter((a) => !a.startsWith("--"));
|
||
if (positional.length < 1)
|
||
error("Usage: aidlc-state.ts finalize <completed-slug>");
|
||
const completedSlug = positional[0];
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// C2b lost-update safety: read→decide→write under one lock (no audit here).
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const completedStage = findStageBySlug(completedSlug);
|
||
if (!completedStage) error(`Unknown stage: ${completedSlug}`);
|
||
|
||
// Artifact guard. finalize also marks a stage [x], so it is a completing
|
||
// transition that must not rubber-stamp. The shared admission chain guards only
|
||
// when the slug is not already [x] (an idempotent re-finalize already passed
|
||
// it), and runs before any mutation so a refusal leaves state untouched.
|
||
admitStageAction(pd, content, completedStage, {
|
||
action: "complete",
|
||
entrypoint: "finalize",
|
||
});
|
||
|
||
// 1. Mark completed
|
||
content = setCheckbox(content, completedSlug, "completed");
|
||
|
||
// 2. Sync Completed counter to actual [x] count
|
||
const completedCount = countCheckboxes(content, "completed");
|
||
content = setField(content, "Completed", String(completedCount));
|
||
|
||
// 3. Look up next in-scope stage. Refuse silent fallback on missing/invalid
|
||
// Scope — matches handleAdvance's stance. Adversarial: pre-Phase-11 code
|
||
// silently used "feature" when Scope was absent, hiding state-file corruption.
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) {
|
||
error(
|
||
`State file has no Scope field. Refusing to finalize — fix the state file first.`
|
||
);
|
||
}
|
||
if (!validScopes().has(scope)) {
|
||
error(
|
||
`State file has invalid Scope "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
// Thread the live state content into BOTH walks so per-stage EXECUTE/SKIP
|
||
// suffix overrides (a recomposed plan) and prior [x]/[S] checkboxes are
|
||
// honoured - the same threading the advance path does (:869/:935). Without
|
||
// it these two calls project the next move from the STATIC scope grid and
|
||
// route around any recompose flip.
|
||
const nextStage = nextInScopeStage(completedSlug, scope, content);
|
||
const nextAfterNext = nextStage ? nextInScopeStage(nextStage.slug, scope, content) : null;
|
||
const timestamp = isoTimestamp();
|
||
|
||
// 4. Update state fields (but do NOT mark next stage [-] or set In Progress)
|
||
if (nextStage) {
|
||
content = setField(content, "Current Stage", nextStage.slug);
|
||
content = setField(content, "Next Stage", nextAfterNext ? nextAfterNext.slug : "none");
|
||
content = setField(content, "Lifecycle Phase", nextStage.phase.toUpperCase());
|
||
content = setField(content, "Active Agent", nextStage.lead_agent);
|
||
// Phase Progress boundary flip - mirrors handleAdvance's. finalize moves
|
||
// the cursor without emitting phase events, but the display rows must
|
||
// still track the phase the cursor now sits in.
|
||
if (completedStage.phase !== nextStage.phase) {
|
||
content = setPhaseProgress(content, completedStage.phase, "Verified");
|
||
content = setPhaseProgress(content, nextStage.phase, "Active");
|
||
}
|
||
} else {
|
||
content = setField(content, "Current Stage", "none");
|
||
content = setField(content, "Next Stage", "none");
|
||
content = setField(content, "Status", "Completed");
|
||
content = setField(content, "In Progress", "none");
|
||
// No next stage: the workflow is done - the final phase is Verified.
|
||
content = setPhaseProgress(content, completedStage.phase, "Verified");
|
||
}
|
||
content = setField(content, "Last Completed Stage", completedSlug);
|
||
content = setField(content, "Last Updated", timestamp);
|
||
content = setField(content, "Next Action", nextStage ? `Resume from ${nextStage.name}` : "Workflow complete");
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(
|
||
JSON.stringify({
|
||
completed: completedSlug,
|
||
completed_count: completedCount,
|
||
next_stage: nextStage?.slug || "none",
|
||
phase: nextStage?.phase.toUpperCase() || completedStage.phase.toUpperCase(),
|
||
timestamp,
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
function handleCompleteWorkflow(
|
||
args: string[],
|
||
inheritedValidationWarning?: string,
|
||
): void {
|
||
// Keep <completed-slug> positional and distinct from the --reason value.
|
||
// --reason takes a value, so its argument is excluded from positionals too.
|
||
const reasonIdx = args.indexOf("--reason");
|
||
const reasonValueIdx = reasonIdx !== -1 ? reasonIdx + 1 : -1;
|
||
const positional = args.filter(
|
||
(a, i) => !a.startsWith("--") && i !== reasonValueIdx,
|
||
);
|
||
if (positional.length < 1)
|
||
error("Usage: aidlc-state.ts complete-workflow <completed-slug> [--reason <text>]");
|
||
const completedSlug = positional[0];
|
||
|
||
// Optional --reason flag for recording why the workflow completed early
|
||
let reason: string | undefined;
|
||
if (reasonIdx !== -1 && reasonIdx + 1 < args.length) {
|
||
reason = args[reasonIdx + 1];
|
||
}
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
const stageUsageFields = stageRollupFields(pd, completedSlug);
|
||
const workflowUsageFields = workflowRollupFields(pd);
|
||
// C2b lost-update safety: read→decide→emit-audit (4 rows)→write under one
|
||
// lock so the 4 audit rows and the completion state commit atomically against
|
||
// a single snapshot (audit-first / decide-inside-lock). emitAudit uses the
|
||
// unlocked variant because the lock is held.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const completedStage = findStageBySlug(completedSlug);
|
||
if (!completedStage) error(`Unknown stage: ${completedSlug}`);
|
||
|
||
// If the slug is already [x], approve already emitted STAGE_COMPLETED —
|
||
// skip re-emission to avoid duplicates. Matches handleAdvance's
|
||
// alreadyMarkedCompleted guard.
|
||
const alreadyMarkedCompleted =
|
||
parseCheckboxes(content).find((c) => c.slug === completedSlug)?.state ===
|
||
"completed";
|
||
const stageCompletedAlreadyAudited =
|
||
alreadyMarkedCompleted && hasStageAuditEvent(pd, "STAGE_COMPLETED", completedSlug);
|
||
|
||
// Artifact guard (issue #366). complete-workflow marks the FINAL stage [x], so
|
||
// it is a completing transition too. Guard only when the slug is not already
|
||
// [x]: approve delegates here AFTER marking the slug [x] and running the guard
|
||
// itself, so this skips the double-check on that path while still refusing a
|
||
// direct `complete-workflow <active-slug>` that never produced artifacts. Runs
|
||
// before any mutation so a refusal leaves state untouched.
|
||
admitStageAction(pd, content, completedStage, {
|
||
action: "complete",
|
||
entrypoint: "complete-workflow",
|
||
});
|
||
|
||
// 1. Mark completed
|
||
content = setCheckbox(content, completedSlug, "completed");
|
||
|
||
// 2. Sync Completed counter
|
||
const completedCount = countCheckboxes(content, "completed");
|
||
content = setField(content, "Completed", String(completedCount));
|
||
|
||
// 3. Update all fields atomically for workflow completion
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Status", "Completed");
|
||
content = setField(content, "Last Updated", timestamp);
|
||
content = setField(content, "Last Completed Stage", completedSlug);
|
||
content = setField(content, "In Progress", "none");
|
||
content = setField(content, "Next Stage", "none");
|
||
content = setField(content, "Next Action", "Workflow complete");
|
||
// Phase Progress: workflow completion is the final phase's boundary - its
|
||
// row flips to Verified alongside the PHASE_COMPLETED/PHASE_VERIFIED pair
|
||
// emitted below (the advance-side flip only fires on stage->stage
|
||
// boundaries, so the last phase would otherwise stay Active forever).
|
||
content = setPhaseProgress(content, completedStage.phase, "Verified");
|
||
|
||
// 4. Atomic audit emissions. Refuse silent fallback — matches handleAdvance.
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) {
|
||
error(
|
||
`State file has no Scope field. Refusing to complete workflow — fix the state file first.`
|
||
);
|
||
}
|
||
if (!validScopes().has(scope)) {
|
||
error(
|
||
`State file has invalid Scope "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
const validationFields =
|
||
!alreadyMarkedCompleted || !stageCompletedAlreadyAudited
|
||
? stageValidationAuditFields(pd, completedStage, content)
|
||
: {};
|
||
const validationWarning =
|
||
inheritedValidationWarning ?? validationFields[VALIDATION_WARNING_FIELD];
|
||
try {
|
||
if (!alreadyMarkedCompleted || !stageCompletedAlreadyAudited) {
|
||
emitAudit(pd, "STAGE_COMPLETED", {
|
||
Stage: completedSlug,
|
||
...validationFields,
|
||
Details: `Final stage ${completedStage.name} completed`,
|
||
...stageUsageFields,
|
||
});
|
||
}
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": completedStage.phase,
|
||
"To phase": "(end)",
|
||
"Stages completed": String(completedCount),
|
||
});
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": `${completedStage.phase} → end`,
|
||
});
|
||
const workflowFields: Record<string, string> = {
|
||
Scope: scope,
|
||
Details: `Scope: ${scope}, ${completedCount} stages completed`,
|
||
...workflowUsageFields,
|
||
};
|
||
if (reason) workflowFields.Reason = reason;
|
||
emitAudit(pd, "WORKFLOW_COMPLETED", workflowFields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
// Intent status lifecycle: terminal completion flips the active intent's
|
||
// registry row to "complete". This is the determinism (field write) gated by
|
||
// the human-confirmed completion that drove complete-workflow here — never an
|
||
// automatic inference from state, so a crashed run never self-completes. Runs
|
||
// under the workspace lock already held (every intents.json mutation takes the
|
||
// sentinel bucket). No-op for the legacy flat record (no registry row).
|
||
const completedSelection = resolveWorkflowSelection(pd);
|
||
const completedIntentDir = completedSelection.intent;
|
||
if (completedIntentDir) {
|
||
updateIntentStatus(
|
||
pd,
|
||
completedIntentDir,
|
||
"complete",
|
||
completedSelection.space,
|
||
);
|
||
}
|
||
console.log(
|
||
JSON.stringify({
|
||
completed: completedSlug,
|
||
completed_count: completedCount,
|
||
status: "Completed",
|
||
reason: reason || null,
|
||
timestamp,
|
||
...(validationWarning ? { warnings: [validationWarning] } : {}),
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
// --- New gate/approve/reject/skip/revise/resume/reuse-artifact commands (state-machine refactor #50) ---
|
||
|
||
// Helper: get the current state of a specific slug
|
||
function getSlugState(content: string, slug: string): CheckboxState | null {
|
||
const checkboxes = parseCheckboxes(content);
|
||
const match = checkboxes.find((c) => c.slug === slug);
|
||
return match ? match.state : null;
|
||
}
|
||
|
||
function validateSlugInState(
|
||
content: string,
|
||
slug: string,
|
||
expected: CheckboxState | CheckboxState[]
|
||
): void {
|
||
const actual = getSlugState(content, slug);
|
||
if (actual === null) error(`Stage not found in state file: ${slug}`);
|
||
const allowed = Array.isArray(expected) ? expected : [expected];
|
||
if (!allowed.includes(actual)) {
|
||
error(
|
||
`Stage ${slug} is in state '${actual}' but command requires one of: ${allowed.join(", ")}`
|
||
);
|
||
}
|
||
}
|
||
|
||
interface TeamGateContext {
|
||
unit: string;
|
||
scope: UnitGateScope;
|
||
stages: NonNullable<ReturnType<typeof findStageBySlug>>[];
|
||
}
|
||
|
||
function teamGateContext(
|
||
content: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
args: string[],
|
||
explicitProjectDir?: string,
|
||
): TeamGateContext | null {
|
||
const unit = getFlagValue(args, "--unit")?.trim();
|
||
if (!isTeamUnitOwnership(content)) {
|
||
if (unit) {
|
||
error("--unit gate decisions require Unit Ownership: team.");
|
||
}
|
||
return null;
|
||
}
|
||
if (stage.phase !== "construction" || stage.for_each !== "unit-of-work") {
|
||
if (unit) {
|
||
error(
|
||
`Stage "${stage.slug}" is not a per-unit Construction stage; team unit gates do not apply.`,
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
if (!unit) {
|
||
error(
|
||
`Unit Ownership: team requires --unit <name> for gate decisions on "${stage.slug}".`,
|
||
);
|
||
}
|
||
const unitError = validateUnitName(unit);
|
||
if (unitError) error(unitError);
|
||
const pd = explicitProjectDir ?? resolveProjectDir(projectDir);
|
||
validateLiveUnitScope(pd, unit);
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state !== "ok" || !resolution.units.includes(unit)) {
|
||
error(`Unit "${unit}" is not in the authoritative unit DAG.`);
|
||
}
|
||
const rhythm = effectiveUnitGateRhythm(pd, content);
|
||
if (rhythm === "per-stage") {
|
||
return { unit, scope: "per-stage", stages: [stage] };
|
||
}
|
||
const workflowScope = getField(content, "Scope") ?? "";
|
||
const stages = unitMajorConstructionStageSlugs(
|
||
workflowScope,
|
||
content,
|
||
true,
|
||
)
|
||
.map((slug) => findStageBySlug(slug))
|
||
.filter(
|
||
(entry): entry is NonNullable<ReturnType<typeof findStageBySlug>> =>
|
||
!!entry,
|
||
);
|
||
const final = stages[stages.length - 1];
|
||
if (!final || final.slug !== stage.slug) {
|
||
error(
|
||
`Unit-end gate for "${unit}" must be reported against the final per-unit stage ` +
|
||
`"${final?.slug ?? "unknown"}", not "${stage.slug}".`,
|
||
);
|
||
}
|
||
return { unit, scope: "unit-end", stages };
|
||
}
|
||
|
||
function applicableUnitProduces(
|
||
pd: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
unit: string,
|
||
): string[] {
|
||
let required = stage.produces ?? [];
|
||
if (stage.produces_kinds !== undefined) {
|
||
const resolution = resolveBoltDag(pd);
|
||
if (resolution.state === "ok" && resolution.unitKinds !== null) {
|
||
required = filterProducesByKind(
|
||
stage.produces_kinds,
|
||
required,
|
||
resolution.unitKinds.get(unit) ?? null,
|
||
);
|
||
}
|
||
}
|
||
return required;
|
||
}
|
||
|
||
function verifyReviewerPreconditionForUnit(
|
||
pd: string,
|
||
content: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
unit: string,
|
||
action: ReviewerPreconditionAction,
|
||
): void {
|
||
if (!stage.reviewer) return;
|
||
const reviewClass = resolveReviewClass(
|
||
stage.review_class ?? "adversarial",
|
||
getField(content, "Scope") ?? "",
|
||
content,
|
||
);
|
||
if (reviewClass === "none") return;
|
||
// The same governed checkpoint as the stage-level verifier, for one Unit.
|
||
const receipts = freshReviewReceipts(pd, content, stage, { reviewClass });
|
||
observeChangeControl(pd, content, receipts);
|
||
if (!receipts.unitVerdicts.has(unit)) {
|
||
const message =
|
||
`Refusing gate for unit "${unit}" of "${stage.slug}": no fresh ` +
|
||
`REVIEW_COMPLETED receipt from ${stage.reviewer} is recorded for this unit.`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REVIEW_EVIDENCE_MISSING",
|
||
blockedAction: action,
|
||
invariant: "Reviewer-bearing Units have current terminal review evidence.",
|
||
userMessage: message,
|
||
unit,
|
||
receipts,
|
||
});
|
||
}
|
||
}
|
||
|
||
function verifyTeamUnitGateEvidence(
|
||
pd: string,
|
||
content: string,
|
||
context: TeamGateContext,
|
||
action: ReviewerPreconditionAction = "present-approval-gate",
|
||
): void {
|
||
for (const stage of context.stages) {
|
||
if (applicableUnitProduces(pd, stage, context.unit).length === 0) continue;
|
||
const missing = missingUnitArtifacts(pd, stage, context.unit);
|
||
if (missing.length > 0) {
|
||
const message =
|
||
`Refusing gate for unit "${context.unit}" of "${stage.slug}": required ` +
|
||
`artifacts are missing (${missing.join(", ")}).`;
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "REQUIRED_ARTIFACTS_MISSING",
|
||
blockedAction: action,
|
||
invariant: "Every applicable Unit output exists before certification.",
|
||
userMessage: message,
|
||
unit: context.unit,
|
||
});
|
||
}
|
||
if (!unitCompletedReceipts(pd, stage.slug).has(context.unit)) {
|
||
const message =
|
||
`Refusing gate for unit "${context.unit}" of "${stage.slug}": no current ` +
|
||
"UNIT_COMPLETED receipt is recorded.";
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "UNIT_COMPLETION_MISSING",
|
||
blockedAction: action,
|
||
invariant: "The Unit lifecycle is complete in the current attempt.",
|
||
userMessage: message,
|
||
unit: context.unit,
|
||
});
|
||
}
|
||
const summary = checkSummaryConfirmationEvidence(pd, stage, {
|
||
stateContent: content,
|
||
unit: context.unit,
|
||
});
|
||
observeChangeControl(pd, content, summary);
|
||
if (!summary.ok) {
|
||
refuseStateGuard(pd, content, stage, {
|
||
code: "SUMMARY_EVIDENCE_INVALID",
|
||
blockedAction: action,
|
||
invariant:
|
||
"Generated Unit outputs descend from current human-backed summary confirmation.",
|
||
userMessage: summary.message,
|
||
unit: context.unit,
|
||
summaryCoverage: summary.summaryCoverage,
|
||
});
|
||
}
|
||
verifyReviewerPreconditionForUnit(
|
||
pd,
|
||
content,
|
||
stage,
|
||
context.unit,
|
||
action,
|
||
);
|
||
if (stage.workspace_requires) verifyStageArtifacts(pd, stage, action);
|
||
}
|
||
}
|
||
|
||
function verifyTeamUnitGatePipelinePrecondition(
|
||
pd: string,
|
||
context: TeamGateContext,
|
||
): void {
|
||
for (const stage of context.stages) {
|
||
verifyPipelineLinkPrecondition(pd, stage);
|
||
}
|
||
}
|
||
|
||
export type GuardPreflightAction =
|
||
| "present-approval-gate"
|
||
| "revise"
|
||
| "complete"
|
||
| "review-request";
|
||
|
||
export type GuardPreflightResult =
|
||
| { executable: true }
|
||
| {
|
||
executable: false;
|
||
refusal: GuardRefusal;
|
||
attempt: GuardAttemptState;
|
||
resources: string[];
|
||
};
|
||
|
||
export type StageAdmissionOptions = {
|
||
action: Exclude<GuardPreflightAction, "review-request">;
|
||
unit?: string;
|
||
entrypoint?: "approve" | "advance" | "finalize" | "complete-workflow";
|
||
};
|
||
|
||
// THE guard chain for a lifecycle action, listed once. The enforcing handlers
|
||
// call it before they change state; the router calls it (through
|
||
// guardPreflight) before it spawns the handler. Both see the same immutable
|
||
// snapshot and the same refusal, so a guard added here is a guard at both
|
||
// sites. Refusals throw StateGuardRefusalError; structural refusals throw
|
||
// StateCommandError; either unwinds to the caller unchanged.
|
||
function admitStageAction(
|
||
pd: string,
|
||
stateContent: string,
|
||
stage: StageEntry,
|
||
options: StageAdmissionOptions,
|
||
): void {
|
||
if (options.unit !== undefined) {
|
||
const team = teamGateContext(
|
||
stateContent,
|
||
stage,
|
||
["--unit", options.unit],
|
||
pd,
|
||
);
|
||
if (team !== null) {
|
||
verifyTeamUnitGateEvidence(
|
||
pd,
|
||
stateContent,
|
||
team,
|
||
options.action === "complete" ? "complete" : "present-approval-gate",
|
||
);
|
||
if (options.action !== "complete") {
|
||
verifyTeamUnitGatePipelinePrecondition(pd, team);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (options.action !== "complete") {
|
||
verifyGateOpeningGuards(pd, stateContent, stage);
|
||
return;
|
||
}
|
||
|
||
const alreadyCompleted =
|
||
parseCheckboxes(stateContent).find((entry) => entry.slug === stage.slug)
|
||
?.state === "completed";
|
||
if (options.entrypoint === "approve") {
|
||
verifyStageArtifacts(pd, stage);
|
||
verifySummaryConfirmationPrecondition(pd, stateContent, stage);
|
||
verifyPipelineLinkPrecondition(pd, stage);
|
||
verifyReviewerPrecondition(pd, stateContent, stage);
|
||
return;
|
||
}
|
||
// A true replay is already fully applied and stays idempotent. A crash-window
|
||
// partial approval still reaches the source comparison: already-[x] recovery
|
||
// may lack review receipts, but any modern source binding still has to match.
|
||
verifyReviewerPrecondition(
|
||
pd,
|
||
stateContent,
|
||
stage,
|
||
"complete",
|
||
!alreadyCompleted,
|
||
);
|
||
if (!alreadyCompleted) {
|
||
verifyStageArtifacts(pd, stage);
|
||
verifySummaryConfirmationPrecondition(pd, stateContent, stage);
|
||
verifyPipelineLinkPrecondition(pd, stage);
|
||
}
|
||
}
|
||
|
||
// The router's view of admitStageAction: the same call, with the two throw
|
||
// kinds turned into a verdict. A structural refusal is "cannot decide here" and
|
||
// fails open to the real command, which will refuse with the full message.
|
||
export function guardPreflight(
|
||
pd: string,
|
||
stateContent: string,
|
||
stage: StageEntry,
|
||
options: {
|
||
action: GuardPreflightAction;
|
||
unit?: string;
|
||
entrypoint?: "approve" | "advance" | "finalize" | "complete-workflow";
|
||
},
|
||
): GuardPreflightResult {
|
||
if (options.action === "review-request") {
|
||
// Review request authority is owned by aidlc-log, whose admission is
|
||
// evaluated at routing time from the same reducer (summaryRefusalForRouting
|
||
// and the recovery-spent branch in the per-unit wave); nothing to add here.
|
||
return { executable: true };
|
||
}
|
||
changeControlPreflight = true;
|
||
try {
|
||
admitStageAction(pd, stateContent, stage, {
|
||
action: options.action,
|
||
...(options.unit !== undefined ? { unit: options.unit } : {}),
|
||
...(options.entrypoint ? { entrypoint: options.entrypoint } : {}),
|
||
});
|
||
return { executable: true };
|
||
} catch (error) {
|
||
if (error instanceof StateGuardRefusalError) {
|
||
return {
|
||
executable: false,
|
||
refusal: error.refusal,
|
||
attempt: error.attempt,
|
||
resources: error.resourceFingerprints,
|
||
};
|
||
}
|
||
return { executable: true };
|
||
} finally {
|
||
changeControlPreflight = false;
|
||
}
|
||
}
|
||
|
||
function teamGateFields(
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
context: TeamGateContext,
|
||
): Record<string, string> {
|
||
return {
|
||
Stage: stage.slug,
|
||
Unit: context.unit,
|
||
"Gate Scope": context.scope,
|
||
"Gate Stages": context.stages.map((entry) => entry.slug).join(","),
|
||
...claimAttemptFields(resolveProjectDir(projectDir), context.unit),
|
||
};
|
||
}
|
||
|
||
// gate-start <slug> — fire gate-bound sensors, then open either the global
|
||
// stage gate or the claim-scoped team Unit gate.
|
||
// On an existing gate, re-run every opening guard without writing another
|
||
// transition. A consumed blocking-sensor override emits a Revalidated row so
|
||
// its human authorization cannot be reused; ordinary revalidation stays quiet.
|
||
// --recovered marks a BACKFILLED gate row (the engine opening a gate the
|
||
// conductor skipped, e.g. report's explicit-stage recovery) with
|
||
// Recovered=true so audit consumers can tell backfills from organic opens.
|
||
function handleGateStart(args: string[]): void {
|
||
if (args.length < 1) {
|
||
error(
|
||
"Usage: aidlc-state.ts gate-start <slug> [--artifacts <csv>] " +
|
||
"[--recovered] [--override-blocking-sensors] [--user-input <choice>]",
|
||
);
|
||
}
|
||
const slug = args[0];
|
||
let artifacts: string | undefined;
|
||
const artifactsIdx = args.indexOf("--artifacts");
|
||
if (artifactsIdx !== -1 && artifactsIdx + 1 < args.length) {
|
||
artifacts = args[artifactsIdx + 1];
|
||
}
|
||
const recovered = args.includes("--recovered");
|
||
const overrideBlockingSensors = args.includes("--override-blocking-sensors");
|
||
const overrideInput = getFlagValue(args.slice(1), "--user-input");
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// Sensor dispatch MUST stay outside the state transaction: aidlc-sensor.ts
|
||
// takes the same audit lock around its FIRED and terminal rows.
|
||
const preflightContent = readStateFile(pd);
|
||
const preflightStage = findStageBySlug(slug);
|
||
if (!preflightStage) error(`Unknown stage: ${slug}`);
|
||
const preflightTeamGate = teamGateContext(
|
||
preflightContent,
|
||
preflightStage,
|
||
args.slice(1),
|
||
);
|
||
if (!preflightTeamGate) {
|
||
validateSlugInState(
|
||
preflightContent,
|
||
slug,
|
||
["in-progress", "awaiting-approval"],
|
||
);
|
||
}
|
||
admitStageAction(pd, preflightContent, preflightStage, {
|
||
action: "present-approval-gate",
|
||
...(preflightTeamGate ? { unit: preflightTeamGate.unit } : {}),
|
||
});
|
||
const gateSensorEvaluation = fireGateSensors(
|
||
pd,
|
||
preflightStage,
|
||
artifacts,
|
||
);
|
||
enforceBlockingGateSensors(
|
||
pd,
|
||
preflightContent,
|
||
slug,
|
||
"awaiting-approval",
|
||
gateSensorEvaluation.issues,
|
||
overrideBlockingSensors,
|
||
overrideInput,
|
||
);
|
||
|
||
// C2b lost-update safety: validate→transition→emit-audit→write under one
|
||
// lock (the state-precondition check and the write see one snapshot).
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
const teamGate = teamGateContext(content, stage, args.slice(1));
|
||
if (teamGate) {
|
||
admitStageAction(pd, content, stage, {
|
||
action: "present-approval-gate",
|
||
unit: teamGate.unit,
|
||
});
|
||
verifyGateSensorArtifactsUnchanged(slug, gateSensorEvaluation);
|
||
const status = unitGateStatus(
|
||
pd,
|
||
stage.slug,
|
||
teamGate.unit,
|
||
teamGate.scope,
|
||
);
|
||
if (status === "approved") {
|
||
error(
|
||
`Gate for unit "${teamGate.unit}" of "${stage.slug}" is already approved.`,
|
||
);
|
||
}
|
||
if (status === "awaiting-approval") {
|
||
console.log(
|
||
JSON.stringify({
|
||
slug,
|
||
unit: teamGate.unit,
|
||
gate_scope: teamGate.scope,
|
||
new_state: "awaiting-approval",
|
||
already_awaiting_approval: true,
|
||
revalidated: true,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
try {
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", {
|
||
...teamGateFields(stage, teamGate),
|
||
...(artifacts ? { Artifacts: artifacts } : {}),
|
||
...(recovered ? { Recovered: "true" } : {}),
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
unit: teamGate.unit,
|
||
gate_scope: teamGate.scope,
|
||
new_state: "awaiting-approval",
|
||
timestamp,
|
||
}));
|
||
return;
|
||
}
|
||
validateSlugInState(content, slug, ["in-progress", "awaiting-approval"]);
|
||
const alreadyAwaiting = getSlugState(content, slug) === "awaiting-approval";
|
||
admitStageAction(pd, content, stage, { action: "present-approval-gate" });
|
||
verifyGateSensorArtifactsUnchanged(slug, gateSensorEvaluation);
|
||
if (alreadyAwaiting) {
|
||
if (
|
||
gateSensorEvaluation.issues.length > 0 &&
|
||
overrideBlockingSensors
|
||
) {
|
||
try {
|
||
const fields: Record<string, string> = {
|
||
Stage: slug,
|
||
Revalidated: "true",
|
||
};
|
||
addBlockingSensorOverrideFields(
|
||
fields,
|
||
gateSensorEvaluation.issues,
|
||
true,
|
||
);
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
}
|
||
console.log(
|
||
JSON.stringify({
|
||
slug,
|
||
new_state: "awaiting-approval",
|
||
already_awaiting_approval: true,
|
||
revalidated: true,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
content = setCheckbox(content, slug, "awaiting-approval");
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
|
||
try {
|
||
const fields: Record<string, string> = { Stage: slug };
|
||
if (artifacts) fields.Artifacts = artifacts;
|
||
if (recovered) fields.Recovered = "true";
|
||
addBlockingSensorOverrideFields(
|
||
fields,
|
||
gateSensorEvaluation.issues,
|
||
overrideBlockingSensors,
|
||
);
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ slug, new_state: "awaiting-approval", timestamp }));
|
||
});
|
||
}
|
||
|
||
// approve <slug> [--user-input <exact-choice>]
|
||
// Transition: [?] → [x] AND auto-advance to the next in-scope stage (or
|
||
// complete the workflow if this was the final stage). Human judgment ends
|
||
// at the gate response; everything after is deterministic bookkeeping, so
|
||
// approve owns it end-to-end. Emits GATE_APPROVED + STAGE_COMPLETED, then
|
||
// delegates to handleAdvance or handleCompleteWorkflow for the remaining
|
||
// transitions. Eliminates the t59-class bug where the orchestrator approved
|
||
// but forgot to call advance, leaving Current Stage pointing at a [x] slug.
|
||
function verifyApprovalDecision(
|
||
pd: string,
|
||
content: string,
|
||
stage: NonNullable<ReturnType<typeof findStageBySlug>>,
|
||
userInput?: string,
|
||
forceHuman = false,
|
||
): { approvalInput: string | undefined; autonomousDecision: boolean } {
|
||
const autonomousDecision =
|
||
!forceHuman && isAutonomousConstructionDecision(content, stage.phase);
|
||
const approvalInput = userInput?.trim();
|
||
const approvalAuthorship =
|
||
autonomousDecision || humanPresenceGuardDisabled()
|
||
? null
|
||
: selfAttributedDecisionMarker(approvalInput, "approval");
|
||
if (approvalAuthorship) {
|
||
error(
|
||
`Cannot approve "${stage.slug}" because --user-input says the choice came from the ` +
|
||
`assistant (${approvalAuthorship.category}: "${approvalAuthorship.phrase}"). ` +
|
||
"This check looks for explicit assistant provenance; it does not prove who wrote " +
|
||
"the reply. Approval is the human's decision: end the turn and let them answer, " +
|
||
"and if something is blocking completion, explain that blocker at the gate in " +
|
||
"project terms instead of recording their decision.",
|
||
);
|
||
}
|
||
if (!autonomousDecision && !humanPresenceGuardDisabled()) {
|
||
const rawRevisionCount = getField(content, "Revision Count");
|
||
const parsedRevisionCount = rawRevisionCount
|
||
? parseInt(rawRevisionCount, 10)
|
||
: 0;
|
||
const revisionCount = Number.isFinite(parsedRevisionCount)
|
||
? parsedRevisionCount
|
||
: 0;
|
||
const matchesOfferedApproval =
|
||
approvalInput === "Approve" ||
|
||
(approvalInput === "Accept as-is" && revisionCount >= 3);
|
||
if (!matchesOfferedApproval) {
|
||
const cancellation = isNonAnswer(approvalInput)
|
||
? " The reply is cancellation boilerplate, not consent."
|
||
: "";
|
||
error(
|
||
`Cannot approve "${stage.slug}" because the reply ` +
|
||
`${formatReceivedReply(approvalInput)} did not match one of the offered ` +
|
||
`choices.${cancellation} Present the original question with every choice again ` +
|
||
"and wait for the human to pick one.",
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
!autonomousDecision &&
|
||
!humanPresenceGuardDisabled() &&
|
||
!humanActedSinceGate(pd)
|
||
) {
|
||
error(
|
||
`Cannot approve "${stage.slug}" because no new human reply has been received for ` +
|
||
"this approval question. Wait for the human to type their choice, then retry the " +
|
||
`approval.${unattendedHumanPresenceHint()}`,
|
||
);
|
||
}
|
||
return { approvalInput, autonomousDecision };
|
||
}
|
||
|
||
function handleApprove(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts approve <slug> [--user-input <text>]");
|
||
const slug = args[0];
|
||
const { userInput } = parseApproveFlags(args.slice(1));
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
const preflightContent = readStateFile(pd);
|
||
const preflightStage = findStageBySlug(slug);
|
||
if (!preflightStage) error(`Unknown stage: ${slug}`);
|
||
const preflightTeamGate = teamGateContext(
|
||
preflightContent,
|
||
preflightStage,
|
||
args.slice(1),
|
||
);
|
||
if (!preflightTeamGate) {
|
||
validateSlugInState(preflightContent, slug, "awaiting-approval");
|
||
}
|
||
const preflightDecision = verifyApprovalDecision(
|
||
pd,
|
||
preflightContent,
|
||
preflightStage,
|
||
userInput,
|
||
preflightTeamGate !== null,
|
||
);
|
||
if (preflightTeamGate) {
|
||
admitStageAction(pd, preflightContent, preflightStage, {
|
||
action: "complete",
|
||
entrypoint: "approve",
|
||
unit: preflightTeamGate.unit,
|
||
});
|
||
} else {
|
||
verifyStageArtifacts(pd, preflightStage);
|
||
verifySummaryConfirmationPrecondition(
|
||
pd,
|
||
preflightContent,
|
||
preflightStage,
|
||
);
|
||
}
|
||
const preflightBackstop =
|
||
preflightTeamGate === null &&
|
||
!revisionBackstopDisabled() &&
|
||
!preflightDecision.autonomousDecision &&
|
||
unrecordedRevisionSinceGateOpen(pd, preflightStage);
|
||
const backstopSensorEvaluation = preflightBackstop
|
||
? fireGateSensors(pd, preflightStage)
|
||
: { issues: [], fingerprints: new Map<string, string>() };
|
||
|
||
// Per-stage token/cost rollup - computed BEFORE the lock opens (ledger read
|
||
// only, try/caught). Approve is the STAGE_COMPLETED that fires in the normal
|
||
// gate flow (the nested advance/complete-workflow suppress their own once this
|
||
// one is audited), so the usage rollup attaches here. {} when no ledger exists.
|
||
const usageFields = stageRollupFields(pd, slug);
|
||
// C2b lost-update safety: the ENTIRE approve transaction — including the
|
||
// nested handleAdvance / handleCompleteWorkflow calls below — runs under one
|
||
// outer lock. withAuditLock is REENTRANT (per-pd depth counter): the nested
|
||
// handlers' own withAuditLock calls bump depth 1→2→1 and run inline without
|
||
// re-acquiring the OS lock, so approve+advance commit as one atomic unit and
|
||
// no concurrent writer can interleave between approve's write and the
|
||
// advance's re-read. The original ordering is preserved: approve writes its
|
||
// own state (slug → [x]) BEFORE delegating, so the nested re-read sees it.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
const teamGate = teamGateContext(content, stage, args.slice(1));
|
||
if (!teamGate) {
|
||
validateSlugInState(content, slug, "awaiting-approval");
|
||
}
|
||
const { approvalInput, autonomousDecision } = verifyApprovalDecision(
|
||
pd,
|
||
content,
|
||
stage,
|
||
userInput,
|
||
teamGate !== null,
|
||
);
|
||
|
||
if (teamGate) {
|
||
admitStageAction(pd, content, stage, {
|
||
action: "complete",
|
||
entrypoint: "approve",
|
||
unit: teamGate.unit,
|
||
});
|
||
const reviewFindingDispositions = acceptedRiskDispositionField(
|
||
pd,
|
||
teamGate.stages,
|
||
teamGate.unit,
|
||
);
|
||
const status = unitGateStatus(
|
||
pd,
|
||
stage.slug,
|
||
teamGate.unit,
|
||
teamGate.scope,
|
||
);
|
||
if (status !== "awaiting-approval") {
|
||
error(
|
||
`Gate for unit "${teamGate.unit}" of "${stage.slug}" is ${status}; ` +
|
||
"open or revise the gate before approving it.",
|
||
);
|
||
}
|
||
if (
|
||
!humanPresenceGuardDisabled() &&
|
||
!humanActedSinceGate(pd)
|
||
) {
|
||
error(
|
||
`Refusing to approve unit "${teamGate.unit}" for "${slug}": a real human ` +
|
||
"has not acted at this gate since it opened.",
|
||
);
|
||
}
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
try {
|
||
emitAudit(pd, "GATE_APPROVED", {
|
||
...teamGateFields(stage, teamGate),
|
||
...(approvalInput ? { "User Input": approvalInput } : {}),
|
||
...(reviewFindingDispositions
|
||
? {
|
||
[REVIEW_FINDING_DISPOSITIONS_FIELD]:
|
||
reviewFindingDispositions,
|
||
}
|
||
: {}),
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
unit: teamGate.unit,
|
||
gate_scope: teamGate.scope,
|
||
approved: true,
|
||
timestamp,
|
||
}));
|
||
return;
|
||
}
|
||
|
||
// Artifact guard (issue #366): a stage cannot be approved without evidence of
|
||
// work on disk. Runs BEFORE any mutation so a refusal (error() -> exit) leaves
|
||
// state untouched. The nested handleAdvance / handleCompleteWorkflow below see
|
||
// the slug as already [x] and skip their own guard, so this is the single
|
||
// enforcement point on the approve path. Bypass via AIDLC_SKIP_ARTIFACT_GUARD.
|
||
// Covers per-unit Construction stages (globs the record's
|
||
// construction/<unit>/<slug>/) and code-producing stages (workspace_requires).
|
||
verifyStageArtifacts(pd, stage);
|
||
verifySummaryConfirmationPrecondition(pd, content, stage);
|
||
|
||
// Gate-revision backstop: reconcile a revision the conductor performed at an
|
||
// open gate but never recorded (it skipped the `reject` verb). When the ledger
|
||
// proves the human revised this stage's artifact at the open gate with no
|
||
// recorded reject (unrecordedRevisionSinceGateOpen), backfill the missing
|
||
// GATE_REJECTED + STAGE_REVISING pair (tagged Recovered) and persist [R].
|
||
// A reviewer-bearing stage must then obtain a fresh post-rejection receipt
|
||
// before this command may emit the recovered gate re-entry. When that guard
|
||
// refuses, the durable [R] state routes the conductor through normal `revise`
|
||
// after review instead of leaving an invalid [?] gate open.
|
||
// Skipped under the off-switch and in autonomous Construction (no human at the
|
||
// gate, so no human-driven revision to reconcile).
|
||
const backstopNow =
|
||
!revisionBackstopDisabled() &&
|
||
!autonomousDecision &&
|
||
unrecordedRevisionSinceGateOpen(pd, stage);
|
||
if (backstopNow && !preflightBackstop) {
|
||
error(
|
||
`Refusing to approve "${slug}": revision evidence changed during the gate ` +
|
||
"preflight. Retry the approval so revised bytes can be checked before re-entry.",
|
||
);
|
||
}
|
||
if (backstopNow) {
|
||
const priorCount = getField(content, "Revision Count");
|
||
const priorParsed = priorCount ? parseInt(priorCount, 10) : 0;
|
||
const revCount = (Number.isFinite(priorParsed) ? priorParsed : 0) + 1;
|
||
content = setField(content, "Revision Count", String(revCount));
|
||
content = setCheckbox(content, slug, "revising");
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
// Audit-first: a failed emission aborts before any state write (matches the
|
||
// GATE_APPROVED/STAGE_COMPLETED try/catch below).
|
||
try {
|
||
emitAudit(pd, "GATE_REJECTED", {
|
||
Stage: slug,
|
||
Recovered: "true",
|
||
Details:
|
||
"Backfilled by the revision backstop: the artifact was revised at " +
|
||
"an open gate with no reject recorded",
|
||
...priorAcceptedSourceFields(pd, stage),
|
||
});
|
||
emitAudit(pd, "STAGE_REVISING", {
|
||
Stage: slug,
|
||
"Revision count": String(revCount),
|
||
Recovered: "true",
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
verifyGateSensorArtifactsUnchanged(slug, backstopSensorEvaluation);
|
||
verifySummaryConfirmationPrecondition(pd, content, stage);
|
||
verifyPipelineLinkPrecondition(pd, stage);
|
||
if (!reviewerGateGuardDisabled()) {
|
||
verifyReviewerPrecondition(pd, content, stage, "present-approval-gate");
|
||
}
|
||
enforceBlockingGateSensors(
|
||
pd,
|
||
content,
|
||
slug,
|
||
"revised",
|
||
backstopSensorEvaluation.issues,
|
||
false,
|
||
);
|
||
try {
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", {
|
||
Stage: slug,
|
||
Recovered: "true",
|
||
Details: "Re-entering gate after backfilled revision",
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
content = setCheckbox(content, slug, "awaiting-approval");
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
writeStateFile(pd, content);
|
||
}
|
||
|
||
// The shared admission chain for approve: artifacts and summary again (they
|
||
// ran before the backstop above and are re-read here), then pipeline and
|
||
// reviewer evidence. The router preflights `approve` through the same call.
|
||
admitStageAction(pd, content, stage, {
|
||
action: "complete",
|
||
entrypoint: "approve",
|
||
});
|
||
const reviewFindingDispositions = acceptedRiskDispositionField(pd, stage);
|
||
|
||
// Scope is required for next-stage derivation. Validate it before persisting
|
||
// approval: a post-write routing failure would otherwise leave `[x]` as a
|
||
// durable but incomplete approval and make crash recovery security-sensitive.
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) {
|
||
error(
|
||
`State file has no Scope field. Refusing to advance after approve — fix the state file first.`,
|
||
);
|
||
}
|
||
if (!validScopes().has(scope)) {
|
||
error(
|
||
`State file has invalid Scope "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`,
|
||
);
|
||
}
|
||
|
||
const timestamp = isoTimestamp();
|
||
|
||
content = setCheckbox(content, slug, "completed");
|
||
content = setField(content, "Last Updated", timestamp);
|
||
const completedCount = countCheckboxes(content, "completed");
|
||
content = setField(content, "Completed", String(completedCount));
|
||
content = setField(content, "Last Completed Stage", slug);
|
||
|
||
// Capture before the first audit append. The lifecycle-facing helper is
|
||
// fail-open: failures produce a receipt-less completion plus a persistent
|
||
// warning instead of stranding GATE_APPROVED without STAGE_COMPLETED.
|
||
const validationFields = stageValidationAuditFields(pd, stage, content);
|
||
const validationWarning = validationFields[VALIDATION_WARNING_FIELD];
|
||
|
||
// Atomic audit emissions (audit-first). GATE_APPROVED records the human
|
||
// decision; STAGE_COMPLETED records the state transition the approval
|
||
// implies. Both emit here so the audit trail is correct even if the
|
||
// downstream advance/complete-workflow fails.
|
||
try {
|
||
const gateFields: Record<string, string> = { Stage: slug };
|
||
if (approvalInput) gateFields["User Input"] = approvalInput;
|
||
if (reviewFindingDispositions) {
|
||
gateFields[REVIEW_FINDING_DISPOSITIONS_FIELD] =
|
||
reviewFindingDispositions;
|
||
}
|
||
emitAudit(pd, "GATE_APPROVED", gateFields);
|
||
|
||
emitAudit(pd, "STAGE_COMPLETED", {
|
||
Stage: slug,
|
||
...validationFields,
|
||
Details: `Stage ${stage.name} approved by gate`,
|
||
...usageFields,
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
|
||
// No explicit consume step (ledger-event design): the GATE_APPROVED
|
||
// emitted by this commit IS the freshness boundary for the next gate. A second
|
||
// gate auto-cascaded in the same human turn finds the last gate resolution
|
||
// (this GATE_APPROVED) AFTER the only HUMAN_TURN, so humanActedSinceGate refuses
|
||
// it — one commit per human turn, from ledger order, with no marker to flip.
|
||
const next = nextInScopeStage(slug, scope, content);
|
||
if (next) {
|
||
// Delegate to handleAdvance. The slug is now [x], so handleAdvance takes
|
||
// the alreadyMarkedCompleted path and skips re-emitting STAGE_COMPLETED.
|
||
// Reentrant call — runs under the depth-2 lock without re-acquire.
|
||
handleAdvance([slug], validationWarning);
|
||
} else {
|
||
// Final stage — complete the workflow. handleCompleteWorkflow re-sets
|
||
// the checkbox to [x] (idempotent) and emits PHASE_COMPLETED +
|
||
// PHASE_VERIFIED + WORKFLOW_COMPLETED. Reentrant call — see above.
|
||
handleCompleteWorkflow([slug], validationWarning);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Look up a flag's value while guarding against value-starting-with-"--"
|
||
// ambiguity. If the user forgets to provide a value (e.g. `--user-input
|
||
// --reason`), indexOf+slice would consume the next flag as the value —
|
||
// silently wrong. This helper errors cleanly when the value starts with "--".
|
||
// Returns undefined if the flag is absent.
|
||
function getFlagValue(args: string[], flag: string): string | undefined {
|
||
const idx = args.indexOf(flag);
|
||
if (idx === -1) return undefined;
|
||
if (idx + 1 >= args.length) {
|
||
error(`${flag} expects a value, got end of arguments.`);
|
||
}
|
||
const val = args[idx + 1];
|
||
if (val.startsWith("--")) {
|
||
error(`${flag} expects a value, got another flag: "${val}". Did you forget the value?`);
|
||
}
|
||
return val;
|
||
}
|
||
|
||
function getFlagValues(args: string[], flag: string): string[] {
|
||
const values: string[] = [];
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] !== flag) continue;
|
||
if (i + 1 >= args.length) {
|
||
error(`${flag} expects a value, got end of arguments.`);
|
||
}
|
||
const value = args[i + 1];
|
||
if (value.startsWith("--")) {
|
||
error(
|
||
`${flag} expects a value, got another flag: "${value}". Did you forget the value?`,
|
||
);
|
||
}
|
||
values.push(value);
|
||
i++;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
// Flag parser for approve — handles --user-input (value).
|
||
function parseApproveFlags(args: string[]): { userInput?: string } {
|
||
return {
|
||
userInput: getFlagValue(args, "--user-input"),
|
||
};
|
||
}
|
||
|
||
// reject <slug> [--user-input <exact-choice>] [--feedback <text>]
|
||
// [--reject-finding <review-artifact>#R-NN=<human reason>]...
|
||
// — transition
|
||
// [?] or [-] → [R], emit GATE_REJECTED + STAGE_REVISING, and increment Revision
|
||
// Count. The direct Active → Revising path deliberately does not fabricate a
|
||
// recovered approval gate: a persisted rejection is valid when gate-start was
|
||
// skipped, while STAGE_AWAITING_APPROVAL describes only a gate that passed its
|
||
// opening guards.
|
||
function handleReject(args: string[]): void {
|
||
if (args.length < 1) {
|
||
error(
|
||
'Usage: aidlc-state.ts reject <slug> [--user-input "Request Changes"] ' +
|
||
"[--feedback <text>] " +
|
||
"[--reject-finding <review-artifact>#R-NN=<human reason>]...",
|
||
);
|
||
}
|
||
const slug = args[0];
|
||
const decision = getFlagValue(args.slice(1), "--user-input")?.trim();
|
||
const feedback =
|
||
(getFlagValue(args.slice(1), "--feedback") ??
|
||
getFlagValue(args.slice(1), "--reason"))?.trim();
|
||
const rejectedFindings = getFlagValues(
|
||
args.slice(1),
|
||
"--reject-finding",
|
||
);
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// C2b lost-update safety: validate→increment Revision Count→emit-audit→write
|
||
// under one lock. The Revision Count read-modify-write is the exposed bit —
|
||
// two concurrent rejects must not both read N and both write N+1 (one
|
||
// increment lost). emit-then-write stays idempotent on retry: the lock
|
||
// serialises, and re-running the same input recomputes from the locked
|
||
// snapshot rather than double-incrementing a stale value.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
const teamGate = teamGateContext(content, stage, args.slice(1));
|
||
if (!teamGate) {
|
||
validateSlugInState(content, slug, ["awaiting-approval", "in-progress"]);
|
||
}
|
||
const feedbackStatus = guardRecoveryFeedbackStatus(
|
||
pd,
|
||
content,
|
||
slug,
|
||
teamGate?.unit,
|
||
feedback ?? "",
|
||
);
|
||
if (feedbackStatus === "other-remedy") {
|
||
const selectedAction = selectedGuardRecoveryRemedyAction(
|
||
pd,
|
||
content,
|
||
slug,
|
||
teamGate?.unit,
|
||
);
|
||
error(
|
||
`Refusing to reject "${slug}": the recovery-question choice was not Request Changes.` +
|
||
(selectedAction ? ` The selected action was "${selectedAction}".` : "") +
|
||
" Carry out that action, or re-present the recovery question and wait for the human to choose Request Changes.",
|
||
);
|
||
}
|
||
if (feedbackStatus === "awaiting-feedback") {
|
||
error(
|
||
`Refusing to reject "${slug}": the guard-recovery choice is not revision ` +
|
||
`feedback. Ask "What should change?", end the turn, and wait for the ` +
|
||
"human's separate response before retrying.",
|
||
);
|
||
}
|
||
const autonomousDecision =
|
||
!teamGate && isAutonomousConstructionDecision(content, stage.phase);
|
||
if (
|
||
!autonomousDecision &&
|
||
feedbackStatus === "not-applicable" &&
|
||
!humanPresenceGuardDisabled() &&
|
||
!isRequestChangesChoice(decision)
|
||
) {
|
||
const cancellation = isNonAnswer(decision)
|
||
? " The reply is cancellation boilerplate, not a decision."
|
||
: "";
|
||
error(
|
||
`Refusing to reject "${slug}": received reply ${formatReceivedReply(decision)} did not ` +
|
||
`match an offered choice at the held gate.${cancellation} ` +
|
||
"Re-present the original held gate with every offered choice and wait for the human " +
|
||
"to choose one.",
|
||
);
|
||
}
|
||
if (!feedback) {
|
||
error(
|
||
`Refusing to reject "${slug}": Request Changes requires nonblank revision feedback in ` +
|
||
"--feedback (or --reason through aidlc-orchestrate.ts report).",
|
||
);
|
||
}
|
||
if (isNonAnswer(feedback)) {
|
||
error(
|
||
`Refusing to reject "${slug}": revision feedback ${formatReceivedReply(feedback)} is ` +
|
||
"cancellation boilerplate. Re-present the original held gate with every offered choice " +
|
||
"and wait for the human to choose one.",
|
||
);
|
||
}
|
||
if (feedbackStatus === "mismatch") {
|
||
error(
|
||
`Refusing to reject "${slug}": --feedback does not exactly match the ` +
|
||
"human's separate guard-recovery response. Pass their text unchanged.",
|
||
);
|
||
}
|
||
|
||
const autonomousMode = isAutonomousMode(content);
|
||
const recoveryResetNeedsHuman =
|
||
!teamGate &&
|
||
autonomousMode &&
|
||
reviewRecoverySpentInCurrentAttempt(pd, content, stage);
|
||
if (
|
||
(!autonomousDecision || recoveryResetNeedsHuman) &&
|
||
!humanPresenceGuardDisabled() &&
|
||
!humanActedSinceGate(pd)
|
||
) {
|
||
if (recoveryResetNeedsHuman) {
|
||
error(
|
||
`Cannot request changes for "${slug}" because its recovery review has already ` +
|
||
`been used and only a new human choice can start another review attempt. Present ` +
|
||
`the situation at the approval question and wait for a typed Request Changes choice.${unattendedHumanPresenceHint()}`,
|
||
);
|
||
}
|
||
error(
|
||
`Cannot request changes for "${slug}" because no new human reply has been received ` +
|
||
`for this approval question. Wait for the human to type Request Changes and their ` +
|
||
`feedback, then retry.${unattendedHumanPresenceHint()}`,
|
||
);
|
||
}
|
||
|
||
// Authorship floor (issue 742). The presence check above proves a human is in
|
||
// the session, not that this rejection is theirs — so a conductor blocked by
|
||
// the review-budget/receipt ordering can satisfy it while writing its own
|
||
// change request, because GATE_REJECTED is the only event that restores an
|
||
// advisory review budget. That reopen is the single most attractive forgery in
|
||
// the protocol and the one seen in the field, so refuse the self-attributed
|
||
// rejection here rather than laundering it into the trail as the human's.
|
||
// Autonomous Construction is exempt (the conductor owns the decision there).
|
||
const rejectionAuthorship =
|
||
autonomousDecision || humanPresenceGuardDisabled()
|
||
? null
|
||
: selfAttributedDecisionMarker(feedback, "rejection");
|
||
if (rejectionAuthorship) {
|
||
error(
|
||
`Cannot request changes for "${slug}" because --feedback says it was written by the ` +
|
||
`assistant (${rejectionAuthorship.category}: "${rejectionAuthorship.phrase}"). ` +
|
||
`Requesting changes is the human's decision. Explain why another review is needed at ` +
|
||
`the approval question and wait for the human to choose.`,
|
||
);
|
||
}
|
||
const reviewFindingDispositions = rejectedFindingDispositionField(
|
||
pd,
|
||
teamGate?.stages ?? stage,
|
||
rejectedFindings,
|
||
teamGate?.unit,
|
||
);
|
||
|
||
if (teamGate) {
|
||
const priorStatus = unitGateStatus(
|
||
pd,
|
||
stage.slug,
|
||
teamGate.unit,
|
||
teamGate.scope,
|
||
);
|
||
if (
|
||
priorStatus !== "awaiting-approval" &&
|
||
priorStatus !== "pending"
|
||
) {
|
||
error(
|
||
`Gate for unit "${teamGate.unit}" of "${stage.slug}" is ${priorStatus}; ` +
|
||
"only a pending or awaiting gate can be rejected.",
|
||
);
|
||
}
|
||
const current = getField(content, "Revision Count");
|
||
const parsed = current ? parseInt(current, 10) : 0;
|
||
const revCount = (Number.isFinite(parsed) ? parsed : 0) + 1;
|
||
content = setField(content, "Revision Count", String(revCount));
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
const fields = teamGateFields(stage, teamGate);
|
||
try {
|
||
emitAudit(pd, "GATE_REJECTED", {
|
||
...fields,
|
||
Feedback: feedback,
|
||
...(reviewFindingDispositions
|
||
? {
|
||
[REVIEW_FINDING_DISPOSITIONS_FIELD]:
|
||
reviewFindingDispositions,
|
||
}
|
||
: {}),
|
||
});
|
||
emitAudit(pd, "STAGE_REVISING", {
|
||
...fields,
|
||
"Revision count": String(revCount),
|
||
Feedback: feedback,
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
unit: teamGate.unit,
|
||
gate_scope: teamGate.scope,
|
||
new_state: "revising",
|
||
revision_count: revCount,
|
||
timestamp,
|
||
}));
|
||
return;
|
||
}
|
||
|
||
// Increment Revision Count. Guard against non-numeric values (missing field,
|
||
// manual edits, legacy state files) by coercing non-integers to 0.
|
||
const current = getField(content, "Revision Count");
|
||
const parsed = current ? parseInt(current, 10) : 0;
|
||
const revCount = (Number.isFinite(parsed) ? parsed : 0) + 1;
|
||
content = setField(content, "Revision Count", String(revCount));
|
||
|
||
content = setCheckbox(content, slug, "revising");
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
|
||
try {
|
||
const rejFields: Record<string, string> = {
|
||
Stage: slug,
|
||
Feedback: feedback,
|
||
...priorAcceptedSourceFields(pd, stage),
|
||
};
|
||
if (reviewFindingDispositions) {
|
||
rejFields[REVIEW_FINDING_DISPOSITIONS_FIELD] =
|
||
reviewFindingDispositions;
|
||
}
|
||
emitAudit(pd, "GATE_REJECTED", rejFields);
|
||
emitAudit(pd, "STAGE_REVISING", {
|
||
Stage: slug,
|
||
"Revision count": String(revCount),
|
||
Feedback: feedback,
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ slug, new_state: "revising", revision_count: revCount, timestamp }));
|
||
});
|
||
}
|
||
|
||
// revise <slug> — fire gate-bound sensors, then transition [R] → [?]
|
||
// (re-enter gate after revision work)
|
||
function handleRevise(args: string[]): void {
|
||
if (args.length < 1) {
|
||
error(
|
||
"Usage: aidlc-state.ts revise <slug> [--override-blocking-sensors] " +
|
||
"[--user-input <choice>]",
|
||
);
|
||
}
|
||
const slug = args[0];
|
||
const overrideBlockingSensors = args.includes("--override-blocking-sensors");
|
||
const overrideInput = getFlagValue(args.slice(1), "--user-input");
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// Sensor dispatch MUST stay outside the state transaction: aidlc-sensor.ts
|
||
// takes the same audit lock around its FIRED and terminal rows.
|
||
const preflightContent = readStateFile(pd);
|
||
const preflightStage = findStageBySlug(slug);
|
||
if (!preflightStage) error(`Unknown stage: ${slug}`);
|
||
const preflightTeamGate = teamGateContext(
|
||
preflightContent,
|
||
preflightStage,
|
||
args.slice(1),
|
||
);
|
||
if (preflightTeamGate) {
|
||
const status = unitGateStatus(
|
||
pd,
|
||
preflightStage.slug,
|
||
preflightTeamGate.unit,
|
||
preflightTeamGate.scope,
|
||
);
|
||
if (status !== "revising") {
|
||
error(
|
||
`Gate for unit "${preflightTeamGate.unit}" of "${preflightStage.slug}" is ${status}; ` +
|
||
"only a revising gate can re-enter approval.",
|
||
);
|
||
}
|
||
} else {
|
||
validateSlugInState(preflightContent, slug, "revising");
|
||
}
|
||
admitStageAction(pd, preflightContent, preflightStage, {
|
||
action: "revise",
|
||
...(preflightTeamGate ? { unit: preflightTeamGate.unit } : {}),
|
||
});
|
||
const gateSensorEvaluation = fireGateSensors(pd, preflightStage);
|
||
enforceBlockingGateSensors(
|
||
pd,
|
||
preflightContent,
|
||
slug,
|
||
"revised",
|
||
gateSensorEvaluation.issues,
|
||
overrideBlockingSensors,
|
||
overrideInput,
|
||
);
|
||
|
||
// C2b lost-update safety: validate→transition→emit-audit→write under one lock.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
const teamGate = teamGateContext(content, stage, args.slice(1));
|
||
if (teamGate) {
|
||
const status = unitGateStatus(
|
||
pd,
|
||
stage.slug,
|
||
teamGate.unit,
|
||
teamGate.scope,
|
||
);
|
||
if (status !== "revising") {
|
||
error(
|
||
`Gate for unit "${teamGate.unit}" of "${stage.slug}" is ${status}; ` +
|
||
"only a revising gate can re-enter approval.",
|
||
);
|
||
}
|
||
admitStageAction(pd, content, stage, {
|
||
action: "revise",
|
||
unit: teamGate.unit,
|
||
});
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
try {
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", {
|
||
...teamGateFields(stage, teamGate),
|
||
Details: "Re-entering unit gate after revision",
|
||
});
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
unit: teamGate.unit,
|
||
gate_scope: teamGate.scope,
|
||
new_state: "awaiting-approval",
|
||
timestamp,
|
||
}));
|
||
return;
|
||
}
|
||
validateSlugInState(content, slug, "revising");
|
||
admitStageAction(pd, content, stage, { action: "revise" });
|
||
verifyGateSensorArtifactsUnchanged(slug, gateSensorEvaluation);
|
||
|
||
content = setCheckbox(content, slug, "awaiting-approval");
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
|
||
try {
|
||
const fields: Record<string, string> = {
|
||
Stage: slug,
|
||
Details: "Re-entering gate after revision",
|
||
};
|
||
addBlockingSensorOverrideFields(
|
||
fields,
|
||
gateSensorEvaluation.issues,
|
||
overrideBlockingSensors,
|
||
);
|
||
emitAudit(pd, "STAGE_AWAITING_APPROVAL", fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ slug, new_state: "awaiting-approval", timestamp }));
|
||
});
|
||
}
|
||
|
||
// skip <slug> [--reason <text>] [--route]
|
||
//
|
||
// The historical un-routed form remains a narrow state primitive for internal
|
||
// repair and tests: it flips [ ]/[-]/[R] to [S] and emits STAGE_SKIPPED.
|
||
// `--route` is the engine-owned stage outcome. In one locked transaction it
|
||
// preserves [S], emits STAGE_SKIPPED exactly once, then starts the next
|
||
// in-scope stage (including phase-boundary events) or completes the workflow.
|
||
// An [S] slug with an unmoved Current Stage is accepted only on this internal
|
||
// routed path so an interrupted historical transition can finish without
|
||
// duplicating STAGE_SKIPPED.
|
||
function handleSkip(args: string[]): void {
|
||
if (args.length < 1) {
|
||
error("Usage: aidlc-state.ts skip <slug> [--reason <text>] [--route]");
|
||
}
|
||
const slug = args[0];
|
||
const reason = getFlagValue(args.slice(1), "--reason")?.trim();
|
||
const route = args.includes("--route");
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
const workflowUsageFields = workflowRollupFields(pd);
|
||
// C2b lost-update safety: validate→transition→emit-audit→write under one lock.
|
||
withAuditLock(pd, () => {
|
||
let content = readStateFile(pd);
|
||
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
if (!route) {
|
||
validateSlugInState(content, slug, ["pending", "in-progress", "revising"]);
|
||
|
||
content = setCheckbox(content, slug, "skipped");
|
||
const timestamp = isoTimestamp();
|
||
content = setField(content, "Last Updated", timestamp);
|
||
|
||
try {
|
||
const fields: Record<string, string> = { Stage: slug };
|
||
if (reason) fields.Reason = reason;
|
||
emitAudit(pd, "STAGE_SKIPPED", fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
console.log(JSON.stringify({ slug, new_state: "skipped", timestamp }));
|
||
return;
|
||
}
|
||
|
||
if (!reason) {
|
||
error("aidlc-state.ts skip --route requires a nonblank --reason <text>.");
|
||
}
|
||
validateSlugInState(content, slug, [
|
||
"in-progress",
|
||
"revising",
|
||
"skipped",
|
||
]);
|
||
const currentStage = getField(content, "Current Stage");
|
||
if (currentStage !== slug) {
|
||
error(
|
||
`Cannot route skipped stage "${slug}": Current Stage is "${currentStage ?? ""}".`,
|
||
);
|
||
}
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) {
|
||
error(
|
||
"State file has no Scope field. Refusing to route skip - fix the state file first.",
|
||
);
|
||
}
|
||
if (!validScopes().has(scope)) {
|
||
error(
|
||
`State file has invalid Scope "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`,
|
||
);
|
||
}
|
||
|
||
const wasSkipped = getSlugState(content, slug) === "skipped";
|
||
const skipAuditTail = wasSkipped
|
||
? currentRoutedSkipAuditTail(pd, slug)
|
||
: null;
|
||
const skipAlreadyAudited = skipAuditTail !== null;
|
||
content = setCheckbox(content, slug, "skipped");
|
||
const timestamp = isoTimestamp();
|
||
const nextStage = nextInScopeStage(slug, scope, content);
|
||
const crossesPhaseBoundary =
|
||
nextStage !== null && stage.phase !== nextStage.phase;
|
||
const boundaryTarget = nextStage?.phase ?? "(end)";
|
||
const boundary = `${stage.phase} → ${nextStage?.phase ?? "end"}`;
|
||
const phaseCompletedAlreadyAudited = skipAuditTail !== null && auditTailHasFields(
|
||
skipAuditTail,
|
||
"PHASE_COMPLETED",
|
||
{
|
||
"From phase": stage.phase,
|
||
"To phase": boundaryTarget,
|
||
},
|
||
);
|
||
const phaseVerifiedAlreadyAudited = skipAuditTail !== null && auditTailHasFields(
|
||
skipAuditTail,
|
||
"PHASE_VERIFIED",
|
||
{ "Phase boundary": boundary },
|
||
);
|
||
const phaseStartedAlreadyAudited = nextStage
|
||
? skipAuditTail !== null && auditTailHasFields(skipAuditTail, "PHASE_STARTED", {
|
||
Phase: nextStage.phase,
|
||
Scope: scope,
|
||
})
|
||
: false;
|
||
const stageStartedAlreadyAudited = nextStage
|
||
? skipAuditTail !== null && auditTailHasFields(skipAuditTail, "STAGE_STARTED", {
|
||
Stage: nextStage.slug,
|
||
})
|
||
: false;
|
||
const workflowCompletedAlreadyAudited = nextStage === null
|
||
? skipAuditTail !== null && auditTailHasFields(skipAuditTail, "WORKFLOW_COMPLETED", {
|
||
Scope: scope,
|
||
Details: `Scope: ${scope}, final stage ${slug} skipped`,
|
||
})
|
||
: false;
|
||
|
||
if (nextStage) {
|
||
content = setCheckbox(content, nextStage.slug, "in-progress");
|
||
const nextAfterNext = nextInScopeStage(nextStage.slug, scope, content);
|
||
content = setField(content, "Current Stage", nextStage.slug);
|
||
content = setField(content, "Lifecycle Phase", nextStage.phase.toUpperCase());
|
||
content = setField(
|
||
content,
|
||
"Next Stage",
|
||
nextAfterNext ? nextAfterNext.slug : "none",
|
||
);
|
||
content = setField(content, "In Progress", nextStage.slug);
|
||
content = setField(content, "Active Agent", nextStage.lead_agent);
|
||
content = setField(content, "Status", "Running");
|
||
content = setField(content, "Next Action", `Execute ${nextStage.name}`);
|
||
if (crossesPhaseBoundary) {
|
||
content = setPhaseProgress(content, stage.phase, "Verified");
|
||
content = setPhaseProgress(content, nextStage.phase, "Active");
|
||
}
|
||
} else {
|
||
content = setField(content, "Status", "Completed");
|
||
content = setField(content, "In Progress", "none");
|
||
content = setField(content, "Next Stage", "none");
|
||
content = setField(content, "Next Action", "Workflow complete");
|
||
content = setPhaseProgress(content, stage.phase, "Verified");
|
||
}
|
||
content = setField(
|
||
content,
|
||
"Completed",
|
||
String(countCheckboxes(content, "completed")),
|
||
);
|
||
content = setField(content, "Last Updated", timestamp);
|
||
|
||
try {
|
||
if (!skipAlreadyAudited) {
|
||
emitAudit(pd, "STAGE_SKIPPED", {
|
||
Stage: slug,
|
||
Reason: reason,
|
||
"Skip Kind": "conditional-runtime",
|
||
});
|
||
}
|
||
if (nextStage) {
|
||
if (crossesPhaseBoundary) {
|
||
if (!phaseCompletedAlreadyAudited) {
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": stage.phase,
|
||
"To phase": nextStage.phase,
|
||
"Stages completed": String(countCheckboxes(content, "completed")),
|
||
});
|
||
}
|
||
if (!phaseVerifiedAlreadyAudited) {
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": boundary,
|
||
});
|
||
}
|
||
if (!phaseStartedAlreadyAudited) {
|
||
emitAudit(pd, "PHASE_STARTED", {
|
||
Phase: nextStage.phase,
|
||
Scope: scope,
|
||
});
|
||
}
|
||
}
|
||
if (!stageStartedAlreadyAudited) {
|
||
emitAudit(pd, "STAGE_STARTED", {
|
||
Stage: nextStage.slug,
|
||
Agent: nextStage.lead_agent,
|
||
...(nextStage.workspace_requires
|
||
? sourceBaselineAuditFields(pd, nextStage.slug)
|
||
: {}),
|
||
});
|
||
}
|
||
} else {
|
||
if (!phaseCompletedAlreadyAudited) {
|
||
emitAudit(pd, "PHASE_COMPLETED", {
|
||
"From phase": stage.phase,
|
||
"To phase": "(end)",
|
||
"Stages completed": String(countCheckboxes(content, "completed")),
|
||
});
|
||
}
|
||
if (!phaseVerifiedAlreadyAudited) {
|
||
emitAudit(pd, "PHASE_VERIFIED", {
|
||
"Phase boundary": boundary,
|
||
});
|
||
}
|
||
if (!workflowCompletedAlreadyAudited) {
|
||
emitAudit(pd, "WORKFLOW_COMPLETED", {
|
||
Scope: scope,
|
||
Details: `Scope: ${scope}, final stage ${slug} skipped`,
|
||
Reason: reason,
|
||
...workflowUsageFields,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, content);
|
||
if (!nextStage) {
|
||
const completedSelection = resolveWorkflowSelection(pd);
|
||
const completedIntentDir = completedSelection.intent;
|
||
if (completedIntentDir) {
|
||
updateIntentStatus(
|
||
pd,
|
||
completedIntentDir,
|
||
"complete",
|
||
completedSelection.space,
|
||
);
|
||
}
|
||
}
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
new_state: "skipped",
|
||
started: nextStage?.slug ?? null,
|
||
workflow_completed: nextStage === null,
|
||
recovered: wasSkipped || skipAlreadyAudited,
|
||
timestamp,
|
||
}));
|
||
});
|
||
}
|
||
|
||
// resume — read-only re-entry marker used by the orchestrator's resume path.
|
||
// Returns structured JSON the orchestrator can branch on, including compaction
|
||
// detection (was the most recent audit event SESSION_COMPACTED without any
|
||
// subsequent stage activity?). Session-level SESSION_RESUMED emission is the
|
||
// SessionStart hook's job, NOT this tool — this is a pure reader.
|
||
function handleResume(_args: string[]): void {
|
||
const pd = resolveProjectDir(projectDir);
|
||
const content = readStateFile(pd);
|
||
const currentStage = getField(content, "Current Stage") || "unknown";
|
||
const status = getField(content, "Status") || "unknown";
|
||
const phase = getField(content, "Lifecycle Phase") || "unknown";
|
||
const scope = getField(content, "Scope") || "unknown";
|
||
const activeAgent = getField(content, "Active Agent") || "unknown";
|
||
const nextStage = getField(content, "Next Stage") || "none";
|
||
|
||
// Stage-level gate awareness — tells the orchestrator whether the user is
|
||
// the blocker on this stage (awaiting approval / revising).
|
||
const checkboxes = parseCheckboxes(content);
|
||
const currentCb = checkboxes.find((c) => c.slug === currentStage);
|
||
const gateState = currentCb?.state ?? "unknown";
|
||
|
||
// Compaction detection — scan the tail of audit.md for a SESSION_COMPACTED
|
||
// event that has no subsequent stage activity. The orchestrator uses this
|
||
// to surface the compaction-awareness prompt without a fragile shell pipeline.
|
||
let compactionPending = false;
|
||
try {
|
||
// Merge across per-clone audit shards (single shard in the common case).
|
||
const raw = readAllAuditShards(pd);
|
||
if (raw.length > 0) {
|
||
// Read last ~400 lines (enough to cover ~30 events' worth of blocks)
|
||
const tailLines = raw.split("\n").slice(-400);
|
||
const tail = tailLines.join("\n");
|
||
// Find the index of the last SESSION_COMPACTED event
|
||
const lastCompactIdx = tail.lastIndexOf("**Event**: SESSION_COMPACTED");
|
||
if (lastCompactIdx !== -1) {
|
||
const after = tail.slice(lastCompactIdx);
|
||
// Any stage activity OR explicit recovery after the compaction?
|
||
// STAGE_STARTED / STAGE_COMPLETED / GATE_APPROVED / SESSION_RESUMED
|
||
// are normal progress; RECOVERY_COMPLETED is the explicit "user saw
|
||
// the compaction prompt and chose how to proceed" signal.
|
||
const hasActivity =
|
||
/\*\*Event\*\*: (STAGE_STARTED|STAGE_COMPLETED|GATE_APPROVED|SESSION_RESUMED|RECOVERY_COMPLETED)/.test(
|
||
after
|
||
);
|
||
compactionPending = !hasActivity;
|
||
}
|
||
}
|
||
} catch {
|
||
// Audit read failures are non-fatal — default to false, orchestrator
|
||
// will use the standard resume flow.
|
||
}
|
||
|
||
// Unit-level checkpoint (issue 681 claim 2): a resumed session must land on
|
||
// the exact unit stopping point, not just the stage. Read from the runtime
|
||
// mirror fields the `unit` verb maintains; absent on stage-level workflows.
|
||
const activeUnit = getField(content, "Active Unit");
|
||
const unitState = getField(content, "Unit State");
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
resumed: true,
|
||
current_stage: currentStage,
|
||
phase,
|
||
status,
|
||
scope,
|
||
active_agent: activeAgent,
|
||
next_stage: nextStage,
|
||
gate_state: gateState,
|
||
compaction_pending: compactionPending,
|
||
...(activeUnit
|
||
? {
|
||
active_unit: activeUnit,
|
||
unit_state: unitState ?? "in-progress",
|
||
unit_pause_reason: getField(content, "Unit Pause Reason") ?? undefined,
|
||
unit_next_action: getField(content, "Unit Next Action") ?? undefined,
|
||
}
|
||
: {}),
|
||
})
|
||
);
|
||
}
|
||
|
||
// acknowledge-compaction --choice <continue|review|restart>
|
||
//
|
||
// Called by the orchestrator's compaction-awareness flow AFTER the user picks
|
||
// Continue / Review / Restart in response to a pending SESSION_COMPACTED event.
|
||
// Emits RECOVERY_COMPLETED to record that the user was presented with the
|
||
// prompt and made a choice — closing the "compaction detected but not yet
|
||
// handled" window. Refuses if `handleResume` would report compaction_pending=false,
|
||
// so the event is only emitted when the flow is genuinely recovering.
|
||
function handleAcknowledgeCompaction(args: string[]): void {
|
||
const pd = resolveProjectDir(projectDir);
|
||
let choice = "";
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === "--choice" && i + 1 < args.length) {
|
||
choice = args[i + 1];
|
||
i++;
|
||
}
|
||
}
|
||
if (!choice) {
|
||
error(
|
||
"Usage: aidlc-state.ts acknowledge-compaction --choice <continue|review|restart>"
|
||
);
|
||
}
|
||
if (!["continue", "review", "restart"].includes(choice)) {
|
||
error(`Invalid --choice: ${choice}. Valid: continue, review, restart`);
|
||
}
|
||
|
||
const content = readStateFile(pd);
|
||
const currentStage = getField(content, "Current Stage") || "unknown";
|
||
|
||
// Only emit if compaction is pending. This prevents spurious
|
||
// RECOVERY_COMPLETED events when the orchestrator calls acknowledge unnecessarily.
|
||
let compactionPending = false;
|
||
try {
|
||
const raw = readAllAuditShards(pd);
|
||
if (raw.length > 0) {
|
||
const tail = raw.split("\n").slice(-400).join("\n");
|
||
const lastCompactIdx = tail.lastIndexOf("**Event**: SESSION_COMPACTED");
|
||
if (lastCompactIdx !== -1) {
|
||
const after = tail.slice(lastCompactIdx);
|
||
compactionPending =
|
||
!/\*\*Event\*\*: (STAGE_STARTED|STAGE_COMPLETED|GATE_APPROVED|SESSION_RESUMED|RECOVERY_COMPLETED)/.test(
|
||
after
|
||
);
|
||
}
|
||
}
|
||
} catch {
|
||
// Audit unreadable — nothing to recover.
|
||
}
|
||
|
||
if (!compactionPending) {
|
||
error(
|
||
"No pending compaction to acknowledge (latest SESSION_COMPACTED already followed by stage activity or recovery)."
|
||
);
|
||
}
|
||
|
||
emitAudit(pd, "RECOVERY_COMPLETED", {
|
||
Choice: choice,
|
||
"Current Stage": currentStage,
|
||
});
|
||
|
||
console.log(
|
||
JSON.stringify({ acknowledged: true, choice, current_stage: currentStage })
|
||
);
|
||
}
|
||
|
||
// practices-event --type <discovered|override|empty> [--field "K: V"]...
|
||
// Emits a PRACTICES_* audit event from tool code (not stage prose).
|
||
// Required by the audit-first invariant: every audit event must originate
|
||
// in .ts code so t48's emitter-pairing check passes. Called by the
|
||
// practices-discovery stage for discovery and advisory fallback events.
|
||
// PRACTICES_AFFIRMED is reserved for practices-promote, which atomically pairs
|
||
// it with the state timestamp after both memory targets are written.
|
||
function handlePracticesEvent(args: string[]): void {
|
||
const pd = resolveProjectDir(projectDir);
|
||
let eventTypeArg = "";
|
||
const fields: Record<string, string> = {};
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === "--type" && i + 1 < args.length) {
|
||
eventTypeArg = args[i + 1];
|
||
i++;
|
||
} else if (args[i] === "--field" && i + 1 < args.length) {
|
||
const kv = args[i + 1];
|
||
const idx = kv.indexOf(":");
|
||
if (idx > 0) {
|
||
const key = kv.slice(0, idx).trim();
|
||
const value = kv.slice(idx + 1).trim();
|
||
fields[key] = value;
|
||
}
|
||
i++;
|
||
}
|
||
}
|
||
if (!eventTypeArg) {
|
||
error(
|
||
'Usage: aidlc-state.ts practices-event --type <discovered|override|empty> [--field "Key: Value"]...'
|
||
);
|
||
}
|
||
// Explicit literal-string emitAudit calls per --type so t48's
|
||
// emitter-pairing check (which scans for `emitAudit(... "EVENT_NAME")`
|
||
// literals) finds each event at a real call site.
|
||
//
|
||
// --type empty handles the orchestrator's layer-3 fallback path (when
|
||
// extractMarkdownSection returns "" and the orchestrator falls back to
|
||
// scope-hardcoded defaults). Advisory-only — does not block execution.
|
||
// The `override` case is reused by the orchestrator with --field "Reason:
|
||
// bolt-plan-marker-conflict" + --field "Practices Stance: ..." +
|
||
// --field "Bolt-Plan Marker: ..." + --field "Bolt slug: ..." for the
|
||
// orchestrator-overrides-bolt-plan-marker semantic. The write-failure path
|
||
// uses --field "Reason: write-failure-..." — same event, distinct Reason
|
||
// field (discriminator-field disambiguation, no
|
||
// audit-count bump).
|
||
let emittedEvent: string;
|
||
switch (eventTypeArg) {
|
||
case "discovered":
|
||
emitAudit(pd, "PRACTICES_DISCOVERED", fields);
|
||
emittedEvent = "PRACTICES_DISCOVERED";
|
||
break;
|
||
case "affirmed":
|
||
error(
|
||
"PRACTICES_AFFIRMED is reserved for practices-promote so the audit receipt cannot be minted without successful memory promotion."
|
||
);
|
||
return;
|
||
case "override":
|
||
emitAudit(pd, "PRACTICES_OVERRIDE", fields);
|
||
emittedEvent = "PRACTICES_OVERRIDE";
|
||
break;
|
||
case "empty":
|
||
emitAudit(pd, "PRACTICES_SECTION_EMPTY", fields);
|
||
emittedEvent = "PRACTICES_SECTION_EMPTY";
|
||
break;
|
||
default:
|
||
error(
|
||
`Invalid --type: ${eventTypeArg}. Must be discovered, override, or empty.`
|
||
);
|
||
return;
|
||
}
|
||
console.log(
|
||
JSON.stringify({ emitted: emittedEvent, fields_count: Object.keys(fields).length })
|
||
);
|
||
}
|
||
|
||
// practices-promote --team-practices <path> --discovered-rules <path>
|
||
// [--affirming-user <name>] [--target-dir <path>]
|
||
//
|
||
// Cross-row promotion of affirmed practices into the team-authored method
|
||
// files. Reads two draft files from the active intent's practices-discovery
|
||
// and applies them deterministically to the relocated method files the
|
||
// resolver reads (aidlc/spaces/<space>/memory/, neutral names):
|
||
//
|
||
// memory/team.md ........... replaceSection × 5 (Way of Working,
|
||
// Walking Skeleton, Testing Posture,
|
||
// Deployment, Code Style)
|
||
// memory/project.md ........ appendUnderHeading × 2 (Mandated,
|
||
// Forbidden), each rule stamped
|
||
// with `(affirmed YYYY-MM-DD)`
|
||
//
|
||
// Atomicity:
|
||
// 1. Validate every declared support contribution (fail before any write).
|
||
// 2. Read both drafts (fail closed before any write).
|
||
// 3. Read both targets (fail closed if either missing).
|
||
// 4. Build new contents in memory.
|
||
// 5. Write project.md first (smaller, more constrained).
|
||
// 6. Write team.md second.
|
||
// 7. On success → emit PRACTICES_AFFIRMED.
|
||
// 8. On any failure → emit PRACTICES_OVERRIDE with the failure reason
|
||
// and rethrow so the caller halts the gate.
|
||
//
|
||
// Why this exists: when stage prose tells the LLM to write to the method
|
||
// files directly, the LLM (running non-interactively under `claude -p`)
|
||
// hallucinates a sensitive-file permission policy that does not actually
|
||
// exist. The orchestrator then halts at "awaiting-approval" and emits
|
||
// PRACTICES_OVERRIDE without ever attempting the write — the workflow
|
||
// bricks. Routing the writes through a tool subcommand removes the LLM's
|
||
// judgment from the path: the path is never the LLM's write target, so the
|
||
// hallucinated policy never fires.
|
||
function handlePracticesPromote(args: string[]): void {
|
||
const flags: Record<string, string> = {};
|
||
for (let i = 0; i < args.length; i++) {
|
||
const a = args[i];
|
||
if (a.startsWith("--") && i + 1 < args.length) {
|
||
flags[a.slice(2)] = args[i + 1];
|
||
i++;
|
||
}
|
||
}
|
||
if (!flags["team-practices"] || !flags["discovered-rules"]) {
|
||
error(
|
||
'Usage: aidlc-state.ts practices-promote --team-practices <path> --discovered-rules <path> [--affirming-user <name>] [--target-dir <path>]'
|
||
);
|
||
}
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
// The affirmed practices land in the relocated method files the resolver
|
||
// reads — team.md / project.md under aidlc/spaces/<space>/memory/ (neutral
|
||
// names, no `aidlc-` prefix). memoryDirFor() derives the path from the SAME
|
||
// MEMORY_SEGMENTS loadRules() reads from, so this writer and the reader can
|
||
// never drift (P5 relocated the reader; P6 closes the seam here). --target-dir
|
||
// lets tests point the writes at a fixture memory dir; it defaults to the
|
||
// project's resolved memory dir.
|
||
const targetRoot = flags["target-dir"] ?? memoryDirFor(pd);
|
||
const teamMdPath = join(targetRoot, "team.md");
|
||
const guardrailsPath = join(targetRoot, "project.md");
|
||
|
||
const today = isoTimestamp().slice(0, 10);
|
||
const sectionsWritten: string[] = [];
|
||
const rulesAppended = { mandated: 0, forbidden: 0 };
|
||
|
||
const fail = (reason: string): never => {
|
||
try {
|
||
emitAudit(pd, "PRACTICES_OVERRIDE", {
|
||
Reason: reason,
|
||
});
|
||
} catch {
|
||
// If audit emission itself fails, surface the original reason.
|
||
}
|
||
error(`practices-promote failed: ${reason}`);
|
||
throw new Error(reason); // unreachable; error() exits, but TS needs this
|
||
};
|
||
|
||
// Step 1: Revalidate the hub-and-spoke evidence immediately before the
|
||
// cross-row memory write. The gate-open report checks the same contract
|
||
// before asking the human, but files can be deleted or malformed while the
|
||
// gate is open. Promotion is the irreversible seam, so it fails closed too.
|
||
const teamPracticesPath = flags["team-practices"];
|
||
const discoveredRulesPath = flags["discovered-rules"];
|
||
const practicesStage = findStageBySlug("practices-discovery");
|
||
if (!practicesStage) {
|
||
fail("practices-discovery is absent from the compiled stage graph");
|
||
}
|
||
const draftDir = dirname(teamPracticesPath);
|
||
if (dirname(discoveredRulesPath) !== draftDir) {
|
||
fail("team-practices and discovered-rules drafts must share one stage directory");
|
||
}
|
||
const missingContributions: string[] = [];
|
||
for (const agent of practicesStage!.support_agents ?? []) {
|
||
const contribution = join(draftDir, "contributions", `${agent}.md`);
|
||
let firstLine = "";
|
||
try {
|
||
firstLine = readFileSync(contribution, "utf-8").split("\n", 1)[0].trim();
|
||
} catch {
|
||
missingContributions.push(`${agent} (no contribution file)`);
|
||
continue;
|
||
}
|
||
if (firstLine !== `**Collaborator:** ${agent}`) {
|
||
missingContributions.push(`${agent} (missing identity-marker first line)`);
|
||
}
|
||
}
|
||
if (missingContributions.length > 0) {
|
||
fail(
|
||
"ensemble evidence is incomplete: " + missingContributions.join("; "),
|
||
);
|
||
}
|
||
|
||
// Step 2: Read both drafts.
|
||
if (!existsSync(teamPracticesPath))
|
||
fail(`team-practices draft not found: ${teamPracticesPath}`);
|
||
if (!existsSync(discoveredRulesPath))
|
||
fail(`discovered-rules draft not found: ${discoveredRulesPath}`);
|
||
|
||
let teamPracticesDraft: string;
|
||
let discoveredRulesDraft: string;
|
||
try {
|
||
teamPracticesDraft = readFileSync(teamPracticesPath, "utf-8");
|
||
discoveredRulesDraft = readFileSync(discoveredRulesPath, "utf-8");
|
||
} catch (e) {
|
||
fail(`could not read drafts: ${errorMessage(e)}`);
|
||
return;
|
||
}
|
||
|
||
// Step 3: Read both target files. Fail closed if either is missing.
|
||
if (!existsSync(teamMdPath)) fail(`team.md not found at ${teamMdPath}`);
|
||
if (!existsSync(guardrailsPath))
|
||
fail(`project.md not found at ${guardrailsPath}`);
|
||
|
||
let teamMd: string;
|
||
let guardrailsMd: string;
|
||
try {
|
||
teamMd = readFileSync(teamMdPath, "utf-8");
|
||
guardrailsMd = readFileSync(guardrailsPath, "utf-8");
|
||
} catch (e) {
|
||
fail(`could not read targets: ${errorMessage(e)}`);
|
||
return;
|
||
}
|
||
|
||
// Step 4a: Build new team.md by section-replacing each of the five
|
||
// sections. team.md uses Title Case headings; the draft mirrors that
|
||
// shape.
|
||
const TEAM_SECTIONS = [
|
||
"## Way of Working",
|
||
"## Walking Skeleton",
|
||
"## Testing Posture",
|
||
"## Deployment",
|
||
"## Code Style",
|
||
];
|
||
let newTeamMd = teamMd;
|
||
for (const heading of TEAM_SECTIONS) {
|
||
const draftSection = extractMarkdownSection(teamPracticesDraft, heading);
|
||
if (draftSection === "") {
|
||
// Section absent from draft → leave the live file's section alone.
|
||
// Useful for partial re-runs that only change one practice area.
|
||
continue;
|
||
}
|
||
try {
|
||
newTeamMd = replaceSection(newTeamMd, heading, draftSection);
|
||
sectionsWritten.push(heading.slice(3));
|
||
} catch (e) {
|
||
fail(
|
||
`replaceSection failed on team.md for "${heading}": ${errorMessage(e)}`
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Step 4b: Build new project-guardrails.md by appending each rule under the
|
||
// matching heading with a date stamp. Rules are one-per-line in the draft;
|
||
// empty/blank lines and comment lines are skipped.
|
||
const parseRules = (sectionContent: string): string[] => {
|
||
return sectionContent
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0 && !l.startsWith("<!--") && !l.startsWith("#"));
|
||
};
|
||
const mandatedDraft = extractMarkdownSection(
|
||
discoveredRulesDraft,
|
||
"## Mandated"
|
||
);
|
||
const forbiddenDraft = extractMarkdownSection(
|
||
discoveredRulesDraft,
|
||
"## Forbidden"
|
||
);
|
||
const mandatedRules = parseRules(mandatedDraft);
|
||
const forbiddenRules = parseRules(forbiddenDraft);
|
||
|
||
let newGuardrailsMd = guardrailsMd;
|
||
const existingGuardrailLines = new Set(
|
||
newGuardrailsMd.split("\n").map((line) => line.trim()),
|
||
);
|
||
for (const rule of mandatedRules) {
|
||
const stampedLine = `${rule} (affirmed ${today})`;
|
||
if (existingGuardrailLines.has(stampedLine)) continue;
|
||
try {
|
||
newGuardrailsMd = appendUnderHeading(
|
||
newGuardrailsMd,
|
||
"## Mandated",
|
||
`${stampedLine}\n`
|
||
);
|
||
existingGuardrailLines.add(stampedLine);
|
||
rulesAppended.mandated++;
|
||
} catch (e) {
|
||
fail(`appendUnderHeading failed on Mandated: ${errorMessage(e)}`);
|
||
return;
|
||
}
|
||
}
|
||
for (const rule of forbiddenRules) {
|
||
const stampedLine = `${rule} (affirmed ${today})`;
|
||
if (existingGuardrailLines.has(stampedLine)) continue;
|
||
try {
|
||
newGuardrailsMd = appendUnderHeading(
|
||
newGuardrailsMd,
|
||
"## Forbidden",
|
||
`${stampedLine}\n`
|
||
);
|
||
existingGuardrailLines.add(stampedLine);
|
||
rulesAppended.forbidden++;
|
||
} catch (e) {
|
||
fail(`appendUnderHeading failed on Forbidden: ${errorMessage(e)}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Step 5 & 6: Write project.md first, then team.md.
|
||
// If the project write fails, team.md is untouched. If the team write
|
||
// fails after project succeeded, we surface that as PRACTICES_OVERRIDE and
|
||
// the user re-enters the gate. Exact dated project rules are deduplicated
|
||
// above, so a retry cannot accumulate copies after this partial-write path.
|
||
try {
|
||
writeFileSync(guardrailsPath, newGuardrailsMd, "utf-8");
|
||
} catch (e) {
|
||
fail(`writing project.md failed: ${errorMessage(e)}`);
|
||
return;
|
||
}
|
||
try {
|
||
writeFileSync(teamMdPath, newTeamMd, "utf-8");
|
||
} catch (e) {
|
||
fail(
|
||
`writing team.md failed AFTER project.md was written: ${errorMessage(e)}`
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Step 7: Emit PRACTICES_AFFIRMED and record the matching state timestamp in
|
||
// one audit-locked transaction. The promotion tool owns both facts; leaving a
|
||
// follow-up generic `set` to stage prose would let the timestamp be omitted or
|
||
// forged independently of a successful promotion.
|
||
let affirmedAt = "";
|
||
try {
|
||
withAuditLock(pd, () => {
|
||
let state = readStateFile(pd);
|
||
affirmedAt = emitAudit(pd, "PRACTICES_AFFIRMED", {
|
||
"Affirming User": flags["affirming-user"] ?? "unknown",
|
||
"Sections Written": sectionsWritten.join(", "),
|
||
"Mandated Rules Appended": String(rulesAppended.mandated),
|
||
"Forbidden Rules Appended": String(rulesAppended.forbidden),
|
||
});
|
||
// setOrInsertField, not setField: on a state file missing the row (a
|
||
// hand-edited or pre-field file) setField silently no-ops, and the
|
||
// approve gate that requires this timestamp would then refuse forever
|
||
// while its remediation ("run practices-promote") keeps no-opping.
|
||
state = setOrInsertField(
|
||
state,
|
||
"## Project Information",
|
||
"Practices Affirmed Timestamp",
|
||
affirmedAt,
|
||
);
|
||
state = setField(state, "Last Updated", affirmedAt);
|
||
writeStateFile(pd, state);
|
||
});
|
||
} catch (e) {
|
||
fail(
|
||
`audit/state commit failed AFTER both files were written: ${errorMessage(e)}`
|
||
);
|
||
return;
|
||
}
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
emitted: "PRACTICES_AFFIRMED",
|
||
sections_written: sectionsWritten,
|
||
mandated_appended: rulesAppended.mandated,
|
||
forbidden_appended: rulesAppended.forbidden,
|
||
affirmed_at: affirmedAt,
|
||
team_md: teamMdPath,
|
||
project_guardrails: guardrailsPath,
|
||
})
|
||
);
|
||
}
|
||
|
||
// reuse-artifact <slug> --decision <keep|modify|redo> --artifacts <csv>
|
||
// [--repo <repo>] [--single]
|
||
function handleReuseArtifact(args: string[]): void {
|
||
if (args.length < 1)
|
||
error("Usage: aidlc-state.ts reuse-artifact <slug> --decision <keep|modify|redo> --artifacts <csv> [--repo <repo>] [--single]");
|
||
const slug = args[0];
|
||
const rest = args.slice(1);
|
||
const decision = getFlagValue(rest, "--decision");
|
||
const artifacts = getFlagValue(rest, "--artifacts");
|
||
const repo = getFlagValue(rest, "--repo");
|
||
const singleRun = rest.includes("--single");
|
||
if (!decision) error("Missing --decision <keep|modify|redo>");
|
||
if (!artifacts) error("Missing --artifacts <csv>");
|
||
|
||
if (!["keep", "modify", "redo"].includes(decision)) {
|
||
error(`Invalid decision: ${decision}. Must be keep, modify, or redo.`);
|
||
}
|
||
|
||
// Validate stage exists in graph (adversarial finding C: reuse-artifact
|
||
// was accepting any slug). This prevents orphan ARTIFACT_REUSED emissions
|
||
// against non-existent stages.
|
||
const stage = findStageBySlug(slug);
|
||
if (!stage) error(`Unknown stage: ${slug}`);
|
||
|
||
const pd = resolveProjectDir(projectDir);
|
||
if (singleRun) {
|
||
if (!singleStageAttemptIsOpen(pd, slug)) {
|
||
error(
|
||
`Cannot record isolated reuse for "${slug}": run next --stage ${slug} --single first.`,
|
||
);
|
||
}
|
||
if (
|
||
slug === "reverse-engineering" &&
|
||
decision === "keep"
|
||
) {
|
||
const repos = intentRepos(pd);
|
||
if (repos.length > 0) {
|
||
if (!repo) {
|
||
error(
|
||
`Cannot record isolated reverse-engineering reuse: this intent records repository identity; pass --repo <repo>.`,
|
||
);
|
||
}
|
||
if (!repos.includes(repo)) {
|
||
error(
|
||
`Cannot record isolated reverse-engineering reuse: repo "${repo}" is not registered for this intent (${repos.join(", ")}).`,
|
||
);
|
||
}
|
||
} else if (repo) {
|
||
error(
|
||
"Cannot record isolated reverse-engineering reuse: this intent has no registered repo identity; omit --repo.",
|
||
);
|
||
}
|
||
const resolvedRepo = repo ?? codekbRepoName(pd);
|
||
const expected = `${relativeCodekbDir(pd, resolvedRepo)}/`;
|
||
if (artifacts.trim().replaceAll("\\", "/") !== expected) {
|
||
error(
|
||
`Cannot record isolated reverse-engineering reuse: --artifacts must be the exact current store ${expected}.`,
|
||
);
|
||
}
|
||
if (!codekbStoreIsCurrent(pd, resolvedRepo)) {
|
||
error(
|
||
`Cannot record isolated reverse-engineering reuse for "${resolvedRepo}": the CodeKB store is not CURRENT. Rescan it instead.`,
|
||
);
|
||
}
|
||
const artifactInspection = inspectRequiredArtifactInstances(
|
||
pd,
|
||
stage,
|
||
{ codekbRepos: [resolvedRepo] },
|
||
);
|
||
if (!artifactInspection.ok) {
|
||
error(
|
||
`Cannot record isolated reverse-engineering reuse for "${resolvedRepo}": ` +
|
||
`the required CodeKB artifact set is incomplete or invalid (${artifactInspection.failures.map((failure) => failure.path).join(", ")}). Rescan it instead.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
const fields: Record<string, string> = {
|
||
Stage: slug,
|
||
Decision: decision,
|
||
Artifacts: artifacts,
|
||
};
|
||
if (repo) fields.Repo = repo;
|
||
if (singleRun) fields.Workflow = `single-stage:${slug}`;
|
||
emitAudit(pd, "ARTIFACT_REUSED", fields);
|
||
} catch (e) {
|
||
error(`Audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
console.log(JSON.stringify({
|
||
slug,
|
||
decision,
|
||
artifacts,
|
||
...(repo ? { repo } : {}),
|
||
...(singleRun ? { single: true } : {}),
|
||
emitted: "ARTIFACT_REUSED",
|
||
}));
|
||
}
|
||
|
||
function handleLookup(args: string[]): void {
|
||
if (args.length < 1) error("Usage: aidlc-state.ts lookup <subcommand> [args...]");
|
||
const sub = args[0];
|
||
const subArgs = args.slice(1);
|
||
|
||
switch (sub) {
|
||
case "phase-of": {
|
||
if (subArgs.length < 1) error("Usage: lookup phase-of <slug>");
|
||
const stage = resolveStage(subArgs[0]);
|
||
if (!stage) error(`Unknown stage: ${subArgs[0]}`);
|
||
console.log(stage.phase);
|
||
break;
|
||
}
|
||
case "next-stage": {
|
||
if (subArgs.length < 2) error("Usage: lookup next-stage <slug> <scope>");
|
||
// Thread the live state file (when one exists) so the projection honours
|
||
// per-stage suffix overrides (a recomposed plan) and [x]/[S] checkboxes,
|
||
// matching the advance/finalize walks. A stateless workspace still
|
||
// answers from the static grid (read-only either way).
|
||
let stateForWalk: string | undefined;
|
||
try {
|
||
const pd = resolveProjectDir(projectDir);
|
||
stateForWalk = readStateFile(pd);
|
||
} catch {
|
||
stateForWalk = undefined;
|
||
}
|
||
const next = nextInScopeStage(subArgs[0], subArgs[1], stateForWalk);
|
||
console.log(next ? next.slug : "none");
|
||
break;
|
||
}
|
||
case "agent-for": {
|
||
if (subArgs.length < 1) error("Usage: lookup agent-for <slug>");
|
||
const stage = resolveStage(subArgs[0]);
|
||
if (!stage) error(`Unknown stage: ${subArgs[0]}`);
|
||
console.log(stage.lead_agent);
|
||
break;
|
||
}
|
||
case "number-of": {
|
||
if (subArgs.length < 1) error("Usage: lookup number-of <slug>");
|
||
const stage = resolveStage(subArgs[0]);
|
||
if (!stage) error(`Unknown stage: ${subArgs[0]}`);
|
||
console.log(stage.number);
|
||
break;
|
||
}
|
||
case "stages-in-scope": {
|
||
if (subArgs.length < 1) error("Usage: lookup stages-in-scope <scope>");
|
||
const stages = stagesInScope(subArgs[0]);
|
||
if (stages.length === 0) error(`Unknown scope: ${subArgs[0]}`);
|
||
console.log(JSON.stringify(stages));
|
||
break;
|
||
}
|
||
case "first-in-phase": {
|
||
if (subArgs.length < 2) error("Usage: lookup first-in-phase <phase> <scope>");
|
||
const stage = firstInScopeStageOfPhase(subArgs[0], subArgs[1]);
|
||
console.log(stage ? stage.slug : "none");
|
||
break;
|
||
}
|
||
case "validate-stage": {
|
||
if (subArgs.length < 1) error("Usage: lookup validate-stage <slug-or-number>");
|
||
const stage = resolveStage(subArgs[0]);
|
||
if (!stage) {
|
||
console.log(JSON.stringify({ valid: false, input: subArgs[0] }));
|
||
} else {
|
||
console.log(
|
||
JSON.stringify({
|
||
valid: true,
|
||
slug: stage.slug,
|
||
number: stage.number,
|
||
name: stage.name,
|
||
phase: stage.phase,
|
||
lead_agent: stage.lead_agent,
|
||
})
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
case "validate-phase": {
|
||
if (subArgs.length < 1) error("Usage: lookup validate-phase <phase-or-number>");
|
||
const input = subArgs[0].toLowerCase();
|
||
const phase =
|
||
PHASE_NUMBERS[input] ||
|
||
((PHASES as readonly string[]).includes(input) ? input : null);
|
||
if (!phase) {
|
||
console.log(JSON.stringify({ valid: false, input: subArgs[0] }));
|
||
} else {
|
||
const phaseNumber = Object.entries(PHASE_NUMBERS).find(([_, v]) => v === phase)?.[0];
|
||
console.log(
|
||
JSON.stringify({
|
||
valid: true,
|
||
canonical: phase,
|
||
number: phaseNumber,
|
||
display: phase.toUpperCase(),
|
||
})
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
error(
|
||
`Unknown lookup subcommand: ${sub}. Valid: phase-of, next-stage, agent-for, number-of, stages-in-scope, first-in-phase, validate-stage, validate-phase`
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- State fork/merge ---
|
||
//
|
||
// Per-Bolt state isolation for Construction worktrees. fork copies main state
|
||
// to <worktreePath>/aidlc-docs/aidlc-state.md on Bolt start; merge copies it
|
||
// back on gate approval. Strict audit-first per docs/reference/12-state-machine.md
|
||
// — the audit-of-intent exception at line 322 is bounded to the three
|
||
// WORKTREE_* events because git worktree add has no idempotent re-run path
|
||
// under kill-9; state fork/merge are idempotent (re-reading and re-writing a
|
||
// file is repeatable), so strict audit-first applies.
|
||
//
|
||
// Conflict resolution by alphabetical-slug is defence-in-depth, not load-bearing:
|
||
// the v7 schema has workflow-level singletons, not per-(Bolt, stage) cells.
|
||
// Realistic per-Bolt contention is rare; main wins on workflow-level fields,
|
||
// alphabetical-slug only fires as a tiebreak on the artificial case of two
|
||
// worktrees flipping the same Construction Stage Progress cell to different
|
||
// values.
|
||
//
|
||
// (SLUG_RE, validateSlug, errorWithSlug, sha256, parseFlags are declared
|
||
// near the top of the file so main() can reach them — handlers live below.)
|
||
|
||
// fork --slug <slug> [--target-dir <path>]
|
||
//
|
||
// Forks main's aidlc-state.md to <worktreePath>/aidlc-docs/aidlc-state.md.
|
||
// Adds slug to main's Bolt Refs list. Decorative Worktree Path on the
|
||
// worktree-side state file (recoverable from cwd; debugging breadcrumb only).
|
||
function handleFork(args: string[]): void {
|
||
const flags = parseFlags(args);
|
||
const slug = validateSlug(flags.slug);
|
||
const pd = resolveProjectDir(projectDir);
|
||
|
||
// The space+intent selector pins this fork to ONE intent end-to-end (vision
|
||
// §5): --intent <record> / --space <name> override the active cursor;
|
||
// omitted -> default-resolution (the active cursor / lone intent). The SAME
|
||
// selector threads main-side state/audit/lock AND the worktree mirror, and
|
||
// MUST match what merge resolves so they touch one record.
|
||
const selection = resolveWorkflowSelection(pd, {
|
||
intent: flags.intent,
|
||
space: flags.space,
|
||
});
|
||
const intent = selection.intent ?? undefined;
|
||
const space = selection.space;
|
||
requireLiveClaimForTeamUnit(pd, slug, {
|
||
intent,
|
||
space,
|
||
walkingSkeletonMain: args.includes("--walking-skeleton-main"),
|
||
});
|
||
// recordPrefix is the worktree mirror's relative record dir (null -> the flat
|
||
// legacy mirror, today's behaviour); wtRecord is the resolved record-dir NAME
|
||
// the worktree state file lives under (null -> flat). Resolved on the MAIN
|
||
// side so fork and merge pin to the same intent regardless of the worktree's
|
||
// own cursor.
|
||
const recordPrefix = relativeRecordDir(pd, intent, space);
|
||
// Resolve the intent ONCE, here, BEFORE acquiring the lock. activeIntent maps
|
||
// an omitted (--intent unset) selector to the active cursor / lone record, so
|
||
// `resolvedIntent` is the SAME value the per-intent path helpers (readStateFile
|
||
// / writeStateFile / auditFilePath) resolve internally. Threading the RAW
|
||
// flags.intent to the lock instead would key the __workspace__ sentinel on the
|
||
// omitted path while the writes target the resolved per-intent shard — LOCK !=
|
||
// WRITE, the exact lost-update race the lock exists to prevent (a concurrent
|
||
// explicit-intent op on the same shard would hold a DIFFERENT lock). So we use
|
||
// `resolvedIntent` for the wrapping lock AND every main-side read/write/audit
|
||
// below. `wtRecord` is the same value (kept as a distinct name for the
|
||
// worktree-mirror write, whose null->flat semantics read clearer there).
|
||
const resolvedIntent = selection.intent ?? undefined;
|
||
const wtRecord = resolvedIntent;
|
||
// Publish the resolved lock context so any errorWithSlug fired inside the
|
||
// per-intent withAuditLock below routes ERROR_LOGGED to the bucket we hold
|
||
// (see error()/emitError). Cleared after the transaction.
|
||
lockIntent = resolvedIntent;
|
||
lockSpace = space;
|
||
|
||
// target-dir lets tests point fork at a fixture worktree-parent. Defaults
|
||
// to the project's .aidlc/worktrees/bolt-<slug>/ via worktreePath().
|
||
const wtPath = flags["target-dir"] ?? worktreePath(pd, slug);
|
||
|
||
if (!existsSync(wtPath)) {
|
||
errorWithSlug(slug, `worktree directory does not exist: ${wtPath}. Run aidlc-worktree create first.`);
|
||
}
|
||
|
||
// mkdir BEFORE acquiring the lock. A read-only-fs mkdir failure must not
|
||
// leave a phantom STATE_FORKED row, and acquiring the lock for a doomed
|
||
// operation just delays the failure.
|
||
const wtDocsDir = worktreeDocsDir(wtPath, recordPrefix);
|
||
try {
|
||
mkdirSync(wtDocsDir, { recursive: true });
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to create ${wtDocsDir}: ${errorMessage(e)}`);
|
||
}
|
||
|
||
// Hold the audit lock across the whole transaction so:
|
||
// - the dedup-check / emit / write are atomic against concurrent forks
|
||
// (no two forks for the same slug can both pass the dedup check);
|
||
// - the audit row only emits when we know the write will land cleanly
|
||
// (no phantom STATE_FORKED on duplicate-slug or stale-state failures);
|
||
// - process.exit() inside the body still releases the lock dir via
|
||
// withAuditLock's exit-handler safety net (Bun's process.exit skips
|
||
// `finally`, which would otherwise poison the project for ~5s).
|
||
let srcSha: string;
|
||
try {
|
||
// Lock the SAME per-intent bucket the inner state/audit writes target
|
||
// (resolvedIntent+space threaded), NOT the __workspace__ sentinel — without
|
||
// this the transaction serializes every intent's fork on one workspace lock
|
||
// (the P3 shared-lock cliff) and intent-create/migration would block unrelated
|
||
// forks. resolvedIntent (not raw flags.intent) makes LOCK == WRITE even when
|
||
// --intent is omitted (both resolve to the active record).
|
||
srcSha = withAuditLock(pd, () => {
|
||
let mainContent: string;
|
||
try {
|
||
mainContent = readStateFile(pd, resolvedIntent, space);
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to read main state: ${errorMessage(e)}`);
|
||
return ""; // unreachable
|
||
}
|
||
const sha = sha256(mainContent);
|
||
|
||
// Dedup BEFORE emit: if the slug is already in Bolt Refs, fail without
|
||
// emitting a phantom audit row. Recovery from a stale ref entry is the
|
||
// caller's responsibility (see SKILL.md Step 0.6 recovery seam — discard
|
||
// + re-fork is supported because the next fork sees the slug already
|
||
// present and exits without poisoning audit).
|
||
const currentRefs = getField(mainContent, "Bolt Refs") ?? "";
|
||
if (parseRefsList(currentRefs).includes(slug)) {
|
||
errorWithSlug(slug, `slug already in Bolt Refs (current: ${currentRefs.trim()}). If a prior fork failed mid-operation, run 'aidlc-worktree discard --slug ${slug}' and 'aidlc-state.ts merge --slug ${slug}' (which will exit "already merged" cleanly) or remove the stale entry from main state, then retry.`);
|
||
}
|
||
|
||
// Append slug to main's Bolt Refs first (the side effect that "registers"
|
||
// the fork). If this fails, no audit, no worktree state — clean recovery.
|
||
let mainNow = mainContent;
|
||
try {
|
||
mainNow = setFieldStrict(mainNow, "Bolt Refs", appendSlug(currentRefs, slug));
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to compute updated Bolt Refs: ${errorMessage(e)}`);
|
||
}
|
||
|
||
// Audit-first within the locked critical section. Use the unlocked
|
||
// variant since we already hold the lock.
|
||
try {
|
||
appendAuditEntryUnlocked("STATE_FORKED", {
|
||
"Bolt slug": slug,
|
||
"Worktree path": relative(pd, wtPath).replaceAll("\\", "/"),
|
||
"Source state hash": sha,
|
||
"Target state hash": sha, // fork = byte-identical copy
|
||
...claimAttemptFields(pd, slug),
|
||
}, pd, resolvedIntent, space);
|
||
} catch (e) {
|
||
errorWithSlug(slug, `audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
// Write main state with updated Bolt Refs.
|
||
try {
|
||
writeStateFile(pd, mainNow, resolvedIntent, space);
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to write main state with updated Bolt Refs: ${errorMessage(e)}`);
|
||
}
|
||
|
||
// Write worktree state with the decorative Worktree Path breadcrumb.
|
||
// Done last so a write failure here leaves a recoverable surface: main's
|
||
// Bolt Refs has the slug, audit has the row, but the worktree's state
|
||
// file is missing — doctor reconciles by checking
|
||
// `<worktreePath>/aidlc-docs/aidlc-state.md` existence against Bolt Refs.
|
||
let wtContent = mainContent;
|
||
try {
|
||
wtContent = setFieldStrict(wtContent, "Worktree Path", relative(pd, wtPath).replaceAll("\\", "/"));
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to set Worktree Path on worktree state: ${errorMessage(e)}`);
|
||
}
|
||
try {
|
||
// The worktree mirror lives under the SAME record (wtRecord/space) the
|
||
// main side resolved — NOT the worktree's own cursor — so fork and merge
|
||
// read/write one file. wtRecord===undefined -> the flat legacy mirror.
|
||
writeStateFile(wtPath, wtContent, wtRecord, space);
|
||
const scopeStamp = readUnitScopeStamp(pd);
|
||
if (scopeStamp) writeUnitScopeStamp(wtPath, scopeStamp);
|
||
} catch (e) {
|
||
errorWithSlug(slug, `failed to write worktree state at ${wtPath}: ${errorMessage(e)}`);
|
||
}
|
||
|
||
return sha;
|
||
}, resolvedIntent, space);
|
||
} catch (e) {
|
||
// Slug-tag any error from the locked block (most commonly: lock-acquire
|
||
// timeout when a peer tool holds the lock across the retry budget).
|
||
errorWithSlug(slug, errorMessage(e));
|
||
return; // unreachable
|
||
}
|
||
// Transaction done — clear the lock context so any subsequent sentinel-locked
|
||
// emit in this process keys the sentinel, not a stale per-intent bucket.
|
||
lockIntent = undefined;
|
||
lockSpace = undefined;
|
||
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
status: "forked",
|
||
slug,
|
||
worktree_path: wtPath,
|
||
source_state_hash: srcSha,
|
||
})}\n`
|
||
);
|
||
}
|
||
|
||
// merge --slug <slug> [--target-dir <path>]
|
||
//
|
||
// Merges <worktreePath>/aidlc-docs/aidlc-state.md back to main. Workflow-level
|
||
// singletons are kept from main (untouched); Construction Stage Progress cells
|
||
// merge from the worktree; alphabetical-slug tiebreak as defence-in-depth.
|
||
// Idempotent: re-running for an already-merged slug exits non-zero with a
|
||
// clear "already merged" error and emits no second STATE_MERGED row.
|
||
function handleMerge(args: string[]): void {
|
||
const flags = parseFlags(args);
|
||
const slug = validateSlug(flags.slug);
|
||
const pd = resolveProjectDir(projectDir);
|
||
|
||
// Same selector the fork used -> the SAME intent record on both ends (vision
|
||
// §5). recordPrefix pins the worktree mirror; wtRecord is its record-dir NAME.
|
||
const selection = resolveWorkflowSelection(pd, {
|
||
intent: flags.intent,
|
||
space: flags.space,
|
||
});
|
||
const intent = selection.intent ?? undefined;
|
||
const space = selection.space;
|
||
requireLiveClaimForTeamUnit(pd, slug, {
|
||
intent,
|
||
space,
|
||
walkingSkeletonMain: args.includes("--walking-skeleton-main"),
|
||
});
|
||
const recordPrefix = relativeRecordDir(pd, intent, space);
|
||
// Resolve the intent ONCE before locking (same rationale as handleFork):
|
||
// activeIntent maps an omitted selector to the active record, so resolvedIntent
|
||
// == the value the per-intent path helpers resolve internally. Threading it to
|
||
// the wrapping lock AND every main-side read/write/audit makes LOCK == WRITE
|
||
// even when --intent is omitted; raw flags.intent would key the sentinel while
|
||
// the writes hit the per-intent shard (lost-update race). wtRecord is the same
|
||
// value, named for the worktree-mirror read where null->flat reads clearer.
|
||
const resolvedIntent = selection.intent ?? undefined;
|
||
const wtRecord = resolvedIntent;
|
||
// Publish the lock context for the in-transaction error path (see error()).
|
||
lockIntent = resolvedIntent;
|
||
lockSpace = space;
|
||
|
||
const wtPath = flags["target-dir"] ?? worktreePath(pd, slug);
|
||
if (!existsSync(wtPath)) {
|
||
errorWithSlug(slug, `worktree directory does not exist: ${wtPath}.`);
|
||
}
|
||
const wtStatePath = worktreeStateFilePath(wtPath, recordPrefix);
|
||
if (!existsSync(wtStatePath)) {
|
||
errorWithSlug(slug, `worktree state file does not exist: ${wtStatePath}. Was fork run?`);
|
||
}
|
||
|
||
// Read worktree state outside the lock — its file isn't shared with peers
|
||
// (each Bolt owns its own worktree state file), so it doesn't need the
|
||
// audit lock for consistency. Read the SAME record the fork wrote.
|
||
const wtContent = readStateFile(wtPath, wtRecord, space);
|
||
const wtSha = sha256(wtContent);
|
||
const wtCheckboxes = parseCheckboxes(wtContent);
|
||
|
||
// Hold the audit lock across the entire decide-emit-write transaction so
|
||
// conflict-resolution decisions, the audit Target state hash, and the
|
||
// actual main state write are all consistent with the SAME view of main.
|
||
// Without this, a third concurrent merge landing between our snapshot and
|
||
// our write would cause: (a) the audit Target hash to disagree with the
|
||
// actual post-write SHA, (b) stale Bolt Refs being used to compute the
|
||
// alphabetical tiebreak, and (c) one merge clobbering another's writes.
|
||
let result: { postMergeSha: string; conflictResolutionField: string };
|
||
try {
|
||
// Lock the per-intent bucket (resolvedIntent+space threaded) the inner
|
||
// writes target — same fix as handleFork: the __workspace__ sentinel would
|
||
// serialize all intents' merges and let intent-create block an unrelated
|
||
// merge (P3 shared-lock cliff). resolvedIntent (not raw flags.intent) makes
|
||
// LOCK == WRITE on the omitted-intent path.
|
||
result = withAuditLock(pd, () => {
|
||
const mainContent = readStateFile(pd, resolvedIntent, space);
|
||
|
||
// Idempotency: if slug is not in main's Bolt Refs, this is a re-run after
|
||
// a prior successful merge (or a never-forked slug). Either way, no work
|
||
// to do; emit no second audit row.
|
||
const currentRefs = getField(mainContent, "Bolt Refs") ?? "";
|
||
const refsList = parseRefsList(currentRefs);
|
||
if (!refsList.includes(slug)) {
|
||
errorWithSlug(slug, `already merged: not in Bolt Refs (current: ${currentRefs.trim()})`);
|
||
}
|
||
|
||
// Per-field merge rule, computed against the LOCKED snapshot:
|
||
// - Workflow-level singletons (Project, Project Type, Scope, Start Date,
|
||
// State Version, Active Agent, Practices Affirmed Timestamp): main
|
||
// wins. These come straight from `mainContent` untouched.
|
||
// - Construction Stage Progress checkboxes: take the worktree's value
|
||
// when the worktree advanced past main's, IF this slug is the
|
||
// alphabetically-lowest active ref. Workflow-level fields stay from
|
||
// main automatically because we start from mainContent and only
|
||
// overwrite the per-stage cells.
|
||
// - Tiebreak (alphabetical-slug, defence-in-depth): if multiple slugs
|
||
// in Bolt Refs would compete for the same cell, the lower
|
||
// alphabetical slug wins.
|
||
let merged = mainContent;
|
||
const conflictResolution: string[] = [];
|
||
const mainCheckboxes = parseCheckboxes(mainContent);
|
||
const mainStateMap = new Map(mainCheckboxes.map((c) => [c.slug, c.state]));
|
||
const candidateSlugs = [...refsList].sort();
|
||
const winningSlug = candidateSlugs[0];
|
||
|
||
for (const wtCb of wtCheckboxes) {
|
||
const mainCbState = mainStateMap.get(wtCb.slug);
|
||
if (!mainCbState) continue;
|
||
if (mainCbState === wtCb.state) continue;
|
||
|
||
if (winningSlug === slug) {
|
||
merged = setCheckbox(merged, wtCb.slug, wtCb.state);
|
||
if (refsList.length > 1) {
|
||
conflictResolution.push(`${wtCb.slug}:slug-precedence:${slug}`);
|
||
}
|
||
} else {
|
||
conflictResolution.push(`${wtCb.slug}:deferred-to:${winningSlug}`);
|
||
}
|
||
}
|
||
|
||
// Remove slug from Bolt Refs.
|
||
merged = setFieldStrict(merged, "Bolt Refs", removeSlug(currentRefs, slug));
|
||
|
||
const conflictResolutionField =
|
||
conflictResolution.length === 0 ? "clean" : conflictResolution.join("; ");
|
||
// Target hash matches the actual post-write content — computed inside the
|
||
// lock against the final `merged` value so doctor can verify by
|
||
// re-hashing the file at observation time.
|
||
const postMergeSha = sha256(merged);
|
||
|
||
// Strict audit-first within the locked critical section.
|
||
try {
|
||
appendAuditEntryUnlocked("STATE_MERGED", {
|
||
"Bolt slug": slug,
|
||
"Worktree path": relative(pd, wtPath).replaceAll("\\", "/"),
|
||
"Source state hash": wtSha,
|
||
"Target state hash": postMergeSha,
|
||
"Conflict resolution": conflictResolutionField,
|
||
}, pd, resolvedIntent, space);
|
||
} catch (e) {
|
||
errorWithSlug(slug, `audit emission failed: ${errorMessage(e)}`);
|
||
}
|
||
|
||
writeStateFile(pd, merged, resolvedIntent, space);
|
||
|
||
return { postMergeSha, conflictResolutionField };
|
||
}, resolvedIntent, space);
|
||
} catch (e) {
|
||
// An already slug-tagged refusal from inside the locked block passes through;
|
||
// anything else (most commonly a lock-acquire timeout when a peer tool holds
|
||
// the lock across the retry budget) is slug-tagged here.
|
||
if (e instanceof StateCommandError) throw e;
|
||
errorWithSlug(slug, errorMessage(e));
|
||
return; // unreachable
|
||
}
|
||
// Transaction done — clear the lock context (see handleFork).
|
||
lockIntent = undefined;
|
||
lockSpace = undefined;
|
||
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
status: "merged",
|
||
slug,
|
||
worktree_path: wtPath,
|
||
source_state_hash: wtSha,
|
||
target_state_hash: result.postMergeSha,
|
||
conflict_resolution: result.conflictResolutionField,
|
||
})}\n`
|
||
);
|
||
}
|
||
|
||
// --- Utility ---
|
||
|
||
// Refuse the current command. Throws rather than exits so the guard admission
|
||
// functions below stay callable in-process by the router; main() catches the
|
||
// throw and exits through exitWithError, which is the one place that writes the
|
||
// ERROR_LOGGED row and ends the process.
|
||
function error(msg: string): never {
|
||
throw new StateCommandError(msg);
|
||
}
|
||
|
||
function exitWithError(msg: string): never {
|
||
// Honor module-level projectDir (set from --project-dir in main) so test
|
||
// fixtures and explicit overrides propagate to ERROR_LOGGED.
|
||
const pd = resolveProjectDir(projectDir);
|
||
const command = `aidlc-state ${process.argv.slice(2).join(" ")}`.trim();
|
||
// Thread the per-intent lock context fork/merge set before their per-intent
|
||
// withAuditLock. The throw released the lock on its way here, so emitError
|
||
// keys and briefly re-acquires the SAME bucket the transaction used; every
|
||
// sentinel-locked handler leaves these unset and keys the sentinel.
|
||
emitError(pd, "aidlc-state", command, msg, lockIntent, lockSpace);
|
||
}
|