@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/.context/notes.md +0 -0
- package/.context/todos.md +0 -0
- package/README.md +73 -16
- package/dist/cli.js +1754 -249
- package/package.json +2 -2
- package/src/cli.ts +499 -112
- package/src/goal-runner.ts +709 -0
- package/src/planner.ts +238 -0
- package/src/runner.ts +427 -47
- package/src/security.ts +162 -0
- package/src/types.ts +85 -4
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 -
|
|
36
|
+
# overnight - Autonomous Build Runner for Claude Code
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
Two modes: goal-driven autonomous loops, or task-list batch jobs.
|
|
28
39
|
|
|
29
40
|
## Quick Start
|
|
30
41
|
|
|
31
42
|
\`\`\`bash
|
|
32
|
-
#
|
|
33
|
-
overnight
|
|
43
|
+
# Hammer mode: just give it a goal and go
|
|
44
|
+
overnight hammer "Build a multiplayer MMO"
|
|
34
45
|
|
|
35
|
-
#
|
|
36
|
-
overnight
|
|
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
|
-
#
|
|
39
|
-
overnight run tasks.yaml --notify
|
|
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
|
|
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
|
-
##
|
|
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
|
|
56
|
-
verify: true
|
|
57
|
-
allowed_tools:
|
|
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
|
-
#
|
|
97
|
-
nohup overnight
|
|
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
|
-
#
|
|
100
|
-
overnight
|
|
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
|
-
#
|
|
103
|
-
overnight
|
|
124
|
+
# Batch tasks overnight
|
|
125
|
+
nohup overnight run tasks.yaml --notify -r report.md > overnight.log 2>&1 &
|
|
104
126
|
|
|
105
|
-
# Resume after crash
|
|
106
|
-
overnight resume
|
|
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:
|
|
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\` -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
201
|
-
.argument("<
|
|
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
|
-
.
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
284
|
+
// Detect file type and dispatch
|
|
285
|
+
if (isGoalFile(inputFile)) {
|
|
286
|
+
// --- Goal mode ---
|
|
287
|
+
const goal = parseGoalFile(inputFile);
|
|
219
288
|
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
397
|
+
console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
|
|
242
398
|
}
|
|
243
|
-
}
|
|
244
399
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
432
|
+
if (!opts.quiet) {
|
|
433
|
+
printSummary(results);
|
|
434
|
+
}
|
|
258
435
|
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
494
|
+
`\x1b[1movernight: Resuming — ${completedCount} done, ${pendingCount} remaining\x1b[0m`
|
|
305
495
|
);
|
|
306
|
-
console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m
|
|
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
|
-
|
|
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
|
|
388
|
-
.
|
|
389
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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();
|