jeo-code 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,7 +48,7 @@
48
48
  * contract for users who never opted in.
49
49
  */
50
50
  import { callLlm } from "./loop";
51
- import { resolveRoleModel, modelServableWithConfig } from "../ai/model-manager";
51
+ import { resolveRoleModel, modelServableWithConfig, resolveProvider, oauthServesModel } from "../ai/model-manager";
52
52
  import { catalogMetadata, MODEL_CATALOG, compareReleaseDate, liveProviderCatalogModels, isLiveProviderModel, type CatalogModel, type ThinkLevel } from "../ai/model-catalog";
53
53
  import { priceForModel } from "../ai/pricing";
54
54
  import { tryExtractJsonObject } from "./json";
@@ -222,23 +222,97 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
222
222
  return !!(config.oauth?.antigravity || config.oauth?.gemini);
223
223
  }
224
224
 
225
+ /** A model's classified credential-sharing SCOPE — models on the SAME scope share
226
+ * ONE account-wide rate-limit/billing window, so an account-level failure (429,
227
+ * usage/quota limit, credential rejection, billing block) on one model means every
228
+ * OTHER model in the same scope is doomed the same way THIS instant. `"oauth-subscription"`:
229
+ * an OAuth SUBSCRIPTION (Claude Pro/Max, ChatGPT/Codex, Kimi Code, Antigravity Cloud
230
+ * Code Assist) serves this model — `key` is `${provider}:oauth` (bare `"antigravity"`
231
+ * for that provider: all three re-exported companies route through the SAME Cloud
232
+ * Code Assist account/credential, confirmed no per-company quota separation exists).
233
+ * `"no-credential"`: the provider has ZERO usable credential of any kind — every
234
+ * model on it is equally dead. */
235
+ export type CredentialScope = { kind: "no-credential" | "oauth-subscription"; key: string };
236
+
237
+ /** Classify `model` into the credential it actually resolves through, mirroring
238
+ * `resolveCall`/`effectiveCredentialForProvider`'s real precedence (OAuth wins over
239
+ * a configured API key whenever it serves the model) WITHOUT touching the network —
240
+ * config-only, so this stays safely callable from a synchronous fast-path predicate
241
+ * (e.g. the engine's per-retry `rateLimitFallbackAvailable` check).
242
+ *
243
+ * `null` = API-key-served (or keyless/local) — an API key is typically its OWN
244
+ * independent per-key/per-model budget (Groq/OpenAI/etc rate-limit each model
245
+ * independently, unlike an OAuth subscription's single shared window), so a `null`
246
+ * candidate is NEVER excluded as a group — only its exact model id is excluded on
247
+ * failure, preserving the intentionally-tested "same-provider API-key fallback"
248
+ * behavior (see test/launch-fallback-live-discovery.test.ts). */
249
+ export function credentialScopeFor(model: string, config: RoutingConfig): CredentialScope | null {
250
+ const provider = resolveProvider(model);
251
+ if (provider === "ollama" || provider === "lmstudio") return null; // keyless, no shared account limit
252
+ if (provider === "antigravity") {
253
+ const hasOauth = !!(config.oauth?.antigravity || config.oauth?.gemini);
254
+ return hasOauth ? { kind: "oauth-subscription", key: "antigravity" } : { kind: "no-credential", key: "antigravity" };
255
+ }
256
+ const auth = provider as AuthProvider;
257
+ const hasApiKey = !!config.providers?.[auth];
258
+ const hasOauth = !!config.oauth?.[auth];
259
+ if (!hasApiKey && !hasOauth) return { kind: "no-credential", key: provider };
260
+ // OAuth wins over a configured API key whenever it actually serves this model
261
+ // (same precedence resolveCall/effectiveCredentialForProvider apply live) — that
262
+ // is the credential this call will really use, and its subscription window is
263
+ // what a 429 here reports on. When OAuth is present but does NOT serve this
264
+ // model (e.g. a non-Codex id under OpenAI OAuth), the call falls through to the
265
+ // API key (if any) — API-key-served, so `null` (independent budget).
266
+ if (hasOauth && oauthServesModel(auth, model)) return { kind: "oauth-subscription", key: `${provider}:oauth` };
267
+ // Defense-in-depth (currently unreachable in practice — every OAuth provider
268
+ // whose token doesn't serve a given model AND has no verified end-to-end backend
269
+ // is already excluded upstream from every auto-select pool, per
270
+ // isCoreEligible/hasAntigravityOauth): OAuth is configured, has NO API key
271
+ // fallback, and does not actually serve this model (e.g. a bare `gemini`-provider
272
+ // model under gemini-OAuth-only, since gemini's OAuth backend is not verified
273
+ // end-to-end) — genuinely zero usable credential for this specific model, not an
274
+ // independent API-key budget.
275
+ if (hasOauth && !hasApiKey) return { kind: "no-credential", key: provider };
276
+ return null;
277
+ }
278
+
279
+ /** Builds the routing catalog once per call: static `MODEL_CATALOG` rows need the
280
+ * full `isAutoSelectCandidate` gate (including the `isLiveProviderModel` check,
281
+ * when `openaiBaseUrl` is set), but rows already returned by
282
+ * `liveProviderCatalogModels()` are live BY CONSTRUCTION — re-verifying each one
283
+ * with `isLiveProviderModel` is pure O(n) waste (it dominated this function's
284
+ * cost: ~1.4ms/call at 2000 live models). Live rows still need the shared
285
+ * provider/OAuth gates (cloud provider, credential, antigravity/gemini), just not
286
+ * the live-membership check specific to static rows. */
225
287
  function routingCatalog(config: RoutingConfig): CatalogModel[] {
226
- const rows = [...MODEL_CATALOG, ...liveProviderCatalogModels(config)];
227
- const eligible = rows.filter(m => isAutoSelectCandidate(m, config));
288
+ const staticEligible = MODEL_CATALOG.filter(m => isAutoSelectCandidate(m, config));
289
+ const liveEligible = liveProviderCatalogModels(config).filter(m => isAutoSelectCandidateLive(m, config));
228
290
  const byId = new Map<string, CatalogModel>();
229
- for (const row of eligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
291
+ for (const row of staticEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
292
+ for (const row of liveEligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
230
293
  return [...byId.values()];
231
294
  }
232
295
 
233
- function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
234
- // Shared model-LEVEL credential gate (the full auth matrix is documented on
235
- // modelServableWithConfig; keep aligned with resolveCall).
296
+ /** Shared provider/OAuth eligibility gates, independent of live-membership. */
297
+ function isCoreEligible(m: CatalogModel, config: RoutingConfig): boolean {
236
298
  if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
237
- if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
238
299
  if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
239
300
  return true;
240
301
  }
241
302
 
303
+ function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
304
+ if (!isCoreEligible(m, config)) return false;
305
+ if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
306
+ return true;
307
+ }
308
+
309
+ /** Same eligibility as `isAutoSelectCandidate`, for a row ALREADY sourced from
310
+ * `liveProviderCatalogModels()` — skips the live-membership re-check (see
311
+ * `routingCatalog`'s doc comment). */
312
+ function isAutoSelectCandidateLive(m: CatalogModel, config: RoutingConfig): boolean {
313
+ return isCoreEligible(m, config);
314
+ }
315
+
242
316
 
243
317
  /** Size class jeo infers for a catalog model, used to group EQUIVALENT models
244
318
  * across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
@@ -353,17 +427,19 @@ export function selectFromPool(pool: readonly string[], sessionId: string | unde
353
427
  * model has a known price (caller falls back to `defaultModel`). Tiebreak on
354
428
  * an exact cost tie: NEWER `releaseDate` wins (a same-priced newer model is
355
429
  * likely a refined successor), then canonical id (deterministic fallback
356
- * when recency also ties or is unconfirmed on both sides). */
357
- export function cheapestCredentialed(config: RoutingConfig): string | null {
430
+ * when recency also ties or is unconfirmed on both sides). Optional `filter`
431
+ * narrows the candidate pool BEFORE price comparison (e.g. vision-capable
432
+ * only, matching `strongestCredentialed`'s filter param) — a cheap model that
433
+ * fails the filter is never picked over a pricier one that passes it. */
434
+ export function cheapestCredentialed(config: RoutingConfig, filter?: (m: CatalogModel) => boolean): string | null {
358
435
  let best: CatalogModel | null = null;
359
436
  let bestCost = Infinity;
360
437
  for (const m of routingCatalog(config)) {
361
- // Candidate eligibility is centralized in `isAutoSelectCandidate`: it keeps
362
- // OAuth-backed Antigravity routing on provider-qualified 3.1+ Gemini rows and
363
- // prevents public `gemini` catalog rows from winning when Antigravity OAuth is
364
- // the available Gemini lane. The real veto gate in launch.ts's `runTurn` still
365
- // re-verifies full readiness (including live OAuth expiry) before a turn uses it.
366
- if (!isAutoSelectCandidate(m, config)) continue;
438
+ if (filter && !filter(m)) continue;
439
+ // `routingCatalog(config)` already filtered to eligible rows (static rows via
440
+ // `isAutoSelectCandidate`, live rows via `isAutoSelectCandidateLive`) — no need
441
+ // to re-check here. See cheapestCredentialed's own doc comment for the
442
+ // Antigravity/Gemini OAuth rationale behind that eligibility gate.
367
443
  const price = priceForModel(m.canonical);
368
444
  if (!price) continue;
369
445
  const cost = price.inPerM + price.outPerM;
@@ -388,6 +464,27 @@ export function cheapestCredentialed(config: RoutingConfig): string | null {
388
464
  return best?.canonical ?? null;
389
465
  }
390
466
 
467
+ /** Cheap-tier model for a GRADER/VERIFIER call (goal-verifier, vision-verify,
468
+ * `critic` role) — the blog-post "Haiku-for-graders" principle: a verdict call
469
+ * reads an artifact + a rubric, it does not need frontier reasoning strength,
470
+ * and defaulting graders to the SAME expensive model driving the turn they are
471
+ * grading both wastes budget and (for self-graded turns) reintroduces the very
472
+ * self-preference bias an independent grader exists to avoid. Resolution order:
473
+ * `roles.smol` (explicit user pin, but only when it also satisfies `requireImages`)
474
+ * → live-cheapest credentialed (filtered the same way) → `defaultModel` (never
475
+ * null — every caller needs a concrete model id to pass to `callLlm`).
476
+ * `opts.requireImages` (the browser vision-verify caller) excludes catalog rows
477
+ * with `images: false` — a text-only cheap model silently ignores an attached
478
+ * screenshot rather than erroring, which would surface as a hallucinated verdict
479
+ * instead of a loud failure, so the filter runs BEFORE cost/pin selection, not after. */
480
+ export function resolveVerifierModel(config: RoutingConfig, opts: { requireImages?: boolean } = {}): string {
481
+ const pinned = config.roles?.smol;
482
+ const pinnedOk = pinned && (!opts.requireImages || catalogMetadata(pinned)?.images !== false);
483
+ if (pinnedOk) return pinned!;
484
+ const filter = opts.requireImages ? (m: CatalogModel) => m.images !== false : undefined;
485
+ return cheapestCredentialed(config, filter) || config.defaultModel;
486
+ }
487
+
391
488
  /** Most capable cloud model jeo has a credential for, computed LIVE off
392
489
  * `MODEL_CATALOG` — ranks by full (xhigh) thinking support first, then max
393
490
  * output tokens, then context window, then NEWER `releaseDate` (a same-specs
@@ -406,9 +503,8 @@ export function strongestCredentialed(
406
503
  ): string | null {
407
504
  let best: CatalogModel | null = null;
408
505
  for (const m of routingCatalog(config)) {
409
- // Candidate eligibility is centralized in `isAutoSelectCandidate` (see
506
+ // `routingCatalog(config)` already filtered to eligible rows (see
410
507
  // cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
411
- if (!isAutoSelectCandidate(m, config)) continue;
412
508
  if (filter && !filter(m)) continue;
413
509
  if (!best) { best = m; continue; }
414
510
  const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
@@ -433,7 +529,7 @@ export function strongestCredentialed(
433
529
  * a genuinely mid-class model like `claude-sonnet-4-6`. `null` when no
434
530
  * mid-class-suffixed model is credentialed (caller falls through to
435
531
  * `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
436
- function strongestMidTierCredentialed(config: RoutingConfig): string | null {
532
+ export function strongestMidTierCredentialed(config: RoutingConfig): string | null {
437
533
  const midClass = routingCatalog(config).filter(
438
534
  m => sizeClassFor(m) === "mid",
439
535
  );
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Skill self-compounding: the durable link the bundled/user skill docs were
3
+ * missing. `recordFailedAttempt` (memory.ts) already writes stalled turns into
4
+ * OKF concept memory, but nothing ever fed that signal BACK into the skill
5
+ * files themselves — a skill that repeatedly leads an agent into the same dead
6
+ * end never learns to warn about it. This module closes that loop:
7
+ *
8
+ * - `matchLessonToSkill` deterministically (no LLM) maps free text to the
9
+ * bundled skill it most plausibly concerns, conservatively preferring
10
+ * `undefined` over a wrong guess.
11
+ * - `appendSkillLesson`/`recordSkillLesson` append a lesson bullet into a
12
+ * `## Known Failure Modes` or `## Anti-Patterns (do NOT do)` section of the
13
+ * project-level `.jeo/skills/<name>.md` file (seeded from the bundled
14
+ * skill on first write), idempotent on exact-title re-append.
15
+ * - `evalSkillLessons` is the self-eval half: ONE batched LLM call judges
16
+ * whether each recorded lesson is still "covered" by the skill's current
17
+ * guidance or has gone "stale" (guidance drifted, entry looks obsolete),
18
+ * so a human/agent can prune or fold lessons back into the skill body.
19
+ */
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ import { SKILLS, getSkill, bundledSkillFileContent } from "../skills/catalog";
23
+ import { callLlm } from "./loop";
24
+ import { resolveVerifierModel } from "./prompt-router";
25
+ import { readGlobalConfig } from "./state";
26
+ import { tryExtractJsonObject } from "./json";
27
+
28
+ export interface SkillLesson {
29
+ skill: string;
30
+ kind: "failure-mode" | "anti-pattern";
31
+ title: string;
32
+ detail: string;
33
+ }
34
+
35
+ const SECTION_HEADER: Record<SkillLesson["kind"], string> = {
36
+ "failure-mode": "## Known Failure Modes",
37
+ "anti-pattern": "## Anti-Patterns (do NOT do)",
38
+ };
39
+
40
+ /** Hand-picked keyword sets per bundled skill, used ONLY for the deterministic
41
+ * free-text -> skill matcher below. Kept as one small record (not inline in
42
+ * the function) so the mapping is easy to audit/extend as skills are added. */
43
+ const SKILL_KEYWORDS: Record<string, string[]> = {
44
+ "deep-interview": ["ambiguity", "interview", "clarify", "clarification", "requirements", "socratic", "vague"],
45
+ "deep-dive": ["root cause", "root-cause", "diagnose", "diagnosis", "trace", "hypothesis", "causal"],
46
+ "ralplan": ["critic", "consensus", "[okay]", "[iterate]", "plan draft", "plan critique", "iterate verdict"],
47
+ "team": ["parallel_group", "worktree", "team executor", "plan step", "coordinated agents", "shared task list"],
48
+ "ultragoal": ["verification", "acceptance criteria", "goal verifier", "goal state", "verdict: met", "not_met"],
49
+ };
50
+
51
+ /**
52
+ * Deterministic (no LLM) free-text -> bundled-skill match. Scores each SKILLS
53
+ * entry by counting keyword/substring hits (the skill's own `name` plus its
54
+ * hand-picked related terms from {@link SKILL_KEYWORDS}) against the lowercased
55
+ * input text, and returns the highest-scoring skill name — or `undefined` when
56
+ * nothing scores at all, since a wrong guess is worse than no guess here.
57
+ */
58
+ export function matchLessonToSkill(text: string): string | undefined {
59
+ const haystack = text.toLowerCase();
60
+ if (!haystack.trim()) return undefined;
61
+
62
+ let bestName: string | undefined;
63
+ let bestScore = 0;
64
+ for (const skill of SKILLS) {
65
+ const terms = [skill.name.toLowerCase(), ...(SKILL_KEYWORDS[skill.name] ?? [])];
66
+ let score = 0;
67
+ for (const term of terms) {
68
+ if (term && haystack.includes(term)) score++;
69
+ }
70
+ if (score > bestScore) {
71
+ bestScore = score;
72
+ bestName = skill.name;
73
+ }
74
+ }
75
+ return bestScore > 0 ? bestName : undefined;
76
+ }
77
+
78
+ /** Project-level skill file path — `<cwd>/.jeo/skills/<name>.md`, the same
79
+ * highest-precedence directory `skillDirs()` (catalog.ts) resolves last, so a
80
+ * lesson written here transparently overrides/extends the bundled version by
81
+ * name the next time skills are loaded. */
82
+ export function skillFilePath(cwd: string, skillName: string): string {
83
+ return path.join(cwd, ".jeo", "skills", `${skillName}.md`);
84
+ }
85
+
86
+ /** Locate the target H2 section within `content`'s lines. Returns the line
87
+ * index range `[headerLine, bulletsEnd)` where `bulletsEnd` is the index to
88
+ * insert a new bullet at (immediately before the next `## ` header, or
89
+ * end-of-file, trimming any trailing blank lines inside the section so the
90
+ * new bullet lands directly after the last existing one). `null` when the
91
+ * section header isn't present yet. */
92
+ function findSection(lines: string[], header: string): { headerLine: number; insertAt: number } | null {
93
+ const headerLine = lines.findIndex(l => l.trim() === header);
94
+ if (headerLine < 0) return null;
95
+ let end = lines.length;
96
+ for (let i = headerLine + 1; i < lines.length; i++) {
97
+ if (lines[i]!.startsWith("## ")) { end = i; break; }
98
+ }
99
+ let insertAt = end;
100
+ while (insertAt > headerLine + 1 && lines[insertAt - 1]!.trim() === "") insertAt--;
101
+ return { headerLine, insertAt };
102
+ }
103
+
104
+ /** Does a `- **title**: ...` (or bare `- title...`) bullet with this exact
105
+ * title (case-insensitive) already exist between `[start, end)`? */
106
+ function sectionHasTitle(lines: string[], start: number, end: number, title: string): boolean {
107
+ const needle = title.trim().toLowerCase();
108
+ for (let i = start; i < end; i++) {
109
+ const m = lines[i]!.match(/^-\s+\*\*(.+?)\*\*\s*:/);
110
+ if (m && m[1]!.trim().toLowerCase() === needle) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Core compounding write: append `lesson` as a markdown bullet into the
117
+ * project-level skill file's failure-mode/anti-pattern section, seeding the
118
+ * file from the bundled skill's own on-disk text on first write so existing
119
+ * guidance is never lost. Idempotent on exact-title re-append (case-
120
+ * insensitive) — returns `{ appended: false, reason: "duplicate" }` rather
121
+ * than writing a second copy. Best-effort: any I/O error is caught and
122
+ * reported, never thrown (mirrors `recordFailedAttempt`'s fail-open contract).
123
+ */
124
+ export async function appendSkillLesson(cwd: string, lesson: SkillLesson): Promise<{ appended: boolean; reason?: string }> {
125
+ const title = lesson.title.trim();
126
+ if (!title) return { appended: false, reason: "empty title" };
127
+ const detail = lesson.detail.trim();
128
+ const header = SECTION_HEADER[lesson.kind];
129
+
130
+ try {
131
+ const filePath = skillFilePath(cwd, lesson.skill);
132
+ let content: string;
133
+ try {
134
+ content = await fs.readFile(filePath, "utf-8");
135
+ } catch {
136
+ const bundled = getSkill(lesson.skill);
137
+ content = bundled ? bundledSkillFileContent(bundled) : `# ${lesson.skill}\n\n`;
138
+ }
139
+
140
+ let lines = content.replace(/\r\n/g, "\n").split("\n");
141
+ let section = findSection(lines, header);
142
+ if (!section) {
143
+ // Append the section header at the end of the file (blank-line separated).
144
+ if (lines.length > 0 && lines[lines.length - 1]!.trim() !== "") lines.push("");
145
+ if (!(lines.length > 0 && lines[lines.length - 1] === "")) lines.push("");
146
+ lines.push(header, "");
147
+ section = { headerLine: lines.length - 2, insertAt: lines.length };
148
+ } else if (sectionHasTitle(lines, section.headerLine + 1, section.insertAt, title)) {
149
+ return { appended: false, reason: "duplicate" };
150
+ }
151
+
152
+ const bullet = `- **${title}**: ${detail}`;
153
+ lines.splice(section.insertAt, 0, bullet);
154
+
155
+ const finalContent = lines.join("\n").replace(/\n{3,}$/, "\n\n");
156
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
157
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
158
+ await fs.writeFile(tmpPath, finalContent, "utf-8");
159
+ await fs.rename(tmpPath, filePath);
160
+ return { appended: true };
161
+ } catch (err) {
162
+ return { appended: false, reason: err instanceof Error ? err.message : String(err) };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Convenience wrapper for the automatic (stall-guard) path: matches `freeText`
168
+ * to a bundled skill via {@link matchLessonToSkill} and, only on a match,
169
+ * writes the lesson via {@link appendSkillLesson}. Unrelated text (no skill
170
+ * match) correctly writes nothing.
171
+ */
172
+ export async function recordSkillLesson(
173
+ cwd: string,
174
+ freeText: string,
175
+ lesson: Omit<SkillLesson, "skill">,
176
+ ): Promise<{ appended: boolean; skill?: string; reason?: string }> {
177
+ const skill = matchLessonToSkill(freeText);
178
+ if (!skill) return { appended: false, reason: "no matching skill" };
179
+ const result = await appendSkillLesson(cwd, { ...lesson, skill });
180
+ return { ...result, skill };
181
+ }
182
+
183
+ export interface LessonEvalResult {
184
+ skill: string;
185
+ total: number;
186
+ covered: number;
187
+ stale: Array<{ title: string; reason: string }>;
188
+ }
189
+
190
+ interface ParsedBullet {
191
+ title: string;
192
+ detail: string;
193
+ }
194
+
195
+ /** Extract every bullet from `[start, end)`, splitting the `- **title**:
196
+ * detail` format {@link appendSkillLesson} produces — a plain `- detail`
197
+ * bullet (no bold title) tolerantly uses the whole bullet text as both. */
198
+ function extractBullets(lines: string[], start: number, end: number): ParsedBullet[] {
199
+ const out: ParsedBullet[] = [];
200
+ for (let i = start; i < end; i++) {
201
+ const line = lines[i]!;
202
+ if (!line.startsWith("- ")) continue;
203
+ const m = line.match(/^-\s+\*\*(.+?)\*\*\s*:\s*(.*)$/);
204
+ if (m) {
205
+ out.push({ title: m[1]!.trim(), detail: m[2]!.trim() });
206
+ } else {
207
+ const text = line.slice(2).trim();
208
+ if (text) out.push({ title: text, detail: text });
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /** The skill body with BOTH lesson sections (header + their bullets) removed —
215
+ * i.e. just the skill's core guidance, which is what the eval judge should
216
+ * compare each lesson against. */
217
+ function coreBodyWithoutLessons(lines: string[]): string {
218
+ const headers = new Set(Object.values(SECTION_HEADER));
219
+ const kept: string[] = [];
220
+ let skipping = false;
221
+ for (const line of lines) {
222
+ if (headers.has(line.trim())) { skipping = true; continue; }
223
+ if (skipping && line.startsWith("## ")) skipping = false;
224
+ if (!skipping) kept.push(line);
225
+ }
226
+ return kept.join("\n").trim();
227
+ }
228
+
229
+ /**
230
+ * Self-eval half of the compounding loop: reads the project-level skill file
231
+ * and, for every recorded failure-mode/anti-pattern bullet, asks an
232
+ * independent LLM call whether the skill's CURRENT core guidance still
233
+ * addresses it ("covered") or has drifted ("stale"). Zero bullets short-
234
+ * circuits to a zero-cost result (no LLM call). `null` when the project-level
235
+ * file doesn't exist at all (no lessons ever recorded for this skill).
236
+ * Parse failure fails conservatively: every entry is treated as "covered"
237
+ * rather than risk falsely flagging a lesson as obsolete.
238
+ */
239
+ export async function evalSkillLessons(
240
+ cwd: string,
241
+ skillName: string,
242
+ opts: { model?: string } = {},
243
+ ): Promise<LessonEvalResult | null> {
244
+ const filePath = skillFilePath(cwd, skillName);
245
+ let content: string;
246
+ try {
247
+ content = await fs.readFile(filePath, "utf-8");
248
+ } catch {
249
+ return null;
250
+ }
251
+
252
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
253
+ const failureSection = findSection(lines, SECTION_HEADER["failure-mode"]);
254
+ const antiSection = findSection(lines, SECTION_HEADER["anti-pattern"]);
255
+ const bullets: ParsedBullet[] = [
256
+ ...(failureSection ? extractBullets(lines, failureSection.headerLine + 1, failureSection.insertAt) : []),
257
+ ...(antiSection ? extractBullets(lines, antiSection.headerLine + 1, antiSection.insertAt) : []),
258
+ ];
259
+
260
+ if (bullets.length === 0) {
261
+ return { skill: skillName, total: 0, covered: 0, stale: [] };
262
+ }
263
+
264
+ const model = opts.model ?? resolveVerifierModel(await readGlobalConfig());
265
+ const coreBody = coreBodyWithoutLessons(lines);
266
+ const lessonList = bullets.map((b, i) => `${i + 1}. "${b.title}": ${b.detail}`).join("\n");
267
+
268
+ const systemPrompt = `You are an independent Skill Auditor. You are shown a skill's CURRENT core guidance text and a list of lessons (failure modes / anti-patterns) previously recorded against earlier versions of this skill.
269
+
270
+ For EACH lesson, judge:
271
+ - "covered": the skill's current guidance still clearly addresses this lesson (a reader following the current guidance would avoid/handle it).
272
+ - "stale": the guidance has drifted and no longer clearly addresses this lesson, OR the lesson itself looks obsolete/no-longer-applicable.
273
+
274
+ Respond with ONLY a JSON object: {"results": [{"title": string, "verdict": "covered" | "stale", "reason": string}]}. Include EVERY lesson by its exact title. No markdown, no code fences, no other text.`;
275
+
276
+ const userMessage = `Skill's current core guidance:\n\n${coreBody}\n\nRecorded lessons:\n\n${lessonList}\n\nJudge each lesson.`;
277
+
278
+ const verdictByTitle = new Map<string, { verdict: "covered" | "stale"; reason: string }>();
279
+ try {
280
+ const response = await callLlm(
281
+ [
282
+ { role: "system", content: systemPrompt },
283
+ { role: "user", content: userMessage },
284
+ ],
285
+ { model, jsonMode: true, maxTokens: 1500, reasoningEffort: "none" },
286
+ );
287
+ const parsed = tryExtractJsonObject<{ results?: unknown }>(response);
288
+ if (parsed && Array.isArray(parsed.results)) {
289
+ for (const entry of parsed.results) {
290
+ if (
291
+ entry && typeof entry === "object" &&
292
+ "title" in entry && typeof entry.title === "string" &&
293
+ "verdict" in entry && (entry.verdict === "covered" || entry.verdict === "stale")
294
+ ) {
295
+ const reason = "reason" in entry && typeof entry.reason === "string" ? entry.reason : "";
296
+ verdictByTitle.set(entry.title.trim().toLowerCase(), { verdict: entry.verdict, reason });
297
+ }
298
+ }
299
+ }
300
+ } catch {
301
+ // fail conservatively below: an empty verdictByTitle treats every bullet as "covered"
302
+ }
303
+
304
+ let covered = 0;
305
+ const stale: Array<{ title: string; reason: string }> = [];
306
+ for (const b of bullets) {
307
+ const v = verdictByTitle.get(b.title.trim().toLowerCase());
308
+ if (v && v.verdict === "stale") {
309
+ stale.push({ title: b.title, reason: v.reason || "flagged stale by skill auditor" });
310
+ } else {
311
+ covered++;
312
+ }
313
+ }
314
+ return { skill: skillName, total: bullets.length, covered, stale };
315
+ }
@@ -16,7 +16,7 @@ import architectPrompt from "../prompts/agents/architect.md" with { type: "text"
16
16
  import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
17
17
  import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
18
18
  import plannerPrompt from "../prompts/agents/planner.md" with { type: "text" };
19
- import type { PromptTier } from "./prompt-router";
19
+ import { strongestCredentialed, cheapestCredentialed, type PromptTier } from "./prompt-router";
20
20
 
21
21
  const PROMPT_ROUTE_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
22
22
 
@@ -176,9 +176,6 @@ export function resolveSubagentModel(
176
176
  const entry = config.subagents?.[normalized];
177
177
  if (entry?.model) return entry.model;
178
178
 
179
- // Exception: load strongestCredentialed dynamically using require to prevent eager loading and circular dependency issues that break test mocking.
180
- // eslint-disable-next-line @typescript-eslint/no-require-imports
181
- const { strongestCredentialed } = require("./prompt-router");
182
179
  if (normalized === "executor") {
183
180
  return config.roles?.xhigh || config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
184
181
  }
@@ -189,11 +186,15 @@ export function resolveSubagentModel(
189
186
  return config.roles?.high || strongestCredentialed(config) || config.defaultModel;
190
187
  }
191
188
  if (normalized === "critic") {
189
+ // Grader role (plan reviewer): prefer an explicit mid/cheap pin, else the
190
+ // live-cheapest credentialed model — a GENERAL search (not 2 hardcoded ids),
191
+ // so critic tracks catalog drift the same way every other role's fallback
192
+ // does instead of silently collapsing to defaultModel when neither o3-mini
193
+ // nor gemini-2.5-flash happens to be credentialed.
192
194
  return (
193
195
  config.roles?.medium ||
194
196
  config.roles?.smol ||
195
- strongestCredentialed(config, (m: CatalogModel) => m.canonical === "o3-mini") ||
196
- strongestCredentialed(config, (m: CatalogModel) => m.canonical === "gemini-2.5-flash") ||
197
+ cheapestCredentialed(config) ||
197
198
  config.defaultModel
198
199
  );
199
200
  }