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

3013 lines
124 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
// (`<PHASES.indexOf(phase)>.<maxIndexInPhase + 1>` 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: [<id>]` 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<string, string[]>;
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/<space>/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/<space>/
* 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 (<ws>/<harness>/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 "<harness>/<rulesSubdir>/<f>"
* 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:
* `<projectDir>/aidlc/spaces/<space>/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:
* `<projectDir>/aidlc/spaces/<space>/memory/templates` — where SEED ships the
* `templates/` floor and a team drops `<artifact>.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 `<harness>/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 <harness>/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<string> | 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-<name>.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;
// `## <heading>` -> 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<string, string>;
}
// 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/<phase>.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/<phase>.md (the flat aidlc-phase-<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<string, number> = {
"org": 0,
"team": 1,
"project": 2,
"phase": 3,
};
/** Split a rule-file body into `## <heading>` -> 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<string, string> {
const out = new Map<string, string>();
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("<!--") && !trimmed.includes("-->")) {
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/<phase>.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: <name>` 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: <name>` 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: [<id>]` 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-<id>.md"
manifest: SensorManifest;
}
// Filename anchor — sensor manifests live at `.claude/sensors/aidlc-<id>.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<string, SensorFile> {
const dir = sensorsDir();
const out = new Map<string, SensorFile>();
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<string, SensorFile>,
): 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<string, number>();
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<string, number>();
const lowlink = new Map<string, number>();
const onStack = new Set<string>();
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<string, "EXECUTE" | "SKIP">
): 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<string, "EXECUTE" | "SKIP"> = {};
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<string, string>,
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<string, "EXECUTE" | "SKIP">,
);
// 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<string, "EXECUTE" | "SKIP">,
);
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<string> {
const names = new Set<string>();
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<string> {
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<string, ScopeDefinition>;
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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
// `{ <scope>: { stages: { <slug>: "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-<name>.md`, not here.
export interface ScopeGrid {
[scope: string]: { stages: Record<string, "EXECUTE" | "SKIP"> };
}
/** 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<string>,
): ScopeGrid {
const scopeNames = new Set<string>();
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<string, "EXECUTE" | "SKIP"> = {};
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<string>,
): 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<string, unknown>)) {
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<string>,
): ReadonlySet<string> {
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<string, unknown>)
.filter((name) => !stockScopeNames.has(name))
.sort(),
);
}
function stageDeclaredScopeNames(stages: readonly Pick<GraphStage, "scopes">[]): ReadonlySet<string> {
const names = new Set<string>();
for (const stage of stages) {
for (const name of stage.scopes ?? []) names.add(name);
}
return names;
}
function filterScopeGrid(
grid: ScopeGrid,
allowedScopes: ReadonlySet<string> | null,
exemptScopes: ReadonlySet<string> = 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<string> | 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
* `<phase>/<slug>.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 `<phase>/<slug>.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<string>();
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<GraphStage, "plugin">): 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<string, GraphStage[]>();
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<number, number>();
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<number, NewStageSeed[]>();
// Track slug-to-first-file so duplicate-slug errors name both files.
const slugToFile = new Map<string, string>();
type StageDeclaration = { file: string; slug: string };
const artifactProducers = new Map<string, StageDeclaration[]>();
const artifactConsumers = new Map<string, StageDeclaration[]>();
// 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<string, unknown>;
try {
parsed = parseStageFrontmatter(raw) as Record<string, unknown>;
} 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-<slug>` for core
// but the bare slug for plugin stages, so a plugin named `aidlc-<x>`
// generates runner paths identical to core's and silently clobbers them
// (/aidlc-<x>-... 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-<x> 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> | 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 <value>). 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} <value>`);
}
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<ArsComponent, number>;
componentInfo: Record<ArsComponent, { name: string }>;
componentBands: { lowMax: number; medMax: number };
compositeBands: Array<{ min: number; max: number; label: string; shape: string }>;
evThresholds: Record<string, number>;
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<ArsComponent, { name: string; score: number; band: ArsBand }>;
composite: { raw: number; total: number; label: string; shape: string };
evScreen: ArsScreenRow[];
screenGrid: Record<string, "EXECUTE" | "SKIP">;
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<ArsComponent, number>,
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<string, ArsDecision>();
const deferred = new Set<string>();
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<string, "EXECUTE" | "SKIP"> = {};
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<string, Handler> = {
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 <name>` -> 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 <s> --csu <s> --ve <s> --r <s> --ua <s> [--completed <csv>]
// [--project-type <bg>] - 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<ArsComponent, number>;
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 <path> [--strict] [--project-type <bg>]
// [--keywords <csv>] - 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<string, unknown>;
const gridRaw =
obj !== null && typeof obj === "object" && typeof obj.stages === "object" && obj.stages !== null
? (obj.stages as Record<string, unknown>)
: obj;
if (gridRaw === null || typeof gridRaw !== "object" || Array.isArray(gridRaw)) {
console.error(
"validate-grid: proposal must be a JSON object of {\"<stage-slug>\": \"EXECUTE\"|\"SKIP\"} (or {stages: {...}})."
);
process.exit(1);
}
const grid: Record<string, string> = {};
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
// <value>` 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 <strict|relaxed>.");
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 <scope> — 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 <subcommand>
Subcommands:
${available}
--help, -h Show this message
Common forms:
aidlc-graph artifacts List all artifact slugs
aidlc-graph producers <artifact> Stages producing an artifact
aidlc-graph consumers <artifact> Stages consuming an artifact
aidlc-graph topo Topological sort of full graph
aidlc-graph cycles Cycle check on full graph
aidlc-graph cycles --scope <name> Cycle check on scope sub-DAG
aidlc-graph scope <name> Stages on a scope's path
aidlc-graph validate-scope <name> Validate scope dependencies
aidlc-graph validate-grid --proposal <path> [--strict] [--project-type <t>] [--keywords <csv>]
Validate an arbitrary EXECUTE/SKIP grid
(--strict rejects a starved required input;
--keywords rejects keywords an existing scope claims)
aidlc-graph ars --iae <s> --csu <s> --ve <s> --r <s> --ua <s> [--completed <csv>] [--project-type <t>]
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 <name> 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<void> {
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 <subcommand>. 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));