stonecut 1.0.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/LICENSE +21 -0
- package/README.md +162 -0
- package/package.json +53 -0
- package/src/cli.ts +370 -0
- package/src/git.ts +189 -0
- package/src/github.ts +178 -0
- package/src/local.ts +116 -0
- package/src/logger.ts +32 -0
- package/src/naming.ts +14 -0
- package/src/prompt.ts +50 -0
- package/src/runner.ts +298 -0
- package/src/runners/claude.ts +89 -0
- package/src/runners/codex.ts +77 -0
- package/src/runners/index.ts +21 -0
- package/src/skills/stonecut-interview/SKILL.md +20 -0
- package/src/skills/stonecut-issues/SKILL.md +167 -0
- package/src/skills/stonecut-prd/SKILL.md +127 -0
- package/src/skills.ts +163 -0
- package/src/templates/.gitkeep +0 -0
- package/src/templates/execute.md +28 -0
- package/src/types.ts +83 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner — commit flow, orchestration loop, and session helpers.
|
|
3
|
+
*
|
|
4
|
+
* verifyAndFix: single check → fix cycle.
|
|
5
|
+
* commitIssue: stage, commit, retry on failure up to maxRetries times.
|
|
6
|
+
* runAfkLoop: main orchestration loop over issues from any source.
|
|
7
|
+
* fmtTime / printSummary: session output formatting.
|
|
8
|
+
*
|
|
9
|
+
* Modules throw on failure. No process.exit, no console output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
commitChanges as realCommitChanges,
|
|
14
|
+
revertUncommitted as realRevertUncommitted,
|
|
15
|
+
snapshotWorkingTree as realSnapshotWorkingTree,
|
|
16
|
+
stageChanges as realStageChanges,
|
|
17
|
+
} from "./git";
|
|
18
|
+
import type {
|
|
19
|
+
GitOps,
|
|
20
|
+
IterationResult,
|
|
21
|
+
LogWriter,
|
|
22
|
+
Runner,
|
|
23
|
+
Session,
|
|
24
|
+
Source,
|
|
25
|
+
WorkingTreeSnapshot,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
/** Default git operations that call the real git module. */
|
|
29
|
+
export const defaultGitOps: GitOps = {
|
|
30
|
+
snapshotWorkingTree: realSnapshotWorkingTree,
|
|
31
|
+
stageChanges: realStageChanges,
|
|
32
|
+
commitChanges: realCommitChanges,
|
|
33
|
+
revertUncommitted: realRevertUncommitted,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Console-only logger for backward compatibility. */
|
|
37
|
+
export const consoleLogger: LogWriter = {
|
|
38
|
+
log: (message: string) => console.log(message),
|
|
39
|
+
close: () => {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Single check → fix cycle.
|
|
44
|
+
*
|
|
45
|
+
* Runs `check`. If it passes, returns immediately. If it fails,
|
|
46
|
+
* spawns the runner with a prompt built from the error output,
|
|
47
|
+
* then runs the check once more.
|
|
48
|
+
*
|
|
49
|
+
* Returns [success, output] from the final check.
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyAndFix(
|
|
52
|
+
runner: Runner,
|
|
53
|
+
check: () => [boolean, string],
|
|
54
|
+
fixPrompt: (error: string) => string,
|
|
55
|
+
): Promise<[boolean, string]> {
|
|
56
|
+
const [ok, output] = check();
|
|
57
|
+
if (ok) {
|
|
58
|
+
return [true, output];
|
|
59
|
+
}
|
|
60
|
+
await runner.run(fixPrompt(output));
|
|
61
|
+
return check();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stage, commit, and retry on failure up to `maxRetries` times.
|
|
66
|
+
*
|
|
67
|
+
* Returns [success, output] where output is the commit or error
|
|
68
|
+
* output from the last attempt.
|
|
69
|
+
*/
|
|
70
|
+
export async function commitIssue(
|
|
71
|
+
runner: Runner,
|
|
72
|
+
message: string,
|
|
73
|
+
snapshot: WorkingTreeSnapshot,
|
|
74
|
+
maxRetries: number = 3,
|
|
75
|
+
git: GitOps = defaultGitOps,
|
|
76
|
+
): Promise<[boolean, string]> {
|
|
77
|
+
git.stageChanges(snapshot);
|
|
78
|
+
|
|
79
|
+
let output = "";
|
|
80
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
81
|
+
const [ok, result] = await verifyAndFix(
|
|
82
|
+
runner,
|
|
83
|
+
() => git.commitChanges(message),
|
|
84
|
+
(error) =>
|
|
85
|
+
"The git commit failed with the following output. " +
|
|
86
|
+
"Fix the issues and stop. Do not commit.\n\n" +
|
|
87
|
+
error,
|
|
88
|
+
);
|
|
89
|
+
output = result;
|
|
90
|
+
if (ok) {
|
|
91
|
+
return [true, output];
|
|
92
|
+
}
|
|
93
|
+
// Re-stage after the fix attempt (runner may have changed files)
|
|
94
|
+
git.stageChanges(snapshot);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [false, output];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run the autonomous loop over issues from any source.
|
|
102
|
+
*
|
|
103
|
+
* Uses the provided Session to execute each issue's prompt.
|
|
104
|
+
* After each successful run, Stonecut stages and commits the changes.
|
|
105
|
+
* Works with both LocalSource and GitHubSource.
|
|
106
|
+
*
|
|
107
|
+
* A failing issue is retried once (2 total attempts). If it fails
|
|
108
|
+
* a second time, the session stops — issues are sequential vertical
|
|
109
|
+
* slices, so skipping is not viable.
|
|
110
|
+
*/
|
|
111
|
+
export async function runAfkLoop<T extends { number: number }>(
|
|
112
|
+
source: Source<T>,
|
|
113
|
+
iterations: number | "all",
|
|
114
|
+
renderPrompt: (issue: T) => string | Promise<string>,
|
|
115
|
+
displayName: (issue: T) => string,
|
|
116
|
+
commitMessage: (issue: T) => string,
|
|
117
|
+
session: Session,
|
|
118
|
+
onNoChanges?: (issue: T, output: string | undefined) => void,
|
|
119
|
+
): Promise<IterationResult[]> {
|
|
120
|
+
const { logger, git, runner, runnerName } = session;
|
|
121
|
+
|
|
122
|
+
logger.log(`Session started — runner: ${runnerName}, iterations: ${iterations}`);
|
|
123
|
+
logger.log("");
|
|
124
|
+
const results: IterationResult[] = [];
|
|
125
|
+
const sessionStart = performance.now();
|
|
126
|
+
let iteration = 0;
|
|
127
|
+
let lastFailedIssueNumber: number | null = null;
|
|
128
|
+
|
|
129
|
+
while (true) {
|
|
130
|
+
// Check iteration limit
|
|
131
|
+
if (typeof iterations === "number" && iteration >= iterations) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const issue = await source.getNextIssue();
|
|
136
|
+
if (issue === null) {
|
|
137
|
+
if (iteration === 0) {
|
|
138
|
+
logger.log("All issues complete!");
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
iteration++;
|
|
144
|
+
const name = displayName(issue);
|
|
145
|
+
const [remaining, total] = await source.getRemainingCount();
|
|
146
|
+
|
|
147
|
+
// Detect retry
|
|
148
|
+
if (lastFailedIssueNumber === issue.number) {
|
|
149
|
+
logger.log(
|
|
150
|
+
`--- Iteration ${iteration} --- [Issue ${issue.number}: ${name}] (${remaining}/${total} remaining)`,
|
|
151
|
+
);
|
|
152
|
+
logger.log(`Retrying issue ${issue.number} (attempt 2 of 2)`);
|
|
153
|
+
} else {
|
|
154
|
+
logger.log(
|
|
155
|
+
`--- Iteration ${iteration} --- [Issue ${issue.number}: ${name}] (${remaining}/${total} remaining)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Snapshot working tree before runner
|
|
160
|
+
const snapshot = git.snapshotWorkingTree();
|
|
161
|
+
|
|
162
|
+
logger.log(`Running ${runnerName}...`);
|
|
163
|
+
const prompt = await renderPrompt(issue);
|
|
164
|
+
const runResult = await runner.run(prompt);
|
|
165
|
+
|
|
166
|
+
if (!runResult.success) {
|
|
167
|
+
logger.log(`Reverted working tree to pre-run snapshot.`);
|
|
168
|
+
git.revertUncommitted(snapshot);
|
|
169
|
+
const errorDetail = runResult.error || "unknown error";
|
|
170
|
+
logger.log(
|
|
171
|
+
`Issue ${issue.number}: runner failed — ${errorDetail} ` +
|
|
172
|
+
`(${fmtTime(runResult.durationSeconds)})`,
|
|
173
|
+
);
|
|
174
|
+
results.push({
|
|
175
|
+
issueNumber: issue.number,
|
|
176
|
+
issueFilename: name,
|
|
177
|
+
success: false,
|
|
178
|
+
elapsedSeconds: runResult.durationSeconds,
|
|
179
|
+
error: runResult.error,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (lastFailedIssueNumber === issue.number) {
|
|
183
|
+
logger.log(`Issue ${issue.number}: failed twice consecutively — stopping session.`);
|
|
184
|
+
logger.log("");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
lastFailedIssueNumber = issue.number;
|
|
188
|
+
logger.log("");
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Runner succeeded — check for changes
|
|
193
|
+
logger.log(`Staging changes...`);
|
|
194
|
+
const hasChanges = git.stageChanges(snapshot);
|
|
195
|
+
if (!hasChanges) {
|
|
196
|
+
const errorMsg = "runner produced no changes";
|
|
197
|
+
logger.log(`Issue ${issue.number}: ${errorMsg} ` + `(${fmtTime(runResult.durationSeconds)})`);
|
|
198
|
+
if (onNoChanges) {
|
|
199
|
+
onNoChanges(issue, runResult.output);
|
|
200
|
+
}
|
|
201
|
+
results.push({
|
|
202
|
+
issueNumber: issue.number,
|
|
203
|
+
issueFilename: name,
|
|
204
|
+
success: false,
|
|
205
|
+
elapsedSeconds: runResult.durationSeconds,
|
|
206
|
+
error: errorMsg,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (lastFailedIssueNumber === issue.number) {
|
|
210
|
+
logger.log(`Issue ${issue.number}: failed twice consecutively — stopping session.`);
|
|
211
|
+
logger.log("");
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
lastFailedIssueNumber = issue.number;
|
|
215
|
+
logger.log("");
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Commit the changes
|
|
220
|
+
logger.log(`Committing...`);
|
|
221
|
+
const msg = commitMessage(issue);
|
|
222
|
+
const [committed] = await commitIssue(runner, msg, snapshot, 3, git);
|
|
223
|
+
|
|
224
|
+
if (!committed) {
|
|
225
|
+
logger.log(
|
|
226
|
+
`Issue ${issue.number}: commit failed after retries ` +
|
|
227
|
+
`(${fmtTime(runResult.durationSeconds)})`,
|
|
228
|
+
);
|
|
229
|
+
git.revertUncommitted(snapshot);
|
|
230
|
+
results.push({
|
|
231
|
+
issueNumber: issue.number,
|
|
232
|
+
issueFilename: name,
|
|
233
|
+
success: false,
|
|
234
|
+
elapsedSeconds: runResult.durationSeconds,
|
|
235
|
+
error: "commit failed after retries",
|
|
236
|
+
});
|
|
237
|
+
// Commit failures always stop the session immediately
|
|
238
|
+
logger.log("Stopping session: unable to commit.");
|
|
239
|
+
logger.log("");
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Commit succeeded — mark issue complete
|
|
244
|
+
await source.completeIssue(issue);
|
|
245
|
+
logger.log(
|
|
246
|
+
`Issue ${issue.number}: committed and completed ` + `(${fmtTime(runResult.durationSeconds)})`,
|
|
247
|
+
);
|
|
248
|
+
results.push({
|
|
249
|
+
issueNumber: issue.number,
|
|
250
|
+
issueFilename: name,
|
|
251
|
+
success: true,
|
|
252
|
+
elapsedSeconds: runResult.durationSeconds,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
lastFailedIssueNumber = null;
|
|
256
|
+
logger.log("");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Session summary
|
|
260
|
+
const totalElapsed = (performance.now() - sessionStart) / 1000;
|
|
261
|
+
printSummary(results, totalElapsed, logger);
|
|
262
|
+
return results;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Format seconds as a human-readable duration. */
|
|
266
|
+
export function fmtTime(seconds: number): string {
|
|
267
|
+
if (seconds < 60) {
|
|
268
|
+
return `${Math.round(seconds)}s`;
|
|
269
|
+
}
|
|
270
|
+
const minutes = Math.floor(seconds / 60);
|
|
271
|
+
const secs = Math.floor(seconds % 60);
|
|
272
|
+
return `${minutes}m ${secs}s`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Print a summary of the afk session. */
|
|
276
|
+
export function printSummary(
|
|
277
|
+
results: IterationResult[],
|
|
278
|
+
totalSeconds: number,
|
|
279
|
+
logger: LogWriter = consoleLogger,
|
|
280
|
+
): void {
|
|
281
|
+
if (results.length === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
logger.log("=== Session Summary ===");
|
|
286
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
287
|
+
const failed = results.filter((r) => !r.success).length;
|
|
288
|
+
|
|
289
|
+
for (const r of results) {
|
|
290
|
+
const status = r.success ? "completed" : "failed";
|
|
291
|
+
const elapsed = fmtTime(r.elapsedSeconds);
|
|
292
|
+
logger.log(` Issue ${r.issueNumber} (${r.issueFilename}): ${status} (${elapsed})`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logger.log("");
|
|
296
|
+
logger.log(`Total: ${results.length} issues — ${succeeded} completed, ${failed} failed`);
|
|
297
|
+
logger.log(`Total time: ${fmtTime(totalSeconds)}`);
|
|
298
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeRunner — adapter for Claude Code CLI.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a headless Claude Code session, parses the JSON output to
|
|
5
|
+
* determine success/failure, and translates error subtypes into
|
|
6
|
+
* human-readable messages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Runner, RunResult } from "../types.js";
|
|
10
|
+
|
|
11
|
+
const ERROR_MESSAGES: Record<string, string> = {
|
|
12
|
+
error_max_turns: "max turns exceeded",
|
|
13
|
+
error_max_budget_usd: "max budget exceeded",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class ClaudeRunner implements Runner {
|
|
17
|
+
async run(prompt: string): Promise<RunResult> {
|
|
18
|
+
const start = performance.now();
|
|
19
|
+
|
|
20
|
+
let proc: Awaited<ReturnType<typeof Bun.spawn>>;
|
|
21
|
+
try {
|
|
22
|
+
proc = Bun.spawn(
|
|
23
|
+
[
|
|
24
|
+
"claude",
|
|
25
|
+
"-p",
|
|
26
|
+
"--output-format",
|
|
27
|
+
"json",
|
|
28
|
+
"--allowedTools",
|
|
29
|
+
"Bash,Edit,Read,Write,Glob,Grep",
|
|
30
|
+
],
|
|
31
|
+
{ stdin: new Response(prompt), stdout: "pipe", stderr: "pipe" },
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return {
|
|
35
|
+
success: false,
|
|
36
|
+
exitCode: 1,
|
|
37
|
+
durationSeconds: (performance.now() - start) / 1000,
|
|
38
|
+
error: "claude binary not found in PATH",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const exitCode = await proc.exited;
|
|
43
|
+
const durationSeconds = (performance.now() - start) / 1000;
|
|
44
|
+
|
|
45
|
+
const stdout = await new Response(proc.stdout as ReadableStream).text();
|
|
46
|
+
const output = stdout || undefined;
|
|
47
|
+
|
|
48
|
+
if (output === undefined) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
exitCode,
|
|
52
|
+
durationSeconds,
|
|
53
|
+
error: `no output (exit code ${exitCode})`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let data: unknown;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(output);
|
|
60
|
+
} catch {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
exitCode,
|
|
64
|
+
durationSeconds,
|
|
65
|
+
output,
|
|
66
|
+
error: "malformed JSON output",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
exitCode,
|
|
74
|
+
durationSeconds,
|
|
75
|
+
output,
|
|
76
|
+
error: "unexpected JSON output (not an object)",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const subtype = (data as Record<string, unknown>).subtype ?? "";
|
|
81
|
+
if (subtype === "success") {
|
|
82
|
+
return { success: true, exitCode, durationSeconds, output };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const error =
|
|
86
|
+
ERROR_MESSAGES[subtype as string] ?? `failed (${(subtype as string) || "unknown"})`;
|
|
87
|
+
return { success: false, exitCode, durationSeconds, output, error };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodexRunner — adapter for OpenAI Codex CLI.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a headless Codex session in full-auto mode, uses exit code as
|
|
5
|
+
* the primary success signal, and extracts error details from JSONL
|
|
6
|
+
* output on failure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Runner, RunResult } from "../types.js";
|
|
10
|
+
|
|
11
|
+
function extractError(stdout: string): string {
|
|
12
|
+
for (const raw of stdout.split("\n")) {
|
|
13
|
+
const line = raw.trim();
|
|
14
|
+
if (!line) continue;
|
|
15
|
+
|
|
16
|
+
let event: unknown;
|
|
17
|
+
try {
|
|
18
|
+
event = JSON.parse(line);
|
|
19
|
+
} catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (typeof event !== "object" || event === null || Array.isArray(event)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rec = event as Record<string, unknown>;
|
|
28
|
+
const eventType = rec.type;
|
|
29
|
+
|
|
30
|
+
if (eventType === "turn.failed") {
|
|
31
|
+
const nested = rec.error;
|
|
32
|
+
if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
|
|
33
|
+
const msg = (nested as Record<string, unknown>).message;
|
|
34
|
+
if (typeof msg === "string" && msg) return msg;
|
|
35
|
+
}
|
|
36
|
+
} else if (eventType === "error") {
|
|
37
|
+
const msg = rec.message;
|
|
38
|
+
if (typeof msg === "string" && msg) return msg;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return "codex exited with non-zero status";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CodexRunner implements Runner {
|
|
46
|
+
async run(prompt: string): Promise<RunResult> {
|
|
47
|
+
const start = performance.now();
|
|
48
|
+
|
|
49
|
+
let proc: Awaited<ReturnType<typeof Bun.spawn>>;
|
|
50
|
+
try {
|
|
51
|
+
proc = Bun.spawn(["codex", "exec", "--full-auto", "--json", "--ephemeral", "-"], {
|
|
52
|
+
stdin: new Response(prompt),
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
durationSeconds: (performance.now() - start) / 1000,
|
|
61
|
+
error: "codex binary not found in PATH",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const exitCode = await proc.exited;
|
|
66
|
+
const durationSeconds = (performance.now() - start) / 1000;
|
|
67
|
+
|
|
68
|
+
if (exitCode === 0) {
|
|
69
|
+
return { success: true, exitCode: 0, durationSeconds };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stdout = await new Response(proc.stdout as ReadableStream).text();
|
|
73
|
+
const error = extractError(stdout);
|
|
74
|
+
|
|
75
|
+
return { success: false, exitCode, durationSeconds, error };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner registry — maps runner names to their implementations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Runner } from "../types.js";
|
|
6
|
+
import { ClaudeRunner } from "./claude.js";
|
|
7
|
+
import { CodexRunner } from "./codex.js";
|
|
8
|
+
|
|
9
|
+
const RUNNERS: Record<string, new () => Runner> = {
|
|
10
|
+
claude: ClaudeRunner,
|
|
11
|
+
codex: CodexRunner,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function getRunner(name: string): Runner {
|
|
15
|
+
const Cls = RUNNERS[name];
|
|
16
|
+
if (!Cls) {
|
|
17
|
+
const available = Object.keys(RUNNERS).sort().join(", ");
|
|
18
|
+
throw new Error(`Unknown runner '${name}'. Available runners: ${available}`);
|
|
19
|
+
}
|
|
20
|
+
return new Cls();
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stonecut-interview
|
|
3
|
+
description: Stress-test a plan or design through relentless interviewing. Walk down each branch of the decision tree, resolving dependencies one-by-one until reaching shared understanding. Use when the user wants to validate an idea before writing a PRD.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one.
|
|
7
|
+
|
|
8
|
+
If a question can be answered by exploring the codebase, explore the codebase instead.
|
|
9
|
+
|
|
10
|
+
## Guidelines
|
|
11
|
+
|
|
12
|
+
- Be relentless. Push back on vague answers. The goal is stress-testing, not politeness.
|
|
13
|
+
- Prioritize questions that could change the shape of the solution.
|
|
14
|
+
- When you discover a contradiction or gap, state it clearly and demand resolution.
|
|
15
|
+
- For each question, provide your recommended answer.
|
|
16
|
+
- Once a branch is resolved, move on. Don't revisit settled decisions.
|
|
17
|
+
|
|
18
|
+
## Next Step
|
|
19
|
+
|
|
20
|
+
When the interview is complete and you've reached shared understanding, ask the user: "Ready to write the PRD? I can run `/stonecut-prd` next."
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stonecut-issues
|
|
3
|
+
description: Break a PRD into independently-grabbable GitHub issues or local markdown files using tracer-bullet vertical slices. Use when the user wants to convert a PRD into implementation tickets or work items.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are breaking a PRD into issues as part of the Stonecut workflow. Each issue should be a thin vertical slice that cuts through all integration layers end-to-end.
|
|
7
|
+
|
|
8
|
+
## Process
|
|
9
|
+
|
|
10
|
+
### 1. Locate the PRD
|
|
11
|
+
|
|
12
|
+
Determine where the PRD lives. Check these in order:
|
|
13
|
+
|
|
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 (e.g., `.stonecut/ASC-1/prd.md`) or a GitHub issue number."
|
|
16
|
+
|
|
17
|
+
If given a GitHub issue number, fetch it with `gh issue view <number>`.
|
|
18
|
+
If given a local path, read the file.
|
|
19
|
+
|
|
20
|
+
### 2. Explore the codebase (optional)
|
|
21
|
+
|
|
22
|
+
If you have not already explored the codebase, do so to understand the current state of the code.
|
|
23
|
+
|
|
24
|
+
### 3. Draft vertical slices
|
|
25
|
+
|
|
26
|
+
Break the PRD into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer.
|
|
27
|
+
|
|
28
|
+
Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible.
|
|
29
|
+
|
|
30
|
+
<vertical-slice-rules>
|
|
31
|
+
- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests)
|
|
32
|
+
- A completed slice is demoable or verifiable on its own
|
|
33
|
+
- Prefer many thin slices over few thick ones
|
|
34
|
+
</vertical-slice-rules>
|
|
35
|
+
|
|
36
|
+
#### Documentation Impact check
|
|
37
|
+
|
|
38
|
+
Inspect the PRD's **Documentation Impact** section to determine whether documentation work is needed:
|
|
39
|
+
|
|
40
|
+
- **Non-trivial changes** (new README sections, updated usage examples, new docs/ pages): generate a **dedicated documentation issue** — typically the last or near-last slice, since it depends on the feature being built.
|
|
41
|
+
- **Trivial changes** (adding one line to a table, fixing a version number): fold the doc update into the relevant feature slice's acceptance criteria instead of creating a separate issue.
|
|
42
|
+
- **No changes needed**: no action required — the PRD author already stated why.
|
|
43
|
+
|
|
44
|
+
### 4. Quiz the user
|
|
45
|
+
|
|
46
|
+
Present the proposed breakdown as a numbered list. For each slice, show:
|
|
47
|
+
|
|
48
|
+
- **Title**: short descriptive name
|
|
49
|
+
- **Type**: HITL / AFK
|
|
50
|
+
- **Blocked by**: which other slices (if any) must complete first
|
|
51
|
+
- **User stories covered**: which user stories from the PRD this addresses
|
|
52
|
+
|
|
53
|
+
Ask the user:
|
|
54
|
+
|
|
55
|
+
- Does the granularity feel right? (too coarse / too fine)
|
|
56
|
+
- Are the dependency relationships correct?
|
|
57
|
+
- Should any slices be merged or split further?
|
|
58
|
+
- Are the correct slices marked as HITL and AFK?
|
|
59
|
+
|
|
60
|
+
Iterate until the user approves the breakdown.
|
|
61
|
+
|
|
62
|
+
### 5. Determine where to create the issues
|
|
63
|
+
|
|
64
|
+
Default to **matching the PRD location**:
|
|
65
|
+
|
|
66
|
+
- If the PRD is a **local file** (e.g., `.stonecut/ASC-1/prd.md`), default to creating issues in the same directory under `issues/` (e.g., `.stonecut/ASC-1/issues/01-short-title.md`).
|
|
67
|
+
- If the PRD is a **GitHub issue**, default to creating issues as GitHub issues using `gh issue create`.
|
|
68
|
+
|
|
69
|
+
Confirm with the user before creating. If they want a different destination, respect that.
|
|
70
|
+
|
|
71
|
+
### 6. Create the issues
|
|
72
|
+
|
|
73
|
+
#### Local files
|
|
74
|
+
|
|
75
|
+
Create each issue as a markdown file in the issues directory. Use zero-padded numbering with a kebab-case descriptive suffix:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
.stonecut/<name>/issues/
|
|
79
|
+
01-short-descriptive-title.md
|
|
80
|
+
02-another-slice-title.md
|
|
81
|
+
...
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Create issues in dependency order (blockers first). Use the local issue template below.
|
|
85
|
+
|
|
86
|
+
<local-issue-template>
|
|
87
|
+
# Issue <N>: <Title>
|
|
88
|
+
|
|
89
|
+
## Parent PRD
|
|
90
|
+
|
|
91
|
+
See `.stonecut/<name>/prd.md`
|
|
92
|
+
|
|
93
|
+
## What to build
|
|
94
|
+
|
|
95
|
+
A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation. Reference specific sections of the parent PRD rather than duplicating content.
|
|
96
|
+
|
|
97
|
+
## Acceptance criteria
|
|
98
|
+
|
|
99
|
+
- [ ] Criterion 1
|
|
100
|
+
- [ ] Criterion 2
|
|
101
|
+
- [ ] Criterion 3
|
|
102
|
+
|
|
103
|
+
## Blocked by
|
|
104
|
+
|
|
105
|
+
- Blocked by Issue <N> (if any)
|
|
106
|
+
|
|
107
|
+
Or "None — can start immediately" if no blockers.
|
|
108
|
+
|
|
109
|
+
## User stories addressed
|
|
110
|
+
|
|
111
|
+
Reference by number from the parent PRD:
|
|
112
|
+
|
|
113
|
+
- User story 3
|
|
114
|
+
- User story 7
|
|
115
|
+
|
|
116
|
+
</local-issue-template>
|
|
117
|
+
|
|
118
|
+
#### GitHub issues
|
|
119
|
+
|
|
120
|
+
Create each issue using `gh issue create`. Create in dependency order so you can reference real issue numbers in the "Blocked by" field.
|
|
121
|
+
|
|
122
|
+
After creating each GitHub issue, link it as a sub-issue of the PRD:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Get node IDs
|
|
126
|
+
PRD_NODE_ID=$(gh api repos/{owner}/{repo}/issues/{prd_number} --jq '.node_id')
|
|
127
|
+
ISSUE_NODE_ID=$(gh api repos/{owner}/{repo}/issues/{new_issue_number} --jq '.node_id')
|
|
128
|
+
|
|
129
|
+
# Link as sub-issue
|
|
130
|
+
gh api graphql -f query="mutation { addSubIssue(input: { issueId: \"$PRD_NODE_ID\" subIssueId: \"$ISSUE_NODE_ID\" }) { subIssue { number } } }"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
<github-issue-template>
|
|
134
|
+
## What to build
|
|
135
|
+
|
|
136
|
+
A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation. Reference specific sections of the parent PRD rather than duplicating content.
|
|
137
|
+
|
|
138
|
+
## Acceptance criteria
|
|
139
|
+
|
|
140
|
+
- [ ] Criterion 1
|
|
141
|
+
- [ ] Criterion 2
|
|
142
|
+
- [ ] Criterion 3
|
|
143
|
+
|
|
144
|
+
## Blocked by
|
|
145
|
+
|
|
146
|
+
- Blocked by #<issue-number> (if any)
|
|
147
|
+
|
|
148
|
+
Or "None - can start immediately" if no blockers.
|
|
149
|
+
|
|
150
|
+
## User stories addressed
|
|
151
|
+
|
|
152
|
+
Reference by number from the parent PRD:
|
|
153
|
+
|
|
154
|
+
- User story 3
|
|
155
|
+
- User story 7
|
|
156
|
+
|
|
157
|
+
</github-issue-template>
|
|
158
|
+
|
|
159
|
+
Do NOT close or modify the parent PRD issue (if it's a GitHub issue).
|
|
160
|
+
|
|
161
|
+
## Workflow Complete
|
|
162
|
+
|
|
163
|
+
Once all issues are created, present the user with their options:
|
|
164
|
+
|
|
165
|
+
1. **Implement now** — Start on the first issue in this session
|
|
166
|
+
2. **Start fresh** — Pick up implementation in a new session
|
|
167
|
+
3. **Use the stonecut CLI** — Run implementation via the CLI
|