286 lines
14 KiB
TypeScript
286 lines
14 KiB
TypeScript
// 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<Tier, TierProjection> = {
|
|
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<Record<Tier, KiroEffort>> = {};
|
|
|
|
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<H extends Harness>(
|
|
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<string, KiroEffort> {
|
|
const out: Record<string, KiroEffort> = {};
|
|
// 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;
|
|
}
|