jeo-code 0.8.22 → 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.
package/src/cli/runner.ts CHANGED
@@ -119,11 +119,23 @@ export const COMMANDS: readonly CommandSpec[] = [
119
119
  return args => m.runDaemonCommand(args);
120
120
  },
121
121
  },
122
+ {
123
+ name: "routine",
124
+ summary: "Generate a GitHub Actions workflow that runs jeo headlessly on a schedule or repo event.",
125
+ usage: 'routine init --trigger <schedule|issues|pull_request> --prompt "<task>" [--cron <expr>] [--name <name>] [--out <path>] [--no-pr] [--force] [--dry-run] [--json]',
126
+ loader: async () => {
127
+ // Dynamic import: lazy-loaded command registry entry (CommandSpec.loader
128
+ // contract), matching every other entry in this array — avoids eagerly
129
+ // importing all ~25 command modules at CLI startup.
130
+ const m = await import("../commands/routine");
131
+ return args => m.runRoutineCommand(args);
132
+ },
133
+ },
122
134
 
123
135
  {
124
136
  name: "skills",
125
137
  summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
126
- usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]] [--json]",
138
+ usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]|lesson <skill> <failure|anti-pattern> \"<title>\" \"<detail>\"|eval <skill>] [--json]",
127
139
  loader: async () => {
128
140
  const m = await import("../commands/skills");
129
141
  return args => m.runSkillsCommand(args);
@@ -1,14 +1,16 @@
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
 
8
8
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
9
9
  import { initialDynamicStepLimit } from "../agent/step-budget";
10
10
  import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
11
+ import { recordSkillLesson } from "../agent/skill-lessons";
11
12
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
13
+ import { createEvalTool, evalToolProtocolLine } from "../agent/eval-tool";
12
14
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
13
15
  import { SubagentRegistry } from "../agent/subagent-registry";
14
16
  import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
@@ -51,7 +53,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
51
53
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
52
54
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
53
55
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
54
- import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
56
+ import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
55
57
  import { RouteHistory } from "../agent/route-history";
56
58
 
57
59
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
@@ -549,7 +551,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
549
551
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
550
552
  const contextFiles = await loadProjectContext(cwd);
551
553
 
552
- 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"]);
553
555
  let allowedTools = new Set(KNOWN_TOOLS);
554
556
 
555
557
  if (flags.noTools) {
@@ -617,6 +619,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
617
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." +
618
620
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
619
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) : "") +
620
623
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
621
624
  (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
622
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." : "") +
@@ -976,11 +979,77 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
976
979
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
977
980
  let routeCredentialNotice: string | undefined;
978
981
  let routeVetoNote: string | undefined;
982
+ // Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
983
+ // usage/quota limit, or zero usable credential) THIS turn — accumulates across
984
+ // every fallback round. Read by both `equivalentRouteFallback` (post/pre-call
985
+ // fallback search) and `rateLimitFallbackAvailable` (the engine's fast-bail
986
+ // predicate) so neither path proposes a candidate doomed to fail the same way.
987
+ //
988
+ // Scoped to the CREDENTIAL, not the bare provider (see `credentialScopeFor` in
989
+ // prompt-router.ts for the full classification rationale/doc comment) — an OAuth
990
+ // SUBSCRIPTION shares ONE account-wide rate-limit window across every model it
991
+ // serves; an API key is typically its OWN independent per-key/per-model budget.
992
+ // `null` (from `credentialScopeFor`) means "API-key-served, or otherwise not
993
+ // sharing a subscription credential" — never added to or matched against this set.
994
+ const excludedCredentialScopes = new Set<string>();
995
+ // Matches routeFailureReason's post-call reasons ("hit a rate limit", "hit a
996
+ // usage/quota limit", "is unauthenticated for this session", "requires
997
+ // billing/payment on this provider account") AND the pre-call credential gate's
998
+ // "(<provider>) has no usable credential this turn" — every string that means
999
+ // "this CREDENTIAL is exhausted/broken", as opposed to a model-specific issue
1000
+ // (OAuth serves only some models, a single model is retired/gated — see
1001
+ // routeFailureReason's own doc comments for why those stay model-scoped).
1002
+ //
1003
+ // Billing/payment (402) is intentionally included even though the ORIGINAL field
1004
+ // observation behind routeFailureReason's 402 branch (Tencent: kimi-k2.5/
1005
+ // deepseek-v3.2 402 while deepseek-v4-flash/glm-5.2 kept working on the SAME key)
1006
+ // is a per-MODEL billing block on an API-KEY-served provider — this does NOT
1007
+ // contradict including it here: `credentialScopeFor` already returns `null` for
1008
+ // every API-key-served model (never group-excluded regardless of this regex's
1009
+ // answer), so the Tencent case is unaffected either way. What this DOES newly
1010
+ // catch: an OAuth SUBSCRIPTION billing failure (e.g. a Claude Pro/Max payment
1011
+ // lapse) is account-wide by construction — every model riding that subscription
1012
+ // is equally blocked — and previously wasted a full fallback round retrying a
1013
+ // same-scope sibling model guaranteed to 402 identically before ever reaching a
1014
+ // genuinely different credential.
1015
+ const isAccountLevelRouteFailure = (reason: string): boolean =>
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);
979
1033
  const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
