stonecut 1.1.1 → 1.2.1

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/local.ts CHANGED
@@ -1,8 +1,9 @@
1
- /** Local spec source — reads issues from .stonecut/<name>/. */
1
+ /** Local spec source — reads issues from .stonecut/prd/<name>/. */
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, statSync } from "fs";
4
4
  import { join } from "path";
5
5
  import type { Issue, Source } from "./types";
6
+ import { parseFrontmatter } from "./frontmatter";
6
7
 
7
8
  export class LocalSource implements Source<Issue> {
8
9
  readonly name: string;
@@ -10,7 +11,7 @@ export class LocalSource implements Source<Issue> {
10
11
 
11
12
  constructor(name: string) {
12
13
  this.name = name;
13
- this.specDir = join(".stonecut", name);
14
+ this.specDir = join(".stonecut", "prd", name);
14
15
  this.validate();
15
16
  }
16
17
 
@@ -65,18 +66,20 @@ export class LocalSource implements Source<Issue> {
65
66
  }
66
67
 
67
68
  async getPrdContent(): Promise<string> {
68
- return readFileSync(join(this.specDir, "prd.md"), "utf-8");
69
+ const raw = readFileSync(join(this.specDir, "prd.md"), "utf-8");
70
+ return parseFrontmatter(raw).body;
69
71
  }
70
72
 
71
73
  async getNextIssue(): Promise<Issue | null> {
72
74
  const completed = this.readStatus();
73
75
  for (const issue of this.allIssues()) {
74
76
  if (!completed.has(issue.number)) {
77
+ const raw = readFileSync(issue.path, "utf-8");
75
78
  return {
76
79
  number: issue.number,
77
80
  filename: issue.filename,
78
81
  path: issue.path,
79
- content: readFileSync(issue.path, "utf-8"),
82
+ content: parseFrontmatter(raw).body,
80
83
  };
81
84
  }
82
85
  }
package/src/prompt.ts CHANGED
@@ -28,23 +28,3 @@ export async function renderLocal({
28
28
  issue_content: issueContent,
29
29
  });
30
30
  }
31
-
32
- export async function renderGithub({
33
- prdContent,
34
- issueNumber,
35
- issueTitle,
36
- issueContent,
37
- }: {
38
- prdContent: string;
39
- issueNumber: number;
40
- issueTitle: string;
41
- issueContent: string;
42
- }): Promise<string> {
43
- return renderTemplate({
44
- task_source: "a GitHub issue",
45
- prd_content: prdContent,
46
- issue_number: issueNumber,
47
- issue_filename: issueTitle,
48
- issue_content: issueContent,
49
- });
50
- }
package/src/runner.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  snapshotWorkingTree as realSnapshotWorkingTree,
16
16
  stageChanges as realStageChanges,
17
17
  } from "./git";
18
+ import { readFileSync } from "fs";
19
+ import { parseFrontmatter } from "./frontmatter";
20
+ import { getSourceProvider } from "./sources/index";
18
21
  import type {
19
22
  GitOps,
20
23
  IterationResult,
@@ -39,6 +42,81 @@ export const consoleLogger: LogWriter = {
39
42
  close: () => {},
40
43
  };
41
44
 
45
+ /**
46
+ * Configuration for syncing issue/PRD completion back to an external source.
47
+ *
48
+ * When provided to runAfkLoop, the runner reads frontmatter from completed
49
+ * issue files. If a `source` field is present, the corresponding provider
50
+ * is resolved and notified of the completion.
51
+ */
52
+ export interface SyncBackConfig<T> {
53
+ /** Return the file path for a completed issue, or undefined to skip sync-back. */
54
+ getIssuePath: (issue: T) => string | undefined;
55
+ /** Path to the PRD file, used for sync-back after all issues complete. */
56
+ prdPath?: string;
57
+ /** Read a file's contents. Injectable for testing; defaults to fs.readFileSync. */
58
+ readFile?: (path: string) => string;
59
+ /** Resolve a source provider by name. Injectable for testing; defaults to getSourceProvider. */
60
+ resolveProvider?: (name: string) => {
61
+ onIssueComplete(id: string): Promise<void>;
62
+ onPrdComplete(id: string): Promise<void>;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Sync a completed issue back to its external source.
68
+ *
69
+ * Reads frontmatter from the issue file. If `source` and `issue` fields
70
+ * are present, resolves the provider and calls onIssueComplete.
71
+ * Failures are logged as warnings but never thrown.
72
+ */
73
+ export async function syncBackIssue(
74
+ filePath: string,
75
+ logger: LogWriter,
76
+ readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
77
+ resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
78
+ ): Promise<void> {
79
+ try {
80
+ const content = readFile(filePath);
81
+ const { meta } = parseFrontmatter(content);
82
+ if (!meta.source || !meta.issue) return;
83
+
84
+ const provider = resolveProvider!(meta.source);
85
+ await provider.onIssueComplete(meta.issue);
86
+ logger.log(`Synced issue #${meta.issue} back to ${meta.source}`);
87
+ } catch (err: unknown) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ logger.log(`Warning: sync-back failed for issue at ${filePath}: ${message}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Sync PRD completion back to its external source.
95
+ *
96
+ * Reads frontmatter from the PRD file. If `source` and `issue` fields
97
+ * are present, resolves the provider and calls onPrdComplete.
98
+ * Failures are logged as warnings but never thrown.
99
+ */
100
+ export async function syncBackPrd(
101
+ filePath: string,
102
+ logger: LogWriter,
103
+ readFile: (path: string) => string = (p) => readFileSync(p, "utf-8"),
104
+ resolveProvider: SyncBackConfig<unknown>["resolveProvider"] = getSourceProvider,
105
+ ): Promise<void> {
106
+ try {
107
+ const content = readFile(filePath);
108
+ const { meta } = parseFrontmatter(content);
109
+ if (!meta.source || !meta.issue) return;
110
+
111
+ const provider = resolveProvider!(meta.source);
112
+ await provider.onPrdComplete(meta.issue);
113
+ logger.log(`Synced PRD #${meta.issue} back to ${meta.source}`);
114
+ } catch (err: unknown) {
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ logger.log(`Warning: sync-back failed for PRD at ${filePath}: ${message}`);
117
+ }
118
+ }
119
+
42
120
  /**
43
121
  * Single check → fix cycle.
44
122
  *
@@ -102,7 +180,7 @@ export async function commitIssue(
102
180
  *
103
181
  * Uses the provided Session to execute each issue's prompt.
104
182
  * After each successful run, Stonecut stages and commits the changes.
105
- * Works with both LocalSource and GitHubSource.
183
+ * Works with LocalSource (the single execution path).
106
184
  *
107
185
  * A failing issue is retried once (2 total attempts). If it fails
108
186
  * a second time, the session stops — issues are sequential vertical
@@ -116,6 +194,7 @@ export async function runAfkLoop<T extends { number: number }>(
116
194
  commitMessage: (issue: T) => string,
117
195
  session: Session,
118
196
  onNoChanges?: (issue: T, output: string | undefined) => void,
197
+ syncBack?: SyncBackConfig<T>,
119
198
  ): Promise<IterationResult[]> {
120
199
  const { logger, git, runner, runnerName } = session;
121
200
 
@@ -243,6 +322,15 @@ export async function runAfkLoop<T extends { number: number }>(
243
322
 
244
323
  // Commit succeeded — mark issue complete
245
324
  await source.completeIssue(issue);
325
+
326
+ // Sync completion back to external source if configured
327
+ if (syncBack) {
328
+ const issuePath = syncBack.getIssuePath(issue);
329
+ if (issuePath) {
330
+ await syncBackIssue(issuePath, logger, syncBack.readFile, syncBack.resolveProvider);
331
+ }
332
+ }
333
+
246
334
  logger.log(
247
335
  `Issue ${issue.number}: committed and completed ` + `(${fmtTime(runResult.durationSeconds)})`,
248
336
  );
@@ -257,6 +345,14 @@ export async function runAfkLoop<T extends { number: number }>(
257
345
  logger.log("");
258
346
  }
259
347
 
348
+ // Sync PRD completion back to external source if all issues are done
349
+ if (syncBack?.prdPath) {
350
+ const nextIssue = await source.getNextIssue();
351
+ if (nextIssue === null && results.some((r) => r.success)) {
352
+ await syncBackPrd(syncBack.prdPath, logger, syncBack.readFile, syncBack.resolveProvider);
353
+ }
354
+ }
355
+
260
356
  // Session summary
261
357
  const totalElapsed = (performance.now() - sessionStart) / 1000;
262
358
  printSummary(results, totalElapsed, logger);
@@ -0,0 +1,109 @@
1
+ # Reference
2
+
3
+ ## Dependency Categories
4
+
5
+ When assessing a candidate for deepening, classify its dependencies:
6
+
7
+ ### 1. In-process
8
+
9
+ Pure computation, in-memory state, no I/O. Always deepenable — just merge the modules and test directly.
10
+
11
+ ### 2. Local-substitutable
12
+
13
+ Dependencies that have local test stand-ins (e.g., PGLite for Postgres, in-memory filesystem). Deepenable if the test substitute exists. The deepened module is tested with the local stand-in running in the test suite.
14
+
15
+ ### 3. Remote but owned (Ports & Adapters)
16
+
17
+ Your own services across a network boundary (microservices, internal APIs). Define a port (interface) at the module boundary. The deep module owns the logic; the transport is injected. Tests use an in-memory adapter. Production uses the real HTTP/gRPC/queue adapter.
18
+
19
+ Recommendation shape: "Define a shared interface (port), implement an HTTP adapter for production and an in-memory adapter for testing, so the logic can be tested as one deep module even though it's deployed across a network boundary."
20
+
21
+ ### 4. True external (Mock)
22
+
23
+ Third-party services (Stripe, Twilio, etc.) you don't control. Mock at the boundary. The deepened module takes the external dependency as an injected port, and tests provide a mock implementation.
24
+
25
+ ## Testing Strategy
26
+
27
+ The core principle: **replace, don't layer.**
28
+
29
+ - Old unit tests on shallow modules are waste once boundary tests exist — delete them
30
+ - Write new tests at the deepened module's interface boundary
31
+ - Tests assert on observable outcomes through the public interface, not internal state
32
+ - Tests should survive internal refactors — they describe behavior, not implementation
33
+
34
+ ## RFC Template
35
+
36
+ <rfc-template>
37
+
38
+ ## Context
39
+
40
+ Why this exploration was initiated:
41
+
42
+ - What motivated the review (e.g., preparing for new features, observable friction, scaling concerns)
43
+ - What areas of the codebase were explored
44
+ - High-level findings from the exploration
45
+
46
+ ## Constraints
47
+
48
+ Non-negotiable rules that narrow the solution space:
49
+
50
+ - Architectural invariants that must be preserved
51
+ - Backward compatibility requirements
52
+ - Testing or deployment constraints
53
+
54
+ ## Problem
55
+
56
+ Describe the architectural friction:
57
+
58
+ - Which modules are shallow and tightly coupled
59
+ - What integration risk exists in the seams between them
60
+ - Why this makes the codebase harder to navigate and maintain
61
+
62
+ ## Alternatives Considered
63
+
64
+ For each design explored during the multi-agent step:
65
+
66
+ - **Design name** — One-line summary of the approach
67
+ - Key trade-offs: what it gains, what it loses
68
+ - Why it was accepted, rejected, or partially adopted
69
+
70
+ Include a comparison (table or prose) so the reader can see why the chosen design won.
71
+
72
+ ## Proposed Design
73
+
74
+ The chosen design. For each new or modified module:
75
+
76
+ - What the module owns (responsibilities)
77
+ - What it hides (implementation details callers don't see)
78
+ - What it exposes (the public surface area — described, not full signatures)
79
+ - Dependency graph between modules (which depends on which, and why)
80
+
81
+ ## Dependency Strategy
82
+
83
+ Which category applies and how dependencies are handled:
84
+
85
+ - **In-process**: merged directly
86
+ - **Local-substitutable**: tested with [specific stand-in]
87
+ - **Ports & adapters**: port definition, production adapter, test adapter
88
+ - **Mock**: mock boundary for external services
89
+
90
+ ## Testing Impact
91
+
92
+ High-level testing implications of the design:
93
+
94
+ - What new boundary tests the design enables
95
+ - What existing test patterns become redundant
96
+ - Any test infrastructure changes needed
97
+
98
+ ## Implementation Guidance
99
+
100
+ Durable architectural guidance — the _what and why_, not the _how_:
101
+
102
+ - What the module should own (responsibilities)
103
+ - What it should hide (implementation details)
104
+ - What it should expose (the interface contract)
105
+ - Key invariants the implementation must preserve
106
+
107
+ Do NOT include specific file-level migration steps, exact function signatures, or line-by-line changes. Those belong in the PRD and issues.
108
+
109
+ </rfc-template>
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: stonecut-review-architecture
3
+ description: Explore a codebase to find opportunities for architectural improvement, focusing on making the codebase more testable by deepening shallow modules. Use when user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more AI-navigable.
4
+ ---
5
+
6
+ # Review Architecture
7
+
8
+ Explore a codebase like an AI would, surface architectural friction, discover opportunities for improving testability, and propose module-deepening refactors.
9
+
10
+ A **deep module** (John Ousterhout, "A Philosophy of Software Design") has a small interface hiding a large implementation. Deep modules are more testable, more AI-navigable, and let you test at the boundary instead of inside.
11
+
12
+ ## Process
13
+
14
+ ### 1. Explore the codebase
15
+
16
+ Use the Agent tool with subagent_type=Explore to navigate the codebase naturally. Do NOT follow rigid heuristics — explore organically and note where you experience friction:
17
+
18
+ - Where does understanding one concept require bouncing between many small files?
19
+ - Where are modules so shallow that the interface is nearly as complex as the implementation?
20
+ - Where have pure functions been extracted just for testability, but the real bugs hide in how they're called?
21
+ - Where do tightly-coupled modules create integration risk in the seams between them?
22
+ - Which parts of the codebase are untested, or hard to test?
23
+
24
+ The friction you encounter IS the signal.
25
+
26
+ ### 2. Present candidates
27
+
28
+ Present a numbered list of deepening opportunities. For each candidate, show:
29
+
30
+ - **Cluster**: Which modules/concepts are involved
31
+ - **Why they're coupled**: Shared types, call patterns, co-ownership of a concept
32
+ - **Dependency category**: See [REFERENCE.md](REFERENCE.md) for the four categories
33
+ - **Test impact**: What existing tests would be replaced by boundary tests
34
+
35
+ Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
36
+
37
+ ### 3. User picks a candidate
38
+
39
+ ### 4. Frame the problem space
40
+
41
+ Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
42
+
43
+ - The constraints any new interface would need to satisfy
44
+ - The dependencies it would need to rely on
45
+ - A rough illustrative code sketch to make the constraints concrete
46
+
47
+ Show this to the user, then immediately proceed to Step 5.
48
+
49
+ ### 5. Design multiple interfaces
50
+
51
+ Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
52
+
53
+ Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category, what's being hidden). This brief is independent of the user-facing explanation in Step 4. Give each agent a different design constraint:
54
+
55
+ - Agent 1: "Minimize the interface — aim for 1-3 entry points max"
56
+ - Agent 2: "Maximize flexibility — support many use cases and extension"
57
+ - Agent 3: "Optimize for the most common caller — make the default case trivial"
58
+ - Agent 4 (if applicable): "Design around the ports & adapters pattern for cross-boundary dependencies"
59
+
60
+ Each sub-agent outputs:
61
+
62
+ 1. Interface signature (types, methods, params)
63
+ 2. Usage example showing how callers use it
64
+ 3. What complexity it hides internally
65
+ 4. Dependency strategy (how deps are handled — see [REFERENCE.md](REFERENCE.md))
66
+ 5. Trade-offs
67
+
68
+ Present designs sequentially, then compare them in prose.
69
+
70
+ After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid.
71
+
72
+ ### 6. User picks an interface (or accepts recommendation)
73
+
74
+ ### 7. Choose a destination
75
+
76
+ Ask the user where to save the RFC:
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.
79
+ - **GitHub issue** — Create a GitHub issue using `gh issue create --label rfc`. Before creating, ensure the `rfc` label exists:
80
+
81
+ ```bash
82
+ # Only create the label if it doesn't already exist
83
+ if ! gh label list --search "rfc" --json name --jq '.[].name' | grep -qx "rfc"; then
84
+ gh label create rfc --description "Request for Comments — architecture/design decision" --color "D93F0B"
85
+ fi
86
+ ```
87
+
88
+ If the project already has a `.stonecut/` directory, default to suggesting local. Otherwise, just ask.
89
+
90
+ ### 8. Write the RFC
91
+
92
+ Use the template in [REFERENCE.md](REFERENCE.md). Do NOT ask the user to review before creating — just create it and share the result.
93
+
94
+ The RFC captures design decisions — what was chosen, what was rejected, and why. The PRD writer (whether in this session or a future one) will explore the codebase itself; the RFC's job is to carry the decisions so they don't need to be re-derived. Leave implementation-level detail (exact signatures, migration steps, test file changes) for the PRD and issues.
95
+
96
+ ## Next Step
97
+
98
+ Once the RFC is saved, ask the user: "Ready to write the PRD? I can run `/stonecut-prd` next."
package/src/skills.ts CHANGED
@@ -17,7 +17,12 @@ import {
17
17
  import { join, resolve } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
 
20
- export const SKILL_NAMES = ["stonecut-interview", "stonecut-prd", "stonecut-issues"];
20
+ export const SKILL_NAMES = [
21
+ "stonecut-interview",
22
+ "stonecut-prd",
23
+ "stonecut-issues",
24
+ "stonecut-review-architecture",
25
+ ];
21
26
 
22
27
  /**
23
28
  * Return the path to the skills/ directory shipped with this package.
@@ -1,6 +1,11 @@
1
- /** GitHub source — wraps the gh CLI to interact with GitHub issues. */
1
+ /**
2
+ * GitHub source provider — fetches PRDs and issues via the gh CLI.
3
+ *
4
+ * Reuses the validation and API patterns from src/github.ts but
5
+ * structured as a stateless SourceProvider.
6
+ */
2
7
 
3
- import type { GitHubIssue, GitHubPrd, Source } from "./types";
8
+ import type { IssueData, PrdData, PrdSummary, SourceProvider } from "./types.js";
4
9
 
5
10
  function runSync(cmd: string[]): { exitCode: number; stdout: string; stderr: string } {
6
11
  const proc = Bun.spawnSync(cmd, {
@@ -14,18 +19,15 @@ function runSync(cmd: string[]): { exitCode: number; stdout: string; stderr: str
14
19
  };
15
20
  }
16
21
 
17
- export class GitHubSource implements Source<GitHubIssue> {
18
- readonly prdNumber: number;
22
+ export class GitHubSourceProvider implements SourceProvider {
19
23
  readonly owner: string;
20
24
  readonly repo: string;
21
25
 
22
- constructor(prdNumber: number) {
23
- this.prdNumber = prdNumber;
24
- GitHubSource.validateGhCli();
25
- const [owner, repo] = GitHubSource.getOwnerRepo();
26
+ constructor() {
27
+ GitHubSourceProvider.validateGhCli();
28
+ const [owner, repo] = GitHubSourceProvider.getOwnerRepo();
26
29
  this.owner = owner;
27
30
  this.repo = repo;
28
- this.validatePrd();
29
31
  }
30
32
 
31
33
  static validateGhCli(): void {
@@ -64,53 +66,98 @@ export class GitHubSource implements Source<GitHubIssue> {
64
66
  throw new Error(`Error: could not parse owner/repo from remote URL: ${url}`);
65
67
  }
66
68
 
67
- validatePrd(): void {
69
+ async fetchPrd(identifier: string): Promise<PrdData> {
70
+ const issueNumber = Number(identifier);
71
+ this.validatePrdLabel(issueNumber);
72
+
73
+ const result = runSync(["gh", "issue", "view", String(issueNumber), "--json", "title,body"]);
74
+ if (result.exitCode !== 0) {
75
+ throw new Error(`Error: failed to fetch PRD issue #${issueNumber}.`);
76
+ }
77
+ const data = JSON.parse(result.stdout);
78
+ return {
79
+ issueNumber,
80
+ title: (data.title ?? "").trim(),
81
+ body: (data.body ?? "").trim(),
82
+ };
83
+ }
84
+
85
+ async fetchIssues(identifier: string): Promise<IssueData[]> {
86
+ const issueNumber = Number(identifier);
87
+ const nodes = this.fetchSubIssues(issueNumber);
88
+ return nodes
89
+ .sort((a, b) => a.number - b.number)
90
+ .map((n) => ({
91
+ title: n.title,
92
+ body: n.body ?? "",
93
+ number: n.number,
94
+ state: n.state,
95
+ }));
96
+ }
97
+
98
+ async listPrds(): Promise<PrdSummary[]> {
99
+ const result = runSync([
100
+ "gh",
101
+ "issue",
102
+ "list",
103
+ "--label",
104
+ "prd",
105
+ "--state",
106
+ "open",
107
+ "--json",
108
+ "number,title",
109
+ "--limit",
110
+ "50",
111
+ ]);
112
+ if (result.exitCode !== 0) {
113
+ throw new Error(`Error: failed to list PRD issues: ${result.stderr.trim()}`);
114
+ }
115
+ const data: Array<{ number: number; title: string }> = JSON.parse(result.stdout);
116
+ return data
117
+ .sort((a, b) => a.number - b.number)
118
+ .map((d) => ({ number: d.number, title: d.title }));
119
+ }
120
+
121
+ async onIssueComplete(issueId: string): Promise<void> {
122
+ const result = runSync(["gh", "issue", "close", issueId]);
123
+ if (result.exitCode !== 0) {
124
+ throw new Error(`Error: failed to close issue #${issueId}: ${result.stderr.trim()}`);
125
+ }
126
+ }
127
+
128
+ async onPrdComplete(prdId: string): Promise<void> {
129
+ const result = runSync(["gh", "issue", "close", prdId]);
130
+ if (result.exitCode !== 0) {
131
+ throw new Error(`Error: failed to close PRD issue #${prdId}: ${result.stderr.trim()}`);
132
+ }
133
+ }
134
+
135
+ private validatePrdLabel(issueNumber: number): void {
68
136
  const result = runSync([
69
137
  "gh",
70
138
  "issue",
71
139
  "view",
72
- String(this.prdNumber),
140
+ String(issueNumber),
73
141
  "--json",
74
142
  "labels",
75
143
  "-q",
76
144
  ".labels[].name",
77
145
  ]);
78
146
  if (result.exitCode !== 0) {
79
- throw new Error(`Error: GitHub issue #${this.prdNumber} not found.`);
147
+ throw new Error(`Error: GitHub issue #${issueNumber} not found.`);
80
148
  }
81
149
  const labels = result.stdout
82
150
  .trim()
83
151
  .split("\n")
84
152
  .filter((l) => l);
85
153
  if (!labels.includes("prd")) {
86
- throw new Error(`Error: Issue #${this.prdNumber} does not have the 'prd' label.`);
154
+ throw new Error(`Error: Issue #${issueNumber} does not have the 'prd' label.`);
87
155
  }
88
156
  }
89
157
 
90
- async getPrdContent(): Promise<string> {
91
- const prd = this.getPrd();
92
- return prd.body;
93
- }
94
-
95
- getPrd(): GitHubPrd {
96
- const result = runSync(["gh", "issue", "view", String(this.prdNumber), "--json", "title,body"]);
97
- if (result.exitCode !== 0) {
98
- throw new Error(`Error: failed to fetch PRD issue #${this.prdNumber}.`);
99
- }
100
- const data = JSON.parse(result.stdout);
101
- return {
102
- number: this.prdNumber,
103
- title: (data.title ?? "").trim(),
104
- body: (data.body ?? "").trim(),
105
- };
106
- }
107
-
108
- private fetchSubIssues(): Array<{
109
- number: number;
110
- title: string;
111
- state: string;
112
- body: string;
113
- }> {
158
+ private fetchSubIssues(
159
+ issueNumber: number,
160
+ ): Array<{ number: number; title: string; state: string; body: string }> {
114
161
  const query = `
115
162
  query($owner: String!, $repo: String!, $number: Int!) {
116
163
  repository(owner: $owner, name: $repo) {
@@ -135,7 +182,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
135
182
  "-F",
136
183
  `repo=${this.repo}`,
137
184
  "-F",
138
- `number=${this.prdNumber}`,
185
+ `number=${issueNumber}`,
139
186
  "-f",
140
187
  `query=${query}`,
141
188
  ]);
@@ -145,34 +192,4 @@ query($owner: String!, $repo: String!, $number: Int!) {
145
192
  const data = JSON.parse(result.stdout);
146
193
  return data.data.repository.issue.subIssues.nodes;
147
194
  }
148
-
149
- async getNextIssue(): Promise<GitHubIssue | null> {
150
- const subIssues = this.fetchSubIssues();
151
- const openIssues = subIssues
152
- .filter((i) => i.state === "OPEN")
153
- .sort((a, b) => a.number - b.number);
154
- if (openIssues.length === 0) {
155
- return null;
156
- }
157
- const first = openIssues[0];
158
- return {
159
- number: first.number,
160
- title: first.title,
161
- body: first.body ?? "",
162
- };
163
- }
164
-
165
- async getRemainingCount(): Promise<[number, number]> {
166
- const subIssues = this.fetchSubIssues();
167
- const total = subIssues.length;
168
- const remaining = subIssues.filter((i) => i.state === "OPEN").length;
169
- return [remaining, total];
170
- }
171
-
172
- async completeIssue(issue: GitHubIssue): Promise<void> {
173
- const result = runSync(["gh", "issue", "close", String(issue.number)]);
174
- if (result.exitCode !== 0) {
175
- throw new Error(`Error: failed to close issue #${issue.number}: ${result.stderr.trim()}`);
176
- }
177
- }
178
195
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Source provider registry — maps provider names to their implementations.
3
+ */
4
+
5
+ import type { SourceProvider } from "./types.js";
6
+ import { GitHubSourceProvider } from "./github.js";
7
+
8
+ const PROVIDERS: Record<string, new () => SourceProvider> = {
9
+ github: GitHubSourceProvider,
10
+ };
11
+
12
+ export function getSourceProvider(name: string): SourceProvider {
13
+ const Cls = PROVIDERS[name];
14
+ if (!Cls) {
15
+ const available = Object.keys(PROVIDERS).sort().join(", ");
16
+ throw new Error(`Unknown source provider '${name}'. Available providers: ${available}`);
17
+ }
18
+ return new Cls();
19
+ }
20
+
21
+ export type { SourceProvider, PrdData, IssueData, PrdSummary } from "./types.js";