stonecut 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonecut",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "CLI that drives PRD-driven development with agentic coding CLIs",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/execute.ts CHANGED
@@ -11,10 +11,9 @@
11
11
 
12
12
  import { checkoutOrCreateBranch, createPr, pushBranch } from "./git";
13
13
  import { LocalSource } from "./local";
14
- import { slugifyBranchComponent } from "./naming";
15
14
  import { renderLocal } from "./prompt";
16
15
  import { Logger } from "./logger";
17
- import { defaultGitOps, runAfkLoop } from "./runner";
16
+ import { defaultGitOps, formatTokens, runAfkLoop } from "./runner";
18
17
  import { getRunner } from "./runners/index";
19
18
  import type { Issue, IterationResult, Session } from "./types";
20
19
 
@@ -22,6 +21,22 @@ import type { Issue, IterationResult, Session } from "./types";
22
21
  // Stonecut report
23
22
  // ---------------------------------------------------------------------------
24
23
 
24
+ /** Build the metrics parenthetical for a report line. */
25
+ function formatMetrics(r: IterationResult): string {
26
+ const parts: string[] = [];
27
+ if (r.turns != null) {
28
+ parts.push(`${r.turns} turns`);
29
+ }
30
+ const totalTokens =
31
+ r.inputTokens != null || r.outputTokens != null
32
+ ? (r.inputTokens ?? 0) + (r.outputTokens ?? 0)
33
+ : undefined;
34
+ if (totalTokens != null) {
35
+ parts.push(`${formatTokens(totalTokens)} tokens`);
36
+ }
37
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
38
+ }
39
+
25
40
  /**
26
41
  * Build the Stonecut Report section for a PR body.
27
42
  */
@@ -32,11 +47,12 @@ export function buildReport(
32
47
  ): string {
33
48
  const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
34
49
  for (const r of results) {
50
+ const metrics = formatMetrics(r);
35
51
  if (r.success) {
36
- lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed`);
52
+ lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed${metrics}`);
37
53
  } else {
38
54
  const reason = r.error || "unknown error";
39
- lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}`);
55
+ lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}${metrics}`);
40
56
  }
41
57
  }
42
58
 
