import { spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, cpSync, type Dirent, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, opendirSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { hostname, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { TextDecoder } from "node:util"; import { inflateSync } from "node:zlib"; import { dlopen, FFIType, type Pointer } from "bun:ffi"; import { aidlcInvocation, resolveHarnessPath, } from "./aidlc-runtime-paths.ts"; import { artifactFilename, KNOWN_CODEKB_STAGES, } from "./aidlc-artifact-vocabulary.ts"; export { artifactFilename, KNOWN_CODEKB_STAGES, } from "./aidlc-artifact-vocabulary.ts"; import { _resetSettingsCacheForTests, RECORDABLE_PROJECT_BYPASSES, resolveAidlcSettings, type ProjectFlagsRecord, type RecordableProjectBypass, } from "./aidlc-settings.ts"; export { normalizeProjectFlagsRecord, RECORDABLE_PROJECT_BYPASSES, type ProjectFlagsRecord, type RecordableProjectBypass, } from "./aidlc-settings.ts"; // Type-only import for the lazy-loaded aidlc-graph.ts dependency. The // runtime require() below avoids the circular import (aidlc-graph.ts // imports loadScopeMapping/loadStageGraph from this file). Type-only // imports are erased at runtime so they don't create the cycle. import type { subgraphForScope as SubgraphForScope } from "./aidlc-graph.ts"; // --- Types --- export interface StageEntry { slug: string; number: string; name: string; phase: string; // Present only when a plugin selection has disabled this node. Enabled nodes // omit the key so an install with no selection keeps byte-identical compiled // data. enabled?: false; execution: "ALWAYS" | "CONDITIONAL"; lead_agent: string; support_agents: string[]; mode: string; // Optional fields populated by aidlc-graph compile from YAML sources. // Existing callers read only the 8 required fields above; optional // additions are source-compatible. Library code that needs these // fields uses the GraphStage type in aidlc-graph.ts (required there). plugin?: string; condition?: string; reviewer?: string; review_artifact?: string; reviewer_max_iterations?: number; review_class?: "adversarial" | "advisory"; // Summary-confirmation policy for stages using the unified question flow. // `required` means every execution owes a questions file and receipt; // `if-present` enforces a receipt only when the conditional flow created one. summary_confirmation?: "required" | "if-present"; produces?: string[]; // Artifacts the stage MAY write per unit; exempt from the per-unit // coverage check in aidlc-orchestrate.ts unitCovered. See GraphStage in // aidlc-graph.ts. optional_produces?: string[]; // Per-kind applicability map: artifact name to the unit kinds it applies to. // An unlisted artifact applies to all kinds; a listed one is pruned out of a // unit whose kind is not in its list (both directive paths and coverage). // Absent map = full matrix (every produces entry applies to every unit). produces_kinds?: Record; consumes?: Array<{ artifact: string; required: boolean; conditional_on?: string }>; requires_stage?: string[]; scopes?: string[]; inputs?: string; outputs?: string; for_each?: string; // True for stages that must write source code to the workspace root (not just // planning docs under the per-intent record dir). The stage-completion artifact // guard (aidlc-state.ts) uses this to require a non-doc workspace file before // approve/advance: a code-generation stage that wrote only its markdown // produces[] docs but no actual code must not pass (issue #366). workspace_requires?: boolean; // Compile-resolved sensor bindings. Runtime dispatchers consume the detailed // graph shape; user-facing directives intentionally project only sensor ids. sensors_applicable?: Array<{ id: string; path: string; fire_on: "write" | "gate"; default_severity: "advisory" | "blocking"; category?: string; matches?: string; }>; } // The per-unit marker carried by the Construction stages that run once per // Unit of Work. It lives on the stage's `for_each` field (stage frontmatter, // compiled onto the GraphStage and into stage-graph.json). The canonical // 5-stage set (nfr-requirements, nfr-design, functional-design, // infrastructure-design, code-generation) is the defensive cross-check; the // node's own `for_each` is the source of truth so a future per-unit stage is // picked up without editing this file. Exported so both the runtime resolver // (isPerUnit in aidlc-orchestrate.ts) and the cost summary (gridCostSummary // below) resolve per-unit identically. export const PER_UNIT_FOR_EACH = "unit-of-work"; export const KNOWN_PER_UNIT_STAGES: ReadonlySet = new Set([ "nfr-requirements", "nfr-design", "functional-design", "infrastructure-design", "code-generation", ]); // True when a stage runs once per Unit of Work. Reads the node's own // `for_each` marker (source of truth); the known-set membership is a defensive // cross-check so a typo'd marker on one of the five canonical stages still // resolves per-unit. Structural param so both a GraphStage and a bare // {slug, for_each} record satisfy it. export function isPerUnitStage(e: { slug: string; for_each?: string }): boolean { return e.for_each === PER_UNIT_FOR_EACH || KNOWN_PER_UNIT_STAGES.has(e.slug); } export interface ScopeDefinition { depth: string; stages: Record; // Optional fields from scope-mapping.json. `testStrategy` can override // the depth-derived default; `keywords` drives NL scope inference (see // aidlc-utility.ts inferScopeFromText); `description` is a one-line // scope summary rendered into HELP_TEXT. testStrategy?: string; keywords?: string[]; description?: string; plugin?: string; runner?: boolean; skeleton?: boolean; /** The scope's Change Control default (`change_control:` frontmatter); * absent means strict. Resolution lives in resolveChangeControl. */ changeControl?: ChangeControl; } export type CheckboxState = "pending" | "in-progress" | "awaiting-approval" | "revising" | "completed" | "skipped"; export const CHECKBOX_MAP: Record = { pending: "[ ]", "in-progress": "[-]", "awaiting-approval": "[?]", revising: "[R]", completed: "[x]", skipped: "[S]", }; export const CHECKBOX_REVERSE: Record = { "[ ]": "pending", "[-]": "in-progress", "[?]": "awaiting-approval", "[R]": "revising", "[x]": "completed", "[S]": "skipped", }; export const PHASES = [ "initialization", "ideation", "inception", "construction", "operation", ] as const; export type Phase = (typeof PHASES)[number]; export const PHASE_NUMBERS: Record = { "0": "initialization", "1": "ideation", "2": "inception", "3": "construction", "4": "operation", }; // --- Harness dir resolution (.claude vs .kiro vs .codex) --- // The deterministic core ships in multiple harness trees: Claude Code reads // it from /.claude/, Kiro CLI from /.kiro/, Codex CLI from // /.codex/, and ANY future harness from //. Every // runtime path that names the harness directory flows through harnessDir() so // the SAME tool sources work in every tree. Resolution order mirrors // resolveProjectDir: env seam (tests/fixtures) → script-path derivation (this // module ships at //tools/aidlc-lib.ts, so the harness dir is // simply the directory two levels up — derived OPEN-SET, not matched against a // fixed list, so harness #N needs no edit here) → CWD probe → ".claude" // fallback. // // KNOWN_HARNESS_DIRS is NOT the source of truth for which harnesses exist — the // script-path derivation handles any dir. It is only a probe-ORDER hint for the // dev-repo CWD rung, where more than one harness dir can coexist and the Claude // tree is canonical (".claude" must win). A real single-harness install never // reaches the probe; it resolves by script path. export const KNOWN_HARNESS_DIRS = [".claude", ".kiro", ".codex", ".aidlc", ".cursor"] as const; // True for a plausible harness dir name: a dot-prefixed segment, e.g. ".claude" // / ".kiro" / ".gemini". Guards the script-path derivation so an unexpected // layout (lib copied loose in a test, a non-dotted parent) falls through to the // CWD probe instead of returning a bogus harness dir. function isHarnessDirName(name: string): boolean { return /^\.[a-z0-9][a-z0-9._-]*$/i.test(name); } function deriveHarnessDir(): string { // Script-path derivation (open-set): the module ships at // //tools/aidlc-lib.ts, so the harness dir is the basename // of the grandparent of this file — whatever it is named. const scriptDir = dirname(fileURLToPath(import.meta.url)); if (basename(scriptDir) === "tools") { const candidate = basename(dirname(scriptDir)); if (isHarnessDirName(candidate)) return candidate; } // CWD probe (dev repo, multiple trees coexist): known dirs in canonical order. const cwd = process.cwd(); for (const h of KNOWN_HARNESS_DIRS) { if (existsSync(join(cwd, h))) return h; } return ".claude"; } let _harnessDir: string | null = null; export function harnessDir(): string { // Env read at call time (not cached) so tests can flip it between bun // invocations — same pattern as stageGraphPath() below. if (process.env.AIDLC_HARNESS_DIR) return process.env.AIDLC_HARNESS_DIR; if (_harnessDir === null) _harnessDir = deriveHarnessDir(); return _harnessDir; } // The AIDLC markdown rule layers (aidlc-org/team/project/phase .md) live under // a per-harness subdirectory of the harness dir: `.claude/rules/`, // `.kiro/steering/` (Kiro reads steering files as its native rule surface), // `.codex/aidlc-rules/` (Codex's native `.codex/rules/` is Starlark permission // rules — D-10). The packager renames the SHIPPED directory and the prose/JSON // that names it (transform()/applyRulesRename + renameRulesInCompiledData), but // the .ts tools are byte-copied across all trees, so any runtime path a tool // builds to a rule file MUST go through rulesSubdir() — a hardcoded "rules" // segment targets a directory that does not exist on a rename-rules harness. // // The rename is a fact only the harness MANIFEST knows, so the packager emits // it per-tree into tools/data/harness.json (alongside the manifest name used // by runtime path resolution) — the open-set source of truth: a new harness // ships its own harness.json and needs no edit here. Resolution: // AIDLC_RULES_SUBDIR env seam (fixtures) → // AIDLC_HARNESS_DIR test-seam map (so "pretend to be .kiro" yields "steering" // without a .kiro tree on disk) → the shipped harness.json (the real-install // rung) → KNOWN_RULES_SUBDIR dev-fallback map → "rules". Returns the LAST path // segment only (e.g. "steering"); callers join it under harnessDir(). const KNOWN_RULES_SUBDIR: Record = { ".claude": "rules", ".kiro": "steering", ".codex": "aidlc-rules", // opencode: the ENGINE dir is .aidlc (opencode auto-imports .opencode/tools/ // *.ts as custom tools, so the engine cannot live there); no rename needed. ".aidlc": "rules", ".cursor": "rules", }; /** One MIME type's text extractor, as configured in harness.json. */ export interface DocumentExtractorSpec { argv: readonly string[]; timeoutMs?: number; } interface ShippedHarnessData { rulesSubdir: string | null; plugins: ReadonlySet | null; documentExtractors: ReadonlyMap | null; runnerFrontmatterAdditions: readonly string[]; } let _shippedHarnessData: ShippedHarnessData | null = null; export function harnessDataPath(): string { return join(resolveDataDir(), "harness.json"); } function readShippedHarnessData(): ShippedHarnessData { if (_shippedHarnessData !== null) return _shippedHarnessData; // tools/data/harness.json sits beside the compiled stage-graph.json in the // shipped tree (DATA_DIR). Absent in a dev checkout's core/ (authored source // carries no compiled data) → defaults, and the caller falls through. const p = harnessDataPath(); try { const raw = readFileSync(p, "utf-8"); const parsed = JSON.parse(raw) as { rulesSubdir?: unknown; plugins?: unknown; runnerFrontmatterAdditions?: unknown; models?: unknown; flags?: unknown; }; const policyKeys = ["models", "flags"].filter((key) => Object.hasOwn(parsed, key) ); if (policyKeys.length > 0) { throw new Error( `${p}: harness.json contains legacy policy key(s) ${policyKeys.join(", ")}. ` + `Remove ${policyKeys.join(", ")} from ${p}, then run ` + `'${aidlcInvocation()} config' to record policy in aidlc.settings.json.`, ); } let plugins: ReadonlySet | null = null; if (Object.hasOwn(parsed, "plugins")) { if (!Array.isArray(parsed.plugins)) { throw new Error(`${p}: harness.json field "plugins" must be an array of non-empty strings.`); } const names: string[] = []; for (const [idx, value] of parsed.plugins.entries()) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${p}: harness.json field "plugins" entry ${idx} must be a non-empty string.`); } names.push(value.trim()); } plugins = new Set(names); } // documentExtractors: strict, and fail-closed. The value becomes a PROCESS // INVOCATION, so a half-understood block must never reach spawn: `argv` is an // array of non-empty strings, never a shell string that gets helpfully split. let documentExtractors: ReadonlyMap | null = null; if (Object.hasOwn(parsed, "documentExtractors")) { const raw = (parsed as { documentExtractors?: unknown }).documentExtractors; if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error( `${p}: harness.json field "documentExtractors" must be an object keyed by MIME type.`, ); } const map = new Map(); for (const [mime, spec] of Object.entries(raw as Record)) { if (typeof spec !== "object" || spec === null || Array.isArray(spec)) { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" must be an object.`, ); } const argv = (spec as { argv?: unknown }).argv; if (typeof argv === "string") { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" argv must be an ARRAY ` + `of strings, not a shell string — it is spawned without a shell, so a string ` + `cannot be split safely.`, ); } if (!Array.isArray(argv) || argv.length === 0) { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" argv must be a ` + `non-empty array of strings.`, ); } for (const [idx, part] of argv.entries()) { if (typeof part !== "string" || part.length === 0) { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" argv[${idx}] must ` + `be a non-empty string.`, ); } } // argv[0] is the EXECUTABLE, never substituted: `extractDocument`'s // spawn is `spawnSync(argv[0], argv.slice(1).map(sub))` -- index 0 // names the program, so a "$IN" placeholder there is NEVER replaced // and the tool literally tries to spawn a program called `$IN`. // Measured against the shipped tool: `argv: ["$IN"]` passed the OLD // validator (it counted `$IN` across the WHOLE array and accepted // exactly one, wherever it fell) and every document routed to it // reported `extractor_unavailable` with `extractor.name === "$IN"` -- // no extraction ever ran, silently, for a config an author might // reasonably believe was valid ("one $IN, as required"). if (argv[0] === "$IN") { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" argv[0] must be a ` + `real executable name, not the "$IN" placeholder -- argv[0] is never substituted ` + `(only argv[1..] receives the document's path), so "$IN" there spawns a program ` + `literally named "$IN".`, ); } // Exactly one `$IN`, and only among the ARGUMENTS (argv[1..], the // slice that is actually substituted). Zero means the spawned process // never receives the document path at all -- whatever it prints on // stdout would be recorded as the extraction of EVERY document routed // to this entry, silently. More than one is equally a config error the // author almost certainly did not intend (e.g. a copy-paste), and // there is no stdin-input mode today for a config that wants zero -- // so both directions fail closed rather than one being treated as // advisory. const inCount = argv.slice(1).filter((a) => a === "$IN").length; if (inCount !== 1) { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" argv must contain ` + `exactly one "$IN" placeholder among its arguments, argv[1..] (found ${inCount}) ` + `-- that is how the document's path reaches the spawned process; without it the ` + `process never receives the file.`, ); } const timeoutMs = (spec as { timeoutMs?: unknown }).timeoutMs; if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) { throw new Error( `${p}: harness.json field "documentExtractors" entry "${mime}" timeoutMs must be a ` + `positive number of milliseconds.`, ); } map.set(mime, { argv: argv as string[], ...(timeoutMs === undefined ? {} : { timeoutMs: timeoutMs as number }), }); } documentExtractors = map; } const rulesSubdir = typeof parsed.rulesSubdir === "string" && parsed.rulesSubdir.length > 0 ? parsed.rulesSubdir : null; let runnerFrontmatterAdditions: string[] = []; if (Object.hasOwn(parsed, "runnerFrontmatterAdditions")) { if ( !Array.isArray(parsed.runnerFrontmatterAdditions) || parsed.runnerFrontmatterAdditions.some( (line) => typeof line !== "string" || !/^[A-Za-z_][\w-]*\s*:/.test(line), ) ) { throw new Error( `${p}: harness.json field "runnerFrontmatterAdditions" must be an array of YAML key lines.`, ); } runnerFrontmatterAdditions = [...parsed.runnerFrontmatterAdditions]; } _shippedHarnessData = { rulesSubdir, plugins, documentExtractors, runnerFrontmatterAdditions, }; return _shippedHarnessData; } catch (err) { if (err instanceof Error && err.message.startsWith(`${p}:`)) throw err; // no harness.json (dev core/, or a tree built before this landed) → fall through } _shippedHarnessData = { rulesSubdir: null, plugins: null, documentExtractors: null, runnerFrontmatterAdditions: [], }; return _shippedHarnessData; } function shippedRulesSubdir(): string | null { try { return readShippedHarnessData().rulesSubdir; } catch (err) { // rulesSubdir() has historically tolerated malformed/missing harness data. // pluginsEnabled() and documentExtractors() are the strict readers for their // own fields. // // The blast radius matters here: this catch STRING-MATCHES, so any validation // error it does not recognise rethrows OUT of a function whose only job is to // name the rules dir. A malformed documentExtractors block would otherwise // break rules resolution -- an unrelated caller crashing on a field it never // reads. Extraction itself still fails closed; the strict accessor below is // where that throw belongs. if (err instanceof Error && (err.message.includes('field "plugins"') || err.message.includes('field "documentExtractors"'))) { return null; } throw err; } } /** * The configured DocumentKB extractors, or null when none are configured. * * STRICT: a malformed block throws here rather than being silently ignored, * because the value becomes a process invocation and a half-parsed extractor is * worse than none. Absent is the normal case — the tool then probes `pdftotext` * on PATH and degrades to `extractor_unavailable`. */ export function documentExtractors(): ReadonlyMap | null { return readShippedHarnessData().documentExtractors; } export function pluginsEnabled(): ReadonlySet | null { return readShippedHarnessData().plugins; } export function projectFlags(): ProjectFlagsRecord | null { return resolveAidlcSettings(resolveProjectDir()).flags; } const PROJECT_FLAG_FIELDS: Record = { AWS_AIDLC_DEFAULT_SCOPE: "defaultScope", AIDLC_USE_SWARM: "swarm", AIDLC_HOOK_DEBUG: "hookDebug", AIDLC_SENSOR_TIMEOUT_MS: "sensorTimeoutMs", }; export function resolveProjectFlag( envName: string, env: NodeJS.ProcessEnv = process.env, ): string | undefined { if (Object.hasOwn(env, envName)) return env[envName]; const flags = projectFlags(); if (!flags) return undefined; if ( (RECORDABLE_PROJECT_BYPASSES as readonly string[]).includes(envName) ) { return flags.bypasses?.includes(envName as RecordableProjectBypass) ? "1" : undefined; } const field = PROJECT_FLAG_FIELDS[envName]; const value = field ? flags[field] : undefined; if (typeof value === "boolean") return value ? "1" : ""; if (typeof value === "number") return String(value); return typeof value === "string" ? value : undefined; } export function runnerFrontmatterAdditions(): readonly string[] { return readShippedHarnessData().runnerFrontmatterAdditions; } export function isPluginEnabled(plugin: string): boolean { const selected = pluginsEnabled(); return selected === null || selected.has(plugin); } export function stageEnabledBySelection(stage: { plugin?: string; phase?: string }): boolean { if (stage.phase === "initialization") return true; return isPluginEnabled(stage.plugin ?? "aidlc"); } export function _resetHarnessDataForTests(): void { _shippedHarnessData = null; _resetSettingsCacheForTests(); } export function rulesSubdir(): string { if (process.env.AIDLC_RULES_SUBDIR) return process.env.AIDLC_RULES_SUBDIR; // Test seam: AIDLC_HARNESS_DIR pins the harness without a tree on disk, so it // must out-rank the physically-shipped harness.json (which reflects THIS lib // copy's tree). Real installs don't set it and fall to the shipped value. if (process.env.AIDLC_HARNESS_DIR) { return KNOWN_RULES_SUBDIR[process.env.AIDLC_HARNESS_DIR] ?? "rules"; } return shippedRulesSubdir() ?? KNOWN_RULES_SUBDIR[harnessDir()] ?? "rules"; } // --- Project dir resolution --- export function resolveProjectDir(explicitDir?: string): string { // 1. Explicit --project-dir argument if (explicitDir) { return isAbsolute(explicitDir) ? explicitDir : resolvePath(process.cwd(), explicitDir); } // 2. Dispatcher/plugin explicit project environment if (process.env.AIDLC_PROJECT_DIR) { return isAbsolute(process.env.AIDLC_PROJECT_DIR) ? process.env.AIDLC_PROJECT_DIR : resolvePath(process.cwd(), process.env.AIDLC_PROJECT_DIR); } // 3. CLAUDE_PROJECT_DIR env var if (process.env.CLAUDE_PROJECT_DIR) { return isAbsolute(process.env.CLAUDE_PROJECT_DIR) ? process.env.CLAUDE_PROJECT_DIR : resolvePath(process.cwd(), process.env.CLAUDE_PROJECT_DIR); } // 4. Script path derivation (open-set): this module ships at // //tools/, so strip "/tools" for ANY harness // dir name — the project root is the dir two levels up. const scriptDir = dirname(fileURLToPath(import.meta.url)); const fromScript = stripHarnessLeaf(scriptDir, "tools"); if (fromScript) return fromScript; // 5. CWD has a known harness directory (dev repo). const cwd = process.cwd(); for (const h of KNOWN_HARNESS_DIRS) { if (existsSync(join(cwd, h))) { return cwd; } } // Fallback to CWD return cwd; } // If `dir` is "//" with a harness-dir name and // the given segment (tools | hooks), return ; else null. Open-set: // the harness segment is validated by SHAPE (isHarnessDirName), not membership // in a fixed list, so a new harness needs no edit here. function stripHarnessLeaf(dir: string, leaf: string): string | null { if (basename(dir) !== leaf) return null; const harnessDirPath = dirname(dir); if (!isHarnessDirName(basename(harnessDirPath))) return null; return dirname(harnessDirPath); } // --- Hook project dir resolution --- export function resolveProjectDirFromHook(importMetaUrl: string): string { // 1. Dispatcher/plugin explicit project environment if (process.env.AIDLC_PROJECT_DIR) { return isAbsolute(process.env.AIDLC_PROJECT_DIR) ? process.env.AIDLC_PROJECT_DIR : resolvePath(process.cwd(), process.env.AIDLC_PROJECT_DIR); } // 2. CLAUDE_PROJECT_DIR env var if (process.env.CLAUDE_PROJECT_DIR) { return isAbsolute(process.env.CLAUDE_PROJECT_DIR) ? process.env.CLAUDE_PROJECT_DIR : resolvePath(process.cwd(), process.env.CLAUDE_PROJECT_DIR); } // 3. Script path derivation (open-set): hooks ship at // //hooks/, so strip "/hooks" for ANY harness. const scriptDir = dirname(fileURLToPath(importMetaUrl)); const fromScript = stripHarnessLeaf(scriptDir, "hooks"); if (fromScript) return fromScript; // 4. CWD has a known harness directory (dev repo). const cwd = process.cwd(); for (const h of KNOWN_HARNESS_DIRS) { if (existsSync(join(cwd, h))) { return cwd; } } return cwd; } // --- File paths --- export function toPosix(p: string): string { return sep === "/" ? p : p.split(sep).join("/"); } // --- Workspace selectors: space + intent --------------------------------------- // // The record (state · audit · artifacts · diary) re-roots per INTENT under a // per-team SPACE: `aidlc/spaces//intents/-/…`. Two cursors // pick the active space/intent, both GITIGNORED (per-user, not shared truth): // - `aidlc/active-space` → the active space // - `aidlc/spaces//intents/active-intent` → that space's active intent // // Resolution precedence (vision §5): // space: explicit arg > active-space pointer > "default" (NEVER errors). // intent: explicit arg > active-intent pointer > lone-intent > null. // // NULL RESOLUTION (P9 end state — no flat root). When NO intent record resolves // (activeIntent() → null: a fresh SEED shell before auto-create, or a flat project // still awaiting migration), the absolute path helpers resolve to the bare SPACE // record root (aidlc/spaces//intents/ — see spaceRecordRoot). No // aidlc-state.md ever lives directly there, so existence-gated consumers // (loadStateFileIfPresent) read "no workflow yet" and the orchestrator either // creates an intent or reports an error. The ONLY surviving flat `aidlc-docs` // read is the one-time // migration's SOURCE (flatStateSource/flatMigrationSource below). // activeIntent() returning null IS that "no record yet" signal. export const ACTIVE_SPACE_POINTER = "active-space"; export const ACTIVE_INTENT_POINTER = "active-intent"; export const DEFAULT_SPACE = "default"; // --- Terminal-command classification (the deterministic-dispatch seam) --- // // A small set of `/aidlc` commands are TERMINAL: they map 1:1 to an // `aidlc-utility.ts` subcommand that runs a tool, prints its output, and stops — // they carry NO workflow work and never advance an intent. The orchestration // engine's `next` already routes these to a terminal `print` directive // (handleNext Branch 1 + 1b). They are exported HERE so a pre-LLM harness seam // (e.g. the Kiro userPromptSubmit hook) can dispatch them deterministically off // the SAME classification the engine uses — never a divergent hardcoded list. // // - read-only utility flags: matched ANYWHERE in the args (mirrors the engine's // parseNextFlags, which sets `readOnly` on any matching token). Each maps to // its subcommand by stripping the leading `--` (--status→status, …). // - workspace commands: parsed ONLY when the LEADING token is a workspace // noun/legacy verb, so freeform prose merely containing "space"/"intent" // stays intent text. A leading workspace noun wins over later read-only // flags because those tokens belong to that command's argv. export const READ_ONLY_FLAGS: ReadonlySet = new Set([ "--status", "--help", "--doctor", "--version", ]); export const WORKSPACE_VERBS: ReadonlySet = new Set([ "space", "space-create", "intent", ]); export type WorkspaceNoun = "intent" | "space"; export const INTENT_VERBS: ReadonlySet = new Set([ "list", "switch", "create", ]); export const SPACE_VERBS: ReadonlySet = new Set([ "list", "switch", "create", ]); export const RESERVED_FUTURE: ReadonlySet = new Set([ "archive", "rename", "show", "birth", ]); export type WorkspaceCommand = | { kind: "list"; noun: WorkspaceNoun; json: boolean } | { kind: "switch"; noun: WorkspaceNoun; name: string; explicit: boolean } | { kind: "create"; noun: "space"; name: string } | { kind: "create-intent"; noun: "intent"; rest: string[] } | { kind: "help"; noun: WorkspaceNoun } | { kind: "error"; noun: WorkspaceNoun; code: "missing-name"; verb: "switch" | "create" | "space-create"; message: string; } | { kind: "error"; noun: WorkspaceNoun; code: "reserved-future-verb"; verb: string; message: string; } | { kind: "not-workspace" }; function missingWorkspaceName( noun: WorkspaceNoun, verb: "switch" | "create" | "space-create", ): WorkspaceCommand { const usage = verb === "space-create" ? "space-create " : `${noun} ${verb} `; return { kind: "error", noun, code: "missing-name", verb, message: `Usage: aidlc ${usage}`, }; } function reservedFutureWorkspaceVerb( noun: WorkspaceNoun, verb: string, ): WorkspaceCommand { return { kind: "error", noun, code: "reserved-future-verb", verb, message: `${noun} ${verb} is reserved for a future workspace verb and is not implemented yet. ` + `Use ${noun} switch ${verb} to select an existing record with that name.`, }; } function isWorkspaceNoun(token: string | undefined): token is WorkspaceNoun { return token === "intent" || token === "space"; } function isReservedFutureWorkspaceVerb( token: string | undefined, ): token is string { return token !== undefined && RESERVED_FUTURE.has(token); } function explicitWorkspaceList( noun: WorkspaceNoun, tokens: string[], ): WorkspaceCommand { return { kind: "list", noun, json: tokens[2] === "--json" }; } export function parseWorkspaceCommand(tokens: string[]): WorkspaceCommand { const head = tokens[0]; if (head === "space-create") { const name = tokens[1]; if (name === undefined) { return missingWorkspaceName("space", "space-create"); } return { kind: "create", noun: "space", name }; } if (!isWorkspaceNoun(head)) return { kind: "not-workspace" }; const noun = head; const verbOrName = tokens[1]; if (verbOrName === undefined) { return { kind: "list", noun, json: false }; } if (verbOrName === "--json") { return { kind: "list", noun, json: true }; } if (verbOrName === "help" || verbOrName === "-h") { return { kind: "help", noun }; } if (isReservedFutureWorkspaceVerb(verbOrName)) { return reservedFutureWorkspaceVerb(noun, verbOrName); } if (noun === "intent") { if (verbOrName === "list") return explicitWorkspaceList(noun, tokens); if (verbOrName === "switch") { const name = tokens[2]; if (name === undefined) return missingWorkspaceName(noun, "switch"); return { kind: "switch", noun, name, explicit: true }; } if (verbOrName === "create") { return { kind: "create-intent", noun, rest: tokens.slice(2) }; } } if (noun === "space") { if (verbOrName === "list") return explicitWorkspaceList(noun, tokens); if (verbOrName === "switch") { const name = tokens[2]; if (name === undefined) return missingWorkspaceName(noun, "switch"); return { kind: "switch", noun, name, explicit: true }; } if (verbOrName === "create") { const name = tokens[2]; if (name === undefined) return missingWorkspaceName(noun, "create"); return { kind: "create", noun, name }; } } return { kind: "switch", noun, name: verbOrName, explicit: false }; } export function workspaceCommandUtilityArgv( command: WorkspaceCommand, ): string[] | null { switch (command.kind) { case "list": return command.json ? [command.noun, "--json"] : [command.noun]; case "switch": return command.explicit ? [command.noun, "switch", command.name] : [command.noun, command.name]; case "create": return ["space-create", command.name]; case "create-intent": return ["intent-create", ...command.rest]; case "help": return ["help"]; case "error": case "not-workspace": return null; } } export function splitDoubleQuotedArgs(raw: string): string[] { const tokens: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (ch === "\\" && raw[i + 1] === "\"") { current += "\""; i++; continue; } if (ch === "\"") { inQuotes = !inQuotes; continue; } if (!inQuotes && /\s/.test(ch)) { if (current.length > 0) { tokens.push(current); current = ""; } continue; } current += ch; } if (current.length > 0) tokens.push(current); return tokens; } // Kiro prompt/hook arguments are shell-like but may contain native Windows // paths before any shell parses them. Preserve backslashes literally unless // one escapes whitespace or a shell separator outside quotes, or the active // quote delimiter. In particular, do not collapse `C:\path`, quoted Windows // paths, or UNC `\\host` prefixes while still accepting `one\ argument`, // `one\;two`, and `\"`/`\'` literals. export function splitKiroCommandArgs(raw: string): string[] { const tokens: string[] = []; let current = ""; let quote: "'" | '"' | null = null; let started = false; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (ch === "\\") { const next = raw[i + 1]; const outputValueToken = tokens[tokens.length - 1] === "--output"; const windowsPathToken = /^[A-Za-z]:$/.test(current) || current.includes("\\"); let followingToken = ""; if (next !== undefined && /\s/.test(next)) { let start = i + 1; while (start < raw.length && /\s/.test(raw[start])) start++; let end = start; while (end < raw.length && !/\s/.test(raw[end])) end++; followingToken = raw.slice(start, end); } const endsOutputPathBeforeOption = outputValueToken && quote === null && (followingToken === "--export" || followingToken === "--output"); const closesQuotedOutputPath = outputValueToken && quote !== null && next === quote && ( raw[i + 2] === undefined || /\s/.test(raw[i + 2]) ); const escapesWhitespace = quote === null && !windowsPathToken && !endsOutputPathBeforeOption && next !== undefined && /\s/.test(next); const escapesShellSeparator = quote === null && !windowsPathToken && next === ";"; const escapesQuote = next !== undefined && !windowsPathToken && !closesQuotedOutputPath && ( (quote === null && (next === "'" || next === '"')) || (quote !== null && next === quote) ); if (escapesWhitespace || escapesShellSeparator || escapesQuote) { current += next; i++; } else { current += "\\"; } started = true; continue; } if (quote !== null) { if (ch === quote) quote = null; else current += ch; started = true; continue; } if (ch === "'" || ch === '"') { quote = ch; started = true; continue; } if (/\s/.test(ch)) { if (started) { tokens.push(current); current = ""; started = false; } continue; } current += ch; started = true; } if (started) tokens.push(current); return tokens; } export const RESERVED_RECORD_NAME_LIST = Object.freeze( [...new Set(["help", ...INTENT_VERBS, ...SPACE_VERBS, ...RESERVED_FUTURE])], ); // Slugs a record (intent or space) may never take. These names are grammar: // help, current workspace verbs, and reserved future verbs all change how the // router reads `intent ` / `space `. Refusing them at the // creation chokepoints keeps new records reachable. Pre-existing records with // these names remain reachable via explicit `switch`; doctor flags them as an // advisory so humans can rename them deliberately. export const RESERVED_RECORD_NAMES: ReadonlySet = new Set( RESERVED_RECORD_NAME_LIST, ); export type PluginCommand = | { kind: "not-plugin" } | { kind: "help" } | { kind: "error"; message: string } | { kind: "run"; argv: string[] }; export function parsePluginCommand(args: string[]): PluginCommand { if (args[0] !== "plugin") return { kind: "not-plugin" }; const verb = args[1]; if (verb === "help" || verb === "-h" || verb === "--help") { return { kind: "help" }; } const target = verb === "select" ? "select-plugins" : verb === "list" ? "plugin-list" : verb === "sync" ? "plugin-sync" : verb === "validate" ? "plugin-validate" : verb === "build" ? "plugin-build" : undefined; if (target !== undefined) { return { kind: "run", argv: [target, ...args.slice(2)] }; } const detail = verb ? `unknown verb '${verb}'` : "missing verb"; return { kind: "error", message: `aidlc: ${detail} for noun 'plugin'; try 'aidlc help --all'`, }; } // A classified terminal command: the aidlc-utility.ts subcommand to run, plus an // optional positional arg (the for a workspace verb). `source` records // which family matched, for diagnostics. export interface TerminalCommand { subcommand: string; arg?: string; args?: string[]; error?: string; display?: string; source: "read-only-flag" | "workspace-verb" | "plugin-verb" | "knowledge-verb"; } function terminalCommandFromPluginCommand( command: PluginCommand, originalArgs: string[], ): TerminalCommand | null { if (command.kind === "not-plugin") return null; if (command.kind === "help") { return { subcommand: "help", display: originalArgs.join(" "), source: "plugin-verb" }; } if (command.kind === "error") { return { subcommand: "error", error: command.message, display: originalArgs.join(" "), source: "plugin-verb", }; } const [subcommand, ...tail] = command.argv; return { subcommand, ...(tail.length > 0 ? { args: tail } : {}), display: originalArgs.join(" "), source: "plugin-verb", }; } // The DocumentKB verbs, in the order `aidlc knowledge help` lists them. A frozen // array rather than a switch so the dispatcher, the docs pin, and the skill can // all enumerate the same surface instead of three hand-kept copies drifting. // `remove` is deliberately absent: deletion stays "delete your own original, // then sync", so the tool never holds a destructive verb over user-owned files. // // `summarize` (S3b) is a deliberate EIGHTH verb, not a flag riding on an // existing one. It is not the same case the design rejected for an extractor- // config verb (§7's option (b), which had a strictly better packager-owned // alternative): a summary is LLM-authored text with no other entry point into // the tool, so persisting it needs its own verb exactly as `associate`/ // `dissociate` needed theirs. The tool stays deterministic -- it validates, // bounds, digests and persists the text a caller supplies; it never generates // or judges content itself (design §6's execution model). export const KNOWLEDGE_VERBS: readonly string[] = Object.freeze([ "onboard", "sync", "list", "show", "associate", "dissociate", "rebind", "summarize", ]); export type KnowledgeCommand = | { kind: "not-knowledge" } | { kind: "help" } | { kind: "error"; message: string } | { kind: "run"; argv: string[] }; // Parse the public `knowledge` noun once for every entrypoint, mirroring // parsePluginCommand. The slash orchestrator, Kiro's pre-LLM interceptor, and // the binary dispatcher must all agree that these are terminal utilities rather // than freeform workflow text -- an unrecognized noun does not error, it falls // through to the LLM conductor as intent prose, which is how a command can // appear to exist and then behave like a prompt. // // Unlike `plugin`, the verb IS the subcommand: the DocumentKB tool owns its own // verb names, so there is no translation table to keep in sync. export function parseKnowledgeCommand(args: string[]): KnowledgeCommand { if (args[0] !== "knowledge") return { kind: "not-knowledge" }; const verb = args[1]; if (verb === "help" || verb === "-h" || verb === "--help") { return { kind: "help" }; } if (verb !== undefined && KNOWLEDGE_VERBS.includes(verb)) { return { kind: "run", argv: [verb, ...args.slice(2)] }; } const detail = verb ? `unknown verb '${verb}'` : "missing verb"; return { kind: "error", message: `aidlc: ${detail} for noun 'knowledge'; try 'aidlc help --all'`, }; } function terminalCommandFromKnowledgeCommand( command: KnowledgeCommand, originalArgs: string[], ): TerminalCommand | null { if (command.kind === "not-knowledge") return null; if (command.kind === "help") { return { subcommand: "help", display: originalArgs.join(" "), source: "knowledge-verb" }; } if (command.kind === "error") { return { subcommand: "error", error: command.message, display: originalArgs.join(" "), source: "knowledge-verb", }; } const [subcommand, ...tail] = command.argv; return { subcommand, ...(tail.length > 0 ? { args: tail } : {}), display: originalArgs.join(" "), source: "knowledge-verb", }; } // The allowlisted trailing flags `--doctor` accepts (diagnostic export). Kept // The allowlisted trailing flags `--doctor` accepts. Kept // as a set here so the engine (parseNextFlags) and this classifier — the two // terminal-command deciders — stay byte-for-byte in agreement. A fixed // allowlist, so an arbitrary token can never ride the read-only path into the // tool. export const DOCTOR_EXPORT_FLAGS: ReadonlySet = new Set([ "--export", "--output", "--verbose", ]); // Collect the allowlisted `--doctor` args (`--export`, `--output `, // `--verbose`) // from the token stream after the `--doctor` match, so the seam runs the same // command the engine's directive names. Mirrors parseNextFlags in the engine. function collectDoctorExportArgs(args: string[], doctorIdx: number): string[] { const extra: string[] = []; for (let j = doctorIdx + 1; j < args.length; j++) { const t = args[j]; if (!DOCTOR_EXPORT_FLAGS.has(t)) continue; extra.push(t); if (t === "--output") { const val = args[j + 1]; if (val !== undefined && !val.startsWith("--")) { extra.push(val); j++; } } } return extra; } function terminalCommandFromWorkspaceCommand( command: WorkspaceCommand, originalArgs: string[], ): TerminalCommand | null { if (command.kind === "not-workspace") return null; if (command.kind === "help") { return { subcommand: "help", source: "read-only-flag" }; } if (command.kind === "error") { return { subcommand: "error", error: command.message, display: originalArgs.join(" "), source: "workspace-verb", }; } const argv = workspaceCommandUtilityArgv(command); if (argv === null) return null; const [subcommand, ...tail] = argv; const terminal: TerminalCommand = { subcommand, source: "workspace-verb" }; if (tail.length === 1 && !tail[0].startsWith("--")) { terminal.arg = tail[0]; } if (tail.length > 1 || (tail.length === 1 && tail[0].startsWith("--"))) { terminal.args = tail; } return terminal; } // Classify the post-`/aidlc` argument tokens. Returns the terminal command to run // deterministically, or null when the input is NOT a terminal command (freeform // intent text, a --scope/--stage/--phase jump, a config/scope change, creation - all // of which carry workflow work and MUST go through the engine + conductor). The // matching rules are byte-for-byte the engine's parseNextFlags terminal branches // (read-only flag anywhere; workspace verb only at index 0) so the seam and the // engine can never disagree about what is terminal. export function classifyTerminalCommand(args: string[]): TerminalCommand | null { // A SOLE bare `help` / `-h` token is a help REQUEST (terminal, read-only); // mirrors parseNextFlags in the engine. Without this the token reads as // freeform intent text and the funnel offers to create an intent named // "help". Sole-token only: `help` inside a longer description stays freeform. if (args.length === 1 && (args[0] === "help" || args[0] === "-h")) { return { subcommand: "help", source: "read-only-flag" }; } const pluginCommand = parsePluginCommand(args); if (pluginCommand.kind !== "not-plugin") { return terminalCommandFromPluginCommand(pluginCommand, args); } const knowledgeCommand = parseKnowledgeCommand(args); if (knowledgeCommand.kind !== "not-knowledge") { return terminalCommandFromKnowledgeCommand(knowledgeCommand, args); } // Leading workspace nouns own the command. Any later read-only-looking token // is part of that workspace command's argv, not a mode switch, because the // public grammar promises leading-token semantics. const workspaceCommand = parseWorkspaceCommand(args); if (workspaceCommand.kind !== "not-workspace") { // Intent creation mutates workflow state and must remain on the normal // engine/conductor/shell path. In particular, Kiro's prompt interceptor has // no session_id, while the shell PostToolUse event does; executing creation // off-band would make exact session ownership impossible. if (workspaceCommand.kind === "create-intent") return null; return terminalCommandFromWorkspaceCommand(workspaceCommand, args); } for (let i = 0; i < args.length; i++) { const a = args[i]; if (READ_ONLY_FLAGS.has(a)) { const subcommand = a.replace(/^--/, ""); // --doctor carries allowlisted args (--export, --output , --verbose) // so the documented diagnostic surfaces reach the tool through the // Kiro/Codex seam too, not only a direct invocation. Carried via `args` (v2's // forwarded-args field), mirrored by the engine's parseNextFlags. if (a === "--doctor") { const extra = collectDoctorExportArgs(args, i); if (extra.length > 0) return { subcommand, source: "read-only-flag", args: extra }; } return { subcommand, source: "read-only-flag" }; } } return null; } // Kiro's plain-text hook channel must carry UTF-8 without terminal protocol // bytes. Keep this transform narrowly scoped to adapter output that is // explicitly plain text: structured hook JSON and refusal payloads must retain // their exact bytes and exit semantics. export function sanitizeHarnessPlainText(value: string): string { let out = ""; let i = 0; const csiEnd = (start: number): number => { for (let j = start; j < value.length; j++) { const code = value.charCodeAt(j); if (code >= 0x40 && code <= 0x7e) return j; } return -1; }; const stringControlEnd = (start: number): number => { for (let j = start; j < value.length; j++) { const code = value.charCodeAt(j); if (code === 0x07 || code === 0x9c) return j; if ( code === 0x1b && j + 1 < value.length && value.charCodeAt(j + 1) === 0x5c ) { return j + 1; } } return -1; }; while (i < value.length) { const code = value.charCodeAt(i); if (code === 0x1b) { const next = value.charCodeAt(i + 1); if (next === 0x5b) { const end = csiEnd(i + 2); i = end >= 0 ? end + 1 : value.length; continue; } if ( next === 0x50 || next === 0x58 || next === 0x5d || next === 0x5e || next === 0x5f ) { const end = stringControlEnd(i + 2); i = end >= 0 ? end + 1 : value.length; continue; } if ( next === 0x28 || next === 0x29 || next === 0x2a || next === 0x2b || next === 0x2d || next === 0x2e || next === 0x2f ) { i = Math.min(value.length, i + 3); continue; } if (next >= 0x40 && next <= 0x5f) { i += 2; continue; } i++; continue; } if (code === 0x9b) { const end = csiEnd(i + 1); i = end >= 0 ? end + 1 : value.length; continue; } if ( code === 0x90 || code === 0x98 || code === 0x9d || code === 0x9e || code === 0x9f ) { const end = stringControlEnd(i + 1); i = end >= 0 ? end + 1 : value.length; continue; } if ( (code >= 0x00 && code <= 0x08) || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) || (code >= 0x7f && code <= 0x9f) ) { i++; continue; } out += value[i]; i++; } return out; } export function decodeHarnessPlainText( bytes: Uint8Array | undefined, ): string { return sanitizeHarnessPlainText( new TextDecoder("utf-8").decode(bytes ?? new Uint8Array()), ); } // --- Engine command detectors (hook classifier seam) --- // // These raw command-string classifiers are shared by hooks and tests. They do // not attempt shell parsing: English-prose mentions and quoted echoes of command // strings match, which is a pre-existing class shared with the old detectors. // That direction fails closed: over-detection nudges, never releases. const engineCommandHarnessPattern = KNOWN_HARNESS_DIRS .map((dir) => dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("|"); const sourceEngineDispatcherPath = String.raw`(?:${engineCommandHarnessPattern})[/\\]tools[/\\]aidlc\.ts`; // Normalize the source dispatcher's executable token, including quoted project // roots. Leave surrounding shell wrappers, arguments, and legacy tools intact. const sourceEngineDispatcher = new RegExp( String.raw`\bbun[ \t]+(?:"(?:[^"\r\n]*[/\\])?${sourceEngineDispatcherPath}"|'(?:[^'\r\n]*[/\\])?${sourceEngineDispatcherPath}'|(?:[^\s"';&|<>]*[/\\])?${sourceEngineDispatcherPath})[ \t]+(?=engine\b)`, "g", ); // Authored methodology uses the native dispatcher's hidden engine namespace. // Canonicalize only the engine tools these detectors own so the Bun and native // spellings share one classification policy. Other engine tools remain untouched. function canonicalEngineCommand(text: string): string { return text .replace(sourceEngineDispatcher, "aidlc ") .replace( /\baidlc\s+engine\s+orchestrate\s+help\b/g, "aidlc help", ) .replace( /\baidlc\s+engine\s+(orchestrate|state|jump|bolt|swarm|scope|config|status|recompose)\b/g, "aidlc $1", ); } // A workflow-engine tool call: a Bash invocation of legacy // aidlc-orchestrate/aidlc-state, a new-grammar `aidlc ...` engine command, or a // tool whose name itself references aidlc. These are the calls that mean "the // conductor engaged the workflow this turn"; their presence in the turn that // answered the human disqualifies the turn from the conversational carve-out (a // conductor that ran the engine and then quit mid-loop must still be nudged). export function isEngineToolCall(name: string, input: unknown): boolean { const cmd = input !== null && typeof input === "object" ? String((input as Record).command ?? "") : ""; // The command text to inspect: a Bash/Shell command, or (for harnesses that // surface the tool by name) the tool name itself. const rawText = /^(bash|shell|execute_bash)$/i.test(name) ? cmd : name; const text = canonicalEngineCommand(rawText); // Fast reject: no AIDLC engine/state/workspace tool named at all -> not a // workflow engagement (a chat turn that ran git/cat/ls etc.). if ( !/aidlc-(orchestrate|state|jump|bolt|swarm|unit)\b/.test(text) && !/\baidlc\s+(?:next|report|park|orchestrate|state|jump|bolt|swarm|unit)\b/.test(text) ) { return false; } // Split on shell separators so a CHAINED command is judged per sub-command, // not as one blob. Otherwise a read-only flag anywhere in the line // (`... --status && aidlc-orchestrate report ...`) would wrongly exempt a // mutating call elsewhere in the same line. Each segment is judged on its own. const segments = text.split(/&&|\|\||[;|\n]/); for (const seg of segments) { // Path normalization can remove a substitution inside a quoted dispatcher // path. Such a command cannot receive the static navigation exemption. if (isEngineEngagementSegment(seg, !/\$\(|`/.test(rawText))) return true; } return false; } // Current legacy-shape engagement rules. Kept as a helper so the exported // classifier can preserve every old-shape result while adding the new grammar. function legacyEngineEngagementSegment(seg: string): boolean { if (!/aidlc-(orchestrate|state|jump|bolt|swarm|unit)\b/.test(seg)) return false; // A PURE read-only query: a read-only flag present AND no mutating/advancing // verb in the SAME segment. `next --status` is read-only; `report --status` // (nonsensical, but) still has `report` so is engagement. const hasReadOnlyFlag = /--status\b|--doctor\b|--help\b|--version\b/.test(seg); if (/aidlc-orchestrate\b/.test(seg)) { const advances = /\bnext\b|\breport\b/.test(seg); if (!advances) return false; // e.g. an orchestrate invocation with only a read-only flag // `next --status` is the read-only status query; a bare `next` (or any // `report`) advances. So: advancing verb present -> engagement UNLESS the // ONLY advancing token is `next` and it carries a read-only flag. if (hasReadOnlyFlag && /\bnext\b/.test(seg) && !/\breport\b/.test(seg)) return false; return true; } if (/aidlc-state\b/.test(seg)) { // The mutating / completing subcommands. (Read-only aidlc-state reads like // `get`/`show` are not here, so they fall through to non-engagement.) return /\b(approve|advance|finalize|complete-workflow|gate-start|checkbox|park|unpark|set|skip|reject|revise|resume)\b/.test(seg); } if (/aidlc-unit\b/.test(seg)) { return !/\b(status|merge-status)\b/.test(seg); } // aidlc-jump / aidlc-bolt / aidlc-swarm: a read-only query (--help/--status) // is not engagement; anything else mutates (jump moves the pointer, bolt forks/ // merges, swarm runs Construction) so counts as engagement. if (hasReadOnlyFlag) return false; return true; } // A next call that only routes workspace navigation does not engage a workflow. // Require a static, complete command before applying this exemption; unknown // wrappers, substitutions, redirects, and malformed quoting retain the existing // conservative classification. Shell chains are classified segment by segment. function isWorkspaceNavigationNext(seg: string): boolean { if (/[\\`$<>&()[\]{}*?~^#]/.test(seg)) return false; let quote: "'" | '"' | null = null; for (let i = 0; i < seg.length; i++) { const char = seg[i]; if (quote) { if (char === quote) quote = null; } else if (char === "'" || char === '"') { quote = char; } } if (quote) return false; const words = splitKiroCommandArgs(seg.trim()); if (words[0] === "env") words.shift(); while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? "")) words.shift(); if (words[0] === "command" || words[0] === "exec") { words.shift(); if (words.at(0) === "--") words.shift(); } let args: string[]; if (words[0] === "aidlc" && words[1] === "orchestrate" && words[2] === "next") { args = words.slice(3); } else if (words[0] === "aidlc" && words[1] === "next") { args = words.slice(2); } else if ( words[0] === "bun" && /(?:^|[/\\])aidlc-orchestrate\.ts$/.test(words[1] ?? "") && words[2] === "next" ) { args = words.slice(3); } else { return false; } // Dispatcher/engine global options can be removed or moved before workspace // parsing, revealing a different verb. Grant no exemption for those ambiguous // forms. A trailing bare option cannot reveal another verb (`space --json`). if ( args.some((arg) => arg === "--project-dir" || arg === "--aidlc-attempt-id") || args.slice(0, -1).some((arg) => ["--json", "--quiet", "--no-color", "--yes", "--offline", "--verbose"].includes(arg) ) ) return false; // Intent creation explicitly returns null here: it starts workflow work and // must retain the normal engagement and session-handoff rules. return parseWorkspaceCommand(args).kind !== "not-workspace" && classifyTerminalCommand(args) !== null; } // One shell sub-command. True when it ENGAGES the forwarding loop or MUTATES // workflow state, false for a read-only query. A human chatting may legitimately // ask "what stage am I on?" answered with `--status` / `next --status` / // `--doctor` / `--help` / `--version` or a read-only utility call: those must // NOT disqualify the conversational carve-out. Anything that advances the loop // (`next` fetching a directive, `report` committing a transition) or mutates // state (aidlc-state completing/transition verbs; a checkbox/jump/bolt/swarm // move) DOES count as engagement. Fail-toward-engagement: an aidlc-orchestrate/ // state/jump/bolt/swarm verb we do not specifically recognise is treated as // engagement (BLOCK), so an unrecognised mutating verb can never leak through as // "chat" - the conservative direction for loop integrity. export function isEngineEngagementSegment( seg: string, allowWorkspaceNavigation = true, ): boolean { if (allowWorkspaceNavigation && isWorkspaceNavigationNext(seg)) return false; if ( /aidlc-(orchestrate|state|jump|bolt|swarm|unit)\b/.test(seg) && legacyEngineEngagementSegment(seg) ) { return true; } if (!/\baidlc\s+(?:next|report|park|orchestrate|state|jump|bolt|swarm|unit)\b/.test(seg)) { return false; } const hasReadOnlyFlag = /--status\b|--doctor\b|--help\b|--version\b/.test(seg); const hasTopNext = /\baidlc\s+next\b/.test(seg); const hasTopReport = /\baidlc\s+report\b/.test(seg); const hasTopPark = /\baidlc\s+park\b/.test(seg); const hasNounNext = /\baidlc\s+orchestrate\s+next\b/.test(seg); const hasNounReport = /\baidlc\s+orchestrate\s+report\b/.test(seg); const hasNounPark = /\baidlc\s+orchestrate\s+park\b/.test(seg); const hasOrchestrateNoun = /\baidlc\s+orchestrate\b/.test(seg); const hasNext = hasTopNext || hasNounNext; const hasReport = hasTopReport || hasNounReport; const hasPark = hasTopPark || hasNounPark; if (hasNext || hasReport || hasPark || hasOrchestrateNoun) { // Deliberate grammar delta: new-shape `aidlc park` counts as engagement. // The old orchestrate branch did not count `aidlc-orchestrate.ts park` // because legacy orchestrate engagement recognized only next/report. if (!hasNext && !hasReport && !hasPark) return false; if (hasReadOnlyFlag && hasNext && !hasReport && !hasPark) return false; return true; } if (/\baidlc\s+state\b/.test(seg)) { return /\b(approve|advance|finalize|complete-workflow|gate-start|checkbox|park|unpark|set|set-status|skip|reject|revise|resume|init)\b/.test(seg); } if (/\baidlc\s+(?:jump|bolt|swarm)\b/.test(seg)) { if (hasReadOnlyFlag) return false; return true; } if (/\baidlc\s+unit\b/.test(seg)) { return !/\baidlc\s+unit\s+(?:status|merge-status)\b/.test(seg); } return false; } function shellCommandSegments(command: string): string[] { const segments: string[] = []; let start = 0; let quote: "'" | '"' | null = null; let escaped = false; for (let i = 0; i < command.length; i++) { const char = command[i]; if (escaped) { escaped = false; continue; } if (char === "\\" && quote !== "'") { escaped = true; continue; } if (quote) { if (char === quote) quote = null; continue; } if (char === "'" || char === '"') { quote = char; continue; } const separatorWidth = char === "&" && command[i + 1] === "&" ? 2 : char === "|" || char === ";" || char === "\n" ? 1 : 0; if (separatorWidth === 0) continue; segments.push(command.slice(start, i)); i += separatorWidth - 1; start = i + 1; } segments.push(command.slice(start)); return segments; } // Classify commands for the rebuild-stage-graph hook's cheap PostToolUse gate. // Transition matching stays intentionally lexical, but the recursion guard // only examines real unquoted shell-command segments. const runtimeCompileTool = new RegExp( `\\bbun\\b.*(?:${engineCommandHarnessPattern})/tools/aidlc-(state|jump|bolt|unit|utility)\\.ts\\b`, ); const runtimeCompileReport = new RegExp( `\\bbun\\b.*(?:${engineCommandHarnessPattern})/tools/aidlc-orchestrate\\.ts\\b.*\\breport\\b`, ); const runtimeCompileSelf = new RegExp( `\\bbun\\b.*(?:${engineCommandHarnessPattern})/tools/aidlc-runtime\\.ts\\b`, ); export function classifyRuntimeCompileCommand( command: string, ): "reject" | "fire" | "pass" { const canonical = canonicalEngineCommand(command); const invokesRuntime = shellCommandSegments(command).some((segment) => /^\s*aidlc\s+engine\s+runtime\s+compile\b/.test(canonicalEngineCommand(segment)) ); if (runtimeCompileSelf.test(command) || invokesRuntime) { return "reject"; } if ( runtimeCompileTool.test(canonical) || runtimeCompileReport.test(canonical) || /\baidlc\s+(?:state|jump|bolt|unit|recompose)\b|\baidlc\s+(?:status|doctor|version|help)\b|\baidlc\s+scope\s+change\b|\baidlc\s+config\s+set\b/.test(canonical) || /\baidlc\s+report\b|\baidlc\s+orchestrate\s+report\b|\baidlc\s+next\b.*\breport\b/.test(canonical) ) { // Semantic-route rationale: keep D2 parity for the public/read-only // one-shots while the utility implementation remains the backing tool. // Deliberately do not fire for the new // workspace/gen/sensor/intent/space nouns. Old-shape utility calls keep // firing via the retained old regex. return "fire"; } return "pass"; } // `aidlc/` — the harness-neutral workspace roof (memory · codekb · knowledge · // intents live under spaces// here; the engine stays in /). function workspaceRoot(projectDir: string): string { return join(projectDir, "aidlc"); } function canonicalPathKey(path: string): string { const resolved = resolvePath(path); try { return realpathSync(resolved); } catch { return resolved; } } // The active space for this project. Reads the `aidlc/active-space` cursor; // defaults to "default". NEVER throws — the default space is always valid even // when nothing is on disk yet (the resolver tolerates an absent space dir). export function activeSpace(projectDir: string): string { const ptr = join(workspaceRoot(projectDir), ACTIVE_SPACE_POINTER); try { const raw = readFileSync(ptr, "utf-8").trim(); if (raw.length > 0) return raw; } catch { // no cursor → default } return DEFAULT_SPACE; } // `aidlc/spaces//intents` — the intent registry + record root. export function intentsDir(projectDir: string, space?: string): string { const sp = space ?? activeSpace(projectDir); return join(workspaceRoot(projectDir), "spaces", sp, "intents"); } // `aidlc/spaces//knowledge` — SPACE DOMAIN knowledge (durable, free-form, // team-authored, empty at bootstrap). A space-level sibling of memory/codekb/ // intents (vision §"Spaces": "its own memory, codekb, knowledge, and intent // record") — NOT per-intent: domain knowledge accumulates across every intent in // the space, so it must not live inside one intent's record. Distinct from the // engine's per-agent METHODOLOGY knowledge at /knowledge/ (shipped, // untouched). Created lazily by ensure-exists, never by SEED. export function knowledgeDir(projectDir: string, space?: string): string { const sp = resolveWorkflowSelection(projectDir, { space }).space; return join(workspaceRoot(projectDir), "spaces", sp, "knowledge"); } // A `--space ` flag names an EXISTING space; it is a path SEGMENT, so it // must never reach a join() raw — `--space ../../../outside` would otherwise // escape the workspace. `space create` slugifies at the creation chokepoint, so // any tool accepting the flag has to enforce the same shape on the way back in. // Returns the validated name, or null when it is not a bare slug (the caller // owns the exit code and the message). // // The shape is slugify()'s own output shape — a name `space create` could have // produced. A separate constant from BOLT_SLUG_REGEX despite the identical // pattern today, following the convention that comment states: Bolt slugs, // stage/artifact slugs, and space names are distinct domains that must be free // to tighten independently. export const SPACE_NAME_REGEX = /^[a-z][a-z0-9-]*$/; export function validSpaceFlag(raw: string): string | null { return SPACE_NAME_REGEX.test(raw) ? raw : null; } // Enumerate the intent RECORD directories in a space (each `-/` // holding an aidlc-state.md). Returns the bare directory names, sorted; [] when // the space has no intents dir or no records yet. The intents.json registry is // the canonical list for humans/ordering — this on-disk scan is the cheap // "does any record exist?" signal the path resolver and migration detector need // (it must not depend on the registry being present). export function listIntentDirs(projectDir: string, space?: string): string[] { const dir = intentsDir(projectDir, space); let entries: string[]; try { entries = readdirSync(dir); } catch { return []; } const records: string[] = []; for (const name of entries) { // A record dir holds aidlc-state.md; skip the active-intent cursor, // intents.json, and any stray files. if (existsSync(join(dir, name, "aidlc-state.md"))) records.push(name); } return records.sort(); } // The active intent's RECORD directory NAME (`-`) for a space, or // null when no record resolves (→ the path helpers resolve the bare space record // root). Precedence: explicit > active-intent cursor (if it names a real record) // > lone intent. Returns null rather than throwing on ambiguity so the path // helpers stay total; the verb/handler layer (P4) owns the error/prompt for the // >1-intent-no-cursor case. export function activeIntent( projectDir: string, space?: string, explicit?: string, ): string | null { const sp = space ?? activeSpace(projectDir); const dir = intentsDir(projectDir, sp); if (explicit) return explicit; // Cursor: a real record the pointer names. try { const raw = readFileSync(join(dir, ACTIVE_INTENT_POINTER), "utf-8").trim(); if (raw.length > 0 && existsSync(join(dir, raw, "aidlc-state.md"))) return raw; } catch { // no cursor → fall through to lone-intent } const records = listIntentDirs(projectDir, sp); if (records.length === 1) return records[0]; // 0 records → null (bare space root); >1 with no cursor → null (the handler // layer prompts; a path helper cannot guess which intent the caller meant). return null; } // The absolute RECORD directory for an intent: // `aidlc/spaces//intents/-/`. Returns null when no intent // resolves, signalling the bare-space-root resolution in the path helpers. function resolveRecordDir( projectDir: string, intent?: string, space?: string, ): { dir: string | null; space: string } { const selection = resolveWorkflowSelection(projectDir, { space, intent }); return { dir: selection.intent === null ? null : join(intentsDir(projectDir, selection.space), selection.intent), space: selection.space, }; } export function recordDir( projectDir: string, intent?: string, space?: string, ): string | null { return resolveRecordDir(projectDir, intent, space).dir; } // Relative record-dir prefix for the engine's agent-consumed artifact/diary // paths: `aidlc/spaces//intents/-` with forward slashes // regardless of host OS (portable across worktrees). Returns null → the engine // resolvers resolve the bare space-relative record prefix // (relativeSpaceRecordPrefix). The space + intent come from the active cursors // unless passed explicitly; the engine threads the active intent's record-dir // name in (it knows projectDir but the resolvers themselves take no projectDir — // see aidlc-orchestrate.ts). export function relativeRecordDir( projectDir: string, intent?: string, space?: string, ): string | null { const selection = resolveWorkflowSelection(projectDir, { space, intent }); const sp = selection.space; const slug = selection.intent; if (slug === null) return null; return `aidlc/spaces/${sp}/intents/${slug}`; } // `aidlc/spaces//codekb//` — the durable per-repo code // knowledge base, a space-level sibling of memory/knowledge/intents (vision // §Spaces; committed glob aidlc/spaces/*/codekb/**). NOT per-intent: it is keyed // by repo and shared across every intent in the space, so it must NOT carry the // intents/ tail. Mirrors knowledgeDir's space-aware shape. export function codekbDir(projectDir: string, repo: string, space?: string): string { const sp = resolveWorkflowSelection(projectDir, { space }).space; return join(workspaceRoot(projectDir), "spaces", sp, "codekb", repo); } // Relative analog of codekbDir (posix slashes), the engine-emitted form // the conductor/subagent reads. Mirrors relativeRecordDir (takes projectDir so it // can read the active-space cursor — NOT relativeSpaceRecordPrefix, which is // pinned to the default space). export function relativeCodekbDir(projectDir: string, repo: string, space?: string): string { const sp = resolveWorkflowSelection(projectDir, { space }).space; return `aidlc/spaces/${sp}/codekb/${repo}`; } // The deterministic repo NAME for codekb keying (NOT the intent slug): // 1 recorded repo -> that name // 0 recorded repos (workspace root IS the repo) -> basename(projectDir) // >1 recorded -> caller loops per repo (this returns basename as a safe // default; callers that know the repo pass --repo explicitly). // basename done here (lib has basename imported) so callers never inline it. export function codekbRepoName( projectDir: string, space?: string, intent?: string, ): string { const selection = resolveWorkflowSelection(projectDir, { space, intent }); const repos = intentRepos( projectDir, selection.intent ?? undefined, selection.space, ); return repos.length === 1 ? repos[0] : basename(projectDir); } // --- Codekb scope of analysis ------------------------------------------------- // // The reverse-engineering stage records WHAT its scan covered in a fenced yaml // block inside reverse-engineering-timestamp.md (the store's freshness marker). // The parser + fingerprint here are the deterministic half of the rerun // guard: `codekb-scope-diff` compares a store's recorded scope against the // live working tree (status) or an incoming run's scope (compare), so the // human at the RE gate decides reuse/rescan/replace on evidence instead of // silently losing a prior intent's knowledge to a narrower overwrite. // // Block shape (scope_version 1 - authored by the architect at synthesis, // behind the RE approval gate): // // ```yaml // scope_version: 1 // kind: partial # or: full // intent: fix-payment-timeout // fingerprint: 3f2a9c... # codekbScopeFingerprint over analyzed.paths // analyzed: // paths: // - src/payments/ // components: // - payment-gateway // shallow: // paths: // - src/ // ``` // // Pure data - no model call. Same idiom as parseBoltDag: a constrained // line-walker, no YAML dependency. export type ReScope = { kind: "full" | "partial"; intent: string; fingerprint: string | null; analyzedPaths: string[]; analyzedComponents: string[]; shallowPaths: string[]; }; export type ReScopeParse = | { ok: true; scope: ReScope } | { ok: false; reason: "absent" | "malformed"; detail: string }; // Find the fenced yaml block carrying `scope_version:` anywhere in the body // (keyed on the version line, not a heading, so prose edits around the block // don't break parsing). Returns the inner lines, or null when no block exists. function extractScopeBlock(body: string): string | null { const lines = body.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (/^```ya?ml\s*$/.test(lines[i].trim())) { const inner: string[] = []; let j = i + 1; for (; j < lines.length; j++) { if (/^```\s*$/.test(lines[j].trim())) break; inner.push(lines[j]); } const block = inner.join("\n"); if (/^\s*scope_version\s*:/m.test(block)) return block; i = j; // not the scope block - resume past its close fence } } return null; } // Parse the scope block out of a reverse-engineering-timestamp.md body. // Unknown scope_version parses as malformed (a future writer must not be // half-read by an old reader); a missing block is "absent" (legacy store). export function parseReScope(body: string): ReScopeParse { const block = extractScopeBlock(body); if (block === null) { return { ok: false, reason: "absent", detail: "no fenced yaml scope_version block found" }; } const scope: ReScope = { kind: "partial", intent: "", fingerprint: null, analyzedPaths: [], analyzedComponents: [], shallowPaths: [], }; let section: "analyzed" | "shallow" | null = null; let list: "paths" | "components" | null = null; let sawKind = false; for (const raw of block.split("\n")) { const t = raw.trim(); if (t === "" || t.startsWith("#")) continue; const indent = raw.length - raw.trimStart().length; if (indent === 0) { section = null; list = null; if (t.startsWith("scope_version:")) { const v = t.slice("scope_version:".length).trim(); if (v !== "1") { return { ok: false, reason: "malformed", detail: `unknown scope_version: ${v}` }; } } else if (t.startsWith("kind:")) { const k = t.slice("kind:".length).trim(); if (k !== "full" && k !== "partial") { return { ok: false, reason: "malformed", detail: `kind must be full|partial, got: ${k}` }; } scope.kind = k; sawKind = true; } else if (t.startsWith("intent:")) { scope.intent = t.slice("intent:".length).trim(); } else if (t.startsWith("fingerprint:")) { const f = t.slice("fingerprint:".length).trim(); scope.fingerprint = f === "" || f === "unknown" ? null : f; } else if (t === "analyzed:") { section = "analyzed"; } else if (t === "shallow:") { section = "shallow"; } } else if (section !== null && !t.startsWith("-") && t.endsWith(":")) { list = t === "paths:" ? "paths" : t === "components:" ? "components" : null; } else if (section !== null && list !== null && t.startsWith("-")) { const item = t.slice(1).trim(); if (item === "") continue; if (section === "analyzed" && list === "paths") scope.analyzedPaths.push(item); else if (section === "analyzed" && list === "components") scope.analyzedComponents.push(item); else if (section === "shallow" && list === "paths") scope.shallowPaths.push(item); } } if (!sawKind) { return { ok: false, reason: "malformed", detail: "missing kind: line" }; } if (scope.kind === "partial" && scope.analyzedPaths.length === 0) { return { ok: false, reason: "malformed", detail: "kind: partial requires analyzed.paths entries" }; } if (scope.kind === "partial" && scope.analyzedPaths.includes("./")) { return { ok: false, reason: "malformed", detail: "repository-root coverage (./) requires kind: full", }; } if (scope.kind === "full" && !scope.analyzedPaths.includes("./")) { return { ok: false, reason: "malformed", detail: "kind: full requires repository-root coverage (analyzed.paths must include ./)", }; } return { ok: true, scope }; } // Content fingerprint of the WORKING TREE restricted to the scope's analyzed // paths: `git write-tree` over a temporary index populated by `git add -A -- // `. Hashes what is actually on disk (uncommitted edits included), so // rebases/squashes/amends that vaporise a recorded commit hash cannot break // the comparison, and reverting an edit restores the original fingerprint. // Ignored files stay excluded (git add semantics). Callers may exclude generated // paths that live inside an analyzed root, such as the codekb being fingerprinted; // exclusions outside every analyzed root are omitted before invoking git. // Returns null when repoDir is not a git work tree, git is unavailable, or any // pathspec is invalid/unmatched or stages zero paths (callers report UNVERIFIED, // never a false verdict or the empty-tree fingerprint). export function codekbScopeFingerprint( repoDir: string, paths: string[], excludedPaths: string[] = [], ): string | null { if (paths.length === 0) return null; const normalizePath = (path: string): string => { let normalized = path.replaceAll("\\", "/"); normalized = normalized.replace(/^(?:\.\/)+/, "").replace(/\/+$/, ""); return normalized === "." ? "" : normalized; }; const normalizedExclusions = excludedPaths.map(normalizePath); const survivingPaths = paths .map((original) => ({ original, normalized: normalizePath(original) })) .filter( ({ normalized: positive }) => !normalizedExclusions.some( (exclusion) => exclusion === positive || positive.startsWith(`${exclusion}/`), ), ); if (survivingPaths.length === 0) return null; const exclusions = normalizedExclusions .filter((exclusion) => survivingPaths.some( ({ normalized: positive }) => positive === "" || exclusion.startsWith(`${positive}/`), ), ) .map((exclusion) => `:(exclude,literal)${exclusion}`); const inTree = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: repoDir, encoding: "utf-8", }); if (inTree.status !== 0 || inTree.stdout.trim() !== "true") return null; const indexFile = join(tmpdir(), `.aidlc-scope-index-${randomUUID()}`); const env = { ...process.env, GIT_INDEX_FILE: indexFile }; try { const add = spawnSync( "git", ["add", "-A", "--", ...survivingPaths.map(({ original }) => original), ...exclusions], { cwd: repoDir, env, encoding: "utf-8", }, ); if (add.status !== 0) return null; const staged = spawnSync("git", ["ls-files", "-z"], { cwd: repoDir, env, encoding: "utf-8", }); if (staged.status !== 0 || staged.stdout.length === 0) return null; const wt = spawnSync("git", ["write-tree"], { cwd: repoDir, env, encoding: "utf-8" }); if (wt.status !== 0) return null; const hash = wt.stdout.trim(); return /^[0-9a-f]{40,64}$/.test(hash) ? hash : null; } finally { try { unlinkSync(indexFile); } catch { // best-effort cleanup - a leaked temp index is inert } } } function normalizeGenerationPath(path: string): string | null { const portable = path.trim().replaceAll("\\", "/"); if ( portable === "" || portable.startsWith("/") || /^[A-Za-z]:\//.test(portable) || /[*?[\]]/.test(portable) ) { return null; } const segments = portable.split("/").filter((segment) => segment !== "" && segment !== "."); if (segments.some((segment) => segment === "..")) return null; return segments.length === 0 ? "." : segments.join("/"); } function treeGeneration( rootDir: string, paths: string[], excludedPaths: string[] = [], ): string | null { const normalizedPaths = [...new Set(paths.map(normalizeGenerationPath))]; if (normalizedPaths.includes(null) || normalizedPaths.length === 0) return null; const normalizedExcludes = new Set( [".git", ...excludedPaths] .map(normalizeGenerationPath) .filter((path): path is string => path !== null), ); const root = resolvePath(rootDir); const seen = new Set(); const hash = createHash("sha256"); const excluded = (portable: string): boolean => [...normalizedExcludes].some( (entry) => portable === entry || portable.startsWith(`${entry}/`), ); const visit = (absPath: string, portable: string): boolean => { if (portable !== "." && excluded(portable)) return true; if (seen.has(portable)) return true; seen.add(portable); let stat: ReturnType; try { stat = lstatSync(absPath); } catch { return false; } if (stat.isSymbolicLink()) { hash.update(`L\0${portable}\0${readlinkSync(absPath)}\0`, "utf-8"); return true; } if (stat.isDirectory()) { hash.update(`D\0${portable}\0`, "utf-8"); let names: string[]; try { names = readdirSync(absPath).sort(); } catch { return false; } for (const name of names) { const childPortable = portable === "." ? name : `${portable}/${name}`; if (!visit(join(absPath, name), childPortable)) return false; } return true; } if (!stat.isFile()) return false; hash.update(`F\0${portable}\0${stat.size}\0`, "utf-8"); hash.update(readFileSync(absPath)); hash.update("\0", "utf-8"); return true; }; for (const portable of normalizedPaths as string[]) { const absPath = portable === "." ? root : resolvePath(root, ...portable.split("/")); const rel = relative(root, absPath); if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) return null; hash.update(`S\0${portable}\0`, "utf-8"); if (!visit(absPath, portable)) return null; } return hash.digest("hex"); } // A generation token for the source paths that informed one CodeKB candidate. // Prefer the existing git-aware fingerprint (ignored files excluded); fall back // to a byte-exact tree hash so non-git workspaces still receive a real CAS token. export function codekbSourceFingerprint( repoDir: string, paths: string[], excludedPaths: string[] = [], ): string | null { const git = codekbScopeFingerprint(repoDir, paths, excludedPaths); if (git !== null) return `git:${git}`; const tree = treeGeneration(repoDir, paths, excludedPaths); return tree === null ? null : `tree:${tree}`; } // Hash the complete on-disk CodeKB directory, not only its timestamp. This is // the compare-and-swap generation for cumulative merges: any concurrent edit to // any artifact changes the token and makes a stale publish refuse. export function codekbStoreGeneration(storeDir: string): string { if (!existsSync(storeDir)) return "none"; const generation = treeGeneration(storeDir, ["./"]); if (generation === null) { throw new Error(`cannot compute CodeKB store generation for ${storeDir}`); } return `sha256:${generation}`; } // True only when the durable CodeKB store for `repo` carries a valid scope // block whose recorded fingerprint still matches the current source tree. // This is the programmatic form of `codekb-scope-diff`'s CURRENT verdict, used // by authority-bearing reuse receipts so freshness is checked both when the // receipt is minted and when pipeline completion consumes it. export function codekbStoreIsCurrent( projectDir: string, requestedRepo?: string, space?: string, ): boolean { const sp = space ?? activeSpace(projectDir); const repo = requestedRepo ?? codekbRepoName(projectDir, sp); const timestamp = join( codekbDir(projectDir, repo, sp), "reverse-engineering-timestamp.md", ); if (!existsSync(timestamp)) return false; let parsed: ReScopeParse; try { parsed = parseReScope(readFileSync(timestamp, "utf-8")); } catch { return false; } if (!parsed.ok || parsed.scope.fingerprint === null) return false; const sibling = repoDir(projectDir, repo); const sourceRoot = existsSync(sibling) && statSync(sibling).isDirectory() ? sibling : projectDir; const current = codekbScopeFingerprint( sourceRoot, parsed.scope.analyzedPaths, sourceRoot === projectDir ? ["aidlc"] : [], ); return current !== null && current === parsed.scope.fingerprint; } // Coverage test for the compare mode: does the incoming run's analyzed set // cover a store entry? Literal match, or an incoming DIRECTORY prefix (entry // ending "/") subsuming the store path. Deliberately prefix-only - scope // paths are authored as repo-relative dirs/files, not globs. export function scopePathCovered(incoming: string[], storePath: string): boolean { return incoming.some( (p) => p === storePath || (p.endsWith("/") && storePath.startsWith(p)), ); } // The bare SPACE record root: `aidlc/spaces//intents/`. The absolute path // helpers resolve here when no intent record exists (activeIntent → null) — a // fresh SEED shell before auto-create, or a flat project still awaiting migration. // No aidlc-state.md ever lives directly here, so existence-gated readers // (loadStateFileIfPresent) see "no workflow yet" and the orchestrator // creates an intent or reports an error. This is the P9 end state; there is no // flat `aidlc-docs/` root. function spaceRecordRoot(projectDir: string, space?: string): string { return intentsDir(projectDir, space); } // The bare space-RELATIVE record prefix (posix slashes) — the relative analog of // spaceRecordRoot, used by the engine/worktree resolvers when no per-intent // record prefix is threaded. The relative resolvers take no projectDir, so they // cannot read the active-space cursor and default to `default` (the same // single-string limitation the old flat relative prefix had — not a regression; // a non-default space threads relativeRecordDir explicitly). export function relativeSpaceRecordPrefix(space: string = DEFAULT_SPACE): string { return `aidlc/spaces/${space}/intents`; } // --- Intent identity: UUIDv7 + slugify ---------------------------------------- // // The canonical intent id is a UUIDv7 (time-ordered, globally unique, merge-safe, // stable across a slug rename). The dir name is `-` where id8 is the // trailing 8 hex of the uuid (a derived disambiguator). A within-space clash // resolves by the next-longer prefix of the SAME uuid (id8→id10→…), never a // re-mint. // Generate a UUIDv7: a 48-bit Unix-ms timestamp prefix + version 7 nibble + // random/variant tail. Sorting by uuid string is creation order. Date.now() // supplies the timestamp; randomUUID() supplies the random + variant bits (no // Math.random): take the v4 uuid's 32 hex digits, // overwrite the first 12 (the timestamp) and the 13th (the version nibble → 7), // and keep digits 13..31 (which include the v4 variant nibble) cryptographically // sourced. export function uuidv7(): string { const hex = randomUUID().replace(/-/g, ""); // 32 hex chars, v4 const ms = Date.now(); const tsHex = ms.toString(16).padStart(12, "0").slice(-12); // 48 bits = 12 hex const body = `${tsHex}7${hex.slice(13)}`; // ts(12) + version(1) + tail(19) return `${body.slice(0, 8)}-${body.slice(8, 12)}-${body.slice(12, 16)}-${body.slice(16, 20)}-${body.slice(20, 32)}`; } // The id8 disambiguator: trailing 8 hex chars of the uuid (digits only, dashes // stripped). Used in the `-` dir name. export function idSuffix(uuid: string, length = 8): string { const hex = uuid.replace(/-/g, ""); return hex.slice(-length); } // Deterministic free-text → SLUG_RE-valid kebab: lowercase; non-alphanumerics → // hyphens; collapse + trim hyphens; cap length; ensure a leading letter. Pure + // idempotent (slugify(slugify(x)) === slugify(x)). Falls back to "intent" when // the input reduces to empty. export function slugify(text: string, maxLength = 48): string { let s = text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, maxLength) .replace(/-+$/g, ""); // Ensure a leading LETTER (SLUG_RE = /^[a-z][a-z0-9-]*$/). if (!/^[a-z]/.test(s)) s = `intent-${s}`.replace(/-+$/g, ""); if (s.length === 0) s = "intent"; return s; } // --- Intent record dir name: - --------------------------- // // SPIKE (date-prefix). The record dir name leads with a compact UTC date so the // records sort CHRONOLOGICALLY in any file browser / `ls` (the time token is a // PREFIX, where lexicographic sort = creation order — a suffix would sort by the // label). The label is a SHORT human slug (cap 24, vs the old 48) — the // orchestrator is expected to pass a 2-3 word essence ("simple calc"), not the // full request sentence. Uniqueness within the space is the caller's collision // loop (a -N counter), NOT this name: the canonical, collision-proof id stays the // UUIDv7 in the registry row, and the row now stores this dirName verbatim (so the // readers never reconstruct it from slug+uuid). // The human-readable LABEL for a record dir name, for display/orphan rows when // no registry row supplies a slug. SPIKE (date-prefix): strip a leading `YYMMDD-` // date prefix; else strip a legacy trailing `-` id8. Falls back to the whole // name if neither shape matches. export function displaySlugFromDirName(dirName: string): string { const dated = /^\d{6}-(.+)$/.exec(dirName); if (dated) return dated[1]; return dirName.replace(/-[0-9a-f]+$/, ""); } // Compact UTC date stamp YYMMDD. UTC (not local) so the stamp is reproducible // regardless of the clone's timezone — matches isoTimestamp's UTC basis. export function dateStamp(date: Date = new Date()): string { const yy = String(date.getUTCFullYear()).slice(-2); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); return `${yy}${mm}${dd}`; } // Build the BASE record dir name `-` (pre-collision). The // label is slugified with the tighter 24-char cap. Starts with a DIGIT — legal, // since no SLUG_RE validates the intent dir name (those guard the bolt/stage/ // artifact slugs). The collision loop appends `-2`, `-3`, … to this base. export function intentDirNameBase(label: string, date: Date = new Date()): string { return `${dateStamp(date)}-${slugify(label, 24)}`; } // Resolve a within-space dir clash by appending a numeric counter: ``, // `-2`, `-3`, … (the date prefix has no hex tail to extend, unlike the // pre-spike scheme). Two intents created on the same day with the same short // label are the only collision case; the counter keeps the readable name AND // uniqueness, and // the canonical id is still the row's UUIDv7. Returns the first free name. // // Bounded by MAX_DIR_COLLISIONS: 998 same-day same-label intents is not a real // workflow - it is a bug or a pathological caller (e.g. a script creating intents in a // loop with a constant label). Fail LOUD with a diagnostic rather than spin, so // the cause surfaces. Safe to throw here: the caller holds the workspace lock via // withAuditLock, which releases in its `finally` (and an on-exit net), so the // throw unwinds without leaking the lock. export function resolveUniqueIntentDir(intentsRoot: string, base: string): string { if (!existsSync(join(intentsRoot, base))) return base; const MAX_DIR_COLLISIONS = 1000; for (let n = 2; n < MAX_DIR_COLLISIONS; n++) { const candidate = `${base}-${n}`; if (!existsSync(join(intentsRoot, candidate))) return candidate; } throw new Error( `Could not find a free intent record dir for "${base}" after ${MAX_DIR_COLLISIONS} attempts in ${intentsRoot}. ` + `This many same-day intents with the same label indicates a bug or a runaway caller — pass a distinct --label.`, ); } // --- Flat-layout migration (one-time, lock-guarded, crash-safe) --------------- // // A pre-workspace project keeps its record at the flat `aidlc-docs/` root. This // moves it ONCE into a per-intent record dir under spaces/default/. Two review // blockers shaped the design (vision plan P1 migration box): // // (1) DETECTION keys on a signal SEED does NOT ship: a flat `aidlc-docs/ // aidlc-state.md` present AND no `aidlc/spaces/*/intents/*/aidlc-state.md` // record yet AND no `.migrated` marker. (SEED ships `aidlc/spaces/default/`, // so "no spaces dir" would never fire and would orphan the legacy tree.) // (2) IDEMPOTENCY keys on the `.migrated` marker ALONE (written LAST), never on // `aidlc/spaces/` existence — a crash after the parent mkdir but before the // move completes must re-detect and re-stage from the untouched original. // // MECHANISM (all inside withAuditLock on the WORKSPACE bucket): mint a UUIDv7; // slug from existing state or "default"; (1) stage a COPY of the whole aidlc-docs/ // tree into a temp dir UNDER the workspace root (same filesystem — NOT tmpdir(), // or a cross-device rename degrades to non-atomic); (2) mkdir the intent dir's // PARENT chain; (3) ONE atomic rename of the staged tree into the leaf // -/ (the leaf is created BY this rename); (4) append to intents.json // + set active-intent; (5) write the `.migrated` marker LAST. The flat tree is // git-rm'd post-move (the data MOVED, not deleted); the source is NEVER rmSync'd. // // THE ONE SURVIVING `aidlc-docs` READ. P9 removed the transitional dual-layout // fallback — the record tree is now a SINGLE per-intent layout. The ONLY place // the legacy flat `aidlc-docs/` root is still read is this one-time migration: // needsFlatMigration() probes flatStateSource() and migrateFlatLayout() moves // flatMigrationSource(). These two private helpers localise that read so the // grep gate's `aidlc-docs` allowlist in core code is exactly this constant. const FLAT_MIGRATION_ROOT = "aidlc-docs"; function flatMigrationSource(projectDir: string): string { return join(projectDir, FLAT_MIGRATION_ROOT); } function flatStateSource(projectDir: string): string { return join(flatMigrationSource(projectDir), "aidlc-state.md"); } export const MIGRATED_MARKER = ".migrated"; // The marker path: `aidlc/.migrated` (workspace-level, committed, idempotency key). export function migratedMarkerPath(projectDir: string): string { return join(workspaceRoot(projectDir), MIGRATED_MARKER); } // Does this project need a flat→per-intent migration? Detection per blocker (1). export function needsFlatMigration(projectDir: string): boolean { // Marker present → already migrated (idempotency key, blocker 2). if (existsSync(migratedMarkerPath(projectDir))) return false; // No flat state → nothing to migrate (a fresh SEED shell, or already moved). // This is the migration DETECTION trigger — the sole legitimate read of the // legacy flat state path (allowlisted in the grep gate). const flatState = flatStateSource(projectDir); if (!existsSync(flatState)) return false; // Any new-layout intent RECORD already present → migration ran (or a fresh // created intent exists); do not move a second tree on top of it. if (anyIntentRecordExists(projectDir)) return false; return true; } // True iff any space already holds an intent record (a `/aidlc-state.md`). // Scans aidlc/spaces/*/intents/*/aidlc-state.md WITHOUT relying on the registry. export function anyIntentRecordExists(projectDir: string): boolean { const spacesRoot = join(workspaceRoot(projectDir), "spaces"); let spaces: string[]; try { spaces = readdirSync(spacesRoot); } catch { return false; } for (const sp of spaces) { if (listIntentDirs(projectDir, sp).length > 0) return true; } return false; } // Append an intent to the space's intents.json registry (creating it if absent). // MUST be called under the WORKSPACE lock bucket (invariant 2) — the registry is // shared workspace-level truth. Each row: {uuid, slug, scope, repos, status}. export interface IntentRegistryEntry { uuid: string; slug: string; // The on-disk record dir name. SPIKE (date-prefix): stored verbatim at creation so // readers join a row to its dir DIRECTLY, never reconstructing it from slug+uuid // (the date-prefixed name `-