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

1598 lines
74 KiB
TypeScript

// Stop hook: enforce the forwarding loop on turn-end.
//
// This is one of the framework's flow-altering hooks. The advisory hooks
// observe (audit, sensors, statusline, state
// validation) and always exit 0. The run-sensors hook in particular carries
// an explicit advisory contract: it NEVER returns {decision: block} (its own
// contract, asserted by t95 Case 7 — not a framework ban). This hook is a
// DIFFERENT, sanctioned contract: it may emit {"decision":"block", ...} to
// keep the interactive forwarding loop running until the engine says `done`.
//
// Why it exists. The forwarding loop is the conductor (LLM) calling the engine
// for the next move, acting on it, and reporting. On the gated/interactive
// path the conductor holds the loop because only it can ask the human a
// question. If the conductor forgets to consult the engine — after a long
// conversation, or by improvising — the workflow drifts. So the loop cannot
// rest on the conductor's good behaviour: when the conductor tries to end its
// turn, this hook runs the engine (`aidlc-orchestrate next`) and, if a
// directive is still PENDING, blocks the stop and injects the directive back
// via `reason`. The conductor cannot quit until the engine answers `done`.
// Enforced by the harness, not by the LLM remembering.
//
// The reason is an ON-TASK CONTINUATION — it names the work the conductor
// still owes (run the loop, act on the directive, report), never an
// override-shaped instruction. That phrasing is the security property:
// override-shaped directives are refused by the conductor's own safety
// training, so a buggy or compromised engine can only ever CONTINUE sanctioned
// work, never hijack the session.
//
// Two bounds keep a stuck loop from trapping the session (a stuck block is the
// ONE way to trap a session, so this is the safety-critical part):
// 1. `stop_hook_active` — Claude Code sets this true when the current stop is
// itself the product of a prior Stop-hook block. We read it as a signal
// that we are already inside a blocked sequence.
// 2. A NO-PROGRESS counter — consecutive blocks with no intervening workflow
// advance (the stable state digest and pending-directive fingerprint are both
// unchanged). It is persisted across the rapid-fire blocks in a transient
// file under aidlc-docs/.aidlc-stop-hook/. Under a no-progress ceiling
// exposed as CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, once the count reaches the cap
// we LET GO (allow the stop). The default ceiling is run-mode aware: an
// unattended autonomous Construction run keeps the long ceiling (8, the
// loop must run to completion with no human to release it), while an
// INTERACTIVE run uses a low ceiling (2, issue #365 itself recommends
// BLOCK_CAP=2 as the workaround) so a human who just wants to pause/chat is
// released after one nudge, not eight. When workflow state or the pending
// directive advances, the signature changes and the counter resets to 0,
// so a healthy loop is never throttled.
//
// Eight turn-stop carve-outs keep the hook from punishing a turn that ended
// for a legitimate wait (human input, background work, or conversation):
// 1. The Esc interrupt is FREE: Stop hooks do not fire on user interrupt, so
// an Esc can never be trapped — no code needed for that case.
// 2. The interactive GATE is not free: the Stop hook DOES fire when the
// conductor ends its turn to await an `AskUserQuestion` answer. At an
// approval gate ([?] awaiting-approval) or in the Request-Changes loop
// ([R] revising) the engine still returns a pending run-stage (the stage is
// in-flight, aidlc-orchestrate.ts:1161-1176), so without a carve-out the
// hook would block and spam the forwarding-loop nudge until the cap bleeds
// out. So when the current stage's checkbox is positively [?]/[R] we ALLOW
// the stop (isHumanWaitStop below). Positive-confirmation only and
// fail-open: stateless cases fall through to the cap-bounded block.
// 3. A mid-stage CLARIFYING QUESTION parks the stage at [-] in-progress — the
// same state as a lazy quit, so [-] alone can't be carved out. But the
// conductor must write a `<slug>-questions.md` with blank [Answer]: tags
// before asking (stage-protocol.md §3); an unanswered tag is a positive
// signal that a question is pending, so we ALLOW the stop then too
// (isPendingQuestionStop below). The active directive stage selects the
// questions file because a unit-major walk can run ahead of Current Stage.
// Autonomous Construction stays guarded except for unit-major
// code-generation's mandatory Plan Approval. Any miss falls through to the
// cap-bounded block, so a genuine mid-stage quit is still nudged.
// 4. A LOGGED NON-GATE QUESTION has a current-stage DECISION_RECORDED with no
// later QUESTION_ANSWERED. This is the positive signal for structured
// questions that do not live in the stage questions file (notably the
// learnings ritual), and for harnesses that render questions as prose.
// Like the pending-file carve-out, it is limited to [-] and suppressed
// under autonomous Construction.
// 5. An IN-FLIGHT COMPOSE gate is positively signalled by the fresh
// workspace-level compose marker and is suppressed under autonomous
// Construction.
// 6. An IN-FLIGHT BACKGROUND SUBAGENT is positively signalled by a fresh
// session-scoped ledger entry added after background dispatch acceptance
// and removed one-at-a-time on SubagentStop. Autonomous Construction
// remains guarded.
// 7. A CONVERSATIONAL turn ends with the human's last prompt answered and NO
// workflow-engine engagement (the conductor ran neither aidlc-orchestrate
// nor aidlc-state since that prompt). Issue #365's broader reading: a human
// who just wants to CHAT mid-workflow should not be nudged at all. We ALLOW
// the stop when the most recent genuine human prompt was answered with zero
// engine calls (isConversationalStop below). ONE predicate, TWO evidence
// sources: the harness TRANSCRIPT where the Stop payload delivers
// `transcript_path` (Claude, Codex), and the `.aidlc-human-turn` vs
// `.aidlc-engine-touch` MARKER mtimes where it does not (Kiro IDE, Kiro CLI,
// opencode — these expose no turn history to a hook at all, so the framework
// writes the two facts itself on the mint and engine seams). The marker path
// depends on the engine skipping its touch for this hook's OWN `next` probe
// (STOP_HOOK_PROBE_ENV); without that the predicate would be false forever.
// POSITIVE-CONFIRMATION only and fail-closed on both paths: it never fires
// under autonomous Construction, and any engine call in the responding turn,
// an unreadable transcript, a missing marker, no human prompt found, or any
// parse miss falls through to the cap-bounded block. It only ever ALLOWS;
// it can never block more.
// NOT FULL PARITY. The marker path answers the same question more
// COARSELY than the transcript: it is blind to aidlc-jump / aidlc-bolt /
// aidlc-swarm and the mutating aidlc-state verbs, which the transcript
// DOES count as engagement, because none of those tools touch the engine
// marker. A conductor that jumps the pointer and then quits is released
// here and blocked on Claude. Narrow but real; see the coverage-gap note
// on markEngineTouch in aidlc-lib.ts.
// 8. A RESUME CHOICE has a state-bound active-directive marker with kind
// `ask` and resume status `waiting`. On the shared non-Copilot path we must
// read this latch BEFORE probing `next`, because the probe publishes its
// own sessionless directive and can overwrite the `ask` kind. We ALLOW the
// stop while the human chooses how to resume. Autonomous Construction is
// guarded and falls through to the cap-bounded block.
//
// No-op outside AIDLC. The frontmatter Stop matcher scopes this to the `aidlc`
// skill, but we defend here too: with no active workflow (no aidlc-state.md
// under the project dir) we exit 0 immediately. A non-AIDLC session is NEVER
// blocked. Any unexpected error also falls through to allow the stop — failing
// open is the only safe failure mode for a hook that can otherwise trap a turn.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
ActiveDirectiveLockContendedError,
clearSessionIntentHandoff,
composeMarkerPath,
consumeCopilotConversation,
copilotStopEvidence,
COMPOSE_MARKER_TTL_MS,
docsRoot,
errorMessage,
findIntentByUuid,
effectiveUnitGateRhythm,
getField,
stateDigest,
hasCurrentSharedResumeWait,
hasPendingDecision,
hookChildEnv,
isEngineToolCall,
hooksHealthDir,
isoTimestamp,
isTeamUnitOwnership,
matchSubagentInflight,
parseCheckboxes,
readActiveDirectiveMarker,
readSessionIntentHandoff,
readSessionIntentUuid,
recordHookDrop,
resolveProjectDirFromHook,
resolveWorkflowSelection,
stageDir,
stateFilePathForSelection,
stopHookDir,
STOP_HOOK_PROBE_ENV,
turnMarkersShowConversational,
validSessionId,
updateCopilotStopCount,
SESSION_INTENT_HANDOFF_TTL_MS,
harnessDir,
unitGateStatus,
} from "../tools/aidlc-lib.ts";
import {
foldTranscriptIntoLedger,
writeCurrentTranscriptPath,
} from "../tools/aidlc-usage.ts";
import { questionsFileHasPendingPlanApproval } from "./aidlc-plan-approval-guard.ts";
const HOOK_NAME = "continue-workflow";
// The block-cap ceiling: the maximum number of consecutive no-progress blocks
// before the hook releases the session. Exposed as an env var so a fork can
// tune it. An explicit CLAUDE_CODE_STOP_HOOK_BLOCK_CAP always wins. With no
// override the default is RUN-MODE aware:
// - autonomous Construction -> 8 (the long ceiling SPIKE 1 validated). An
// unattended run has no human to release it, so the loop must run far before
// letting go; only a genuine hang should ever hit the cap there.
// - interactive (everything else) -> 2. Issue #365 itself recommends
// CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=2 as the workaround: a human who pauses or
// just chats mid-workflow is released after a single nudge, not eight. A
// healthy loop is still never throttled because real progress (a `report`)
// changes the signature and resets the counter to 0 well before 2.
// A non-numeric / non-positive override falls back to the mode default rather
// than disabling the guard — the guard must never be silently turned off.
function blockCap(stateContent: string): number {
const raw = process.env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP;
const fallback = defaultBlockCap(stateContent);
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
// The mode-aware default cap (used when no env override is set).
function defaultBlockCap(stateContent: string): number {
return getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous"
? AUTONOMOUS_BLOCK_CAP
: INTERACTIVE_BLOCK_CAP;
}
const AUTONOMOUS_BLOCK_CAP = 8;
const INTERACTIVE_BLOCK_CAP = 2;
// Upper bound on the `aidlc-orchestrate next` consultation. A `next` that never
// returns must not hang the hook for the whole turn (a session trap the
// block-count guard cannot see — it only counts blocks that complete). The
// read-only engine answers in well under a second normally; 10s is generous
// headroom. On timeout the spawn returns non-zero and runEngineNextDirective fails
// OPEN (allows the stop).
const ENGINE_TIMEOUT_MS = 10_000;
// Allow the stop: emit nothing, exit 0. This is the precedent non-blocking
// pattern shared by every other framework hook. The conductor's turn ends.
function allowStop(): number {
return 0;
}
// Block the stop and inject the pending work back into the session. The reason
// is an on-task continuation (the work still owed), NOT an override-shaped
// instruction — that phrasing is the security property (see header).
function blockStop(reason: string): number {
console.log(JSON.stringify({ decision: "block", reason }));
return 0;
}
// --- Recursion guard: a durable no-progress counter ---------------------------
//
// We persist a tiny JSON record keyed on the workflow's PROGRESS SIGNATURE: the
// Current Stage slug, the workflow-state digest with volatile Last Updated
// metadata removed, and the pending directive identity (including steering
// token/part, run-stage wave, swarm units, and dispatched worker/repo). A `report` or
// directive transition changes one of those components — that is how we detect
// "progress was made since the last block". Audit-only appends do not manufacture
// progress. When the signature is unchanged across two blocks, workflow state
// and the pending directive did not advance, so we increment the counter; when
// it changes, the loop is healthy and we reset to 0.
//
// The file lives under the gitignored aidlc-docs/.aidlc-stop-hook/ alongside
// the other transient framework state. It is keyed off the project dir, so it
// is per-workflow and survives across the rapid-fire blocks within one stuck
// turn (the blocks happen in the same project; each re-invocation re-reads it).
interface GuardRecord {
signature: string;
count: number; // consecutive no-progress blocks observed at this signature
}
function guardFilePath(projectDir: string): string {
return join(stopHookDir(projectDir), "block-count.json");
}
// The Current Stage slug from the state file. Factored from the regex the
// signature and continuation both used inline (was duplicated at two sites);
// returns "" when the field is absent. Matches `**Current Stage**:`, with or
// without the bold markers / backticks, exactly as before.
function currentStageSlug(stateContent: string): string {
const stageMatch = stateContent.match(/Current Stage\*{0,2}:?\s*`?([^\n`]*)`?/);
return (stageMatch?.[1] ?? "").trim();
}
// The current workflow position signature. It changes when workflow state or
// the pending directive advances, while ignoring unrelated audit-only traffic.
function progressSignature(stateContent: string, directive: EngineDirective): string {
const stage = currentStageSlug(stateContent);
// The same projection the engine binds a directive to, so "did the workflow
// advance?" and "is the issued directive still current?" cannot disagree. This
// hook already ignored Last Updated for exactly this reason; the projection
// generalizes that to the whole cache layer.
const stateSha256 = stateDigest(stateContent);
const directiveFingerprint = createHash("sha256")
.update(
JSON.stringify({
kind: directive.kind,
stage: directive.stage ?? "",
unit: directive.unit ?? "",
part: directive.part ?? null,
parts: directive.parts ?? null,
continue_token_sha256: directive.continueToken
? createHash("sha256").update(directive.continueToken, "utf-8").digest("hex")
: "",
rules_content_sha256: directive.rulesContent
? createHash("sha256")
.update(JSON.stringify(directive.rulesContent), "utf-8")
.digest("hex")
: "",
units: directive.units ?? [],
worker: directive.worker ?? "",
repo: directive.repo ?? "",
wave_sha256:
directive.wave === undefined
? ""
: createHash("sha256")
.update(JSON.stringify(directive.wave), "utf-8")
.digest("hex"),
}),
"utf-8",
)
.digest("hex");
return `${stage}::${stateSha256}::${directiveFingerprint}`;
}
function readGuard(projectDir: string): GuardRecord | null {
try {
const path = guardFilePath(projectDir);
if (!existsSync(path)) return null;
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
if (
raw !== null &&
typeof raw === "object" &&
"signature" in raw &&
typeof (raw as { signature: unknown }).signature === "string" &&
"count" in raw &&
typeof (raw as { count: unknown }).count === "number"
) {
return raw as GuardRecord;
}
} catch {
// Corrupt / unreadable guard file — treat as no prior record (count 0).
}
return null;
}
function writeGuard(projectDir: string, record: GuardRecord): void {
try {
const dir = stopHookDir(projectDir);
mkdirSync(dir, { recursive: true });
writeFileSync(guardFilePath(projectDir), JSON.stringify(record), "utf-8");
} catch {
// If we cannot persist the counter we still proceed; the stop_hook_active
// flag remains a second, native bound (see decideBlock). Worst case the
// counter under-counts — never over-blocks — because an unwritable record
// reads back as count 0, and the stop_hook_active escape hatch still fires.
}
}
// Decide whether to block, accounting for the recursion bounds. Returns true to
// block (work is pending and we are within the no-progress budget), false to
// RELEASE (let go — the ceiling is hit, so a stuck loop cannot trap the turn).
//
// PROGRESS is authoritative. The workflow position signature (Current Stage +
// stable state digest + pending-directive fingerprint) changes when workflow state or
// the pending directive advances, so:
// - signature CHANGED since the prior block → progress was made; RESET the
// streak to 1. A healthy loop that keeps advancing is never throttled, even
// if the conductor forgets to consult the engine on every single turn.
// - signature UNCHANGED from the prior block → no workflow or directive
// progress; INCREMENT the streak. Audit-only appends leave it unchanged.
// This is the genuinely-stuck case the cap bounds.
// stop_hook_active is a secondary signal used ONLY to seed the streak when
// there is no prior record yet but Claude Code already reports this stop as the
// product of a prior block (so a sequence we are joining mid-flight starts at 2,
// not 1). It NEVER overrides an observed signature change — progress always
// wins, so the counter can only climb on real no-progress and can therefore
// only ever make us release SOONER under a true hang, never trap a live loop.
// Once the streak reaches the cap we RELEASE: a stuck loop must always let go.
function decideBlock(
projectDir: string,
stateContent: string,
directive: EngineDirective,
stopHookActive: boolean,
): boolean {
const cap = blockCap(stateContent);
const signature = progressSignature(stateContent, directive);
const prior = readGuard(projectDir);
const sameSignature = prior !== null && prior.signature === signature;
let nextCount: number;
if (sameSignature) {
// No progress since the prior block at this signature — extend the streak.
nextCount = prior.count + 1;
} else if (prior === null && stopHookActive) {
// No prior record, but Claude Code flags this as a post-block stop: we are
// joining a sequence already in flight. Seed at 2 (this is at least the
// second block) rather than under-counting from 1.
nextCount = 2;
} else {
// Either a fresh first block, or the signature changed (progress was made):
// start a new streak.
nextCount = 1;
}
// Persist the updated counter for the NEXT invocation in this sequence.
writeGuard(projectDir, { signature, count: nextCount });
// RELEASE when the no-progress streak has reached the cap. This is the
// hardest acceptance criterion: a stuck loop must always let go.
if (nextCount >= cap) {
return false; // let go
}
return true; // within budget — block and re-feed the pending work
}
// Reset the guard once the loop reaches `done` (or any allow path with state),
// so the next stuck sequence starts its count from scratch rather than
// inheriting a stale streak from an earlier, since-resolved hang.
function resetGuard(projectDir: string): void {
try {
const dir = stopHookDir(projectDir);
mkdirSync(dir, { recursive: true });
writeFileSync(guardFilePath(projectDir), JSON.stringify({ signature: "", count: 0 }), "utf-8");
} catch {
// Non-fatal — a stale streak only ever makes us release SOONER, never trap.
}
}
// --- Human-wait carve-out -----------------------------------------------------
//
// The block path punishes a conductor that quit mid-loop. But a conductor parked
// at an approval gate or in the Request-Changes loop has ALSO ended its turn with
// the engine still returning a pending directive — and from the engine's vantage
// it looks identical, because a stage in `awaiting-approval` ([?]) or `revising`
// ([R]) is still "in-flight", so `next` re-emits a run-stage for it
// (aidlc-orchestrate.ts:1161-1176). Yet these states exist BECAUSE the human was
// engaged: [?] only because a gate is open awaiting approve/reject, [R] only
// because changes were just requested. Blocking there spams the forwarding-loop
// nudge until the cap bleeds out — confusing and unprofessional at an
// interactive gate.
//
// So when the CURRENT stage's checkbox is positively in one of those states,
// allow the stop. This is the only safe widening of an allow: it can only ever
// make the hook release MORE readily, never block more.
//
// One honest caveat on [R]: the row stays `revising` across the WHOLE rework
// window (it flips back to [?] only when the conductor calls `revise`; see
// stage-protocol.md:164). So [R] covers both the human-wait prompt ("what would
// you like changed?") AND the autonomous rework edits that follow. Allowing the
// stop on [R] means a conductor that quits mid-rework is not nudged — the same
// [-]-style ambiguity we accept for in-progress, here scoped to a window the
// human just opened. It is still only ever an allow (never blocks more), and the
// dominant [R] experience is the human-wait prompt this carve-out targets.
//
// POSITIVE-CONFIRMATION ONLY. We allow ONLY when a checkbox row for the current
// slug exists AND its state is [?]/[R]. No rows, slug not found, or any other
// state → return false and fall through. [-] in-progress is NOT carved out HERE:
// it is also the normal "stage work still owed" state, indistinguishable from a
// lazy mid-stage quit by checkbox alone, so a blanket [-] carve-out would gut
// the hook. (A mid-stage [-] stage with a genuinely pending question is handled
// separately and conservatively by isPendingQuestionStop below, which keys off
// the conductor's questions file rather than checkbox state.) Any parse error
// falls through too: fail-open is the only safe failure mode for a hook that can
// otherwise trap a turn.
function isHumanWaitStop(
projectDir: string,
stateContent: string,
activeStage?: string,
activeUnit?: string,
): boolean {
try {
if (
isTeamUnitOwnership(stateContent) &&
activeStage &&
activeUnit
) {
const status = unitGateStatus(
projectDir,
activeStage,
activeUnit,
effectiveUnitGateRhythm(projectDir, stateContent),
);
if (status === "awaiting-approval" || status === "revising") return true;
}
const slug = currentStageSlug(stateContent);
if (slug.length === 0) return false;
const row = parseCheckboxes(stateContent).find((c) => c.slug === slug);
return row?.state === "awaiting-approval" || row?.state === "revising";
} catch {
// Unparseable / odd content — fall through to decideBlock (never trap).
return false;
}
}
// --- Tier-2: pending mid-stage question carve-out -----------------------------
//
// A clarifying question asked mid-stage leaves the stage at [-] in-progress —
// the SAME checkbox state as a conductor that lazily quit, so [-] alone cannot
// be carved out (tier 1 deliberately left it to the cap). But there IS a
// conductor-emitted artifact that disambiguates: stage-protocol.md §3 mandates a
// `<slug>-questions.md` is created (Step 1) with blank `[Answer]:` tags before
// the conductor asks, and every tag is filled before the stage proceeds (Step
// 4). So a questions file with an UNANSWERED tag means a question is genuinely
// pending — the conductor is parked on the human, exactly like a gate.
//
// Two strict gates make this safe (it can still only ever ALLOW, never block
// more):
// 1. POSITIVE-CONFIRMATION — allow only when a `<slug>-questions.md` under the
// active directive stage's canonical dir, or the exact active-unit dir
// carried by a Construction directive, has at least one `[Answer]:` tag
// that is empty or underscores-only. No file, all answered, or any read
// error → false (fall through to the cap).
// 2. AUTONOMY GUARD — never fires under autonomous Construction except for
// unit-major code-generation. That mode suppresses the autonomous swarm
// and routes code-generation through the interactive per-unit walk, whose
// Plan Approval is mandatory. Every other autonomous path must keep running
// unattended, so a stray open question cannot strand the run.
// Fail-open throughout: any error returns false and the cap-bounded block stands.
// True when the `<slug>-questions.md` under the active stage dir has an
// unanswered tag.
// An `[Answer]:` line is "unanswered" when, after the colon, only whitespace or
// underscores remain (stage-protocol.md:333 — "blank or contains only
// underscores"). Standard stages use `<record>/<phase>/<slug>/`; a per-unit
// Construction directive carries its exact unit and uses
// `<record>/construction/<unit>/<slug>/`. We never recursively accept a question
// from a different unit: an old unanswered file must not disable enforcement for
// the unit currently named by the engine.
function hasPendingQuestion(
projectDir: string,
slug: string,
phase: string,
unit?: string,
planApprovalOnly = false,
): boolean {
if (slug.length === 0 || phase.length === 0) return false;
const normalizedPhase = phase.toLowerCase();
const stageDirPath =
normalizedPhase === "construction" && unit
? join(docsRoot(projectDir), normalizedPhase, unit, slug)
: stageDir(projectDir, normalizedPhase, slug);
if (!existsSync(stageDirPath)) return false;
let files: string[];
try {
files = planApprovalOnly
? readdirSync(stageDirPath).filter((f) => f === `${slug}-questions.md`)
: readdirSync(stageDirPath).filter((f) => f.endsWith("-questions.md"));
} catch {
return false;
}
for (const f of files) {
let body: string;
try {
body = readFileSync(join(stageDirPath, f), "utf-8");
} catch {
continue;
}
if (planApprovalOnly) {
if (questionsFileHasPendingPlanApproval(body)) return true;
} else if (/\[Answer\]:[ \t]*_*[ \t]*$/m.test(body)) {
// An [Answer]: tag whose value is empty or underscores-only.
return true;
}
}
return false;
}
// The tier-2 carve-out decision: the state cursor is [-] in-progress and the
// active directive stage has a pending question. Autonomous Construction is
// excluded except when unit-major routes code-generation through its mandatory
// interactive Plan Approval.
function isPendingQuestionStop(
projectDir: string,
stateContent: string,
activeStage?: string,
unit?: string,
): boolean {
try {
const currentSlug = currentStageSlug(stateContent);
const slug = activeStage?.trim() || currentSlug;
const phase = getField(stateContent, "Lifecycle Phase") ?? "";
const unitMajorCodeGeneration =
slug === "code-generation" &&
unit !== undefined &&
phase.trim().toLowerCase() === "construction" &&
getField(stateContent, "Construction Iteration")?.trim() === "unit-major";
if (
getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous" &&
!unitMajorCodeGeneration
) {
return false; // autonomy guard — keep the loop alive
}
if (currentSlug.length === 0 || slug.length === 0) return false;
const teamUnitMajorDirective =
isTeamUnitOwnership(stateContent) &&
activeStage !== undefined &&
unit !== undefined &&
getField(stateContent, "Construction Iteration")?.trim() === "unit-major";
if (!teamUnitMajorDirective) {
const row = parseCheckboxes(stateContent).find(
(c) => c.slug === currentSlug,
);
if (row?.state !== "in-progress") return false; // positive [-] only
}
return hasPendingQuestion(
projectDir,
slug,
phase,
unit,
unitMajorCodeGeneration &&
getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous",
);
} catch {
// Unparseable / odd content — fall through to decideBlock (never trap).
return false;
}
}
// A structured non-gate question is logged before it is rendered and answered
// afterward. That audit handshake is the positive human-wait signal for prompts
// that are not represented by a blank tag in `<slug>-questions.md`, such as the
// §13 learning selection and "Anything to add?" prompts. Keep the same strict
// stage-state and autonomy gates as the question-file carve-out.
function isPendingDecisionStop(
projectDir: string,
stateContent: string,
activeStage?: string,
activeUnit?: string,
): boolean {
try {
if (getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous") {
return false;
}
const currentSlug = currentStageSlug(stateContent);
const teamUnitMajorDirective =
isTeamUnitOwnership(stateContent) &&
activeStage !== undefined &&
activeUnit !== undefined &&
getField(stateContent, "Construction Iteration")?.trim() === "unit-major";
const slug = teamUnitMajorDirective
? (activeStage?.trim() || currentSlug)
: currentSlug;
if (slug.length === 0) return false;
if (!teamUnitMajorDirective) {
const row = parseCheckboxes(stateContent).find((c) => c.slug === slug);
if (row?.state !== "in-progress") return false;
}
return hasPendingDecision(
projectDir,
slug,
teamUnitMajorDirective ? undefined : "STAGE_STARTED",
teamUnitMajorDirective ? activeUnit : undefined,
teamUnitMajorDirective,
);
} catch {
return false;
}
}
// --- Tier-2b: pending in-flight compose proposal carve-out --------------------
//
// The adaptive composer's IN-FLIGHT approve/edit/reject gate is a turn-stop
// like a stage gate, but it has no [?]/[R] checkbox signal: the current stage
// stays [ ]/[-], so this hook's bare-`next` probe sees the pending run-stage
// and would block the turn - shoving the conductor back into stage execution
// mid-compose and abandoning the gate (the mid-workflow trap class, reopened
// for compose). POSITIVE-CONFIRMATION: the conductor writes the marker file
// `aidlc/.aidlc-compose-pending` before presenting the gate (the engine's
// compose dispatch print instructs it) and deletes it on approve/reject, the
// same disk-signal discipline as tier-2's <slug>-questions.md. AUTONOMY GUARD:
// never fires under autonomous Construction (an unattended run has no human to
// answer the gate; a stray marker must not strand it). Fail-open: any read
// error falls through to the cap-bounded block. Front/report composes are
// unaffected (cold start has no state file; the hook allows before this).
//
// STALENESS BOUND. The conductor owns the write/delete, but a session that
// crashes or is killed between "write the marker" and "gate resolves" leaves the
// marker on disk forever - a permanently open carve-out that silently disables
// the forwarding-loop enforcement for the whole workspace until someone
// hand-deletes a hidden file they have never heard of. So the carve-out honours
// the marker ONLY while it is FRESH: younger than COMPOSE_MARKER_TTL_MS by its
// mtime. The window is generous (24h) so a human who steps away from an open
// gate for a long pause is still covered; anything older is an orphan, not a
// live gate. A stale marker is IGNORED (fall through to the cap-bounded block)
// AND best-effort deleted here (the janitor), so the next turn starts clean.
// The delete is wrapped so a failure to unlink never changes the stop decision.
// The path spelling and the TTL are shared with the doctor probe via aidlc-lib.
function isPendingComposeStop(projectDir: string, stateContent: string): boolean {
try {
if (getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous") {
return false; // autonomy guard - keep the loop alive
}
const marker = composeMarkerPath(projectDir);
if (!existsSync(marker)) return false;
const ageMs = Date.now() - statSync(marker).mtimeMs;
if (ageMs <= COMPOSE_MARKER_TTL_MS) return true; // fresh - honour the carve-out
// Orphaned marker: do not honour it, and best-effort clean it up so it
// cannot disable the enforcement loop indefinitely.
try {
unlinkSync(marker);
} catch {
// Unlink failure is non-fatal - the staleness check above already refused
// to honour the marker, so the loop stays enforced regardless.
}
recordHookDrop(
projectDir,
HOOK_NAME,
"ignoring an orphaned compose marker (aidlc/.aidlc-compose-pending older than the freshness window); cleaned it up and falling through to the cap-bounded block",
);
return false;
} catch {
return false;
}
}
// --- Tier-2c: pending in-flight background subagent carve-out ----------------
//
// A background Agent/Task dispatch legitimately ends the conductor's turn
// while the worker remains in flight. The stage stays pending, so the bare
// `next` probe would otherwise inject a forwarding-loop nudge before the
// background result arrives. POSITIVE-CONFIRMATION: the dispatch hook adds one
// session-scoped ledger entry only for an accepted `run_in_background: true`
// call, and SubagentStop removes one entry for that same session. AUTONOMY
// GUARD: never fires under autonomous Construction, where the unattended loop
// must remain enforced.
//
// STALENESS BOUND. A crashed session or missing SubagentStop can strand an
// entry. The shared ledger helper prunes stale entries under the workspace lock
// and matches only the current payload session. Malformed or foreign-session
// evidence fails closed and falls through to the cap-bounded block.
function isPendingSubagentStop(
projectDir: string,
stateContent: string,
sessionId: unknown,
): boolean {
try {
if (getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous") {
return false; // autonomy guard - keep the loop alive
}
const match = matchSubagentInflight(projectDir, sessionId);
if (match.malformed) {
recordHookDrop(
projectDir,
HOOK_NAME,
"background-subagent in-flight ledger is malformed; refusing the pending-subagent carve-out",
);
return false;
}
if (match.staleRemoved > 0) {
recordHookDrop(
projectDir,
HOOK_NAME,
`pruned ${match.staleRemoved} orphaned background-subagent in-flight ${match.staleRemoved === 1 ? "entry" : "entries"} before evaluating the pending-subagent carve-out`,
);
}
return match.active;
} catch {
return false;
}
}
// --- Tier-3: conversational-turn carve-out (issue #365 broader reading) -------
//
// Issue #365's literal fix is `park` (the conductor explicitly pauses the run).
// But the reported pain is broader: during an ACTIVE workflow a human who just
// wants to CHAT (ask a question, discuss a decision, course-correct) should
// not be nudged back into the forwarding loop at all. Park does not cover that
// (it is not automatic). This carve-out does: when the turn that is ending was
// CONVERSATIONAL (the most recent genuine human prompt was answered with NO
// workflow-engine engagement, i.e. the conductor ran neither aidlc-orchestrate
// nor aidlc-state since that prompt) we ALLOW the stop.
//
// The signal is the harness transcript. Claude and Codex both deliver a
// `transcript_path` on the Stop payload (Claude JSONL; Codex date-sharded
// rollout JSONL); Kiro delivers none, so on Kiro this carve-out is simply inert
// and the run-mode-aware low interactive cap (blockCap) is the safety net that
// releases a chatting human after one nudge instead of eight.
//
// Two strict gates make this safe (it can still only ever ALLOW, never block
// more), mirroring isPendingQuestionStop:
// 1. POSITIVE-CONFIRMATION: allow only on a transcript we could read that
// shows a genuine human prompt answered with zero engine calls. A missing
// path, unreadable file, no human prompt found, or ANY engine call in the
// responding turn returns false (fall through to the cap-bounded block).
// 2. AUTONOMY GUARD: never fires under autonomous Construction. There the
// loop must keep running unattended; there is no human chatting to release.
// Fail-closed throughout: any error returns false and the cap-bounded block stands.
// True when a user-role transcript entry's text is actually the hook's OWN
// injected continuation (a re-prompt after a block), not the human talking.
// Two shapes: Claude Code wraps the block reason as "Stop hook feedback: ..."
// (isMeta:true), but other harnesses (Codex) may re-inject the RAW reason text
// with no wrapper. continuationReason() (below) always opens with "The AIDLC
// workflow has a pending step" and names "the workflow loop", so match either
// signature. Excluding these is what keeps an engine-engaged turn whose last
// user entry is the hook's nudge from being misread as a fresh human prompt.
// The two phrases MUST stay in step with continuationReason(): if its wording
// changes without this matcher changing too, an injected nudge reads as a fresh
// human prompt and the conversational carve-out silently mis-allows the stop.
function isInjectedHookFeedback(text: string): boolean {
const t = text.trimStart();
return (
t.startsWith("Stop hook feedback:") ||
(t.startsWith("The AIDLC workflow has a pending step") &&
/workflow loop/.test(t))
);
}
// Read the transcript and classify the ending turn as conversational. Supports
// both delivered formats; returns true ONLY with positive evidence. `format`
// distinguishes Claude's message-shaped JSONL from Codex's {type,payload}
// rollout. Fail-closed on every miss.
function transcriptIsConversational(transcriptPath: string, format: "claude" | "codex"): boolean {
let raw: string;
try {
raw = readFileSync(transcriptPath, "utf-8");
} catch {
return false; // unreadable transcript: fall through to the cap
}
const lines = raw.split("\n");
// Parse to a flat sequence of {role, engineCall} events in file order.
type Turn = { role: "user" | "assistant"; engineCall: boolean; humanPrompt: boolean };
const turns: Turn[] = [];
for (const line of lines) {
if (line.trim().length === 0) continue;
let o: unknown;
try {
o = JSON.parse(line);
} catch {
continue; // skip non-JSON / partial lines
}
if (o === null || typeof o !== "object") continue;
const entry = o as Record<string, unknown>;
if (format === "claude") {
// Claude JSONL: {type:"user"|"assistant", message:{role, content}}.
const type = entry.type;
const message = entry.message as Record<string, unknown> | undefined;
if (!message) continue;
const role = message.role;
const content = message.content;
if (type === "user" && role === "user") {
// SKIP synthetic / non-human user turns. Claude Code records several
// things as `type:"user"` that are NOT the human talking:
// - `isMeta: true` entries: the Stop hook's OWN injected block-feedback
// ("Stop hook feedback: ...") and command-message wrappers. Counting
// these as a human prompt would let the hook's own nudge masquerade
// as the human, so an engine-engaged turn could be misread as chat.
// - tool_result arrays: a tool's output, not a prompt.
// Both must be excluded so "the most recent genuine human prompt" is the
// human, not the harness.
if (entry.isMeta === true) continue;
const isToolResult =
Array.isArray(content) &&
content.some((x) => (x as Record<string, unknown>)?.type === "tool_result");
if (isToolResult) continue; // a tool_result is not a human prompt
// Defence-in-depth: the hook's continuation text is injected as a user
// turn; exclude it by content even if a future build drops `isMeta`.
const asText =
typeof content === "string"
? content
: Array.isArray(content)
? content
.map((x) => {
const b = x as Record<string, unknown>;
return b?.type === "text" ? String(b.text ?? "") : "";
})
.join("")
: "";
if (isInjectedHookFeedback(asText)) continue;
// A genuine human prompt: string content, or an array with a text block.
const isHuman =
typeof content === "string" ||
(Array.isArray(content) &&
content.some((x) => (x as Record<string, unknown>)?.type === "text"));
if (isHuman) turns.push({ role: "user", engineCall: false, humanPrompt: true });
} else if (type === "assistant" && role === "assistant" && Array.isArray(content)) {
let engineCall = false;
for (const block of content) {
const b = block as Record<string, unknown>;
if (b?.type === "tool_use" && isEngineToolCall(String(b.name ?? ""), b.input)) {
engineCall = true;
break;
}
}
turns.push({ role: "assistant", engineCall, humanPrompt: false });
}
} else {
// Codex rollout JSONL: {type:"response_item", payload:{type, role, content,
// name, ...}}. function_call entries carry the tool name/arguments.
const payload = entry.payload as Record<string, unknown> | undefined;
if (entry.type !== "response_item" || !payload) continue;
const ptype = payload.type;
if (ptype === "message" && payload.role === "user") {
// input_text blocks are the human prompt; tool output rides function_call_output.
const content = payload.content;
// Exclude the hook's own injected continuation (delivered as a user
// message on a re-prompt) so it is not mistaken for the human, mirroring
// the Claude reader's `Stop hook feedback:` guard.
const asText =
typeof content === "string"
? content
: Array.isArray(content)
? content
.map((x) => {
const b = x as Record<string, unknown>;
return b?.type === "input_text" || b?.type === "text" ? String(b.text ?? "") : "";
})
.join("")
: "";
if (isInjectedHookFeedback(asText)) continue;
const isHuman =
typeof content === "string" ||
(Array.isArray(content) &&
content.some((x) => {
const t = (x as Record<string, unknown>)?.type;
return t === "input_text" || t === "text";
}));
if (isHuman) turns.push({ role: "user", engineCall: false, humanPrompt: true });
} else if (ptype === "message" && payload.role === "assistant") {
turns.push({ role: "assistant", engineCall: false, humanPrompt: false });
} else if (ptype === "function_call" || ptype === "local_shell_call") {
const name = String(payload.name ?? (ptype === "local_shell_call" ? "Shell" : ""));
const args = payload.arguments ?? payload.action ?? {};
// function_call arguments are a JSON string on Codex; parse leniently.
let parsedArgs: Record<string, unknown> = {};
if (typeof args === "string") {
try {
const j = JSON.parse(args);
parsedArgs = j !== null && typeof j === "object" ? (j as Record<string, unknown>) : { command: args };
} catch {
parsedArgs = { command: args };
}
} else if (args !== null && typeof args === "object") {
parsedArgs = args as Record<string, unknown>;
}
// Normalise the command field so isEngineToolCall sees the full command
// text (Codex may key it `command`, or carry it as the raw arguments
// string). Routing it ALL through isEngineToolCall keeps the read-only
// exemption (--status etc.) consistent across both transcript formats,
// rather than a loose regex that would re-flag a read-only query.
if (typeof parsedArgs.command !== "string") {
parsedArgs = { ...parsedArgs, command: typeof args === "string" ? args : JSON.stringify(args) };
}
const engineCall = isEngineToolCall(
/^(bash|shell|execute_bash|local_shell_call)$/i.test(name) ? "Bash" : name,
parsedArgs,
);
turns.push({ role: "assistant", engineCall, humanPrompt: false });
}
}
}
// Find the most recent genuine human prompt.
let lastHumanIdx = -1;
for (let i = turns.length - 1; i >= 0; i--) {
if (turns[i].humanPrompt) {
lastHumanIdx = i;
break;
}
}
if (lastHumanIdx === -1) return false; // no human prompt found: cannot confirm chat
// Any engine call AFTER that prompt means the conductor engaged the workflow;
// a mid-loop bail must still be nudged. Zero engine calls -> conversational.
for (let i = lastHumanIdx + 1; i < turns.length; i++) {
if (turns[i].engineCall) return false;
}
return true;
}
// The tier-3 carve-out decision: not autonomous, and the ending turn is
// positively confirmed conversational by whichever evidence the harness offers.
//
// TWO READINGS OF ONE PREDICATE. The question is identical in both — "was the
// human's most recent prompt answered with zero workflow-engine calls?" — only
// the evidence differs:
//
// - TRANSCRIPT (Claude, Codex): the Stop payload carries `transcript_path`, so
// the turn history is read directly and classified per tool call. Highest
// fidelity; preferred whenever available.
// - MARKER mtimes (Kiro IDE, Kiro CLI, opencode): these harnesses deliver NO
// transcript and expose no turn history to a hook at all, so the same
// predicate is reconstructed from two files the framework already writes on
// the relevant seams — `.aidlc-human-turn` (the UserPromptSubmit mint) and
// `.aidlc-engine-touch` (every advancing aidlc-orchestrate invocation). A
// human turn NEWER than the last engine advance is the marker spelling of
// "answered with zero engine calls".
//
// Before the marker path existed this returned false on every transcript-free
// harness, so tier 3 was inert there and the low interactive cap was the only
// net — meaning exactly one spurious forwarding-loop nudge per conversational
// detour, on the very interaction AI-DLC wants to encourage (a human
// interrogating the process mid-stage).
//
// POSITIVE-CONFIRMATION AND FAIL-CLOSED on both paths, unchanged: an autonomous
// Construction run, a missing/unreadable transcript, a missing/unreadable marker,
// no human prompt found, or ANY engine engagement in the responding turn all
// return false and fall through to the cap-bounded block. This function can only
// ever ALLOW a stop; it can never cause one to block.
function isConversationalStop(
projectDir: string,
stateContent: string,
transcriptPath: string | null,
format: "claude" | "codex",
copilotSession = "",
): boolean {
try {
if (getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous") {
return false; // autonomy guard: keep the loop alive
}
if (copilotSession) return consumeCopilotConversation(projectDir, stateContent, copilotSession);
if (transcriptPath === null || transcriptPath.length === 0) {
// No transcript delivered — fall back to the marker mtimes.
return turnMarkersShowConversational(projectDir);
}
return transcriptIsConversational(transcriptPath, format);
} catch {
// Unparseable / odd content: fall through to decideBlock (never trap).
return false;
}
}
// --- Compose the engine -------------------------------------------------------
//
interface EngineDirective {
kind: string;
stage?: string;
unit?: string;
continueToken?: string;
part?: number;
parts?: number;
units?: string[];
worker?: string;
repo?: string;
wave?: unknown;
retained?: boolean;
rulesContent?: Array<{ path: string; text: string }>;
}
// Run `aidlc-orchestrate.ts next` and return the parsed directive fields the
// hook needs, or null
// if the engine could not be consulted (spawn failure, non-zero exit, or
// unparseable stdout). A null directive fails OPEN — the caller allows the stop —
// because we will not trap a turn on the engine's behalf when we cannot read a
// directive. We pass --project-dir explicitly so the engine resolves the same
// workspace regardless of the spawned process's cwd.
function runEngineNextDirective(
projectDir: string,
sessionId: string,
): EngineDirective | null {
const enginePath = join(projectDir, harnessDir(), "tools", "aidlc-orchestrate.ts");
if (!existsSync(enginePath)) return null;
// The spawn MUST be time-bounded. Without a timeout a hung `next` (an engine
// that never returns) would hang this hook for the whole turn — a session
// trap by a path the block-count guard cannot see. On timeout spawnSync
// returns with a non-zero/absent exitCode (and sets `proc.error`), which the
// null-return below treats as "engine could not be consulted" → fail OPEN
// (allow the stop). Mirrors aidlc-run-sensors.ts's bounded spawn.
//
// STOP_HOOK_PROBE_ENV MARKS THIS SPAWN AS THE HOOK'S OWN PROBE, and that is
// load-bearing for the conversational carve-out — not a debug nicety. The
// engine touches `.aidlc-engine-touch` on every advancing invocation, and the
// transcript-free carve-out below asks "is the last human turn newer than the
// last engine touch?". This consultation runs on EVERY stop, so without the
// marker it would refresh the engine mtime first and the answer would be `no`
// forever: tier 3 would look implemented and never fire. markEngineTouch() is a
// no-op when it sees this env var (aidlc-lib.ts).
const proc = Bun.spawnSync({
cmd: [
"bun",
enginePath,
"next",
"--project-dir",
projectDir,
],
stdout: "pipe",
stderr: "pipe",
timeout: ENGINE_TIMEOUT_MS,
env: hookChildEnv(projectDir, sessionId, {
[STOP_HOOK_PROBE_ENV]: "1",
}),
});
if (proc.exitCode !== 0) return null;
const stdout = new TextDecoder().decode(proc.stdout).trim();
if (stdout.length === 0) return null;
try {
const parsed: unknown = JSON.parse(stdout);
if (
parsed !== null &&
typeof parsed === "object" &&
"kind" in parsed &&
typeof (parsed as { kind: unknown }).kind === "string"
) {
const kind = (parsed as { kind: string }).kind;
const stage =
"stage" in parsed && typeof (parsed as { stage?: unknown }).stage === "string"
? (parsed as { stage: string }).stage.trim()
: "";
const unit =
"unit" in parsed && typeof (parsed as { unit?: unknown }).unit === "string"
? (parsed as { unit: string }).unit.trim()
: "";
const continueToken =
"continue_token" in parsed &&
typeof (parsed as { continue_token?: unknown }).continue_token === "string"
? (parsed as { continue_token: string }).continue_token.trim()
: "";
const part =
"part" in parsed &&
typeof (parsed as { part?: unknown }).part === "number" &&
Number.isInteger((parsed as { part: number }).part)
? (parsed as { part: number }).part
: undefined;
const parts =
"parts" in parsed &&
typeof (parsed as { parts?: unknown }).parts === "number" &&
Number.isInteger((parsed as { parts: number }).parts)
? (parsed as { parts: number }).parts
: undefined;
const rawUnits = "units" in parsed ? (parsed as { units?: unknown }).units : undefined;
const units =
Array.isArray(rawUnits) && rawUnits.every((entry) => typeof entry === "string")
? rawUnits.map((entry) => entry.trim()).filter((entry) => entry.length > 0)
: undefined;
const worker =
"worker" in parsed && typeof (parsed as { worker?: unknown }).worker === "string"
? (parsed as { worker: string }).worker.trim()
: "";
const repo =
"repo" in parsed && typeof (parsed as { repo?: unknown }).repo === "string"
? (parsed as { repo: string }).repo.trim()
: "";
const wave = "wave" in parsed ? (parsed as { wave?: unknown }).wave : undefined;
const rawRulesContent =
"rules_content" in parsed
? (parsed as { rules_content?: unknown }).rules_content
: undefined;
const rulesContent =
Array.isArray(rawRulesContent) &&
rawRulesContent.every(
(entry) =>
entry !== null &&
typeof entry === "object" &&
"path" in entry &&
typeof (entry as { path?: unknown }).path === "string" &&
"text" in entry &&
typeof (entry as { text?: unknown }).text === "string",
)
? rawRulesContent as Array<{ path: string; text: string }>
: undefined;
return {
kind,
...(stage.length > 0 ? { stage } : {}),
...(unit.length > 0 ? { unit } : {}),
...(continueToken.length > 0 ? { continueToken } : {}),
...(part !== undefined ? { part } : {}),
...(parts !== undefined ? { parts } : {}),
...(units ? { units } : {}),
...(worker.length > 0 ? { worker } : {}),
...(repo.length > 0 ? { repo } : {}),
...(wave !== undefined ? { wave } : {}),
...(rulesContent ? { rulesContent } : {}),
};
}
} catch {
// Unparseable directive — fail open.
}
return null;
}
// Build the on-task continuation injected when blocking. It names the pending
// work the conductor still owes — run the forwarding loop, act on the directive
// the engine emits, then report — and the directive kind / stage for context.
// Deliberately phrased as continuation of sanctioned work, never as an
// instruction to do something new or out-of-band (the security property).
function continuationReason(
kind: string,
stage: string,
continueToken?: string,
rulesContent?: Array<{ path: string; text: string }>,
retained = false,
): string {
const where = stage.length > 0 ? ` for "${stage}"` : "";
if (kind === "rehydrate") {
return `AI-DLC coordination evidence is missing or stale. Run one fresh \`bun ${harnessDir()}/tools/aidlc-orchestrate.ts next\`; do not reuse an earlier continuation token.`;
}
if (retained && kind === "load-steering" && continueToken) {
return `The delivered AIDLC steering part${where} is still active. Apply every path/text entry from its already-delivered \`rules_content\`, then run \`bun ${harnessDir()}/tools/aidlc-orchestrate.ts continue "${continueToken}"\`. Keep applying and continuing every returned load-steering part until \`run-stage\`; do not restart at part 1, and do not summarise or narrate rule chunks to the user.`;
}
if (retained && kind === "run-stage") {
return `The exact delivered AIDLC run-stage${where} is still active. Complete that exact stage, then use \`report\` for the real outcome; use \`park\` for a clean pause. Never rubber-stamp approval or revision gates.`;
}
if (kind === "load-steering" && continueToken) {
const exactContent = JSON.stringify(rulesContent ?? []);
// Print order and execution order intentionally differ. The opaque token
// must precede the large payload so host truncation cannot discard it, but
// the conductor must still apply this chunk before advancing the cursor.
return (
`The AIDLC workflow still has rules to load${where}. ` +
"Preserve this step-two continuation command, but do not run it yet: " +
`\`bun ${harnessDir()}/tools/aidlc-orchestrate.ts continue "${continueToken}"\` ` +
"First, apply every path/text entry in the exact `rules_content` payload below. " +
"Second, run the preserved command and keep following each load-steering step it " +
"returns, applying its rule chunk before every continuation, until it answers " +
"`run-stage`. Do not summarise or narrate these " +
`rule chunks to the user.\n\n${exactContent}`
);
}
return (
`The AIDLC workflow has a pending step (a ${kind} directive${where}). ` +
"You have not finished the workflow loop yet. Run " +
`\`bun ${harnessDir()}/tools/aidlc-orchestrate.ts next\`, do what the step it prints ` +
"asks, then run `aidlc-orchestrate report --stage <stage> --result <outcome>` to record " +
"the outcome. Repeat until it answers `done`. " +
"If you meant to pause this workflow instead and pick it up in a later " +
`session, run \`bun ${harnessDir()}/tools/aidlc-orchestrate.ts park\` to stop ` +
"cleanly between stages - never mark a stage complete just to end the turn."
);
}
// --- Main ---------------------------------------------------------------------
export async function run(input: string): Promise<number> {
const projectDir = resolveProjectDirFromHook(import.meta.url);
let earlySessionId = "";
let earlyRawSessionId: unknown;
try {
const early = JSON.parse(input) as { session_id?: unknown };
earlyRawSessionId = early.session_id;
if (typeof early.session_id === "string") {
earlySessionId = validSessionId(early.session_id) ?? "";
}
} catch {
/* malformed input remains fail-open */
}
// Write a health heartbeat (mirrors the other hooks' .aidlc-hooks-health beat).
try {
const healthDir = hooksHealthDir(projectDir);
mkdirSync(healthDir, { recursive: true });
writeFileSync(join(healthDir, "continue-workflow.last"), isoTimestamp(), "utf-8");
} catch {
// Heartbeat failure is non-fatal — never let it affect the stop decision.
}
// Mirror the SubagentStop hook's stdin idiom: a TTY means no Claude Code JSON
// is coming (test/debug contexts) — allow the stop rather than block on a
// terminal read.
if (process.stdin.isTTY) return allowStop();
// No-op outside AIDLC: if there is no workflow state file under the project dir,
// there is nothing to enforce — allow the stop. Defends the frontmatter scoping.
const selection = resolveWorkflowSelection(projectDir, {
sessionId: earlySessionId || undefined,
});
const statePath = stateFilePathForSelection(projectDir, selection);
if (!existsSync(statePath)) return allowStop();
let stateContent: string;
try {
stateContent = readFileSync(statePath, "utf-8");
} catch (e) {
// Unreadable state — fail open (never trap) and record the drop.
recordHookDrop(projectDir, HOOK_NAME, errorMessage(e));
return allowStop();
}
// Parse the Stop-hook input. Garbage / empty stdin must NOT crash and must NOT
// trap the turn (fail open). We read `stop_hook_active` (the recursion bound)
// and `transcript_path` (the conversational carve-out, tier 3). Claude and Codex
// both deliver `transcript_path`; Kiro and opencode deliver neither, so
// transcriptPath stays null there and the carve-out reads the turn-shape markers
// instead (see isConversationalStop).
let stopHookActive = false;
let transcriptPath: string | null = null;
// The conversation id Claude Code stamps on Stop input - used to key the
// session-scoped `<sessionId>.transcript` pointer written below. "" when absent
// (a TTY/empty invocation or a host that omits it); writeCurrentTranscriptPath
// still writes the unscoped `current.transcript` fallback in that case.
let sessionId = earlySessionId;
let rawSessionId = earlyRawSessionId;
// Transcript format: Codex's rollout JSONL lives under a `.../sessions/<date>/
// rollout-*.jsonl` path and uses a {type,payload} shape; Claude's is message-
// shaped JSONL. Default to Claude; switch to Codex when the path looks like a
// Codex rollout. (Both readers fail-closed, so a misclassification can only ever
// return false and fall through to the cap, never a false allow.)
let transcriptFormat: "claude" | "codex" = "claude";
try {
const raw: unknown = JSON.parse(input);
if (raw !== null && typeof raw === "object") {
const obj = raw as Record<string, unknown>;
if ("stop_hook_active" in obj) stopHookActive = obj.stop_hook_active === true;
if ("session_id" in obj) rawSessionId = obj.session_id;
if (typeof obj.session_id === "string") {
sessionId = validSessionId(obj.session_id) ?? "";
}
if (typeof obj.transcript_path === "string" && obj.transcript_path.length > 0) {
transcriptPath = obj.transcript_path;
if (/[/\\]rollout-[^/\\]*\.jsonl$/.test(transcriptPath)) transcriptFormat = "codex";
}
}
} catch {
// Malformed JSON (or empty): proceed with stopHookActive=false and no
// transcript. The engine read below still governs whether work is pending; the
// counter still bounds any block. We never crash on bad input.
}
// A confirmed second intent deliberately moves the shared cursor before this
// old conversation ends. The PostToolUse hook writes an exact per-session
// receipt for that transition. Allow only when the receipt is fresh, the
// session now owns the created intent. The shared cursor is intentionally not
// evidence here: another session may move it before this Stop event.
if (sessionId) {
const handoff = readSessionIntentHandoff(projectDir, sessionId);
if (handoff) {
const now = Date.now();
const fresh =
handoff.issuedAtMs <= now &&
now - handoff.issuedAtMs <= SESSION_INTENT_HANDOFF_TTL_MS;
const target = findIntentByUuid(projectDir, handoff.toIntentUuid);
const exactBoundary =
fresh &&
readSessionIntentUuid(projectDir, sessionId) === handoff.toIntentUuid &&
target !== null &&
selection.space === target.space &&
selection.intent === target.dirName;
if (exactBoundary) {
clearSessionIntentHandoff(projectDir, sessionId);
resetGuard(projectDir);
recordHookDrop(
projectDir,
HOOK_NAME,
"allowing stop at the exact post-create fresh-session handoff boundary",
);
return allowStop();
}
if (!fresh) clearSessionIntentHandoff(projectDir, sessionId);
}
}
// Usage bookkeeping - persist the live transcript path and fold its new turns
// into the durable usage ledger under the current stage. This is THE turn-end
// producer of usage-ledger.json alongside the per-tool Pre/PostToolUse fold:
// without it the statusline cost segment lags the final turn. Both calls are
// cheap (the fold advances per-file cursors, so only new turns are read) and
// BOTH are fully guarded - a usage failure must NEVER break or delay the Stop
// hook, so any throw is swallowed here rather than propagated. Only Claude
// transcripts are folded (the reader is Claude-format-specific); a Codex rollout
// path is left alone. currentStage is the same Current Stage slug the rest of
// this hook reads; null when absent so byStage isn't polluted.
if (transcriptPath && transcriptFormat === "claude") {
try {
writeCurrentTranscriptPath(projectDir, sessionId, transcriptPath);
const currentStage = currentStageSlug(stateContent) || null;
// The turn is ending, so every file's last message-id group is complete and
// must be counted now (PostToolUse holds it back; Stop closes it).
foldTranscriptIntoLedger(
projectDir,
transcriptPath,
currentStage,
"flush-all",
{ sessionId },
);
} catch {
// best-effort - usage never breaks the hook
}
}
// Consult the engine for the next move. A null directive (engine unavailable /
// unparseable) fails open — allow the stop.
const copilotSession = process.env.AIDLC_COPILOT_SESSION_ID === sessionId ? sessionId : "";
const copilotEvidence = copilotSession ? copilotStopEvidence(projectDir, stateContent, copilotSession) : null;
if (copilotEvidence?.status === "contended") {
recordHookDrop(projectDir, HOOK_NAME, "active-directive lock contended while reading Copilot Stop evidence; allowing stop");
return allowStop();
}
if (copilotEvidence?.status === "foreign" || copilotEvidence?.status === "resume") return allowStop();
if (!copilotSession) {
let resumeWaiting = false;
try {
resumeWaiting = hasCurrentSharedResumeWait(projectDir);
} catch (error) {
recordHookDrop(
projectDir,
HOOK_NAME,
`active-directive evidence unavailable while reading shared resume wait: ${errorMessage(error)}; allowing stop`,
);
return allowStop();
}
if (resumeWaiting) {
recordHookDrop(
projectDir,
HOOK_NAME,
"active resume choice is waiting on the human; allowing the stop before the shared next probe",
);
return allowStop();
}
}
const retainedDirective = copilotEvidence?.status === "directive" ? copilotEvidence.directive : undefined;
const directive: EngineDirective | null = copilotEvidence
? retainedDirective
? { ...retainedDirective, retained: true }
: { kind: "rehydrate", retained: true }
: runEngineNextDirective(projectDir, sessionId);
if (directive === null) {
recordHookDrop(projectDir, HOOK_NAME, "engine next returned no parseable directive; allowing stop");
return allowStop();
}
const kind = directive.kind;
const activeMarker = readActiveDirectiveMarker(projectDir, stateContent);
const activeStage = directive.stage ?? activeMarker?.stage;
const activeUnit =
directive.unit ??
(
activeMarker && activeMarker.stage === activeStage
? activeMarker.unit
: undefined
);
// `done` → the workflow is complete; allow the turn to end and clear the guard
// so a future stuck sequence starts fresh.
if (kind === "done") {
resetGuard(projectDir);
return allowStop();
}
if (kind === "notice") {
resetGuard(projectDir);
return allowStop();
}
// `parked` -> the workflow was intentionally parked mid-flow (issue #367); a
// human resumes it later with /aidlc --resume. This is the SUPPORTED
// multi-session exit: allow the turn to end and clear the guard exactly like
// `done`, so the conductor parks at a clean inter-stage boundary instead of
// rubber-stamping the remaining stages to force a `done`. Terminal allow only
// (never a new block), so it can never trap a session.
//
// AUTONOMY GUARD (salvaged from the #365 suspend branch): an unattended
// autonomous Construction run (`Construction Autonomy Mode: autonomous`) MUST
// keep moving and never self-park. There is no human to resume it later, so a
// park would strand the swarm/Bolt run waiting on someone who was told they
// weren't needed. When autonomous, decline the parked allow and fall through to
// the cap-bounded block below (the loop stays alive; a genuine hang still
// releases via the no-progress cap). This mirrors isPendingQuestionStop's
// identical guard (:391) for consistency across every carve-out in this hook.
if (kind === "parked") {
if (getField(stateContent, "Construction Autonomy Mode")?.trim() === "autonomous") {
recordHookDrop(
projectDir,
HOOK_NAME,
"parked directive seen under autonomous Construction; declining the parked allow (an unattended run must not self-park), falling through to the cap-bounded block",
);
} else {
resetGuard(projectDir);
return allowStop();
}
}
// `ask` → the engine is explicitly waiting for human input (for example,
// freeform scope routing or a paused Unit). Allow the turn to end so the user
// can respond, rather than re-feeding the loop.
if (kind === "ask") {
return allowStop();
}
// Human-wait carve-out: the engine returns a pending directive, but the current
// stage is positively at [?] awaiting-approval or [R] revising — the conductor
// is correctly parked on the human (an approval gate or the Request-Changes
// loop), with genuinely nothing to do without their input. Allow the stop
// instead of spamming the forwarding-loop nudge. Positive-confirmation only and
// fail-open (see isHumanWaitStop): any other state, no checkbox row, or a parse
// error falls through to the cap-bounded block below, unchanged. (This is the
// current-stage-scoped successor to the broad `[?]` substring match that landed
// in 679153d; scoping to the current slug and adding [R] is strictly safer.)
if (isHumanWaitStop(projectDir, stateContent, activeStage, activeUnit)) {
recordHookDrop(
projectDir,
HOOK_NAME,
`current stage ${currentStageSlug(stateContent)} is awaiting approval or being revised; allowing the stop (human-wait carve-out)`,
);
return allowStop();
}
// Pending-question carve-out (tier 2): the current [-] cursor has an unanswered
// question for the active directive stage, so the conductor is parked on the
// human's answer. The active stage can be later than Current Stage during the
// unit-major walk. Strictly gated and fail-open (see isPendingQuestionStop).
if (isPendingQuestionStop(projectDir, stateContent, activeStage, activeUnit)) {
const pendingStage = activeStage ?? currentStageSlug(stateContent);
recordHookDrop(
projectDir,
HOOK_NAME,
`active stage ${pendingStage} has an unanswered question; allowing the stop (pending-question carve-out)`,
);
return allowStop();
}
// Logged-question carve-out: a DECISION_RECORDED for the current [-] stage has
// no later QUESTION_ANSWERED. Copilot's numbered-prose questions end the turn
// without a native picker, so this signal keeps the Stop hook from injecting a
// continuation that the model could mistake for the answer.
if (isPendingDecisionStop(projectDir, stateContent, activeStage, activeUnit)) {
const teamPending =
isTeamUnitOwnership(stateContent) &&
activeStage !== undefined &&
activeUnit !== undefined &&
getField(stateContent, "Construction Iteration")?.trim() === "unit-major";
const pendingStage = teamPending
? (activeStage ?? currentStageSlug(stateContent))
: currentStageSlug(stateContent);
recordHookDrop(
projectDir,
HOOK_NAME,
teamPending
? `active stage ${pendingStage} has an unanswered logged decision; allowing the stop (pending-decision carve-out)`
: `current stage ${pendingStage} has an unanswered logged decision; allowing the stop (pending-decision carve-out)`,
);
return allowStop();
}
// Pending-compose carve-out (tier 2b): an in-flight compose proposal is
// awaiting the human's approve/edit/reject (the conductor's marker file is on
// disk) and we are NOT in autonomous Construction - the conductor is parked on
// the human exactly like a stage gate, so allow the turn to end instead of
// nudging it back into stage execution mid-compose. Positive-confirmation only
// (the marker), autonomy-guarded, fail-open (see isPendingComposeStop).
if (isPendingComposeStop(projectDir, stateContent)) {
recordHookDrop(
projectDir,
HOOK_NAME,
"an in-flight compose proposal is pending human approval (aidlc/.aidlc-compose-pending present); allowing the stop (pending-compose carve-out)",
);
return allowStop();
}
// Pending-background-subagent carve-out (tier 2c): a background Agent/Task is
// still running, so the conductor is correctly parked until its result arrives.
// Positive-confirmation only (a matching ledger entry), session-isolated,
// autonomy-guarded, freshness-bounded, and fail-open (see
// isPendingSubagentStop).
if (isPendingSubagentStop(projectDir, stateContent, rawSessionId)) {
recordHookDrop(
projectDir,
HOOK_NAME,
"a background subagent is still in flight for this session; allowing the stop (pending-subagent carve-out)",
);
return allowStop();
}
// Conversational carve-out (tier 3, issue #365 broader reading): the ending turn
// answered the human's most recent prompt with NO workflow-engine engagement, so
// the human was just chatting mid-workflow, allow the stop instead of nudging
// them back into the loop. Two evidence sources for one predicate: the harness
// transcript where it is delivered (Claude / Codex), and the `.aidlc-human-turn`
// vs `.aidlc-engine-touch` mtime comparison where it is not (Kiro IDE, Kiro CLI,
// opencode). Strictly gated and fail-closed (see isConversationalStop): no
// evidence, no human prompt, ANY engine call in the responding turn, an
// autonomous run, or any read error falls through to the cap-bounded block below,
// so a conductor that engaged the workflow and then quit mid-loop (and every
// autonomous run) is still nudged.
if (isConversationalStop(projectDir, stateContent, transcriptPath, transcriptFormat, copilotSession)) {
recordHookDrop(
projectDir,
HOOK_NAME,
"the ending turn was conversational (human's last prompt answered with no workflow-engine call); allowing the stop (conversational carve-out)",
);
return allowStop();
}
// A directive is PENDING (run-stage / dispatch-subagent / invoke-swarm /
// present-gate / ask / print / error). Decide whether to block, honouring the
// recursion bounds. When the bounds say release, LET GO — a stuck loop must
// never trap the session.
let markerCount: { shouldBlock: boolean; count: number } | null = null;
if (copilotSession && copilotEvidence &&
(copilotEvidence.status === "directive" || copilotEvidence.status === "recovery")) {
try {
markerCount = updateCopilotStopCount(
projectDir,
stateContent,
copilotSession,
[kind, activeStage ?? "", activeUnit ?? "", directive.part ?? "", directive.parts ?? "", copilotEvidence.tokenSha256, copilotEvidence.stateSha256, copilotEvidence.resumeStatus, copilotEvidence.resumeAction, copilotEvidence.ownerSession, copilotEvidence.ownerEpoch].join("|"),
stopHookActive,
blockCap(stateContent),
);
} catch (error) {
if (!(error instanceof ActiveDirectiveLockContendedError)) throw error;
recordHookDrop(projectDir, HOOK_NAME, "active-directive lock contended while updating Copilot Stop count; allowing stop");
return allowStop();
}
}
const shouldBlock = copilotSession
? markerCount?.shouldBlock ?? false
: decideBlock(projectDir, stateContent, directive, stopHookActive);
if (!shouldBlock) {
recordHookDrop(
projectDir,
HOOK_NAME,
`recursion guard released the stop (no-progress block cap ${blockCap(stateContent)} reached; stop_hook_active=${stopHookActive})`,
);
return allowStop();
}
// Within budget — block the stop and re-feed the pending work.
return blockStop(
continuationReason(
kind,
activeStage ?? currentStageSlug(stateContent),
directive.continueToken,
directive.rulesContent,
directive.retained,
),
);
}
if (import.meta.main) {
process.exit(await run(await Bun.stdin.text()));
}