jeopi-cli 16.2.30 → 16.4.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli.js +2824 -2736
  3. package/dist/types/config/settings-schema.d.ts +32 -2
  4. package/dist/types/goals/runtime.d.ts +16 -0
  5. package/dist/types/goals/verifier.d.ts +9 -0
  6. package/dist/types/memories/index.d.ts +9 -0
  7. package/dist/types/memory-backend/types.d.ts +11 -0
  8. package/dist/types/session/agent-session.d.ts +22 -7
  9. package/dist/types/slash-commands/helpers/skill-verify.d.ts +5 -0
  10. package/dist/types/task/model-tier-classifier.d.ts +8 -0
  11. package/dist/types/tools/builtin-names.d.ts +1 -1
  12. package/dist/types/tools/index.d.ts +1 -0
  13. package/dist/types/tools/learn.d.ts +4 -0
  14. package/dist/types/tools/path-write-lock.d.ts +10 -0
  15. package/dist/types/tools/vision-verify.d.ts +34 -0
  16. package/package.json +12 -12
  17. package/src/autolearn/controller.ts +21 -5
  18. package/src/autoresearch/index.ts +60 -6
  19. package/src/config/settings-schema.ts +38 -2
  20. package/src/edit/index.ts +2 -1
  21. package/src/goals/runtime.ts +51 -0
  22. package/src/goals/verifier.ts +100 -0
  23. package/src/internal-urls/docs-index.generated.txt +2 -2
  24. package/src/memories/index.ts +10 -2
  25. package/src/memory-backend/local-backend.ts +34 -0
  26. package/src/memory-backend/types.ts +11 -0
  27. package/src/prompts/agents/designer.md +1 -0
  28. package/src/prompts/agents/goal-verifier.md +51 -0
  29. package/src/prompts/tools/learn.md +2 -0
  30. package/src/prompts/tools/vision-verify-system.md +16 -0
  31. package/src/prompts/tools/vision-verify.md +20 -0
  32. package/src/sdk.ts +2 -0
  33. package/src/session/agent-session.ts +51 -8
  34. package/src/slash-commands/builtin-registry.ts +9 -0
  35. package/src/slash-commands/helpers/skill-verify.ts +171 -0
  36. package/src/task/agents.ts +2 -0
  37. package/src/task/index.ts +26 -1
  38. package/src/task/model-tier-classifier.ts +43 -0
  39. package/src/tools/builtin-names.ts +1 -0
  40. package/src/tools/index.ts +4 -0
  41. package/src/tools/learn.ts +16 -1
  42. package/src/tools/path-write-lock.ts +44 -0
  43. package/src/tools/vision-verify.ts +347 -0
  44. package/src/tools/write.ts +12 -9
@@ -1342,7 +1342,7 @@ export function getMemoryRoot(agentDir: string, cwd: string): string {
1342
1342
  * consolidation artifacts (`MEMORY.md`, `memory_summary.md`, `skills/`) so a
1343
1343
  * consolidation pass never clobbers manually captured lessons.
1344
1344
  */
1345
- const LEARNED_LESSONS_FILE = "learned.md";
1345
+ export const LEARNED_LESSONS_FILE = "learned.md";
1346
1346
  /** Newest-first cap on retained lessons, bounding file growth by entry count. */
1347
1347
  const MAX_LEARNED_LESSONS = 100;
1348
1348
  /** Per-field char caps so a single huge capture can't bloat learned.md. */
@@ -1414,7 +1414,15 @@ export async function saveLearnedLesson(
1414
1414
  return { backend: "local", stored: 0, message: "Empty lesson; nothing stored." };
1415
1415
  }
1416
1416
  const context = input.context ? normalizeLearnedText(input.context, MAX_LEARNED_CONTEXT_CHARS) : "";
1417
- const line = context ? `- ${content} _(context: ${context})_` : `- ${content}`;
1417
+ const contextSuffix = context ? ` _(context: ${context})_` : "";
1418
+ let line: string;
1419
+ if (input.verified === true) {
1420
+ const evidence = input.evidence ? normalizeLearnedText(input.evidence, MAX_LEARNED_CONTEXT_CHARS) : "";
1421
+ const evidenceSuffix = evidence ? ` _(evidence: ${evidence})_` : "";
1422
+ line = `- [VERIFIED] ${content}${evidenceSuffix}${contextSuffix}`;
1423
+ } else {
1424
+ line = `- [UNVERIFIED] ${content}${contextSuffix}`;
1425
+ }
1418
1426
  const filePath = path.join(getMemoryRoot(agentDir, cwd), LEARNED_LESSONS_FILE);
1419
1427
 
1420
1428
  // Serialize the read-modify-write per file: parallel `learn` calls (sibling
@@ -1,8 +1,12 @@
1
+ import * as path from "node:path";
2
+ import { isEnoent } from "jeopi-utils";
1
3
  import {
2
4
  buildMemoryToolDeveloperInstructions,
3
5
  clearMemoryData,
4
6
  clearMemoryToolDeveloperInstructionsCache,
5
7
  enqueueMemoryConsolidation,
8
+ getMemoryRoot,
9
+ LEARNED_LESSONS_FILE,
6
10
  saveLearnedLesson,
7
11
  startMemoryStartupTask,
8
12
  } from "../memories";
@@ -44,4 +48,34 @@ export const localBackend: MemoryBackend = {
44
48
  "Local rollout-summary memory is active; lessons from the `learn` tool are saved to learned.md. Structured search is not available.",
45
49
  };
46
50
  },
