@yail259/overnight 0.2.0 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yail259/overnight",
3
- "version": "0.2.0",
4
- "description": "Batch job runner for Claude Code - queue tasks, run overnight, wake up to results",
3
+ "version": "0.3.0",
4
+ "description": "Autonomous build runner for Claude Code - goal-driven loops with verification gates",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "overnight": "./dist/cli.js"
package/src/cli.ts CHANGED
@@ -6,13 +6,17 @@ import {
6
6
  type JobConfig,
7
7
  type JobResult,
8
8
  type TasksFile,
9
+ type GoalConfig,
9
10
  type SecurityConfig,
11
+ DEFAULT_TOOLS,
10
12
  DEFAULT_TIMEOUT,
11
13
  DEFAULT_STALL_TIMEOUT,
12
14
  DEFAULT_VERIFY_PROMPT,
13
15
  DEFAULT_STATE_FILE,
16
+ DEFAULT_GOAL_STATE_FILE,
14
17
  DEFAULT_NTFY_TOPIC,
15
18
  DEFAULT_MAX_TURNS,
19
+ DEFAULT_MAX_ITERATIONS,
16
20
  DEFAULT_DENY_PATTERNS,
17
21
  } from "./types.js";
18
22
  import { validateSecurityConfig } from "./security.js";
@@ -25,55 +29,75 @@ import {
25
29
  } from "./runner.js";
26
30
  import { sendNtfyNotification } from "./notify.js";
27
31
  import { generateReport } from "./report.js";
32
+ import { runGoal, parseGoalFile } from "./goal-runner.js";
33
+ import { runPlanner } from "./planner.js";
28
34
 
29
35
  const AGENT_HELP = `
30
- # overnight - Batch Job Runner for Claude Code
36
+ # overnight - Autonomous Build Runner for Claude Code
31
37
 
32
- Queue tasks, run them unattended, get results. Designed for overnight/AFK use.
38
+ Two modes: goal-driven autonomous loops, or task-list batch jobs.
33
39
 
34
40
  ## Quick Start
35
41
 
36
42
  \`\`\`bash
37
- # Create a tasks.yaml file
38
- overnight init
43
+ # Hammer mode: just give it a goal and go
44
+ overnight hammer "Build a multiplayer MMO"
39
45
 
40
- # Run all tasks
41
- overnight run tasks.yaml
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
42
49
 
43
- # Run with notifications and report
44
- overnight run tasks.yaml --notify -r report.md
50
+ # Task mode: explicit task list
51
+ overnight run tasks.yaml --notify
45
52
  \`\`\`
46
53
 
47
54
  ## Commands
48
55
 
49
56
  | Command | Description |
50
57
  |---------|-------------|
51
- | \`overnight run <file>\` | Run jobs from YAML file |
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) |
52
61
  | \`overnight resume <file>\` | Resume interrupted run from checkpoint |
53
62
  | \`overnight single "<prompt>"\` | Run a single task directly |
54
- | \`overnight init\` | Create example tasks.yaml |
63
+ | \`overnight init\` | Create example goal.yaml or tasks.yaml |
55
64
 
56
- ## tasks.yaml Format
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.
57
90
 
58
91
  \`\`\`yaml
59
92
  defaults:
60
- timeout_seconds: 300 # Per-task timeout (default: 300)
61
- verify: true # Run verification pass (default: true)
62
- allowed_tools: # Whitelist tools (default: Read,Edit,Write,Glob,Grep)
63
- - Read
64
- - Edit
65
- - Glob
66
- - Grep
93
+ timeout_seconds: 300
94
+ verify: true
95
+ allowed_tools: [Read, Edit, Write, Glob, Grep]
67
96
 
68
97
  tasks:
69
- # Simple format
70
98
  - "Fix the bug in auth.py"
71
-
72
- # Detailed format
73
99
  - prompt: "Add input validation"
74
100
  timeout_seconds: 600
75
- verify: false
76
- allowed_tools: [Read, Edit, Bash, Glob, Grep]
77
101
  \`\`\`
78
102
 
79
103
  ## Key Options
@@ -83,48 +107,54 @@ tasks:
83
107
  | \`-o, --output <file>\` | Save results JSON |
84
108
  | \`-r, --report <file>\` | Generate markdown report |
85
109
  | \`-s, --state-file <file>\` | Custom checkpoint file |
110
+ | \`--max-iterations <n>\` | Max build loop iterations (goal mode) |
86
111
  | \`--notify\` | Send push notification via ntfy.sh |
87
- | \`--notify-topic <topic>\` | ntfy.sh topic (default: overnight) |
88
112
  | \`-q, --quiet\` | Minimal output |
89
113
 
90
- ## Features
91
-
92
- 1. **Crash Recovery**: Auto-checkpoints after each job. Use \`overnight resume\` to continue.
93
- 2. **Retry Logic**: Auto-retries 3x on API/network errors with exponential backoff.
94
- 3. **Notifications**: \`--notify\` sends summary to ntfy.sh (free, no signup).
95
- 4. **Reports**: \`-r report.md\` generates markdown summary with next steps.
96
- 5. **Security**: No Bash by default. Whitelist tools per-task.
97
-
98
114
  ## Example Workflows
99
115
 
100
116
  \`\`\`bash
101
- # Development: run overnight, check in morning
102
- nohup overnight run tasks.yaml --notify -r report.md -o results.json > overnight.log 2>&1 &
117
+ # Simplest: just hammer a goal overnight
118
+ nohup overnight hammer "Build a REST API with auth and tests" --notify > overnight.log 2>&1 &
103
119
 
104
- # CI/CD: run and fail if any task fails
105
- overnight run tasks.yaml -q
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 &
106
123
 
107
- # Single task with Bash access
108
- overnight single "Run tests and fix failures" -T Read -T Edit -T Bash -T Glob
124
+ # Batch tasks overnight
125
+ nohup overnight run tasks.yaml --notify -r report.md > overnight.log 2>&1 &
109
126
 
110
- # Resume after crash/interrupt
111
- overnight resume tasks.yaml
127
+ # Resume after crash
128
+ overnight resume goal.yaml
112
129
  \`\`\`
113
130
 
114
131
  ## Exit Codes
115
132
 
116
- - 0: All tasks succeeded
117
- - 1: One or more tasks failed
133
+ - 0: All tasks succeeded / gate passed
134
+ - 1: Failures occurred / gate failed
118
135
 
119
136
  ## Files Created
120
137
 
121
- - \`.overnight-state.json\` - Checkpoint file (deleted on success)
138
+ - \`.overnight-goal-state.json\` - Goal mode checkpoint
139
+ - \`.overnight-iterations/\` - Per-iteration state + summaries
140
+ - \`.overnight-state.json\` - Task mode checkpoint
122
141
  - \`report.md\` - Summary report (if -r used)
123
- - \`results.json\` - Full results (if -o used)
124
142
 
125
143
  Run \`overnight <command> --help\` for command-specific options.
126
144
  `;
