@yail259/overnight 0.3.0 → 1.0.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/cli.ts DELETED
@@ -1,821 +0,0 @@
1
- #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import { readFileSync, writeFileSync, existsSync } from "fs";
4
- import { parse as parseYaml } from "yaml";
5
- import {
6
- type JobConfig,
7
- type JobResult,
8
- type TasksFile,
9
- type GoalConfig,
10
- type SecurityConfig,
11
- DEFAULT_TOOLS,
12
- DEFAULT_TIMEOUT,
13
- DEFAULT_STALL_TIMEOUT,
14
- DEFAULT_VERIFY_PROMPT,
15
- DEFAULT_STATE_FILE,
16
- DEFAULT_GOAL_STATE_FILE,
17
- DEFAULT_NTFY_TOPIC,
18
- DEFAULT_MAX_TURNS,
19
- DEFAULT_MAX_ITERATIONS,
20
- DEFAULT_DENY_PATTERNS,
21
- } from "./types.js";
22
- import { validateSecurityConfig } from "./security.js";
23
- import {
24
- runJob,
25
- runJobsWithState,
26
- loadState,
27
- resultsToJson,
28
- taskKey,
29
- } from "./runner.js";
30
- import { sendNtfyNotification } from "./notify.js";
31
- import { generateReport } from "./report.js";
32
- import { runGoal, parseGoalFile } from "./goal-runner.js";
33
- import { runPlanner } from "./planner.js";
34
-
35
- const AGENT_HELP = `
36
- # overnight - Autonomous Build Runner for Claude Code
37
-
38
- Two modes: goal-driven autonomous loops, or task-list batch jobs.
39
-
40
- ## Quick Start
41
-
42
- \`\`\`bash
43
- # Hammer mode: just give it a goal and go
44
- overnight hammer "Build a multiplayer MMO"
45
-
46
- # Or: design session first, then autonomous build
47
- overnight plan "Build a multiplayer game" # Interactive design → goal.yaml
48
- overnight run goal.yaml --notify # Autonomous build loop
49
-
50
- # Task mode: explicit task list
51
- overnight run tasks.yaml --notify
52
- \`\`\`
53
-
54
- ## Commands
55
-
56
- | Command | Description |
57
- |---------|-------------|
58
- | \`overnight hammer "<goal>"\` | Autonomous build loop from a string |
59
- | \`overnight plan "<goal>"\` | Interactive design session → goal.yaml |
60
- | \`overnight run <file>\` | Run goal.yaml (loop) or tasks.yaml (batch) |
61
- | \`overnight resume <file>\` | Resume interrupted run from checkpoint |
62
- | \`overnight single "<prompt>"\` | Run a single task directly |
63
- | \`overnight init\` | Create example goal.yaml or tasks.yaml |
64
-
65
- ## Goal Mode (goal.yaml)
66
-
67
- Autonomous convergence loop: agent iterates toward a goal, then a separate
68
- gate agent verifies everything before declaring done.
69
-
70
- \`\`\`yaml
71
- goal: "Build a clone of Flappy Bird with leaderboard"
72
-
73
- acceptance_criteria:
74
- - "Game renders and is playable in browser"
75
- - "Leaderboard persists scores to localStorage"
76
-
77
- verification_commands:
78
- - "npm run build"
79
- - "npm test"
80
-
81
- constraints:
82
- - "Use vanilla JS, no frameworks"
83
-
84
- max_iterations: 15
85
- \`\`\`
86
-
87
- ## Task Mode (tasks.yaml)
88
-
89
- Explicit task list with optional dependency DAG.
90
-
91
- \`\`\`yaml
92
- defaults:
93
- timeout_seconds: 300
94
- verify: true
95
- allowed_tools: [Read, Edit, Write, Glob, Grep]
96
-
97
- tasks:
98
- - "Fix the bug in auth.py"
99
- - prompt: "Add input validation"
100
- timeout_seconds: 600
101
- \`\`\`
102
-
103
- ## Key Options
104
-
105
- | Option | Description |
106
- |--------|-------------|
107
- | \`-o, --output <file>\` | Save results JSON |
108
- | \`-r, --report <file>\` | Generate markdown report |
109
- | \`-s, --state-file <file>\` | Custom checkpoint file |
110
- | \`--max-iterations <n>\` | Max build loop iterations (goal mode) |
111
- | \`--notify\` | Send push notification via ntfy.sh |
112
- | \`-q, --quiet\` | Minimal output |
113
-
114
- ## Example Workflows
115
-
116
- \`\`\`bash
117
- # Simplest: just hammer a goal overnight
118
- nohup overnight hammer "Build a REST API with auth and tests" --notify > overnight.log 2>&1 &
119
-
120
- # Design first, then run
121
- overnight plan "Build a REST API with auth"
122
- nohup overnight run goal.yaml --notify > overnight.log 2>&1 &
123
-
124
- # Batch tasks overnight
125
- nohup overnight run tasks.yaml --notify -r report.md > overnight.log 2>&1 &
126
-
127
- # Resume after crash
128
- overnight resume goal.yaml
129
- \`\`\`
130
-
131
- ## Exit Codes
132
-
133
- - 0: All tasks succeeded / gate passed
134
- - 1: Failures occurred / gate failed
135
-
136
- ## Files Created
137
-
138
- - \`.overnight-goal-state.json\` - Goal mode checkpoint
139
- - \`.overnight-iterations/\` - Per-iteration state + summaries
140
- - \`.overnight-state.json\` - Task mode checkpoint
141
- - \`report.md\` - Summary report (if -r used)
142
-
143
- Run \`overnight <command> --help\` for command-specific options.
144
- `;
145
-
146
- // --- File type detection ---
147
-
148
- function isGoalFile(path: string): boolean {
149
- try {
150
- const content = readFileSync(path, "utf-8");
151
- const data = parseYaml(content) as Record<string, unknown>;
152
- return typeof data?.goal === "string";
153
- } catch {
154
- return false;
155
- }
156
- }
157
-
158
- interface ParsedConfig {
159
- configs: JobConfig[];
160
- security?: SecurityConfig;
161
- }
162
-
163
- function parseTasksFile(path: string, cliSecurity?: Partial<SecurityConfig>): ParsedConfig {
164
- const content = readFileSync(path, "utf-8");
165
- let data: TasksFile | (string | JobConfig)[];
166
- try {
167
- data = parseYaml(content) as TasksFile | (string | JobConfig)[];
168
- } catch (e) {
169
- const error = e as Error;
170
- console.error(`\x1b[31mError parsing ${path}:\x1b[0m`);
171
- console.error(` ${error.message.split('\n')[0]}`);
172
- process.exit(1);
173
- }
174
-
175
- const tasks = Array.isArray(data) ? data : data.tasks ?? [];
176
- const defaults = Array.isArray(data) ? {} : data.defaults ?? {};
177
-
178
- // Merge CLI security options with file security options (CLI takes precedence)
179
- const fileSecurity = (!Array.isArray(data) && data.defaults?.security) || {};
180
- const security: SecurityConfig | undefined = (cliSecurity || Object.keys(fileSecurity).length > 0)
181
- ? {
182
- ...fileSecurity,
183
- ...cliSecurity,
184
- // Use default deny patterns if none specified
185
- deny_patterns: cliSecurity?.deny_patterns ?? fileSecurity.deny_patterns ?? DEFAULT_DENY_PATTERNS,
186
- }
187
- : undefined;
188
-
189
- const configs = tasks.map((task) => {
190
- if (typeof task === "string") {
191
- return {
192
- prompt: task,
193
- timeout_seconds: defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
194
- stall_timeout_seconds:
195
- defaults.stall_timeout_seconds ?? DEFAULT_STALL_TIMEOUT,
196
- verify: defaults.verify ?? true,
197
- verify_prompt: defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
198
- allowed_tools: defaults.allowed_tools,
199
- security,
200
- };
201
- }
202
- return {
203
- id: task.id ?? undefined,
204
- depends_on: task.depends_on ?? undefined,
205
- prompt: task.prompt,
206
- working_dir: task.working_dir ?? undefined,
207
- timeout_seconds:
208
- task.timeout_seconds ?? defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
209
- stall_timeout_seconds:
210
- task.stall_timeout_seconds ??
211
- defaults.stall_timeout_seconds ??
212
- DEFAULT_STALL_TIMEOUT,
213
- verify: task.verify ?? defaults.verify ?? true,
214
- verify_prompt:
215
- task.verify_prompt ?? defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
216
- allowed_tools: task.allowed_tools ?? defaults.allowed_tools,
217
- security: task.security ?? security,
218
- };
219
- });
220
-
221
- return { configs, security };
222
- }
223
-
224
- function printSummary(results: JobResult[]): void {
225
- const statusColors: Record<string, string> = {
226
- success: "\x1b[32m",
227
- failed: "\x1b[31m",
228
- timeout: "\x1b[33m",
229
- stalled: "\x1b[35m",
230
- verification_failed: "\x1b[33m",
231
- };
232
- const reset = "\x1b[0m";
233
- const bold = "\x1b[1m";
234
-
235
- console.log(`\n${bold}Job Results${reset}`);
236
- console.log("─".repeat(70));
237
-
238
- results.forEach((r, i) => {
239
- const color = statusColors[r.status] ?? "";
240
- const task = r.task.length > 40 ? r.task.slice(0, 40) + "..." : r.task;
241
- const verified = r.verified ? "✓" : "✗";
242
- console.log(
243
- `${i + 1}. ${color}${r.status.padEnd(12)}${reset} ${r.duration_seconds.toFixed(1).padStart(6)}s ${verified} ${task}`
244
- );
245
- });
246
-
247
- const succeeded = results.filter((r) => r.status === "success").length;
248
- console.log(
249
- `\n${bold}Summary:${reset} ${succeeded}/${results.length} succeeded`
250
- );
251
- }
252
-
253
- const program = new Command();
254
-
255
- program
256
- .name("overnight")
257
- .description("Batch job runner for Claude Code")
258
- .version("0.3.0")
259
- .action(() => {
260
- console.log(AGENT_HELP);
261
- });
262
-
263
- program
264
- .command("run")
265
- .description("Run goal.yaml (autonomous loop) or tasks.yaml (batch jobs)")
266
- .argument("<file>", "Path to goal.yaml or tasks.yaml")
267
- .option("-o, --output <file>", "Output file for results JSON")
268
- .option("-q, --quiet", "Minimal output")
269
- .option("-s, --state-file <file>", "Custom state file path")
270
- .option("--notify", "Send push notification via ntfy.sh")
271
- .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
272
- .option("-r, --report <file>", "Generate markdown report")
273
- .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
274
- .option("--max-turns <n>", "Max agent iterations per task", String(DEFAULT_MAX_TURNS))
275
- .option("--max-iterations <n>", "Max build loop iterations (goal mode)", String(DEFAULT_MAX_ITERATIONS))
276
- .option("--audit-log <file>", "Audit log file path")
277
- .option("--no-security", "Disable default security (deny patterns)")
278
- .action(async (inputFile, opts) => {
279
- if (!existsSync(inputFile)) {
280
- console.error(`Error: File not found: ${inputFile}`);
281
- process.exit(1);
282
- }
283
-
284
- // Detect file type and dispatch
285
- if (isGoalFile(inputFile)) {
286
- // --- Goal mode ---
287
- const goal = parseGoalFile(inputFile);
288
-
289
- // Apply CLI overrides
290
- if (opts.maxIterations) {
291
- goal.max_iterations = parseInt(opts.maxIterations, 10);
292
- }
293
- if (opts.sandbox) {
294
- goal.defaults = goal.defaults ?? {};
295
- goal.defaults.security = goal.defaults.security ?? {};
296
- goal.defaults.security.sandbox_dir = opts.sandbox;
297
- }
298
- if (opts.maxTurns) {
299
- goal.defaults = goal.defaults ?? {};
300
- goal.defaults.security = goal.defaults.security ?? {};
301
- goal.defaults.security.max_turns = parseInt(opts.maxTurns, 10);
302
- }
303
-
304
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
305
- const startTime = Date.now();
306
-
307
- const runState = await runGoal(goal, {
308
- stateFile: opts.stateFile ?? DEFAULT_GOAL_STATE_FILE,
309
- log,
310
- });
311
-
312
- const totalDuration = (Date.now() - startTime) / 1000;
313
-
314
- if (opts.notify) {
315
- const passed = runState.status === "gate_passed";
316
- const title = passed
317
- ? `overnight: Goal completed (${runState.iterations.length} iterations)`
318
- : `overnight: ${runState.status} after ${runState.iterations.length} iterations`;
319
- const message = passed
320
- ? `Gate passed. ${runState.iterations.length} iterations.`
321
- : `Status: ${runState.status}. Check report for details.`;
322
-
323
- try {
324
- await fetch(`https://ntfy.sh/${opts.notifyTopic ?? DEFAULT_NTFY_TOPIC}`, {
325
- method: "POST",
326
- headers: {
327
- Title: title,
328
- Priority: passed ? "default" : "high",
329
- Tags: passed ? "white_check_mark" : "warning",
330
- },
331
- body: message,
332
- });
333
- if (!opts.quiet) console.log(`\x1b[2mNotification sent\x1b[0m`);
334
- } catch {
335
- if (!opts.quiet) console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
336
- }
337
- }
338
-
339
- // Print summary
340
- if (!opts.quiet) {
341
- console.log(`\n\x1b[1m━━━ Goal Run Summary ━━━\x1b[0m`);
342
- console.log(`Status: ${runState.status === "gate_passed" ? "\x1b[32m" : "\x1b[31m"}${runState.status}\x1b[0m`);
343
- console.log(`Iterations: ${runState.iterations.length}`);
344
- console.log(`Gate attempts: ${runState.gate_results.length}`);
345
-
346
- // Duration formatting
347
- let durationStr: string;
348
- if (totalDuration >= 3600) {
349
- const hours = Math.floor(totalDuration / 3600);
350
- const mins = Math.floor((totalDuration % 3600) / 60);
351
- durationStr = `${hours}h ${mins}m`;
352
- } else if (totalDuration >= 60) {
353
- const mins = Math.floor(totalDuration / 60);
354
- const secs = Math.floor(totalDuration % 60);
355
- durationStr = `${mins}m ${secs}s`;
356
- } else {
357
- durationStr = `${totalDuration.toFixed(1)}s`;
358
- }
359
- console.log(`Duration: ${durationStr}`);
360
-
361
- if (runState.gate_results.length > 0) {
362
- const lastGate = runState.gate_results[runState.gate_results.length - 1];
363
- console.log(`\nGate: ${lastGate.summary}`);
364
- for (const check of lastGate.checks) {
365
- const icon = check.passed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
366
- console.log(` ${icon} ${check.name}`);
367
- }
368
- }
369
- }
370
-
371
- if (runState.status !== "gate_passed") {
372
- process.exit(1);
373
- }
374
- } else {
375
- // --- Task mode (legacy) ---
376
- const cliSecurity: Partial<SecurityConfig> | undefined = opts.security === false
377
- ? undefined
378
- : {
379
- ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
380
- ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
381
- ...(opts.auditLog && { audit_log: opts.auditLog }),
382
- };
383
-
384
- const { configs, security } = parseTasksFile(inputFile, cliSecurity);
385
- if (configs.length === 0) {
386
- console.error("No tasks found in file");
387
- process.exit(1);
388
- }
389
-
390
- const existingState = loadState(opts.stateFile ?? DEFAULT_STATE_FILE);
391
- if (existingState) {
392
- const done = Object.keys(existingState.completed).length;
393
- const pending = configs.filter(c => !(taskKey(c) in existingState.completed)).length;
394
- console.log(`\x1b[1movernight: Resuming — ${done} done, ${pending} remaining\x1b[0m`);
395
- console.log(`\x1b[2mLast checkpoint: ${existingState.timestamp}\x1b[0m`);
396
- } else {
397
- console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
398
- }
399
-
400
- if (security && !opts.quiet) {
401
- console.log("\x1b[2mSecurity:\x1b[0m");
402
- validateSecurityConfig(security);
403
- }
404
- console.log("");
405
-
406
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
407
- const startTime = Date.now();
408
- const reloadConfigs = () => parseTasksFile(inputFile, cliSecurity).configs;
409
-
410
- const results = await runJobsWithState(configs, {
411
- stateFile: opts.stateFile,
412
- log,
413
- reloadConfigs,
414
- });
415
-
416
- const totalDuration = (Date.now() - startTime) / 1000;
417
-
418
- if (opts.notify) {
419
- const success = await sendNtfyNotification(results, totalDuration, opts.notifyTopic);
420
- if (success) {
421
- console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
422
- } else {
423
- console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
424
- }
425
- }
426
-
427
- if (opts.report) {
428
- generateReport(results, totalDuration, opts.report);
429
- console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
430
- }
431
-
432
- if (!opts.quiet) {
433
- printSummary(results);
434
- }
435
-
436
- if (opts.output) {
437
- writeFileSync(opts.output, resultsToJson(results));
438
- console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
439
- }
440
-
441
- if (results.some((r) => r.status !== "success")) {
442
- process.exit(1);
443
- }
444
- }
445
- });
446
-
447
- program
448
- .command("resume")
449
- .description("Resume a previous run from saved state")
450
- .argument("<tasks-file>", "Path to tasks.yaml file")
451
- .option("-o, --output <file>", "Output file for results JSON")
452
- .option("-q, --quiet", "Minimal output")
453
- .option("-s, --state-file <file>", "Custom state file path")
454
- .option("--notify", "Send push notification via ntfy.sh")
455
- .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
456
- .option("-r, --report <file>", "Generate markdown report")
457
- .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
458
- .option("--max-turns <n>", "Max agent iterations per task", String(DEFAULT_MAX_TURNS))
459
- .option("--audit-log <file>", "Audit log file path")
460
- .option("--no-security", "Disable default security (deny patterns)")
461
- .action(async (tasksFile, opts) => {
462
- const stateFile = opts.stateFile ?? DEFAULT_STATE_FILE;
463
- const state = loadState(stateFile);
464
-
465
- if (!state) {
466
- console.error(`No state file found at ${stateFile}`);
467
- console.error("Run 'overnight run' first to start jobs.");
468
- process.exit(1);
469
- }
470
-
471
- if (!existsSync(tasksFile)) {
472
- console.error(`Error: File not found: ${tasksFile}`);
473
- process.exit(1);
474
- }
475
-
476
- // Build CLI security config
477
- const cliSecurity: Partial<SecurityConfig> | undefined = opts.security === false
478
- ? undefined
479
- : {
480
- ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
481
- ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
482
- ...(opts.auditLog && { audit_log: opts.auditLog }),
483
- };
484
-
485
- const { configs, security } = parseTasksFile(tasksFile, cliSecurity);
486
- if (configs.length === 0) {
487
- console.error("No tasks found in file");
488
- process.exit(1);
489
- }
490
-
491
- const completedCount = Object.keys(state.completed).length;
492
- const pendingCount = configs.filter(c => !(taskKey(c) in state.completed)).length;
493
- console.log(
494
- `\x1b[1movernight: Resuming — ${completedCount} done, ${pendingCount} remaining\x1b[0m`
495
- );
496
- console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m`);
497
-
498
- // Show security config if enabled
499
- if (security && !opts.quiet) {
500
- console.log("\x1b[2mSecurity:\x1b[0m");
501
- validateSecurityConfig(security);
502
- }
503
- console.log("");
504
-
505
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
506
- const startTime = Date.now();
507
- const reloadConfigs = () => parseTasksFile(tasksFile, cliSecurity).configs;
508
-
509
- const results = await runJobsWithState(configs, {
510
- stateFile,
511
- log,
512
- reloadConfigs,
513
- });
514
-
515
- const totalDuration = (Date.now() - startTime) / 1000;
516
-
517
- if (opts.notify) {
518
- const success = await sendNtfyNotification(
519
- results,
520
- totalDuration,
521
- opts.notifyTopic
522
- );
523
- if (success) {
524
- console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
525
- } else {
526
- console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
527
- }
528
- }
529
-
530
- if (opts.report) {
531
- generateReport(results, totalDuration, opts.report);
532
- console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
533
- }
534
-
535
- if (!opts.quiet) {
536
- printSummary(results);
537
- }
538
-
539
- if (opts.output) {
540
- writeFileSync(opts.output, resultsToJson(results));
541
- console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
542
- }
543
-
544
- if (results.some((r) => r.status !== "success")) {
545
- process.exit(1);
546
- }
547
- });
548
-
549
- program
550
- .command("single")
551
- .description("Run a single job directly")
552
- .argument("<prompt>", "The task prompt")
553
- .option("-t, --timeout <seconds>", "Timeout in seconds", "300")
554
- .option("--verify", "Run verification pass", true)
555
- .option("--no-verify", "Skip verification pass")
556
- .option("-T, --tools <tool...>", "Allowed tools")
557
- .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
558
- .option("--max-turns <n>", "Max agent iterations", String(DEFAULT_MAX_TURNS))
559
- .option("--no-security", "Disable default security (deny patterns)")
560
- .action(async (prompt, opts) => {
561
- // Build security config
562
- const security: SecurityConfig | undefined = opts.security === false
563
- ? undefined
564
- : {
565
- ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
566
- ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
567
- deny_patterns: DEFAULT_DENY_PATTERNS,
568
- };
569
-
570
- const config: JobConfig = {
571
- prompt,
572
- timeout_seconds: parseInt(opts.timeout, 10),
573
- verify: opts.verify,
574
- allowed_tools: opts.tools,
575
- security,
576
- };
577
-
578
- const log = (msg: string) => console.log(msg);
579
- const result = await runJob(config, log);
580
-
581
- if (result.status === "success") {
582
- console.log("\n\x1b[32mSuccess\x1b[0m");
583
- if (result.result) {
584
- console.log(result.result);
585
- }
586
- } else {
587
- console.log(`\n\x1b[31m${result.status}\x1b[0m`);
588
- if (result.error) {
589
- console.log(`\x1b[31m${result.error}\x1b[0m`);
590
- }
591
- process.exit(1);
592
- }
593
- });
594
-
595
- program
596
- .command("hammer")
597
- .description("Autonomous build loop from an inline goal string")
598
- .argument("<goal>", "The goal to work toward")
599
- .option("--max-iterations <n>", "Max build loop iterations", String(DEFAULT_MAX_ITERATIONS))
600
- .option("--max-turns <n>", "Max agent turns per iteration", String(DEFAULT_MAX_TURNS))
601
- .option("-t, --timeout <seconds>", "Timeout per iteration in seconds", "600")
602
- .option("-T, --tools <tool...>", "Allowed tools")
603
- .option("--sandbox <dir>", "Sandbox directory")
604
- .option("-s, --state-file <file>", "Custom state file path")
605
- .option("--notify", "Send push notification via ntfy.sh")
606
- .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
607
- .option("-q, --quiet", "Minimal output")
608
- .option("--no-security", "Disable default security")
609
- .action(async (goalStr, opts) => {
610
- const goal: GoalConfig = {
611
- goal: goalStr,
612
- max_iterations: parseInt(opts.maxIterations, 10),
613
- defaults: {
614
- timeout_seconds: parseInt(opts.timeout, 10),
615
- allowed_tools: opts.tools ?? [...DEFAULT_TOOLS, "Bash"],
616
- security: opts.security === false
617
- ? undefined
618
- : {
619
- ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
620
- max_turns: parseInt(opts.maxTurns, 10),
621
- deny_patterns: DEFAULT_DENY_PATTERNS,
622
- },
623
- },
624
- };
625
-
626
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
627
- const startTime = Date.now();
628
-
629
- const runState = await runGoal(goal, {
630
- stateFile: opts.stateFile ?? DEFAULT_GOAL_STATE_FILE,
631
- log,
632
- });
633
-
634
- const totalDuration = (Date.now() - startTime) / 1000;
635
-
636
- if (opts.notify) {
637
- const passed = runState.status === "gate_passed";
638
- try {
639
- await fetch(`https://ntfy.sh/${opts.notifyTopic ?? DEFAULT_NTFY_TOPIC}`, {
640
- method: "POST",
641
- headers: {
642
- Title: passed
643
- ? `overnight: Goal completed (${runState.iterations.length} iterations)`
644
- : `overnight: ${runState.status} after ${runState.iterations.length} iterations`,
645
- Priority: passed ? "default" : "high",
646
- Tags: passed ? "white_check_mark" : "warning",
647
- },
648
- body: passed
649
- ? `Gate passed. ${runState.iterations.length} iterations.`
650
- : `Status: ${runState.status}. Check report for details.`,
651
- });
652
- if (!opts.quiet) console.log(`\x1b[2mNotification sent\x1b[0m`);
653
- } catch {
654
- if (!opts.quiet) console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
655
- }
656
- }
657
-
658
- if (!opts.quiet) {
659
- console.log(`\n\x1b[1m━━━ Hammer Summary ━━━\x1b[0m`);
660
- console.log(`Status: ${runState.status === "gate_passed" ? "\x1b[32m" : "\x1b[31m"}${runState.status}\x1b[0m`);
661
- console.log(`Iterations: ${runState.iterations.length}`);
662
- console.log(`Gate attempts: ${runState.gate_results.length}`);
663
-
664
- let durationStr: string;
665
- if (totalDuration >= 3600) {
666
- const hours = Math.floor(totalDuration / 3600);
667
- const mins = Math.floor((totalDuration % 3600) / 60);
668
- durationStr = `${hours}h ${mins}m`;
669
- } else if (totalDuration >= 60) {
670
- const mins = Math.floor(totalDuration / 60);
671
- const secs = Math.floor(totalDuration % 60);
672
- durationStr = `${mins}m ${secs}s`;
673
- } else {
674
- durationStr = `${totalDuration.toFixed(1)}s`;
675
- }
676
- console.log(`Duration: ${durationStr}`);
677
-
678
- if (runState.gate_results.length > 0) {
679
- const lastGate = runState.gate_results[runState.gate_results.length - 1];
680
- console.log(`\nGate: ${lastGate.summary}`);
681
- for (const check of lastGate.checks) {
682
- const icon = check.passed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
683
- console.log(` ${icon} ${check.name}`);
684
- }
685
- }
686
- }
687
-
688
- if (runState.status !== "gate_passed") {
689
- process.exit(1);
690
- }
691
- });
692
-
693
- program
694
- .command("plan")
695
- .description("Interactive design session to create a goal.yaml")
696
- .argument("<goal>", "High-level goal description")
697
- .option("-o, --output <file>", "Output file path", "goal.yaml")
698
- .action(async (goal, opts) => {
699
- const result = await runPlanner(goal, {
700
- outputFile: opts.output,
701
- log: (msg: string) => console.log(msg),
702
- });
703
-
704
- if (!result) {
705
- process.exit(1);
706
- }
707
- });
708
-
709
- program
710
- .command("init")
711
- .description("Create an example goal.yaml or tasks.yaml")
712
- .option("--tasks", "Create tasks.yaml instead of goal.yaml")
713
- .action((opts) => {
714
- if (opts.tasks) {
715
- // Legacy tasks.yaml template
716
- const example = `# overnight task file
717
- # Run with: overnight run tasks.yaml
718
-
719
- defaults:
720
- timeout_seconds: 300 # 5 minutes per task
721
- verify: true # Run verification after each task
722
-
723
- # Secure defaults - no Bash, just file operations
724
- allowed_tools:
725
- - Read
726
- - Edit
727
- - Write
728
- - Glob
729
- - Grep
730
-
731
- # Security settings (optional - deny_patterns enabled by default)
732
- security:
733
- sandbox_dir: "." # Restrict to current directory
734
- max_turns: 100 # Prevent runaway agents
735
- # audit_log: "overnight-audit.log" # Uncomment to enable
736
-
737
- tasks:
738
- # Simple string format
739
- - "Find and fix any TODO comments in the codebase"
740
-
741
- # Dict format with overrides
742
- - prompt: "Add input validation to all form handlers"
743
- timeout_seconds: 600 # Allow more time
744
-
745
- - prompt: "Review code for security issues"
746
- verify: false # Don't need to verify a review
747
-
748
- # Can add Bash for specific tasks that need it
749
- - prompt: "Run the test suite and fix any failures"
750
- allowed_tools:
751
- - Read
752
- - Edit
753
- - Bash
754
- - Glob
755
- - Grep
756
- `;
757
-
758
- if (existsSync("tasks.yaml")) {
759
- console.log("\x1b[33mtasks.yaml already exists\x1b[0m");
760
- process.exit(1);
761
- }
762
-
763
- writeFileSync("tasks.yaml", example);
764
- console.log("\x1b[32mCreated tasks.yaml\x1b[0m");
765
- console.log("Edit the file, then run: \x1b[1movernight run tasks.yaml\x1b[0m");
766
- } else {
767
- // Goal mode template (new default)
768
- const example = `# overnight goal file
769
- # Run with: overnight run goal.yaml
770
- #
771
- # Or use "overnight plan" for an interactive design session:
772
- # overnight plan "Build a multiplayer game"
773
-
774
- goal: "Describe your project goal here"
775
-
776
- acceptance_criteria:
777
- - "The project builds without errors"
778
- - "All tests pass"
779
- - "Core features are functional"
780
-
781
- verification_commands:
782
- - "npm run build"
783
- - "npm test"
784
-
785
- constraints:
786
- - "Don't modify existing API contracts"
787
- - "Keep dependencies minimal"
788
-
789
- # How many build iterations before stopping
790
- max_iterations: 15
791
-
792
- # Stop if remaining items don't shrink for this many iterations
793
- convergence_threshold: 3
794
-
795
- defaults:
796
- timeout_seconds: 600 # 10 minutes per iteration
797
- allowed_tools:
798
- - Read
799
- - Edit
800
- - Write
801
- - Glob
802
- - Grep
803
- - Bash
804
- security:
805
- sandbox_dir: "."
806
- max_turns: 150
807
- `;
808
-
809
- if (existsSync("goal.yaml")) {
810
- console.log("\x1b[33mgoal.yaml already exists\x1b[0m");
811
- process.exit(1);
812
- }
813
-
814
- writeFileSync("goal.yaml", example);
815
- console.log("\x1b[32mCreated goal.yaml\x1b[0m");
816
- console.log("Edit the file, then run: \x1b[1movernight run goal.yaml\x1b[0m");
817
- console.log("\x1b[2mTip: Use 'overnight plan \"your goal\"' for an interactive design session\x1b[0m");
818
- }
819
- });
820
-
821
- program.parse();