@yail259/overnight 0.1.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/src/cli.ts CHANGED
@@ -6,69 +6,98 @@ import {
6
6
  type JobConfig,
7
7
  type JobResult,
8
8
  type TasksFile,
9
+ type GoalConfig,
10
+ type SecurityConfig,
11
+ DEFAULT_TOOLS,
9
12
  DEFAULT_TIMEOUT,
10
13
  DEFAULT_STALL_TIMEOUT,
11
14
  DEFAULT_VERIFY_PROMPT,
12
15
  DEFAULT_STATE_FILE,
16
+ DEFAULT_GOAL_STATE_FILE,
13
17
  DEFAULT_NTFY_TOPIC,
18
+ DEFAULT_MAX_TURNS,
19
+ DEFAULT_MAX_ITERATIONS,
20
+ DEFAULT_DENY_PATTERNS,
14
21
  } from "./types.js";
22
+ import { validateSecurityConfig } from "./security.js";
15
23
  import {
16
24
  runJob,
17
25
  runJobsWithState,
18
26
  loadState,
19
27
  resultsToJson,
28
+ taskKey,
20
29
  } from "./runner.js";
21
30
  import { sendNtfyNotification } from "./notify.js";
22
31
  import { generateReport } from "./report.js";
32
+ import { runGoal, parseGoalFile } from "./goal-runner.js";
33
+ import { runPlanner } from "./planner.js";
23
34
 
24
35
  const AGENT_HELP = `
25
- # overnight - Batch Job Runner for Claude Code
36
+ # overnight - Autonomous Build Runner for Claude Code
26
37
 
27
- Queue tasks, run them unattended, get results. Designed for overnight/AFK use.
38
+ Two modes: goal-driven autonomous loops, or task-list batch jobs.
28
39
 
29
40
  ## Quick Start
30
41
 
31
42
  \`\`\`bash
32
- # Create a tasks.yaml file
33
- overnight init
43
+ # Hammer mode: just give it a goal and go
44
+ overnight hammer "Build a multiplayer MMO"
34
45
 
35
- # Run all tasks
36
- 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
37
49
 
38
- # Run with notifications and report
39
- overnight run tasks.yaml --notify -r report.md
50
+ # Task mode: explicit task list
51
+ overnight run tasks.yaml --notify
40
52
  \`\`\`
41
53
 
42
54
  ## Commands
43
55
 
44
56
  | Command | Description |
45
57
  |---------|-------------|
46
- | \`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) |
47
61
  | \`overnight resume <file>\` | Resume interrupted run from checkpoint |
48
62
  | \`overnight single "<prompt>"\` | Run a single task directly |
49
- | \`overnight init\` | Create example tasks.yaml |
63
+ | \`overnight init\` | Create example goal.yaml or tasks.yaml |
50
64
 
51
- ## 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.
52
90
 
53
91
  \`\`\`yaml
54
92
  defaults:
55
- timeout_seconds: 300 # Per-task timeout (default: 300)
56
- verify: true # Run verification pass (default: true)
57
- allowed_tools: # Whitelist tools (default: Read,Edit,Write,Glob,Grep)
58
- - Read
59
- - Edit
60
- - Glob
61
- - Grep
93
+ timeout_seconds: 300
94
+ verify: true
95
+ allowed_tools: [Read, Edit, Write, Glob, Grep]
62
96
 
63
97
  tasks:
64
- # Simple format
65
98
  - "Fix the bug in auth.py"
66
-
67
- # Detailed format
68
99
  - prompt: "Add input validation"
69
100
  timeout_seconds: 600
70
- verify: false
71
- allowed_tools: [Read, Edit, Bash, Glob, Grep]
72
101
  \`\`\`
73
102
 
74
103
  ## Key Options
@@ -78,56 +107,86 @@ tasks:
78
107
  | \`-o, --output <file>\` | Save results JSON |
79
108
  | \`-r, --report <file>\` | Generate markdown report |
80
109
  | \`-s, --state-file <file>\` | Custom checkpoint file |
110
+ | \`--max-iterations <n>\` | Max build loop iterations (goal mode) |
81
111
  | \`--notify\` | Send push notification via ntfy.sh |
82
- | \`--notify-topic <topic>\` | ntfy.sh topic (default: overnight) |
83
112
  | \`-q, --quiet\` | Minimal output |
84
113
 
85
- ## Features
86
-
87
- 1. **Crash Recovery**: Auto-checkpoints after each job. Use \`overnight resume\` to continue.
88
- 2. **Retry Logic**: Auto-retries 3x on API/network errors with exponential backoff.
89
- 3. **Notifications**: \`--notify\` sends summary to ntfy.sh (free, no signup).
90
- 4. **Reports**: \`-r report.md\` generates markdown summary with next steps.
91
- 5. **Security**: No Bash by default. Whitelist tools per-task.
92
-
93
114
  ## Example Workflows
94
115
 
95
116
  \`\`\`bash
96
- # Development: run overnight, check in morning
97
- 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 &
98
119
 
99
- # CI/CD: run and fail if any task fails
100
- 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 &
101
123
 
102
- # Single task with Bash access
103
- 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 &
104
126
 
105
- # Resume after crash/interrupt
106
- overnight resume tasks.yaml
127
+ # Resume after crash
128
+ overnight resume goal.yaml
107
129
  \`\`\`
108
130
 
109
131
  ## Exit Codes
110
132
 
111
- - 0: All tasks succeeded
112
- - 1: One or more tasks failed
133
+ - 0: All tasks succeeded / gate passed
134
+ - 1: Failures occurred / gate failed
113
135
 
114
136
  ## Files Created
115
137
 
116
- - \`.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
117
141
  - \`report.md\` - Summary report (if -r used)
118
- - \`results.json\` - Full results (if -o used)
119
142
 
120
143
  Run \`overnight <command> --help\` for command-specific options.
121
144
  `;
