stonecut 1.0.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/LICENSE +21 -0
- package/README.md +162 -0
- package/package.json +53 -0
- package/src/cli.ts +370 -0
- package/src/git.ts +189 -0
- package/src/github.ts +178 -0
- package/src/local.ts +116 -0
- package/src/logger.ts +32 -0
- package/src/naming.ts +14 -0
- package/src/prompt.ts +50 -0
- package/src/runner.ts +298 -0
- package/src/runners/claude.ts +89 -0
- package/src/runners/codex.ts +77 -0
- package/src/runners/index.ts +21 -0
- package/src/skills/stonecut-interview/SKILL.md +20 -0
- package/src/skills/stonecut-issues/SKILL.md +167 -0
- package/src/skills/stonecut-prd/SKILL.md +127 -0
- package/src/skills.ts +163 -0
- package/src/templates/.gitkeep +0 -0
- package/src/templates/execute.md +28 -0
- package/src/types.ts +83 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch-level operations — branch management, working tree checks,
|
|
3
|
+
* working tree lifecycle (snapshot/stage/commit/revert), and PR creation.
|
|
4
|
+
*
|
|
5
|
+
* All functions throw on failure. No process.exit, no console output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WorkingTreeSnapshot } from "./types";
|
|
9
|
+
|
|
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
|
+
/** Detect the remote's default branch, falling back to "main". */
|
|
31
|
+
export function defaultBranch(cwd?: string): string {
|
|
32
|
+
const result = runSync(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
|
|
33
|
+
if (result.exitCode === 0) {
|
|
34
|
+
const ref = result.stdout.trim();
|
|
35
|
+
const prefix = "refs/remotes/origin/";
|
|
36
|
+
if (ref.startsWith(prefix)) {
|
|
37
|
+
const branch = ref.slice(prefix.length);
|
|
38
|
+
if (branch) {
|
|
39
|
+
return branch;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return "main";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Throw if the working tree has uncommitted changes. */
|
|
47
|
+
export function ensureCleanTree(cwd?: string): void {
|
|
48
|
+
const result = runSync(["git", "status", "--porcelain"], cwd);
|
|
49
|
+
if (result.stdout.trim()) {
|
|
50
|
+
throw new Error("Working tree has uncommitted changes. Commit or stash them first.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check out the branch if it exists locally, otherwise create it. */
|
|
55
|
+
export function checkoutOrCreateBranch(branch: string, cwd?: string): void {
|
|
56
|
+
const verify = runSync(["git", "rev-parse", "--verify", branch], cwd);
|
|
57
|
+
if (verify.exitCode === 0) {
|
|
58
|
+
const checkout = runSync(["git", "checkout", branch], cwd);
|
|
59
|
+
if (checkout.exitCode !== 0) {
|
|
60
|
+
throw new Error(`Failed to checkout branch ${branch}: ${checkout.stderr.trim()}`);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
const create = runSync(["git", "checkout", "-b", branch], cwd);
|
|
64
|
+
if (create.exitCode !== 0) {
|
|
65
|
+
throw new Error(`Failed to create branch ${branch}: ${create.stderr.trim()}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Push the branch to the remote with upstream tracking. */
|
|
71
|
+
export function pushBranch(branch: string, cwd?: string): void {
|
|
72
|
+
const result = runSync(["git", "push", "-u", "origin", branch], cwd);
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
throw new Error(`Failed to push branch ${branch}: ${result.stderr.trim()}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Create a pull request via the gh CLI. */
|
|
79
|
+
export function createPr(title: string, body: string, baseBranch: string): void {
|
|
80
|
+
const result = runSync([
|
|
81
|
+
"gh",
|
|
82
|
+
"pr",
|
|
83
|
+
"create",
|
|
84
|
+
"--title",
|
|
85
|
+
title,
|
|
86
|
+
"--body",
|
|
87
|
+
body,
|
|
88
|
+
"--base",
|
|
89
|
+
baseBranch,
|
|
90
|
+
]);
|
|
91
|
+
if (result.exitCode !== 0) {
|
|
92
|
+
throw new Error(`Failed to create PR: ${result.stderr.trim()}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Working tree lifecycle — snapshot / stage / commit / revert
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Capture the current set of untracked files before a runner session. */
|
|
101
|
+
export function snapshotWorkingTree(cwd?: string): WorkingTreeSnapshot {
|
|
102
|
+
const result = runSync(["git", "status", "--porcelain"], cwd);
|
|
103
|
+
const untracked = new Set<string>();
|
|
104
|
+
for (const line of result.stdout.split("\n")) {
|
|
105
|
+
if (line.startsWith("??")) {
|
|
106
|
+
untracked.add(line.slice(3).trim());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { untracked };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Stage files changed during a runner session.
|
|
114
|
+
*
|
|
115
|
+
* Stages modified tracked files and new untracked files that were not
|
|
116
|
+
* present in the snapshot. Returns true if anything was staged.
|
|
117
|
+
*/
|
|
118
|
+
export function stageChanges(snapshot: WorkingTreeSnapshot, cwd?: string): boolean {
|
|
119
|
+
// Stage all modified tracked files
|
|
120
|
+
const addU = runSync(["git", "add", "-u"], cwd);
|
|
121
|
+
if (addU.exitCode !== 0) {
|
|
122
|
+
throw new Error(`Failed to stage tracked changes: ${addU.stderr.trim()}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Find new untracked files (not in pre-run snapshot)
|
|
126
|
+
const status = runSync(["git", "status", "--porcelain"], cwd);
|
|
127
|
+
const newFiles: string[] = [];
|
|
128
|
+
for (const line of status.stdout.split("\n")) {
|
|
129
|
+
if (line.startsWith("??")) {
|
|
130
|
+
const path = line.slice(3).trim();
|
|
131
|
+
if (!snapshot.untracked.has(path)) {
|
|
132
|
+
newFiles.push(path);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (newFiles.length > 0) {
|
|
138
|
+
const addNew = runSync(["git", "add", "--", ...newFiles], cwd);
|
|
139
|
+
if (addNew.exitCode !== 0) {
|
|
140
|
+
throw new Error(`Failed to stage new files: ${addNew.stderr.trim()}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check if anything is staged
|
|
145
|
+
const diff = runSync(["git", "diff", "--cached", "--quiet"], cwd);
|
|
146
|
+
return diff.exitCode !== 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a git commit with the given message.
|
|
151
|
+
*
|
|
152
|
+
* Returns [success, output]. On failure the output contains the
|
|
153
|
+
* error details (e.g. pre-commit hook output).
|
|
154
|
+
*/
|
|
155
|
+
export function commitChanges(message: string, cwd?: string): [boolean, string] {
|
|
156
|
+
const result = runSync(["git", "commit", "-m", message], cwd);
|
|
157
|
+
const output = `${result.stdout}\n${result.stderr}`.trim();
|
|
158
|
+
return [result.exitCode === 0, output];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Revert uncommitted changes, restoring the tree to the last commit.
|
|
163
|
+
*
|
|
164
|
+
* Removes new untracked files created during the runner session (those
|
|
165
|
+
* not in the snapshot) and restores modified tracked files.
|
|
166
|
+
*/
|
|
167
|
+
export function revertUncommitted(snapshot: WorkingTreeSnapshot, cwd?: string): void {
|
|
168
|
+
// Unstage everything (clear the index back to HEAD)
|
|
169
|
+
runSync(["git", "reset", "HEAD"], cwd);
|
|
170
|
+
|
|
171
|
+
// Restore modified tracked files
|
|
172
|
+
runSync(["git", "checkout", "."], cwd);
|
|
173
|
+
|
|
174
|
+
// Remove new untracked files (only those created during the session)
|
|
175
|
+
const status = runSync(["git", "status", "--porcelain"], cwd);
|
|
176
|
+
const newFiles: string[] = [];
|
|
177
|
+
for (const line of status.stdout.split("\n")) {
|
|
178
|
+
if (line.startsWith("??")) {
|
|
179
|
+
const path = line.slice(3).trim();
|
|
180
|
+
if (!snapshot.untracked.has(path)) {
|
|
181
|
+
newFiles.push(path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (newFiles.length > 0) {
|
|
187
|
+
runSync(["git", "clean", "-fd", "--", ...newFiles], cwd);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/** GitHub source — wraps the gh CLI to interact with GitHub issues. */
|
|
2
|
+
|
|
3
|
+
import type { GitHubIssue, GitHubPrd, Source } from "./types";
|
|
4
|
+
|
|
5
|
+
function runSync(cmd: string[]): { exitCode: number; stdout: string; stderr: string } {
|
|
6
|
+
const proc = Bun.spawnSync(cmd, {
|
|
7
|
+
stdout: "pipe",
|
|
8
|
+
stderr: "pipe",
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
exitCode: proc.exitCode,
|
|
12
|
+
stdout: proc.stdout.toString(),
|
|
13
|
+
stderr: proc.stderr.toString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GitHubSource implements Source<GitHubIssue> {
|
|
18
|
+
readonly prdNumber: number;
|
|
19
|
+
readonly owner: string;
|
|
20
|
+
readonly repo: string;
|
|
21
|
+
|
|
22
|
+
constructor(prdNumber: number) {
|
|
23
|
+
this.prdNumber = prdNumber;
|
|
24
|
+
GitHubSource.validateGhCli();
|
|
25
|
+
const [owner, repo] = GitHubSource.getOwnerRepo();
|
|
26
|
+
this.owner = owner;
|
|
27
|
+
this.repo = repo;
|
|
28
|
+
this.validatePrd();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static validateGhCli(): void {
|
|
32
|
+
try {
|
|
33
|
+
runSync(["gh", "--version"]);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error("Error: gh CLI is not installed. See https://cli.github.com");
|
|
36
|
+
}
|
|
37
|
+
const result = runSync(["gh", "auth", "status"]);
|
|
38
|
+
if (result.exitCode !== 0) {
|
|
39
|
+
throw new Error("Error: gh CLI is not authenticated. Run 'gh auth login'.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static getOwnerRepo(): [string, string] {
|
|
44
|
+
const result = runSync(["git", "remote", "get-url", "origin"]);
|
|
45
|
+
if (result.exitCode !== 0) {
|
|
46
|
+
throw new Error("Error: could not determine git remote origin.");
|
|
47
|
+
}
|
|
48
|
+
const url = result.stdout.trim();
|
|
49
|
+
|
|
50
|
+
// SSH: git@github.com:owner/repo.git
|
|
51
|
+
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
52
|
+
if (sshMatch) {
|
|
53
|
+
return [sshMatch[1], sshMatch[2]];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// HTTPS or SSH protocol: https://github.com/owner/repo.git
|
|
57
|
+
const httpsMatch = url.match(
|
|
58
|
+
/^(?:https?|ssh):\/\/[^/]*github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
59
|
+
);
|
|
60
|
+
if (httpsMatch) {
|
|
61
|
+
return [httpsMatch[1], httpsMatch[2]];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(`Error: could not parse owner/repo from remote URL: ${url}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
validatePrd(): void {
|
|
68
|
+
const result = runSync([
|
|
69
|
+
"gh",
|
|
70
|
+
"issue",
|
|
71
|
+
"view",
|
|
72
|
+
String(this.prdNumber),
|
|
73
|
+
"--json",
|
|
74
|
+
"labels",
|
|
75
|
+
"-q",
|
|
76
|
+
".labels[].name",
|
|
77
|
+
]);
|
|
78
|
+
if (result.exitCode !== 0) {
|
|
79
|
+
throw new Error(`Error: GitHub issue #${this.prdNumber} not found.`);
|
|
80
|
+
}
|
|
81
|
+
const labels = result.stdout
|
|
82
|
+
.trim()
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((l) => l);
|
|
85
|
+
if (!labels.includes("prd")) {
|
|
86
|
+
throw new Error(`Error: Issue #${this.prdNumber} does not have the 'prd' label.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
+
}> {
|
|
114
|
+
const query = `
|
|
115
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
116
|
+
repository(owner: $owner, name: $repo) {
|
|
117
|
+
issue(number: $number) {
|
|
118
|
+
subIssues(first: 100) {
|
|
119
|
+
nodes {
|
|
120
|
+
number
|
|
121
|
+
title
|
|
122
|
+
state
|
|
123
|
+
body
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}`;
|
|
129
|
+
const result = runSync([
|
|
130
|
+
"gh",
|
|
131
|
+
"api",
|
|
132
|
+
"graphql",
|
|
133
|
+
"-F",
|
|
134
|
+
`owner=${this.owner}`,
|
|
135
|
+
"-F",
|
|
136
|
+
`repo=${this.repo}`,
|
|
137
|
+
"-F",
|
|
138
|
+
`number=${this.prdNumber}`,
|
|
139
|
+
"-f",
|
|
140
|
+
`query=${query}`,
|
|
141
|
+
]);
|
|
142
|
+
if (result.exitCode !== 0) {
|
|
143
|
+
throw new Error(`Error fetching sub-issues: ${result.stderr.trim()}`);
|
|
144
|
+
}
|
|
145
|
+
const data = JSON.parse(result.stdout);
|
|
146
|
+
return data.data.repository.issue.subIssues.nodes;
|
|
147
|
+
}
|
|
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
|
+
}
|
package/src/local.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Local spec source — reads issues from .stonecut/<name>/. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, statSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import type { Issue, Source } from "./types";
|
|
6
|
+
|
|
7
|
+
export class LocalSource implements Source<Issue> {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
private readonly specDir: string;
|
|
10
|
+
|
|
11
|
+
constructor(name: string) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.specDir = join(".stonecut", name);
|
|
14
|
+
this.validate();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private validate(): void {
|
|
18
|
+
if (!existsSync(this.specDir) || !statSync(this.specDir).isDirectory()) {
|
|
19
|
+
throw new Error(`Error: spec directory not found: ${this.specDir}/`);
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
!existsSync(join(this.specDir, "prd.md")) ||
|
|
23
|
+
!statSync(join(this.specDir, "prd.md")).isFile()
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(`Error: ${this.specDir}/prd.md not found`);
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
!existsSync(join(this.specDir, "issues")) ||
|
|
29
|
+
!statSync(join(this.specDir, "issues")).isDirectory()
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(`Error: ${this.specDir}/issues/ not found`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private statusPath(): string {
|
|
36
|
+
return join(this.specDir, "status.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private readStatus(): Set<number> {
|
|
40
|
+
const path = this.statusPath();
|
|
41
|
+
if (!existsSync(path)) {
|
|
42
|
+
writeFileSync(path, '{ "completed": [] }\n');
|
|
43
|
+
return new Set();
|
|
44
|
+
}
|
|
45
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
46
|
+
return new Set<number>(data.completed ?? []);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private allIssues(): Array<{ number: number; filename: string; path: string }> {
|
|
50
|
+
const issuesDir = join(this.specDir, "issues");
|
|
51
|
+
const entries = readdirSync(issuesDir).sort();
|
|
52
|
+
const results: Array<{ number: number; filename: string; path: string }> = [];
|
|
53
|
+
for (const name of entries) {
|
|
54
|
+
if (!name.endsWith(".md")) continue;
|
|
55
|
+
const match = name.match(/^(\d+)/);
|
|
56
|
+
if (match) {
|
|
57
|
+
results.push({
|
|
58
|
+
number: parseInt(match[1], 10),
|
|
59
|
+
filename: name,
|
|
60
|
+
path: join(issuesDir, name),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getPrdContent(): Promise<string> {
|
|
68
|
+
return readFileSync(join(this.specDir, "prd.md"), "utf-8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getNextIssue(): Promise<Issue | null> {
|
|
72
|
+
const completed = this.readStatus();
|
|
73
|
+
for (const issue of this.allIssues()) {
|
|
74
|
+
if (!completed.has(issue.number)) {
|
|
75
|
+
return {
|
|
76
|
+
number: issue.number,
|
|
77
|
+
filename: issue.filename,
|
|
78
|
+
path: issue.path,
|
|
79
|
+
content: readFileSync(issue.path, "utf-8"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getRemainingCount(): Promise<[number, number]> {
|
|
87
|
+
const completed = this.readStatus();
|
|
88
|
+
const all = this.allIssues();
|
|
89
|
+
const total = all.length;
|
|
90
|
+
const remaining = all.filter((i) => !completed.has(i.number)).length;
|
|
91
|
+
return [remaining, total];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async completeIssue(issue: Issue): Promise<void> {
|
|
95
|
+
// Update status.json
|
|
96
|
+
const path = this.statusPath();
|
|
97
|
+
let data: { completed: number[] };
|
|
98
|
+
if (existsSync(path)) {
|
|
99
|
+
data = JSON.parse(readFileSync(path, "utf-8"));
|
|
100
|
+
} else {
|
|
101
|
+
data = { completed: [] };
|
|
102
|
+
}
|
|
103
|
+
const completed = new Set<number>(data.completed ?? []);
|
|
104
|
+
completed.add(issue.number);
|
|
105
|
+
data.completed = [...completed].sort((a, b) => a - b);
|
|
106
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
107
|
+
|
|
108
|
+
// Append to progress.txt
|
|
109
|
+
const now = new Date();
|
|
110
|
+
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
111
|
+
appendFileSync(
|
|
112
|
+
join(this.specDir, "progress.txt"),
|
|
113
|
+
`${timestamp} — Issue ${issue.number} complete: ${issue.filename}\n`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger — dual-output session logger.
|
|
3
|
+
*
|
|
4
|
+
* Writes to both console and a project-scoped log file at
|
|
5
|
+
* .stonecut/logs/<prdIdentifier>-<timestamp>.log.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import type { LogWriter } from "./types";
|
|
11
|
+
|
|
12
|
+
export class Logger implements LogWriter {
|
|
13
|
+
readonly filePath: string;
|
|
14
|
+
|
|
15
|
+
constructor(prdIdentifier: string) {
|
|
16
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
17
|
+
const logDir = join(".stonecut", "logs");
|
|
18
|
+
mkdirSync(logDir, { recursive: true });
|
|
19
|
+
this.filePath = join(logDir, `${prdIdentifier}-${timestamp}.log`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
log(message: string): void {
|
|
23
|
+
console.log(message);
|
|
24
|
+
const ts = new Date().toISOString();
|
|
25
|
+
appendFileSync(this.filePath, `[${ts}] ${message}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): void {
|
|
29
|
+
// No-op: appendFileSync doesn't hold a file handle.
|
|
30
|
+
// Exists as a lifecycle hook for future buffered/async writers.
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming helpers for branches and pull requests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Normalize a free-form title into a branch-safe slug. */
|
|
6
|
+
export function slugifyBranchComponent(value: string): string {
|
|
7
|
+
let normalized = value
|
|
8
|
+
.trim()
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-");
|
|
11
|
+
normalized = normalized.replace(/-{2,}/g, "-");
|
|
12
|
+
normalized = normalized.replace(/^-+|-+$/g, "");
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt builder — loads and renders the execute.md template.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
async function renderTemplate(vars: Record<string, string | number>): Promise<string> {
|
|
8
|
+
const template = await Bun.file(join(import.meta.dir, "templates", "execute.md")).text();
|
|
9
|
+
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? `{${key}}`));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function renderLocal({
|
|
13
|
+
prdContent,
|
|
14
|
+
issueNumber,
|
|
15
|
+
issueFilename,
|
|
16
|
+
issueContent,
|
|
17
|
+
}: {
|
|
18
|
+
prdContent: string;
|
|
19
|
+
issueNumber: number;
|
|
20
|
+
issueFilename: string;
|
|
21
|
+
issueContent: string;
|
|
22
|
+
}): Promise<string> {
|
|
23
|
+
return renderTemplate({
|
|
24
|
+
task_source: "a structured spec",
|
|
25
|
+
prd_content: prdContent,
|
|
26
|
+
issue_number: issueNumber,
|
|
27
|
+
issue_filename: issueFilename,
|
|
28
|
+
issue_content: issueContent,
|
|
29
|
+
});
|
|
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
|
+
}
|