@callumvass/forgeflow-dev 0.4.4 → 0.6.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.
@@ -103,6 +103,10 @@ PRBODY
103
103
  gh pr create --title "My title" --body-file /tmp/pr-body.md
104
104
  ```
105
105
 
106
+ ## Commit Style
107
+
108
+ Use [Conventional Commits](https://www.conventionalcommits.org/). Read `git log --oneline -10` before your first commit to match the repo's existing style. Common prefixes: `feat:`, `fix:`, `test:`, `refactor:`, `chore:`, `docs:`. Keep messages concise (under 72 chars).
109
+
106
110
  ## Before Committing
107
111
 
108
112
  - **Reachability check**: Every new module, class, or function you created must be imported and called from production code — not just from tests. Trace from the entry point to your new code.
@@ -37,3 +37,4 @@ You are a refactorer agent. You run after a feature has been implemented to find
37
37
  - **Keep it small**: Each refactoring should be a single, focused change.
38
38
  - **If nothing to do, say so**: "No refactoring needed" is a perfectly valid outcome.
39
39
  - **Preserve public interfaces**: Don't rename or restructure exports without updating all callers.
40
+ - **Commit style**: Use [Conventional Commits](https://www.conventionalcommits.org/). Read `git log --oneline -10` before committing to match the repo's style. Use `refactor:` prefix.
@@ -5,8 +5,40 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
5
5
  throw Error('Dynamic require of "' + x + '" is not supported');
6
6
  });
7
7
 
8
- // ../shared/dist/confluence.js
8
+ // ../shared/dist/exec.js
9
9
  import { spawn } from "child_process";
10
+ function exec(cmd, cwd) {
11
+ return new Promise((resolve2, reject) => {
12
+ const proc = spawn("bash", ["-c", cmd], {
13
+ cwd,
14
+ stdio: ["ignore", "pipe", "pipe"]
15
+ });
16
+ let stdout = "";
17
+ let stderr = "";
18
+ proc.stdout.on("data", (d) => {
19
+ stdout += d.toString();
20
+ });
21
+ proc.stderr.on("data", (d) => {
22
+ stderr += d.toString();
23
+ });
24
+ proc.on("close", (code) => {
25
+ if (code !== 0) {
26
+ reject(new Error(`Command failed (exit ${code}): ${cmd}
27
+ ${stderr.trim()}`));
28
+ } else {
29
+ resolve2(stdout.trim());
30
+ }
31
+ });
32
+ proc.on("error", (err) => reject(err));
33
+ });
34
+ }
35
+ async function execSafe(cmd, cwd) {
36
+ try {
37
+ return await exec(cmd, cwd);
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
10
42
 
11
43
  // ../shared/dist/constants.js
12
44
  var TOOLS_ALL = ["read", "write", "edit", "bash", "grep", "find"];
@@ -18,12 +50,8 @@ var SIGNALS = {
18
50
  blocked: "BLOCKED.md"
19
51
  };
20
52
 
21
- // ../shared/dist/run-agent.js
22
- import { spawn as spawn2 } from "child_process";
23
- import * as fs from "fs";
24
- import * as os from "os";
25
- import * as path from "path";
26
- import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
53
+ // ../shared/dist/rendering.js
54
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
27
55
 
28
56
  // ../shared/dist/types.js
29
57
  function emptyUsage() {
@@ -66,7 +94,131 @@ function sumUsage(stages) {
66
94
  return total;
67
95
  }
68
96
 
97
+ // ../shared/dist/rendering.js
98
+ function getDisplayItems(messages) {
99
+ const items = [];
100
+ for (const msg of messages) {
101
+ if (msg.role === "assistant") {
102
+ for (const part of msg.content) {
103
+ if (part.type === "text")
104
+ items.push({ type: "text", text: part.text });
105
+ else if (part.type === "toolCall")
106
+ items.push({ type: "toolCall", name: part.name, args: part.arguments });
107
+ }
108
+ }
109
+ }
110
+ return items;
111
+ }
112
+ function formatToolCallShort(name, args, fg) {
113
+ switch (name) {
114
+ case "bash": {
115
+ const cmd = args.command || "...";
116
+ return fg("muted", "$ ") + fg("toolOutput", cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd);
117
+ }
118
+ case "read":
119
+ case "write":
120
+ case "edit":
121
+ return fg("muted", `${name} `) + fg("accent", args.file_path || args.path || "...");
122
+ case "grep":
123
+ return fg("muted", "grep ") + fg("accent", `/${args.pattern || ""}/`);
124
+ case "find":
125
+ return fg("muted", "find ") + fg("accent", args.pattern || "*");
126
+ default:
127
+ return fg("accent", name);
128
+ }
129
+ }
130
+ function formatUsage(usage, model) {
131
+ const parts = [];
132
+ if (usage.turns)
133
+ parts.push(`${usage.turns}t`);
134
+ if (usage.input)
135
+ parts.push(`\u2191${usage.input < 1e3 ? usage.input : `${Math.round(usage.input / 1e3)}k`}`);
136
+ if (usage.output)
137
+ parts.push(`\u2193${usage.output < 1e3 ? usage.output : `${Math.round(usage.output / 1e3)}k`}`);
138
+ if (usage.cost)
139
+ parts.push(`$${usage.cost.toFixed(4)}`);
140
+ if (model)
141
+ parts.push(model);
142
+ return parts.join(" ");
143
+ }
144
+ function stageIcon(stage, theme) {
145
+ return stage.status === "done" ? theme.fg("success", "\u2713") : stage.status === "running" ? theme.fg("warning", "\u27F3") : stage.status === "failed" ? theme.fg("error", "\u2717") : theme.fg("muted", "\u25CB");
146
+ }
147
+ function renderExpanded(details, theme, toolLabel) {
148
+ const container = new Container();
149
+ container.addChild(new Text(theme.fg("toolTitle", theme.bold(`${toolLabel} `)) + theme.fg("accent", details.pipeline), 0, 0));
150
+ container.addChild(new Spacer(1));
151
+ for (const stage of details.stages) {
152
+ const icon = stageIcon(stage, theme);
153
+ container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(stage.name))}`, 0, 0));
154
+ const items = getDisplayItems(stage.messages);
155
+ for (const item of items) {
156
+ if (item.type === "toolCall") {
157
+ container.addChild(new Text(` ${theme.fg("muted", "\u2192 ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`, 0, 0));
158
+ }
159
+ }
160
+ const output = getFinalOutput(stage.messages);
161
+ if (output) {
162
+ container.addChild(new Spacer(1));
163
+ try {
164
+ const { getMarkdownTheme } = __require("@mariozechner/pi-coding-agent");
165
+ container.addChild(new Markdown(output.trim(), 0, 0, getMarkdownTheme()));
166
+ } catch {
167
+ container.addChild(new Text(theme.fg("toolOutput", output.slice(0, 500)), 0, 0));
168
+ }
169
+ }
170
+ const usageStr = formatUsage(stage.usage, stage.model);
171
+ if (usageStr)
172
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
173
+ container.addChild(new Spacer(1));
174
+ }
175
+ return container;
176
+ }
177
+ function renderResult(result2, expanded, theme, toolLabel) {
178
+ const details = result2.details;
179
+ if (!details || details.stages.length === 0) {
180
+ const text = result2.content[0];
181
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
182
+ }
183
+ return expanded ? renderExpanded(details, theme, toolLabel) : renderCollapsed(details, theme, toolLabel);
184
+ }
185
+ function renderCollapsed(details, theme, toolLabel) {
186
+ let text = theme.fg("toolTitle", theme.bold(`${toolLabel} `)) + theme.fg("accent", details.pipeline);
187
+ for (const stage of details.stages) {
188
+ const icon = stageIcon(stage, theme);
189
+ text += `
190
+ ${icon} ${theme.fg("toolTitle", stage.name)}`;
191
+ if (stage.status === "running") {
192
+ const items = getDisplayItems(stage.messages);
193
+ const last = items.filter((i) => i.type === "toolCall").slice(-3);
194
+ for (const item of last) {
195
+ if (item.type === "toolCall") {
196
+ text += `
197
+ ${theme.fg("muted", "\u2192 ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`;
198
+ }
199
+ }
200
+ } else if (stage.status === "done" || stage.status === "failed") {
201
+ const preview = stage.output.split("\n")[0]?.slice(0, 80) || "(no output)";
202
+ text += theme.fg("dim", ` ${preview}`);
203
+ const usageStr = formatUsage(stage.usage, stage.model);
204
+ if (usageStr)
205
+ text += ` ${theme.fg("dim", usageStr)}`;
206
+ }
207
+ }
208
+ return new Text(text, 0, 0);
209
+ }
210
+
69
211
  // ../shared/dist/run-agent.js