1034
+ if (isAccountLevelRouteFailure(reason)) {
1035
+ const scope = credentialScopeFor(decision.model, turnConfig);
1036
+ if (scope) excludedCredentialScopes.add(scope.key);
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));
980
1044
  const excluded = new Set([decision.model, ...exclude]);
981
1045
  const poolCandidates = (): string[] =>
982
1046
  tierModelPool(decision.tier, turnConfig)
983
1047
  .filter(model => !excluded.has(model))
1048
+ .filter(model => {
1049
+ const scope = credentialScopeFor(model, turnConfig);
1050
+ return !scope || !excludedCredentialScopes.has(scope.key);
1051
+ })
1052
+ .filter(model => !excludedSafetyProviders.has(resolveProvider(model)))
984
1053
  .filter(model => !images?.length || catalogMetadata(model)?.images !== false);
985
1054
  let candidates = poolCandidates();
986
1055
  // The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
@@ -1383,7 +1452,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1383
1452
  }
1384
1453
 
1385
1454
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1386
- const llmVerdict = await verifyGoal(goalState.condition, history, activeModel);
1455
+ const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
1387
1456
  // Deterministic downgrade: a fresh MET from the transcript-only LLM
1388
1457
  // judge still needs mutation/verification evidence to back it up —
1389
1458
  // see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
@@ -1421,6 +1490,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1421
1490
  ? (e => tui?.onSubagentEvent(e))
1422
1491
  : (e => logTaskSubEvent(e)),
1423
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
+ }),
1424
1501
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1425
1502
  subagent: createSubagentTool(subagentRegistry),
1426
1503
  job: createJobTool(jobRegistry),
@@ -1448,6 +1525,76 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1448
1525
  tags: ["jeo", "launch"],
1449
1526
  });
1450
1527
  opik.startTurn();
