stonecut 1.2.1 → 1.3.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 +10 -10
- package/package.json +2 -1
- package/src/cli.ts +31 -333
- package/src/execute.ts +142 -0
- package/src/git.ts +1 -20
- package/src/import.ts +1 -1
- package/src/local.ts +28 -2
- package/src/prd.ts +136 -0
- package/src/runner.ts +4 -79
- package/src/skills/stonecut-issues/SKILL.md +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -1
- package/src/skills/stonecut-review-architecture/SKILL.md +1 -1
- package/src/sources/github.ts +1 -12
- package/src/spawn.ts +22 -0
- package/src/sync-back.ts +88 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Ideas can come from anywhere — Jira tickets, Slack threads, MCP servers, or ju
|
|
|
14
14
|
1. **`/stonecut-interview`** — Stress-test the idea. Get grilled on the plan until it's solid.
|
|
15
15
|
2. **`/stonecut-prd`** — Turn the validated idea into a PRD (local file or GitHub issue).
|
|
16
16
|
3. **`/stonecut-issues`** — Break the PRD into independently-grabbable issues (local markdown files or GitHub sub-issues).
|
|
17
|
-
4. **`stonecut import`** _(optional)_ — If the PRD lives in GitHub, import it and its issues into `.stonecut/`.
|
|
17
|
+
4. **`stonecut import`** _(optional)_ — If the PRD lives in GitHub, import it and its issues into `.stonecut/specs/`.
|
|
18
18
|
5. **`stonecut`** — Execute the issues sequentially with an agentic coding CLI.
|
|
19
19
|
|
|
20
20
|
**From a technical exploration:** `RFC issue → PRD → issues → execute`
|
|
@@ -83,7 +83,7 @@ Running bare `stonecut` starts the interactive run wizard — the primary workfl
|
|
|
83
83
|
|
|
84
84
|
### `stonecut` — Interactive wizard
|
|
85
85
|
|
|
86
|
-
Stonecut uses a local-first execution model: `stonecut run` always reads from `.stonecut/<name>/`. External sources like GitHub are handled via `stonecut import`, which pulls PRDs and issues into the local structure before execution.
|
|
86
|
+
Stonecut uses a local-first execution model: `stonecut run` always reads from `.stonecut/specs/<name>/`. External sources like GitHub are handled via `stonecut import`, which pulls PRDs and issues into the local structure before execution.
|
|
87
87
|
|
|
88
88
|
When flags are omitted, Stonecut prompts for each missing parameter:
|
|
89
89
|
|
|
@@ -100,7 +100,7 @@ stonecut -i all
|
|
|
100
100
|
|
|
101
101
|
You can also use `stonecut run` explicitly — it's identical to bare `stonecut`.
|
|
102
102
|
|
|
103
|
-
The wizard scans `.stonecut/*/` 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.
|
|
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.
|
|
104
104
|
|
|
105
105
|
Flags provided via CLI skip the corresponding prompts. When all flags are given, the command runs without any prompts.
|
|
106
106
|
|
|
@@ -143,7 +143,7 @@ When config is present, the wizard uses these as default values — you can hit
|
|
|
143
143
|
|
|
144
144
|
### `stonecut import` — Import from external sources
|
|
145
145
|
|
|
146
|
-
Pulls a PRD and its sub-issues from an external source into `.stonecut/<name>/` so they can be executed locally with `stonecut run`.
|
|
146
|
+
Pulls a PRD and its sub-issues from an external source into `.stonecut/specs/<name>/` so they can be executed locally with `stonecut run`.
|
|
147
147
|
|
|
148
148
|
```sh
|
|
149
149
|
# Import GitHub PRD #42 — spec name derived from PRD title
|
|
@@ -161,7 +161,7 @@ The import command:
|
|
|
161
161
|
1. Fetches the PRD content and title from the source.
|
|
162
162
|
2. Fetches all sub-issues, ordered by number.
|
|
163
163
|
3. Derives a local spec name from the PRD title (e.g. "Add user authentication" becomes `add-user-authentication`). Override with `--name`.
|
|
164
|
-
4. Writes `prd.md` and numbered issue files into `.stonecut/<name>/issues/`.
|
|
164
|
+
4. Writes `prd.md` and numbered issue files into `.stonecut/specs/<name>/issues/`.
|
|
165
165
|
5. Creates initial `status.json` and `progress.txt`.
|
|
166
166
|
|
|
167
167
|
Import errors if the spec directory already exists (to avoid overwriting in-progress work). Use `--force` to overwrite intentionally.
|
|
@@ -220,7 +220,7 @@ When issues imported from GitHub are completed, Stonecut automatically closes th
|
|
|
220
220
|
|
|
221
221
|
| Flag | Short | Required | Description |
|
|
222
222
|
| -------------- | ----- | -------- | ------------------------------------------------------------------------ |
|
|
223
|
-
| `--local` | — | No | Local PRD name (`.stonecut/<name>/`). Prompted if omitted.
|
|
223
|
+
| `--local` | — | No | Local PRD name (`.stonecut/specs/<name>/`). Prompted if omitted. |
|
|
224
224
|
| `--iterations` | `-i` | No | Positive integer or `all`. Prompted with default `all` if omitted. |
|
|
225
225
|
| `--runner` | — | No | Agentic CLI runner (`claude`, `codex`). Default from config or `claude`. |
|
|
226
226
|
|
|
@@ -260,10 +260,10 @@ For imported PRDs, the PR body includes a `Closes #<number>` reference to the pa
|
|
|
260
260
|
|
|
261
261
|
## Local PRD structure
|
|
262
262
|
|
|
263
|
-
All execution reads from `.stonecut/<name>/` directories with this structure:
|
|
263
|
+
All execution reads from `.stonecut/specs/<name>/` directories with this structure:
|
|
264
264
|
|
|
265
265
|
```
|
|
266
|
-
.stonecut/my-feature/
|
|
266
|
+
.stonecut/specs/my-feature/
|
|
267
267
|
├── prd.md # The full PRD
|
|
268
268
|
├── issues/
|
|
269
269
|
│ ├── 01-setup.md # Issue files, numbered for ordering
|
|
@@ -279,7 +279,7 @@ PRDs and issues are committed to git; runtime state (`status.json`, `progress.tx
|
|
|
279
279
|
|
|
280
280
|
Issue and PRD files support YAML frontmatter for metadata. Locally-authored files don't need frontmatter — it's added automatically by `stonecut import`.
|
|
281
281
|
|
|
282
|
-
**PRD frontmatter** (`.stonecut/<name>/prd.md`):
|
|
282
|
+
**PRD frontmatter** (`.stonecut/specs/<name>/prd.md`):
|
|
283
283
|
|
|
284
284
|
```markdown
|
|
285
285
|
---
|
|
@@ -291,7 +291,7 @@ title: Add user authentication
|
|
|
291
291
|
The actual PRD content starts here...
|
|
292
292
|
```
|
|
293
293
|
|
|
294
|
-
**Issue frontmatter** (`.stonecut/<name>/issues/01-setup.md`):
|
|
294
|
+
**Issue frontmatter** (`.stonecut/specs/<name>/issues/01-setup.md`):
|
|
295
295
|
|
|
296
296
|
```markdown
|
|
297
297
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stonecut",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "CLI that drives PRD-driven development with agentic coding CLIs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"lint": "eslint src/ tests/",
|
|
28
28
|
"format": "prettier --write .",
|
|
29
29
|
"format:check": "prettier --check .",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
30
31
|
"test": "bun test",
|
|
31
32
|
"prepare": "husky"
|
|
32
33
|
},
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Stonecut CLI — PRD-driven development workflow orchestrator.
|
|
5
5
|
*
|
|
6
|
+
* Commander definitions, validation helpers, wizard prompts, and entry point.
|
|
6
7
|
* Modules throw errors; only this file catches them, formats user-facing
|
|
7
8
|
* messages, and calls process.exit().
|
|
8
9
|
*/
|
|
@@ -10,27 +11,15 @@
|
|
|
10
11
|
import * as clack from "@clack/prompts";
|
|
11
12
|
import { Command, InvalidArgumentError } from "commander";
|
|
12
13
|
import { createRequire } from "module";
|
|
13
|
-
import {
|
|
14
|
-
checkoutOrCreateBranch,
|
|
15
|
-
createPr,
|
|
16
|
-
defaultBranch,
|
|
17
|
-
ensureCleanTree,
|
|
18
|
-
pushBranch,
|
|
19
|
-
} from "./git";
|
|
20
|
-
import { LocalSource } from "./local";
|
|
14
|
+
import { defaultBranch, ensureCleanTree } from "./git";
|
|
21
15
|
import { slugifyBranchComponent } from "./naming";
|
|
22
|
-
import { renderLocal } from "./prompt";
|
|
23
|
-
import { Logger } from "./logger";
|
|
24
|
-
import { defaultGitOps, runAfkLoop } from "./runner";
|
|
25
|
-
import { getRunner } from "./runners/index";
|
|
26
16
|
import { setupSkills, removeSkills } from "./skills";
|
|
27
17
|
import { init } from "./init";
|
|
28
18
|
import { importSpec } from "./import";
|
|
29
19
|
import { loadConfig } from "./config";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import type { Issue, IterationResult, Session } from "./types";
|
|
20
|
+
import { existsSync } from "fs";
|
|
21
|
+
import { promptForPrd } from "./prd";
|
|
22
|
+
import { executeLocal } from "./execute";
|
|
34
23
|
|
|
35
24
|
const require = createRequire(import.meta.url);
|
|
36
25
|
const { version } = require("../package.json");
|
|
@@ -75,292 +64,6 @@ export function validateRunSource(
|
|
|
75
64
|
return { kind: "prompt" };
|
|
76
65
|
}
|
|
77
66
|
|
|
78
|
-
/** A locally available PRD with its completion status. */
|
|
79
|
-
export interface LocalPrdEntry {
|
|
80
|
-
name: string;
|
|
81
|
-
completed: number;
|
|
82
|
-
total: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Scan .stonecut subdirectories for directories containing prd.md and compute
|
|
87
|
-
* completion counts from status.json.
|
|
88
|
-
*/
|
|
89
|
-
export function scanLocalPrds(baseDir: string = ".stonecut/prd"): LocalPrdEntry[] {
|
|
90
|
-
if (!existsSync(baseDir)) return [];
|
|
91
|
-
|
|
92
|
-
const entries: LocalPrdEntry[] = [];
|
|
93
|
-
for (const name of readdirSync(baseDir)) {
|
|
94
|
-
const specDir = join(baseDir, name);
|
|
95
|
-
if (!statSync(specDir).isDirectory()) continue;
|
|
96
|
-
if (!existsSync(join(specDir, "prd.md"))) continue;
|
|
97
|
-
|
|
98
|
-
const issuesDir = join(specDir, "issues");
|
|
99
|
-
let total = 0;
|
|
100
|
-
if (existsSync(issuesDir) && statSync(issuesDir).isDirectory()) {
|
|
101
|
-
total = readdirSync(issuesDir).filter((f) => f.endsWith(".md")).length;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let completed = 0;
|
|
105
|
-
const statusPath = join(specDir, "status.json");
|
|
106
|
-
if (existsSync(statusPath)) {
|
|
107
|
-
try {
|
|
108
|
-
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
109
|
-
completed = Array.isArray(data.completed) ? data.completed.length : 0;
|
|
110
|
-
} catch {
|
|
111
|
-
// Malformed status.json — treat as 0 completed
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
entries.push({ name, completed, total });
|
|
116
|
-
}
|
|
117
|
-
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Format a PRD entry for display in the wizard.
|
|
122
|
-
*/
|
|
123
|
-
function formatPrdOption(entry: LocalPrdEntry): string {
|
|
124
|
-
if (entry.total === 0) return `${entry.name} (no issues)`;
|
|
125
|
-
if (entry.completed === 0) return `${entry.name} (not started)`;
|
|
126
|
-
return `${entry.name} (${entry.completed}/${entry.total} done)`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Sentinel value for the "Import from GitHub" wizard option. */
|
|
130
|
-
const IMPORT_FROM_GITHUB = "__import_from_github__";
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Prompt the user to select a local PRD or import from GitHub.
|
|
134
|
-
* Returns the local spec name to run.
|
|
135
|
-
*/
|
|
136
|
-
export async function promptForPrd(): Promise<{ kind: "local"; name: string }> {
|
|
137
|
-
const prds = scanLocalPrds();
|
|
138
|
-
|
|
139
|
-
const options: Array<{ value: string; label: string }> = prds.map((entry) => ({
|
|
140
|
-
value: entry.name,
|
|
141
|
-
label: formatPrdOption(entry),
|
|
142
|
-
}));
|
|
143
|
-
options.push({ value: IMPORT_FROM_GITHUB, label: "Import from GitHub" });
|
|
144
|
-
|
|
145
|
-
const selection = await clack.select({
|
|
146
|
-
message: "Select a PRD:",
|
|
147
|
-
options,
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
if (clack.isCancel(selection)) {
|
|
151
|
-
throw new Error("Cancelled.");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (selection === IMPORT_FROM_GITHUB) {
|
|
155
|
-
const specName = await inlineGitHubImport();
|
|
156
|
-
return { kind: "local", name: specName };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return { kind: "local", name: selection as string };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Inline GitHub import flow within the wizard.
|
|
164
|
-
* Lists PRDs by `prd` label, user picks one, import runs.
|
|
165
|
-
* Returns the imported spec name.
|
|
166
|
-
*/
|
|
167
|
-
async function inlineGitHubImport(): Promise<string> {
|
|
168
|
-
const provider = getSourceProvider("github");
|
|
169
|
-
const prdList = await provider.listPrds();
|
|
170
|
-
|
|
171
|
-
if (prdList.length === 0) {
|
|
172
|
-
throw new Error("No open PRDs with the 'prd' label found on GitHub.");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const prdOptions = prdList.map((p) => ({
|
|
176
|
-
value: String(p.number),
|
|
177
|
-
label: `#${p.number}: ${p.title}`,
|
|
178
|
-
}));
|
|
179
|
-
|
|
180
|
-
const selected = await clack.select({
|
|
181
|
-
message: "Select a GitHub PRD to import:",
|
|
182
|
-
options: prdOptions,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
if (clack.isCancel(selected)) {
|
|
186
|
-
throw new Error("Cancelled.");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const result = await importSpec({
|
|
190
|
-
provider: "github",
|
|
191
|
-
identifier: selected as string,
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
console.log(
|
|
195
|
-
`Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
return result.specName;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// Stonecut report
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Build the Stonecut Report section for a PR body.
|
|
207
|
-
*/
|
|
208
|
-
export function buildReport(
|
|
209
|
-
results: IterationResult[],
|
|
210
|
-
runnerName: string,
|
|
211
|
-
prdNumber?: number,
|
|
212
|
-
): string {
|
|
213
|
-
const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
|
|
214
|
-
for (const r of results) {
|
|
215
|
-
if (r.success) {
|
|
216
|
-
lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed`);
|
|
217
|
-
} else {
|
|
218
|
-
const reason = r.error || "unknown error";
|
|
219
|
-
lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (prdNumber !== undefined && results.every((r) => r.success)) {
|
|
224
|
-
lines.push("");
|
|
225
|
-
lines.push(`Closes #${prdNumber}`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return lines.join("\n");
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
// Pre-execution flow
|
|
233
|
-
// ---------------------------------------------------------------------------
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Run pre-execution prompts and git checks.
|
|
237
|
-
* Returns [branch, baseBranch].
|
|
238
|
-
*/
|
|
239
|
-
export async function preExecution(
|
|
240
|
-
suggestedBranch: string,
|
|
241
|
-
prefilled?: { branch?: string; baseBranch?: string },
|
|
242
|
-
): Promise<[string, string]> {
|
|
243
|
-
let branch: string;
|
|
244
|
-
if (prefilled?.branch) {
|
|
245
|
-
branch = prefilled.branch;
|
|
246
|
-
} else {
|
|
247
|
-
const branchInput = await clack.text({
|
|
248
|
-
message: "Branch name:",
|
|
249
|
-
defaultValue: suggestedBranch,
|
|
250
|
-
placeholder: suggestedBranch,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
if (clack.isCancel(branchInput)) {
|
|
254
|
-
throw new Error("Cancelled.");
|
|
255
|
-
}
|
|
256
|
-
branch = branchInput;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let baseBranch: string;
|
|
260
|
-
if (prefilled?.baseBranch) {
|
|
261
|
-
baseBranch = prefilled.baseBranch;
|
|
262
|
-
} else {
|
|
263
|
-
const detectedDefault = defaultBranch();
|
|
264
|
-
const baseBranchInput = await clack.text({
|
|
265
|
-
message: "Base branch / PR target:",
|
|
266
|
-
defaultValue: detectedDefault,
|
|
267
|
-
placeholder: detectedDefault,
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
if (clack.isCancel(baseBranchInput)) {
|
|
271
|
-
throw new Error("Cancelled.");
|
|
272
|
-
}
|
|
273
|
-
baseBranch = baseBranchInput;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
checkoutOrCreateBranch(branch);
|
|
277
|
-
console.log("");
|
|
278
|
-
|
|
279
|
-
return [branch, baseBranch];
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
// Post-loop: push and conditionally create PR
|
|
284
|
-
// ---------------------------------------------------------------------------
|
|
285
|
-
|
|
286
|
-
export async function pushAndMaybePr(
|
|
287
|
-
results: IterationResult[],
|
|
288
|
-
source: { getRemainingCount(): Promise<[number, number]> },
|
|
289
|
-
branch: string,
|
|
290
|
-
baseBranch: string,
|
|
291
|
-
prTitle: string,
|
|
292
|
-
runnerName: string,
|
|
293
|
-
logger: { log(message: string): void },
|
|
294
|
-
prdNumber?: number,
|
|
295
|
-
): Promise<void> {
|
|
296
|
-
if (!results.some((r) => r.success)) {
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
pushBranch(branch);
|
|
301
|
-
logger.log(`Pushed branch '${branch}'.`);
|
|
302
|
-
|
|
303
|
-
const [remaining, total] = await source.getRemainingCount();
|
|
304
|
-
if (remaining === 0) {
|
|
305
|
-
const body = buildReport(results, runnerName, prdNumber);
|
|
306
|
-
createPr(prTitle, body, baseBranch);
|
|
307
|
-
logger.log("Created PR.");
|
|
308
|
-
} else {
|
|
309
|
-
logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
// Execution paths
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
export async function runLocal(
|
|
318
|
-
name: string,
|
|
319
|
-
iterations: number | "all",
|
|
320
|
-
runnerName: string,
|
|
321
|
-
prefilled?: { branch?: string; baseBranch?: string },
|
|
322
|
-
): Promise<void> {
|
|
323
|
-
const runner = getRunner(runnerName);
|
|
324
|
-
const source = new LocalSource(name);
|
|
325
|
-
const prdIdentifier = slugifyBranchComponent(name) || "spec";
|
|
326
|
-
const logger = new Logger(prdIdentifier);
|
|
327
|
-
|
|
328
|
-
const session: Session = { logger, git: defaultGitOps, runner, runnerName };
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const suggestedBranch = prdIdentifier ? `stonecut/${prdIdentifier}` : "stonecut/spec";
|
|
332
|
-
const [branch, baseBranch] = await preExecution(suggestedBranch, prefilled);
|
|
333
|
-
|
|
334
|
-
const prdContent = await source.getPrdContent();
|
|
335
|
-
const results = await runAfkLoop<Issue>(
|
|
336
|
-
source,
|
|
337
|
-
iterations,
|
|
338
|
-
(issue) =>
|
|
339
|
-
renderLocal({
|
|
340
|
-
prdContent,
|
|
341
|
-
issueNumber: issue.number,
|
|
342
|
-
issueFilename: issue.filename,
|
|
343
|
-
issueContent: issue.content,
|
|
344
|
-
}),
|
|
345
|
-
(issue) => issue.filename,
|
|
346
|
-
(issue) => `Issue ${issue.number}: ${issue.filename}`,
|
|
347
|
-
session,
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
await pushAndMaybePr(
|
|
351
|
-
results,
|
|
352
|
-
source,
|
|
353
|
-
branch,
|
|
354
|
-
baseBranch,
|
|
355
|
-
`Stonecut: ${name}`,
|
|
356
|
-
runnerName,
|
|
357
|
-
logger,
|
|
358
|
-
);
|
|
359
|
-
} finally {
|
|
360
|
-
logger.close();
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
67
|
// ---------------------------------------------------------------------------
|
|
365
68
|
// Program definition
|
|
366
69
|
// ---------------------------------------------------------------------------
|
|
@@ -378,17 +81,17 @@ export function buildProgram(): Command {
|
|
|
378
81
|
program
|
|
379
82
|
.command("run", { isDefault: true })
|
|
380
83
|
.description("Execute issues from a local PRD.")
|
|
381
|
-
.option("--local <name>", "Local PRD name (.stonecut/
|
|
84
|
+
.option("--local <name>", "Local PRD name (.stonecut/specs/<name>/)")
|
|
382
85
|
.option("-i, --iterations <value>", "Number of issues to process, or 'all'")
|
|
86
|
+
.option("--branch <name>", "Branch name for the run")
|
|
87
|
+
.option("--base-branch <name>", "Base branch / PR target")
|
|
383
88
|
.option("--runner <name>", "Agentic CLI runner (claude, codex)")
|
|
384
89
|
.action(async (opts) => {
|
|
385
90
|
ensureCleanTree();
|
|
386
|
-
|
|
387
91
|
const config = loadConfig();
|
|
388
92
|
|
|
389
93
|
const validated = validateRunSource(opts.local);
|
|
390
94
|
let source: { kind: "local"; name: string };
|
|
391
|
-
|
|
392
95
|
if (validated.kind === "local") {
|
|
393
96
|
source = validated;
|
|
394
97
|
} else {
|
|
@@ -419,40 +122,45 @@ export function buildProgram(): Command {
|
|
|
419
122
|
}
|
|
420
123
|
|
|
421
124
|
const isWizard = validated.kind === "prompt" || needsIterationPrompt;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!existsSync(".stonecut")) {
|
|
426
|
-
console.log("Hint: run `stonecut init` to set up project config and gitignore.\n");
|
|
427
|
-
}
|
|
125
|
+
if (isWizard && !existsSync(".stonecut")) {
|
|
126
|
+
console.log("Hint: run `stonecut init` to set up project config and gitignore.\n");
|
|
127
|
+
}
|
|
428
128
|
|
|
129
|
+
let branch: string;
|
|
130
|
+
if (opts.branch) {
|
|
131
|
+
branch = opts.branch;
|
|
132
|
+
} else {
|
|
429
133
|
const branchPrefix = config?.branchPrefix ?? "stonecut/";
|
|
430
134
|
const suggestedBranch = `${branchPrefix}${slugifyBranchComponent(source.name) || "spec"}`;
|
|
431
|
-
|
|
432
|
-
const branch = await clack.text({
|
|
135
|
+
const branchInput = await clack.text({
|
|
433
136
|
message: "Branch name:",
|
|
434
137
|
defaultValue: suggestedBranch,
|
|
435
138
|
placeholder: suggestedBranch,
|
|
436
139
|
});
|
|
437
|
-
if (clack.isCancel(
|
|
140
|
+
if (clack.isCancel(branchInput)) {
|
|
438
141
|
throw new Error("Cancelled.");
|
|
439
142
|
}
|
|
143
|
+
branch = branchInput;
|
|
144
|
+
}
|
|
440
145
|
|
|
146
|
+
let baseBranch: string;
|
|
147
|
+
if (opts.baseBranch) {
|
|
148
|
+
baseBranch = opts.baseBranch;
|
|
149
|
+
} else {
|
|
441
150
|
const detectedDefault = config?.baseBranch ?? defaultBranch();
|
|
442
|
-
const
|
|
151
|
+
const baseBranchInput = await clack.text({
|
|
443
152
|
message: "Base branch / PR target:",
|
|
444
153
|
defaultValue: detectedDefault,
|
|
445
154
|
placeholder: detectedDefault,
|
|
446
155
|
});
|
|
447
|
-
if (clack.isCancel(
|
|
156
|
+
if (clack.isCancel(baseBranchInput)) {
|
|
448
157
|
throw new Error("Cancelled.");
|
|
449
158
|
}
|
|
450
|
-
|
|
451
|
-
prefilled = { branch, baseBranch };
|
|
159
|
+
baseBranch = baseBranchInput;
|
|
452
160
|
}
|
|
453
161
|
|
|
454
162
|
const runnerName: string = opts.runner ?? config?.runner ?? "claude";
|
|
455
|
-
await
|
|
163
|
+
await executeLocal(source.name, branch, baseBranch, iterations, runnerName);
|
|
456
164
|
});
|
|
457
165
|
|
|
458
166
|
program
|
|
@@ -473,14 +181,12 @@ export function buildProgram(): Command {
|
|
|
473
181
|
if (opts.github === undefined) {
|
|
474
182
|
throw new Error("Specify a source: --github <number>");
|
|
475
183
|
}
|
|
476
|
-
|
|
477
184
|
const result = await importSpec({
|
|
478
185
|
provider: "github",
|
|
479
186
|
identifier: String(opts.github),
|
|
480
187
|
name: opts.name,
|
|
481
188
|
force: opts.force,
|
|
482
189
|
});
|
|
483
|
-
|
|
484
190
|
console.log(
|
|
485
191
|
`Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
|
|
486
192
|
);
|
|
@@ -495,12 +201,8 @@ export function buildProgram(): Command {
|
|
|
495
201
|
)
|
|
496
202
|
.action((opts) => {
|
|
497
203
|
const result = setupSkills(opts.target);
|
|
498
|
-
for (const msg of result.messages)
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
for (const warn of result.warnings) {
|
|
502
|
-
console.error(warn);
|
|
503
|
-
}
|
|
204
|
+
for (const msg of result.messages) console.log(msg);
|
|
205
|
+
for (const warn of result.warnings) console.error(warn);
|
|
504
206
|
});
|
|
505
207
|
|
|
506
208
|
program
|
|
@@ -512,12 +214,8 @@ export function buildProgram(): Command {
|
|
|
512
214
|
)
|
|
513
215
|
.action((opts) => {
|
|
514
216
|
const result = removeSkills(opts.target);
|
|
515
|
-
for (const msg of result.messages)
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
for (const warn of result.warnings) {
|
|
519
|
-
console.error(warn);
|
|
520
|
-
}
|
|
217
|
+
for (const msg of result.messages) console.log(msg);
|
|
218
|
+
for (const warn of result.warnings) console.error(warn);
|
|
521
219
|
});
|
|
522
220
|
|
|
523
221
|
return program;
|
package/src/execute.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution orchestration — non-interactive, prompt-free.
|
|
3
|
+
*
|
|
4
|
+
* executeLocal: set up session, checkout branch, run loop, push/PR.
|
|
5
|
+
* pushAndMaybePr: push branch and conditionally create PR.
|
|
6
|
+
* buildReport: format iteration results for PR body.
|
|
7
|
+
*
|
|
8
|
+
* This module never imports @clack/prompts — the interactive/execution
|
|
9
|
+
* boundary is enforced by module structure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { checkoutOrCreateBranch, createPr, pushBranch } from "./git";
|
|
13
|
+
import { LocalSource } from "./local";
|
|
14
|
+
import { slugifyBranchComponent } from "./naming";
|
|
15
|
+
import { renderLocal } from "./prompt";
|
|
16
|
+
import { Logger } from "./logger";
|
|
17
|
+
import { defaultGitOps, runAfkLoop } from "./runner";
|
|
18
|
+
import { getRunner } from "./runners/index";
|
|
19
|
+
import type { Issue, IterationResult, Session } from "./types";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Stonecut report
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the Stonecut Report section for a PR body.
|
|
27
|
+
*/
|
|
28
|
+
export function buildReport(
|
|
29
|
+
results: IterationResult[],
|
|
30
|
+
runnerName: string,
|
|
31
|
+
closingRefs?: string[],
|
|
32
|
+
): string {
|
|
33
|
+
const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
|
|
34
|
+
for (const r of results) {
|
|
35
|
+
if (r.success) {
|
|
36
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed`);
|
|
37
|
+
} else {
|
|
38
|
+
const reason = r.error || "unknown error";
|
|
39
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (closingRefs && closingRefs.length > 0) {
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(closingRefs.join("\n"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Post-loop: push and conditionally create PR
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export async function pushAndMaybePr(
|
|
56
|
+
results: IterationResult[],
|
|
57
|
+
source: {
|
|
58
|
+
getRemainingCount(): Promise<[number, number]>;
|
|
59
|
+
getClosingRefs?(completedIssueNumbers: number[]): string[];
|
|
60
|
+
},
|
|
61
|
+
branch: string,
|
|
62
|
+
baseBranch: string,
|
|
63
|
+
prTitle: string,
|
|
64
|
+
runnerName: string,
|
|
65
|
+
logger: { log(message: string): void },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!results.some((r) => r.success)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pushBranch(branch);
|
|
72
|
+
logger.log(`Pushed branch '${branch}'.`);
|
|
73
|
+
|
|
74
|
+
const [remaining, total] = await source.getRemainingCount();
|
|
75
|
+
if (remaining === 0) {
|
|
76
|
+
const completed = results.filter((r) => r.success).map((r) => r.issueNumber);
|
|
77
|
+
const closingRefs = source.getClosingRefs?.(completed);
|
|
78
|
+
const body = buildReport(results, runnerName, closingRefs);
|
|
79
|
+
createPr(prTitle, body, baseBranch);
|
|
80
|
+
logger.log("Created PR.");
|
|
81
|
+
} else {
|
|
82
|
+
logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Execution orchestration
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Execute a local PRD run — non-interactive orchestration.
|
|
92
|
+
*
|
|
93
|
+
* All parameters are explicit: no interactive prompts, no @clack/prompts.
|
|
94
|
+
* Handles branch checkout, runner session, loop, push, and PR creation.
|
|
95
|
+
*/
|
|
96
|
+
export async function executeLocal(
|
|
97
|
+
name: string,
|
|
98
|
+
branch: string,
|
|
99
|
+
baseBranch: string,
|
|
100
|
+
iterations: number | "all",
|
|
101
|
+
runnerName: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const runner = getRunner(runnerName);
|
|
104
|
+
const source = new LocalSource(name);
|
|
105
|
+
const prdIdentifier = slugifyBranchComponent(name) || "spec";
|
|
106
|
+
const logger = new Logger(prdIdentifier);
|
|
107
|
+
|
|
108
|
+
const session: Session = { logger, git: defaultGitOps, runner, runnerName };
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
checkoutOrCreateBranch(branch);
|
|
112
|
+
console.log("");
|
|
113
|
+
|
|
114
|
+
const prdContent = await source.getPrdContent();
|
|
115
|
+
const results = await runAfkLoop<Issue>(
|
|
116
|
+
source,
|
|
117
|
+
iterations,
|
|
118
|
+
(issue) =>
|
|
119
|
+
renderLocal({
|
|
120
|
+
prdContent,
|
|
121
|
+
issueNumber: issue.number,
|
|
122
|
+
issueFilename: issue.filename,
|
|
123
|
+
issueContent: issue.content,
|
|
124
|
+
}),
|
|
125
|
+
(issue) => issue.filename,
|
|
126
|
+
(issue) => `Issue ${issue.number}: ${issue.filename}`,
|
|
127
|
+
session,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await pushAndMaybePr(
|
|
131
|
+
results,
|
|
132
|
+
source,
|
|
133
|
+
branch,
|
|
134
|
+
baseBranch,
|
|
135
|
+
`Stonecut: ${name}`,
|
|
136
|
+
runnerName,
|
|
137
|
+
logger,
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
logger.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -5,28 +5,9 @@
|
|
|
5
5
|
* All functions throw on failure. No process.exit, no console output.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { runSync } from "./spawn";
|
|
8
9
|
import type { WorkingTreeSnapshot } from "./types";
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
* Run a command synchronously, optionally in a specific working directory.
|
|
12
|
-
* When cwd is omitted, uses the current process working directory.
|
|
13
|
-
*/
|
|
14
|
-
function runSync(
|
|
15
|
-
cmd: string[],
|
|
16
|
-
cwd?: string,
|
|
17
|
-
): { exitCode: number; stdout: string; stderr: string } {
|
|
18
|
-
const proc = Bun.spawnSync(cmd, {
|
|
19
|
-
stdout: "pipe",
|
|
20
|
-
stderr: "pipe",
|
|
21
|
-
...(cwd && { cwd }),
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
exitCode: proc.exitCode,
|
|
25
|
-
stdout: proc.stdout.toString(),
|
|
26
|
-
stderr: proc.stderr.toString(),
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
11
|
/** Detect the remote's default branch, falling back to "main". */
|
|
31
12
|
export function defaultBranch(cwd?: string): string {
|
|
32
13
|
const result = runSync(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
|
package/src/import.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function importSpec(options: ImportOptions): Promise<ImportResult>
|
|
|
37
37
|
throw new Error("Could not derive a spec name from the PRD title. Use --name to specify one.");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const specDir = join(".stonecut", "
|
|
40
|
+
const specDir = join(".stonecut", "specs", specName);
|
|
41
41
|
|
|
42
42
|
if (existsSync(specDir)) {
|
|
43
43
|
if (!options.force) {
|
package/src/local.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Local spec source — reads issues from .stonecut/
|
|
1
|
+
/** Local spec source — reads issues from .stonecut/specs/<name>/. */
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, statSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
@@ -11,7 +11,7 @@ export class LocalSource implements Source<Issue> {
|
|
|
11
11
|
|
|
12
12
|
constructor(name: string) {
|
|
13
13
|
this.name = name;
|
|
14
|
-
this.specDir = join(".stonecut", "
|
|
14
|
+
this.specDir = join(".stonecut", "specs", name);
|
|
15
15
|
this.validate();
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -86,6 +86,32 @@ export class LocalSource implements Source<Issue> {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
getClosingRefs(completedIssueNumbers: number[]): string[] {
|
|
90
|
+
const completed = new Set(completedIssueNumbers);
|
|
91
|
+
const refs: string[] = [];
|
|
92
|
+
const all = this.allIssues();
|
|
93
|
+
|
|
94
|
+
for (const issue of all) {
|
|
95
|
+
if (!completed.has(issue.number)) continue;
|
|
96
|
+
const raw = readFileSync(issue.path, "utf-8");
|
|
97
|
+
const num = parseInt(parseFrontmatter(raw).meta.issue, 10);
|
|
98
|
+
if (num && !isNaN(num)) {
|
|
99
|
+
refs.push(`Closes #${num}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const allComplete = all.every((i) => completed.has(i.number));
|
|
104
|
+
if (allComplete) {
|
|
105
|
+
const raw = readFileSync(join(this.specDir, "prd.md"), "utf-8");
|
|
106
|
+
const num = parseInt(parseFrontmatter(raw).meta.issue, 10);
|
|
107
|
+
if (num && !isNaN(num)) {
|
|
108
|
+
refs.push(`Closes #${num}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return refs;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
async getRemainingCount(): Promise<[number, number]> {
|
|
90
116
|
const completed = this.readStatus();
|
|
91
117
|
const all = this.allIssues();
|
package/src/prd.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD selection UI — filesystem scanning, interactive picker, and inline
|
|
3
|
+
* GitHub import flow.
|
|
4
|
+
*
|
|
5
|
+
* This is the only extracted module that imports @clack/prompts, as it is
|
|
6
|
+
* part of the interactive UI layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as clack from "@clack/prompts";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { importSpec } from "./import";
|
|
13
|
+
import { getSourceProvider } from "./sources/index";
|
|
14
|
+
|
|
15
|
+
/** A locally available PRD with its completion status. */
|
|
16
|
+
export interface LocalPrdEntry {
|
|
17
|
+
name: string;
|
|
18
|
+
completed: number;
|
|
19
|
+
total: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Scan .stonecut subdirectories for directories containing prd.md and compute
|
|
24
|
+
* completion counts from status.json.
|
|
25
|
+
*/
|
|
26
|
+
export function scanLocalPrds(baseDir: string = ".stonecut/specs"): LocalPrdEntry[] {
|
|
27
|
+
if (!existsSync(baseDir)) return [];
|
|
28
|
+
|
|
29
|
+
const entries: LocalPrdEntry[] = [];
|
|
30
|
+
for (const name of readdirSync(baseDir)) {
|
|
31
|
+
const specDir = join(baseDir, name);
|
|
32
|
+
if (!statSync(specDir).isDirectory()) continue;
|
|
33
|
+
if (!existsSync(join(specDir, "prd.md"))) continue;
|
|
34
|
+
|
|
35
|
+
const issuesDir = join(specDir, "issues");
|
|
36
|
+
let total = 0;
|
|
37
|
+
if (existsSync(issuesDir) && statSync(issuesDir).isDirectory()) {
|
|
38
|
+
total = readdirSync(issuesDir).filter((f) => f.endsWith(".md")).length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let completed = 0;
|
|
42
|
+
const statusPath = join(specDir, "status.json");
|
|
43
|
+
if (existsSync(statusPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
46
|
+
completed = Array.isArray(data.completed) ? data.completed.length : 0;
|
|
47
|
+
} catch {
|
|
48
|
+
// Malformed status.json — treat as 0 completed
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
entries.push({ name, completed, total });
|
|
53
|
+
}
|
|
54
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format a PRD entry for display in the wizard.
|
|
59
|
+
*/
|
|
60
|
+
export function formatPrdOption(entry: LocalPrdEntry): string {
|
|
61
|
+
if (entry.total === 0) return `${entry.name} (no issues)`;
|
|
62
|
+
if (entry.completed === 0) return `${entry.name} (not started)`;
|
|
63
|
+
return `${entry.name} (${entry.completed}/${entry.total} done)`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sentinel value for the "Import from GitHub" wizard option. */
|
|
67
|
+
const IMPORT_FROM_GITHUB = "__import_from_github__";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Prompt the user to select a local PRD or import from GitHub.
|
|
71
|
+
* Returns the local spec name to run.
|
|
72
|
+
*/
|
|
73
|
+
export async function promptForPrd(): Promise<{ kind: "local"; name: string }> {
|
|
74
|
+
const prds = scanLocalPrds();
|
|
75
|
+
|
|
76
|
+
const options: Array<{ value: string; label: string }> = prds.map((entry) => ({
|
|
77
|
+
value: entry.name,
|
|
78
|
+
label: formatPrdOption(entry),
|
|
79
|
+
}));
|
|
80
|
+
options.push({ value: IMPORT_FROM_GITHUB, label: "Import from GitHub" });
|
|
81
|
+
|
|
82
|
+
const selection = await clack.select({
|
|
83
|
+
message: "Select a PRD:",
|
|
84
|
+
options,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (clack.isCancel(selection)) {
|
|
88
|
+
throw new Error("Cancelled.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (selection === IMPORT_FROM_GITHUB) {
|
|
92
|
+
const specName = await inlineGitHubImport();
|
|
93
|
+
return { kind: "local", name: specName };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { kind: "local", name: selection as string };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Inline GitHub import flow within the wizard.
|
|
101
|
+
* Lists PRDs by `prd` label, user picks one, import runs.
|
|
102
|
+
* Returns the imported spec name.
|
|
103
|
+
*/
|
|
104
|
+
async function inlineGitHubImport(): Promise<string> {
|
|
105
|
+
const provider = getSourceProvider("github");
|
|
106
|
+
const prdList = await provider.listPrds();
|
|
107
|
+
|
|
108
|
+
if (prdList.length === 0) {
|
|
109
|
+
throw new Error("No open PRDs with the 'prd' label found on GitHub.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const prdOptions = prdList.map((p) => ({
|
|
113
|
+
value: String(p.number),
|
|
114
|
+
label: `#${p.number}: ${p.title}`,
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
const selected = await clack.select({
|
|
118
|
+
message: "Select a GitHub PRD to import:",
|
|
119
|
+
options: prdOptions,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (clack.isCancel(selected)) {
|
|
123
|
+
throw new Error("Cancelled.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await importSpec({
|
|
127
|
+
provider: "github",
|
|
128
|
+
identifier: selected as string,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
`Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return result.specName;
|
|
136
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runner —
|
|
2
|
+
* Runner — orchestration loop, commit flow, and session helpers.
|
|
3
3
|
*
|
|
4
4
|
* verifyAndFix: single check → fix cycle.
|
|
5
5
|
* commitIssue: stage, commit, retry on failure up to maxRetries times.
|
|
6
6
|
* runAfkLoop: main orchestration loop over issues from any source.
|
|
7
7
|
* fmtTime / printSummary: session output formatting.
|
|
8
8
|
*
|
|
9
|
+
* Sync-back logic lives in sync-back.ts.
|
|
9
10
|
* Modules throw on failure. No process.exit, no console output.
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -15,9 +16,8 @@ import {
|
|
|
15
16
|
snapshotWorkingTree as realSnapshotWorkingTree,
|
|
16
17
|
stageChanges as realStageChanges,
|
|
17
18
|
} from "./git";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { getSourceProvider } from "./sources/index";
|
|
19
|
+
import { syncBackIssue, syncBackPrd } from "./sync-back";
|
|
20
|
+
import type { SyncBackConfig } from "./sync-back";
|
|
21
21
|
import type {
|
|
22
22
|
GitOps,
|
|
23
23
|
IterationResult,
|
|
@@ -42,81 +42,6 @@ export const consoleLogger: LogWriter = {
|
|
|
42
42
|
close: () => {},
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
* Configuration for syncing issue/PRD completion back to an external source.
|
|
47
|
-
*
|
|
48
|
-
* When provided to runAfkLoop, the runner reads frontmatter from completed
|
|
49
|
-
* issue files. If a `source` field is present, the corresponding provider
|
|
50
|
-
* is resolved and notified of the completion.
|
|
51
|
-
*/
|
|
52
|
-
export interface SyncBackConfig<T> {
|
|
53
|
-
/** Return the file path for a completed issue, or undefined to skip sync-back. */
|
|
54
|
-
getIssuePath: (issue: T) => string | undefined;
|
|
55
|
-
/** Path to the PRD file, used for sync-back after all issues complete. */
|
|
56
|
-
prdPath?: string;
|
|
57
|
-
/** Read a file's contents. Injectable for testing; defaults to fs.readFileSync. */
|
|
58
|
-
readFile?: (path: string) => string;
|
|
59
|
-
/** Resolve a source provider by name. Injectable for testing; defaults to getSourceProvider. */
|
|
60
|
-
resolveProvider?: (name: string) => {
|
|
61
|
-
onIssueComplete(id: string): Promise<void>;
|
|
62
|
-
onPrdComplete(id: string): Promise<void>;
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Sync a completed issue back to its external source.
|
|
68
|
-
*
|
|
69
|
-
* Reads frontmatter from the issue file. If `source` and `issue` fields
|
|
70
|
-
* are present, resolves the provider and calls onIssueComplete.
|
|
71
|
-
* Failures are logged as warnings but never thrown.
|
|
72
|
-
*/
|
|
73
|
-
export async function syncBackIssue(
|
|
74
|
-
filePath: string,
|
|
75
|
-
logger: LogWriter,
|
|
76
|
-
readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
|
|
77
|
-
resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
|
|
78
|
-
): Promise<void> {
|
|
79
|
-
try {
|
|
80
|
-
const content = readFile(filePath);
|
|
81
|
-
const { meta } = parseFrontmatter(content);
|
|
82
|
-
if (!meta.source || !meta.issue) return;
|
|
83
|
-
|
|
84
|
-
const provider = resolveProvider!(meta.source);
|
|
85
|
-
await provider.onIssueComplete(meta.issue);
|
|
86
|
-
logger.log(`Synced issue #${meta.issue} back to ${meta.source}`);
|
|
87
|
-
} catch (err: unknown) {
|
|
88
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
-
logger.log(`Warning: sync-back failed for issue at ${filePath}: ${message}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Sync PRD completion back to its external source.
|
|
95
|
-
*
|
|
96
|
-
* Reads frontmatter from the PRD file. If `source` and `issue` fields
|
|
97
|
-
* are present, resolves the provider and calls onPrdComplete.
|
|
98
|
-
* Failures are logged as warnings but never thrown.
|
|
99
|
-
*/
|
|
100
|
-
export async function syncBackPrd(
|
|
101
|
-
filePath: string,
|
|
102
|
-
logger: LogWriter,
|
|
103
|
-
readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
|
|
104
|
-
resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
|
|
105
|
-
): Promise<void> {
|
|
106
|
-
try {
|
|
107
|
-
const content = readFile(filePath);
|
|
108
|
-
const { meta } = parseFrontmatter(content);
|
|
109
|
-
if (!meta.source || !meta.issue) return;
|
|
110
|
-
|
|
111
|
-
const provider = resolveProvider!(meta.source);
|
|
112
|
-
await provider.onPrdComplete(meta.issue);
|
|
113
|
-
logger.log(`Synced PRD #${meta.issue} back to ${meta.source}`);
|
|
114
|
-
} catch (err: unknown) {
|
|
115
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
-
logger.log(`Warning: sync-back failed for PRD at ${filePath}: ${message}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
45
|
/**
|
|
121
46
|
* Single check → fix cycle.
|
|
122
47
|
*
|
|
@@ -12,7 +12,7 @@ You are breaking a PRD into issues as part of the Stonecut workflow. Each issue
|
|
|
12
12
|
Determine where the PRD lives. Check these in order:
|
|
13
13
|
|
|
14
14
|
1. **Conversation context** — If a PRD was created earlier in this conversation (via `/stonecut-prd` or otherwise), you already know where it is. State where you found it and confirm with the user.
|
|
15
|
-
2. **Ask the user** — If no PRD is in context, ask: "Where is the PRD? Give me a local file path (
|
|
15
|
+
2. **Ask the user** — If no PRD is in context, ask: "Where is the PRD? Give me a local file path (`.stonecut/specs/<name>/prd.md`) or a GitHub issue number."
|
|
16
16
|
|
|
17
17
|
If given a GitHub issue number, fetch it with `gh issue view <number>`.
|
|
18
18
|
If given a local path, read the file.
|
|
@@ -63,7 +63,7 @@ Iterate until the user approves the breakdown.
|
|
|
63
63
|
|
|
64
64
|
Default to **matching the PRD location**:
|
|
65
65
|
|
|
66
|
-
- If the PRD is a **local file
|
|
66
|
+
- If the PRD is a **local file**, default to creating issues under `.stonecut/specs/<name>/issues/`.
|
|
67
67
|
- If the PRD is a **GitHub issue**, default to creating issues as GitHub issues using `gh issue create`.
|
|
68
68
|
|
|
69
69
|
Confirm with the user before creating. If they want a different destination, respect that.
|
|
@@ -75,7 +75,7 @@ Confirm with the user before creating. If they want a different destination, res
|
|
|
75
75
|
Create each issue as a markdown file in the issues directory. Use zero-padded numbering with a kebab-case descriptive suffix:
|
|
76
76
|
|
|
77
77
|
```
|
|
78
|
-
.stonecut/<name>/issues/
|
|
78
|
+
.stonecut/specs/<name>/issues/
|
|
79
79
|
01-short-descriptive-title.md
|
|
80
80
|
02-another-slice-title.md
|
|
81
81
|
...
|
|
@@ -88,7 +88,7 @@ Create issues in dependency order (blockers first). Use the local issue template
|
|
|
88
88
|
|
|
89
89
|
## Parent PRD
|
|
90
90
|
|
|
91
|
-
See `.stonecut/<name>/prd.md`
|
|
91
|
+
See `.stonecut/specs/<name>/prd.md`
|
|
92
92
|
|
|
93
93
|
## What to build
|
|
94
94
|
|
|
@@ -29,7 +29,7 @@ Check with the user that these modules match their expectations. Ask which modul
|
|
|
29
29
|
|
|
30
30
|
Ask the user where to save the PRD:
|
|
31
31
|
|
|
32
|
-
- **Local file** — Save as `.stonecut/<name>/prd.md` in the project. Ask the user: "What should I name this spec?" The name can be anything — a ticket ID (e.g., `ASC-1`), a descriptive slug (e.g., `auth-refactor`), or whatever fits. Create the `.stonecut/<name>/` directory if it doesn't exist.
|
|
32
|
+
- **Local file** — Save as `.stonecut/specs/<name>/prd.md` in the project. Ask the user: "What should I name this spec?" The name can be anything — a ticket ID (e.g., `ASC-1`), a descriptive slug (e.g., `auth-refactor`), or whatever fits. Create the `.stonecut/specs/<name>/` directory if it doesn't exist.
|
|
33
33
|
- **GitHub issue** — Create a GitHub issue using `gh issue create --label prd`. Before creating, ensure the `prd` label exists:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
@@ -75,7 +75,7 @@ After comparing, give your own recommendation: which design you think is stronge
|
|
|
75
75
|
|
|
76
76
|
Ask the user where to save the RFC:
|
|
77
77
|
|
|
78
|
-
- **Local file** — Save as `.stonecut/<name>/rfc.md` in the project. Ask the user: "What should I name this spec?" Create the `.stonecut/<name>/` directory if it doesn't exist.
|
|
78
|
+
- **Local file** — Save as `.stonecut/specs/<name>/rfc.md` in the project. Ask the user: "What should I name this spec?" Create the `.stonecut/specs/<name>/` directory if it doesn't exist.
|
|
79
79
|
- **GitHub issue** — Create a GitHub issue using `gh issue create --label rfc`. Before creating, ensure the `rfc` label exists:
|
|
80
80
|
|
|
81
81
|
```bash
|
package/src/sources/github.ts
CHANGED
|
@@ -5,20 +5,9 @@
|
|
|
5
5
|
* structured as a stateless SourceProvider.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { runSync } from "../spawn";
|
|
8
9
|
import type { IssueData, PrdData, PrdSummary, SourceProvider } from "./types.js";
|
|
9
10
|
|
|
10
|
-
function runSync(cmd: string[]): { exitCode: number; stdout: string; stderr: string } {
|
|
11
|
-
const proc = Bun.spawnSync(cmd, {
|
|
12
|
-
stdout: "pipe",
|
|
13
|
-
stderr: "pipe",
|
|
14
|
-
});
|
|
15
|
-
return {
|
|
16
|
-
exitCode: proc.exitCode,
|
|
17
|
-
stdout: proc.stdout.toString(),
|
|
18
|
-
stderr: proc.stderr.toString(),
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
11
|
export class GitHubSourceProvider implements SourceProvider {
|
|
23
12
|
readonly owner: string;
|
|
24
13
|
readonly repo: string;
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared synchronous process wrapper used by git.ts and sources/github.ts.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for Bun.spawnSync stdout/stderr conversion.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Run a command synchronously, optionally in a specific working directory. */
|
|
8
|
+
export function runSync(
|
|
9
|
+
cmd: string[],
|
|
10
|
+
cwd?: string,
|
|
11
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
12
|
+
const proc = Bun.spawnSync(cmd, {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
...(cwd && { cwd }),
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
exitCode: proc.exitCode,
|
|
19
|
+
stdout: proc.stdout.toString(),
|
|
20
|
+
stderr: proc.stderr.toString(),
|
|
21
|
+
};
|
|
22
|
+
}
|
package/src/sync-back.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync-back — notify external sources when issues or PRDs complete.
|
|
3
|
+
*
|
|
4
|
+
* syncBackIssue: read frontmatter from an issue file, resolve provider, call onIssueComplete.
|
|
5
|
+
* syncBackPrd: read frontmatter from a PRD file, resolve provider, call onPrdComplete.
|
|
6
|
+
*
|
|
7
|
+
* Failures are logged as warnings but never thrown — sync-back is best-effort.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { parseFrontmatter } from "./frontmatter";
|
|
12
|
+
import { getSourceProvider } from "./sources/index";
|
|
13
|
+
import type { LogWriter } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for syncing issue/PRD completion back to an external source.
|
|
17
|
+
*
|
|
18
|
+
* When provided to runAfkLoop, the runner reads frontmatter from completed
|
|
19
|
+
* issue files. If a `source` field is present, the corresponding provider
|
|
20
|
+
* is resolved and notified of the completion.
|
|
21
|
+
*/
|
|
22
|
+
export interface SyncBackConfig<T> {
|
|
23
|
+
/** Return the file path for a completed issue, or undefined to skip sync-back. */
|
|
24
|
+
getIssuePath: (issue: T) => string | undefined;
|
|
25
|
+
/** Path to the PRD file, used for sync-back after all issues complete. */
|
|
26
|
+
prdPath?: string;
|
|
27
|
+
/** Read a file's contents. Injectable for testing; defaults to fs.readFileSync. */
|
|
28
|
+
readFile?: (path: string) => string;
|
|
29
|
+
/** Resolve a source provider by name. Injectable for testing; defaults to getSourceProvider. */
|
|
30
|
+
resolveProvider?: (name: string) => {
|
|
31
|
+
onIssueComplete(id: string): Promise<void>;
|
|
32
|
+
onPrdComplete(id: string): Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sync a completed issue back to its external source.
|
|
38
|
+
*
|
|
39
|
+
* Reads frontmatter from the issue file. If `source` and `issue` fields
|
|
40
|
+
* are present, resolves the provider and calls onIssueComplete.
|
|
41
|
+
* Failures are logged as warnings but never thrown.
|
|
42
|
+
*/
|
|
43
|
+
export async function syncBackIssue(
|
|
44
|
+
filePath: string,
|
|
45
|
+
logger: LogWriter,
|
|
46
|
+
readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
|
|
47
|
+
resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
const content = readFile(filePath);
|
|
51
|
+
const { meta } = parseFrontmatter(content);
|
|
52
|
+
if (!meta.source || !meta.issue) return;
|
|
53
|
+
|
|
54
|
+
const provider = resolveProvider!(meta.source);
|
|
55
|
+
await provider.onIssueComplete(meta.issue);
|
|
56
|
+
logger.log(`Synced issue #${meta.issue} back to ${meta.source}`);
|
|
57
|
+
} catch (err: unknown) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
logger.log(`Warning: sync-back failed for issue at ${filePath}: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sync PRD completion back to its external source.
|
|
65
|
+
*
|
|
66
|
+
* Reads frontmatter from the PRD file. If `source` and `issue` fields
|
|
67
|
+
* are present, resolves the provider and calls onPrdComplete.
|
|
68
|
+
* Failures are logged as warnings but never thrown.
|
|
69
|
+
*/
|
|
70
|
+
export async function syncBackPrd(
|
|
71
|
+
filePath: string,
|
|
72
|
+
logger: LogWriter,
|
|
73
|
+
readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
|
|
74
|
+
resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
const content = readFile(filePath);
|
|
78
|
+
const { meta } = parseFrontmatter(content);
|
|
79
|
+
if (!meta.source || !meta.issue) return;
|
|
80
|
+
|
|
81
|
+
const provider = resolveProvider!(meta.source);
|
|
82
|
+
await provider.onPrdComplete(meta.issue);
|
|
83
|
+
logger.log(`Synced PRD #${meta.issue} back to ${meta.source}`);
|
|
84
|
+
} catch (err: unknown) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
logger.log(`Warning: sync-back failed for PRD at ${filePath}: ${message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|