stonecut 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -15
- package/package.json +2 -1
- package/src/cli.ts +32 -334
- package/src/execute.ts +142 -0
- package/src/git.ts +1 -20
- package/src/import.ts +1 -1
- package/src/local.ts +28 -2
- package/src/prd.ts +136 -0
- package/src/runner.ts +4 -79
- package/src/skills/stonecut-issues/SKILL.md +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -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/sources/github.ts +1 -12
- package/src/spawn.ts +22 -0
- package/src/sync-back.ts +88 -0
package/src/execute.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution orchestration — non-interactive, prompt-free.
|
|
3
|
+
*
|
|
4
|
+
* executeLocal: set up session, checkout branch, run loop, push/PR.
|
|
5
|
+
* pushAndMaybePr: push branch and conditionally create PR.
|
|
6
|
+
* buildReport: format iteration results for PR body.
|
|
7
|
+
*
|
|
8
|
+
* This module never imports @clack/prompts — the interactive/execution
|
|
9
|
+
* boundary is enforced by module structure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { checkoutOrCreateBranch, createPr, pushBranch } from "./git";
|
|
13
|
+
import { LocalSource } from "./local";
|
|
14
|
+
import { slugifyBranchComponent } from "./naming";
|
|
15
|
+
import { renderLocal } from "./prompt";
|
|
16
|
+
import { Logger } from "./logger";
|
|
17
|
+
import { defaultGitOps, runAfkLoop } from "./runner";
|
|
18
|
+
import { getRunner } from "./runners/index";
|
|
19
|
+
import type { Issue, IterationResult, Session } from "./types";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Stonecut report
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the Stonecut Report section for a PR body.
|
|
27
|
+
*/
|
|
28
|
+
export function buildReport(
|
|
29
|
+
results: IterationResult[],
|
|
30
|
+
runnerName: string,
|
|
31
|
+
closingRefs?: string[],
|
|
32
|
+
): string {
|
|
33
|
+
const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
|
|
34
|
+
for (const r of results) {
|
|
35
|
+
if (r.success) {
|
|
36
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed`);
|
|
37
|
+
} else {
|
|
38
|
+
const reason = r.error || "unknown error";
|
|
39
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (closingRefs && closingRefs.length > 0) {
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(closingRefs.join("\n"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Post-loop: push and conditionally create PR
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export async function pushAndMaybePr(
|
|
56
|
+
results: IterationResult[],
|
|
57
|
+
source: {
|
|
58
|
+
getRemainingCount(): Promise<[number, number]>;
|
|
59
|
+
getClosingRefs?(completedIssueNumbers: number[]): string[];
|
|
60
|
+
},
|
|
61
|
+
branch: string,
|
|
62
|
+
baseBranch: string,
|
|
63
|
+
prTitle: string,
|
|
64
|
+
runnerName: string,
|
|
65
|
+
logger: { log(message: string): void },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!results.some((r) => r.success)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pushBranch(branch);
|
|
72
|
+
logger.log(`Pushed branch '${branch}'.`);
|
|
73
|
+
|
|
74
|
+
const [remaining, total] = await source.getRemainingCount();
|
|
75
|
+
if (remaining === 0) {
|
|
76
|
+
const completed = results.filter((r) => r.success).map((r) => r.issueNumber);
|
|
77
|
+
const closingRefs = source.getClosingRefs?.(completed);
|
|
78
|
+
const body = buildReport(results, runnerName, closingRefs);
|
|
79
|
+
createPr(prTitle, body, baseBranch);
|
|
80
|
+
logger.log("Created PR.");
|
|
81
|
+
} else {
|
|
82
|
+
logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Execution orchestration
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Execute a local PRD run — non-interactive orchestration.
|
|
92
|
+
*
|
|
93
|
+
* All parameters are explicit: no interactive prompts, no @clack/prompts.
|
|
94
|
+
* Handles branch checkout, runner session, loop, push, and PR creation.
|
|
95
|
+
*/
|
|
96
|
+
export async function executeLocal(
|
|
97
|
+
name: string,
|
|
98
|
+
branch: string,
|
|
99
|
+
baseBranch: string,
|
|
100
|
+
iterations: number | "all",
|
|
101
|
+
runnerName: string,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const runner = getRunner(runnerName);
|
|
104
|
+
const source = new LocalSource(name);
|
|
105
|
+
const prdIdentifier = slugifyBranchComponent(name) || "spec";
|
|
106
|
+
const logger = new Logger(prdIdentifier);
|
|
107
|
+
|
|
108
|
+
const session: Session = { logger, git: defaultGitOps, runner, runnerName };
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
checkoutOrCreateBranch(branch);
|
|
112
|
+
console.log("");
|
|
113
|
+
|
|
114
|
+
const prdContent = await source.getPrdContent();
|
|
115
|
+
const results = await runAfkLoop<Issue>(
|
|
116
|
+
source,
|
|
117
|
+
iterations,
|
|
118
|
+
(issue) =>
|
|
119
|
+
renderLocal({
|
|
120
|
+
prdContent,
|
|
121
|
+
issueNumber: issue.number,
|
|
122
|
+
issueFilename: issue.filename,
|
|
123
|
+
issueContent: issue.content,
|
|
124
|
+
}),
|
|
125
|
+
(issue) => issue.filename,
|
|
126
|
+
(issue) => `Issue ${issue.number}: ${issue.filename}`,
|
|
127
|
+
session,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await pushAndMaybePr(
|
|
131
|
+
results,
|
|
132
|
+
source,
|
|
133
|
+
branch,
|
|
134
|
+
baseBranch,
|
|
135
|
+
`Stonecut: ${name}`,
|
|
136
|
+
runnerName,
|
|
137
|
+
logger,
|
|
138
|
+
);
|
|
139
|
+
} finally {
|
|
140
|
+
logger.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -5,28 +5,9 @@
|
|
|
5
5
|
* All functions throw on failure. No process.exit, no console output.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { runSync } from "./spawn";
|
|
8
9
|
import type { WorkingTreeSnapshot } from "./types";
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
* Run a command synchronously, optionally in a specific working directory.
|
|
12
|
-
* When cwd is omitted, uses the current process working directory.
|
|
13
|
-
*/
|
|
14
|
-
function runSync(
|
|
15
|
-
cmd: string[],
|
|
16
|
-
cwd?: string,
|
|
17
|
-
): { exitCode: number; stdout: string; stderr: string } {
|
|
18
|
-
const proc = Bun.spawnSync(cmd, {
|
|
19
|
-
stdout: "pipe",
|
|
20
|
-
stderr: "pipe",
|
|
21
|
-
...(cwd && { cwd }),
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
exitCode: proc.exitCode,
|
|
25
|
-
stdout: proc.stdout.toString(),
|
|
26
|
-
stderr: proc.stderr.toString(),
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
11
|
/** Detect the remote's default branch, falling back to "main". */
|
|
31
12
|
export function defaultBranch(cwd?: string): string {
|
|
32
13
|
const result = runSync(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
|
package/src/import.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function importSpec(options: ImportOptions): Promise<ImportResult>
|
|
|
37
37
|
throw new Error("Could not derive a spec name from the PRD title. Use --name to specify one.");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const specDir = join(".stonecut", specName);
|
|
40
|
+
const specDir = join(".stonecut", "specs", specName);
|
|
41
41
|
|
|
42
42
|
if (existsSync(specDir)) {
|
|
43
43
|
if (!options.force) {
|
package/src/local.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Local spec source — reads issues from .stonecut/<name>/. */
|
|
1
|
+
/** Local spec source — reads issues from .stonecut/specs/<name>/. */
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, statSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
@@ -11,7 +11,7 @@ export class LocalSource implements Source<Issue> {
|
|
|
11
11
|
|
|
12
12
|
constructor(name: string) {
|
|
13
13
|
this.name = name;
|
|
14
|
-
this.specDir = join(".stonecut", name);
|
|
14
|
+
this.specDir = join(".stonecut", "specs", name);
|
|
15
15
|
this.validate();
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -86,6 +86,32 @@ export class LocalSource implements Source<Issue> {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
getClosingRefs(completedIssueNumbers: number[]): string[] {
|
|
90
|
+
const completed = new Set(completedIssueNumbers);
|
|
91
|
+
const refs: string[] = [];
|
|
92
|
+
const all = this.allIssues();
|
|
93
|
+
|
|
94
|
+
for (const issue of all) {
|
|
95
|
+
if (!completed.has(issue.number)) continue;
|
|
96
|
+
const raw = readFileSync(issue.path, "utf-8");
|
|
97
|
+
const num = parseInt(parseFrontmatter(raw).meta.issue, 10);
|
|
98
|
+
if (num && !isNaN(num)) {
|
|
99
|
+
refs.push(`Closes #${num}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const allComplete = all.every((i) => completed.has(i.number));
|
|
104
|
+
if (allComplete) {
|
|
105
|
+
const raw = readFileSync(join(this.specDir, "prd.md"), "utf-8");
|
|
106
|
+
const num = parseInt(parseFrontmatter(raw).meta.issue, 10);
|
|
107
|
+
if (num && !isNaN(num)) {
|
|
108
|
+
refs.push(`Closes #${num}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return refs;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
async getRemainingCount(): Promise<[number, number]> {
|
|
90
116
|
const completed = this.readStatus();
|
|
91
117
|
const all = this.allIssues();
|
package/src/prd.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD selection UI — filesystem scanning, interactive picker, and inline
|
|
3
|
+
* GitHub import flow.
|
|
4
|
+
*
|
|
5
|
+
* This is the only extracted module that imports @clack/prompts, as it is
|
|
6
|
+
* part of the interactive UI layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as clack from "@clack/prompts";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { importSpec } from "./import";
|
|
13
|
+
import { getSourceProvider } from "./sources/index";
|
|
14
|
+
|
|
15
|
+
/** A locally available PRD with its completion status. */
|
|
16
|
+
export interface LocalPrdEntry {
|
|
17
|
+
name: string;
|
|
18
|
+
completed: number;
|
|
19
|
+
total: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Scan .stonecut subdirectories for directories containing prd.md and compute
|
|
24
|
+
* completion counts from status.json.
|
|
25
|
+
*/
|
|
26
|
+
export function scanLocalPrds(baseDir: string = ".stonecut/specs"): LocalPrdEntry[] {
|
|
27
|
+
if (!existsSync(baseDir)) return [];
|
|
28
|
+
|
|
29
|
+
const entries: LocalPrdEntry[] = [];
|
|
30
|
+
for (const name of readdirSync(baseDir)) {
|
|
31
|
+
const specDir = join(baseDir, name);
|
|
32
|
+
if (!statSync(specDir).isDirectory()) continue;
|
|
33
|
+
if (!existsSync(join(specDir, "prd.md"))) continue;
|
|
34
|
+
|
|
35
|
+
const issuesDir = join(specDir, "issues");
|
|
36
|
+
let total = 0;
|
|
37
|
+
if (existsSync(issuesDir) && statSync(issuesDir).isDirectory()) {
|
|
38
|
+
total = readdirSync(issuesDir).filter((f) => f.endsWith(".md")).length;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let completed = 0;
|
|
42
|
+
const statusPath = join(specDir, "status.json");
|
|
43
|
+
if (existsSync(statusPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(readFileSync(statusPath, "utf-8"));
|
|
46
|
+
completed = Array.isArray(data.completed) ? data.completed.length : 0;
|
|
47
|
+
} catch {
|
|
48
|
+
// Malformed status.json — treat as 0 completed
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
entries.push({ name, completed, total });
|
|
53
|
+
}
|
|
54
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format a PRD entry for display in the wizard.
|
|
59
|
+
*/
|
|
60
|
+
export function formatPrdOption(entry: LocalPrdEntry): string {
|
|
61
|
+
if (entry.total === 0) return `${entry.name} (no issues)`;
|
|
62
|
+
if (entry.completed === 0) return `${entry.name} (not started)`;
|
|
63
|
+
return `${entry.name} (${entry.completed}/${entry.total} done)`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sentinel value for the "Import from GitHub" wizard option. */
|
|
67
|
+
const IMPORT_FROM_GITHUB = "__import_from_github__";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Prompt the user to select a local PRD or import from GitHub.
|
|
71
|
+
* Returns the local spec name to run.
|
|
72
|
+
*/
|
|
73
|
+
export async function promptForPrd(): Promise<{ kind: "local"; name: string }> {
|
|
74
|
+
const prds = scanLocalPrds();
|
|
75
|
+
|
|
76
|
+
const options: Array<{ value: string; label: string }> = prds.map((entry) => ({
|
|
77
|
+
value: entry.name,
|
|
78
|
+
label: formatPrdOption(entry),
|
|
79
|
+
}));
|
|
80
|
+
options.push({ value: IMPORT_FROM_GITHUB, label: "Import from GitHub" });
|
|
81
|
+
|
|
82
|
+
const selection = await clack.select({
|
|
83
|
+
message: "Select a PRD:",
|
|
84
|
+
options,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (clack.isCancel(selection)) {
|
|
88
|
+
throw new Error("Cancelled.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (selection === IMPORT_FROM_GITHUB) {
|
|
92
|
+
const specName = await inlineGitHubImport();
|
|
93
|
+
return { kind: "local", name: specName };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { kind: "local", name: selection as string };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Inline GitHub import flow within the wizard.
|
|
101
|
+
* Lists PRDs by `prd` label, user picks one, import runs.
|
|
102
|
+
* Returns the imported spec name.
|
|
103
|
+
*/
|
|
104
|
+
async function inlineGitHubImport(): Promise<string> {
|
|
105
|
+
const provider = getSourceProvider("github");
|
|
106
|
+
const prdList = await provider.listPrds();
|
|
107
|
+
|
|
108
|
+
if (prdList.length === 0) {
|
|
109
|
+
throw new Error("No open PRDs with the 'prd' label found on GitHub.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const prdOptions = prdList.map((p) => ({
|
|
113
|
+
value: String(p.number),
|
|
114
|
+
label: `#${p.number}: ${p.title}`,
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
const selected = await clack.select({
|
|
118
|
+
message: "Select a GitHub PRD to import:",
|
|
119
|
+
options: prdOptions,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (clack.isCancel(selected)) {
|
|
123
|
+
throw new Error("Cancelled.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await importSpec({
|
|
127
|
+
provider: "github",
|
|
128
|
+
identifier: selected as string,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
`Imported PRD #${result.prdIssueNumber} → ${result.specDir}/ (${result.issueCount} issues)`,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return result.specName;
|
|
136
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runner —
|
|
2
|
+
* Runner — orchestration loop, commit flow, and session helpers.
|
|
3
3
|
*
|
|
4
4
|
* verifyAndFix: single check → fix cycle.
|
|
5
5
|
* commitIssue: stage, commit, retry on failure up to maxRetries times.
|
|
6
6
|
* runAfkLoop: main orchestration loop over issues from any source.
|
|
7
7
|
* fmtTime / printSummary: session output formatting.
|
|
8
8
|
*
|
|
9
|
+
* Sync-back logic lives in sync-back.ts.
|
|
9
10
|
* Modules throw on failure. No process.exit, no console output.
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -15,9 +16,8 @@ import {
|
|
|
15
16
|
snapshotWorkingTree as realSnapshotWorkingTree,
|
|
16
17
|
stageChanges as realStageChanges,
|
|
17
18
|
} from "./git";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { getSourceProvider } from "./sources/index";
|
|
19
|
+
import { syncBackIssue, syncBackPrd } from "./sync-back";
|
|
20
|
+
import type { SyncBackConfig } from "./sync-back";
|
|
21
21
|
import type {
|
|
22
22
|
GitOps,
|
|
23
23
|
IterationResult,
|
|
@@ -42,81 +42,6 @@ export const consoleLogger: LogWriter = {
|
|
|
42
42
|
close: () => {},
|
|
43
43
|
};
|
|
44
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
|
-
|
|
120
45
|
/**
|
|
121
46
|
* Single check → fix cycle.
|
|
122
47
|
*
|
|
@@ -12,7 +12,7 @@ You are breaking a PRD into issues as part of the Stonecut workflow. Each issue
|
|
|
12
12
|
Determine where the PRD lives. Check these in order:
|
|
13
13
|
|
|
14
14
|
1. **Conversation context** — If a PRD was created earlier in this conversation (via `/stonecut-prd` or otherwise), you already know where it is. State where you found it and confirm with the user.
|
|
15
|
-
2. **Ask the user** — If no PRD is in context, ask: "Where is the PRD? Give me a local file path (
|
|
15
|
+
2. **Ask the user** — If no PRD is in context, ask: "Where is the PRD? Give me a local file path (`.stonecut/specs/<name>/prd.md`) or a GitHub issue number."
|
|
16
16
|
|
|
17
17
|
If given a GitHub issue number, fetch it with `gh issue view <number>`.
|
|
18
18
|
If given a local path, read the file.
|
|
@@ -63,7 +63,7 @@ Iterate until the user approves the breakdown.
|
|
|
63
63
|
|
|
64
64
|
Default to **matching the PRD location**:
|
|
65
65
|
|
|
66
|
-
- If the PRD is a **local file
|
|
66
|
+
- If the PRD is a **local file**, default to creating issues under `.stonecut/specs/<name>/issues/`.
|
|
67
67
|
- If the PRD is a **GitHub issue**, default to creating issues as GitHub issues using `gh issue create`.
|
|
68
68
|
|
|
69
69
|
Confirm with the user before creating. If they want a different destination, respect that.
|
|
@@ -75,7 +75,7 @@ Confirm with the user before creating. If they want a different destination, res
|
|
|
75
75
|
Create each issue as a markdown file in the issues directory. Use zero-padded numbering with a kebab-case descriptive suffix:
|
|
76
76
|
|
|
77
77
|
```
|
|
78
|
-
.stonecut/<name>/issues/
|
|
78
|
+
.stonecut/specs/<name>/issues/
|
|
79
79
|
01-short-descriptive-title.md
|
|
80
80
|
02-another-slice-title.md
|
|
81
81
|
...
|
|
@@ -88,7 +88,7 @@ Create issues in dependency order (blockers first). Use the local issue template
|
|
|
88
88
|
|
|
89
89
|
## Parent PRD
|
|
90
90
|
|
|
91
|
-
See `.stonecut/<name>/prd.md`
|
|
91
|
+
See `.stonecut/specs/<name>/prd.md`
|
|
92
92
|
|
|
93
93
|
## What to build
|
|
94
94
|
|
|
@@ -29,7 +29,7 @@ Check with the user that these modules match their expectations. Ask which modul
|
|
|
29
29
|
|
|
30
30
|
Ask the user where to save the PRD:
|
|
31
31
|
|
|
32
|
-
- **Local file** — Save as `.stonecut/<name>/prd.md` in the project. Ask the user: "What should I name this spec?" The name can be anything — a ticket ID (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 (e.g., `ASC-1`), 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
|
|
@@ -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>
|