jeo-code 0.8.22 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { executeComputerAction } from "./computer";
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";
12
13
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
13
14
  import { SubagentRegistry } from "../agent/subagent-registry";
@@ -51,7 +52,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
51
52
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
52
53
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
53
54
  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";
55
+ import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
55
56
  import { RouteHistory } from "../agent/route-history";
56
57
 
57
58
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
@@ -976,11 +977,54 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
976
977
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
977
978
  let routeCredentialNotice: string | undefined;
978
979
  let routeVetoNote: string | undefined;
980
+ // Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
981
+ // usage/quota limit, or zero usable credential) THIS turn — accumulates across
982
+ // every fallback round. Read by both `equivalentRouteFallback` (post/pre-call
983
+ // fallback search) and `rateLimitFallbackAvailable` (the engine's fast-bail
984
+ // predicate) so neither path proposes a candidate doomed to fail the same way.
985
+ //
986
+ // Scoped to the CREDENTIAL, not the bare provider (see `credentialScopeFor` in
987
+ // prompt-router.ts for the full classification rationale/doc comment) — an OAuth
988
+ // SUBSCRIPTION shares ONE account-wide rate-limit window across every model it
989
+ // serves; an API key is typically its OWN independent per-key/per-model budget.
990
+ // `null` (from `credentialScopeFor`) means "API-key-served, or otherwise not
991
+ // sharing a subscription credential" — never added to or matched against this set.
992
+ const excludedCredentialScopes = new Set<string>();
993
+ // Matches routeFailureReason's post-call reasons ("hit a rate limit", "hit a
994
+ // usage/quota limit", "is unauthenticated for this session", "requires
995
+ // billing/payment on this provider account") AND the pre-call credential gate's
996
+ // "(<provider>) has no usable credential this turn" — every string that means
997
+ // "this CREDENTIAL is exhausted/broken", as opposed to a model-specific issue
998
+ // (OAuth serves only some models, a single model is retired/gated — see
999
+ // routeFailureReason's own doc comments for why those stay model-scoped).
1000
+ //
1001
+ // Billing/payment (402) is intentionally included even though the ORIGINAL field
1002
+ // observation behind routeFailureReason's 402 branch (Tencent: kimi-k2.5/
1003
+ // deepseek-v3.2 402 while deepseek-v4-flash/glm-5.2 kept working on the SAME key)
1004
+ // is a per-MODEL billing block on an API-KEY-served provider — this does NOT
1005
+ // contradict including it here: `credentialScopeFor` already returns `null` for
1006
+ // every API-key-served model (never group-excluded regardless of this regex's
1007
+ // answer), so the Tencent case is unaffected either way. What this DOES newly
1008
+ // catch: an OAuth SUBSCRIPTION billing failure (e.g. a Claude Pro/Max payment
1009
+ // lapse) is account-wide by construction — every model riding that subscription
1010
+ // is equally blocked — and previously wasted a full fallback round retrying a
1011
+ // same-scope sibling model guaranteed to 402 identically before ever reaching a
1012
+ // genuinely different credential.
1013
+ const isAccountLevelRouteFailure = (reason: string): boolean =>
1014
+ /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);
979
1015
  const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
1016
+ if (isAccountLevelRouteFailure(reason)) {
1017
+ const scope = credentialScopeFor(decision.model, turnConfig);
1018
+ if (scope) excludedCredentialScopes.add(scope.key);
1019
+ }
980
1020
  const excluded = new Set([decision.model, ...exclude]);
981
1021
  const poolCandidates = (): string[] =>
982
1022
  tierModelPool(decision.tier, turnConfig)
983
1023
  .filter(model => !excluded.has(model))
1024
+ .filter(model => {
1025
+ const scope = credentialScopeFor(model, turnConfig);
1026
+ return !scope || !excludedCredentialScopes.has(scope.key);
1027
+ })
984
1028
  .filter(model => !images?.length || catalogMetadata(model)?.images !== false);
985
1029
  let candidates = poolCandidates();
986
1030
  // The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
@@ -1383,7 +1427,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1383
1427
  }
