stonecut 1.4.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.4.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": {
@@ -32,9 +32,7 @@
32
32
  "prepare": "husky"
33
33
  },
34
34
  "dependencies": {
35
- "@anthropic-ai/claude-agent-sdk": "^0.2.92",
36
35
  "@clack/prompts": "^0.10.0",
37
- "@openai/codex-sdk": "^0.118.0",
38
36
  "commander": "^13.1.0"
39
37
  },
40
38
  "lint-staged": {
package/src/execute.ts CHANGED
@@ -11,7 +11,6 @@
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
16
  import { defaultGitOps, formatTokens, runAfkLoop } from "./runner";
@@ -118,13 +117,13 @@ export async function executeLocal(
118
117
  runnerName: string,
119
118
  ): Promise<void> {
120
119
  const source = new LocalSource(name);
121
- const prdIdentifier = slugifyBranchComponent(name) || "spec";
122
- const logger = new Logger(prdIdentifier);
120
+ const logger = new Logger();
123
121
  const runner = getRunner(runnerName, logger);
124
122
 
125
123
  const session: Session = { logger, git: defaultGitOps, runner, runnerName };
126
124
 
127
125
  try {
126
+ logger.logFile(`PRD: ${name}`);
128
127
  checkoutOrCreateBranch(branch);
129
128
  console.log("");
130
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, "");
@@ -1,36 +1,79 @@
1
1
  /**
2
- * ClaudeRunner — adapter for Claude Code via the Agent SDK.
2
+ * ClaudeRunner — adapter for Claude Code via CLI subprocess.
3
3
  *
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.
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.
8
8
  */
9
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";
18
- import type { LogWriter, Runner, RunResult } from "../types.js";
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;
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;
29
68
  return () => {
30
- queryFn = prev;
69
+ spawnFn = prev;
31
70
  };
32
71
  }
33
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Error subtype mapping
75
+ // ---------------------------------------------------------------------------
76
+
34
77
  const ERROR_MESSAGES: Record<string, string> = {
35
78
  error_max_turns: "max turns exceeded",
36
79
  error_max_budget_usd: "max budget exceeded",
@@ -38,34 +81,23 @@ const ERROR_MESSAGES: Record<string, string> = {
38
81
  error_max_structured_output_retries: "max structured output retries exceeded",
39
82
  };
40
83
 
84
+ // ---------------------------------------------------------------------------
85
+ // Runner implementation
86
+ // ---------------------------------------------------------------------------
87
+
41
88
  export class ClaudeRunner implements Runner {
42
89
  private logger: LogWriter;
43
90
 
44
91
  constructor(logger: LogWriter) {
45
92
  this.logger = logger;
46
- const configDir = process.env.CLAUDE_CONFIG_DIR || "~/.claude (default)";
47
- logger.log(`Claude config: ${configDir}`);
48
93
  }
49
94
 
50
95
  async run(prompt: string): Promise<RunResult> {
51
96
  const start = performance.now();
52
- let result: SDKResultMessage | undefined;
97
+ let child: ChildResult;
53
98
 
54
99
  try {
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
- }
100
+ child = spawnFn(CLI_ARGS, prompt);
69
101
  } catch (error: unknown) {
70
102
  const durationSeconds = (performance.now() - start) / 1000;
71
103
  const msg = error instanceof Error ? error.message : String(error);
@@ -76,16 +108,46 @@ export class ClaudeRunner implements Runner {
76
108
  return { success: false, durationSeconds, error: msg };
77
109
  }
78
110
 
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;
79
136
  const durationSeconds = (performance.now() - start) / 1000;
80
137
 
138
+ if (exitCode !== 0) {
139
+ return { success: false, durationSeconds, error: `claude exited with code ${exitCode}` };
140
+ }
141
+
81
142
  if (!result) {
82
143
  return { success: false, durationSeconds, error: "no result message received" };
83
144
  }
84
145
 
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;
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;
89
151
 
90
152
  this.logger.logFile(
91
153
  `[claude] Result: ${result.subtype}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
@@ -95,7 +157,7 @@ export class ClaudeRunner implements Runner {
95
157
  return {
96
158
  success: true,
97
159
  durationSeconds,
98
- output: result.result,
160
+ output: result.result as string | undefined,
99
161
  turns,
100
162
  inputTokens,
101
163
  outputTokens,
@@ -103,8 +165,10 @@ export class ClaudeRunner implements Runner {
103
165
  };
104
166
  }
105
167
 
106
- const error = ERROR_MESSAGES[result.subtype] ?? `failed (${result.subtype})`;
107
- const detail = result.errors?.length ? `: ${result.errors.join("; ")}` : "";
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("; ")}` : "";
108
172
  return {
109
173
  success: false,
110
174
  durationSeconds,
@@ -116,13 +180,19 @@ export class ClaudeRunner implements Runner {
116
180
  };
117
181
  }
118
182
 
119
- private handleMessage(message: SDKMessage): void {
183
+ // -----------------------------------------------------------------------
184
+ // Message routing
185
+ // -----------------------------------------------------------------------
186
+
187
+ private handleMessage(message: Record<string, unknown>): void {
120
188
  switch (message.type) {
121
189
  case "assistant":
122
190
  this.logAssistant(message);
123
191
  break;
124
- case "stream_event":
125
- this.logStreamEvent(message);
192
+ case "system":
193
+ case "user":
194
+ case "rate_limit_event":
195
+ this.logger.logFile(`[claude] ${message.type}: ${JSON.stringify(message)}`);
126
196
  break;
127
197
  case "result":
128
198
  break;
@@ -132,32 +202,25 @@ export class ClaudeRunner implements Runner {
132
202
  }
133
203
  }
134
204
 
135
- private logAssistant(message: SDKAssistantMessage): void {
136
- if (message.error) {
137
- this.logger.log(`[claude] Error: ${message.error}`);
138
- }
205
+ private logAssistant(message: Record<string, unknown>): void {
206
+ const msgBody = message.message as { content?: unknown[] } | undefined;
207
+ const content = msgBody?.content;
139
208
 
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
- }
209
+ if (!Array.isArray(content)) {
210
+ this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(message)}`);
211
+ return;
146
212
  }
147
213
 
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}`);
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}` : ""}`);
159
220
  }
160
221
  }
222
+
223
+ this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(content)}`);
161
224
  }
162
225
 
163
226
  private toolTarget(name: string, input: Record<string, unknown>): string | undefined {
@@ -1,27 +1,68 @@
1
1
  /**
2
- * CodexRunner — adapter for OpenAI Codex via the Agent SDK.
2
+ * CodexRunner — adapter for Codex via CLI subprocess.
3
3
  *
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.
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.
8
8
  */
9
9
 
10
- import { Codex, type ThreadEvent, type ThreadItem } from "@openai/codex-sdk";
11
- import type { LogWriter, Runner, RunResult } from "../types.js";
10
+ import type { ChildResult, LogWriter, Runner, RunResult, SpawnFn } from "../types.js";
11
+
12
+ const CLI_ARGS = ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox"];
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Subprocess spawning (injectable for tests)
16
+ // ---------------------------------------------------------------------------
17
+
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;
31
+ }
32
+ }
33
+ if (buffer.trim()) yield buffer;
34
+ } finally {
35
+ reader.releaseLock();
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
+ }
12
50
 
13
- type CodexFactory = (...args: ConstructorParameters<typeof Codex>) => InstanceType<typeof Codex>;
14
- let createCodex: CodexFactory = (...args) => new Codex(...args);
51
+ let spawnFn: SpawnFn = defaultSpawn;
15
52
 
16
- /** @internal Replace the Codex factory. Returns a restore function. */
17
- export function _setCodexFactory(fn: CodexFactory): () => void {
18
- const prev = createCodex;
19
- createCodex = fn;
53
+ /** @internal Replace the spawn function. Returns a restore function. */
54
+ export function _setSpawnFn(fn: SpawnFn): () => void {
55
+ const prev = spawnFn;
56
+ spawnFn = fn;
20
57
  return () => {
21
- createCodex = prev;
58
+ spawnFn = prev;
22
59
  };
23
60
  }
24
61
 
62
+ // ---------------------------------------------------------------------------
63
+ // Runner implementation
64
+ // ---------------------------------------------------------------------------
65
+
25
66
  export class CodexRunner implements Runner {
26
67
  private logger: LogWriter;
27
68
 
@@ -31,6 +72,19 @@ export class CodexRunner implements Runner {
31
72
 
32
73
  async run(prompt: string): Promise<RunResult> {
33
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
+ }
34
88
 
35
89
  let threadId: string | undefined;
36
90
  let turns = 0;
@@ -41,58 +95,68 @@ export class CodexRunner implements Runner {
41
95
  let errorMessage: string | undefined;
42
96
 
43
97
  try {
44
- const codex = createCodex();
45
- const thread = codex.startThread({
46
- approvalPolicy: "never",
47
- sandboxMode: "danger-full-access",
48
- });
49
-
50
- const { events } = await thread.runStreamed(prompt);
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
+ }
51
106
 
52
- for await (const event of events) {
53
- this.handleEvent(event);
107
+ this.logger.logFile(`[codex] ${parsed.type}: ${JSON.stringify(parsed)}`);
54
108
 
55
- switch (event.type) {
109
+ switch (parsed.type) {
56
110
  case "thread.started":
57
- threadId = event.thread_id;
111
+ threadId = parsed.thread_id as string;
58
112
  break;
59
113
  case "turn.started":
60
114
  turns++;
115
+ completed = false;
61
116
  break;
62
117
  case "turn.completed":
63
118
  completed = true;
64
119
  this.logger.log(`[codex] Turn ${turns} completed`);
65
- inputTokens += event.usage.input_tokens;
66
- outputTokens += event.usage.output_tokens;
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
+ }
67
127
  break;
68
128
  case "turn.failed":
69
129
  failed = true;
70
- errorMessage = event.error.message;
130
+ {
131
+ const err = parsed.error as { message?: string } | undefined;
132
+ errorMessage = err?.message ?? "codex turn failed";
133
+ }
71
134
  this.logger.log(`[codex] Error: ${errorMessage}`);
72
135
  break;
73
136
  case "error":
74
137
  failed = true;
75
- errorMessage = event.message;
138
+ errorMessage = parsed.message as string;
76
139
  this.logger.log(`[codex] Error: ${errorMessage}`);
77
140
  break;
78
141
  case "item.started":
79
142
  case "item.completed":
80
- this.logItem(event.type, event.item);
143
+ this.logItem(parsed.type, parsed.item as Record<string, unknown>);
81
144
  break;
82
145
  }
83
146
  }
84
147
  } catch (error: unknown) {
85
148
  const durationSeconds = (performance.now() - start) / 1000;
86
149
  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
150
  return { success: false, durationSeconds, error: msg };
92
151
  }
93
152
 
153
+ const exitCode = await child.exitCode;
94
154
  const durationSeconds = (performance.now() - start) / 1000;
95
155
 
156
+ if (exitCode !== 0) {
157
+ return { success: false, durationSeconds, error: `codex exited with code ${exitCode}` };
158
+ }
159
+
96
160
  const status = failed ? "failed" : completed ? "success" : "incomplete";
97
161
  this.logger.logFile(
98
162
  `[codex] Result: ${status}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
@@ -132,21 +196,19 @@ export class CodexRunner implements Runner {
132
196
  };
133
197
  }
134
198
 
135
- private handleEvent(event: ThreadEvent): void {
136
- this.logger.logFile(`[codex] ${event.type}: ${JSON.stringify(event)}`);
137
- }
138
-
139
- private logItem(phase: "item.started" | "item.completed", item: ThreadItem): void {
199
+ private logItem(phase: string, item: Record<string, unknown>): void {
140
200
  switch (item.type) {
141
201
  case "command_execution":
142
- if (phase === "item.started") {
202
+ if (phase === "item.started" && typeof item.command === "string") {
143
203
  this.logger.log(`[codex] Bash: ${item.command.slice(0, 80)}`);
144
204
  }
145
205
  break;
146
206
  case "file_change":
147
- if (phase === "item.completed") {
207
+ if (phase === "item.completed" && Array.isArray(item.changes)) {
148
208
  for (const change of item.changes) {
149
- this.logger.log(`[codex] Edit: ${change.path}`);
209
+ if (typeof change?.path === "string") {
210
+ this.logger.log(`[codex] Edit: ${change.path}`);
211
+ }
150
212
  }
151
213
  }
152
214
  break;
package/src/types.ts CHANGED
@@ -2,6 +2,15 @@
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;