1528
+ // Hoisted above `runLoopWithModel` (moved from after its first call) so the
1529
+ // rate-limit fast-fallback predicate below can see the CURRENT attempted-model
1530
+ // set on every call, including the very first one. `attemptedRouteModels` is
1531
+ // mutated in place (`.add`) as the fallback loop below tries more models — this
1532
+ // closure always reads its live contents, never a stale snapshot.
1533
+ const attemptedRouteModels = new Set<string>([activeModel]);
1534
+ // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1535
+ // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1536
+ // failure — the exact "5-hour usage window exhausted, model just stops working"
1537
+ // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1538
+ // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1539
+ // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1540
+ let fallbackBaseDecision: RouteDecision = routed ?? {
1541
+ model: activeModel,
1542
+ thinking: activeThinking,
1543
+ tier: inferTierForModel(activeModel),
1544
+ confidence: 1,
1545
+ source: "heuristic",
1546
+ signals: [],
1547
+ };
1548
+ // Cheap SYNC check (no network/credential probing — that full validation is
1549
+ // `equivalentRouteFallback`'s job when a switch actually happens) for "does an
1550
+ // untried, DIFFERENT-PROVIDER candidate exist RIGHT NOW". Feeds
1551
+ // AgentLoopOptions.rateLimitFallbackAvailable so the engine can bail a 429 retry
1552
+ // ladder on the first failed attempt instead of riding ~90s of backoff on a
1553
+ // model that has an equivalent sitting right there. Mirrors
1554
+ // `equivalentRouteFallback`'s own `poolCandidates` filters (tier pool minus
1555
+ // already-tried models minus image-incapable models when this turn has images)
1556
+ // PLUS excludes any candidate sharing the SAME CREDENTIAL SCOPE as the model
1557
+ // CURRENTLY being attempted (a 429 on this model means ITS credential's
1558
+ // account/subscription window is exhausted THIS instant — another model
1559
+ // riding the SAME OAuth subscription hits that exact limit and is not a real
1560
+ // fallback; an API-key-served candidate has an independent budget and is NOT
1561
+ // excluded) and any scope already excluded by an earlier account-level
1562
+ // failure this turn — see `excludedCredentialScopes`'s doc comment. Without
1563
+ // this, the predicate could see e.g. `claude-sonnet-4-6` as "available" while
1564
+ // `claude-sonnet-5` (same Anthropic OAuth subscription) is the one currently
1565
+ // 429ing, bail, switch to it, and immediately re-429 — which looks
1566
+ // indistinguishable from "never escaping auto-retry #1".
1567
+ const rateLimitFallbackAvailable = (): boolean => {
1568
+ const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
1569
+ return tierModelPool(fallbackBaseDecision.tier, turnConfig)
1570
+ .filter(model => !attemptedRouteModels.has(model))
1571
+ .filter(model => {
1572
+ const scope = credentialScopeFor(model, turnConfig);
1573
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
1574
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
1575
+ return !excludedCredentialScopes.has(scope.key);
1576
+ })
1577
+ .some(model => !images?.length || catalogMetadata(model)?.images !== false);
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
+ };
1451
1598
  const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
1452
1599
  cwd,
1453
1600
  tools,
@@ -1459,11 +1606,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1459
1606
  signal: ac.signal,
1460
1607
  sessionKey: cacheKey,
1461
1608
  steer: drainSteer,
1609
+ rateLimitFallbackAvailable,
1610
+ safetyFallbackAvailable,
1462
1611
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1463
1612
  });
1464
1613
  const routeFailureReason = (doneReason: unknown): string | null => {
1465
1614
  const message = String(doneReason ?? "");
1466
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)";
1467
1624
  const asError = new Error(message);
1468
1625
  if (isRateLimitError(asError)) return "hit a rate limit";
1469
1626
  if (isUsageLimitError(asError)) return "hit a usage/quota limit";
@@ -1530,21 +1687,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1530
1687
  const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1531
1688
  ? Math.floor(routeFallbackMaxRaw)
1532
1689
  : 3;
1533
- const attemptedRouteModels = new Set<string>([activeModel]);
1534
- // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1535
- // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1536
- // failure — the exact "5-hour usage window exhausted, model just stops working"
1537
- // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1538
- // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1539
- // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1540
- let fallbackBaseDecision: RouteDecision = routed ?? {
1541
- model: activeModel,
1542
- thinking: activeThinking,
1543
- tier: inferTierForModel(activeModel),
1544
- confidence: 1,
1545
- source: "heuristic",
1546
- signals: [],
1547
- };
1548
1690
  for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1549
1691
  const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
1550
1692
  if (!routedFailureReason) break;
@@ -1592,6 +1734,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1592
1734
  signal: ac.signal,
1593
1735
  sessionKey: turnSessionKey,
1594
1736
  steer: drainSteer,
1737
+ // Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
1738
+ // retry has no reroute loop around it (unlike runLoopWithModel's
1739
+ // routeFallbackAttempt loop) — bailing the retry ladder early on a 429
1740
+ // would only fail FASTER with the identical outcome (no fallback model to
1741
+ // switch to either way), trading away the one real win the full backoff
1742
+ // ladder still offers here: a transient (<~90s) rate-limit blip clearing
1743
+ // mid-wait and this retry succeeding. See launch.ts's `rateLimitFallbackAvailable`
1744
+ // doc comment / commit history for why this predicate is paired with an
1745
+ // actual model-switch loop everywhere else it's used.
1595
1746
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1596
1747
  });
1597
1748
  const usage =
@@ -1630,7 +1781,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1630
1781
  }
1631
1782
  // A completed turn with an empty done-reason must NOT masquerade as a step-limit
1632
1783
  // failure ("reached the 3-step limit" after the model called done at step 3).