1384
1428
 
1385
1429
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1386
- const llmVerdict = await verifyGoal(goalState.condition, history, activeModel);
1430
+ const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
1387
1431
  // Deterministic downgrade: a fresh MET from the transcript-only LLM
1388
1432
  // judge still needs mutation/verification evidence to back it up —
1389
1433
  // see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
@@ -1448,6 +1492,57 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1448
1492
  tags: ["jeo", "launch"],
1449
1493
  });
1450
1494
  opik.startTurn();
1495
+ // Hoisted above `runLoopWithModel` (moved from after its first call) so the
1496
+ // rate-limit fast-fallback predicate below can see the CURRENT attempted-model
1497
+ // set on every call, including the very first one. `attemptedRouteModels` is
1498
+ // mutated in place (`.add`) as the fallback loop below tries more models — this
1499
+ // closure always reads its live contents, never a stale snapshot.
1500
+ const attemptedRouteModels = new Set<string>([activeModel]);
1501
+ // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1502
+ // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1503
+ // failure — the exact "5-hour usage window exhausted, model just stops working"
1504
+ // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1505
+ // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1506
+ // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1507
+ let fallbackBaseDecision: RouteDecision = routed ?? {
1508
+ model: activeModel,
1509
+ thinking: activeThinking,
1510
+ tier: inferTierForModel(activeModel),
1511
+ confidence: 1,
1512
+ source: "heuristic",
1513
+ signals: [],
1514
+ };
1515
+ // Cheap SYNC check (no network/credential probing — that full validation is
1516
+ // `equivalentRouteFallback`'s job when a switch actually happens) for "does an
1517
+ // untried, DIFFERENT-PROVIDER candidate exist RIGHT NOW". Feeds
1518
+ // AgentLoopOptions.rateLimitFallbackAvailable so the engine can bail a 429 retry
1519
+ // ladder on the first failed attempt instead of riding ~90s of backoff on a
1520
+ // model that has an equivalent sitting right there. Mirrors
1521
+ // `equivalentRouteFallback`'s own `poolCandidates` filters (tier pool minus
1522
+ // already-tried models minus image-incapable models when this turn has images)
1523
+ // PLUS excludes any candidate sharing the SAME CREDENTIAL SCOPE as the model
1524
+ // CURRENTLY being attempted (a 429 on this model means ITS credential's
1525
+ // account/subscription window is exhausted THIS instant — another model
1526
+ // riding the SAME OAuth subscription hits that exact limit and is not a real
1527
+ // fallback; an API-key-served candidate has an independent budget and is NOT
1528
+ // excluded) and any scope already excluded by an earlier account-level
1529
+ // failure this turn — see `excludedCredentialScopes`'s doc comment. Without
1530
+ // this, the predicate could see e.g. `claude-sonnet-4-6` as "available" while
1531
+ // `claude-sonnet-5` (same Anthropic OAuth subscription) is the one currently
1532
+ // 429ing, bail, switch to it, and immediately re-429 — which looks
1533
+ // indistinguishable from "never escaping auto-retry #1".
1534
+ const rateLimitFallbackAvailable = (): boolean => {
1535
+ const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
1536
+ return tierModelPool(fallbackBaseDecision.tier, turnConfig)
1537
+ .filter(model => !attemptedRouteModels.has(model))
1538
+ .filter(model => {
1539
+ const scope = credentialScopeFor(model, turnConfig);
1540
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
1541
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
1542
+ return !excludedCredentialScopes.has(scope.key);
1543
+ })
1544
+ .some(model => !images?.length || catalogMetadata(model)?.images !== false);
1545
+ };
1451
1546
  const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
1452
1547
  cwd,
1453
1548
  tools,
@@ -1459,6 +1554,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1459
1554
  signal: ac.signal,
1460
1555
  sessionKey: cacheKey,
1461
1556
  steer: drainSteer,
1557
+ rateLimitFallbackAvailable,
1462
1558
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1463
1559
  });