122
145
 
123
- function parseTasksFile(path: string): JobConfig[] {
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 {
124
164
  const content = readFileSync(path, "utf-8");
125
- const data = parseYaml(content) as TasksFile | (string | JobConfig)[];
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
+ }
126
174
 
127
175
  const tasks = Array.isArray(data) ? data : data.tasks ?? [];
128
176
  const defaults = Array.isArray(data) ? {} : data.defaults ?? {};
129
177
 
130
- return tasks.map((task) => {
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) => {
131
190
  if (typeof task === "string") {
132
191
  return {
133
192
  prompt: task,
@@ -137,9 +196,12 @@ function parseTasksFile(path: string): JobConfig[] {
137
196
  verify: defaults.verify ?? true,
138
197
  verify_prompt: defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
139
198
  allowed_tools: defaults.allowed_tools,
199
+ security,
140
200
  };
141
201
  }
142
202
  return {
203
+ id: task.id ?? undefined,
204
+ depends_on: task.depends_on ?? undefined,
143
205
  prompt: task.prompt,
144
206
  working_dir: task.working_dir ?? undefined,
145
207
  timeout_seconds:
@@ -152,8 +214,11 @@ function parseTasksFile(path: string): JobConfig[] {
152
214
  verify_prompt:
153
215
  task.verify_prompt ?? defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
154
216
  allowed_tools: task.allowed_tools ?? defaults.allowed_tools,
217
+ security: task.security ?? security,
155
218
  };
156
219
  });
220
+
221
+ return { configs, security };
157
222
  }
158
223
 
159
224
  function printSummary(results: JobResult[]): void {
@@ -190,74 +255,192 @@ const program = new Command();
190
255
  program
191
256
  .name("overnight")
192
257
  .description("Batch job runner for Claude Code")
193
- .version("0.1.0")
258
+ .version("0.3.0")
194
259
  .action(() => {
195
260
  console.log(AGENT_HELP);
196
261
  });
197
262
 
198
263
  program
199
264
  .command("run")
200
- .description("Run jobs from a YAML tasks file")
201
- .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")
202
267
  .option("-o, --output <file>", "Output file for results JSON")
203
268
  .option("-q, --quiet", "Minimal output")
204
269
  .option("-s, --state-file <file>", "Custom state file path")
205
270
  .option("--notify", "Send push notification via ntfy.sh")
206
271
  .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
207
272
  .option("-r, --report <file>", "Generate markdown report")
208
- .action(async (tasksFile, opts) => {
209
- if (!existsSync(tasksFile)) {
210
- console.error(`Error: File not found: ${tasksFile}`);
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}`);
211
281
  process.exit(1);
212
282
  }
213
283
 
214
- const configs = parseTasksFile(tasksFile);
215
- if (configs.length === 0) {
216
- console.error("No tasks found in file");
217
- process.exit(1);
218
- }
284
+ // Detect file type and dispatch
285
+ if (isGoalFile(inputFile)) {
286
+ // --- Goal mode ---
287
+ const goal = parseGoalFile(inputFile);
219
288
 
220
- console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m\n`);
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
+ }
221
303
 
222
- const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
223
- const startTime = Date.now();
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
+ }
224
338
 
225
- const results = await runJobsWithState(configs, {
226
- stateFile: opts.stateFile,
227
- log,
228
- });
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
+ }
229
370
 
230
- 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
+ }
231
389
 
