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/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