stonecut 1.2.1 → 1.4.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 +10 -10
- package/package.json +4 -1
- package/src/cli.ts +31 -333
- package/src/execute.ts +159 -0
- package/src/git.ts +1 -20
- package/src/import.ts +1 -1
- package/src/local.ts +28 -2
- package/src/logger.ts +4 -0
- package/src/prd.ts +136 -0
- package/src/runner.ts +42 -81
- package/src/runners/claude.ts +140 -56
- package/src/runners/codex.ts +134 -54
- package/src/runners/index.ts +4 -4
- package/src/skills/stonecut-issues/SKILL.md +4 -4
- package/src/skills/stonecut-prd/SKILL.md +1 -3
- package/src/skills/stonecut-review-architecture/SKILL.md +1 -1
- package/src/sources/github.ts +1 -12
- package/src/spawn.ts +22 -0
- package/src/sync-back.ts +88 -0
- package/src/types.ts +9 -2
package/src/execute.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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, formatTokens, 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
|
+
/** Build the metrics parenthetical for a report line. */
|
|
26
|
+
function formatMetrics(r: IterationResult): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
if (r.turns != null) {
|
|
29
|
+
parts.push(`${r.turns} turns`);
|
|
30
|
+
}
|
|
31
|
+
const totalTokens =
|
|
32
|
+
r.inputTokens != null || r.outputTokens != null
|
|
33
|
+
? (r.inputTokens ?? 0) + (r.outputTokens ?? 0)
|
|
34
|
+
: undefined;
|
|
35
|
+
if (totalTokens != null) {
|
|
36
|
+
parts.push(`${formatTokens(totalTokens)} tokens`);
|
|
37
|
+
}
|
|
38
|
+
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the Stonecut Report section for a PR body.
|
|
43
|
+
*/
|
|
44
|
+
export function buildReport(
|
|
45
|
+
results: IterationResult[],
|
|
46
|
+
runnerName: string,
|
|
47
|
+
closingRefs?: string[],
|
|
48
|
+
): string {
|
|
49
|
+
const lines = ["## Stonecut Report", `**Runner:** ${runnerName}`, ""];
|
|
50
|
+
for (const r of results) {
|
|
51
|
+
const metrics = formatMetrics(r);
|
|
52
|
+
if (r.success) {
|
|
53
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: completed${metrics}`);
|
|
54
|
+
} else {
|
|
55
|
+
const reason = r.error || "unknown error";
|
|
56
|
+
lines.push(`- #${r.issueNumber} ${r.issueFilename}: failed — ${reason}${metrics}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (closingRefs && closingRefs.length > 0) {
|
|
61
|
+
lines.push("");
|
|
62
|
+
lines.push(closingRefs.join("\n"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Post-loop: push and conditionally create PR
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export async function pushAndMaybePr(
|
|
73
|
+
results: IterationResult[],
|
|
74
|
+
source: {
|
|
75
|
+
getRemainingCount(): Promise<[number, number]>;
|
|
76
|
+
getClosingRefs?(completedIssueNumbers: number[]): string[];
|
|
77
|
+
},
|
|
78
|
+
branch: string,
|
|
79
|
+
baseBranch: string,
|
|
80
|
+
prTitle: string,
|
|
81
|
+
runnerName: string,
|
|
82
|
+
logger: { log(message: string): void },
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (!results.some((r) => r.success)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pushBranch(branch);
|
|
89
|
+
logger.log(`Pushed branch '${branch}'.`);
|
|
90
|
+
|
|
91
|
+
const [remaining, total] = await source.getRemainingCount();
|
|
92
|
+
if (remaining === 0) {
|
|
93
|
+
const completed = results.filter((r) => r.success).map((r) => r.issueNumber);
|
|
94
|
+
const closingRefs = source.getClosingRefs?.(completed);
|
|
95
|
+
const body = buildReport(results, runnerName, closingRefs);
|
|
96
|
+
createPr(prTitle, body, baseBranch);
|
|
97
|
+
logger.log("Created PR.");
|
|
98
|
+
} else {
|
|
99
|
+
logger.log(`${remaining}/${total} issues remaining — PR deferred.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Execution orchestration
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute a local PRD run — non-interactive orchestration.
|
|
109
|
+
*
|
|
110
|
+
* All parameters are explicit: no interactive prompts, no @clack/prompts.
|
|
111
|
+
* Handles branch checkout, runner session, loop, push, and PR creation.
|
|
112
|
+
*/
|
|
113
|
+
export async function executeLocal(
|
|
114
|
+
name: string,
|
|
115
|
+
branch: string,
|
|
116
|
+
baseBranch: string,
|
|
117
|
+
iterations: number | "all",
|
|
118
|
+
runnerName: string,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const source = new LocalSource(name);
|
|
121
|
+
const prdIdentifier = slugifyBranchComponent(name) || "spec";
|
|
122
|
+
const logger = new Logger(prdIdentifier);
|
|
123
|
+
const runner = getRunner(runnerName, logger);
|
|
124
|
+
|
|
125
|
+
const session: Session = { logger, git: defaultGitOps, runner, runnerName };
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
checkoutOrCreateBranch(branch);
|
|
129
|
+
console.log("");
|
|
130
|
+
|
|
131
|
+
const prdContent = await source.getPrdContent();
|
|
132
|
+
const results = await runAfkLoop<Issue>(
|
|
133
|
+
source,
|
|
134
|
+
iterations,
|
|
135
|
+
(issue) =>
|
|
136
|
+
renderLocal({
|
|
137
|
+
prdContent,
|
|
138
|
+
issueNumber: issue.number,
|
|
139
|
+
issueFilename: issue.filename,
|
|
140
|
+
issueContent: issue.content,
|
|
141
|
+
}),
|
|
142
|
+
(issue) => issue.filename,
|
|
143
|
+
(issue) => `Issue ${issue.number}: ${issue.filename}`,
|
|
144
|
+
session,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await pushAndMaybePr(
|
|
148
|
+
results,
|
|
149
|
+
source,
|
|
150
|
+
branch,
|
|
151
|
+
baseBranch,
|
|
152
|
+
`Stonecut: ${name}`,
|
|
153
|
+
runnerName,
|
|
154
|
+
logger,
|
|
155
|
+
);
|
|
156
|
+
} finally {
|
|
157
|
+
logger.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
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", "
|
|
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/
|
|
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", "
|
|
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/logger.ts
CHANGED
|
@@ -29,6 +29,10 @@ export class Logger implements LogWriter {
|
|
|
29
29
|
|
|
30
30
|
log(message: string): void {
|
|
31
31
|
console.log(message);
|
|
32
|
+
this.logFile(message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
logFile(message: string): void {
|
|
32
36
|
const ts = new Date().toISOString();
|
|
33
37
|
const fileWasMissing = !existsSync(this.filePath);
|
|
34
38
|
try {
|
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,
|
|
@@ -39,84 +39,10 @@ export const defaultGitOps: GitOps = {
|
|
|
39
39
|
/** Console-only logger for backward compatibility. */
|
|
40
40
|
export const consoleLogger: LogWriter = {
|
|
41
41
|
log: (message: string) => console.log(message),
|
|
42
|
+
logFile: () => {},
|
|
42
43
|
close: () => {},
|
|
43
44
|
};
|
|
44
45
|
|
|
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
46
|
/**
|
|
121
47
|
* Single check → fix cycle.
|
|
122
48
|
*
|
|
@@ -199,7 +125,6 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
199
125
|
const { logger, git, runner, runnerName } = session;
|
|
200
126
|
|
|
201
127
|
logger.log(`Session started — runner: ${runnerName}, iterations: ${iterations}`);
|
|
202
|
-
runner.logEnvironment(logger);
|
|
203
128
|
logger.log("");
|
|
204
129
|
const results: IterationResult[] = [];
|
|
205
130
|
const sessionStart = performance.now();
|
|
@@ -257,6 +182,9 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
257
182
|
success: false,
|
|
258
183
|
elapsedSeconds: runResult.durationSeconds,
|
|
259
184
|
error: runResult.error,
|
|
185
|
+
turns: runResult.turns,
|
|
186
|
+
inputTokens: runResult.inputTokens,
|
|
187
|
+
outputTokens: runResult.outputTokens,
|
|
260
188
|
});
|
|
261
189
|
|
|
262
190
|
if (lastFailedIssueNumber === issue.number) {
|
|
@@ -284,6 +212,9 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
284
212
|
success: false,
|
|
285
213
|
elapsedSeconds: runResult.durationSeconds,
|
|
286
214
|
error: errorMsg,
|
|
215
|
+
turns: runResult.turns,
|
|
216
|
+
inputTokens: runResult.inputTokens,
|
|
217
|
+
outputTokens: runResult.outputTokens,
|
|
287
218
|
});
|
|
288
219
|
|
|
289
220
|
if (lastFailedIssueNumber === issue.number) {
|
|
@@ -313,6 +244,9 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
313
244
|
success: false,
|
|
314
245
|
elapsedSeconds: runResult.durationSeconds,
|
|
315
246
|
error: "commit failed after retries",
|
|
247
|
+
turns: runResult.turns,
|
|
248
|
+
inputTokens: runResult.inputTokens,
|
|
249
|
+
outputTokens: runResult.outputTokens,
|
|
316
250
|
});
|
|
317
251
|
// Commit failures always stop the session immediately
|
|
318
252
|
logger.log("Stopping session: unable to commit.");
|
|
@@ -339,6 +273,9 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
339
273
|
issueFilename: name,
|
|
340
274
|
success: true,
|
|
341
275
|
elapsedSeconds: runResult.durationSeconds,
|
|
276
|
+
turns: runResult.turns,
|
|
277
|
+
inputTokens: runResult.inputTokens,
|
|
278
|
+
outputTokens: runResult.outputTokens,
|
|
342
279
|
});
|
|
343
280
|
|
|
344
281
|
lastFailedIssueNumber = null;
|
|
@@ -359,6 +296,19 @@ export async function runAfkLoop<T extends { number: number }>(
|
|
|
359
296
|
return results;
|
|
360
297
|
}
|
|
361
298
|
|
|
299
|
+
/** Format a token count as a human-readable string (e.g., 12400 → "12.4k"). */
|
|
300
|
+
export function formatTokens(count: number): string {
|
|
301
|
+
if (count >= 1_000_000) {
|
|
302
|
+
const m = count / 1_000_000;
|
|
303
|
+
return m % 1 === 0 ? `${m}M` : `${parseFloat(m.toFixed(1))}M`;
|
|
304
|
+
}
|
|
305
|
+
if (count >= 1_000) {
|
|
306
|
+
const k = count / 1_000;
|
|
307
|
+
return k % 1 === 0 ? `${k}k` : `${parseFloat(k.toFixed(1))}k`;
|
|
308
|
+
}
|
|
309
|
+
return `${count}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
362
312
|
/** Format seconds as a human-readable duration. */
|
|
363
313
|
export function fmtTime(seconds: number): string {
|
|
364
314
|
if (seconds < 60) {
|
|
@@ -386,7 +336,18 @@ export function printSummary(
|
|
|
386
336
|
for (const r of results) {
|
|
387
337
|
const status = r.success ? "completed" : "failed";
|
|
388
338
|
const elapsed = fmtTime(r.elapsedSeconds);
|
|
389
|
-
|
|
339
|
+
const details = [elapsed];
|
|
340
|
+
if (r.turns != null) {
|
|
341
|
+
details.push(`${r.turns} turns`);
|
|
342
|
+
}
|
|
343
|
+
const totalTokens =
|
|
344
|
+
r.inputTokens != null || r.outputTokens != null
|
|
345
|
+
? (r.inputTokens ?? 0) + (r.outputTokens ?? 0)
|
|
346
|
+
: undefined;
|
|
347
|
+
if (totalTokens != null) {
|
|
348
|
+
details.push(`${formatTokens(totalTokens)} tokens`);
|
|
349
|
+
}
|
|
350
|
+
logger.log(` Issue ${r.issueNumber} (${r.issueFilename}): ${status} (${details.join(", ")})`);
|
|
390
351
|
}
|
|
391
352
|
|
|
392
353
|
logger.log("");
|