stonecut 1.3.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonecut",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI that drives PRD-driven development with agentic coding CLIs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,9 @@
32
32
  "prepare": "husky"
33
33
  },
34
34
  "dependencies": {
35
+ "@anthropic-ai/claude-agent-sdk": "^0.2.92",
35
36
  "@clack/prompts": "^0.10.0",
37
+ "@openai/codex-sdk": "^0.118.0",
36
38
  "commander": "^13.1.0"
37
39
  },
38
40
  "lint-staged": {
package/src/execute.ts CHANGED
@@ -14,7 +14,7 @@ import { LocalSource } from "./local";
14
14
  import { slugifyBranchComponent } from "./naming";
15
15
  import { renderLocal } from "./prompt";
16
16
  import { Logger } from "./logger";
17
- import { defaultGitOps, runAfkLoop } from "./runner";
17
+ import { defaultGitOps, formatTokens, runAfkLoop } from "./runner";
18
18
  import { getRunner } from "./runners/index";
19
19
  import type { Issue, IterationResult, Session } from "./types";
20
20
 
@@ -22,6 +22,22 @@ import type { Issue, IterationResult, Session } from "./types";
22
22
  // Stonecut report
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
+ /** Build the metrics parenthetical for a report line. */
26
+ function formatMetrics(r: IterationResult): string {
27
+ const parts: string[] = [];
28
+ if (r.turns != null) {
29
+ parts.push(`${r.turns} turns`);
30
+ }
31
+ const totalTokens =
32
+ r.inputTokens != null || r.outputTokens != null
33
+ ? (r.inputTokens ?? 0) + (r.outputTokens ?? 0)
34
+ : undefined;
35
+ if (totalTokens != null) {
36
+ parts.push(`${formatTokens(totalTokens)} tokens`);
37
+ }
38
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
39
+ }
40
+
25
41
  /**
26
42
  * Build the Stonecut Report section for a PR body.
27
43
  */
@@ -32,11 +48,12 @@ export function buildReport(
32
48
  ): string {
33
49
  const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
34
50
  for (const r of results) {
51
+ const metrics = formatMetrics(r);
35
52
  if (r.success) {
36
- lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed`);
53
+ lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed${metrics}`);
37
54
  } else {
38
55
  const reason = r.error || "unknown error";
39
- lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}`);
56
+ lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}${metrics}`);
40
57
  }
41
58
  }
42
59
 
