@lovenyberg/ove 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queue.ts CHANGED
@@ -19,6 +19,18 @@ export interface Task {
19
19
  completedAt: string | null;
20
20
  }
21
21
 
22
+ interface TaskRow {
23
+ id: string;
24
+ user_id: string;
25
+ repo: string;
26
+ prompt: string;
27
+ status: string;
28
+ result: string | null;
29
+ task_type: string | null;
30
+ created_at: string;
31
+ completed_at: string | null;
32
+ }
33
+
22
34
  export class TaskQueue {
23
35
  private db: Database;
24
36
 
@@ -38,10 +50,9 @@ export class TaskQueue {
38
50
  )
39
51
  `);
40
52
  // Migration: add task_type column if missing (backward compat)
41
- try {
42
- this.db.run(`ALTER TABLE tasks ADD COLUMN task_type TEXT`);
43
- } catch {
44
- // Column already exists
53
+ const columns = this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
54
+ if (!columns.some(c => c.name === "task_type")) {
55
+ this.db.run("ALTER TABLE tasks ADD COLUMN task_type TEXT");
45
56
  }
46
57
  }
47
58
 
@@ -64,7 +75,7 @@ export class TaskQueue {
64
75
  ORDER BY created_at ASC
65
76
  LIMIT 1`
66
77
  )
67
- .get() as any;
78
+ .get() as TaskRow;
68
79
 
69
80
  if (!row) return null;
70
81
 
@@ -88,7 +99,7 @@ export class TaskQueue {
88
99
  }
89
100
 
90
101
  get(id: string): Task | null {
91
- const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as any;
102
+ const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow;
92
103
  return row ? this.rowToTask(row) : null;
93
104
  }
94
105
 
@@ -97,7 +108,7 @@ export class TaskQueue {
97
108
  .query(
98
109
  `SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
99
110
  )
100
- .all(userId, limit) as any[];
111
+ .all(userId, limit) as TaskRow[];
101
112
  return rows.map((r) => this.rowToTask(r));
102
113
  }
103
114
 
@@ -111,17 +122,42 @@ export class TaskQueue {
111
122
  COUNT(*) FILTER (WHERE status = 'failed') as failed
112
123
  FROM tasks`
113
124
  )
114
- .get() as any;
125
+ .get() as { pending: number; running: number; completed: number; failed: number };
115
126
  return row;
116
127
  }
117
128
 