127
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
+
128
158
  interface ParsedConfig {
129
159
  configs: JobConfig[];
130
160
  security?: SecurityConfig;
@@ -225,15 +255,15 @@ const program = new Command();
225
255
  program
226
256
  .name("overnight")
227
257
  .description("Batch job runner for Claude Code")
228
- .version("0.2.0")
258
+ .version("0.3.0")
229
259
  .action(() => {
230
260
  console.log(AGENT_HELP);
231
261
  });
232
262
 
233
263
  program
234
264
  .command("run")
235
- .description("Run jobs from a YAML tasks file")
236
- .argument("<tasks-file>", "Path to tasks.yaml file")
265
+ .description("Run goal.yaml (autonomous loop) or tasks.yaml (batch jobs)")
266
+ .argument("<file>", "Path to goal.yaml or tasks.yaml")
237
267
  .option("-o, --output <file>", "Output file for results JSON")
238
268
  .option("-q, --quiet", "Minimal output")
239
269
  .option("-s, --state-file <file>", "Custom state file path")
@@ -242,89 +272,175 @@ program
242
272
  .option("-r, --report <file>", "Generate markdown report")
243
273
  .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
244
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))
245
276
  .option("--audit-log <file>", "Audit log file path")
246
277
  .option("--no-security", "Disable default security (deny patterns)")