@@ -100,14 +116,14 @@ export async function executeLocal(
100
116
  iterations: number | "all",
101
117
  runnerName: string,
102
118
  ): Promise<void> {
103
- const runner = getRunner(runnerName);
104
119
  const source = new LocalSource(name);
105
- const prdIdentifier = slugifyBranchComponent(name) || "spec";
106
- const logger = new Logger(prdIdentifier);
120
+ const logger = new Logger();
121
+ const runner = getRunner(runnerName, logger);
107
122
 
108
123
  const session: Session = { logger, git: defaultGitOps, runner, runnerName };
109
124
 
110
125
  try {
126
+ logger.logFile(`PRD: ${name}`);
111
127
  checkoutOrCreateBranch(branch);
112
128
  console.log("");
113
129
 
package/src/logger.ts CHANGED
@@ -14,10 +14,11 @@ export class Logger implements LogWriter {
14
14
  private warnedRecreated = false;
15
15
  private warnedUnavailable = false;
16
16
 
17
- constructor(prdIdentifier: string) {
18
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
17
+ constructor() {
18
+ const now = new Date();
19
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}T${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
19
20
  const logDir = resolve(".stonecut", "logs");
20
- this.filePath = join(logDir, `${prdIdentifier}-${timestamp}.log`);
21
+ this.filePath = join(logDir, `${timestamp}.log`);
21
22
  try {
22
23
  mkdirSync(logDir, { recursive: true });
23
24
  appendFileSync(this.filePath, "");
@@ -29,6 +30,10 @@ export class Logger implements LogWriter {
29
30
 
30
31
  log(message: string): void {
31
32
  console.log(message);
33
+ this.logFile(message);
34
+ }
35
+
36
+ logFile(message: string): void {
32
37
  const ts = new Date().toISOString();
33
38
  const fileWasMissing = !existsSync(this.filePath);
34
39
  try {
package/src/runner.ts CHANGED
@@ -39,6 +39,7 @@ export const defaultGitOps: GitOps = {
39
39
  /** Console-only logger for backward compatibility. */
40
40
  export const consoleLogger: LogWriter = {
41
41
  log: (message: string) => console.log(message),
42
+ logFile: () => {},
42
43
  close: () => {},
43
44
  };
44
45
 
@@ -124,7 +125,6 @@ export async function runAfkLoop<T extends { number: number }>(
124
125
  const { logger, git, runner, runnerName } = session;
125
126
 
126
127
  logger.log(`Session started — runner: ${runnerName}, iterations: ${iterations}`);
127
- runner.logEnvironment(logger);
128
128
  logger.log("");
129
129
  const results: IterationResult[] = [];
130
130
  const sessionStart = performance.now();
@@ -182,6 +182,9 @@ export async function runAfkLoop<T extends { number: number }>(
182
182
  success: false,
183
183
  elapsedSeconds: runResult.durationSeconds,
184
184
  error: runResult.error,
185
+ turns: runResult.turns,
186
+ inputTokens: runResult.inputTokens,
187
+ outputTokens: runResult.outputTokens,
185
188
  });
186
189
 
187
190
  if (lastFailedIssueNumber === issue.number) {
@@ -209,6 +212,9 @@ export async function runAfkLoop<T extends { number: number }>(
209
212
  success: false,
210
213
  elapsedSeconds: runResult.durationSeconds,
211
214
  error: errorMsg,
215
+ turns: runResult.turns,
216
+ inputTokens: runResult.inputTokens,
217
+ outputTokens: runResult.outputTokens,
212
218
  });
213
219
 
214
220
  if (lastFailedIssueNumber === issue.number) {
@@ -238,6 +244,9 @@ export async function runAfkLoop<T extends { number: number }>(
238
244
  success: false,
239
245
  elapsedSeconds: runResult.durationSeconds,
240
246
  error: "commit failed after retries",
247
+ turns: runResult.turns,
248
+ inputTokens: runResult.inputTokens,
249
+ outputTokens: runResult.outputTokens,
241
250
  });
242
251
  // Commit failures always stop the session immediately
243
252
  logger.log("Stopping session: unable to commit.");
@@ -264,6 +273,9 @@ export async function runAfkLoop<T extends { number: number }>(
264
273
  issueFilename: name,
265
274
  success: true,
266
275
  elapsedSeconds: runResult.durationSeconds,
276
+ turns: runResult.turns,
277
+ inputTokens: runResult.inputTokens,
278
+ outputTokens: runResult.outputTokens,
267
279
  });
268
280
 
269
281
  lastFailedIssueNumber = null;
@@ -284,6 +296,19 @@ export async function runAfkLoop<T extends { number: number }>(
284
296
  return results;
285
297
  }
286
298
 
299
+ /** Format a token count as a human-readable string (e.g., 12400 → "12.4k"). */
300
+ export function formatTokens(count: number): string {
301
+ if (count >= 1_000_000) {
302
+ const m = count / 1_000_000;
303
+ return m % 1 === 0 ? `${m}M` : `${parseFloat(m.toFixed(1))}M`;
304
+ }
305
+ if (count >= 1_000) {
306
+ const k = count / 1_000;
307
+ return k % 1 === 0 ? `${k}k` : `${parseFloat(k.toFixed(1))}k`;
308
+ }
309
+ return `${count}`;
310
+ }
311
+
287
312
  /** Format seconds as a human-readable duration. */
288
313
  export function fmtTime(seconds: number): string {
289
314
  if (seconds < 60) {
@@ -311,7 +336,18 @@ export function printSummary(
311
336
  for (const r of results) {
312
337
  const status = r.success ? "completed" : "failed";
313
338
  const elapsed = fmtTime(r.elapsedSeconds);
314
- logger.log(` Issue ${r.issueNumber} (${r.issueFilename}): ${status} (${elapsed})`);
339
+ const details = [elapsed];
340
+ if (r.turns != null) {
341
+ details.push(`${r.turns} turns`);
342
+ }
343
+ const totalTokens =
344
+ r.inputTokens != null || r.outputTokens != null
345
+ ? (r.inputTokens ?? 0) + (r.outputTokens ?? 0)
346
+ : undefined;
347
+ if (totalTokens != null) {
348
+ details.push(`${formatTokens(totalTokens)} tokens`);
349
+ }
350
+ logger.log(` Issue ${r.issueNumber} (${r.issueFilename}): ${status} (${details.join(", ")})`);
315
351
  }
316
352
 
317
353
  logger.log("");
@@ -1,94 +1,241 @@
1
1
  /**
2
- * ClaudeRunner — adapter for Claude Code CLI.
2
+ * ClaudeRunner — adapter for Claude Code via CLI subprocess.
3
3
  *
4
- * Spawns a headless Claude Code session, parses the JSON output to
5
- * determine success/failure, and translates error subtypes into
6
- * human-readable messages.
4
+ * Spawns `claude --print --output-format stream-json --verbose` and
5
+ * consumes stdout as newline-delimited JSON. Console gets concise
6
+ * progress (tool calls, errors); the log file captures the full
7
+ * event stream for debugging.
7
8
  */
8
9
 
9
- import type { LogWriter, Runner, RunResult } from "../types.js";
10
+ import type { ChildResult, LogWriter, Runner, RunResult, SpawnFn } from "../types.js";
11
+
12
+ const CLI_ARGS = [
13
+ "claude",
14
+ "--print",
15
+ "--output-format",
16
+ "stream-json",
17
+ "--verbose",
18
+ "--allowedTools",
19
+ "Bash,Edit,Read,Write,Glob,Grep",
20
+ "--permission-mode",
21
+ "acceptEdits",
22
+ "--no-session-persistence",
23
+ ];
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Subprocess spawning (injectable for tests)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
30
+ const reader = stream.getReader();
31
+ const decoder = new TextDecoder();
32
+ let buffer = "";
33
+ try {
34
+ while (true) {
35
+ const { done, value } = await reader.read();
36
+ if (done) break;
37
+ buffer += decoder.decode(value, { stream: true });
38
+ const lines = buffer.split("\n");
39
+ buffer = lines.pop()!;
40
+ for (const line of lines) {
41
+ if (line.trim()) yield line;
42
+ }
43
+ }
44
+ if (buffer.trim()) yield buffer;
45
+ } finally {
46
+ reader.releaseLock();
47
+ }
48
+ }
49
+
50
+ function defaultSpawn(cmd: string[], stdin: string): ChildResult {
51
+ const proc = Bun.spawn(cmd, {
52
+ stdin: new Blob([stdin]),
53
+ stdout: "pipe",
54
+ stderr: "ignore",
55
+ });
56
+ return {
57
+ lines: readLines(proc.stdout),
58
+ exitCode: proc.exited,
59
+ };
60
+ }
61
+
62
+ let spawnFn: SpawnFn = defaultSpawn;
63
+
64
+ /** @internal Replace the spawn function. Returns a restore function. */
65
+ export function _setSpawnFn(fn: SpawnFn): () => void {
66
+ const prev = spawnFn;
67
+ spawnFn = fn;
68
+ return () => {
69
+ spawnFn = prev;
70
+ };
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Error subtype mapping
75
+ // ---------------------------------------------------------------------------
10
76
 
11
77
  const ERROR_MESSAGES: Record<string, string> = {
12
78
  error_max_turns: "max turns exceeded",
13
79
  error_max_budget_usd: "max budget exceeded",
80
+ error_during_execution: "execution error",
81
+ error_max_structured_output_retries: "max structured output retries exceeded",
14
82
  };
15
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Runner implementation
86
+ // ---------------------------------------------------------------------------
87
+
16
88
  export class ClaudeRunner implements Runner {
17
- logEnvironment(logger: LogWriter): void {
18
- const configDir = process.env.CLAUDE_CONFIG_DIR || "~/.claude (default)";
19
- logger.log(`Claude config: ${configDir}`);
89
+ private logger: LogWriter;
90
+
91
+ constructor(logger: LogWriter) {
92
+ this.logger = logger;
20
93
  }
21
94
 
22
95
  async run(prompt: string): Promise<RunResult> {
23
96
  const start = performance.now();
97
+ let child: ChildResult;
24
98
 
25
- let proc: Awaited<ReturnType<typeof Bun.spawn>>;
26
99
  try {
27
- proc = Bun.spawn(
28
- [
29
- "claude",
30
- "-p",
31
- "--output-format",
32
- "json",
33
- "--allowedTools",
34
- "Bash,Edit,Read,Write,Glob,Grep",
35
- ],
36
- { stdin: new Response(prompt), stdout: "pipe", stderr: "pipe" },
37
- );
38
- } catch {
39
- return {
40
- success: false,
41
- exitCode: 1,
42
- durationSeconds: (performance.now() - start) / 1000,
43
- error: "claude binary not found in PATH",
44
- };
100
+ child = spawnFn(CLI_ARGS, prompt);
101
+ } catch (error: unknown) {
102
+ const durationSeconds = (performance.now() - start) / 1000;
103
+ const msg = error instanceof Error ? error.message : String(error);
104
+
105
+ if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
106
+ return { success: false, durationSeconds, error: "claude binary not found in PATH" };
107
+ }
108
+ return { success: false, durationSeconds, error: msg };
45
109
  }
46
110
 
47
- const exitCode = await proc.exited;
111
+ let result: Record<string, unknown> | undefined;
112
+
113
+ try {
114
+ for await (const line of child.lines) {
115
+ let parsed: Record<string, unknown>;
116
+ try {
117
+ parsed = JSON.parse(line);
118
+ } catch {
119
+ this.logger.logFile(`[claude] Malformed JSON line: ${line}`);
120
+ continue;
121
+ }
122
+
123
+ this.handleMessage(parsed);
124
+
125
+ if (parsed.type === "result") {
126
+ result = parsed;
127
+ }
128
+ }
129
+ } catch (error: unknown) {
130
+ const durationSeconds = (performance.now() - start) / 1000;
131
+ const msg = error instanceof Error ? error.message : String(error);
132
+ return { success: false, durationSeconds, error: msg };
133
+ }
134
+
135
+ const exitCode = await child.exitCode;
48
136
  const durationSeconds = (performance.now() - start) / 1000;
49
137
 
50
- const stdout = await new Response(proc.stdout as ReadableStream).text();
51
- const output = stdout || undefined;
138
+ if (exitCode !== 0) {
139
+ return { success: false, durationSeconds, error: `claude exited with code ${exitCode}` };
140
+ }
52
141
 
53
- if (output === undefined) {
54
- return {
55
- success: false,
56
- exitCode,
57
- durationSeconds,
58
- error: `no output (exit code ${exitCode})`,
59
- };
142
+ if (!result) {
143
+ return { success: false, durationSeconds, error: "no result message received" };
60
144
  }
61
145
 
62
- let data: unknown;
63
- try {
64
- data = JSON.parse(output);
65
- } catch {
146
+ const sessionId = result.session_id as string | undefined;
147
+ const turns = result.num_turns as number | undefined;
148
+ const usage = result.usage as { input_tokens?: number; output_tokens?: number } | undefined;
149
+ const inputTokens = usage?.input_tokens;
150
+ const outputTokens = usage?.output_tokens;
151
+
152
+ this.logger.logFile(
153
+ `[claude] Result: ${result.subtype}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
154
+ );
155
+
156
+ if (result.subtype === "success") {
66
157
  return {
67
- success: false,
68
- exitCode,
158
+ success: true,
69
159
  durationSeconds,
70
- output,
71
- error: "malformed JSON output",
160
+ output: result.result as string | undefined,
161
+ turns,
162
+ inputTokens,
163
+ outputTokens,
164
+ sessionId,
72
165
  };
73
166
  }
74
167
 
75
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
76
- return {
77
- success: false,
78
- exitCode,
79
- durationSeconds,
80
- output,
81
- error: "unexpected JSON output (not an object)",
82
- };
168
+ const subtype = result.subtype as string;
169
+ const error = ERROR_MESSAGES[subtype] ?? `failed (${subtype})`;
170
+ const errors = result.errors as string[] | undefined;
171
+ const detail = errors?.length ? `: ${errors.join("; ")}` : "";
172
+ return {
173
+ success: false,
174
+ durationSeconds,
175
+ error: error + detail,
176
+ turns,
177
+ inputTokens,
178
+ outputTokens,
179
+ sessionId,
180
+ };
181
+ }
182
+
183
+ // -----------------------------------------------------------------------
184
+ // Message routing
185
+ // -----------------------------------------------------------------------
186
+
187
+ private handleMessage(message: Record<string, unknown>): void {
188
+ switch (message.type) {
189
+ case "assistant":
190
+ this.logAssistant(message);
191
+ break;
192
+ case "system":
193
+ case "user":
194
+ case "rate_limit_event":
195
+ this.logger.logFile(`[claude] ${message.type}: ${JSON.stringify(message)}`);
196
+ break;
197
+ case "result":
198
+ break;
199
+ default:
200
+ this.logger.logFile(`[claude] ${message.type}: ${JSON.stringify(message)}`);
201
+ break;
202
+ }
203
+ }
204
+
205
+ private logAssistant(message: Record<string, unknown>): void {
206
+ const msgBody = message.message as { content?: unknown[] } | undefined;
207
+ const content = msgBody?.content;
208
+
209
+ if (!Array.isArray(content)) {
210
+ this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(message)}`);
211
+ return;
83
212
  }
84
213
 
85
- const subtype = (data as Record<string, unknown>).subtype ?? "";
86
- if (subtype === "success") {
87
- return { success: true, exitCode, durationSeconds, output };
214
+ for (const block of content) {
215
+ if (typeof block === "string" || block === null || typeof block !== "object") continue;
216
+ const b = block as Record<string, unknown>;
217
+ if (b.type === "tool_use") {
218
+ const target = this.toolTarget(b.name as string, b.input as Record<string, unknown>);
219
+ this.logger.log(`[claude] ${b.name}${target ? `: ${target}` : ""}`);
220
+ }
88
221
  }
89
222
 
90
- const error =
91
- ERROR_MESSAGES[subtype as string] ?? `failed (${(subtype as string) || "unknown"})`;
92
- return { success: false, exitCode, durationSeconds, output, error };
223
+ this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(content)}`);
224
+ }
225
+
226
+ private toolTarget(name: string, input: Record<string, unknown>): string | undefined {
227
+ switch (name) {
228
+ case "Bash":
229
+ return typeof input.command === "string" ? input.command.slice(0, 80) : undefined;
230
+ case "Edit":
231
+ case "Read":
232
+ case "Write":
233
+ return typeof input.file_path === "string" ? input.file_path : undefined;
234
+ case "Glob":
235
+ case "Grep":
236
+ return typeof input.pattern === "string" ? input.pattern : undefined;
237
+ default:
238
+ return undefined;
239
+ }
93
240
  }
94
241
  }
@@ -1,81 +1,223 @@
1
1
  /**
2
- * CodexRunner — adapter for OpenAI Codex CLI.
2
+ * CodexRunner — adapter for Codex via CLI subprocess.
3
3
  *
4
- * Spawns a headless Codex session in full-auto mode, uses exit code as
5
- * the primary success signal, and extracts error details from JSONL
6
- * output on failure.
4
+ * Spawns `codex exec --json --dangerously-bypass-approvals-and-sandbox` and
5
+ * consumes stdout as newline-delimited JSON. Console gets concise
6
+ * progress (tool calls, file changes, turn boundaries, errors); the log
7
+ * file captures the full event stream for debugging.
7
8
  */
8
9
 
9
- import type { LogWriter, Runner, RunResult } from "../types.js";
10
+ import type { ChildResult, LogWriter, Runner, RunResult, SpawnFn } from "../types.js";
10
11
 
11
- function extractError(stdout: string): string {
12
- for (const raw of stdout.split("\n")) {
13
- const line = raw.trim();
14
- if (!line) continue;
12
+ const CLI_ARGS = ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox"];
15
13
 
16
- let event: unknown;
17
- try {
18
- event = JSON.parse(line);
19
- } catch {
20
- continue;
21
- }
22
-
23
- if (typeof event !== "object" || event === null || Array.isArray(event)) {
24
- continue;
25
- }
26
-
27
- const rec = event as Record<string, unknown>;
28
- const eventType = rec.type;
14
+ // ---------------------------------------------------------------------------
15
+ // Subprocess spawning (injectable for tests)
16
+ // ---------------------------------------------------------------------------
29
17
 
30
- if (eventType === "turn.failed") {
31
- const nested = rec.error;
32
- if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
33
- const msg = (nested as Record<string, unknown>).message;
34
- if (typeof msg === "string" && msg) return msg;
18
+ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
19
+ const reader = stream.getReader();
20
+ const decoder = new TextDecoder();
21
+ let buffer = "";
22
+ try {
23
+ while (true) {
24
+ const { done, value } = await reader.read();
25
+ if (done) break;
26
+ buffer += decoder.decode(value, { stream: true });
27
+ const lines = buffer.split("\n");
28
+ buffer = lines.pop()!;
29
+ for (const line of lines) {
30
+ if (line.trim()) yield line;
35
31
  }
36
- } else if (eventType === "error") {
37
- const msg = rec.message;
38
- if (typeof msg === "string" && msg) return msg;
39
32
  }
33
+ if (buffer.trim()) yield buffer;
34
+ } finally {
35
+ reader.releaseLock();
40
36
  }
37
+ }
38
+
39
+ function defaultSpawn(cmd: string[], stdin: string): ChildResult {
40
+ const proc = Bun.spawn(cmd, {
41
+ stdin: new Blob([stdin]),
42
+ stdout: "pipe",
43
+ stderr: "ignore",
44
+ });
45
+ return {
46
+ lines: readLines(proc.stdout),
47
+ exitCode: proc.exited,
48
+ };
49
+ }
50
+
51
+ let spawnFn: SpawnFn = defaultSpawn;
41
52
 
42
- return "codex exited with non-zero status";
53
+ /** @internal Replace the spawn function. Returns a restore function. */
54
+ export function _setSpawnFn(fn: SpawnFn): () => void {
55
+ const prev = spawnFn;
56
+ spawnFn = fn;
57
+ return () => {
58
+ spawnFn = prev;
59
+ };
43
60
  }
44
61
 
62
+ // ---------------------------------------------------------------------------
63
+ // Runner implementation
64
+ // ---------------------------------------------------------------------------
65
+
45
66
  export class CodexRunner implements Runner {
46
- logEnvironment(_logger: LogWriter): void {
47
- // No environment-specific config to log for Codex.
67
+ private logger: LogWriter;
68
+
69
+ constructor(logger: LogWriter) {
70
+ this.logger = logger;
48
71
  }
49
72
 
50
73
  async run(prompt: string): Promise<RunResult> {
51
74
  const start = performance.now();
75
+ let child: ChildResult;
76
+
77
+ try {
78
+ child = spawnFn(CLI_ARGS, prompt);
79
+ } catch (error: unknown) {
80
+ const durationSeconds = (performance.now() - start) / 1000;
81
+ const msg = error instanceof Error ? error.message : String(error);
82
+
83
+ if (msg.includes("ENOENT") || (error as NodeJS.ErrnoException)?.code === "ENOENT") {
84
+ return { success: false, durationSeconds, error: "codex binary not found in PATH" };
85
+ }
86
+ return { success: false, durationSeconds, error: msg };
87
+ }
88
+
89
+ let threadId: string | undefined;
90
+ let turns = 0;
91
+ let inputTokens = 0;
92
+ let outputTokens = 0;
93
+ let failed = false;
94
+ let completed = false;
95
+ let errorMessage: string | undefined;
52
96
 
53
- let proc: Awaited<ReturnType<typeof Bun.spawn>>;
54
97
  try {
55
- proc = Bun.spawn(["codex", "exec", "--full-auto", "--json", "--ephemeral", "-"], {
56
- stdin: new Response(prompt),
57
- stdout: "pipe",
58
- stderr: "pipe",
59
- });
60
- } catch {
98
+ for await (const line of child.lines) {
99
+ let parsed: Record<string, unknown>;
100
+ try {
101
+ parsed = JSON.parse(line);
102
+ } catch {
103
+ this.logger.logFile(`[codex] Malformed JSON line: ${line}`);
104
+ continue;
105
+ }
106
+
107
+ this.logger.logFile(`[codex] ${parsed.type}: ${JSON.stringify(parsed)}`);
108
+
109
+ switch (parsed.type) {
110
+ case "thread.started":
111
+ threadId = parsed.thread_id as string;
112
+ break;
113
+ case "turn.started":
114
+ turns++;
115
+ completed = false;
116
+ break;
117
+ case "turn.completed":
118
+ completed = true;
119
+ this.logger.log(`[codex] Turn ${turns} completed`);
120
+ {
121
+ const usage = parsed.usage as
122
+ | { input_tokens?: number; output_tokens?: number }
123
+ | undefined;
124
+ inputTokens += usage?.input_tokens ?? 0;
125
+ outputTokens += usage?.output_tokens ?? 0;
126
+ }
127
+ break;
128
+ case "turn.failed":
129
+ failed = true;
130
+ {
131
+ const err = parsed.error as { message?: string } | undefined;
132
+ errorMessage = err?.message ?? "codex turn failed";
133
+ }
134
+ this.logger.log(`[codex] Error: ${errorMessage}`);
135
+ break;
136
+ case "error":
137
+ failed = true;
138
+ errorMessage = parsed.message as string;
139
+ this.logger.log(`[codex] Error: ${errorMessage}`);
140
+ break;
141
+ case "item.started":
142
+ case "item.completed":
143
+ this.logItem(parsed.type, parsed.item as Record<string, unknown>);
144
+ break;
145
+ }
146
+ }
147
+ } catch (error: unknown) {
148
+ const durationSeconds = (performance.now() - start) / 1000;
149
+ const msg = error instanceof Error ? error.message : String(error);
150
+ return { success: false, durationSeconds, error: msg };
151
+ }
152
+
153
+ const exitCode = await child.exitCode;
154
+ const durationSeconds = (performance.now() - start) / 1000;
155
+
156
+ if (exitCode !== 0) {
157
+ return { success: false, durationSeconds, error: `codex exited with code ${exitCode}` };
158
+ }
159
+
160
+ const status = failed ? "failed" : completed ? "success" : "incomplete";
161
+ this.logger.logFile(
162
+ `[codex] Result: ${status}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
163
+ );
164
+
165
+ if (failed) {
61
166
  return {
62
167
  success: false,
63
- exitCode: 1,
64
- durationSeconds: (performance.now() - start) / 1000,
65
- error: "codex binary not found in PATH",
168
+ durationSeconds,
169
+ error: errorMessage ?? "codex run failed",
170
+ turns,
171
+ inputTokens,
172
+ outputTokens,
173
+ sessionId: threadId,
66
174
  };
67
175
  }
68
176
 
69
- const exitCode = await proc.exited;
70
- const durationSeconds = (performance.now() - start) / 1000;
71
-
72
- if (exitCode === 0) {
73
- return { success: true, exitCode: 0, durationSeconds };
177
+ if (!completed) {
178
+ return {
179
+ success: false,
180
+ durationSeconds,
181
+ error: "no terminal event received from Codex",
182
+ turns,
183
+ inputTokens,
184
+ outputTokens,
185
+ sessionId: threadId,
186
+ };
74
187
  }
75
188
 
76
- const stdout = await new Response(proc.stdout as ReadableStream).text();
77
- const error = extractError(stdout);
189
+ return {
190
+ success: true,
191
+ durationSeconds,
192
+ turns,
193
+ inputTokens,
194
+ outputTokens,
195
+ sessionId: threadId,
196
+ };
197
+ }
78
198
 
79
- return { success: false, exitCode, durationSeconds, error };
199
+ private logItem(phase: string, item: Record<string, unknown>): void {
200
+ switch (item.type) {
201
+ case "command_execution":
202
+ if (phase === "item.started" && typeof item.command === "string") {
203
+ this.logger.log(`[codex] Bash: ${item.command.slice(0, 80)}`);
204
+ }
205
+ break;
206
+ case "file_change":
207
+ if (phase === "item.completed" && Array.isArray(item.changes)) {
208
+ for (const change of item.changes) {
209
+ if (typeof change?.path === "string") {
210
+ this.logger.log(`[codex] Edit: ${change.path}`);
211
+ }
212
+ }
213
+ }
214
+ break;
215
+ case "reasoning":
216
+ this.logger.logFile(`[codex] reasoning: ${item.text}`);
217
+ break;
218
+ case "error":
219
+ this.logger.log(`[codex] Error: ${item.message}`);
220
+ break;
221
+ }
80
222
  }
81
223
  }
@@ -2,20 +2,20 @@
2
2
  * Runner registry — maps runner names to their implementations.
3
3
  */
4
4
 
5
- import type { Runner } from "../types.js";
5
+ import type { LogWriter, Runner } from "../types.js";
6
6
  import { ClaudeRunner } from "./claude.js";
7
7
  import { CodexRunner } from "./codex.js";
8
8
 
9
- const RUNNERS: Record<string, new () => Runner> = {
9
+ const RUNNERS: Record<string, new (logger: LogWriter) => Runner> = {
10
10
  claude: ClaudeRunner,
11
11
  codex: CodexRunner,
12
12
  };
13
13
 
14
- export function getRunner(name: string): Runner {
14
+ export function getRunner(name: string, logger: LogWriter): Runner {
15
15
  const Cls = RUNNERS[name];
16
16
  if (!Cls) {
17
17
  const available = Object.keys(RUNNERS).sort().join(", ");
18
18
  throw new Error(`Unknown runner '${name}'. Available runners: ${available}`);
19
19
  }
20
- return new Cls();
20
+ return new Cls(logger);
21
21
  }
@@ -29,7 +29,7 @@ Check with the user that these modules match their expectations. Ask which modul
29
29
 
30
30
  Ask the user where to save the PRD:
31
31
 
32
- - **Local file** — Save as `.stonecut/specs/<name>/prd.md` in the project. Ask the user: "What should I name this spec?" The name can be anything — a ticket ID (e.g., `ASC-1`), a descriptive slug (e.g., `auth-refactor`), or whatever fits. Create the `.stonecut/specs/<name>/` directory if it doesn't exist.
32
+ - **Local file** — Save as `.stonecut/specs/<name>/prd.md` in the project. Ask the user: "What should I name this spec?" The name can be anything — a ticket ID, a descriptive slug (e.g., `auth-refactor`), or whatever fits. Create the `.stonecut/specs/<name>/` directory if it doesn't exist.
33
33
  - **GitHub issue** — Create a GitHub issue using `gh issue create --label prd`. Before creating, ensure the `prd` label exists:
34
34
 
35
35
  ```bash
@@ -39,8 +39,6 @@ Ask the user where to save the PRD:
39
39
  fi
40
40
  ```
41
41
 
42
- If the project already has a `.stonecut/` directory, default to suggesting local. Otherwise, just ask.
43
-
44
42
  ### 6. Documentation impact check
45
43
 
46
44
  Before writing the PRD, ensure that documentation impact has been explicitly addressed. Based on everything you've learned from the interview and codebase exploration, analyze which user-facing documentation artifacts (README, CLI help text, docs/ content) would be affected by these changes.
package/src/types.ts CHANGED
@@ -2,13 +2,25 @@
2
2
  * Core types and interfaces shared across the Stonecut CLI.
3
3
  */
4
4
 
5
+ /** Return type from SpawnFn — stdout line stream and process exit code. */
6
+ export interface ChildResult {
7
+ lines: AsyncIterable<string>;
8
+ exitCode: Promise<number>;
9
+ }
10
+
11
+ /** Injectable subprocess factory for runner testing. */
12
+ export type SpawnFn = (cmd: string[], stdin: string) => ChildResult;
13
+
5
14
  /** Structured result from a single runner execution. */
6
15
  export interface RunResult {
7
16
  success: boolean;
8
- exitCode: number;
9
17
  durationSeconds: number;
10
18
  output?: string;
11
19
  error?: string;
20
+ turns?: number;
21
+ inputTokens?: number;
22
+ outputTokens?: number;
23
+ sessionId?: string;
12
24
  }
13
25
 
14
26
  /** Result of a single afk iteration. */
@@ -18,12 +30,14 @@ export interface IterationResult {
18
30
  success: boolean;
19
31
  elapsedSeconds: number;
20
32
  error?: string;
33
+ turns?: number;
34
+ inputTokens?: number;
35
+ outputTokens?: number;
21
36
  }
22
37
 
23
38
  /** Protocol that all runner adapters must satisfy. */
24
39
  export interface Runner {
25
40
  run(prompt: string): Promise<RunResult>;
26
- logEnvironment(logger: LogWriter): void;
27
41
  }
28
42
 
29
43
  /** Snapshot of the working tree state before a runner session. */
@@ -58,6 +72,8 @@ export interface Source<T> {
58
72
  /** Logger interface for session-scoped logging. */
59
73
  export interface LogWriter {
60
74
  log(message: string): void;
75
+ /** Write a message only to the log file (not console). */
76
+ logFile(message: string): void;
61
77
  close(): void;
62
78
  }
63
79