1464
1560
  const routeFailureReason = (doneReason: unknown): string | null => {
@@ -1530,21 +1626,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1530
1626
  const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1531
1627
  ? Math.floor(routeFallbackMaxRaw)
1532
1628
  : 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
1629
  for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1549
1630
  const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
1550
1631
  if (!routedFailureReason) break;
@@ -1592,6 +1673,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1592
1673
  signal: ac.signal,
1593
1674
  sessionKey: turnSessionKey,
1594
1675
  steer: drainSteer,
1676
+ // Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
1677
+ // retry has no reroute loop around it (unlike runLoopWithModel's
1678
+ // routeFallbackAttempt loop) — bailing the retry ladder early on a 429
1679
+ // would only fail FASTER with the identical outcome (no fallback model to
1680
+ // switch to either way), trading away the one real win the full backoff
1681
+ // ladder still offers here: a transient (<~90s) rate-limit blip clearing
1682
+ // mid-wait and this retry succeeding. See launch.ts's `rateLimitFallbackAvailable`
1683
+ // doc comment / commit history for why this predicate is paired with an
1684
+ // actual model-switch loop everywhere else it's used.
1595
1685
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1596
1686
  });
1597
1687
  const usage =
@@ -1685,12 +1775,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1685
1775
  const tags = Array.from(new Set(
1686
1776
  task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
1687
1777
  )).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,