@@ -100,10 +117,10 @@ export async function executeLocal(
100
117
  iterations: number | "all",
101
118
  runnerName: string,
102
119
  ): Promise<void> {
103
- const runner = getRunner(runnerName);
104
120
  const source = new LocalSource(name);
105
121
  const prdIdentifier = slugifyBranchComponent(name) || "spec";
106
122
  const logger = new Logger(prdIdentifier);
123
+ const runner = getRunner(runnerName, logger);
107
124
 
108
125
  const session: Session = { logger, git: defaultGitOps, runner, runnerName };
109
126
 
package/src/logger.ts CHANGED
@@ -29,6 +29,10 @@ export class Logger implements LogWriter {
29
29
 
30
30
  log(message: string): void {
31
31
  console.log(message);
32
+ this.logFile(message);
33
+ }
34
+
35
+ logFile(message: string): void {
32
36
  const ts = new Date().toISOString();
33
37
  const fileWasMissing = !existsSync(this.filePath);
34
38
  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,178 @@
1
1
  /**
2
- * ClaudeRunner — adapter for Claude Code CLI.
2
+ * ClaudeRunner — adapter for Claude Code via the Agent SDK.
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
+ * Calls query() from @anthropic-ai/claude-agent-sdk and consumes the
5
+ * async message stream internally. Console gets concise progress
6
+ * (tool calls, turn boundaries, errors); the log file captures the
7
+ * full event stream for debugging.
7
8
  */
8
9
 
10
+ import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
11
+ import type {
12
+ Query,
13
+ SDKAssistantMessage,
14
+ SDKMessage,
15
+ SDKPartialAssistantMessage,
16
+ SDKResultMessage,
17
+ } from "@anthropic-ai/claude-agent-sdk";
9
18
  import type { LogWriter, Runner, RunResult } from "../types.js";
10
19
 
20
+ const ALLOWED_TOOLS = ["Bash", "Edit", "Read", "Write", "Glob", "Grep"];
21
+
22
+ type QueryFn = (params: { prompt: string; options?: Record<string, unknown> }) => Query;
23
+ let queryFn: QueryFn = sdkQuery;
24
+
25
+ /** @internal Replace the SDK query function. Returns a restore function. */
26
+ export function _setQueryFn(fn: QueryFn): () => void {
27
+ const prev = queryFn;
28
+ queryFn = fn;
29
+ return () => {
30
+ queryFn = prev;
31
+ };
32
+ }
33
+
11
34
  const ERROR_MESSAGES: Record<string, string> = {
12
35
  error_max_turns: "max turns exceeded",
13
36
  error_max_budget_usd: "max budget exceeded",
37
+ error_during_execution: "execution error",
38
+ error_max_structured_output_retries: "max structured output retries exceeded",
14
39
  };
15
40
 
16
41
  export class ClaudeRunner implements Runner {
17
- logEnvironment(logger: LogWriter): void {
42
+ private logger: LogWriter;
43
+
44
+ constructor(logger: LogWriter) {
45
+ this.logger = logger;
18
46
  const configDir = process.env.CLAUDE_CONFIG_DIR || "~/.claude (default)";
19
47
  logger.log(`Claude config: ${configDir}`);
20
48
  }
21
49
 
22
50
  async run(prompt: string): Promise<RunResult> {
23
51
  const start = performance.now();
52
+ let result: SDKResultMessage | undefined;
24
53
 
25
- let proc: Awaited<ReturnType<typeof Bun.spawn>>;
26
54
  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
- };
55
+ const stream = queryFn({
56
+ prompt,
57
+ options: {
58
+ allowedTools: ALLOWED_TOOLS,
59
+ permissionMode: "acceptEdits",
60
+ },
61
+ });
62
+
63
+ for await (const message of stream) {
64
+ this.handleMessage(message);
65
+ if (message.type === "result") {
66
+ result = message;
67
+ }
68
+ }
69
+ } catch (error: unknown) {
70
+ const durationSeconds = (performance.now() - start) / 1000;
71
+ const msg = error instanceof Error ? error.message : String(error);
72
+
73
+ if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
74
+ return { success: false, durationSeconds, error: "claude binary not found in PATH" };
75
+ }
76
+ return { success: false, durationSeconds, error: msg };
45
77
  }
46
78
 
47
- const exitCode = await proc.exited;
48
79
  const durationSeconds = (performance.now() - start) / 1000;
49
80
 
50
- const stdout = await new Response(proc.stdout as ReadableStream).text();
51
- const output = stdout || undefined;
81
+ if (!result) {
82
+ return { success: false, durationSeconds, error: "no result message received" };
83
+ }
84
+
85
+ const sessionId = result.session_id;
86
+ const turns = result.num_turns;
87
+ const inputTokens = result.usage.input_tokens;
88
+ const outputTokens = result.usage.output_tokens;
52
89
 
53
- if (output === undefined) {
90
+ this.logger.logFile(
91
+ `[claude] Result: ${result.subtype}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
92
+ );
93
+
94
+ if (result.subtype === "success") {
54
95
  return {
55
- success: false,
56
- exitCode,
96
+ success: true,
57
97
  durationSeconds,
58
- error: `no output (exit code ${exitCode})`,
98
+ output: result.result,
99
+ turns,
100
+ inputTokens,
101
+ outputTokens,
102
+ sessionId,
59
103
  };
60
104
  }
61
105
 
62
- let data: unknown;
63
- try {
64
- data = JSON.parse(output);
65
- } catch {
66
- return {
67
- success: false,
68
- exitCode,
69
- durationSeconds,
70
- output,
71
- error: "malformed JSON output",
72
- };
106
+ const error = ERROR_MESSAGES[result.subtype] ?? `failed (${result.subtype})`;
107
+ const detail = result.errors?.length ? `: ${result.errors.join("; ")}` : "";
108
+ return {
109
+ success: false,
110
+ durationSeconds,
111
+ error: error + detail,
112
+ turns,
113
+ inputTokens,
114
+ outputTokens,
115
+ sessionId,
116
+ };
117
+ }
118
+
119
+ private handleMessage(message: SDKMessage): void {
120
+ switch (message.type) {
121
+ case "assistant":
122
+ this.logAssistant(message);
123
+ break;
124
+ case "stream_event":
125
+ this.logStreamEvent(message);
126
+ break;
127
+ case "result":
128
+ break;
129
+ default:
130
+ this.logger.logFile(`[claude] ${message.type}: ${JSON.stringify(message)}`);
131
+ break;
73
132
  }
133
+ }
74
134
 
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
- };
135
+ private logAssistant(message: SDKAssistantMessage): void {
136
+ if (message.error) {
137
+ this.logger.log(`[claude] Error: ${message.error}`);
83
138
  }
84
139
 
85
- const subtype = (data as Record<string, unknown>).subtype ?? "";
86
- if (subtype === "success") {
87
- return { success: true, exitCode, durationSeconds, output };
140
+ for (const block of message.message.content) {
141
+ if (typeof block === "string") continue;
142
+ if (block.type === "tool_use") {
143
+ const target = this.toolTarget(block.name, block.input as Record<string, unknown>);
144
+ this.logger.log(`[claude] ${block.name}${target ? `: ${target}` : ""}`);
145
+ }
88
146
  }
89
147
 
90
- const error =
91
- ERROR_MESSAGES[subtype as string] ?? `failed (${(subtype as string) || "unknown"})`;
92
- return { success: false, exitCode, durationSeconds, output, error };
148
+ this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(message.message.content)}`);
149
+ }
150
+
151
+ private logStreamEvent(message: SDKPartialAssistantMessage): void {
152
+ const event = message.event;
153
+ if (event.type === "content_block_delta") {
154
+ const delta = event.delta;
155
+ if ("text" in delta && typeof delta.text === "string") {
156
+ this.logger.logFile(`[claude] text: ${delta.text}`);
157
+ } else if ("thinking" in delta && typeof delta.thinking === "string") {
158
+ this.logger.logFile(`[claude] thinking: ${delta.thinking}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ private toolTarget(name: string, input: Record<string, unknown>): string | undefined {
164
+ switch (name) {
165
+ case "Bash":
166
+ return typeof input.command === "string" ? input.command.slice(0, 80) : undefined;
167
+ case "Edit":
168
+ case "Read":
169
+ case "Write":
170
+ return typeof input.file_path === "string" ? input.file_path : undefined;
171
+ case "Glob":
172
+ case "Grep":
173
+ return typeof input.pattern === "string" ? input.pattern : undefined;
174
+ default:
175
+ return undefined;
176
+ }
93
177
  }
94
178
  }
@@ -1,81 +1,161 @@
1
1
  /**
2
- * CodexRunner — adapter for OpenAI Codex CLI.
2
+ * CodexRunner — adapter for OpenAI Codex via the Agent SDK.
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
+ * Creates a Codex client and thread per run(), consumes the async event
5
+ * stream internally. Console gets concise progress (tool calls, file
6
+ * changes, turn boundaries, errors); the log file captures the full
7
+ * event stream for debugging.
7
8
  */
8
9
 
10
+ import { Codex, type ThreadEvent, type ThreadItem } from "@openai/codex-sdk";
9
11
  import type { LogWriter, Runner, RunResult } from "../types.js";
10
12
 
11
- function extractError(stdout: string): string {
12
- for (const raw of stdout.split("\n")) {
13
- const line = raw.trim();
14
- if (!line) continue;
13
+ type CodexFactory = (...args: ConstructorParameters<typeof Codex>) => InstanceType<typeof Codex>;
14
+ let createCodex: CodexFactory = (...args) => new Codex(...args);
15
15
 
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;
29
-
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;
35
- }
36
- } else if (eventType === "error") {
37
- const msg = rec.message;
38
- if (typeof msg === "string" && msg) return msg;
39
- }
40
- }
41
-
42
- return "codex exited with non-zero status";
16
+ /** @internal Replace the Codex factory. Returns a restore function. */
17
+ export function _setCodexFactory(fn: CodexFactory): () => void {
18
+ const prev = createCodex;
19
+ createCodex = fn;
20
+ return () => {
21
+ createCodex = prev;
22
+ };
43
23
  }
44
24
 
45
25
  export class CodexRunner implements Runner {
46
- logEnvironment(_logger: LogWriter): void {
47
- // No environment-specific config to log for Codex.
26
+ private logger: LogWriter;
27
+
28
+ constructor(logger: LogWriter) {
29
+ this.logger = logger;
48
30
  }
49
31
 
50
32
  async run(prompt: string): Promise<RunResult> {
51
33
  const start = performance.now();
52
34
 
53
- let proc: Awaited<ReturnType<typeof Bun.spawn>>;
35
+ let threadId: string | undefined;
36
+ let turns = 0;
37
+ let inputTokens = 0;
38
+ let outputTokens = 0;
39
+ let failed = false;
40
+ let completed = false;
41
+ let errorMessage: string | undefined;
42
+
54
43
  try {
55
- proc = Bun.spawn(["codex", "exec", "--full-auto", "--json", "--ephemeral", "-"], {
56
- stdin: new Response(prompt),
57
- stdout: "pipe",
58
- stderr: "pipe",
44
+ const codex = createCodex();
45
+ const thread = codex.startThread({
46
+ approvalPolicy: "never",
47
+ sandboxMode: "danger-full-access",
59
48
  });
60
- } catch {
49
+
50
+ const { events } = await thread.runStreamed(prompt);
51
+
52
+ for await (const event of events) {
53
+ this.handleEvent(event);
54
+
55
+ switch (event.type) {
56
+ case "thread.started":
57
+ threadId = event.thread_id;
58
+ break;
59
+ case "turn.started":
60
+ turns++;
61
+ break;
62
+ case "turn.completed":
63
+ completed = true;
64
+ this.logger.log(`[codex] Turn ${turns} completed`);
65
+ inputTokens += event.usage.input_tokens;
66
+ outputTokens += event.usage.output_tokens;
67
+ break;
68
+ case "turn.failed":
69
+ failed = true;
70
+ errorMessage = event.error.message;
71
+ this.logger.log(`[codex] Error: ${errorMessage}`);
72
+ break;
73
+ case "error":
74
+ failed = true;
75
+ errorMessage = event.message;
76
+ this.logger.log(`[codex] Error: ${errorMessage}`);
77
+ break;
78
+ case "item.started":
79
+ case "item.completed":
80
+ this.logItem(event.type, event.item);
81
+ break;
82
+ }
83
+ }
84
+ } catch (error: unknown) {
85
+ const durationSeconds = (performance.now() - start) / 1000;
86
+ const msg = error instanceof Error ? error.message : String(error);
87
+
88
+ if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
89
+ return { success: false, durationSeconds, error: "codex binary not found in PATH" };
90
+ }
91
+ return { success: false, durationSeconds, error: msg };
92
+ }
93
+
94
+ const durationSeconds = (performance.now() - start) / 1000;
95
+
96
+ const status = failed ? "failed" : completed ? "success" : "incomplete";
97
+ this.logger.logFile(
98
+ `[codex] Result: ${status}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
99
+ );
100
+
101
+ if (failed) {
61
102
  return {
62
103
  success: false,
63
- exitCode: 1,
64
- durationSeconds: (performance.now() - start) / 1000,
65
- error: "codex binary not found in PATH",
104
+ durationSeconds,
105
+ error: errorMessage ?? "codex run failed",
106
+ turns,
107
+ inputTokens,
108
+ outputTokens,
109
+ sessionId: threadId,
66
110
  };
67
111
  }
68
112
 
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 };
113
+ if (!completed) {
114
+ return {
115
+ success: false,
116
+ durationSeconds,
117
+ error: "no terminal event received from Codex",
118
+ turns,
119
+ inputTokens,
120
+ outputTokens,
121
+ sessionId: threadId,
122
+ };
74
123
  }
75
124
 
76
- const stdout = await new Response(proc.stdout as ReadableStream).text();
77
- const error = extractError(stdout);
125
+ return {
126
+ success: true,
127
+ durationSeconds,
128
+ turns,
129
+ inputTokens,
130
+ outputTokens,
131
+ sessionId: threadId,
132
+ };
133
+ }
134
+
135
+ private handleEvent(event: ThreadEvent): void {
136
+ this.logger.logFile(`[codex] ${event.type}: ${JSON.stringify(event)}`);
137
+ }
78
138
 
79
- return { success: false, exitCode, durationSeconds, error };
139
+ private logItem(phase: "item.started" | "item.completed", item: ThreadItem): void {
140
+ switch (item.type) {
141
+ case "command_execution":
142
+ if (phase === "item.started") {
143
+ this.logger.log(`[codex] Bash: ${item.command.slice(0, 80)}`);
144
+ }
145
+ break;
146
+ case "file_change":
147
+ if (phase === "item.completed") {
148
+ for (const change of item.changes) {
149
+ this.logger.log(`[codex] Edit: ${change.path}`);
150
+ }
151
+ }
152
+ break;
153
+ case "reasoning":
154
+ this.logger.logFile(`[codex] reasoning: ${item.text}`);
155
+ break;
156
+ case "error":
157
+ this.logger.log(`[codex] Error: ${item.message}`);
158
+ break;
159
+ }
80
160
  }
81
161
  }
@@ -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
@@ -5,10 +5,13 @@
5
5
  /** Structured result from a single runner execution. */
6
6
  export interface RunResult {
7
7
  success: boolean;
8
- exitCode: number;
9
8
  durationSeconds: number;
10
9
  output?: string;
11
10
  error?: string;
11
+ turns?: number;
12
+ inputTokens?: number;
13
+ outputTokens?: number;
14
+ sessionId?: string;
12
15
  }
13
16
 
14
17
  /** Result of a single afk iteration. */
@@ -18,12 +21,14 @@ export interface IterationResult {
18
21
  success: boolean;
19
22
  elapsedSeconds: number;
20
23
  error?: string;
24
+ turns?: number;
25
+ inputTokens?: number;
26
+ outputTokens?: number;
21
27
  }
22
28
 
23
29
  /** Protocol that all runner adapters must satisfy. */
24
30
  export interface Runner {
25
31
  run(prompt: string): Promise<RunResult>;
26
- logEnvironment(logger: LogWriter): void;
27
32
  }
28
33
 
29
34
  /** Snapshot of the working tree state before a runner session. */
@@ -58,6 +63,8 @@ export interface Source<T> {
58
63
  /** Logger interface for session-scoped logging. */
59
64
  export interface LogWriter {
60
65
  log(message: string): void;
66
+ /** Write a message only to the log file (not console). */
67
+ logFile(message: string): void;
61
68
  close(): void;
62
69
  }
63
70