jeopi-cli 16.2.30 → 16.3.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.
@@ -189,12 +189,12 @@ export declare const SETTINGS_SCHEMA: {
189
189
  };
190
190
  readonly "advisor.includeThinking": {
191
191
  readonly type: "boolean";
192
- readonly default: true;
192
+ readonly default: false;
193
193
  readonly ui: {
194
194
  readonly tab: "model";
195
195
  readonly group: "Advisor";
196
196
  readonly label: "Advisor Sees Thinking";
197
- readonly description: "Include the primary agent's thinking blocks in advisor transcript updates. Some provider classifiers (e.g. Anthropic's reasoning_extraction) refuse payloads that replay thinking as text; the advisor also auto-disables this for the session after such a refusal.";
197
+ readonly description: "Include the primary agent's thinking blocks in advisor transcript updates. Off by default: a reviewer that sees the maker's own reasoning trail tends to anchor on conclusions the maker already committed to instead of bringing an independent angle (the advisor's own directive is to 'bring the angle they skipped, never re-run reasoning they already have'); the advisor still sees full messages, tool calls, results, and diffs. Some provider classifiers (e.g. Anthropic's reasoning_extraction) also refuse payloads that replay thinking as text if enabled; the advisor auto-disables this for the session after such a refusal.";
198
198
  readonly condition: "advisorEnabled";
199
199
  };
200
200
  };
@@ -4056,6 +4056,16 @@ export declare const SETTINGS_SCHEMA: {
4056
4056
  readonly description: "Run modes where active goals may auto-continue between turns";
4057
4057
  };
4058
4058
  };
