// The orchestration engine — the deterministic "what's next?" answerer that // stands BESIDE the prose orchestrator (skills/aidlc/SKILL.md), not inside it. // Nothing in SKILL.md calls this file yet; it is exercised only by its own // unit tests until the differential corpus proves it emits the same directive // sequence the prose orchestrator produces today. Framework behaviour is // unchanged by this file's existence. // // The engine reads workflow state (aidlc-docs/aidlc-state.md) and the compiled // stage graph (data/stage-graph.json), then emits EXACTLY ONE typed Directive // (JSON) to stdout. `next` is read-only for every legacy/solo workflow. Exact // team ownership is the narrow exception: before routing it delegates a // guarded `refresh-unit-progress` projection to aidlc-state.ts so the derived // grid and aggregate Construction checkboxes cannot drift from audit receipts. // Creation remains read-only: on a fresh workspace the engine NAMES the // deterministic `intent-create` move via a print directive, and the conductor // runs that separate tool. The directive's `kind` tells the conductor the // single move to make next; the conductor relays human choices // and supplies resolved facts, but the engine never originates a deviation, // never calls AskUserQuestion (that is a Bash tool the conductor owns), and // never spawns agents. Clean boundaries: a refused or malformed directive is a // clear signal, not a silent miss — every emitted directive is validated // against the frozen aidlc-directive.ts contract before it is printed. // // Subcommand dispatch table: // next — resolve scope (state > flag > env > default), find the workflow's // position, refresh only the exact-team derived projection, and emit // one directive. Ordinary routing is otherwise read-only; `--single` // records its isolated audit start before dispatch. LIVE. // report — commit a transition after the conductor acted on a directive. // LIVE. A stage-aware dispatcher: it shells out to aidlc-state.ts // transitions so the next `next` reads fresh state. Explicit // `--stage` pins the acted directive, and a missing gated // in-progress state is recovered by opening the gate before approve. // // COMPOSE, don't reimplement. Every read composes an existing deterministic // tool/library function: // - aidlc-graph.ts loadGraph() — the compiled stage graph (one read, // cached); the node carries every // routing field the run-stage // directive needs. // - aidlc-lib.ts nextInScopeStage() — the next EXECUTE stage after a slug // for a scope (state-override aware). // - aidlc-lib.ts firstInScopeStageOfPhase() — first EXECUTE stage of a // phase (for the --phase resolution). // - aidlc-lib.ts validScopes() — the canonical scope-name set, derived // from scope-mapping.json. // - aidlc-lib.ts getField/parseCheckboxes — state-field + checkbox reads. // - aidlc-lib.ts resolveProjectDir/readStateFile — project-dir + state I/O. // // The non-happy-path branches (jump, resume, init, scope/config-change, // env-scope validation) COMPOSE the sibling CLI tools by SHELLING OUT — none of // those handlers is an importable symbol (aidlc-jump.ts and aidlc-utility.ts // both export zero CLI handlers; they are reachable only by argv dispatch). The // engine spawns the subcommand with Bun.spawnSync, inspects its exitCode, and // captures its stderr VERBATIM so the user-facing error wording (e.g. the // canonical `Invalid AWS_AIDLC_DEFAULT_SCOPE "...". Valid scopes: ...`) is // relayed unchanged rather than reconstructed — reconstruction would drift from // the tool the rest of the framework asserts on. The one read-only invariant // `next` keeps: it never spawns a subcommand that MUTATES. The jump-direction // (resolve) and env-scope (resolve-env-scope) subcommands are pure reads; the // init guard is spawned ONLY on the already-state-exists path, where the tool // dies at its guard before any scaffold write. // // The things the engine ADDS — not composes — are (1) the decision rule that // maps (observed state + graph) -> directive kind, and (2) the artifact-path // resolver that turns the graph node's vocabulary NAMES into canonical // aidlc-docs/... paths and drops conditional_on consumes-entries against the // workflow's project type. The primitives above expose the facts; no existing // query answers "what directive applies here?" and no graph function maps a // vocabulary name to a path. Both are pure deterministic code — the right home // per the tool/agent/human split (routing string-building to an LLM would // invert the whole thesis). import { createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto"; import { constants as fsConstants, copyFileSync, type Dirent, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type AskDirective, type Directive, type ErrorDirective, type GuardRecoveryAskDirective, GATE_UNRESOLVED, type GateValue, type LegacyPlanApprovalChoices, type LoadSteeringDirective, type ParkedDirective, type PrintDirective, type NoticeDirective, type ProtocolModule, type RunStageDirective, type RunStageWave, type RunStageWaveEntry, type StageValidityAdvisory, validateDirective, } from "./aidlc-directive.ts"; import { ActiveDirectiveLockContendedError, advanceContinuationCursor, activeUnitCheckpoint, artifactFilename, auditBlockField, BLOCKING_SENSOR_OVERRIDE_CHOICE, type CheckboxState, type CheckboxLine, checkSummaryConfirmationEvidence, clearActiveDirectiveMarker, codekbRepoName, currentUnitLifecycleMode, effectivePlanAction, errorMessage, evaluateGuardRefusal, filterProducesByKind, firstInScopeStageOfPhase, formatReceivedReply, freshReviewReceipts, getField, GUARD_RECOVERY_ASK_TYPE, type GuardRefusal, guardAttemptState, type GuardAttemptState, guardRecoveryAskFromRefusalText, guardRefusalStreakView, type GuardRemedy, humanAuthorityState, recordGuardRefusal, currentGuardRecoveryAskMarker, type SummaryConfirmationEvidence, gridCostSummary, hasAnyUnitClaimRefs, installedHarnessName, intentRepos, inspectContinuationCursor, isPluginEnabled, isPerUnitStage, isReadOnlyEngineProbe, isRegularFile, isRouteCheckProbe, isStopHookProbe, isTeamUnitOwnership, KNOWN_CODEKB_STAGES, listIntents, LEGACY_PLAN_APPROVAL_RECOVERY_CHOICE, loadScopeMetadata, loadScopeMetadataAll, resolveReviewClass, loadScopeMapping, nextInScopeStage, parseCheckboxes, parseChangeControl, pipelineLinkEvidence, parseBoltDag, type KnowledgeCommand, parseKnowledgeCommand, type PluginCommand, parsePluginCommand, PHASE_NUMBERS, PHASES, parseWorkspaceCommand, READ_ONLY_FLAGS, readKiroIdeLegacyPlanApprovalHost, readAllAuditShards, readAuditShardEvents, readApplicableTeamUnitScopeStamp, readStateFile, recordHookDrop, recoveryGuidance, markEngineTouch, kiroIdeLegacyPlanApprovalSessionId, relativeCodekbDir, relativeRecordDirForSelection, relativeSpaceRecordPrefix, resolveBoltDag, type BoltDagResolution, resolveProjectDir, resolveProjectFlag, resolveWorkflowSelection, scopeCostSummary, selectionAwareDefaultScope, singleStageAttemptIsOpen, resolveDefaultScope, DEFAULT_SCOPE, type StageEntry, type AuditShardEvent, stateFilePath, stateDigest, readActiveDirectiveMarker, type ActiveDirectiveMarker, EngineModeViolationError, stateFilePathForSelection, teamUnitGateStatus, unitDependencyPath, unitParkedPath, unitParticipantPath, swarmConvergedUnits, unitCompletedReceipts, unitGateStatus, type UnitGateRhythm, unitLifecycleReceiptsInUse, usesStageLevelPerUnitArtifacts, unitLifecycleSnapshot, unitMergedReceipts, unitMergeTransactions, unitMergeTransactionsForIdentity, unitMajorConstructionStageSlugs, toPosix, validateLiveUnitScope, validScopes, harnessDir, type WorkspaceCommand, type WorkflowSelection, writeActiveDirectiveMarker, type PlanApprovalLegacyOfferCandidate, workspaceCommandUtilityArgv, classifyStateVersion, currentSwarmAttemptObligations, effectiveUnitGateRhythm, requestChangesResetIsExecutable, } from "./aidlc-lib.ts"; import { reviewRecoverySpentMessage } from "./aidlc-log.ts"; import { cachedUnitClaimOverview, localUnitClaimOverviewForIntent, type UnitClaimOverview, } from "./aidlc-unit.ts"; import { type Consume, type GraphStage, loadGraph, producersOf, subgraphForScope, } from "./aidlc-graph.ts"; // inferScopeFromText is a PURE function (keyword matching over the scope // registry) - importing it keeps `next` read-only. The audit-emitting // detect-scope verb remains the conductor's separate recording move; the // import is safe (aidlc-utility.ts main() runs only under import.meta.main, // and utility never imports this module - no cycle). import { detectWorkspace, inferScopeFromText } from "./aidlc-utility.ts"; import { aidlcDispatcherInvocation, aidlcInvocation, aidlcToolInvocation, isCompiledExecutable, resolveHarnessPath, resolveHarnessRoot, } from "./aidlc-runtime-paths.ts"; import { appendAuditEntries } from "./aidlc-audit.ts"; import { inspectRequiredArtifactInstances } from "./aidlc-artifact-resolution.ts"; import { type GuardPreflightAction, type GuardPreflightResult, guardPreflight as stateGuardPreflight, } from "./aidlc-state.ts"; import { inspectStageValidity } from "./aidlc-validity.ts"; import { readRuleBundle, rulesContentEntries, type RuleContent, } from "./aidlc-steering.ts"; // Read the workflow state file if it exists, else null. The engine's `next` is // a pure read: an absent state file is a legitimate branch (no workflow yet), // not an error to throw. Composes engineStateFilePath() for the canonical location. function loadStateFileIfPresent(projectDir: string): string | null { const path = engineStateFilePath(projectDir); if (!existsSync(path)) return null; return readFileSync(path, "utf-8"); } // The default scope when neither the state file, a --scope flag, nor the // AWS_AIDLC_DEFAULT_SCOPE env var supplies one lives in aidlc-lib.ts // (DEFAULT_SCOPE, imported above) so exactly one constant plus the env var // control the implicit default everywhere. Mirrors the prose orchestrator's // freeform-fallback default (SKILL.md detect-scope fallback). // selectionAwareDefaultScope() maps it to the sole enabled plugin's // nominated default on a plugin-only install where it is deselected. // READ_ONLY_FLAGS (--status/--help/--doctor/--version) and the shared workspace // parser (space/space-create/intent) are the terminal-command sources of truth // in aidlc-lib.ts, so the engine's `next` routing and any pre-LLM harness seam // (the Kiro userPromptSubmit dispatch) classify the same tokens identically. // See classifyTerminalCommand there. // Both dispatch before any state inspection (SKILL.md "Read-Only Utility // Commands" + workspace-vision §3): each maps to a TERMINAL print directive — // the engine answers "what move?", the conductor runs the tool and prints its // stdout. The verbs never advance a workflow, so there is nothing for `next` to // continue into; they are recognised ONLY as the LEADING positional token // (parseNextFlags guards on i === 0) so freeform prose containing // "space"/"intent" mid-sentence stays freeform intent text. // --- Directive emission --- interface PreparedEmission { transported: Directive; serialized: string; resultSha256: string; projectDir?: string; marker?: { kind: "ask" | "load-steering" | "run-stage" | "invoke-swarm"; stage: string; unit?: string; units?: string[]; part?: number; parts?: number; continue_token?: string; state_sha256: string; rules_bundle?: string; directive_sha256?: string; ask_type?: string; remedies?: Array>; }; } interface PreparedLegacyPlanApproval { prepared: PreparedEmission; offer?: PlanApprovalLegacyOfferCandidate; session?: string; } let engineInvocation: { attemptId?: string; commandKind: "next" | "continue" | "report" | "park"; commandSha256: string } | null = null; let activeStageValidityAdvisory: StageValidityAdvisory | undefined; let engineProjectDir: string | undefined; function projectStageValidityAdvisory( projectDir: string, stateContent: string, ): StageValidityAdvisory | undefined { try { const validity = inspectStageValidity(projectDir, stateContent); if (validity.issues.length === 0 && validity.warnings.length === 0) { return undefined; } const direct = validity.issues .filter((issue) => issue.direct) .map((issue) => issue.stage); const downstream = validity.issues .filter((issue) => !issue.direct) .map((issue) => issue.stage); const earliest = direct[0] ?? validity.issues[0]?.stage ?? null; const state = validity.warnings.length > 0 ? "unavailable" : "drifted"; const warning = state === "drifted" ? `Completed stage results have drifted; routing is continuing in advisory mode` + (earliest ? `. Suggested redo: /aidlc --stage ${earliest}.` : ".") : `Stage-validity inspection is partly unavailable; routing is continuing in advisory mode. ${validity.warnings.join(" ")}`; return { state, directly_stale: direct, needs_revalidation: downstream, untracked: validity.untracked, earliest_affected_stage: earliest, warning, }; } catch (error) { return { state: "unavailable", directly_stale: [], needs_revalidation: [], untracked: [], earliest_affected_stage: null, warning: `Stage-validity inspection failed; routing is continuing in advisory mode: ` + errorMessage(error), }; } } let engineSessionId: string | undefined; const engineSelections = new Map(); function engineSelection(projectDir: string): WorkflowSelection { const cached = engineSelections.get(projectDir); if (cached) return cached; const selection = resolveWorkflowSelection(projectDir, { sessionId: engineSessionId, }); engineSelections.set(projectDir, selection); return selection; } function engineStateFilePath(projectDir: string): string { return stateFilePathForSelection(projectDir, engineSelection(projectDir)); } function engineRelativeRecordDir(projectDir: string): string | null { return relativeRecordDirForSelection(engineSelection(projectDir)); } function engineChildEnv( extra: Record = {}, ): Record { return { ...process.env, ...extra, ...(engineSessionId ? { AIDLC_SESSION_OVERRIDE: engineSessionId } : {}), }; } // Print exactly one directive as JSON to stdout, after validating it against // the frozen contract. A malformed directive is a hard error (clean // boundaries), never a silent miss — we exit non-zero so a wiring bug surfaces // loudly rather than emitting a lie the conductor would act on. function prepareEmission(directive: Directive): PreparedEmission { const route = directive.kind === "run-stage" ? runStageRoutes.get(directive) : undefined; const publication = publicationContexts.get(directive); // A route check asks one question: which Unit would the engine route now? It // never loads rules, so it skips transport entirely - which also keeps it from // minting the machine-local steering key on a checkout that has none. const askState = directive.kind === "ask" && engineProjectDir ? loadStateFileIfPresent(engineProjectDir) : null; let transported = directive.kind === "run-stage" && route && !isRouteCheckProbe() ? transportRunStage(directive, route) : directive; if (activeStageValidityAdvisory) { transported = { ...transported, stage_validity: activeStageValidityAdvisory, } as Directive; } // Per-unit Construction beats: `unit` is attached by callers after the // run-stage is built, so the builder's stage-entry line is wrong here (the // stage was entered on the first unit, not on this one). Every path that sets // `unit` funnels through here - stage-major, unit-major, the swarm settle, and // the continue-token rehydration - so this is the one place the rule can hold. // // Silence was the original answer and it did not survive contact: a moment // with no words is a moment the conductor fills, and what it reaches for is // the loop's own bookkeeping (which pass this is, what the gate boolean now // says). So a building beat gets ONE short line naming the two things that are // real to the user: the stage and the unit. The settle beat stays silent // because the gate ritual immediately owns that turn. if (transported.kind === "run-stage" && transported.unit !== undefined) { const line = narratePerUnitBeat(transported); if (line === null) delete transported.narration; else transported.narration = line; } const result = validateDirective(transported); if (!result.valid) { console.error( `aidlc-orchestrate: refusing to emit a malformed directive: ${result.errors.join("; ")}`, ); process.exit(1); } const serialized = JSON.stringify(result.data); if (Buffer.byteLength(serialized, "utf-8") > DIRECTIVE_MAX_BYTES) { console.error( `aidlc-orchestrate: refusing to emit a directive larger than ${DIRECTIVE_MAX_BYTES} bytes`, ); process.exit(1); } let marker: PreparedEmission["marker"]; // A guard-recovery ask is published as a marker so the human's selection has // somewhere to live across turns. Other asks keep their own machinery (the // resume choice) or none; publishing every ask would supersede a live // run-stage marker for a question the engine re-derives on every call. if ( transported.kind === "ask" && transported.ask_type === GUARD_RECOVERY_ASK_TYPE && askState !== null ) { marker = { kind: "ask", stage: transported.stage, ask_type: GUARD_RECOVERY_ASK_TYPE, ...(typeof transported.unit === "string" ? { unit: transported.unit } : {}), remedies: transported.remedies.map(({ op, action }) => ({ op, action })), state_sha256: stateDigest(askState), }; } if ((transported.kind === "load-steering" || transported.kind === "run-stage") && route) { const markerStateHash = route.stateHash ?? ( directive.kind === "run-stage" && directive.single === true && existsSync(engineStateFilePath(route.codekbCtx.projectDir)) ? stateDigest(readFileSync(engineStateFilePath(route.codekbCtx.projectDir), "utf-8")) : sha256("") ); marker = { kind: transported.kind, stage: transported.stage, ...(directive.kind === "run-stage" && directive.unit ? { unit: directive.unit } : {}), ...(transported.kind === "load-steering" ? { part: transported.part, parts: transported.parts, continue_token: transported.continue_token, } : {}), ...(preparedTransportIdentity ? { rules_bundle: preparedTransportIdentity.bundle, directive_sha256: preparedTransportIdentity.directiveSha256, } : {}), state_sha256: markerStateHash, }; } if (transported.kind === "invoke-swarm" && publication) { marker = { kind: "invoke-swarm", stage: "code-generation", units: transported.units, state_sha256: publication.stateHash, }; } return { transported, serialized, resultSha256: sha256(serialized), ...(route ? { projectDir: route.codekbCtx.projectDir } : publication ? { projectDir: publication.projectDir } : marker?.kind === "ask" && engineProjectDir ? { projectDir: engineProjectDir } : {}), ...(marker ? { marker } : {}), }; } function attachLegacyKiroPlanApprovalChoices( prepared: PreparedEmission, ): PreparedLegacyPlanApproval { const projectDir = prepared.projectDir; const directive = prepared.transported; if ( !projectDir || prepared.marker?.stage !== "code-generation" || installedHarnessName(projectDir) !== "kiro-ide" ) { return { prepared }; } const session = kiroIdeLegacyPlanApprovalSessionId(); if ( !session || readKiroIdeLegacyPlanApprovalHost(projectDir, session)?.session !== session ) { return { prepared }; } const eligible = ( directive.kind === "run-stage" && directive.stage === "code-generation" && directive.swarm_settled !== true ) || directive.kind === "invoke-swarm"; if (!eligible) return { prepared, session }; const nonce = randomBytes(6).toString("hex"); const choices: LegacyPlanApprovalChoices = { approve: `Approve Plan [${nonce}]`, request_changes: `Request Changes [${nonce}]`, }; directive.legacy_plan_approval_choices = choices; const validated = validateDirective(directive); if (!validated.valid) { throw new Error( `legacy Plan Approval choices produced an invalid directive: ${validated.errors.join("; ")}`, ); } const serialized = JSON.stringify(validated.data); if (Buffer.byteLength(serialized, "utf-8") > DIRECTIVE_MAX_BYTES) { throw new Error( "legacy Plan Approval choices exceed the directive transport limit", ); } const optionHashes = [ sha256(choices.approve.toLowerCase()), sha256(choices.request_changes.toLowerCase()), ] as [string, string]; return { prepared: { ...prepared, transported: validated.data, serialized, resultSha256: sha256(serialized), }, offer: { session, optionHashes }, session, }; } function writePrepared(prepared: PreparedEmission): void { writeFileSync(1, `${prepared.serialized}\n`, "utf-8"); } function legacyPlanApprovalRecoveryDirective(): AskDirective { return { kind: "ask", ask_type: "legacy-plan-approval-recovery", response_route: "next", recovery_choice: LEGACY_PLAN_APPROVAL_RECOVERY_CHOICE, question: "This legacy Kiro window must recover the current Code Generation Plan Approval capability before it can be reissued. Choose exactly: Recover Plan Approval", }; } // Whether the issued marker already IS this guard-recovery ask for this state. function guardRecoveryAskMarkerIsCurrent( projectDir: string, marker: NonNullable, ): boolean { const state = loadStateFileIfPresent(projectDir); if (state === null) return false; const current = currentGuardRecoveryAskMarker( projectDir, state, marker.stage, marker.unit, ); return current !== null && current.state_sha256 === marker.state_sha256 && current.remedies !== undefined && marker.remedies !== undefined && current.remedies.length === marker.remedies.length && current.remedies.every((remedy, index) => remedy.op === marker.remedies?.[index]?.op && remedy.action === marker.remedies[index]?.action ); } function emit(directive: Directive): void { const withLegacyOffer = attachLegacyKiroPlanApprovalChoices( prepareEmission(directive), ); const prepared = withLegacyOffer.prepared; // An observer never publishes. Publishing from a query bumped the directive's // issuance identity and used to delete the plan-approval runtime dir, so the // challenge minted in turn N was destroyed by turn N's own Stop probe and an // approval could never be recorded. The suppression is not team-specific: the // hook parses the directive off stdout for every Unit Ownership. // The same guard-recovery ask for the same state is the same question: the // issued marker is kept as it is, so a selection the human already made on it // (recorded by the human-turn hook as consumed) survives the re-ask. Routing // is recomputed every time; only the marker rewrite is skipped. const sameGuardRecoveryAsk = prepared.marker?.kind === "ask" && prepared.marker.ask_type === GUARD_RECOVERY_ASK_TYPE && prepared.projectDir !== undefined && guardRecoveryAskMarkerIsCurrent(prepared.projectDir, prepared.marker); if ( prepared.marker && !isReadOnlyEngineProbe() && !retainedIssuedDirective && !sameGuardRecoveryAsk ) { const projectDir = prepared.projectDir; try { if (projectDir) { const publication = writeActiveDirectiveMarker(projectDir, prepared.marker, { ...(engineInvocation?.attemptId ? { attemptId: engineInvocation.attemptId } : {}), ...(engineInvocation ? { commandKind: engineInvocation.commandKind } : {}), ...(engineInvocation ? { commandSha256: engineInvocation.commandSha256 } : {}), ...(withLegacyOffer.offer ? { legacyPlanApprovalOffer: withLegacyOffer.offer } : {}), ...(withLegacyOffer.session ? { legacyPlanApprovalSession: withLegacyOffer.session } : {}), resultSha256: prepared.resultSha256, }); if (publication === "legacy-plan-approval-owned") { writePrepared(prepareEmission(errorDirective( "Legacy Kiro Plan Approval is owned by another active IDE window. Continue the pending approval there; this call did not receive or rotate its protected choices.", ))); return; } if (publication === "legacy-plan-approval-recovery-required") { writePrepared( prepareEmission(legacyPlanApprovalRecoveryDirective()), ); return; } if ( publication === "legacy-plan-approval-reissued" || publication === "legacy-plan-approval-transport" ) { writePrepared(prepared); return; } if (publication === "stale-attempt") { recordHookDrop(projectDir, "active-directive", "tracked fresh next attempt was superseded before publication"); writePrepared(prepareEmission(errorDirective( "This tracked `next` attempt is stale or superseded, so its prepared result was not issued. Run a fresh `next` in the current Copilot session.", ))); return; } if (publication !== "copilot-committed" && publication !== "generic-committed") { recordHookDrop(projectDir, "active-directive", "fresh next did not commit its directive"); writePrepared(prepareEmission(errorDirective( "The directive could not be published, so no work directive was issued. Retry the command; if coordination remains busy, run `/aidlc --doctor`.", ))); return; } } } catch (e) { // A barrier violation is an engine defect, not a workflow problem, and must // surface as a non-zero exit: both observers fail safe on that (the Stop // hook allows the stop and records a drop; the route check reports the // error). Turning it into an `error` directive with exit 0 would tell the // conductor to stop and print a message, hiding the defect. if (e instanceof EngineModeViolationError) throw e; if (projectDir) { recordHookDrop(projectDir, "active-directive", errorMessage(e)); } writePrepared(prepareEmission(errorDirective( "The directive could not be published, so no work directive was issued. Retry the command; if coordination remains busy, run `/aidlc --doctor`.", ))); return; } } writePrepared(prepared); } // --- Composing sibling CLI tools --- // // The non-happy-path branches reuse aidlc-jump.ts / aidlc-utility.ts handlers, // none of which is importable (both files export zero CLI handlers). We resolve // the tools directory off THIS module's own location in source mode. A compiled // executable re-enters the public dispatcher grammar instead. const TOOLS_DIR = dirname(fileURLToPath(import.meta.url)); const IS_COMPILED = isCompiledExecutable(); function isKiroRoutingHarness(): boolean { if (IS_COMPILED) { const explicit = process.env.AIDLC_HARNESS_NAME?.trim(); return explicit === "kiro" || explicit === "kiro-ide"; } const invokedScript = (process.argv[1] ?? "").replaceAll("\\", "/"); if (/(^|\/)\.kiro\/tools\/aidlc-orchestrate\.ts$/.test(invokedScript)) { return true; } try { const parsed = JSON.parse( readFileSync(join(TOOLS_DIR, "data", "harness.json"), "utf-8"), ) as { name?: unknown }; return parsed.name === "kiro" || parsed.name === "kiro-ide"; } catch { // Authored core and compiled binaries can lack generated metadata. const explicit = process.env.AIDLC_HARNESS_NAME?.trim(); return explicit === "kiro" || explicit === "kiro-ide"; } } function toolPath(file: string): string { return join(TOOLS_DIR, file); } function toolCommand(toolFile: string, args: string[]): string[] { if (IS_COMPILED) { if (toolFile === "aidlc-utility.ts" && args[0] === "resolve-env-scope") { return [process.execPath, "engine", "scope", "resolve-env", ...args.slice(1)]; } if (toolFile === "aidlc-jump.ts") { return [process.execPath, "engine", "jump", ...args]; } throw new Error(`No compiled dispatcher route for ${toolFile} ${args.join(" ")}`); } return [process.execPath, toolPath(toolFile), ...args]; } // The result of spawning a sibling tool: its exit code plus captured streams. // stderr carries the tool's canonical error envelope on a non-zero exit (the // shared die()/emitError() helper prints `{"error":""}` to // stderr and exits 1), which we relay UNCHANGED into an error directive. interface ToolRun { ok: boolean; stdout: string; stderr: string; } function runTool(toolFile: string, args: string[]): ToolRun { const proc = Bun.spawnSync({ cmd: toolCommand(toolFile, args), env: engineChildEnv(), stdout: "pipe", stderr: "pipe", }); return { ok: proc.exitCode === 0, stdout: new TextDecoder().decode(proc.stdout), stderr: new TextDecoder().decode(proc.stderr), }; } // Extract the human-facing message from a tool's failure. The shared error // helper prints `{"error":""}` to stderr; we unwrap that envelope so // the directive carries the message itself (e.g. the verbatim // `Invalid AWS_AIDLC_DEFAULT_SCOPE "...". Valid scopes: ...`) rather than the // JSON wrapper. If stderr is not the expected envelope (an unexpected crash), // fall back to the raw stderr so nothing is swallowed. function toolErrorMessage(run: ToolRun): string { const raw = run.stderr.trim(); try { const parsed: unknown = JSON.parse(raw); if ( parsed !== null && typeof parsed === "object" && "error" in parsed && typeof (parsed as { error: unknown }).error === "string" ) { return (parsed as { error: string }).error; } } catch { // Not JSON — fall through to the raw text. } return raw.length > 0 ? raw : run.stdout.trim(); } // --- Narration (the spoken line the conductor relays) --- // // Every line below is authored HERE, next to the facts, because the engine knows // them deterministically and the conductor does not have to guess. Left to // improvise, the conductor narrates what it can see - the tool it ran, the kind // it received, the routing it is following - which is the machinery, not the // user's project. These lines describe the work instead. // // House style for anything added here: // - One sentence. Two only when the second one tells the user what to expect. // - About the user's project, never about this framework's parts. No internal // nouns: the reader has no engine, no directive, no dispatch, no conductor. // - Present tense, first person, plain. "Setting up ...", "Starting ...". // - Name real things by their real names: stage display names, scope names, // and file paths are the user's landmarks and stay verbatim. // - Say nothing a reader would have to already know the framework to parse. // // A line is deliberately ABSENT for beats that should be silent: rule-bundle // transport, per-unit iteration beats, and anything the user did not ask about. // Absence is the instruction to say nothing, and it is the common case. // The user-facing name for a phase. The graph's phase tokens are SHOUTED // machine values (IDEATION); spoken prose wants ordinary words. function phaseInWords(phase: string): string { const normalized = phase.trim().toLowerCase(); if (normalized.length === 0) return ""; return normalized; } // The first run-stage of a workflow is the one place a spoken line can set the // whole frame: what kind of plan is running, and what the first real step is. // Later stages get the shorter per-stage line. function narrateStageEntry( node: GraphStage, scope: string, isFirst: boolean, gate: GateValue, ): string { const stageName = node.name; if (isFirst) { return ( `Starting the ${scope} plan for this project. First step is ${stageName}, ` + `and I will stop for your review before anything is final.` ); } // Entering the build phase names a piece of vocabulary the user is about to // see in their own artifacts (bolt-plan.md, and every later beat of this // phase), so the line that introduces it defines it in the same breath. The // definition is delivery-planning's own, said the way a colleague would say // it. Said once, on the phase boundary; later Construction stages get the // ordinary per-stage line. if (isFirstConstructionStage(node, scope)) { return ( `Starting the first Bolt now: one build pass over the code, tests and ` + `checks for a piece of the work. First step is ${stageName}.` ); } // A non-gating stage runs straight through, so the line says so rather than // leaving the user waiting for a prompt that is not coming. if (gate === false) { return `Next up: ${stageName}. This one runs through without needing your input.`; } // Who is in the room. On an inline stage the session adopts the lead's // perspective and any supports as further perspectives, and that is worth one // clause: the user is meeting colleagues by trade, which is a fact about their // project's work, where "loaded the persona files" is a fact about ours. return `Now working on ${stageName}, ${peopleClause(node)}.`; } // The trades participating in an inline stage, phrased as a person would: // "wearing the product manager hat, with the architect on hand". Falls back to // the phase clause when no trade resolves, so a stage never gets a broken line. function peopleClause(node: GraphStage): string { const lead = roleInWords(node.lead_agent); if (!lead) return `in the ${phaseInWords(node.phase)} phase`; const supports = (node.support_agents ?? []) .map(roleInWords) .filter((trade) => trade.length > 0); if (supports.length === 0) return `wearing the ${lead} hat`; const list = supports.length === 1 ? supports[0] : `${supports.slice(0, -1).join(", ")} and ${supports[supports.length - 1]}`; return `wearing the ${lead} hat, with the ${list} on hand`; } // A dispatched stage hands the work to a named specialist. The user cares that // someone with a particular focus is doing it, not that a Task call happened. function narrateSpecialistStage(node: GraphStage): string { const role = roleInWords(node.lead_agent); return role ? `Bringing in the ${role} to work on ${node.name}.` : `Now working on ${node.name}.`; } // The spoken line for ONE iteration of a per-unit Construction stage. Called // from emit(), the single choke point every unit-carrying directive passes // through, and deliberately the SHORTEST line in this file: the user is watching // the same stage name go past once per piece of work, so anything longer reads // as repetition. Two facts, both theirs: the stage, and which piece of their // work it is running for. // // null = say nothing. That is the settle beat (gate not false), where the stage // is fully built and the very next thing the conductor does is present the gate // ritual, which owns its own words. A line here would preface that with a // re-announcement of a stage the user has already watched run. // // The placeholder unit (a scope with no unit DAG) is not a real name, so it // falls back to the stage alone rather than saying the token out loud. function narratePerUnitBeat(directive: RunStageDirective): string | null { if (directive.gate !== false) return null; const unit = directive.unit; if (unit === undefined || unit === UNIT_NAME_PLACEHOLDER) return null; const stageName = nodeForSlug(directive.stage)?.name ?? directive.stage; return `Now working on ${unit}: the ${stageName} pass.`; } // True when `node` is the FIRST in-scope Construction stage, i.e. the stage the // workflow crosses the Construction boundary on. Reuses the same resolution the // walking-skeleton gate uses (isSkeletonGateStage), so "the first Bolt" means // the same stage to the spoken line as it does to the gate. function isFirstConstructionStage(node: GraphStage, scope: string): boolean { return isSkeletonGateStage(node, scope); } // Turn an agent filename into the TRADE a person would say out loud: // aidlc-architect-agent -> "architect", aidlc-product-agent -> "product manager". // The user is meeting a colleague, so the words are the ones a colleague would // use about themselves; a slug fragment like "product" or "aws platform" is not // one. Unmapped names fall back to the de-slugged fragment, and an unfamiliar // shape returns "" so the caller can drop the role clause rather than invent it. const TRADE_BY_ROLE: Readonly> = { product: "product manager", "product lead": "product lead", design: "designer", delivery: "delivery lead", architect: "architect", "architecture reviewer": "architecture reviewer", "aws platform": "platform engineer", compliance: "compliance specialist", devsecops: "security engineer", developer: "developer", quality: "quality engineer", "pipeline deploy": "release engineer", operations: "operations engineer", }; function roleInWords(agent: string): string { const match = /^aidlc-(.+)-agent$/.exec(agent.trim()); if (!match) return ""; const fragment = match[1].replaceAll("-", " "); return TRADE_BY_ROLE[fragment] ?? fragment; } // Record that the engine was ADVANCED this turn, for the Stop hook's // conversational carve-out on transcript-free harnesses (Kiro, opencode). The // hook compares .aidlc-engine-touch's mtime against .aidlc-human-turn's: newer // engine => the conductor engaged the workflow => a bail mid-loop must still be // nudged; older => the human's last prompt was answered as pure chat. // // TWO exclusions keep the marker honest, and BOTH are load-bearing: // 1. The Stop hook's OWN `next` probe. markEngineTouch is a no-op when // STOP_HOOK_PROBE_ENV is set (aidlc-lib.ts). Without it the hook's own // consultation would refresh the marker on every stop, the predicate would // be permanently false, and the carve-out would be silently dead code. // 2. Read-only routing (--status / --doctor / --help / --version, and the // workspace verbs). These carry no workflow intent, so counting them as // engagement would make "what's my status?" a non-conversational turn. // isEngineToolCall exempts the same read-only flags, so the two predicates // agree HERE — but they do not agree everywhere: the marker is blind to // aidlc-jump / aidlc-bolt / aidlc-swarm and the mutating aidlc-state verbs, // which the transcript predicate does count. See the coverage-gap note on // markEngineTouch in aidlc-lib.ts; do not restate this as full parity. // Advisory throughout: a marker failure must never fail an engine invocation. function touchEngineMarker(projectDir: string | undefined): void { try { markEngineTouch(resolveProjectDir(projectDir)); } catch { /* advisory - the marker is a Stop-hook optimisation, never a hard dependency */ } } // --- Terminal-directive constructors (the non-run-stage kinds) --- function askDirective(question: string): AskDirective { return { kind: "ask", question }; } function newWorkRoutingAskDirective( question: string, numberedProseQuestion: string, description: string, proposedScope: string, availableIntents?: string[], ): AskDirective { // Once emitted, this typed ask is the sole route authority for the pending // prose. Harnesses render it and stop rather than reclassifying the request. return { kind: "ask", ask_type: "new-work-routing", response_route: "next", question, numbered_prose_question: numberedProseQuestion, new_work_description: description, proposed_scope: proposedScope, ...(availableIntents ? { available_intents: availableIntents } : {}), }; } function printDirective(message: string): PrintDirective { return { kind: "print", message }; } function noticeDirective(message: string): NoticeDirective { return { kind: "notice", message }; } function unitClaimAskDirective( overview: ReturnType, ): AskDirective { const claimed = overview.claimed.length === 0 ? "none" : overview.claimed.map((row) => `${row.unit} (${row.owner})`).join(", "); const waiting = overview.waiting.length === 0 ? "none" : overview.waiting .map((row) => `${row.unit} waits on ${row.blockedBy.join(", ")}`) .join("; "); return { kind: "ask", ask_type: "unit-claim", response_route: "claim", question: `Choose a Unit to claim. Claimable: ${overview.claimable.join(", ") || "none"}. ` + `Claimed: ${claimed}. Waiting: ${waiting}.`, claimable_units: overview.claimable, claimed_units: overview.claimed.map((row) => ({ unit: row.unit, holder: row.owner, })), waiting_units: overview.waiting.map((row) => ({ unit: row.unit, blocked_by: row.blockedBy, })), }; } export interface TeamConstructionBoard { grid: string; claims: Array<{ unit: string; status: "claimed" | "released"; owner: string; generation: number; observedActivity: string; }>; awaitingMerge: Array<{ unit: string; status: string; pinnedOid: string; readiness: string; releasedAfterGitAccepted: boolean; }>; claimable: string[]; blocked: Array<{ unit: string; blockedBy: string[] }>; warning?: string; fanoutActive: boolean; } function observedClaimActivity( claim: UnitClaimOverview["claims"] extends Map ? T : never, ): string { if (claim.movementObserved) { return claim.observedAt ? `observed ref movement since ${claim.observedAt}` : "observed ref movement since the prior snapshot"; } return claim.observedAt ? `last observed ref movement ${claim.observedAt}` : "no ref movement observation recorded"; } function mergeReadiness(status: string): string { switch (status) { case "pinned": return "pinned and ready for merge gate"; case "approved": return "merge gate approved; ready to land"; case "rejected": return "merge gate rejected; revise and re-pin"; case "git-landed": return "content landed; state fold pending"; case "state-folded": return "state folded; audit finalization pending"; default: return status; } } function compareBoardKeys(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } export function buildTeamConstructionBoard( projectDir: string, stateContent: string, options: { readOnly?: boolean; overview?: UnitClaimOverview; } = {}, ): TeamConstructionBoard { const overview = options.overview ?? cachedUnitClaimOverview(projectDir, { writeCache: options.readOnly !== true, }); const model = deriveTeamUnitProgressModel( projectDir, stateContent, undefined, undefined, { readOnly: options.readOnly === true }, ); return assembleTeamConstructionBoard( model.section, overview, unitMergeTransactions(projectDir), ); } function unitProgressSectionFromState(stateContent: string): string | null { const heading = /^## Unit Progress\s*$/m.exec(stateContent); if (!heading) return null; const after = heading.index + heading[0].length; const next = /^## /m.exec(stateContent.slice(after)); return stateContent .slice(heading.index, next ? after + next.index : stateContent.length) .trimEnd(); } function initialUnitProgressSection( stateContent: string, dependencyBody: string, overview: UnitClaimOverview, transactions: ReturnType, ): string { const parsed = parseBoltDag(dependencyBody); if (!parsed.ok || parsed.units.length === 0) { throw new Error( `Team Construction board requires a valid non-empty Unit DAG: ${ parsed.ok ? "no Units found" : `${parsed.reason}: ${parsed.detail}` }.`, ); } const scope = getField(stateContent, "Scope") ?? ""; const stages = unitMajorConstructionStageSlugs(scope, stateContent, true); if (stages.length === 0) { throw new Error( "Team Construction board found no active per-Unit Construction stages.", ); } const transactionByUnit = new Map( transactions.map((transaction) => [transaction.unit, transaction]), ); const mergeTracking = transactions.length > 0; return [ "## Unit Progress", "", `| unit | owner | ${stages.join(" | ")} | gate |${ mergeTracking ? " merged |" : "" }`, `| --- | --- | ${stages.map(() => "---").join(" | ")} | --- |${ mergeTracking ? " --- |" : "" }`, ...parsed.units.map((entry) => { const claim = overview.claims.get(entry.name); const transaction = transactionByUnit.get(entry.name); const owner = claim?.status === "claimed" ? claim.owner : transaction?.owner ?? "-"; return `| ${entry.name} | ${owner} | ${ stages.map(() => "[ ]").join(" | ") } | [ ] |${ mergeTracking ? ` ${transaction?.status === "complete" ? "[x]" : "[ ]"} |` : "" }`; }), ].join("\n"); } export function buildTeamConstructionBoardForIntent( projectDir: string, stateContent: string, selector: { space: string; intentUuid: string; dependencyBody: string; }, ): TeamConstructionBoard { const overview = localUnitClaimOverviewForIntent(projectDir, { space: selector.space, intentUuid: selector.intentUuid, stateContent, dependencyBody: selector.dependencyBody, }); const transactions = unitMergeTransactionsForIdentity( projectDir, selector.space, selector.intentUuid, ); const grid = unitProgressSectionFromState(stateContent) ?? initialUnitProgressSection( stateContent, selector.dependencyBody, overview, transactions, ); return assembleTeamConstructionBoard( grid, overview, transactions, ); } function assembleTeamConstructionBoard( grid: string, overview: UnitClaimOverview, transactions: ReturnType, ): TeamConstructionBoard { const claims = [...overview.claims.values()] .sort((a, b) => compareBoardKeys(a.unit, b.unit)) .map((claim) => ({ unit: claim.unit, status: claim.status, owner: claim.owner, generation: claim.generation, observedActivity: observedClaimActivity(claim), })); const awaitingMerge = transactions .filter((transaction) => transaction.status !== "complete") .sort((a, b) => compareBoardKeys(a.unit, b.unit)) .map((transaction) => ({ unit: transaction.unit, status: transaction.status, pinnedOid: transaction.pinned_oid, readiness: mergeReadiness(transaction.status), releasedAfterGitAccepted: transaction.released_after_git !== undefined, })); const claimable = [...overview.claimable].sort(); const blocked = overview.waiting .map((row) => ({ unit: row.unit, blockedBy: [...row.blockedBy].sort(), })) .sort((a, b) => compareBoardKeys(a.unit, b.unit)); return { grid, claims, awaitingMerge, claimable, blocked, ...(overview.warning ? { warning: overview.warning } : {}), fanoutActive: awaitingMerge.length > 0 || overview.claimed.length > 0, }; } export function renderTeamConstructionBoard( board: TeamConstructionBoard, mode: "dispatcher" | "snapshot", ): string { const title = mode === "snapshot" ? "# Team Construction Snapshot" : "# Team Construction Dispatcher"; const claimRows = board.claims.length === 0 ? ["| - | - | - | - | no claim refs observed |"] : board.claims.map( (claim) => `| ${claim.unit} | ${claim.status} | ${claim.owner} | ${claim.generation} | ${claim.observedActivity} |`, ); const mergeRows = board.awaitingMerge.length === 0 ? ["| - | - | - | none |"] : board.awaitingMerge.map( (row) => `| ${row.unit} | ${row.status} | \`${row.pinnedOid.slice(0, 12)}\` | ${row.readiness} |`, ); const blockedRows = board.blocked.length === 0 ? ["| - | none |"] : board.blocked.map( (row) => `| ${row.unit} | ${row.blockedBy.join(", ")} |`, ); const claimedSummary = board.claims .filter((claim) => claim.status === "claimed") .map((claim) => `${claim.unit} (${claim.owner})`) .join(", ") || "none"; const blockedSummary = board.blocked .map((row) => `${row.unit} waits on ${row.blockedBy.join(", ")}`) .join("; ") || "none"; const nextActions: string[] = []; const reclaimable = new Set([ ...board.claimable, ...board.claims .filter((claim) => claim.status === "released") .map((claim) => claim.unit), ]); for (const unit of [...reclaimable].sort(compareBoardKeys)) { nextActions.push( `- Claim or reclaim \`${unit}\` when eligible with \`/aidlc --claim ${unit}\`.`, ); } for (const row of board.awaitingMerge) { if (row.status === "pinned") { nextActions.push( `- Record the pinned merge decision for \`${row.unit}\` with \`aidlc unit gate ${row.unit}\`.`, ); } else if ( row.status === "approved" || row.status === "git-landed" || row.status === "state-folded" ) { const released = board.claims.some( (claim) => claim.unit === row.unit && claim.status === "released", ); nextActions.push( released && row.status === "git-landed" && !row.releasedAfterGitAccepted ? `- The landed attempt for \`${row.unit}\` was released. Inspect the merge commit, then run \`aidlc unit land ${row.unit} --accept-released-attempt --user-input ""\`.` : `- Resume \`${row.unit}\` with \`aidlc unit land ${row.unit}\`.`, ); } else if (row.status === "rejected") { nextActions.push( `- Revise, publish, and re-pin \`${row.unit}\` before requesting another merge gate.`, ); } } if (nextActions.length === 0) { nextActions.push( "- No dispatcher action is pending; main may resume the normal Construction walk.", ); } return [ title, mode === "snapshot" ? "_Read-only local snapshot; observed activity is not a remote push time._" : "_Turn-terminal dispatcher view; observed activity is not a remote push time._", `**Claimed:** ${claimedSummary}. **Claimable:** ${ board.claimable.join(", ") || "none" }. **Waiting:** ${blockedSummary}.`, "", board.grid, "", "## Claim Registry", "| unit | status | owner | attempt | observed activity |", "| --- | --- | --- | ---: | --- |", ...claimRows, "", "## Awaiting Merge", "| unit | transaction | pinned OID | readiness |", "| --- | --- | --- | --- |", ...mergeRows, "", `## Claimable Units\n${board.claimable.length > 0 ? board.claimable.map((unit) => `- ${unit}`).join("\n") : "- none"}`, "", "## Blocked Units", "| unit | blockers |", "| --- | --- |", ...blockedRows, "", "## Next Actions", ...nextActions, ...(board.warning ? ["", `> Warning: ${board.warning}`] : []), ].join("\n"); } function errorDirective(message: string): ErrorDirective { return { kind: "error", message }; } // State-schema-version guard. The classifier (aidlc-lib.ts // `classifyStateVersion`) is the single source of truth for parsing and // classifying `- **State Version**: N` lines; runtime (next/report) and doctor // call it the same way so they can never disagree on whether a state is // unparseable / past / future / ok. staleStateVersionError() is the runtime // adapter: it returns the classifier's message on any incompatible verdict and // null on `ok`, so next/report can emit the message as an errorDirective // before any workflow-cursor read/advance. function staleStateVersionError(stateContent: string): string | null { const verdict = classifyStateVersion(stateContent); return verdict.kind === "ok" ? null : verdict.message; } function shellArg(value: string): string { if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; return `'${value.replaceAll("'", "'\"'\"'")}'`; } // parked - the terminal directive a parked workflow emits (issue #367). Carries // the slug it parked at; the Stop hook treats `parked` as a terminal allow so // the conductor can end its turn at a clean inter-stage boundary. function parkedDirective(reason: string, stage: string): ParkedDirective { return { kind: "parked", reason, stage, // Parking is the one stop that a user could mistake for a crash, so the // spoken line says the work is safe and names the way back in. narration: "Pausing here with everything saved. Run `/aidlc --resume` when you want to pick it back up.", }; } // Workspace detection can serve several scope examples in one routing answer; // cache it so a process scans each project root at most once. const workspaceProjectType = new Map(); function detectedProjectType(projectDir: string): string | null { if (workspaceProjectType.has(projectDir)) { return workspaceProjectType.get(projectDir) ?? null; } let projectType: string | null = null; try { projectType = detectWorkspace(projectDir).projectType.toLowerCase(); } catch { // Cost disclosure must never block routing; nominal counts remain useful. } workspaceProjectType.set(projectDir, projectType); return projectType; } function effectiveScopeCostSummary(scope: string, projectDir: string) { const nominal = scopeCostSummary(scope); if (!nominal) return null; const definition = loadScopeMapping()[scope]; if ( definition?.stages["reverse-engineering"] !== "EXECUTE" || detectedProjectType(projectDir) !== "greenfield" ) { return nominal; } const adjusted = { ...definition.stages, "reverse-engineering": "SKIP" as const }; return gridCostSummary(adjusted); } // The one-line ceremony preview for a scope, deterministic from the effective // compiled grid: "N of T stages, G approval gates" plus a per-unit clause when // Construction stages fan out per Unit of Work. Greenfield previews apply the // same reverse-engineering adjustment intent creation writes into state. // Returns "" for a scope that does not resolve (a fixture tree without it), so // callers can drop the whole clause rather than emit a broken preview. function costClause(scope: string, projectDir: string): string { const c = effectiveScopeCostSummary(scope, projectDir); if (!c) return ""; const perUnit = c.perUnitStages > 0 ? `, ${c.perUnitStages} ${c.perUnitStages === 1 ? "stage repeats" : "stages repeat"} per unit of work in Construction` : ""; return `${c.execute} of ${c.total} stages, ${c.gates} approval gates${perUnit}`; } // --- Flag parsing --- interface ParsedFlags { scope?: string; positionalScope?: string; // leading valid scope token (e.g. `/aidlc bugfix Fix the crash`) stage?: string; phase?: string; depth?: string; testStrategy?: string; review?: string; // --review : per-run review-class override changeControl?: string; // --change-control : the per-intent Change Control value readOnly?: string; // the matched read-only flag, if any readOnlyArgs?: string[]; // allowlisted trailing args for the read-only flag (e.g. --doctor --export --output ) config?: boolean; // --config [section]: terminal in-session project configuration alias configSection?: ConfigSection; resume?: boolean; // --resume: continue an existing workflow directly single?: boolean; // --single: run ONE stage under a synthetic workflow id, never touching the main pointer newIntent?: boolean; // --new-intent: the conductor confirmed new-work alongside an active intent → emit the SAME creation directive (with the --label seam) the fresh-start path uses, instead of constructing intent-create from SKILL.md prose intent?: string; // freeform request text (no leading --flag) workspaceCommand?: WorkspaceCommand; // leading workspace command (space/space-create/intent) pluginCommand?: Exclude; // leading plugin noun: terminal list/sync/select/help/error knowledgeCommand?: Exclude; // leading knowledge noun: terminal DocumentKB verbs/help/error compose?: boolean; // leading `compose` verb: force the composer (front or in-flight) newScope?: boolean; // --new-scope: force the composer to SYNTHESIZE a custom scope even when a stock scope matches report?: string; // --report : compose from a scan report (the composer triages the file) claim?: string; release?: string; claimTeam?: string; claimRhythm?: string; projectDir?: string; parseError?: string; } const CONFIG_SECTIONS = [ "models", "runtime", "providers", "trust", "flags", "project", ] as const; type ConfigSection = (typeof CONFIG_SECTIONS)[number]; // Extract the flags the `next` decision rule consumes. --project-dir is pulled // out by the caller before this runs; here we read scope/stage/phase/depth/ // test-strategy, the boolean mode flags (--resume/--single), and detect a // read-only utility flag. Any leading non-flag token is the freeform intent // (mirrors `/aidlc `). Mirrors the prose orchestrator's // flag extraction — the value of a valued flag is the following argv token. function parseNextFlags(args: string[]): ParsedFlags { // A SOLE bare `help` / `-h` token is a help REQUEST, not intent text. Without // this, the token falls into intentWords and the freeform funnel offers to // create an intent literally named "help" (fresh workspace) or silently // advances the active stage (live workflow). Sole-token only: `help` inside a // longer description ("help me build auth") stays freeform intent text. // PARITY: classifyTerminalCommand (aidlc-lib.ts) mirrors this rule - the Kiro // verb-intercept seam and the engine must never disagree on what is terminal. if (args.length === 1 && (args[0] === "help" || args[0] === "-h")) { return { readOnly: "--help" }; } const configIndex = args.indexOf("--config"); if (configIndex >= 0) { const trailing = args.slice(configIndex + 1); if ( configIndex !== 0 || trailing.length > 1 || (trailing.length === 1 && !(CONFIG_SECTIONS as readonly string[]).includes(trailing[0])) ) { return { parseError: "Usage: /aidlc --config [models|runtime|providers|trust|flags|project].", }; } return { config: true, ...(trailing[0] ? { configSection: trailing[0] as ConfigSection } : {}), }; } const pluginCommand = parsePluginCommand(args); if (pluginCommand.kind !== "not-plugin") return { pluginCommand }; const knowledgeCommand = parseKnowledgeCommand(args); if (knowledgeCommand.kind !== "not-knowledge") return { knowledgeCommand }; // Leading workspace nouns own the command. Any later read-only-looking token // is part of that workspace command's argv, not a mode switch, because the // public grammar promises leading-token semantics. const workspaceCommand = parseWorkspaceCommand(args); if (workspaceCommand.kind !== "not-workspace") { if (workspaceCommand.kind === "help") return { readOnly: "--help" }; return { workspaceCommand }; } const flags: ParsedFlags = {}; const intentWords: string[] = []; let literalIntent = false; for (let i = 0; i < args.length; i++) { const a = args[i]; if (literalIntent) { intentWords.push(a); continue; } if (a === "--") { literalIntent = true; continue; } if (READ_ONLY_FLAGS.has(a)) { flags.readOnly = a; continue; } // Allowlisted trailing args for `--doctor`: `--export` and `--verbose` // (booleans), plus `--output `. Recognised ONLY once `--doctor` has matched, so they // never leak into another read-only flag or into freeform intent text. // Kept as a fixed allowlist (mirrored by classifyTerminalCommand in // aidlc-lib.ts) so an arbitrary token can never ride the read-only path // into the tool. The value of `--output` is the following non-flag token. if ( flags.readOnly === "--doctor" && (a === "--export" || a === "--output" || a === "--verbose") ) { flags.readOnlyArgs = flags.readOnlyArgs ?? []; flags.readOnlyArgs.push(a); if (a === "--output") { const next = args[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags.readOnlyArgs.push(next); i++; } } continue; } // A LEADING `compose` verb forces the composer (front on a fresh workspace, // in-flight recompose over an active one). DELIBERATELY its own check, NOT a // WORKSPACE_VERBS entry: that set feeds classifyTerminalCommand, which the // Kiro verb-intercept hook runs OFF-BAND as a terminal aidlc-utility // subcommand (and arms the roll-forward latch) - compose is workflow work // the conductor must dispatch, never a terminal utility. Only the FIRST // positional token counts, so freeform prose containing "compose" // mid-sentence stays intent text. Any text after the verb is the compose // request (falls through to intentWords). if (i === 0 && a === "compose") { flags.compose = true; continue; } if (a === "--resume") { flags.resume = true; } else if (a === "--single") { flags.single = true; } else if (a === "--new-intent") { flags.newIntent = true; } else if (a === "--scope" && i + 1 < args.length) { flags.scope = args[i + 1]; i++; } else if (a === "--stage" && i + 1 < args.length) { flags.stage = args[i + 1]; i++; } else if (a === "--phase" && i + 1 < args.length) { flags.phase = args[i + 1]; i++; } else if (a === "--depth" && i + 1 < args.length) { flags.depth = args[i + 1]; i++; } else if (a === "--test-strategy" && i + 1 < args.length) { flags.testStrategy = args[i + 1]; i++; } else if (a === "--review") { const value = args[i + 1]; if (value === undefined || value.startsWith("--")) { flags.parseError = "--review requires ."; } else { flags.review = value; i++; } } else if (a === "--change-control") { const value = args[i + 1]; if (value === undefined || value.startsWith("--")) { flags.parseError = "--change-control requires ."; } else { const parsed = parseChangeControl(value); if (parsed === null) { flags.parseError = `--change-control requires ; received "${value}".`; } else { flags.changeControl = parsed; } i++; } } else if (a === "--new-scope") { flags.newScope = true; } else if (a === "--report" && i + 1 < args.length) { // CONSUME the value: an unrecognized valued flag would leak its value // into the freeform intent text (the path would read as intent words). flags.report = args[i + 1]; i++; } else if (a === "--claim" && i + 1 < args.length) { flags.claim = args[i + 1]; i++; } else if (a === "--claim") { flags.parseError = "--claim requires ."; } else if (a === "--release" && i + 1 < args.length) { flags.release = args[i + 1]; i++; } else if (a === "--release") { flags.parseError = "--release requires ."; } else if (a === "--team" && i + 1 < args.length) { flags.claimTeam = args[i + 1]; i++; } else if (a === "--team") { flags.parseError = "--team requires