jeo-code 0.8.23 → 0.8.24

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.
@@ -9,7 +9,7 @@
9
9
  * repo). Subagents are spawned with `subagentToolset(role)`, which never includes
10
10
  * `task` itself, so delegation cannot recurse infinitely.
11
11
  */
12
- import { runAgentLoop, type ToolHandler } from "./engine";
12
+ import { runAgentLoop, isSafetyFallbackBail, stripSafetyFallbackTag, type ToolHandler } from "./engine";
13
13
  import { toolTarget } from "./step-budget";
14
14
 
15
15
  import type { ToolResult } from "./tools";
@@ -30,7 +30,7 @@ import {
30
30
  validateSubagentDoneReason,
31
31
  type SubagentRole,
32
32
  } from "./subagents";
33
- import { resolveMaxOutputTokens } from "../ai/model-manager";
33
+ import { resolveMaxOutputTokens, resolveProvider } from "../ai/model-manager";
34
34
  import type { SubagentRegistry } from "./subagent-registry";
35
35
  import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
36
36
  import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool, strongestMidTierCredentialed, type RoutingConfig } from "./prompt-router";
@@ -107,7 +107,7 @@ export interface TaskToolOptions {
107
107
  * disjoint files (documented in `taskToolProtocolLine`); overlapping scopes
108
108
  * should either run sequentially (separate `task` calls) or coordinate — jeo has
109
109
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
110
- const MAX_FANOUT = 4;
110
+ export const MAX_FANOUT = 4;
111
111
 
112
112
  /** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
113
113
  * launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
@@ -263,6 +263,14 @@ export interface SubagentRunResult extends ToolResult {
263
263
  fileMutations: number;
264
264
  /** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
265
265
  bashRuns: number;
266
+ /** Successful read/search/find/ast_grep/lsp calls observed — evidence a
267
+ * READ-ONLY role (architect/critic) actually inspected real files before
268
+ * rendering a verdict, mirroring goal-verifier.ts's `applyEvidenceGate`
269
+ * philosophy (a claim is only trustworthy when backed by an OBSERVED
270
+ * signal in THIS run, never by the model's own unverified assertion).
271
+ * Zero means the verdict's `Inspected:`/prose claims are unevidenced,
272
+ * regardless of what the text says. */
273
+ readOnlyEvidenceCalls: number;
266
274
  }
267
275
 
