// Stage-graph library + CLI. Exports the 8-function API consumed by // the doctor collector (see aidlc-utility.ts collectDoctorReport) and the // runtime resolution layer (lib.ts's nextInScopeStage, // firstInScopeStageOfPhase, stagesInScope delegate here via lazy // require). // // Architectural model (see docs/reference/15-stage-definition.md): // - The graph is structural truth: 31 stage definitions + every // requires_stage / produces / consumes edge they declare = the // complete DAG. Compiled from YAML into stage-graph.json. // - A scope is a sub-DAG: scope-mapping.json's EXECUTE slice + // whichever requires_stage edges exist among those nodes. // - The serial runtime linearizes each sub-DAG to numeric order for // iteration. Numeric order is a valid topological sort of the full // graph (proven by t65 assertion 17; protected by compile's // edge-local invariant check below). The future worktree scheduler // will consume the sub-DAG structure directly for parallel Bolts. // - topoSort and findCycles exist in the library for analysis // (doctor consumes them) and for future scheduling; they do not // gate runtime iteration today. // // Compile is the YAML -> JSON transform. In an installed runtime it preserves // number + name from the existing stage-graph.json so composed plugin rows stay // pinned. A clean source package has no existing graph: core numbers derive // from requires_stage order, and display names come from authored `name:` // overrides or title-cased slugs. Numbers are ALWAYS assigned by the engine, // never claimed by authors — a plugin's authored `number:` is a // relative-ordering hint among its own new stages, its absolute value never // used, so uncoordinated plugins cannot collide. // // A NEW stage slug (a .md on disk with no row in stage-graph.json yet) is // seeded on compile rather than rejected: each phase's batch of new // stages is ordered by its own requires_stage edges (Kahn's algorithm; // ties among independent stages break by the authored `number:` hint, // then slug), then assigned next-free contiguous indices // (`.` onward); name comes // from authored `name:`, defaulting to the title-cased slug. Both are // written into the regenerated JSON. Installed compiles then preserve those // pinned rows. A core author who needs a hand-tuned display name writes `name:` // in stage frontmatter. (Seeding only ever ADDS rows, it never renumbers a // stage that already has a row, so an in-flight workflow's slug-keyed state is // safe.) // // See docs/reference/16-artifact-vocabulary.md for artifact naming. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { aidlcToolInvocation, resolveDistributionPath, resolveHarnessPath, runtimeProjectDir, } from "./aidlc-runtime-paths.ts"; import { _resetAgentsForTests, _resetHarnessDataForTests, _resetScopeMappingForTests, _resetStageGraphForTests, auditLockOwnedByProcess, type AgentMetadata, errorMessage, gridCostSummary, loadAgents, loadScopeMapping, loadScopeMetadata, loadScopeMetadataAll, harnessDir, PHASES, type Phase, loadStageGraph, loadStageGraphAll, pluginsEnabled, type ScopeCostSummary, mustGet, mustPop, mustShift, parseStageFrontmatter, planFilePath, CHANGE_CONTROL_VALUES, type ChangeControl, changeControlMemoryStrictRefusal, memoryChangeControlDeclarations, parseChangeControl, resolveProjectDir, resolveWorkflowSelection, type ScopeDefinition, type StageEntry, stageEnabledBySelection, toPosix, validScopes, withAuditLock, writeFileAtomic, } from "./aidlc-lib.ts"; import { parseRuleFrontmatter, type RuleFrontmatter, validateRuleFrontmatter, } from "./aidlc-rule-schema.ts"; import { parseSensorManifest, type SensorManifest, validateSensorManifest, } from "./aidlc-sensor-schema.ts"; import { type StageFrontmatter, validateStageFrontmatter } from "./aidlc-stage-schema.ts"; // --- Types --- export interface Consume { artifact: string; required: boolean; conditional_on?: "brownfield" | "greenfield"; } // Per-rule resolution row baked into each stage's rules_in_context. // Shape is intentionally minimal — `{path, scope}` only. The strict-additive // runtime model carries no `enforcement` field: every applicable rule is // concatenated and ALL apply at runtime; conflicts are rejected at // admission gates (practices-discovery, memory gate) before they reach // the resolver, not by runtime drop logic. export interface RuleResolution { path: string; scope: "org" | "team" | "project" | "phase"; } // Per-sensor resolution row baked into each stage's sensors_applicable. // Pull authoring: the stage's frontmatter `sensors: []` declares the // import; the resolver looks the manifest up by id and copies its // dispatch policy and capability metadata verbatim. matches is omitted when // the manifest declares no path filter. Runtime dispatchers read this // snapshotted binding off the graph node — never re-open the manifest. export interface SensorResolution { id: string; path: string; fire_on: "write" | "gate"; default_severity: "advisory" | "blocking"; category?: string; matches?: string; } // Authoritative graph stage shape — fully-populated, no optionals // except for genuinely-optional-per-spec fields (condition, for_each). // StageEntry in lib.ts carries the same fields as optional so existing // runtime callers stay source-compatible without caring about the // extended shape. export interface GraphStage extends StageEntry { plugin?: string; enabled?: false; condition?: string; produces: string[]; // optional_produces - artifacts the stage MAY write per unit (marked // CONDITIONAL in the stage body). Genuinely optional per spec, like // for_each: only annotated stages carry it. Exempt from the per-unit // coverage check in aidlc-orchestrate.ts unitCovered, but still resolved // into the run-stage directive's produces paths and unioned into the // artifact registry / producersOf lookups. optional_produces?: string[]; // produces_kinds - per-kind applicability map (artifact name to unit kinds). // Lives on stage YAML, round-trips through parse/emit, and compiles into // stage-graph.json. The engine's produces filter reads it to prune the // per-unit construction matrix; an unlisted artifact applies to all kinds. produces_kinds?: Record; consumes: Consume[]; requires_stage: string[]; // sensors is the stage-side pull import — a list of sensor manifest // ids. Optional because most stages declare empty (initialization) or // some subset; the resolver treats absent and `[]` identically. Lives // on stage YAML and round-trips through parse/emit; sensors_applicable // is the resolved view. sensors?: string[]; // scopes is the stage-side scope-membership list — the transpose of the // legacy scope-mapping.json EXECUTE/SKIP matrix onto stages. A scope name // present here marks this stage EXECUTE under that scope. Optional because // a fixture stage may declare none; resolver treats absent and `[]` // identically. Lives on stage YAML, round-trips through parse/emit, and is // transposed into the compiled grid (scope-grid.json) at compile time. scopes?: string[]; inputs: string; outputs: string; for_each?: string; // rules_in_context is REQUIRED — never undefined. The resolver always // assigns an array (org+team+project minimum on populated workspaces; // [] only when .claude/rules/ is empty). Lives only on the in-memory // GraphStage and the compiled stage-graph.json — NOT on stage YAML. // (validateStageFrontmatter at aidlc-stage-schema.ts rejects unknown // stage YAML keys; introducing rules_in_context to stage frontmatter // would trip that guard.) rules_in_context: RuleResolution[]; // sensors_applicable is REQUIRED — assigned [] when stage.sensors is // absent/empty. Same compile-baked discipline as rules_in_context. sensors_applicable: SensorResolution[]; // reviewer — the agent to invoke as a quality gate after the stage body. // Absent when no review step is configured. Parsed from stage frontmatter // `reviewer:` field and carried through to the run-stage directive. reviewer?: string; // Required Markdown output that owns the appended reviewer section. review_artifact?: string; // reviewer_max_iterations — review cycle cap before escalating to human. // Defaults to 2 when reviewer is present. reviewer_max_iterations?: number; // review_class — how the review runs: "adversarial" (refute + fix loop up // to the cap, §12a classic) or "advisory" (single pass, findings quoted at // the human gate, no fix loop). Defaults to "adversarial" when a reviewer // is present (the pre-class behavior). Absent when no reviewer. The // EFFECTIVE class at runtime may be lowered by the scope's review_cap or a // run override — resolveReviewClass in aidlc-lib.ts owns that resolution. review_class?: "adversarial" | "advisory"; // Deterministic pre-generation consolidated-summary checkpoint policy. summary_confirmation?: "required" | "if-present"; } export interface ScopeValidation { valid: boolean; errors: string[]; advisories: string[]; // The deterministic ceremony count of the validated grid (stage/gate/per-unit // counts). The composer copies this into its proposal verbatim so the gate the // human sees leads with numbers the validator computed, not an LLM recount. summary?: ScopeCostSummary; // Graph/plugin-authored stock scopes ranked by grid distance from the // validated proposal; composer-authored entries are excluded. A front/report // matched-vs-custom verdict routes on nearest_stock[0].diff (match when <= 2 // and depth is compatible), so the routing is the final validator's number, // not an LLM recount or the earlier mechanical screen. In-flight treats the // ranking as advisory and preserves the running plan. nearest_stock?: Array<{ scope: string; diff: number; differs: string[] }>; // The Change Control value the proposal carried (`--change-control` or the // proposal's `changeControl` member), echoed once validated so the gate row // the human sees is the validator's word. change_control?: ChangeControl; } // --- Module-local state --- const __FILE_DIR = dirname(fileURLToPath(import.meta.url)); function resolveDataDir(): string { return resolveHarnessPath(["tools", "data"]); } function mutableDataDir(projectDir: string): string { return resolveHarnessPath(["tools", "data"], { mutable: true, projectDir }); } function requireInstalledHarness(projectDir: string): void { const installedLib = resolveHarnessPath( ["tools", "aidlc-lib.ts"], { mutable: true, projectDir }, ); if (!existsSync(installedLib)) { throw new Error( `compile requires an installed project harness at ${dirname(dirname(installedLib))}`, ); } } /** Resolve the stages directory. AIDLC_STAGES_DIR env-var seam mirrors * AIDLC_RULES_DIR + AIDLC_SENSORS_DIR so t89's fixture-driven import * tests can isolate from the real stages tree (e.g., zero-sensors * scenarios where no stage may declare any imports). Evaluated at call * time. */ function stagesDir(): string { return process.env.AIDLC_STAGES_DIR ?? resolveHarnessPath(["aidlc-common", "stages"]); } /** Resolve the stage-graph.json path. Mirrors lib.ts:loadStageGraph()'s * AIDLC_STAGE_GRAPH env-var seam (lib.ts:295-296) so tests can point both * loader and compile-check at a temp file. Evaluated at call time so tests * that set/unset the env mid-process see the change. */ function stageGraphPath(): string { return process.env.AIDLC_STAGE_GRAPH ?? join(resolveDataDir(), "stage-graph.json"); } function mutableStageGraphPath(projectDir: string): string { return process.env.AIDLC_STAGE_GRAPH ?? join(mutableDataDir(projectDir), "stage-graph.json"); } // The relocated method ("memory") is harness-neutral and lives at the // WORKSPACE ROOT under aidlc/spaces//memory/, NOT inside the harness // dir — one hand-editable copy, read by every harness via its own native // include (Claude @-stub, Kiro resources glob, Codex AGENTS.md/@-mention). // `default` is the always-present space and the zero-cursor fallback. // // Two resolution families share these segments: // • The COMPILE/DISPLAY family — rulesDir()/memoryDisplayPath() — stays pinned // to `default`. rules_in_context is frozen into stage-graph.json at PACKAGE // time (compileStageGraph) pointed at default; it is a list of display PATHS, // not rule content, so it is correct to ship default-pinned and is never // re-resolved at runtime. AIDLC_RULES_DIR still overrides rulesDir() outright. // • The PROJECT family — memoryDirFor()/memoryTemplatesDir() — FOLLOWS the // active-space cursor. These feed the learnings/practices WRITERS and the // templates sensor — the load-bearing channel for a non-default space — so a // learning promoted while active-space=teamB lands under teamB/memory, and // the templates sensor reads teamB's templates. A cursorless resolve still // yields `default` (activeSpace() falls back to DEFAULT_SPACE). const MEMORY_SPACE = "default"; const MEMORY_SEGMENTS = ["aidlc", "spaces", MEMORY_SPACE, "memory"] as const; /** Method ("memory") path segments for an explicit space — the active-space * analog of the default-pinned MEMORY_SEGMENTS. Keeps the `aidlc/spaces// * memory` shape in one place so the project-family resolvers can never drift * from the compile/display family's layout. */ function memorySegmentsForSpace(space: string): string[] { return ["aidlc", "spaces", space, "memory"]; } /** Resolve the method ("memory") directory — the single source of truth for * the layered practices (org/team/project + phases/). AIDLC_RULES_DIR env-var * seam mirrors AIDLC_STAGE_GRAPH so t88's fixture-driven inheritance tests can * isolate from the real tree. Evaluated at call time. Ladder: env seam → * project-dir workspace root → this tool's location (//tools/ → * up two to the workspace root; module-relative, so a dev checkout or an * installed tree resolves without env) → the executable's packaged * distribution (compiled binary outside any install). */ function rulesDir(): string { if (process.env.AIDLC_RULES_DIR) return process.env.AIDLC_RULES_DIR; const projectRules = join(runtimeProjectDir(), ...MEMORY_SEGMENTS); if (existsSync(projectRules)) return projectRules; const moduleRules = join(__FILE_DIR, "..", "..", ...MEMORY_SEGMENTS); if (existsSync(moduleRules)) return moduleRules; return resolveDistributionPath(MEMORY_SEGMENTS); } /** The harness-neutral DISPLAY path baked into each RuleResolution — the * workspace-relative location of a method file (e.g. "aidlc/spaces/default/ * memory/org.md"). Replaces the old per-harness "//" * display form: the method now lives at the neutral aidlc/ roof, identical on * every harness, so the baked path is harness-neutral too. `rel` is the file's * sub-path under memory/ (e.g. "org.md" or "phases/construction.md"). */ function memoryDisplayPath(rel: string): string { return toPosix(join(...MEMORY_SEGMENTS, rel)); } /** The method ("memory") directory under a given workspace root: * `/aidlc/spaces//memory`. FOLLOWS the active-space cursor — * `space` defaults to `activeSpace(projectDir)` (which itself falls back to * `default` when no cursor is set), mirroring the `space?`/`?? activeSpace` * shape of codekbDir()/knowledgeDir()/intentsDir() in aidlc-lib.ts. So the * learnings/practices writers that resolve through here land under the active * space, while a cursorless resolve still yields `default`. The path layout * stays byte-aligned with the packager's emit and the native includes via * `memorySegmentsForSpace`. (The TPL templates dir is this + "templates"; see * `memoryTemplatesDir`.) */ export function memoryDirFor(projectDir: string, space?: string): string { const resolvedSpace = space ?? resolveWorkflowSelection(projectDir).space; return join(projectDir, ...memorySegmentsForSpace(resolvedSpace)); } /** The TPL template-override source-of-truth dir for a workspace: * `/aidlc/spaces//memory/templates` — where SEED ships the * `templates/` floor and a team drops `.md` overrides. Used by the * `required-sections` sensor dispatcher as the default `--templates-dir`. Like * `memoryDirFor`, FOLLOWS the active-space cursor (defaults to * `activeSpace(projectDir)`, cursorless → `default`) so a team in space teamB * gets teamB's templates. Kept here (not hardcoded in the dispatcher) so it * stays byte-aligned with where the packager emits and the resolver reads. */ export function memoryTemplatesDir(projectDir: string, space?: string): string { const resolvedSpace = space ?? resolveWorkflowSelection(projectDir).space; return join(projectDir, ...memorySegmentsForSpace(resolvedSpace), "templates"); } /** The FRAMEWORK-DEFAULT templates dir — the read-only, engine-shipped middle * tier of the §10 templates resolution order (team override → framework default * → generic floor). Ships at `/tools/data/templates/` beside the * compiled data, resolved relative to THIS tool's location (like DATA_DIR), so * it is harness-correct and space-INDEPENDENT (a framework default is the same * for every space — it's the baseline a team optionally overrides per-space via * `memoryTemplatesDir`). The framework ships zero default files at GA, so this * dir resolves but holds only a marker → the sensor's middle branch misses and * falls through to the floor. AIDLC_FRAMEWORK_TEMPLATES_DIR is a test/relocation * seam mirroring AIDLC_TEMPLATES_DIR. */ export function frameworkTemplatesDir(): string { return process.env.AIDLC_FRAMEWORK_TEMPLATES_DIR ?? join(resolveDataDir(), "templates"); } /** Engine-only-install self-heal: the ENGINE-BUNDLED method ("memory") seed — the * core/memory/ tree copied INSIDE the engine at /tools/data/memory-seed/ * by the packager (mirrors frameworkTemplatesDir's tools/data/templates). It * exists so an ENGINE-ONLY install (a user who copies only the harness engine * dir, NOT the sibling aidlc/ workspace shell) can self-heal: the first /aidlc * copies this OUT to aidlc/spaces/default/memory/ via ensureWorkspaceDirs IF that * default tree is absent. Resolved relative to THIS tool's location (DATA_DIR), * like frameworkTemplatesDir, so it is harness-correct on every harness. * AIDLC_MEMORY_SEED_DIR is a test/relocation seam mirroring AIDLC_FRAMEWORK_TEMPLATES_DIR. */ export function frameworkMemorySeedDir(): string { return process.env.AIDLC_MEMORY_SEED_DIR ?? join(resolveDataDir(), "memory-seed"); } /** Resolve the sensors directory. AIDLC_SENSORS_DIR env-var seam mirrors * AIDLC_RULES_DIR so t89's fixture-driven import tests can isolate from * the real .claude/sensors/ tree. Evaluated at call time. */ function sensorsDir(): string { return process.env.AIDLC_SENSORS_DIR ?? resolveHarnessPath(["sensors"]); } /** Resolve the compiled scope-grid.json path. Mirrors stageGraphPath()'s * AIDLC_STAGE_GRAPH seam: AIDLC_SCOPE_GRID lets the parity/transpose tests * point `compile --check` at a tempfile without touching the real grid. * Evaluated at call time so tests that set/unset mid-process see it. */ function scopeGridPath(): string { return process.env.AIDLC_SCOPE_GRID ?? join(resolveDataDir(), "scope-grid.json"); } function mutableScopeGridPath(projectDir: string): string { return process.env.AIDLC_SCOPE_GRID ?? join(mutableDataDir(projectDir), "scope-grid.json"); } let _graph: GraphStage[] | null = null; let _artifactsRegistry: ReadonlySet | null = null; let _scopeGrid: ScopeGrid | null = null; /** Reset all module-level caches. Test-only — used when fixture * injection via AIDLC_STAGE_GRAPH swaps the backing file mid-process. * Also resets lib.ts's scope-mapping and agent caches (AIDLC_SCOPE_MAPPING / * AIDLC_AGENTS_DIR env-seams) because compile/export consumers read them in * the same call; resetting only the local cache leaves stale fixture views. */ export function __resetGraphCache(): void { _graph = null; _artifactsRegistry = null; _scopeGrid = null; _resetHarnessDataForTests(); _resetStageGraphForTests(); _resetAgentsForTests(); _resetScopeMappingForTests(); } /** Load the compiled scope-grid.json (the transpose). Cached. The grid is * the runtime source of truth for EXECUTE/SKIP per scope after the * scope-mapping.json source-of-truth is retired — subgraphForScope and * lib.ts's loadScopeMapping() both read it. Falls back to recompiling * from stage YAML when the file is absent (e.g. a fresh fixture tree) * so callers never see a hard ENOENT for a derivable artifact. */ export function loadScopeGrid(): ScopeGrid { if (_scopeGrid !== null) return _scopeGrid; // When the AIDLC_SCOPE_MAPPING JSON-fixture seam is active, the grid must // come from that SAME fixture's `.stages` slices, not the real compiled // grid — otherwise the injected scope set (validScopes) and the grid // diverge. loadScopeMapping() already reads the fixture under the seam, so // project its `.stages` into the grid shape. if (process.env.AIDLC_SCOPE_MAPPING) { const mapping = loadScopeMapping(); const grid: ScopeGrid = {}; for (const [name, def] of Object.entries(mapping)) { grid[name] = { stages: def.stages }; } _scopeGrid = grid; return _scopeGrid; } const p = scopeGridPath(); try { _scopeGrid = JSON.parse(readFileSync(p, "utf-8")) as ScopeGrid; } catch { // Derive on the fly from the loaded graph when no compiled grid exists. _scopeGrid = transposeScopeGrid(loadGraph()); } return _scopeGrid; } // --- Field-order pin for canonical JSON emission --- const FIELD_ORDER = [ "slug", "number", "name", "plugin", "enabled", "phase", "execution", "condition", "lead_agent", "support_agents", "mode", "for_each", "workspace_requires", "produces", "optional_produces", "produces_kinds", "consumes", "requires_stage", "sensors", "scopes", "reviewer", "review_artifact", "reviewer_max_iterations", "review_class", "summary_confirmation", "inputs", "outputs", "rules_in_context", "sensors_applicable", ] as const; // --- Rule resolution --- // // Strict-additive runtime model: every applicable rule is concatenated // into rules_in_context. No drop logic, no overrides, no enforcement // keyword. Conflicts (narrower contradicting broader policy) are // rejected at admission gates (practices-discovery, memory gate) by // section-level LLM check before content reaches the resolver. // // Per-stage chain: org → team → project → phase. Pull authoring puts // the phase→stage relationship on the stage's existing `phase:` // declaration; the resolver attaches the matching aidlc-phase-.md // file with no rule-side glob filter. A confirmed learning is a PRACTICE // (vision §6): the §13 gate appends it under a topical heading in // team.md / project.md directly — there is no parallel `*-learnings.md` // surface and no fractional override tier. export interface RuleFile { path: string; // "aidlc/spaces/default/memory/org.md" scope: "org" | "team" | "project" | "phase"; phase?: string; // populated only when scope === "phase" frontmatter: RuleFrontmatter; // `## ` -> concatenated body text, surfaced from the same `raw` // loadRules() already reads. The doctor rule-drift check reads this // directly (single walking surface) instead of re-reading from `path` // (a relative DISPLAY path that would miss the AIDLC_RULES_DIR fixture). headings: Map; } // Filename anchors for the relocated method tree (aidlc/memory/). The layered // practice files are top-level (org/team/project, plain neutral names — no // `aidlc-` prefix now that they live under the neutral aidlc/ roof); the // phase-scoped files are nested under phases/.md. A confirmed learning // is a practice (vision §6) — it lands in team.md / project.md directly, so // there is no `*-learnings.md` slot and no fractional override tier. Anything // not matching is silently ignored — including user-extension overlays like // `team-overrides.md`, per 08-rule-system.md. const RULE_FILE_REGEX = /^(org|team|project)\.md$/; // Phase rule files live in phases/.md (the flat aidlc-phase-.md // scheme moved under a nested phases/ dir in the aidlc/memory/ relocation). const PHASE_RULES_SUBDIR = "phases"; const PHASE_FILE_REGEX = /^([a-z][a-z0-9-]*)\.md$/; // Scope-priority for the deterministic sort — the resolved chain reads // org → team → project → phase (a clean four-layer additive chain). const SCOPE_PRIORITY: Record = { "org": 0, "team": 1, "project": 2, "phase": 3, }; /** Split a rule-file body into `## ` -> concatenated body text. * Skips fenced code blocks (```), blockquote lines (>), and HTML comment * lines — both single-line (``) AND multi-line (`` across lines, tracked by an `inComment` flag). The multi-line * flag is the difference from parseMemoryHeadings (lib.ts), which only * skips single-line comments; rule files (e.g. aidlc-org.md's * `## Corrections`) carry multi-line comment blocks whose interior lines * would otherwise count as body and produce false drift candidates. * Private — surfaced to the doctor rule-drift check via RuleFile.headings. */ function parseRuleHeadings(raw: string): Map { const out = new Map(); const normalized = raw.replace(/^/, "").replace(/\r\n/g, "\n"); const lines = normalized.split("\n"); let current: string | null = null; let inFence = false; let inComment = false; for (const line of lines) { if (/^```/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const trimmed = line.trim(); // Multi-line HTML comment tracking. A line can both open and close a // comment (single-line ``) — that is skipped by the body // filter below. A line that opens without closing flips inComment; // the closing line flips it back. Interior lines never count as body. if (inComment) { if (trimmed.includes("-->")) inComment = false; continue; } if (trimmed.startsWith("")) { inComment = true; continue; } if (/^## /.test(line)) { current = line.slice(3).trim(); if (!out.has(current)) out.set(current, ""); continue; } if (current === null) continue; if (trimmed === "") continue; if (/^>/.test(trimmed)) continue; if (/^\s*$/.test(trimmed)) continue; const prior = out.get(current) ?? ""; out.set(current, prior === "" ? trimmed : `${prior}\n${trimmed}`); } return out; } /** Walk the rules directory and return parsed + validated rule files in * precedence order. Public — the future doctor rule-drift check imports * this same walker (single walking surface, no parser duplication). * Tolerates a missing rules dir (returns []) so the zero-rules edge * case stays clean. */ export function loadRules(): RuleFile[] { const dir = rulesDir(); if (!existsSync(dir)) return []; // Each candidate: the absolute on-disk path to read, the display sub-path // (relative to aidlc/memory/, e.g. "org.md" or "phases/construction.md") // baked into the RuleResolution, the resolved scope, and the phase name when // scope === "phase". The method tree is shallow: top-level layered files plus // one nested phases/ dir, so the walk is two explicit reads (no recursion). type Candidate = { rel: string; filePath: string; scope: RuleFile["scope"]; phase?: string; }; const candidates: Candidate[] = []; // 1. Top-level layered files: org/team/project (the neutral practice files). for (const f of readdirSync(dir)) { const m = f.match(RULE_FILE_REGEX); if (!m) continue; const scopeKey = m[1]; if (scopeKey !== "org" && scopeKey !== "team" && scopeKey !== "project") { continue; // unreachable given the regex, but keep the guard explicit } candidates.push({ rel: f, filePath: join(dir, f), scope: scopeKey }); } // 2. Phase-scoped files nested under phases/.md. const phasesDir = join(dir, PHASE_RULES_SUBDIR); if (existsSync(phasesDir)) { for (const f of readdirSync(phasesDir)) { const m = f.match(PHASE_FILE_REGEX); if (!m) continue; candidates.push({ rel: toPosix(join(PHASE_RULES_SUBDIR, f)), filePath: join(phasesDir, f), scope: "phase", phase: m[1], }); } } const matched: RuleFile[] = []; for (const c of candidates) { const raw = readFileSync(c.filePath, "utf-8"); const fm = parseRuleFrontmatter(raw); validateRuleFrontmatter(fm, c.filePath); const headings = parseRuleHeadings(raw); matched.push({ path: memoryDisplayPath(c.rel), scope: c.scope, phase: c.phase, frontmatter: fm, headings, }); } // Deterministic sort: (scope-priority, filename). readdirSync is // filesystem-order; non-portable. The sort is the determinism contract // that t66's canonical-emitter pin and `--check` rely on. matched.sort((a, b) => { const pri = SCOPE_PRIORITY[a.scope] - SCOPE_PRIORITY[b.scope]; if (pri !== 0) return pri; return a.path.localeCompare(b.path); }); return matched; } /** Build the strict-additive per-stage chain. Every applicable rule is * included; nothing drops. Length 3 (org+team+project) when no phase * rule applies, 4 (org+team+project+phase) when the stage's * `phase: ` matches a phase-rule filename. Length 0 only when * the rules directory is empty. * * Pull authoring: org/team/project attach by filename to every stage * (universal-default tier); the matching phase rule attaches because * the stage already declared `phase: ` in its frontmatter — that * declaration is the pull import. No glob filter on the rule side. */ export function resolveRulesForStage( stage: GraphStage, rules: RuleFile[], ): RuleResolution[] { const out: RuleResolution[] = []; for (const r of rules) { if (r.scope === "org" || r.scope === "team" || r.scope === "project") { out.push({ path: r.path, scope: r.scope }); } else if (r.scope === "phase" && r.phase === stage.phase) { out.push({ path: r.path, scope: r.scope }); } } return out; } // --- Sensor resolution --- // // Pull authoring: each stage's frontmatter `sensors: []` declares // the manifests that fire when an agent writes a stage output. The // resolver indexes .claude/sensors/ by id, looks each declared import // up, and copies the manifest's `matches` filter verbatim into // sensors_applicable. Unknown ids fail loud at compile — not silently // at fire time. matches is compile-snapshotted, never re-read by the // PostToolUse hook (preserves the BGP-stability invariant for in-flight // workflows). export interface SensorFile { id: string; path: string; // ".claude/sensors/aidlc-.md" manifest: SensorManifest; } // Filename anchor — sensor manifests live at `.claude/sensors/aidlc-.md`. // Anything not matching the prefix is silently ignored (mirrors loadRules). const SENSOR_FILE_REGEX = /^aidlc-([a-z][a-z0-9-]*)\.md$/; /** Walk the sensors directory and return a Map keyed by manifest id for * O(1) lookup at resolution time. Public — future doctor sensor-drift * check imports this same walker (single walking surface, no parser * duplication). Tolerates a missing sensors dir (returns empty Map) so * the zero-sensors edge case stays clean. Throws on duplicate ids. * readdirSync is filesystem-order; non-portable across macOS/Linux. The * sort is the determinism contract the canonical JSON emitter relies * on (mirrors loadRules above). */ export function loadSensors(): Map { const dir = sensorsDir(); const out = new Map(); if (!existsSync(dir)) return out; for (const f of readdirSync(dir).sort()) { const m = f.match(SENSOR_FILE_REGEX); if (!m) continue; const filenameId = m[1]; const filePath = join(dir, f); const raw = readFileSync(filePath, "utf-8"); let manifest: SensorManifest; try { manifest = parseSensorManifest(raw); } catch (err) { throw new Error(`${filePath}: ${errorMessage(err)}`); } // Duplicate-id check before full validation so two manifests claiming // the same id surface the duplicate error, not a downstream // id↔filename mismatch on the second file. The check uses the parsed // id (which the schema later cross-validates against the filename). if (typeof manifest.id === "string" && out.has(manifest.id)) { const previous = mustGet(out, manifest.id, "sensor-manifest dup"); throw new Error( `${filePath}: duplicate sensor id "${manifest.id}" — also declared ` + `in ${previous.path}. Rename one of them.`, ); } validateSensorManifest(manifest, filePath, filenameId); out.set(manifest.id, { id: manifest.id, path: toPosix(join(harnessDir(), "sensors", f)), manifest, }); } return out; } /** Resolve a stage's `sensors:` imports against the manifest registry. * Throws when an imported id has no matching manifest — authoring * errors fail loud at compile, not silently at fire time. Preserves * declared import order (deterministic emission for the JSON pin). */ export function resolveSensorsForStage( stage: GraphStage, sensorsById: Map, ): SensorResolution[] { const out: SensorResolution[] = []; const ids = stage.sensors ?? []; for (const id of ids) { const sensor = sensorsById.get(id); if (!sensor) { const known = [...sensorsById.keys()].sort().join(", ") || "(none)"; throw new Error( `Stage "${stage.slug}" imports unknown sensor id "${id}". ` + `Known ids: ${known}`, ); } const entry: SensorResolution = { id: sensor.id, path: sensor.path, fire_on: sensor.manifest.fire_on, default_severity: sensor.manifest.default_severity, }; if (sensor.manifest.category !== undefined) { entry.category = sensor.manifest.category; } if (sensor.manifest.matches !== undefined) { entry.matches = sensor.manifest.matches; } out.push(entry); } return out; } // --- Library API (8 functions) --- // rules_in_context is populated by compileStageGraph; downstream // consumers (dispatcher, doctor) read pre-resolved arrays off graph // nodes — no runtime walks of .claude/rules/. /** Load the compiled graph (cached). Reads stage-graph.json via * lib.ts's loadStageGraph(). Caller must NOT mutate the returned array. * StageEntry and GraphStage are structurally compatible (GraphStage * extends StageEntry's runtime shape); the validateStageFrontmatter * pass at compile time has populated the extended fields. */ export function loadGraph(): GraphStage[] { if (!_graph) { // Single trust-boundary cast: stage-graph.json was emitted by // canonicalStageGraphJson, which writes only fields declared on // GraphStage. The narrowing happens at compile, not at load. // type-coverage:ignore-next-line _graph = loadStageGraph() as GraphStage[]; } return _graph; } /** Stages that produce the given artifact. Empty array = orphan * consumer candidate (doctor surfaces). Unions produces and * optional_produces so a conditionally-produced artifact still resolves to * its producer stage. */ export function producersOf(artifact: string): GraphStage[] { return loadGraph().filter( (s) => (s.produces ?? []).includes(artifact) || (s.optional_produces ?? []).includes(artifact) ); } /** Stages that consume the given artifact. */ export function consumersOf(artifact: string): GraphStage[] { return loadGraph().filter((s) => (s.consumes ?? []).some((c) => c.artifact === artifact) ); } /** Consumed artifacts with more than one loaded producer. Runtime resolution * selects the first producer by graph load order, so callers can surface this * ambiguous configuration before that implicit choice affects a workflow. */ export function consumedArtifactProducerCollisions(): { artifact: string; producers: string[]; consumers: string[]; }[] { const consumedArtifacts = [ ...new Set( loadGraph().flatMap((stage) => (stage.consumes ?? []).map((consume) => consume.artifact) ) ), ].sort(); return consumedArtifacts .map((artifact) => ({ artifact, producers: producersOf(artifact).map((stage) => stage.slug), consumers: consumersOf(artifact).map((stage) => stage.slug).sort(), })) .filter(({ producers }) => producers.length >= 2); } /** TPL — the subset of a stage's `produces[]` eligible for a template * override. The template-override layer keys a template off the * output-filename stem (artifact X → X.md, per resolveArtifactPath's * `<...>/${name}.md`), but that stem==artifact key is SOUND only for prose * artifacts: a `*-questions.md` Q&A file or a `*-timestamp.md` marker is * intentionally not a ≥2-H2 doc, so applying a heading-set template to it * would yield spurious missing-section findings. The per-sensor * required-sections script gets only --stage/--output-path and so cannot know * the stage's artifact set — the dispatcher (aidlc-sensor.ts) and the * PostToolUse fire hook (aidlc-run-sensors.ts) both hold the GraphStage and * thread this filtered set so a resolved template applies ONLY to a * declared-prose artifact. Lives here so both invocation sites derive it * identically without importing the dispatcher (whose top-level main() would * run on import). */ export function templateEligibleArtifacts(produces: string[]): string[] { return (produces ?? []).filter( (a) => typeof a === "string" && a.length > 0 && !a.endsWith("-questions") && !a.endsWith("-timestamp") ); } /** Topological sort of the given subset using Kahn's algorithm with * numeric-order tiebreak. Operates on arbitrary subsets: full graph, * scope sub-DAG, or synthetic test fixtures. Edges to nodes outside * the input subset are ignored. Throws on cycle. */ export function topoSort(stages: GraphStage[]): string[] { const inSet = new Set(stages.map((s) => s.slug)); // inDegree counts only edges where both ends are in the input subset. const inDegree = new Map(); for (const s of stages) inDegree.set(s.slug, 0); for (const s of stages) { for (const dep of s.requires_stage ?? []) { if (!inSet.has(dep)) continue; inDegree.set(s.slug, (inDegree.get(s.slug) ?? 0) + 1); } } // Priority queue by numeric order. Plain sort is fine at 31-node scale. const ready = stages .filter((s) => (inDegree.get(s.slug) ?? 0) === 0) .sort((a, b) => numericStageOrder(a.number, b.number)); const result: string[] = []; while (ready.length > 0) { const next = mustShift(ready, "topoSort.ready"); result.push(next.slug); for (const s of stages) { if (!(s.requires_stage ?? []).includes(next.slug)) continue; const remaining = (inDegree.get(s.slug) ?? 0) - 1; inDegree.set(s.slug, remaining); if (remaining === 0) { // Insert in numeric order. let i = 0; while ( i < ready.length && numericStageOrder(ready[i].number, s.number) < 0 ) { i++; } ready.splice(i, 0, s); } } } if (result.length !== stages.length) { throw new Error( `topoSort: cycle detected. Processed ${result.length} of ` + `${stages.length} nodes. Use findCycles() to enumerate.` ); } return result; } /** Strongly-connected components of size >= 2, plus self-loops. * Tarjan's algorithm. Works on arbitrary subsets; edges to out-of- * subset nodes ignored. */ export function findCycles(stages: GraphStage[]): string[][] { const inSet = new Set(stages.map((s) => s.slug)); const bySlug = new Map(stages.map((s) => [s.slug, s])); const index = new Map(); const lowlink = new Map(); const onStack = new Set(); const stack: string[] = []; let idx = 0; const cycles: string[][] = []; function strongconnect(v: string): void { index.set(v, idx); lowlink.set(v, idx); idx++; stack.push(v); onStack.add(v); const stage = bySlug.get(v); const deps = (stage?.requires_stage ?? []).filter((d) => inSet.has(d)); for (const w of deps) { if (!index.has(w)) { strongconnect(w); lowlink.set( v, Math.min( mustGet(lowlink, v, "Tarjan.lowlink[v]"), mustGet(lowlink, w, "Tarjan.lowlink[w]") ) ); } else if (onStack.has(w)) { lowlink.set( v, Math.min( mustGet(lowlink, v, "Tarjan.lowlink[v]"), mustGet(index, w, "Tarjan.index[w]") ) ); } } if (lowlink.get(v) === index.get(v)) { const scc: string[] = []; let w: string; do { w = mustPop(stack, "Tarjan.stack"); onStack.delete(w); scc.push(w); } while (w !== v); // Report SCCs with size >= 2 (real cycles) OR size 1 with self-loop. if (scc.length >= 2) { cycles.push(scc); } else if (scc.length === 1) { const self = scc[0]; const stageObj = bySlug.get(self); if ((stageObj?.requires_stage ?? []).includes(self)) { cycles.push([self]); } } } } for (const s of stages) { if (!index.has(s.slug)) strongconnect(s.slug); } return cycles; } /** The scope's sub-DAG as a linear array, sorted by numeric order. * Filter to scope-mapping's EXECUTE slice, then sort by number. * No topological sort at runtime — numeric order is a valid topo- * order of the full graph (proven by t65, protected by compile's * invariant) and therefore of any node subset. The future worktree * scheduler will consume the sub-DAG structure directly for * parallelism. * * Throws on unknown scope. Returns [] when scope has zero EXECUTE * entries — a legitimate edge case, e.g. a freshly-dropped * .claude/scopes/aidlc-x.md that no stage names yet (valid scope, empty * grid column). Scope validity is the .md-presence authority (validScopes), * not the grid: a scope present as a file but absent from the grid is a * zero-EXECUTE scope, not an unknown one. */ export function subgraphForScope(scope: string): GraphStage[] { if (!validScopes().has(scope)) { throw new Error( `Unknown scope: "${scope}". Valid scopes: ${[...validScopes()].join(", ")}` ); } const entry = loadScopeGrid()[scope]; const executeSlugs = new Set( Object.entries(entry?.stages ?? {}) .filter(([, action]) => action === "EXECUTE") .map(([slug]) => slug) ); return loadGraph() .filter((s) => executeSlugs.has(s.slug)) .sort((a, b) => numericStageOrder(a.number, b.number)); } /** Rank every graph/plugin-authored stock scope by grid distance from the given * EXECUTE/SKIP grid: `{scope, diff, differs}` sorted by diff then name. * Composer-authored entries appended to scope-grid.json are deliberately * excluded. Distance covers the union of proposal and stock keys, so missing * proposal stages and unknown extras are differences rather than invisible * overlap. Shared by `ars` (against the complete mechanical screen grid) and * `validate-grid` (against the composer's proposal); only the latter is a * front/report stock-match authority. */ export function nearestStockScopes( grid: Record ): Array<{ scope: string; diff: number; differs: string[] }> { const stockScopeNames = stageDeclaredScopeNames(loadGraph()); return Object.entries(loadScopeGrid()) // Composer-authored scopes are appended only to scope-grid.json; no stage // declares them. They remain runnable but must never become stock-match // candidates for an unrelated later composition. .filter(([scope]) => stockScopeNames.has(scope)) .map(([scope, def]) => { const differs: string[] = []; const slugs = new Set([ ...Object.keys(def.stages), ...Object.keys(grid), ]); for (const slug of slugs) { if (grid[slug] !== def.stages[slug]) differs.push(slug); } return { scope, diff: differs.length, differs }; }) .sort((a, b) => a.diff - b.diff || a.scope.localeCompare(b.scope)); } /** Resolve a scope's plan: the EXECUTE/SKIP slice over the full graph in * numeric order, shaped `{slug, phase, action}` — byte-identical to * lib.ts's stagesInScope() / the legacy scope-mapping-derived plan. The * `aidlc-graph resolve` subcommand writes this to .aidlc-plan.json. The * parity test asserts this matches the legacy plan across all 11 scopes. */ export function resolvePlanForScope( scope: string ): Array<{ slug: string; phase: string; action: "EXECUTE" | "SKIP" }> { if (!validScopes().has(scope)) { throw new Error( `Unknown scope: "${scope}". Valid scopes: ${[...validScopes()].join(", ")}` ); } const entry = loadScopeGrid()[scope]; const stages = entry?.stages ?? {}; return loadGraph() .slice() .sort((a, b) => numericStageOrder(a.number, b.number)) .map((s) => ({ slug: s.slug, phase: s.phase, action: stages[s.slug] === "EXECUTE" ? ("EXECUTE" as const) : ("SKIP" as const), })); } /** Validate a scope's sub-DAG. Returns structured result so callers * (doctor, future CI hooks) can tier severity: * - errors: orphan consumes (artifact has no producer anywhere). * Hard graph-level bugs. * - advisories: off-path producer (artifact produced by a stage * not on this scope's path — the scope author chose * the shortcut and is responsible for the upstream * work). * * consumes[].required: false is silent (not error, not advisory — * optional consumes missing producers is a first-class valid state). * * opts.projectType filters conditional_on: brownfield/greenfield * consumes. Without projectType, conditional consumes are checked as * if they fire; advisories for scope-skipped producers still surface. * * Future home of the reserved `when:` predicate evaluation — * contributors extend opts rather than adding a new function. */ export function validateScope( scope: string, opts?: { projectType?: "brownfield" | "greenfield" } ): ScopeValidation { // Delegate to the arbitrary-grid core over the named scope's EXECUTE set. // Default (lenient) mode preserves this function's historical behavior // byte-for-byte: off-path producers advise, only a TRUE orphan errors. const subgraph = subgraphForScope(scope); // throws on unknown scope (unchanged) const grid: Record = {}; for (const s of loadGraph()) grid[s.slug] = "SKIP"; for (const s of subgraph) grid[s.slug] = "EXECUTE"; return validateGrid(grid, { ...opts, label: scope }); } /** Validate an ARBITRARY {slug -> EXECUTE|SKIP} grid - the composer's * proposal shape, not yet a named scope. Same dependency walk as * validateScope (which now delegates here), with one addition: * * opts.strict - RECOMPOSE MODE. Promotes the off-path-producer advisory * to a hard ERROR: a required consume whose producer exists in the graph * but is not on the proposed EXECUTE set REJECTS the grid instead of * advising. Plain (lenient) validation returns valid:true for that case * because a pre-composed scope's author owns the upstream work; an * IN-FLIGHT re-shape has no such author guarantee - an ADD whose producer * was SKIPped would run starved, so it must be refused, not advised. * * The TRUE-orphan hard error (no producer anywhere in the graph) applies in * BOTH modes. Unknown slugs in the grid error in both modes too - a typo'd * stage name must never pass as an implicit SKIP. * * opts.projectType filters conditional_on consumes exactly as * validateScope does. opts.label names the grid in messages (defaults to * "proposed grid"). */ export function validateGrid( grid: Record, opts?: { projectType?: "brownfield" | "greenfield"; strict?: boolean; label?: string; } ): ScopeValidation { const label = opts?.label ?? "proposed grid"; const graph = loadGraph(); const knownSlugs = new Set(graph.map((s) => s.slug)); const errors: string[] = []; const advisories: string[] = []; // Reject unknown slugs up front (a typo silently treated as SKIP would // validate a different plan than the one proposed). for (const slug of Object.keys(grid)) { if (!knownSlugs.has(slug)) { errors.push( `Grid names unknown stage "${slug}" - not in the compiled stage graph.` ); } const action = grid[slug]; if (action !== "EXECUTE" && action !== "SKIP") { errors.push( `Grid entry "${slug}" has invalid action "${action}" (expected EXECUTE or SKIP).` ); } } const missingSlugs = graph .map((stage) => stage.slug) .filter((slug) => !(slug in grid)); if (missingSlugs.length > 0) { errors.push( `Grid is missing ${missingSlugs.length} compiled stage entr${missingSlugs.length === 1 ? "y" : "ies"}: ` + `${missingSlugs.join(", ")}. Every compiled stage must be explicitly EXECUTE or SKIP.`, ); } const onPath = new Set( Object.entries(grid) .filter(([slug, action]) => action === "EXECUTE" && knownSlugs.has(slug)) .map(([slug]) => slug) ); const subgraph = graph .filter((s) => onPath.has(s.slug)) .sort((a, b) => numericStageOrder(a.number, b.number)); for (const stage of subgraph) { for (const consume of stage.consumes ?? []) { // required: false -> silent if (!consume.required) continue; // projectType filter for conditional consumes if ( consume.conditional_on && opts?.projectType && consume.conditional_on !== opts.projectType ) { continue; } const producers = producersOf(consume.artifact); if (producers.length === 0) { errors.push( `Stage "${stage.slug}" requires artifact "${consume.artifact}" ` + `but no stage in the graph produces it.` ); continue; } const onPathProducers = producers.filter((p) => onPath.has(p.slug)); if (onPathProducers.length === 0) { const message = `Stage "${stage.slug}" requires artifact "${consume.artifact}" ` + `whose producer(s) [${producers.map((p) => p.slug).join(", ")}] ` + `are not on the "${label}" path.`; if (opts?.strict) { errors.push( `${message} Strict (recompose) mode rejects a starved required input.` ); } else { advisories.push(`${message} Ensure existing artifact is current.`); } } } } // The ceremony count travels with the validation so the composer relays the // validator's numbers, not a hand recount. Computed over the raw proposal // entries; unknown slugs already produced errors above and contribute only to // total/execute per gridCostSummary's graph-lookup guard. const summary = gridCostSummary( grid as Record, ); // Distance to each stock scope travels with the validation for the same // reason as summary: the match decision must ride the validator's numbers. // Unknown and missing slugs already errored above; the ranking still counts // them so an invalid partial grid can never look like an exact stock match. const nearest_stock = nearestStockScopes( grid as Record, ); return { valid: errors.length === 0, errors, advisories, summary, nearest_stock }; } /** Check proposed (granted-at-the-gate) keywords against the keywords the * existing scopes already claim - the same loadScopeMapping data both * inference (inferScopeFromText) and findScopeByKeyword read. Inference * takes the FIRST ALPHABETICAL keyword match, so a duplicate keyword would * permanently shadow the incumbent scope on every future cold start; a * collision is therefore a hard error naming the colliding scope, never an * advisory. Comparison is case-insensitive exact equality, matching * findScopeByKeyword. */ export function keywordCollisions(granted: string[]): string[] { const mapping = loadScopeMapping(); const errors: string[] = []; for (const kw of granted) { const holders = Object.keys(mapping) .filter((scope) => (mapping[scope]?.keywords ?? []).some( (k) => k.toLowerCase() === kw.toLowerCase() ) ) .sort(); if (holders.length > 0) { errors.push( `Keyword "${kw}" is already claimed by scope${holders.length > 1 ? "s" : ""} ` + `[${holders.join(", ")}] - granting it would shadow that scope in ` + `keyword inference. Pick a keyword no existing scope claims.` ); } } return errors; } /** Union of produces[] and optional_produces[] across all stages. */ export function artifactsRegistryFor(stages: readonly GraphStage[]): ReadonlySet { const names = new Set(); for (const stage of stages) { for (const name of stage.produces ?? []) { names.add(name); } for (const name of stage.optional_produces ?? []) { names.add(name); } } return names; } export function artifactsRegistry(): ReadonlySet { if (!_artifactsRegistry) { _artifactsRegistry = artifactsRegistryFor(loadGraph()); } return _artifactsRegistry; } // --- Designer export --- // // Raw, unversioned bundle of graph + scopes + artifacts + agents. // Consumed by the visual workflow designer when that ships. No --format // flag, no version envelope, no schema chapter — the bundle is a data // snapshot, not a stable contract. The designer-v1 schema lands when // the consumer spec materialises. Reshapes with its inputs (future // stage renumbering, new phase sub-stages) — releases that change the // underlying YAML regenerate the golden fixture at // tests/fixtures/designer-export/export.json in the same commit, identical // pattern to `compile` regenerating stage-graph.json. interface ExportBundle { stages: GraphStage[]; scopes: Record; artifacts: string[]; agents: AgentMetadata[]; } const TOP_EXPORT_ORDER = ["stages", "scopes", "artifacts", "agents"] as const; const AGENT_FIELD_ORDER = ["slug", "display_name", "examples"] as const; /** Union the live graph + scopes + artifacts + agents into a single * object. Pure — no I/O beyond what the underlying loaders already do. * Stages and scopes pass through in file-insertion order from their JSON * sources; artifacts are alphabetically sorted; agents are pre-sorted by * slug in loadAgents(). */ export function exportBundle(): ExportBundle { return { stages: loadGraph(), scopes: loadScopeMapping(), artifacts: [...artifactsRegistry()].sort(), agents: loadAgents(), }; } /** Canonical JSON emitter for the designer export. Mirrors * canonicalStageGraphJson's pinned-key-order discipline so the golden * fixture at tests/fixtures/designer-export/export.json survives any runtime * change to JS property iteration order. Pins top-level keys via * TOP_EXPORT_ORDER, stage fields via FIELD_ORDER (reused), agent fields * via AGENT_FIELD_ORDER. scopes values are primitive-valued records from * loadScopeMapping() — JSON.stringify preserves insertion order for * string keys per ECMAScript spec, so no per-scope rebuild is needed. */ export function canonicalExportJson(b: ExportBundle): string { const orderedStages = b.stages.map((s) => { const out: Record = {}; for (const key of FIELD_ORDER) { const v: unknown = s[key as keyof GraphStage]; if (v === undefined) continue; out[key] = v; } return out; }); const orderedAgents = b.agents.map((a) => { const out: Record = {}; for (const key of AGENT_FIELD_ORDER) { const v: unknown = a[key as keyof AgentMetadata]; if (v === undefined) continue; out[key] = v; } return out; }); const ordered: Record = {}; for (const key of TOP_EXPORT_ORDER) { if (key === "stages") ordered[key] = orderedStages; else if (key === "agents") ordered[key] = orderedAgents; else ordered[key] = b[key]; } return `${JSON.stringify(ordered, null, 2)}\n`; } // --- Compile --- /** Canonical JSON emitter. The ONLY place that writes stage-graph.json * bytes. Pinning the emitter in one function makes `compile --check` * byte-compare robust — formatter drift is impossible when there's * exactly one writer. */ export function canonicalStageGraphJson(stages: GraphStage[]): string { // Build each object with pinned key order so JSON.stringify emits // keys in the canonical order regardless of construction order. const ordered = stages.map((s) => { const out: Record = {}; for (const key of FIELD_ORDER) { const v: unknown = s[key as keyof GraphStage]; if (v === undefined) continue; out[key] = v; } return out; }); return `${JSON.stringify(ordered, null, 2)}\n`; } // --- Scope grid (the transpose) --- // // The compiled scope-grid.json is the EXECUTE/SKIP matrix, derived by // transposing each stage's `scopes:` membership list. It is a PURE // transpose — no graph-closure, no predicate. Shape is // `{ : { stages: { : "EXECUTE" | "SKIP" } } }`, exactly the // `.stages` half of the legacy scope-mapping.json so the runtime // consumers that read `mapping[scope].stages` stay byte-for-byte // unchanged. The scope-prose metadata (depth/keywords/description) lives // in `.claude/scopes/aidlc-.md`, not here. export interface ScopeGrid { [scope: string]: { stages: Record }; } /** Transpose the per-stage `scopes:` lists into the EXECUTE/SKIP grid. * Scope columns = the sorted union of every name any stage declares. * Slug rows = stage order (the array passed in — already numeric-sorted * by compileStageGraph). A stage that names a scope is EXECUTE under it; * every other scope/stage cell is SKIP. Pure — no I/O. */ export function transposeScopeGrid( stages: GraphStage[], allowedScopes?: ReadonlySet, ): ScopeGrid { const scopeNames = new Set(); for (const s of stages) { for (const name of s.scopes ?? []) { if (allowedScopes === undefined || allowedScopes.has(name)) scopeNames.add(name); } } if (allowedScopes !== undefined) { for (const name of allowedScopes) scopeNames.add(name); } const grid: ScopeGrid = {}; for (const scope of [...scopeNames].sort()) { const stagesMap: Record = {}; for (const s of stages) { stagesMap[s.slug] = s.phase === "initialization" || (s.scopes ?? []).includes(scope) ? "EXECUTE" : "SKIP"; } grid[scope] = { stages: stagesMap }; } return grid; } /** Canonical JSON emitter for the scope grid. The ONLY place that writes * scope-grid.json bytes — same sole-writer discipline as * canonicalStageGraphJson, so `compile --check` byte-compares are robust. * Scopes are emitted in sorted order (transposeScopeGrid already sorts); * per-scope stage keys follow the stages array's numeric order. */ export function canonicalScopeGridJson(grid: ScopeGrid): string { return `${JSON.stringify(grid, null, 2)}\n`; } /** Fold COMPOSED-scope entries from the on-disk grid into a freshly * transposed one. The transpose derives only the stock scopes (those a * stage's `scopes:` frontmatter names); a composed scope's grid entry is * appended at approval time by the composer and has no frontmatter * producer, so a bare re-transpose would silently drop it — and with the * scope's `.md` still present the name stays "valid" and resolves as * all-SKIP, an emptied plan with no diagnostic. Any on-disk entry whose * scope name the transpose does not produce survives the recompile; keys * re-sort so the canonical emitter stays deterministic. Unparseable or * malformed on-disk grids contribute nothing (fresh wins). When * `preserveNames` is supplied, an orphan grid column with no matching scope * identity file is dropped rather than mistaken for a composed scope. */ export function mergeComposedScopes( fresh: ScopeGrid, onDiskJson: string | null, preserveNames?: ReadonlySet, ): ScopeGrid { if (!onDiskJson) return fresh; let onDisk: unknown; try { onDisk = JSON.parse(onDiskJson); } catch { return fresh; } if (typeof onDisk !== "object" || onDisk === null || Array.isArray(onDisk)) return fresh; const merged: ScopeGrid = { ...fresh }; for (const [name, entry] of Object.entries(onDisk as Record)) { if (name in merged) continue; if (preserveNames !== undefined && !preserveNames.has(name)) continue; if ( typeof entry === "object" && entry !== null && !Array.isArray(entry) && typeof (entry as { stages?: unknown }).stages === "object" ) { merged[name] = entry as ScopeGrid[string]; } } const sorted: ScopeGrid = {}; for (const k of Object.keys(merged).sort()) sorted[k] = merged[k]; return sorted; } function composedScopeNames( onDiskJson: string | null, stockScopeNames: ReadonlySet, ): ReadonlySet { if (!onDiskJson) return new Set(); let onDisk: unknown; try { onDisk = JSON.parse(onDiskJson); } catch { return new Set(); } if (typeof onDisk !== "object" || onDisk === null || Array.isArray(onDisk)) { return new Set(); } return new Set( Object.keys(onDisk as Record) .filter((name) => !stockScopeNames.has(name)) .sort(), ); } function stageDeclaredScopeNames(stages: readonly Pick[]): ReadonlySet { const names = new Set(); for (const stage of stages) { for (const name of stage.scopes ?? []) names.add(name); } return names; } function filterScopeGrid( grid: ScopeGrid, allowedScopes: ReadonlySet | null, exemptScopes: ReadonlySet = new Set(), ): ScopeGrid { if (allowedScopes === null) return grid; const filtered: ScopeGrid = {}; for (const scope of Object.keys(grid).sort()) { if (allowedScopes.has(scope) || exemptScopes.has(scope)) filtered[scope] = grid[scope]; } return filtered; } function enabledScopeNames(): ReadonlySet | null { if (pluginsEnabled() === null) return null; return new Set(Object.keys(loadScopeMetadata()).sort()); } /** Parse a numeric stage identifier like "3.5" into a tuple [phase, index] * for total-ordering comparison. Returns negative, zero, or positive. */ export function numericStageOrder(a: string, b: string): number { const [aP, aI] = a.split(".").map((x) => parseInt(x, 10)); const [bP, bI] = b.split(".").map((x) => parseInt(x, 10)); if (aP !== bP) return aP - bP; return aI - bI; } /** Two-direction drift between the on-disk stage `.md` files and the compiled * stage-graph.json. Pure set-difference over slugs, no YAML parse, no graph * rebuild, so it is cheap enough to run on the session-start hot path. * * - `missingFiles`: graph->disk. A slug in stage-graph.json with no matching * `/.md` on disk, a real runtime breakage (the conductor is * handed a path to a file that does not exist). The doctor reports it as a * hard fail. * - `uncompiledStages`: disk->graph. A `/.md` whose slug is absent * from the compiled graph, the issue #364 case. The runtime resolves stages * from the compiled graph only (loadGraph), so this file is silently never * executed until `aidlc-graph compile` regenerates the graph. Advisory: the * file is inert, not corrupt, and recompiling is a deliberate authoring act. * - `graphCount`: how many slugs the compiled graph holds. Returned here so a * caller (the doctor) can label the in-sync case without a second * loadStageGraph() call. * * Honours the AIDLC_STAGES_DIR (stagesDir) and AIDLC_STAGE_GRAPH * (loadStageGraph) seams so a test can point both sources at a temp tree. */ export function stageGraphDrift(): { missingFiles: string[]; uncompiledStages: string[]; graphCount: number; } { const graphSlugs = new Set(loadStageGraphAll().map((s) => s.slug)); const diskSlugs = new Set(); const root = stagesDir(); for (const phase of PHASES) { const dir = join(root, phase); if (!existsSync(dir)) continue; for (const f of readdirSync(dir)) { if (f.endsWith(".md")) diskSlugs.add(f.replace(/\.md$/, "")); } } return { missingFiles: [...graphSlugs].filter((s) => !diskSlugs.has(s)).sort(), uncompiledStages: [...diskSlugs].filter((s) => !graphSlugs.has(s)).sort(), graphCount: graphSlugs.size, }; } /** Default display name for an auto-seeded stage: title-cased slug * ("my-custom-stage" -> "My Custom Stage"). A one-time default only, * compile pins it into stage-graph.json, so an author can refine the name * there afterwards (e.g. "NFR Requirements") and the next compile keeps it. */ function titleCaseSlug(slug: string): string { return slug .split("-") .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) .join(" "); } function stagePluginOwner(stage: Pick): string { return stage.plugin ?? "aidlc"; } function applyPluginSelection(stages: GraphStage[]): void { for (const stage of stages) { delete stage.enabled; if (!stageEnabledBySelection(stage)) stage.enabled = false; } } function validateSelectionClosure(stages: GraphStage[]): void { const producersByArtifact = new Map(); for (const stage of stages) { for (const artifact of [...(stage.produces ?? []), ...(stage.optional_produces ?? [])]) { const producers = producersByArtifact.get(artifact) ?? []; producers.push(stage); producersByArtifact.set(artifact, producers); } } for (const stage of stages.filter((s) => s.enabled !== false)) { for (const consume of stage.consumes ?? []) { if (!consume.required) continue; const producers = producersByArtifact.get(consume.artifact) ?? []; if (producers.length === 0) continue; const enabledProducers = producers.filter((p) => p.enabled !== false); if (enabledProducers.length > 0) continue; const producerList = producers .map((p) => `${p.slug} (${stagePluginOwner(p)})`) .sort() .join(", "); const disabledPlugins = [...new Set(producers.map(stagePluginOwner))].sort(); throw new Error( `Plugin selection closure failed: enabled stage "${stage.slug}" consumes required artifact "${consume.artifact}", ` + `but its only producer(s) are disabled: ${producerList}. ` + `Enable plugin(s) ${disabledPlugins.join(", ")} or disable the consuming stage.` ); } } } /** Enabled stages whose requires_stage points at a selection-disabled stage. * NOT part of the closure ERROR: an ordering edge to a never-running stage is * vacuous (topoSort ignores edges outside the enabled subset), and the shipped * plugin-only flow legitimately runs plugin stages whose requires_stage names * core stages. But the silently-dropped edge is worth surfacing - doctor * reports these as an advisory so a surprising walk order is explainable. */ export function selectionDroppedOrderingEdges( stages: Array<{ slug: string; plugin?: string; enabled?: boolean; requires_stage?: string[] }>, ): string[] { const bySlug = new Map(stages.map((s) => [s.slug, s])); const dropped: string[] = []; for (const stage of stages.filter((s) => s.enabled !== false)) { for (const dep of stage.requires_stage ?? []) { const depStage = bySlug.get(dep); if (depStage?.enabled !== false) continue; dropped.push(`${stage.slug} requires ${dep} (${stagePluginOwner(depStage)}, disabled)`); } } return dropped.sort(); } /** Regenerate stage-graph.json from the YAML stage files. * Preserves pinned rows from an installed graph when present; a clean source * build derives core ordering and display names from stage frontmatter. * Asserts the * edge-local invariant: every requires_stage edge points from a * higher-numbered stage to a lower-numbered one. Also transposes each * stage's `scopes:` into the compiled scope-grid.json (gridJson) — both * artifacts derive from the same in-memory stages, so a single compile * keeps stage-graph.json and scope-grid.json in lockstep. */ export function compileStageGraph(): { json: string; gridJson: string; stages: GraphStage[]; } { // Load selected scope metadata up front so scope authoring invariants, such // as a single enabled freeform default, fail during compile. loadScopeMetadata(); // Harvest number + name mappings from existing JSON. A slug already in the // JSON keeps its pinned row; a NEW slug is auto-seeded below, with an // authored name override when present. // A source checkout has no compiled graph until packaging materializes one. // Installed runtimes still preserve their pinned rows (including plugin // stages), while a clean package build derives the core graph from YAML. const existing = existsSync(stageGraphPath()) ? loadStageGraphAll() : []; const numberBySlug = new Map(existing.map((s) => [s.slug, s.number])); const nameBySlug = new Map(existing.map((s) => [s.slug, s.name])); // Highest index already used in each phase (keyed by numeric prefix), // so a new stage in that phase gets the next free index. Seeded from the // existing JSON, then bumped as new stages in the same phase are seeded // within this compile, so adding several new stages to one phase at once // assigns distinct, contiguous indices rather than colliding. const maxIndexByPhasePrefix = new Map(); for (const s of existing) { const [prefix, index] = s.number.split(".").map((n) => parseInt(n, 10)); if (!Number.isFinite(prefix) || !Number.isFinite(index)) continue; maxIndexByPhasePrefix.set( prefix, Math.max(maxIndexByPhasePrefix.get(prefix) ?? 0, index) ); } const stages: GraphStage[] = []; // NEW slugs (no pinned row yet), grouped by phase prefix for the // topological number seed after the walk. type NewStageSeed = { data: StageFrontmatter; phase: string; prefix: number; name: string }; const newByPrefix = new Map(); // Track slug-to-first-file so duplicate-slug errors name both files. const slugToFile = new Map(); type StageDeclaration = { file: string; slug: string }; const artifactProducers = new Map(); const artifactConsumers = new Map(); // Known agent slugs (the `name:` field of each .claude/agents/*.md), passed // to validateStageFrontmatter so a stage referencing a lead_agent or // support_agent with no matching agent file fails the compile loudly rather // than surfacing at runtime as a "subagent not registered" Task error. // Hoisted once: loadAgents() is memoised, but the .map is per-call. const knownAgents = loadAgents().map((a) => a.slug); const stagesRoot = stagesDir(); for (const phase of readdirSync(stagesRoot)) { const pdir = join(stagesRoot, phase); if (!statSync(pdir).isDirectory()) continue; for (const f of readdirSync(pdir).filter((f) => f.endsWith(".md")).sort()) { const filePath = join(pdir, f); const raw = readFileSync(filePath, "utf-8"); // Wrap parse in filename context — parseStageFrontmatter's default // error messages don't include the file path, which makes debugging // a bad YAML edit across 31 stage files painful. let parsed: Record; try { parsed = parseStageFrontmatter(raw) as Record; } catch (err) { throw new Error(`${filePath}: ${errorMessage(err)}`); } // Validate frontmatter against stage-schema.ts before extracting fields // — the validator returns a typed StageFrontmatter, so subsequent reads // (slug, phase, etc.) need no casts. Catches missing required fields // (e.g., execution: undefined would silently drop from the emitted JSON // via canonicalStageGraphJson's undefined skip). Passing knownAgents // activates the agent-registration cross-check (Rule 9): an unknown // lead_agent / support_agent fails here, not at runtime. const validation = validateStageFrontmatter(parsed, { agents: knownAgents }); if (!validation.valid) { throw new Error( `${filePath}: schema validation failed: ${validation.errors.join("; ")}` ); } const slug = validation.data.slug; const plugin = validation.data.plugin; if (plugin !== undefined) { if (plugin === "aidlc") { throw new Error( `${filePath}: stage "${slug}" declares plugin "aidlc"; omit plugin for core stages.` ); } // `aidlc-` is core's namespace: runner dirs are `aidlc-` for core // but the bare slug for plugin stages, so a plugin named `aidlc-` // generates runner paths identical to core's and silently clobbers them // (/aidlc--... routes to the wrong stage). if (plugin.startsWith("aidlc-")) { throw new Error( `${filePath}: stage "${slug}" declares plugin "${plugin}"; the "aidlc-" prefix is reserved for core (a plugin named aidlc- collides with core runner paths). Rename the plugin.` ); } if (!slug.startsWith(`${plugin}-`)) { throw new Error( `${filePath}: stage "${slug}" declares plugin "${plugin}", but plugin-owned stage slugs must start with "${plugin}-". Rename the slug or fix the plugin field.` ); } } const filenameStem = basename(filePath, ".md"); if (filenameStem !== slug) { throw new Error( `${filePath}: stage filename stem "${filenameStem}" does not match frontmatter slug "${slug}". Rename the file or fix the slug.` ); } // Duplicate-slug guard: two YAML files claiming the same slug would // silently produce a corrupt graph (two rows, findStageBySlug returns // only the first). Catch it loud and name both files. const previousFile = slugToFile.get(slug); if (previousFile) { throw new Error( `Duplicate stage slug "${slug}" in ${filePath} — already declared ` + `in ${previousFile}. Rename one of them.` ); } slugToFile.set(slug, filePath); const declaration = { file: filePath, slug }; // Match producersOf(): required and optional outputs share one artifact // producer namespace. Set semantics avoid counting one stage twice if an // author repeats a name across both lists. for (const artifact of new Set([ ...(validation.data.produces ?? []), ...(validation.data.optional_produces ?? []), ])) { const producers = artifactProducers.get(artifact) ?? []; producers.push(declaration); artifactProducers.set(artifact, producers); } for (const artifact of new Set( (validation.data.consumes ?? []).map((consume) => consume.artifact), )) { const consumers = artifactConsumers.get(artifact) ?? []; consumers.push(declaration); artifactConsumers.set(artifact, consumers); } // Existing slug -> keep its pinned number + name (the "computed once, // stable thereafter" contract; a pinned row missing only its name // seeds the name inline). New slug -> DEFER numbering to the per-phase // topological seed after the file walk (below): with several new // stages arriving in one compile (a multi-stage plugin), numbering // them in file-walk (alphabetical) order can contradict their own // requires_stage edges and fail the lower-numbered-dependency // invariant, so the batch is ordered by its edges first. const prefix = PHASES.indexOf(phase as Phase); if (prefix < 0) { // A stage directory whose name is not one of the five canonical // phases can't be placed on the numeric spine, fail loud rather // than invent a prefix. throw new Error( `Stage "${slug}" (${filePath}) is in an unknown phase directory ` + `"${phase}". Stage phase directories must be one of: ${PHASES.join(", ")}.` ); } const number = numberBySlug.get(slug); const name = nameBySlug.get(slug) ?? validation.data.name ?? titleCaseSlug(slug); if (number) { stages.push(buildGraphStage(validation.data, phase, number, name)); } else { newByPrefix.get(prefix)?.push({ data: validation.data, phase, prefix, name }) ?? newByPrefix.set(prefix, [{ data: validation.data, phase, prefix, name }]); } } } for (const [artifact, producers] of artifactProducers) { if (producers.length < 2) continue; const consumer = artifactConsumers.get(artifact)?.[0]; if (!consumer) continue; // Shared artifact names are legal when unconsumed: traceability is // produced by eight stages and consumed by none, so only consumed names // require a unique producer. const producerList = producers .map(({ file, slug }) => `${file} (stage "${slug}")`) .join(", "); throw new Error( `Duplicate producers for consumed artifact "${artifact}" in ${producerList} — ` + `consumed by stage "${consumer.slug}" in ${consumer.file}. ` + `Rename one produced artifact or update the consumer.` ); } // Per-phase topological seed for NEW slugs. Numbers are assigned by the // ENGINE, never claimed by authors: within one phase's batch of new // stages, order by the batch's own requires_stage edges (Kahn), breaking // ties among independent stages by the authored `number:` hint (a // relative-ordering hint only — its absolute value is never used) and // then slug; assign next-free contiguous indices in that order. Edges to // stages OUTSIDE the batch need no handling here: an already-pinned // same-phase dependency is lower-numbered by construction (new indices // start past the phase max), and cross-phase edges are ordered by the // phase prefix — the edge-local invariant below still backstops all of // it. Uncoordinated plugins therefore cannot collide on numbers, and a // batch whose file order contradicts its flow order still seeds validly. for (const prefix of [...newByPrefix.keys()].sort((a, b) => a - b)) { const batch = newByPrefix.get(prefix)!; const inBatch = new Map(batch.map((e) => [e.data.slug, e])); // Dedupe each stage's edges: the decrement below fires once per // dependent, so a duplicated requires_stage entry would strand the // stage at indegree > 0 and misreport a copy-paste duplicate as a // cycle (the schema shape-checks the list but does not dedupe it). const indegree = new Map(batch.map((e) => [e.data.slug, 0])); for (const e of batch) { for (const dep of new Set(e.data.requires_stage ?? [])) { if (inBatch.has(dep)) indegree.set(e.data.slug, (indegree.get(e.data.slug) ?? 0) + 1); } } const hint = (e: NewStageSeed): number => { const authored = e.data.number; if (!authored) return Number.POSITIVE_INFINITY; const idx = parseInt(authored.split(".")[1], 10); return Number.isFinite(idx) ? idx : Number.POSITIVE_INFINITY; }; const byHintThenSlug = (a: NewStageSeed, b: NewStageSeed): number => hint(a) - hint(b) || a.data.slug.localeCompare(b.data.slug); const ready = batch.filter((e) => indegree.get(e.data.slug) === 0).sort(byHintThenSlug); const seeded: NewStageSeed[] = []; while (ready.length > 0) { const e = ready.shift()!; seeded.push(e); for (const other of batch) { if (!(other.data.requires_stage ?? []).includes(e.data.slug)) continue; const d = (indegree.get(other.data.slug) ?? 0) - 1; indegree.set(other.data.slug, d); if (d === 0) { ready.push(other); ready.sort(byHintThenSlug); } } } if (seeded.length < batch.length) { // The unseeded set = the cycle's members plus anything downstream of // them, so name it "stuck", not "the cycle" — a stage can appear here // solely because its dependency is cyclic. const stuck = batch.filter((e) => !seeded.includes(e)).map((e) => e.data.slug); throw new Error( `Cannot seed stage numbers for phase "${batch[0].phase}": ` + `requires_stage cycle among new stages (stuck: ${stuck.join(", ")}). Break the cycle.` ); } for (const e of seeded) { const nextIndex = (maxIndexByPhasePrefix.get(prefix) ?? 0) + 1; maxIndexByPhasePrefix.set(prefix, nextIndex); stages.push(buildGraphStage(e.data, e.phase, `${prefix}.${nextIndex}`, e.name)); } } // Sort by numeric order (phase-prefix.index). stages.sort((a, b) => numericStageOrder(a.number, b.number)); const stockScopeNames = stageDeclaredScopeNames(stages); // Resolve per-stage rule chain. Strict-additive: every applicable rule // appears in rules_in_context (org+team+project + phase when stage's // `phase:` matches the rule's filename suffix). The walk + parse + // validate happens once per compile; downstream consumers (dispatcher, // doctor) read pre-resolved arrays off graph nodes — no runtime walks // of .claude/rules/. const rules = loadRules(); for (const stage of stages) { stage.rules_in_context = resolveRulesForStage(stage, rules); } // Resolve per-stage sensor imports. Pull authoring: each stage's // sensors[] list is looked up against the manifest registry; dispatch // policy, severity, category, and matches are copied into the resolved // entry. Unknown ids throw — // authoring errors fail loud at compile, not at fire time. const sensorsById = loadSensors(); for (const stage of stages) { stage.sensors_applicable = resolveSensorsForStage(stage, sensorsById); } applyPluginSelection(stages); validateSelectionClosure(stages); // Edge-local invariant: for every edge A in B.requires_stage, // numericOrder(A) < numericOrder(B). Topological sort is non-unique // in the presence of fan-out (Construction's NFR and functional- // design branches are independent); sort-equivalence would be // tautological. The edge-local check captures the real failure mode. const numberLookup = new Map(stages.map((s) => [s.slug, s.number])); for (const stage of stages) { for (const dep of stage.requires_stage ?? []) { const depNum = numberLookup.get(dep); if (!depNum) { throw new Error( `Unknown requires_stage: "${dep}" on stage "${stage.slug}". ` + `Every requires_stage entry must reference a known stage slug.` ); } if (numericStageOrder(depNum, stage.number) >= 0) { throw new Error( `Compile invariant violated: stage "${stage.slug}" (${stage.number}) ` + `requires "${dep}" (${depNum}) — dependency must be lower-numbered. ` + `Fix: either renumber in stage-graph.json to match the dependency ` + `direction, or remove the offending requires_stage edge.` ); } } } // Swarm-trigger guard (advisory): the autonomous Construction swarm fires // on a field match — for_each: unit-of-work + mode: subagent (see // SWARM_FOR_EACH / SWARM_MODE in aidlc-orchestrate.ts). A per-unit // Construction stage carrying any OTHER mode silently falls off the swarm // path and builds its units serially, which is legal (the topology is the // author's call) but easy to do by accident when retuning modes. Warn on // stderr; never fail — warnings do not affect the emitted JSON, so // compile --check parity is untouched. for (const stage of stages) { if ( stage.phase === "construction" && stage.for_each === "unit-of-work" && stage.workspace_requires === true && stage.mode !== "subagent" ) { console.error( `[advisory] stage "${stage.slug}" is the per-unit build stage ` + `(for_each: unit-of-work + workspace_requires) but mode is ` + `"${stage.mode}", not "subagent" — the autonomous Construction ` + `swarm will NOT fire for it; units build serially.` ); } } // The grid transpose covers only frontmatter-declared (stock) scopes; // composed scopes live solely as appended grid entries, so fold the // on-disk grid's composed entries back in before emitting — a recompile // must never destroy an approved composed scope. let onDiskGrid: string | null = null; try { onDiskGrid = readFileSync(scopeGridPath(), "utf-8"); } catch { /* first compile: no grid on disk yet */ } const selectedScopeNames = enabledScopeNames(); const installedScopeNames = new Set(Object.keys(loadScopeMetadataAll())); const composedNames = new Set( [...composedScopeNames(onDiskGrid, stockScopeNames)].filter((name) => installedScopeNames.has(name), ), ); const seededScopeNames = selectedScopeNames === null ? undefined : new Set([...selectedScopeNames].filter((name) => !composedNames.has(name))); return { json: canonicalStageGraphJson(stages), gridJson: canonicalScopeGridJson( filterScopeGrid( mergeComposedScopes( transposeScopeGrid( stages.filter((s) => s.enabled !== false), seededScopeNames, ), onDiskGrid, composedNames, ), selectedScopeNames, composedNames, ), ), stages, }; } function buildGraphStage( parsed: StageFrontmatter, phase: string, number: string, name: string ): GraphStage { const slug = parsed.slug; // Support_agents + produces + requires_stage are always arrays // (parseStageFrontmatter normalises empty). const support_agents = parsed.support_agents ?? []; const produces = parsed.produces ?? []; // Dependency edges are set-valued. Normalize copy-paste duplicates here so // every graph consumer, including topoSort's indegree accounting, observes // the same edge cardinality as the compile-time number seeder. const requires_stage = [...new Set(parsed.requires_stage ?? [])]; const consumesRaw = parsed.consumes ?? []; const consumes: Consume[] = consumesRaw.map((c) => { const out: Consume = { artifact: c.artifact, required: c.required, }; if (c.conditional_on !== undefined) { out.conditional_on = c.conditional_on; } return out; }); const stage: GraphStage = { slug, number, name, phase: parsed.phase ?? phase, execution: parsed.execution, lead_agent: parsed.lead_agent, support_agents, mode: parsed.mode, produces, consumes, requires_stage, inputs: parsed.inputs ?? "", outputs: parsed.outputs ?? "", // Filled by resolveRulesForStage in compileStageGraph after the // sort. The field is REQUIRED on GraphStage; assigning [] here // keeps the type honest until resolution runs. rules_in_context: [], // Filled by resolveSensorsForStage in compileStageGraph. Same // discipline as rules_in_context — REQUIRED on GraphStage. sensors_applicable: [], }; if (parsed.plugin !== undefined) { stage.plugin = parsed.plugin; } if (parsed.condition !== undefined) { stage.condition = parsed.condition; } if (parsed.for_each !== undefined) { stage.for_each = parsed.for_each; } if (parsed.workspace_requires !== undefined) { stage.workspace_requires = parsed.workspace_requires; } if (parsed.optional_produces !== undefined) { stage.optional_produces = parsed.optional_produces; } if (parsed.produces_kinds !== undefined) { stage.produces_kinds = parsed.produces_kinds; } if (parsed.sensors !== undefined) { stage.sensors = parsed.sensors; } if (parsed.scopes !== undefined) { stage.scopes = parsed.scopes; } if (parsed.reviewer !== undefined) { stage.reviewer = parsed.reviewer; stage.review_artifact = parsed.review_artifact; // Default the cap to 2 when a reviewer is declared but no explicit cap is // set. The parser (V1) now returns a real number and validateStageFrontmatter // (V2) rejects a non-positive-integer cap upstream, so this should always // see a valid number or undefined. Keep the coercion defensive: a value // that isn't a positive integer falls back to the default 2 rather than // letting NaN reach stage-graph.json. const cap = Number(parsed.reviewer_max_iterations); stage.reviewer_max_iterations = parsed.reviewer_max_iterations !== undefined && Number.isInteger(cap) && cap >= 1 ? cap : 2; // Default the class to "adversarial" (the pre-class behavior) when a // reviewer is declared without one. Schema (V2) rejects any value other // than adversarial/advisory upstream; keep the coercion defensive so a // bad value degrades to the strict default rather than leaking through. stage.review_class = parsed.review_class === "advisory" ? "advisory" : "adversarial"; } if (parsed.summary_confirmation !== undefined) { stage.summary_confirmation = parsed.summary_confirmation; } return stage; } function runCompileCheck(): void { const { json, gridJson } = compileStageGraph(); const graphOnDisk = readFileSync(stageGraphPath(), "utf-8"); if (json !== graphOnDisk) { console.error( `stage-graph.json is out of date. Run \`${aidlcToolInvocation("graph", undefined, false)} compile\` to regenerate.` ); process.exit(1); } // The scope grid is the second compiled artifact (the transpose of every // stage's scopes:). Same drift discipline as stage-graph.json — a stale // grid (someone edited a stage's scopes: without recompiling) fails CI. // Read the grid path lazily so a missing grid file reports the same way // as a stale one rather than throwing an unhandled ENOENT. The on-disk // bytes are re-emitted through the canonical emitter before comparing: // the composer APPENDS its approved entry (insertion order, end of file) // while the emitter sorts scope keys, so a purely positional difference // must not read as drift — only a real content difference (a cell, a // scope, a stage set) fails the check. let gridOnDisk: string; try { gridOnDisk = readFileSync(scopeGridPath(), "utf-8"); } catch { gridOnDisk = ""; } try { const parsed = JSON.parse(gridOnDisk) as ScopeGrid; const sorted: ScopeGrid = {}; for (const k of Object.keys(parsed).sort()) sorted[k] = parsed[k]; gridOnDisk = canonicalScopeGridJson(sorted); } catch { /* unparseable/missing grid: compare the raw bytes (guaranteed drift) */ } if (gridJson !== gridOnDisk) { console.error( `scope-grid.json is out of date. Run \`${aidlcToolInvocation("graph", undefined, false)} compile\` to regenerate.` ); process.exit(1); } } // --- CLI --- type Handler = (args: string[]) => Promise | void; function requireArg(args: string[], label: string): string { if (args.length === 0 || args[0].startsWith("--")) { throw new Error(`Missing required argument: <${label}>`); } return args[0]; } // A required VALUED flag (--flag ). Throws when the flag is absent or // its value slot is missing/another flag. function requireFlag(args: string[], flag: string): string { const idx = args.indexOf(flag); if (idx === -1 || idx + 1 >= args.length || args[idx + 1].startsWith("--")) { throw new Error(`Missing required flag: ${flag} `); } return args[idx + 1]; } function printSlugs(stages: GraphStage[]): void { for (const s of stages) console.log(s.slug); } // --- ARS (Autonomy Risk Score) deterministic scoring --- // // The composer persona scores the five entropy components from evidence (the // knowledge half); THIS code owns every downstream number: the weighted // composite, band labels, the per-stage expected-value screen against the // cost-prior table, nearest stock scopes by grid diff count, and the two // pre-rendered gate tables. Same component scores in, same proposal numbers // out — auditable and runnable without an LLM. All constants live in // tools/data/ars-priors.json (schema-versioned); the persona's tables are // documentation of that file, not the source. The composite stays an // ADVISORY index: nothing deterministic routes on it. const ARS_COMPONENTS = ["iae", "csu", "ve", "r", "ua"] as const; export type ArsComponent = (typeof ARS_COMPONENTS)[number]; type ArsBand = "LOW" | "MED" | "HIGH"; type ArsDecision = "EXECUTE" | "SKIP" | "COMPLETED"; const ARS_PROJECT_TYPES = ["brownfield", "greenfield"] as const; type ArsProjectType = (typeof ARS_PROJECT_TYPES)[number]; /** IEEE summation of the weighted terms can land a hair under an exact * half-point - 0.75 + 12.45 + 7.3 evaluates to 20.499999999999996, which * Math.round would drop into the band BELOW the one the documented formula * computes exactly, and the band label is the one thing the gate table * bolds. Normalising the sum at a precision far above the accumulated * error (~1e-14 at composite magnitudes) makes the rounded total agree * with exact arithmetic and keeps `raw` free of 62.74999999999999 noise. */ const ARS_RAW_PRECISION = 9; interface ArsPriors { schemaVersion: number; weights: Record; componentInfo: Record; componentBands: { lowMax: number; medMax: number }; compositeBands: Array<{ min: number; max: number; label: string; shape: string }>; evThresholds: Record; stages: Record< string, { targets: ArsComponent[]; cost: number | null; role?: string; // Present only on stages whose compiled `condition:` restricts them to // one kind of project (today: reverse-engineering, brownfield-only). // Absent = the stage runs on either project type. projectTypes?: ArsProjectType[]; } >; } export interface ArsScreenRow { stage: string; number: string; decision: ArsDecision; screen: | "component" | "initialization" | "core" | "phase-gate" | "structural" | "project-type" | "no-cost-prior" | "no-prior" | "completed"; targets: ArsComponent[]; cost: number | null; maxTargetScore: number | null; threshold: number | null; reason: string; } export interface ArsResult { schemaVersion: 1; components: Record; composite: { raw: number; total: number; label: string; shape: string }; evScreen: ArsScreenRow[]; screenGrid: Record; nearestScopes: Array<{ scope: string; diff: number; differs: string[] }>; completed: string[]; projectType: ArsProjectType | null; tables: { arsScores: string; stageDecisions: string }; } /** Resolve tools/data/ars-priors.json. AIDLC_ARS_PRIORS mirrors the * AIDLC_SCOPE_GRID test seam. Evaluated at call time. */ function arsPriorsPath(): string { return process.env.AIDLC_ARS_PRIORS ?? join(resolveDataDir(), "ars-priors.json"); } /** Load + schema-validate the priors file. Throws (-> exit 1 via main's * catch) on any violation: a silent fallback default would reintroduce * exactly the unauditable arithmetic this file exists to remove. */ export function loadArsPriors(): ArsPriors { const p = arsPriorsPath(); let parsed: unknown; try { parsed = JSON.parse(readFileSync(p, "utf-8")); } catch (err) { throw new Error(`cannot read ars priors at ${p}: ${errorMessage(err)}`); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`ars priors at ${p}: not a JSON object.`); } const priors = parsed as ArsPriors; if (priors.schemaVersion !== 1) { throw new Error( `ars priors at ${p}: unsupported schemaVersion ${String(priors.schemaVersion)} (expected 1).` ); } let weightSum = 0; for (const c of ARS_COMPONENTS) { const w = priors.weights?.[c]; if (typeof w !== "number" || w < 0 || w > 1) { throw new Error(`ars priors: weights.${c} must be a number in [0,1].`); } weightSum += w; if (typeof priors.componentInfo?.[c]?.name !== "string") { throw new Error(`ars priors: componentInfo.${c}.name is missing.`); } } if (Math.abs(weightSum - 1) > 1e-9) { throw new Error(`ars priors: weights must sum to 1.0 (got ${weightSum}).`); } const lowMax = priors.componentBands?.lowMax; const medMax = priors.componentBands?.medMax; if ( typeof lowMax !== "number" || typeof medMax !== "number" || !(0 < lowMax && lowMax < medMax && medMax <= 1) ) { throw new Error("ars priors: componentBands must satisfy 0 < lowMax < medMax <= 1."); } if (!Array.isArray(priors.compositeBands) || priors.compositeBands.length === 0) { throw new Error("ars priors: compositeBands must be a non-empty array."); } let expectMin = 0; for (const b of priors.compositeBands) { if ( b.min !== expectMin || typeof b.max !== "number" || b.max < b.min || typeof b.label !== "string" || typeof b.shape !== "string" ) { throw new Error("ars priors: compositeBands must tile 0..100 contiguously with label + shape."); } expectMin = b.max + 1; } if (expectMin !== 101) { throw new Error("ars priors: compositeBands must end at 100."); } for (const [key, t] of Object.entries(priors.evThresholds ?? {})) { if (typeof t !== "number" || t < 0 || t > 1) { throw new Error(`ars priors: evThresholds["${key}"] must be a number in [0,1].`); } } for (const [slug, st] of Object.entries(priors.stages ?? {})) { if (!Array.isArray(st.targets) || st.targets.some((t) => !ARS_COMPONENTS.includes(t))) { throw new Error( `ars priors: stages.${slug}.targets must be a subset of {${ARS_COMPONENTS.join(", ")}}.` ); } // Type before lookup: `String(cost) in evThresholds` alone accepts the // STRING "1", which then leaks into the result JSON's cost fields and // breaks the `number | null` contract this interface declares. if (st.cost !== null && typeof st.cost !== "number") { throw new Error( `ars priors: stages.${slug}.cost must be a number or null (got ${typeof st.cost}).` ); } if (st.cost !== null && !(String(st.cost) in (priors.evThresholds ?? {}))) { throw new Error(`ars priors: stages.${slug}.cost ${String(st.cost)} has no evThresholds entry.`); } if (st.projectTypes !== undefined) { if ( !Array.isArray(st.projectTypes) || st.projectTypes.length === 0 || st.projectTypes.some((t) => !ARS_PROJECT_TYPES.includes(t)) ) { throw new Error( `ars priors: stages.${slug}.projectTypes must be a non-empty subset of {${ARS_PROJECT_TYPES.join(", ")}}.` ); } } } return priors; } /** The deterministic half of the composer's Step 2/4/6/8a: composite + * bands + EV screen + nearest stock scopes + gate tables. Pure with * respect to its inputs; throws on out-of-range scores or unknown stage * slugs (same typo discipline as validate-grid). */ export function computeArs( scores: Record, opts?: { completed?: string[]; projectType?: ArsProjectType } ): ArsResult { const priors = loadArsPriors(); for (const c of ARS_COMPONENTS) { const v = scores[c]; if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1) { throw new Error(`--${c} must be a number in [0.00, 1.00] (got ${String(v)}).`); } // The rubric and every rendered table speak in two decimals. Banding reads // the EXACT value while `fmt` renders it rounded, so a finer input makes // the two disagree in print: 0.299 renders "0.30 | LOW" against the // documented LOW < 0.30, and 0.4004 yields "reduces CSU=0.40 > threshold // 0.4". Rejecting here keeps table and band incapable of contradicting. if (Number(v.toFixed(2)) !== v) { throw new Error(`--${c} must have at most two decimals (got ${String(v)}).`); } } const graph = loadGraph(); const knownSlugs = new Set(graph.map((s) => s.slug)); const completed = opts?.completed ?? []; for (const slug of completed) { if (!knownSlugs.has(slug)) { throw new Error(`--completed names unknown stage "${slug}" - not in the compiled stage graph.`); } } // Same discipline for the priors themselves: an entry naming a stage the // compiled graph does not know is stale data, not a screening input. The // check runs against the UNFILTERED graph on purpose - loadGraph() drops // stages a plugin selection marked `enabled: false`, and the shipped // priors name every core stage, so screening against the filtered set // would make every `ars` call exit 1 on an install that disabled one. // A slug missing from the unfiltered graph is still stale and still throws. const compiledSlugs = new Set(loadStageGraphAll().map((s) => s.slug)); for (const slug of Object.keys(priors.stages)) { if (!compiledSlugs.has(slug)) { throw new Error(`ars priors: stages.${slug} is not in the compiled stage graph.`); } } const band = (v: number): ArsBand => v < priors.componentBands.lowMax ? "LOW" : v < priors.componentBands.medMax ? "MED" : "HIGH"; const fmt = (v: number): string => v.toFixed(2); const sym = (c: ArsComponent): string => c.toUpperCase(); const components = {} as ArsResult["components"]; for (const c of ARS_COMPONENTS) { components[c] = { name: priors.componentInfo[c].name, score: scores[c], band: band(scores[c]) }; } const raw = Number( (100 * ARS_COMPONENTS.reduce((acc, c) => acc + priors.weights[c] * scores[c], 0)).toFixed( ARS_RAW_PRECISION ) ); const total = Math.round(raw); const compositeBand = priors.compositeBands.find((b) => total >= b.min && total <= b.max); if (!compositeBand) { throw new Error(`composite ${total} falls outside the compositeBands coverage.`); } // Pass 1 - decide every stage except phase-gates (they key off the other // decisions in their phase). COMPLETED (in-flight context) wins over any // screen: the stage already ran, so it stays EXECUTE in the derived grid. const completedSet = new Set(completed); const projectType = opts?.projectType; // A stage whose compiled `condition:` restricts it to one project type is // decided by that condition, not by the component arithmetic: without this // the screen could emit `reverse-engineering EXECUTE` on a greenfield // project, contradicting the stage the composer would have to run. Only a // COMPLETED stage outranks it (it already ran; in-flight evidence wins). const offProjectType = (p?: { projectTypes?: ArsProjectType[] }): boolean => projectType !== undefined && p?.projectTypes !== undefined && !p.projectTypes.includes(projectType); const decisionOf = new Map(); const deferred = new Set(); for (const s of graph) { const p = priors.stages[s.slug]; if (completedSet.has(s.slug)) { decisionOf.set(s.slug, "COMPLETED"); } else if (offProjectType(p)) { decisionOf.set(s.slug, "SKIP"); } else if (p?.role === "phase-gate") { deferred.add(s.slug); } else if (p?.role === "initialization" || p?.role === "core") { decisionOf.set(s.slug, "EXECUTE"); } else if (!p || p.role === "structural" || p.cost === null) { decisionOf.set(s.slug, "SKIP"); } else { const maxTarget = p.targets.length > 0 ? Math.max(...p.targets.map((t) => scores[t])) : 0; const threshold = priors.evThresholds[String(p.cost)]; decisionOf.set(s.slug, maxTarget > threshold ? "EXECUTE" : "SKIP"); } } // Pass 2 - a phase-gate executes iff any OTHER stage in its phase does // (persona: approval-handoff is "Always at ideation->inception boundary"; // when the whole phase folds away, the boundary does not exist). for (const s of graph) { if (!deferred.has(s.slug)) continue; const phaseActive = graph.some( (o) => o.phase === s.phase && o.slug !== s.slug && decisionOf.get(o.slug) !== "SKIP" ); decisionOf.set(s.slug, phaseActive ? "EXECUTE" : "SKIP"); } // Pass 3 - render the screen rows in graph order with the reasoning the // gate table shows verbatim. const evScreen: ArsScreenRow[] = []; for (const s of graph) { const p = priors.stages[s.slug]; const decision = decisionOf.get(s.slug) as ArsDecision; const base = { stage: s.slug, number: s.number, decision, targets: p?.targets ?? [], cost: p?.cost ?? null, maxTargetScore: null as number | null, threshold: null as number | null, }; if (decision === "COMPLETED") { evScreen.push({ ...base, screen: "completed", reason: "completed - in-flight evidence; kept as EXECUTE in the derived grid", }); } else if (!p) { evScreen.push({ ...base, screen: "no-prior", reason: "no entry in ars-priors.json - not screenable", }); } else if (offProjectType(p)) { evScreen.push({ ...base, screen: "project-type", reason: `project is ${String(projectType)} - the stage's compiled condition restricts it to ${(p.projectTypes ?? []).join("/")} projects`, }); } else if (p.role === "initialization") { evScreen.push({ ...base, screen: "initialization", reason: "initialization - always runs" }); } else if (p.role === "core") { evScreen.push({ ...base, screen: "core", reason: "spine - always (core implementation / verification)" }); } else if (p.role === "phase-gate") { evScreen.push({ ...base, screen: "phase-gate", reason: decision === "EXECUTE" ? `phase gate - other ${s.phase} stages execute, so the boundary exists` : `phase gate - every other ${s.phase} stage folds away, so the boundary does not exist`, }); } else if (p.role === "structural") { evScreen.push({ ...base, screen: "structural", reason: "structural (decomposition) - not numerically screenable; mechanical default SKIP, human judgment at the gate", }); } else if (p.cost === null) { evScreen.push({ ...base, screen: "no-cost-prior", reason: "no cost prior in the shipped table - not numerically screenable; human judgment at the gate", }); } else { const maxSym = p.targets.reduce((a, b) => (scores[a] >= scores[b] ? a : b)); const maxTarget = scores[maxSym]; const threshold = priors.evThresholds[String(p.cost)]; evScreen.push({ ...base, maxTargetScore: maxTarget, threshold, screen: "component", reason: decision === "EXECUTE" ? `reduces ${sym(maxSym)}=${fmt(maxTarget)} > threshold ${threshold} (cost ${p.cost})` : `max target ${sym(maxSym)}=${fmt(maxTarget)} <= threshold ${threshold} (cost ${p.cost})`, }); } } const screenGrid: Record = {}; for (const s of graph) { screenGrid[s.slug] = decisionOf.get(s.slug) === "SKIP" ? "SKIP" : "EXECUTE"; } // Nearest stock scopes by grid diff count against the mechanical screen // grid. The composer's folded grid may differ - this is the deterministic // starting signal, not the proposal. const nearestScopes = nearestStockScopes(screenGrid); const arsScores = [ "| Component | Symbol | Score | Band |", "|-----------|--------|-------|------|", ...ARS_COMPONENTS.map( (c) => `| ${components[c].name} | ${sym(c)} | ${fmt(scores[c])} | ${components[c].band} |` ), `| **Composite ARS (advisory)** | - | **${total} / 100** | **${compositeBand.label}** |`, ].join("\n"); const stageDecisions = [ "| # | Stage | Decision | Reasoning |", "|---|-------|----------|-----------|", ...evScreen.map((r) => `| ${r.number} | ${r.stage} | ${r.decision} | ${r.reason} |`), ].join("\n"); return { schemaVersion: 1, components, composite: { raw, total, label: compositeBand.label, shape: compositeBand.shape }, evScreen, screenGrid, nearestScopes, completed, projectType: opts?.projectType ?? null, tables: { arsScores, stageDecisions }, }; } const COMMANDS: Record = { artifacts: () => { for (const name of [...artifactsRegistry()].sort()) { console.log(name); } }, producers: (args) => { printSlugs(producersOf(requireArg(args, "artifact"))); }, consumers: (args) => { printSlugs(consumersOf(requireArg(args, "artifact"))); }, topo: () => { for (const slug of topoSort(loadGraph())) console.log(slug); }, cycles: (args) => { // `cycles` -> full graph; `cycles --scope ` -> per-scope sub-DAG. const scopeIdx = args.indexOf("--scope"); const stages = scopeIdx >= 0 && args[scopeIdx + 1] ? subgraphForScope(args[scopeIdx + 1]) : loadGraph(); const cs = findCycles(stages); if (cs.length === 0) return; for (const c of cs) console.log(c.join(" -> ")); process.exit(1); }, scope: (args) => { printSlugs(subgraphForScope(requireArg(args, "scope"))); }, "validate-scope": (args) => { const r = validateScope(requireArg(args, "scope")); for (const a of r.advisories) console.error(`[advisory] ${a}`); if (!r.valid) { for (const e of r.errors) console.error(`[error] ${e}`); process.exit(1); } }, // ars --iae --csu --ve --r --ua [--completed ] // [--project-type ] - the deterministic ARS arithmetic: weighted // composite + band labels, the per-stage EV screen against the cost-prior // table, nearest stock scopes by grid diff count, and the two gate tables // pre-rendered as markdown - all constants read from // tools/data/ars-priors.json. The composer scores the five components from // evidence, runs this, and copies the output verbatim; a model never does // the multiplication. --project-type screens out the stages whose compiled // condition restricts them to the other kind of project (greenfield -> // reverse-engineering SKIPs) so the mechanical screen cannot contradict a // stage's own execution condition. Prints a JSON ArsResult on stdout; exit // 1 on out-of-range scores, unknown stage slugs (same typo discipline as // validate-grid), or a priors-schema violation. The composite is advisory: // nothing deterministic routes on it. ars: (args) => { const scores = {} as Record; for (const c of ARS_COMPONENTS) { const rawScore = requireFlag(args, `--${c}`); const v = Number(rawScore); if (rawScore.trim() === "" || !Number.isFinite(v) || v < 0 || v > 1) { console.error(`ars: --${c} must be a number in [0.00, 1.00] (got "${rawScore}").`); process.exit(1); } if (Number(v.toFixed(2)) !== v) { console.error(`ars: --${c} must have at most two decimals (got "${rawScore}").`); process.exit(1); } scores[c] = v; } const compIdx = args.indexOf("--completed"); const compRaw = compIdx >= 0 ? args[compIdx + 1] : undefined; if (compIdx >= 0 && (compRaw === undefined || compRaw.startsWith("--"))) { console.error("ars: --completed requires a comma-separated value."); process.exit(1); } const completed = compRaw === undefined ? undefined : compRaw.split(",").map((s) => s.trim()).filter(Boolean); const ptIdx = args.indexOf("--project-type"); const ptRaw = ptIdx >= 0 ? args[ptIdx + 1] : undefined; // Same shape as the --completed guard one screen up: a trailing flag with // no value must not fall through to "unset". Silently ignoring it would // report EXECUTE for a stage the caller believes the screen excluded. // (A flag-as-value, `--project-type --completed x`, is already rejected by // the enum check below, which names what it read.) if (ptIdx >= 0 && ptRaw === undefined) { console.error("ars: --project-type requires a value (brownfield or greenfield)."); process.exit(1); } let projectType: ArsProjectType | undefined; if (ptRaw !== undefined) { const lowered = ptRaw.toLowerCase(); if (lowered !== "brownfield" && lowered !== "greenfield") { console.error(`ars: --project-type must be brownfield or greenfield (got "${ptRaw}").`); process.exit(1); } projectType = lowered; } const result = computeArs(scores, { completed, projectType }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); }, // validate-grid --proposal [--strict] [--project-type ] // [--keywords ] - validate an ARBITRARY {slug: EXECUTE|SKIP} grid // (the composer's proposal JSON; also accepts a { stages: {...} } wrapper // matching a scope-grid entry). Lenient mode mirrors validate-scope // (off-path producer of a required consume = advisory); --strict is the // recompose mode that REJECTS a starved required input. --keywords checks // each granted keyword against the keywords already claimed by existing // scopes (the same loadScopeMapping data inference reads): a collision is // a hard ERROR naming the colliding scope, because inference takes the // first alphabetical keyword match and a duplicate would permanently // shadow the incumbent. Prints a JSON ScopeValidation on stdout; exit 1 // iff invalid - callers branch on the exit code and read the reasons off // stdout. "validate-grid": (args) => { const proposalPath = requireFlag(args, "--proposal"); const strict = args.includes("--strict"); const kwIdx = args.indexOf("--keywords"); const kwRaw = kwIdx >= 0 ? args[kwIdx + 1] : undefined; if (kwIdx >= 0 && (kwRaw === undefined || kwRaw.startsWith("--"))) { console.error("validate-grid: --keywords requires a comma-separated value."); process.exit(1); } const ptIdx = args.indexOf("--project-type"); const ptRaw = ptIdx >= 0 ? args[ptIdx + 1] : undefined; let projectType: "brownfield" | "greenfield" | undefined; if (ptRaw !== undefined) { const lowered = ptRaw.toLowerCase(); if (lowered !== "brownfield" && lowered !== "greenfield") { console.error( `validate-grid: --project-type must be brownfield or greenfield (got "${ptRaw}").` ); process.exit(1); } projectType = lowered; } let parsed: unknown; try { parsed = JSON.parse(readFileSync(proposalPath, "utf-8")); } catch (err) { console.error(`validate-grid: cannot read ${proposalPath}: ${errorMessage(err)}`); process.exit(1); } // Accept either the bare {slug: action} map or a {stages: {...}} wrapper // (the shape of a scope-grid.json entry / the composer's proposal.grid). const obj = parsed as Record; const gridRaw = obj !== null && typeof obj === "object" && typeof obj.stages === "object" && obj.stages !== null ? (obj.stages as Record) : obj; if (gridRaw === null || typeof gridRaw !== "object" || Array.isArray(gridRaw)) { console.error( "validate-grid: proposal must be a JSON object of {\"\": \"EXECUTE\"|\"SKIP\"} (or {stages: {...}})." ); process.exit(1); } const grid: Record = {}; for (const [slug, action] of Object.entries(gridRaw)) grid[slug] = String(action); const r = validateGrid(grid, { strict, projectType }); if (kwRaw !== undefined) { const granted = kwRaw.split(",").map((k) => k.trim()).filter(Boolean); for (const err of keywordCollisions(granted)) r.errors.push(err); } // The composer's Change Control proposal rides with the grid: `--change-control // ` or a `changeControl` member beside `stages`. It must be one of the // two values, and a memory layer that declares strict refuses a relaxed // proposal here, before the gate, naming that file. const ccIdx = args.indexOf("--change-control"); const ccRaw = ccIdx >= 0 ? args[ccIdx + 1] : typeof obj.changeControl === "string" ? obj.changeControl : undefined; if (ccIdx >= 0 && (ccRaw === undefined || ccRaw.startsWith("--"))) { console.error("validate-grid: --change-control requires ."); process.exit(1); } if (ccRaw !== undefined) { const changeControl = parseChangeControl(ccRaw); if (changeControl === null) { r.errors.push( `Change Control must be one of: ${CHANGE_CONTROL_VALUES.join(", ")} (got "${ccRaw}").`, ); } else { r.change_control = changeControl; if (changeControl === "relaxed") { const projectDir = resolveProjectDir(); const intentIdx = args.indexOf("--intent"); const spaceIdx = args.indexOf("--space"); const memoryStrict = memoryChangeControlDeclarations(projectDir, { intent: intentIdx >= 0 ? args[intentIdx + 1] : undefined, space: spaceIdx >= 0 ? args[spaceIdx + 1] : undefined, }).find((declaration) => declaration.value === "strict"); if (memoryStrict) r.errors.push(changeControlMemoryStrictRefusal(memoryStrict)); } } } r.valid = r.errors.length === 0; process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); if (!r.valid) process.exit(1); }, compile: (args) => { if (args.includes("--check")) return runCompileCheck(); // Concurrency-safe write per the explainer's "from day one" stance: // - withAuditLock serialises concurrent compiles. The second waits // for the first; both run against fresh source state. // - writeFileAtomic (temp + POSIX rename) means readers always see // either the previous output or the new one, never a half-written // file. Crash mid-write leaves stage-graph.json intact. // Both compiled artifacts (stage-graph.json + the transposed // scope-grid.json) are derived from the same in-memory stages and // written under the one lock so they never diverge. const pd = resolveProjectDir(); requireInstalledHarness(pd); const writeCompiledGraph = (): void => { const { json, gridJson } = compileStageGraph(); writeFileAtomic(mutableStageGraphPath(pd), json); writeFileAtomic(mutableScopeGridPath(pd), gridJson); }; const inheritedOwnerRaw = process.env.AIDLC_WORKSPACE_LOCK_OWNER_PID; if (inheritedOwnerRaw !== undefined) { const inheritedOwner = Number(inheritedOwnerRaw); if ( inheritedOwner !== process.ppid || !auditLockOwnedByProcess(pd, inheritedOwner) ) { throw new Error( "Refusing inherited workspace lock: the declared owner is not this process's live parent lock holder." ); } writeCompiledGraph(); } else { withAuditLock(pd, writeCompiledGraph); } }, resolve: (args) => { // resolve — emit the active scope's plan (.aidlc-plan.json) to // the project dir. The plan is the EXECUTE/SKIP slice for the scope, // derived from the compiled grid (the same transpose runtime reads). // Feature-flagged via AIDLC_GRAPH_RESOLVE=1 so it ships // behind a gate until the orchestrator opts into engine-side resolution. if (process.env.AIDLC_GRAPH_RESOLVE !== "1") { console.error( "aidlc-graph resolve is gated behind AIDLC_GRAPH_RESOLVE=1 (rollout flag)." ); process.exit(1); } const scope = requireArg(args, "scope"); const plan = resolvePlanForScope(scope); const pd = resolveProjectDir(); const outPath = process.env.AIDLC_PLAN_PATH ?? planFilePath(pd); const planJson = `${JSON.stringify(plan, null, 2)}\n`; if (args.includes("--stdout")) { process.stdout.write(planJson); return; } writeFileAtomic(outPath, planJson); console.log(outPath); }, export: (args) => { const json = canonicalExportJson(exportBundle()); if (args.includes("--check")) { const fixturePath = exportFixturePath(); let expected: string; try { expected = readFileSync(fixturePath, "utf-8"); } catch { console.error(`export --check: fixture not found at ${fixturePath}`); process.exit(1); } if (json !== expected) { console.error( `export --check: bundle drift vs ${fixturePath}. ` + `Regenerate with: ${ aidlcToolInvocation("graph", undefined, false) } export > ${fixturePath}` ); process.exit(1); } return; } // process.stdout.write preserves the emitter's canonical trailing // newline exactly. console.log would add a second newline, breaking // byte-parity between `export > file` and `export --check` against // that file. process.stdout.write(json); }, }; /** Resolve the designer-export fixture path. Mirrors stageGraphPath()'s * env-var seam pattern so tests can point `export --check` at a tempfile * without mutating the real fixture. Repo-root is 4 levels up from * dist/claude/.claude/tools/ (tools → .claude → claude → dist → root). */ function exportFixturePath(): string { const envPath = process.env.AIDLC_EXPORT_FIXTURE; if (envPath) return envPath; const repoRoot = join(__FILE_DIR, "..", "..", "..", ".."); return join(repoRoot, "tests", "fixtures", "designer-export", "export.json"); } function printHelp(): void { const available = Object.keys(COMMANDS).sort().join(", "); console.log(`Usage: aidlc-graph Subcommands: ${available} --help, -h Show this message Common forms: aidlc-graph artifacts List all artifact slugs aidlc-graph producers Stages producing an artifact aidlc-graph consumers Stages consuming an artifact aidlc-graph topo Topological sort of full graph aidlc-graph cycles Cycle check on full graph aidlc-graph cycles --scope Cycle check on scope sub-DAG aidlc-graph scope Stages on a scope's path aidlc-graph validate-scope Validate scope dependencies aidlc-graph validate-grid --proposal [--strict] [--project-type ] [--keywords ] Validate an arbitrary EXECUTE/SKIP grid (--strict rejects a starved required input; --keywords rejects keywords an existing scope claims) aidlc-graph ars --iae --csu --ve --r --ua [--completed ] [--project-type ] Deterministic ARS arithmetic: composite + bands, per-stage EV screen, nearest stock scopes, and the two gate tables (data: tools/data/ars-priors.json) aidlc-graph compile Regenerate stage-graph.json + scope-grid.json from YAML aidlc-graph compile --check CI drift guard (exit 1 on mismatch) aidlc-graph resolve Emit .aidlc-plan.json for a scope (AIDLC_GRAPH_RESOLVE=1) aidlc-graph export Emit designer-facing bundle (stdout) aidlc-graph export --check CI drift guard against fixture See docs/reference/16-artifact-vocabulary.md for artifact rules.`); } export async function main(argv: string[]): Promise { const [cmd, ...args] = argv; if (cmd === "--help" || cmd === "-h") { printHelp(); return; } if (cmd === undefined) { // No subcommand — print usage hint to stderr and exit 1. t63 asserts // this shape (stderr-only, mentions "artifacts" to aid discovery). const available = Object.keys(COMMANDS).sort().join(", "); console.error( `Usage: aidlc-graph . Valid: ${available}. Run with --help for detail.` ); process.exit(1); } const handler = COMMANDS[cmd]; if (!handler) { const available = Object.keys(COMMANDS).sort().join(", "); console.error( `Unknown subcommand: ${cmd}. Valid: ${available}` ); process.exit(1); } try { await handler(args); } catch (err) { console.error(`aidlc-graph ${cmd}: ${errorMessage(err)}`); process.exit(1); } } if (import.meta.main) void main(process.argv.slice(2));