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/cli.ts CHANGED
@@ -17,15 +17,20 @@ import {
17
17
  ensureCleanTree,
18
18
  pushBranch,
19
19
  } from "./git";
20
- import { GitHubSource } from "./github";
21
20
  import { LocalSource } from "./local";
22
21
  import { slugifyBranchComponent } from "./naming";
23
- import { renderGithub, renderLocal } from "./prompt";
22
+ import { renderLocal } from "./prompt";
24
23
  import { Logger } from "./logger";
25
24
  import { defaultGitOps, runAfkLoop } from "./runner";
26
25
  import { getRunner } from "./runners/index";
27
26
  import { setupSkills, removeSkills } from "./skills";
28
- import type { GitHubIssue, Issue, IterationResult, Session } from "./types";
27
+ import { init } from "./init";
28
+ import { importSpec } from "./import";
29
+ import { loadConfig } from "./config";
30
+ import { getSourceProvider } from "./sources/index";
31
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
32
+ import { join } from "path";
33
+ import type { Issue, IterationResult, Session } from "./types";
29
34
 
30
35
  const require = createRequire(import.meta.url);
31
36
  const { version } = require("../package.json");
@@ -60,68 +65,137 @@ export function parseGitHubIssueNumber(value: string): number {
60
65
  }
61
66
 
62
67
  /**
63
- * Ensure exactly one of --local or --github was provided.
64
- * Returns a tagged tuple so the caller can dispatch.
68
+ * Check if --local was provided.
69
+ * Returns a tagged union so the caller can dispatch.
65
70
  */
66
71
  export function validateRunSource(
67
72
  local: string | undefined,
68
- github: number | undefined,
69
- ): { kind: "local"; name: string } | { kind: "github"; number: number } | { kind: "prompt" } {
70
- if (local !== undefined && github !== undefined) {
71
- throw new Error("Use exactly one of --local or --github.");
72
- }
73
- if (local === undefined && github === undefined) {
74
- return { kind: "prompt" };
75
- }
73
+ ): { kind: "local"; name: string } | { kind: "prompt" } {
76
74
  if (local !== undefined) return { kind: "local", name: local };
77
- return { kind: "github", number: github! };
75
+ return { kind: "prompt" };
76
+ }
77
+
78
+ /** A locally available PRD with its completion status. */
79
+ export interface LocalPrdEntry {
80
+ name: string;
81
+ completed: number;
82
+ total: number;
83
+ }
84
+
85
+ /**
86
+ * Scan .stonecut subdirectories for directories containing prd.md and compute
87
+ * completion counts from status.json.
88
+ */
89
+ export function scanLocalPrds(baseDir: string = ".stonecut/prd"): LocalPrdEntry[] {
90
+ if (!existsSync(baseDir)) return [];
91
+
92
+ const entries: LocalPrdEntry[] = [];
93
+ for (const name of readdirSync(baseDir)) {
94
+ const specDir = join(baseDir, name);
95
+ if (!statSync(specDir).isDirectory()) continue;
96
+ if (!existsSync(join(specDir, "prd.md"))) continue;
97
+
98
+ const issuesDir = join(specDir, "issues");
99
+ let total = 0;
100
+ if (existsSync(issuesDir) && statSync(issuesDir).isDirectory()) {
101
+ total = readdirSync(issuesDir).filter((f) => f.endsWith(".md")).length;
102
+ }
103
+
104
+ let completed = 0;
105
+ const statusPath = join(specDir, "status.json");
106
+ if (existsSync(statusPath)) {
107
+ try {
108
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
109
+ completed = Array.isArray(data.completed) ? data.completed.length : 0;
110
+ } catch {
111
+ // Malformed status.json — treat as 0 completed
112
+ }
113
+ }
114
+
115
+ entries.push({ name, completed, total });
116
+ }
117
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
78
118
  }
79
119
 
80
120
  /**
81
- * Prompt the user interactively for the source type and value.
82
- * Returns a tagged tuple matching the shape of validateRunSource().
121
+ * Format a PRD entry for display in the wizard.
83
122
  */
