@yattalo/task-system 0.1.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.
Files changed (94) hide show
  1. package/README.md +66 -0
  2. package/dist/commands/dashboard.d.ts +7 -0
  3. package/dist/commands/dashboard.d.ts.map +1 -0
  4. package/dist/commands/dashboard.js +30 -0
  5. package/dist/commands/dashboard.js.map +1 -0
  6. package/dist/commands/init.d.ts +8 -0
  7. package/dist/commands/init.d.ts.map +1 -0
  8. package/dist/commands/init.js +316 -0
  9. package/dist/commands/init.js.map +1 -0
  10. package/dist/commands/kanban.d.ts +7 -0
  11. package/dist/commands/kanban.d.ts.map +1 -0
  12. package/dist/commands/kanban.js +68 -0
  13. package/dist/commands/kanban.js.map +1 -0
  14. package/dist/commands/report.d.ts +8 -0
  15. package/dist/commands/report.d.ts.map +1 -0
  16. package/dist/commands/report.js +60 -0
  17. package/dist/commands/report.js.map +1 -0
  18. package/dist/commands/seed.d.ts +6 -0
  19. package/dist/commands/seed.d.ts.map +1 -0
  20. package/dist/commands/seed.js +30 -0
  21. package/dist/commands/seed.js.map +1 -0
  22. package/dist/commands/status.d.ts +6 -0
  23. package/dist/commands/status.d.ts.map +1 -0
  24. package/dist/commands/status.js +62 -0
  25. package/dist/commands/status.js.map +1 -0
  26. package/dist/generators/agent-ops-scripts.d.ts +8 -0
  27. package/dist/generators/agent-ops-scripts.d.ts.map +1 -0
  28. package/dist/generators/agent-ops-scripts.js +843 -0
  29. package/dist/generators/agent-ops-scripts.js.map +1 -0
  30. package/dist/generators/agent-ops.d.ts +6 -0
  31. package/dist/generators/agent-ops.d.ts.map +1 -0
  32. package/dist/generators/agent-ops.js +290 -0
  33. package/dist/generators/agent-ops.js.map +1 -0
  34. package/dist/generators/claude-hooks.d.ts +6 -0
  35. package/dist/generators/claude-hooks.d.ts.map +1 -0
  36. package/dist/generators/claude-hooks.js +260 -0
  37. package/dist/generators/claude-hooks.js.map +1 -0
  38. package/dist/generators/cli-aliases.d.ts +6 -0
  39. package/dist/generators/cli-aliases.d.ts.map +1 -0
  40. package/dist/generators/cli-aliases.js +187 -0
  41. package/dist/generators/cli-aliases.js.map +1 -0
  42. package/dist/generators/dashboard.d.ts +6 -0
  43. package/dist/generators/dashboard.d.ts.map +1 -0
  44. package/dist/generators/dashboard.js +732 -0
  45. package/dist/generators/dashboard.js.map +1 -0
  46. package/dist/generators/git-hook.d.ts +6 -0
  47. package/dist/generators/git-hook.d.ts.map +1 -0
  48. package/dist/generators/git-hook.js +163 -0
  49. package/dist/generators/git-hook.js.map +1 -0
  50. package/dist/generators/http.d.ts +6 -0
  51. package/dist/generators/http.d.ts.map +1 -0
  52. package/dist/generators/http.js +175 -0
  53. package/dist/generators/http.js.map +1 -0
  54. package/dist/generators/orchestrator.d.ts +6 -0
  55. package/dist/generators/orchestrator.d.ts.map +1 -0
  56. package/dist/generators/orchestrator.js +391 -0
  57. package/dist/generators/orchestrator.js.map +1 -0
  58. package/dist/generators/schema.d.ts +8 -0
  59. package/dist/generators/schema.d.ts.map +1 -0
  60. package/dist/generators/schema.js +470 -0
  61. package/dist/generators/schema.js.map +1 -0
  62. package/dist/generators/skill.d.ts +6 -0
  63. package/dist/generators/skill.d.ts.map +1 -0
  64. package/dist/generators/skill.js +147 -0
  65. package/dist/generators/skill.js.map +1 -0
  66. package/dist/generators/slash-commands.d.ts +6 -0
  67. package/dist/generators/slash-commands.d.ts.map +1 -0
  68. package/dist/generators/slash-commands.js +268 -0
  69. package/dist/generators/slash-commands.js.map +1 -0
  70. package/dist/generators/tasks.d.ts +6 -0
  71. package/dist/generators/tasks.d.ts.map +1 -0
  72. package/dist/generators/tasks.js +451 -0
  73. package/dist/generators/tasks.js.map +1 -0
  74. package/dist/index.d.ts +3 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +54 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/presets/research.d.ts +3 -0
  79. package/dist/presets/research.d.ts.map +1 -0
  80. package/dist/presets/research.js +56 -0
  81. package/dist/presets/research.js.map +1 -0
  82. package/dist/presets/software.d.ts +3 -0
  83. package/dist/presets/software.d.ts.map +1 -0
  84. package/dist/presets/software.js +56 -0
  85. package/dist/presets/software.js.map +1 -0
  86. package/dist/utils/detect.d.ts +3 -0
  87. package/dist/utils/detect.d.ts.map +1 -0
  88. package/dist/utils/detect.js +76 -0
  89. package/dist/utils/detect.js.map +1 -0
  90. package/dist/utils/merge.d.ts +14 -0
  91. package/dist/utils/merge.d.ts.map +1 -0
  92. package/dist/utils/merge.js +43 -0
  93. package/dist/utils/merge.js.map +1 -0
  94. package/package.json +39 -0