247
- .action(async (tasksFile, opts) => {
248
- if (!existsSync(tasksFile)) {
249
- console.error(`Error: File not found: ${tasksFile}`);
278
+ .action(async (inputFile, opts) => {
279
+ if (!existsSync(inputFile)) {
280
+ console.error(`Error: File not found: ${inputFile}`);
250
281
  process.exit(1);
251
282
  }
252
283
 
253
- // Build CLI security config
254
- const cliSecurity: Partial<SecurityConfig> | undefined = opts.security === false
255
- ? undefined
256
- : {
257
- ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
258
- ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
259
- ...(opts.auditLog && { audit_log: opts.auditLog }),
260
- };
261
-
262
- const { configs, security } = parseTasksFile(tasksFile, cliSecurity);
263
- if (configs.length === 0) {
264
- console.error("No tasks found in file");
265
- process.exit(1);
266
- }
284
+ // Detect file type and dispatch
285
+ if (isGoalFile(inputFile)) {
286
+ // --- Goal mode ---
287
+ const goal = parseGoalFile(inputFile);
267
288
 
268
- // Check if resuming from existing state
269
- const existingState = loadState(opts.stateFile ?? DEFAULT_STATE_FILE);
270
- if (existingState) {
271
- const done = Object.keys(existingState.completed).length;
272
- const pending = configs.filter(c => !(taskKey(c) in existingState.completed)).length;
273
- console.log(`\x1b[1movernight: Resuming ${done} done, ${pending} remaining\x1b[0m`);
274
- console.log(`\x1b[2mLast checkpoint: ${existingState.timestamp}\x1b[0m`);
275
- } else {
276
- console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
277
- }
278
-
279
- // Show security config if enabled
280
- if (security && !opts.quiet) {
281
- console.log("\x1b[2mSecurity:\x1b[0m");
282
- validateSecurityConfig(security);
283
- }
284
- console.log("");
285
-
286
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
287
- const startTime = Date.now();
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
+ }
288
303
 
289
- const reloadConfigs = () => parseTasksFile(tasksFile, cliSecurity).configs;
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
+ }
290
338
 
291
- const results = await runJobsWithState(configs, {
292
- stateFile: opts.stateFile,
293
- log,
294
- reloadConfigs,
295
- });
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
+ }
296
370
 
297
- const totalDuration = (Date.now() - startTime) / 1000;
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
+ }
298
389
 
299
- if (opts.notify) {
300
- const success = await sendNtfyNotification(
301
- results,
302
- totalDuration,
303
- opts.notifyTopic
304
- );
305
- if (success) {
306
- console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
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`);
307
396
  } else {
308
- console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
397
+ console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
309
398
  }
310
- }
311
399
 
312
- if (opts.report) {
313
- generateReport(results, totalDuration, opts.report);
314
- console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
315
- }
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
+ }
316
426
 
317
- if (!opts.quiet) {
318
- printSummary(results);
319
- }
427
+ if (opts.report) {
428
+ generateReport(results, totalDuration, opts.report);
429
+ console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
430
+ }
320
431
 
321
- if (opts.output) {
322
- writeFileSync(opts.output, resultsToJson(results));
323
- console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
324
- }
432
+ if (!opts.quiet) {
433
+ printSummary(results);
434
+ }
325
435
 
326
- if (results.some((r) => r.status !== "success")) {
327
- process.exit(1);
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
+ }
328
444
  }
329
445
  });
330
446
 
@@ -476,11 +592,128 @@ program
476
592
  }
477
593
  });
478
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
+
479
709
  program
480
710
  .command("init")
481
- .description("Create an example tasks.yaml file")
482
- .action(() => {
483
- const example = `# overnight task file
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
484
717
  # Run with: overnight run tasks.yaml
485
718
 
486
719
  defaults:
@@ -500,9 +733,6 @@ defaults:
500
733
  sandbox_dir: "." # Restrict to current directory
501
734
  max_turns: 100 # Prevent runaway agents
502
735
  # audit_log: "overnight-audit.log" # Uncomment to enable
503
- # deny_patterns: # Default patterns block .env, .key, .pem, etc.
504
- # - "**/.env*"
505
- # - "**/*.key"
506
736
 
507
737
  tasks:
508
738
  # Simple string format
@@ -525,14 +755,67 @@ tasks:
525
755
  - Grep
526
756
  `;
527
757
 
528
- if (existsSync("tasks.yaml")) {
529
- console.log("\x1b[33mtasks.yaml already exists\x1b[0m");
530
- process.exit(1);
531
- }
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"
532
773
 
533
- writeFileSync("tasks.yaml", example);
534
- console.log("\x1b[32mCreated tasks.yaml\x1b[0m");
535
- console.log("Edit the file, then run: \x1b[1movernight run tasks.yaml\x1b[0m");
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
+ }
536
819
  });
537
820
 
538
821
  program.parse();