stonecut 1.2.1 → 1.4.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 +4 -1
- package/src/cli.ts +31 -333
- package/src/execute.ts +159 -0
- package/src/git.ts +1 -20
- package/src/import.ts +1 -1
- package/src/local.ts +28 -2
- package/src/logger.ts +4 -0
- package/src/prd.ts +136 -0
- package/src/runner.ts +42 -81
- package/src/runners/claude.ts +140 -56
- package/src/runners/codex.ts +134 -54
- package/src/runners/index.ts +4 -4
- package/src/skills/stonecut-issues/SKILL.md +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -3
- 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/src/types.ts +9 -2
package/src/runners/claude.ts
CHANGED
|
@@ -1,94 +1,178 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClaudeRunner — adapter for Claude Code
|
|
2
|
+
* ClaudeRunner — adapter for Claude Code via the Agent SDK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Calls query() from @anthropic-ai/claude-agent-sdk and consumes the
|
|
5
|
+
* async message stream internally. Console gets concise progress
|
|
6
|
+
* (tool calls, turn boundaries, errors); the log file captures the
|
|
7
|
+
* full event stream for debugging.
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import type {
|
|
12
|
+
Query,
|
|
13
|
+
SDKAssistantMessage,
|
|
14
|
+
SDKMessage,
|
|
15
|
+
SDKPartialAssistantMessage,
|
|
16
|
+
SDKResultMessage,
|
|
17
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
9
18
|
import type { LogWriter, Runner, RunResult } from "../types.js";
|
|
10
19
|
|
|
20
|
+
const ALLOWED_TOOLS = ["Bash", "Edit", "Read", "Write", "Glob", "Grep"];
|
|
21
|
+
|
|
22
|
+
type QueryFn = (params: { prompt: string; options?: Record<string, unknown> }) => Query;
|
|
23
|
+
let queryFn: QueryFn = sdkQuery;
|
|
24
|
+
|
|
25
|
+
/** @internal Replace the SDK query function. Returns a restore function. */
|
|
26
|
+
export function _setQueryFn(fn: QueryFn): () => void {
|
|
27
|
+
const prev = queryFn;
|
|
28
|
+
queryFn = fn;
|
|
29
|
+
return () => {
|
|
30
|
+
queryFn = prev;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
const ERROR_MESSAGES: Record<string, string> = {
|
|
12
35
|
error_max_turns: "max turns exceeded",
|
|
13
36
|
error_max_budget_usd: "max budget exceeded",
|
|
37
|
+
error_during_execution: "execution error",
|
|
38
|
+
error_max_structured_output_retries: "max structured output retries exceeded",
|
|
14
39
|
};
|
|
15
40
|
|
|
16
41
|
export class ClaudeRunner implements Runner {
|
|
17
|
-
|
|
42
|
+
private logger: LogWriter;
|
|
43
|
+
|
|
44
|
+
constructor(logger: LogWriter) {
|
|
45
|
+
this.logger = logger;
|
|
18
46
|
const configDir = process.env.CLAUDE_CONFIG_DIR || "~/.claude (default)";
|
|
19
47
|
logger.log(`Claude config: ${configDir}`);
|
|
20
48
|
}
|
|
21
49
|
|
|
22
50
|
async run(prompt: string): Promise<RunResult> {
|
|
23
51
|
const start = performance.now();
|
|
52
|
+
let result: SDKResultMessage | undefined;
|
|
24
53
|
|
|
25
|
-
let proc: Awaited<ReturnType<typeof Bun.spawn>>;
|
|
26
54
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
const stream = queryFn({
|
|
56
|
+
prompt,
|
|
57
|
+
options: {
|
|
58
|
+
allowedTools: ALLOWED_TOOLS,
|
|
59
|
+
permissionMode: "acceptEdits",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for await (const message of stream) {
|
|
64
|
+
this.handleMessage(message);
|
|
65
|
+
if (message.type === "result") {
|
|
66
|
+
result = message;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error: unknown) {
|
|
70
|
+
const durationSeconds = (performance.now() - start) / 1000;
|
|
71
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
72
|
+
|
|
73
|
+
if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
|
|
74
|
+
return { success: false, durationSeconds, error: "claude binary not found in PATH" };
|
|
75
|
+
}
|
|
76
|
+
return { success: false, durationSeconds, error: msg };
|
|
45
77
|
}
|
|
46
78
|
|
|
47
|
-
const exitCode = await proc.exited;
|
|
48
79
|
const durationSeconds = (performance.now() - start) / 1000;
|
|
49
80
|
|
|
50
|
-
|
|
51
|
-
|
|
81
|
+
if (!result) {
|
|
82
|
+
return { success: false, durationSeconds, error: "no result message received" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sessionId = result.session_id;
|
|
86
|
+
const turns = result.num_turns;
|
|
87
|
+
const inputTokens = result.usage.input_tokens;
|
|
88
|
+
const outputTokens = result.usage.output_tokens;
|
|
52
89
|
|
|
53
|
-
|
|
90
|
+
this.logger.logFile(
|
|
91
|
+
`[claude] Result: ${result.subtype}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (result.subtype === "success") {
|
|
54
95
|
return {
|
|
55
|
-
success:
|
|
56
|
-
exitCode,
|
|
96
|
+
success: true,
|
|
57
97
|
durationSeconds,
|
|
58
|
-
|
|
98
|
+
output: result.result,
|
|
99
|
+
turns,
|
|
100
|
+
inputTokens,
|
|
101
|
+
outputTokens,
|
|
102
|
+
sessionId,
|
|
59
103
|
};
|
|
60
104
|
}
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
const error = ERROR_MESSAGES[result.subtype] ?? `failed (${result.subtype})`;
|
|
107
|
+
const detail = result.errors?.length ? `: ${result.errors.join("; ")}` : "";
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
durationSeconds,
|
|
111
|
+
error: error + detail,
|
|
112
|
+
turns,
|
|
113
|
+
inputTokens,
|
|
114
|
+
outputTokens,
|
|
115
|
+
sessionId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private handleMessage(message: SDKMessage): void {
|
|
120
|
+
switch (message.type) {
|
|
121
|
+
case "assistant":
|
|
122
|
+
this.logAssistant(message);
|
|
123
|
+
break;
|
|
124
|
+
case "stream_event":
|
|
125
|
+
this.logStreamEvent(message);
|
|
126
|
+
break;
|
|
127
|
+
case "result":
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
this.logger.logFile(`[claude] ${message.type}: ${JSON.stringify(message)}`);
|
|
131
|
+
break;
|
|
73
132
|
}
|
|
133
|
+
}
|
|
74
134
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
exitCode,
|
|
79
|
-
durationSeconds,
|
|
80
|
-
output,
|
|
81
|
-
error: "unexpected JSON output (not an object)",
|
|
82
|
-
};
|
|
135
|
+
private logAssistant(message: SDKAssistantMessage): void {
|
|
136
|
+
if (message.error) {
|
|
137
|
+
this.logger.log(`[claude] Error: ${message.error}`);
|
|
83
138
|
}
|
|
84
139
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
140
|
+
for (const block of message.message.content) {
|
|
141
|
+
if (typeof block === "string") continue;
|
|
142
|
+
if (block.type === "tool_use") {
|
|
143
|
+
const target = this.toolTarget(block.name, block.input as Record<string, unknown>);
|
|
144
|
+
this.logger.log(`[claude] ${block.name}${target ? `: ${target}` : ""}`);
|
|
145
|
+
}
|
|
88
146
|
}
|
|
89
147
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
148
|
+
this.logger.logFile(`[claude] Assistant message: ${JSON.stringify(message.message.content)}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private logStreamEvent(message: SDKPartialAssistantMessage): void {
|
|
152
|
+
const event = message.event;
|
|
153
|
+
if (event.type === "content_block_delta") {
|
|
154
|
+
const delta = event.delta;
|
|
155
|
+
if ("text" in delta && typeof delta.text === "string") {
|
|
156
|
+
this.logger.logFile(`[claude] text: ${delta.text}`);
|
|
157
|
+
} else if ("thinking" in delta && typeof delta.thinking === "string") {
|
|
158
|
+
this.logger.logFile(`[claude] thinking: ${delta.thinking}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private toolTarget(name: string, input: Record<string, unknown>): string | undefined {
|
|
164
|
+
switch (name) {
|
|
165
|
+
case "Bash":
|
|
166
|
+
return typeof input.command === "string" ? input.command.slice(0, 80) : undefined;
|
|
167
|
+
case "Edit":
|
|
168
|
+
case "Read":
|
|
169
|
+
case "Write":
|
|
170
|
+
return typeof input.file_path === "string" ? input.file_path : undefined;
|
|
171
|
+
case "Glob":
|
|
172
|
+
case "Grep":
|
|
173
|
+
return typeof input.pattern === "string" ? input.pattern : undefined;
|
|
174
|
+
default:
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
93
177
|
}
|
|
94
178
|
}
|
package/src/runners/codex.ts
CHANGED
|
@@ -1,81 +1,161 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CodexRunner — adapter for OpenAI Codex
|
|
2
|
+
* CodexRunner — adapter for OpenAI Codex via the Agent SDK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Creates a Codex client and thread per run(), consumes the async event
|
|
5
|
+
* stream internally. Console gets concise progress (tool calls, file
|
|
6
|
+
* changes, turn boundaries, errors); the log file captures the full
|
|
7
|
+
* event stream for debugging.
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
import { Codex, type ThreadEvent, type ThreadItem } from "@openai/codex-sdk";
|
|
9
11
|
import type { LogWriter, Runner, RunResult } from "../types.js";
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const line = raw.trim();
|
|
14
|
-
if (!line) continue;
|
|
13
|
+
type CodexFactory = (...args: ConstructorParameters<typeof Codex>) => InstanceType<typeof Codex>;
|
|
14
|
+
let createCodex: CodexFactory = (...args) => new Codex(...args);
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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";
|
|
16
|
+
/** @internal Replace the Codex factory. Returns a restore function. */
|
|
17
|
+
export function _setCodexFactory(fn: CodexFactory): () => void {
|
|
18
|
+
const prev = createCodex;
|
|
19
|
+
createCodex = fn;
|
|
20
|
+
return () => {
|
|
21
|
+
createCodex = prev;
|
|
22
|
+
};
|
|
43
23
|
}
|
|
44
24
|
|
|
45
25
|
export class CodexRunner implements Runner {
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
private logger: LogWriter;
|
|
27
|
+
|
|
28
|
+
constructor(logger: LogWriter) {
|
|
29
|
+
this.logger = logger;
|
|
48
30
|
}
|
|
49
31
|
|
|
50
32
|
async run(prompt: string): Promise<RunResult> {
|
|
51
33
|
const start = performance.now();
|
|
52
34
|
|
|
53
|
-
let
|
|
35
|
+
let threadId: string | undefined;
|
|
36
|
+
let turns = 0;
|
|
37
|
+
let inputTokens = 0;
|
|
38
|
+
let outputTokens = 0;
|
|
39
|
+
let failed = false;
|
|
40
|
+
let completed = false;
|
|
41
|
+
let errorMessage: string | undefined;
|
|
42
|
+
|
|
54
43
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
44
|
+
const codex = createCodex();
|
|
45
|
+
const thread = codex.startThread({
|
|
46
|
+
approvalPolicy: "never",
|
|
47
|
+
sandboxMode: "danger-full-access",
|
|
59
48
|
});
|
|
60
|
-
|
|
49
|
+
|
|
50
|
+
const { events } = await thread.runStreamed(prompt);
|
|
51
|
+
|
|
52
|
+
for await (const event of events) {
|
|
53
|
+
this.handleEvent(event);
|
|
54
|
+
|
|
55
|
+
switch (event.type) {
|
|
56
|
+
case "thread.started":
|
|
57
|
+
threadId = event.thread_id;
|
|
58
|
+
break;
|
|
59
|
+
case "turn.started":
|
|
60
|
+
turns++;
|
|
61
|
+
break;
|
|
62
|
+
case "turn.completed":
|
|
63
|
+
completed = true;
|
|
64
|
+
this.logger.log(`[codex] Turn ${turns} completed`);
|
|
65
|
+
inputTokens += event.usage.input_tokens;
|
|
66
|
+
outputTokens += event.usage.output_tokens;
|
|
67
|
+
break;
|
|
68
|
+
case "turn.failed":
|
|
69
|
+
failed = true;
|
|
70
|
+
errorMessage = event.error.message;
|
|
71
|
+
this.logger.log(`[codex] Error: ${errorMessage}`);
|
|
72
|
+
break;
|
|
73
|
+
case "error":
|
|
74
|
+
failed = true;
|
|
75
|
+
errorMessage = event.message;
|
|
76
|
+
this.logger.log(`[codex] Error: ${errorMessage}`);
|
|
77
|
+
break;
|
|
78
|
+
case "item.started":
|
|
79
|
+
case "item.completed":
|
|
80
|
+
this.logItem(event.type, event.item);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
const durationSeconds = (performance.now() - start) / 1000;
|
|
86
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
87
|
+
|
|
88
|
+
if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
|
|
89
|
+
return { success: false, durationSeconds, error: "codex binary not found in PATH" };
|
|
90
|
+
}
|
|
91
|
+
return { success: false, durationSeconds, error: msg };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const durationSeconds = (performance.now() - start) / 1000;
|
|
95
|
+
|
|
96
|
+
const status = failed ? "failed" : completed ? "success" : "incomplete";
|
|
97
|
+
this.logger.logFile(
|
|
98
|
+
`[codex] Result: ${status}, turns=${turns}, input=${inputTokens}, output=${outputTokens}`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (failed) {
|
|
61
102
|
return {
|
|
62
103
|
success: false,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
durationSeconds,
|
|
105
|
+
error: errorMessage ?? "codex run failed",
|
|
106
|
+
turns,
|
|
107
|
+
inputTokens,
|
|
108
|
+
outputTokens,
|
|
109
|
+
sessionId: threadId,
|
|
66
110
|
};
|
|
67
111
|
}
|
|
68
112
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
113
|
+
if (!completed) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
durationSeconds,
|
|
117
|
+
error: "no terminal event received from Codex",
|
|
118
|
+
turns,
|
|
119
|
+
inputTokens,
|
|
120
|
+
outputTokens,
|
|
121
|
+
sessionId: threadId,
|
|
122
|
+
};
|
|
74
123
|
}
|
|
75
124
|
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
durationSeconds,
|
|
128
|
+
turns,
|
|
129
|
+
inputTokens,
|
|
130
|
+
outputTokens,
|
|
131
|
+
sessionId: threadId,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private handleEvent(event: ThreadEvent): void {
|
|
136
|
+
this.logger.logFile(`[codex] ${event.type}: ${JSON.stringify(event)}`);
|
|
137
|
+
}
|
|
78
138
|
|
|
79
|
-
|
|
139
|
+
private logItem(phase: "item.started" | "item.completed", item: ThreadItem): void {
|
|
140
|
+
switch (item.type) {
|
|
141
|
+
case "command_execution":
|
|
142
|
+
if (phase === "item.started") {
|
|
143
|
+
this.logger.log(`[codex] Bash: ${item.command.slice(0, 80)}`);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "file_change":
|
|
147
|
+
if (phase === "item.completed") {
|
|
148
|
+
for (const change of item.changes) {
|
|
149
|
+
this.logger.log(`[codex] Edit: ${change.path}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "reasoning":
|
|
154
|
+
this.logger.logFile(`[codex] reasoning: ${item.text}`);
|
|
155
|
+
break;
|
|
156
|
+
case "error":
|
|
157
|
+
this.logger.log(`[codex] Error: ${item.message}`);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
80
160
|
}
|
|
81
161
|
}
|
package/src/runners/index.ts
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
* Runner registry — maps runner names to their implementations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Runner } from "../types.js";
|
|
5
|
+
import type { LogWriter, Runner } from "../types.js";
|
|
6
6
|
import { ClaudeRunner } from "./claude.js";
|
|
7
7
|
import { CodexRunner } from "./codex.js";
|
|
8
8
|
|
|
9
|
-
const RUNNERS: Record<string, new () => Runner> = {
|
|
9
|
+
const RUNNERS: Record<string, new (logger: LogWriter) => Runner> = {
|
|
10
10
|
claude: ClaudeRunner,
|
|
11
11
|
codex: CodexRunner,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
export function getRunner(name: string): Runner {
|
|
14
|
+
export function getRunner(name: string, logger: LogWriter): Runner {
|
|
15
15
|
const Cls = RUNNERS[name];
|
|
16
16
|
if (!Cls) {
|
|
17
17
|
const available = Object.keys(RUNNERS).sort().join(", ");
|
|
18
18
|
throw new Error(`Unknown runner '${name}'. Available runners: ${available}`);
|
|
19
19
|
}
|
|
20
|
-
return new Cls();
|
|
20
|
+
return new Cls(logger);
|
|
21
21
|
}
|
|
@@ -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
|
|
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, 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
|
|
@@ -39,8 +39,6 @@ Ask the user where to save the PRD:
|
|
|
39
39
|
fi
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
If the project already has a `.stonecut/` directory, default to suggesting local. Otherwise, just ask.
|
|
43
|
-
|
|
44
42
|
### 6. Documentation impact check
|
|
45
43
|
|
|
46
44
|
Before writing the PRD, ensure that documentation impact has been explicitly addressed. Based on everything you've learned from the interview and codebase exploration, analyze which user-facing documentation artifacts (README, CLI help text, docs/ content) would be affected by these changes.
|
|
@@ -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
|
+
}
|