@callumvass/forgeflow-dev 0.1.0 → 0.3.2
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 +37 -0
- package/extensions/index.js +54 -13
- package/package.json +9 -3
- 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
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
|
-
}
|
package/src/utils/git.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { exec } from "./exec.js";
|
|
2
|
-
|
|
3
|
-
export interface ResolvedIssue {
|
|
4
|
-
source: "github" | "jira";
|
|
5
|
-
key: string; // "42" for GH, "CUS-123" for Jira
|
|
6
|
-
number: number; // GH issue number, 0 for Jira
|
|
7
|
-
title: string;
|
|
8
|
-
body: string;
|
|
9
|
-
branch: string;
|
|
10
|
-
existingPR?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function slugify(text: string, maxLen = 40): string {
|
|
14
|
-
return text
|
|
15
|
-
.toLowerCase()
|
|
16
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
-
.replace(/^-|-$/g, "")
|
|
18
|
-
.slice(0, maxLen)
|
|
19
|
-
.replace(/-$/, "");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const JIRA_KEY_RE = /^[A-Z]+-\d+$/;
|
|
23
|
-
const JIRA_BRANCH_RE = /feat\/([A-Z]+-\d+)/;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Checkout a branch, creating it if it doesn't exist.
|
|
27
|
-
*/
|
|
28
|
-
export async function ensureBranch(cwd: string, branch: string): Promise<void> {
|
|
29
|
-
const currentBranch = await exec("git branch --show-current", cwd);
|
|
30
|
-
if (currentBranch === branch) return;
|
|
31
|
-
const exists = await exec(`git rev-parse --verify ${branch} 2>/dev/null && echo yes || echo no`, cwd);
|
|
32
|
-
if (exists === "yes") {
|
|
33
|
-
await exec(`git checkout ${branch}`, cwd);
|
|
34
|
-
} else {
|
|
35
|
-
await exec(`git checkout -b ${branch}`, cwd);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Resolve which issue to implement:
|
|
41
|
-
* 1. Jira key (CUS-123) → fetch from jira-cli
|
|
42
|
-
* 2. Numeric GitHub issue → fetch from gh
|
|
43
|
-
* 3. On a feature branch → extract from branch name
|
|
44
|
-
*/
|
|
45
|
-
export async function resolveIssue(cwd: string, issueArg?: string): Promise<ResolvedIssue | string> {
|
|
46
|
-
// Explicit Jira key
|
|
47
|
-
if (issueArg && JIRA_KEY_RE.test(issueArg)) {
|
|
48
|
-
return resolveJiraIssue(cwd, issueArg);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Explicit GitHub issue number
|
|
52
|
-
if (issueArg && /^\d+$/.test(issueArg)) {
|
|
53
|
-
return resolveGitHubIssue(cwd, parseInt(issueArg, 10));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Free-text description (not a number or Jira key)
|
|
57
|
-
if (issueArg) {
|
|
58
|
-
return { source: "github", key: "", number: 0, title: issueArg, body: issueArg, branch: "" };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Detect from branch name
|
|
62
|
-
const branch = await exec("git branch --show-current", cwd);
|
|
63
|
-
|
|
64
|
-
const jiraMatch = branch.match(JIRA_BRANCH_RE);
|
|
65
|
-
if (jiraMatch) {
|
|
66
|
-
// biome-ignore lint/style/noNonNullAssertion: match[1] guaranteed by regex
|
|
67
|
-
return resolveJiraIssue(cwd, jiraMatch[1]!, branch);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const ghMatch = branch.match(/(?:feat\/)?issue-(\d+)/);
|
|
71
|
-
if (ghMatch) {
|
|
72
|
-
// biome-ignore lint/style/noNonNullAssertion: match[1] guaranteed by regex
|
|
73
|
-
return resolveGitHubIssue(cwd, parseInt(ghMatch[1]!, 10));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return `On branch "${branch}" — can't detect issue. Use /implement <issue#> or /implement <JIRA-KEY>.`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function resolveGitHubIssue(cwd: string, issueNum: number): Promise<ResolvedIssue | string> {
|
|
80
|
-
const issueJson = await exec(`gh issue view ${issueNum} --json number,title,body`, cwd);
|
|
81
|
-
if (!issueJson) return `Could not fetch issue #${issueNum}.`;
|
|
82
|
-
|
|
83
|
-
let issue: { number: number; title: string; body: string };
|
|
84
|
-
try {
|
|
85
|
-
issue = JSON.parse(issueJson);
|
|
86
|
-
} catch {
|
|
87
|
-
return `Could not parse issue #${issueNum}.`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const branch = `feat/issue-${issueNum}`;
|
|
91
|
-
const prJson = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
|
|
92
|
-
const existingPR = prJson && prJson !== "null" ? parseInt(prJson, 10) : undefined;
|
|
93
|
-
|
|
94
|
-
return { source: "github", key: String(issueNum), ...issue, branch, existingPR };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function resolveJiraIssue(
|
|
98
|
-
cwd: string,
|
|
99
|
-
jiraKey: string,
|
|
100
|
-
existingBranch?: string,
|
|
101
|
-
): Promise<ResolvedIssue | string> {
|
|
102
|
-
const raw = await exec(`jira issue view ${jiraKey} --raw`, cwd);
|
|
103
|
-
if (!raw) return `Could not fetch Jira issue ${jiraKey}.`;
|
|
104
|
-
|
|
105
|
-
// biome-ignore lint/suspicious/noExplicitAny: Jira JSON shape varies by instance
|
|
106
|
-
let data: any;
|
|
107
|
-
try {
|
|
108
|
-
data = JSON.parse(raw);
|
|
109
|
-
} catch {
|
|
110
|
-
return `Could not parse Jira issue ${jiraKey}.`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const fields = data.fields ?? {};
|
|
114
|
-
const title = fields.summary ?? jiraKey;
|
|
115
|
-
|
|
116
|
-
// Build body from available Jira fields
|
|
117
|
-
const bodyParts: string[] = [];
|
|
118
|
-
if (fields.description) bodyParts.push(fields.description);
|
|
119
|
-
if (fields.acceptance_criteria) bodyParts.push(`## Acceptance Criteria\n${fields.acceptance_criteria}`);
|
|
120
|
-
if (fields.status?.name) bodyParts.push(`**Status:** ${fields.status.name}`);
|
|
121
|
-
if (fields.priority?.name) bodyParts.push(`**Priority:** ${fields.priority.name}`);
|
|
122
|
-
if (fields.story_points != null) bodyParts.push(`**Story Points:** ${fields.story_points}`);
|
|
123
|
-
if (fields.sprint?.name) bodyParts.push(`**Sprint:** ${fields.sprint.name}`);
|
|
124
|
-
|
|
125
|
-
const body = bodyParts.join("\n\n");
|
|
126
|
-
const branch = existingBranch ?? `feat/${jiraKey}-${slugify(title)}`;
|
|
127
|
-
|
|
128
|
-
const prJson = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
|
|
129
|
-
const existingPR = prJson && prJson !== "null" ? parseInt(prJson, 10) : undefined;
|
|
130
|
-
|
|
131
|
-
return { source: "jira", key: jiraKey, number: 0, title, body, branch, existingPR };
|
|
132
|
-
}
|
package/src/utils/ui.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { AnyCtx } from "@callumvass/forgeflow-shared";
|
|
2
|
-
|
|
3
|
-
export function setForgeflowStatus(ctx: AnyCtx, text: string | undefined): void {
|
|
4
|
-
if (ctx.hasUI) ctx.ui.setStatus("forgeflow-dev", text);
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function setForgeflowWidget(ctx: AnyCtx, lines: string[] | undefined): void {
|
|
8
|
-
if (ctx.hasUI) ctx.ui.setWidget("forgeflow-dev", lines);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function updateProgressWidget(
|
|
12
|
-
ctx: AnyCtx,
|
|
13
|
-
progress: Map<number, { title: string; status: string }>,
|
|
14
|
-
totalCost: number,
|
|
15
|
-
): void {
|
|
16
|
-
let done = 0;
|
|
17
|
-
for (const [, info] of progress) {
|
|
18
|
-
if (info.status === "done") done++;
|
|
19
|
-
}
|
|
20
|
-
let header = `implement-all · ${done}/${progress.size}`;
|
|
21
|
-
if (totalCost > 0) header += ` · $${totalCost.toFixed(2)}`;
|
|
22
|
-
const lines: string[] = [header];
|
|
23
|
-
for (const [num, info] of progress) {
|
|
24
|
-
const icon = info.status === "done" ? "✓" : info.status === "running" ? "⟳" : info.status === "failed" ? "✗" : "○";
|
|
25
|
-
const title = info.title.length > 50 ? `${info.title.slice(0, 50)}...` : info.title;
|
|
26
|
-
lines.push(` ${icon} #${num} ${title}`);
|
|
27
|
-
}
|
|
28
|
-
setForgeflowWidget(ctx, lines);
|
|
29
|
-
}
|