51
+ async stats(agentDir, cwd) {
52
+ const filePath = path.join(getMemoryRoot(agentDir, cwd), LEARNED_LESSONS_FILE);
53
+ let raw = "";
54
+ try {
55
+ raw = await Bun.file(filePath).text();
56
+ } catch (err) {
57
+ if (!isEnoent(err)) throw err;
58
+ }
59
+ let total = 0;
60
+ let verified = 0;
61
+ for (const rawLine of raw.split("\n")) {
62
+ const line = rawLine.trim();
63
+ if (!line.startsWith("- ")) continue;
64
+ total++;
65
+ if (line.startsWith(VERIFIED_PREFIX)) verified++;
66
+ }
67
+ const unverified = total - verified;
68
+ const coverage = total > 0 ? `${((verified / total) * 100).toFixed(1)}%` : "N/A";
69
+ return [
70
+ "# Local Memory Stats",
71
+ "",
72
+ `- Lessons: ${total}`,
73
+ `- Verified: ${verified}`,
74
+ `- Unverified: ${unverified}`,
75
+ `- Verification coverage: ${coverage}`,
76
+ ].join("\n");
77
+ },
47
78
  };
79
+
80
+ /** Line prefix `saveLearnedLesson` writes for verified/unverified lessons in `learned.md`. */
81
+ const VERIFIED_PREFIX = "- [VERIFIED]";
@@ -60,6 +60,17 @@ export interface MemoryBackendSaveInput {
60
60
  context?: string;
61
61
  source?: string;
62
62
  importance?: number;
63
+ /**
64
+ * Marks this lesson as independently re-checked against the repo (a
65
+ * command run, a file read, a test executed) rather than merely asserted
66
+ * from inference. Mirrors the "verify" stage of the fail → investigate →
67
+ * verify → distill → consult self-improvement progression: a lesson that
68
+ * skips verification is still worth capturing, but downstream consumers
69
+ * (e.g. `stats()`) should be able to tell the two apart.
70
+ */
71
+ verified?: boolean;
72
+ /** The short check that was run to establish `verified` (a command, a file path, a test name). Ignored when `verified` is not `true`. */
73
+ evidence?: string;
63
74
  }
64
75
 
