@callumvass/forgeflow-dev 0.1.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/agents/architecture-reviewer.md +67 -0
- package/agents/code-reviewer.md +44 -0
- package/agents/implementor.md +98 -0
- package/agents/planner.md +88 -0
- package/agents/refactorer.md +39 -0
- package/agents/review-judge.md +44 -0
- package/agents/skill-discoverer.md +110 -0
- package/extensions/index.js +1279 -0
- package/package.json +42 -0
- package/skills/code-review/SKILL.md +119 -0
- package/skills/plugins/SKILL.md +58 -0
- package/skills/stitch/SKILL.md +46 -0
- package/skills/tdd/SKILL.md +115 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +86 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +98 -0
- package/src/index.ts +380 -0
- package/src/pipelines/architecture.ts +67 -0
- package/src/pipelines/discover-skills.ts +33 -0
- package/src/pipelines/implement-all.ts +181 -0
- package/src/pipelines/implement.ts +305 -0
- package/src/pipelines/review.ts +183 -0
- package/src/resolve.ts +6 -0
- package/src/utils/exec.ts +13 -0
- package/src/utils/git.ts +132 -0
- package/src/utils/ui.ts +29 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsup.config.ts +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { type AnyCtx, getFinalOutput, type PipelineDetails, type StageResult } from "@callumvass/forgeflow-shared";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { runArchitecture } from "./pipelines/architecture.js";
|
|
6
|
+
import { runDiscoverSkills } from "./pipelines/discover-skills.js";
|
|
7
|
+
import { runImplement } from "./pipelines/implement.js";
|
|
8
|
+
import { runImplementAll } from "./pipelines/implement-all.js";
|
|
9
|
+
import { runReview } from "./pipelines/review.js";
|
|
10
|
+
|
|
11
|
+
// ─── Display helpers ──────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, unknown> };
|
|
14
|
+
|
|
15
|
+
interface ForgeflowDevInput {
|
|
16
|
+
pipeline: string;
|
|
17
|
+
issue?: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
skipPlan?: boolean;
|
|
20
|
+
skipReview?: boolean;
|
|
21
|
+
customPrompt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseImplFlags(args: string) {
|
|
25
|
+
const skipPlan = args.includes("--skip-plan");
|
|
26
|
+
const skipReview = args.includes("--skip-review");
|
|
27
|
+
const rest = args
|
|
28
|
+
.replace(/--skip-plan/g, "")
|
|
29
|
+
.replace(/--skip-review/g, "")
|
|
30
|
+
.trim();
|
|
31
|
+
|
|
32
|
+
const firstSpace = rest.indexOf(" ");
|
|
33
|
+
const issue = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
|
|
34
|
+
const customPrompt =
|
|
35
|
+
firstSpace === -1
|
|
36
|
+
? ""
|
|
37
|
+
: rest
|
|
38
|
+
.slice(firstSpace + 1)
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/^"(.*)"$/, "$1");
|
|
41
|
+
|
|
42
|
+
const flags = [skipPlan ? ", skipPlan: true" : "", skipReview ? ", skipReview: true" : ""].join("");
|
|
43
|
+
return { issue, customPrompt, flags };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseReviewArgs(args: string) {
|
|
47
|
+
const trimmed = args.trim();
|
|
48
|
+
if (!trimmed) return { target: "", customPrompt: "" };
|
|
49
|
+
|
|
50
|
+
if (trimmed.startsWith("--branch")) {
|
|
51
|
+
const afterFlag = trimmed.replace(/^--branch\s*/, "").trim();
|
|
52
|
+
const firstSpace = afterFlag.indexOf(" ");
|
|
53
|
+
if (firstSpace === -1) return { target: `--branch ${afterFlag}`, customPrompt: "" };
|
|
54
|
+
return {
|
|
55
|
+
target: `--branch ${afterFlag.slice(0, firstSpace)}`,
|
|
56
|
+
customPrompt: afterFlag
|
|
57
|
+
.slice(firstSpace + 1)
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/^"(.*)"$/, "$1"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
64
|
+
if (firstSpace === -1) return { target: trimmed, customPrompt: "" };
|
|
65
|
+
return {
|
|
66
|
+
target: trimmed.slice(0, firstSpace),
|
|
67
|
+
customPrompt: trimmed
|
|
68
|
+
.slice(firstSpace + 1)
|
|
69
|
+
.trim()
|
|
70
|
+
.replace(/^"(.*)"$/, "$1"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getDisplayItems(messages: AnyCtx[]): DisplayItem[] {
|
|
75
|
+
const items: DisplayItem[] = [];
|
|
76
|
+
for (const msg of messages) {
|
|
77
|
+
if (msg.role === "assistant") {
|
|
78
|
+
for (const part of msg.content) {
|
|
79
|
+
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
80
|
+
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return items;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatToolCallShort(
|
|
88
|
+
name: string,
|
|
89
|
+
args: Record<string, unknown>,
|
|
90
|
+
fg: (c: string, t: string) => string,
|
|
91
|
+
): string {
|
|
92
|
+
switch (name) {
|
|
93
|
+
case "bash": {
|
|
94
|
+
const cmd = (args.command as string) || "...";
|
|
95
|
+
return fg("muted", "$ ") + fg("toolOutput", cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd);
|
|
96
|
+
}
|
|
97
|
+
case "read":
|
|
98
|
+
return fg("muted", "read ") + fg("accent", (args.file_path || args.path || "...") as string);
|
|
99
|
+
case "write":
|
|
100
|
+
return fg("muted", "write ") + fg("accent", (args.file_path || args.path || "...") as string);
|
|
101
|
+
case "edit":
|
|
102
|
+
return fg("muted", "edit ") + fg("accent", (args.file_path || args.path || "...") as string);
|
|
103
|
+
case "grep":
|
|
104
|
+
return fg("muted", "grep ") + fg("accent", `/${args.pattern || ""}/`);
|
|
105
|
+
case "find":
|
|
106
|
+
return fg("muted", "find ") + fg("accent", (args.pattern || "*") as string);
|
|
107
|
+
default:
|
|
108
|
+
return fg("accent", name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatUsage(usage: { input: number; output: number; cost: number; turns: number }, model?: string): string {
|
|
113
|
+
const parts: string[] = [];
|
|
114
|
+
if (usage.turns) parts.push(`${usage.turns}t`);
|
|
115
|
+
if (usage.input) parts.push(`↑${usage.input < 1000 ? usage.input : `${Math.round(usage.input / 1000)}k`}`);
|
|
116
|
+
if (usage.output) parts.push(`↓${usage.output < 1000 ? usage.output : `${Math.round(usage.output / 1000)}k`}`);
|
|
117
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
118
|
+
if (model) parts.push(model);
|
|
119
|
+
return parts.join(" ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Tool registration ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const ForgeflowDevParams = Type.Object({
|
|
125
|
+
pipeline: Type.String({
|
|
126
|
+
description: 'Which pipeline to run: "implement", "implement-all", "review", "architecture", or "discover-skills"',
|
|
127
|
+
}),
|
|
128
|
+
issue: Type.Optional(
|
|
129
|
+
Type.String({
|
|
130
|
+
description: "Issue number or description for implement pipeline",
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
target: Type.Optional(Type.String({ description: "PR number or --branch for review pipeline" })),
|
|
134
|
+
skipPlan: Type.Optional(Type.Boolean({ description: "Skip planner, implement directly (default false)" })),
|
|
135
|
+
skipReview: Type.Optional(Type.Boolean({ description: "Skip code review after implementation (default false)" })),
|
|
136
|
+
customPrompt: Type.Optional(
|
|
137
|
+
Type.String({ description: "Additional user instructions passed to agents (e.g. 'check the openapi spec')" }),
|
|
138
|
+
),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function registerForgeflowDevTool(pi: ExtensionAPI) {
|
|
142
|
+
pi.registerTool({
|
|
143
|
+
name: "forgeflow-dev",
|
|
144
|
+
label: "Forgeflow Dev",
|
|
145
|
+
description: [
|
|
146
|
+
"Run forgeflow dev pipelines: implement (plan→TDD→refactor a single issue),",
|
|
147
|
+
"implement-all (loop through all open issues autonomously), review (deterministic checks→code review→judge),",
|
|
148
|
+
"architecture (analyze codebase for structural friction→create RFC issues),",
|
|
149
|
+
"discover-skills (find and install domain-specific plugins).",
|
|
150
|
+
"Each pipeline spawns specialized sub-agents with isolated context.",
|
|
151
|
+
].join(" "),
|
|
152
|
+
parameters: ForgeflowDevParams as AnyCtx,
|
|
153
|
+
|
|
154
|
+
async execute(
|
|
155
|
+
_toolCallId: string,
|
|
156
|
+
_params: unknown,
|
|
157
|
+
signal: AbortSignal | undefined,
|
|
158
|
+
onUpdate: AnyCtx,
|
|
159
|
+
ctx: AnyCtx,
|
|
160
|
+
) {
|
|
161
|
+
const params = _params as ForgeflowDevInput;
|
|
162
|
+
const cwd = ctx.cwd as string;
|
|
163
|
+
const sig = signal ?? new AbortController().signal;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
switch (params.pipeline) {
|
|
167
|
+
case "implement":
|
|
168
|
+
return await runImplement(cwd, params.issue ?? "", sig, onUpdate, ctx, {
|
|
169
|
+
skipPlan: params.skipPlan ?? false,
|
|
170
|
+
skipReview: params.skipReview ?? false,
|
|
171
|
+
customPrompt: params.customPrompt,
|
|
172
|
+
});
|
|
173
|
+
case "implement-all":
|
|
174
|
+
return await runImplementAll(cwd, sig, onUpdate, ctx, {
|
|
175
|
+
skipPlan: params.skipPlan ?? false,
|
|
176
|
+
skipReview: params.skipReview ?? false,
|
|
177
|
+
});
|
|
178
|
+
case "review":
|
|
179
|
+
return await runReview(cwd, params.target ?? "", sig, onUpdate, ctx, params.customPrompt);
|
|
180
|
+
case "architecture":
|
|
181
|
+
return await runArchitecture(cwd, sig, onUpdate, ctx);
|
|
182
|
+
case "discover-skills":
|
|
183
|
+
return await runDiscoverSkills(cwd, params.issue ?? "", sig, onUpdate, ctx);
|
|
184
|
+
default:
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: `Unknown pipeline: ${params.pipeline}. Use: implement, implement-all, review, architecture, discover-skills`,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
details: { pipeline: params.pipeline, stages: [] } as PipelineDetails,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
if (ctx.hasUI) {
|
|
197
|
+
ctx.ui.setStatus("forgeflow-dev", undefined);
|
|
198
|
+
ctx.ui.setWidget("forgeflow-dev", undefined);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
renderCall(_args: unknown, theme: AnyCtx) {
|
|
204
|
+
const args = _args as ForgeflowDevInput;
|
|
205
|
+
const pipeline = args.pipeline || "?";
|
|
206
|
+
let text = theme.fg("toolTitle", theme.bold("forgeflow-dev ")) + theme.fg("accent", pipeline);
|
|
207
|
+
if (args.issue) {
|
|
208
|
+
const prefix = /^[A-Z]+-\d+$/.test(args.issue) ? " " : " #";
|
|
209
|
+
text += theme.fg("dim", `${prefix}${args.issue}`);
|
|
210
|
+
}
|
|
211
|
+
if (args.target) text += theme.fg("dim", ` ${args.target}`);
|
|
212
|
+
return new Text(text, 0, 0);
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
renderResult(result: AnyCtx, { expanded }: { expanded: boolean }, theme: AnyCtx) {
|
|
216
|
+
const details = result.details as PipelineDetails | undefined;
|
|
217
|
+
if (!details || details.stages.length === 0) {
|
|
218
|
+
const text = result.content[0];
|
|
219
|
+
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (expanded) {
|
|
223
|
+
return renderExpanded(details, theme);
|
|
224
|
+
}
|
|
225
|
+
return renderCollapsed(details, theme);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Rendering ────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function renderExpanded(details: PipelineDetails, theme: AnyCtx) {
|
|
233
|
+
const container = new Container();
|
|
234
|
+
container.addChild(
|
|
235
|
+
new Text(theme.fg("toolTitle", theme.bold("forgeflow-dev ")) + theme.fg("accent", details.pipeline), 0, 0),
|
|
236
|
+
);
|
|
237
|
+
container.addChild(new Spacer(1));
|
|
238
|
+
|
|
239
|
+
for (const stage of details.stages) {
|
|
240
|
+
const icon = stageIcon(stage, theme);
|
|
241
|
+
container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(stage.name))}`, 0, 0));
|
|
242
|
+
|
|
243
|
+
const items = getDisplayItems(stage.messages);
|
|
244
|
+
for (const item of items) {
|
|
245
|
+
if (item.type === "toolCall") {
|
|
246
|
+
container.addChild(
|
|
247
|
+
new Text(
|
|
248
|
+
` ${theme.fg("muted", "→ ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`,
|
|
249
|
+
0,
|
|
250
|
+
0,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const output = getFinalOutput(stage.messages);
|
|
257
|
+
if (output) {
|
|
258
|
+
container.addChild(new Spacer(1));
|
|
259
|
+
try {
|
|
260
|
+
const { getMarkdownTheme } = require("@mariozechner/pi-coding-agent");
|
|
261
|
+
container.addChild(new Markdown(output.trim(), 0, 0, getMarkdownTheme()));
|
|
262
|
+
} catch {
|
|
263
|
+
container.addChild(new Text(theme.fg("toolOutput", output.slice(0, 500)), 0, 0));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const usageStr = formatUsage(stage.usage, stage.model);
|
|
268
|
+
if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
269
|
+
container.addChild(new Spacer(1));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return container;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderCollapsed(details: PipelineDetails, theme: AnyCtx) {
|
|
276
|
+
let text = theme.fg("toolTitle", theme.bold("forgeflow-dev ")) + theme.fg("accent", details.pipeline);
|
|
277
|
+
for (const stage of details.stages) {
|
|
278
|
+
const icon = stageIcon(stage, theme);
|
|
279
|
+
text += `\n ${icon} ${theme.fg("toolTitle", stage.name)}`;
|
|
280
|
+
|
|
281
|
+
if (stage.status === "running") {
|
|
282
|
+
const items = getDisplayItems(stage.messages);
|
|
283
|
+
const last = items.filter((i) => i.type === "toolCall").slice(-3);
|
|
284
|
+
for (const item of last) {
|
|
285
|
+
if (item.type === "toolCall") {
|
|
286
|
+
text += `\n ${theme.fg("muted", "→ ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else if (stage.status === "done" || stage.status === "failed") {
|
|
290
|
+
const preview = stage.output.split("\n")[0]?.slice(0, 80) || "(no output)";
|
|
291
|
+
text += theme.fg("dim", ` ${preview}`);
|
|
292
|
+
const usageStr = formatUsage(stage.usage, stage.model);
|
|
293
|
+
if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return new Text(text, 0, 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function stageIcon(stage: StageResult, theme: AnyCtx): string {
|
|
300
|
+
return stage.status === "done"
|
|
301
|
+
? theme.fg("success", "✓")
|
|
302
|
+
: stage.status === "running"
|
|
303
|
+
? theme.fg("warning", "⟳")
|
|
304
|
+
: stage.status === "failed"
|
|
305
|
+
? theme.fg("error", "✗")
|
|
306
|
+
: theme.fg("muted", "○");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Extension entry point ────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
const extension: (pi: ExtensionAPI) => void = (pi) => {
|
|
312
|
+
registerForgeflowDevTool(pi);
|
|
313
|
+
|
|
314
|
+
pi.registerCommand("implement", {
|
|
315
|
+
description:
|
|
316
|
+
"Implement a single issue using TDD. Usage: /implement <issue#|JIRA-KEY> [custom prompt] [--skip-plan] [--skip-review]",
|
|
317
|
+
handler: async (args) => {
|
|
318
|
+
const { issue, customPrompt, flags } = parseImplFlags(args);
|
|
319
|
+
const promptPart = customPrompt ? `, customPrompt: "${customPrompt}"` : "";
|
|
320
|
+
|
|
321
|
+
if (issue) {
|
|
322
|
+
pi.sendUserMessage(
|
|
323
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="implement", issue="${issue}"${promptPart}${flags}. Do not interpret the issue number — pass it as-is.`,
|
|
324
|
+
);
|
|
325
|
+
} else {
|
|
326
|
+
pi.sendUserMessage(
|
|
327
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="implement"${promptPart}${flags}. No issue number provided — the tool will detect it from the current branch. Do NOT ask for an issue number.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
pi.registerCommand("implement-all", {
|
|
334
|
+
description:
|
|
335
|
+
"Loop through all open auto-generated issues: implement, review, merge. Flags: --skip-plan, --skip-review",
|
|
336
|
+
handler: async (args) => {
|
|
337
|
+
const { flags } = parseImplFlags(args);
|
|
338
|
+
|
|
339
|
+
pi.sendUserMessage(
|
|
340
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="implement-all"${flags}. Do NOT ask for confirmation — run autonomously.`,
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
pi.registerCommand("review", {
|
|
346
|
+
description: "Run code review: deterministic checks → reviewer → judge. Usage: /review [target] [custom prompt]",
|
|
347
|
+
handler: async (args) => {
|
|
348
|
+
const { target, customPrompt } = parseReviewArgs(args);
|
|
349
|
+
const promptPart = customPrompt ? `, customPrompt: "${customPrompt}"` : "";
|
|
350
|
+
pi.sendUserMessage(
|
|
351
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="review"${target ? `, target="${target}"` : ""}${promptPart}. Do not interpret the target — pass it as-is.`,
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
pi.registerCommand("architecture", {
|
|
357
|
+
description: "Analyze codebase for architectural friction and create RFC issues",
|
|
358
|
+
handler: async () => {
|
|
359
|
+
pi.sendUserMessage(`Call the forgeflow-dev tool now with these exact parameters: pipeline="architecture".`);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
pi.registerCommand("discover-skills", {
|
|
364
|
+
description: "Find and install domain-specific plugins from skills.sh for this project's tech stack",
|
|
365
|
+
handler: async (args) => {
|
|
366
|
+
const query = args.trim();
|
|
367
|
+
if (query) {
|
|
368
|
+
pi.sendUserMessage(
|
|
369
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="discover-skills", issue="${query}". Present the tool's output verbatim — do not summarize or reformat it.`,
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
pi.sendUserMessage(
|
|
373
|
+
`Call the forgeflow-dev tool now with these exact parameters: pipeline="discover-skills". Present the tool's output verbatim — do not summarize or reformat it.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export default extension;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type AnyCtx, emptyStage, runAgent, TOOLS_READONLY } from "@callumvass/forgeflow-shared";
|
|
2
|
+
import { AGENTS_DIR } from "../resolve.js";
|
|
3
|
+
|
|
4
|
+
export async function runArchitecture(cwd: string, signal: AbortSignal, onUpdate: AnyCtx, ctx: AnyCtx) {
|
|
5
|
+
const stages = [emptyStage("architecture-reviewer")];
|
|
6
|
+
const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "architecture", onUpdate };
|
|
7
|
+
|
|
8
|
+
// Phase 1: Explore codebase for friction
|
|
9
|
+
const exploreResult = await runAgent(
|
|
10
|
+
"architecture-reviewer",
|
|
11
|
+
"Explore this codebase and identify architectural friction. Present numbered candidates ranked by severity.",
|
|
12
|
+
{ ...opts, tools: TOOLS_READONLY },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (exploreResult.status === "failed") {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text" as const, text: `Exploration failed: ${exploreResult.output}` }],
|
|
18
|
+
details: { pipeline: "architecture", stages },
|
|
19
|
+
isError: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Non-interactive: return candidates
|
|
24
|
+
if (!ctx.hasUI) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text" as const, text: exploreResult.output }],
|
|
27
|
+
details: { pipeline: "architecture", stages },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Interactive gate: user reviews candidates, can edit/annotate
|
|
32
|
+
const edited = await ctx.ui.editor(
|
|
33
|
+
"Review architecture candidates (edit to highlight your pick)",
|
|
34
|
+
exploreResult.output,
|
|
35
|
+
);
|
|
36
|
+
const action = await ctx.ui.select("Create RFC issue for a candidate?", ["Yes — generate RFC", "Skip"]);
|
|
37
|
+
|
|
38
|
+
if (action === "Skip" || action == null) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text" as const, text: "Architecture review complete. No RFC created." }],
|
|
41
|
+
details: { pipeline: "architecture", stages },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Phase 2: Generate RFC and create GitHub issue
|
|
46
|
+
stages.push(emptyStage("architecture-rfc"));
|
|
47
|
+
const candidateContext = edited ?? exploreResult.output;
|
|
48
|
+
|
|
49
|
+
const rfcResult = await runAgent(
|
|
50
|
+
"architecture-reviewer",
|
|
51
|
+
`Based on the following architectural analysis, generate a detailed RFC and create a GitHub issue (with label "architecture") for the highest-priority candidate — or the one the user highlighted/edited.\n\nANALYSIS:\n${candidateContext}`,
|
|
52
|
+
{ ...opts, tools: TOOLS_READONLY },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Extract issue URL/number from agent output
|
|
56
|
+
const issueMatch = rfcResult.output?.match(/https:\/\/github\.com\/[^\s]+\/issues\/(\d+)/);
|
|
57
|
+
const issueNum = issueMatch?.[1];
|
|
58
|
+
const issueUrl = issueMatch?.[0];
|
|
59
|
+
const summary = issueUrl
|
|
60
|
+
? `Architecture RFC issue created: ${issueUrl}\n\nRun \`/implement ${issueNum}\` to implement it.`
|
|
61
|
+
: "Architecture RFC issue created.";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text" as const, text: summary }],
|
|
65
|
+
details: { pipeline: "architecture", stages },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AnyCtx, emptyStage, runAgent, TOOLS_ALL, TOOLS_NO_EDIT } from "@callumvass/forgeflow-shared";
|
|
2
|
+
import { AGENTS_DIR } from "../resolve.js";
|
|
3
|
+
|
|
4
|
+
export async function runDiscoverSkills(
|
|
5
|
+
cwd: string,
|
|
6
|
+
query: string,
|
|
7
|
+
signal: AbortSignal,
|
|
8
|
+
onUpdate: AnyCtx,
|
|
9
|
+
_ctx: AnyCtx,
|
|
10
|
+
) {
|
|
11
|
+
// If query looks like specific skill names (contains commas or known skill identifiers),
|
|
12
|
+
// treat as install mode. Otherwise, discover mode.
|
|
13
|
+
const isInstall = query.includes(",") || query.includes("/");
|
|
14
|
+
|
|
15
|
+
const stages = [emptyStage("skill-discoverer")];
|
|
16
|
+
const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "discover-skills", onUpdate };
|
|
17
|
+
|
|
18
|
+
const task = isInstall
|
|
19
|
+
? `Install these skills as forgeflow plugins: ${query}`
|
|
20
|
+
: query
|
|
21
|
+
? `Discover skills related to "${query}" — recommend only, do NOT install.`
|
|
22
|
+
: "Analyze the project tech stack and discover relevant skills — recommend only, do NOT install.";
|
|
23
|
+
|
|
24
|
+
// Install mode needs write access, discover mode is read-only
|
|
25
|
+
const tools = isInstall ? TOOLS_ALL : TOOLS_NO_EDIT;
|
|
26
|
+
|
|
27
|
+
const result = await runAgent("skill-discoverer", task, { ...opts, tools });
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text" as const, text: result.output || "No skills found." }],
|
|
31
|
+
details: { pipeline: "discover-skills", stages },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
}
|