1633
- const reply = result.doneReason
1784
+ const reply = stripSafetyFallbackTag(result.doneReason ?? "")
1634
1785
  || (result.done
1635
1786
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1636
1787
  : `(reached the ${result.steps}-step limit without signaling done)`);
@@ -1685,12 +1836,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1685
1836
  const tags = Array.from(new Set(
1686
1837
  task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
1687
1838
  )).slice(0, 8);
1688
- void recordFailedAttempt(cwd, {
1689
- title: `Stalled on: ${excerpt}`,
1690
- description: `A prior turn stalled (${why}) on this task — change approach before retrying.`,
1691
- body: `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1692
- `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`,
1693
- tags,
1839
+ const title = `Stalled on: ${excerpt}`;
1840
+ const description = `A prior turn stalled (${why}) on this task — change approach before retrying.`;
1841
+ const body = `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1842
+ `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`;
1843
+ void recordFailedAttempt(cwd, { title, description, body, tags }).catch(() => {});
1844
+ // Also offer the same failure to the skill-lessons system (deterministic
1845
+ // free-text -> skill match; writes nothing when no skill clearly relates).
1846
+ void recordSkillLesson(cwd, `${title} ${description}`, {
1847
+ kind: "failure-mode",
1848
+ title,
1849
+ detail: body.slice(0, 300),
1694
1850
  }).catch(() => {});
1695
1851
  }
1696
1852
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
@@ -1725,6 +1881,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1725
1881
 
1726
1882
  const joinedArgs = flags.message;
1727
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
+ };
1728
1896
 
1729
1897
  if (isOneShot) {
1730
1898
  let messageContent = joinedArgs;
@@ -1911,12 +2079,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1911
2079
  for (const inv of skillChain.invocations) {
1912
2080
  await runOneSkillShot(inv);
1913
2081
  }
2082
+ await maybeDistillOneShot();
1914
2083
  return;
1915
2084
  }
1916
2085
  // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
1917
2086
  const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1918
2087
  if (skillInvocation) {
1919
2088
  await runOneSkillShot(skillInvocation);
2089
+ await maybeDistillOneShot();
1920
2090
  return;
1921
2091
  }
1922
2092
  try {
@@ -1926,6 +2096,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1926
2096
  } catch (err) {
1927
2097
  console.log(`! ${friendlyProviderError(err)}`);
1928
2098
  }
2099
+ await maybeDistillOneShot();
1929
2100
  return;
1930
2101
  }
1931
2102
 
@@ -19,6 +19,9 @@ import {
19
19
  resolveSubagentMaxSteps,
20
20
  } from "../agent/subagents";
21
21
  import { runAgentLoop } from "../agent/engine";
22
+ import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool } from "../agent/prompt-router";
23
+ import { isRateLimitError } from "../util/retry";
24
+ import { MAX_SUBAGENT_REROUTES } from "../agent/task-tool";
22
25
 