65
76
  export interface MemoryBackendSaveResult {
@@ -20,6 +20,7 @@ Treat the design system as the foundation — UI built without one collapses int
20
20
  2. **No coherent system? Build the minimal one first.** Extract what exists, then define a palette, type scale, spacing scale (4px/8px base), radii/shadows/transitions, and primitive components — THEN implement the request against it.
21
21
  3. **Compose with the system, never around it.** Colors → tokens/CSS variables, never hardcoded hex; spacing → scale values, never arbitrary px; type → scale steps; components → extend/compose existing primitives, not one-off div soup. Need something outside the system? Add the new token to the system first, then use it — never a one-off override.
22
22
  4. **Verify before done.** Every color a token, every spacing on the scale, every component on the existing composition pattern, zero magic numbers — a designer would see consistency across old and new. Any "no" → not done.
23
+ 5. **Visually verify the rendered result.** Code-level checks (tokens/spacing) don't catch layout/rendering bugs. After producing a UI change, capture it (`tab.screenshot()` for browser-rendered UI, or the existing screenshot path) and call `vision_verify` with the task's stated goal — pass `baseline` when checking for regression against a known-good prior state.
23
24
  </design-system>
24
25
 
25
26
  <procedure>
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: goal-verifier
3
+ description: "Read-only independent grader for goal-mode completion. Verdicts whether the actual diff satisfies the stated objective and evidence claim — sees only the objective, evidence, and repo state, never the maker's reasoning transcript."
4
+ tools: read, grep, glob, bash, ast_grep
5
+ spawns: explore
6
+ model: pi/slow
7
+ thinking-level: high
8
+ output:
9
+ properties:
10
+ verdict:
11
+ metadata:
12
+ description: Completion verdict for the goal under review
13
+ enum: [okay, iterate, reject]
14
+ justification:
15
+ metadata:
16
+ description: Evidence-backed reasoning for the verdict, citing inspected files/diff hunks/command output
17
+ type: string
18
+ summary:
19
+ metadata:
20
+ description: 1-3 sentence plain-text summary of what was actually verified
21
+ type: string
22
+ optionalProperties:
23
+ required_fixes:
24
+ metadata:
25
+ description: Concrete gaps that must be fixed before the goal can be marked complete (empty for okay)
26
+ elements:
27
+ type: string
28
+ ---
29
+
30
+ Decide whether a goal-mode objective is genuinely satisfied by the repo's current state — you are the independent grader, not the agent that did the work.
31
+
32
+ <procedure>
33
+ 1. Read the `<objective>` and `<evidence>` given to you below. Treat both as claims to verify, not facts.
34
+ 2. Run `git status` and `git diff` (add `git diff --stat` first on a large diff) to see every file actually changed this session. Read the changed files for context beyond the diff hunks when the diff alone doesn't establish correctness.
35
+ 3. If the evidence claims a verification command ran (test/build/lint/typecheck), re-run the narrowest form of that command yourself against the current tree — do not trust the claim without confirming it. Do not run a full/slow suite when a scoped one covers the changed files; do not run anything destructive.
36
+ 4. Cross-check the diff against the objective: does every part of the objective have corresponding evidence in the diff, or a clear reason no code change was needed for that part? Flag objective clauses with no corresponding change.
37
+ 5. Decide the verdict and record it with the structured output.
38
+ </procedure>
39
+
40
+ <constraints>
41
+ - Read-only: you NEVER edit, write, or revert files. Verification commands you run must not mutate the tree (no `--fix`, no auto-format writes, no `git commit`/`git checkout`).
42
+ - Do not invent problems; reject or iterate only with concrete, cited gaps between the objective and the diff.
43
+ - A verdict grounded in nothing is worthless: your justification MUST name the files/diff hunks/commands you actually inspected or ran, and their actual output.
44
+ - You have no access to the maker's reasoning, chat transcript, or thinking blocks — judge the objective against the artifact (diff + verification output) alone. If the evidence text asserts something the diff doesn't support, that is a gap, not a detail to take on faith.
45
+ </constraints>
46
+
47
+ <verdicts>
48
+ - `okay` — every part of the objective is satisfied by the diff, and claimed verification actually holds when re-checked.
49
+ - `iterate` — real progress exists but named gaps remain (missing piece of the objective, evidence claim that doesn't hold up, or verification that fails when re-run); list each in `required_fixes`.
50
+ - `reject` — the diff does not address the objective at all, or the evidence is fabricated/contradicted by re-running it.
51
+ </verdicts>
@@ -5,3 +5,5 @@ Use after solving something whose insight will pay off again: a non-obvious fix,
5
5
  Provide the optional `skill` object when the lesson is a repeatable *procedure* worth codifying as a `SKILL.md` (not just a fact). Managed skills are written to an isolated directory (`~/.jeopi/agent/managed-skills`) and are surfaced like normal skills next session. They NEVER touch user-authored skills. Frontmatter is generated from `name` and `description`.
6
6
 
7
7
  Capture sparingly and specifically. One strong, reusable lesson beats several vague ones.
8
+
9
+ Set `verified: true` when you independently re-checked the claim against the repo — you ran a command, read a file, or ran a test that directly confirmed it — and pass a short `evidence` string naming that check (e.g. "ran `bun test src/foo.test.ts`, all 12 pass" or "read `package.json:12`, confirms dependency pin"). Leave `verified` unset (the default) for a hypothesis or pattern you noticed but did not independently confirm.
@@ -0,0 +1,16 @@
1
+ You are a UI/artifact verification assistant.
2
+
3
+ Core behavior:
4
+ - Be evidence-first: judge only what is visibly present in the image(s).
5
+ - If something is unclear or partially occluded, treat it as unmet rather than guessing it is fine.
6
+ - NEVER fabricate details that are not visible.
7
+ - When a baseline (prior) screenshot is provided, compare the current screenshot against it and treat any visible divergence as a gap unless the goal explicitly calls for that change.
8
+
9
+ Output contract — respond with ONLY a single JSON object, no prose before or after it, no markdown code fence, matching exactly:
10
+ {"matches": boolean, "summary": string, "gaps": string[]}
11
+
12
+ - `matches`: true only if the current screenshot fully satisfies the stated goal (and, when a baseline is given, shows no unintended regression from it).
13
+ - `summary`: one to three sentences describing what you observed.
14
+ - `gaps`: specific, concrete differences between what is observed and what the goal (or baseline) requires. Empty array when `matches` is true.
15
+
16
+ Return raw JSON only — any other format is a failure to follow instructions.
@@ -0,0 +1,20 @@
1
+ Verifies a screenshot or UI artifact against a stated goal with a vision-capable model and returns a structured verdict.
2
+
3
+ <instruction>
4
+ - Use this after producing or modifying a UI to check the result against the task's stated goal
5
+ - Provide `screenshot` as a local image file path, `Image #N` attachment label, or `attachment://N` URI
6
+ - Write a specific `goal`: what the UI/artifact should look like or achieve
7
+ - Pass `baseline` (a path to a prior screenshot of the same view) to check for visual regression against a known-good state
8
+ - Use this tool over `inspect_image` when you need a pass/fail verdict with structured gaps, not free-text description
9
+ </instruction>
10
+
11
+ <output>
12
+ - Returns a structured verdict: `matches` (boolean), `summary`, and `gaps` (empty when `matches` is true)
13
+ - No image content blocks are returned in tool output
14
+ </output>
15
+
16
+ <critical>
17
+ - If image submission is blocked by settings, the tool will fail with an actionable error
18
+ - If configured model does not support image input, configure a vision-capable model role before retrying
19
+ - If the model's response cannot be parsed as the expected JSON shape, the tool fails with the raw response included — retry rather than trust an unstructured fallback
20
+ </critical>
package/src/sdk.ts CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  setActiveSkills,
77
77
  } from "./extensibility/skills";
78
78
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./extensibility/slash-commands";
79
+ import { verifyGoalCompletion } from "./goals/verifier";
79
80
  import type { HindsightSessionState } from "./hindsight/state";
80
81
  import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls";
81
82
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
@@ -2756,6 +2757,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2756
2757
  providerSessionId: options.providerSessionId,
2757
2758
  parentEvalSessionId: options.parentEvalSessionId,
2758
2759
  advisorTools,
2760
+ verifyGoalCompletion: (objective, evidence) => verifyGoalCompletion(toolSession, objective, evidence),
2759
2761
  titleSystemPrompt: options.titleSystemPrompt,
2760
2762
  });