1778
+ const title = `Stalled on: ${excerpt}`;
1779
+ const description = `A prior turn stalled (${why}) on this task — change approach before retrying.`;
1780
+ const body = `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1781
+ `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`;
1782
+ void recordFailedAttempt(cwd, { title, description, body, tags }).catch(() => {});
1783
+ // Also offer the same failure to the skill-lessons system (deterministic
1784
+ // free-text -> skill match; writes nothing when no skill clearly relates).
1785
+ void recordSkillLesson(cwd, `${title} ${description}`, {
1786
+ kind: "failure-mode",
1787
+ title,
1788
+ detail: body.slice(0, 300),
1694
1789
  }).catch(() => {});
1695
1790
  }
1696
1791
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
@@ -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,14 +51,48 @@ 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
+ const runOnce = () => runAgentLoop(history, {
52
77
  cwd: args.cwd,
53
- model,
78
+ model: activeModel,
54
79
  maxSteps,
55
80
  budget: { maxExtensions: 0 },
56
81
  signal: args.signal,
57
82
  tools: subagentToolset(role),
83
+ rateLimitFallbackAvailable,
58
84
  });
85
+ let result = await runOnce();
86
+ for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
87
+ if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
88
+ const failedScope = credentialScopeFor(activeModel, config);
89
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
90
+ const candidates = fallbackCandidates();
91
+ if (candidates.length === 0) break;
92
+ activeModel = selectFromPool(candidates, undefined);
93
+ attemptedModels.add(activeModel);
94
+ result = await runOnce();
95
+ }
59
96
  const reason = result.doneReason?.trim() ?? "";
60
97
  if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
61
98
  const contract = validateSubagentDoneReason(role, reason);
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `jeo routine init` — generate a GitHub Actions workflow that runs
3
+ * `jeo "<prompt>" -p` headlessly on a schedule or repo event.
4
+ *
5
+ * This is the SCOPED-SAFE take on "Routines": jeo-code itself never runs a
6
+ * scheduler, listens on a port, or accepts a webhook — it's a pure template
7
+ * generator. GitHub's own hosted runners do the actual triggering/running,
8
+ * which achieves "runs without your laptop" with zero new attack surface
9
+ * inside jeo-code's own process. See ../util/routine-template.ts for the
10
+ * pure YAML-building logic this command wraps with flag parsing + I/O.
11
+ */
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { renderRoutineWorkflow, validateCron, type RoutineConfig, type RoutineTrigger } from "../util/routine-template";
15
+ import { slugify } from "../agent/memory-okf";
16
+
17
+ const TRIGGERS: readonly RoutineTrigger[] = ["schedule", "issues", "pull_request"];
18
+ function isTrigger(val: string | undefined): val is RoutineTrigger {
19
+ return val !== undefined && (TRIGGERS as readonly string[]).includes(val);
20
+ }
21
+
22
+ const VALUE_FLAGS: Record<string, true> = { "--name": true, "--trigger": true, "--cron": true, "--prompt": true, "--api-key-env": true, "--out": true };
23
+
24
+ interface ParsedInit {
25
+ name: string;
26
+ triggerRaw?: string;
27
+ cron?: string;
28
+ prompt?: string;
29
+ apiKeyEnvVar: string;
30
+ openPr: boolean;
31
+ out?: string;
32
+ force: boolean;
33
+ dryRun: boolean;
34
+ json: boolean;
35
+ help: boolean;
36
+ /** Parse-level problems: unknown flags, or a value-flag given with no value. */
37
+ errors: string[];
38
+ }
39
+
40
+ /** `--flag value` or `--flag=value`, matching launch/flags.ts's takeValue shape
41
+ * (a bare value-flag followed by another `-`-prefixed token is treated as
42
+ * missing, not as accidentally swallowing the next flag). */
43
+ function takeValue(args: string[], index: number, flag: string): { value?: string; nextIndex: number } {
44
+ const current = args[index]!;
45
+ const inline = `${flag}=`;
46
+ if (current.startsWith(inline)) return { value: current.slice(inline.length), nextIndex: index };
47
+ const next = args[index + 1];
48
+ if (next !== undefined && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
49
+ return { nextIndex: index };
50
+ }
51
+
52
+ function parseInitArgs(args: string[]): ParsedInit {
53
+ const parsed: ParsedInit = {
54
+ name: "jeo routine",
55
+ apiKeyEnvVar: "ANTHROPIC_API_KEY",
56
+ openPr: true,
57
+ force: false,
58
+ dryRun: false,
59
+ json: false,
60
+ help: false,
61
+ errors: [],
62
+ };
63
+
64
+ for (let i = 0; i < args.length; i++) {
65
+ const a = args[i]!;
66
+ const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
67
+
68
+ if (flag === "-h" || flag === "--help") { parsed.help = true; continue; }
69
+ if (flag === "--no-pr") { parsed.openPr = false; continue; }
70
+ if (flag === "--force") { parsed.force = true; continue; }
71
+ if (flag === "--dry-run") { parsed.dryRun = true; continue; }
72
+ if (flag === "--json") { parsed.json = true; continue; }
73
+
74
+ if (VALUE_FLAGS[flag]) {
75
+ const { value, nextIndex } = takeValue(args, i, flag);
76
+ i = nextIndex;
77
+ if (value === undefined) {
78
+ parsed.errors.push(`${flag} requires a value`);
79
+ continue;
80
+ }
81
+ switch (flag) {
82
+ case "--name": parsed.name = value; break;
83
+ case "--trigger": parsed.triggerRaw = value; break;
84
+ case "--cron": parsed.cron = value; break;
85
+ case "--prompt": parsed.prompt = value; break;
86
+ case "--api-key-env": parsed.apiKeyEnvVar = value; break;
87
+ case "--out": parsed.out = value; break;
88
+ }
89
+ continue;
90
+ }
91
+
92
+ parsed.errors.push(`Unknown flag: ${a}`);
93
+ }
94
+
95
+ return parsed;
96
+ }
97
+
98
+ function printUsage(): void {
99
+ console.log("Usage: jeo routine init [options]");
100
+ console.log("");
101
+ console.log('Generate a GitHub Actions workflow that runs `jeo "<prompt>" -p` headlessly');
102
+ console.log("on a schedule or repo event. GitHub's own hosted runners do the triggering —");
103
+ console.log("jeo-code never runs a scheduler or listens on a port for this.");
104
+ console.log("");
105
+ console.log("Options:");
106
+ console.log(" --trigger <schedule|issues|pull_request> Required. What fires the routine.");
107
+ console.log(" --prompt <text> Required. The task/goal text passed to `jeo -p`.");
108
+ console.log(' --cron <expr> Required when --trigger schedule (e.g. "0 7 * * *").');
109
+ console.log(' --name <name> Workflow display name. Default: "jeo routine".');
110
+ console.log(" --api-key-env <VAR_NAME> Secret env var name. Default: ANTHROPIC_API_KEY.");
111
+ console.log(" --no-pr Commit directly to the branch instead of opening a PR.");
112
+ console.log(" --out <path> Output path. Default: .github/workflows/jeo-routine-<slug>.yml");
113
+ console.log(" --force Overwrite an existing file at --out.");
114
+ console.log(" --dry-run Print the rendered YAML instead of writing it.");
115
+ console.log(" --json Machine-readable output.");
116
+ console.log(" -h, --help Show this help message.");
117
+ }
118
+
119
+ async function runInit(args: string[]): Promise<void> {
120
+ const parsed = parseInitArgs(args);
121
+
122
+ if (parsed.errors.length > 0) {
123
+ for (const e of parsed.errors) console.log(`Error: ${e}`);
124
+ printUsage();
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+
129
+ if (parsed.help) {
130
+ printUsage();
131
+ return;
132
+ }
133
+
134
+ const problems: string[] = [];
135
+ if (!isTrigger(parsed.triggerRaw)) {
136
+ problems.push(
137
+ parsed.triggerRaw
138
+ ? `--trigger must be one of: schedule, issues, pull_request (got '${parsed.triggerRaw}')`
139
+ : "--trigger is required (one of: schedule, issues, pull_request)",
140
+ );
141
+ }
142
+ if (!parsed.prompt) {
143
+ problems.push("--prompt is required");
144
+ }
145
+ const trigger = isTrigger(parsed.triggerRaw) ? parsed.triggerRaw : undefined;
146
+ if (trigger === "schedule") {
147
+ if (!parsed.cron) {
148
+ problems.push("--cron is required when --trigger schedule");
149
+ } else if (!validateCron(parsed.cron)) {
150
+ problems.push(`--cron '${parsed.cron}' does not look like a valid 5-field cron expression`);
151
+ }
152
+ }
153
+
154
+ if (problems.length > 0) {
155
+ for (const p of problems) console.log(`Error: ${p}`);
156
+ printUsage();
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+
161
+ const config: RoutineConfig = {
162
+ name: parsed.name,
163
+ trigger: trigger!,
164
+ cron: parsed.cron,
165
+ prompt: parsed.prompt!,
166
+ apiKeyEnvVar: parsed.apiKeyEnvVar,
167
+ openPr: parsed.openPr,
168
+ };
169
+
170
+ let yaml: string;
171
+ try {
172
+ yaml = renderRoutineWorkflow(config);
173
+ } catch (err) {
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ if (parsed.json) console.log(JSON.stringify({ error: msg }));
176
+ else console.log(`Error: ${msg}`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+
181
+ const outPath = parsed.out ?? path.join(".github", "workflows", `jeo-routine-${slugify(parsed.name)}.yml`);
182
+ const resolvedOut = path.resolve(process.cwd(), outPath);
183
+
184
+ if (parsed.dryRun) {
185
+ if (parsed.json) console.log(JSON.stringify({ dryRun: true, out: resolvedOut, yaml }, null, 2));
186
+ else console.log(yaml);
187
+ return;
188
+ }
189
+
190
+ let exists = true;
191
+ try {
192
+ await fs.access(resolvedOut);
193
+ } catch {
194
+ exists = false;
195
+ }
196
+ if (exists && !parsed.force) {
197
+ const msg = `${resolvedOut} already exists. Use --force to overwrite.`;
198
+ if (parsed.json) console.log(JSON.stringify({ error: msg }));
199
+ else console.log(`Error: ${msg}`);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+
204
+ await fs.mkdir(path.dirname(resolvedOut), { recursive: true });
205
+ // Atomic write: build the file fully off to the side, then rename into
206
+ // place, so a crash mid-write never leaves a truncated workflow file.
207
+ const tmpPath = `${resolvedOut}.tmp-${process.pid}`;
208
+ await fs.writeFile(tmpPath, yaml, "utf-8");
209
+ await fs.rename(tmpPath, resolvedOut);
210
+
211
+ if (parsed.json) {
212
+ console.log(JSON.stringify({ wrote: resolvedOut, trigger, apiKeyEnvVar: parsed.apiKeyEnvVar, openPr: parsed.openPr }, null, 2));
213
+ return;
214
+ }
215
+
216
+ console.log(`Wrote ${resolvedOut}`);
217
+ console.log("");
218
+ console.log("Next steps:");
219
+ console.log(` - Add '${parsed.apiKeyEnvVar}' as a repo secret: Settings -> Secrets and variables -> Actions.`);
220
+ if (trigger === "schedule") {
221
+ console.log(" - Scheduled workflows only run on the repo's default branch, and GitHub may");
222
+ console.log(" delay or skip scheduled runs on low-activity repos — this is documented");
223
+ console.log(" GitHub Actions behavior, not a bug in the generated workflow.");
224
+ }
225
+ console.log(` - Commit and push ${resolvedOut} to enable it.`);
226
+ }
227
+
228
+ export async function runRoutineCommand(args: string[]): Promise<void> {
229
+ const sub = args[0];
230
+
231
+ if (sub === "-h" || sub === "--help") {
232
+ printUsage();
233
+ return;
234
+ }
235
+
236
+ if (sub === undefined) {
237
+ printUsage();
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+
242
+ if (sub !== "init") {
243
+ console.log(`Unknown 'jeo routine' subcommand '${sub}'. Usage: jeo routine init [options]`);
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+
248
+ await runInit(args.slice(1));
249
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
4
4
  import { getLocalJeoDir } from "../agent/state";
5
+ import { appendSkillLesson, evalSkillLessons, type SkillLesson } from "../agent/skill-lessons";
5
6
 
6
7
  export { userSkillsDir };
7
8
 
@@ -147,6 +148,62 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
147
148
  return;
148
149
  }
149
150
 
151
+ // `jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"` — manual
152
+ // lesson entry into the project-level skill file (the same compounding write the
153
+ // launch.ts stall-guard fires automatically; lets an agent or human record one directly).
154
+ if (cleanArgs[0] === "lesson") {
155
+ const [, skillName, kindArg, title, detail] = cleanArgs;
156
+ if (!skillName || !kindArg || !title || !detail) {
157
+ console.log('Usage: jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"');
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ const kind: SkillLesson["kind"] | null =
162
+ kindArg === "failure" ? "failure-mode" : kindArg === "anti-pattern" ? "anti-pattern" : null;
163
+ if (!kind) {
164
+ console.log(`Error: kind must be 'failure' or 'anti-pattern' (got '${kindArg}').`);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ const result = await appendSkillLesson(cwd, { skill: skillName, kind, title, detail });
169
+ if (isJson) {
170
+ console.log(JSON.stringify(result, null, 2));
171
+ } else if (result.appended) {
172
+ console.log("Appended.");
173
+ } else if (result.reason === "duplicate") {
174
+ console.log("Already recorded (duplicate).");
175
+ } else {
176
+ console.log(`Error: ${result.reason ?? "unknown error"}`);
177
+ process.exitCode = 1;
178
+ }
179
+ return;
180
+ }
181
+
182
+ // `jeo skills eval <skill>` — self-eval half: judge whether recorded lessons are
183
+ // still covered by the skill's current guidance, or have gone stale.
184
+ if (cleanArgs[0] === "eval") {
185
+ const skillName = cleanArgs[1];
186
+ if (!skillName) {
187
+ console.log("Error: Missing skill name for 'eval' command.");
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ const result = await evalSkillLessons(cwd, skillName);
192
+ if (result === null) {
193
+ console.log(`No recorded lessons for '${skillName}' yet.`);
194
+ return;
195
+ }
196
+ if (isJson) {
197
+ console.log(JSON.stringify(result, null, 2));
198
+ } else {
199
+ console.log(`${result.covered}/${result.total} lessons still covered by current guidance.`);
200
+ for (const s of result.stale) {
201
+ console.log(` STALE ${s.title}: ${s.reason}`);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+
150
207
  // List/lookup over the MERGED set (bundled + ~/.jeo/skills + ~/.agents/skills + project dirs), matching the REPL /skill.
151
208
  const skills = await loadSkills(cwd);
152
209
  const command = cleanArgs[0];
@@ -117,6 +117,21 @@ export function friendlyProviderError(err: unknown): string {
117
117
  if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
118
118
  return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
119
119
  }
120
+ // A persistent 5xx (server-side fault on the provider's backend) — `ProviderHttpError`
121
+ // already embeds the numeric status directly in its message ("… failed (HTTP 500) …",
122
+ // caught here too, redundantly but harmlessly), but `ProviderStreamError` (the in-band
123
+ // SSE/stream error shape — antigravity.ts/gemini.ts's `google.rpc.Status` and
124
+ // openai-responses.ts's `response.failed`/`error` events) composes its `.message` from
125
+ // a STRING status code only ("Antigravity stream failed (INTERNAL): internal error"),
126
+ // never the numeric one, even though `.status` carries it correctly (explicitStatus
127
+ // for antigravity/gemini's google.rpc.Status `error.code`; the 500 default for
128
+ // openai-responses's non-rate_limit_exceeded codes). Without a digit anywhere in the
129
+ // text, launch.ts's `routeFailureReason` `/\b(?:500|502|503|504|529)\b/` text regex
130
+ // never matched, so the equivalent-pool fallback never fired for a stream-shaped 5xx
131
+ // even with a credentialed same-tier fallback sitting right there.
132
+ if (status !== undefined && status >= 500 && status < 600) {
133
+ return `${provider} hit a persistent server-side error (HTTP ${status}) — ${msg}. The provider's backend itself is failing; switch model with /model or wait and retry.`;
134
+ }
120
135
  if ((err instanceof Error && err.name === "TimeoutError") || /the operation timed out/i.test(msg)) {
121
136
  return `${provider} did not complete the request within the call timeout (default 30min). This is expected for a HIGH/XHIGH-reasoning-effort completion that legitimately runs long on a hard problem — raise JEO_CALL_TIMEOUT_MS (ms) if this recurs, or lower the thinking level with /model.`;
122
137
  }
package/src/util/retry.ts CHANGED
@@ -5,8 +5,16 @@ export interface RetryOptions {
5
5
  isRetryable?: (err: unknown, attempt: number) => boolean;
6
6
  sleep?: (ms: number) => Promise<void>; // injectable for tests (default real setTimeout)
7
7
  random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
8
- /** Notified before each backoff wait; `delayMs` is the wait actually applied. */
9
- onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
8
+ /** Notified before each backoff wait; `delayMs` is the wait actually applied.
9
+ * Returning `false` (explicitly) aborts the retry ladder immediately — the
10
+ * pending error is thrown right away instead of sleeping and re-attempting.
11
+ * Lets a caller with a same-tier FALLBACK model available bail out of a
12
+ * same-model rate-limit retry loop early rather than riding the full
13
+ * `rateLimitRetries` budget (up to ~90s of escalating backoff) before the
14
+ * turn-level equivalent-pool fallback ever gets a chance to switch models.
15
+ * Any other return value (including `undefined`, the common case) continues
16
+ * the retry ladder unchanged. */
17
+ onRetry?: (attempt: number, err: unknown, delayMs: number) => void | false;
10
18
  /** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
11
19
  * aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
12
20
  * for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
@@ -200,8 +208,11 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
200
208
  : 0;
201
209
  const delay = Math.max(base, floor);
202
210
 
203
- if (onRetry) {
204
- onRetry(attempt, err, delay);
211
+ // A `false` return from onRetry means the caller wants to bail NOW instead of
212
+ // sleeping and re-attempting (see RetryOptions.onRetry's doc comment) — throw
213
+ // the pending error immediately rather than entering the backoff wait.
214
+ if (onRetry && onRetry(attempt, err, delay) === false) {
215
+ throw err;
205
216
  }
206
217
 
207
218
  await abortableSleep(sleep, delay, signal);