@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/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: Timer;
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 | null,
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 | null,
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.*",