2761
2763
  hasSession = true;
@@ -222,7 +222,7 @@ import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
222
222
  import type { HookCommandContext } from "../extensibility/hooks/types";
223
223
  import type { Skill, SkillWarning } from "../extensibility/skills";
224
224
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
225
- import { GoalRuntime } from "../goals/runtime";
225
+ import { type GoalCompletionVerdict, GoalRuntime } from "../goals/runtime";
226
226
  import type { Goal, GoalModeState } from "../goals/state";
227
227
  import type { HindsightSessionState } from "../hindsight/state";
228
228
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
@@ -539,13 +539,19 @@ function refusalBackoffBudgetMs(): number {
539
539
  }
540
540
  /**
541
541
  * Budget for callers on the user-visible `/quit` / `/exit` shutdown path that
542
- * want to cap how long they wait for `MnemopiSessionState.dispose()` to finish
543
- * its consolidate pass. Consolidate fires fresh LLM fact extractions, each a
544
- * 1–3 s round-trip, so interactive shutdown passes this budget to keep the
545
- * UI responsive. Callers that keep the process/session host alive must omit it
546
- * so dispose still awaits the full consolidate-then-close pipeline.
542
+ * want to cap how long they wait for `MnemopiSessionState.dispose()` (and the
543
+ * Hindsight retain-queue flush, see the dispose() call site) to finish writing
544
+ * this session's memory before the process exits. Consolidate fires fresh LLM
545
+ * fact extractions, each a 1-3s round-trip; the retain-queue flush is a single
546
+ * HTTP POST. The prior 1500ms budget was tighter than a single extraction
547
+ * round-trip, so it lost the race on effectively every session with any
548
+ * memory-worthy content — "write before walking away" in name only. 8s still
549
+ * bounds worst-case UI unresponsiveness on /exit while giving a small
550
+ * consolidation batch (1-3 facts) a realistic chance to land before the
551
+ * process exits. Callers that keep the process/session host alive must omit
552
+ * it so dispose still awaits the full write pipeline unbounded.
547
553
  */
548
- export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500;
554
+ export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 8_000;
549
555
 