268
276
  /**
@@ -312,6 +320,7 @@ export async function runSubagentOnce(
312
320
  // never which model dispatched a given step.
313
321
  let fileMutations = 0;
314
322
  let bashRuns = 0;
323
+ let readOnlyEvidenceCalls = 0;
315
324
  // Per-run prompt-cache key: the subagent replays its own growing history each
316
325
  // step, so a stable key (even across a model switch below — a stale cache key
317
326
  // on a NEW provider is simply unused, never wrong) gets provider cache hits
@@ -357,6 +366,28 @@ export async function runSubagentOnce(
357
366
  // retry ladder on the FIRST failed attempt instead of riding ~90s of backoff when
358
367
  // a genuinely different-credential-scope candidate is available RIGHT NOW.
359
368
  const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
369
+ // Providers that have ALREADY produced an uncategorized-refusal bail THIS run —
370
+ // accumulates across reroutes (mirrors excludedCredentialScopes' own pattern)
371
+ // so a 2nd safety bail (on the FIRST fallback model) can never cycle back to a
372
+ // provider already ruled out, even though `attemptedModels` only tracks exact
373
+ // model ids, not whole providers.
374
+ const excludedSafetyProviders = new Set<string>();
375
+ // Stricter than fallbackCandidates(): a same-provider sibling shares the SAME
376
+ // constitutional/moderation classifier (unlike a same-credential-scope sibling,
377
+ // which is a BILLING boundary, not a classifier boundary) — trying it after an
378
+ // uncategorized refusal is not a genuinely different verdict, it's very likely
379
+ // the identical false positive again. Always excludes the CURRENT provider (not
380
+ // just accumulated ones), so this is correct from the very first check too.
381
+ const safetyFallbackCandidates = (): string[] => {
382
+ const currentProvider = resolveProvider(activeModel);
383
+ return fallbackCandidates().filter(m => {
384
+ const p = resolveProvider(m);
385
+ return p !== currentProvider && !excludedSafetyProviders.has(p);
386
+ });
387
+ };
388
+ // Fed to AgentLoopOptions.safetyFallbackAvailable — see its doc comment for the
389
+ // "false positive -> try a different model" design this implements.
390
+ const safetyFallbackAvailable = (): boolean => safetyFallbackCandidates().length > 0;
360
391
 
361
392
  let activeModel = initialModel;
362
393
  emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
@@ -374,6 +405,7 @@ export async function runSubagentOnce(
374
405
  steer,
375
406
  tools: subagentToolset(role),
376
407
  rateLimitFallbackAvailable,
408
+ safetyFallbackAvailable,
377
409
  events: {
378
410
  onStep: n => { currentStep = n; },
379
411
  // Live reasoning preview (native extended-thinking models only — the JSON-protocol
@@ -398,6 +430,7 @@ export async function runSubagentOnce(
398
430
  if (success) {
399
431
  if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
400
432
  else if (tool === "bash") bashRuns++;
433
+ else if (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp") readOnlyEvidenceCalls++;
401
434
  }
402
435
  const label = lastTarget || tool;
403
436
  const summary = firstUsefulLine(output);
@@ -418,22 +451,35 @@ export async function runSubagentOnce(
418
451
 
419
452
  let result = await runOnce();
420
453
  for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
421
- if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
422
- // The model that just 429'd: exclude its WHOLE credential scope (not just its
423
- // own id) so a sibling model riding the SAME exhausted OAuth subscription is
424
- // never proposed as a "fallback" (cca5fe2's fix, mirrored here).
425
- const failedScope = credentialScopeFor(activeModel, opts.config);
426
- if (failedScope) excludedCredentialScopes.add(failedScope.key);
427
- const candidates = fallbackCandidates();
428
- if (candidates.length === 0) break;
454
+ const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
455
+ const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
456
+ if (!rateLimited && !safetyBail) break;
429
457
  const previousModel = activeModel;
430
- activeModel = selectFromPool(candidates, undefined);
458
+ if (rateLimited) {
459
+ // The model that just 429'd: exclude its WHOLE credential scope (not just its
460
+ // own id) so a sibling model riding the SAME exhausted OAuth subscription is
461
+ // never proposed as a "fallback" (cca5fe2's fix, mirrored here).
462
+ const failedScope = credentialScopeFor(activeModel, opts.config);
463
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
464
+ const candidates = fallbackCandidates();
465
+ if (candidates.length === 0) break;
466
+ activeModel = selectFromPool(candidates, undefined);
467
+ emit({ role: role.id, kind: "step", detail: `↳ rate limited on '${previousModel}' — switching to equivalent '${activeModel}'`, step: currentStep, maxSteps, model: activeModel });
468
+ } else {
469
+ // Uncategorized refusal with a fallback available (engine already verified
470
+ // this via safetyFallbackAvailable before bailing) — exclude the WHOLE
471
+ // provider (see safetyFallbackCandidates' doc comment), not just this model.
472
+ excludedSafetyProviders.add(resolveProvider(activeModel));
473
+ const candidates = safetyFallbackCandidates();
474
+ if (candidates.length === 0) break;
475
+ activeModel = selectFromPool(candidates, undefined);
476
+ emit({ role: role.id, kind: "step", detail: `↳ refused on '${previousModel}' — switching to '${activeModel}' in case of a classifier false positive`, step: currentStep, maxSteps, model: activeModel });
477
+ }
431
478
  attemptedModels.add(activeModel);
432
- emit({ role: role.id, kind: "step", detail: `↳ rate limited on '${previousModel}' — switching to equivalent '${activeModel}'`, step: currentStep, maxSteps, model: activeModel });
433
479
  result = await runOnce();
434
480
  }
435
481
 
436
- const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
482
+ const reason = stripSafetyFallbackTag(result.doneReason?.trim() || "") || `(subagent reached the ${result.steps}-step limit without signaling done)`;
437
483
  const validation = validateSubagentDoneReason(role, reason);
438
484
  const complete = result.done && validation.ok;
439
485
  const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
@@ -459,6 +505,7 @@ export async function runSubagentOnce(
459
505
  missingMarkers: validation.ok ? undefined : validation.missing,
460
506
  fileMutations,
461
507
  bashRuns,
508
+ readOnlyEvidenceCalls,
462
509
  };
463
510
  }
464
511
 
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { PassThrough } from "node:stream";
4
- import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
4
+ import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
5
5
  import { computerSupervisor } from "../agent/computer-supervisor";
6
6
  import { executeComputerAction } from "./computer";
7
7
 
@@ -10,6 +10,7 @@ import { initialDynamicStepLimit } from "../agent/step-budget";
10
10
  import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
11
11
  import { recordSkillLesson } from "../agent/skill-lessons";
12
12
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
13
+ import { createEvalTool, evalToolProtocolLine } from "../agent/eval-tool";
13
14
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
14
15
  import { SubagentRegistry } from "../agent/subagent-registry";
15
16
  import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
@@ -550,7 +551,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
550
551
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
551
552
  const contextFiles = await loadProjectContext(cwd);
552
553
 
553
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
554
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "eval", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
554
555
  let allowedTools = new Set(KNOWN_TOOLS);
555
556
 
556
557
  if (flags.noTools) {
@@ -618,6 +619,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
618
619
  "\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
619
620
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
620
621
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
622
+ (allowedTools.has("eval") ? "\n\nDynamic Workflows: " + evalToolProtocolLine(cfg) : "") +
621
623
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
622
624
  (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
623
625
  " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
@@ -1012,11 +1014,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1012
1014
  // genuinely different credential.
1013
1015
  const isAccountLevelRouteFailure = (reason: string): boolean =>
1014
1016
  /hit a rate limit|hit a usage\/quota limit|is unauthenticated for this session|has no usable credential|requires billing\/payment on this provider account/i.test(reason);
1017
+ // Providers that have ALREADY produced a safety-classifier bail THIS turn —
1018
+ // accumulates across fallback rounds (mirrors excludedCredentialScopes exactly,
1019
+ // one paragraph up). Provider-scoped rather than credential-scoped: a
1020
+ // same-provider sibling model shares the SAME constitutional/moderation
1021
+ // classifier regardless of which credential serves it (unlike a rate limit,
1022
+ // which is a billing/quota boundary, not a classifier boundary) — trying it
1023
+ // after an uncategorized refusal is very likely the identical false positive,
1024
+ // not a genuinely different verdict.
1025
+ const excludedSafetyProviders = new Set<string>();
1026
+ // Matches ONLY routeFailureReason's dedicated safety-fallback branch (the
1027
+ // engine's `SafetyFallback (uncategorized):`-tagged bail, stripped to this
1028
+ // reason text) — deliberately narrower than isRefusalError/isSafetyFallbackBail
1029
+ // so a genuinely categorized (true-positive) refusal, which never reaches this
1030
+ // branch at all, can never be classified as an account-level OR safety-level
1031
+ // route failure by this predicate.
1032
+ const isSafetyRouteFailure = (reason: string): boolean => /was refused \(possible safety classifier false positive\)/i.test(reason);
1015
1033
  const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
1016
1034
  if (isAccountLevelRouteFailure(reason)) {
1017
1035
  const scope = credentialScopeFor(decision.model, turnConfig);
1018
1036
  if (scope) excludedCredentialScopes.add(scope.key);
1019
1037
  }
1038
+ // Provider-level exclusion (not just the credential scope above): a
1039
+ // same-provider sibling shares the identical classifier a safety refusal
1040
+ // just tripped on, so it is excluded ENTIRELY here — a stricter exclusion
1041
+ // than the account-level branch's credential-scope-only one, matching
1042
+ // task-tool.ts's `safetyFallbackCandidates` (same rationale, same shape).
1043
+ if (isSafetyRouteFailure(reason)) excludedSafetyProviders.add(resolveProvider(decision.model));
1020
1044
  const excluded = new Set([decision.model, ...exclude]);
1021
1045
  const poolCandidates = (): string[] =>
1022
1046
  tierModelPool(decision.tier, turnConfig)
@@ -1025,6 +1049,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1025
1049
  const scope = credentialScopeFor(model, turnConfig);
1026
1050
  return !scope || !excludedCredentialScopes.has(scope.key);
1027
1051
  })
1052
+ .filter(model => !excludedSafetyProviders.has(resolveProvider(model)))
1028
1053
  .filter(model => !images?.length || catalogMetadata(model)?.images !== false);
1029
1054
  let candidates = poolCandidates();
1030
1055
  // The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
@@ -1465,6 +1490,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1465
1490
  ? (e => tui?.onSubagentEvent(e))
1466
1491
  : (e => logTaskSubEvent(e)),
1467
1492
  }),
1493
+ eval: createEvalTool({
1494
+ config: { ...turnConfig, defaultModel: activeModel },
1495
+ signal: ac.signal,
1496
+ steer: drainSteer,
1497
+ onEvent: useTui
1498
+ ? (e => tui?.onSubagentEvent(e))
1499
+ : (e => logTaskSubEvent(e)),
1500
+ }),
1468
1501
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1469
1502
  subagent: createSubagentTool(subagentRegistry),
1470
1503
  job: createJobTool(jobRegistry),
@@ -1543,6 +1576,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1543
1576
  })
1544
1577
  .some(model => !images?.length || catalogMetadata(model)?.images !== false);
1545
1578
  };
1579
+ // Same shape as rateLimitFallbackAvailable above, plus a same-PROVIDER
1580
+ // exclusion (not just same-credential-scope): a sibling model from the
1581
+ // provider that JUST safety-bailed shares its classifier, so it is not a
1582
+ // genuinely different fallback candidate (see excludedSafetyProviders'
1583
+ // doc comment). Fed to AgentLoopOptions.safetyFallbackAvailable.
1584
+ const safetyFallbackAvailable = (): boolean => {
1585
+ const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
1586
+ const currentProvider = resolveProvider(fallbackBaseDecision.model);
1587
+ return tierModelPool(fallbackBaseDecision.tier, turnConfig)
1588
+ .filter(model => !attemptedRouteModels.has(model))
1589
+ .filter(model => resolveProvider(model) !== currentProvider && !excludedSafetyProviders.has(resolveProvider(model)))
1590
+ .filter(model => {
1591
+ const scope = credentialScopeFor(model, turnConfig);
1592
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
1593
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
1594
+ return !excludedCredentialScopes.has(scope.key);
1595
+ })
1596
+ .some(model => !images?.length || catalogMetadata(model)?.images !== false);
1597
+ };
1546
1598
  const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
1547
1599
  cwd,
1548
1600
  tools,
@@ -1555,11 +1607,20 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1555
1607
  sessionKey: cacheKey,
1556
1608
  steer: drainSteer,
1557
1609
  rateLimitFallbackAvailable,
1610
+ safetyFallbackAvailable,
1558
1611
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1559
1612
  });
1560
1613
  const routeFailureReason = (doneReason: unknown): string | null => {
1561
1614
  const message = String(doneReason ?? "");
1562
1615
  if (!message) return "stopped without a final answer";
1616
+ // MUST be checked before every pattern below: the engine's own tagged
1617
+ // safety-fallback bail wraps a `friendlyProviderError` refusal message,
1618
+ // which contains "refusal"/"safety" text that the safety-refusal guard
1619
+ // further down (`returned no content` branch) would otherwise swallow
1620
+ // (return null, never reroute) — this tag means the engine ALREADY
1621
+ // verified a fallback is available and deliberately asked for a reroute,
1622
+ // so it must win outright, not fall through to that guard.
1623
+ if (isSafetyFallbackBail(message)) return "was refused (possible safety classifier false positive)";
1563
1624
  const asError = new Error(message);
1564
1625
  if (isRateLimitError(asError)) return "hit a rate limit";
1565
1626
  if (isUsageLimitError(asError)) return "hit a usage/quota limit";
@@ -1720,7 +1781,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1720
1781
  }
1721
1782
  // A completed turn with an empty done-reason must NOT masquerade as a step-limit
1722
1783
  // failure ("reached the 3-step limit" after the model called done at step 3).
1723
- const reply = result.doneReason
1784
+ const reply = stripSafetyFallbackTag(result.doneReason ?? "")
1724
1785
  || (result.done
1725
1786
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1726
1787
  : `(reached the ${result.steps}-step limit without signaling done)`);
@@ -1820,6 +1881,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1820
1881
 
1821
1882
  const joinedArgs = flags.message;
1822
1883
  const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
1884
+ // hermes-style experience distill (plan/gjc-inheritance.md B6) for one-shot
1885
+ // (`-p`/`--print`/piped) mode — previously ONLY the interactive exit path
1886
+ // spawned this, so a one-shot invocation (the common CI/scripted shape, e.g.
1887
+ // `jeo "task" -p`) never wrote anything to session memory no matter how many
1888
+ // turns it ran. Mirrors the interactive path's exact gate (sessionUsage.turns
1889
+ // > 0) and stays silent (no stdout noise) since one-shot output may be
1890
+ // piped/parsed by a script.
1891
+ const maybeDistillOneShot = async (): Promise<void> => {
1892
+ if (sessionUsage.turns > 0) {
1893
+ await spawnDetachedDistill(history, cwd, sessionModel || defaultModel);
1894
+ }
1895
+ };
1823
1896
 
1824
1897
  if (isOneShot) {
1825
1898
  let messageContent = joinedArgs;
@@ -2006,12 +2079,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2006
2079
  for (const inv of skillChain.invocations) {
2007
2080
  await runOneSkillShot(inv);
2008
2081
  }
2082
+ await maybeDistillOneShot();
2009
2083
  return;
2010
2084
  }
2011
2085
  // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
2012
2086
  const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
2013
2087
  if (skillInvocation) {
2014
2088
  await runOneSkillShot(skillInvocation);
2089
+ await maybeDistillOneShot();
2015
2090
  return;
2016
2091
  }
2017
2092
  try {
@@ -2021,6 +2096,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2021
2096
  } catch (err) {
2022
2097
  console.log(`! ${friendlyProviderError(err)}`);
2023
2098
  }
2099
+ await maybeDistillOneShot();
2024
2100
  return;
2025
2101
  }
2026
2102
 
@@ -73,6 +73,13 @@ export async function runConsensusCriticGate(args: {
73
73
  const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
74
74
 
75
75
  let activeModel = initialModel;
76
+ // Evidence tracking (mirrors task-tool.ts's runSubagentOnce readOnlyEvidenceCalls
77
+ // + team.ts's evaluateSubagentResult gate): this consensus gate is the OTHER
78
+ // independent-verifier code path in jeo (team.ts's per-step gate is the first) —
79
+ // it does NOT go through runSubagentOnce, so it needs its own tracking. A verdict
80
+ // is gated the SAME way: zero observed read/search/find/ast_grep/lsp calls means
81
+ // the [OKAY]/[ITERATE]/[REJECT] text is unevidenced regardless of what it claims.
82
+ let evidenceCalls = 0;
76
83
  const runOnce = () => runAgentLoop(history, {
77
84
  cwd: args.cwd,
78
85
  model: activeModel,
@@ -81,6 +88,11 @@ export async function runConsensusCriticGate(args: {
81
88
  signal: args.signal,
82
89
  tools: subagentToolset(role),
83
90
  rateLimitFallbackAvailable,
91
+ events: {
92
+ onToolResult: (tool, success) => {
93
+ if (success && (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp")) evidenceCalls++;
94
+ },
95
+ },
84
96
  });
85
97
  let result = await runOnce();
86
98
  for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
@@ -97,6 +109,9 @@ export async function runConsensusCriticGate(args: {
97
109
  if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
98
110
  const contract = validateSubagentDoneReason(role, reason);
99
111
  if (!contract.ok) return { verdict: "unverified", detail: `critic report incomplete (missing ${contract.missing?.join(", ")}): ${reason.slice(0, 300)}` };
112
+ if (evidenceCalls === 0) {
113
+ return { verdict: "unverified", detail: `critic returned a verdict with ZERO observed read/search/find/ast_grep/lsp calls — unevidenced, regardless of the verdict text: ${reason.slice(0, 300)}` };
114
+ }
100
115
  const firstLine = reason.split(/\r?\n/, 1)[0]?.trim() ?? "";
101
116
  const verdict = firstLine === "[OKAY]" ? "okay" : firstLine === "[ITERATE]" ? "iterate" : firstLine === "[REJECT]" ? "reject" : "unverified";
102
117
  return { verdict, detail: reason };
@@ -189,7 +204,14 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
189
204
  `reading or building on another's output. Never mark two steps parallel if one depends on, ` +
190
205
  `reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
191
206
  `serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
192
- `execution.`;
207
+ `execution.\n\n` +
208
+ `MANDATORY: if ANY step mutates (role: executor, or role omitted — omitted defaults to ` +
209
+ `executor), the plan MUST end with a DEDICATED role: architect or role: critic step AFTER ` +
210
+ `the last mutating step (never inside the same parallel_group as a mutating step — concurrent ` +
211
+ `steps cannot verify each other). A plan of executor-only steps with no trailing verifier is ` +
212
+ `REJECTED. This is the independent verifier that inspects what was actually built before the ` +
213
+ `plan counts as done — a read-only-only plan (planner/architect/critic steps with no executor ` +
214
+ `at all) needs no verifier, since nothing mutated.`;
193
215
 
194
216
  const PLANNER = `You are the PLANNER. From the crystallized spec, sequence the work into a logical, outcome-based progression of concrete, ordered tasks.\n` + SCHEMA_SPEC;
195
217
  const ARCHITECT = `You are the ARCHITECT. Review the Planner's draft for technical feasibility, correct file targets, directory structure, and any missing setup/wiring/test steps. Return an improved plan (same shape).\n` + SCHEMA_SPEC;
@@ -212,16 +234,26 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
212
234
  const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt, model });
213
235
  return raw.replace(/```yaml|```/g, "").trim();
214
236
  };
215
- const isValidPlan = (yaml: string): boolean => {
237
+ /** Validates `yaml` against `PlanSchema` + role-name legality, returning the
238
+ * FIRST concrete reason it's invalid (zod issue message, or an unknown-role
239
+ * name) — or `undefined` when valid. Feeding the actual reason back into the
240
+ * repair prompt (instead of a generic "didn't match the schema") lets the
241
+ * Critic's one repair attempt fix the REAL problem (e.g. a missing trailing
242
+ * verifier step) instead of guessing. */
243
+ const planValidationError = (yaml: string): string | undefined => {
216
244
  try {
217
245
  const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(yaml)));
