import { createHash, randomUUID } from "node:crypto"; import { cpSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as winPath, } from "node:path"; import { pathToFileURL } from "node:url"; import { appendAuditEntries, appendAuditEntry, appendAuditEntryUnlocked, type AuditEntryInput, } from "./aidlc-audit.ts"; import { VERSION_ID } from "./aidlc-channel.ts"; import { main as pluginBuildMain } from "./aidlc-plugin-build.ts"; import { main as pluginValidateMain } from "./aidlc-plugin-validate.ts"; import { type LegacyDoctorResult, redactSecretPatterns, } from "./aidlc-doctor-bundle.ts"; import { artifactsRegistryFor, consumedArtifactProducerCollisions, findCycles, frameworkMemorySeedDir, loadGraph, loadRules, memoryDirFor, selectionDroppedOrderingEdges, stageGraphDrift, type GraphStage, validateGrid, validateScope, } from "./aidlc-graph.ts"; import { repointHarnessIncludes } from "./aidlc-includes.ts"; import { TRUSTED_COMMAND_PREFIX, TRUSTED_COMMAND_TOKENS, trustedCommand, } from "./aidlc-command.ts"; import { workspaceManifestChecks } from "./aidlc-workspace-doctor.ts"; import { instructionFileDoctorCheck, runtimeDoctorChecks, } from "./aidlc-config-diagnostics.ts"; import { type cachedUnitClaimOverview, localUnitClaimOverviewForIntent, main as unitMain, } from "./aidlc-unit.ts"; import { activeIntent, activeSpace, authoritativeProjectDescription, assertNoSymlinkInChainOrThrow, auditBlockField, auditFilePath, auditShards, assertChangeControlLedgerWritable, CHANGE_CONTROL_FIELD, CHANGE_CONTROL_VALUES, type ChangeControlMemoryDeclaration, changeControlMemoryStrictRefusal, formatChangeControl, memoryChangeControlDeclarations, parseChangeControl, recordChangeControlSet, resolveChangeControl, createIntent, composeMarkerPath, COMPOSE_MARKER_TTL_MS, DEFAULT_SCOPE, DEFAULT_SPACE, detectLeakedLocks, documentInputRequestFilePath, DOCUMENT_INPUT_REQUEST_FILE, docsDir, envDefaultScope, knowledgeDir, agentsDir, emitError, errorMessage, escapeRegex, findAllEvents, findStageBySlug, frontmatterBlock, getField, hasUnsafeSingleLineCharacter, holdsAuditLock, hooksHealthDir, isAutonomousMode, isPlainObject, isTeamUnitOwnership, isPluginEnabled, isoTimestamp, isPackageJson, isValidRepoName, codekbDir, intentsDir, codekbRepoName, codekbScopeFingerprint, codekbSourceFingerprint, codekbStoreGeneration, parseReScope, relativeCodekbDir, RESERVED_RECORD_NAMES, scopePathCovered, gridCostSummary, listIntents, listSpaces, loadAgents, loadScopeMapping, loadStageGraph, loadStageGraphAll, loadScopeMetadataAll, MERGE_SUCCEEDED_TAG_REGEX, migrateFlatLayout, needsFlatMigration, nextInScopeStage, PHASES, parseArgs, parseCheckboxes, parseRefsList, parseStageFrontmatter, parseStateStageSuffixes, readAllAuditShards, readAuditShardEvents, readActiveDirectiveMarker, readUnitClaimRegistryCache, readUnitScopeStamp, recordHookDrop, readCurrentSessionId, readProjectDescriptionAuthority, resolveWorkflowSelection, readStateFile, refreshActiveDirectiveMarker, resolveIntentRepoSet, resolveProjectDir, setActiveIntentCursor, setActiveSpaceCursor, slugify, SLUG_TAG_REGEX, spacesRoot, type StageEntry, setCheckbox, setField, setPhaseProgress, setStageSuffix, scopeGridPath, scopesDir, inspectSubagentInflight, harnessDataPath, pluginsEnabled, resolveProjectFlag, selectionAwareDefaultScope, resolveDefaultScope, projectDescriptionFilePath, PROJECT_DESCRIPTION_FILE, scalarField, stageEnabledBySelection, stagesInScope, stateFilePath, clearSessionIntentUuid, sourceBaselineAuditFields, unitDependencyPath, withAuditLock, validateBoltSlug, validScopes, worktreeAuditFilePath, worktreePath, worktreeStateFilePath, writeFileAtomic, writeSessionIntentUuid, writeSessionBinding, writeStateFile, harnessDir, rulesSubdir, _resetHarnessDataForTests, _resetScopeMappingForTests, _resetStageGraphForTests, classifyStateVersion, clearSessionRebindOffer, CURRENT_STATE_VERSION, type AuditShardEvent, idSuffix, lastWorkspaceSourceFailure, hookExecutionRecoveryText, hookLiveness, workspaceSourceState, type WorkspaceSourceState, } from "./aidlc-lib.ts"; import { validateStageFrontmatter } from "./aidlc-stage-schema.ts"; import { isRuleStale } from "./aidlc-rule-schema.ts"; import { captureStageValidationBasis, inspectStageValidity, } from "./aidlc-validity.ts"; import { AIDLC_VERSION } from "./aidlc-version.ts"; import { copyProjectSurfaces, projectDiffPlan, } from "./aidlc-plugin.ts"; import { executePlan } from "./aidlc-transaction.ts"; import { aidlcInvocation, aidlcDispatcherInvocation, aidlcToolInvocation, compiledExecutable, isCompiledExecutable, resolveHarnessPath, resolveSkillsPath, runtimeHarnessName, } from "./aidlc-runtime-paths.ts"; import { activeVersion, binRoot, commandPath, inspectProjectPinTarget, inspectInstalledVersion, installRoot, readActiveExecutable, rollbackVersionPath, versionRoot as installedVersionRoot, } from "./aidlc-install-paths.ts"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const VALID_DEPTHS: Record = { minimal: "Minimal", standard: "Standard", comprehensive: "Comprehensive", }; const VALID_TEST_STRATEGIES: Record = { minimal: "Minimal", standard: "Standard", comprehensive: "Comprehensive", }; const CONFIG_KEYS = ["depth", "test-strategy", "review"] as const; type ReviewOverride = "adversarial" | "advisory" | "none"; function parseReviewOverride(raw: string | undefined): ReviewOverride | undefined { if (!raw) return undefined; const value = raw.toLowerCase(); if (value !== "adversarial" && value !== "advisory" && value !== "none") { die(`Unknown review class: "${raw}". Valid: adversarial, advisory, none.`); } return value; } function storedReviewOverride(value: ReviewOverride): string { // "adversarial" means no per-run ceiling; stage declarations and scope caps // still apply, so represent it with the same empty field as config-change. return value === "adversarial" ? "" : value; } function applyReviewOverride( content: string, value: ReviewOverride | undefined, ): { content: string; oldReview: string | null; storedReview: string | undefined; changed: boolean; } { const oldReview = getField(content, "Review Override"); if (value === undefined) { return { content, oldReview, storedReview: undefined, changed: false }; } const storedReview = storedReviewOverride(value); const changed = storedReview !== (oldReview ?? ""); if (!changed) return { content, oldReview, storedReview, changed }; if (oldReview === null) { const beforeInsert = content; content = content.replace( /^(- \*\*Test Strategy\*\*:[^\n]*)$/m, "$1\n- **Review Override**:", ); if (content === beforeInsert) { content = content.replace( /^(- \*\*Scope\*\*:[^\n]*)$/m, "$1\n- **Review Override**:", ); } if (content === beforeInsert) { content = `${content.trimEnd()}\n- **Review Override**:\n`; } } content = setField(content, "Review Override", storedReview); return { content, oldReview, storedReview, changed }; } // These workspace transactions can legitimately queue behind a full plugin // compose (compile + runner regeneration), so they share its ~60s lock budget. const WORKSPACE_MUTATION_LOCK_RETRIES = 600; const INTENT_CREATE_VALUE_FLAGS = [ "scope", "arguments", "label", "depth", "test-strategy", "review", "change-control", "repos", "space", "project-dir", ] as const; const INTENT_CREATE_DESCRIPTIVE_FLAGS = ["scope", "arguments", "label"] as const; const NO_STATE_FILE_MESSAGE = "No state file found. Start a workflow first by describing what to build (/aidlc \"build the auth service\")."; const INIT_TRANSITION_MESSAGE = "init now lays down the project data tree and is not yet available in this release. To start work, describe what to build: /aidlc \"build the auth service\"."; const UPGRADE_UNAVAILABLE_MESSAGE = "upgrade is not available in this install; it arrives with the packaged binary distribution."; let errorArgs: string[] = []; let errorProjectDirArg: string | undefined; let errorSelection: { intent?: string; space?: string } = {}; function die(msg: string): never { // main(argv) seeds this context before dispatch so ERROR_LOGGED lands in the // same workflow the argv-selected command was targeting. Fall back to default // resolution (env var / cwd) for direct in-process helper calls. const args = errorArgs; const pd = resolveProjectDir(errorProjectDirArg); const command = `aidlc-utility ${args.join(" ")}`.trim(); emitError( pd, "aidlc-utility", command, msg, errorSelection.intent, errorSelection.space, ); } function validateIntentCreateFlagValues( flags: Record, missingValueFlags: ReadonlySet, ): void { // Creation names the new intent itself, so an --intent selector has nothing // to select; --space is the one selector creation takes (the target space). if (flags.intent !== undefined || missingValueFlags.has("intent")) { die( "intent-create does not accept --intent: it creates a new intent and names " + "it itself. Use --space to choose the space it is created in.", ); } const invalid = INTENT_CREATE_VALUE_FLAGS.filter( (name) => missingValueFlags.has(name) || (flags[name] !== undefined && flags[name].trim().length === 0), ); if (invalid.length > 0) { die( `intent-create refused: ${invalid.map((name) => `--${name}`).join(", ")} ` + `${invalid.length === 1 ? "requires" : "require"} a nonblank value.`, ); } for (const name of INTENT_CREATE_VALUE_FLAGS) { if (flags[name] !== undefined) flags[name] = flags[name].trim(); } } // Thin wrapper around the canonical appendAuditEntry. All events must be in // aidlc-audit.ts VALID_EVENT_TYPES. Throws on invalid event or audit failure — // caller is expected to let that propagate (creation failures should stop creation). // // Lock-aware (mirrors aidlc-state.ts emitAudit): handleIntentCreate wraps the // whole creation transaction in withAuditLock on the WORKSPACE sentinel bucket, so // this process already owns that OS lock. Routing through appendAuditEntry // (which calls the NON-reentrant acquireAuditLock keyed on the same sentinel // when intent is omitted) would self-deadlock and burn the 5s retry budget // before throwing — so detect the held lock and use the unlocked variant. // Outside a held lock (every other caller — status/doctor/etc.) it takes its // own lock as before. function appendAuditEvent( projectDir: string, event: string, fields: Record, intent?: string, space?: string, ): void { // Held on either bucket this process could own: the workspace sentinel (the // creation transaction) or the named record's own per-intent bucket. const held = holdsAuditLock(projectDir) || (intent !== undefined && holdsAuditLock(projectDir, intent, space)); if (held) { appendAuditEntryUnlocked(event, fields, projectDir, intent, space); } else { appendAuditEntry(event, fields, projectDir, intent, space); } } // --------------------------------------------------------------------------- // help // --------------------------------------------------------------------------- // // HELP_TEXT is no longer a static constant — the scopes block renders // from loadScopeMapping() so stage counts stay fresh by construction. // Previously hardcoded counts drifted as scopes evolved; sourcing from // the live mapping makes that impossible. const HELP_TEXT_HEAD = `AI-DLC - AI-Driven Development Life Cycle Usage: /aidlc [command] Scopes (set depth, test strategy, and stage count): `; const HELP_TEXT_TAIL = ` Utilities: --status Show current workflow progress (read-only) --config [section] Configure models, runtime, providers, trust, flags, or project in-session --claim Atomically claim a team-owned Unit in this checkout --release Release a Unit claim from the unscoped main checkout unit adopt Adopt the checked-out live claim branch in a fresh clone unit participate Mark this checkout for the guided Unit-claim picker unit publish Publish this scoped checkout's committed candidate unit pin Pin and validate a completed candidate from main unit gate Record approve/reject against the pinned candidate unit land Land pinned content, fold state, and finalize receipts (explicit post-git release recovery supported) unit merge-status Show the local pinned-merge transaction unit status Show claimable, claimed, and dependency-blocked Units compose "" Suggest a plan tailored to this task (mid-workflow: adjust the steps not yet run) compose --report Build a plan from a scan report (sort findings into a fix-and-ship run) --new-scope "" Build a custom plan even when a ready-made one matches intent list List intents in the active space (read-only; --json for structured output) intent switch Switch the active intent (bare intent still works) space list List spaces (read-only; --json for structured output) space switch Switch the active space (bare space still works) space create Create a new space (space-create still works) config get Show active workflow config (depth, test-strategy, review) config set Change active workflow config (depth, test-strategy, review) config list List active workflow config (--json for structured output) plugin select [names] Show or set the enabled plugin list plugin list List installed plugins and enabled state (--json for structured output) plugin sync Compose installed plugins into the current install plugin validate [path] Validate authored plugin content (--json for structured output) plugin build [outDir] Build a host plugin projection (--plugin-root ) knowledge onboard [path] Index customer documents into the space DocumentKB knowledge sync Reconcile the catalog with disk; retries extractor_unavailable rows knowledge list The DocumentKB catalog (--json for structured output) knowledge show One document's record, plus its extracted text knowledge associate --intent [slug] Scope a document to one intent knowledge dissociate --intent [slug] Remove that scoping knowledge rebind --to Repair a row whose original moved AND changed --doctor Run health check on hooks, settings, and directory structure --doctor --export Write a redacted diagnostic report (timeline + findings, no work product); --output to relocate --stage Jump to a specific stage (by slug or number, e.g., code-generation or 3.5) --phase Jump to the first in-scope stage of a phase (e.g., construction or 3) --scope Set or change scope (standalone or with --stage/--phase) --depth Override depth (minimal, standard, comprehensive) --test-strategy Override test strategy (minimal, standard, comprehensive) --review Cap stage reviews for this run (adversarial, advisory, none) --change-control Set what an input change after an approval does for this piece of work (strict, relaxed) --version Show the framework version --help Show this help message Other: Describe what to build - scope is auto-detected (no arguments) Resume existing workflow, or start fresh if none exists Examples: /aidlc feature Start a feature workflow /aidlc Fix the login timeout bug Auto-detected as bugfix scope /aidlc compose "harden the deploy pipeline" Composer proposes a tailored plan /aidlc config list Show depth, test strategy, and review override /aidlc plugin list Show installed plugin selection /aidlc plugin validate Validate the plugin in the current directory /aidlc plugin build claude Build its Claude projection /aidlc Resume or begin /aidlc --stage code-generation Jump to code-generation stage /aidlc --phase construction --scope bugfix Jump to construction with bugfix scope /aidlc --scope bugfix --depth comprehensive Bugfix with comprehensive depth /aidlc --depth minimal Change depth of active workflow /aidlc --depth standard --test-strategy minimal Full artifacts, minimal tests /aidlc --review advisory Single-pass reviews, findings at the gate /aidlc --change-control relaxed Record and announce input changes after approval instead of re-approving`; /** Exported for t67 unit tests. */ export function renderHelpText(): string { const mapping = loadScopeMapping(); const defaultResolution = selectionAwareDefaultScope(); const defaultScope = defaultResolution.error ? "" : defaultResolution.scope; const scopeLines = [...validScopes()].map((name) => { const def = mapping[name]; const execute = Object.values(def.stages).filter((v) => v === "EXECUTE") .length; const total = Object.keys(def.stages).length; const depth = def.depth.toLowerCase(); const ts = def.testStrategy ? `, ${def.testStrategy.toLowerCase()} test strategy` : ""; const desc = def.description ? ` - ${def.description}` : ""; const defaultMarker = name === defaultScope ? " (default)" : ""; const countStr = execute === total ? `All ${total} stages` : `${execute} of ${total} stages`; return ` ${name.padEnd(18)}${countStr}, ${depth} depth${ts}${defaultMarker}${desc}`; }); // Blank line before HELP_TEXT_TAIL so the `Utilities:` header is visually // separated from the scope list. return `${HELP_TEXT_HEAD + scopeLines.join("\n")}\n${HELP_TEXT_TAIL}`; } function handleHelp(): void { process.stdout.write(`${renderHelpText()}\n`); } // --------------------------------------------------------------------------- // version // --------------------------------------------------------------------------- function handleVersion(): void { process.stdout.write(`aidlc ${AIDLC_VERSION}\n`); } // --------------------------------------------------------------------------- // select-plugins // --------------------------------------------------------------------------- function resetSelectionSensitiveCaches(): void { _resetHarnessDataForTests(); _resetStageGraphForTests(); _resetScopeMappingForTests(); } function mutableHarnessDataPath(projectDir: string): string { return resolveHarnessPath( ["tools", "data", "harness.json"], { mutable: true, projectDir }, ); } function requireInstalledHarness(projectDir: string): void { const installedLib = resolveHarnessPath( ["tools", "aidlc-lib.ts"], { mutable: true, projectDir }, ); if (!existsSync(installedLib)) { die( `select-plugins requires an installed project harness at ${dirname(dirname(installedLib))}.`, ); } } function knownPluginNames(): string[] { const names = new Set(["aidlc"]); try { for (const stage of loadStageGraphAll()) { if (stage.plugin) names.add(stage.plugin); } } catch { // Scope files still provide known plugin identities when the graph is stale. } for (const meta of Object.values(loadScopeMetadataAll())) { names.add(meta.plugin ?? "aidlc"); } return [...names].sort(); } function selectionOwner(stage: Pick): string { return stage.plugin ?? "aidlc"; } function countOwner(stage: Pick): string { return stage.phase === "initialization" ? "bootstrap" : selectionOwner(stage); } function expectedEnabledBySelection(stage: Pick): boolean { return stageEnabledBySelection(stage); } function parsePluginSelectionArgs(positional: string[]): { names: string[]; hasEmpty: boolean } { const parts = positional.slice(1).join(",").split(",").map((s) => s.trim()); return { names: parts.filter((s) => s.length > 0), hasEmpty: parts.some((s) => s.length === 0), }; } function renderPluginSelection(selected: ReadonlySet | null): string { return selected === null ? "all enabled (no selection)" : [...selected].sort().join(", "); } function readHarnessDataObject(): Record { try { const parsed = JSON.parse(readFileSync(harnessDataPath(), "utf-8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } } catch { // Reconstruct a legacy/missing file from runtime defaults. } return { harnessDir: harnessDir(), rulesSubdir: rulesSubdir() }; } function writePluginSelection(projectDir: string, names: string[]): void { const data = readHarnessDataObject(); data.plugins = names; const path = mutableHarnessDataPath(projectDir); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8"); resetSelectionSensitiveCaches(); } function runBunTool(projectDir: string, rel: string, args: string[], label: string): void { let dispatcherArgs: string[]; if (rel === "aidlc-graph.ts") { dispatcherArgs = ["engine", "graph", ...args]; } else if (rel === "aidlc-runner-gen.ts" && args[0] === "write") { dispatcherArgs = ["engine", "gen", "runners", ...args.slice(1)]; } else if (rel === "aidlc-runner-gen.ts" && args[0] === "scopes") { dispatcherArgs = ["engine", "gen", "runner-scopes", ...args.slice(1)]; } else { throw new Error(`No dispatcher route for ${rel} ${args.join(" ")}`); } dispatcherArgs.push("--project-dir", projectDir); const executable = compiledExecutable(); const command = executable ? [executable, ...dispatcherArgs] : [ process.execPath, resolveHarnessPath(["tools", rel], { projectDir }), ...args, "--project-dir", projectDir, ]; const result = Bun.spawnSync({ cmd: command, cwd: projectDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, AIDLC_HARNESS_DIR: harnessDir(), AIDLC_HARNESS_NAME: runtimeHarnessName(projectDir, harnessDir()), AIDLC_PROJECT_DIR: projectDir, ...(holdsAuditLock(projectDir) ? { AIDLC_WORKSPACE_LOCK_OWNER_PID: String(process.pid) } : {}), }, }); if (result.exitCode !== 0) { const stdout = new TextDecoder().decode(result.stdout).trim(); const stderr = new TextDecoder().decode(result.stderr).trim(); throw new Error(`${label} failed: ${(stderr || stdout || `exit ${result.exitCode}`).slice(0, 800)}`); } } interface GeneratedRegionLocation { beginIdx: number; endIdx: number; regionEndIdx: number; } function findGeneratedRegion( body: string, beginMarker: string, endMarker: string, verb: string, skillPath: string, ): GeneratedRegionLocation { const beginIdx = body.indexOf(beginMarker); const lastBeginIdx = body.lastIndexOf(beginMarker); const endIdx = body.indexOf(endMarker); const lastEndIdx = body.lastIndexOf(endMarker); if (beginIdx === -1 || endIdx === -1) { throw new Error( `SKILL.md at ${skillPath} is missing ${verb} markers. Expected:\n ${beginMarker}\n ${endMarker}`, ); } if (beginIdx !== lastBeginIdx || endIdx !== lastEndIdx) { throw new Error( `SKILL.md at ${skillPath} has duplicate ${verb} markers. Expected exactly one BEGIN and one END.`, ); } if (endIdx < beginIdx) { throw new Error( `SKILL.md at ${skillPath} has ${verb} markers out of order (END before BEGIN).`, ); } return { beginIdx, endIdx, regionEndIdx: endIdx + endMarker.length }; } function replaceGeneratedRegion( verb: string, beginMarker: string, endMarker: string, region: string, ): void { const path = skillMdPath(); const before = readFileSync(path, "utf-8").replace(/\r\n/g, "\n"); const located = findGeneratedRegion(before, beginMarker, endMarker, verb, path); const after = before.slice(0, located.beginIdx) + region + before.slice(located.regionEndIdx); if (after !== before) writeFileSync(path, after, "utf-8"); } export function regenerateSelectionSurfaces( projectDir: string, displayProjectDir = projectDir, ): void { runBunTool(projectDir, "aidlc-graph.ts", ["compile"], "aidlc-graph compile"); resetSelectionSensitiveCaches(); const skillsDir = resolveSkillsPath([], { mutable: true, projectDir }); if (existsSync(skillsDir)) { runBunTool(projectDir, "aidlc-runner-gen.ts", ["write"], "aidlc-runner-gen write"); runBunTool(projectDir, "aidlc-runner-gen.ts", ["scopes"], "aidlc-runner-gen scopes"); } else { process.stdout.write( `note: runner regeneration skipped: ${ resolveSkillsPath([], { mutable: true, projectDir: displayProjectDir }) } not present in this install\n`, ); } resetSelectionSensitiveCaches(); replaceGeneratedRegion( "stage-table", STAGE_TABLE_BEGIN, STAGE_TABLE_END, canonicalStageTableRegion(renderStageTable()), ); replaceGeneratedRegion( "scope-table", SCOPE_TABLE_BEGIN, SCOPE_TABLE_END, canonicalScopeTableRegion(renderScopeTable()), ); } // --- disable-time contribution strip ----------------------------------------- // // Compose merges a plugin's structural adds (produces/sensors/consumes/ // scopes/required_sections) into CORE stage source, where no selection filter // reaches, and records what it actually added in a per-plugin sidecar // (tools/data/plugin-contrib-.json). Prose fragments carry their own // sentinel markers. On disable, select-plugins strips both, so a disabled // plugin's contributions stop steering enabled stages; re-enabling restores // them on the next session start (the plugin's compose hook re-merges). interface ConsumeContribRecord { artifact: string; required: boolean; conditional_on?: string; } interface StageContribRecord { produces?: string[]; sensors?: string[]; consumes?: Array; scopes?: string[]; required_sections?: string[]; required_sections_created?: boolean; fragments?: Array<{ anchor: string; order: number; hash: string }>; } function pluginContribSidecarPath(plugin: string): string { return resolveHarnessPath( ["tools", "data", `plugin-contrib-${plugin.replace(/[^\w.-]/g, "_")}.json`], { mutable: true }, ); } function installedStagesRoot(): string { return resolveHarnessPath(["aidlc-common", "stages"], { mutable: true }); } // Remove recorded values from a `field:` block. An emptied block collapses to // the inline `field: []` form (the shape compose's merge expanded from); a // created-by-compose required_sections field is deleted outright. function removeListValues(content: string, field: string, values: ReadonlySet, dropEmptyField: boolean): string { const blockRe = new RegExp(`^${field}:\\n((?: - .+\\n)*)`, "m"); const m = content.match(blockRe); if (!m) return content; const entries = [...m[1].matchAll(/^ {2}- (.+)$/gm)].map((x) => x[1]); const removeIndexes = new Set(); const remaining = new Set(values); // Compose renders ordinary structural additions unquoted. Prefer that exact // spelling so a legacy sidecar cannot remove an equivalent quoted membership // that predated the plugin. for (let i = 0; i < entries.length; i++) { if (remaining.delete(entries[i].trim())) removeIndexes.add(i); } // required_sections are rendered quoted while their sidecar values are bare. // Remove at most one canonical match for each recorded addition. for (const value of remaining) { const index = entries.findIndex((entry, i) => { if (removeIndexes.has(i)) return false; const bare = entry.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); return bare === value; }); if (index !== -1) removeIndexes.add(index); } const kept = entries.filter((_, i) => !removeIndexes.has(i)); const replacement = kept.length > 0 ? `${field}:\n${kept.map((v) => ` - ${v}`).join("\n")}\n` : dropEmptyField ? "" : `${field}: []\n`; return content.replace(blockRe, replacement); } function removeConsumesEntries(content: string, artifacts: ReadonlySet): string { const blockRe = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m; const m = content.match(blockRe); if (!m) return content; const kept = [...m[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)] .filter((entry) => !artifacts.has(entry[1])) .map((entry) => entry[0]); const replacement = kept.length > 0 ? `consumes:\n${kept.join("")}` : "consumes: []\n"; return content.replace(blockRe, replacement); } function fragmentProseHash(content: string): string { let hash = 0x811c9dc5; for (let i = 0; i < content.length; i++) { hash ^= content.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(16).padStart(8, "0"); } function missingRecordedContributions( parsed: Record, content: string, plugin: string, record: StageContribRecord, ): string[] { const missing: string[] = []; for (const field of ["produces", "sensors", "scopes", "required_sections"] as const) { const recorded = record[field]; if (!Array.isArray(recorded) || recorded.length === 0) continue; const present = new Set( (Array.isArray(parsed[field]) ? parsed[field] : []) .filter((value): value is string => typeof value === "string"), ); const absent = recorded.filter((value) => !present.has(value)); if (absent.length > 0) missing.push(`${field}=[${absent.join(", ")}]`); } if (Array.isArray(record.consumes) && record.consumes.length > 0) { const present = (Array.isArray(parsed.consumes) ? parsed.consumes : []) .flatMap((entry): ConsumeContribRecord[] => { if ( !isPlainObject(entry) || typeof entry.artifact !== "string" || typeof entry.required !== "boolean" ) { return []; } return [{ artifact: entry.artifact, required: entry.required, ...(typeof entry.conditional_on === "string" ? { conditional_on: entry.conditional_on } : {}), }]; }); const absent = record.consumes.filter((expected) => { if (typeof expected === "string") { return !present.some((entry) => entry.artifact === expected); } return !present.some((entry) => entry.artifact === expected.artifact && entry.required === expected.required && entry.conditional_on === expected.conditional_on ); }).map((expected) => typeof expected === "string" ? expected : `${expected.artifact}(required=${expected.required}${ expected.conditional_on ? `, conditional_on=${expected.conditional_on}` : "" })` ); if (absent.length > 0) missing.push(`consumes=[${absent.join(", ")}]`); } if (Array.isArray(record.fragments) && record.fragments.length > 0) { const absent = record.fragments.flatMap((fragment) => { const id = `${fragment.anchor}@${fragment.order}:${fragment.hash}`; const open = ``; const close = ``; const openIdx = content.indexOf(open); if (openIdx === -1) return [id]; const bodyStart = openIdx + open.length; const closeIdx = content.indexOf(close, bodyStart); if (closeIdx === -1) return [id]; const wrapped = content.slice(bodyStart, closeIdx); if (!wrapped.startsWith("\n") || !wrapped.endsWith("\n")) return [id]; return fragmentProseHash(wrapped.slice(1, -1)) === fragment.hash ? [] : [id]; }); if (absent.length > 0) missing.push(`fragments=[${absent.join(", ")}]`); } return missing; } function contributionRecordError(value: unknown): string | undefined { if (!isPlainObject(value)) return "expected an object"; let hasContribution = false; for (const field of ["produces", "sensors", "scopes", "required_sections"] as const) { if (!(field in value)) continue; if (!Array.isArray(value[field])) return `${field} must be an array`; if (value[field].some((entry) => typeof entry !== "string" || entry.length === 0)) { return `${field} must contain non-empty strings`; } if (value[field].length > 0) hasContribution = true; } if ("consumes" in value) { if (!Array.isArray(value.consumes)) return "consumes must be an array"; for (const [index, consume] of value.consumes.entries()) { if (typeof consume === "string") { if (consume.length === 0) return `consumes[${index}] must be a non-empty string`; continue; } if (!isPlainObject(consume)) { return `consumes[${index}] must be a legacy string or an object`; } if (typeof consume.artifact !== "string" || consume.artifact.length === 0) { return `consumes[${index}].artifact must be a non-empty string`; } if (typeof consume.required !== "boolean") { return `consumes[${index}].required must be a boolean`; } if ( "conditional_on" in consume && consume.conditional_on !== undefined && consume.conditional_on !== "brownfield" && consume.conditional_on !== "greenfield" ) { return `consumes[${index}].conditional_on must be brownfield or greenfield`; } } if (value.consumes.length > 0) hasContribution = true; } if ( "required_sections_created" in value && typeof value.required_sections_created !== "boolean" ) { return "required_sections_created must be a boolean"; } if ("fragments" in value) { if (!Array.isArray(value.fragments)) return "fragments must be an array"; const identities = new Set(); for (const [index, fragment] of value.fragments.entries()) { if (!isPlainObject(fragment)) return `fragments[${index}] must be an object`; if ( typeof fragment.anchor !== "string" || !/^\S+$/.test(fragment.anchor) ) { return `fragments[${index}].anchor must be a non-empty token`; } if ( typeof fragment.order !== "number" || !Number.isSafeInteger(fragment.order) || fragment.order < 0 ) { return `fragments[${index}].order must be a non-negative integer`; } if (typeof fragment.hash !== "string" || !/^[0-9a-f]{8}$/.test(fragment.hash)) { return `fragments[${index}].hash must be an 8-character lowercase hex hash`; } const identity = `${fragment.anchor}\0${fragment.order}`; if (identities.has(identity)) { return `fragments contains duplicate identity ${fragment.anchor}@${fragment.order}`; } identities.add(identity); } if (value.fragments.length > 0) hasContribution = true; } return hasContribution ? undefined : "record has no contributions"; } // Strip every sentinel-marked prose fragment this plugin spliced. The open // and close markers carry the plugin name, so removal needs no sidecar. // Anchors may themselves contain colons (after-step:9, in:Sensors), so the // anchor segment is matched non-greedily up to the trailing :order:hash. function removePluginFragments(content: string, plugin: string): string { const pE = escapeRegex(plugin); const openRe = new RegExp(``, "g"); let out = content; let match = openRe.exec(out); while (match !== null) { const close = ` ${phaseProgressLines} ## Stage Progress ${stageProgress} ## Current Status - **Lifecycle Phase**: ${firstPostInitPhase} - **Current Stage**: ${firstPostInit} - **Next Stage**: ${nextStageName} - **Status**: Running - **Last Updated**: ${ts} ## Session Resume Point - **Last Completed Stage**: state-init - **Next Action**: Execute ${firstPostInit} - **Pending Artifacts**: none `; writeFileAtomic( projectDescriptionFilePath(projectDir, createdDir, createdSpace), `${JSON.stringify(rawProjectDesc)}\n`, ); writeStateFile(projectDir, stateContent, createdDir, createdSpace); appendAuditEvent(projectDir, "WORKSPACE_INITIALISED", { Request: `/aidlc ${flags.arguments || scope}`, "Project Type": scan.projectType, Scope: scope, Languages: scan.languages, Frameworks: scan.frameworks, "Build System": scan.buildSystem, Details: `${totalInScope} stages in scope, routing to ${firstPostInit}`, }, createdDir, createdSpace); appendAuditEvent(projectDir, "STAGE_COMPLETED", { Stage: "state-init", Details: `State initialized: ${scope} scope, ${totalInScope} stages, routing to ${firstPostInit}`, }, createdDir, createdSpace); // Phase hand-off: initialization → first post-init phase. The state file // advertises Current Stage = first post-init, so the audit must reflect // the same transition (PHASE_COMPLETED + PHASE_VERIFIED + PHASE_STARTED + // STAGE_STARTED) to keep the two streams coherent. Without these, the first // subsequent `advance` call would appear to jump from workspace-scaffold // directly into a fresh phase. if (firstPostInitEntry && firstPostInitEntry.phase !== "initialization") { appendAuditEvent(projectDir, "PHASE_COMPLETED", { "From phase": "initialization", "To phase": firstPostInitEntry.phase, "Stages completed": String(completedInit), }, createdDir, createdSpace); appendAuditEvent(projectDir, "PHASE_VERIFIED", { "Phase boundary": `initialization → ${firstPostInitEntry.phase}`, }, createdDir, createdSpace); appendAuditEvent(projectDir, "PHASE_STARTED", { Phase: firstPostInitEntry.phase, Scope: scope, }, createdDir, createdSpace); appendAuditEvent(projectDir, "STAGE_STARTED", { Stage: firstPostInit, Agent: firstPostInitAgent, }, createdDir, createdSpace); } // Combined stdout summary (intent created + state-build). The state file and // every row above name the created record explicitly. const submoduleWarningLine = uninitSubmodules.length > 0 ? `Warning: ${uninitSubmodules.length} uninitialized git submodule path(s) (${enumerateSubmodulePaths(uninitSubmodules)}) - run '${SUBMODULE_INIT_REMEDY}' before proceeding so reverse-engineering can read the code.\n` : ""; process.stdout.write( `Intent created: ${createdDir} (space: ${createdSpace}) State initialized: ${scope} scope, ${totalInScope} stages, ${effectiveDepth} depth Project type: ${scan.projectType} Languages: ${scan.languages} Frameworks: ${scan.frameworks} Build System: ${scan.buildSystem} ${submoduleWarningLine}First post-init stage: ${firstPostInit} (${firstPostInitPhase}) ` ); } // --------------------------------------------------------------------------- // state-init / init - transition aliases // --------------------------------------------------------------------------- function handleInitTransition(): void { die(INIT_TRANSITION_MESSAGE); } function handleStateInit(_projectDir: string, _flags: Record): void { die( "state-init is merged into intent-create. Just describe what you want to build (/aidlc \"build the auth service\") and the workflow record is created for you." ); } function handleUpgrade(): void { die(UPGRADE_UNAVAILABLE_MESSAGE); } // --------------------------------------------------------------------------- // intent / space — the verb families + the deterministic query layer // --------------------------------------------------------------------------- // Print an intent listing (the query layer's human OR --json mode). Both modes // read the SAME listSpaces/listIntents source so they never diverge. --json // shape: {active, spaces:[...], intents:[{uuid,slug,status,repos}]} — consumed // by the creation gate, resume-rebind, and statusline; human text is the bare // `/aidlc intent` rendering. Pure read. function printIntentListing( projectDir: string, asJson: boolean, ): void { const selection = resolveWorkflowSelection(projectDir); const space = selection.space; const intents = listIntents(projectDir, space, selection.intent); const active = intents.find((i) => i.active); if (asJson) { process.stdout.write( `${JSON.stringify({ active: active ? active.dirName : null, space, intents: intents.map((i) => ({ uuid: i.uuid, slug: i.slug, status: i.status, repos: i.repos ?? [], dirName: i.dirName, active: i.active, })), })}\n` ); return; } if (intents.length === 0) { process.stdout.write( `No intents in space "${space}" yet. Start one by describing what to build: /aidlc "build the auth service"\n` ); return; } let out = `Intents in space "${space}":\n`; for (const i of intents) { const marker = i.active ? "*" : " "; out += `${marker} ${i.dirName ?? i.slug} [${i.status}]\n`; } if (!active) { out += `\n(no active intent - switch with /aidlc intent )\n`; } process.stdout.write(out); } // Print a space listing (human OR --json). --json shape: // {active, spaces:[{name,active}]}. Pure read. function printSpaceListing( projectDir: string, asJson: boolean, ): void { const selection = resolveWorkflowSelection(projectDir); const spaces = listSpaces(projectDir, selection.space); const active = spaces.find((s) => s.active); if (asJson) { process.stdout.write( `${JSON.stringify({ active: active ? active.name : DEFAULT_SPACE, spaces: spaces.map((s) => ({ name: s.name, active: s.active })), })}\n` ); return; } let out = `Spaces:\n`; for (const s of spaces) { out += `${s.active ? "*" : " "} ${s.name}\n`; } process.stdout.write(out); } // `/aidlc intent` (list) · `/aidlc intent ` (switch the active-intent // cursor). Switching an intent is a PURE cursor write (an intent has no native // include — only a space does). The matches a record dir name exactly, // or a slug (when unambiguous within the space). --json on the bare list emits // the structured query shape. function handleIntent( projectDir: string, positional: string[], flags: Record, ): void { const asJson = flags.json === "true"; const verbOrTarget = positional[1]; if (verbOrTarget === "list") { printIntentListing(projectDir, asJson); return; } if (verbOrTarget === "create") { handleIntentCreate(projectDir, flags); return; } const target = verbOrTarget === "switch" ? positional[2] : verbOrTarget; if (verbOrTarget === "switch" && !target) { die("Usage: aidlc-utility intent switch "); } if (!target) { printIntentListing(projectDir, asJson); return; } // `intent help`/`-h` is a help request, not a switch to a record named // "help" ("help" is a reserved record name, so no real record is shadowed). // The engine routes it to help before it ever reaches this tool; this arm is // the backstop for a direct invocation, so a confused caller gets the help // text instead of an "Unknown intent" error that reads like an invitation to // start new work. if (target === "help" || target === "-h") { handleHelp(); return; } const selection = resolveWorkflowSelection(projectDir); const space = selection.space; const intents = listIntents(projectDir, space, selection.intent); // Exact record-dir match first; then a unique slug match. let match = intents.find((i) => i.dirName === target); if (!match) { const bySlug = intents.filter((i) => i.slug === target && i.dirName !== null); if (bySlug.length === 1) match = bySlug[0]; else if (bySlug.length > 1) { die( `Ambiguous intent "${target}" in space "${space}" (${bySlug.length} match). Use the full record-dir name: ${bySlug.map((i) => i.dirName).join(", ")}.` ); } } if (!match || match.dirName === null) { // Deliberately NOT "describe what to build to start a new one": a conductor // recovering from a failed switch read that as an instruction and created an // unwanted intent. Point at the read-only listing only; starting new work // stays a separate, human-confirmed move. die( `Unknown intent "${target}" in space "${space}". This command only switches between existing intents - run /aidlc intent to list them. Do not start a new workflow to recover from this error.` ); } setActiveIntentCursor(projectDir, match.dirName, space); // Re-stamp the LIVE conversation's session→intent record to the switched-to // intent. WHY: the resume-rebind stamp (session-start hook) is keyed by // session_id, which this tool never sees; only the hook does. Without this, a // deliberate in-conversation `/aidlc intent ` switch leaves the session // stamped at the OLD intent, so resuming THIS same conversation fires a FALSE // rebind nag ("was working X, switch back?"). The hook records the live session // in `.current-session` on every fire (it owns session-id capture); we read // that marker here and re-stamp deterministically. Self-switch: the marker // names THIS session → its stamp follows the cursor → no false nag. Foreign // drift (a DIFFERENT session moved the cursor): the marker names that OTHER // session → its stamp moves, not ours → a genuine resume of our session still // offers the rebind. writeSessionIntentUuid no-ops on a blank uuid, so an // orphan (registry-less) record is fail-safe. Best-effort throughout. const sid = selection.sessionId ?? readCurrentSessionId(projectDir); if (sid) { writeSessionBinding(projectDir, sid, space, match.dirName); clearSessionRebindOffer(projectDir, sid); if (match.uuid) writeSessionIntentUuid(projectDir, sid, match.uuid); } process.stdout.write(`Active intent -> ${match.dirName} (space: ${space})\n`); } // `/aidlc space` (list) · `/aidlc space ` (switch the active-space // cursor). Switching a space does TWO per-user writes: move the gitignored // active-space cursor, then SURGICALLY repoint the harness-native rule includes // in place so the next turn loads the switched space's method (the ambient // channel — Claude @-stub / Kiro resources glob / Codex AIDLC_RULES_DIR). Both // are per-user: the cursor is gitignored, and the include re-point is a no-op at // `default` (so a single-team user never dirties the committed tree). Switching // to a non-existent space errors (use space-create). --json on the bare list // emits the structured shape. function handleSpace(projectDir: string, positional: string[], flags: Record): void { const asJson = flags.json === "true"; const verbOrTarget = positional[1]; if (verbOrTarget === "list") { printSpaceListing(projectDir, asJson); return; } if (verbOrTarget === "create") { handleSpaceCreate(projectDir, ["space-create", positional[2] ?? ""], flags); return; } const raw = verbOrTarget === "switch" ? positional[2] : verbOrTarget; if (verbOrTarget === "switch" && !raw) { die("Usage: aidlc-utility space switch "); } if (!raw) { printSpaceListing(projectDir, asJson); return; } // `space help`/`-h` is a help request, not a switch to a space named "help" // - same backstop as handleIntent (the engine routes it to help upstream, // and "help" is a reserved space name). if (raw === "help" || raw === "-h") { handleHelp(); return; } // Spaces are STORED under their slug (handleSpaceCreate writes slugify(raw)), // so slugify the switch target before lookup AND before the cursor write — // otherwise `/aidlc space "My Space"` (stored as my-space) would miss. const target = slugify(raw); const spaces = listSpaces(projectDir); if (!spaces.some((s) => s.name === target)) { die( `Unknown space "${target}". Existing: ${spaces.map((s) => s.name).join(", ")}. This command only switches between existing spaces. Do not create a space to recover from this error - creating one is a separate, deliberate move (/aidlc space create , or legacy /aidlc space-create ).` ); } const selection = resolveWorkflowSelection(projectDir); setActiveSpaceCursor(projectDir, target); const sessionId = selection.sessionId ?? readCurrentSessionId(projectDir); if (sessionId) { const targetIntent = activeIntent(projectDir, target); writeSessionBinding(projectDir, sessionId, target, targetIntent); clearSessionRebindOffer(projectDir, sessionId); if (targetIntent) { const uuid = listIntents(projectDir, target).find( (entry) => entry.dirName === targetIntent, )?.uuid; if (uuid) writeSessionIntentUuid(projectDir, sessionId, uuid); } else { clearSessionIntentUuid(projectDir, sessionId); } } // Re-point the harness-native includes at the switched space so the NEXT turn // loads its method into ambient context (the cursor alone only moves AIDLC's // own resolver; the CLI-native include is the ambient channel). Surgical // in-place rewrite of the pointer segment only — preserves all engine wiring. const repointed = repointHarnessIncludes(projectDir, target); process.stdout.write(`Active space -> ${target}\n`); if (repointed.length > 0) { process.stdout.write(` repointed ${repointed.length} harness include(s) -> ${target}\n`); } } // `aidlc-utility.ts codekb-path [--repo ] [--json]` — read-only. Prints the // deterministic space-level per-repo codekb directory (forward-slash, workspace- // relative) the reverse-engineering stage writes its 9 artifacts into. The repo // is the caller-supplied --repo, else the engine-resolved codekbRepoName (the // lone recorded repo, or basename(projectDir) when none is recorded). No mkdir, // no state read, no audit — mirrors the intent/space read-only query arms. function handleCodekbPath(projectDir: string, flags: Record): void { const asJson = flags.json === "true"; const selection = resolveWorkflowSelection(projectDir); const space = selection.space; const repo = flags.repo && flags.repo.length > 0 ? flags.repo : codekbRepoName(projectDir, space, selection.intent ?? undefined); const dir = relativeCodekbDir(projectDir, repo, space); if (asJson) { process.stdout.write(`${JSON.stringify({ space, repo, dir })}\n`); return; } process.stdout.write(`${dir}/\n`); } // `aidlc-utility.ts document-input` - read-only. Reads one selected path from // the active record's fixed DOCUMENT_INPUT_REQUEST_FILE, so customer-controlled // filename bytes never enter a shell command. Resolves that path from the // project root and refuses search/fallback, symlinks, non-regular files, // out-of-project targets, binary input, and content beyond the same // 200k-character delivery cap used by DocumentKB. Successful output carries // DocumentKB's path/content trust notices in the same JSON object as the bytes // they govern. No mkdir, state write, or audit event. function handleProjectDescription(projectDir: string): void { const recordRoot = dirname(stateFilePath(projectDir)); process.stdout.write( `${JSON.stringify(readProjectDescriptionAuthority(recordRoot))}\n`, ); } async function handleDocumentInput(projectDir: string): Promise { const { detectMimeType, EXTRACT_OUTPUT_CHAR_CAP, readDocumentBytes, resolveContainedFile, UNTRUSTED_CONTENT_NOTICE, UNTRUSTED_PATH_NOTICE, } = await import("./aidlc-knowledge.ts"); const documentInputByteCap = EXTRACT_OUTPUT_CHAR_CAP * 4; // The transport file carries ONE path line, so it gets a path-sized cap, not // the document cap. Without an explicit bound the whole file is allocated and // UTF-8 decoded BEFORE the one-line check, so a sparse multi-megabyte // .aidlc-document-input-path kills the process with an out-of-memory error // before any validation runs. 4096 bytes covers PATH_MAX on every supported // platform, plus the trailing newline. const requestFileByteCap = 4096; const refuse = (message: string): never => die(`${UNTRUSTED_PATH_NOTICE} ${message}`); const requestFile = documentInputRequestFilePath(projectDir); const requested = (() => { let raw: string; try { raw = new TextDecoder("utf-8", { fatal: true }).decode( readDocumentBytes( requestFile, DOCUMENT_INPUT_REQUEST_FILE, undefined, requestFileByteCap, ), ); } catch (error) { return refuse( `cannot read ${DOCUMENT_INPUT_REQUEST_FILE}: ${errorMessage(error)}. ` + "Write one exact path to that active-record file with the native file-write tool.", ); } const value = raw.replace(/\r?\n$/, ""); if (value === "" || /[\r\n]/.test(value)) { return refuse( `${DOCUMENT_INPUT_REQUEST_FILE} must contain exactly one non-empty path line.`, ); } return value; })(); const projectRoot = (() => { try { return realpathSync(projectDir); } catch (error) { return refuse(`cannot resolve the project root: ${errorMessage(error)}`); } })(); const requestedAbs = isAbsolute(requested) ? resolve(requested) : resolve(projectRoot, requested); const rel = relative(projectRoot, requestedAbs); if ( rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) ) { refuse( `document path must resolve to a file inside the project root: ${JSON.stringify(requested)}`, ); } const portablePath = rel.split(sep).join("/"); const { absPath, bytes } = (() => { try { const resolved = resolveContainedFile(projectRoot, portablePath); return { absPath: resolved.absPath, bytes: readDocumentBytes( resolved.absPath, `document input ${JSON.stringify(portablePath)}`, undefined, documentInputByteCap, resolved.identity, ), }; } catch (error) { return refuse( `cannot read ${JSON.stringify(portablePath)} directly: ${errorMessage(error)} ` + "The path is resolved from the project root and filenames are not searched " + "recursively. Provide one accessible regular file inside the project, or use DocumentKB.", ); } })(); const mime = detectMimeType(absPath, bytes); if (mime !== "text/plain" && mime !== "text/markdown") { refuse( `${JSON.stringify(portablePath)} is ${mime}, not direct UTF-8 text or Markdown. ` + "Place it under aidlc/spaces//knowledge/documents/, run " + "`/aidlc knowledge onboard `, then read it with `/aidlc knowledge show `.", ); } const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); if (content.length > EXTRACT_OUTPUT_CHAR_CAP) { refuse( `${JSON.stringify(portablePath)} contains ${content.length} characters; direct input is ` + `limited to ${EXTRACT_OUTPUT_CHAR_CAP}. Use DocumentKB so extraction and truncation ` + "are explicit.", ); } process.stdout.write( `${JSON.stringify({ path_notice: UNTRUSTED_PATH_NOTICE, content_notice: UNTRUSTED_CONTENT_NOTICE, path: portablePath, bytes: bytes.length, content_trust: "untrusted", content_handling: "data-not-instructions", content, })}\n`, ); } const CODEKB_ARTIFACT_FILES = [ "api-documentation.md", "architecture.md", "business-overview.md", "code-quality-assessment.md", "code-structure.md", "component-inventory.md", "dependencies.md", "reverse-engineering-timestamp.md", "technology-stack.md", ] as const; function resolveCodekbRepo( projectDir: string, flags: Record, ): { space: string; repo: string; repoDir: string; storeDir: string; excludes: string[] } { const selection = resolveWorkflowSelection(projectDir); const space = selection.space; const repo = flags.repo && flags.repo.length > 0 ? flags.repo : codekbRepoName(projectDir, space, selection.intent ?? undefined); if (!isValidRepoName(repo)) { die(`Invalid --repo "${repo}": a repo name must be one path segment.`); } const siblingDir = join(projectDir, repo); const sourceDir = existsSync(siblingDir) && statSync(siblingDir).isDirectory() ? siblingDir : projectDir; return { space, repo, repoDir: sourceDir, storeDir: codekbDir(projectDir, repo, space), excludes: sourceDir === projectDir ? ["aidlc"] : [], }; } function codekbPaths(flags: Record, command: string): string[] { const paths = (flags.paths ?? "") .split(",") .map((path) => path.trim()) .filter((path) => path !== ""); if (paths.length === 0) { die(`${command}: pass --paths `); } return [...new Set(paths)]; } function codekbLockIntent(repo: string): string { return `__codekb__${createHash("sha256").update(repo).digest("hex").slice(0, 16)}`; } function codekbTransactionRoot( projectDir: string, space: string, repo: string, ): string { return join( projectDir, "aidlc", "spaces", space, "intents", ".aidlc-codekb-transactions", repo, ); } function trustedCodekbRecoveryPath( projectReal: string, relativePath: string, ): string { try { return assertNoSymlinkInChainOrThrow(projectReal, relativePath); } catch (error) { throw new Error( `refusing CodeKB recovery through an unsafe project path: ${errorMessage(error)}`, ); } } function recoverCodekbTransactions( projectDir: string, space: string, repo: string, ): void { const projectReal = realpathSync(projectDir); const rootRelative = relative( projectReal, codekbTransactionRoot(projectReal, space, repo), ); const storeRelative = join("aidlc", "spaces", space, "codekb", repo); const root = trustedCodekbRecoveryPath(projectReal, rootRelative); const storeDir = trustedCodekbRecoveryPath(projectReal, storeRelative); if (!existsSync(root)) return; const rootStat = lstatSync(root); if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { throw new Error(`refusing CodeKB recovery from non-directory transaction root: ${root}`); } for (const name of readdirSync(root).sort()) { const txnRelative = join(rootRelative, name); const txn = trustedCodekbRecoveryPath(projectReal, txnRelative); const txnStat = lstatSync(txn); if (!txnStat.isDirectory() || txnStat.isSymbolicLink()) { throw new Error(`refusing CodeKB recovery from non-directory transaction: ${txn}`); } const backupRelative = join(txnRelative, "backup"); const backup = trustedCodekbRecoveryPath(projectReal, backupRelative); if (!existsSync(storeDir) && existsSync(backup)) { const backupStat = lstatSync(backup); if (!backupStat.isDirectory() || backupStat.isSymbolicLink()) { throw new Error(`refusing CodeKB recovery from non-directory backup: ${backup}`); } const checkedStore = trustedCodekbRecoveryPath(projectReal, storeRelative); const checkedBackup = trustedCodekbRecoveryPath(projectReal, backupRelative); mkdirSync(dirname(checkedStore), { recursive: true }); renameSync(checkedBackup, checkedStore); } rmSync( trustedCodekbRecoveryPath(projectReal, txnRelative), { recursive: true, force: true }, ); } rmSync( trustedCodekbRecoveryPath(projectReal, rootRelative), { recursive: true, force: true }, ); } function withCodekbLock( projectDir: string, space: string, repo: string, fn: () => T extends Promise ? never : T, ): T extends Promise ? never : T { return withAuditLock( projectDir, fn, codekbLockIntent(repo), space, ); } // Snapshot the two generations a scan is built from. The stage takes this // immediately before scanning and passes both tokens to codekb-publish. function handleCodekbSnapshot( projectDir: string, flags: Record, ): void { const { space, repo, repoDir, storeDir, excludes } = resolveCodekbRepo(projectDir, flags); const paths = codekbPaths(flags, "codekb-snapshot"); const snapshot = withCodekbLock(projectDir, space, repo, () => { recoverCodekbTransactions(projectDir, space, repo); const sourceFingerprint = codekbSourceFingerprint(repoDir, paths, excludes); if (sourceFingerprint === null) { die(`codekb-snapshot: cannot fingerprint source paths: ${paths.join(", ")}`); } return { repo, store: `${relativeCodekbDir(projectDir, repo, space)}/`, paths, store_generation: codekbStoreGeneration(storeDir), source_fingerprint: sourceFingerprint, }; }); if (flags.json === "true") { process.stdout.write(`${JSON.stringify(snapshot)}\n`); return; } process.stdout.write( `STORE_GENERATION ${snapshot.store_generation}\n` + `SOURCE_FINGERPRINT ${snapshot.source_fingerprint}\n` + `SOURCE_PATHS ${snapshot.paths.join(",")}\n`, ); } function readCodekbCandidate( projectDir: string, stagedFlag: string | undefined, ): { files: Map; scope: Extract, { ok: true }>["scope"]; } { if (!stagedFlag) { die("codekb-publish: pass --staged "); } const stagedPath = resolve(projectDir, stagedFlag); const rel = relative(projectDir, stagedPath); if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { die("codekb-publish: --staged must resolve inside the project directory"); } let stagedStat: ReturnType; try { stagedStat = lstatSync(stagedPath); } catch { die(`codekb-publish: staged directory not found: ${stagedFlag}`); } if (!stagedStat.isDirectory() || stagedStat.isSymbolicLink()) { die("codekb-publish: --staged must be a real directory, not a symlink"); } const projectReal = realpathSync(projectDir); const stagedDir = realpathSync(stagedPath); const realRel = relative(projectReal, stagedDir); if ( realRel === "" || realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel) ) { die("codekb-publish: --staged must not escape the project through a symlinked ancestor"); } const entries = readdirSync(stagedDir).sort(); const required = [...CODEKB_ARTIFACT_FILES].sort(); if (JSON.stringify(entries) !== JSON.stringify(required)) { die( `codekb-publish: staged directory must contain exactly the nine CodeKB artifacts; ` + `found: ${entries.join(", ") || "(empty)"}`, ); } const files = new Map(); for (const name of required) { const path = join(stagedDir, name); const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink()) { die(`codekb-publish: staged artifact must be a regular file: ${name}`); } files.set(name, readFileSync(path)); } const parsed = parseReScope( files.get("reverse-engineering-timestamp.md")?.toString("utf-8") ?? "", ); if (!parsed.ok) { die( `codekb-publish: staged reverse-engineering-timestamp.md has an invalid ` + `Scope of Analysis block (${parsed.reason}: ${parsed.detail})`, ); } return { files, scope: parsed.scope }; } function handleCodekbPublish( projectDir: string, flags: Record, ): void { const { space, repo, repoDir, storeDir, excludes } = resolveCodekbRepo(projectDir, flags); const expectedStore = flags["expect-store"]; const expectedSource = flags["expect-source"]; if (!expectedStore || !expectedSource) { die( "codekb-publish: pass --expect-store and --expect-source from codekb-snapshot", ); } const sourcePaths = codekbPaths(flags, "codekb-publish"); const candidate = readCodekbCandidate(projectDir, flags.staged); for (const path of candidate.scope.analyzedPaths) { if (!sourcePaths.includes("./") && !scopePathCovered(sourcePaths, path)) { die( `codekb-publish: snapshot paths do not cover candidate analyzed path "${path}"; ` + `take a fresh codekb-snapshot over the complete candidate scope`, ); } } const result = withCodekbLock(projectDir, space, repo, () => { recoverCodekbTransactions(projectDir, space, repo); const currentStore = codekbStoreGeneration(storeDir); if (currentStore !== expectedStore) { die( `CODEKB_STORE_CHANGED: expected ${expectedStore}, found ${currentStore}. ` + `Re-read the current store, re-merge the staged scan, take a fresh snapshot, and retry.`, ); } const currentSource = codekbSourceFingerprint(repoDir, sourcePaths, excludes); if (currentSource === null || currentSource !== expectedSource) { die( `CODEKB_SOURCE_CHANGED: expected ${expectedSource}, found ${currentSource ?? "unavailable"}. ` + `Re-scan the affected source, re-synthesize all nine artifacts, take a fresh snapshot, and retry.`, ); } const currentCandidateFingerprint = codekbScopeFingerprint( repoDir, candidate.scope.analyzedPaths, excludes, ); if ( candidate.scope.fingerprint !== currentCandidateFingerprint && !(candidate.scope.fingerprint === null && currentCandidateFingerprint === null) ) { die( `CODEKB_CANDIDATE_STALE: staged fingerprint ` + `${candidate.scope.fingerprint ?? "unknown"} does not match the current source ` + `${currentCandidateFingerprint ?? "unknown"}. Re-mint the timestamp and retry.`, ); } const txn = join( codekbTransactionRoot(projectDir, space, repo), `${process.pid}-${randomUUID()}`, ); const next = join(txn, "next"); const backup = join(txn, "backup"); mkdirSync(next, { recursive: true }); try { for (const [name, bytes] of candidate.files) { writeFileSync(join(next, name), bytes); } mkdirSync(dirname(storeDir), { recursive: true }); const hadStore = existsSync(storeDir); if (hadStore) renameSync(storeDir, backup); try { renameSync(next, storeDir); } catch (error) { if (hadStore && existsSync(backup) && !existsSync(storeDir)) { renameSync(backup, storeDir); } throw error; } rmSync(backup, { recursive: true, force: true }); return { repo, published: `${relativeCodekbDir(projectDir, repo, space)}/`, generation: codekbStoreGeneration(storeDir), }; } finally { rmSync(txn, { recursive: true, force: true }); } }); process.stdout.write( flags.json === "true" ? `${JSON.stringify(result)}\n` : `PUBLISHED ${result.published} ${result.generation}\n`, ); } // `aidlc-utility.ts codekb-scope-diff [--repo ] [--compare ] // [--json]` - read-only. The deterministic half of the reverse-engineering // rerun guard (the store is shared space-level knowledge; compare mode reports // which paths/components are no longer claimed as verified deep coverage). // // Status mode (default): parse the STORE's reverse-engineering-timestamp.md // scope block and recompute the content fingerprint over its analyzed paths. // NO_STORE no store timestamp - first scan, nothing to guard // CURRENT fingerprint matches - the store's deep knowledge is exact // STALE analyzed paths changed since the store was built // UNVERIFIED scope parsed but no/uncomputable fingerprint (non-git) // UNKNOWN_SCOPE block absent (legacy store) or malformed // // Compare mode (--compare ): does the incoming run's // analyzed scope cover the store's? COVERS, or NARROWER + the exact paths and // components an overwrite would discard. // // Mint mode (--mint --paths ): print the content fingerprint over // the given repo-relative paths - the value the architect writes into the // scope block's `fingerprint:` line at synthesis time. Prints `unknown` when // not computable (non-git or invalid pathspec), which the block records // verbatim. // // Always exits 0 with the verdict in the output (read-only query - mirrors // codekb-path; refusals are for lifecycle verbs). No mkdir, no state write, // no audit. function handleCodekbScopeDiff(projectDir: string, flags: Record): void { const asJson = flags.json === "true"; const selection = resolveWorkflowSelection(projectDir); const space = selection.space; const repo = flags.repo && flags.repo.length > 0 ? flags.repo : codekbRepoName(projectDir, space, selection.intent ?? undefined); const storeDir = relativeCodekbDir(projectDir, repo, space); const storePath = join(projectDir, ...storeDir.split("/"), "reverse-engineering-timestamp.md"); // The repo's source root: the sibling dir `//` when it // exists (the multi-repo layout reverse-engineering.md Step 1 scans), else // the workspace root itself (the lone-repo case, where codekbRepoName is // basename(projectDir)). const siblingDir = join(projectDir, repo); const repoDir = existsSync(siblingDir) && statSync(siblingDir).isDirectory() ? siblingDir : projectDir; // In the lone-repo layout the framework-owned aidlc workspace tree lives // under the repository root. Exclude it from full-root fingerprints so // writing the scope draft, codekb, audit, or state cannot stale its own hash. const fingerprintExcludes = repoDir === projectDir ? ["aidlc"] : []; if (flags.mint === "true") { const paths = (flags.paths ?? "") .split(",") .map((p) => p.trim()) .filter((p) => p !== ""); if (paths.length === 0) { die("codekb-scope-diff --mint: pass --paths "); } const fp = codekbScopeFingerprint(repoDir, paths, fingerprintExcludes) ?? "unknown"; if (asJson) process.stdout.write(`${JSON.stringify({ repo, fingerprint: fp, paths })}\n`); else process.stdout.write(`${fp}\n`); return; } const emit = (payload: Record, human: string): void => { if (asJson) process.stdout.write(`${JSON.stringify({ repo, store: `${storeDir}/`, ...payload })}\n`); else process.stdout.write(`${human}\n`); }; if (!existsSync(storePath)) { emit( { verdict: "NO_STORE" }, `NO_STORE: no reverse-engineering-timestamp.md at ${storeDir}/ - first scan, nothing to compare.`, ); return; } const parsed = parseReScope(readFileSync(storePath, "utf-8")); if (!parsed.ok) { emit( { verdict: "UNKNOWN_SCOPE", reason: parsed.reason, detail: parsed.detail }, `UNKNOWN_SCOPE (${parsed.reason}): ${parsed.detail}. The store predates scope tracking. A focused merge may retain its prose, but prior paths and components are not claimed as verified coverage until rescanned.`, ); return; } const store = parsed.scope; if (flags.compare !== undefined) { const incomingPath = flags.compare; if (!incomingPath || !existsSync(incomingPath)) { die(`codekb-scope-diff --compare: file not found: ${incomingPath || "(missing path)"}`); } const incomingParsed = parseReScope(readFileSync(incomingPath, "utf-8")); if (!incomingParsed.ok) { emit( { verdict: "UNKNOWN_SCOPE", reason: incomingParsed.reason, detail: `incoming: ${incomingParsed.detail}` }, `UNKNOWN_SCOPE (incoming ${incomingParsed.reason}): ${incomingParsed.detail}.`, ); return; } const incoming = incomingParsed.scope; const fullScopeDowngrade = store.kind === "full" && incoming.kind !== "full"; const discardedPaths = incoming.kind === "full" ? [] : fullScopeDowngrade ? [...store.analyzedPaths] : store.analyzedPaths.filter((p) => !scopePathCovered(incoming.analyzedPaths, p)); const discardedComponents = incoming.kind === "full" ? [] : store.analyzedComponents.filter((c) => !incoming.analyzedComponents.includes(c)); const narrower = discardedPaths.length > 0 || discardedComponents.length > 0; const payload = { verdict: narrower ? "NARROWER" : "COVERS", store_intent: store.intent, incoming_intent: incoming.intent, discarded_paths: discardedPaths, discarded_components: discardedComponents, }; if (narrower) { emit( payload, `NARROWER: the incoming scope no longer claims verified deep coverage for:\n` + discardedPaths.map((p) => ` - ${p}`).join("\n") + (discardedComponents.length > 0 ? `\n components: ${discardedComponents.join(", ")}` : "") + `\n(store intent: ${store.intent || "unrecorded"}; incoming intent: ${incoming.intent || "unrecorded"})`, ); } else { emit(payload, `COVERS: the incoming scan covers everything the store analyzed.`); } return; } // Status mode. const currentFingerprint = store.analyzedPaths.length > 0 ? codekbScopeFingerprint(repoDir, store.analyzedPaths, fingerprintExcludes) : null; const scopeLines = store.analyzedPaths.map((p) => ` - ${p}`).join("\n"); if (store.fingerprint === null || currentFingerprint === null) { emit( { verdict: "UNVERIFIED", store_intent: store.intent, kind: store.kind, analyzed_paths: store.analyzedPaths, detail: store.fingerprint === null ? "store has no fingerprint" : "fingerprint not computable here", }, `UNVERIFIED: the store (intent: ${store.intent || "unrecorded"}) analyzed:\n${scopeLines}\n` + `but ${store.fingerprint === null ? "recorded no fingerprint" : "the current tree's fingerprint cannot be computed"} - freshness unknown.`, ); return; } const current = store.fingerprint === currentFingerprint; emit( { verdict: current ? "CURRENT" : "STALE", store_intent: store.intent, kind: store.kind, analyzed_paths: store.analyzedPaths, store_fingerprint: store.fingerprint, current_fingerprint: currentFingerprint, }, current ? `CURRENT: the analyzed paths are unchanged since the store was built (intent: ${store.intent || "unrecorded"}, coverage: ${store.kind}):\n${scopeLines}` : `STALE: the analyzed paths have changed since the store was built (intent: ${store.intent || "unrecorded"}):\n${scopeLines}`, ); } // `detect [--json]` - read-only. Runs the workspace scan (detectWorkspace) on // the bare project dir - it needs no aidlc/ workspace; it scans the app root - // and prints projectType (Greenfield/Brownfield), languages, frameworks, and // buildSystem. ALSO prints the resolved scope-registry paths (scopesDir + // scopeGridPath): those are module-relative to the installed tool, which a // prose agent cannot derive itself, so the composer agent is TOLD where the // runtime reads scope data (and therefore where an authored scope must land). // Writes nothing, no audit, no mkdir - mirrors codekb-path's read-only shape. function handleDetect(projectDir: string, flags: Record): void { const scan = detectWorkspace(projectDir); const payload = { projectType: scan.projectType, languages: scan.languages, frameworks: scan.frameworks, buildSystem: scan.buildSystem, ...(scan.nestedRoot ? { nestedRoot: scan.nestedRoot } : {}), submodules: scan.submodules, scopesDir: scopesDir(), scopeGridPath: scopeGridPath(), scopes: [...validScopes()], }; if (flags.json === "true") { process.stdout.write(`${JSON.stringify(payload)}\n`); return; } const uninitCount = scan.submodules.filter((s) => !s.initialized).length; const submoduleLine = scan.submodules.length > 0 ? `Submodules: ${scan.submodules.length} declared, ${uninitCount} uninitialized\n` : ""; process.stdout.write( `Project type: ${payload.projectType}\n` + `Languages: ${payload.languages}\n` + `Frameworks: ${payload.frameworks}\n` + `Build system: ${payload.buildSystem}\n` + (scan.nestedRoot ? `Nested root: ${scan.nestedRoot}\n` : "") + submoduleLine + `Scopes dir: ${payload.scopesDir}\n` + `Scope grid: ${payload.scopeGridPath}\n` + `Valid scopes: ${payload.scopes.join(", ")}\n`, ); } // `/aidlc space create ` (legacy `/aidlc space-create `) - seed a NEW space's memory. org.md is copied // from spaces/default/memory/org.md (the always-present SEED baseline), plus // fresh empty team.md/project.md/phases stubs + the templates/ floor. A new team // starts at the framework baseline and earns its OWN practices — it does NOT // inherit another space's learnings. (A new INTENT, by contrast, seeds nothing: // it reads its space's live memory — handled in createIntent.) function handleSpaceCreate(projectDir: string, positional: string[], _flags: Record): void { const raw = positional[1]; if (!raw) die("Usage: aidlc-utility space-create "); // A help-shaped arg is a help request, not a name. Checked BEFORE slugify: // slugify("-h") is "h", which is not a reserved name, so the guard below // would let it through and a junk space would be created. if (raw === "-h" || raw === "help") { die("Did you mean /aidlc --help? To create a space, pass a name: /aidlc space-create ."); } const name = slugify(raw); // "help" is grammar (`space help` prints help), so a space with that slug // would be unswitchable by name - refuse it here, the creation chokepoint. if (RESERVED_RECORD_NAMES.has(name)) { die( `"${name}" is a reserved name and cannot be a space name. Pick a name that describes the team.` ); } const dest = join(spacesRoot(projectDir), name); if (existsSync(dest)) die(`Space "${name}" already exists at ${dest}.`); const memoryDest = join(dest, "memory"); mkdirSync(memoryDest, { recursive: true }); mkdirSync(join(memoryDest, "phases"), { recursive: true }); mkdirSync(join(memoryDest, "templates"), { recursive: true }); mkdirSync(join(dest, "intents"), { recursive: true }); // #5 — a new space gets the FULL space shape so it matches default's // committed layout (vision §11.2 "identical shape"): the space-level codekb/ // and knowledge/ siblings of memory/intents. Built as bare parents — the // per-repo codekb// subdir is authored later by RE/codekb-path (no repo // is recorded at create time, so codekbDir() can't be called here), and // knowledge/ is free-form/empty at bootstrap. .gitkeep floors so the empty // dirs track (codekb output is COMMITTED, so the floor is not gitignored). mkdirSync(join(dest, "codekb"), { recursive: true }); mkdirSync(knowledgeDir(projectDir, name), { recursive: true }); // Copy the org.md baseline from the default space (the always-present SEED // shell). If absent (a malformed shell), fall back to an empty stub rather // than dying — the resolver tolerates an empty/absent rules dir. const orgSrc = join(spacesRoot(projectDir), DEFAULT_SPACE, "memory", "org.md"); const orgDest = join(memoryDest, "org.md"); if (existsSync(orgSrc)) { writeFileSync(orgDest, readFileSync(orgSrc, "utf-8"), "utf-8"); } else { writeFileSync(orgDest, "# Organization defaults\n", "utf-8"); } // Fresh empty team/project stubs (a new team earns its own practices). if (!existsSync(join(memoryDest, "team.md"))) { writeFileSync(join(memoryDest, "team.md"), "# Team practices\n", "utf-8"); } if (!existsSync(join(memoryDest, "project.md"))) { writeFileSync(join(memoryDest, "project.md"), "# Project overrides\n", "utf-8"); } // templates/ floor marker so the empty dir is tracked (mirrors SEED's floor). const floor = join(memoryDest, "templates", ".gitkeep"); if (!existsSync(floor)) writeFileSync(floor, "", "utf-8"); // codekb/ + knowledge/ floors so the empty siblings track (both committed). const codekbFloor = join(dest, "codekb", ".gitkeep"); if (!existsSync(codekbFloor)) writeFileSync(codekbFloor, "", "utf-8"); const knowledgeFloor = join(knowledgeDir(projectDir, name), ".gitkeep"); if (!existsSync(knowledgeFloor)) writeFileSync(knowledgeFloor, "", "utf-8"); process.stdout.write( `Space created: ${name}\n memory/org.md (copied from default), team.md, project.md, phases/, templates/, codekb/, knowledge/\nSwitch to it with /aidlc space ${name}.\n` ); } // Caller is responsible for applying any scope- or project-type-specific // downgrades (e.g., reverse-engineering SKIP for greenfield) to the mapping // before calling this helper. Walks post-init stages and returns the slug of // the first EXECUTE entry. function determineFirstPostInitStage( adjustedMapping: Record, graph: StageEntry[] ): string { for (const stage of graph) { if (stage.phase === "initialization") continue; const action = adjustedMapping[stage.slug] || "SKIP"; if (action === "EXECUTE") { return stage.slug; } } return "intent-capture"; // fallback } // --------------------------------------------------------------------------- // scope-change — atomically change scope on an existing workflow // --------------------------------------------------------------------------- function handleScopeChange(projectDir: string, flags: Record): void { const newScope = flags.scope; if (!newScope) die("--scope is required for scope-change"); const depthOverride = flags.depth; if (depthOverride && !VALID_DEPTHS[depthOverride.toLowerCase()]) { die(`Unknown depth: "${depthOverride}". Valid depths: minimal, standard, comprehensive.`); } const testStrategyOverride = flags["test-strategy"]; if (testStrategyOverride && !VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()]) { die(`Unknown test strategy: "${testStrategyOverride}". Valid: minimal, standard, comprehensive.`); } const reviewOverride = parseReviewOverride(flags.review); const selection = resolveWorkflowSelection(projectDir, { intent: flags.intent, space: flags.space, }); const intent = selection.intent ?? undefined; const space = selection.space; const sp = stateFilePath(projectDir, intent, space); if (!existsSync(sp)) die("No state file found. Start a workflow first by describing what to build (/aidlc \"build the auth service\")."); const scopeMapping = loadScopeMapping(); const newScopeDef = scopeMapping[newScope]; if (!newScopeDef) die(`Unknown scope: ${newScope}. Valid scopes: ${Object.keys(scopeMapping).join(", ")}`); const contentBefore = readStateFile(projectDir, intent, space); const before = resolveChangeControl(projectDir, contentBefore, { selection: { intent, space }, }); let content = contentBefore; // AUTONOMY GUARD (the recompose guard's twin, same rationale): scope-change // flips stage EXECUTE/SKIP suffixes exactly like recompose does, so an // unattended autonomous Construction run must not have its plan re-shaped // through this verb either - there is no human at the gate to approve the // new shape. Guard placed before the same-scope early exit (fail-fast, the // recompose posture): under autonomy even a no-op call is refused, so the // conductor learns the rule on first contact rather than on the first // differing scope. Uses the exported isAutonomousMode predicate per its // contract (new gate sites use the helper; only pre-existing open-coded // sites are grandfathered), so this site cannot drift from the others. if (isAutonomousMode(content)) { die( "Cannot change scope while Construction is running unattended (Construction Autonomy Mode " + "is autonomous). Changing the plan needs someone to approve it, and nobody is being asked " + "right now. Either switch back to stopping for approval at each Bolt " + "(aidlc-bolt set-autonomy --mode gated) or wait for the current build to finish, then change scope.", ); } const oldScope = getField(content, "Scope"); if (!oldScope) die("Cannot read current Scope from state file."); if (oldScope === newScope) { if (depthOverride || testStrategyOverride || reviewOverride !== undefined) { handleConfigChange(projectDir, flags); return; } process.stdout.write(`Scope is already ${newScope}\n`); return; } const graph = loadStageGraph(); const projectType = getField(content, "Project Type") || "Greenfield"; // Compute adjusted mapping (greenfield reverse-engineering adjustment) const adjustedMapping = { ...newScopeDef.stages }; if (projectType.toLowerCase() === "greenfield") { if (adjustedMapping["reverse-engineering"] === "EXECUTE") { adjustedMapping["reverse-engineering"] = "SKIP"; } } // Compute new execute/skip lists const executeStages: string[] = []; const skipStages: string[] = []; for (const stage of graph) { const action = adjustedMapping[stage.slug] || "SKIP"; if (action === "EXECUTE") { executeStages.push(stage.number); } else { let reason = stage.slug; if (stage.slug === "reverse-engineering" && projectType.toLowerCase() === "greenfield" && newScopeDef.stages["reverse-engineering"] === "EXECUTE") { reason += " — greenfield"; } skipStages.push(`${stage.number} (${reason})`); } } // Parse existing checkboxes to preserve states const existingCheckboxes = parseCheckboxes(content); const existingMap = new Map(existingCheckboxes.map(c => [c.slug, c])); // Rebuild Stage Progress section const phaseMap: Record = {}; for (const stage of graph) { if (!phaseMap[stage.phase]) phaseMap[stage.phase] = []; phaseMap[stage.phase].push(stage); } const phaseHeaders: Record = { initialization: "INITIALIZATION PHASE", ideation: "IDEATION PHASE", inception: "INCEPTION PHASE", construction: "CONSTRUCTION PHASE", operation: "OPERATION PHASE", }; let newStageProgress = ""; for (const phase of PHASES) { const stages = phaseMap[phase] || []; newStageProgress += `\n### ${phaseHeaders[phase]}\n`; if (phase === "construction") { // Preserve existing "Per unit:" line const perUnitMatch = content.match(/^Per unit:.*$/m); if (perUnitMatch) { newStageProgress += `${perUnitMatch[0]}\n`; } } for (const stage of stages) { const action = adjustedMapping[stage.slug] || "SKIP"; const existing = existingMap.get(stage.slug); // Preserve existing checkbox state, default to [ ] if not found const marker = existing ? `[${existing.state === "completed" ? "x" : existing.state === "in-progress" ? "-" : existing.state === "skipped" ? "S" : " "}]` : "[ ]"; const suffix = action === "EXECUTE" ? "EXECUTE" : "SKIP"; newStageProgress += `- ${marker} ${stage.slug} \u2014 ${suffix}\n`; } } // Replace Stage Progress section in content const stageProgressRegex = /## Stage Progress\n\n([\s\S]*?)(?=\n## (?!Stage Progress))/; const stageProgressHeader = "## Stage Progress\n\n"; content = content.replace(stageProgressRegex, stageProgressHeader + newStageProgress); // Update fields content = setField(content, "Scope", newScope); content = setField(content, "Stages to Execute", executeStages.join(", ")); content = setField(content, "Stages to Skip", skipStages.length > 0 ? skipStages.join(", ") : "none"); const effectiveDepth = depthOverride ? VALID_DEPTHS[depthOverride.toLowerCase()] : newScopeDef.depth; content = setField(content, "Depth", effectiveDepth); const effectiveTestStrategy = testStrategyOverride ? VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()] : (newScopeDef.testStrategy ?? effectiveDepth); content = setField(content, "Test Strategy", effectiveTestStrategy); // A Change Control line the scope supplied follows the new scope; a value // the human set, a legacy intent with no line, or a memory override stays. const scopeSuppliedChangeControl = before.intent?.source.startsWith("scope ") === true; const scopeChangeControl = newScopeDef.changeControl ?? "strict"; if (scopeSuppliedChangeControl) { content = setField( content, CHANGE_CONTROL_FIELD, formatChangeControl(scopeChangeControl, `scope ${newScope}`), ); } const reviewUpdate = applyReviewOverride(content, reviewOverride); content = reviewUpdate.content; content = setField(content, "Total Stages", String(executeStages.length)); // Recount completed based on actual [x] count of in-scope EXECUTE stages const updatedCheckboxes = parseCheckboxes(content); const executeSlugs = new Set( graph.filter(s => (adjustedMapping[s.slug] || "SKIP") === "EXECUTE").map(s => s.slug) ); const completedCount = updatedCheckboxes.filter( c => c.state === "completed" && executeSlugs.has(c.slug) ).length; content = setField(content, "Completed", String(completedCount)); // Re-derive the not-yet-reached Phase Progress rows against the new plan: // a phase the new scope leaves without EXECUTE stages reads Skipped, one it // (re-)includes reads Pending. Verified/Active rows record history the // scope change does not rewrite (checkbox states are preserved above for // the same reason), so they are left untouched. for (const phase of PHASES) { const label = phase.charAt(0).toUpperCase() + phase.slice(1); const row = getField(content, label); if (row !== "Pending" && row !== "Skipped") continue; const hasExecute = graph.some( (s) => s.phase === phase && (adjustedMapping[s.slug] || "SKIP") === "EXECUTE" ); content = setPhaseProgress(content, phase, hasExecute ? "Pending" : "Skipped"); } // Update Last Updated timestamp content = setField(content, "Last Updated", isoTimestamp()); const after = resolveChangeControl(projectDir, content, { selection: { intent, space }, }); // Build every audit row before mutation. The batch lands in one append // before the state write under the same lock, so append failure leaves both untouched. const oldScopeDef = scopeMapping[oldScope]; const oldExecuteCount = oldScopeDef ? graph.filter(s => (oldScopeDef.stages[s.slug] || "SKIP") === "EXECUTE").length : 0; const stageDelta = executeStages.length - oldExecuteCount; const deltaStr = stageDelta >= 0 ? `+${stageDelta}` : String(stageDelta); // Ceremony preview for the switch: gate count from the effective grid (the // reverse-engineering greenfield adjustment already applied) so the same // disclosure exists on a scope switch as on the cold-start confirm. const gates = gridCostSummary( adjustedMapping as Record, ).gates; const auditEntries: AuditEntryInput[] = [{ eventType: "SCOPE_CHANGED", fields: { "Old Scope": oldScope, "New Scope": newScope, "Stage Count Delta": deltaStr, "Stages in Scope": String(executeStages.length), "Approval Gates": String(gates), Depth: effectiveDepth, }, }]; if (reviewUpdate.changed) { auditEntries.push({ eventType: "REVIEW_CLASS_CHANGED", fields: { "Old Override": reviewUpdate.oldReview || "none set", "New Override": reviewUpdate.storedReview || "cleared (stage defaults apply)", }, }); } if (before.value !== after.value) { auditEntries.push({ eventType: "CHANGE_CONTROL_SET", fields: { "Old Value": before.value, "New Value": after.value, Source: `scope ${newScope}`, }, }); } withAuditLock(projectDir, () => { try { if (before.value !== after.value) assertChangeControlLedgerWritable(); appendAuditEntries(auditEntries, projectDir, intent, space); } catch (error) { throw new Error(`Cannot record the scope change: ${errorMessage(error)}`); } writeStateFile(projectDir, content, intent, space); }, intent, space); process.stdout.write( `Scope changed: ${oldScope} -> ${newScope} Stages in scope: ${executeStages.length} (${deltaStr}) Approval gates: ${gates} Depth: ${effectiveDepth} ${reviewOverride === undefined ? "" : `Review override: ${reviewUpdate.storedReview || "adversarial (stage defaults)"}\n`}Completed: ${completedCount}/${executeStages.length} ` ); } // --------------------------------------------------------------------------- // recompose - flip a PENDING stage's plan suffix on the live state file // (the adaptive composer's in-flight write). `--skip ` drops stages // from the plan; `--add ` promotes them back (comma-separated). The // whole mutation runs under withAuditLock; validation is STRICT (a starved // required input rejects, not advises); the derived state fields are rebuilt // the way scope-change rebuilds them; a RECOMPOSED audit event lands with the // flip lists. A run that never calls recompose is byte-identical to before - // the verb is inert when unused. // --------------------------------------------------------------------------- function handleRecompose(projectDir: string, flags: Record): void { const skipList = (flags.skip ?? "").split(",").map((s) => s.trim()).filter(Boolean); const addList = (flags.add ?? "").split(",").map((s) => s.trim()).filter(Boolean); if (skipList.length === 0 && addList.length === 0) { die("Usage: recompose [--skip ] [--add ] - name at least one flip."); } const overlap = skipList.filter((s) => addList.includes(s)); if (overlap.length > 0) { die(`Cannot both --skip and --add the same stage: ${overlap.join(", ")}.`); } const sp = stateFilePath(projectDir, flags.intent, flags.space); if (!existsSync(sp)) { die("No state file found. recompose re-shapes a RUNNING workflow; start one first."); } withAuditLock(projectDir, () => { let content = readStateFile(projectDir, flags.intent, flags.space); // AUTONOMY GUARD (mirrors the park guard's shape in aidlc-state.ts): an // unattended autonomous Construction run has no human at the gate, so a // conductor that drifts into "improving the plan" must not flip pending // stages on its own. The SKILL.md prose says plan-reshape never runs under // autonomous Construction on any harness; this is the deterministic anchor // that enforcement was missing (the strict validator catches starvation and // anchor moves, but not the absence of a human). Refuse outright; a // legitimate unattended-recompose story, if one ever arrives, comes as an // explicit flag, not the default. if (getField(content, "Construction Autonomy Mode")?.trim() === "autonomous") { die( "Cannot change the plan while Construction is running unattended (Construction Autonomy " + "Mode is autonomous). Changing the plan needs someone to approve it, and nobody is being " + "asked right now. Either switch back to stopping for approval at each Bolt " + "(aidlc-bolt set-autonomy --mode gated) or wait for the current build to finish, then recompose.", ); } // Only a RUNNING workflow has a live plan to re-shape. A Completed (or // Parked/terminated) state file is a terminal record: flipping its rows // would grow Total Stages under a summary computed at completion and // leave no cursor to ever reach the added stage — a corrupted record, // not a plan change. (With no cursor, the behind-cursor guard below is // also inert, so this check is the only thing standing between recompose // and a finished workflow.) const wfStatus = getField(content, "Status") || ""; if (wfStatus !== "Running") { die( `Cannot recompose: workflow Status is "${wfStatus || "unknown"}", not Running. ` + "Recompose re-shapes a LIVE plan; for finished work start a new workflow instead.", ); } const scope = getField(content, "Scope"); if (!scope) die("Cannot read current Scope from state file."); const scopeDef = loadScopeMapping()[scope]; if (!scopeDef) die(`Unknown scope in state file: ${scope}.`); const graph = loadStageGraph(); const knownSlugs = new Set(graph.map((s) => s.slug)); const checkboxes = parseCheckboxes(content); const checkboxMap = new Map(checkboxes.map((c) => [c.slug, c.state])); const suffixes = parseStateStageSuffixes(content); const currentSlug = getField(content, "Current Stage") || ""; const currentIdx = graph.findIndex((s) => s.slug === currentSlug); // The effective pre-flip plan (suffix override wins over the grid). const effective = (slug: string): "EXECUTE" | "SKIP" => { const v = suffixes.get(slug) ?? scopeDef.stages[slug]; return v === "EXECUTE" ? "EXECUTE" : "SKIP"; }; // --- Per-flip guards: pending-only, ahead-of-cursor, skeleton-gate ------ const reject = (slug: string, why: string): never => die(`Cannot recompose "${slug}": ${why}`); for (const slug of [...skipList, ...addList]) { if (!knownSlugs.has(slug)) { reject(slug, "not a compiled stage."); } const state = checkboxMap.get(slug); if (state === "completed" || state === "in-progress" || state === "skipped" || state === "awaiting-approval" || state === "revising") { reject(slug, `its checkbox is not pending ([${state}]). Only a PENDING stage's plan can be re-shaped; completed/in-progress/skipped stages are frozen.`); } const idx = graph.findIndex((s) => s.slug === slug); if (currentIdx !== -1 && idx !== -1 && idx <= currentIdx) { reject(slug, `it is at or behind the current stage ("${currentSlug}"). In-flight recompose only reaches forward; re-running the past is out of scope.`); } } // The walking-skeleton gate derivation keys off the FIRST construction // EXECUTE stage (static). A flip that MOVES that anchor - skipping the // current anchor, or adding a construction stage AHEAD of it - would // silently relocate Bolt 1 and the skeleton stance round-trip. Compare // the anchor before and after the proposed flips and reject any move // (the cheapest sound answer; a suffix-aware gate derivation is a larger // change this verb must not smuggle in). const anchorOf = (plan: (slug: string) => "EXECUTE" | "SKIP"): string | undefined => graph.find((s) => s.phase === "construction" && plan(s.slug) === "EXECUTE")?.slug; const anchorBefore = anchorOf(effective); const anchorAfter = anchorOf((slug) => { if (skipList.includes(slug)) return "SKIP"; if (addList.includes(slug)) return "EXECUTE"; return effective(slug); }); if (anchorBefore !== anchorAfter) { const mover = anchorBefore && skipList.includes(anchorBefore) ? anchorBefore : (anchorAfter ?? anchorBefore ?? "construction"); reject( mover, `the flip moves the first EXECUTE stage of Construction (the walking-skeleton gate anchor) from "${anchorBefore ?? "none"}" to "${anchorAfter ?? "none"}". The skeleton gate must stay anchored; jump or change scope instead.`, ); } // --- Build the proposed effective grid and validate STRICT -------------- // Strictness is a DIFF against the pre-flip baseline: a stock scope may be // CREATED with structural advisories (e.g. bugfix's code-generation consumes // unit-of-work from the skipped units-generation - the scope author owns // that upstream work), and those must not veto an unrelated flip. What the // recompose validator hard-rejects is NEW starvation the flips introduce: // any strict error present post-flip that was absent pre-flip. const baseGrid: Record = {}; for (const s of graph) baseGrid[s.slug] = effective(s.slug); const proposed: Record = { ...baseGrid }; for (const slug of skipList) proposed[slug] = "SKIP"; for (const slug of addList) proposed[slug] = "EXECUTE"; // Stages already completed [x] satisfy their consumers even if the plan // now skips them - mark them EXECUTE for the dependency walk (in BOTH // grids) so a flip after a producer already ran is not falsely starved. for (const c of checkboxes) { if (c.state === "completed") { baseGrid[c.slug] = "EXECUTE"; proposed[c.slug] = "EXECUTE"; } } const projectType = (getField(content, "Project Type") || "").toLowerCase(); const pt = projectType === "brownfield" || projectType === "greenfield" ? (projectType as "brownfield" | "greenfield") : undefined; const label = `recomposed ${scope}`; const baseErrors = new Set( validateGrid(baseGrid, { strict: true, projectType: pt, label }).errors, ); const validation = validateGrid(proposed, { strict: true, projectType: pt, label, }); const newErrors = validation.errors.filter((e) => !baseErrors.has(e)); if (newErrors.length > 0) { die( `Recompose rejected by the strict validator:\n${newErrors.map((e) => ` - ${e}`).join("\n")}`, ); } // --- Apply the suffix flips --------------------------------------------- for (const slug of skipList) content = setStageSuffix(content, slug, "SKIP"); for (const slug of addList) content = setStageSuffix(content, slug, "EXECUTE"); // --- Rebuild the derived fields against the EFFECTIVE plan -------------- // (the scope-change set: Stages to Execute / to Skip / Total / Completed). const postSuffixes = parseStateStageSuffixes(content); const eff = (slug: string): "EXECUTE" | "SKIP" => { const v = postSuffixes.get(slug) ?? scopeDef.stages[slug]; return v === "EXECUTE" ? "EXECUTE" : "SKIP"; }; // The Stages to Skip row carries creation/scope-change annotations (entry // shape " ()", e.g. "2.1 (reverse-engineering — greenfield)") // that a bare-slug rebuild would destroy. Preserve each existing entry // VERBATIM, in its existing position, when its stage is still skipped; // drop entries whose stage was promoted; append newly-skipped stages in // graph order, rendered the way scope-change renders them. A skip+add // round trip therefore leaves the row byte-identical. const priorSkipRow = getField(content, "Stages to Skip") || ""; const priorTokens = priorSkipRow.trim() === "" || priorSkipRow.trim() === "none" ? [] : priorSkipRow.split(", "); const slugOfSkipToken = (token: string): string => { const m = /^\S+ \((.+)\)$/.exec(token); const inner = m ? m[1] : token; return inner.split(" — ")[0]; }; const executeStages: string[] = []; const skipStages: string[] = []; const preservedSlugs = new Set(); for (const token of priorTokens) { const slug = slugOfSkipToken(token); if (knownSlugs.has(slug) && eff(slug) === "SKIP") { skipStages.push(token); preservedSlugs.add(slug); } } for (const s of graph) { if (eff(s.slug) === "EXECUTE") executeStages.push(s.number); else if (!preservedSlugs.has(s.slug)) skipStages.push(`${s.number} (${s.slug})`); } content = setField(content, "Stages to Execute", executeStages.join(", ")); content = setField(content, "Stages to Skip", skipStages.length > 0 ? skipStages.join(", ") : "none"); content = setField(content, "Total Stages", String(executeStages.length)); const completedCount = parseCheckboxes(content).filter( (c) => c.state === "completed" && eff(c.slug) === "EXECUTE", ).length; content = setField(content, "Completed", String(completedCount)); // Re-derive not-yet-reached Phase Progress rows against the effective // plan (scope-change's twin): a flip can empty a phase of EXECUTE stages // (-> Skipped) or give a Skipped phase its first (-> Pending). // Verified/Active rows are history and stay untouched. for (const phase of PHASES) { const phaseLabel = phase.charAt(0).toUpperCase() + phase.slice(1); const row = getField(content, phaseLabel); if (row !== "Pending" && row !== "Skipped") continue; const hasExecute = graph.some( (s) => s.phase === phase && eff(s.slug) === "EXECUTE", ); content = setPhaseProgress(content, phase, hasExecute ? "Pending" : "Skipped"); } // The Next Stage projection over the recomposed plan (override-aware). if (currentSlug) { const next = nextInScopeStage(currentSlug, scope, content); content = setField(content, "Next Stage", next ? next.slug : "none"); } content = setField(content, "Last Updated", isoTimestamp()); writeStateFile(projectDir, content, flags.intent, flags.space); appendAuditEvent(projectDir, "RECOMPOSED", { Scope: scope, "Stages skipped": skipList.length > 0 ? skipList.join(", ") : "none", "Stages added": addList.length > 0 ? addList.join(", ") : "none", "Stages in Scope": String(executeStages.length), }); process.stdout.write( `Recomposed: ${skipList.length} skipped (${skipList.join(", ") || "none"}), ` + `${addList.length} added (${addList.join(", ") || "none"})\n` + `Stages in scope: ${executeStages.length}\n` + `Completed: ${completedCount}/${executeStages.length}\n`, ); }, undefined, undefined, WORKSPACE_MUTATION_LOCK_RETRIES); } // --------------------------------------------------------------------------- // config get/list/set - read or update active workflow config // --------------------------------------------------------------------------- function configFieldForKey(key: string): "Depth" | "Test Strategy" | "Review Override" | null { if (key === "depth") return "Depth"; if (key === "test-strategy") return "Test Strategy"; if (key === "review") return "Review Override"; return null; } function readConfigField(projectDir: string, flags: Record, field: "Depth" | "Test Strategy" | "Review Override"): string { const sp = stateFilePath(projectDir, flags.intent, flags.space); if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE); const content = readStateFile(projectDir, flags.intent, flags.space); return getField(content, field) || ""; } function handleConfigGet(projectDir: string, positional: string[], flags: Record): void { const key = positional[1] ?? ""; const field = configFieldForKey(key); if (!field) die(`Unknown config key: "${key}". Valid keys: ${CONFIG_KEYS.join(", ")}.`); process.stdout.write(`${readConfigField(projectDir, flags, field)}\n`); } function handleConfigList(projectDir: string, flags: Record): void { const depth = readConfigField(projectDir, flags, "Depth"); const testStrategy = readConfigField(projectDir, flags, "Test Strategy"); const review = readConfigField(projectDir, flags, "Review Override"); if (flags.json === "true") { process.stdout.write(`${JSON.stringify({ depth, "test-strategy": testStrategy, review })}\n`); return; } process.stdout.write(`depth: ${depth}\ntest-strategy: ${testStrategy}\nreview: ${review}\n`); } function handleConfigChange(projectDir: string, flags: Record): void { const rawDepth = flags.depth; const rawStrategy = flags["test-strategy"]; const rawReview = flags.review; if (!rawDepth && !rawStrategy && !rawReview) { die("config-change requires --depth, --test-strategy, and/or --review"); } let newDepth: string | undefined; if (rawDepth) { newDepth = VALID_DEPTHS[rawDepth.toLowerCase()]; if (!newDepth) die(`Unknown depth: "${rawDepth}". Valid depths: minimal, standard, comprehensive.`); } let newStrategy: string | undefined; if (rawStrategy) { newStrategy = VALID_TEST_STRATEGIES[rawStrategy.toLowerCase()]; if (!newStrategy) die(`Unknown test strategy: "${rawStrategy}". Valid: minimal, standard, comprehensive.`); } // --review sets the per-run Review Override (a CEILING on the effective // review class, low-wins against stage declaration and scope review_cap). const newReview = parseReviewOverride(rawReview); const sp = stateFilePath(projectDir, flags.intent, flags.space); if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE); let content = readStateFile(projectDir, flags.intent, flags.space); const oldDepth = getField(content, "Depth"); const oldStrategy = getField(content, "Test Strategy"); // Inline existence checks (instead of caching to a boolean) so TS narrows // newDepth / newStrategy at each use site — avoids non-null assertions. if (newDepth !== undefined && newDepth !== oldDepth) { content = setField(content, "Depth", newDepth); } if (newStrategy !== undefined && newStrategy !== oldStrategy) { content = setField(content, "Test Strategy", newStrategy); } const reviewUpdate = applyReviewOverride(content, newReview); content = reviewUpdate.content; const { oldReview, storedReview } = reviewUpdate; const depthChanging = newDepth !== undefined && newDepth !== oldDepth; const strategyChanging = newStrategy !== undefined && newStrategy !== oldStrategy; const reviewChanging = reviewUpdate.changed; if (depthChanging || strategyChanging || reviewChanging) { content = setField(content, "Last Updated", isoTimestamp()); writeStateFile(projectDir, content, flags.intent, flags.space); } if (newDepth !== undefined && newDepth !== oldDepth) { appendAuditEvent(projectDir, "DEPTH_CHANGED", { "Old Depth": oldDepth || "unknown", "New Depth": newDepth, }); } if (newStrategy !== undefined && newStrategy !== oldStrategy) { appendAuditEvent(projectDir, "TEST_STRATEGY_CHANGED", { "Old Strategy": oldStrategy || "unknown", "New Strategy": newStrategy, }); } if (reviewChanging) { appendAuditEvent(projectDir, "REVIEW_CLASS_CHANGED", { "Old Override": oldReview || "none set", "New Override": storedReview || "cleared (stage defaults apply)", }); } if (newDepth !== undefined) { process.stdout.write( depthChanging ? `Depth changed: ${oldDepth} -> ${newDepth}\n` : `Depth is already ${newDepth}\n` ); } if (newStrategy !== undefined) { process.stdout.write( strategyChanging ? `Test strategy changed: ${oldStrategy} -> ${newStrategy}\n` : `Test strategy is already ${newStrategy}\n` ); } if (newReview !== undefined) { const display = storedReview === "" ? "adversarial (stage defaults)" : storedReview; process.stdout.write( reviewChanging ? `Review override changed: ${oldReview || "none"} -> ${display}\n` : `Review override is already ${display}\n` ); } } // --------------------------------------------------------------------------- // change-control - rewrite the intent's Change Control line // --------------------------------------------------------------------------- // // The one write path for the per-intent value: the `/aidlc --change-control` // flag and the plain-chat request both land here. A memory layer that declares // strict refuses the flip and names its file; the value is then not the // human's to change from chat. The rewritten line carries `(set by you)` so // `--status` can say where the value came from; the ledger gets one // CHANGE_CONTROL_SET row per real change. function handleChangeControl( projectDir: string, positional: string[], flags: Record, ): void { const raw = positional[1]; const requested = parseChangeControl(raw); if (raw === undefined || requested === null) { die( `change-control requires exactly one of: ${CHANGE_CONTROL_VALUES.join(", ")}` + (raw === undefined ? "." : ` (received "${raw}").`), ); } const selection = resolveWorkflowSelection(projectDir, { intent: flags.intent, space: flags.space, }); const intent = selection.intent ?? undefined; const space = selection.space; const sp = stateFilePath(projectDir, intent, space); if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE); let content = readStateFile(projectDir, intent, space); const resolution = resolveChangeControl(projectDir, content, { tolerateInvalidState: true, selection: { intent, space }, }); if (resolution.memoryStrict !== null && requested !== "strict") { die(changeControlMemoryStrictRefusal(resolution.memoryStrict)); } const previous = getField(content, CHANGE_CONTROL_FIELD); const line = formatChangeControl(requested, "you"); if (previous === line) { process.stdout.write(`Change Control is already ${line}\n`); return; } if (previous === null) { const beforeInsert = content; for (const anchor of ["Review Override", "Test Strategy", "Scope"]) { content = content.replace( new RegExp(`^(- \\*\\*${anchor}\\*\\*:[^\\n]*)$`, "m"), `$1\n- **${CHANGE_CONTROL_FIELD}**:`, ); if (content !== beforeInsert) break; } if (content === beforeInsert) { content = `${content.trimEnd()}\n- **${CHANGE_CONTROL_FIELD}**:\n`; } } content = setField(content, CHANGE_CONTROL_FIELD, line); content = setField(content, "Last Updated", isoTimestamp()); // Audit first, then the state write, like every other state-mutating verb: // a ledger that cannot take the row leaves the line untouched. const oldAuditValue = resolution.intent === null && resolution.rawStateValue !== null ? resolution.rawStateValue : resolution.value; recordChangeControlSet(projectDir, oldAuditValue, requested, "you", { intent, space }); writeStateFile(projectDir, content, intent, space); const oldDisplay = resolution.intent === null && resolution.rawStateValue !== null ? resolution.rawStateValue : formatChangeControl(resolution.value, resolution.source); process.stdout.write(`Change Control changed: ${oldDisplay} to ${line}\n`); } // --------------------------------------------------------------------------- // set-status — atomically update statusline fields at stage start // --------------------------------------------------------------------------- export function setStatus( projectDir: string, flags: Record, ): { phase: string; stage: string; agent: string } { const sp = stateFilePath(projectDir, flags.intent, flags.space); if (!existsSync(sp)) throw new Error(NO_STATE_FILE_MESSAGE); const stage = flags.stage; if (!stage) throw new Error("--stage is required for set-status"); const entry = findStageBySlug(stage); if (!entry) throw new Error(`Unknown stage: ${stage}`); const phase = (flags.phase || entry.phase).toUpperCase(); const agent = flags.agent || entry.lead_agent; const previousContent = readStateFile(projectDir, flags.intent, flags.space); const currentStage = (getField(previousContent, "Current Stage") ?? "").trim(); const activeDirective = readActiveDirectiveMarker(projectDir, previousContent); const preserveUnitMajorCursor = getField(previousContent, "Construction Iteration")?.trim() === "unit-major" && phase === "CONSTRUCTION" && activeDirective?.stage === stage && activeDirective.unit !== undefined && currentStage.length > 0 && currentStage !== stage; let content = previousContent; content = setField(content, "Lifecycle Phase", phase); content = setField(content, "Active Agent", agent); content = setField(content, "Status", "Running"); content = setField(content, "Last Updated", isoTimestamp()); if (!preserveUnitMajorCursor) { content = setField(content, "Current Stage", stage); content = setField(content, "In Progress", stage); content = setCheckbox(content, stage, "in-progress"); } writeStateFile(projectDir, content, flags.intent, flags.space); try { refreshActiveDirectiveMarker(projectDir, stage, previousContent, content); } catch (e) { recordHookDrop(projectDir, "active-directive", errorMessage(e)); } return { phase, stage, agent }; } function handleSetStatus(projectDir: string, flags: Record): void { if ( process.env.AIDLC_STATUSLINE_OWNER !== `statusline:${process.ppid}` ) { die( "Direct aidlc-utility set-status is blocked: status synchronization is owned by the sync-workflow-state hook.", ); } try { const result = setStatus(projectDir, flags); process.stdout.write(`${JSON.stringify({ updated: true, ...result })}\n`); } catch (error) { die(errorMessage(error)); } } // --------------------------------------------------------------------------- // Scope inference from freeform text // // The keyword sets live in each scope's `.claude/scopes/aidlc-.md` // frontmatter `keywords` field; this // helper resolves the scope using word-boundary matching (so "debug" // does not match "bug"), // alphabetical iteration over scopes (so first-match-wins is // deterministic), and a ">5 word" heuristic that requires an affirmative // high-specificity keyword. Generic or negated mentions in long descriptions // fall back to the selection-aware default scope. // // Exported for t67 unit tests; not a stable public API. export interface InferResult { scope: string; source: "keyword" | "freeform"; matches: Array<{ scope: string; keyword: string }>; } // These core-owned keywords can identify a scope in a long description // (issue #1072). Generic words still defer to the word-count heuristic. // Plugin vocabularies remain owned by their plugins; declaring specificity // in scope frontmatter is a separate follow-up. const HIGH_SPECIFICITY_KEYWORDS = new Set([ "refactor", "mvp", "minimum viable", "poc", "proof of concept", "cve", ]); function isNegatedScopeKeyword(text: string, index: number): boolean { // Keep this local to the occurrence: "refactor without changing behavior" // is affirmative, and a new clause can request a different scope. This is // a conservative lexical guard, not a general natural-language parser. const prefix = text .slice(0, index) .split(/[.!?;:\n]|\b(?:but|however|instead)\b/) .pop() ?? ""; const normalized = prefix.replace(/\bnot\s+(?:only|just|merely)\b/g, ""); return /\b(?:no|not|never|without|avoid(?:ing)?|skip(?:ping)?|exclud(?:e|ing)|[a-z]+n['’]t)\b(?:[\s"'“”‘’()-]+\w+){0,4}[\s"'“”‘’()-]*$/.test(normalized); } export function inferScopeFromText(input: string): InferResult { const text = input.toLowerCase(); const trimmed = input.trim(); const wordCount = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length; const mapping = loadScopeMapping(); const allMatches: Array<{ scope: string; keyword: string }> = []; let specificMatch: { scope: string; keyword: string } | undefined; // Iterate in alphabetical order for determinism (not JSON insertion // order). validScopes() already returns a sorted set. Multi-word // keywords like "proof of concept" allow any whitespace run between // tokens, so "proof of concept" (double-spaced) still matches. for (const scope of [...validScopes()]) { const keywords = mapping[scope]?.keywords ?? []; let firstMatch: { scope: string; keyword: string } | undefined; for (const kw of keywords) { const normalized = kw.toLowerCase().trim().replace(/\s+/g, " "); const tokens = normalized.split(" ").map(escapeRegex); const re = new RegExp(`\\b${tokens.join("\\s+")}\\b`, "gi"); for (const match of text.matchAll(re)) { firstMatch ??= { scope, keyword: kw }; if ( wordCount > 5 && specificMatch === undefined && HIGH_SPECIFICITY_KEYWORDS.has(normalized) && !isNegatedScopeKeyword(text, match.index) ) { specificMatch = { scope, keyword: kw }; } } } // Preserve one diagnostic match per scope and short-input precedence, // while checking every keyword for the long-input exemption. if (firstMatch) allMatches.push(firstMatch); } // No matches at all → default (freeform). if (allMatches.length === 0) { return { scope: selectionAwareDefaultScope().scope, source: "freeform", matches: allMatches, }; } // Long descriptions need an affirmative high-specificity match. if (wordCount > 5 && specificMatch === undefined) { return { scope: selectionAwareDefaultScope().scope, source: "freeform", matches: allMatches, }; } // First alphabetical match wins (deterministic across calls). In long // prose a high-specificity match takes precedence over an alphabetically // earlier incidental low-specificity one. const winner = wordCount > 5 && specificMatch !== undefined ? specificMatch : allMatches[0]; return { scope: winner.scope, source: "keyword", matches: allMatches, }; } /** Doctor uses this for keyword-overlap detection. */ export function findScopeByKeyword(kw: string): string[] { const mapping = loadScopeMapping(); const hits: string[] = []; for (const scope of [...validScopes()]) { if ( (mapping[scope]?.keywords ?? []).some( (k) => k.toLowerCase() === kw.toLowerCase() ) ) { hits.push(scope); } } return hits; } // --------------------------------------------------------------------------- // scope-table - compiled summary of the scope grid for SKILL.md // // Emits a Markdown table delimited by BEGIN/END HTML comments. SKILL.md // has a matching region that is regenerated via this tool. --check mode // byte-compares the current SKILL.md region against the rendered output // and exits 1 on drift. Mirrors aidlc-graph.ts compile / compile --check. // // AIDLC_SKILL_MD_PATH env-seam lets t67 sandbox --check against a // fixture SKILL.md (so drift tests never mutate the real file). const SCOPE_TABLE_BEGIN = ``; const SCOPE_TABLE_END = ""; /** Exported for t67 unit tests. */ export function renderScopeTable(): string { const mapping = loadScopeMapping(); const scopes = [...validScopes()]; // alphabetical const lines = [ "| Scope | Depth | TestStrategy | EXECUTE / Total |", "|----------------|---------------|--------------|-----------------|", ]; for (const name of scopes) { const def = mapping[name]; const stages = def.stages; const total = Object.keys(stages).length; const execute = Object.values(stages).filter((v) => v === "EXECUTE").length; const depth = def.depth; const ts = def.testStrategy ?? "(default)"; lines.push( `| ${name.padEnd(14)} | ${depth.padEnd(13)} | ${ts.padEnd(12)} | ${`${execute} / ${total}`.padEnd(15)} |` ); } return lines.join("\n"); } /** Canonical byte-shape: BEGIN\n\n\n\nEND. */ export function canonicalScopeTableRegion(table: string): string { return `${SCOPE_TABLE_BEGIN}\n\n${table}\n\n${SCOPE_TABLE_END}`; } function skillMdPath(): string { if (process.env.AIDLC_SKILL_MD_PATH) return process.env.AIDLC_SKILL_MD_PATH; const harnessSkill = resolveSkillsPath(["aidlc", "SKILL.md"]); if (existsSync(harnessSkill)) return harnessSkill; const agentsSkill = join( dirname(resolveHarnessPath([])), ".agents", "skills", "aidlc", "SKILL.md", ); if (existsSync(agentsSkill)) return agentsSkill; return harnessSkill; } function checkGeneratedTableRegion( verb: string, beginMarker: string, endMarker: string, renderRegion: () => string, ): void { const skillPath = skillMdPath(); let skillRaw: string; try { skillRaw = readFileSync(skillPath, "utf-8"); } catch (err) { console.error( `SKILL.md not readable at ${skillPath}: ${errorMessage(err)}` ); process.exit(1); } // Normalize line endings before comparison so Windows CRLF files // (core.autocrlf=true) don't false-positive as drifted. skillRaw = skillRaw.replace(/\r\n/g, "\n"); let located: GeneratedRegionLocation; try { located = findGeneratedRegion(skillRaw, beginMarker, endMarker, verb, skillPath); } catch (err) { console.error(errorMessage(err)); process.exit(1); } const currentRegion = skillRaw.substring(located.beginIdx, located.regionEndIdx); const expectedRegion = renderRegion(); if (currentRegion === expectedRegion) { return; // exit 0 silent } console.error( `SKILL.md ${verb} region is out of date. Refresh it from \`${aidlcDispatcherInvocation(`gen ${verb}`)}\`.` ); process.exit(1); } function handleScopeTable( _projectDir: string, _flags: Record, rawArgs: string[] ): void { const check = rawArgs.includes("--check"); const expectedRegion = canonicalScopeTableRegion(renderScopeTable()); if (!check) { process.stdout.write(`${expectedRegion}\n`); return; } checkGeneratedTableRegion( "scope-table", SCOPE_TABLE_BEGIN, SCOPE_TABLE_END, () => expectedRegion, ); } // --------------------------------------------------------------------------- // stage-table — compiled summary of the stage graph for SKILL.md // // Emits a Markdown table delimited by BEGIN/END HTML comments. SKILL.md // has a matching region that is regenerated via this tool. --check mode // byte-compares the current SKILL.md region against the rendered output // and exits 1 on drift. Mirrors scope-table above. // // AIDLC_SKILL_MD_PATH env-seam lets tests sandbox --check against a // fixture SKILL.md (so drift tests never mutate the real file). const STAGE_TABLE_BEGIN = ``; const STAGE_TABLE_END = ""; function displayPhase(phase: string): string { return phase.charAt(0).toUpperCase() + phase.slice(1); } function displayLeadAgent(agent: string): string { return agent === "orchestrator" ? "(orchestrator)" : agent; } function displaySupportAgents(agents: string[] | undefined): string { return Array.isArray(agents) && agents.length > 0 ? agents.join(", ") : "—"; } /** Exported for t32 integration tests. */ export function renderStageTable(): string { const lines = [ "| Slug | # | Stage | Phase | Execution | Lead Agent | Support Agents | Mode |", "|------|---|-------|-------|-----------|------------|----------------|------|", ]; for (const stage of loadStageGraph()) { lines.push( `| ${stage.slug} | ${stage.number} | ${stage.name} | ${displayPhase(stage.phase)} | ${stage.execution} | ${displayLeadAgent(stage.lead_agent)} | ${displaySupportAgents(stage.support_agents)} | ${stage.mode} |` ); } return lines.join("\n"); } /** Canonical byte-shape: BEGIN\n\n
\n\nEND. */ export function canonicalStageTableRegion(table: string): string { return `${STAGE_TABLE_BEGIN}\n\n${table}\n\n${STAGE_TABLE_END}`; } function handleStageTable( _projectDir: string, _flags: Record, rawArgs: string[] ): void { const check = rawArgs.includes("--check"); const expectedRegion = canonicalStageTableRegion(renderStageTable()); if (!check) { process.stdout.write(`${expectedRegion}\n`); return; } checkGeneratedTableRegion( "stage-table", STAGE_TABLE_BEGIN, STAGE_TABLE_END, () => expectedRegion, ); } // --------------------------------------------------------------------------- // detect-scope — record a scope-detection event // // Two modes: // 1. Explicit: `--scope --input [--source ...]`. // Recorded unchanged. // 2. Inference: `--from-text --input `. // Resolves the scope via inferScopeFromText and emits SCOPE_DETECTED // with Source=keyword (match) or Source=freeform (default fallback). // // Passing both `--scope` and `--from-text` is an error — they are // mutually exclusive modes. Missing both is also an error. const VALID_SCOPE_SOURCES: ReadonlySet = new Set([ "freeform", "keyword", "env", "cli", ]); function handleDetectScope( projectDir: string, flags: Record ): void { const fromText = flags["from-text"] !== undefined; const explicitScope = flags.scope; if (fromText && explicitScope) { die( "Cannot combine --from-text and --scope. Use one or the other." ); } if (!fromText && !explicitScope) { die( "Missing --scope (or pass --from-text to infer from --input)." ); } // --input requirement differs by mode: // --scope mode: --input is required (audit event needs original text). // --from-text mode: --input may be empty string — inferScopeFromText // returns `feature` as the documented default. Missing --input // entirely is still an error; an empty string is fine. const input = flags.input; if (input === undefined) { die("Missing --input "); } if (!fromText && input === "") { die("--input cannot be empty under --scope mode."); } let scope: string; let source: string; let matchedKeywords: string[] = []; if (fromText) { const result = inferScopeFromText(input); scope = result.scope; source = result.source; matchedKeywords = result.matches.map((m) => m.keyword); } else { scope = explicitScope; source = flags.source || "freeform"; if (!VALID_SCOPE_SOURCES.has(source)) { die( `Unknown source: "${source}". Valid: ${[...VALID_SCOPE_SOURCES].join(", ")}.` ); } } if (!validScopes().has(scope)) { die( `Unknown scope: "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.` ); } const auditFields: Record = { "Detected scope": scope, "Input text": input, Source: source, }; if (matchedKeywords.length > 0) { auditFields["Matched keywords"] = matchedKeywords.join(", "); } appendAuditEvent(projectDir, "SCOPE_DETECTED", auditFields); process.stdout.write( `${JSON.stringify({ emitted: "SCOPE_DETECTED", scope, source, matches: matchedKeywords, })}\n` ); } // --------------------------------------------------------------------------- // resolve-env-scope — validate AWS_AIDLC_DEFAULT_SCOPE and emit its value // // The orchestrator's step 0 in SKILL.md calls this to resolve the env default // deterministically. Behavior: // - Env unset or empty: exit 0, no output. The orchestrator takes the // non-env path (CLI flag, keyword detection, or hard-coded fallback). // - Env set to a valid scope: exit 0, print `scope=` to stdout. // The orchestrator synthesizes `--scope ` into $ARGUMENTS. // - Env names an installed but disabled scope: resolve the selection-aware // default. This preserves plugin-only installs whose existing config names // a deselected core scope such as `feature`. // - Env names an unknown scope: exit 1 with the canonical error. Explicit // typos never enter the internal default-fallback path. // // Centralising validation here (instead of leaving it to LLM prose) guarantees // the error message shape and guarantees invalid env never reaches scope-change // / state-init. // --------------------------------------------------------------------------- function handleResolveEnvScope(): void { const envScope = (resolveProjectFlag("AWS_AIDLC_DEFAULT_SCOPE") || "").trim(); if (envScope === "") { return; // unset — no output, exit 0 } if (!validScopes().has(envScope)) { if (loadScopeMetadataAll()[envScope] === undefined) { die( `Invalid AWS_AIDLC_DEFAULT_SCOPE "${envScope}". Valid scopes: ${[...validScopes()].join(", ")}.` ); } const fallback = selectionAwareDefaultScope(envScope); if (!fallback.error && validScopes().has(fallback.scope)) { if (fallback.note) { process.stderr.write( `AWS_AIDLC_DEFAULT_SCOPE="${envScope}" is not an enabled scope; using ${fallback.scope} (sole enabled plugin's first scope)\n`, ); } process.stdout.write(`scope=${fallback.scope}\n`); return; } die( `Invalid AWS_AIDLC_DEFAULT_SCOPE "${envScope}". Valid scopes: ${[...validScopes()].join(", ")}.` ); } process.stdout.write(`scope=${envScope}\n`); } // --------------------------------------------------------------------------- // CLI entry point // --------------------------------------------------------------------------- export async function main(argv: string[]): Promise { const rawArgs = argv; errorArgs = [...rawArgs]; const { positional, flags, bareFlags, blankFlags } = parseArgs(rawArgs); const subcommand = positional[0]; if ( (subcommand === "intent-create" || subcommand === "init") && (flags.help === "true" || rawArgs.includes("-h")) ) { process.stdout.write( "Usage: aidlc-utility intent-create --scope " + '[--arguments ""] [--label ""] ' + "[--depth ] [--test-strategy ] [--review ] [--change-control ] [--repos ] " + "[--space ] [--project-dir ]\n", ); return; } const isIntentCreate = subcommand === "intent-create" || subcommand === "init" || (subcommand === "intent" && positional[1] === "create"); const missingValueFlags = new Set([...bareFlags, ...blankFlags]); errorProjectDirArg = missingValueFlags.has("project-dir") ? undefined : flags["project-dir"]; errorSelection = { intent: missingValueFlags.has("intent") ? undefined : flags.intent, space: missingValueFlags.has("space") ? undefined : flags.space, }; if (isIntentCreate) { validateIntentCreateFlagValues(flags, missingValueFlags); } const projectDir = resolveProjectDir(flags["project-dir"]); switch (subcommand) { case "help": handleHelp(); break; case "version": handleVersion(); break; case "status": handleStatus(projectDir, flags); break; case "claim": unitMain([ "claim", ...rawArgs.slice(1), "--project-dir", projectDir, ]); break; case "release": unitMain([ "release", ...rawArgs.slice(1), "--project-dir", projectDir, ]); break; case "participate": unitMain(["participate", "--project-dir", projectDir]); break; case "doctor": await (await import("./aidlc-doctor.ts")).main(rawArgs); break; case "intent-create": handleIntentCreate(projectDir, flags); break; case "intent": handleIntent(projectDir, positional, flags); break; case "space": handleSpace(projectDir, positional, flags); break; case "space-create": handleSpaceCreate(projectDir, positional, flags); break; // codekb-path — read-only query verb. Prints the deterministic // space-level per-repo codekb dir the RE stage writes into. Mirrors the // read-only intent/space query arms: no mutation, no audit, no mkdir. case "codekb-path": handleCodekbPath(projectDir, flags); break; // project-description - read-only exact-description authority boundary. // Marked records must load the JSON sidecar; only unmarked legacy records // fall back to the state preview. case "project-description": handleProjectDescription(projectDir); break; // document-input - read-only direct-document boundary used by Intent Capture // and Requirements Analysis. One exact path in, one trust-marked JSON object // out; no search, mutation, or audit. case "document-input": await handleDocumentInput(projectDir); break; case "codekb-snapshot": handleCodekbSnapshot(projectDir, flags); break; case "codekb-publish": handleCodekbPublish(projectDir, flags); break; // codekb-scope-diff - read-only query verb. Compares the codekb store's // recorded scope of analysis against the live tree (status) or an // incoming run's timestamp (--compare). The RE stage's rerun guard. case "codekb-scope-diff": handleCodekbScopeDiff(projectDir, flags); break; // detect - read-only query verb. Prints the workspace scan // (greenfield/brownfield, languages) + the resolved scope-registry paths so // the composer agent is told where scope data lives. No mutation, no audit. case "detect": handleDetect(projectDir, flags); break; case "select-plugins": handleSelectPlugins(projectDir, positional); break; case "plugin-list": handlePluginList(flags); break; case "plugin-sync": await handlePluginSync(projectDir); break; case "plugin-validate": handlePluginValidate(positional, flags); break; case "plugin-build": handlePluginBuild(positional, flags, missingValueFlags); break; // init / state-init are transition-only and intentionally absent from help. // Stale init callers get a loud error for this release; workflow start is // still intent-create through the orchestrator. case "init": handleInitTransition(); break; case "state-init": handleStateInit(projectDir, flags); break; case "upgrade": handleUpgrade(); break; case "scope-change": handleScopeChange(projectDir, flags); break; // recompose - the adaptive composer's in-flight write: flip PENDING // stages' plan suffixes (--skip/--add) under the audit lock, strict- // validated, derived fields rebuilt, RECOMPOSED audited. case "recompose": handleRecompose(projectDir, flags); break; case "config-change": handleConfigChange(projectDir, flags); break; case "config-get": handleConfigGet(projectDir, positional, flags); break; case "config-list": handleConfigList(projectDir, flags); break; case "set-status": handleSetStatus(projectDir, flags); break; case "detect-scope": handleDetectScope(projectDir, flags); break; case "change-control": handleChangeControl(projectDir, positional, flags); break; case "resolve-env-scope": handleResolveEnvScope(); break; case "scope-table": handleScopeTable(projectDir, flags, rawArgs); break; case "stage-table": handleStageTable(projectDir, flags, rawArgs); break; default: // `intent-birth` was renamed to `intent-create`; point the old name at // the new one rather than burying it in the verb list. if (subcommand === "intent-birth") { die( "`intent-birth` was renamed to `intent-create`. Run the same command with " + "`intent-create` instead (flags are unchanged)." ); } die( `Unknown command "${subcommand}". Run \`aidlc-utility help\` for what this tool can do.\n\n` + "Available commands: help, version, status, doctor, intent-create, intent, space, " + "space-create, codekb-path, codekb-snapshot, codekb-publish, project-description, document-input, codekb-scope-diff, detect, select-plugins, plugin-list, plugin-sync, plugin-validate, plugin-build, " + "recompose, scope-change, config-change, config-get, config-list, set-status, " + "detect-scope, resolve-env-scope, scope-table, stage-table, upgrade\n" + "Common options: [--project-dir ] [--scope ] [--json]" ); } } if (import.meta.main) { void main(process.argv.slice(2)).catch((error) => { die(errorMessage(error)); }); }