@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/.context/notes.md +0 -0
- package/.context/todos.md +0 -0
- package/dist/cli.js +1205 -181
- package/package.json +2 -2
- package/src/cli.ts +410 -127
- package/src/goal-runner.ts +709 -0
- package/src/planner.ts +238 -0
- package/src/runner.ts +7 -4
- package/src/security.ts +6 -6
- package/src/types.ts +48 -0
package/src/planner.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
5
|
+
import * as readline from "readline";
|
|
6
|
+
import {
|
|
7
|
+
type GoalConfig,
|
|
8
|
+
DEFAULT_TOOLS,
|
|
9
|
+
DEFAULT_TIMEOUT,
|
|
10
|
+
DEFAULT_MAX_TURNS,
|
|
11
|
+
DEFAULT_MAX_ITERATIONS,
|
|
12
|
+
DEFAULT_CONVERGENCE_THRESHOLD,
|
|
13
|
+
DEFAULT_DENY_PATTERNS,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
type LogCallback = (msg: string) => void;
|
|
17
|
+
|
|
18
|
+
// --- Claude executable ---
|
|
19
|
+
|
|
20
|
+
let claudeExecutablePath: string | undefined;
|
|
21
|
+
|
|
22
|
+
function findClaudeExecutable(): string | undefined {
|
|
23
|
+
if (claudeExecutablePath !== undefined) return claudeExecutablePath;
|
|
24
|
+
if (process.env.CLAUDE_CODE_PATH) {
|
|
25
|
+
claudeExecutablePath = process.env.CLAUDE_CODE_PATH;
|
|
26
|
+
return claudeExecutablePath;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const cmd = process.platform === "win32" ? "where claude" : "which claude";
|
|
30
|
+
claudeExecutablePath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
|
|
31
|
+
return claudeExecutablePath;
|
|
32
|
+
} catch {
|
|
33
|
+
const commonPaths = [
|
|
34
|
+
"/usr/local/bin/claude",
|
|
35
|
+
"/opt/homebrew/bin/claude",
|
|
36
|
+
`${process.env.HOME}/.local/bin/claude`,
|
|
37
|
+
];
|
|
38
|
+
for (const p of commonPaths) {
|
|
39
|
+
if (existsSync(p)) {
|
|
40
|
+
claudeExecutablePath = p;
|
|
41
|
+
return claudeExecutablePath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Interactive prompting ---
|
|
49
|
+
|
|
50
|
+
function createReadline(): readline.Interface {
|
|
51
|
+
return readline.createInterface({
|
|
52
|
+
input: process.stdin,
|
|
53
|
+
output: process.stdout,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ask(rl: readline.Interface, question: string): Promise<string> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
rl.question(question, (answer) => resolve(answer.trim()));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Planner ---
|
|
64
|
+
|
|
65
|
+
const PLANNER_SYSTEM_PROMPT = `You are an expert software architect helping plan an autonomous overnight build.
|
|
66
|
+
|
|
67
|
+
Your job is to have a focused design conversation with the user, then produce a goal.yaml file that an autonomous build agent will use to implement the project overnight.
|
|
68
|
+
|
|
69
|
+
Guidelines:
|
|
70
|
+
- Ask clarifying questions about scope, technology choices, priorities, and constraints
|
|
71
|
+
- Keep the conversation focused and efficient — 3-5 rounds max
|
|
72
|
+
- When you have enough information, produce the goal.yaml
|
|
73
|
+
- The goal.yaml should be specific enough for an agent to work autonomously
|
|
74
|
+
- Include concrete acceptance criteria that can be verified
|
|
75
|
+
- Include verification commands when possible (build, test, lint)
|
|
76
|
+
- Set realistic constraints
|
|
77
|
+
|
|
78
|
+
When you're ready to produce the final plan, output it in this format:
|
|
79
|
+
|
|
80
|
+
\`\`\`yaml
|
|
81
|
+
goal: "Clear description of what to build"
|
|
82
|
+
|
|
83
|
+
acceptance_criteria:
|
|
84
|
+
- "Specific, verifiable criterion 1"
|
|
85
|
+
- "Specific, verifiable criterion 2"
|
|
86
|
+
|
|
87
|
+
verification_commands:
|
|
88
|
+
- "npm run build"
|
|
89
|
+
- "npm test"
|
|
90
|
+
|
|
91
|
+
constraints:
|
|
92
|
+
- "Don't modify existing API contracts"
|
|
93
|
+
|
|
94
|
+
max_iterations: 15
|
|
95
|
+
convergence_threshold: 3
|
|
96
|
+
|
|
97
|
+
defaults:
|
|
98
|
+
timeout_seconds: 600
|
|
99
|
+
allowed_tools:
|
|
100
|
+
- Read
|
|
101
|
+
- Edit
|
|
102
|
+
- Write
|
|
103
|
+
- Glob
|
|
104
|
+
- Grep
|
|
105
|
+
- Bash
|
|
106
|
+
security:
|
|
107
|
+
sandbox_dir: "."
|
|
108
|
+
max_turns: 150
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
IMPORTANT: Only output the yaml block when you and the user agree the plan is ready. Before that, ask questions and discuss.`;
|
|
112
|
+
|
|
113
|
+
export async function runPlanner(
|
|
114
|
+
initialGoal: string,
|
|
115
|
+
options: {
|
|
116
|
+
outputFile?: string;
|
|
117
|
+
log?: LogCallback;
|
|
118
|
+
} = {}
|
|
119
|
+
): Promise<GoalConfig | null> {
|
|
120
|
+
const log = options.log ?? ((msg: string) => console.log(msg));
|
|
121
|
+
const outputFile = options.outputFile ?? "goal.yaml";
|
|
122
|
+
const claudePath = findClaudeExecutable();
|
|
123
|
+
|
|
124
|
+
if (!claudePath) {
|
|
125
|
+
log("\x1b[31m✗ Error: Could not find 'claude' CLI.\x1b[0m");
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log("\x1b[1movernight plan: Interactive design session\x1b[0m");
|
|
130
|
+
log("\x1b[2mDescribe your goal and I'll help shape it into a plan.\x1b[0m");
|
|
131
|
+
log("\x1b[2mType 'done' to finalize, 'quit' to abort.\x1b[0m\n");
|
|
132
|
+
|
|
133
|
+
const rl = createReadline();
|
|
134
|
+
const conversationHistory: Array<{ role: "user" | "assistant"; content: string }> = [];
|
|
135
|
+
|
|
136
|
+
// First turn: send the initial goal to the planner
|
|
137
|
+
let currentPrompt = `The user wants to plan the following project for an overnight autonomous build:\n\n${initialGoal}\n\nAsk clarifying questions to understand scope, tech choices, priorities, and constraints. Be concise.`;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
let sessionId: string | undefined;
|
|
141
|
+
|
|
142
|
+
for (let round = 0; round < 10; round++) {
|
|
143
|
+
// Run Claude
|
|
144
|
+
const sdkOptions: ClaudeCodeOptions = {
|
|
145
|
+
allowedTools: ["Read", "Glob", "Grep"], // Read-only for planning
|
|
146
|
+
systemPrompt: PLANNER_SYSTEM_PROMPT,
|
|
147
|
+
permissionMode: "acceptEdits",
|
|
148
|
+
pathToClaudeCodeExecutable: claudePath,
|
|
149
|
+
...(sessionId && { resume: sessionId }),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
let result: string | undefined;
|
|
153
|
+
|
|
154
|
+
const conversation = query({ prompt: currentPrompt, options: sdkOptions });
|
|
155
|
+
for await (const message of conversation) {
|
|
156
|
+
if (message.type === "result") {
|
|
157
|
+
sessionId = message.session_id;
|
|
158
|
+
if (message.subtype === "success") {
|
|
159
|
+
result = message.result;
|
|
160
|
+
}
|
|
161
|
+
} else if (message.type === "system" && "subtype" in message) {
|
|
162
|
+
if (message.subtype === "init") {
|
|
163
|
+
sessionId = message.session_id;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!result) {
|
|
169
|
+
log("\x1b[31m✗ No response from planner\x1b[0m");
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
conversationHistory.push({ role: "assistant", content: result });
|
|
174
|
+
|
|
175
|
+
// Check if the planner produced a goal.yaml
|
|
176
|
+
const yamlMatch = result.match(/```yaml\n([\s\S]*?)\n```/);
|
|
177
|
+
if (yamlMatch) {
|
|
178
|
+
// Show the plan
|
|
179
|
+
log("\n\x1b[1m━━━ Proposed Plan ━━━\x1b[0m\n");
|
|
180
|
+
log(yamlMatch[1]);
|
|
181
|
+
log("\n\x1b[1m━━━━━━━━━━━━━━━━━━━━\x1b[0m\n");
|
|
182
|
+
|
|
183
|
+
const answer = await ask(rl, "\x1b[36m?\x1b[0m Accept this plan? (yes/no/revise): ");
|
|
184
|
+
|
|
185
|
+
if (answer.toLowerCase() === "yes" || answer.toLowerCase() === "y") {
|
|
186
|
+
// Write the goal.yaml
|
|
187
|
+
writeFileSync(outputFile, yamlMatch[1]);
|
|
188
|
+
log(`\n\x1b[32m✓ Plan saved to ${outputFile}\x1b[0m`);
|
|
189
|
+
log(`Run with: \x1b[1movernight run ${outputFile}\x1b[0m`);
|
|
190
|
+
rl.close();
|
|
191
|
+
|
|
192
|
+
// Parse and return
|
|
193
|
+
const { parse: parseYaml } = await import("yaml");
|
|
194
|
+
return parseYaml(yamlMatch[1]) as GoalConfig;
|
|
195
|
+
} else if (answer.toLowerCase() === "quit" || answer.toLowerCase() === "q") {
|
|
196
|
+
log("\x1b[33mAborted\x1b[0m");
|
|
197
|
+
rl.close();
|
|
198
|
+
return null;
|
|
199
|
+
} else {
|
|
200
|
+
// User wants revisions
|
|
201
|
+
const revision = await ask(rl, "\x1b[36m?\x1b[0m What would you like to change? ");
|
|
202
|
+
currentPrompt = revision;
|
|
203
|
+
conversationHistory.push({ role: "user", content: revision });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Show the assistant's response
|
|
209
|
+
log(`\n\x1b[2m─── Planner ───\x1b[0m\n`);
|
|
210
|
+
log(result);
|
|
211
|
+
log("");
|
|
212
|
+
|
|
213
|
+
// Get user input
|
|
214
|
+
const userInput = await ask(rl, "\x1b[36m>\x1b[0m ");
|
|
215
|
+
|
|
216
|
+
if (userInput.toLowerCase() === "done") {
|
|
217
|
+
// Ask the planner to finalize
|
|
218
|
+
currentPrompt = "The user is satisfied. Please produce the final goal.yaml now based on our discussion.";
|
|
219
|
+
conversationHistory.push({ role: "user", content: currentPrompt });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (userInput.toLowerCase() === "quit" || userInput.toLowerCase() === "q") {
|
|
224
|
+
log("\x1b[33mAborted\x1b[0m");
|
|
225
|
+
rl.close();
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
currentPrompt = userInput;
|
|
230
|
+
conversationHistory.push({ role: "user", content: userInput });
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
rl.close();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
log("\x1b[33m⚠ Design session ended without producing a plan\x1b[0m");
|
|
237
|
+
return null;
|
|
238
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -132,7 +132,7 @@ async function runWithTimeout<T>(
|
|
|
132
132
|
promise: Promise<T>,
|
|
133
133
|
timeoutMs: number
|
|
134
134
|
): Promise<T> {
|
|
135
|
-
let timeoutId:
|
|
135
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
136
136
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
137
137
|
timeoutId = setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs);
|
|
138
138
|
});
|
|
@@ -191,8 +191,10 @@ async function collectResultWithProgress(
|
|
|
191
191
|
|
|
192
192
|
// Handle different message types
|
|
193
193
|
if (message.type === "result") {
|
|
194
|
-
result = message.result;
|
|
195
194
|
sessionId = message.session_id;
|
|
195
|
+
if (message.subtype === "success") {
|
|
196
|
+
result = message.result;
|
|
197
|
+
}
|
|
196
198
|
} else if (message.type === "assistant" && "message" in message) {
|
|
197
199
|
// Assistant message with tool use - SDK nests content in message.message
|
|
198
200
|
const assistantMsg = message.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> };
|
|
@@ -274,6 +276,8 @@ export async function runJob(
|
|
|
274
276
|
logMsg(`\x1b[36m▶\x1b[0m ${taskPreview}`);
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
let sessionId: string | undefined;
|
|
280
|
+
|
|
277
281
|
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
278
282
|
try {
|
|
279
283
|
// Build security hooks if security config provided
|
|
@@ -289,7 +293,6 @@ export async function runJob(
|
|
|
289
293
|
...(resumeSessionId && { resume: resumeSessionId }),
|
|
290
294
|
};
|
|
291
295
|
|
|
292
|
-
let sessionId: string | undefined;
|
|
293
296
|
let result: string | undefined;
|
|
294
297
|
|
|
295
298
|
// Prompt: if resuming, ask to continue; otherwise use original prompt
|
|
@@ -465,7 +468,7 @@ export function taskHash(prompt: string): string {
|
|
|
465
468
|
}
|
|
466
469
|
|
|
467
470
|
function validateDag(configs: JobConfig[]): string | null {
|
|
468
|
-
const ids = new Set(configs.map(c => c.id).filter(Boolean));
|
|
471
|
+
const ids = new Set(configs.map(c => c.id).filter((id): id is string => Boolean(id)));
|
|
469
472
|
// Check all depends_on references exist
|
|
470
473
|
for (const c of configs) {
|
|
471
474
|
for (const dep of c.depends_on ?? []) {
|
package/src/security.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function createSecurityHooks(config: SecurityConfig) {
|
|
|
49
49
|
// PreToolUse hook for path validation
|
|
50
50
|
const preToolUseHook = async (
|
|
51
51
|
input: Record<string, unknown>,
|
|
52
|
-
_toolUseId: string |
|
|
52
|
+
_toolUseId: string | undefined,
|
|
53
53
|
_context: { signal?: AbortSignal }
|
|
54
54
|
) => {
|
|
55
55
|
const hookEventName = input.hook_event_name as string;
|
|
@@ -80,8 +80,8 @@ export function createSecurityHooks(config: SecurityConfig) {
|
|
|
80
80
|
if (sandboxDir && !isPathWithinSandbox(filePath, sandboxDir)) {
|
|
81
81
|
return {
|
|
82
82
|
hookSpecificOutput: {
|
|
83
|
-
hookEventName,
|
|
84
|
-
permissionDecision: "deny",
|
|
83
|
+
hookEventName: "PreToolUse" as const,
|
|
84
|
+
permissionDecision: "deny" as const,
|
|
85
85
|
permissionDecisionReason: `Path "${filePath}" is outside sandbox directory "${sandboxDir}"`,
|
|
86
86
|
},
|
|
87
87
|
};
|
|
@@ -92,8 +92,8 @@ export function createSecurityHooks(config: SecurityConfig) {
|
|
|
92
92
|
if (matchedPattern) {
|
|
93
93
|
return {
|
|
94
94
|
hookSpecificOutput: {
|
|
95
|
-
hookEventName,
|
|
96
|
-
permissionDecision: "deny",
|
|
95
|
+
hookEventName: "PreToolUse" as const,
|
|
96
|
+
permissionDecision: "deny" as const,
|
|
97
97
|
permissionDecisionReason: `Path "${filePath}" matches deny pattern "${matchedPattern}"`,
|
|
98
98
|
},
|
|
99
99
|
};
|
|
@@ -105,7 +105,7 @@ export function createSecurityHooks(config: SecurityConfig) {
|
|
|
105
105
|
// PostToolUse hook for audit logging
|
|
106
106
|
const postToolUseHook = async (
|
|
107
107
|
input: Record<string, unknown>,
|
|
108
|
-
_toolUseId: string |
|
|
108
|
+
_toolUseId: string | undefined,
|
|
109
109
|
_context: { signal?: AbortSignal }
|
|
110
110
|
) => {
|
|
111
111
|
if (!auditLog) return {};
|
package/src/types.ts
CHANGED
|
@@ -55,6 +55,51 @@ export interface TasksFile {
|
|
|
55
55
|
tasks: (string | JobConfig)[];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// --- Goal mode types ---
|
|
59
|
+
|
|
60
|
+
export interface GoalConfig {
|
|
61
|
+
goal: string; // High-level objective
|
|
62
|
+
acceptance_criteria?: string[]; // What must be true for the goal to be met
|
|
63
|
+
verification_commands?: string[]; // Commands that must exit 0 (e.g. "npm test", "npm run build")
|
|
64
|
+
constraints?: string[]; // Things the agent should NOT do
|
|
65
|
+
max_iterations?: number; // Hard cap on build loop iterations
|
|
66
|
+
convergence_threshold?: number; // Stalled iterations before stopping (default: 3)
|
|
67
|
+
defaults?: TasksFile["defaults"]; // Same defaults as tasks.yaml
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IterationState {
|
|
71
|
+
iteration: number;
|
|
72
|
+
completed_items: string[];
|
|
73
|
+
remaining_items: string[];
|
|
74
|
+
known_issues: string[];
|
|
75
|
+
files_modified: string[];
|
|
76
|
+
agent_done: boolean; // Did the agent self-report "done"?
|
|
77
|
+
timestamp: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GateCheck {
|
|
81
|
+
name: string;
|
|
82
|
+
passed: boolean;
|
|
83
|
+
output: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GateResult {
|
|
87
|
+
passed: boolean;
|
|
88
|
+
checks: GateCheck[];
|
|
89
|
+
summary: string;
|
|
90
|
+
failures: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface GoalRunState {
|
|
94
|
+
goal: string;
|
|
95
|
+
iterations: IterationState[];
|
|
96
|
+
gate_results: GateResult[];
|
|
97
|
+
status: "running" | "gate_passed" | "gate_failed" | "stalled" | "max_iterations";
|
|
98
|
+
timestamp: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Constants ---
|
|
102
|
+
|
|
58
103
|
export const DEFAULT_TOOLS = ["Read", "Edit", "Write", "Glob", "Grep"];
|
|
59
104
|
export const DEFAULT_TIMEOUT = 300;
|
|
60
105
|
export const DEFAULT_STALL_TIMEOUT = 120;
|
|
@@ -62,8 +107,11 @@ export const DEFAULT_RETRY_COUNT = 3;
|
|
|
62
107
|
export const DEFAULT_RETRY_DELAY = 5;
|
|
63
108
|
export const DEFAULT_VERIFY_PROMPT = "Review what you just implemented. Check for correctness, completeness, and compile errors. Fix any issues you find.";
|
|
64
109
|
export const DEFAULT_STATE_FILE = ".overnight-state.json";
|
|
110
|
+
export const DEFAULT_GOAL_STATE_FILE = ".overnight-goal-state.json";
|
|
65
111
|
export const DEFAULT_NTFY_TOPIC = "overnight";
|
|
66
112
|
export const DEFAULT_MAX_TURNS = 100;
|
|
113
|
+
export const DEFAULT_MAX_ITERATIONS = 20;
|
|
114
|
+
export const DEFAULT_CONVERGENCE_THRESHOLD = 3;
|
|
67
115
|
export const DEFAULT_DENY_PATTERNS = [
|
|
68
116
|
"**/.env",
|
|
69
117
|
"**/.env.*",
|