212
+ import { spawn as spawn2 } from "child_process";
213
+ import * as fs from "fs";
214
+ import * as os from "os";
215
+ import * as path from "path";
216
+ import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
217
+ async function resolveRunAgent(injected) {
218
+ if (injected)
219
+ return injected;
220
+ return runAgent;
221
+ }
70
222
  function getPiInvocation(args) {
71
223
  const currentScript = process.argv[1];
72
224
  if (currentScript && fs.existsSync(currentScript)) {
@@ -213,9 +365,8 @@ function getLastToolCall(messages) {
213
365
  for (let i = messages.length - 1; i >= 0; i--) {
214
366
  const msg = messages[i];
215
367
  if (msg.role === "assistant") {
216
- const parts = msg.content;
217
- for (let j = parts.length - 1; j >= 0; j--) {
218
- const part = parts[j];
368
+ for (let j = msg.content.length - 1; j >= 0; j--) {
369
+ const part = msg.content[j];
219
370
  if (part?.type === "toolCall") {
220
371
  const name = part.name;
221
372
  const args = part.arguments ?? {};
@@ -283,7 +434,7 @@ function cleanSignal(cwd, signal) {
283
434
  }
284
435
 
285
436
  // src/index.ts
286
- import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
437
+ import { Text as Text2 } from "@mariozechner/pi-tui";
287
438
  import { Type } from "@sinclair/typebox";
288
439
 
289
440
  // src/resolve.ts
@@ -293,6 +444,22 @@ var __dirname = path3.dirname(fileURLToPath(import.meta.url));
293
444
  var AGENTS_DIR = path3.resolve(__dirname, "..", "agents");
294
445
 
295
446
  // src/pipelines/architecture.ts
447
+ function parseCandidates(text) {
448
+ const pattern = /^(?:#{1,4}\s+)?(\d+)\.\s+(.+)$/gm;
449
+ const matches = [...text.matchAll(pattern)];
450
+ if (matches.length === 0) return [];
451
+ const results = [];
452
+ for (let i = 0; i < matches.length; i++) {
453
+ const match = matches[i];
454
+ const num = match[1];
455
+ const name = match[2].replace(/[*#]+/g, "").trim();
456
+ const start = match.index;
457
+ const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
458
+ const body = text.slice(start, end).trim();
459
+ results.push({ label: `${num}. ${name}`, body });
460
+ }
461
+ return results;
462
+ }
296
463
  async function runArchitecture(cwd, signal, onUpdate, ctx) {
297
464
  const stages = [emptyStage("architecture-reviewer")];
298
465
  const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "architecture", onUpdate };
@@ -318,29 +485,52 @@ async function runArchitecture(cwd, signal, onUpdate, ctx) {
318
485
  "Review architecture candidates (edit to highlight your pick)",
319
486
  exploreResult.output
320
487
  );
321
- const action = await ctx.ui.select("Create RFC issue for a candidate?", ["Yes \u2014 generate RFC", "Skip"]);
488
+ const candidateContext = edited ?? exploreResult.output;
489
+ const candidates = parseCandidates(candidateContext);
490
+ const selectOptions = candidates.length > 1 ? [...candidates.map((c) => c.label), "All candidates", "Skip"] : candidates.length === 1 ? [candidates[0].label, "Skip"] : ["Yes \u2014 generate RFC", "Skip"];
491
+ const action = await ctx.ui.select("Create RFC issues for which candidates?", selectOptions);
322
492
  if (action === "Skip" || action == null) {
323
493
  return {
324
494
  content: [{ type: "text", text: "Architecture review complete. No RFC created." }],
325
495
  details: { pipeline: "architecture", stages }
326
496
  };
327
497
  }
328
- stages.push(emptyStage("architecture-rfc"));
329
- const candidateContext = edited ?? exploreResult.output;
330
- const rfcResult = await runAgent(
331
- "architecture-reviewer",
332
- `Based on the following architectural analysis, generate a detailed RFC and create a GitHub issue (with label "architecture") for the highest-priority candidate \u2014 or the one the user highlighted/edited.
498
+ let selectedCandidates;
499
+ if (action === "All candidates") {
500
+ selectedCandidates = candidates;
501
+ } else if (action === "Yes \u2014 generate RFC") {
502
+ selectedCandidates = [{ label: "RFC", body: candidateContext }];
503
+ } else {
504
+ const match = candidates.find((c) => c.label === action);
505
+ selectedCandidates = match ? [match] : [{ label: "RFC", body: candidateContext }];
506
+ }
507
+ const createdIssues = [];
508
+ for (const candidate of selectedCandidates) {
509
+ const stageName = `architecture-rfc-${selectedCandidates.indexOf(candidate) + 1}`;
510
+ stages.push(emptyStage(stageName));
511
+ const rfcResult = await runAgent(
512
+ "architecture-reviewer",
513
+ `Based on the following architectural analysis, generate a detailed RFC and create a GitHub issue (with label "architecture") for this specific candidate.
333
514
 
334
- ANALYSIS:
335
- ${candidateContext}`,
336
- { ...opts, tools: TOOLS_READONLY }
337
- );
338
- const issueMatch = rfcResult.output?.match(/https:\/\/github\.com\/[^\s]+\/issues\/(\d+)/);
339
- const issueNum = issueMatch?.[1];
340
- const issueUrl = issueMatch?.[0];
341
- const summary = issueUrl ? `Architecture RFC issue created: ${issueUrl}
515
+ CANDIDATE:
516
+ ${candidate.body}
342
517
 
343
- Run \`/implement ${issueNum}\` to implement it.` : "Architecture RFC issue created.";
518
+ FULL ANALYSIS (for context):
519
+ ${candidateContext}`,
520
+ { ...opts, stageName, tools: TOOLS_READONLY }
521
+ );
522
+ const issueMatch = rfcResult.output?.match(/https:\/\/github\.com\/[^\s]+\/issues\/(\d+)/);
523
+ const issueUrl = issueMatch?.[0];
524
+ const issueNum = issueMatch?.[1];
525
+ if (issueUrl) {
526
+ createdIssues.push(`- ${issueUrl} \u2014 run \`/implement ${issueNum}\` to implement`);
527
+ } else {
528
+ createdIssues.push(`- ${candidate.label}: RFC created (no issue URL found in output)`);
529
+ }
530
+ }
531
+ const summary = createdIssues.length === 1 ? `Architecture RFC issue created:
532
+ ${createdIssues[0]}` : `Architecture RFC issues created (${createdIssues.length}):
533
+ ${createdIssues.join("\n")}`;
344
534
  return {
345
535
  content: [{ type: "text", text: summary }],
346
536
  details: { pipeline: "architecture", stages }
@@ -354,31 +544,91 @@ async function runDiscoverSkills(cwd, query, signal, onUpdate, _ctx) {
354
544
  const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "discover-skills", onUpdate };
355
545
  const task = isInstall ? `Install these skills as forgeflow plugins: ${query}` : query ? `Discover skills related to "${query}" \u2014 recommend only, do NOT install.` : "Analyze the project tech stack and discover relevant skills \u2014 recommend only, do NOT install.";
356
546
  const tools = isInstall ? TOOLS_ALL : TOOLS_NO_EDIT;
357
- const result = await runAgent("skill-discoverer", task, { ...opts, tools });
547
+ const result2 = await runAgent("skill-discoverer", task, { ...opts, tools });
358
548
  return {
359
- content: [{ type: "text", text: result.output || "No skills found." }],
549
+ content: [{ type: "text", text: result2.output || "No skills found." }],
360
550
  details: { pipeline: "discover-skills", stages }
361
551
  };
362
552
  }
363
553
 
364
- // src/utils/exec.ts
365
- import { spawn as spawn3 } from "child_process";
366
- function exec(cmd, cwd) {
367
- return new Promise((resolve2) => {
368
- const proc = spawn3("bash", ["-c", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
369
- let out = "";
370
- proc.stdout.on("data", (d) => {
371
- out += d.toString();
372
- });
373
- proc.on("close", () => resolve2(out.trim()));
374
- proc.on("error", () => resolve2(""));
375
- });
376
- }
377
-
378
554
  // src/utils/git.ts
555
+ import * as fs4 from "fs";
556
+ import * as path5 from "path";
557
+
558
+ // src/utils/git-workflow.ts
379
559
  import * as fs3 from "fs";
380
560
  import * as os2 from "os";
381
561
  import * as path4 from "path";
562
+ async function setupBranch(cwd, branch, execFn = exec) {
563
+ const aheadStr = await execFn(`git rev-list main..${branch} --count 2>/dev/null || echo 0`, cwd);
564
+ const ahead = parseInt(aheadStr, 10) || 0;
565
+ if (ahead > 0) {
566
+ await execFn(`git checkout ${branch}`, cwd);
567
+ const current2 = await execFn("git branch --show-current", cwd);
568
+ if (current2 !== branch) {
569
+ return { status: "failed", error: `Failed to switch to ${branch} (on ${current2})` };
570
+ }
571
+ return { status: "resumed", ahead };
572
+ }
573
+ await execFn(`git branch -D ${branch} 2>/dev/null; git branch -dr origin/${branch} 2>/dev/null; echo done`, cwd);
574
+ await execFn(`git checkout -b ${branch}`, cwd);
575
+ let current = await execFn("git branch --show-current", cwd);
576
+ if (current !== branch) {
577
+ await execFn(`git checkout ${branch} 2>/dev/null || git checkout -b ${branch}`, cwd);
578
+ current = await execFn("git branch --show-current", cwd);
579
+ }
580
+ if (current !== branch) {
581
+ return { status: "failed", error: `Failed to switch to ${branch} (on ${current})` };
582
+ }
583
+ return { status: "fresh" };
584
+ }
585
+ async function ensurePr(cwd, title, body, branch, execFn = exec) {
586
+ await execFn(`git push -u origin ${branch}`, cwd);
587
+ const existingNum = await findPrNumber(cwd, branch, execFn);
588
+ if (existingNum != null) {
589
+ return { number: existingNum, created: false };
590
+ }
591
+ const tmp = path4.join(os2.tmpdir(), `forgeflow-pr-${Date.now()}.md`);
592
+ try {
593
+ fs3.writeFileSync(tmp, body, "utf-8");
594
+ const createOutput = await execFn(`gh pr create --title "${title}" --body-file "${tmp}" --head ${branch}`, cwd);
595
+ const urlMatch = createOutput.match(/\/pull\/(\d+)/);
596
+ if (urlMatch?.[1]) {
597
+ return { number: parseInt(urlMatch[1], 10), created: true };
598
+ }
599
+ const createdNum = await findPrNumber(cwd, branch, execFn);
600
+ return { number: createdNum ?? 0, created: true };
601
+ } finally {
602
+ try {
603
+ fs3.unlinkSync(tmp);
604
+ } catch {
605
+ }
606
+ }
607
+ }
608
+ async function findPrNumber(cwd, branch, execFn = exec) {
609
+ const result2 = await execFn(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
610
+ if (result2 && result2 !== "null") {
611
+ return parseInt(result2, 10);
612
+ }
613
+ return null;
614
+ }
615
+ async function mergePr(cwd, prNumber, execFn = exec) {
616
+ const mergeResult = await execFn(`gh pr merge ${prNumber} --squash --delete-branch`, cwd);
617
+ if (mergeResult.includes("Merged") || mergeResult.includes("merged")) {
618
+ return;
619
+ }
620
+ const prState = await execFn(`gh pr view ${prNumber} --json state --jq '.state'`, cwd);
621
+ if (prState === "MERGED") {
622
+ return;
623
+ }
624
+ throw new Error(`Failed to merge PR #${prNumber}. State: ${prState || "unknown"}`);
625
+ }
626
+ async function returnToMain(cwd, execFn = exec) {
627
+ await execFn("git checkout main", cwd);
628
+ await execFn("git pull --rebase", cwd);
629
+ }
630
+
631
+ // src/utils/git.ts
382
632
  var PR_TEMPLATE_PATHS = [
383
633
  ".github/pull_request_template.md",
384
634
  ".github/PULL_REQUEST_TEMPLATE.md",
@@ -390,9 +640,9 @@ function buildPrBody(cwd, issue) {
390
640
  const isGitHub = issue.source === "github" && issue.number > 0;
391
641
  const defaultBody = isGitHub ? `Closes #${issue.number}` : `Jira: ${issue.key}`;
392
642
  for (const rel of PR_TEMPLATE_PATHS) {
393
- const abs = path4.join(cwd, rel);
643
+ const abs = path5.join(cwd, rel);
394
644
  try {
395
- const template = fs3.readFileSync(abs, "utf-8");
645
+ const template = fs4.readFileSync(abs, "utf-8");
396
646
  const closeRef = isGitHub ? `Closes #${issue.number}` : `Jira: ${issue.key}`;
397
647
  return `${closeRef}
398
648
 
@@ -402,18 +652,6 @@ ${template}`;
402
652
  }
403
653
  return defaultBody;
404
654
  }
405
- async function createPr(cwd, title, body, branch) {
406
- const tmp = path4.join(os2.tmpdir(), `forgeflow-pr-${Date.now()}.md`);
407
- try {
408
- fs3.writeFileSync(tmp, body, "utf-8");
409
- await exec(`gh pr create --title "${title}" --body-file "${tmp}" --head ${branch}`, cwd);
410
- } finally {
411
- try {
412
- fs3.unlinkSync(tmp);
413
- } catch {
414
- }
415
- }
416
- }
417
655
  function slugify(text, maxLen = 40) {
418
656
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, maxLen).replace(/-$/, "");
419
657
  }
@@ -441,7 +679,7 @@ async function resolveIssue(cwd, issueArg) {
441
679
  return `On branch "${branch}" \u2014 can't detect issue. Use /implement <issue#> or /implement <JIRA-KEY>.`;
442
680
  }
443
681
  async function resolveGitHubIssue(cwd, issueNum) {
444
- const issueJson = await exec(`gh issue view ${issueNum} --json number,title,body`, cwd);
682
+ const issueJson = await execSafe(`gh issue view ${issueNum} --json number,title,body`, cwd);
445
683
  if (!issueJson) return `Could not fetch issue #${issueNum}.`;
446
684
  let issue;
447
685
  try {
@@ -450,12 +688,11 @@ async function resolveGitHubIssue(cwd, issueNum) {
450
688
  return `Could not parse issue #${issueNum}.`;
451
689
  }
452
690
  const branch = `feat/issue-${issueNum}`;
453
- const prJson = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
454
- const existingPR = prJson && prJson !== "null" ? parseInt(prJson, 10) : void 0;
691
+ const existingPR = await findPrNumber(cwd, branch) ?? void 0;
455
692
  return { source: "github", key: String(issueNum), ...issue, branch, existingPR };
456
693
  }
457
694
  async function resolveJiraIssue(cwd, jiraKey, existingBranch) {
458
- const raw = await exec(`jira issue view ${jiraKey} --raw`, cwd);
695
+ const raw = await execSafe(`jira issue view ${jiraKey} --raw`, cwd);
459
696
  if (!raw) return `Could not fetch Jira issue ${jiraKey}.`;
460
697
  let data;
461
698
  try {
@@ -475,8 +712,7 @@ ${fields.acceptance_criteria}`);
475
712
  if (fields.sprint?.name) bodyParts.push(`**Sprint:** ${fields.sprint.name}`);
476
713
  const body = bodyParts.join("\n\n");
477
714
  const branch = existingBranch ?? `feat/${jiraKey}-${slugify(title)}`;
478
- const prJson = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
479
- const existingPR = prJson && prJson !== "null" ? parseInt(prJson, 10) : void 0;
715
+ const existingPR = await findPrNumber(cwd, branch) ?? void 0;
480
716
  return { source: "jira", key: jiraKey, number: 0, title, body, branch, existingPR };
481
717
  }
482
718
 
@@ -503,152 +739,60 @@ function updateProgressWidget(ctx, progress, totalCost) {
503
739
  setForgeflowWidget(ctx, lines);
504
740
  }
505
741
 
506
- // src/pipelines/review.ts
507
- async function runReviewInline(cwd, signal, onUpdate, ctx, stages, diffCmd = "git diff main...HEAD", pipeline = "review", options = {}) {
508
- const diff = await exec(diffCmd, cwd);
509
- if (!diff) {
510
- return { content: [{ type: "text", text: "No changes to review." }] };
511
- }
512
- const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline, onUpdate };
513
- const extraInstructions = options.customPrompt ? `
742
+ // src/pipelines/review-orchestrator.ts
743
+ async function runReviewPipeline(diff, opts) {
744
+ const { cwd, signal, stages, pipeline = "review", onUpdate, customPrompt } = opts;
745
+ const runAgentFn = await resolveRunAgent(opts.runAgentFn);
746
+ const agentOpts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline, onUpdate };
747
+ const extraInstructions = customPrompt ? `
514
748
 
515
749
  ADDITIONAL INSTRUCTIONS FROM USER:
516
- ${options.customPrompt}` : "";
750
+ ${customPrompt}` : "";
517
751
  cleanSignal(cwd, "findings");
518
752
  stages.push(emptyStage("code-reviewer"));
519
- await runAgent("code-reviewer", `Review the following diff:
753
+ await runAgentFn("code-reviewer", `Review the following diff:
520
754
 
521
755
  ${diff}${extraInstructions}`, {
522
- ...opts,
756
+ ...agentOpts,
523
757
  tools: TOOLS_NO_EDIT
524
758
  });
525
759
  if (!signalExists(cwd, "findings")) {
526
- return { content: [{ type: "text", text: "Review passed \u2014 no actionable findings." }] };
760
+ return { passed: true };
527
761
  }
528
762
  stages.push(emptyStage("review-judge"));
529
763
  const findings = readSignal(cwd, "findings") ?? "";
530
- await runAgent(
764
+ await runAgentFn(
531
765
  "review-judge",
532
766
  `Validate the following code review findings against the actual code:
533
767
 
534
768
  ${findings}`,
535
- { ...opts, tools: TOOLS_NO_EDIT }
769
+ { ...agentOpts, tools: TOOLS_NO_EDIT }
536
770
  );
537
771
  if (!signalExists(cwd, "findings")) {
538
- return { content: [{ type: "text", text: "Review passed \u2014 judge filtered all findings." }] };
772
+ return { passed: true };
539
773
  }
540
774
  const validatedFindings = readSignal(cwd, "findings") ?? "";
541
- if (options.interactive && options.prNumber) {
542
- const repo = await exec("gh repo view --json nameWithOwner --jq .nameWithOwner", cwd);
543
- const prNum = options.prNumber;
544
- const proposalPrompt = `You have validated code review findings for PR #${prNum} in ${repo}.
545
-
546
- FINDINGS:
547
- ${validatedFindings}
548
-
549
- Generate ready-to-run \`gh api\` commands to post each finding as a PR review comment. One command per finding.
550
-
551
- Format each as:
552
-
553
- **Finding N** \u2014 path/to/file.ts:LINE
554
-
555
- \`\`\`bash
556
- gh api repos/${repo}/pulls/${prNum}/comments \\
557
- --method POST \\
558
- --field body="<comment>" \\
559
- --field commit_id="$(gh pr view ${prNum} --repo ${repo} --json headRefOid -q .headRefOid)" \\
560
- --field path="path/to/file.ts" \\
561
- --field line=LINE \\
562
- --field side="RIGHT"
563
- \`\`\`
564
-
565
- Comment tone rules:
566
- - Write like a teammate, not an auditor. Casual, brief, direct.
567
- - 1-2 short sentences max. Lead with the suggestion, not the problem.
568
- - Use "might be worth..." / "could we..." / "what about..." / "small thing:"
569
- - No em dashes, no "Consider...", no "Note that...", no hedging filler.
570
- - Use GitHub \`\`\`suggestion\`\`\` blocks when proposing code changes.
571
- - Only generate commands for findings with a specific file + line.
572
-
573
- After the comments, add the review decision command:
574
-
575
- \`\`\`bash
576
- gh pr review ${prNum} --request-changes --body "Left a few comments" --repo ${repo}
577
- \`\`\`
578
-
579
- Output ONLY the commands, no other text.`;
580
- stages.push(emptyStage("propose-comments"));
581
- await runAgent("review-judge", proposalPrompt, {
582
- agentsDir: AGENTS_DIR,
583
- cwd,
584
- signal,
585
- stages,
586
- pipeline,
587
- onUpdate,
588
- tools: TOOLS_READONLY
589
- });
590
- const commentStage = stages.find((s) => s.name === "propose-comments");
591
- const proposedCommands = commentStage?.output || "";
592
- if (proposedCommands && ctx.hasUI) {
593
- const reviewed = await ctx.ui.editor(
594
- `Review PR comments for PR #${prNum} (edit or close to skip)`,
595
- `${validatedFindings}
596
-
597
- ---
598
-
599
- Proposed commands (run these to post):
600
-
601
- ${proposedCommands}`
602
- );
603
- if (reviewed != null) {
604
- const action = await ctx.ui.select("Post these review comments?", ["Post comments", "Skip"]);
605
- if (action === "Post comments") {
606
- const commands = reviewed.match(/```bash\n([\s\S]*?)```/g) || [];
607
- for (const block of commands) {
608
- const cmd = block.replace(/```bash\n/, "").replace(/```$/, "").trim();
609
- if (cmd.startsWith("gh ")) {
610
- await exec(cmd, cwd);
611
- }
612
- }
613
- }
614
- }
615
- }
616
- }
617
- return { content: [{ type: "text", text: validatedFindings }], isError: true };
775
+ return { passed: false, findings: validatedFindings };
618
776
  }
619
- async function runReview(cwd, target, signal, onUpdate, ctx, customPrompt) {
620
- const stages = [];
621
- let diffCmd = "git diff main...HEAD";
622
- let prNumber;
623
- if (target.match(/^\d+$/)) {
624
- diffCmd = `gh pr diff ${target}`;
625
- prNumber = target;
626
- } else if (target.startsWith("--branch")) {
627
- const branch = target.replace("--branch", "").trim() || "HEAD";
628
- diffCmd = `git diff main...${branch}`;
629
- } else {
630
- const pr = await exec("gh pr view --json number --jq .number 2>/dev/null", cwd);
631
- if (pr && pr !== "") prNumber = pr;
632
- }
633
- if (ctx.hasUI && !customPrompt) {
634
- const extra = await ctx.ui.input("Additional instructions?", "Skip");
635
- if (extra != null && extra.trim() !== "") {
636
- customPrompt = extra.trim();
637
- }
638
- }
639
- const result = await runReviewInline(cwd, signal, onUpdate, ctx, stages, diffCmd, "review", {
640
- prNumber,
641
- interactive: ctx.hasUI,
642
- customPrompt
777
+
778
+ // src/pipelines/agents.ts
779
+ async function runImplementor(cwd, prompt, signal, stages, onUpdate) {
780
+ await runAgent("implementor", prompt, {
781
+ agentsDir: AGENTS_DIR,
782
+ cwd,
783
+ signal,
784
+ stages,
785
+ pipeline: "implement",
786
+ onUpdate,
787
+ tools: TOOLS_ALL
643
788
  });
644
- return { ...result, details: { pipeline: "review", stages } };
645
789
  }
646
-
647
- // src/pipelines/implement.ts
648
- async function reviewAndFix(cwd, signal, onUpdate, ctx, stages, pipeline = "implement") {
649
- const reviewResult = await runReviewInline(cwd, signal, onUpdate, ctx, stages);
650
- if (reviewResult.isError) {
651
- const findings = reviewResult.content[0]?.type === "text" ? reviewResult.content[0].text : "";
790
+ async function reviewAndFix(cwd, signal, onUpdate, _ctx, stages, pipeline = "implement") {
791
+ const diff = await exec("git diff main...HEAD", cwd);
792
+ if (!diff) return;
793
+ const reviewResult2 = await runReviewPipeline(diff, { cwd, signal, stages, pipeline, onUpdate });
794
+ if (!reviewResult2.passed) {
795
+ const findings = reviewResult2.findings ?? "";
652
796
  stages.push(emptyStage("fix-findings"));
653
797
  await runAgent(
654
798
  "implementor",
@@ -676,21 +820,56 @@ async function refactorAndReview(cwd, signal, onUpdate, ctx, stages, skipReview,
676
820
  await reviewAndFix(cwd, signal, onUpdate, ctx, stages, pipeline);
677
821
  }
678
822
  }
679
- async function resolveQuestions(plan, ctx) {
680
- const sectionMatch = plan.match(/### Unresolved Questions\n([\s\S]*?)(?=\n###|$)/);
681
- if (!sectionMatch) return plan;
682
- const section = sectionMatch[1] ?? "";
683
- const itemRe = /^(?:[-*]|\d+[.)]+|[a-z][.)]+)\s+(.+(?:\n(?!(?:[-*]|\d+[.)]+|[a-z][.)]+)\s).*)*)/gm;
684
- const items = [];
685
- for (const m of section.matchAll(itemRe)) {
686
- if (m[0] && m[1]) items.push({ full: m[0], text: m[1] });
687
- }
688
- if (items.length === 0) return plan;
689
- let updatedSection = section;
690
- for (const item of items) {
691
- const answer = await ctx.ui.input(`${item.text}`, "Skip to use defaults");
692
- if (answer != null && answer.trim() !== "") {
693
- updatedSection = updatedSection.replace(item.full, `${item.full}
823
+ function buildImplementorPrompt(issueContext, plan, customPrompt, resolved, autonomous) {
824
+ const isGitHub = resolved.source === "github" && resolved.number > 0;
825
+ const planSection = plan ? `
826
+
827
+ IMPLEMENTATION PLAN:
828
+ ${plan}` : "";
829
+ const customPromptSection = customPrompt ? `
830
+
831
+ ADDITIONAL INSTRUCTIONS FROM USER:
832
+ ${customPrompt}` : "";
833
+ const branchNote = resolved.branch ? `
834
+ - You should be on branch: ${resolved.branch} \u2014 do NOT create or switch branches.` : "\n- Do NOT create or switch branches.";
835
+ const prNote = resolved.existingPR ? `
836
+ - PR #${resolved.existingPR} already exists for this branch.` : "";
837
+ const closeNote = isGitHub ? `
838
+ - The PR body MUST end with a blank line then 'Closes #${resolved.number}' on its own line (not inline with other text), so the issue auto-closes on merge.` : `
839
+ - The PR body should reference Jira issue ${resolved.key}.`;
840
+ const unresolvedNote = autonomous ? `
841
+ - If the plan has unresolved questions, resolve them yourself using sensible defaults. Do NOT stop and wait.` : "";
842
+ return `Implement the following issue using strict TDD (red-green-refactor).
843
+
844
+ ${issueContext}${planSection}${customPromptSection}
845
+
846
+ WORKFLOW:
847
+ 1. Read the codebase.
848
+ 2. TDD${plan ? " following the plan" : ""}.
849
+ 3. Refactor after all tests pass.
850
+ 4. Run check command, fix failures.
851
+ 5. Commit, push, and create a PR.
852
+
853
+ CONSTRAINTS:${branchNote}${prNote}${closeNote}${unresolvedNote}
854
+ - If blocked, write BLOCKED.md with the reason and stop.`;
855
+ }
856
+
857
+ // src/pipelines/planning.ts
858
+ async function resolveQuestions(plan, ctx) {
859
+ const sectionMatch = plan.match(/### Unresolved Questions\n([\s\S]*?)(?=\n###|$)/);
860
+ if (!sectionMatch) return plan;
861
+ const section = sectionMatch[1] ?? "";
862
+ const itemRe = /^(?:[-*]|\d+[.)]+|[a-z][.)]+)\s+(.+(?:\n(?!(?:[-*]|\d+[.)]+|[a-z][.)]+)\s).*)*)/gm;
863
+ const items = [];
864
+ for (const m of section.matchAll(itemRe)) {
865
+ if (m[0] && m[1]) items.push({ full: m[0], text: m[1] });
866
+ }
867
+ if (items.length === 0) return plan;
868
+ let updatedSection = section;
869
+ for (const item of items) {
870
+ const answer = await ctx.ui.input(`${item.text}`, "Skip to use defaults");
871
+ if (answer != null && answer.trim() !== "") {
872
+ updatedSection = updatedSection.replace(item.full, `${item.full}
694
873
  **Answer:** ${answer.trim()}`);
695
874
  }
696
875
  }
@@ -698,186 +877,120 @@ async function resolveQuestions(plan, ctx) {
698
877
  ${section}`, `### Unresolved Questions
699
878
  ${updatedSection}`);
700
879
  }
880
+ async function runPlanning(cwd, issueContext, customPrompt, opts) {
881
+ const { signal, onUpdate, ctx, interactive, stages } = opts;
882
+ const runAgentFn = await resolveRunAgent(opts.runAgentFn);
883
+ const customPromptSection = customPrompt ? `
884
+
885
+ ADDITIONAL INSTRUCTIONS FROM USER:
886
+ ${customPrompt}` : "";
887
+ const planResult = await runAgentFn(
888
+ "planner",
889
+ `Plan the implementation for this issue by producing a sequenced list of test cases.
890
+
891
+ ${issueContext}${customPromptSection}`,
892
+ { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "implement", onUpdate, tools: TOOLS_READONLY }
893
+ );
894
+ if (planResult.status === "failed") {
895
+ return { plan: planResult.output, cancelled: false, failed: true, stages };
896
+ }
897
+ let plan = planResult.output;
898
+ if (interactive && plan) {
899
+ const edited = await ctx.ui.editor("Review implementation plan", plan);
900
+ if (edited != null && edited !== plan) {
901
+ plan = edited;
902
+ }
903
+ plan = await resolveQuestions(plan, ctx);
904
+ const action = await ctx.ui.select("Plan ready. What next?", ["Approve and implement", "Cancel"]);
905
+ if (action === "Cancel" || action == null) {
906
+ return { plan, cancelled: true, stages };
907
+ }
908
+ }
909
+ return { plan, cancelled: false, stages };
910
+ }
911
+
912
+ // src/pipelines/implement.ts
913
+ function result(text, stages, isError) {
914
+ return {
915
+ content: [{ type: "text", text }],
916
+ details: { pipeline: "implement", stages },
917
+ ...isError ? { isError } : {}
918
+ };
919
+ }
701
920
  async function runImplement(cwd, issueArg, signal, onUpdate, ctx, flags = {
702
921
  skipPlan: false,
703
922
  skipReview: false
704
923
  }) {
705
924
  const interactive = ctx.hasUI && !flags.autonomous;
706
925
  const resolved = await resolveIssue(cwd, issueArg || void 0);
707
- if (typeof resolved === "string") {
708
- return { content: [{ type: "text", text: resolved }], details: { pipeline: "implement", stages: [] } };
709
- }
710
- const isGitHub = resolved.source === "github" && resolved.number > 0;
711
- const issueLabel = isGitHub ? `#${resolved.number}: ${resolved.title}` : `${resolved.key}: ${resolved.title}`;
712
- if (!flags.autonomous && (resolved.number || resolved.key)) {
713
- const tag = isGitHub ? `#${resolved.number}` : resolved.key;
714
- setForgeflowStatus(ctx, `${tag} ${resolved.title} \xB7 ${resolved.branch}`);
715
- }
716
- const issueContext = isGitHub ? `Issue #${resolved.number}: ${resolved.title}
926
+ if (typeof resolved === "string") return result(resolved, []);
927
+ const isGH = resolved.source === "github" && resolved.number > 0;
928
+ const issueLabel = isGH ? `#${resolved.number}: ${resolved.title}` : `${resolved.key}: ${resolved.title}`;
929
+ const issueContext = isGH ? `Issue #${resolved.number}: ${resolved.title}
717
930
 
718
931
  ${resolved.body}` : `Jira ${resolved.key}: ${resolved.title}
719
932
 
720
933
  ${resolved.body}`;
934
+ if (!flags.autonomous && (resolved.number || resolved.key))
935
+ setForgeflowStatus(ctx, `${isGH ? `#${resolved.number}` : resolved.key} ${resolved.title} \xB7 ${resolved.branch}`);
721
936
  if (interactive && !flags.customPrompt) {
722
937
  const extra = await ctx.ui.input("Additional instructions?", "Skip");
723
- if (extra != null && extra.trim() !== "") {
724
- flags.customPrompt = extra.trim();
725
- }
938
+ if (extra?.trim()) flags.customPrompt = extra.trim();
726
939
  }
727
- const customPromptSection = flags.customPrompt ? `
728
-
729
- ADDITIONAL INSTRUCTIONS FROM USER:
730
- ${flags.customPrompt}` : "";
731
940
  if (resolved.existingPR) {
732
941
  const stages2 = [];
733
- if (!flags.skipReview) {
734
- await reviewAndFix(cwd, signal, onUpdate, ctx, stages2);
735
- }
736
- return {
737
- content: [{ type: "text", text: `Resumed ${issueLabel} \u2014 PR #${resolved.existingPR} already exists.` }],
738
- details: { pipeline: "implement", stages: stages2 }
739
- };
942
+ if (!flags.skipReview) await reviewAndFix(cwd, signal, onUpdate, ctx, stages2);
943
+ return result(`Resumed ${issueLabel} \u2014 PR #${resolved.existingPR} already exists.`, stages2);
740
944
  }
741
945
  if (resolved.branch) {
742
- const branch = resolved.branch;
743
- const branchSetup = await exec(
744
- `ahead=$(git rev-list main..${branch} --count 2>/dev/null || echo 0); if [ "$ahead" -gt 0 ] 2>/dev/null; then git checkout ${branch} && echo "RESUME:$ahead"; else git branch -D ${branch} 2>/dev/null; git branch -dr origin/${branch} 2>/dev/null; git checkout -b ${branch} && echo "FRESH"; fi`,
745
- cwd
746
- );
747
- if (branchSetup.startsWith("RESUME:")) {
748
- await exec(`git push -u origin ${branch}`, cwd);
749
- const prBody = buildPrBody(cwd, resolved);
750
- await createPr(cwd, resolved.title, prBody, branch);
946
+ const branchResult = await setupBranch(cwd, resolved.branch);
947
+ if (branchResult.status === "resumed") {
948
+ await ensurePr(cwd, resolved.title, buildPrBody(cwd, resolved), resolved.branch);
751
949
  const stages2 = [];
752
950
  await refactorAndReview(cwd, signal, onUpdate, ctx, stages2, flags.skipReview);
753
- return {
754
- content: [{ type: "text", text: `Resumed ${issueLabel} \u2014 pushed existing commits and created PR.` }],
755
- details: { pipeline: "implement", stages: stages2 }
756
- };
757
- }
758
- let afterBranch = await exec("git branch --show-current", cwd);
759
- if (afterBranch !== branch) {
760
- await exec(`git checkout ${branch} 2>/dev/null || git checkout -b ${branch}`, cwd);
761
- afterBranch = await exec("git branch --show-current", cwd);
762
- }
763
- if (afterBranch !== branch) {
764
- return {
765
- content: [
766
- {
767
- type: "text",
768
- text: `Failed to switch to ${branch} (on ${afterBranch}). Setup output: ${branchSetup || "(empty)"}`
769
- }
770
- ],
771
- details: { pipeline: "implement", stages: [] },
772
- isError: true
773
- };
951
+ return result(`Resumed ${issueLabel} \u2014 pushed existing commits and created PR.`, stages2);
774
952
  }
953
+ if (branchResult.status === "failed")
954
+ return result(branchResult.error || `Failed to switch to ${resolved.branch}.`, [], true);
775
955
  }
776
- const stageList = [];
777
- if (!flags.skipPlan) stageList.push(emptyStage("planner"));
778
- stageList.push(emptyStage("implementor"));
779
- stageList.push(emptyStage("refactorer"));
780
- const stages = stageList;
781
- const opts = { agentsDir: AGENTS_DIR, cwd, signal, stages, pipeline: "implement", onUpdate };
956
+ const stages = [];
957
+ if (!flags.skipPlan) stages.push(emptyStage("planner"));
958
+ stages.push(emptyStage("implementor"), emptyStage("refactorer"));
782
959
  let plan = "";
783
960
  if (!flags.skipPlan) {
784
- const planResult = await runAgent(
785
- "planner",
786
- `Plan the implementation for this issue by producing a sequenced list of test cases.
787
-
788
- ${issueContext}${customPromptSection}`,
789
- { ...opts, tools: TOOLS_READONLY }
790
- );
791
- if (planResult.status === "failed") {
792
- return {
793
- content: [{ type: "text", text: `Planner failed: ${planResult.output}` }],
794
- details: { pipeline: "implement", stages },
795
- isError: true
796
- };
797
- }
798
- plan = planResult.output;
799
- if (interactive && plan) {
800
- const edited = await ctx.ui.editor(`Review implementation plan for ${issueLabel}`, plan);
801
- if (edited != null && edited !== plan) {
802
- plan = edited;
803
- }
804
- plan = await resolveQuestions(plan, ctx);
805
- const action = await ctx.ui.select("Plan ready. What next?", ["Approve and implement", "Cancel"]);
806
- if (action === "Cancel" || action == null) {
807
- return {
808
- content: [{ type: "text", text: "Implementation cancelled." }],
809
- details: { pipeline: "implement", stages }
810
- };
811
- }
812
- }
961
+ const planResult = await runPlanning(cwd, issueContext, flags.customPrompt, {
962
+ signal,
963
+ onUpdate,
964
+ ctx,
965
+ interactive,
966
+ stages
967
+ });
968
+ if (planResult.failed) return result(`Planner failed: ${planResult.plan}`, stages, true);
969
+ if (planResult.cancelled) return result("Implementation cancelled.", stages);
970
+ plan = planResult.plan;
813
971
  }
814
972
  cleanSignal(cwd, "blocked");
815
- const planSection = plan ? `
816
-
817
- IMPLEMENTATION PLAN:
818
- ${plan}` : "";
819
- const branchNote = resolved.branch ? `
820
- - You should be on branch: ${resolved.branch} \u2014 do NOT create or switch branches.` : "\n- Do NOT create or switch branches.";
821
- const prNote = resolved.existingPR ? `
822
- - PR #${resolved.existingPR} already exists for this branch.` : "";
823
- const closeNote = isGitHub ? `
824
- - The PR body MUST end with a blank line then 'Closes #${resolved.number}' on its own line (not inline with other text), so the issue auto-closes on merge.` : `
825
- - The PR body should reference Jira issue ${resolved.key}.`;
826
- const unresolvedNote = flags.autonomous ? `
827
- - If the plan has unresolved questions, resolve them yourself using sensible defaults. Do NOT stop and wait.` : "";
828
- await runAgent(
829
- "implementor",
830
- `Implement the following issue using strict TDD (red-green-refactor).
831
-
832
- ${issueContext}${planSection}${customPromptSection}
833
-
834
- WORKFLOW:
835
- 1. Read the codebase.
836
- 2. TDD${plan ? " following the plan" : ""}.
837
- 3. Refactor after all tests pass.
838
- 4. Run check command, fix failures.
839
- 5. Commit, push, and create a PR.
840
-
841
- CONSTRAINTS:${branchNote}${prNote}${closeNote}${unresolvedNote}
842
- - If blocked, write BLOCKED.md with the reason and stop.`,
843
- { ...opts, tools: TOOLS_ALL }
844
- );
845
- if (signalExists(cwd, "blocked")) {
846
- const reason = readSignal(cwd, "blocked") ?? "";
847
- return {
848
- content: [{ type: "text", text: `Implementor blocked:
849
- ${reason}` }],
850
- details: { pipeline: "implement", stages },
851
- isError: true
852
- };
853
- }
973
+ const prompt = buildImplementorPrompt(issueContext, plan, flags.customPrompt, resolved, flags.autonomous);
974
+ await runImplementor(cwd, prompt, signal, stages, onUpdate);
975
+ if (signalExists(cwd, "blocked"))
976
+ return result(`Implementor blocked:
977
+ ${readSignal(cwd, "blocked") ?? ""}`, stages, true);
854
978
  await refactorAndReview(cwd, signal, onUpdate, ctx, stages, flags.skipReview);
855
- let prNumber = "";
979
+ let prNumber = 0;
856
980
  if (resolved.branch) {
857
- await exec(`git push -u origin ${resolved.branch}`, cwd);
858
- prNumber = await exec(`gh pr list --head "${resolved.branch}" --json number --jq '.[0].number'`, cwd);
859
- if (!prNumber || prNumber === "null") {
860
- const prBody = buildPrBody(cwd, resolved);
861
- await createPr(cwd, resolved.title, prBody, resolved.branch);
862
- prNumber = await exec(`gh pr list --head "${resolved.branch}" --json number --jq '.[0].number'`, cwd);
863
- }
981
+ const prResult = await ensurePr(cwd, resolved.title, buildPrBody(cwd, resolved), resolved.branch);
982
+ prNumber = prResult.number;
864
983
  }
865
- if (!flags.autonomous && prNumber && prNumber !== "null") {
984
+ if (!flags.autonomous && prNumber > 0) {
866
985
  const mergeStage = emptyStage("merge");
867
986
  stages.push(mergeStage);
868
- await exec(`gh pr merge ${prNumber} --squash --delete-branch`, cwd);
869
- await exec("git checkout main && git pull", cwd);
987
+ await mergePr(cwd, prNumber);
988
+ await returnToMain(cwd);
870
989
  mergeStage.status = "done";
871
990
  mergeStage.output = `Merged PR #${prNumber}`;
872
- onUpdate?.({
873
- content: [{ type: "text", text: "Pipeline complete" }],
874
- details: { pipeline: "implement", stages }
875
- });
991
+ onUpdate?.({ content: [{ type: "text", text: "Pipeline complete" }], details: { pipeline: "implement", stages } });
876
992
  }
877
- return {
878
- content: [{ type: "text", text: `Implementation of ${issueLabel} complete.` }],
879
- details: { pipeline: "implement", stages }
880
- };
993
+ return result(`Implementation of ${issueLabel} complete.`, stages);
881
994
  }
882
995
 
883
996
  // src/pipelines/implement-all.ts
@@ -903,7 +1016,7 @@ async function runImplementAll(cwd, signal, onUpdate, ctx, flags) {
903
1016
  const maxIterations = 50;
904
1017
  while (iteration++ < maxIterations) {
905
1018
  if (signal.aborted) break;
906
- await exec("git checkout main && git pull --rebase", cwd);
1019
+ await returnToMain(cwd, exec);
907
1020
  const issuesJson = await exec(
908
1021
  `gh issue list --state open --label "auto-generated" --json number,title,body --jq 'sort_by(.number)'`,
909
1022
  cwd
@@ -970,25 +1083,20 @@ async function runImplementAll(cwd, signal, onUpdate, ctx, flags) {
970
1083
  };
971
1084
  }
972
1085
  const branch = `feat/issue-${issueNum}`;
973
- await exec("git checkout main && git pull --rebase", cwd);
974
- const prNum = await exec(`gh pr list --head "${branch}" --json number --jq '.[0].number'`, cwd);
975
- if (prNum && prNum !== "null") {
976
- const mergeResult = await exec(`gh pr merge ${prNum} --squash --delete-branch`, cwd);
977
- if (mergeResult.includes("Merged") || mergeResult === "") {
1086
+ await returnToMain(cwd, exec);
1087
+ const prNum = await findPrNumber(cwd, branch, exec);
1088
+ if (prNum != null) {
1089
+ try {
1090
+ await mergePr(cwd, prNum, exec);
978
1091
  completed.add(issueNum);
979
- } else {
980
- const prState = await exec(`gh pr view ${prNum} --json state --jq '.state'`, cwd);
981
- if (prState === "MERGED") {
982
- completed.add(issueNum);
983
- } else {
984
- issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
985
- updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
986
- return {
987
- content: [{ type: "text", text: `Failed to merge PR #${prNum} for issue #${issueNum}.` }],
988
- details: { pipeline: "implement-all", stages: allStages },
989
- isError: true
990
- };
991
- }
1092
+ } catch {
1093
+ issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
1094
+ updateProgressWidget(ctx, issueProgress, sumUsage(allStages).cost);
1095
+ return {
1096
+ content: [{ type: "text", text: `Failed to merge PR #${prNum} for issue #${issueNum}.` }],
1097
+ details: { pipeline: "implement-all", stages: allStages },
1098
+ isError: true
1099
+ };
992
1100
  }
993
1101
  } else {
994
1102
  issueProgress.set(issueNum, { title: issueTitle, status: "failed" });
@@ -1012,6 +1120,139 @@ async function runImplementAll(cwd, signal, onUpdate, ctx, flags) {
1012
1120
  };
1013
1121
  }
1014
1122
 
1123
+ // src/pipelines/review-comments.ts
1124
+ function buildCommentProposalPrompt(findings, prNum, repo) {
1125
+ return `You have validated code review findings for PR #${prNum} in ${repo}.
1126
+
1127
+ FINDINGS:
1128
+ ${findings}
1129
+
1130
+ Generate ready-to-run \`gh api\` commands to post each finding as a PR review comment. One command per finding.
1131
+
1132
+ Format each as:
1133
+
1134
+ **Finding N** \u2014 path/to/file.ts:LINE
1135
+
1136
+ \`\`\`bash
1137
+ gh api repos/${repo}/pulls/${prNum}/comments \\
1138
+ --method POST \\
1139
+ --field body="<comment>" \\
1140
+ --field commit_id="$(gh pr view ${prNum} --repo ${repo} --json headRefOid -q .headRefOid)" \\
1141
+ --field path="path/to/file.ts" \\
1142
+ --field line=LINE \\
1143
+ --field side="RIGHT"
1144
+ \`\`\`
1145
+
1146
+ Comment tone rules:
1147
+ - Write like a teammate, not an auditor. Casual, brief, direct.
1148
+ - 1-2 short sentences max. Lead with the suggestion, not the problem.
1149
+ - Use "might be worth..." / "could we..." / "what about..." / "small thing:"
1150
+ - No em dashes, no "Consider...", no "Note that...", no hedging filler.
1151
+ - Use GitHub \`\`\`suggestion\`\`\` blocks when proposing code changes.
1152
+ - Only generate commands for findings with a specific file + line.
1153
+
1154
+ After the comments, add the review decision command:
1155
+
1156
+ \`\`\`bash
1157
+ gh pr review ${prNum} --request-changes --body "Left a few comments" --repo ${repo}
1158
+ \`\`\`
1159
+
1160
+ Output ONLY the commands, no other text.`;
1161
+ }
1162
+ function extractGhCommands(text) {
1163
+ const blocks = text.match(/```bash\n([\s\S]*?)```/g) || [];
1164
+ const commands = [];
1165
+ for (const block of blocks) {
1166
+ const cmd = block.replace(/```bash\n/, "").replace(/```$/, "").trim();
1167
+ if (cmd.startsWith("gh ")) {
1168
+ commands.push(cmd);
1169
+ }
1170
+ }
1171
+ return commands;
1172
+ }
1173
+ async function proposeAndPostComments(findings, pr, opts) {
1174
+ const { cwd, signal, stages, ctx, pipeline = "review", onUpdate } = opts;
1175
+ const execFn = opts.execFn ?? exec;
1176
+ const runAgentFn = await resolveRunAgent(opts.runAgentFn);
1177
+ const proposalPrompt = buildCommentProposalPrompt(findings, pr.number, pr.repo);
1178
+ stages.push(emptyStage("propose-comments"));
1179
+ await runAgentFn("review-judge", proposalPrompt, {
1180
+ agentsDir: AGENTS_DIR,
1181
+ cwd,
1182
+ signal,
1183
+ stages,
1184
+ pipeline,
1185
+ onUpdate,
1186
+ tools: TOOLS_READONLY
1187
+ });
1188
+ const commentStage = stages.find((s) => s.name === "propose-comments");
1189
+ const proposedCommands = commentStage?.output || "";
1190
+ if (!proposedCommands || !ctx.hasUI) return;
1191
+ const reviewed = await ctx.ui.editor(
1192
+ `Review PR comments for PR #${pr.number} (edit or close to skip)`,
1193
+ `${findings}
1194
+
1195
+ ---
1196
+
1197
+ Proposed commands (run these to post):
1198
+
1199
+ ${proposedCommands}`
1200
+ );
1201
+ if (reviewed == null) return;
1202
+ const action = await ctx.ui.select("Post these review comments?", ["Post comments", "Skip"]);
1203
+ if (action !== "Post comments") return;
1204
+ const commands = extractGhCommands(reviewed);
1205
+ for (const cmd of commands) {
1206
+ await execFn(cmd, cwd);
1207
+ }
1208
+ }
1209
+
1210
+ // src/pipelines/review-diff.ts
1211
+ async function resolveDiffTarget(cwd, target, execFn = execSafe) {
1212
+ if (target.match(/^\d+$/)) {
1213
+ return { diffCmd: `gh pr diff ${target}`, prNumber: target };
1214
+ }
1215
+ if (target.startsWith("--branch")) {
1216
+ const branch = target.replace("--branch", "").trim() || "HEAD";
1217
+ return { diffCmd: `git diff main...${branch}` };
1218
+ }
1219
+ let prNumber;
1220
+ const pr = await execFn("gh pr view --json number --jq .number", cwd);
1221
+ if (pr && pr !== "") {
1222
+ prNumber = pr;
1223
+ }
1224
+ return { diffCmd: "git diff main...HEAD", prNumber };
1225
+ }
1226
+
1227
+ // src/pipelines/review.ts
1228
+ var reviewResult = (text, stages, isError) => ({
1229
+ content: [{ type: "text", text }],
1230
+ ...isError ? { isError } : {},
1231
+ details: { pipeline: "review", stages }
1232
+ });
1233
+ async function runReview(cwd, target, signal, onUpdate, ctx, customPrompt) {
1234
+ const stages = [];
1235
+ const { diffCmd, prNumber } = await resolveDiffTarget(cwd, target);
1236
+ if (ctx.hasUI && !customPrompt) {
1237
+ const extra = await ctx.ui.input("Additional instructions?", "Skip");
1238
+ if (extra?.trim()) customPrompt = extra.trim();
1239
+ }
1240
+ const diff = await exec(diffCmd, cwd);
1241
+ if (!diff) return reviewResult("No changes to review.", stages);
1242
+ const result2 = await runReviewPipeline(diff, { cwd, signal, stages, pipeline: "review", onUpdate, customPrompt });
1243
+ if (result2.passed) return reviewResult("Review passed \u2014 no actionable findings.", stages);
1244
+ const findings = result2.findings ?? "";
1245
+ if (ctx.hasUI && prNumber) {
1246
+ const repo = await exec("gh repo view --json nameWithOwner --jq .nameWithOwner", cwd);
1247
+ await proposeAndPostComments(
1248
+ findings,
1249
+ { number: prNumber, repo },
1250
+ { cwd, signal, stages, ctx, pipeline: "review", onUpdate }
1251
+ );
1252
+ }
1253
+ return reviewResult(findings, stages, true);
1254
+ }
1255
+
1015
1256
  // src/index.ts
1016
1257
  function parseImplFlags(args) {
1017
1258
  const skipPlan = args.includes("--skip-plan");
@@ -1042,47 +1283,6 @@ function parseReviewArgs(args) {
1042
1283
  customPrompt: trimmed.slice(firstSpace + 1).trim().replace(/^"(.*)"$/, "$1")
1043
1284
  };
1044
1285
  }
1045
- function getDisplayItems(messages) {
1046
- const items = [];
1047
- for (const msg of messages) {
1048
- if (msg.role === "assistant") {
1049
- for (const part of msg.content) {
1050
- if (part.type === "text") items.push({ type: "text", text: part.text });
1051
- else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
1052
- }
1053
- }
1054
- }
1055
- return items;
1056
- }
1057
- function formatToolCallShort(name, args, fg) {
1058
- switch (name) {
1059
- case "bash": {
1060
- const cmd = args.command || "...";
1061
- return fg("muted", "$ ") + fg("toolOutput", cmd.length > 60 ? `${cmd.slice(0, 60)}...` : cmd);
1062
- }
1063
- case "read":
1064
- return fg("muted", "read ") + fg("accent", args.file_path || args.path || "...");
1065
- case "write":
1066
- return fg("muted", "write ") + fg("accent", args.file_path || args.path || "...");
1067
- case "edit":
1068
- return fg("muted", "edit ") + fg("accent", args.file_path || args.path || "...");
1069
- case "grep":
1070
- return fg("muted", "grep ") + fg("accent", `/${args.pattern || ""}/`);
1071
- case "find":
1072
- return fg("muted", "find ") + fg("accent", args.pattern || "*");
1073
- default:
1074
- return fg("accent", name);
1075
- }
1076
- }
1077
- function formatUsage(usage, model) {
1078
- const parts = [];
1079
- if (usage.turns) parts.push(`${usage.turns}t`);
1080
- if (usage.input) parts.push(`\u2191${usage.input < 1e3 ? usage.input : `${Math.round(usage.input / 1e3)}k`}`);
1081
- if (usage.output) parts.push(`\u2193${usage.output < 1e3 ? usage.output : `${Math.round(usage.output / 1e3)}k`}`);
1082
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
1083
- if (model) parts.push(model);
1084
- return parts.join(" ");
1085
- }
1086
1286
  var ForgeflowDevParams = Type.Object({
1087
1287
  pipeline: Type.String({
1088
1288
  description: 'Which pipeline to run: "implement", "implement-all", "review", "architecture", or "discover-skills"'
@@ -1161,85 +1361,13 @@ function registerForgeflowDevTool(pi) {
1161
1361
  text += theme.fg("dim", `${prefix}${args.issue}`);
1162
1362
  }
1163
1363
  if (args.target) text += theme.fg("dim", ` ${args.target}`);
1164
- return new Text(text, 0, 0);
1364
+ return new Text2(text, 0, 0);
1165
1365
  },
1166
- renderResult(result, { expanded }, theme) {
1167
- const details = result.details;
1168
- if (!details || details.stages.length === 0) {
1169
- const text = result.content[0];
1170
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1171
- }
1172
- if (expanded) {
1173
- return renderExpanded(details, theme);
1174
- }
1175
- return renderCollapsed(details, theme);
1366
+ renderResult(result2, { expanded }, theme) {
1367
+ return renderResult(result2, expanded, theme, "forgeflow-dev");
1176
1368
  }
1177
1369
  });
1178
1370
  }
1179
- function renderExpanded(details, theme) {
1180
- const container = new Container();
1181
- container.addChild(
1182
- new Text(theme.fg("toolTitle", theme.bold("forgeflow-dev ")) + theme.fg("accent", details.pipeline), 0, 0)
1183
- );
1184
- container.addChild(new Spacer(1));
1185
- for (const stage of details.stages) {
1186
- const icon = stageIcon(stage, theme);
1187
- container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(stage.name))}`, 0, 0));
1188
- const items = getDisplayItems(stage.messages);
1189
- for (const item of items) {
1190
- if (item.type === "toolCall") {
1191
- container.addChild(
1192
- new Text(
1193
- ` ${theme.fg("muted", "\u2192 ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`,
1194
- 0,
1195
- 0
1196
- )
1197
- );
1198
- }
1199
- }
1200
- const output = getFinalOutput(stage.messages);
1201
- if (output) {
1202
- container.addChild(new Spacer(1));
1203
- try {
1204
- const { getMarkdownTheme } = __require("@mariozechner/pi-coding-agent");
1205
- container.addChild(new Markdown(output.trim(), 0, 0, getMarkdownTheme()));
1206
- } catch {
1207
- container.addChild(new Text(theme.fg("toolOutput", output.slice(0, 500)), 0, 0));
1208
- }
1209
- }
1210
- const usageStr = formatUsage(stage.usage, stage.model);
1211
- if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
1212
- container.addChild(new Spacer(1));
1213
- }
1214
- return container;
1215
- }
1216
- function renderCollapsed(details, theme) {
1217
- let text = theme.fg("toolTitle", theme.bold("forgeflow-dev ")) + theme.fg("accent", details.pipeline);
1218
- for (const stage of details.stages) {
1219
- const icon = stageIcon(stage, theme);
1220
- text += `
1221
- ${icon} ${theme.fg("toolTitle", stage.name)}`;
1222
- if (stage.status === "running") {
1223
- const items = getDisplayItems(stage.messages);
1224
- const last = items.filter((i) => i.type === "toolCall").slice(-3);
1225
- for (const item of last) {
1226
- if (item.type === "toolCall") {
1227
- text += `
1228
- ${theme.fg("muted", "\u2192 ")}${formatToolCallShort(item.name, item.args, theme.fg.bind(theme))}`;
1229
- }
1230
- }
1231
- } else if (stage.status === "done" || stage.status === "failed") {
1232
- const preview = stage.output.split("\n")[0]?.slice(0, 80) || "(no output)";
1233
- text += theme.fg("dim", ` ${preview}`);
1234
- const usageStr = formatUsage(stage.usage, stage.model);
1235
- if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
1236
- }
1237
- }
1238
- return new Text(text, 0, 0);
1239
- }
1240
- function stageIcon(stage, theme) {
1241
- return stage.status === "done" ? theme.fg("success", "\u2713") : stage.status === "running" ? theme.fg("warning", "\u27F3") : stage.status === "failed" ? theme.fg("error", "\u2717") : theme.fg("muted", "\u25CB");
1242
- }
1243
1371
  var extension = (pi) => {
1244
1372
  registerForgeflowDevTool(pi);
1245
1373
  pi.registerCommand("implement", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callumvass/forgeflow-dev",
3
- "version": "0.4.4",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Dev pipeline for Pi — TDD implementation, code review, architecture, and skill discovery.",
6
6
  "keywords": [