stonecut 1.4.0 → 1.5.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/README.md +59 -22
- package/package.json +1 -3
- package/src/cli.ts +173 -38
- package/src/config.ts +3 -1
- package/src/execute.ts +10 -9
- package/src/git.ts +17 -13
- package/src/local.ts +67 -4
- package/src/logger.ts +4 -3
- package/src/prd.ts +3 -2
- package/src/runners/claude.ts +136 -73
- package/src/runners/codex.ts +104 -42
- package/src/runners/index.ts +5 -0
- package/src/types.ts +27 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ For projects using GitHub issues, we recommend tracking ideas with a `roadmap` l
|
|
|
32
32
|
### Prerequisites
|
|
33
33
|
|
|
34
34
|
- [Bun](https://bun.sh/) — install with `curl -fsSL https://bun.sh/install | bash`
|
|
35
|
-
- An agentic coding CLI — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) is the default
|
|
35
|
+
- An agentic coding CLI — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) is the default agent and must be in your PATH. [OpenAI Codex CLI](https://github.com/openai/codex) (`codex`) is required only when using `--agent codex`.
|
|
36
36
|
- [GitHub CLI](https://cli.github.com/) — `gh`, authenticated. Required for importing from GitHub (`stonecut import --github`), syncing issue completion back to GitHub, and for pushing branches / creating PRs.
|
|
37
37
|
|
|
38
38
|
### Install from npm
|
|
@@ -88,22 +88,33 @@ Stonecut uses a local-first execution model: `stonecut run` always reads from `.
|
|
|
88
88
|
When flags are omitted, Stonecut prompts for each missing parameter:
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
|
-
# Full wizard — prompted for PRD,
|
|
91
|
+
# Full wizard — prompted for PRD, branch, base branch, PR mode, agent, iterations
|
|
92
92
|
stonecut
|
|
93
93
|
|
|
94
|
-
# Partial —
|
|
94
|
+
# Partial — skip PRD selection
|
|
95
95
|
stonecut --local my-feature
|
|
96
96
|
|
|
97
|
-
# Partial —
|
|
98
|
-
stonecut -
|
|
97
|
+
# Partial — skip branch and base branch prompts
|
|
98
|
+
stonecut --branch feat/my-feature --base-branch main
|
|
99
|
+
|
|
100
|
+
# Partial — skip agent and iterations prompts
|
|
101
|
+
stonecut --agent claude -i all
|
|
102
|
+
|
|
103
|
+
# Draft PR mode
|
|
104
|
+
stonecut --draft
|
|
105
|
+
|
|
106
|
+
# Override a stored draft preference back to ready
|
|
107
|
+
stonecut --ready
|
|
99
108
|
```
|
|
100
109
|
|
|
101
110
|
You can also use `stonecut run` explicitly — it's identical to bare `stonecut`.
|
|
102
111
|
|
|
103
|
-
The wizard scans `.stonecut/specs/*/` for local PRDs and presents them with completion counts (e.g. "my-feature (3/7 done)"). An "Import from GitHub" option is always available at the bottom of the list for importing PRDs inline.
|
|
112
|
+
The wizard scans `.stonecut/specs/*/` for local PRDs and presents them with completion counts (e.g. "my-feature (3/7 done)"). Completed PRDs (all issues done) and empty PRDs (no issues) are automatically hidden. An "Import from GitHub" option is always available at the bottom of the list for importing PRDs inline.
|
|
104
113
|
|
|
105
114
|
Flags provided via CLI skip the corresponding prompts. When all flags are given, the command runs without any prompts.
|
|
106
115
|
|
|
116
|
+
**Persistence across runs:** The wizard remembers your branch name, base branch, and PR mode (draft/ready) in `status.json` per PRD. On subsequent runs, stored values are used silently — no prompt is shown. CLI flags override stored values when provided. Agent and iterations are intentionally per-run decisions and are always prompted when not provided via flags.
|
|
117
|
+
|
|
107
118
|
If no `.stonecut/` directory exists, the wizard prints a hint suggesting `stonecut init`.
|
|
108
119
|
|
|
109
120
|
### `stonecut init` — Project setup
|
|
@@ -127,7 +138,7 @@ The command errors if `config.json` already exists, preventing accidental overwr
|
|
|
127
138
|
|
|
128
139
|
```json
|
|
129
140
|
{
|
|
130
|
-
"
|
|
141
|
+
"agent": "claude",
|
|
131
142
|
"baseBranch": "main",
|
|
132
143
|
"branchPrefix": "stonecut/"
|
|
133
144
|
}
|
|
@@ -135,7 +146,7 @@ The command errors if `config.json` already exists, preventing accidental overwr
|
|
|
135
146
|
|
|
136
147
|
| Field | Default | Description |
|
|
137
148
|
| -------------- | ------------- | ------------------------------------------------------------------------------- |
|
|
138
|
-
| `
|
|
149
|
+
| `agent` | `"claude"` | Agentic CLI agent (`claude`, `codex`). Used when `--agent` is omitted. |
|
|
139
150
|
| `baseBranch` | `"main"` | Default PR target branch. Suggested in the wizard's base branch prompt. |
|
|
140
151
|
| `branchPrefix` | `"stonecut/"` | Prefix for suggested branch names (e.g. `feat/stonecut/` for team conventions). |
|
|
141
152
|
|
|
@@ -198,7 +209,7 @@ Or use the interactive wizard, which offers inline GitHub import:
|
|
|
198
209
|
stonecut
|
|
199
210
|
# → Select "Import from GitHub"
|
|
200
211
|
# → Pick a PRD from the list (filtered by `prd` label)
|
|
201
|
-
# → Continue with
|
|
212
|
+
# → Continue with branch, base branch, PR mode, agent, and iterations prompts
|
|
202
213
|
```
|
|
203
214
|
|
|
204
215
|
When issues imported from GitHub are completed, Stonecut automatically closes the corresponding GitHub issues. When all issues are done, the parent PRD issue is closed as well.
|
|
@@ -218,11 +229,15 @@ When issues imported from GitHub are completed, Stonecut automatically closes th
|
|
|
218
229
|
|
|
219
230
|
**`run` flags:**
|
|
220
231
|
|
|
221
|
-
| Flag
|
|
222
|
-
|
|
|
223
|
-
| `--local`
|
|
224
|
-
| `--iterations`
|
|
225
|
-
| `--
|
|
232
|
+
| Flag | Short | Required | Description |
|
|
233
|
+
| --------------- | ----- | -------- | ----------------------------------------------------------------------------- |
|
|
234
|
+
| `--local` | — | No | Local PRD name (`.stonecut/specs/<name>/`). Prompted if omitted. |
|
|
235
|
+
| `--iterations` | `-i` | No | Positive integer or `all`. Prompted with default `all` if omitted. |
|
|
236
|
+
| `--branch` | — | No | Branch name for the run. Persisted per PRD. Prompted if omitted on first run. |
|
|
237
|
+
| `--base-branch` | — | No | Base branch / PR target. Persisted per PRD. Prompted if omitted on first run. |
|
|
238
|
+
| `--agent` | — | No | Agentic CLI agent (`claude`, `codex`). Default from config or `claude`. |
|
|
239
|
+
| `--draft` | — | No | Create PR as draft. |
|
|
240
|
+
| `--ready` | — | No | Create PR as ready for review (overrides stored draft preference). |
|
|
226
241
|
|
|
227
242
|
**`import` flags:**
|
|
228
243
|
|
|
@@ -242,19 +257,28 @@ When issues imported from GitHub are completed, Stonecut automatically closes th
|
|
|
242
257
|
|
|
243
258
|
### Pre-execution prompts
|
|
244
259
|
|
|
245
|
-
Before starting, Stonecut prompts for any missing parameters
|
|
260
|
+
Before starting, Stonecut prompts for any missing parameters. Persistent decisions are asked first and remembered across runs; per-run decisions are asked last.
|
|
246
261
|
|
|
247
|
-
1. **PRD** —
|
|
248
|
-
2. **
|
|
249
|
-
3. **
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
1. **PRD** — Select from local PRDs or import from GitHub. Completed PRDs and PRDs with no issues are hidden. Skipped when `--local` is provided.
|
|
263
|
+
2. **Branch** — If you're on a non-default branch, offers to use the current branch or create a new one. If on the default branch (main/master), prompts for a branch name with a suggested default (`<branchPrefix><slug>`). **Persisted per PRD.** Skipped on subsequent runs or when `--branch` is provided.
|
|
264
|
+
3. **Base branch** — Smart select with contextual defaults based on your branch choice:
|
|
265
|
+
- Created a new branch from a non-default branch → defaults to the current branch (enables PR chaining / stacked PRs)
|
|
266
|
+
- Created a new branch from the default branch → defaults to the repo default (main)
|
|
267
|
+
- Using the current branch → defaults to the repo default (main)
|
|
268
|
+
- An "Enter a different branch" option is always available. **Persisted per PRD.** Skipped on subsequent runs or when `--base-branch` is provided.
|
|
269
|
+
4. **PR mode** — "Should the PR be opened as a draft?" (y/N). Default: ready. **Persisted per PRD.** Skipped on subsequent runs, or when `--draft` / `--ready` is provided.
|
|
270
|
+
5. **Agent** — "Which agent should execute the issues?" Select from available agents. Default: config value or `claude`. **Not persisted** — asked each run unless `--agent` is provided.
|
|
271
|
+
6. **Iterations** — "How many issues should I tackle this run?" Default: `all`. **Not persisted** — asked each run unless `-i` is provided.
|
|
272
|
+
|
|
273
|
+
After prompts complete, the branch is created or checked out and execution begins.
|
|
252
274
|
|
|
253
275
|
When all parameters are provided via flags, no prompts appear and the command runs non-interactively.
|
|
254
276
|
|
|
255
277
|
### After a run
|
|
256
278
|
|
|
257
|
-
Stonecut automatically pushes the branch
|
|
279
|
+
Stonecut automatically pushes the branch and creates a PR (or draft PR, depending on your PR mode preference) with a Stonecut Report listing each issue with its status (completed or failed with error reason). The report also shows which agent was used. Timing stats are printed per iteration and for the full session.
|
|
280
|
+
|
|
281
|
+
The PR URL is printed to the terminal after creation so you can navigate to it directly.
|
|
258
282
|
|
|
259
283
|
For imported PRDs, the PR body includes a `Closes #<number>` reference to the parent GitHub issue when all issues are complete.
|
|
260
284
|
|
|
@@ -269,10 +293,23 @@ All execution reads from `.stonecut/specs/<name>/` directories with this structu
|
|
|
269
293
|
│ ├── 01-setup.md # Issue files, numbered for ordering
|
|
270
294
|
│ ├── 02-core.md
|
|
271
295
|
│ └── 03-api.md
|
|
272
|
-
├── status.json # Auto-created: tracks
|
|
296
|
+
├── status.json # Auto-created: tracks completion and wizard state (gitignored)
|
|
273
297
|
└── progress.txt # Auto-created: timestamped completion log (gitignored)
|
|
274
298
|
```
|
|
275
299
|
|
|
300
|
+
`status.json` stores both issue completion and persistent wizard decisions:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"completed": [1, 2, 3],
|
|
305
|
+
"branch": "stonecut/my-feature",
|
|
306
|
+
"baseBranch": "main",
|
|
307
|
+
"prMode": "ready"
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The `branch`, `baseBranch`, and `prMode` fields are written after the wizard completes on first run. On subsequent runs, stored values are used silently. Old status files missing these fields are handled gracefully — the wizard prompts as if it's the first run.
|
|
312
|
+
|
|
276
313
|
PRDs and issues are committed to git; runtime state (`status.json`, `progress.txt`, `logs/`) is gitignored by the `.stonecut/.gitignore` created during `stonecut init`.
|
|
277
314
|
|
|
278
315
|
### Frontmatter
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stonecut",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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/cli.ts
CHANGED
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
import * as clack from "@clack/prompts";
|
|
12
12
|
import { Command, InvalidArgumentError } from "commander";
|
|
13
13
|
import { createRequire } from "module";
|
|
14
|
-
import { defaultBranch, ensureCleanTree } from "./git";
|
|
14
|
+
import { currentBranch, defaultBranch, ensureCleanTree } from "./git";
|
|
15
15
|
import { slugifyBranchComponent } from "./naming";
|
|
16
16
|
import { setupSkills, removeSkills } from "./skills";
|
|
17
17
|
import { init } from "./init";
|
|
18
18
|
import { importSpec } from "./import";
|
|
19
19
|
import { loadConfig } from "./config";
|
|
20
|
+
import { getWizardState, setWizardState } from "./local";
|
|
20
21
|
import { existsSync } from "fs";
|
|
21
22
|
import { promptForPrd } from "./prd";
|
|
22
23
|
import { executeLocal } from "./execute";
|
|
24
|
+
import { availableRunners } from "./runners/index";
|
|
23
25
|
|
|
24
26
|
const require = createRequire(import.meta.url);
|
|
25
27
|
const { version } = require("../package.json");
|
|
@@ -85,7 +87,9 @@ export function buildProgram(): Command {
|
|
|
85
87
|
.option("-i, --iterations <value>", "Number of issues to process, or 'all'")
|
|
86
88
|
.option("--branch <name>", "Branch name for the run")
|
|
87
89
|
.option("--base-branch <name>", "Base branch / PR target")
|
|
88
|
-
.option("--
|
|
90
|
+
.option("--agent <name>", "Agentic CLI to execute issues (claude, codex)")
|
|
91
|
+
.option("--draft", "Create PR as draft")
|
|
92
|
+
.option("--ready", "Create PR as ready for review (overrides stored draft preference)")
|
|
89
93
|
.action(async (opts) => {
|
|
90
94
|
ensureCleanTree();
|
|
91
95
|
const config = loadConfig();
|
|
@@ -98,13 +102,168 @@ export function buildProgram(): Command {
|
|
|
98
102
|
source = await promptForPrd();
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
// ---- Branch (persistent, immediately after PRD selection) ----
|
|
106
|
+
const wizardState = getWizardState(source.name);
|
|
107
|
+
const repoDefault = defaultBranch();
|
|
108
|
+
const current = currentBranch();
|
|
109
|
+
|
|
110
|
+
let branch: string;
|
|
111
|
+
let needsBranchPrompt = false;
|
|
112
|
+
// Track how the branch was chosen so the base branch prompt can offer smart defaults.
|
|
113
|
+
let branchContext:
|
|
114
|
+
| "used_current"
|
|
115
|
+
| "created_new_from_non_default"
|
|
116
|
+
| "created_new_from_default"
|
|
117
|
+
| "no_context" = "no_context";
|
|
118
|
+
|
|
119
|
+
if (opts.branch) {
|
|
120
|
+
branch = opts.branch;
|
|
121
|
+
} else if (wizardState.branch) {
|
|
122
|
+
branch = wizardState.branch;
|
|
123
|
+
} else {
|
|
124
|
+
needsBranchPrompt = true;
|
|
125
|
+
const branchPrefix = config?.branchPrefix ?? "stonecut/";
|
|
126
|
+
const suggestedBranch = `${branchPrefix}${slugifyBranchComponent(source.name) || "spec"}`;
|
|
127
|
+
|
|
128
|
+
if (current && current !== repoDefault) {
|
|
129
|
+
const choice = await clack.select({
|
|
130
|
+
message: "What branch should I work on?",
|
|
131
|
+
options: [
|
|
132
|
+
{ value: "__use_current__", label: `Use current branch (${current})` },
|
|
133
|
+
{ value: "__create_new__", label: "Create a new branch" },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
if (clack.isCancel(choice)) throw new Error("Cancelled.");
|
|
137
|
+
|
|
138
|
+
if (choice === "__use_current__") {
|
|
139
|
+
branch = current;
|
|
140
|
+
branchContext = "used_current";
|
|
141
|
+
} else {
|
|
142
|
+
const branchInput = await clack.text({
|
|
143
|
+
message: "What branch should I work on?",
|
|
144
|
+
defaultValue: suggestedBranch,
|
|
145
|
+
placeholder: suggestedBranch,
|
|
146
|
+
});
|
|
147
|
+
if (clack.isCancel(branchInput)) throw new Error("Cancelled.");
|
|
148
|
+
branch = branchInput;
|
|
149
|
+
branchContext = "created_new_from_non_default";
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
const branchInput = await clack.text({
|
|
153
|
+
message: "What branch should I work on?",
|
|
154
|
+
defaultValue: suggestedBranch,
|
|
155
|
+
placeholder: suggestedBranch,
|
|
156
|
+
});
|
|
157
|
+
if (clack.isCancel(branchInput)) throw new Error("Cancelled.");
|
|
158
|
+
branch = branchInput;
|
|
159
|
+
branchContext = "created_new_from_default";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Base branch (persistent, smart prompt) ----
|
|
164
|
+
let baseBranch: string;
|
|
165
|
+
let needsBaseBranchPrompt = false;
|
|
166
|
+
if (opts.baseBranch) {
|
|
167
|
+
baseBranch = opts.baseBranch;
|
|
168
|
+
} else if (wizardState.baseBranch) {
|
|
169
|
+
baseBranch = wizardState.baseBranch;
|
|
170
|
+
} else {
|
|
171
|
+
needsBaseBranchPrompt = true;
|
|
172
|
+
const configBaseBranch = config?.baseBranch ?? repoDefault;
|
|
173
|
+
|
|
174
|
+
if (branchContext === "created_new_from_non_default") {
|
|
175
|
+
// Creating a new branch from a non-default branch: default to current (for PR chaining)
|
|
176
|
+
const options: Array<{ value: string; label: string }> = [
|
|
177
|
+
{ value: current!, label: `${current} (current branch)` },
|
|
178
|
+
];
|
|
179
|
+
if (configBaseBranch !== current) {
|
|
180
|
+
options.push({ value: configBaseBranch, label: `${configBaseBranch} (repo default)` });
|
|
181
|
+
}
|
|
182
|
+
options.push({ value: "__enter_different__", label: "Enter a different branch" });
|
|
183
|
+
|
|
184
|
+
const choice = await clack.select({
|
|
185
|
+
message: "What branch should the PR target?",
|
|
186
|
+
options,
|
|
187
|
+
});
|
|
188
|
+
if (clack.isCancel(choice)) throw new Error("Cancelled.");
|
|
189
|
+
|
|
190
|
+
if (choice === "__enter_different__") {
|
|
191
|
+
const input = await clack.text({ message: "What branch should the PR target?" });
|
|
192
|
+
if (clack.isCancel(input)) throw new Error("Cancelled.");
|
|
193
|
+
baseBranch = input;
|
|
194
|
+
} else {
|
|
195
|
+
baseBranch = choice;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Using current branch, created from default, or no context: default to repo default
|
|
199
|
+
const choice = await clack.select({
|
|
200
|
+
message: "What branch should the PR target?",
|
|
201
|
+
options: [
|
|
202
|
+
{ value: configBaseBranch, label: `${configBaseBranch} (repo default)` },
|
|
203
|
+
{ value: "__enter_different__", label: "Enter a different branch" },
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
if (clack.isCancel(choice)) throw new Error("Cancelled.");
|
|
207
|
+
|
|
208
|
+
if (choice === "__enter_different__") {
|
|
209
|
+
const input = await clack.text({ message: "What branch should the PR target?" });
|
|
210
|
+
if (clack.isCancel(input)) throw new Error("Cancelled.");
|
|
211
|
+
baseBranch = input;
|
|
212
|
+
} else {
|
|
213
|
+
baseBranch = choice;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- PR mode (persistent) ----
|
|
219
|
+
let draft: boolean;
|
|
220
|
+
let needsPrModePrompt = false;
|
|
221
|
+
if (opts.draft) {
|
|
222
|
+
draft = true;
|
|
223
|
+
} else if (opts.ready) {
|
|
224
|
+
draft = false;
|
|
225
|
+
} else if (wizardState.prMode !== undefined) {
|
|
226
|
+
draft = wizardState.prMode === "draft";
|
|
227
|
+
} else {
|
|
228
|
+
needsPrModePrompt = true;
|
|
229
|
+
const shouldDraft = await clack.confirm({
|
|
230
|
+
message: "Should the PR be opened as a draft?",
|
|
231
|
+
initialValue: false,
|
|
232
|
+
});
|
|
233
|
+
if (clack.isCancel(shouldDraft)) throw new Error("Cancelled.");
|
|
234
|
+
draft = shouldDraft;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- Agent (per-run) ----
|
|
238
|
+
let runnerName: string;
|
|
239
|
+
let needsAgentPrompt = false;
|
|
240
|
+
if (opts.agent) {
|
|
241
|
+
runnerName = opts.agent;
|
|
242
|
+
} else {
|
|
243
|
+
const configAgent = config?.agent ?? config?.runner ?? "claude";
|
|
244
|
+
const agents = availableRunners();
|
|
245
|
+
if (agents.length > 1) {
|
|
246
|
+
needsAgentPrompt = true;
|
|
247
|
+
const agentChoice = await clack.select({
|
|
248
|
+
message: "Which agent should execute the issues?",
|
|
249
|
+
options: agents.map((a) => ({ value: a, label: a })),
|
|
250
|
+
initialValue: configAgent,
|
|
251
|
+
});
|
|
252
|
+
if (clack.isCancel(agentChoice)) throw new Error("Cancelled.");
|
|
253
|
+
runnerName = agentChoice;
|
|
254
|
+
} else {
|
|
255
|
+
runnerName = configAgent;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Iterations (per-run) ----
|
|
101
260
|
let iterations: number | "all";
|
|
102
261
|
const needsIterationPrompt = opts.iterations === undefined;
|
|
103
262
|
if (!needsIterationPrompt) {
|
|
104
263
|
iterations = parseIterations(opts.iterations);
|
|
105
264
|
} else {
|
|
106
265
|
const iterationsInput = await clack.text({
|
|
107
|
-
message: "
|
|
266
|
+
message: "How many issues should I tackle this run?",
|
|
108
267
|
defaultValue: "all",
|
|
109
268
|
placeholder: "all",
|
|
110
269
|
validate: (value) => {
|
|
@@ -121,46 +280,22 @@ export function buildProgram(): Command {
|
|
|
121
280
|
iterations = parseIterations(iterationsInput);
|
|
122
281
|
}
|
|
123
282
|
|
|
124
|
-
const isWizard =
|
|
283
|
+
const isWizard =
|
|
284
|
+
validated.kind === "prompt" ||
|
|
285
|
+
needsIterationPrompt ||
|
|
286
|
+
needsBranchPrompt ||
|
|
287
|
+
needsBaseBranchPrompt ||
|
|
288
|
+
needsPrModePrompt ||
|
|
289
|
+
needsAgentPrompt;
|
|
125
290
|
if (isWizard && !existsSync(".stonecut")) {
|
|
126
291
|
console.log("Hint: run `stonecut init` to set up project config and gitignore.\n");
|
|
127
292
|
}
|
|
128
293
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} else {
|
|
133
|
-
const branchPrefix = config?.branchPrefix ?? "stonecut/";
|
|
134
|
-
const suggestedBranch = `${branchPrefix}${slugifyBranchComponent(source.name) || "spec"}`;
|
|
135
|
-
const branchInput = await clack.text({
|
|
136
|
-
message: "Branch name:",
|
|
137
|
-
defaultValue: suggestedBranch,
|
|
138
|
-
placeholder: suggestedBranch,
|
|
139
|
-
});
|
|
140
|
-
if (clack.isCancel(branchInput)) {
|
|
141
|
-
throw new Error("Cancelled.");
|
|
142
|
-
}
|
|
143
|
-
branch = branchInput;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
let baseBranch: string;
|
|
147
|
-
if (opts.baseBranch) {
|
|
148
|
-
baseBranch = opts.baseBranch;
|
|
149
|
-
} else {
|
|
150
|
-
const detectedDefault = config?.baseBranch ?? defaultBranch();
|
|
151
|
-
const baseBranchInput = await clack.text({
|
|
152
|
-
message: "Base branch / PR target:",
|
|
153
|
-
defaultValue: detectedDefault,
|
|
154
|
-
placeholder: detectedDefault,
|
|
155
|
-
});
|
|
156
|
-
if (clack.isCancel(baseBranchInput)) {
|
|
157
|
-
throw new Error("Cancelled.");
|
|
158
|
-
}
|
|
159
|
-
baseBranch = baseBranchInput;
|
|
160
|
-
}
|
|
294
|
+
// ---- Persist wizard state ----
|
|
295
|
+
const prMode = draft ? "draft" : "ready";
|
|
296
|
+
setWizardState(source.name, { branch, baseBranch, prMode });
|
|
161
297
|
|
|
162
|
-
|
|
163
|
-
await executeLocal(source.name, branch, baseBranch, iterations, runnerName);
|
|
298
|
+
await executeLocal(source.name, branch, baseBranch, iterations, runnerName, draft);
|
|
164
299
|
});
|
|
165
300
|
|
|
166
301
|
program
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { join } from "path";
|
|
|
9
9
|
|
|
10
10
|
/** Shape of `.stonecut/config.json`. All fields are optional. */
|
|
11
11
|
export interface StonecutConfig {
|
|
12
|
+
agent?: string;
|
|
13
|
+
/** @deprecated Use `agent` instead. Kept for backward compatibility with existing configs. */
|
|
12
14
|
runner?: string;
|
|
13
15
|
baseBranch?: string;
|
|
14
16
|
branchPrefix?: string;
|
|
@@ -47,7 +49,7 @@ export function writeDefaultConfig(cwd?: string): void {
|
|
|
47
49
|
mkdirSync(dir, { recursive: true });
|
|
48
50
|
|
|
49
51
|
const defaults: StonecutConfig = {
|
|
50
|
-
|
|
52
|
+
agent: "claude",
|
|
51
53
|
baseBranch: "main",
|
|
52
54
|
branchPrefix: "stonecut/",
|
|
53
55
|
};
|
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";
|
|
@@ -46,7 +45,7 @@ export function buildReport(
|
|
|
46
45
|
runnerName: string,
|
|
47
46
|
closingRefs?: string[],
|
|
48
47
|
): string {
|
|
49
|
-
const lines = ["## Stonecut Report", `**
|
|
48
|
+
const lines = ["## Stonecut Report", `**Agent:** ${runnerName}`, ""];
|
|
50
49
|
for (const r of results) {
|
|
51
50
|
const metrics = formatMetrics(r);
|
|
52
51
|
if (r.success) {
|
|
@@ -73,13 +72,14 @@ export async function pushAndMaybePr(
|
|
|
73
72
|
results: IterationResult[],
|
|
74
73
|
source: {
|
|
75
74
|
getRemainingCount(): Promise<[number, number]>;
|
|
76
|
-
getClosingRefs?(
|
|
75
|
+
getClosingRefs?(): string[];
|
|
77
76
|
},
|
|
78
77
|
branch: string,
|
|
79
78
|
baseBranch: string,
|
|
80
79
|
prTitle: string,
|
|
81
80
|
runnerName: string,
|
|
82
81
|
logger: { log(message: string): void },
|
|
82
|
+
draft = false,
|
|
83
83
|
): Promise<void> {
|
|
84
84
|
if (!results.some((r) => r.success)) {
|
|
85
85
|
return;
|
|
@@ -90,11 +90,10 @@ export async function pushAndMaybePr(
|
|
|
90
90
|
|
|
91
91
|
const [remaining, total] = await source.getRemainingCount();
|
|
92
92
|
if (remaining === 0) {
|
|
93
|
-
const
|
|
94
|
-
const closingRefs = source.getClosingRefs?.(completed);
|
|
93
|
+
const closingRefs = source.getClosingRefs?.();
|
|
95
94
|
const body = buildReport(results, runnerName, closingRefs);
|
|
96
|
-
createPr(prTitle, body, baseBranch);
|
|
97
|
-
logger.log(
|
|
95
|
+
const prUrl = createPr(prTitle, body, baseBranch, draft);
|
|
96
|
+
logger.log(`Created PR: ${prUrl}`);
|
|
98
97
|
} else {
|
|
99
98
|
logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
|
|
100
99
|
}
|
|
@@ -116,15 +115,16 @@ export async function executeLocal(
|
|
|
116
115
|
baseBranch: string,
|
|
117
116
|
iterations: number | "all",
|
|
118
117
|
runnerName: string,
|
|
118
|
+
draft = false,
|
|
119
119
|
): Promise<void> {
|
|
120
120
|
const source = new LocalSource(name);
|
|
121
|
-
const
|
|
122
|
-
const logger = new Logger(prdIdentifier);
|
|
121
|
+
const logger = new Logger();
|
|
123
122
|
const runner = getRunner(runnerName, logger);
|
|
124
123
|
|
|
125
124
|
const session: Session = { logger, git: defaultGitOps, runner, runnerName };
|
|
126
125
|
|
|
127
126
|
try {
|
|
127
|
+
logger.logFile(`PRD: ${name}`);
|
|
128
128
|
checkoutOrCreateBranch(branch);
|
|
129
129
|
console.log("");
|
|
130
130
|
|
|
@@ -152,6 +152,7 @@ export async function executeLocal(
|
|
|
152
152
|
`Stonecut: ${name}`,
|
|
153
153
|
runnerName,
|
|
154
154
|
logger,
|
|
155
|
+
draft,
|
|
155
156
|
);
|
|
156
157
|
} finally {
|
|
157
158
|
logger.close();
|
package/src/git.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
import { runSync } from "./spawn";
|
|
9
9
|
import type { WorkingTreeSnapshot } from "./types";
|
|
10
10
|
|
|
11
|
+
/** Return the name of the currently checked-out branch (empty on detached HEAD). */
|
|
12
|
+
export function currentBranch(cwd?: string): string {
|
|
13
|
+
const result = runSync(["git", "branch", "--show-current"], cwd);
|
|
14
|
+
if (result.exitCode !== 0) {
|
|
15
|
+
throw new Error(`Failed to detect current branch: ${result.stderr.trim()}`);
|
|
16
|
+
}
|
|
17
|
+
return result.stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/** Detect the remote's default branch, falling back to "main". */
|
|
12
21
|
export function defaultBranch(cwd?: string): string {
|
|
13
22
|
const result = runSync(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
|
|
@@ -56,22 +65,17 @@ export function pushBranch(branch: string, cwd?: string): void {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
/** Create a pull request via the gh CLI. */
|
|
60
|
-
export function createPr(title: string, body: string, baseBranch: string):
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
title,
|
|
67
|
-
"--body",
|
|
68
|
-
body,
|
|
69
|
-
"--base",
|
|
70
|
-
baseBranch,
|
|
71
|
-
]);
|
|
68
|
+
/** Create a pull request via the gh CLI. Returns the PR URL from stdout. */
|
|
69
|
+
export function createPr(title: string, body: string, baseBranch: string, draft = false): string {
|
|
70
|
+
const cmd = ["gh", "pr", "create", "--title", title, "--body", body, "--base", baseBranch];
|
|
71
|
+
if (draft) {
|
|
72
|
+
cmd.push("--draft");
|
|
73
|
+
}
|
|
74
|
+
const result = runSync(cmd);
|
|
72
75
|
if (result.exitCode !== 0) {
|
|
73
76
|
throw new Error(`Failed to create PR: ${result.stderr.trim()}`);
|
|
74
77
|
}
|
|
78
|
+
return result.stdout.trim();
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
// ---------------------------------------------------------------------------
|
package/src/local.ts
CHANGED
|
@@ -2,9 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, statSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import type { Issue, Source } from "./types";
|
|
5
|
+
import type { Issue, Source, WizardState, StatusData } from "./types";
|
|
6
6
|
import { parseFrontmatter } from "./frontmatter";
|
|
7
7
|
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Standalone wizard-state helpers (no directory validation required)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function statusPathFor(specName: string): string {
|
|
13
|
+
if (!specName || specName.includes("..") || specName.includes("/") || specName.includes("\\")) {
|
|
14
|
+
throw new Error(`Invalid spec name: ${specName}`);
|
|
15
|
+
}
|
|
16
|
+
return join(".stonecut", "specs", specName, "status.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read wizard state from status.json without constructing a full LocalSource. */
|
|
20
|
+
export function getWizardState(specName: string): WizardState {
|
|
21
|
+
const path = statusPathFor(specName);
|
|
22
|
+
if (!existsSync(path)) return {};
|
|
23
|
+
let parsed: unknown;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
30
|
+
const data = parsed as Partial<StatusData>;
|
|
31
|
+
const state: WizardState = {};
|
|
32
|
+
if (data.branch !== undefined) state.branch = data.branch;
|
|
33
|
+
if (data.baseBranch !== undefined) state.baseBranch = data.baseBranch;
|
|
34
|
+
if (data.prMode !== undefined) state.prMode = data.prMode;
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Write wizard state to status.json without constructing a full LocalSource. */
|
|
39
|
+
export function setWizardState(specName: string, state: WizardState): void {
|
|
40
|
+
const path = statusPathFor(specName);
|
|
41
|
+
let data: Partial<StatusData> = { completed: [] };
|
|
42
|
+
if (existsSync(path)) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
45
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
46
|
+
data = parsed as Partial<StatusData>;
|
|
47
|
+
if (!Array.isArray(data.completed)) data.completed = [];
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// malformed JSON — fall through with default
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (state.branch !== undefined) data.branch = state.branch;
|
|
54
|
+
if (state.baseBranch !== undefined) data.baseBranch = state.baseBranch;
|
|
55
|
+
if (state.prMode !== undefined) data.prMode = state.prMode;
|
|
56
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// LocalSource
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
8
63
|
export class LocalSource implements Source<Issue> {
|
|
9
64
|
readonly name: string;
|
|
10
65
|
private readonly specDir: string;
|
|
@@ -86,8 +141,8 @@ export class LocalSource implements Source<Issue> {
|
|
|
86
141
|
return null;
|
|
87
142
|
}
|
|
88
143
|
|
|
89
|
-
getClosingRefs(
|
|
90
|
-
const completed =
|
|
144
|
+
getClosingRefs(): string[] {
|
|
145
|
+
const completed = this.readStatus();
|
|
91
146
|
const refs: string[] = [];
|
|
92
147
|
const all = this.allIssues();
|
|
93
148
|
|
|
@@ -120,10 +175,18 @@ export class LocalSource implements Source<Issue> {
|
|
|
120
175
|
return [remaining, total];
|
|
121
176
|
}
|
|
122
177
|
|
|
178
|
+
getWizardState(): WizardState {
|
|
179
|
+
return getWizardState(this.name);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setWizardState(state: WizardState): void {
|
|
183
|
+
setWizardState(this.name, state);
|
|
184
|
+
}
|
|
185
|
+
|
|
123
186
|
async completeIssue(issue: Issue): Promise<void> {
|
|
124
187
|
// Update status.json
|
|
125
188
|
const path = this.statusPath();
|
|
126
|
-
let data:
|
|
189
|
+
let data: StatusData;
|
|
127
190
|
if (existsSync(path)) {
|
|
128
191
|
data = JSON.parse(readFileSync(path, "utf-8"));
|
|
129
192
|
} else {
|
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, "");
|
package/src/prd.ts
CHANGED
|
@@ -51,14 +51,15 @@ export function scanLocalPrds(baseDir: string = ".stonecut/specs"): LocalPrdEntr
|
|
|
51
51
|
|
|
52
52
|
entries.push({ name, completed, total });
|
|
53
53
|
}
|
|
54
|
-
return entries
|
|
54
|
+
return entries
|
|
55
|
+
.filter((e) => e.total > 0 && e.completed < e.total)
|
|
56
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
60
|
* Format a PRD entry for display in the wizard.
|
|
59
61
|
*/
|
|
60
62
|
export function formatPrdOption(entry: LocalPrdEntry): string {
|
|
61
|
-
if (entry.total === 0) return `${entry.name} (no issues)`;
|
|
62
63
|
if (entry.completed === 0) return `${entry.name} (not started)`;
|
|
63
64
|
return `${entry.name} (${entry.completed}/${entry.total} done)`;
|
|
64
65
|
}
|
package/src/runners/claude.ts
CHANGED
|
@@ -1,36 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClaudeRunner — adapter for Claude Code via
|
|
2
|
+
* ClaudeRunner — adapter for Claude Code via CLI subprocess.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (tool calls,
|
|
7
|
-
*
|
|
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 {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
97
|
+
let child: ChildResult;
|
|
53
98
|
|
|
54
99
|
try {
|
|
55
|
-
|
|
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
|
|
88
|
-
const
|
|
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
|
|
107
|
-
const
|
|
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
|
-
|
|
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 "
|
|
125
|
-
|
|
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:
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 {
|
package/src/runners/codex.ts
CHANGED
|
@@ -1,27 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CodexRunner — adapter for
|
|
2
|
+
* CodexRunner — adapter for Codex via CLI subprocess.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* changes, turn boundaries, errors); the log
|
|
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 {
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
let createCodex: CodexFactory = (...args) => new Codex(...args);
|
|
51
|
+
let spawnFn: SpawnFn = defaultSpawn;
|
|
15
52
|
|
|
16
|
-
/** @internal Replace the
|
|
17
|
-
export function
|
|
18
|
-
const prev =
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
this.handleEvent(event);
|
|
107
|
+
this.logger.logFile(`[codex] ${parsed.type}: ${JSON.stringify(parsed)}`);
|
|
54
108
|
|
|
55
|
-
switch (
|
|
109
|
+
switch (parsed.type) {
|
|
56
110
|
case "thread.started":
|
|
57
|
-
threadId =
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
209
|
+
if (typeof change?.path === "string") {
|
|
210
|
+
this.logger.log(`[codex] Edit: ${change.path}`);
|
|
211
|
+
}
|
|
150
212
|
}
|
|
151
213
|
}
|
|
152
214
|
break;
|
package/src/runners/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ const RUNNERS: Record<string, new (logger: LogWriter) => Runner> = {
|
|
|
11
11
|
codex: CodexRunner,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
/** Return sorted list of registered runner names. */
|
|
15
|
+
export function availableRunners(): string[] {
|
|
16
|
+
return Object.keys(RUNNERS).sort();
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export function getRunner(name: string, logger: LogWriter): Runner {
|
|
15
20
|
const Cls = RUNNERS[name];
|
|
16
21
|
if (!Cls) {
|
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;
|
|
@@ -44,6 +53,24 @@ export interface GitOps {
|
|
|
44
53
|
revertUncommitted(snapshot: WorkingTreeSnapshot): void;
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
/** PR mode for wizard persistence. */
|
|
57
|
+
export type PrMode = "ready" | "draft";
|
|
58
|
+
|
|
59
|
+
/** Persistent wizard state stored in status.json. */
|
|
60
|
+
export interface WizardState {
|
|
61
|
+
branch?: string;
|
|
62
|
+
baseBranch?: string;
|
|
63
|
+
prMode?: PrMode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Full status.json schema. */
|
|
67
|
+
export interface StatusData {
|
|
68
|
+
completed: number[];
|
|
69
|
+
branch?: string;
|
|
70
|
+
baseBranch?: string;
|
|
71
|
+
prMode?: PrMode;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
/** A single issue from a local spec. */
|
|
48
75
|
export interface Issue {
|
|
49
76
|
number: number;
|