// core/tools/aidlc-tiers.ts - the tunable-tier projection module. // // A per-agent TIER names HOW MUCH JUDGMENT the persona's work demands, and the // packager projects that single authored fact into each harness's native model // and effort knobs. `judgment` marks multi-constraint reasoning under ambiguity // whose output cascades downstream (architect, developer, product, ...): it // inherits the session's own model and effort so the user's ceiling is never // silently capped. `balanced` marks reviewer-shaped work (novel input judged // against explicit criteria): the measured reviewer baseline pins a mid-size // model at medium effort. `templated` marks dominantly pattern-following output // whose methodology already lives in knowledge (delivery plans, CI/CD config, // runbooks). It remains a distinct models-dial group, but its shipped baseline // now inherits the session model and effort. The names describe the WORK, not // the dial, so a reader can classify a new agent without knowing today's model // lineup. // // Projection targets (see TIER_PROJECTIONS): // - Claude Code agent .md frontmatter: `model:` and optional `effort:`. // An OMITTED key inherits the session value, and a pinned // `effort:` overrides the session in both directions - a pin // is a cap, not a floor. `judgment` and `templated` write // `model: inherit` and NO effort line; `balanced` writes // `model: sonnet` with `effort: medium`. // - Codex CLI agent role .toml: `model` and `model_reasoning_effort`. // Omitted keys fall back to the shipped .codex/config.toml // session defaults (live-verified on codex-cli 0.139.0 and // 0.142.5: a role TOML without `model` spawns on the // config.toml model + effort). `judgment` // omits both keys. // - Kiro CLI/IDE every tier omits `"model"` — Kiro agents INHERIT the // session model (a shipped model ID resolves only when that // model is enabled on the user's install; a session on // another model rejects every delegated spawn with // "Invalid model ID", and Kiro also rejects the // Claude-dialect aliases outright, so there is no safe // pinnable value). The agent-v1 schema documents the // fallback: "If not specified, uses the default model" — // the session model at its own default effort. kiro-cli // also fail-closes on any effort-like key in agent JSON, // so no Kiro agent surface may EVER carry one; a per-model // effort default can only ride on cli.json // chat.modelDefaults (see kiroModelDefaults below). // // Kiro collapse rule (dormant while no tier pins a Kiro model): two tiers // whose Kiro model IDs are equal are the same tier on Kiro (there is no // per-agent effort surface to tell them apart). When tiers share a model, // the cli.json chat.modelDefaults entry for that model takes the HIGHER // tier's effort - kiroModelDefaults() computes this. Today no tier pins a // Kiro model, so kiroModelDefaults() contributes no entries and only the // authored cli.json entries ship. // // Tier-ceiling override, resolved at PACK time (runtime composition is out of // scope): the space-memory `tier_cap:` frontmatter key on the layered method // files (org.md -> team.md -> project.md, last writer wins) is the persistent // project knob, and the AIDLC_TIER_CAP env var is the per-invocation override // that beats it. Setting the cap to `balanced` selects the measured reviewer // baseline for judgment work; `templated` selects the inheriting Writing up // projection for both higher tiers. Use `aidlc config models` for an explicit // per-install cost policy. See resolveTierCap(). import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; /** The tier vocabulary, ordered HIGH to LOW. The order is load-bearing: * capTier() clamps by index, so index 0 is the top rung. */ export const TIERS = ["judgment", "balanced", "templated"] as const; export type Tier = (typeof TIERS)[number]; /** Claude Code agent-frontmatter effort values (sub-agent contract). */ export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max"; /** Codex model_reasoning_effort values (config.toml contract). */ export type CodexEffort = "low" | "medium" | "high" | "xhigh"; /** Kiro effort values (chat.modelDefaults / --effort contract). */ export type KiroEffort = "low" | "medium" | "high" | "xhigh" | "max"; /** opencode agent-frontmatter `variant` values (provider-specific reasoning * effort; the Anthropic-on-Bedrock provider accepts these). */ export type OpencodeVariant = "low" | "medium" | "high" | "max"; /** Per-harness projection of one tier. A `null` model or effort means the * harness-native key is OMITTED so the harness's own session/config default * applies ("inherit" on Claude is an explicit frontmatter value, so it stays * a string there). The kiro slot is model-only BY DESIGN: kiro-cli rejects * effort-like keys in agent surfaces (fail-closed schema), so the type makes * an effort leak structurally impossible - Kiro effort lives in * KIRO_TIER_EFFORT and reaches users via cli.json, never the agent files. */ export type TierProjection = { claude: { model: string; effort: ClaudeEffort | null }; codex: { model: string | null; effort: CodexEffort | null }; kiro: { model: string | null }; /** opencode agent .md frontmatter: `model:` ("provider/model-id") and * optional `variant:` (reasoning effort). Omitted keys inherit the * session's opencode.json defaults — same inherit-by-omission contract * as codex. */ opencode: { model: string | null; variant: OpencodeVariant | null }; /** Copilot CLI + VS Code agent mode share one dist (one .github/ tree), and * the model slot is model-only AND always omitted BY DESIGN, like kiro: * the two surfaces disagree on `model:` value syntax (the CLI forwards the * frontmatter string verbatim to the BYOK provider - an IDE display name * like "Claude Sonnet 5" is a live-verified 400 there - while the IDE * silently skips CLI catalog ids), so there is no safe pinnable value. * Agents inherit the session model (BYOK env on the CLI, the model picker * on the IDE); the type makes a model leak structurally impossible. */ copilot: { model: null }; /** Cursor agent .md frontmatter: `model:` (Cursor model id, e.g. * "claude-opus-5-medium"). Model-only BY DESIGN, like kiro: Cursor has no * effort key in agent frontmatter (effort rides the model id suffix). All * tiers ship null — model availability is Cursor-plan-dependent (Free * accounts reject every named model), so a pinned id would hard-fail * installs on lower plans; agents inherit the session model instead. */ cursor: { model: string | null }; }; export type Harness = keyof TierProjection; /** The projection table. Tune here; every harness moves in lock-step. */ export const TIER_PROJECTIONS: Record = { judgment: { // The session's model AND effort win: `inherit` follows the session model // (a Fable session keeps Fable), and the omitted effort key follows the // session effort. The framework never silently downgrades judgment work. claude: { model: "inherit", effort: null }, codex: { model: null, effort: null }, kiro: { model: null }, opencode: { model: null, variant: null }, copilot: { model: null }, cursor: { model: null }, }, balanced: { // Effort pinned to medium (was: inherit the session effort). Balanced is // the reviewer tier - both review-only agents carry it, nothing else does // - and live A/B runs showed a medium review pass at ~half the wall-clock // of an xhigh-inheriting one with no verdict/finding quality loss. A // session pinned to xhigh was silently doubling every review's cost. claude: { model: "sonnet", effort: "medium" }, codex: { model: "openai.gpt-5.6-terra", effort: "medium" }, cursor: { model: null }, kiro: { model: null }, opencode: { model: "amazon-bedrock/global.anthropic.claude-sonnet-4-6", variant: "medium" }, copilot: { model: null }, }, templated: { // The tier remains a models-dial group for pattern-following work, but the // shipped baseline inherits. Users who want a lower writing-up baseline // record that per install through `aidlc config models`. claude: { model: "inherit", effort: null }, codex: { model: null, effort: null }, kiro: { model: null }, opencode: { model: null, variant: null }, copilot: { model: null }, cursor: { model: null }, }, }; /** Kiro effort per tier - used ONLY to derive cli.json chat.modelDefaults * entries (effort rides on the model on Kiro, never on the agent). Kept out * of TierProjection so no agent-surface writer can reach it. EMPTY today: * no tier pins a Kiro model (Kiro agents inherit the session model), so * there is no model entry to carry a tier effort - every agent runs at * the session model's own default effort. If a tier ever pins a Kiro model * again, add its effort here and kiroModelDefaults() resumes emitting. */ export const KIRO_TIER_EFFORT: Partial> = {}; export function isTier(v: string): v is Tier { return (TIERS as readonly string[]).includes(v); } /** Clamp tier `t` to the ceiling `cap` (both-null-safe). TIERS is ordered * high to low, so the clamped tier is the one with the LARGER index. */ export function capTier(t: Tier, cap: Tier | null | undefined): Tier { if (!cap) return t; return TIERS[Math.max(TIERS.indexOf(t), TIERS.indexOf(cap))]; } /** Read the AIDLC_TIER_CAP env var. Unset/empty -> null; an unknown value is * a loud error (the packager must fail, not silently ship uncapped). */ export function readEnvCap(env: NodeJS.ProcessEnv = process.env): Tier | null { const v = env.AIDLC_TIER_CAP; if (!v) return null; if (isTier(v)) return v; throw new Error( `AIDLC_TIER_CAP=${JSON.stringify(v)} is not a valid tier; use one of ${TIERS.join(", ")}`, ); } // The layered method files a tier_cap: may ride on, in precedence order // (later files override earlier ones - the same last-writer-wins order the // rule resolver applies to org -> team -> project). const MEMORY_CAP_FILES = ["org.md", "team.md", "project.md"] as const; /** Extract a `tier_cap:` scalar from a method file's YAML frontmatter block. * Returns null when the file has no frontmatter or no tier_cap: line. A * PRESENT key with an empty or invalid value throws, naming the file - a * user who wrote the key believes the cap is active, so silently ignoring a * malformed value would ship uncapped agents without any error. Tolerates * the common YAML scalar spellings: quoted values and trailing comments. */ function tierCapFromFrontmatter(raw: string, file: string): Tier | null { const cleaned = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; const m = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return null; const kv = m[1].match(/^tier_cap:(.*)$/m); if (!kv) return null; // Strip a trailing comment, whitespace, and matching quotes. let v = kv[1].replace(/\s#.*$/, "").trim(); if ( (v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2) ) { v = v.slice(1, -1).trim(); } if (isTier(v)) return v; throw new Error( `${file}: tier_cap: ${JSON.stringify(v)} is not a valid tier; use one of ${TIERS.join(", ")}`, ); } /** Read the persistent tier cap from the space memory layer: org.md, team.md, * project.md under `memoryDir`, in that order, LAST writer wins (a project * may lower OR raise the org ceiling). Missing dir or files -> null. */ export function readMemoryCap(memoryDir: string): Tier | null { let cap: Tier | null = null; for (const f of MEMORY_CAP_FILES) { const p = join(memoryDir, f); if (!existsSync(p)) continue; const found = tierCapFromFrontmatter(readFileSync(p, "utf-8"), p); if (found) cap = found; } return cap; } /** The effective pack-time cap: the AIDLC_TIER_CAP env var (per-invocation) * beats the space-memory `tier_cap:` key (persistent), which itself resolves * org -> team -> project, last writer wins. */ export function resolveTierCap( memoryDir: string, env: NodeJS.ProcessEnv = process.env, ): Tier | null { return readEnvCap(env) ?? readMemoryCap(memoryDir); } /** Project one tier onto one harness, applying the cap. This is the ONE seam * the packager and the codex emit call; every harness gets an identically * derived projection. Throws on an unknown tier string so a typo in agent * frontmatter fails the build loudly. */ export function projectTier( t: string, harness: H, cap: Tier | null = null, ): TierProjection[H] { if (!isTier(t)) { throw new Error(`unknown tier ${JSON.stringify(t)}; use one of ${TIERS.join(", ")}`); } return TIER_PROJECTIONS[capTier(t, cap)][harness]; } /** Derive the Kiro cli.json chat.modelDefaults entries the tier table needs: * one entry per DISTINCT pinned Kiro model, carrying the HIGHEST sharing * tier's effort (the Kiro collapse rule - when tiers share a model there is * no per-agent surface to tell them apart, so the more demanding tier's * effort wins). Tiers with no pinned Kiro model (judgment) contribute no * entry. NOTE: the orchestrator's own model entry (claude-opus-4.8 -> * xhigh) is authored in the per-harness kiro settings cli.json, outside * this table - the orchestrator agent is not a tier-carrying persona. */ export function kiroModelDefaults( cap: Tier | null = null, additions: readonly { model: string; effort: KiroEffort }[] = [], ): Record { const out: Record = {}; // TIERS is ordered high to low, so the first tier to claim a model wins - // exactly the "higher tier's effort" collapse rule. for (const tier of TIERS) { const model = TIER_PROJECTIONS[capTier(tier, cap)].kiro.model; const effort = KIRO_TIER_EFFORT[capTier(tier, cap)]; if (!model || !effort) continue; if (!(model in out)) out[model] = effort; } const effortOrder: readonly KiroEffort[] = ["low", "medium", "high", "xhigh", "max"]; for (const { model, effort } of additions) { const current = out[model]; if (!current || effortOrder.indexOf(effort) > effortOrder.indexOf(current)) { out[model] = effort; } } return out; }