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 +3 -1
- package/src/execute.ts +21 -4
- package/src/logger.ts +4 -0
- package/src/runner.ts +38 -2
- package/src/runners/claude.ts +140 -56
- package/src/runners/codex.ts +134 -54
- package/src/runners/index.ts +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -3
- package/src/types.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stonecut",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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("");
|
package/src/runners/claude.ts
CHANGED
|
@@ -1,94 +1,178 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClaudeRunner — adapter for Claude Code
|
|
2
|
+
* ClaudeRunner — adapter for Claude Code via the Agent SDK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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:
|
|
56
|
-
exitCode,
|
|
96
|
+
success: true,
|
|
57
97
|
durationSeconds,
|
|
58
|
-
|
|
98
|
+
output: result.result,
|
|
99
|
+
turns,
|
|
100
|
+
inputTokens,
|
|
101
|
+
outputTokens,
|
|
102
|
+
sessionId,
|
|
59
103
|
};
|
|
60
104
|
}
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|
package/src/runners/codex.ts
CHANGED
|
@@ -1,81 +1,161 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CodexRunner — adapter for OpenAI Codex
|
|
2
|
+
* CodexRunner — adapter for OpenAI Codex via the Agent SDK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
44
|
+
const codex = createCodex();
|
|
45
|
+
const thread = codex.startThread({
|
|
46
|
+
approvalPolicy: "never",
|
|
47
|
+
sandboxMode: "danger-full-access",
|
|
59
48
|
});
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
durationSeconds,
|
|
105
|
+
error: errorMessage ?? "codex run failed",
|
|
106
|
+
turns,
|
|
107
|
+
inputTokens,
|
|
108
|
+
outputTokens,
|
|
109
|
+
sessionId: threadId,
|
|
66
110
|
};
|
|
67
111
|
}
|
|
68
112
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/runners/index.ts
CHANGED
|
@@ -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
|
|
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
|
|