@callumvass/forgeflow-dev 0.1.0 → 0.3.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/extensions/index.js +54 -13
- package/package.json +8 -2
- package/src/index.ts +0 -380
- package/src/pipelines/architecture.ts +0 -67
- package/src/pipelines/discover-skills.ts +0 -33
- package/src/pipelines/implement-all.ts +0 -181
- package/src/pipelines/implement.ts +0 -305
- package/src/pipelines/review.ts +0 -183
- package/src/resolve.ts +0 -6
- package/src/utils/exec.ts +0 -13
- package/src/utils/git.ts +0 -132
- package/src/utils/ui.ts +0 -29
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
- package/tsup.config.ts +0 -15
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { type AnyCtx, emptyStage, type StageResult, sumUsage } from "@callumvass/forgeflow-shared";
|
|
2
|
-
import { exec } from "../utils/exec.js";
|
|
3
|
-
import { setForgeflowStatus, updateProgressWidget } from "../utils/ui.js";
|
|
4
|
-
import { runImplement } from "./implement.js";
|
|
5
|
-
|
|
6
|
-
interface IssueInfo {
|
|
7
|
-
number: number;
|
|
8
|
-
title: string;
|
|
9
|
-
body: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Get issue numbers whose dependencies (referenced as #N in ## Dependencies section) are satisfied.
|
|
14
|
-
*/
|
|
15
|
-
function getReadyIssues(issues: IssueInfo[], completed: Set<number>): number[] {
|
|
16
|
-
return issues
|
|
17
|
-
.filter((issue) => {
|
|
18
|
-
if (completed.has(issue.number)) return false;
|
|
19
|
-
const parts = issue.body.split("## Dependencies");
|
|
20
|
-
if (parts.length < 2) return true;
|
|
21
|
-
const depSection = parts[1]?.split("\n## ")[0] ?? "";
|
|
22
|
-
const deps = [...depSection.matchAll(/#(\d+)/g)].map((m) => parseInt(m[1] ?? "0", 10));
|
|
23
|
-
return deps.every((d) => completed.has(d));
|
|
24
|
-
})
|
|
25
|
-
.map((i) => i.number);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function runImplementAll(
|
|
29
|
-
cwd: string,
|
|
30
|
-
signal: AbortSignal,
|
|
31
|
-
onUpdate: AnyCtx,
|
|
32
|
-
ctx: AnyCtx,
|
|
33
|
-
flags: { skipPlan: boolean; skipReview: boolean },
|
|
34
|
-
) {
|
|
35
|
-
const allStages: StageResult[] = [];
|
|
36
|
-
const issueProgress = new Map<number, { title: string; status: "pending" | "running" | "done" | "failed" }>();
|
|
37
|
-
|
|
38
|
-
// Seed completed set with already-closed issues
|
|
39
|
-
const closedJson = await exec(
|
|
40
|
-
`gh issue list --state closed --label "auto-generated" --json number --jq '.[].number'`,
|
|
41
|
-
cwd,
|
|
42
|
-
);
|
|
43
|
-
const completed = new Set<number>(closedJson ? closedJson.split("\n").filter(Boolean).map(Number) : []);
|
|
44
|
-
|
|
45
|
-
let iteration = 0;
|
|
46
|
-
const maxIterations = 50;
|
|
47
|
-
|
|
48
|
-
while (iteration++ < maxIterations) {
|
|
49
|
-
if (signal.aborted) break;
|
|
50
|
-
|
|
51
|
-
// Return to main and pull
|
|
52
|
-
await exec("git checkout main && git pull --rebase", cwd);
|
|
53
|
-
|
|
54
|
-
// Fetch open issues
|
|
55
|
-
const issuesJson = await exec(
|
|
56
|
-
`gh issue list --state open --label "auto-generated" --json number,title,body --jq 'sort_by(.number)'`,
|
|
57
|
-
cwd,
|
|
58
|
-
);
|
|
59
|
-
let issues: IssueInfo[];
|
|
60
|
-
try {
|
|
61
|
-
issues = JSON.parse(issuesJson || "[]");
|
|
62
|
-
} catch {
|
|
63
|
-
issues = [];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (issues.length === 0) {
|
|
67
|
-
return {
|
|
68
|
-
content: [{ type: "text" as const, text: "All issues implemented." }],
|
|
69
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Track all known issues in progress widget
|
|
74
|
-
for (const issue of issues) {
|
|
75
|
-
if (!issueProgress.has(issue.number)) {
|
|
76
|
-
issueProgress.set(issue.number, { title: issue.title, status: "pending" });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Find ready issues (deps satisfied)
|
|
81
|
-
const ready = getReadyIssues(issues, completed);
|
|
82
|
-
if (ready.length === 0) {
|
|
83
|
-
return {
|
|
84
|
-
content: [
|
|
85
|
-
{ type: "text" as const, text: `${issues.length} issues remain but all have unresolved dependencies.` },
|
|
86
|
-
],
|
|
87
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
88
|
-
isError: true,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// biome-ignore lint/style/noNonNullAssertion: ready is non-empty (checked above)
|
|
93
|
-
const issueNum = ready[0]!;
|
|
94
|
-
const issueTitle = issues.find((i) => i.number === issueNum)?.title ?? `#${issueNum}`;
|
|
95
|
-
|
|
96
|
-
// Update status + widget
|
|
97
|
-
issueProgress.set(issueNum, { title: issueTitle, status: "running" });
|
|
98
|
-
setForgeflowStatus(
|
|
99
|
-
ctx,
|
|
100
|
-
`implement-all · ${completed.size}/${completed.size + issues.length} · #${issueNum} ${issueTitle}`,
|
|
101
|
-
);
|
|
102
|
-
updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
|
|
103
|
-
|
|
104
|
-
// Run implement for this issue
|
|
105
|
-
allStages.push(emptyStage(`implement-${issueNum}`));
|
|
106
|
-
const implResult = await runImplement(cwd, String(issueNum), signal, onUpdate, ctx, {
|
|
107
|
-
...flags,
|
|
108
|
-
autonomous: true,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// Accumulate usage from detailed stages into the container stage
|
|
112
|
-
const implStage = allStages.find((s) => s.name === `implement-${issueNum}`);
|
|
113
|
-
if (implStage) {
|
|
114
|
-
implStage.status = implResult.isError ? "failed" : "done";
|
|
115
|
-
implStage.output = implResult.content[0]?.type === "text" ? implResult.content[0].text : "";
|
|
116
|
-
const detailedStages = (implResult as AnyCtx).details?.stages as StageResult[] | undefined;
|
|
117
|
-
if (detailedStages) implStage.usage = sumUsage(detailedStages);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (implResult.isError) {
|
|
121
|
-
issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
|
|
122
|
-
updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
|
|
123
|
-
return {
|
|
124
|
-
content: [
|
|
125
|
-
{
|
|
126
|
-
type: "text" as const,
|
|
127
|
-
text: `Failed on issue #${issueNum}: ${implResult.content[0]?.type === "text" ? implResult.content[0].text : "unknown error"}`,
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
131
|
-
isError: true,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Check for PR and merge
|
|
136
|
-
const branch = `feat/issue-${issueNum}`;
|
|
137
|
-
await exec("git checkout main && git pull --rebase", cwd);
|
|
138
|
-
const prNum = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
|
|
139
|
-
|
|
140
|
-
if (prNum && prNum !== "null") {
|
|
141
|
-
const mergeResult = await exec(`gh pr merge ${prNum} --squash --delete-branch`, cwd);
|
|
142
|
-
if (mergeResult.includes("Merged") || mergeResult === "") {
|
|
143
|
-
completed.add(issueNum);
|
|
144
|
-
} else {
|
|
145
|
-
const prState = await exec(`gh pr view ${prNum} --json state --jq '.state'`, cwd);
|
|
146
|
-
if (prState === "MERGED") {
|
|
147
|
-
completed.add(issueNum);
|
|
148
|
-
} else {
|
|
149
|
-
issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
|
|
150
|
-
updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
|
|
151
|
-
return {
|
|
152
|
-
content: [{ type: "text" as const, text: `Failed to merge PR #${prNum} for issue #${issueNum}.` }],
|
|
153
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
154
|
-
isError: true,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
} else {
|
|
159
|
-
issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
|
|
160
|
-
updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
|
|
161
|
-
return {
|
|
162
|
-
content: [{ type: "text" as const, text: `No PR found for issue #${issueNum} after implementation.` }],
|
|
163
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
164
|
-
isError: true,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Mark done and update widget
|
|
169
|
-
issueProgress.set(issueNum, { title: issueTitle, status: "done" });
|
|
170
|
-
setForgeflowStatus(
|
|
171
|
-
ctx,
|
|
172
|
-
`implement-all · ${completed.size}/${completed.size + issues.length - 1} · $${sumUsage(allStages).cost.toFixed(2)}`,
|
|
173
|
-
);
|
|
174
|
-
updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
content: [{ type: "text" as const, text: `Reached max iterations (${maxIterations}).` }],
|
|
179
|
-
details: { pipeline: "implement-all", stages: allStages },
|
|
180
|
-
};
|
|
181
|
-
}
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type AnyCtx,
|
|
3
|
-
cleanSignal,
|
|
4
|
-
emptyStage,
|
|
5
|
-
readSignal,
|
|
6
|
-
runAgent,
|
|
7
|
-
type StageResult,
|
|
8
|
-
signalExists,
|
|
9
|
-
TOOLS_ALL,
|
|
10
|
-
TOOLS_READONLY,
|
|
11
|
-
} from "@callumvass/forgeflow-shared";
|
|
12
|
-
import { AGENTS_DIR } from "../resolve.js";
|
|
13
|
-
import { exec } from "../utils/exec.js";
|
|
14
|
-
import { ensureBranch, resolveIssue } from "../utils/git.js";
|
|
15
|
-
import { setForgeflowStatus } from "../utils/ui.js";
|
|
16
|
-
import { runReviewInline } from "./review.js";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Run review and fix any findings via implementor.
|
|
20
|
-
*/
|
|
21
|
-
async function reviewAndFix(
|
|
22
|
-
cwd: string,
|
|
23
|
-
signal: AbortSignal,
|
|
24
|
-
onUpdate: AnyCtx,
|
|
25
|
-
ctx: AnyCtx,
|
|
26
|
-
stages: StageResult[],
|
|
27
|
-
pipeline = "implement",
|
|
28
|
-
): Promise<void> {
|
|
29
|
-
const reviewResult = await runReviewInline(cwd, signal, onUpdate, ctx, stages);
|
|
30
|
-
if (reviewResult.isError) {
|
|
31
|
-
const findings = reviewResult.content[0]?.type === "text" ? reviewResult.content[0].text : "";
|
|
32
|
-
stages.push(emptyStage("fix-findings"));
|
|
33
|
-
await runAgent(
|
|
34
|
-
"implementor",
|
|
35
|
-
`Fix the following code review findings:\n\n${findings}\n\nRULES:\n- Fix only the cited issues. Do not refactor or improve unrelated code.\n- Run the check command after fixes.\n- Commit and push the fixes.`,
|
|
36
|
-
{ agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline, onUpdate, tools: TOOLS_ALL, stageName: "fix-findings" },
|
|
37
|
-
);
|
|
38
|
-
cleanSignal(cwd, "findings");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Run refactorer then review+fix. Shared by fresh implementation and resume-from-branch paths.
|
|
44
|
-
*/
|
|
45
|
-
async function refactorAndReview(
|
|
46
|
-
cwd: string,
|
|
47
|
-
signal: AbortSignal,
|
|
48
|
-
onUpdate: AnyCtx,
|
|
49
|
-
ctx: AnyCtx,
|
|
50
|
-
stages: StageResult[],
|
|
51
|
-
skipReview: boolean,
|
|
52
|
-
pipeline = "implement",
|
|
53
|
-
): Promise<void> {
|
|
54
|
-
if (!stages.some((s) => s.name === "refactorer")) stages.push(emptyStage("refactorer"));
|
|
55
|
-
await runAgent(
|
|
56
|
-
"refactorer",
|
|
57
|
-
"Review code added in this branch (git diff main...HEAD). Refactor if clear wins exist. Run checks after changes. Commit and push if changed.",
|
|
58
|
-
{ agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline, onUpdate, tools: TOOLS_ALL },
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
if (!skipReview) {
|
|
62
|
-
await reviewAndFix(cwd, signal, onUpdate, ctx, stages, pipeline);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Parse unresolved questions from the plan and prompt the user for answers.
|
|
68
|
-
* Returns the plan with answers injected inline.
|
|
69
|
-
*/
|
|
70
|
-
async function resolveQuestions(plan: string, ctx: AnyCtx): Promise<string> {
|
|
71
|
-
const sectionMatch = plan.match(/### Unresolved Questions\n([\s\S]*?)(?=\n###|$)/);
|
|
72
|
-
if (!sectionMatch) return plan;
|
|
73
|
-
|
|
74
|
-
const section = sectionMatch[1] ?? "";
|
|
75
|
-
const questions: string[] = [];
|
|
76
|
-
for (const m of section.matchAll(/^- (.+)$/gm)) {
|
|
77
|
-
if (m[1]) questions.push(m[1]);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (questions.length === 0) return plan;
|
|
81
|
-
|
|
82
|
-
let updatedSection = section;
|
|
83
|
-
for (const q of questions) {
|
|
84
|
-
const answer = await ctx.ui.input(`${q}`, "Skip to use defaults");
|
|
85
|
-
if (answer != null && answer.trim() !== "") {
|
|
86
|
-
updatedSection = updatedSection.replace(`- ${q}`, `- ${q}\n **Answer:** ${answer.trim()}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return plan.replace(`### Unresolved Questions\n${section}`, `### Unresolved Questions\n${updatedSection}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function runImplement(
|
|
94
|
-
cwd: string,
|
|
95
|
-
issueArg: string,
|
|
96
|
-
signal: AbortSignal,
|
|
97
|
-
onUpdate: AnyCtx,
|
|
98
|
-
ctx: AnyCtx,
|
|
99
|
-
flags: { skipPlan: boolean; skipReview: boolean; autonomous?: boolean; customPrompt?: string } = {
|
|
100
|
-
skipPlan: false,
|
|
101
|
-
skipReview: false,
|
|
102
|
-
},
|
|
103
|
-
) {
|
|
104
|
-
const interactive = ctx.hasUI && !flags.autonomous;
|
|
105
|
-
const resolved = await resolveIssue(cwd, issueArg || undefined);
|
|
106
|
-
if (typeof resolved === "string") {
|
|
107
|
-
return { content: [{ type: "text" as const, text: resolved }], details: { pipeline: "implement", stages: [] } };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const isGitHub = resolved.source === "github" && resolved.number > 0;
|
|
111
|
-
const issueLabel = isGitHub ? `#${resolved.number}: ${resolved.title}` : `${resolved.key}: ${resolved.title}`;
|
|
112
|
-
|
|
113
|
-
// Status line for standalone /implement (implement-all manages its own)
|
|
114
|
-
if (!flags.autonomous && (resolved.number || resolved.key)) {
|
|
115
|
-
const tag = isGitHub ? `#${resolved.number}` : resolved.key;
|
|
116
|
-
setForgeflowStatus(ctx, `${tag} ${resolved.title} · ${resolved.branch}`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const issueContext = isGitHub
|
|
120
|
-
? `Issue #${resolved.number}: ${resolved.title}\n\n${resolved.body}`
|
|
121
|
-
: `Jira ${resolved.key}: ${resolved.title}\n\n${resolved.body}`;
|
|
122
|
-
|
|
123
|
-
const customPromptSection = flags.customPrompt ? `\n\nADDITIONAL INSTRUCTIONS FROM USER:\n${flags.customPrompt}` : "";
|
|
124
|
-
|
|
125
|
-
// --- Resumability: skip to review if work already exists ---
|
|
126
|
-
if (resolved.existingPR) {
|
|
127
|
-
const stages: StageResult[] = [];
|
|
128
|
-
if (!flags.skipReview) {
|
|
129
|
-
await reviewAndFix(cwd, signal, onUpdate, ctx, stages);
|
|
130
|
-
}
|
|
131
|
-
return {
|
|
132
|
-
content: [{ type: "text" as const, text: `Resumed ${issueLabel} — PR #${resolved.existingPR} already exists.` }],
|
|
133
|
-
details: { pipeline: "implement", stages },
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (resolved.branch) {
|
|
138
|
-
const branchExists = await exec(
|
|
139
|
-
`git rev-parse --verify ${resolved.branch} 2>/dev/null && echo yes || echo no`,
|
|
140
|
-
cwd,
|
|
141
|
-
);
|
|
142
|
-
if (branchExists === "yes") {
|
|
143
|
-
await ensureBranch(cwd, resolved.branch);
|
|
144
|
-
const ahead = await exec(`git rev-list main..${resolved.branch} --count`, cwd);
|
|
145
|
-
if (parseInt(ahead, 10) > 0) {
|
|
146
|
-
await exec(`git push -u origin ${resolved.branch}`, cwd);
|
|
147
|
-
const prBody = isGitHub ? `Closes #${resolved.number}` : `Jira: ${resolved.key}`;
|
|
148
|
-
await exec(`gh pr create --title "${resolved.title}" --body "${prBody}" --head ${resolved.branch}`, cwd);
|
|
149
|
-
|
|
150
|
-
const stages: StageResult[] = [];
|
|
151
|
-
await refactorAndReview(cwd, signal, onUpdate, ctx, stages, flags.skipReview);
|
|
152
|
-
return {
|
|
153
|
-
content: [{ type: "text" as const, text: `Resumed ${issueLabel} — pushed existing commits and created PR.` }],
|
|
154
|
-
details: { pipeline: "implement", stages },
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// --- Fresh implementation ---
|
|
161
|
-
const stageList: StageResult[] = [];
|
|
162
|
-
if (!flags.skipPlan) stageList.push(emptyStage("planner"));
|
|
163
|
-
stageList.push(emptyStage("implementor"));
|
|
164
|
-
stageList.push(emptyStage("refactorer"));
|
|
165
|
-
const stages = stageList;
|
|
166
|
-
const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "implement", onUpdate };
|
|
167
|
-
|
|
168
|
-
let plan = "";
|
|
169
|
-
|
|
170
|
-
if (!flags.skipPlan) {
|
|
171
|
-
const planResult = await runAgent(
|
|
172
|
-
"planner",
|
|
173
|
-
`Plan the implementation for this issue by producing a sequenced list of test cases.\n\n${issueContext}${customPromptSection}`,
|
|
174
|
-
{ ...opts, tools: TOOLS_READONLY },
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
if (planResult.status === "failed") {
|
|
178
|
-
return {
|
|
179
|
-
content: [{ type: "text" as const, text: `Planner failed: ${planResult.output}` }],
|
|
180
|
-
details: { pipeline: "implement", stages },
|
|
181
|
-
isError: true,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
plan = planResult.output;
|
|
185
|
-
|
|
186
|
-
// Interactive mode: let user review/edit the plan before proceeding
|
|
187
|
-
if (interactive && plan) {
|
|
188
|
-
const edited = await ctx.ui.editor(`Review implementation plan for ${issueLabel}`, plan);
|
|
189
|
-
if (edited != null && edited !== plan) {
|
|
190
|
-
plan = edited;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Surface unresolved questions one-by-one for user answers
|
|
194
|
-
plan = await resolveQuestions(plan, ctx);
|
|
195
|
-
|
|
196
|
-
const action = await ctx.ui.select("Plan ready. What next?", ["Approve and implement", "Cancel"]);
|
|
197
|
-
if (action === "Cancel" || action == null) {
|
|
198
|
-
return {
|
|
199
|
-
content: [{ type: "text" as const, text: "Implementation cancelled." }],
|
|
200
|
-
details: { pipeline: "implement", stages },
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Create/checkout feature branch if on main
|
|
207
|
-
if (resolved.branch) {
|
|
208
|
-
const currentBranch = await exec("git branch --show-current", cwd);
|
|
209
|
-
if (currentBranch === "main" || currentBranch === "master") {
|
|
210
|
-
const dirty = await exec("git status --porcelain", cwd);
|
|
211
|
-
if (dirty) {
|
|
212
|
-
return {
|
|
213
|
-
content: [
|
|
214
|
-
{
|
|
215
|
-
type: "text" as const,
|
|
216
|
-
text: `Cannot switch to ${resolved.branch} — working tree is dirty. Please commit or stash your changes first.`,
|
|
217
|
-
},
|
|
218
|
-
],
|
|
219
|
-
details: { pipeline: "implement", stages: [] },
|
|
220
|
-
isError: true,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
await ensureBranch(cwd, resolved.branch);
|
|
224
|
-
const afterBranch = await exec("git branch --show-current", cwd);
|
|
225
|
-
if (afterBranch !== resolved.branch) {
|
|
226
|
-
return {
|
|
227
|
-
content: [
|
|
228
|
-
{
|
|
229
|
-
type: "text" as const,
|
|
230
|
-
text: `Failed to switch to ${resolved.branch} (still on ${afterBranch}). Check git state and retry.`,
|
|
231
|
-
},
|
|
232
|
-
],
|
|
233
|
-
details: { pipeline: "implement", stages: [] },
|
|
234
|
-
isError: true,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Clean up stale blockers
|
|
241
|
-
cleanSignal(cwd, "blocked");
|
|
242
|
-
|
|
243
|
-
// Implementor
|
|
244
|
-
const planSection = plan ? `\n\nIMPLEMENTATION PLAN:\n${plan}` : "";
|
|
245
|
-
const branchNote = resolved.branch
|
|
246
|
-
? `\n- You should be on branch: ${resolved.branch} — do NOT create or switch branches.`
|
|
247
|
-
: "\n- Do NOT create or switch branches.";
|
|
248
|
-
const prNote = resolved.existingPR ? `\n- PR #${resolved.existingPR} already exists for this branch.` : "";
|
|
249
|
-
const closeNote = isGitHub
|
|
250
|
-
? `\n- The PR body MUST include 'Closes #${resolved.number}' so the issue auto-closes on merge.`
|
|
251
|
-
: `\n- The PR body should reference Jira issue ${resolved.key}.`;
|
|
252
|
-
const unresolvedNote = flags.autonomous
|
|
253
|
-
? `\n- If the plan has unresolved questions, resolve them yourself using sensible defaults. Do NOT stop and wait.`
|
|
254
|
-
: "";
|
|
255
|
-
|
|
256
|
-
await runAgent(
|
|
257
|
-
"implementor",
|
|
258
|
-
`Implement the following issue using strict TDD (red-green-refactor).\n\n${issueContext}${planSection}${customPromptSection}\n\nWORKFLOW:\n1. Read the codebase.\n2. TDD${plan ? " following the plan" : ""}.\n3. Refactor after all tests pass.\n4. Run check command, fix failures.\n5. Commit, push, and create a PR.\n\nCONSTRAINTS:${branchNote}${prNote}${closeNote}${unresolvedNote}\n- If blocked, write BLOCKED.md with the reason and stop.`,
|
|
259
|
-
{ ...opts, tools: TOOLS_ALL },
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
// Check for blocker
|
|
263
|
-
if (signalExists(cwd, "blocked")) {
|
|
264
|
-
const reason = readSignal(cwd, "blocked") ?? "";
|
|
265
|
-
return {
|
|
266
|
-
content: [{ type: "text" as const, text: `Implementor blocked:\n${reason}` }],
|
|
267
|
-
details: { pipeline: "implement", stages },
|
|
268
|
-
isError: true,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Refactor + review
|
|
273
|
-
await refactorAndReview(cwd, signal, onUpdate, ctx, stages, flags.skipReview);
|
|
274
|
-
|
|
275
|
-
// Ensure PR exists — agent may have skipped or failed `gh pr create`
|
|
276
|
-
let prNumber = "";
|
|
277
|
-
if (resolved.branch) {
|
|
278
|
-
await exec(`git push -u origin ${resolved.branch}`, cwd);
|
|
279
|
-
prNumber = await exec(`gh pr list --head "${resolved.branch}" --json number --jq '.[0].number'`, cwd);
|
|
280
|
-
if (!prNumber || prNumber === "null") {
|
|
281
|
-
const prBody = isGitHub ? `Closes #${resolved.number}` : `Jira: ${resolved.key}`;
|
|
282
|
-
await exec(`gh pr create --title "${resolved.title}" --body "${prBody}" --head ${resolved.branch}`, cwd);
|
|
283
|
-
prNumber = await exec(`gh pr list --head "${resolved.branch}" --json number --jq '.[0].number'`, cwd);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Squash-merge, delete branch, update local main (skip when called from implement-all)
|
|
288
|
-
if (!flags.autonomous && prNumber && prNumber !== "null") {
|
|
289
|
-
const mergeStage = emptyStage("merge");
|
|
290
|
-
stages.push(mergeStage);
|
|
291
|
-
await exec(`gh pr merge ${prNumber} --squash --delete-branch`, cwd);
|
|
292
|
-
await exec("git checkout main && git pull", cwd);
|
|
293
|
-
mergeStage.status = "done";
|
|
294
|
-
mergeStage.output = `Merged PR #${prNumber}`;
|
|
295
|
-
onUpdate?.({
|
|
296
|
-
content: [{ type: "text", text: "Pipeline complete" }],
|
|
297
|
-
details: { pipeline: "implement", stages },
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
content: [{ type: "text" as const, text: `Implementation of ${issueLabel} complete.` }],
|
|
303
|
-
details: { pipeline: "implement", stages },
|
|
304
|
-
};
|
|
305
|
-
}
|
package/src/pipelines/review.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type AnyCtx,
|
|
3
|
-
cleanSignal,
|
|
4
|
-
emptyStage,
|
|
5
|
-
readSignal,
|
|
6
|
-
runAgent,
|
|
7
|
-
type StageResult,
|
|
8
|
-
signalExists,
|
|
9
|
-
TOOLS_NO_EDIT,
|
|
10
|
-
TOOLS_READONLY,
|
|
11
|
-
} from "@callumvass/forgeflow-shared";
|
|
12
|
-
import { AGENTS_DIR } from "../resolve.js";
|
|
13
|
-
import { exec } from "../utils/exec.js";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Shared review logic — used by both standalone /review and chained from /implement.
|
|
17
|
-
* Appends code-reviewer + review-judge stages to the provided stages array.
|
|
18
|
-
*/
|
|
19
|
-
export async function runReviewInline(
|
|
20
|
-
cwd: string,
|
|
21
|
-
signal: AbortSignal,
|
|
22
|
-
onUpdate: AnyCtx,
|
|
23
|
-
ctx: AnyCtx,
|
|
24
|
-
stages: StageResult[],
|
|
25
|
-
diffCmd = "git diff main...HEAD",
|
|
26
|
-
pipeline = "review",
|
|
27
|
-
options: { prNumber?: string; interactive?: boolean; customPrompt?: string } = {},
|
|
28
|
-
): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }> {
|
|
29
|
-
const diff = await exec(diffCmd, cwd);
|
|
30
|
-
|
|
31
|
-
if (!diff) {
|
|
32
|
-
return { content: [{ type: "text", text: "No changes to review." }] };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline, onUpdate };
|
|
36
|
-
const extraInstructions = options.customPrompt
|
|
37
|
-
? `\n\nADDITIONAL INSTRUCTIONS FROM USER:\n${options.customPrompt}`
|
|
38
|
-
: "";
|
|
39
|
-
|
|
40
|
-
// Clean up stale findings
|
|
41
|
-
cleanSignal(cwd, "findings");
|
|
42
|
-
|
|
43
|
-
// Code reviewer
|
|
44
|
-
stages.push(emptyStage("code-reviewer"));
|
|
45
|
-
await runAgent("code-reviewer", `Review the following diff:\n\n${diff}${extraInstructions}`, {
|
|
46
|
-
...opts,
|
|
47
|
-
tools: TOOLS_NO_EDIT,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
if (!signalExists(cwd, "findings")) {
|
|
51
|
-
return { content: [{ type: "text", text: "Review passed — no actionable findings." }] };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Review judge
|
|
55
|
-
stages.push(emptyStage("review-judge"));
|
|
56
|
-
const findings = readSignal(cwd, "findings") ?? "";
|
|
57
|
-
await runAgent(
|
|
58
|
-
"review-judge",
|
|
59
|
-
`Validate the following code review findings against the actual code:\n\n${findings}`,
|
|
60
|
-
{ ...opts, tools: TOOLS_NO_EDIT },
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
if (!signalExists(cwd, "findings")) {
|
|
64
|
-
return { content: [{ type: "text", text: "Review passed — judge filtered all findings." }] };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const validatedFindings = readSignal(cwd, "findings") ?? "";
|
|
68
|
-
|
|
69
|
-
// Interactive mode with PR: show findings and proposed gh commands for approval
|
|
70
|
-
if (options.interactive && options.prNumber) {
|
|
71
|
-
const repo = await exec("gh repo view --json nameWithOwner --jq .nameWithOwner", cwd);
|
|
72
|
-
const prNum = options.prNumber;
|
|
73
|
-
|
|
74
|
-
const proposalPrompt = `You have validated code review findings for PR #${prNum} in ${repo}.
|
|
75
|
-
|
|
76
|
-
FINDINGS:
|
|
77
|
-
${validatedFindings}
|
|
78
|
-
|
|
79
|
-
Generate ready-to-run \`gh api\` commands to post each finding as a PR review comment. One command per finding.
|
|
80
|
-
|
|
81
|
-
Format each as:
|
|
82
|
-
|
|
83
|
-
**Finding N** — path/to/file.ts:LINE
|
|
84
|
-
|
|
85
|
-
\`\`\`bash
|
|
86
|
-
gh api repos/${repo}/pulls/${prNum}/comments \\
|
|
87
|
-
--method POST \\
|
|
88
|
-
--field body="<comment>" \\
|
|
89
|
-
--field commit_id="$(gh pr view ${prNum} --repo ${repo} --json headRefOid -q .headRefOid)" \\
|
|
90
|
-
--field path="path/to/file.ts" \\
|
|
91
|
-
--field line=LINE \\
|
|
92
|
-
--field side="RIGHT"
|
|
93
|
-
\`\`\`
|
|
94
|
-
|
|
95
|
-
Comment tone rules:
|
|
96
|
-
- Write like a teammate, not an auditor. Casual, brief, direct.
|
|
97
|
-
- 1-2 short sentences max. Lead with the suggestion, not the problem.
|
|
98
|
-
- Use "might be worth..." / "could we..." / "what about..." / "small thing:"
|
|
99
|
-
- No em dashes, no "Consider...", no "Note that...", no hedging filler.
|
|
100
|
-
- Use GitHub \`\`\`suggestion\`\`\` blocks when proposing code changes.
|
|
101
|
-
- Only generate commands for findings with a specific file + line.
|
|
102
|
-
|
|
103
|
-
After the comments, add the review decision command:
|
|
104
|
-
|
|
105
|
-
\`\`\`bash
|
|
106
|
-
gh pr review ${prNum} --request-changes --body "Left a few comments" --repo ${repo}
|
|
107
|
-
\`\`\`
|
|
108
|
-
|
|
109
|
-
Output ONLY the commands, no other text.`;
|
|
110
|
-
|
|
111
|
-
stages.push(emptyStage("propose-comments"));
|
|
112
|
-
await runAgent("review-judge", proposalPrompt, {
|
|
113
|
-
agentsDir: AGENTS_DIR,
|
|
114
|
-
cwd,
|
|
115
|
-
signal,
|
|
116
|
-
stages,
|
|
117
|
-
pipeline,
|
|
118
|
-
onUpdate,
|
|
119
|
-
tools: TOOLS_READONLY,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const commentStage = stages.find((s) => s.name === "propose-comments");
|
|
123
|
-
const proposedCommands = commentStage?.output || "";
|
|
124
|
-
|
|
125
|
-
if (proposedCommands && ctx.hasUI) {
|
|
126
|
-
const reviewed = await ctx.ui.editor(
|
|
127
|
-
`Review PR comments for PR #${prNum} (edit or close to skip)`,
|
|
128
|
-
`${validatedFindings}\n\n---\n\nProposed commands (run these to post):\n\n${proposedCommands}`,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
if (reviewed != null) {
|
|
132
|
-
const action = await ctx.ui.select("Post these review comments?", ["Post comments", "Skip"]);
|
|
133
|
-
if (action === "Post comments") {
|
|
134
|
-
const commands = reviewed.match(/```bash\n([\s\S]*?)```/g) || [];
|
|
135
|
-
for (const block of commands) {
|
|
136
|
-
const cmd = block
|
|
137
|
-
.replace(/```bash\n/, "")
|
|
138
|
-
.replace(/```$/, "")
|
|
139
|
-
.trim();
|
|
140
|
-
if (cmd.startsWith("gh ")) {
|
|
141
|
-
await exec(cmd, cwd);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return { content: [{ type: "text", text: validatedFindings }], isError: true };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export async function runReview(
|
|
153
|
-
cwd: string,
|
|
154
|
-
target: string,
|
|
155
|
-
signal: AbortSignal,
|
|
156
|
-
onUpdate: AnyCtx,
|
|
157
|
-
ctx: AnyCtx,
|
|
158
|
-
customPrompt?: string,
|
|
159
|
-
) {
|
|
160
|
-
const stages: StageResult[] = [];
|
|
161
|
-
|
|
162
|
-
let diffCmd = "git diff main...HEAD";
|
|
163
|
-
let prNumber: string | undefined;
|
|
164
|
-
|
|
165
|
-
if (target.match(/^\d+$/)) {
|
|
166
|
-
diffCmd = `gh pr diff ${target}`;
|
|
167
|
-
prNumber = target;
|
|
168
|
-
} else if (target.startsWith("--branch")) {
|
|
169
|
-
const branch = target.replace("--branch", "").trim() || "HEAD";
|
|
170
|
-
diffCmd = `git diff main...${branch}`;
|
|
171
|
-
} else {
|
|
172
|
-
// Try to detect PR from current branch
|
|
173
|
-
const pr = await exec("gh pr view --json number --jq .number 2>/dev/null", cwd);
|
|
174
|
-
if (pr && pr !== "") prNumber = pr;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const result = await runReviewInline(cwd, signal, onUpdate, ctx, stages, diffCmd, "review", {
|
|
178
|
-
prNumber,
|
|
179
|
-
interactive: ctx.hasUI,
|
|
180
|
-
customPrompt,
|
|
181
|
-
});
|
|
182
|
-
return { ...result, details: { pipeline: "review", stages } };
|
|
183
|
-
}
|
package/src/resolve.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import * as path from "node:path";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
|
|
4
|
-
// After bundling: extensions/index.js → up one level → agents/
|
|
5
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
export const AGENTS_DIR = path.resolve(__dirname, "..", "agents");
|
package/src/utils/exec.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
export function exec(cmd: string, cwd: string): Promise<string> {
|
|
4
|
-
return new Promise((resolve) => {
|
|
5
|
-
const proc = spawn("bash", ["-c", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
6
|
-
let out = "";
|
|
7
|
-
proc.stdout.on("data", (d: Buffer) => {
|
|
8
|
-
out += d.toString();
|
|
9
|
-
});
|
|
10
|
-
proc.on("close", () => resolve(out.trim()));
|
|
11
|
-
proc.on("error", () => resolve(""));
|
|
12
|
-
});
|
|
13
|
-
}
|