218
- if (!parsed.success) return false;
246
+ if (!parsed.success) {
247
+ return parsed.error.issues[0]?.message ?? "plan did not match the required schema";
248
+ }
219
249
  // Round-10 #2 (architect ref 8-Round10Planning): write-time parity with
220
250
  // team's execution gate — an unknown role (e.g. "developer") used to pass
221
251
  // here and abort only at `jeo team`, after the planning model was gone.
222
- return parsed.data.steps.every(s => !s.role?.trim() || !!getSubagentRole(s.role));
223
- } catch {
224
- return false;
252
+ const badRole = parsed.data.steps.find(s => s.role?.trim() && !getSubagentRole(s.role));
253
+ if (badRole) return `step "${badRole.name}" declares unknown role "${badRole.role}" (must be executor|planner|architect|critic)`;
254
+ return undefined;
255
+ } catch (e) {
256
+ return e instanceof Error ? e.message : "plan YAML failed to parse";
225
257
  }
226
258
  };
227
259
 
@@ -266,11 +298,12 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
266
298
  // complete (round-10 #2) — failing here, while the model is still in the loop,
267
299
  // beats failing later at `jeo team` with the same plan.
268
300
  let planValid = true;
269
- if (!isValidPlan(cleanPlan)) {
270
- log("[ralplan] Final plan did not match the required shape; requesting a corrected plan…");
271
- cleanPlan = await callRole(CRITIC, `Your previous output was not valid for the required schema. Fix it (roles MUST be one of executor|planner|architect|critic).\n\n${SCHEMA_SPEC}\n\nPlan to fix:\n\n${cleanPlan}`, CRITIC_MODEL);
272
- if (!isValidPlan(cleanPlan)) {
273
- const fallback = [reviewed, draft].find(isValidPlan);
301
+ const firstError = planValidationError(cleanPlan);
302
+ if (firstError) {
303
+ log(`[ralplan] Final plan did not match the required shape (${firstError}); requesting a corrected plan…`);
304
+ cleanPlan = await callRole(CRITIC, `Your previous output was invalid: ${firstError}\n\n${SCHEMA_SPEC}\n\nPlan to fix:\n\n${cleanPlan}`, CRITIC_MODEL);
305
+ if (planValidationError(cleanPlan)) {
306
+ const fallback = [reviewed, draft].find(p => !planValidationError(p));
274
307
  if (fallback) {
275
308
  cleanPlan = fallback;
276
309
  log("[ralplan] Using an earlier valid pass output (Critic output was unparseable).");
@@ -314,7 +347,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
314
347
  `Revise the plan to address every point.\n\n${SCHEMA_SPEC}\n\nCurrent plan:\n\n${cleanPlan}`,
315
348
  CRITIC_MODEL,
316
349
  );
317
- if (isValidPlan(revised)) {
350
+ if (!planValidationError(revised)) {
318
351
  cleanPlan = revised;
319
352
  await fs.writeFile(planPath, cleanPlan, "utf-8");
320
353
  gate = await runConsensusCriticGate({ cwd, seedContent, plan: cleanPlan, signal: opts.signal });
@@ -519,6 +519,25 @@ function evaluateSubagentResult(
519
519
  return false;
520
520
  }
521
521
 
522
+ // Evidence gate (UNCONDITIONAL — same tier as parseRoleGateVerdict, never
523
+ // softened by --strict-mutations): an architect/critic verdict is worthless
524
+ // if the subagent never actually looked at anything. Mirrors goal-verifier.ts's
525
+ // applyEvidenceGate philosophy applied to the OTHER independent-verifier gate
526
+ // in this codebase — a formatted "Architectural Status: CLEAR" or "[OKAY]"
527
+ // first line is text the model can emit with ZERO real inspection; only an
528
+ // OBSERVED read/search/find/ast_grep/lsp call in this run counts as evidence
529
+ // the verdict is grounded in the actual repository state, not the model's own
530
+ // unverified assertion (2026 pattern: "avoid vibes verification — a claim is
531
+ // only trustworthy when backed by a programmatic, observed signal").
532
+ if ((role.id === "architect" || role.id === "critic") && result.readOnlyEvidenceCalls === 0) {
533
+ log(formatRalphStreamEvent(
534
+ "error",
535
+ `${role.title} returned a verdict with ZERO observed read/search/find/ast_grep/lsp calls — an unevidenced verdict is not trustworthy regardless of what the text claims. Blocking.`,
536
+ renderOpts,
537
+ ));
538
+ return false;
539
+ }
540
+
522
541
  // Round-8: a mutating role finished without a successful file mutation — the
523
542
  // task may be legitimately read-only, but its "Changed Files:" claim is
524
543
  // unverified. bash is tracked apart: an only-bash run MIGHT have mutated.