118
- private rowToTask(row: any): Task {
129
+ listActive(limit: number = 20): Task[] {
130
+ const rows = this.db
131
+ .query(
132
+ `SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY created_at ASC LIMIT ?`
133
+ )
134
+ .all(limit) as TaskRow[];
135
+ return rows.map((r) => this.rowToTask(r));
136
+ }
137
+
138
+ cancel(id: string): boolean {
139
+ const result = this.db.run(
140
+ `UPDATE tasks SET status = 'failed', result = 'Cancelled', completed_at = ? WHERE id = ? AND status IN ('running', 'pending')`,
141
+ [new Date().toISOString(), id]
142
+ );
143
+ return result.changes > 0;
144
+ }
145
+
146
+ resetStale(): number {
147
+ const result = this.db.run(
148
+ `UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`,
149
+ [new Date().toISOString()]
150
+ );
151
+ return result.changes;
152
+ }
153
+
154
+ private rowToTask(row: TaskRow): Task {
119
155
  return {
120
156
  id: row.id,
121
157
  userId: row.user_id,
122
158
  repo: row.repo,
123
159
  prompt: row.prompt,
124
- status: row.status,
160
+ status: row.status as "pending" | "running" | "completed" | "failed",
125
161
  result: row.result,
126
162
  taskType: row.task_type || null,
127
163
  createdAt: row.created_at,
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { RepoRegistry, parseGhRepoLine } from "./repo-registry";
4
+
5
+ describe("RepoRegistry", () => {
6
+ let db: Database;
7
+ let registry: RepoRegistry;
8
+
9
+ beforeEach(() => {
10
+ db = new Database(":memory:");
11
+ registry = new RepoRegistry(db);
12
+ });
13
+
14
+ it("creates repos table on construction", () => {
15
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='repos'").all();
16
+ expect(tables.length).toBe(1);
17
+ });
18
+
19
+ it("upserts and retrieves a repo", () => {
20
+ registry.upsert({
21
+ name: "my-app",
22
+ url: "git@github.com:user/my-app.git",
23
+ owner: "user",
24
+ defaultBranch: "main",
25
+ source: "github-sync",
26
+ });
27
+ const repo = registry.getByName("my-app");
28
+ expect(repo).not.toBeNull();
29
+ expect(repo!.url).toBe("git@github.com:user/my-app.git");
30
+ expect(repo!.source).toBe("github-sync");
31
+ });
32
+
33
+ it("upsert updates existing repo", () => {
34
+ registry.upsert({ name: "my-app", url: "old-url", source: "config" });
35
+ registry.upsert({ name: "my-app", url: "new-url", source: "github-sync" });
36
+ const repo = registry.getByName("my-app");
37
+ expect(repo!.url).toBe("new-url");
38
+ });
39
+
40
+ it("returns null for unknown repo", () => {
41
+ expect(registry.getByName("nope")).toBeNull();
42
+ });
43
+
44
+ it("lists all non-excluded repos", () => {
45
+ registry.upsert({ name: "a", url: "u1", source: "github-sync" });
46
+ registry.upsert({ name: "b", url: "u2", source: "github-sync" });
47
+ registry.upsert({ name: "c", url: "u3", source: "github-sync", excluded: true });
48
+ const all = registry.getAll();
49
+ expect(all.length).toBe(2);
50
+ expect(all.map(r => r.name).sort()).toEqual(["a", "b"]);
51
+ });
52
+
53
+ it("lists all repo names", () => {
54
+ registry.upsert({ name: "x", url: "u1", source: "config" });
55
+ registry.upsert({ name: "y", url: "u2", source: "github-sync" });
56
+ const names = registry.getAllNames();
57
+ expect(names.sort()).toEqual(["x", "y"]);
58
+ });
59
+
60
+ it("excludes a repo", () => {
61
+ registry.upsert({ name: "old", url: "u", source: "github-sync" });
62
+ registry.setExcluded("old", true);
63
+ expect(registry.getAll().length).toBe(0);
64
+ expect(registry.getByName("old")!.excluded).toBe(true);
65
+ });
66
+
67
+ it("migrates config repos", () => {
68
+ const configRepos = {
69
+ "my-app": { url: "git@github.com:user/my-app.git", defaultBranch: "main" },
70
+ "infra": { url: "git@github.com:user/infra.git", defaultBranch: "develop" },
71
+ };
72
+ registry.migrateFromConfig(configRepos);
73
+ expect(registry.getAll().length).toBe(2);
74
+ const infra = registry.getByName("infra");
75
+ expect(infra!.defaultBranch).toBe("develop");
76
+ expect(infra!.source).toBe("config");
77
+ });
78
+
79
+ it("migration does not overwrite github-sync repos", () => {
80
+ registry.upsert({ name: "my-app", url: "gh-url", source: "github-sync", defaultBranch: "main" });
81
+ registry.migrateFromConfig({
82
+ "my-app": { url: "config-url", defaultBranch: "main" },
83
+ });
84
+ expect(registry.getByName("my-app")!.url).toBe("gh-url");
85
+ });
86
+ });
87
+
88
+ describe("parseGhRepoLine", () => {
89
+ it("parses standard gh repo list output", () => {
90
+ const result = parseGhRepoLine("jacksoncage/ove\tMy app\tpublic\t2026-02-20T10:00:00Z");
91
+ expect(result).toEqual({ name: "ove", owner: "jacksoncage", fullName: "jacksoncage/ove" });
92
+ });
93
+
94
+ it("parses line with no description", () => {
95
+ const result = parseGhRepoLine("org/repo-name\t\tprivate\t2026-01-01T00:00:00Z");
96
+ expect(result).toEqual({ name: "repo-name", owner: "org", fullName: "org/repo-name" });
97
+ });
98
+
99
+ it("returns null for empty line", () => {
100
+ expect(parseGhRepoLine("")).toBeNull();
101
+ });
102
+
103
+ it("returns null for line without slash", () => {
104
+ expect(parseGhRepoLine("no-slash-here")).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe("config + registry integration", () => {
109
+ it("config repos + registry merge correctly", () => {
110
+ const db = new Database(":memory:");
111
+ const registry = new RepoRegistry(db);
112
+
113
+ // Simulate GitHub sync adding repos
114
+ registry.upsert({ name: "api", url: "git@github.com:org/api.git", owner: "org", source: "github-sync" });
115
+ registry.upsert({ name: "web", url: "git@github.com:org/web.git", owner: "org", source: "github-sync" });
116
+
117
+ // Simulate config migration (manual repo)
118
+ registry.migrateFromConfig({
119
+ "legacy": { url: "git@github.com:me/legacy.git", defaultBranch: "develop" },
120
+ });
121
+
122
+ // All three repos exist
123
+ expect(registry.getAllNames().sort()).toEqual(["api", "legacy", "web"]);
124
+
125
+ // Excluding a repo hides it from getAll but not getByName
126
+ registry.setExcluded("legacy", true);
127
+ expect(registry.getAllNames().sort()).toEqual(["api", "web"]);
128
+ expect(registry.getByName("legacy")).not.toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,201 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { logger } from "./logger";
3
+
4
+ export interface RepoRecord {
5
+ name: string;
6
+ url: string;
7
+ owner: string | null;
8
+ defaultBranch: string;
9
+ source: string;
10
+ excluded: boolean;
11
+ lastSyncedAt: string | null;
12
+ }
13
+
14
+ interface RepoRow {
15
+ name: string;
16
+ url: string;
17
+ owner: string | null;
18
+ default_branch: string;
19
+ source: string;
20
+ excluded: number;
21
+ last_synced_at: string | null;
22
+ }
23
+
24
+ export interface RepoUpsertInput {
25
+ name: string;
26
+ url: string;
27
+ owner?: string;
28
+ defaultBranch?: string;
29
+ source: string;
30
+ excluded?: boolean;
31
+ }
32
+
33
+ export class RepoRegistry {
34
+ private db: Database;
35
+
36
+ constructor(db: Database) {
37
+ this.db = db;
38
+ this.db.run(`
39
+ CREATE TABLE IF NOT EXISTS repos (
40
+ name TEXT PRIMARY KEY,
41
+ url TEXT NOT NULL,
42
+ owner TEXT,
43
+ default_branch TEXT DEFAULT 'main',
44
+ source TEXT NOT NULL,
45
+ excluded INTEGER DEFAULT 0,
46
+ last_synced_at TEXT
47
+ )
48
+ `);
49
+ }
50
+
51
+ upsert(input: RepoUpsertInput): void {
52
+ this.db.run(
53
+ `INSERT INTO repos (name, url, owner, default_branch, source, excluded, last_synced_at)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?)
55
+ ON CONFLICT(name) DO UPDATE SET
56
+ url = excluded.url,
57
+ owner = excluded.owner,
58
+ default_branch = excluded.default_branch,
59
+ source = excluded.source,
60
+ excluded = excluded.excluded,
61
+ last_synced_at = excluded.last_synced_at`,
62
+ [
63
+ input.name,
64
+ input.url,
65
+ input.owner || null,
66
+ input.defaultBranch || "main",
67
+ input.source,
68
+ input.excluded ? 1 : 0,
69
+ new Date().toISOString(),
70
+ ]
71
+ );
72
+ }
73
+
74
+ getByName(name: string): RepoRecord | null {
75
+ const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as RepoRow;
76
+ return row ? this.rowToRecord(row) : null;
77
+ }
78
+
79
+ getAll(): RepoRecord[] {
80
+ const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as RepoRow[];
81
+ return rows.map(r => this.rowToRecord(r));
82
+ }
83
+
84
+ getAllNames(): string[] {
85
+ const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as { name: string }[];
86
+ return rows.map(r => r.name);
87
+ }
88
+
89
+ setExcluded(name: string, excluded: boolean): void {
90
+ this.db.run(`UPDATE repos SET excluded = ? WHERE name = ?`, [excluded ? 1 : 0, name]);
91
+ }
92
+
93
+ migrateFromConfig(configRepos: Record<string, { url: string; defaultBranch?: string }>): void {
94
+ for (const [name, repo] of Object.entries(configRepos)) {
95
+ const existing = this.getByName(name);
96
+ if (existing && existing.source === "github-sync") continue;
97
+
98
+ this.upsert({
99
+ name,
100
+ url: repo.url,
101
+ defaultBranch: repo.defaultBranch || "main",
102
+ source: "config",
103
+ });
104
+ }
105
+ }
106
+
107
+ private rowToRecord(row: RepoRow): RepoRecord {
108
+ return {
109
+ name: row.name,
110
+ url: row.url,
111
+ owner: row.owner,
112
+ defaultBranch: row.default_branch,
113
+ source: row.source,
114
+ excluded: row.excluded === 1,
115
+ lastSyncedAt: row.last_synced_at,
116
+ };
117
+ }
118
+ }
119
+
120
+ export interface GhRepoParsed {
121
+ name: string;
122
+ owner: string;
123
+ fullName: string;
124
+ }
125
+
126
+ export function parseGhRepoLine(line: string): GhRepoParsed | null {
127
+ const trimmed = line.trim();
128
+ if (!trimmed) return null;
129
+ // gh repo list output: owner/name\tdescription\tvisibility\tupdated_at
130
+ const fullName = trimmed.split("\t")[0];
131
+ if (!fullName || !fullName.includes("/")) return null;
132
+ const [owner, ...rest] = fullName.split("/");
133
+ const name = rest.join("/"); // handle potential edge cases
134
+ return { name, owner, fullName };
135
+ }
136
+
137
+ interface GhRepoResponse {
138
+ name: string;
139
+ owner?: { login: string };
140
+ defaultBranchRef?: { name: string };
141
+ isArchived: boolean;
142
+ }
143
+
144
+ export async function syncGitHub(
145
+ registry: RepoRegistry,
146
+ orgs?: string[]
147
+ ): Promise<number> {
148
+ let count = 0;
149
+ const targets = orgs && orgs.length > 0 ? orgs : [undefined];
150
+
151
+ for (const org of targets) {
152
+ try {
153
+ const args = ["repo", "list", "--json", "name,owner,defaultBranchRef,isArchived", "--limit", "500"];
154
+ if (org) args.splice(2, 0, org);
155
+
156
+ const proc = Bun.spawn(["gh", ...args], {
157
+ stdout: "pipe",
158
+ stderr: "pipe",
159
+ });
160
+
161
+ const output = await new Response(proc.stdout).text();
162
+ const exitCode = await proc.exited;
163
+
164
+ if (exitCode !== 0) {
165
+ const stderr = await new Response(proc.stderr).text();
166
+ logger.warn("gh repo list failed", { org, error: stderr.slice(0, 200) });
167
+ continue;
168
+ }
169
+
170
+ let repos: GhRepoResponse[];
171
+ try {
172
+ repos = JSON.parse(output);
173
+ } catch {
174
+ // JSON parsing failed — log and skip this org
175
+ logger.warn("gh repo list returned invalid JSON", { org });
176
+ continue;
177
+ }
178
+
179
+ for (const repo of repos) {
180
+ if (repo.isArchived) continue;
181
+ const owner = repo.owner?.login || org || "";
182
+ const name = repo.name;
183
+ const defaultBranch = repo.defaultBranchRef?.name || "main";
184
+
185
+ registry.upsert({
186
+ name,
187
+ url: `git@github.com:${owner}/${name}.git`,
188
+ owner,
189
+ defaultBranch,
190
+ source: "github-sync",
191
+ });
192
+ count++;
193
+ }
194
+ } catch (err) {
195
+ logger.warn("github sync error", { org, error: String(err) });
196
+ }
197
+ }
198
+
199
+ logger.info("github sync complete", { repos: count });
200
+ return count;
201
+ }
package/src/repos.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { logger } from "./logger";
3
3
 
4
+ const GIT_TIMEOUT = 60_000; // 60 seconds
5
+ const GIT_ENV = {
6
+ ...process.env,
7
+ GIT_SSH_COMMAND: "ssh -o ConnectTimeout=10 -o BatchMode=yes",
8
+ };
9
+
4
10
  export class RepoManager {
5
11
  constructor(private reposDir: string) {}
6
12
 
@@ -23,10 +29,14 @@ export class RepoManager {
23
29
  const proc = Bun.spawn(["git", "clone", url, path], {
24
30
  stdout: "pipe",
25
31
  stderr: "pipe",
32
+ env: GIT_ENV,
26
33
  });
27
- const exitCode = await proc.exited;
34
+ const exitCode = await Promise.race([
35
+ proc.exited,
36
+ Bun.sleep(GIT_TIMEOUT).then(() => { proc.kill(); return -1; }),
37
+ ]);
28
38
  if (exitCode !== 0) {
29
- const stderr = await new Response(proc.stderr).text();
39
+ const stderr = exitCode === -1 ? "git clone timed out" : await new Response(proc.stderr).text();
30
40
  throw new Error(`git clone failed: ${stderr}`);
31
41
  }
32
42
  }
@@ -38,11 +48,15 @@ export class RepoManager {
38
48
  cwd: path,
39
49
  stdout: "pipe",
40
50
  stderr: "pipe",
51
+ env: GIT_ENV,
41
52
  });
42
- const exitCode = await proc.exited;
53
+ const exitCode = await Promise.race([
54
+ proc.exited,
55
+ Bun.sleep(GIT_TIMEOUT).then(() => { proc.kill(); return -1; }),
56
+ ]);
43
57
  if (exitCode !== 0) {
44
- const stderr = await new Response(proc.stderr).text();
45
- logger.warn("git pull failed", { repo: repoName, error: stderr });
58
+ const msg = exitCode === -1 ? "git pull timed out" : await new Response(proc.stderr).text();
59
+ logger.warn("git pull failed", { repo: repoName, error: msg });
46
60
  }
47
61
  }
48
62
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { parseMessage, buildPrompt } from "./router";
2
+ import { parseMessage, buildPrompt, buildCronPrompt } from "./router";
3
3
 
4
4
  describe("parseMessage", () => {
5
5
  it("parses PR review command", () => {
@@ -124,6 +124,29 @@ describe("parseMessage", () => {
124
124
  expect(result.args.scheduleId).toBe(1);
125
125
  });
126
126
 
127
+ it("parses 'every 30 min' as schedule", () => {
128
+ const result = parseMessage("simplify code and create a PR every 30 min on infra-salming-ai");
129
+ expect(result.type).toBe("schedule");
130
+ expect(result.repo).toBe("infra-salming-ai");
131
+ });
132
+
133
+ it("parses 'every 2 hours' as schedule", () => {
134
+ const result = parseMessage("run tests every 2 hours on my-app");
135
+ expect(result.type).toBe("schedule");
136
+ expect(result.repo).toBe("my-app");
137
+ });
138
+
139
+ it("parses 'every hour' as schedule", () => {
140
+ const result = parseMessage("check for updates every hour");
141
+ expect(result.type).toBe("schedule");
142
+ });
143
+
144
+ it("parses 'every 15 minutes' as schedule", () => {
145
+ const result = parseMessage("deploy every 15 minutes on staging");
146
+ expect(result.type).toBe("schedule");
147
+ expect(result.repo).toBe("staging");
148
+ });
149
+
127
150
  it("parses init repo with SSH URL", () => {
128
151
  const result = parseMessage("init repo my-app git@github.com:user/my-app.git");
129
152
  expect(result.type).toBe("init-repo");
@@ -149,6 +172,88 @@ describe("parseMessage", () => {
149
172
  expect(result.type).not.toBe("init-repo");
150
173
  });
151
174
 
175
+ it("parses Telegram /start as help", () => {
176
+ const result = parseMessage("/start");
177
+ expect(result.type).toBe("help");
178
+ });
179
+
180
+ it("parses Telegram /help as help", () => {
181
+ const result = parseMessage("/help");
182
+ expect(result.type).toBe("help");
183
+ });
184
+
185
+ it("parses Telegram /status as status", () => {
186
+ const result = parseMessage("/status");
187
+ expect(result.type).toBe("status");
188
+ });
189
+
190
+ it("parses Telegram /history as history", () => {
191
+ const result = parseMessage("/history");
192
+ expect(result.type).toBe("history");
193
+ });
194
+
195
+ it("parses Telegram /clear as clear", () => {
196
+ const result = parseMessage("/clear");
197
+ expect(result.type).toBe("clear");
198
+ });
199
+
200
+ // Natural language status inquiries
201
+ it("parses 'how's it going?' as status", () => {
202
+ const result = parseMessage("how's it going?");
203
+ expect(result.type).toBe("status");
204
+ });
205
+
206
+ it("parses 'any updates?' as status", () => {
207
+ const result = parseMessage("any updates?");
208
+ expect(result.type).toBe("status");
209
+ });
210
+
211
+ it("parses 'are you done?' as status", () => {
212
+ const result = parseMessage("are you done?");
213
+ expect(result.type).toBe("status");
214
+ });
215
+
216
+ it("parses 'how dose it the work go?' as status", () => {
217
+ const result = parseMessage("how dose it the work go?");
218
+ expect(result.type).toBe("status");
219
+ });
220
+
221
+ it("parses 'how is the work going' as status", () => {
222
+ const result = parseMessage("how is the work going");
223
+ expect(result.type).toBe("status");
224
+ });
225
+
226
+ it("parses 'what's the progress' as status", () => {
227
+ const result = parseMessage("what's the progress");
228
+ expect(result.type).toBe("status");
229
+ });
230
+
231
+ it("parses 'done yet?' as status", () => {
232
+ const result = parseMessage("done yet?");
233
+ expect(result.type).toBe("status");
234
+ });
235
+
236
+ it("parses 'how far along are you' as status", () => {
237
+ const result = parseMessage("how far along are you");
238
+ expect(result.type).toBe("status");
239
+ });
240
+
241
+ it("parses 'is it done' as status", () => {
242
+ const result = parseMessage("is it done");
243
+ expect(result.type).toBe("status");
244
+ });
245
+
246
+ it("parses 'progress?' as status", () => {
247
+ const result = parseMessage("progress?");
248
+ expect(result.type).toBe("status");
249
+ });
250
+
251
+ it("does NOT parse work requests as status", () => {
252
+ expect(parseMessage("update the progress bar on my-app").type).toBe("free-form");
253
+ expect(parseMessage("fix the status page in my-app").type).toBe("free-form");
254
+ expect(parseMessage("add a progress indicator on my-app").type).toBe("free-form");
255
+ });
256
+
152
257
  it("falls back to free-form for unrecognized input", () => {
153
258
  const result = parseMessage("what does the auth middleware do in my-app");
154
259
  expect(result.type).toBe("free-form");
@@ -190,3 +295,22 @@ describe("buildPrompt", () => {
190
295
  expect(prompt).toBe("explain the auth flow");
191
296
  });
192
297
  });
298
+
299
+ describe("buildCronPrompt", () => {
300
+ it("wraps prompt with autonomy instructions", () => {
301
+ const prompt = buildCronPrompt("simplify code and create a PR");
302
+ expect(prompt).toContain("simplify code and create a PR");
303
+ expect(prompt).toContain("autonomous");
304
+ });
305
+
306
+ it("instructs not to ask questions", () => {
307
+ const prompt = buildCronPrompt("run tests");
308
+ expect(prompt).toMatch(/do not ask|never ask|don't ask/i);
309
+ });
310
+
311
+ it("includes the original prompt verbatim", () => {
312
+ const original = "check for security vulnerabilities and fix them";
313
+ const prompt = buildCronPrompt(original);
314
+ expect(prompt).toContain(original);
315
+ });
316
+ });