stonecut 1.4.1 → 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 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 runner and must be in your PATH. [OpenAI Codex CLI](https://github.com/openai/codex) (`codex`) is required only when using `--runner codex`.
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, iterations, branch, and base branch
91
+ # Full wizard — prompted for PRD, branch, base branch, PR mode, agent, iterations
92
92
  stonecut
93
93
 
94
- # Partial — only iterations, branch, and base are prompted
94
+ # Partial — skip PRD selection
95
95
  stonecut --local my-feature
96
96
 
97
- # Partial — only PRD selection, branch, and base are prompted
98
- stonecut -i all
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
- "runner": "claude",
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
- | `runner` | `"claude"` | Agentic CLI runner (`claude`, `codex`). Used when `--runner` is omitted. |
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 iterations, branch, and base branch prompts
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 | Short | Required | Description |
222
- | -------------- | ----- | -------- | ------------------------------------------------------------------------ |
223
- | `--local` | — | No | Local PRD name (`.stonecut/specs/<name>/`). Prompted if omitted. |
224
- | `--iterations` | `-i` | No | Positive integer or `all`. Prompted with default `all` if omitted. |
225
- | `--runner` | — | No | Agentic CLI runner (`claude`, `codex`). Default from config or `claude`. |
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 in order:
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** — select from local PRDs or import from GitHub (skipped when `--local` provided)
248
- 2. **Iterations** — number of issues to process, default `all` (skipped when `-i` provided)
249
- 3. **Branch name** — suggests `<branchPrefix><slug>` based on the spec name (prefix from config or `stonecut/`)
250
- 4. **Base branch** suggests the configured `baseBranch` or the repository's default branch (usually `main`)
251
- 5. Creates or checks out the branch
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, creates a PR, and includes a Stonecut Report listing each issue with its status (completed or failed with error reason). The report also shows which runner was used. Timing stats are printed per iteration and for the full session.
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 completed issues (gitignored)
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.4.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": {
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("--runner <name>", "Agentic CLI runner (claude, codex)")
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: "Iterations:",
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 = validated.kind === "prompt" || needsIterationPrompt;
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
- let branch: string;
130
- if (opts.branch) {
131
- branch = opts.branch;
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
- const runnerName: string = opts.runner ?? config?.runner ?? "claude";
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
- runner: "claude",
52
+ agent: "claude",
51
53
  baseBranch: "main",
52
54
  branchPrefix: "stonecut/",
53
55
  };
package/src/execute.ts CHANGED
@@ -45,7 +45,7 @@ export function buildReport(
45
45
  runnerName: string,
46
46
  closingRefs?: string[],
47
47
  ): string {
48
- const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
48
+ const lines = ["## Stonecut Report", `**Agent:** ${runnerName}`, ""];
49
49
  for (const r of results) {
50
50
  const metrics = formatMetrics(r);
51
51
  if (r.success) {
@@ -72,13 +72,14 @@ export async function pushAndMaybePr(
72
72
  results: IterationResult[],
73
73
  source: {
74
74
  getRemainingCount(): Promise<[number, number]>;
75
- getClosingRefs?(completedIssueNumbers: number[]): string[];
75
+ getClosingRefs?(): string[];
76
76
  },
77
77
  branch: string,
78
78
  baseBranch: string,
79
79
  prTitle: string,
80
80
  runnerName: string,
81
81
  logger: { log(message: string): void },
82
+ draft = false,
82
83
  ): Promise<void> {
83
84
  if (!results.some((r) => r.success)) {
84
85
  return;
@@ -89,11 +90,10 @@ export async function pushAndMaybePr(
89
90
 
90
91
  const [remaining, total] = await source.getRemainingCount();
91
92
  if (remaining === 0) {
92
- const completed = results.filter((r) => r.success).map((r) => r.issueNumber);
93
- const closingRefs = source.getClosingRefs?.(completed);
93
+ const closingRefs = source.getClosingRefs?.();
94
94
  const body = buildReport(results, runnerName, closingRefs);
95
- createPr(prTitle, body, baseBranch);
96
- logger.log("Created PR.");
95
+ const prUrl = createPr(prTitle, body, baseBranch, draft);
96
+ logger.log(`Created PR: ${prUrl}`);
97
97
  } else {
98
98
  logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
99
99
  }
@@ -115,6 +115,7 @@ export async function executeLocal(
115
115
  baseBranch: string,
116
116
  iterations: number | "all",
117
117
  runnerName: string,
118
+ draft = false,
118
119
  ): Promise<void> {
119
120
  const source = new LocalSource(name);
120
121
  const logger = new Logger();
@@ -151,6 +152,7 @@ export async function executeLocal(
151
152
  `Stonecut: ${name}`,
152
153
  runnerName,
153
154
  logger,
155
+ draft,
154
156
  );
155
157
  } finally {
156
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): void {
61
- const result = runSync([
62
- "gh",
63
- "pr",
64
- "create",
65
- "--title",
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(completedIssueNumbers: number[]): string[] {
90
- const completed = new Set(completedIssueNumbers);
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: { completed: number[] };
189
+ let data: StatusData;
127
190
  if (existsSync(path)) {
128
191
  data = JSON.parse(readFileSync(path, "utf-8"));
129
192
  } else {
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.sort((a, b) => a.name.localeCompare(b.name));
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
  }
@@ -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
@@ -53,6 +53,24 @@ export interface GitOps {
53
53
  revertUncommitted(snapshot: WorkingTreeSnapshot): void;
54
54
  }
55
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
+
56
74
  /** A single issue from a local spec. */
57
75
  export interface Issue {
58
76
  number: number;