4059
+ readonly "goal.verifyCompletion": {
4060
+ readonly type: "boolean";
4061
+ readonly default: true;
4062
+ readonly ui: {
4063
+ readonly tab: "tasks";
4064
+ readonly group: "Modes";
4065
+ readonly label: "Verify Goal Completion";
4066
+ readonly description: "Spawn an independent goal-verifier sub-agent to grade a completion claim against the objective and diff before accepting it";
4067
+ };
4068
+ };
4059
4069
  readonly "title.refreshOnReplan": {
4060
4070
  readonly type: "boolean";
4061
4071
  readonly default: true;
@@ -1,5 +1,11 @@
1
1
  import { GoalEvidenceTracker } from "./evidence-gate";
2
2
  import type { Goal, GoalBudgetSteering, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "./state";
3
+ export interface GoalCompletionVerdict {
4
+ verdict: "okay" | "iterate" | "reject";
5
+ justification: string;
6
+ summary?: string;
7
+ requiredFixes: string[];
8
+ }
3
9
  export interface GoalRuntimeHost {
4
10
  getState(): GoalModeState | undefined;
5
11
  setState(state: GoalModeState | undefined): void;
@@ -11,6 +17,16 @@ export interface GoalRuntimeHost {
11
17
  content: string;
12
18
  deliverAs?: "steer" | "followUp" | "nextTurn";
13
19
  }): Promise<void>;
20
+ /**
21
+ * Spawn an independent grader sub-agent to verdict a completion claim against
22
+ * the objective and the actual repo diff (article gap: "the agent that wrote
23
+ * the code is not the agent that grades it"). The sub-agent sees only the
24
+ * objective + evidence text passed here plus its own repo inspection — never
25
+ * this session's transcript/reasoning. Optional: hosts that don't wire this
26
+ * (e.g. test harnesses, `goal.verifyCompletion` disabled) skip independent
27
+ * grading and fall back to the deterministic evidence gate alone.
28
+ */
29
+ verifyCompletion?(objective: string, evidence: string): Promise<GoalCompletionVerdict>;
14
30
  now?(): number;
15
31
  }
16
32
  export interface GoalTurnSnapshot {
@@ -0,0 +1,9 @@
1
+ import type { ToolSession } from "../tools";
2
+ import type { GoalCompletionVerdict } from "./runtime";
3
+ /**
4
+ * Grade a goal-completion claim with the independent `goal-verifier` sub-agent.
5
+ * Never throws: a spawn failure (depth limit, disabled agent, no model) fails
6
+ * open to `iterate` so it costs one corrective bounce, the same as a genuine
7
+ * gap — never a silent skip of independent grading, never a permanent block.
8
+ */
9
+ export declare function verifyGoalCompletion(session: ToolSession, objective: string, evidence: string): Promise<GoalCompletionVerdict>;
@@ -35,7 +35,7 @@ import type { ExtensionRunner } from "../extensibility/extensions";
35
35
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
36
36
  import type { Skill, SkillWarning } from "../extensibility/skills";
37
37
  import { type FileSlashCommand } from "../extensibility/slash-commands";
38
- import { GoalRuntime } from "../goals/runtime";
38
+ import { type GoalCompletionVerdict, GoalRuntime } from "../goals/runtime";
39
39
  import type { Goal, GoalModeState } from "../goals/state";
40
40
  import type { HindsightSessionState } from "../hindsight/state";
41
41
  import { type IrcMessage } from "../irc/bus";
@@ -124,13 +124,19 @@ export type AgentSessionEvent = AgentEvent | {
124
124
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
125
125
  /**
126
126
  * Budget for callers on the user-visible `/quit` / `/exit` shutdown path that
127
- * want to cap how long they wait for `MnemopiSessionState.dispose()` to finish
128
- * its consolidate pass. Consolidate fires fresh LLM fact extractions, each a
129
- * 1–3 s round-trip, so interactive shutdown passes this budget to keep the
130
- * UI responsive. Callers that keep the process/session host alive must omit it
131
- * so dispose still awaits the full consolidate-then-close pipeline.
127
+ * want to cap how long they wait for `MnemopiSessionState.dispose()` (and the
128
+ * Hindsight retain-queue flush, see the dispose() call site) to finish writing
129
+ * this session's memory before the process exits. Consolidate fires fresh LLM
130
+ * fact extractions, each a 1-3s round-trip; the retain-queue flush is a single
131
+ * HTTP POST. The prior 1500ms budget was tighter than a single extraction
132
+ * round-trip, so it lost the race on effectively every session with any
133
+ * memory-worthy content — "write before walking away" in name only. 8s still
134
+ * bounds worst-case UI unresponsiveness on /exit while giving a small
135
+ * consolidation batch (1-3 facts) a realistic chance to land before the
136
+ * process exits. Callers that keep the process/session host alive must omit
137
+ * it so dispose still awaits the full write pipeline unbounded.
132
138
  */
133
- export declare const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1500;
139
+ export declare const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 8000;
134
140
  export interface AgentSessionDisposeOptions {
135
141
  mnemopiConsolidateTimeoutMs?: number;
136
142
  /**
@@ -225,6 +231,15 @@ export interface AgentSessionConfig {
225
231
  }>;
226
232
  /** Rebuild the SSH tool from current capability discovery results. */
227
233
  reloadSshTool?: () => Promise<AgentTool | null>;
234
+ /**
235
+ * Independent-verifier bridge for goal-mode completion (see goals/verifier.ts).
236
+ * Spawns the bundled `goal-verifier` sub-agent to grade a completion claim against
237
+ * the objective and the actual repo diff — a fresh session/context, no exposure to
238
+ * this session's transcript. Wired from sdk.ts (needs the constructed `toolSession`).
239
+ * Undefined when `goal.verifyCompletion` is off or the caller has no tool session
240
+ * (e.g. minimal SDK embedders) — GoalRuntime skips independent grading in that case.
241
+ */
242
+ verifyGoalCompletion?: (objective: string, evidence: string) => Promise<GoalCompletionVerdict>;
228
243
  requestedToolNames?: ReadonlySet<string>;
229
244
  /**
230
245
  * Optional accessor for live MCP server instructions. Read by the session's
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-cli",
4
- "version": "16.2.30",
4
+ "version": "16.3.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -57,17 +57,17 @@
57
57
  "@agentclientprotocol/sdk": "0.25.0",
58
58
  "@babel/parser": "^7.29.7",
59
59
  "@mozilla/readability": "^0.6.0",
60
- "jeopi-hashline": "16.2.30",
61
- "jeopi-stats": "16.2.30",
62
- "jeopi-agent-core": "16.2.30",
63
- "jeopi-ai": "16.2.30",
64
- "jeopi-catalog": "16.2.30",
65
- "jeopi-mnemopi": "16.2.30",
66
- "jeopi-natives": "16.2.30",
67
- "jeopi-tui": "16.2.30",
68
- "jeopi-utils": "16.2.30",
69
- "jeopi-wire": "16.2.30",
70
- "jeopi-snapcompact": "16.2.30",
60
+ "jeopi-hashline": "16.3.0",
61
+ "jeopi-stats": "16.3.0",
62
+ "jeopi-agent-core": "16.3.0",
63
+ "jeopi-ai": "16.3.0",
64
+ "jeopi-catalog": "16.3.0",
65
+ "jeopi-mnemopi": "16.3.0",
66
+ "jeopi-natives": "16.3.0",
67
+ "jeopi-tui": "16.3.0",
68
+ "jeopi-utils": "16.3.0",
69
+ "jeopi-wire": "16.3.0",
70
+ "jeopi-snapcompact": "16.3.0",
71
71
  "@opentelemetry/api": "^1.9.1",
72
72
  "@opentelemetry/context-async-hooks": "^2.7.1",
73
73
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -30,6 +30,16 @@ import type { AutoresearchRuntime, ExperimentResult, PendingRunSummary } from ".
30
30
 
31
31
  const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment", "update_notes"];
32
32
 
33
+ /**
34
+ * Cross-process auto-resume window: a pending (run-but-never-logged) experiment
35
+ * completed within this long ago is resumed automatically on the next session
36
+ * load; older ones are left for the user to inspect and resume by hand. Bounds
37
+ * the "open your laptop, the loop picks back up" pattern to realistic gaps
38
+ * (overnight, a workday) without silently relaunching token-spending work
39
+ * against a session nobody touched in weeks.
40
+ */
41
+ const AUTORESEARCH_RESUME_STALENESS_MS = 24 * 60 * 60 * 1000;
42
+
33
43
  export const createAutoresearchExtension: ExtensionFactory = api => {
34
44
  const runtimeStore = createRuntimeStore();
35
45
  const dashboard = createDashboardController();
@@ -47,7 +57,7 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
47
57
  return { session, currentBranch };
48
58
  };
49
59
 
50
- const rehydrate = async (ctx: ExtensionContext): Promise<void> => {
60
+ const rehydrate = async (ctx: ExtensionContext, resumeEligible: boolean): Promise<void> => {
51
61
  const runtime = getRuntime(ctx);
52
62
  const control = reconstructControlState(ctx.sessionManager.getBranch());
53
63
  runtime.goal = control.goal;
@@ -69,13 +79,15 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
69
79
  const onActiveBranch = session === null || session.branch === null || session.branch === currentBranch;
70
80
  runtime.autoresearchMode = control.autoresearchMode && onActiveBranch;
71
81
 
82
+ let pendingRow: RunRow | null = null;
72
83
  if (session && onActiveBranch) {
73
84
  const storage = await openAutoresearchStorageIfExists(ctx.cwd);
74
85
  if (storage) {
75
86
  const loggedRuns = storage.listLoggedRuns(session.id);
76
87
  runtime.state = buildExperimentState(session, loggedRuns);
77
88
  runtime.goal = runtime.goal ?? session.goal;
78
- runtime.lastRunSummary = pendingRunSummaryFromRow(storage.getPendingRun(session.id));
89
+ pendingRow = storage.getPendingRun(session.id);
90
+ runtime.lastRunSummary = pendingRunSummaryFromRow(pendingRow);
79
91
  } else {
80
92
  runtime.state = createExperimentState();
81
93
  runtime.lastRunSummary = null;
@@ -102,6 +114,41 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
102
114
  if (toolsChanged) {
103
115
  await api.setActiveTools(nextActiveTools);
104
116
  }
117
+
118
+ // Auto-rearm across a process restart (article gap: "the mode itself has to be
119
+ // manually re-armed with /autoresearch after a restart, since session_shutdown
120
+ // doesn't leave anything wired to auto-relaunch" — the "morning briefing"/
121
+ // nightly-loop pattern requires the NEXT process to pick a pending run back up
122
+ // on its own, not wait for the user to notice and retype /autoresearch).
123
+ // `autoResumeArmed` is deliberately NOT restored here (reset above, matching
124
+ // existing behavior) — that flag is a live in-process signal set by the
125
+ // experiment tools mid-run; it cannot be reconstructed from persisted state
126
+ // and re-arming it unconditionally on every resume would fire even when the
127
+ // prior process ended cleanly with nothing left to continue. The only
128
+ // cross-restart-durable signal is an actual pending (run-but-never-logged)
129
+ // run row, so that's the sole trigger here.
130
+ if (
131
+ resumeEligible &&
132
+ runtime.autoresearchMode &&
133
+ pendingRow?.completedAt !== undefined &&
134
+ pendingRow.completedAt !== null &&
135
+ Date.now() - pendingRow.completedAt <= AUTORESEARCH_RESUME_STALENESS_MS &&
136
+ !ctx.hasPendingMessages()
137
+ ) {
138
+ const pendingRun = runtime.lastRunSummary;
139
+ runtime.lastAutoResumePendingRunNumber = pendingRow.id;
140
+ api.sendMessage(
141
+ {
142
+ customType: "autoresearch-resume",
143
+ content: prompt.render(resumeMessageTemplate, {
144
+ has_pending_run: Boolean(pendingRun),
145
+ }),
146
+ display: false,
147
+ attribution: "agent",
148
+ },
149
+ { deliverAs: "nextTurn", triggerTurn: true },
150
+ );
151
+ }
105
152
  };
106
153
 
107
154
  const setMode = (
@@ -245,10 +292,17 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
245
292
  },
246
293
  });
247
294
 
248
- api.on("session_start", (_event, ctx) => rehydrate(ctx));
249
- api.on("session_switch", (_event, ctx) => rehydrate(ctx));
250
- api.on("session_branch", (_event, ctx) => rehydrate(ctx));
251
- api.on("session_tree", (_event, ctx) => rehydrate(ctx));
295
+ api.on("session_start", (_event, ctx) => rehydrate(ctx, true));
296
+ // "resume" = picked from the /resume session picker or `jeopi -r`, the exact
297
+ // "reopen your laptop, the loop should still be going" moment the article
298
+ // describes. "new"/"fork" start a fresh conversation off old history — no
299
+ // auto-continuation there, matching session_start's "don't fire on plain
300
+ // process launch into an unrelated session" restraint.
301
+ api.on("session_switch", (event, ctx) => rehydrate(ctx, event.reason === "resume"));
302
+ // Mid-conversation navigation (branch off an earlier message, move through the
303
+ // tree), not a process/session restart — never auto-fires a fresh turn here.
304
+ api.on("session_branch", (_event, ctx) => rehydrate(ctx, false));
305
+ api.on("session_tree", (_event, ctx) => rehydrate(ctx, false));
252
306
  api.on("session_shutdown", (_event, ctx) => {
253
307
  dashboard.clear(ctx);
254
308
  runtimeStore.clear(getSessionKey(ctx));
@@ -416,13 +416,13 @@ export const SETTINGS_SCHEMA = {
416
416
  },
417
417
  "advisor.includeThinking": {
418
418
  type: "boolean",
419
- default: true,
419
+ default: false,
420
420
  ui: {
421
421
  tab: "model",
422
422
  group: "Advisor",
423
423
  label: "Advisor Sees Thinking",
424
424
  description:
425
- "Include the primary agent's thinking blocks in advisor transcript updates. Some provider classifiers (e.g. Anthropic's reasoning_extraction) refuse payloads that replay thinking as text; the advisor also auto-disables this for the session after such a refusal.",
425
+ "Include the primary agent's thinking blocks in advisor transcript updates. Off by default: a reviewer that sees the maker's own reasoning trail tends to anchor on conclusions the maker already committed to instead of bringing an independent angle (the advisor's own directive is to 'bring the angle they skipped, never re-run reasoning they already have'); the advisor still sees full messages, tool calls, results, and diffs. Some provider classifiers (e.g. Anthropic's reasoning_extraction) also refuse payloads that replay thinking as text if enabled; the advisor auto-disables this for the session after such a refusal.",
426
426
  condition: "advisorEnabled",
427
427
  },
428
428
  },
@@ -3946,6 +3946,18 @@ export const SETTINGS_SCHEMA = {
3946
3946
  },
3947
3947
  },
3948
3948
 
3949
+ "goal.verifyCompletion": {
3950
+ type: "boolean",
3951
+ default: true,
3952
+ ui: {
3953
+ tab: "tasks",
3954
+ group: "Modes",
3955
+ label: "Verify Goal Completion",
3956
+ description:
3957
+ "Spawn an independent goal-verifier sub-agent to grade a completion claim against the objective and diff before accepting it",
3958
+ },
3959
+ },
3960
+
3949
3961
  "title.refreshOnReplan": {
3950
3962
  type: "boolean",
3951
3963
  default: true,
@@ -6,6 +6,13 @@ import { assertSubstantiveCompletionEvidence } from "./completion-evidence";
6
6
  import { GoalEvidenceTracker, isVerificationSignal, MUTATION_TOOL_NAMES } from "./evidence-gate";
7
7
  import type { Goal, GoalBudgetSteering, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "./state";
8
8
 
9
+ export interface GoalCompletionVerdict {
10
+ verdict: "okay" | "iterate" | "reject";
11
+ justification: string;
12
+ summary?: string;
13
+ requiredFixes: string[];
14
+ }
15
+
9
16
  export interface GoalRuntimeHost {
10
17
  getState(): GoalModeState | undefined;
11
18
  setState(state: GoalModeState | undefined): void;
@@ -17,6 +24,16 @@ export interface GoalRuntimeHost {
17
24
  content: string;
18
25
  deliverAs?: "steer" | "followUp" | "nextTurn";
19
26
  }): Promise<void>;
27
+ /**
28
+ * Spawn an independent grader sub-agent to verdict a completion claim against
29
+ * the objective and the actual repo diff (article gap: "the agent that wrote
30
+ * the code is not the agent that grades it"). The sub-agent sees only the
31
+ * objective + evidence text passed here plus its own repo inspection — never
32
+ * this session's transcript/reasoning. Optional: hosts that don't wire this
33
+ * (e.g. test harnesses, `goal.verifyCompletion` disabled) skip independent
34
+ * grading and fall back to the deterministic evidence gate alone.
35
+ */
36
+ verifyCompletion?(objective: string, evidence: string): Promise<GoalCompletionVerdict>;
20
37
  now?(): number;
21
38
  }
22
39
 
@@ -122,6 +139,8 @@ export class GoalRuntime {
122
139
  #turnSnapshot: GoalTurnSnapshot | undefined;
123
140
  #wallClock: GoalWallClockSnapshot;
124
141
  #budgetReportedFor: string | undefined;
142
+ /** Set when an independent-verifier non-okay verdict was bounced once; cleared by any new mutation/verification or a fresh goal. Mirrors GoalEvidenceTracker's bypass latch, kept separate because it gates a different (probabilistic) signal. */
143
+ #verifierBypassArmed = false;
125
144
  #accountingTail: Promise<void> = Promise.resolve();
126
145
 
127
146
  constructor(host: GoalRuntimeHost) {
@@ -219,10 +238,12 @@ export class GoalRuntime {
219
238
  if (!this.#hasAccountingState()) return;
220
239
  if (MUTATION_TOOL_NAMES[toolName]) {
221
240
  this.#evidence.recordMutation();
241
+ this.#verifierBypassArmed = false;
222
242
  return;
223
243
  }
224
244
  if (detail && isVerificationSignal(detail.command ?? "", detail.outputHead ?? "")) {
225
245
  this.#evidence.recordVerification();
246
+ this.#verifierBypassArmed = false;
226
247
  }
227
248
  }
228
249
 
@@ -418,6 +439,7 @@ export class GoalRuntime {
418
439
  const state = this.#createGoalState(objective, input.tokenBudget);
419
440
  this.#budgetReportedFor = undefined;
420
441
  this.#evidence.reset();
442
+ this.#verifierBypassArmed = false;
421
443
  this.#markActiveAccounting(state.goal);
422
444
  await this.#commitState(state, { persist: "goal" });
423
445
  return state;
@@ -437,6 +459,7 @@ export class GoalRuntime {
437
459
  const state = this.#createGoalState(objective, input.tokenBudget);
438
460
  this.#budgetReportedFor = undefined;
439
461
  this.#evidence.reset();
462
+ this.#verifierBypassArmed = false;
440
463
  this.#markActiveAccounting(state.goal);
441
464
  await this.#commitState(state, { persist: "goal" });
442
465
  return state;
@@ -522,6 +545,34 @@ export class GoalRuntime {
522
545
  if (gate.block) {
523
546
  throw new Error(gate.message);
524
547
  }
548
+ // Independent grader (goal-verifier sub-agent, see GoalRuntimeHost.verifyCompletion):
549
+ // maker != grader, per the compounding-loop primitive — the same agent that did
550
+ // the work does not get to be the sole judge of whether it's done. Same bounce-once
551
+ // shape as the deterministic gate above, kept as a separate latch since this signal
552
+ // is probabilistic (an LLM verdict), not mechanical.
553
+ if (this.#host.verifyCompletion) {
554
+ const verdict = await this.#host.verifyCompletion(state.goal.objective, verifiedEvidence);
555
+ if (verdict.verdict !== "okay") {
556
+ if (this.#verifierBypassArmed) {
557
+ // Second attempt with no new mutation/verification since the bounce:
558
+ // escape hatch for a verifier false-positive-reject/iterate.
559
+ this.#verifierBypassArmed = false;
560
+ } else {
561
+ this.#verifierBypassArmed = true;
562
+ const fixes =
563
+ verdict.requiredFixes.length > 0
564
+ ? `\nRequired fixes:\n${verdict.requiredFixes.map(fix => `- ${fix}`).join("\n")}`
565
+ : "";
566
+ throw new Error(
567
+ `Completion blocked: independent verifier verdict was "${verdict.verdict}", not "okay". ` +
568
+ `${verdict.justification}${fixes}\nAddress the gaps above, then call complete again. ` +
569
+ "If the verifier is wrong, call complete again unchanged to override.",
570
+ );
571
+ }
572
+ } else {
573
+ this.#verifierBypassArmed = false;
574
+ }
575
+ }
525
576
  state.enabled = false;
526
577
  state.goal.status = "complete";
527
578
  state.goal.completionEvidence = verifiedEvidence;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Independent-verifier bridge for goal-mode completion (see GoalRuntimeHost.verifyCompletion
3
+ * in runtime.ts). Spawns the bundled `goal-verifier` sub-agent — a fresh session with its own
4
+ * kernel, its own read-only tool set, and no exposure to the maker's transcript/reasoning —
5
+ * to grade a completion claim against the objective and the actual repo diff. Reuses the eval
6
+ * tool's `agent()` bridge as a plain async call (no eval kernel required outside eval cells;
7
+ * `withBridgeTimeoutPause` in agent-bridge.ts is a no-op when no `emitStatus` sink is wired).
8
+ */
9
+ import { runEvalAgent } from "../eval/agent-bridge";
10
+ import { getBundledAgent } from "../task/agents";
11
+ import type { ToolSession } from "../tools";
12
+ import type { GoalCompletionVerdict } from "./runtime";
13
+
14
+ const VERIFIER_AGENT_NAME = "goal-verifier";
15
+
16
+ /** Cap the evidence claim embedded in the verifier prompt; the verifier re-derives ground truth from the repo, not from a long claim. */
17
+ const MAX_EVIDENCE_CHARS = 4000;
18
+
19
+ function parseVerdict(output: string): GoalCompletionVerdict {
20
+ let data: unknown;
21
+ try {
22
+ data = JSON.parse(output);
23
+ } catch {
24
+ // Verifier didn't return a structured payload (refused, crashed, plain-text
25
+ // reply). Fail open to "iterate" rather than silently trusting completion —
26
+ // a broken verifier bounces once instead of being bypassed unnoticed.
27
+ return {
28
+ verdict: "iterate",
29
+ justification: `goal-verifier did not return a structured verdict. Raw output: ${output.slice(0, 500)}`,
30
+ requiredFixes: [],
31
+ };
32
+ }
33
+ if (data === null || typeof data !== "object") {
34
+ return {
35
+ verdict: "iterate",
36
+ justification: "goal-verifier returned a non-object payload.",
37
+ requiredFixes: [],
38
+ };
39
+ }
40
+ const record = data as Record<string, unknown>;
41
+ const verdict = record.verdict;
42
+ if (verdict !== "okay" && verdict !== "iterate" && verdict !== "reject") {
43
+ return {
44
+ verdict: "iterate",
45
+ justification: "goal-verifier returned an unrecognized verdict value.",
46
+ requiredFixes: [],
47
+ };
48
+ }
49
+ const rawFixes = record.required_fixes;
50
+ const requiredFixes = Array.isArray(rawFixes)
51
+ ? rawFixes.filter((fix): fix is string => typeof fix === "string")
52
+ : [];
53
+ const justification = typeof record.justification === "string" ? record.justification : "";
54
+ const summary = typeof record.summary === "string" ? record.summary : undefined;
55
+ return { verdict, justification, summary, requiredFixes };
56
+ }
57
+
58
+ /**
59
+ * Grade a goal-completion claim with the independent `goal-verifier` sub-agent.
60
+ * Never throws: a spawn failure (depth limit, disabled agent, no model) fails
61
+ * open to `iterate` so it costs one corrective bounce, the same as a genuine
62
+ * gap — never a silent skip of independent grading, never a permanent block.
63
+ */
64
+ export async function verifyGoalCompletion(
65
+ session: ToolSession,
66
+ objective: string,
67
+ evidence: string,
68
+ ): Promise<GoalCompletionVerdict> {
69
+ const truncatedEvidence =
70
+ evidence.length > MAX_EVIDENCE_CHARS ? `${evidence.slice(0, MAX_EVIDENCE_CHARS)}\n[truncated]` : evidence;
71
+ const verifierPrompt =
72
+ `<objective>\n${objective}\n</objective>\n\n` +
73
+ `<evidence>\n${truncatedEvidence}\n</evidence>\n\n` +
74
+ "Verdict whether the objective above is genuinely satisfied by the repo's current state. " +
75
+ "The evidence is the maker's claim, not a fact — verify it independently.";
76
+ try {
77
+ // Explicit schema, not the agent's own frontmatter default: runEvalAgent (the
78
+ // eval `agent()` bridge) only applies `outputSchema` when the caller supplies
79
+ // one — unlike the `task` tool, it never falls back to `effectiveAgent.output`
80
+ // (task/index.ts). Read the bundled definition's own `output` so this can't
81
+ // drift from goal-verifier.md's declared shape. Omit the key entirely when
82
+ // undefined: `Object.hasOwn` (agent-bridge.ts's `structured` check) is true
83
+ // for an explicit `schema: undefined`, which would wrongly disable the
84
+ // agent's frontmatter-schema fallback in the bridge.
85
+ const schema = getBundledAgent(VERIFIER_AGENT_NAME)?.output;
86
+ const args =
87
+ schema === undefined
88
+ ? { prompt: verifierPrompt, agent: VERIFIER_AGENT_NAME }
89
+ : { prompt: verifierPrompt, agent: VERIFIER_AGENT_NAME, schema };
90
+ const result = await runEvalAgent(args, { session });
91
+ return parseVerdict(result.text);
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ return {
95
+ verdict: "iterate",
96
+ justification: `Independent verifier could not run: ${message}`,
97
+ requiredFixes: [],
98
+ };
99
+ }
100
+ }
@@ -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>
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;