84
- export async function promptForSource(): Promise<
85
- { kind: "local"; name: string } | { kind: "github"; number: number }
86
- > {
87
- const sourceType = await clack.select({
88
- message: "Source type:",
89
- options: [
90
- { value: "local", label: "Local PRD (.stonecut/<name>/)" },
91
- { value: "github", label: "GitHub PRD (issue number)" },
92
- ],
123
+ function formatPrdOption(entry: LocalPrdEntry): string {
124
+ if (entry.total === 0) return `${entry.name} (no issues)`;
125
+ if (entry.completed === 0) return `${entry.name} (not started)`;
126
+ return `${entry.name} (${entry.completed}/${entry.total} done)`;
127
+ }
128
+
129
+ /** Sentinel value for the "Import from GitHub" wizard option. */
130
+ const IMPORT_FROM_GITHUB = "__import_from_github__";
131
+
132
+ /**
133
+ * Prompt the user to select a local PRD or import from GitHub.
134
+ * Returns the local spec name to run.
135
+ */
136
+ export async function promptForPrd(): Promise<{ kind: "local"; name: string }> {
137
+ const prds = scanLocalPrds();
138
+
139
+ const options: Array<{ value: string; label: string }> = prds.map((entry) => ({
140
+ value: entry.name,
141
+ label: formatPrdOption(entry),
142
+ }));
143
+ options.push({ value: IMPORT_FROM_GITHUB, label: "Import from GitHub" });
144
+
145
+ const selection = await clack.select({
146
+ message: "Select a PRD:",
147
+ options,
93
148
  });
94
149
 
95
- if (clack.isCancel(sourceType)) {
150
+ if (clack.isCancel(selection)) {
96
151
  throw new Error("Cancelled.");
97
152
  }
98
153
 
99
- if (sourceType === "local") {
100
- const name = await clack.text({
101
- message: "Spec name:",
102
- placeholder: "my-spec",
103
- validate: (value) => {
104
- if (!value.trim()) return "Spec name is required.";
105
- },
106
- });
107
- if (clack.isCancel(name)) {
108
- throw new Error("Cancelled.");
109
- }
110
- return { kind: "local", name };
154
+ if (selection === IMPORT_FROM_GITHUB) {
155
+ const specName = await inlineGitHubImport();
156
+ return { kind: "local", name: specName };
111
157
  }
112
158
 
113
- const issueStr = await clack.text({
114
- message: "GitHub issue number:",
115
- placeholder: "42",
116
- validate: (value) => {
117
- const n = Number(value);
118
- if (!Number.isInteger(n) || n <= 0) return "Must be a positive integer.";
119
- },
159
+ return { kind: "local", name: selection as string };
160
+ }
161
+
162
+ /**
163
+ * Inline GitHub import flow within the wizard.
164
+ * Lists PRDs by `prd` label, user picks one, import runs.
165
+ * Returns the imported spec name.
166
+ */
167
+ async function inlineGitHubImport(): Promise<string> {
168
+ const provider = getSourceProvider("github");
169
+ const prdList = await provider.listPrds();
170
+
171
+ if (prdList.length === 0) {
172
+ throw new Error("No open PRDs with the 'prd' label found on GitHub.");
173
+ }
174
+
175
+ const prdOptions = prdList.map((p) => ({
176
+ value: String(p.number),
177
+ label: `#${p.number}: ${p.title}`,
178
+ }));
179
+
180
+ const selected = await clack.select({
181
+ message: "Select a GitHub PRD to import:",
182
+ options: prdOptions,
120
183
  });
121
- if (clack.isCancel(issueStr)) {
184
+
185
+ if (clack.isCancel(selected)) {
122
186
  throw new Error("Cancelled.");
123
187
  }
124
- return { kind: "github", number: Number(issueStr) };
188
+
189
+ const result = await importSpec({
190
+ provider: "github",
191
+ identifier: selected as string,
192
+ });
193
+
194
+ console.log(
195
+ `Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
196
+ );
197
+
198
+ return result.specName;
125
199
  }
126
200
 
127
201
  // ---------------------------------------------------------------------------
@@ -154,23 +228,6 @@ export function buildReport(
154
228
  return lines.join("\n");
155
229
  }
156
230
 
157
- // ---------------------------------------------------------------------------
158
- // GitHub comment on no-changes
159
- // ---------------------------------------------------------------------------
160
-
161
- function commentOnIssue(issueNumber: number, runnerOutput: string | undefined): void {
162
- let body = "**Stonecut:** Runner completed but produced no file changes.\n";
163
- if (runnerOutput) {
164
- body +=
165
- "\n<details><summary>Runner output</summary>\n\n" +
166
- `\`\`\`\n${runnerOutput}\n\`\`\`\n\n</details>`;
167
- }
168
- Bun.spawnSync(["gh", "issue", "comment", String(issueNumber), "--body", body], {
169
- stdout: "pipe",
170
- stderr: "pipe",
171
- });
172
- }
173
-
174
231
  // ---------------------------------------------------------------------------
175
232
  // Pre-execution flow
176
233
  // ---------------------------------------------------------------------------
@@ -183,8 +240,6 @@ export async function preExecution(
183
240
  suggestedBranch: string,
184
241
  prefilled?: { branch?: string; baseBranch?: string },
185
242
  ): Promise<[string, string]> {
186
- ensureCleanTree();
187
-
188
243
  let branch: string;
189
244
  if (prefilled?.branch) {
190
245
  branch = prefilled.branch;
@@ -306,48 +361,6 @@ export async function runLocal(
306
361
  }
307
362
  }
308
363
 
309
- export async function runGitHub(
310
- number: number,
311
- iterations: number | "all",
312
- runnerName: string,
313
- prefilled?: { branch?: string; baseBranch?: string },
314
- ): Promise<void> {
315
- const runner = getRunner(runnerName);
316
- const source = new GitHubSource(number);
317
- const logger = new Logger(`prd-${number}`);
318
-
319
- const session: Session = { logger, git: defaultGitOps, runner, runnerName };
320
-
321
- try {
322
- const prd = source.getPrd();
323
- const prdSlug = slugifyBranchComponent(prd.title);
324
- const suggestedBranch = prdSlug ? `stonecut/${prdSlug}` : `stonecut/issue-${number}`;
325
- const prTitle = prd.title || `PRD #${number}`;
326
- const [branch, baseBranch] = await preExecution(suggestedBranch, prefilled);
327
-
328
- const prdContent = prd.body;
329
- const results = await runAfkLoop<GitHubIssue>(
330
- source,
331
- iterations,
332
- (issue) =>
333
- renderGithub({
334
- prdContent,
335
- issueNumber: issue.number,
336
- issueTitle: issue.title,
337
- issueContent: issue.body,
338
- }),
339
- (issue) => issue.title,
340
- (issue) => `Issue #${issue.number}: ${issue.title}`,
341
- session,
342
- (issue, output) => commentOnIssue(issue.number, output),
343
- );
344
-
345
- await pushAndMaybePr(results, source, branch, baseBranch, prTitle, runnerName, logger, number);
346
- } finally {
347
- logger.close();
348
- }
349
- }
350
-
351
364
  // ---------------------------------------------------------------------------
352
365
  // Program definition
353
366
  // ---------------------------------------------------------------------------
@@ -357,19 +370,30 @@ export function buildProgram(): Command {
357
370
 
358
371
  program
359
372
  .name("stonecut")
360
- .description("Stonecut — execute PRD-driven development workflows using agentic coding CLIs.")
373
+ .description(
374
+ "Stonecut — PRD-driven development with agentic coding CLIs.\n\nRun bare `stonecut` to start the interactive run wizard (the most common workflow).\nUse `stonecut <command>` for other operations.",
375
+ )
361
376
  .version(`stonecut ${version}`, "-V, --version");
362
377
 
363
378
  program
364
- .command("run")
365
- .description("Execute issues from a local PRD or GitHub PRD.")
366
- .option("--local <name>", "Local PRD name (.stonecut/<name>/)")
367
- .option("--github <number>", "GitHub PRD issue number", parseGitHubIssueNumber)
379
+ .command("run", { isDefault: true })
380
+ .description("Execute issues from a local PRD.")
381
+ .option("--local <name>", "Local PRD name (.stonecut/prd/<name>/)")
368
382
  .option("-i, --iterations <value>", "Number of issues to process, or 'all'")
369
- .option("--runner <name>", "Agentic CLI runner (claude, codex)", "claude")
383
+ .option("--runner <name>", "Agentic CLI runner (claude, codex)")
370
384
  .action(async (opts) => {
371
- const validated = validateRunSource(opts.local, opts.github);
372
- const source = validated.kind === "prompt" ? await promptForSource() : validated;
385
+ ensureCleanTree();
386
+
387
+ const config = loadConfig();
388
+
389
+ const validated = validateRunSource(opts.local);
390
+ let source: { kind: "local"; name: string };
391
+
392
+ if (validated.kind === "local") {
393
+ source = validated;
394
+ } else {
395
+ source = await promptForPrd();
396
+ }
373
397
 
374
398
  let iterations: number | "all";
375
399
  const needsIterationPrompt = opts.iterations === undefined;
@@ -398,10 +422,12 @@ export function buildProgram(): Command {
398
422
  let prefilled: { branch?: string; baseBranch?: string } | undefined;
399
423
 
400
424
  if (isWizard) {
401
- const suggestedBranch =
402
- source.kind === "local"
403
- ? `stonecut/${slugifyBranchComponent(source.name) || "spec"}`
404
- : `stonecut/issue-${source.number}`;
425
+ if (!existsSync(".stonecut")) {
426
+ console.log("Hint: run `stonecut init` to set up project config and gitignore.\n");
427
+ }
428
+
429
+ const branchPrefix = config?.branchPrefix ?? "stonecut/";
430
+ const suggestedBranch = `${branchPrefix}${slugifyBranchComponent(source.name) || "spec"}`;
405
431
 
406
432
  const branch = await clack.text({
407
433
  message: "Branch name:",
@@ -412,7 +438,7 @@ export function buildProgram(): Command {
412
438
  throw new Error("Cancelled.");
413
439
  }
414
440
 
415
- const detectedDefault = defaultBranch();
441
+ const detectedDefault = config?.baseBranch ?? defaultBranch();
416
442
  const baseBranch = await clack.text({
417
443
  message: "Base branch / PR target:",
418
444
  defaultValue: detectedDefault,
@@ -425,13 +451,39 @@ export function buildProgram(): Command {
425
451
  prefilled = { branch, baseBranch };
426
452
  }
427
453
 
428
- const runnerName: string = opts.runner;
454
+ const runnerName: string = opts.runner ?? config?.runner ?? "claude";
455
+ await runLocal(source.name, iterations, runnerName, prefilled);
456
+ });
429
457
 
430
- if (source.kind === "local") {
431
- await runLocal(source.name, iterations, runnerName, prefilled);
432
- } else {
433
- await runGitHub(source.number, iterations, runnerName, prefilled);
458
+ program
459
+ .command("init")
460
+ .description("Initialize a .stonecut/ directory with project config and gitignore.")
461
+ .action(() => {
462
+ init();
463
+ console.log("Created .stonecut/config.json and .stonecut/.gitignore");
464
+ });
465
+
466
+ program
467
+ .command("import")
468
+ .description("Import a PRD and its issues from an external source into .stonecut/.")
469
+ .option("--github <number>", "GitHub PRD issue number", parseGitHubIssueNumber)
470
+ .option("--name <name>", "Override the auto-derived spec name")
471
+ .option("--force", "Overwrite an existing spec directory")
472
+ .action(async (opts) => {
473
+ if (opts.github === undefined) {
474
+ throw new Error("Specify a source: --github <number>");
434
475
  }
476
+
477
+ const result = await importSpec({
478
+ provider: "github",
479
+ identifier: String(opts.github),
480
+ name: opts.name,
481
+ force: opts.force,
482
+ });
483
+
484
+ console.log(
485
+ `Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
486
+ );
435
487
  });
436
488
 
437
489
  program
package/src/config.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Project-level configuration for Stonecut.
3
+ *
4
+ * Reads and writes `.stonecut/config.json` in the current working directory.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
8
+ import { join } from "path";
9
+
10
+ /** Shape of `.stonecut/config.json`. All fields are optional. */
11
+ export interface StonecutConfig {
12
+ runner?: string;
13
+ baseBranch?: string;
14
+ branchPrefix?: string;
15
+ }
16
+
17
+ const CONFIG_DIR = ".stonecut";
18
+ const CONFIG_FILE = "config.json";
19
+
20
+ function configPath(cwd?: string): string {
21
+ return join(cwd ?? process.cwd(), CONFIG_DIR, CONFIG_FILE);
22
+ }
23
+
24
+ /**
25
+ * Load project config from `.stonecut/config.json`.
26
+ * Returns null if the file does not exist.
27
+ * Returns an empty object if the file contains malformed JSON.
28
+ */
29
+ export function loadConfig(cwd?: string): StonecutConfig | null {
30
+ const path = configPath(cwd);
31
+ if (!existsSync(path)) return null;
32
+
33
+ try {
34
+ const raw = readFileSync(path, "utf-8");
35
+ return JSON.parse(raw) as StonecutConfig;
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Write a default config file to `.stonecut/config.json`.
43
+ * Creates the `.stonecut/` directory if it doesn't exist.
44
+ */
45
+ export function writeDefaultConfig(cwd?: string): void {
46
+ const dir = join(cwd ?? process.cwd(), CONFIG_DIR);
47
+ mkdirSync(dir, { recursive: true });
48
+
49
+ const defaults: StonecutConfig = {
50
+ runner: "claude",
51
+ baseBranch: "main",
52
+ branchPrefix: "stonecut/",
53
+ };
54
+
55
+ writeFileSync(configPath(cwd), JSON.stringify(defaults, null, 2) + "\n");
56
+ }
@@ -0,0 +1,73 @@
1
+ /** Frontmatter utilities — parse and serialize YAML frontmatter in markdown files. */
2
+
3
+ export interface Frontmatter {
4
+ meta: Record<string, string>;
5
+ body: string;
6
+ }
7
+
8
+ /**
9
+ * Parse YAML frontmatter from markdown content.
10
+ * Returns empty meta and full body if no frontmatter is present.
11
+ */
12
+ export function parseFrontmatter(content: string): Frontmatter {
13
+ if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
14
+ return { meta: {}, body: content };
15
+ }
16
+
17
+ const lineBreak = content.startsWith("---\r\n") ? "\r\n" : "\n";
18
+ const fence = `${lineBreak}---`;
19
+ let searchFrom = 3;
20
+ let closingIndex = -1;
21
+ while (true) {
22
+ const idx = content.indexOf(fence, searchFrom);
23
+ if (idx === -1) break;
24
+ const afterFence = idx + fence.length;
25
+ // Valid closing fence: followed by a newline or at end of string
26
+ if (
27
+ afterFence === content.length ||
28
+ content[afterFence] === "\n" ||
29
+ content[afterFence] === "\r"
30
+ ) {
31
+ closingIndex = idx;
32
+ break;
33
+ }
34
+ searchFrom = afterFence;
35
+ }
36
+ if (closingIndex === -1) {
37
+ return { meta: {}, body: content };
38
+ }
39
+
40
+ const yamlBlock = content.slice(3 + lineBreak.length, closingIndex);
41
+ const body = content.slice(closingIndex + lineBreak.length + 3);
42
+ // Strip leading newline from body (the one right after closing ---)
43
+ const trimmedBody = body.startsWith("\r\n")
44
+ ? body.slice(2)
45
+ : body.startsWith("\n")
46
+ ? body.slice(1)
47
+ : body;
48
+
49
+ const meta: Record<string, string> = {};
50
+ for (const line of yamlBlock.split(lineBreak)) {
51
+ const colonIndex = line.indexOf(":");
52
+ if (colonIndex === -1) continue;
53
+ const key = line.slice(0, colonIndex).trim();
54
+ if (!key) continue;
55
+ const value = line.slice(colonIndex + 1).trim();
56
+ meta[key] = value;
57
+ }
58
+
59
+ return { meta, body: trimmedBody };
60
+ }
61
+
62
+ /**
63
+ * Serialize metadata and body into a frontmatter-delimited markdown string.
64
+ * If meta is empty, returns body as-is (no frontmatter block).
65
+ */
66
+ export function serializeFrontmatter(meta: Record<string, string>, body: string): string {
67
+ if (Object.keys(meta).length === 0) {
68
+ return body;
69
+ }
70
+
71
+ const lines = Object.entries(meta).map(([key, value]) => `${key}: ${value}`);
72
+ return `---\n${lines.join("\n")}\n---\n${body}`;
73
+ }
package/src/import.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Import module — pulls PRDs and issues from external sources into
3
+ * the local .stonecut/ directory structure.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import { getSourceProvider } from "./sources/index";
9
+ import type { SourceProvider } from "./sources/types";
10
+ import { serializeFrontmatter } from "./frontmatter";
11
+ import { slugifyBranchComponent } from "./naming";
12
+
13
+ export interface ImportOptions {
14
+ provider: string;
15
+ identifier: string;
16
+ name?: string;
17
+ force?: boolean;
18
+ /** Override the source provider (used in tests). */
19
+ _sourceProvider?: SourceProvider;
20
+ }
21
+
22
+ export interface ImportResult {
23
+ specName: string;
24
+ specDir: string;
25
+ prdIssueNumber: number;
26
+ issueCount: number;
27
+ }
28
+
29
+ export async function importSpec(options: ImportOptions): Promise<ImportResult> {
30
+ const source = options._sourceProvider ?? getSourceProvider(options.provider);
31
+
32
+ const prd = await source.fetchPrd(options.identifier);
33
+ const issues = await source.fetchIssues(options.identifier);
34
+
35
+ const specName = deriveSpecName(options.name ?? prd.title);
36
+ if (!specName) {
37
+ throw new Error("Could not derive a spec name from the PRD title. Use --name to specify one.");
38
+ }
39
+
40
+ const specDir = join(".stonecut", "prd", specName);
41
+
42
+ if (existsSync(specDir)) {
43
+ if (!options.force) {
44
+ throw new Error(
45
+ `Spec '${specName}' already exists at ${specDir}/. Use --force to overwrite.`,
46
+ );
47
+ }
48
+ rmSync(specDir, { recursive: true });
49
+ }
50
+
51
+ const issuesDir = join(specDir, "issues");
52
+ mkdirSync(issuesDir, { recursive: true });
53
+
54
+ // Write PRD with frontmatter
55
+ const prdMeta: Record<string, string> = {
56
+ source: options.provider,
57
+ issue: String(prd.issueNumber),
58
+ title: prd.title,
59
+ };
60
+ writeFileSync(join(specDir, "prd.md"), serializeFrontmatter(prdMeta, prd.body + "\n"));
61
+
62
+ // Write issues with frontmatter
63
+ for (let i = 0; i < issues.length; i++) {
64
+ const issue = issues[i];
65
+ const num = String(i + 1).padStart(2, "0");
66
+ const slug = slugifyBranchComponent(issue.title);
67
+ const filename = `${num}-${slug}.md`;
68
+
69
+ const issueMeta: Record<string, string> = {
70
+ source: options.provider,
71
+ issue: String(issue.number),
72
+ };
73
+ const body = issue.body ? issue.body + "\n" : "";
74
+ writeFileSync(join(issuesDir, filename), serializeFrontmatter(issueMeta, body));
75
+ }
76
+
77
+ // Create initial status.json (pre-populate with already-closed issues) and progress.txt
78
+ const completed = issues.filter((issue) => issue.state === "CLOSED").map((issue) => issue.number);
79
+ writeFileSync(join(specDir, "status.json"), JSON.stringify({ completed }, null, 2) + "\n");
80
+ writeFileSync(join(specDir, "progress.txt"), "");
81
+
82
+ return {
83
+ specName,
84
+ specDir,
85
+ prdIssueNumber: prd.issueNumber,
86
+ issueCount: issues.length,
87
+ };
88
+ }
89
+
90
+ function deriveSpecName(title: string): string {
91
+ return slugifyBranchComponent(title);
92
+ }
package/src/init.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Scaffolds a `.stonecut/` project directory with config and gitignore.
3
+ */
4
+
5
+ import { existsSync, writeFileSync, mkdirSync } from "fs";
6
+ import { join } from "path";
7
+ import { writeDefaultConfig } from "./config";
8
+
9
+ const CONFIG_DIR = ".stonecut";
10
+ const CONFIG_FILE = "config.json";
11
+ const GITIGNORE_FILE = ".gitignore";
12
+
13
+ const GITIGNORE_CONTENT = `# Stonecut runtime artifacts
14
+ logs/
15
+ status.json
16
+ progress.txt
17
+ `;
18
+
19
+ /**
20
+ * Initialize a `.stonecut/` directory with config and gitignore.
21
+ * Throws if `config.json` already exists to prevent accidental overwrites.
22
+ */
23
+ export function init(cwd?: string): void {
24
+ const base = cwd ?? process.cwd();
25
+ const configPath = join(base, CONFIG_DIR, CONFIG_FILE);
26
+
27
+ if (existsSync(configPath)) {
28
+ throw new Error(
29
+ `.stonecut/config.json already exists. Remove it first if you want to reinitialize.`,
30
+ );
31
+ }
32
+
33
+ const dir = join(base, CONFIG_DIR);
34
+ mkdirSync(dir, { recursive: true });
35
+
36
+ writeDefaultConfig(cwd);
37
+ writeFileSync(join(dir, GITIGNORE_FILE), GITIGNORE_CONTENT);
38
+ }