232
- if (opts.notify) {
233
- const success = await sendNtfyNotification(
234
- results,
235
- totalDuration,
236
- opts.notifyTopic
237
- );
238
- if (success) {
239
- 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`);
240
396
  } else {
241
- console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
397
+ console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
242
398
  }
243
- }
244
399
 
245
- if (opts.report) {
246
- generateReport(results, totalDuration, opts.report);
247
- console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
248
- }
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
+ }
249
426
 
250
- if (!opts.quiet) {
251
- printSummary(results);
252
- }
427
+ if (opts.report) {
428
+ generateReport(results, totalDuration, opts.report);
429
+ console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
430
+ }
253
431
 
254
- if (opts.output) {
255
- writeFileSync(opts.output, resultsToJson(results));
256
- console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
257
- }
432
+ if (!opts.quiet) {
433
+ printSummary(results);
434
+ }
258
435
 
259
- if (results.some((r) => r.status !== "success")) {
260
- 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
+ }
261
444
  }
262
445
  });
263
446
 
@@ -271,6 +454,10 @@ program
271
454
  .option("--notify", "Send push notification via ntfy.sh")
272
455
  .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
273
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)")
274
461
  .action(async (tasksFile, opts) => {
275
462
  const stateFile = opts.stateFile ?? DEFAULT_STATE_FILE;
276
463
  const state = loadState(stateFile);
@@ -286,33 +473,43 @@ program
286
473
  process.exit(1);
287
474
  }
288
475
 
289
- const configs = parseTasksFile(tasksFile);
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);
290
486
  if (configs.length === 0) {
291
487
  console.error("No tasks found in file");
292
488
  process.exit(1);
293
489
  }
294
490
 
295
- if (configs.length !== state.total_jobs) {
296
- console.error(
297
- `Task file has ${configs.length} jobs but state has ${state.total_jobs}`
298
- );
299
- process.exit(1);
300
- }
301
-
302
- const startIndex = state.completed_indices.length;
491
+ const completedCount = Object.keys(state.completed).length;
492
+ const pendingCount = configs.filter(c => !(taskKey(c) in state.completed)).length;
303
493
  console.log(
304
- `\x1b[1movernight: Resuming from job ${startIndex + 1}/${configs.length}...\x1b[0m`
494
+ `\x1b[1movernight: Resuming ${completedCount} done, ${pendingCount} remaining\x1b[0m`
305
495
  );
306
- console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m\n`);
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("");
307
504
 
308
505
  const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
309
506
  const startTime = Date.now();
507
+ const reloadConfigs = () => parseTasksFile(tasksFile, cliSecurity).configs;
310
508
 
311
509
  const results = await runJobsWithState(configs, {
312
510
  stateFile,
313
511
  log,
314
- startIndex,
315
- priorResults: state.results,
512
+ reloadConfigs,
316
513
  });
317
514
 
318
515
  const totalDuration = (Date.now() - startTime) / 1000;
@@ -357,12 +554,25 @@ program
357
554
  .option("--verify", "Run verification pass", true)
358
555
  .option("--no-verify", "Skip verification pass")
359
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)")
360
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
+
361
570
  const config: JobConfig = {
362
571
  prompt,
363
572
  timeout_seconds: parseInt(opts.timeout, 10),
364
573
  verify: opts.verify,
365
574
  allowed_tools: opts.tools,
575
+ security,
366
576
  };
367
577
 
368
578
  const log = (msg: string) => console.log(msg);
@@ -382,16 +592,134 @@ program
382
592
  }
383
593
  });
384
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
+
385
709
  program
386
710
  .command("init")
387
- .description("Create an example tasks.yaml file")
388
- .action(() => {
389
- 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
390
717
  # Run with: overnight run tasks.yaml
391
718
 
392
719
  defaults:
393
720
  timeout_seconds: 300 # 5 minutes per task
394
721
  verify: true # Run verification after each task
722
+
395
723
  # Secure defaults - no Bash, just file operations
396
724
  allowed_tools:
397
725
  - Read
@@ -400,6 +728,12 @@ defaults:
400
728
  - Glob
401
729
  - Grep
402
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
+
403
737
  tasks:
404
738
  # Simple string format
405
739
  - "Find and fix any TODO comments in the codebase"
@@ -421,14 +755,67 @@ tasks:
421
755
  - Grep
422
756
  `;
423
757
 
424
- if (existsSync("tasks.yaml")) {
425
- console.log("\x1b[33mtasks.yaml already exists\x1b[0m");
426
- process.exit(1);
427
- }
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"
428
773
 
429
- writeFileSync("tasks.yaml", example);
430
- console.log("\x1b[32mCreated tasks.yaml\x1b[0m");
431
- 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
+ }
432
819
  });
433
820
 
434
821
  program.parse();