jeo-code 0.8.22 → 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.
@@ -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
  }