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/README.md +235 -50
- package/package.json +1 -1
- package/src/cli.ts +178 -126
- package/src/config.ts +56 -0
- package/src/frontmatter.ts +73 -0
- package/src/import.ts +92 -0
- package/src/init.ts +38 -0
- package/src/local.ts +7 -4
- package/src/prompt.ts +0 -20
- package/src/runner.ts +97 -1
- package/src/skills/stonecut-review-architecture/REFERENCE.md +109 -0
- package/src/skills/stonecut-review-architecture/SKILL.md +98 -0
- package/src/skills.ts +6 -1
- package/src/{github.ts → sources/github.ts} +85 -68
- package/src/sources/index.ts +21 -0
- package/src/sources/types.ts +47 -0
- package/src/types.ts +1 -15
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 {
|
|
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
|
|
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
|
-
*
|
|
64
|
-
* Returns a tagged
|
|
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
|
-
|
|
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: "
|
|
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
|
-
*
|
|
82
|
-
* Returns a tagged tuple matching the shape of validateRunSource().
|
|
121
|
+
* Format a PRD entry for display in the wizard.
|
|
83
122
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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(
|
|
150
|
+
if (clack.isCancel(selection)) {
|
|
96
151
|
throw new Error("Cancelled.");
|
|
97
152
|
}
|
|
98
153
|
|
|
99
|
-
if (
|
|
100
|
-
const
|
|
101
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
184
|
+
|
|
185
|
+
if (clack.isCancel(selected)) {
|
|
122
186
|
throw new Error("Cancelled.");
|
|
123
187
|
}
|
|
124
|
-
|
|
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(
|
|
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
|
|
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)"
|
|
383
|
+
.option("--runner <name>", "Agentic CLI runner (claude, codex)")
|
|
370
384
|
.action(async (opts) => {
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
}
|