23
26
  /** Round-11 (architect ref 8-Round10Planning #1): the REAL consensus gate. A
24
27
  * read-only critic SUBAGENT (repo access via read/search/find) reviews the
@@ -33,7 +36,7 @@ export async function runConsensusCriticGate(args: {
33
36
  }): Promise<{ verdict: "okay" | "iterate" | "reject" | "unverified"; detail: string }> {
34
37
  const role = getSubagentRole("critic")!;
35
38
  const config = await readGlobalConfig();
36
- const model = resolveSubagentModel(role.id, config);
39
+ const initialModel = resolveSubagentModel(role.id, config);
37
40
  const maxSteps = resolveSubagentMaxSteps(role.id, config);
38
41
  const history: Message[] = [
39
42
  { role: "system", content: subagentSystemPrompt(role) },
@@ -48,18 +51,67 @@ export async function runConsensusCriticGate(args: {
48
51
  `Then call done — your reason MUST start with [OKAY], [ITERATE], or [REJECT] and include a 'Justification:' section.`,
49
52
  },
50
53
  ];
51
- const result = await runAgentLoop(history, {
54
+ // Rate-limit fast fallback (see task-tool.ts's `runSubagentOnce` for the full
55
+ // design doc — same pattern, single-call variant: no fan-out here, so no
56
+ // batch-shared excludedCredentialScopes need apply). A bail-only predicate with
57
+ // no real reroute-and-retry loop around it is a net regression (02b7e59) — this
58
+ // gate pairs `rateLimitFallbackAvailable` with the actual switch-and-retry below.
59
+ const tier = inferTierForModel(initialModel);
60
+ const attemptedModels = new Set<string>([initialModel]);
61
+ const excludedCredentialScopes = new Set<string>();
62
+ const fallbackCandidates = (): string[] => {
63
+ const currentScope = credentialScopeFor(activeModel, config);
64
+ return tierModelPool(tier, config)
65
+ .filter(m => !attemptedModels.has(m))
66
+ .filter(m => {
67
+ const scope = credentialScopeFor(m, config);
68
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
69
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
70
+ return !excludedCredentialScopes.has(scope.key);
71
+ });
72
+ };
73
+ const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
74
+
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;
83
+ const runOnce = () => runAgentLoop(history, {
52
84
  cwd: args.cwd,
53
- model,
85
+ model: activeModel,
54
86
  maxSteps,
55
87
  budget: { maxExtensions: 0 },
56
88
  signal: args.signal,
57
89
  tools: subagentToolset(role),
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
+ },
58
96
  });
97
+ let result = await runOnce();
98
+ for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
99
+ if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
100
+ const failedScope = credentialScopeFor(activeModel, config);
101
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
102
+ const candidates = fallbackCandidates();
103
+ if (candidates.length === 0) break;
104
+ activeModel = selectFromPool(candidates, undefined);
105
+ attemptedModels.add(activeModel);
106
+ result = await runOnce();
107
+ }
59
108
  const reason = result.doneReason?.trim() ?? "";
60
109
  if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
61
110
  const contract = validateSubagentDoneReason(role, reason);
62
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
+ }
63
115
  const firstLine = reason.split(/\r?\n/, 1)[0]?.trim() ?? "";
64
116
  const verdict = firstLine === "[OKAY]" ? "okay" : firstLine === "[ITERATE]" ? "iterate" : firstLine === "[REJECT]" ? "reject" : "unverified";
65
117
  return { verdict, detail: reason };
@@ -152,7 +204,14 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
152
204
  `reading or building on another's output. Never mark two steps parallel if one depends on, ` +
153
205
  `reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
154
206
  `serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
155
- `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.`;
156
215
 
157
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;
158
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;
@@ -175,16 +234,26 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
175
234
  const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt, model });
176
235
  return raw.replace(/```yaml|```/g, "").trim();
177
236
  };
178
- 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 => {
179
244
  try {
180
245
  const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(yaml)));
181
- if (!parsed.success) return false;
246
+ if (!parsed.success) {
247
+ return parsed.error.issues[0]?.message ?? "plan did not match the required schema";
248
+ }
182
249
  // Round-10 #2 (architect ref 8-Round10Planning): write-time parity with
183
250
  // team's execution gate — an unknown role (e.g. "developer") used to pass
184
251
  // here and abort only at `jeo team`, after the planning model was gone.
185
- return parsed.data.steps.every(s => !s.role?.trim() || !!getSubagentRole(s.role));
186
- } catch {
187
- 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";
188
257
  }
189
258
  };
190
259
 
@@ -229,11 +298,12 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
229
298
  // complete (round-10 #2) — failing here, while the model is still in the loop,
230
299
  // beats failing later at `jeo team` with the same plan.
231
300
  let planValid = true;
232
- if (!isValidPlan(cleanPlan)) {
233
- log("[ralplan] Final plan did not match the required shape; requesting a corrected plan…");
234
- 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);
235
- if (!isValidPlan(cleanPlan)) {
236
- 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));
237
307
  if (fallback) {
238
308
  cleanPlan = fallback;
239
309
  log("[ralplan] Using an earlier valid pass output (Critic output was unparseable).");
@@ -277,7 +347,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
277
347
  `Revise the plan to address every point.\n\n${SCHEMA_SPEC}\n\nCurrent plan:\n\n${cleanPlan}`,
278
348
  CRITIC_MODEL,
279
349
  );
280
- if (isValidPlan(revised)) {
350
+ if (!planValidationError(revised)) {
281
351
  cleanPlan = revised;
282
352
  await fs.writeFile(planPath, cleanPlan, "utf-8");
283
353
  gate = await runConsensusCriticGate({ cwd, seedContent, plan: cleanPlan, signal: opts.signal });