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 +1 -1
- package/src/execute.ts +23 -7
- package/src/logger.ts +8 -3
- package/src/runner.ts +38 -2
- package/src/runners/claude.ts +206 -59
- package/src/runners/codex.ts +193 -51
- package/src/runners/index.ts +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -3
- package/src/types.ts +18 -2
package/package.json
CHANGED
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
|
|
106
|
-
const
|
|
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(
|
|
18
|
-
const
|
|
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, `${
|
|
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
|
-
|
|
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,241 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClaudeRunner — adapter for Claude Code CLI.
|
|
2
|
+
* ClaudeRunner — adapter for Claude Code via CLI subprocess.
|
|
3
3
|
*
|
|
4
|
-
* Spawns
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
138
|
+
if (exitCode !== 0) {
|
|
139
|
+
return { success: false, durationSeconds, error: `claude exited with code ${exitCode}` };
|
|
140
|
+
}
|
|
52
141
|
|
|
53
|
-
if (
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
68
|
-
exitCode,
|
|
158
|
+
success: true,
|
|
69
159
|
durationSeconds,
|
|
70
|
-
output,
|
|
71
|
-
|
|
160
|
+
output: result.result as string | undefined,
|
|
161
|
+
turns,
|
|
162
|
+
inputTokens,
|
|
163
|
+
outputTokens,
|
|
164
|
+
sessionId,
|
|
72
165
|
};
|
|
73
166
|
}
|
|
74
167
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
}
|
package/src/runners/codex.ts
CHANGED
|
@@ -1,81 +1,223 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CodexRunner — adapter for
|
|
2
|
+
* CodexRunner — adapter for Codex via CLI subprocess.
|
|
3
3
|
*
|
|
4
|
-
* Spawns
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
168
|
+
durationSeconds,
|
|
169
|
+
error: errorMessage ?? "codex run failed",
|
|
170
|
+
turns,
|
|
171
|
+
inputTokens,
|
|
172
|
+
outputTokens,
|
|
173
|
+
sessionId: threadId,
|
|
66
174
|
};
|
|
67
175
|
}
|
|
68
176
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
189
|
+
return {
|
|
190
|
+
success: true,
|
|
191
|
+
durationSeconds,
|
|
192
|
+
turns,
|
|
193
|
+
inputTokens,
|
|
194
|
+
outputTokens,
|
|
195
|
+
sessionId: threadId,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
78
198
|
|
|
79
|
-
|
|
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
|
}
|
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
|
@@ -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
|
|