2726 lines
99 KiB
TypeScript
2726 lines
99 KiB
TypeScript
// Deterministic Testing Posture contract for Code Generation.
|
|
//
|
|
// Practices remain human-authored prose, but code generation needs one stable
|
|
// execution contract. This module resolves methodology independently from
|
|
// coverage/tooling notes, builds a methodology-specific plan profile, binds the
|
|
// result to the active scope/test strategy/project type, and fingerprints the
|
|
// approved plan + unit test instructions. Both the dispatch guard and autonomous
|
|
// swarm referee consume the same contract.
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
import {
|
|
type AcceptedChange,
|
|
activeIntentUuid,
|
|
auditBlockField,
|
|
collectStalePlanApprovalReceipts,
|
|
contentBeforeTerminalReviewAppendix,
|
|
docsRoot,
|
|
getField,
|
|
isoTimestamp,
|
|
latestMainWorkflowStageRunFloorForProject,
|
|
LEGACY_PLAN_APPROVAL_RECOVERY_CHOICE,
|
|
clearPlanApprovalChallenge,
|
|
clearPlanApprovalLegacyOffer,
|
|
clearPlanApprovalOverrideRequest,
|
|
clearPlanApprovalReceipt,
|
|
readActiveDirectiveMarker,
|
|
readAuditShardEvents,
|
|
readPlanApprovalChallenge,
|
|
readPlanApprovalLegacyOffer,
|
|
readPlanApprovalLegacyRecoveryChallenge,
|
|
readPlanApprovalOverrideRequest,
|
|
readPlanApprovalReceipt,
|
|
readPlanApprovalResponse,
|
|
readPlanApprovalViolation,
|
|
recordAcceptedChanges,
|
|
renderChangedPaths,
|
|
governedChangeControl,
|
|
resolveBoltDag,
|
|
resolveChangeControl,
|
|
resolveProjectDir,
|
|
stalePlanApprovalReceiptsForTarget,
|
|
resolveWorkflowSelection,
|
|
stateFilePath,
|
|
structuredField,
|
|
toPosix,
|
|
stripRecommendedDecorator,
|
|
UNBINDABLE_FINGERPRINT,
|
|
validateUnitName,
|
|
visibleMarkdownLines,
|
|
withActiveDirectiveLock,
|
|
withAuditLock,
|
|
workspaceSourceChangedPaths,
|
|
workspaceSourceFailureSuffix,
|
|
workspaceSourceFingerprint,
|
|
workspaceSourceState,
|
|
writePlanApprovalChallenge,
|
|
writePlanApprovalLegacyRecoveryResponse,
|
|
writePlanApprovalOverrideRequest,
|
|
writePlanApprovalReceipt,
|
|
writePlanApprovalResponse,
|
|
writeWorkspaceSourceSnapshot,
|
|
type GuardRemedyOp,
|
|
type PlanApprovalOverrideRequest,
|
|
type PlanApprovalReceiptKey,
|
|
type PlanApprovalRuntimeChallenge,
|
|
type PlanApprovalRuntimeIdentity,
|
|
type PlanApprovalRuntimeProvenance,
|
|
type PlanApprovalRuntimeReceipt,
|
|
type WorkspaceSourceState,
|
|
} from "./aidlc-lib.ts";
|
|
|
|
export type TestingMethodology = "tdd" | "bdd" | "atdd" | "test-after" | "custom";
|
|
export type TestStrategy = "minimal" | "standard" | "comprehensive";
|
|
export type ProjectType = "greenfield" | "brownfield";
|
|
export type MemoryLayer = "org" | "team" | "project";
|
|
|
|
export interface TestingPostureSections {
|
|
org?: string;
|
|
team?: string;
|
|
project?: string;
|
|
}
|
|
|
|
export interface PlanProfile {
|
|
methodology: TestingMethodology;
|
|
runner_step: string;
|
|
runner_ready_before_first_test: true;
|
|
testable_layers: string[];
|
|
steps: string[];
|
|
}
|
|
|
|
export interface TestObligations {
|
|
strategy: TestStrategy;
|
|
strategy_volume: string[];
|
|
scope_floor: string[];
|
|
combination_rule: string;
|
|
}
|
|
|
|
export interface TestingPostureContractBody {
|
|
version: 1;
|
|
methodology: TestingMethodology;
|
|
source: MemoryLayer | "fallback";
|
|
ordering: string;
|
|
scope: string;
|
|
test_strategy: TestStrategy;
|
|
project_type: ProjectType;
|
|
applicable_notes: Array<{ layer: MemoryLayer; text: string }>;
|
|
obligations: TestObligations;
|
|
plan_profile: PlanProfile;
|
|
input_sha256: string;
|
|
}
|
|
|
|
export interface TestingPostureContract extends TestingPostureContractBody {
|
|
contract_sha256: string;
|
|
}
|
|
|
|
export interface CodeGenerationApproval {
|
|
ok: boolean;
|
|
unit: string | null;
|
|
reason: string;
|
|
planExists: boolean;
|
|
instructionsExist: boolean;
|
|
approved: boolean;
|
|
contractValid: boolean;
|
|
fingerprintValid: boolean;
|
|
receiptValid: boolean;
|
|
contractHash: string | null;
|
|
approvalFingerprint: string | null;
|
|
directiveEpoch: string | null;
|
|
/** The reason is the strict source-drift refusal; its remedy is PLAN_SOURCE_DRIFT_REMEDY. */
|
|
sourceDrift?: true;
|
|
/** The current receipt is a human break-glass override (content and attempt only). */
|
|
override?: true;
|
|
}
|
|
|
|
export interface CodeGenerationTarget {
|
|
unit: string | null;
|
|
}
|
|
|
|
export interface CodeGenerationAuthority extends CodeGenerationTarget {
|
|
targetId: string;
|
|
intentId: string;
|
|
directiveEpoch: string;
|
|
runFloor: string;
|
|
stageDir: string;
|
|
sourceFloor: string;
|
|
markerRevision: number;
|
|
}
|
|
|
|
export interface PlanApprovalQuestionEvidence {
|
|
authority: CodeGenerationAuthority;
|
|
fingerprint: string;
|
|
questionsPath: string;
|
|
questionsRelativePath: string;
|
|
questionsSha256: string;
|
|
promptSha256: string;
|
|
plannedSourceSha256: string;
|
|
/**
|
|
* The human lines for source drift this evidence accepted and recorded under
|
|
* Change Control `relaxed` (the CHANGE_ACCEPTED row is written before any
|
|
* re-baseline). Empty under `strict` (drift throws) and when nothing moved.
|
|
*/
|
|
changeNotices: string[];
|
|
}
|
|
|
|
// --- Source drift at the Plan Approval checkpoint --------------------------
|
|
//
|
|
// The plan binds to a workspace source fingerprint. When live source no longer
|
|
// matches it, Change Control decides the consequence: `strict` refuses with the
|
|
// human sentence below (the conductor's remedy travels separately), `relaxed`
|
|
// accepts, records the change once, tells the human once, and re-baselines the
|
|
// recorded source so the same change is not reported at every later check.
|
|
|
|
const CODE_GENERATION_STAGE = "code-generation";
|
|
|
|
/** Conductor-only: the command path that reopens approval. Never the human sentence. */
|
|
export const PLAN_SOURCE_DRIFT_REMEDY =
|
|
"Re-run the fingerprint command and re-present the plan.";
|
|
|
|
// The break-glass exit. It is always the LAST remedy listed, it is never
|
|
// proposed or initiated by the conductor, and it is opened only by the human
|
|
// typing the phrase below as a prompt (the human-turn hook records that typed
|
|
// prompt; a picked option never counts).
|
|
export const PLAN_APPROVAL_OVERRIDE_PHRASE = "Override Plan Approval: <reason>";
|
|
export const PLAN_APPROVAL_OVERRIDE_PHRASE_RE = /^override plan approval:\s*(\S.*)$/i;
|
|
export const PLAN_APPROVAL_BREAK_GLASS_REMEDY =
|
|
"Break glass (human only): type exactly `Override Plan Approval: <reason>` in chat; " +
|
|
"the conductor then runs answer --override with that reason.";
|
|
export const PLAN_APPROVAL_OVERRIDE_HUMAN_ONLY =
|
|
"Plan Approval override is human-only: the human must type exactly " +
|
|
"`Override Plan Approval: <reason>` in chat; then re-run this command with that reason.";
|
|
|
|
export class PlanApprovalSourceDriftError extends Error {
|
|
readonly remedy = PLAN_SOURCE_DRIFT_REMEDY;
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "PlanApprovalSourceDriftError";
|
|
}
|
|
}
|
|
|
|
// A source boundary that cannot be bound is not drift: nothing moved, the walk
|
|
// failed. The refusal names that, and its remedies are ordered: repair the
|
|
// boundary first (an ordinary re-fingerprint then works), break glass last.
|
|
export const PLAN_APPROVAL_SOURCE_UNBINDABLE_CODE = "PLAN_APPROVAL_SOURCE_UNBINDABLE";
|
|
export const BREAK_GLASS_REMEDY_OP = "break-glass-override";
|
|
|
|
export interface PlanApprovalRemedy {
|
|
op: GuardRemedyOp | typeof BREAK_GLASS_REMEDY_OP;
|
|
action: string;
|
|
requiresHuman: boolean;
|
|
executableNow: boolean;
|
|
}
|
|
|
|
export const PLAN_APPROVAL_REPAIR_SOURCE_BOUNDARY_REMEDY =
|
|
"Repair the source boundary: shrink or exclude the offending path, declare real " +
|
|
"source under an excluded directory in .aidlc-source-paths.json, or remove the " +
|
|
"broken symlink; then re-run the fingerprint command and re-present the plan.";
|
|
|
|
export function planApprovalUnbindableRemedies(): PlanApprovalRemedy[] {
|
|
return [
|
|
{
|
|
op: "repair-source-boundary",
|
|
action: PLAN_APPROVAL_REPAIR_SOURCE_BOUNDARY_REMEDY,
|
|
requiresHuman: false,
|
|
executableNow: true,
|
|
},
|
|
{
|
|
op: BREAK_GLASS_REMEDY_OP,
|
|
action: PLAN_APPROVAL_BREAK_GLASS_REMEDY,
|
|
requiresHuman: true,
|
|
executableNow: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
export class PlanApprovalUnbindableError extends Error {
|
|
readonly code = PLAN_APPROVAL_SOURCE_UNBINDABLE_CODE;
|
|
readonly remedies: PlanApprovalRemedy[];
|
|
constructor(blocked: "presented" | "recorded") {
|
|
const remedies = planApprovalUnbindableRemedies();
|
|
super(
|
|
`Plan Approval cannot be ${blocked}: the workspace source cannot be bound${workspaceSourceFailureSuffix()}, so ` +
|
|
`${blocked === "presented" ? "no challenge was minted" : "no receipt was written"}. ` +
|
|
"Remedies, in order: " +
|
|
remedies.map((remedy, index) => `(${index + 1}) ${remedy.action}`).join(" "),
|
|
);
|
|
this.name = "PlanApprovalUnbindableError";
|
|
this.remedies = remedies;
|
|
}
|
|
}
|
|
|
|
// `unbound` is the walk failing now, which is not a change: the reason it
|
|
// failed is named instead of a file list, and the sentence asks for the
|
|
// boundary repair rather than a re-approval that could not certify either.
|
|
function describeSourceDrift(paths: string[] | null, unbound = false): string {
|
|
if (unbound) {
|
|
return `The workspace source cannot be bound${workspaceSourceFailureSuffix()}, so the source this plan was approved against cannot be checked.`;
|
|
}
|
|
if (paths === null || paths.length === 0) {
|
|
return "Source files changed since this plan was approved.";
|
|
}
|
|
const count = paths.length === 1 ? "1 file" : `${paths.length} files`;
|
|
return `${count} changed since this plan was approved: ${renderChangedPaths(paths)}.`;
|
|
}
|
|
|
|
/** The strict human sentence for source drift after the plan was approved. */
|
|
export function planSourceDriftStrictMessage(paths: string[] | null, unbound = false): string {
|
|
return unbound
|
|
? `${describeSourceDrift(paths, true)} ${PLAN_APPROVAL_REPAIR_SOURCE_BOUNDARY_REMEDY} ${PLAN_APPROVAL_BREAK_GLASS_REMEDY}`
|
|
: `${describeSourceDrift(paths)} Look them over and approve the plan again to continue.`;
|
|
}
|
|
|
|
/** The relaxed human sentence for source drift after the plan was approved. */
|
|
export function planSourceDriftRelaxedNotice(paths: string[] | null, unbound = false): string {
|
|
return (
|
|
`${describeSourceDrift(paths, unbound)} Continuing (Change Control: relaxed). ` +
|
|
"Say 'review the plan again' to reopen approval."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The Change Control consequence of the workspace source moving from
|
|
* `recorded` to `current`: under strict, the refusal to throw; under relaxed,
|
|
* the change to record. The listed paths come from the snapshot kept for the
|
|
* recorded fingerprint when one exists; otherwise only the digests speak.
|
|
* This is the checkpoint's one read of the setting, so it is also where a
|
|
* memory edit that moved the value is traced: a mutating caller (the decision
|
|
* and answer records, generation start) passes `trace`, the read-only judge
|
|
* behind the dispatch guard and `next` does not. An invalid memory value is
|
|
* the resolver's validation error under both.
|
|
*/
|
|
function judgePlanSourceDrift(
|
|
projectDir: string,
|
|
unit: string | null,
|
|
recorded: string,
|
|
current: WorkspaceSourceState | null,
|
|
trace: boolean,
|
|
): { accepted: AcceptedChange } | { refusal: PlanApprovalSourceDriftError } {
|
|
const paths = workspaceSourceChangedPaths(projectDir, CODE_GENERATION_STAGE, recorded, current);
|
|
const unbound = current === null;
|
|
const resolution = trace ? governedChangeControl(projectDir) : resolveChangeControl(projectDir);
|
|
if (resolution.value === "strict") {
|
|
return { refusal: new PlanApprovalSourceDriftError(planSourceDriftStrictMessage(paths, unbound)) };
|
|
}
|
|
return {
|
|
accepted: {
|
|
checkpoint: "plan-approval",
|
|
stage: CODE_GENERATION_STAGE,
|
|
unit,
|
|
changed: paths,
|
|
recorded,
|
|
current: current?.fingerprint ?? UNBINDABLE_FINGERPRINT,
|
|
notice: planSourceDriftRelaxedNotice(paths, unbound),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Keep the listing behind the current fingerprint so a later drift can name paths. */
|
|
function keepWorkspaceSourceSnapshot(
|
|
projectDir: string,
|
|
state: WorkspaceSourceState | null,
|
|
): void {
|
|
if (state !== null) writeWorkspaceSourceSnapshot(projectDir, CODE_GENERATION_STAGE, state);
|
|
}
|
|
|
|
// Re-baseline the `[Planned Source]` tag in a questions file to `fingerprint`.
|
|
// Used only before the challenge is minted: after that the prompt hash binds
|
|
// the file bytes and the receipt's certified source is the baseline instead.
|
|
function upsertPlannedSourceTag(questions: string, fingerprint: string): string {
|
|
const eol = questions.includes("\r\n") ? "\r\n" : "\n";
|
|
const raw = questions.split(/\r?\n/);
|
|
const visible = visibleMarkdownLines(questions);
|
|
for (let index = visible.length - 1; index >= 0; index--) {
|
|
if (PLANNED_SOURCE_TAG_RE.test(visible[index])) {
|
|
raw[index] = `[Planned Source]: ${fingerprint}`;
|
|
return raw.join(eol);
|
|
}
|
|
}
|
|
throw new Error("Plan Approval questions file has no [Planned Source]: tag to re-baseline");
|
|
}
|
|
|
|
interface ClassifiedPosture {
|
|
methodology: TestingMethodology;
|
|
ordering: string;
|
|
components: TestingMethodology[];
|
|
}
|
|
|
|
const TESTING_HEADING = "## Testing Posture";
|
|
const TESTABLE_LAYERS = [
|
|
"Data model / database behavior",
|
|
"Repository / data access",
|
|
"Business logic",
|
|
"API / endpoint",
|
|
"Frontend behavior",
|
|
];
|
|
const CONTRACT_HEADING = "## Testing Contract";
|
|
export const PLAN_APPROVAL_CHECKPOINT = "Code Generation Plan Approval";
|
|
const CONTRACT_MARKER_RE =
|
|
/^[ \t]*AIDLC-TESTING-CONTRACT[ \t]*:[ \t]*(sha256:[0-9a-f]{64})[ \t]*$/;
|
|
const MARKDOWN_HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
|
|
const ANSWER_TAG_RE = /^\[Answer\]:[ \t]*(.*)$/;
|
|
// The recorded fingerprint tag. `sha256:v3:<hex>` is the current content-bound
|
|
// format (plan projection plus byte-exact instructions). The `sha256:v2:<hex>`
|
|
// shape (instructions projected like the plan) and the bare `sha256:<hex>` shape
|
|
// (issuance-bound) are still matched so a questions file written under either
|
|
// is READ and reported as "approve again" rather than looking like a line the
|
|
// parser does not understand.
|
|
const FINGERPRINT_TAG_RE =
|
|
/^\[Approval Fingerprint\]:[ \t]*(sha256:(?:v[23]:)?[0-9a-f]{64})?[ \t]*$/;
|
|
// The workspace source the plan was written against, recorded by the fingerprint
|
|
// command so drift between planning and approval is caught with a remedy the
|
|
// conductor can always execute.
|
|
const PLANNED_SOURCE_TAG_RE =
|
|
/^\[Planned Source\]:[ \t]*([0-9a-f]{40}|[0-9a-f]{64}|unbindable)?[ \t]*$/;
|
|
export const APPROVAL_FINGERPRINT_PREFIX = "sha256:v3:";
|
|
|
|
export function approvalFingerprintIsCurrentFormat(tag: string | null): boolean {
|
|
return tag?.startsWith(APPROVAL_FINGERPRINT_PREFIX) === true;
|
|
}
|
|
|
|
/**
|
|
* The `[Approval Fingerprint]` tag recorded in a questions file, in any format
|
|
* this tool has ever written (bare, v2, v3), or null when the file carries no
|
|
* well-formed tag. The one grammar every consumer of the tag reads through, so
|
|
* a format bump never strands a reader that copied the regex.
|
|
*/
|
|
export function recordedApprovalFingerprint(questions: string): string | null {
|
|
for (const line of questions.split(/\r?\n/)) {
|
|
const match = FINGERPRINT_TAG_RE.exec(line);
|
|
if (match) return match[1] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
const APPROVE_PLAN_RE = /^(?:[A-Z][.)][ \t]*)?["']?Approve Plan["']?$/i;
|
|
const QUESTION_PREFIX_RE =
|
|
/^(?:(?:q(?:uestion)?[ \t]*)?\d+[ \t]*[:.)-][ \t]*)/i;
|
|
const NUMBERED_QUESTION_HEADING_RE =
|
|
/^(?:q(?:uestion)?[ \t]*)?\d+[ \t]*[.:)-]?[ \t]*$/i;
|
|
|
|
function canonicalize(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
if (value !== null && typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return Object.fromEntries(
|
|
Object.keys(record)
|
|
.sort()
|
|
.map((key) => [key, canonicalize(record[key])]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return `sha256:${createHash("sha256").update(value, "utf-8").digest("hex")}`;
|
|
}
|
|
|
|
function hashObject(value: unknown): string {
|
|
return sha256(JSON.stringify(canonicalize(value)));
|
|
}
|
|
|
|
function normalizeMethodology(value: string): TestingMethodology | null {
|
|
const normalized = value
|
|
.toLowerCase()
|
|
.replace(/[`*_]/g, "")
|
|
.trim();
|
|
if (/\b(custom|mixed)\b/.test(normalized)) return "custom";
|
|
if (
|
|
/\batdd\b|acceptance[- ]test[- ]driven|acceptance tests? (?:first|before)/.test(
|
|
normalized,
|
|
)
|
|
) {
|
|
return "atdd";
|
|
}
|
|
if (
|
|
/\bbdd\b|behaviou?r[- ]driven|(?:behaviou?r )?scenarios? (?:first|before)/.test(
|
|
normalized,
|
|
)
|
|
) {
|
|
return "bdd";
|
|
}
|
|
if (
|
|
/\btdd\b|test[- ]driven|(?:unit )?tests? (?:first|before implementation)/.test(
|
|
normalized,
|
|
)
|
|
) {
|
|
return "tdd";
|
|
}
|
|
if (
|
|
/\btest[- ]after\b|tests? after implementation|implementation[- ]first|classic/.test(
|
|
normalized,
|
|
)
|
|
) {
|
|
return "test-after";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function structuredMethodology(value: string): TestingMethodology {
|
|
const normalized = value
|
|
.toLowerCase()
|
|
.replace(/[`*_]/g, "")
|
|
.trim();
|
|
if (
|
|
normalized === "tdd" ||
|
|
normalized === "bdd" ||
|
|
normalized === "atdd" ||
|
|
normalized === "test-after" ||
|
|
normalized === "custom"
|
|
) {
|
|
return normalized;
|
|
}
|
|
throw new Error(
|
|
`Invalid Testing Posture Methodology "${value}". Expected one of: tdd, bdd, atdd, test-after, custom.`,
|
|
);
|
|
}
|
|
|
|
function defaultOrdering(methodology: TestingMethodology): string {
|
|
switch (methodology) {
|
|
case "tdd":
|
|
return "For each testable layer: Red, then Green, then Refactor.";
|
|
case "bdd":
|
|
return "Define executable behavior scenarios before implementing each observable feature slice.";
|
|
case "atdd":
|
|
return "Write executable acceptance tests before implementing the complete feature across its required layers.";
|
|
case "test-after":
|
|
return "Implement each testable layer, then write and run that layer's tests.";
|
|
case "custom":
|
|
return "Preserve the explicitly affirmed custom ordering without converting it to another methodology.";
|
|
}
|
|
}
|
|
|
|
type MarkdownFence = { marker: "`" | "~"; length: number };
|
|
|
|
function isEscaped(line: string, offset: number): boolean {
|
|
let backslashes = 0;
|
|
for (let index = offset - 1; index >= 0 && line[index] === "\\"; index--) {
|
|
backslashes++;
|
|
}
|
|
return backslashes % 2 === 1;
|
|
}
|
|
|
|
function hasMatchingTickRun(
|
|
line: string,
|
|
from: number,
|
|
ticks: number,
|
|
): boolean {
|
|
for (let cursor = from; cursor < line.length; cursor++) {
|
|
if (line[cursor] !== "`" || isEscaped(line, cursor)) continue;
|
|
let end = cursor + 1;
|
|
while (line[end] === "`") end++;
|
|
if (end - cursor === ticks) return true;
|
|
cursor = end - 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stripHtmlCommentsFromLine(
|
|
rawLine: string,
|
|
state: { inComment: boolean; inlineCodeTicks: number },
|
|
): string {
|
|
let line = "";
|
|
let cursor = 0;
|
|
while (cursor < rawLine.length) {
|
|
if (state.inComment) {
|
|
const end = rawLine.indexOf("-->", cursor);
|
|
if (end < 0) break;
|
|
state.inComment = false;
|
|
cursor = end + 3;
|
|
continue;
|
|
}
|
|
if (
|
|
rawLine[cursor] === "`" &&
|
|
(state.inlineCodeTicks > 0 || !isEscaped(rawLine, cursor))
|
|
) {
|
|
let end = cursor + 1;
|
|
while (rawLine[end] === "`") end++;
|
|
const ticks = end - cursor;
|
|
if (
|
|
state.inlineCodeTicks === 0 &&
|
|
hasMatchingTickRun(rawLine, end, ticks)
|
|
) {
|
|
state.inlineCodeTicks = ticks;
|
|
} else if (state.inlineCodeTicks === ticks) state.inlineCodeTicks = 0;
|
|
line += rawLine.slice(cursor, end);
|
|
cursor = end;
|
|
continue;
|
|
}
|
|
if (
|
|
state.inlineCodeTicks === 0 &&
|
|
!isEscaped(rawLine, cursor) &&
|
|
rawLine.startsWith("<!--", cursor)
|
|
) {
|
|
state.inComment = true;
|
|
cursor += 4;
|
|
continue;
|
|
}
|
|
line += rawLine[cursor];
|
|
cursor++;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function fenceOpening(line: string): MarkdownFence | null {
|
|
const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
if (opening?.[1][0] === "`" && opening[2].includes("`")) return null;
|
|
return opening
|
|
? {
|
|
marker: opening[1][0] as "`" | "~",
|
|
length: opening[1].length,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
function closesFence(line: string, fence: MarkdownFence): boolean {
|
|
const closing = /^ {0,3}([`~]+)[ \t]*$/.exec(line);
|
|
return Boolean(
|
|
closing &&
|
|
closing[1][0] === fence.marker &&
|
|
Array.from(closing[1]).every((marker) => marker === fence.marker) &&
|
|
closing[1].length >= fence.length,
|
|
);
|
|
}
|
|
|
|
// Remove only rendered HTML comments. Fenced Markdown remains visible content,
|
|
// including literal <!-- tokens inside a fence.
|
|
function markdownWithoutHtmlComments(body: string): string {
|
|
const lines = body.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").split("\n");
|
|
const state = { inComment: false, inlineCodeTicks: 0 };
|
|
let fence: MarkdownFence | null = null;
|
|
return lines
|
|
.map((rawLine) => {
|
|
if (fence) {
|
|
if (closesFence(rawLine, fence)) fence = null;
|
|
return rawLine;
|
|
}
|
|
const startedInComment = state.inComment;
|
|
const line = stripHtmlCommentsFromLine(rawLine, state);
|
|
const commentStart = rawLine.indexOf("<!--");
|
|
const structuralPrefix =
|
|
startedInComment || (line !== rawLine && commentStart < 0)
|
|
? ""
|
|
: commentStart < 0
|
|
? rawLine
|
|
: rawLine.slice(0, commentStart);
|
|
const opening = fenceOpening(structuralPrefix);
|
|
if (opening) {
|
|
state.inComment = false;
|
|
state.inlineCodeTicks = 0;
|
|
}
|
|
fence = opening;
|
|
return opening ? rawLine : line;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function structuralMarkdownLines(body: string): string[] {
|
|
const rawLines = body.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").split("\n");
|
|
const visibleLines = markdownWithoutHtmlComments(body).split("\n");
|
|
return visibleLines.map((line, index) => {
|
|
const rawLine = rawLines[index];
|
|
if (line === rawLine) return line;
|
|
const opening = rawLine.indexOf("<!--");
|
|
const closing = rawLine.indexOf("-->");
|
|
return opening >= 0 && (closing < 0 || opening < closing)
|
|
? rawLine.slice(0, opening)
|
|
: "";
|
|
});
|
|
}
|
|
|
|
function visiblePostureText(section: string): string {
|
|
return markdownWithoutHtmlComments(section).trim();
|
|
}
|
|
|
|
function classifiablePostureText(section: string): string {
|
|
const lines = markdownWithoutHtmlComments(section).split("\n");
|
|
const structuralLines = structuralMarkdownLines(section);
|
|
let fence: MarkdownFence | null = null;
|
|
return lines
|
|
.map((line, index) => {
|
|
const structuralLine = structuralLines[index];
|
|
if (fence) {
|
|
if (closesFence(structuralLine, fence)) fence = null;
|
|
return "";
|
|
}
|
|
const opening = fenceOpening(structuralLine);
|
|
if (opening) {
|
|
fence = opening;
|
|
return "";
|
|
}
|
|
return line;
|
|
})
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
// Find the real Testing Posture section while ignoring headings hidden inside
|
|
// HTML comments or fenced examples. Return the original raw lines so comments
|
|
// and fences remain part of input_sha256 even though classification uses the
|
|
// visible projection above.
|
|
function extractTestingPostureSection(content: string): string {
|
|
const rawLines = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").split("\n");
|
|
const visibleLines = structuralMarkdownLines(content);
|
|
let fence: MarkdownFence | null = null;
|
|
let bodyStart = -1;
|
|
let bodyEnd = rawLines.length;
|
|
|
|
for (let index = 0; index < visibleLines.length; index++) {
|
|
const line = visibleLines[index];
|
|
if (fence) {
|
|
if (closesFence(line, fence)) fence = null;
|
|
continue;
|
|
}
|
|
const opening = fenceOpening(line);
|
|
if (opening) {
|
|
fence = opening;
|
|
continue;
|
|
}
|
|
if (bodyStart < 0) {
|
|
if (line.trimEnd() === TESTING_HEADING) bodyStart = index + 1;
|
|
continue;
|
|
}
|
|
if (/^## [^\n]*$/.test(line)) {
|
|
bodyEnd = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return bodyStart < 0 ? "" : rawLines.slice(bodyStart, bodyEnd).join("\n");
|
|
}
|
|
|
|
function classifyPosture(section: string): ClassifiedPosture | null {
|
|
const body = classifiablePostureText(section);
|
|
if (!body) return null;
|
|
|
|
const structuredMethod = structuredField(body, "Methodology");
|
|
const structuredOrdering = structuredField(body, "Ordering");
|
|
const structured = structuredMethod
|
|
? structuredMethodology(structuredMethod)
|
|
: null;
|
|
const scan = `${structuredMethod ?? ""}\n${structuredOrdering ?? body}`.toLowerCase();
|
|
const components = new Set<TestingMethodology>();
|
|
for (const methodology of ["tdd", "bdd", "atdd", "test-after"] as const) {
|
|
const detected = normalizeMethodology(
|
|
methodology === "test-after"
|
|
? scan.match(
|
|
/test[- ]after|tests? after implementation|implementation[- ]first|classic/,
|
|
)?.[0] ?? ""
|
|
: scan.match(
|
|
methodology === "tdd"
|
|
? /\btdd\b|test[- ]driven/
|
|
: methodology === "bdd"
|
|
? /\bbdd\b|behaviou?r[- ]driven/
|
|
: /\batdd\b|acceptance[- ]test[- ]driven/,
|
|
)?.[0] ?? "",
|
|
);
|
|
if (detected) components.add(detected);
|
|
}
|
|
|
|
const ordering = structuredOrdering ?? body;
|
|
const mixedOrdering =
|
|
(/\b(?:tests?|scenarios?)\b[^.\n]{0,80}\bfirst(?!-)\b/i.test(ordering) ||
|
|
/\b(?:tests?|scenarios?)\b[^.\n]{0,80}\bbefore\b[^.\n]{0,40}\bimplement(?:ation|ing)?\b/i.test(
|
|
ordering,
|
|
)) &&
|
|
(/\btests?\b[^.\n]{0,80}\bafter\b[^.\n]{0,40}\bimplement(?:ation|ing)?\b/i.test(
|
|
ordering,
|
|
) ||
|
|
/\brefactor(?:ing)?\b[^.\n]{0,80}\bafter\b[^.\n]{0,40}\bgreen\b/i.test(
|
|
ordering,
|
|
) ||
|
|
/\btests?\b[^.\n]{0,80}\bfollow\b[^.\n]{0,40}\bimplement(?:ation|ing)?\b/i.test(
|
|
ordering,
|
|
));
|
|
const customSignal =
|
|
/\b(?:custom|mixed)[ -](?:ordering|cadence|posture|methodology)\b|\b(?:ordering|cadence|posture|methodology)[ -](?:custom|mixed)\b/i.test(
|
|
body,
|
|
);
|
|
if (
|
|
structured === null &&
|
|
components.size > 1 &&
|
|
!customSignal &&
|
|
!mixedOrdering
|
|
) {
|
|
return null;
|
|
}
|
|
const methodology =
|
|
structured ??
|
|
(customSignal || mixedOrdering
|
|
? "custom"
|
|
: Array.from(components)[0] ?? null);
|
|
if (methodology === null) return null;
|
|
|
|
if (methodology !== "custom") components.add(methodology);
|
|
return {
|
|
methodology,
|
|
ordering:
|
|
structuredOrdering ??
|
|
(methodology === "custom" ? body.replace(/\s+/g, " ").trim() : defaultOrdering(methodology)),
|
|
components: Array.from(components),
|
|
};
|
|
}
|
|
|
|
function compatibleSpecialization(
|
|
broader: ClassifiedPosture,
|
|
narrower: ClassifiedPosture,
|
|
): boolean {
|
|
if (broader.methodology === narrower.methodology) return true;
|
|
return (
|
|
narrower.methodology === "custom" &&
|
|
narrower.components.includes(broader.methodology)
|
|
);
|
|
}
|
|
|
|
function normalizeStrategy(value: string): TestStrategy {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (
|
|
normalized === "minimal" ||
|
|
normalized === "standard" ||
|
|
normalized === "comprehensive"
|
|
) {
|
|
return normalized;
|
|
}
|
|
return "standard";
|
|
}
|
|
|
|
function normalizeProjectType(value: string): ProjectType {
|
|
return value.trim().toLowerCase() === "brownfield"
|
|
? "brownfield"
|
|
: "greenfield";
|
|
}
|
|
|
|
export function combineTestObligations(
|
|
scope: string,
|
|
strategy: TestStrategy,
|
|
): TestObligations {
|
|
const strategyVolume: Record<TestStrategy, string[]> = {
|
|
minimal: [
|
|
"One verifiable test per requirement at the narrowest effective level.",
|
|
"At least one happy-path unit test per component.",
|
|
"Unit tests are the default; a bugfix/security scope floor may require an integration or E2E regression when that is the narrowest level that reproduces the defect.",
|
|
],
|
|
standard: [
|
|
"Five to eight tests per component.",
|
|
"Unit tests plus integration tests for key boundaries.",
|
|
"Add E2E, performance, or security tests when requirements demand them.",
|
|
],
|
|
comprehensive: [
|
|
"Ten to fifteen tests per component.",
|
|
"Unit, integration, and E2E tests.",
|
|
"Add performance and security tests when NFRs demand them.",
|
|
],
|
|
};
|
|
const normalizedScope = scope.trim().toLowerCase();
|
|
let scopeFloor: string[];
|
|
if (["mvp", "enterprise", "feature", "infra"].includes(normalizedScope)) {
|
|
scopeFloor = [
|
|
"Meet an 80% line-coverage floor.",
|
|
"Run the selected tests in CI before merge.",
|
|
];
|
|
} else if (["bugfix", "security-patch"].includes(normalizedScope)) {
|
|
scopeFloor = [
|
|
"Include a targeted regression for the bug or vulnerability.",
|
|
"Keep the existing test suite green.",
|
|
];
|
|
} else {
|
|
scopeFloor = [
|
|
"Keep the existing test suite green.",
|
|
"This scope adds no extra new-test floor beyond the selected test strategy.",
|
|
];
|
|
}
|
|
return {
|
|
strategy,
|
|
strategy_volume: strategyVolume[strategy],
|
|
scope_floor: scopeFloor,
|
|
combination_rule:
|
|
"Apply every selected-strategy obligation and every scope-floor obligation; neither replaces the other, and a targeted scope regression may add the narrowest necessary test type beyond the strategy default.",
|
|
};
|
|
}
|
|
|
|
export function buildPlanProfile(
|
|
methodology: TestingMethodology,
|
|
ordering: string,
|
|
projectType: ProjectType,
|
|
): PlanProfile {
|
|
const runnerStep =
|
|
projectType === "greenfield"
|
|
? "Bootstrap the minimal test runner/configuration and record the exact unit-scoped command."
|
|
: "Verify the existing test runner/configuration and record the exact unit-scoped command.";
|
|
const steps = [
|
|
"Project structure and production configuration skeleton.",
|
|
runnerStep,
|
|
];
|
|
|
|
if (methodology === "tdd") {
|
|
for (const layer of TESTABLE_LAYERS) {
|
|
steps.push(
|
|
`${layer} - Red: write the failing tests and record the failing command output.`,
|
|
`${layer} - Green: implement only enough behavior to pass.`,
|
|
`${layer} - Refactor: improve the implementation while tests stay green.`,
|
|
);
|
|
}
|
|
} else if (methodology === "bdd") {
|
|
steps.push(
|
|
"Behavior scenarios - define executable examples for the observable feature slice before implementation.",
|
|
"Feature slice - implement the required data, repository, business, API, and frontend layers.",
|
|
"Behavior scenarios - run the scenarios until they pass.",
|
|
"Feature slice - refactor while the scenarios stay green.",
|
|
);
|
|
} else if (methodology === "atdd") {
|
|
steps.push(
|
|
"Acceptance Red - write executable acceptance tests for the complete feature before implementation.",
|
|
"Feature implementation - implement the required layers against the acceptance contract.",
|
|
"Acceptance Green - run the acceptance tests until they pass.",
|
|
"Feature Refactor - improve the cross-layer implementation while acceptance stays green.",
|
|
);
|
|
} else if (methodology === "custom") {
|
|
steps.push(
|
|
`Custom ordering - ${ordering}`,
|
|
"Implementation and tests - preserve that exact ordering; do not convert it to layer-local TDD.",
|
|
);
|
|
} else {
|
|
for (const layer of TESTABLE_LAYERS) {
|
|
steps.push(
|
|
`${layer} - implement.`,
|
|
`${layer} - write and run its tests after implementation.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
steps.push(
|
|
"Environment/build configuration.",
|
|
"Documentation and traceability.",
|
|
);
|
|
return {
|
|
methodology,
|
|
runner_step: runnerStep,
|
|
runner_ready_before_first_test: true,
|
|
testable_layers: TESTABLE_LAYERS.slice(),
|
|
steps,
|
|
};
|
|
}
|
|
|
|
export function resolveTestingPostureFromSections(
|
|
sections: TestingPostureSections,
|
|
options: {
|
|
scope: string;
|
|
testStrategy: TestStrategy;
|
|
projectType: ProjectType;
|
|
},
|
|
): TestingPostureContract {
|
|
const classified = {
|
|
org: classifyPosture(sections.org ?? ""),
|
|
team: classifyPosture(sections.team ?? ""),
|
|
project: classifyPosture(sections.project ?? ""),
|
|
};
|
|
|
|
if (
|
|
classified.team &&
|
|
classified.project &&
|
|
!compatibleSpecialization(classified.team, classified.project)
|
|
) {
|
|
throw new Error(
|
|
`Testing Posture conflict: project methodology "${classified.project.methodology}" ` +
|
|
`contradicts team methodology "${classified.team.methodology}". Revise the narrower rule; ` +
|
|
"strict-additive memory does not permit runtime override.",
|
|
);
|
|
}
|
|
|
|
const selected =
|
|
classified.project
|
|
? { layer: "project" as const, value: classified.project }
|
|
: classified.team
|
|
? { layer: "team" as const, value: classified.team }
|
|
: classified.org
|
|
? { layer: "org" as const, value: classified.org }
|
|
: {
|
|
layer: "fallback" as const,
|
|
value: {
|
|
methodology: "test-after" as const,
|
|
ordering: defaultOrdering("test-after"),
|
|
components: ["test-after" as const],
|
|
},
|
|
};
|
|
const applicableNotes = (["org", "team", "project"] as const)
|
|
.map((layer) => ({
|
|
layer,
|
|
text: visiblePostureText(sections[layer] ?? ""),
|
|
}))
|
|
.filter((entry) => entry.text.length > 0);
|
|
const input = {
|
|
sections: {
|
|
org: sections.org ?? "",
|
|
team: sections.team ?? "",
|
|
project: sections.project ?? "",
|
|
},
|
|
scope: options.scope,
|
|
test_strategy: options.testStrategy,
|
|
project_type: options.projectType,
|
|
};
|
|
const body: TestingPostureContractBody = {
|
|
version: 1,
|
|
methodology: selected.value.methodology,
|
|
source: selected.layer,
|
|
ordering: selected.value.ordering,
|
|
scope: options.scope,
|
|
test_strategy: options.testStrategy,
|
|
project_type: options.projectType,
|
|
applicable_notes: applicableNotes,
|
|
obligations: combineTestObligations(options.scope, options.testStrategy),
|
|
plan_profile: buildPlanProfile(
|
|
selected.value.methodology,
|
|
selected.value.ordering,
|
|
options.projectType,
|
|
),
|
|
input_sha256: hashObject(input),
|
|
};
|
|
return { ...body, contract_sha256: hashObject(body) };
|
|
}
|
|
|
|
export function resolveTestingPosture(
|
|
projectDir: string,
|
|
): TestingPostureContract {
|
|
const space = resolveWorkflowSelection(projectDir).space;
|
|
const memoryDir = join(projectDir, "aidlc", "spaces", space, "memory");
|
|
const sections: TestingPostureSections = {};
|
|
for (const layer of ["org", "team", "project"] as const) {
|
|
const file = join(memoryDir, `${layer}.md`);
|
|
if (!existsSync(file)) continue;
|
|
sections[layer] = extractTestingPostureSection(readFileSync(file, "utf-8"));
|
|
}
|
|
let state = "";
|
|
try {
|
|
state = readFileSync(stateFilePath(projectDir), "utf-8");
|
|
} catch {
|
|
// Pre-creation and focused tests use deterministic defaults.
|
|
}
|
|
return resolveTestingPostureFromSections(sections, {
|
|
scope: (getField(state, "Scope") ?? "feature").trim().toLowerCase(),
|
|
testStrategy: normalizeStrategy(getField(state, "Test Strategy") ?? "standard"),
|
|
projectType: normalizeProjectType(getField(state, "Project Type") ?? "greenfield"),
|
|
});
|
|
}
|
|
|
|
export function renderTestingContract(contract: TestingPostureContract): string {
|
|
return `${CONTRACT_HEADING}\n\n\`\`\`json\n${JSON.stringify(contract, null, 2)}\n\`\`\`\n`;
|
|
}
|
|
|
|
function rawMarkdownSection(content: string, heading: string): string {
|
|
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
const body: string[] = [];
|
|
let found = false;
|
|
let inFence = false;
|
|
for (const line of lines) {
|
|
if (/^```/.test(line)) {
|
|
if (found) body.push(line);
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (!inFence && line.trimEnd() === heading) {
|
|
found = true;
|
|
continue;
|
|
}
|
|
if (found && !inFence && /^## [^\n]*$/.test(line)) break;
|
|
if (found) body.push(line);
|
|
}
|
|
return found ? body.join("\n") : "";
|
|
}
|
|
|
|
export function parseTestingContract(plan: string): TestingPostureContract | null {
|
|
const section = rawMarkdownSection(plan, CONTRACT_HEADING);
|
|
const match = section.match(/```json[ \t]*\r?\n([\s\S]*?)\r?\n```/i);
|
|
if (!match) return null;
|
|
try {
|
|
const parsed = JSON.parse(match[1]) as TestingPostureContract;
|
|
if (
|
|
parsed.version !== 1 ||
|
|
!/^sha256:[0-9a-f]{64}$/.test(parsed.contract_sha256 ?? "")
|
|
) {
|
|
return null;
|
|
}
|
|
const { contract_sha256: recorded, ...body } = parsed;
|
|
return hashObject(body) === recorded ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- The Plan Approval content projection -------------------------------------
|
|
//
|
|
// The approval must survive the edit the stage itself ORDERS after approval, and
|
|
// must not survive an edit to the plan. Byte-exact hashing cannot do both: Step 4
|
|
// tells the developer agent to tick the plan's checkboxes as it works, so hashing
|
|
// raw bytes invalidated every approval as soon as the approved work started.
|
|
//
|
|
// So the fingerprint is taken over a projection that erases exactly these
|
|
// mutations and nothing else:
|
|
//
|
|
// 1. A TERMINAL `## Review` appendix is removed, using the engine's own
|
|
// appendix locator (a `## Review` inside a fence or an HTML comment, a
|
|
// lower-case or unspaced variant, and a mid-plan section are all NOT an
|
|
// appendix and stay material). This is a legacy-compatibility step: the
|
|
// reviewer used to append its verdict to the plan because the plan is the
|
|
// stage's review artifact. Reviews live in review records now and nothing
|
|
// appends to the plan, but a plan reviewed under the earlier protocol may
|
|
// still carry that section, and its approval must not depend on it.
|
|
// 2. List task markers are reset: `[x]`, `[X]` and `[-]` become `[ ]`, outside
|
|
// fenced blocks and HTML comments. A tick is a claim about execution, not a
|
|
// change to the plan.
|
|
// 3. Line endings become LF, trailing whitespace per line is dropped OUTSIDE
|
|
// fences, runs of blank lines outside fences and comments collapse to one, and
|
|
// trailing blank lines are dropped. These are editor artifacts, not content.
|
|
// Inside a fence every byte is kept, because there a whitespace-only line can
|
|
// be the difference between two patches.
|
|
//
|
|
// Everything else is byte-exact, INCLUDING the fenced `## Testing Contract` JSON
|
|
// and any text inside code fences. Reordering, rewording, adding or deleting a
|
|
// step, changing a number, a path, or the contract hash all change the
|
|
// projection.
|
|
//
|
|
// The one thing the projection cannot see is an edit made INSIDE a terminal
|
|
// review appendix. That is closed elsewhere: the worker brief carries the plan
|
|
// BODY (this projection's input), produced by the `brief` command and checked by
|
|
// the dispatch guard, so a step smuggled into the appendix is never delivered as
|
|
// work.
|
|
//
|
|
// This projection is for the PLAN only. The unit-test instructions are not a
|
|
// review artifact and have no mandated post-approval mutation, so they bind
|
|
// byte-exactly (line endings aside): see `projectInstructionsContent`.
|
|
const PLAN_TASK_MARKER_RE = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\[[xX-]\](?=[ \t]|$)/;
|
|
|
|
export function projectPlanApprovalContent(text: string): string {
|
|
const retained = contentBeforeTerminalReviewAppendix(text.replace(/^\uFEFF/, ""));
|
|
const projected: string[] = [];
|
|
let fence: MarkdownFence | null = null;
|
|
let inComment = false;
|
|
let previousBlank = false;
|
|
for (const rawLine of retained.replace(/\r\n?/g, "\n").split("\n")) {
|
|
const line = rawLine.replace(/[ \t]+$/, "");
|
|
if (fence) {
|
|
// Verbatim inside a fence: a whitespace-only line in a diff or a Python block
|
|
// is content, not an editor artifact, and two fences differing only there
|
|
// apply different patches.
|
|
projected.push(rawLine);
|
|
previousBlank = false;
|
|
if (closesFence(line, fence)) fence = null;
|
|
continue;
|
|
}
|
|
if (inComment) {
|
|
projected.push(line);
|
|
previousBlank = false;
|
|
if (line.includes("-->")) inComment = false;
|
|
continue;
|
|
}
|
|
const opening = fenceOpening(line);
|
|
if (opening) {
|
|
fence = opening;
|
|
projected.push(line);
|
|
previousBlank = false;
|
|
continue;
|
|
}
|
|
if (/^ {0,3}<!--/.test(line) && !line.includes("-->")) {
|
|
inComment = true;
|
|
projected.push(line);
|
|
previousBlank = false;
|
|
continue;
|
|
}
|
|
const blank = line.length === 0;
|
|
if (blank && previousBlank) continue;
|
|
previousBlank = blank;
|
|
projected.push(line.replace(PLAN_TASK_MARKER_RE, "$1[ ]"));
|
|
}
|
|
while (projected.length > 0 && projected[projected.length - 1] === "") {
|
|
projected.pop();
|
|
}
|
|
return projected.join("\n");
|
|
}
|
|
|
|
// The unit-test instructions as the fingerprint binds them and as the worker
|
|
// brief hands them over: every byte, with only the line endings normalized. No
|
|
// review strip, no task-marker reset, no whitespace folding, not even a BOM
|
|
// dropped: the instructions are sent to the developer in full, so anything that
|
|
// can change what the developer reads must reopen approval.
|
|
export function projectInstructionsContent(text: string): string {
|
|
return text.replace(/\r\n?/g, "\n");
|
|
}
|
|
|
|
// The value recorded as `[Approval Fingerprint]:`. It binds CONTENT (the
|
|
// projected plan, the byte-exact unit-test instructions, and the Testing Contract
|
|
// hash) to PLACE (target, intent) and to ATTEMPT (the run floor). The tag carries
|
|
// a format version so a value recorded under a previous scheme (issuance-bound,
|
|
// or instructions projected like the plan) is recognised and answered with
|
|
// "approve again" instead of an unexplained mismatch.
|
|
export function approvalFingerprint(
|
|
plan: string,
|
|
instructions: string,
|
|
contractHash: string,
|
|
authority: Pick<CodeGenerationAuthority, "targetId" | "intentId" | "runFloor">,
|
|
): string {
|
|
const digest = hashObject({
|
|
plan: projectPlanApprovalContent(plan),
|
|
instructions: projectInstructionsContent(instructions),
|
|
testing_contract: contractHash,
|
|
target: authority.targetId,
|
|
intent: authority.intentId,
|
|
run_floor: authority.runFloor,
|
|
});
|
|
return `${APPROVAL_FINGERPRINT_PREFIX}${digest.slice("sha256:".length)}`;
|
|
}
|
|
|
|
// --- The worker brief ------------------------------------------------------------
|
|
//
|
|
// What a code-generation worker is handed is exactly what the fingerprint bound,
|
|
// and nothing else: the plan as the approval projection sees it (a terminal
|
|
// `## Review` appendix removed, task markers reset to `[ ]`, spacing
|
|
// normalized) and the unit-test instructions exactly as they were hashed. No
|
|
// byte the fingerprint does not cover reaches the worker, on the interactive
|
|
// path or the autonomous one, fresh or replayed. The brief is produced here,
|
|
// from bytes proven to be the approved ones, so no conductor reads the plan
|
|
// file into a prompt itself. The worker's own progress marks live in the plan
|
|
// file it ticks as it works, not in the brief.
|
|
|
|
export interface WorkerBrief {
|
|
unit: string | null;
|
|
contractHash: string;
|
|
/** The exact text to hand the worker: marker lines, projected plan, instructions. */
|
|
brief: string;
|
|
/** True when the plan carried a terminal review appendix, which the brief omits. */
|
|
appendixStripped: boolean;
|
|
}
|
|
|
|
/** The terminal `## Review` appendix of a plan, or "" when it carries none. */
|
|
export function planReviewAppendix(plan: string): string {
|
|
const body = contentBeforeTerminalReviewAppendix(plan);
|
|
return plan.slice(body.length);
|
|
}
|
|
|
|
export function workerBrief(
|
|
projectDir: string,
|
|
target: CodeGenerationTarget,
|
|
): WorkerBrief {
|
|
const approval = evaluateCodeGenerationApproval(projectDir, target);
|
|
if (!approval.ok || approval.contractHash === null || approval.approvalFingerprint === null) {
|
|
throw new Error(
|
|
`Cannot assemble a worker brief for ${
|
|
target.unit ? `unit "${target.unit}"` : "the stage-level target"
|
|
}: ${approval.reason || "Plan Approval is not current"}`,
|
|
);
|
|
}
|
|
// Read the two files once, then prove THESE bytes are the approved ones by
|
|
// recomputing the fingerprint over them and matching the validated tag. A
|
|
// file that changed between the evaluation and this read cannot pass, so the
|
|
// brief is never assembled from bytes the approval did not cover.
|
|
const stageDir = codeGenerationRecordDir(projectDir, target.unit);
|
|
const plan = readFileSync(join(stageDir, "code-generation-plan.md"), "utf-8");
|
|
const instructions = readFileSync(join(stageDir, "unit-test-instructions.md"), "utf-8");
|
|
const authority = resolveCodeGenerationAuthority(projectDir, target);
|
|
const snapshotFingerprint = approvalFingerprint(
|
|
plan,
|
|
instructions,
|
|
approval.contractHash,
|
|
authority,
|
|
);
|
|
if (snapshotFingerprint !== approval.approvalFingerprint) {
|
|
throw new Error(
|
|
"Cannot assemble a worker brief: the plan or instructions changed while the brief " +
|
|
"was being assembled. Re-run the fingerprint command, re-present the plan, and approve again.",
|
|
);
|
|
}
|
|
const projectedPlan = projectPlanApprovalContent(plan);
|
|
const marker = target.unit
|
|
? `AIDLC-UNIT: ${target.unit}`
|
|
: "AIDLC-STAGE: code-generation";
|
|
const brief =
|
|
`${marker}\n` +
|
|
`AIDLC-TESTING-CONTRACT: ${approval.contractHash}\n` +
|
|
"\n## Approved plan\n\n" +
|
|
`${projectedPlan}\n` +
|
|
"\n## Approved unit-test instructions\n\n" +
|
|
projectInstructionsContent(instructions);
|
|
return {
|
|
unit: approval.unit,
|
|
contractHash: approval.contractHash,
|
|
brief,
|
|
appendixStripped: planReviewAppendix(plan.replace(/^\uFEFF/, "")).length > 0,
|
|
};
|
|
}
|
|
|
|
function isPlanApprovalLabel(value: string): boolean {
|
|
let normalized = value.trim().replace(/[?:][ \t]*$/, "").trim();
|
|
for (const marker of ["**", "__", "*", "_"]) {
|
|
if (
|
|
normalized.startsWith(marker) &&
|
|
normalized.endsWith(marker) &&
|
|
normalized.length > marker.length * 2
|
|
) {
|
|
normalized = normalized.slice(marker.length, -marker.length).trim();
|
|
break;
|
|
}
|
|
}
|
|
return normalized.toLowerCase() === "plan approval";
|
|
}
|
|
|
|
function latestPlanApproval(body: string): {
|
|
found: boolean;
|
|
answer: string | null;
|
|
fingerprint: string | null;
|
|
plannedSource: string | null;
|
|
} {
|
|
let inPlanApproval = false;
|
|
let awaitingNumberedQuestionText = false;
|
|
let foundPlanApproval = false;
|
|
let latestAnswer: string | null = null;
|
|
let latestFingerprint: string | null = null;
|
|
let latestPlannedSource: string | null = null;
|
|
|
|
for (const line of visibleMarkdownLines(body)) {
|
|
const heading = line.match(MARKDOWN_HEADING_RE);
|
|
if (heading) {
|
|
const headingText = heading[2].trim();
|
|
inPlanApproval = isPlanApprovalLabel(
|
|
headingText.replace(QUESTION_PREFIX_RE, ""),
|
|
);
|
|
awaitingNumberedQuestionText =
|
|
!inPlanApproval && NUMBERED_QUESTION_HEADING_RE.test(headingText);
|
|
if (inPlanApproval) {
|
|
foundPlanApproval = true;
|
|
latestAnswer = null;
|
|
latestFingerprint = null;
|
|
latestPlannedSource = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (awaitingNumberedQuestionText && line.trim().length > 0) {
|
|
awaitingNumberedQuestionText = false;
|
|
inPlanApproval = isPlanApprovalLabel(line);
|
|
if (inPlanApproval) {
|
|
foundPlanApproval = true;
|
|
latestAnswer = null;
|
|
latestFingerprint = null;
|
|
latestPlannedSource = null;
|
|
}
|
|
}
|
|
if (!inPlanApproval) continue;
|
|
const answer = line.match(ANSWER_TAG_RE);
|
|
if (answer) latestAnswer = answer[1].trim();
|
|
const fingerprint = line.match(FINGERPRINT_TAG_RE);
|
|
if (fingerprint) latestFingerprint = fingerprint[1] ?? null;
|
|
const plannedSource = line.match(PLANNED_SOURCE_TAG_RE);
|
|
if (plannedSource) latestPlannedSource = plannedSource[1] ?? null;
|
|
}
|
|
return {
|
|
found: foundPlanApproval,
|
|
answer: latestAnswer,
|
|
fingerprint: latestFingerprint,
|
|
plannedSource: latestPlannedSource,
|
|
};
|
|
}
|
|
|
|
export function questionsFileApproved(body: string): boolean {
|
|
const latest = latestPlanApproval(body);
|
|
return (
|
|
latest.found &&
|
|
latest.answer !== null &&
|
|
APPROVE_PLAN_RE.test(latest.answer)
|
|
);
|
|
}
|
|
|
|
export function questionsFileHasPendingPlanApproval(body: string): boolean {
|
|
const latest = latestPlanApproval(body);
|
|
return (
|
|
latest.found &&
|
|
latest.answer !== null &&
|
|
/^_*$/.test(latest.answer)
|
|
);
|
|
}
|
|
|
|
export function questionsFileApprovalFingerprint(body: string): string | null {
|
|
return latestPlanApproval(body).fingerprint;
|
|
}
|
|
|
|
export function questionsFilePlannedSource(body: string): string | null {
|
|
return latestPlanApproval(body).plannedSource;
|
|
}
|
|
|
|
export function promptTestingContractMarkers(text: string): string[] {
|
|
const hashes = new Set<string>();
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const marker = line.match(CONTRACT_MARKER_RE);
|
|
if (marker) hashes.add(marker[1]);
|
|
}
|
|
return Array.from(hashes);
|
|
}
|
|
|
|
function normalizeCodeGenerationTarget(target: CodeGenerationTarget): CodeGenerationTarget {
|
|
if (target.unit === null) return { unit: null };
|
|
const unit = target.unit.trim();
|
|
const error = validateUnitName(unit);
|
|
if (error) throw new Error(error);
|
|
return { unit };
|
|
}
|
|
|
|
export function codeGenerationTargetId(target: CodeGenerationTarget): string {
|
|
const normalized = normalizeCodeGenerationTarget(target);
|
|
return normalized.unit === null ? "stage:code-generation" : `unit:${normalized.unit}`;
|
|
}
|
|
|
|
export function resolveCodeGenerationAuthority(
|
|
projectDir: string,
|
|
requestedTarget: CodeGenerationTarget,
|
|
): CodeGenerationAuthority {
|
|
const target = normalizeCodeGenerationTarget(requestedTarget);
|
|
const statePath = stateFilePath(projectDir);
|
|
if (!existsSync(statePath)) {
|
|
throw new Error("Code Generation approval authority requires an active workflow state");
|
|
}
|
|
const state = readFileSync(statePath, "utf-8");
|
|
const marker = readActiveDirectiveMarker(projectDir, state);
|
|
if (marker?.version !== 2) {
|
|
throw new Error(
|
|
"Code Generation approval authority is unavailable because the active directive is missing, stale, or legacy; run a fresh `next`",
|
|
);
|
|
}
|
|
if (marker.stage !== "code-generation") {
|
|
throw new Error(
|
|
`Code Generation approval authority does not match active directive stage "${marker.stage}"`,
|
|
);
|
|
}
|
|
if (marker.kind !== "run-stage" && marker.kind !== "invoke-swarm") {
|
|
throw new Error(
|
|
`Code Generation approval authority requires a run-stage or invoke-swarm directive, got "${marker.kind}"`,
|
|
);
|
|
}
|
|
|
|
if (target.unit === null) {
|
|
if (marker.kind !== "run-stage" || marker.unit !== undefined) {
|
|
throw new Error(
|
|
"Stage-level Code Generation approval requires a zero-Unit run-stage directive",
|
|
);
|
|
}
|
|
} else if (marker.kind === "run-stage") {
|
|
if (marker.unit !== target.unit) {
|
|
throw new Error(
|
|
`Code Generation approval target unit "${target.unit}" does not match active directive unit "${marker.unit ?? "(none)"}"`,
|
|
);
|
|
}
|
|
} else {
|
|
const dag = resolveBoltDag(projectDir);
|
|
if (
|
|
dag.state !== "ok" ||
|
|
!dag.units.includes(target.unit) ||
|
|
!marker.units?.includes(target.unit)
|
|
) {
|
|
throw new Error(
|
|
`Code Generation approval target unit "${target.unit}" is not in the active swarm directive and authoritative Unit DAG`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const issuanceRevision =
|
|
marker.code_generation_authority_revision ??
|
|
marker.active_attempt?.result_revision ??
|
|
marker.revision;
|
|
if (!Number.isInteger(issuanceRevision)) {
|
|
throw new Error("Code Generation active directive has no stable issuance revision");
|
|
}
|
|
const markerRevision = Number(issuanceRevision);
|
|
const targetId = codeGenerationTargetId(target);
|
|
const intentId = marker.intent_uuid ?? "bare-space";
|
|
const sourceFloor =
|
|
marker.code_generation_source_sha256 ?? UNBINDABLE_FINGERPRINT;
|
|
// Pass the target Unit: a GATE_REJECTED row for one Unit carries that Unit, and
|
|
// without it a rejected per-Unit gate moved no Unit's approval floor in team
|
|
// mode while moving it in solo.
|
|
const runFloor = latestMainWorkflowStageRunFloorForProject(
|
|
projectDir,
|
|
"code-generation",
|
|
getField(state, "Construction Iteration")?.trim() === "unit-major",
|
|
target.unit ?? undefined,
|
|
);
|
|
const directiveEpoch = hashObject({
|
|
version: marker.version,
|
|
project: marker.project_sha256,
|
|
intent: marker.intent_uuid,
|
|
state: marker.state_sha256,
|
|
stage: marker.stage,
|
|
directive_unit: marker.unit ?? null,
|
|
kind: marker.kind,
|
|
issuance_revision: issuanceRevision,
|
|
owner_epoch: marker.owner_epoch,
|
|
context_epoch: marker.context_epoch,
|
|
continue_token: marker.continue_token_sha256 ?? null,
|
|
target: targetId,
|
|
source_floor: sourceFloor,
|
|
});
|
|
return {
|
|
unit: target.unit,
|
|
targetId,
|
|
intentId,
|
|
directiveEpoch,
|
|
runFloor,
|
|
stageDir: codeGenerationRecordDir(projectDir, target.unit),
|
|
sourceFloor,
|
|
markerRevision,
|
|
};
|
|
}
|
|
|
|
export function codeGenerationRecordDir(
|
|
projectDir: string,
|
|
unit: string | null,
|
|
): string {
|
|
const root = join(docsRoot(projectDir), "construction");
|
|
const normalizedUnit = unit?.trim() ?? "";
|
|
return normalizedUnit.length > 0
|
|
? join(root, normalizedUnit, "code-generation")
|
|
: join(root, "code-generation");
|
|
}
|
|
|
|
function codeGenerationApprovalArtifacts(
|
|
projectDir: string,
|
|
authority: CodeGenerationAuthority,
|
|
): {
|
|
plan: string;
|
|
instructions: string;
|
|
questions: string;
|
|
planExists: boolean;
|
|
instructionsExist: boolean;
|
|
approvedAnswer: boolean;
|
|
contractValid: boolean;
|
|
contractHash: string | null;
|
|
expectedFingerprint: string | null;
|
|
recordedFingerprint: string | null;
|
|
questionsPath: string;
|
|
} {
|
|
const planPath = join(authority.stageDir, "code-generation-plan.md");
|
|
const instructionsPath = join(authority.stageDir, "unit-test-instructions.md");
|
|
const questionsPath = join(authority.stageDir, "code-generation-questions.md");
|
|
const plan = existsSync(planPath) ? readFileSync(planPath, "utf-8") : "";
|
|
const instructions = existsSync(instructionsPath)
|
|
? readFileSync(instructionsPath, "utf-8")
|
|
: "";
|
|
const questions = existsSync(questionsPath)
|
|
? readFileSync(questionsPath, "utf-8")
|
|
: "";
|
|
const planExists = plan.trim().length > 0;
|
|
const instructionsExist = instructions.trim().length > 0;
|
|
const approvedAnswer = questionsFileApproved(questions);
|
|
const embedded = planExists ? parseTestingContract(plan) : null;
|
|
const current = planExists ? resolveTestingPosture(projectDir) : null;
|
|
const contractHash = embedded?.contract_sha256 ?? null;
|
|
const contractValid =
|
|
embedded !== null &&
|
|
current !== null &&
|
|
embedded.contract_sha256 === current.contract_sha256;
|
|
const expectedFingerprint =
|
|
planExists && instructionsExist && contractValid && current
|
|
? approvalFingerprint(
|
|
plan,
|
|
instructions,
|
|
current.contract_sha256,
|
|
authority,
|
|
)
|
|
: null;
|
|
return {
|
|
plan,
|
|
instructions,
|
|
questions,
|
|
planExists,
|
|
instructionsExist,
|
|
approvedAnswer,
|
|
contractValid,
|
|
contractHash,
|
|
expectedFingerprint,
|
|
recordedFingerprint: questionsFileApprovalFingerprint(questions),
|
|
questionsPath,
|
|
};
|
|
}
|
|
|
|
export interface LegacyPlanApprovalGuardState {
|
|
active: boolean;
|
|
approved: boolean;
|
|
pending: boolean;
|
|
humanAfterDecision: boolean;
|
|
sourceFloorValid: boolean;
|
|
violated?: boolean;
|
|
target: CodeGenerationTarget | null;
|
|
}
|
|
|
|
/**
|
|
* Legacy Kiro IDE PreToolUse payloads identify the tool but omit its arguments.
|
|
* The adapter therefore cannot distinguish a planning-record write from a
|
|
* workspace mutation. This state lets it preserve the usable workflow:
|
|
* planning remains available before the exact Plan Approval prompt, every tool
|
|
* hard-stops while that prompt awaits a human, and workspace source is checked
|
|
* against the `[Planned Source]` the questions file records, exactly as the
|
|
* answer path checks it. Before a planned source is recorded there is nothing
|
|
* to compare; the adapter records the live source when it mediates the
|
|
* decision. After one is recorded, drift is refused with a remedy the conductor
|
|
* can always execute (re-present the plan), never with "revert the workspace".
|
|
*/
|
|
export function legacyPlanApprovalGuardState(
|
|
projectDir: string,
|
|
): LegacyPlanApprovalGuardState {
|
|
const inactive: LegacyPlanApprovalGuardState = {
|
|
active: false,
|
|
approved: false,
|
|
pending: false,
|
|
humanAfterDecision: false,
|
|
sourceFloorValid: true,
|
|
violated: false,
|
|
target: null,
|
|
};
|
|
try {
|
|
const statePath = stateFilePath(projectDir);
|
|
if (!existsSync(statePath)) return inactive;
|
|
const state = readFileSync(statePath, "utf-8");
|
|
const marker = readActiveDirectiveMarker(projectDir, state);
|
|
if (
|
|
marker?.version !== 2 ||
|
|
marker.stage !== "code-generation" ||
|
|
(marker.kind !== "run-stage" && marker.kind !== "invoke-swarm")
|
|
) {
|
|
return inactive;
|
|
}
|
|
let target: CodeGenerationTarget;
|
|
if (marker.kind === "run-stage") {
|
|
target = { unit: marker.unit?.trim() || null };
|
|
} else {
|
|
const units = marker.units ?? [];
|
|
if (units.length === 0) {
|
|
throw new Error("active swarm directive carries no authoritative units");
|
|
}
|
|
const pending = units.find(
|
|
(unit) => !evaluateCodeGenerationApproval(projectDir, { unit }).ok,
|
|
);
|
|
target = { unit: pending ?? units[0] };
|
|
}
|
|
const authority = resolveCodeGenerationAuthority(projectDir, target);
|
|
const violation = readPlanApprovalViolation(projectDir);
|
|
const violated =
|
|
violation?.version === 1 &&
|
|
violation.markerRevision === authority.markerRevision;
|
|
const approval = evaluateCodeGenerationApproval(projectDir, target);
|
|
const artifacts = codeGenerationApprovalArtifacts(projectDir, authority);
|
|
const plannedSource = questionsFilePlannedSource(artifacts.questions);
|
|
const sourceFloorValid =
|
|
plannedSource === null ||
|
|
plannedSource === UNBINDABLE_FINGERPRINT ||
|
|
workspaceSourceFingerprint(projectDir) === plannedSource;
|
|
if (approval.ok) {
|
|
return {
|
|
active: true,
|
|
approved: true,
|
|
pending: false,
|
|
humanAfterDecision: false,
|
|
sourceFloorValid: true,
|
|
violated,
|
|
target,
|
|
};
|
|
}
|
|
|
|
if (artifacts.expectedFingerprint === null) {
|
|
return {
|
|
active: true,
|
|
approved: false,
|
|
pending: false,
|
|
humanAfterDecision: false,
|
|
sourceFloorValid,
|
|
violated,
|
|
target,
|
|
};
|
|
}
|
|
const promptSha256 = createHash("sha256")
|
|
.update(
|
|
`${artifacts.questions
|
|
.replace(/^\[Answer\]:[ \t]*.*$/gm, "[Answer]:")
|
|
.trimEnd()}\n`,
|
|
"utf-8",
|
|
)
|
|
.digest("hex");
|
|
const allEntries = readAuditShardEvents(projectDir);
|
|
type Entry = (typeof allEntries)[number];
|
|
const latestCausal = (candidates: Entry[]): Entry | null => {
|
|
if (candidates.length === 0) return null;
|
|
let latestTimestamp = candidates[0].timestamp;
|
|
for (const candidate of candidates) {
|
|
if (candidate.timestamp > latestTimestamp) latestTimestamp = candidate.timestamp;
|
|
}
|
|
const atLatestTimestamp = candidates.filter(
|
|
(candidate) => candidate.timestamp === latestTimestamp,
|
|
);
|
|
if (new Set(atLatestTimestamp.map((candidate) => candidate.shard)).size !== 1) {
|
|
return null;
|
|
}
|
|
return atLatestTimestamp.reduce((latest, candidate) =>
|
|
candidate.pos > latest.pos ? candidate : latest
|
|
);
|
|
};
|
|
const latestSession = latestCausal(
|
|
allEntries.filter(
|
|
(entry) =>
|
|
entry.event === "SESSION_STARTED" ||
|
|
entry.event === "SESSION_RESUMED",
|
|
),
|
|
);
|
|
const session = latestSession === null
|
|
? null
|
|
: auditBlockField(latestSession.block, "Session");
|
|
const challenge =
|
|
session === null ? null : readPlanApprovalChallenge(projectDir, session);
|
|
const response =
|
|
session === null ? null : readPlanApprovalResponse(projectDir, session);
|
|
const challengeMatches =
|
|
challenge !== null &&
|
|
runtimeIdentityMatches(challenge, {
|
|
targetId: authority.targetId,
|
|
intentId: authority.intentId,
|
|
runFloor: authority.runFloor,
|
|
fingerprint: artifacts.expectedFingerprint,
|
|
questionsFile: toPosix(relative(projectDir, artifacts.questionsPath)),
|
|
promptSha256,
|
|
});
|
|
const humanAfterDecision =
|
|
challengeMatches &&
|
|
response !== null &&
|
|
response.challengeId === challenge.challengeId;
|
|
return {
|
|
active: true,
|
|
approved: false,
|
|
pending: challengeMatches && !humanAfterDecision,
|
|
humanAfterDecision,
|
|
sourceFloorValid,
|
|
violated,
|
|
target,
|
|
};
|
|
} catch {
|
|
return {
|
|
active: true,
|
|
approved: false,
|
|
pending: false,
|
|
humanAfterDecision: false,
|
|
sourceFloorValid: false,
|
|
violated: true,
|
|
target: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
function runtimeIdentity(
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
): PlanApprovalRuntimeIdentity {
|
|
return {
|
|
targetId: evidence.authority.targetId,
|
|
intentId: evidence.authority.intentId,
|
|
runFloor: evidence.authority.runFloor,
|
|
fingerprint: evidence.fingerprint,
|
|
questionsFile: evidence.questionsRelativePath,
|
|
promptSha256: evidence.promptSha256,
|
|
};
|
|
}
|
|
|
|
// Recorded, never compared. The directive epoch and marker revision describe the
|
|
// directive that happened to be issued when the human answered; the legacy Kiro
|
|
// IDE window handshake still reads the revision off a challenge, and both help a
|
|
// human reading the store understand where a receipt came from.
|
|
function runtimeProvenance(
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
): PlanApprovalRuntimeProvenance {
|
|
return {
|
|
directiveEpoch: evidence.authority.directiveEpoch,
|
|
sourceFloor: evidence.authority.sourceFloor,
|
|
markerRevision: evidence.authority.markerRevision,
|
|
plannedSourceSha256: evidence.plannedSourceSha256,
|
|
};
|
|
}
|
|
|
|
function runtimeIdentityMatches(
|
|
value: PlanApprovalRuntimeIdentity,
|
|
expected: PlanApprovalRuntimeIdentity,
|
|
): boolean {
|
|
return (
|
|
value.targetId === expected.targetId &&
|
|
value.intentId === expected.intentId &&
|
|
value.runFloor === expected.runFloor &&
|
|
value.fingerprint === expected.fingerprint &&
|
|
value.questionsFile === expected.questionsFile &&
|
|
value.promptSha256 === expected.promptSha256
|
|
);
|
|
}
|
|
|
|
export function recordPlanApprovalChallenge(
|
|
projectDir: string,
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
session: string,
|
|
options: [string, string] = ["Approve Plan", "Request Changes"],
|
|
requireExactOptionLabels = false,
|
|
hashOptionLabels = false,
|
|
useLegacyDirectiveOffer = false,
|
|
): PlanApprovalRuntimeChallenge {
|
|
if (!session.trim()) {
|
|
throw new Error("Plan Approval challenge requires a nonblank session");
|
|
}
|
|
const identity = runtimeIdentity(evidence);
|
|
const provenance = runtimeProvenance(evidence);
|
|
if (
|
|
(hashOptionLabels || useLegacyDirectiveOffer) &&
|
|
readPlanApprovalChallenge(projectDir, session)
|
|
) {
|
|
throw new Error(
|
|
"a protected legacy Plan Approval challenge is already pending for this session",
|
|
);
|
|
}
|
|
const createChallenge = (): PlanApprovalRuntimeChallenge => {
|
|
const offer = useLegacyDirectiveOffer
|
|
? readPlanApprovalLegacyOffer(projectDir, session)
|
|
: null;
|
|
if (
|
|
useLegacyDirectiveOffer &&
|
|
(
|
|
!offer ||
|
|
offer.intentId !== identity.intentId ||
|
|
offer.markerRevision !== provenance.markerRevision ||
|
|
!offer.allowedUnits.some((unit) => unit === evidence.authority.unit)
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"legacy Plan Approval requires protected choices from the invoking Code Generation directive",
|
|
);
|
|
}
|
|
const effectiveHashedOptions = hashOptionLabels || useLegacyDirectiveOffer;
|
|
const storedOptions: [string, string] = offer
|
|
? offer.options
|
|
: hashOptionLabels
|
|
? options.map((option) =>
|
|
createHash("sha256")
|
|
.update(option.trim().toLowerCase(), "utf-8")
|
|
.digest("hex")
|
|
) as [string, string]
|
|
: options;
|
|
const challenge: PlanApprovalRuntimeChallenge = {
|
|
version: 1,
|
|
...identity,
|
|
...provenance,
|
|
session,
|
|
// The challenge id covers the compared identity, the session, and the exact
|
|
// options offered. Provenance is deliberately outside it: a challenge that
|
|
// rotated with every directive re-issue is the churn this change removes.
|
|
challengeId: hashObject({
|
|
...identity,
|
|
session,
|
|
options: storedOptions,
|
|
requireExactOptionLabels,
|
|
hashedOptionLabels: effectiveHashedOptions,
|
|
legacyDirectiveOffer: useLegacyDirectiveOffer,
|
|
}),
|
|
options: storedOptions,
|
|
requireExactOptionLabels,
|
|
hashedOptionLabels: effectiveHashedOptions,
|
|
};
|
|
writePlanApprovalChallenge(projectDir, challenge);
|
|
if (useLegacyDirectiveOffer) {
|
|
clearPlanApprovalLegacyOffer(projectDir, session);
|
|
}
|
|
return challenge;
|
|
};
|
|
return useLegacyDirectiveOffer
|
|
? withActiveDirectiveLock(projectDir, createChallenge)
|
|
: createChallenge();
|
|
}
|
|
|
|
function offeredPlanApprovalChoice(
|
|
challenge: PlanApprovalRuntimeChallenge,
|
|
responseText: string,
|
|
): "Approve Plan" | "Request Changes" | null {
|
|
// One trailing "(Recommended)" is the Codex label decoration, not part of the
|
|
// human's choice. Nothing else about the match is loosened.
|
|
const response = stripRecommendedDecorator(responseText);
|
|
const comparison = challenge.hashedOptionLabels
|
|
? createHash("sha256")
|
|
.update(response.toLowerCase(), "utf-8")
|
|
.digest("hex")
|
|
: response.toLowerCase();
|
|
const matchedIndex = challenge.options.findIndex((option) =>
|
|
challenge.hashedOptionLabels
|
|
? option === comparison
|
|
: option.toLowerCase() === comparison
|
|
);
|
|
if (matchedIndex >= 0) {
|
|
return matchedIndex === 0 ? "Approve Plan" : "Request Changes";
|
|
}
|
|
if (challenge.requireExactOptionLabels) return null;
|
|
if (response === "1") return "Approve Plan";
|
|
if (response === "2") return "Request Changes";
|
|
if (response.toLowerCase() === "approve plan") return "Approve Plan";
|
|
if (response.toLowerCase() === "request changes") return "Request Changes";
|
|
return null;
|
|
}
|
|
|
|
export interface PlanApprovalHumanResponseResult {
|
|
recorded: boolean;
|
|
}
|
|
|
|
export function recordPlanApprovalHumanResponse(
|
|
projectDir: string,
|
|
session: string,
|
|
responseText: string,
|
|
): PlanApprovalHumanResponseResult {
|
|
const challenge = readPlanApprovalChallenge(projectDir, session);
|
|
if (challenge) {
|
|
const choice = offeredPlanApprovalChoice(challenge, responseText);
|
|
if (choice) {
|
|
writePlanApprovalResponse(projectDir, {
|
|
version: 1,
|
|
session,
|
|
challengeId: challenge.challengeId,
|
|
choice,
|
|
responseSha256: createHash("sha256")
|
|
.update(responseText.trim(), "utf-8")
|
|
.digest("hex"),
|
|
});
|
|
return { recorded: true };
|
|
}
|
|
}
|
|
const recovery = readPlanApprovalLegacyRecoveryChallenge(
|
|
projectDir,
|
|
session,
|
|
);
|
|
if (
|
|
recovery &&
|
|
responseText.trim() === LEGACY_PLAN_APPROVAL_RECOVERY_CHOICE
|
|
) {
|
|
writePlanApprovalLegacyRecoveryResponse(projectDir, {
|
|
version: 1,
|
|
session,
|
|
challengeId: recovery.challengeId,
|
|
responseSha256: createHash("sha256")
|
|
.update(LEGACY_PLAN_APPROVAL_RECOVERY_CHOICE, "utf-8")
|
|
.digest("hex"),
|
|
});
|
|
return { recorded: true };
|
|
}
|
|
return { recorded: false };
|
|
}
|
|
|
|
export interface PlanApprovalOverrideRequestResult {
|
|
recorded: boolean;
|
|
}
|
|
|
|
/**
|
|
* Half A of the break-glass pairing. Called by the human-turn hook ONLY for a
|
|
* typed prompt (the UserPromptSubmit text), never for a picked option arriving
|
|
* through a tool response. The whole trimmed prompt must be the single line
|
|
* `Override Plan Approval: <reason>`; the reason is kept verbatim (trimmed) and
|
|
* its sha256 is what `answer --override` must match for the same session.
|
|
*/
|
|
export function recordPlanApprovalOverrideRequest(
|
|
projectDir: string,
|
|
session: string,
|
|
promptText: string,
|
|
): PlanApprovalOverrideRequestResult {
|
|
if (!session.trim()) return { recorded: false };
|
|
const match = PLAN_APPROVAL_OVERRIDE_PHRASE_RE.exec(promptText.trim());
|
|
if (!match) return { recorded: false };
|
|
const reason = match[1].trim();
|
|
if (!reason) return { recorded: false };
|
|
const request: PlanApprovalOverrideRequest = {
|
|
version: 1,
|
|
session,
|
|
reason,
|
|
reasonSha256: planApprovalOverrideReasonSha256(reason),
|
|
requestedAt: isoTimestamp(),
|
|
intentId: activeIntentUuid(projectDir) ?? "bare-space",
|
|
};
|
|
writePlanApprovalOverrideRequest(projectDir, request);
|
|
return { recorded: true };
|
|
}
|
|
|
|
export function planApprovalOverrideReasonSha256(reason: string): string {
|
|
return createHash("sha256").update(reason.trim(), "utf-8").digest("hex");
|
|
}
|
|
|
|
export interface PlanApprovalReceiptResult {
|
|
receipt: PlanApprovalRuntimeReceipt | null;
|
|
/** Human lines for source drift accepted under `relaxed` while certifying. */
|
|
changeNotices: string[];
|
|
}
|
|
|
|
export function recordPlanApprovalReceipt(
|
|
projectDir: string,
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
session: string,
|
|
choice: "Approve Plan" | "Request Changes",
|
|
): PlanApprovalReceiptResult {
|
|
return withActiveDirectiveLock(projectDir, () =>
|
|
certifyPlanApprovalReceipt(projectDir, evidence, session, choice),
|
|
);
|
|
}
|
|
|
|
// The receipt path proper. Caller holds the active-directive lock (it is not
|
|
// reentrant), so the break-glass path can run this first and its own write
|
|
// second inside one transaction.
|
|
function certifyPlanApprovalReceipt(
|
|
projectDir: string,
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
session: string,
|
|
choice: "Approve Plan" | "Request Changes",
|
|
): PlanApprovalReceiptResult {
|
|
const identity = runtimeIdentity(evidence);
|
|
const provenance = runtimeProvenance(evidence);
|
|
const challenge = readPlanApprovalChallenge(projectDir, session);
|
|
const response = readPlanApprovalResponse(projectDir, session);
|
|
if (
|
|
!challenge ||
|
|
!response ||
|
|
challenge.challengeId !== response.challengeId ||
|
|
response.choice !== choice ||
|
|
!runtimeIdentityMatches(challenge, identity)
|
|
) {
|
|
throw new Error(
|
|
"Plan Approval requires the actual offered choice from this prompt and session",
|
|
);
|
|
}
|
|
const receiptBarrier =
|
|
process.env.AIDLC_TEST_PLAN_APPROVAL_RECEIPT_BARRIER?.trim();
|
|
if (receiptBarrier) {
|
|
writeFileSync(`${receiptBarrier}.snapshotted`, "snapshotted\n", "utf-8");
|
|
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
const deadline = Date.now() + 30_000;
|
|
while (!existsSync(`${receiptBarrier}.release`)) {
|
|
if (Date.now() >= deadline) {
|
|
throw new Error("timed out waiting at Plan Approval receipt barrier");
|
|
}
|
|
Atomics.wait(waitCell, 0, 0, 10);
|
|
}
|
|
}
|
|
if (choice === "Request Changes") {
|
|
// Requesting changes withdraws the decision, so it clears BOTH halves: the
|
|
// challenge AND any receipt for this exact identity. Without the second
|
|
// clear, identical content could be re-approved by rewriting the answer tag,
|
|
// because nothing else about the identity had moved. A typed break-glass
|
|
// request is withdrawn with it.
|
|
clearPlanApprovalChallenge(projectDir, session);
|
|
clearPlanApprovalReceipt(projectDir, identity);
|
|
clearPlanApprovalOverrideRequest(projectDir, session);
|
|
return { receipt: null, changeNotices: [] };
|
|
}
|
|
// Certify the source twice, then write. The answer path never unlinks a
|
|
// receipt it just wrote: a mutation that lands between the two reads is
|
|
// refused before anything exists on disk, and one that lands after the
|
|
// second read is caught by generation start, which keeps the receipt and
|
|
// asks for re-approval. Source that moved since the plan was fingerprinted
|
|
// is the governed drift: strict refuses, relaxed records the change and
|
|
// certifies the source found now, which every later check compares against.
|
|
const stateBefore = workspaceSourceState(projectDir);
|
|
const sourceBefore = stateBefore?.fingerprint ?? null;
|
|
if (sourceBefore === null) {
|
|
throw new PlanApprovalUnbindableError("recorded");
|
|
}
|
|
const changeNotices: string[] = [];
|
|
if (sourceBefore !== evidence.plannedSourceSha256) {
|
|
const judged = judgePlanSourceDrift(
|
|
projectDir,
|
|
evidence.authority.unit,
|
|
evidence.plannedSourceSha256,
|
|
stateBefore,
|
|
true,
|
|
);
|
|
if ("refusal" in judged) throw judged.refusal;
|
|
changeNotices.push(...recordAcceptedChanges(projectDir, [judged.accepted]));
|
|
}
|
|
const sourceAfter = workspaceSourceFingerprint(projectDir);
|
|
if (sourceAfter === null || sourceAfter !== sourceBefore) {
|
|
throw new Error(
|
|
"Plan Approval source changed during receipt certification. " +
|
|
"Re-run the fingerprint command and re-present the plan.",
|
|
);
|
|
}
|
|
const receipt: PlanApprovalRuntimeReceipt = {
|
|
version: 1,
|
|
...identity,
|
|
...provenance,
|
|
session,
|
|
challengeId: challenge.challengeId,
|
|
choice: "Approve Plan",
|
|
questionsSha256: evidence.questionsSha256,
|
|
certifiedSourceSha256: sourceBefore,
|
|
status: "approved",
|
|
};
|
|
writePlanApprovalReceipt(projectDir, receipt);
|
|
keepWorkspaceSourceSnapshot(projectDir, stateBefore);
|
|
clearPlanApprovalChallenge(projectDir, session);
|
|
// A normal receipt spends any typed break-glass request too: the phrase
|
|
// authorized at most one run, and that run needed no override.
|
|
clearPlanApprovalOverrideRequest(projectDir, session);
|
|
// Sweep this target's receipts from attempts that have ended. Nothing deletes a
|
|
// receipt to invalidate it any more, so the store is tidied here instead.
|
|
collectStalePlanApprovalReceipts(
|
|
projectDir,
|
|
identity.intentId,
|
|
identity.targetId,
|
|
identity.runFloor,
|
|
);
|
|
return { receipt, changeNotices };
|
|
}
|
|
|
|
export interface PlanApprovalOverrideReceiptResult {
|
|
receipt: PlanApprovalRuntimeReceipt;
|
|
/** False when the normal path succeeded and no override was written. */
|
|
overridden: boolean;
|
|
failedChecks: string[];
|
|
changeNotices: string[];
|
|
}
|
|
|
|
export class PlanApprovalOverrideHumanOnlyError extends Error {
|
|
constructor() {
|
|
super(PLAN_APPROVAL_OVERRIDE_HUMAN_ONLY);
|
|
this.name = "PlanApprovalOverrideHumanOnlyError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The typed request that authorizes `answer --override` for this session and
|
|
* reason, or null. Half A must have written it (the human typed the phrase),
|
|
* its digest must be the digest of the reason given now, its stored reason must
|
|
* hash to its own stored digest (an edited file is not a request), and, once
|
|
* the evidence names the intent, it must have been typed under that intent.
|
|
*/
|
|
export function authorizingPlanApprovalOverrideRequest(
|
|
projectDir: string,
|
|
session: string,
|
|
reason: string,
|
|
intentId: string | null,
|
|
): PlanApprovalOverrideRequest | null {
|
|
const request = readPlanApprovalOverrideRequest(projectDir, session);
|
|
if (request === null) return null;
|
|
const expected = planApprovalOverrideReasonSha256(reason);
|
|
if (request.reasonSha256 !== expected) return null;
|
|
if (planApprovalOverrideReasonSha256(request.reason) !== expected) return null;
|
|
if (intentId !== null && request.intentId !== intentId) return null;
|
|
return request;
|
|
}
|
|
|
|
/**
|
|
* Half B of the break-glass pairing. The normal receipt path runs first; when
|
|
* it succeeds there was nothing to override and its receipt stands. When it
|
|
* refuses, its refusal (plus the source-boundary state when the workspace
|
|
* cannot be bound) is kept as the failed checks, the typed request is
|
|
* re-validated and consumed inside this same transaction (a request that
|
|
* vanished or changed since the caller's precheck is not a request), then
|
|
* `recordOverride` is called so the ledger row lands FIRST, and only then is a
|
|
* receipt bound to content and attempt only written: no challenge/response
|
|
* pairing and no source certification. A row that cannot be appended leaves no
|
|
* receipt and spends no request, so the human's phrase is still there for the
|
|
* retry. The certified source is whatever binds now, or `unbindable`;
|
|
* downstream checks skip the source comparison for an override receipt.
|
|
*/
|
|
export function recordPlanApprovalOverrideReceipt(
|
|
projectDir: string,
|
|
evidence: PlanApprovalQuestionEvidence,
|
|
session: string,
|
|
reason: string,
|
|
recordOverride: (failedChecks: readonly string[]) => void,
|
|
): PlanApprovalOverrideReceiptResult {
|
|
return withActiveDirectiveLock(projectDir, () => {
|
|
if (
|
|
authorizingPlanApprovalOverrideRequest(
|
|
projectDir,
|
|
session,
|
|
reason,
|
|
evidence.authority.intentId,
|
|
) === null
|
|
) {
|
|
throw new PlanApprovalOverrideHumanOnlyError();
|
|
}
|
|
const failedChecks: string[] = [];
|
|
try {
|
|
const normal = certifyPlanApprovalReceipt(projectDir, evidence, session, "Approve Plan");
|
|
if (normal.receipt !== null) {
|
|
return {
|
|
receipt: normal.receipt,
|
|
overridden: false,
|
|
failedChecks,
|
|
changeNotices: normal.changeNotices,
|
|
};
|
|
}
|
|
failedChecks.push("the normal receipt path recorded nothing");
|
|
} catch (error) {
|
|
failedChecks.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
const current = workspaceSourceState(projectDir);
|
|
if (current === null) {
|
|
failedChecks.push(`workspace source cannot be bound${workspaceSourceFailureSuffix()}`);
|
|
}
|
|
const identity = runtimeIdentity(evidence);
|
|
const challenge = readPlanApprovalChallenge(projectDir, session);
|
|
const receipt: PlanApprovalRuntimeReceipt = {
|
|
version: 1,
|
|
...identity,
|
|
...runtimeProvenance(evidence),
|
|
session,
|
|
challengeId: challenge?.challengeId ?? "",
|
|
choice: "Approve Plan",
|
|
questionsSha256: evidence.questionsSha256,
|
|
certifiedSourceSha256: current?.fingerprint ?? UNBINDABLE_FINGERPRINT,
|
|
status: "approved",
|
|
override: { reason, failedChecks: [...failedChecks] },
|
|
};
|
|
recordOverride(failedChecks);
|
|
writePlanApprovalReceipt(projectDir, receipt);
|
|
keepWorkspaceSourceSnapshot(projectDir, current);
|
|
clearPlanApprovalChallenge(projectDir, session);
|
|
clearPlanApprovalOverrideRequest(projectDir, session);
|
|
collectStalePlanApprovalReceipts(
|
|
projectDir,
|
|
identity.intentId,
|
|
identity.targetId,
|
|
identity.runFloor,
|
|
);
|
|
return { receipt, overridden: true, failedChecks, changeNotices: [] };
|
|
});
|
|
}
|
|
|
|
export interface PlanApprovalEvidenceOptions {
|
|
/**
|
|
* Break-glass evidence binds to content and attempt only: the `[Planned
|
|
* Source]` tag is read for provenance but never compared, so an unbindable or
|
|
* moved workspace does not refuse the override. Every content check (plan,
|
|
* instructions, Testing Contract, fingerprint, `[Answer]`) still applies.
|
|
*/
|
|
breakGlass?: boolean;
|
|
}
|
|
|
|
export function codeGenerationPlanApprovalQuestionEvidence(
|
|
projectDir: string,
|
|
target: CodeGenerationTarget,
|
|
suppliedQuestionsFile: string,
|
|
expectedAnswer: "" | "Approve Plan" | "Request Changes",
|
|
options: PlanApprovalEvidenceOptions = {},
|
|
): PlanApprovalQuestionEvidence {
|
|
const authority = resolveCodeGenerationAuthority(projectDir, target);
|
|
const expectedPath = resolve(
|
|
authority.stageDir,
|
|
"code-generation-questions.md",
|
|
);
|
|
const suppliedPath = isAbsolute(suppliedQuestionsFile)
|
|
? resolve(suppliedQuestionsFile)
|
|
: resolve(projectDir, suppliedQuestionsFile);
|
|
if (suppliedPath !== expectedPath) {
|
|
throw new Error(
|
|
`Plan Approval questions file must be the active target's canonical file: ${toPosix(relative(projectDir, expectedPath))}`,
|
|
);
|
|
}
|
|
const artifacts = codeGenerationApprovalArtifacts(projectDir, authority);
|
|
if (!artifacts.planExists || !artifacts.instructionsExist) {
|
|
throw new Error("Plan Approval requires non-empty plan and unit-test instructions");
|
|
}
|
|
if (!artifacts.contractValid || artifacts.expectedFingerprint === null) {
|
|
throw new Error("Plan Approval requires the current Testing Contract");
|
|
}
|
|
if (artifacts.recordedFingerprint !== artifacts.expectedFingerprint) {
|
|
throw new Error(
|
|
artifacts.recordedFingerprint !== null &&
|
|
!approvalFingerprintIsCurrentFormat(artifacts.recordedFingerprint)
|
|
? "The recorded Plan Approval fingerprint was written under an earlier format. " +
|
|
"Re-run the fingerprint command, re-present the plan, and approve again."
|
|
: "Plan Approval fingerprint does not match the active intent, target, stage attempt, plan, instructions, and Testing Contract. " +
|
|
"Re-run the fingerprint command, re-present the plan, and approve again.",
|
|
);
|
|
}
|
|
const latest = latestPlanApproval(artifacts.questions);
|
|
if (!latest.found || latest.answer === null || latest.answer !== expectedAnswer) {
|
|
throw new Error(
|
|
`Plan Approval questions file must contain exactly [Answer]: ${expectedAnswer || "(blank)"}`,
|
|
);
|
|
}
|
|
// The source the plan was written against, recorded by the fingerprint command.
|
|
// The approval binds to THIS value rather than to the directive's sticky floor,
|
|
// so drift is always answerable by re-fingerprinting and re-presenting; the
|
|
// sticky floor could only be rotated by a receipt that required the floor to
|
|
// match already, which is the loop that made an out-of-band `git pull` permanent.
|
|
const plannedSource = latest.plannedSource ?? UNBINDABLE_FINGERPRINT;
|
|
if (latest.plannedSource === null && !options.breakGlass) {
|
|
throw new Error(
|
|
"Plan Approval requires a [Planned Source]: tag in the Plan Approval section. " +
|
|
"Re-run the fingerprint command, record both tags it prints, and re-present the plan.",
|
|
);
|
|
}
|
|
// A workspace that cannot be bound now is refused before anything is minted:
|
|
// the challenge such a decision would create can never be accepted by the
|
|
// normal receipt path, so the human would approve into a dead end. A planned
|
|
// source recorded as `unbindable` while the workspace binds now is treated
|
|
// as drift from that recording: strict asks for a re-fingerprint (which now
|
|
// records a real source), relaxed re-baselines the tag before the challenge.
|
|
const currentState = options.breakGlass ? null : workspaceSourceState(projectDir);
|
|
const currentSource = currentState?.fingerprint ?? null;
|
|
if (!options.breakGlass && currentSource === null) {
|
|
throw new PlanApprovalUnbindableError(expectedAnswer === "" ? "presented" : "recorded");
|
|
}
|
|
let questions = artifacts.questions;
|
|
let boundSource = plannedSource;
|
|
const changeNotices: string[] = [];
|
|
if (!options.breakGlass && currentSource !== plannedSource) {
|
|
const judged = judgePlanSourceDrift(projectDir, authority.unit, plannedSource, currentState, true);
|
|
if ("refusal" in judged) throw judged.refusal;
|
|
// The row is written BEFORE anything is re-baselined: a ledger that cannot
|
|
// take it refuses here, with the drift still visible to the next attempt.
|
|
changeNotices.push(...recordAcceptedChanges(projectDir, [judged.accepted]));
|
|
// Before the challenge is minted (the decision record) the questions file
|
|
// is still the conductor's draft, so the tag itself is re-baselined and the
|
|
// human sees the plan against the source it will be approved on. At the
|
|
// answer the prompt hash already binds these bytes; the receipt certifies
|
|
// the current source instead, and that certified value is the baseline
|
|
// every later check compares against.
|
|
if (expectedAnswer === "" && currentSource !== null) {
|
|
questions = upsertPlannedSourceTag(questions, currentSource);
|
|
writeFileSync(suppliedPath, questions, "utf-8");
|
|
keepWorkspaceSourceSnapshot(projectDir, currentState);
|
|
boundSource = currentSource;
|
|
}
|
|
}
|
|
return {
|
|
authority,
|
|
fingerprint: artifacts.expectedFingerprint,
|
|
questionsPath: suppliedPath,
|
|
questionsRelativePath: toPosix(relative(projectDir, suppliedPath)),
|
|
questionsSha256: createHash("sha256")
|
|
.update(questions, "utf-8")
|
|
.digest("hex"),
|
|
promptSha256: createHash("sha256")
|
|
.update(
|
|
`${questions
|
|
.replace(/^\[Answer\]:[ \t]*.*$/gm, "[Answer]:")
|
|
.trimEnd()}\n`,
|
|
"utf-8",
|
|
)
|
|
.digest("hex"),
|
|
plannedSourceSha256: boundSource,
|
|
changeNotices,
|
|
};
|
|
}
|
|
|
|
export function evaluateCodeGenerationApproval(
|
|
projectDir: string,
|
|
target: CodeGenerationTarget,
|
|
): CodeGenerationApproval {
|
|
let normalizedUnit: string | null = null;
|
|
const empty: CodeGenerationApproval = {
|
|
ok: false,
|
|
unit: null,
|
|
reason: "",
|
|
planExists: false,
|
|
instructionsExist: false,
|
|
approved: false,
|
|
contractValid: false,
|
|
fingerprintValid: false,
|
|
receiptValid: false,
|
|
contractHash: null,
|
|
approvalFingerprint: null,
|
|
directiveEpoch: null,
|
|
};
|
|
try {
|
|
const normalizedTarget = normalizeCodeGenerationTarget(target);
|
|
normalizedUnit = normalizedTarget.unit;
|
|
empty.unit = normalizedUnit;
|
|
const authority = resolveCodeGenerationAuthority(projectDir, normalizedTarget);
|
|
empty.directiveEpoch = authority.directiveEpoch;
|
|
const artifacts = codeGenerationApprovalArtifacts(projectDir, authority);
|
|
empty.planExists = artifacts.planExists;
|
|
empty.instructionsExist = artifacts.instructionsExist;
|
|
empty.approved = artifacts.approvedAnswer;
|
|
empty.contractValid = artifacts.contractValid;
|
|
empty.contractHash = artifacts.contractHash;
|
|
empty.approvalFingerprint = artifacts.expectedFingerprint;
|
|
if (!empty.planExists) {
|
|
empty.reason = "code-generation-plan.md is missing or empty";
|
|
return empty;
|
|
}
|
|
if (!empty.instructionsExist) {
|
|
empty.reason = "unit-test-instructions.md is missing or empty";
|
|
return empty;
|
|
}
|
|
if (artifacts.contractHash === null) {
|
|
empty.reason = "code-generation-plan.md has no valid ## Testing Contract JSON block";
|
|
return empty;
|
|
}
|
|
if (!empty.contractValid) {
|
|
empty.reason =
|
|
"the approved Testing Contract is stale because memory, scope, test strategy, or project type changed";
|
|
return empty;
|
|
}
|
|
if (!empty.approved) {
|
|
empty.reason = "Plan Approval is not explicitly answered Approve Plan";
|
|
return empty;
|
|
}
|
|
empty.fingerprintValid =
|
|
artifacts.expectedFingerprint !== null &&
|
|
artifacts.recordedFingerprint === artifacts.expectedFingerprint;
|
|
if (!empty.fingerprintValid) {
|
|
empty.reason =
|
|
artifacts.recordedFingerprint !== null &&
|
|
!approvalFingerprintIsCurrentFormat(artifacts.recordedFingerprint)
|
|
? "the recorded Plan Approval fingerprint was written under an earlier format; re-run the fingerprint command, re-present the plan, and approve again"
|
|
: "the Plan Approval fingerprint does not match the active intent, target, stage attempt, plan, test instructions, and Testing Contract; re-run the fingerprint command, re-present the plan, and approve again";
|
|
return empty;
|
|
}
|
|
// The raw questions-file digest is provenance on the audit row, not part of
|
|
// validity: the prompt hash below binds what the human saw, and a note
|
|
// appended to the file after approval must not retire the decision.
|
|
const promptSha256 = createHash("sha256")
|
|
.update(
|
|
`${artifacts.questions
|
|
.replace(/^\[Answer\]:[ \t]*.*$/gm, "[Answer]:")
|
|
.trimEnd()}\n`,
|
|
"utf-8",
|
|
)
|
|
.digest("hex");
|
|
const identity: PlanApprovalRuntimeIdentity = {
|
|
targetId: authority.targetId,
|
|
intentId: authority.intentId,
|
|
runFloor: authority.runFloor,
|
|
fingerprint: artifacts.expectedFingerprint!,
|
|
questionsFile: toPosix(relative(projectDir, artifacts.questionsPath)),
|
|
promptSha256,
|
|
};
|
|
const violation = readPlanApprovalViolation(projectDir);
|
|
if (
|
|
violation?.version === 1 &&
|
|
violation.markerRevision === authority.markerRevision
|
|
) {
|
|
empty.reason =
|
|
`legacy Plan Approval authority was poisoned by unsupported write target "${violation.target}"`;
|
|
return empty;
|
|
}
|
|
const receipt = readPlanApprovalReceipt(projectDir, identity);
|
|
// Source that moved after the receipt certified it is the governed drift:
|
|
// strict retires the approval until the human approves again; relaxed keeps
|
|
// it current (generation start records the change and re-baselines the
|
|
// receipt). This evaluation reads and never writes, so it only judges. A
|
|
// break-glass receipt is bound to content and attempt only, so its source
|
|
// is never compared: the human already accepted that the source could not
|
|
// be certified when they typed the override.
|
|
let sourceDrift: string | null = null;
|
|
if (
|
|
receipt !== null &&
|
|
receipt.status !== "generation" &&
|
|
receipt.override === undefined
|
|
) {
|
|
const current = workspaceSourceState(projectDir);
|
|
if (current === null || current.fingerprint !== receipt.certifiedSourceSha256) {
|
|
const judged = judgePlanSourceDrift(
|
|
projectDir,
|
|
normalizedUnit,
|
|
receipt.certifiedSourceSha256,
|
|
current,
|
|
false,
|
|
);
|
|
if ("refusal" in judged) sourceDrift = judged.refusal.message;
|
|
}
|
|
}
|
|
const sourceCurrent = receipt !== null && sourceDrift === null;
|
|
empty.receiptValid =
|
|
receipt !== null &&
|
|
runtimeIdentityMatches(receipt, identity) &&
|
|
receipt.choice === "Approve Plan" &&
|
|
sourceCurrent;
|
|
if (!empty.receiptValid) {
|
|
if (receipt !== null && sourceDrift !== null) {
|
|
empty.reason = sourceDrift;
|
|
empty.sourceDrift = true;
|
|
return empty;
|
|
}
|
|
// Distinguish "never approved" from "approved in an attempt that has since
|
|
// ended". The second is the case a redo jump or a rejected gate produces,
|
|
// and it has a different instruction.
|
|
const stale = stalePlanApprovalReceiptsForTarget(
|
|
projectDir,
|
|
authority.intentId,
|
|
authority.targetId,
|
|
authority.runFloor,
|
|
);
|
|
empty.reason = stale.length > 0
|
|
? "the Plan Approval receipt for this target belongs to an earlier stage attempt; present the plan again and approve it for the current attempt"
|
|
: "no current protected Plan Approval receipt matches this prompt, session response, target, stage attempt, and plan content";
|
|
return empty;
|
|
}
|
|
return {
|
|
...empty,
|
|
ok: true,
|
|
reason: "approved",
|
|
...(receipt?.override !== undefined ? { override: true as const } : {}),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
...empty,
|
|
unit: normalizedUnit,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function beginCodeGeneration(
|
|
projectDir: string,
|
|
target: CodeGenerationTarget,
|
|
): string[] {
|
|
return withAuditLock(projectDir, () =>
|
|
withActiveDirectiveLock(projectDir, () => {
|
|
const approval = evaluateCodeGenerationApproval(projectDir, target);
|
|
if (!approval.ok || !approval.approvalFingerprint) {
|
|
if (approval.sourceDrift) throw new PlanApprovalSourceDriftError(approval.reason);
|
|
throw new Error(approval.reason || "Code Generation requires Plan Approval");
|
|
}
|
|
const authority = resolveCodeGenerationAuthority(projectDir, target);
|
|
const receiptKey: PlanApprovalReceiptKey = {
|
|
targetId: authority.targetId,
|
|
runFloor: authority.runFloor,
|
|
fingerprint: approval.approvalFingerprint,
|
|
};
|
|
const receipt = readPlanApprovalReceipt(projectDir, receiptKey);
|
|
if (!receipt) {
|
|
throw new Error("Code Generation has no protected approval receipt");
|
|
}
|
|
if (receipt.status === "generation") return [];
|
|
if (receipt.override !== undefined) {
|
|
// A break-glass receipt is bound to content and attempt only. There is
|
|
// no certified source to compare or re-certify, and no race window to
|
|
// close, so the generation boundary is published as the receipt stands.
|
|
// This is the one place an override could have been downgraded to
|
|
// "approve again": it is not.
|
|
writePlanApprovalReceipt(projectDir, { ...receipt, status: "generation" });
|
|
collectStalePlanApprovalReceipts(
|
|
projectDir,
|
|
authority.intentId,
|
|
authority.targetId,
|
|
authority.runFloor,
|
|
);
|
|
return [];
|
|
}
|
|
const stateBefore = workspaceSourceState(projectDir);
|
|
const sourceBefore = stateBefore?.fingerprint ?? null;
|
|
if (sourceBefore === null) {
|
|
throw new PlanApprovalSourceDriftError(planSourceDriftStrictMessage(null, true));
|
|
}
|
|
const changeNotices: string[] = [];
|
|
if (sourceBefore !== receipt.certifiedSourceSha256) {
|
|
// Strict refuses and KEEPS the receipt: deleting the human's recorded
|
|
// decision because the workspace moved turned a recoverable drift into
|
|
// a state with no way back, and a fresh approval re-baselines the
|
|
// source this plan is bound to. Relaxed records the change and moves
|
|
// that baseline to the source found now, so generation begins and the
|
|
// same change is not reported again.
|
|
const judged = judgePlanSourceDrift(
|
|
projectDir,
|
|
authority.unit,
|
|
receipt.certifiedSourceSha256,
|
|
stateBefore,
|
|
true,
|
|
);
|
|
if ("refusal" in judged) throw judged.refusal;
|
|
changeNotices.push(...recordAcceptedChanges(projectDir, [judged.accepted]));
|
|
keepWorkspaceSourceSnapshot(projectDir, stateBefore);
|
|
}
|
|
// Publication is the generation boundary. It sits between two source
|
|
// fingerprints while both authority locks are held: neither another
|
|
// guard nor directive publication can retire this receipt mid-start.
|
|
writePlanApprovalReceipt(projectDir, {
|
|
...receipt,
|
|
certifiedSourceSha256: sourceBefore,
|
|
status: "generation",
|
|
});
|
|
const publicationBarrier =
|
|
process.env.AIDLC_TEST_PLAN_APPROVAL_PUBLICATION_BARRIER?.trim();
|
|
if (publicationBarrier) {
|
|
writeFileSync(`${publicationBarrier}.published`, "published\n", "utf-8");
|
|
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
const deadline = Date.now() + 30_000;
|
|
while (!existsSync(`${publicationBarrier}.release`)) {
|
|
if (Date.now() >= deadline) {
|
|
writePlanApprovalReceipt(projectDir, { ...receipt, status: "approved" });
|
|
throw new Error(
|
|
"timed out waiting for the Plan Approval publication test barrier",
|
|
);
|
|
}
|
|
Atomics.wait(waitCell, 0, 0, 5);
|
|
}
|
|
}
|
|
const sourceAfter = workspaceSourceFingerprint(projectDir);
|
|
if (sourceAfter === null || sourceAfter !== sourceBefore) {
|
|
// Revert the generation boundary rather than delete the approval: the
|
|
// human's decision is still a fact, only the start is not. This is the
|
|
// race window, not the governed drift, so both Change Control values
|
|
// ask for the step again.
|
|
writePlanApprovalReceipt(projectDir, { ...receipt, status: "approved" });
|
|
throw new Error(
|
|
"Source files changed while code generation was starting. Retry the step.",
|
|
);
|
|
}
|
|
collectStalePlanApprovalReceipts(
|
|
projectDir,
|
|
authority.intentId,
|
|
authority.targetId,
|
|
authority.runFloor,
|
|
);
|
|
return changeNotices;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function flagValue(args: string[], name: string): string | undefined {
|
|
const index = args.indexOf(name);
|
|
return index >= 0 ? args[index + 1] : undefined;
|
|
}
|
|
|
|
function targetFromArgs(
|
|
args: string[],
|
|
subcommand: "fingerprint" | "verify" | "begin" | "brief",
|
|
): CodeGenerationTarget {
|
|
const unitIndex = args.indexOf("--unit");
|
|
const stageLevel = args.includes("--stage-level");
|
|
if (unitIndex >= 0 && stageLevel) {
|
|
throw new Error(`${subcommand} accepts exactly one of --unit <unit> or --stage-level`);
|
|
}
|
|
if (unitIndex >= 0) {
|
|
const unit = args[unitIndex + 1];
|
|
if (!unit || unit.startsWith("--") || unit.trim().length === 0) {
|
|
throw new Error(`${subcommand} requires a non-blank --unit <unit>`);
|
|
}
|
|
return normalizeCodeGenerationTarget({ unit });
|
|
}
|
|
if (stageLevel) return { unit: null };
|
|
throw new Error(`${subcommand} requires exactly one of --unit <unit> or --stage-level`);
|
|
}
|
|
|
|
export function main(argv: string[]): void {
|
|
const subcommand = argv.find((arg) =>
|
|
["resolve", "render", "fingerprint", "verify", "begin", "brief"].includes(arg)
|
|
);
|
|
const projectDir = resolveProjectDir(flagValue(argv, "--project-dir"));
|
|
try {
|
|
switch (subcommand) {
|
|
case "resolve":
|
|
console.log(JSON.stringify(resolveTestingPosture(projectDir), null, 2));
|
|
return;
|
|
case "render":
|
|
process.stdout.write(renderTestingContract(resolveTestingPosture(projectDir)));
|
|
return;
|
|
case "fingerprint": {
|
|
const target = targetFromArgs(argv, "fingerprint");
|
|
const authority = resolveCodeGenerationAuthority(projectDir, target);
|
|
const approval = evaluateCodeGenerationApproval(projectDir, target);
|
|
const stageDir = authority.stageDir;
|
|
const plan = readFileSync(join(stageDir, "code-generation-plan.md"), "utf-8");
|
|
const instructions = readFileSync(
|
|
join(stageDir, "unit-test-instructions.md"),
|
|
"utf-8",
|
|
);
|
|
const questionsPath = join(stageDir, "code-generation-questions.md");
|
|
if (
|
|
existsSync(questionsPath) &&
|
|
questionsFileApproved(readFileSync(questionsPath, "utf-8"))
|
|
) {
|
|
throw new Error(
|
|
"reset the Plan Approval [Answer]: to blank before regenerating its fingerprint",
|
|
);
|
|
}
|
|
const embedded = parseTestingContract(plan);
|
|
const current = resolveTestingPosture(projectDir);
|
|
if (
|
|
!embedded ||
|
|
embedded.contract_sha256 !== current.contract_sha256
|
|
) {
|
|
throw new Error(
|
|
approval.reason ||
|
|
"plan Testing Contract does not match the current effective posture",
|
|
);
|
|
}
|
|
// Print the two tag lines the Plan Approval section must carry, ready to
|
|
// copy: the content fingerprint, and the workspace source this plan was
|
|
// written against. Recording the source here is what makes drift between
|
|
// planning and approval answerable - re-run this command and re-present.
|
|
// The listing behind the source is kept so a later drift can be told to
|
|
// the human as the files that changed.
|
|
const plannedState = workspaceSourceState(projectDir);
|
|
keepWorkspaceSourceSnapshot(projectDir, plannedState);
|
|
const plannedSource = plannedState?.fingerprint ?? UNBINDABLE_FINGERPRINT;
|
|
console.log(
|
|
`[Approval Fingerprint]: ${
|
|
approvalFingerprint(
|
|
plan,
|
|
instructions,
|
|
current.contract_sha256,
|
|
authority,
|
|
)
|
|
}`,
|
|
);
|
|
console.log(`[Planned Source]: ${plannedSource}`);
|
|
if (plannedState === null) {
|
|
// The tag stays machine-readable; the reason rides on stderr so the
|
|
// conductor can relay which budget or path failed before presenting.
|
|
console.error(
|
|
JSON.stringify({
|
|
note:
|
|
`the workspace source cannot be bound${workspaceSourceFailureSuffix()}; ` +
|
|
"a decision on this plan will be refused until the boundary is repaired. " +
|
|
PLAN_APPROVAL_REPAIR_SOURCE_BOUNDARY_REMEDY,
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
case "verify": {
|
|
const target = targetFromArgs(argv, "verify");
|
|
const result = evaluateCodeGenerationApproval(projectDir, target);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
process.exit(result.ok ? 0 : 2);
|
|
return;
|
|
}
|
|
case "begin": {
|
|
const target = targetFromArgs(argv, "begin");
|
|
const changeNotices = beginCodeGeneration(projectDir, target);
|
|
console.log(
|
|
JSON.stringify({
|
|
status: "generation",
|
|
target,
|
|
...(changeNotices.length > 0 ? { change_notices: changeNotices } : {}),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
case "brief": {
|
|
// The worker brief, verbatim on stdout: the two marker lines, the plan
|
|
// BODY, and the byte-exact instructions. Refuses unless approval is
|
|
// current, so the brief can never precede the authority it carries.
|
|
const target = targetFromArgs(argv, "brief");
|
|
const assembled = workerBrief(projectDir, target);
|
|
if (assembled.appendixStripped) {
|
|
console.error(
|
|
JSON.stringify({
|
|
note:
|
|
"the plan carries a terminal review appendix from an earlier protocol; " +
|
|
"it is not part of the approved body and was left out of the brief",
|
|
}),
|
|
);
|
|
}
|
|
process.stdout.write(assembled.brief);
|
|
return;
|
|
}
|
|
default:
|
|
throw new Error(
|
|
`Unknown subcommand: ${subcommand ?? "(none)"}. Valid: resolve, render, fingerprint, verify, begin, brief`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// The human sentence is the error; the conductor's remedy (which command
|
|
// reopens approval) rides beside it, never inside it.
|
|
console.error(
|
|
JSON.stringify({
|
|
error: error instanceof Error ? error.message : String(error),
|
|
...(error instanceof PlanApprovalSourceDriftError ? { remedy: error.remedy } : {}),
|
|
}),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) main(process.argv.slice(2));
|