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.
@@ -1,94 +1,178 @@
1
1
  /**
2
- * ClaudeRunner — adapter for Claude Code CLI.
2
+ * ClaudeRunner — adapter for Claude Code via the Agent SDK.
3
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.
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
- logEnvironment(logger: LogWriter): void {
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
- proc = Bun.spawn(
28
- [
29
- "claude",
30
- "-p",
31
- "--output-format",
32
- "json",
33
- "--allowedTools",
34
- "Bash,Edit,Read,Write,Glob,Grep",
35
- ],
36
- { stdin: new Response(prompt), stdout: "pipe", stderr: "pipe" },
37
- );
38
- } catch {
39
- return {
40
- success: false,
41
- exitCode: 1,
42
- durationSeconds: (performance.now() - start) / 1000,
43
- error: "claude binary not found in PATH",
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
- const stdout = await new Response(proc.stdout as ReadableStream).text();
51
- const output = stdout || undefined;
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
- if (output === undefined) {
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: false,
56
- exitCode,
96
+ success: true,
57
97
  durationSeconds,
58
- error: `no output (exit code ${exitCode})`,
98
+ output: result.result,
99
+ turns,
100
+ inputTokens,
101
+ outputTokens,
102
+ sessionId,
59
103
  };
60
104
  }
61
105
 
62
- let data: unknown;
63
- try {
64
- data = JSON.parse(output);
65
- } catch {
66
- return {
67
- success: false,
68
- exitCode,
69
- durationSeconds,
70
- output,
71
- error: "malformed JSON output",
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
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
76
- return {
77
- success: false,
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
- const subtype = (data as Record<string, unknown>).subtype ?? "";
86
- if (subtype === "success") {
87
- return { success: true, exitCode, durationSeconds, output };
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
- const error =
91
- ERROR_MESSAGES[subtype as string] ?? `failed (${(subtype as string) || "unknown"})`;
92
- return { success: false, exitCode, durationSeconds, output, error };
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
  }
@@ -1,81 +1,161 @@
1
1
  /**
2
- * CodexRunner — adapter for OpenAI Codex CLI.
2
+ * CodexRunner — adapter for OpenAI Codex via the Agent SDK.
3
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.
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
- function extractError(stdout: string): string {
12
- for (const raw of stdout.split("\n")) {
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
- 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";
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
- logEnvironment(_logger: LogWriter): void {
47
- // No environment-specific config to log for Codex.
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 proc: Awaited<ReturnType<typeof Bun.spawn>>;
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
- proc = Bun.spawn(["codex", "exec", "--full-auto", "--json", "--ephemeral", "-"], {
56
- stdin: new Response(prompt),
57
- stdout: "pipe",
58
- stderr: "pipe",
44
+ const codex = createCodex();
45
+ const thread = codex.startThread({
46
+ approvalPolicy: "never",
47
+ sandboxMode: "danger-full-access",
59
48
  });
60
- } catch {
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
- exitCode: 1,
64
- durationSeconds: (performance.now() - start) / 1000,
65
- error: "codex binary not found in PATH",
104
+ durationSeconds,
105
+ error: errorMessage ?? "codex run failed",
106
+ turns,
107
+ inputTokens,
108
+ outputTokens,
109
+ sessionId: threadId,
66
110
  };
67
111
  }
68
112
 
69
- const exitCode = await proc.exited;
70
- const durationSeconds = (performance.now() - start) / 1000;
71
-
72
- if (exitCode === 0) {
73
- return { success: true, exitCode: 0, durationSeconds };
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
- const stdout = await new Response(proc.stdout as ReadableStream).text();
77
- const error = extractError(stdout);
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
- return { success: false, exitCode, durationSeconds, error };
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
  }
@@ -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 (e.g., `.stonecut/ASC-1/prd.md`) or a GitHub issue number."
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** (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`).
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, 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
@@ -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
+ }
@@ -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
+ }