550
556
  export interface AgentSessionDisposeOptions {
551
557
  mnemopiConsolidateTimeoutMs?: number;
@@ -728,6 +734,15 @@ export interface AgentSessionConfig {
728
734
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
729
735
  /** Rebuild the SSH tool from current capability discovery results. */
730
736
  reloadSshTool?: () => Promise<AgentTool | null>;
737
+ /**
738
+ * Independent-verifier bridge for goal-mode completion (see goals/verifier.ts).
739
+ * Spawns the bundled `goal-verifier` sub-agent to grade a completion claim against
740
+ * the objective and the actual repo diff — a fresh session/context, no exposure to
741
+ * this session's transcript. Wired from sdk.ts (needs the constructed `toolSession`).
742
+ * Undefined when `goal.verifyCompletion` is off or the caller has no tool session
743
+ * (e.g. minimal SDK embedders) — GoalRuntime skips independent grading in that case.
744
+ */
745
+ verifyGoalCompletion?: (objective: string, evidence: string) => Promise<GoalCompletionVerdict>;
731
746
  requestedToolNames?: ReadonlySet<string>;
732
747
  /**
733
748
  * Optional accessor for live MCP server instructions. Read by the session's
@@ -2210,6 +2225,14 @@ export class AgentSession {
2210
2225
  { deliverAs: message.deliverAs },
2211
2226
  );
2212
2227
  },
2228
+ verifyCompletion: config.verifyGoalCompletion
2229
+ ? async (objective: string, evidence: string): Promise<GoalCompletionVerdict> => {
2230
+ if (!this.settings.get("goal.verifyCompletion")) {
2231
+ return { verdict: "okay", justification: "goal.verifyCompletion disabled", requiredFixes: [] };
2232
+ }
2233
+ return config.verifyGoalCompletion!(objective, evidence);
2234
+ }
2235
+ : undefined,
2213
2236
  });
2214
2237
  this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => {
2215
2238
  this.#recordSessionExit(reason);
@@ -5618,7 +5641,27 @@ export class AgentSession {
5618
5641
  // Reversed, the spliced batch survives just long enough to fail the
5619
5642
  // identity check and get dropped with a `session vanished` warning.
5620
5643
  const hindsightState = this.getHindsightSessionState();
5621
- await hindsightState?.flushRetainQueue();
5644
+ // Bounded when a shutdown budget is set (interactive /quit /exit): the
5645
+ // underlying HindsightApi client makes plain `fetch()` calls with no
5646
+ // timeout/signal, so an unreachable or hung Hindsight server would
5647
+ // otherwise block process exit indefinitely. Callers that keep the
5648
+ // process/session host alive (no timeout passed) still await the flush
5649
+ // unbounded, matching the mnemopi dispose contract below.
5650
+ if (hindsightState) {
5651
+ const { mnemopiConsolidateTimeoutMs: timeoutMs } = options;
5652
+ if (timeoutMs !== undefined && timeoutMs > 0) {
5653
+ try {
5654
+ await withTimeout(hindsightState.flushRetainQueue(), timeoutMs, "Hindsight retain-queue flush");
5655
+ } catch (error) {
5656
+ logger.warn("Hindsight retain-queue flush exceeded shutdown budget; remaining items dropped", {
5657
+ timeoutMs,
5658
+ error: String(error),
5659
+ });
5660
+ }
5661
+ } else {
5662
+ await hindsightState.flushRetainQueue();
5663
+ }
5664
+ }
5622
5665
  this.setHindsightSessionState(undefined);
5623
5666
  hindsightState?.dispose();
5624
5667
  const mnemopiState = setMnemopiSessionState(this, undefined);
@@ -43,6 +43,7 @@ import { createMarketplaceManager } from "./helpers/marketplace-manager";
43
43
  import { handleMcpAcp } from "./helpers/mcp";
44
44
  import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
45
45
  import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage";
46
+ import { handleSkillCommand } from "./helpers/skill-verify";
46
47
  import { handleSshAcp } from "./helpers/ssh";
47
48
  import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard";
48
49
  import { handleTodoAcp } from "./helpers/todo";
@@ -1348,6 +1349,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1348
1349
  await runtime.ctx.handleSSHCommand(command.text);
1349
1350
  },
1350
1351
  },
1352
+ {
1353
+ name: "verify-skill",
1354
+ description: "Run a skill's `## Eval Cases` and report pass/fail",
1355
+ acpDescription: "Verify a skill's eval cases",
1356
+ inlineHint: "<name>",
1357
+ allowArgs: true,
1358
+ handle: handleSkillCommand,
1359
+ },
1351
1360
  {
1352
1361
  name: "new",
1353
1362
  priority: 96,
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `/verify-skill <name>` — self-verification for a skill's `## Eval Cases`.
3
+ *
4
+ * Resolves a skill from the active session, extracts a bulleted `## Eval
5
+ * Cases` markdown section from its `SKILL.md` body (if present), and spawns
6
+ * one read-only `critic` subagent per case to verdict whether the claim still
7
+ * holds against the current repo state. Renders a pass/fail markdown report;
8
+ * never mutates the skill file.
9
+ *
10
+ * Named `verify-skill` (not `skill`) to avoid colliding with the pre-existing
11
+ * `/skill:<name>` direct-skill-invocation convention (both parse to the
12
+ * command-name token `skill` under `parseSlashCommand`'s earliest-whitespace-
13
+ * or-colon splitting rule — see slash-commands/helpers/parse.ts).
14
+ */
15
+ import { MANAGED_SKILLS_PROVIDER_ID } from "../../autolearn/managed-skills";
16
+ import { runEvalAgent } from "../../eval/agent-bridge";
17
+ import { getBundledAgent } from "../../task/agents";
18
+ import * as taskDiscovery from "../../task/discovery";
19
+ import type { ToolSession } from "../../tools";
20
+ import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
21
+ import { commandConsumed, usage } from "./parse";
22
+
23
+ const VERIFIER_AGENT_NAME = "critic";
24
+
25
+ interface EvalCaseVerdict {
26
+ claim: string;
27
+ passed: boolean;
28
+ reason: string;
29
+ }
30
+
31
+ /** Extract the bulleted list under a `## Eval Cases` heading, if present. */
32
+ export function extractEvalCases(skillBody: string): string[] {
33
+ const headingMatch = /^##\s+Eval Cases\s*$/m.exec(skillBody);
34
+ if (!headingMatch) return [];
35
+ const sectionStart = headingMatch.index + headingMatch[0].length;
36
+ const rest = skillBody.slice(sectionStart);
37
+ const nextHeadingMatch = /^#{1,6}\s+\S/m.exec(rest);
38
+ const sectionBody = nextHeadingMatch ? rest.slice(0, nextHeadingMatch.index) : rest;
39
+ const cases: string[] = [];
40
+ for (const line of sectionBody.split("\n")) {
41
+ const bulletMatch = /^\s*[-*+]\s+(.+)$/.exec(line);
42
+ if (bulletMatch?.[1]) cases.push(bulletMatch[1].trim());
43
+ }
44
+ return cases;
45
+ }
46
+
47
+ /** Verdict a single eval-case claim against the current repo state via the bundled `critic` agent. */
48
+ async function verifyEvalCase(session: ToolSession, claim: string): Promise<EvalCaseVerdict> {
49
+ const verifyPrompt =
50
+ `Verify this claim against the current repository state: ${claim}\n\n` +
51
+ "Report whether it still holds (yes/no) and why, citing files/commands.";
52
+ // Explicit schema, not the agent's own frontmatter default: runEvalAgent (the
53
+ // eval `agent()` bridge) only applies `outputSchema` when the caller supplies
54
+ // one — it never falls back to the agent's `output` frontmatter on its own
55
+ // (see goals/verifier.ts for the same pattern). Read the bundled definition's
56
+ // declared shape so this can't drift from critic.md.
57
+ const schema = getBundledAgent(VERIFIER_AGENT_NAME)?.output;
58
+ const args =
59
+ schema === undefined
60
+ ? { prompt: verifyPrompt, agent: VERIFIER_AGENT_NAME }
61
+ : { prompt: verifyPrompt, agent: VERIFIER_AGENT_NAME, schema };
62
+ try {
63
+ const result = await runEvalAgent(args, { session });
64
+ let verdict: unknown;
65
+ try {
66
+ verdict = JSON.parse(result.text);
67
+ } catch {
68
+ // Freeform fallback: no parseable JSON, treat non-empty output as inconclusive-pass with raw text as reason.
69
+ return { claim, passed: true, reason: result.text.trim().slice(0, 300) || "No structured verdict returned." };
70
+ }
71
+ if (verdict && typeof verdict === "object") {
72
+ const record = verdict as Record<string, unknown>;
73
+ const rawVerdict = record.verdict;
74
+ const passed = rawVerdict === "okay";
75
+ const reason =
76
+ (typeof record.summary === "string" && record.summary) ||
77
+ (typeof record.justification === "string" && record.justification) ||
78
+ "No justification returned.";
79
+ return { claim, passed, reason: reason.slice(0, 400) };
80
+ }
81
+ return { claim, passed: false, reason: "Verifier returned an unparseable verdict." };
82
+ } catch (err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ return { claim, passed: false, reason: `Verification subagent failed: ${message}` };
85
+ }
86
+ }
87
+
88
+ function renderReport(skillName: string, isManaged: boolean, verdicts: EvalCaseVerdict[]): string {
89
+ const lines: string[] = [`## Skill verification: \`${skillName}\``, ""];
90
+ const failures: EvalCaseVerdict[] = [];
91
+ for (const verdict of verdicts) {
92
+ lines.push(`- ${verdict.passed ? "✅ PASS" : "❌ FAIL"} — ${verdict.claim}`);
93
+ lines.push(` ${verdict.reason}`);
94
+ if (!verdict.passed) failures.push(verdict);
95
+ }
96
+ const passCount = verdicts.length - failures.length;
97
+ lines.push("", `${passCount}/${verdicts.length} eval cases passed.`);
98
+ if (failures.length > 0 && isManaged) {
99
+ lines.push(
100
+ "",
101
+ "This is a managed (auto-learn) skill. To record the failure mode, append a line like this under a `## Known Failure Modes` section (not applied automatically):",
102
+ "",
103
+ );
104
+ for (const failure of failures) {
105
+ lines.push(`- "${failure.claim}" — ${failure.reason}`);
106
+ }
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+
111
+ /** `/verify-skill <name>` handler — resolves a skill, runs its `## Eval Cases`, reports pass/fail. */
112
+ export async function handleSkillCommand(
113
+ command: ParsedSlashCommand,
114
+ runtime: SlashCommandRuntime,
115
+ ): Promise<SlashCommandResult> {
116
+ const skillName = command.args.trim();
117
+ if (!skillName) {
118
+ return usage("Usage: /verify-skill <name>", runtime);
119
+ }
120
+ const skill = runtime.session.skills.find(candidate => candidate.name === skillName);
121
+ if (!skill) {
122
+ const available = runtime.session.skills.map(candidate => candidate.name).join(", ") || "none";
123
+ return usage(`Unknown skill "${skillName}". Available: ${available}`, runtime);
124
+ }
125
+
126
+ const body = await Bun.file(skill.filePath).text();
127
+ const evalCases = extractEvalCases(body);
128
+ if (evalCases.length === 0) {
129
+ await runtime.output(
130
+ `No eval cases defined for this skill — add an \`## Eval Cases\` section with bulleted verifiable claims to enable self-verification`,
131
+ );
132
+ return commandConsumed();
133
+ }
134
+
135
+ await runtime.output(`Verifying ${evalCases.length} eval case(s) for \`${skillName}\`…`);
136
+
137
+ // Discover once, upfront, to fail fast with a clear message if the critic
138
+ // agent is unavailable (disabled, or bundled agents failed to load) rather
139
+ // than surfacing N identical spawn errors.
140
+ const { agents } = await taskDiscovery.discoverAgents(runtime.cwd);
141
+ if (!taskDiscovery.getAgent(agents, VERIFIER_AGENT_NAME)) {
142
+ return usage(
143
+ `The "${VERIFIER_AGENT_NAME}" agent is unavailable in this session; cannot run eval cases.`,
144
+ runtime,
145
+ );
146
+ }
147
+
148
+ const session: ToolSession = {
149
+ cwd: runtime.cwd,
150
+ hasUI: false,
151
+ settings: runtime.settings,
152
+ skills: [...runtime.session.skills],
153
+ taskDepth: 0,
154
+ getSessionFile: () => runtime.session.sessionManager.getSessionFile() ?? null,
155
+ getSessionSpawns: () => "*",
156
+ getPlanModeState: () => runtime.session.getPlanModeState(),
157
+ getActiveModelString: () =>
158
+ runtime.session.model ? `${runtime.session.model.provider}/${runtime.session.model.id}` : undefined,
159
+ modelRegistry: runtime.session.modelRegistry,
160
+ authStorage: runtime.session.modelRegistry.authStorage,
161
+ getAgentId: () => runtime.session.getAgentId() ?? null,
162
+ };
163
+ const verdicts: EvalCaseVerdict[] = [];
164
+ for (const claim of evalCases) {
165
+ verdicts.push(await verifyEvalCase(session, claim));
166
+ }
167
+
168
+ const isManaged = skill._source?.provider === MANAGED_SKILLS_PROVIDER_ID;
169
+ await runtime.output(renderReport(skillName, isManaged, verdicts));
170
+ return commandConsumed();
171
+ }
@@ -12,6 +12,7 @@ import designerMd from "../prompts/agents/designer.md" with { type: "text" };
12
12
  import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
13
13
  // Embed agent markdown files at build time
14
14
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
15
+ import goalVerifierMd from "../prompts/agents/goal-verifier.md" with { type: "text" };
15
16
  import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
16
17
 
17
18
  import planMd from "../prompts/agents/plan.md" with { type: "text" };
@@ -47,6 +48,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
47
48
  { fileName: "explore.md", template: exploreMd },
48
49
  { fileName: "plan.md", template: planMd },
49
50
  { fileName: "critic.md", template: criticMd },
51
+ { fileName: "goal-verifier.md", template: goalVerifierMd },
50
52
  { fileName: "architect.md", template: architectMd },
51
53
  { fileName: "designer.md", template: designerMd },
52
54
  { fileName: "reviewer.md", template: reviewerMd },
package/src/task/index.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  prepareIsolationContext,
68
68
  runIsolatedSubprocess,
69
69
  } from "./isolation-runner";
70
+ import { classifyDefaultTaskModelTier } from "./model-tier-classifier";
70
71
  import { generateTaskName } from "./name-generator";
71
72
  import { AgentOutputManager } from "./output-manager";
72
73
  import { mapWithConcurrencyLimit, normalizeConcurrencyLimit, Semaphore } from "./parallel";
@@ -1202,7 +1203,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1202
1203
  const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
1203
1204
  const settingsModelOverride = agentModelOverrides[agentName];
1204
1205
  const parentActiveModelPattern = this.session.getActiveModelString?.();
1205
- const modelOverride = resolveAgentModelPatterns({
1206
+ let modelOverride = resolveAgentModelPatterns({
1206
1207
  settingsOverride: settingsModelOverride,
1207
1208
  agentModel: effectiveAgent.model,
1208
1209
  settings: this.session.settings,
@@ -1211,6 +1212,30 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1211
1212
  });
1212
1213
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
1213
1214
 
1215
+ // Auto-tier the generic default `task` agent to the cheap `smol` model
1216
+ // when the assignment classifies as trivially simple and nothing else
1217
+ // (settings override) already pinned the model. Named specialists carry
1218
+ // their own `model:` frontmatter, so gating on the literal "task" agent
1219
+ // name naturally excludes them without inspecting frontmatter here.
1220
+ const autoModel = this.session.getActiveModel?.();
1221
+ const autoModelRegistry = this.session.modelRegistry;
1222
+ if (
1223
+ agentName === "task" &&
1224
+ settingsModelOverride === undefined &&
1225
+ this.session.settings.get("task.autoModelTier") &&
1226
+ autoModel &&
1227
+ autoModelRegistry
1228
+ ) {
1229
+ const tier = await classifyDefaultTaskModelTier(assignment, {
1230
+ settings: this.session.settings,
1231
+ registry: autoModelRegistry,
1232
+ model: autoModel,
1233
+ sessionId: this.session.getSessionId?.() ?? undefined,
1234
+ signal,
1235
+ });
1236
+ if (tier === "smol") modelOverride = ["pi/smol"];
1237
+ }
1238
+
1214
1239
  // Output schema priority: agent frontmatter > inherited parent session.
1215
1240
  // The task call itself never carries a schema; workflows needing ad-hoc
1216
1241
  // structured output go through eval agent(prompt, schema).
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Default `task`-agent spawn model-tier classifier.
3
+ *
4
+ * Reuses the auto-thinking difficulty classifier ({@link classifyDifficulty})
5
+ * to decide whether a `task()` spawn's assignment is trivially simple enough
6
+ * to route to the cheap `smol` model tier instead of the default `task`
7
+ * tier. Only ever suggests a downgrade — never blocks or upgrades — and
8
+ * fails open (returns `undefined`, meaning "don't override") on any
9
+ * classification error or timeout, mirroring `AgentSession`'s
10
+ * `#applyAutoThinkingLevel` fallback behavior.
11
+ */
12
+ import { Effort } from "jeopi-ai";
13
+ import { logger } from "jeopi-utils";
14
+ import { type ClassifyDifficultyDeps, classifyDifficulty } from "../auto-thinking/classifier";
15
+
16
+ /** Timeout (ms) for the spawn-time model-tier classification before failing open. */
17
+ const MODEL_TIER_CLASSIFICATION_TIMEOUT_MS = 2000;
18
+
19
+ /**
20
+ * Classify `assignment` and return `"smol"` when the work is trivially
21
+ * simple ({@link Effort.Minimal} or {@link Effort.Low}), or `undefined` when
22
+ * the caller should leave normal model resolution in place (not-simple
23
+ * enough, no classifier available, or the classification failed/timed out).
24
+ */
25
+ export async function classifyDefaultTaskModelTier(
26
+ assignment: string,
27
+ deps: ClassifyDifficultyDeps,
28
+ ): Promise<"smol" | undefined> {
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(), MODEL_TIER_CLASSIFICATION_TIMEOUT_MS);
31
+ const signal = deps.signal ? AbortSignal.any([deps.signal, controller.signal]) : controller.signal;
32
+ try {
33
+ const effort = await classifyDifficulty(assignment, { ...deps, signal });
34
+ return effort === Effort.Minimal || effort === Effort.Low ? "smol" : undefined;
35
+ } catch (error) {
36
+ logger.debug("task: model-tier classification failed; leaving model resolution unchanged", {
37
+ error: error instanceof Error ? error.message : String(error),
38
+ });
39
+ return undefined;
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
@@ -13,6 +13,7 @@ export const BUILTIN_TOOL_NAMES = [
13
13
  "grep",
14
14
  "lsp",
15
15
  "inspect_image",
16
+ "vision_verify",
16
17
  "browser",
17
18
  "checkpoint",
18
19
  "rewind",