@@ -0,0 +1,843 @@
1
+ // ============================================================
2
+ // Generator: Layer 4 — Agent-Ops Scripts
3
+ // Generates scripts/agent-ops/*.ts
4
+ // ============================================================
5
+ import { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ export function generateAgentOpsScripts(ctx) {
8
+ const results = [];
9
+ const base = join(ctx.targetDir, "scripts", "agent-ops");
10
+ results.push(generateTypes(ctx, base));
11
+ results.push(generateConfig(ctx, base));
12
+ results.push(generateScheduler(ctx, base));
13
+ results.push(generateLaneQueue(ctx, base));
14
+ results.push(generateGitIsolation(ctx, base));
15
+ results.push(generateAgentLauncher(ctx, base));
16
+ results.push(generateLogStreamer(ctx, base));
17
+ results.push(generatePrCreator(ctx, base));
18
+ results.push(generateCleanup(ctx, base));
19
+ results.push(generateRunner(ctx, base));
20
+ return results;
21
+ }
22
+ function writeIfNotDry(path, content, dryRun) {
23
+ const lineCount = content.split("\n").length;
24
+ if (!dryRun)
25
+ writeFileSync(path, content, "utf-8");
26
+ return lineCount;
27
+ }
28
+ // ── types.ts ──
29
+ function generateTypes(ctx, base) {
30
+ const content = `// Agent-Ops shared types — generated by @yattalo/task-system
31
+
32
+ export interface AgentOpsConfig {
33
+ convexUrl: string;
34
+ pollInterval: number;
35
+ maxConcurrentRuns: number;
36
+ maxRunsPerDay: number;
37
+ defaultTimeoutMinutes: number;
38
+ protectedBranches: string[];
39
+ agentCommands: Record<string, string>;
40
+ projectRoot: string;
41
+ worktreeBase: string;
42
+ }
43
+
44
+ export interface JobSnapshot {
45
+ jobId: string;
46
+ name: string;
47
+ agent: string;
48
+ prompt: string;
49
+ executionMode: string;
50
+ createPR: boolean;
51
+ prBaseBranch?: string;
52
+ maxDurationMinutes?: number;
53
+ laneId?: string;
54
+ }
55
+
56
+ export interface RunContext {
57
+ runId: string;
58
+ jobId: string;
59
+ agent: string;
60
+ prompt: string;
61
+ executionMode: string;
62
+ worktreePath: string;
63
+ branchName: string;
64
+ baseBranch: string;
65
+ startedAt: number;
66
+ }
67
+
68
+ export interface LogEntry {
69
+ timestamp: number;
70
+ level: "info" | "warn" | "error" | "debug";
71
+ message: string;
72
+ }
73
+ `;
74
+ const lines = writeIfNotDry(join(base, "types.ts"), content, ctx.dryRun);
75
+ return { file: "scripts/agent-ops/types.ts", lines };
76
+ }
77
+ // ── config.ts ──
78
+ function generateConfig(ctx, base) {
79
+ const agentOps = ctx.config.agentOps ?? {};
80
+ const agentCmds = agentOps.agentCommands ?? {};
81
+ const cmdsStr = Object.entries(agentCmds)
82
+ .map(([k, v]) => ` ${k}: "${v}"`)
83
+ .join(",\n");
84
+ const content = `// Agent-Ops config loader — generated by @yattalo/task-system
85
+ import { resolve } from "node:path";
86
+ import { existsSync } from "node:fs";
87
+ import type { AgentOpsConfig } from "./types.js";
88
+
89
+ const DEFAULT_CONFIG: AgentOpsConfig = {
90
+ convexUrl: process.env.CONVEX_URL ?? "",
91
+ pollInterval: ${agentOps.pollInterval ?? 30000},
92
+ maxConcurrentRuns: ${agentOps.maxConcurrentRuns ?? 3},
93
+ maxRunsPerDay: ${agentOps.maxRunsPerDay ?? 100},
94
+ defaultTimeoutMinutes: ${agentOps.defaultTimeoutMinutes ?? 30},
95
+ protectedBranches: ${JSON.stringify(agentOps.protectedBranches ?? ["main", "master"])},
96
+ agentCommands: {
97
+ ${cmdsStr}
98
+ },
99
+ projectRoot: process.cwd(),
100
+ worktreeBase: resolve(process.env.HOME ?? "~", ".agent-ops", "worktrees"),
101
+ };
102
+
103
+ export function loadConfig(): AgentOpsConfig {
104
+ // Try to load from task-system.config.ts at project root
105
+ const configPath = resolve(process.cwd(), "task-system.config.ts");
106
+ if (existsSync(configPath)) {
107
+ // Config is TypeScript — in production this would use tsx or similar
108
+ // For now, use defaults + env overrides
109
+ }
110
+
111
+ return {
112
+ ...DEFAULT_CONFIG,
113
+ convexUrl: process.env.CONVEX_URL ?? DEFAULT_CONFIG.convexUrl,
114
+ pollInterval: parseInt(process.env.AGENT_OPS_POLL_INTERVAL ?? String(DEFAULT_CONFIG.pollInterval), 10),
115
+ maxConcurrentRuns: parseInt(process.env.AGENT_OPS_MAX_CONCURRENT ?? String(DEFAULT_CONFIG.maxConcurrentRuns), 10),
116
+ };
117
+ }
118
+ `;
119
+ const lines = writeIfNotDry(join(base, "config.ts"), content, ctx.dryRun);
120
+ return { file: "scripts/agent-ops/config.ts", lines };
121
+ }
122
+ // ── scheduler.ts ──
123
+ function generateScheduler(ctx, base) {
124
+ const content = `// Agent-Ops cron scheduler — generated by @yattalo/task-system
125
+ //
126
+ // Minimal cron parser for Agent-Ops scheduling.
127
+ // Supports standard 5-field cron expressions: minute hour day month weekday
128
+
129
+ export function parseCron(expression: string): { minute: number[]; hour: number[]; day: number[]; month: number[]; weekday: number[] } {
130
+ const parts = expression.trim().split(/\\s+/);
131
+ if (parts.length !== 5) throw new Error(\`Invalid cron: \${expression}\`);
132
+
133
+ return {
134
+ minute: parseField(parts[0], 0, 59),
135
+ hour: parseField(parts[1], 0, 23),
136
+ day: parseField(parts[2], 1, 31),
137
+ month: parseField(parts[3], 1, 12),
138
+ weekday: parseField(parts[4], 0, 6),
139
+ };
140
+ }
141
+
142
+ function parseField(field: string, min: number, max: number): number[] {
143
+ if (field === "*") return range(min, max);
144
+
145
+ const values = new Set<number>();
146
+ for (const part of field.split(",")) {
147
+ if (part.includes("/")) {
148
+ const [rangeStr, stepStr] = part.split("/");
149
+ const step = parseInt(stepStr, 10);
150
+ const [start, end] = rangeStr === "*" ? [min, max] : rangeStr.split("-").map(Number);
151
+ for (let i = start; i <= (end ?? max); i += step) values.add(i);
152
+ } else if (part.includes("-")) {
153
+ const [start, end] = part.split("-").map(Number);
154
+ for (let i = start; i <= end; i++) values.add(i);
155
+ } else {
156
+ values.add(parseInt(part, 10));
157
+ }
158
+ }
159
+ return [...values].sort((a, b) => a - b);
160
+ }
161
+
162
+ function range(min: number, max: number): number[] {
163
+ return Array.from({ length: max - min + 1 }, (_, i) => min + i);
164
+ }
165
+
166
+ export function getNextRun(expression: string, after: Date = new Date()): Date {
167
+ const cron = parseCron(expression);
168
+ const next = new Date(after.getTime() + 60000); // start from next minute
169
+ next.setSeconds(0, 0);
170
+
171
+ for (let attempts = 0; attempts < 525960; attempts++) { // max ~1 year of minutes
172
+ if (
173
+ cron.month.includes(next.getMonth() + 1) &&
174
+ cron.day.includes(next.getDate()) &&
175
+ cron.weekday.includes(next.getDay()) &&
176
+ cron.hour.includes(next.getHours()) &&
177
+ cron.minute.includes(next.getMinutes())
178
+ ) {
179
+ return next;
180
+ }
181
+ next.setTime(next.getTime() + 60000);
182
+ }
183
+
184
+ throw new Error(\`No next run found for: \${expression}\`);
185
+ }
186
+
187
+ export function shouldRunNow(expression: string, lastRunAt?: number): boolean {
188
+ const now = new Date();
189
+ const cron = parseCron(expression);
190
+
191
+ const matches =
192
+ cron.month.includes(now.getMonth() + 1) &&
193
+ cron.day.includes(now.getDate()) &&
194
+ cron.weekday.includes(now.getDay()) &&
195
+ cron.hour.includes(now.getHours()) &&
196
+ cron.minute.includes(now.getMinutes());
197
+
198
+ if (!matches) return false;
199
+ if (!lastRunAt) return true;
200
+
201
+ // Don't run again if already ran this minute
202
+ const lastRun = new Date(lastRunAt);
203
+ return (
204
+ lastRun.getMinutes() !== now.getMinutes() ||
205
+ lastRun.getHours() !== now.getHours() ||
206
+ lastRun.getDate() !== now.getDate()
207
+ );
208
+ }
209
+ `;
210
+ const lines = writeIfNotDry(join(base, "scheduler.ts"), content, ctx.dryRun);
211
+ return { file: "scripts/agent-ops/scheduler.ts", lines };
212
+ }
213
+ // ── lane-queue.ts ──
214
+ function generateLaneQueue(ctx, base) {
215
+ const content = `// Agent-Ops lane queue — generated by @yattalo/task-system
216
+ //
217
+ // Prevents concurrent runs on the same lane.
218
+ // A lane is a logical grouping (e.g., "frontend", "backend") to prevent conflicts.
219
+
220
+ const activeLanes = new Map<string, string>(); // laneId -> runId
221
+
222
+ export function acquireLane(laneId: string, runId: string): boolean {
223
+ if (activeLanes.has(laneId)) {
224
+ return false; // lane is busy
225
+ }
226
+ activeLanes.set(laneId, runId);
227
+ return true;
228
+ }
229
+
230
+ export function releaseLane(laneId: string, runId: string): void {
231
+ if (activeLanes.get(laneId) === runId) {
232
+ activeLanes.delete(laneId);
233
+ }
234
+ }
235
+
236
+ export function isLaneBusy(laneId: string): boolean {
237
+ return activeLanes.has(laneId);
238
+ }
239
+
240
+ export function getActiveLanes(): Map<string, string> {
241
+ return new Map(activeLanes);
242
+ }
243
+ `;
244
+ const lines = writeIfNotDry(join(base, "lane-queue.ts"), content, ctx.dryRun);
245
+ return { file: "scripts/agent-ops/lane-queue.ts", lines };
246
+ }
247
+ // ── git-isolation.ts ──
248
+ function generateGitIsolation(ctx, base) {
249
+ const protectedBranches = JSON.stringify(ctx.config.agentOps?.protectedBranches ?? ["main", "master"]);
250
+ const content = `// Agent-Ops git isolation — generated by @yattalo/task-system
251
+ //
252
+ // Creates and manages git worktrees for isolated agent execution.
253
+ // Each run gets its own worktree to prevent conflicts.
254
+
255
+ import { execSync } from "node:child_process";
256
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
257
+ import { join, resolve } from "node:path";
258
+ import type { AgentOpsConfig } from "./types.js";
259
+
260
+ const PROTECTED_BRANCHES = ${protectedBranches};
261
+
262
+ export interface WorktreeInfo {
263
+ path: string;
264
+ branch: string;
265
+ baseBranch: string;
266
+ }
267
+
268
+ export function createWorktree(
269
+ config: AgentOpsConfig,
270
+ runId: string,
271
+ baseBranch: string = "main",
272
+ ): WorktreeInfo {
273
+ const worktreeBase = config.worktreeBase;
274
+ mkdirSync(worktreeBase, { recursive: true });
275
+
276
+ const branchName = \`agent-ops/\${runId}\`;
277
+ const worktreePath = join(worktreeBase, runId);
278
+
279
+ if (existsSync(worktreePath)) {
280
+ throw new Error(\`Worktree already exists: \${worktreePath}\`);
281
+ }
282
+
283
+ // Validate base branch exists
284
+ try {
285
+ execSync(\`git rev-parse --verify \${baseBranch}\`, {
286
+ cwd: config.projectRoot,
287
+ stdio: "pipe",
288
+ });
289
+ } catch {
290
+ throw new Error(\`Base branch does not exist: \${baseBranch}\`);
291
+ }
292
+
293
+ // Validate not targeting protected branch directly
294
+ if (PROTECTED_BRANCHES.includes(branchName)) {
295
+ throw new Error(\`Cannot create worktree on protected branch: \${branchName}\`);
296
+ }
297
+
298
+ // Create worktree with new branch
299
+ execSync(
300
+ \`git worktree add -b "\${branchName}" "\${worktreePath}" "\${baseBranch}"\`,
301
+ { cwd: config.projectRoot, stdio: "pipe" },
302
+ );
303
+
304
+ return { path: worktreePath, branch: branchName, baseBranch };
305
+ }
306
+
307
+ export function removeWorktree(config: AgentOpsConfig, runId: string): void {
308
+ const worktreePath = join(config.worktreeBase, runId);
309
+
310
+ if (!existsSync(worktreePath)) return;
311
+
312
+ try {
313
+ execSync(\`git worktree remove "\${worktreePath}" --force\`, {
314
+ cwd: config.projectRoot,
315
+ stdio: "pipe",
316
+ });
317
+ } catch {
318
+ // Fallback: manual cleanup
319
+ rmSync(worktreePath, { recursive: true, force: true });
320
+ try {
321
+ execSync("git worktree prune", { cwd: config.projectRoot, stdio: "pipe" });
322
+ } catch {}
323
+ }
324
+
325
+ // Clean up branch
326
+ const branchName = \`agent-ops/\${runId}\`;
327
+ try {
328
+ execSync(\`git branch -D "\${branchName}"\`, {
329
+ cwd: config.projectRoot,
330
+ stdio: "pipe",
331
+ });
332
+ } catch {}
333
+ }
334
+
335
+ export function getWorktreeGitStats(
336
+ worktreePath: string,
337
+ baseBranch: string,
338
+ ): { commitCount: number; filesChanged: number; linesAdded: number; linesRemoved: number } {
339
+ try {
340
+ const commitCount = parseInt(
341
+ execSync(\`git rev-list --count \${baseBranch}..HEAD\`, {
342
+ cwd: worktreePath,
343
+ encoding: "utf-8",
344
+ }).trim(),
345
+ 10,
346
+ );
347
+
348
+ const diffStat = execSync(\`git diff --shortstat \${baseBranch}..HEAD\`, {
349
+ cwd: worktreePath,
350
+ encoding: "utf-8",
351
+ }).trim();
352
+
353
+ let filesChanged = 0, linesAdded = 0, linesRemoved = 0;
354
+ const filesMatch = diffStat.match(/(\\d+) files? changed/);
355
+ const addMatch = diffStat.match(/(\\d+) insertions?/);
356
+ const delMatch = diffStat.match(/(\\d+) deletions?/);
357
+
358
+ if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
359
+ if (addMatch) linesAdded = parseInt(addMatch[1], 10);
360
+ if (delMatch) linesRemoved = parseInt(delMatch[1], 10);
361
+
362
+ return { commitCount, filesChanged, linesAdded, linesRemoved };
363
+ } catch {
364
+ return { commitCount: 0, filesChanged: 0, linesAdded: 0, linesRemoved: 0 };
365
+ }
366
+ }
367
+
368
+ export function listStaleWorktrees(config: AgentOpsConfig, maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
369
+ const worktreeBase = config.worktreeBase;
370
+ if (!existsSync(worktreeBase)) return [];
371
+
372
+ const { readdirSync, statSync } = require("node:fs");
373
+ const now = Date.now();
374
+ const stale: string[] = [];
375
+
376
+ for (const entry of readdirSync(worktreeBase)) {
377
+ const entryPath = join(worktreeBase, entry);
378
+ try {
379
+ const stat = statSync(entryPath);
380
+ if (now - stat.mtimeMs > maxAgeMs) {
381
+ stale.push(entry);
382
+ }
383
+ } catch {}
384
+ }
385
+
386
+ return stale;
387
+ }
388
+ `;
389
+ const lines = writeIfNotDry(join(base, "git-isolation.ts"), content, ctx.dryRun);
390
+ return { file: "scripts/agent-ops/git-isolation.ts", lines };
391
+ }
392
+ // ── agent-launcher.ts ──
393
+ function generateAgentLauncher(ctx, base) {
394
+ const content = `// Agent-Ops agent launcher — generated by @yattalo/task-system
395
+ //
396
+ // Spawns agent CLI processes (claude, gemini, codex) with proper environment.
397
+
398
+ import { spawn, ChildProcess } from "node:child_process";
399
+ import type { AgentOpsConfig, RunContext, LogEntry } from "./types.js";
400
+
401
+ export interface LaunchResult {
402
+ process: ChildProcess;
403
+ exitCode: Promise<number>;
404
+ }
405
+
406
+ export function launchAgent(
407
+ config: AgentOpsConfig,
408
+ run: RunContext,
409
+ onLog: (entry: LogEntry) => void,
410
+ ): LaunchResult {
411
+ const agentCmd = config.agentCommands[run.agent];
412
+ if (!agentCmd) {
413
+ throw new Error(\`No command configured for agent: \${run.agent}\`);
414
+ }
415
+
416
+ const timeoutMs = (config.defaultTimeoutMinutes ?? 30) * 60 * 1000;
417
+
418
+ // Build agent-specific args
419
+ const args = buildAgentArgs(run);
420
+
421
+ onLog({
422
+ timestamp: Date.now(),
423
+ level: "info",
424
+ message: \`Launching \${run.agent}: \${agentCmd} \${args.join(" ")}\`,
425
+ });
426
+
427
+ const proc = spawn(agentCmd, args, {
428
+ cwd: run.worktreePath,
429
+ env: {
430
+ ...process.env,
431
+ AGENT_OPS_RUN_ID: run.runId,
432
+ AGENT_OPS_JOB_ID: run.jobId,
433
+ },
434
+ stdio: ["pipe", "pipe", "pipe"],
435
+ });
436
+
437
+ // Stream stdout/stderr to log callback
438
+ proc.stdout?.on("data", (data: Buffer) => {
439
+ const text = data.toString().trim();
440
+ if (text) {
441
+ onLog({ timestamp: Date.now(), level: "info", message: text.slice(0, 500) });
442
+ }
443
+ });
444
+
445
+ proc.stderr?.on("data", (data: Buffer) => {
446
+ const text = data.toString().trim();
447
+ if (text) {
448
+ onLog({ timestamp: Date.now(), level: "warn", message: text.slice(0, 500) });
449
+ }
450
+ });
451
+
452
+ // Timeout handling
453
+ const timeoutId = setTimeout(() => {
454
+ onLog({ timestamp: Date.now(), level: "error", message: \`Timeout after \${config.defaultTimeoutMinutes}min\` });
455
+ proc.kill("SIGTERM");
456
+ setTimeout(() => proc.kill("SIGKILL"), 5000);
457
+ }, timeoutMs);
458
+
459
+ const exitCode = new Promise<number>((resolve) => {
460
+ proc.on("close", (code) => {
461
+ clearTimeout(timeoutId);
462
+ resolve(code ?? 1);
463
+ });
464
+ proc.on("error", (err) => {
465
+ clearTimeout(timeoutId);
466
+ onLog({ timestamp: Date.now(), level: "error", message: \`Process error: \${err.message}\` });
467
+ resolve(1);
468
+ });
469
+ });
470
+
471
+ return { process: proc, exitCode };
472
+ }
473
+
474
+ function buildAgentArgs(run: RunContext): string[] {
475
+ switch (run.agent) {
476
+ case "claude":
477
+ return ["--print", "--dangerously-skip-permissions", "-p", run.prompt];
478
+ case "gemini":
479
+ return ["--prompt", run.prompt];
480
+ case "codex":
481
+ return ["--prompt", run.prompt, "--approval-mode", "full-auto"];
482
+ default:
483
+ return [run.prompt];
484
+ }
485
+ }
486
+ `;
487
+ const lines = writeIfNotDry(join(base, "agent-launcher.ts"), content, ctx.dryRun);
488
+ return { file: "scripts/agent-ops/agent-launcher.ts", lines };
489
+ }
490
+ // ── log-streamer.ts ──
491
+ function generateLogStreamer(ctx, base) {
492
+ const content = `// Agent-Ops log streamer — generated by @yattalo/task-system
493
+ //
494
+ // Batches log entries and uploads them to Convex periodically.
495
+
496
+ import { execSync } from "node:child_process";
497
+ import type { LogEntry } from "./types.js";
498
+
499
+ export class LogStreamer {
500
+ private buffer: LogEntry[] = [];
501
+ private intervalId: ReturnType<typeof setInterval> | null = null;
502
+ private readonly maxBatchSize = 20;
503
+ private readonly flushIntervalMs = 5000;
504
+
505
+ constructor(
506
+ private readonly runId: string,
507
+ private readonly projectRoot: string,
508
+ ) {}
509
+
510
+ start(): void {
511
+ this.intervalId = setInterval(() => this.flush(), this.flushIntervalMs);
512
+ }
513
+
514
+ stop(): void {
515
+ if (this.intervalId) {
516
+ clearInterval(this.intervalId);
517
+ this.intervalId = null;
518
+ }
519
+ this.flush(); // Final flush
520
+ }
521
+
522
+ push(entry: LogEntry): void {
523
+ this.buffer.push(entry);
524
+ if (this.buffer.length >= this.maxBatchSize) {
525
+ this.flush();
526
+ }
527
+ }
528
+
529
+ private flush(): void {
530
+ if (this.buffer.length === 0) return;
531
+
532
+ const entries = this.buffer.splice(0, this.maxBatchSize);
533
+
534
+ try {
535
+ execSync(
536
+ \`npx convex run taskSystem/agentOps:appendRunLog '\${JSON.stringify({
537
+ runId: this.runId,
538
+ entries,
539
+ })}'\`,
540
+ { cwd: this.projectRoot, stdio: "pipe", timeout: 10000 },
541
+ );
542
+ } catch (err) {
543
+ // Re-add failed entries for next attempt
544
+ this.buffer.unshift(...entries);
545
+ console.error(\`[log-streamer] Failed to flush logs: \${(err as Error).message}\`);
546
+ }
547
+ }
548
+ }
549
+ `;
550
+ const lines = writeIfNotDry(join(base, "log-streamer.ts"), content, ctx.dryRun);
551
+ return { file: "scripts/agent-ops/log-streamer.ts", lines };
552
+ }
553
+ // ── pr-creator.ts ──
554
+ function generatePrCreator(ctx, base) {
555
+ const content = `// Agent-Ops PR creator — generated by @yattalo/task-system
556
+ //
557
+ // Creates GitHub PRs from agent run branches using gh CLI.
558
+
559
+ import { execSync } from "node:child_process";
560
+ import type { RunContext } from "./types.js";
561
+
562
+ export interface PRResult {
563
+ url: string;
564
+ number: number;
565
+ }
566
+
567
+ export function createPR(run: RunContext, summary?: string): PRResult | null {
568
+ try {
569
+ // Check if there are commits to push
570
+ const commitCount = execSync(
571
+ \`git rev-list --count \${run.baseBranch}..HEAD\`,
572
+ { cwd: run.worktreePath, encoding: "utf-8" },
573
+ ).trim();
574
+
575
+ if (commitCount === "0") {
576
+ console.log("[pr-creator] No commits to create PR for");
577
+ return null;
578
+ }
579
+
580
+ // Push branch
581
+ execSync(\`git push -u origin \${run.branchName}\`, {
582
+ cwd: run.worktreePath,
583
+ stdio: "pipe",
584
+ });
585
+
586
+ // Create PR
587
+ const title = \`[Agent-Ops] \${run.agent}/\${run.jobId}: \${summary?.slice(0, 60) ?? "Automated changes"}\`;
588
+ const body = [
589
+ "## Agent-Ops Automated PR",
590
+ "",
591
+ \`- **Job**: \${run.jobId}\`,
592
+ \`- **Run**: \${run.runId}\`,
593
+ \`- **Agent**: \${run.agent}\`,
594
+ \`- **Commits**: \${commitCount}\`,
595
+ "",
596
+ summary ? \`## Summary\\n\${summary}\` : "",
597
+ "",
598
+ "---",
599
+ "Generated by @yattalo/task-system Agent-Ops",
600
+ ].join("\\n");
601
+
602
+ const prUrl = execSync(
603
+ \`gh pr create --title "\${title.replace(/"/g, '\\\\"')}" --body "\${body.replace(/"/g, '\\\\"')}" --base \${run.baseBranch}\`,
604
+ { cwd: run.worktreePath, encoding: "utf-8" },
605
+ ).trim();
606
+
607
+ // Extract PR number from URL
608
+ const match = prUrl.match(/\\/(\\d+)$/);
609
+ const prNumber = match ? parseInt(match[1], 10) : 0;
610
+
611
+ return { url: prUrl, number: prNumber };
612
+ } catch (err) {
613
+ console.error(\`[pr-creator] Failed: \${(err as Error).message}\`);
614
+ return null;
615
+ }
616
+ }
617
+ `;
618
+ const lines = writeIfNotDry(join(base, "pr-creator.ts"), content, ctx.dryRun);
619
+ return { file: "scripts/agent-ops/pr-creator.ts", lines };
620
+ }
621
+ // ── cleanup.ts ──
622
+ function generateCleanup(ctx, base) {
623
+ const content = `// Agent-Ops cleanup — generated by @yattalo/task-system
624
+ //
625
+ // Garbage collects stale worktrees older than 24h.
626
+
627
+ import { listStaleWorktrees, removeWorktree } from "./git-isolation.js";
628
+ import type { AgentOpsConfig } from "./types.js";
629
+
630
+ export function cleanupStaleWorktrees(config: AgentOpsConfig, maxAgeMs?: number): number {
631
+ const stale = listStaleWorktrees(config, maxAgeMs);
632
+
633
+ let cleaned = 0;
634
+ for (const runId of stale) {
635
+ try {
636
+ removeWorktree(config, runId);
637
+ console.log(\`[cleanup] Removed stale worktree: \${runId}\`);
638
+ cleaned++;
639
+ } catch (err) {
640
+ console.error(\`[cleanup] Failed to remove \${runId}: \${(err as Error).message}\`);
641
+ }
642
+ }
643
+
644
+ return cleaned;
645
+ }
646
+ `;
647
+ const lines = writeIfNotDry(join(base, "cleanup.ts"), content, ctx.dryRun);
648
+ return { file: "scripts/agent-ops/cleanup.ts", lines };
649
+ }
650
+ // ── runner.ts (main loop) ──
651
+ function generateRunner(ctx, base) {
652
+ const content = `#!/usr/bin/env npx tsx
653
+ // ============================================================
654
+ // Agent-Ops Runner — Main polling loop
655
+ // Generated by @yattalo/task-system for ${ctx.config.projectName}
656
+ // ============================================================
657
+ //
658
+ // Usage: npx tsx scripts/agent-ops/runner.ts
659
+ //
660
+ // This is the main Agent-Ops daemon. It:
661
+ // 1. Polls Convex for pending runs every N seconds
662
+ // 2. Evaluates scheduled jobs for due runs
663
+ // 3. Creates git worktrees for isolation
664
+ // 4. Launches agent CLI processes
665
+ // 5. Streams logs back to Convex
666
+ // 6. Cleans up on completion
667
+
668
+ import { execSync } from "node:child_process";
669
+ import { loadConfig } from "./config.js";
670
+ import { shouldRunNow, getNextRun } from "./scheduler.js";
671
+ import { acquireLane, releaseLane } from "./lane-queue.js";
672
+ import { createWorktree, removeWorktree, getWorktreeGitStats } from "./git-isolation.js";
673
+ import { launchAgent } from "./agent-launcher.js";
674
+ import { LogStreamer } from "./log-streamer.js";
675
+ import { createPR } from "./pr-creator.js";
676
+ import { cleanupStaleWorktrees } from "./cleanup.js";
677
+ import type { AgentOpsConfig, RunContext, LogEntry } from "./types.js";
678
+
679
+ const config = loadConfig();
680
+ let activeRuns = 0;
681
+ let totalRunsToday = 0;
682
+ let lastCleanup = 0;
683
+
684
+ function convexRun(fn: string, args?: Record<string, unknown>): string {
685
+ const argsStr = args ? \` '\${JSON.stringify(args)}'\` : "";
686
+ return execSync(\`npx convex run taskSystem/agentOps:\${fn}\${argsStr}\`, {
687
+ cwd: config.projectRoot,
688
+ encoding: "utf-8",
689
+ stdio: ["pipe", "pipe", "pipe"],
690
+ }).trim();
691
+ }
692
+
693
+ async function pollOnce(): Promise<void> {
694
+ // 1. Check for pending runs
695
+ try {
696
+ const pendingRaw = convexRun("getPendingRuns");
697
+ const pendingRuns = JSON.parse(pendingRaw);
698
+
699
+ for (const run of pendingRuns) {
700
+ if (activeRuns >= config.maxConcurrentRuns) break;
701
+ if (totalRunsToday >= config.maxRunsPerDay) {
702
+ console.log("[runner] Daily run limit reached");
703
+ break;
704
+ }
705
+
706
+ // Check lane availability
707
+ if (run.laneId && !acquireLane(run.laneId, run.runId)) {
708
+ console.log(\`[runner] Lane \${run.laneId} busy, skipping \${run.runId}\`);
709
+ continue;
710
+ }
711
+
712
+ executeRun(run).catch((err) => {
713
+ console.error(\`[runner] Run \${run.runId} failed: \${err.message}\`);
714
+ });
715
+ }
716
+ } catch (err) {
717
+ console.error(\`[runner] Poll error: \${(err as Error).message}\`);
718
+ }
719
+
720
+ // 2. Evaluate scheduled jobs
721
+ try {
722
+ const jobsRaw = convexRun("listJobs", { enabled: true });
723
+ const jobs = JSON.parse(jobsRaw);
724
+
725
+ for (const job of jobs) {
726
+ if (job.scheduleType === "cron" && job.cronExpression) {
727
+ if (shouldRunNow(job.cronExpression, job.lastRunAt)) {
728
+ console.log(\`[runner] Scheduling job \${job.jobId}\`);
729
+ convexRun("triggerManualRun", { jobId: job.jobId });
730
+ }
731
+ }
732
+ }
733
+ } catch (err) {
734
+ console.error(\`[runner] Schedule check error: \${(err as Error).message}\`);
735
+ }
736
+
737
+ // 3. Periodic cleanup (every hour)
738
+ if (Date.now() - lastCleanup > 3600000) {
739
+ cleanupStaleWorktrees(config);
740
+ lastCleanup = Date.now();
741
+ }
742
+ }
743
+
744
+ async function executeRun(run: any): Promise<void> {
745
+ activeRuns++;
746
+ totalRunsToday++;
747
+ const logStreamer = new LogStreamer(run.runId, config.projectRoot);
748
+
749
+ try {
750
+ // Update status to provisioning
751
+ convexRun("updateRunStatus", { runId: run.runId, status: "provisioning" });
752
+
753
+ // Create worktree
754
+ const baseBranch = run.baseBranch ?? "main";
755
+ const worktree = createWorktree(config, run.runId, baseBranch);
756
+ convexRun("updateRunGitInfo", {
757
+ runId: run.runId,
758
+ worktreePath: worktree.path,
759
+ branchName: worktree.branch,
760
+ });
761
+
762
+ // Update status to running
763
+ convexRun("updateRunStatus", { runId: run.runId, status: "running" });
764
+
765
+ // Start log streaming
766
+ logStreamer.start();
767
+
768
+ const runCtx: RunContext = {
769
+ runId: run.runId,
770
+ jobId: run.jobId,
771
+ agent: run.agent,
772
+ prompt: run.prompt,
773
+ executionMode: run.executionMode,
774
+ worktreePath: worktree.path,
775
+ branchName: worktree.branch,
776
+ baseBranch,
777
+ startedAt: Date.now(),
778
+ };
779
+
780
+ // Launch agent
781
+ const { exitCode } = launchAgent(config, runCtx, (entry) => logStreamer.push(entry));
782
+ const code = await exitCode;
783
+
784
+ // Collect git stats
785
+ const stats = getWorktreeGitStats(worktree.path, baseBranch);
786
+ convexRun("updateRunGitInfo", { runId: run.runId, ...stats });
787
+
788
+ // Create PR if configured
789
+ let prUrl: string | undefined;
790
+ let prNumber: number | undefined;
791
+
792
+ // Get job config for PR creation
793
+ try {
794
+ const jobRaw = convexRun("getJob", { jobId: run.jobId });
795
+ const job = JSON.parse(jobRaw);
796
+ if (job.createPR && stats.commitCount > 0) {
797
+ const pr = createPR(runCtx, \`Job \${run.jobId} completed\`);
798
+ if (pr) {
799
+ prUrl = pr.url;
800
+ prNumber = pr.number;
801
+ }
802
+ }
803
+ } catch {}
804
+
805
+ // Complete run
806
+ const status = code === 0 ? "success" : "failed";
807
+ convexRun("completeRun", {
808
+ runId: run.runId,
809
+ status,
810
+ exitCode: code,
811
+ summary: \`Agent \${run.agent} exited with code \${code}. \${stats.commitCount} commits, \${stats.filesChanged} files changed.\`,
812
+ ...(prUrl ? { prUrl, prNumber } : {}),
813
+ });
814
+
815
+ // Cleanup worktree (keep if PR was created)
816
+ if (!prUrl) {
817
+ removeWorktree(config, run.runId);
818
+ }
819
+ } catch (err) {
820
+ convexRun("completeRun", {
821
+ runId: run.runId,
822
+ status: "failed",
823
+ error: (err as Error).message,
824
+ });
825
+ } finally {
826
+ logStreamer.stop();
827
+ activeRuns--;
828
+ if (run.laneId) releaseLane(run.laneId, run.runId);
829
+ }
830
+ }
831
+
832
+ // Main loop
833
+ console.log("[agent-ops] Starting runner...");
834
+ console.log(\`[agent-ops] Poll interval: \${config.pollInterval}ms\`);
835
+ console.log(\`[agent-ops] Max concurrent: \${config.maxConcurrentRuns}\`);
836
+
837
+ setInterval(pollOnce, config.pollInterval);
838
+ pollOnce(); // Run immediately
839
+ `;
840
+ const lines = writeIfNotDry(join(base, "runner.ts"), content, ctx.dryRun);
841
+ return { file: "scripts/agent-ops/runner.ts", lines };
842
+ }
843
+ //# sourceMappingURL=agent-ops-scripts.js.map