@fiftth/fiftth-cli 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.fiftthnexus/.github/workflows/copilot-orchestrator.yml +78 -0
  2. package/.fiftthnexus/actions/Dockerfile +34 -0
  3. package/.fiftthnexus/actions/copilot-agent.mjs +269 -0
  4. package/.fiftthnexus/actions/package.json +8 -0
  5. package/.fiftthnexus/orchestrator.ts +2304 -0
  6. package/.fiftthnexus/skills/env-implement-prompt.md +65 -0
  7. package/.fiftthnexus/skills/env-plan-prompt.md +33 -0
  8. package/.fiftthnexus/skills/env-review-prompt.md +61 -0
  9. package/.fiftthnexus/skills/grill-me.md +9 -0
  10. package/.fiftthnexus/skills/prd-to-issues.md +150 -0
  11. package/.fiftthnexus/skills/write-prd.md +70 -0
  12. package/README.md +216 -25
  13. package/dist/api/client.d.ts +6 -0
  14. package/dist/api/client.d.ts.map +1 -1
  15. package/dist/api/client.js +13 -2
  16. package/dist/api/client.js.map +1 -1
  17. package/dist/commands/checkout.d.ts +6 -1
  18. package/dist/commands/checkout.d.ts.map +1 -1
  19. package/dist/commands/checkout.js +415 -44
  20. package/dist/commands/checkout.js.map +1 -1
  21. package/dist/commands/login.d.ts +0 -2
  22. package/dist/commands/login.d.ts.map +1 -1
  23. package/dist/commands/login.js +83 -32
  24. package/dist/commands/login.js.map +1 -1
  25. package/dist/commands/model.d.ts +2 -0
  26. package/dist/commands/model.d.ts.map +1 -0
  27. package/dist/commands/model.js +32 -0
  28. package/dist/commands/model.js.map +1 -0
  29. package/dist/commands/planningContext.d.ts +6 -0
  30. package/dist/commands/planningContext.d.ts.map +1 -0
  31. package/dist/commands/planningContext.js +91 -0
  32. package/dist/commands/planningContext.js.map +1 -0
  33. package/dist/commands/repo.d.ts.map +1 -1
  34. package/dist/commands/repo.js +38 -15
  35. package/dist/commands/repo.js.map +1 -1
  36. package/dist/commands/skills.d.ts +2 -0
  37. package/dist/commands/skills.d.ts.map +1 -0
  38. package/dist/commands/skills.js +123 -0
  39. package/dist/commands/skills.js.map +1 -0
  40. package/dist/commands/use.d.ts +1 -5
  41. package/dist/commands/use.d.ts.map +1 -1
  42. package/dist/commands/use.js +63 -48
  43. package/dist/commands/use.js.map +1 -1
  44. package/dist/index.js +86 -27
  45. package/dist/index.js.map +1 -1
  46. package/dist/services/nexusService.d.ts +30 -0
  47. package/dist/services/nexusService.d.ts.map +1 -0
  48. package/dist/services/nexusService.js +188 -0
  49. package/dist/services/nexusService.js.map +1 -0
  50. package/dist/services/prdService.d.ts +12 -0
  51. package/dist/services/prdService.d.ts.map +1 -0
  52. package/dist/services/prdService.js +103 -0
  53. package/dist/services/prdService.js.map +1 -0
  54. package/dist/services/taskSelection.d.ts +10 -0
  55. package/dist/services/taskSelection.d.ts.map +1 -0
  56. package/dist/services/taskSelection.js +112 -0
  57. package/dist/services/taskSelection.js.map +1 -0
  58. package/dist/services/taskService.d.ts +23 -1
  59. package/dist/services/taskService.d.ts.map +1 -1
  60. package/dist/services/taskService.js +118 -12
  61. package/dist/services/taskService.js.map +1 -1
  62. package/dist/utils/config.d.ts +2 -0
  63. package/dist/utils/config.d.ts.map +1 -1
  64. package/dist/utils/config.js +20 -3
  65. package/dist/utils/config.js.map +1 -1
  66. package/dist/utils/dashboard.d.ts +65 -0
  67. package/dist/utils/dashboard.d.ts.map +1 -0
  68. package/dist/utils/dashboard.js +205 -0
  69. package/dist/utils/dashboard.js.map +1 -0
  70. package/dist/utils/models.d.ts +14 -0
  71. package/dist/utils/models.d.ts.map +1 -0
  72. package/dist/utils/models.js +89 -0
  73. package/dist/utils/models.js.map +1 -0
  74. package/dist/utils/ui.d.ts +6 -0
  75. package/dist/utils/ui.d.ts.map +1 -1
  76. package/dist/utils/ui.js +22 -1
  77. package/dist/utils/ui.js.map +1 -1
  78. package/dist/utils/version.d.ts +4 -0
  79. package/dist/utils/version.d.ts.map +1 -0
  80. package/dist/utils/version.js +26 -0
  81. package/dist/utils/version.js.map +1 -0
  82. package/package.json +9 -4
  83. package/.github/workflows/publish-npm.yml +0 -62
  84. package/dist/commands/tasks.d.ts +0 -2
  85. package/dist/commands/tasks.d.ts.map +0 -1
  86. package/dist/commands/tasks.js +0 -69
  87. package/dist/commands/tasks.js.map +0 -1
  88. package/dist/context/runtimeContext.d.ts +0 -14
  89. package/dist/context/runtimeContext.d.ts.map +0 -1
  90. package/dist/context/runtimeContext.js +0 -21
  91. package/dist/context/runtimeContext.js.map +0 -1
  92. package/dist/services/taskContext.d.ts +0 -14
  93. package/dist/services/taskContext.d.ts.map +0 -1
  94. package/dist/services/taskContext.js +0 -15
  95. package/dist/services/taskContext.js.map +0 -1
  96. package/dist/utils/api.d.ts +0 -10
  97. package/dist/utils/api.d.ts.map +0 -1
  98. package/dist/utils/api.js +0 -25
  99. package/dist/utils/api.js.map +0 -1
  100. package/src/api/client.ts +0 -31
  101. package/src/commands/checkout.ts +0 -101
  102. package/src/commands/login.ts +0 -145
  103. package/src/commands/repo.ts +0 -113
  104. package/src/commands/tasks.ts +0 -86
  105. package/src/commands/use.ts +0 -149
  106. package/src/config/configService.ts +0 -56
  107. package/src/context/runtimeContext.ts +0 -42
  108. package/src/git/gitService.ts +0 -29
  109. package/src/index.ts +0 -133
  110. package/src/services/taskContext.ts +0 -32
  111. package/src/services/taskService.ts +0 -53
  112. package/src/utils/api.ts +0 -41
  113. package/src/utils/config.ts +0 -48
  114. package/src/utils/ui.ts +0 -46
  115. package/tsconfig.json +0 -18
  116. package/vitest.config.ts +0 -8
@@ -0,0 +1,2304 @@
1
+ import { execFileSync, execSync, spawn } from "child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { basename, dirname, resolve } from "path";
4
+ import { createInterface } from "readline";
5
+ import { fileURLToPath } from "url";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Resolve executable paths (handles Windows PATH gaps for gh CLI)
9
+ // ---------------------------------------------------------------------------
10
+ function resolveExe(name: string, windowsFallbacks: string[] = []): string {
11
+ try {
12
+ const found = execSync(
13
+ process.platform === "win32" ? `where.exe ${name}` : `which ${name}`,
14
+ { encoding: "utf-8", stdio: "pipe" },
15
+ ).split("\n")[0]?.trim();
16
+ if (found) return found;
17
+ } catch {
18
+ // not in PATH
19
+ }
20
+
21
+ for (const fallback of windowsFallbacks) {
22
+ if (existsSync(fallback)) return fallback;
23
+ }
24
+
25
+ return name; // Return bare name; error will surface when it's used
26
+ }
27
+
28
+ const GH_EXEC = resolveExe("gh", [
29
+ "C:\\Program Files\\GitHub CLI\\gh.exe",
30
+ `${process.env.LOCALAPPDATA ?? ""}\\Programs\\GitHub CLI\\gh.exe`,
31
+ `${process.env.ProgramFiles ?? ""}\\GitHub CLI\\gh.exe`,
32
+ ]);
33
+ const ORCHESTRATOR_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // ANSI Colors & Formatting
37
+ // ---------------------------------------------------------------------------
38
+ const c = {
39
+ reset: "\x1b[0m",
40
+ bold: "\x1b[1m",
41
+ dim: "\x1b[2m",
42
+ red: "\x1b[38;2;224;122;95m",
43
+ green: "\x1b[38;2;245;241;232m",
44
+ yellow: "\x1b[38;2;216;181;106m",
45
+ blue: "\x1b[38;2;122;122;122m",
46
+ magenta: "\x1b[38;2;200;169;106m",
47
+ cyan: "\x1b[38;2;200;169;106m",
48
+ white: "\x1b[37m",
49
+ bgRed: "\x1b[41m",
50
+ bgGreen: "\x1b[42m",
51
+ bgBlue: "\x1b[44m",
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Types
56
+ // ---------------------------------------------------------------------------
57
+ interface GitHubIssue {
58
+ number: number;
59
+ title: string;
60
+ body: string;
61
+ labels: { name: string }[];
62
+ assignees: { login: string }[];
63
+ state: string;
64
+ repoFullName: string;
65
+ repoPath: string;
66
+ targetBranch: string;
67
+ selectedPrdNumber: number | null;
68
+ taskId: string | null;
69
+ key: string;
70
+ }
71
+
72
+ interface RepositoryExecutionContext {
73
+ repoFullName: string;
74
+ repoPath: string;
75
+ prdNumber?: number;
76
+ taskId?: string;
77
+ targetBranch?: string;
78
+ }
79
+
80
+ interface RankedIssue extends GitHubIssue {
81
+ priority: number;
82
+ dependsOn: number[];
83
+ isBlocked: boolean;
84
+ isInProgress: boolean;
85
+ slug: string;
86
+ parentPrdNumber: number | null;
87
+ worktreeKey: string;
88
+ }
89
+
90
+ interface CLIOptions {
91
+ mode: "run" | "status" | "cleanup" | "reset";
92
+ maxParallel: number;
93
+ model: string;
94
+ push: boolean;
95
+ allPrds: boolean;
96
+ verbose: boolean;
97
+ worktree: boolean;
98
+ prd: number | null;
99
+ repo: string;
100
+ targetBranch: string;
101
+ repositoryContexts: RepositoryExecutionContext[];
102
+ ghToken: string;
103
+ }
104
+
105
+ type IssueUiState = "queued" | "planning" | "implementing" | "reviewing" | "done" | "failed" | "timed-out";
106
+
107
+ interface StageResult {
108
+ success: boolean;
109
+ output: string;
110
+ toolCalls: number;
111
+ writes: number;
112
+ errorText: string;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Constants
117
+ // ---------------------------------------------------------------------------
118
+ const DOCKER_IMAGE = "copilot-agent";
119
+ const WORKTREE_DIR = ".worktrees";
120
+ const CONTAINER_PREFIX = "copilot-agent-";
121
+ const AGENT_OUTPUT_DIR = ".agent";
122
+ const ORCHESTRATOR_LABEL = "ai-running-orchestrator";
123
+ const MAX_ITERATIONS = 10;
124
+
125
+ const REQUIRED_REPOSITORY_LABELS = [
126
+ { name: "P0-critical", description: "Critical - execute first", color: "b60205" },
127
+ { name: "P1-high", description: "High priority", color: "d93f0b" },
128
+ { name: "P2-medium", description: "Medium priority", color: "fbca04" },
129
+ { name: "P3-low", description: "Low priority", color: "0e8a16" },
130
+ { name: "ai-running-orchestrator", description: "Issue managed by the Copilot orchestrator", color: "1d76db" },
131
+ { name: "blocked", description: "Blocked by another issue", color: "e4e669" },
132
+ { name: "in-progress", description: "Copilot is working on this", color: "7057ff" },
133
+ { name: "donne", description: "Completed by agent - excluded from orchestrator", color: "006b75" },
134
+ ] as const;
135
+
136
+ let currentUiMode: "default" | "run-minimal" | "run-verbose" = "default";
137
+
138
+ interface RunDashboardState {
139
+ active: boolean;
140
+ events: string[];
141
+ statsLine: string;
142
+ loadingText: string;
143
+ opts: CLIOptions | null;
144
+ liveStates: Map<string, { label: string; state: IssueUiState }>;
145
+ }
146
+
147
+ const RUN_DASHBOARD_LIMIT = 5;
148
+
149
+ const runDashboardState: RunDashboardState = {
150
+ active: false,
151
+ events: [],
152
+ statsLine: "",
153
+ loadingText: "warming nexus status",
154
+ opts: null,
155
+ liveStates: new Map(),
156
+ };
157
+
158
+ const QUIET_RUN_ICONS = new Set([
159
+ "[gh]",
160
+ "[docker]",
161
+ "[list]",
162
+ "[filter]",
163
+ "[worktree]",
164
+ "[reuse]",
165
+ "[save]",
166
+ "[commit]",
167
+ "[diff]",
168
+ "[hint]",
169
+ "[label]",
170
+ "[iter]",
171
+ ]);
172
+
173
+ const IS_INTEGRATED_UI = process.env.FIFTTH_NEXUS_EVENT_STREAM === "true";
174
+ const NEXUS_EVENT_PREFIX = "__FIFTTH_NEXUS_EVENT__";
175
+ const NEXUS_STATE_PREFIX = "__FIFTTH_NEXUS_STATE__";
176
+
177
+ // Copilot CLI headless server — not needed; SDK auto-starts inside Docker
178
+
179
+ // Prompt file paths (relative to project root)
180
+ const PROMPT_PLAN = ".fiftthnexus/skills/env-plan-prompt.md";
181
+ const PROMPT_IMPLEMENT = ".fiftthnexus/skills/env-implement-prompt.md";
182
+ const PROMPT_REVIEW = ".fiftthnexus/skills/env-review-prompt.md";
183
+
184
+ // Dockerfile path (relative to project root)
185
+ const DOCKERFILE_DIR = ".fiftthnexus/actions";
186
+
187
+ function loadRepositoryContexts(): RepositoryExecutionContext[] {
188
+ const raw = process.env.FIFTTH_NEXUS_REPOSITORIES;
189
+ if (!raw) {
190
+ return [];
191
+ }
192
+
193
+ try {
194
+ const parsed = JSON.parse(raw) as RepositoryExecutionContext[];
195
+ return parsed.filter((context) => Boolean(context.repoFullName && context.repoPath));
196
+ } catch {
197
+ return [];
198
+ }
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // CLI Argument Parser
203
+ // ---------------------------------------------------------------------------
204
+ function parseArgs(): CLIOptions {
205
+ const args = process.argv.slice(2);
206
+ const repositoryContexts = loadRepositoryContexts();
207
+ const opts: CLIOptions = {
208
+ mode: "run",
209
+ maxParallel: 3,
210
+ model: process.env.FIFTTH_DEFAULT_MODEL ?? "gpt-5.4",
211
+ push: false,
212
+ allPrds: false,
213
+ verbose: false,
214
+ worktree: false,
215
+ prd: null,
216
+ repo: repositoryContexts[0]?.repoFullName ?? inferGitHubRepo(),
217
+ targetBranch: repositoryContexts[0]?.targetBranch ?? "",
218
+ repositoryContexts,
219
+ ghToken: "",
220
+ };
221
+
222
+ for (let i = 0; i < args.length; i++) {
223
+ const arg = args[i]!;
224
+
225
+ switch (arg) {
226
+ case "--status":
227
+ opts.mode = "status";
228
+ break;
229
+ case "--cleanup":
230
+ opts.mode = "cleanup";
231
+ break;
232
+ case "--reset":
233
+ opts.mode = "reset";
234
+ break;
235
+ case "--max-parallel":
236
+ opts.maxParallel = parseInt(args[++i] ?? "3", 10);
237
+ break;
238
+ case "--model":
239
+ opts.model = args[++i] ?? (process.env.FIFTTH_DEFAULT_MODEL ?? "gpt-5.4");
240
+ break;
241
+ case "--push":
242
+ opts.push = true;
243
+ break;
244
+ case "--all-prds":
245
+ opts.allPrds = true;
246
+ break;
247
+ case "--verbose":
248
+ opts.verbose = true;
249
+ break;
250
+ case "--worktree":
251
+ opts.worktree = true;
252
+ break;
253
+ case "--prd": {
254
+ const parsed = parseInt(args[++i] ?? "", 10);
255
+ if (Number.isNaN(parsed) || parsed <= 0) {
256
+ console.error(`${c.red}Invalid --prd value. Use a positive issue number.${c.reset}`);
257
+ process.exit(1);
258
+ }
259
+ opts.prd = parsed;
260
+ break;
261
+ }
262
+ case "--repo":
263
+ opts.repo = args[++i] ?? opts.repo;
264
+ break;
265
+ case "--target-branch":
266
+ opts.targetBranch = args[++i] ?? "";
267
+ break;
268
+ case "--help":
269
+ case "-h":
270
+ printHelp();
271
+ process.exit(0);
272
+ default:
273
+ console.error(`${c.red}Unknown argument: ${arg}${c.reset}`);
274
+ printHelp();
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ return opts;
280
+ }
281
+
282
+ function printHelp(): void {
283
+ console.log(`
284
+ ${c.bold}fiftth nexus${c.reset}
285
+
286
+ ${c.bold}USAGE:${c.reset}
287
+ npx tsx .fiftthnexus/orchestrator.ts [OPTIONS]
288
+
289
+ ${c.bold}MODES:${c.reset}
290
+ (default) Run the orchestrator loop
291
+ --status Show current state of issues and containers
292
+ --cleanup Remove all worktrees and containers
293
+ --reset Reset in-progress/blocked labels on all issues
294
+
295
+ ${c.bold}OPTIONS:${c.reset}
296
+ --max-parallel <n> Max containers in parallel (default: 3)
297
+ --model <name> Copilot model to use (default: ${process.env.FIFTTH_DEFAULT_MODEL ?? "gpt-5.4"})
298
+ --prd <n> Run only issues linked to parent PRD #n
299
+ --all-prds Run all eligible PRDs without selection prompt
300
+ --verbose Show full container/agent logs
301
+ --push Push branches and create PRs
302
+ --repo <owner/name> GitHub repository to use (default: inferred from git remote)
303
+ --worktree Create the orchestrator branch in a git worktree from --target-branch
304
+ --target-branch <b> Base branch used when --worktree is enabled
305
+
306
+ ${c.bold}ENVIRONMENT:${c.reset}
307
+ (none required - uses gh CLI keyring automatically)
308
+ `);
309
+ }
310
+
311
+ function stripAnsi(value: string): string {
312
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
313
+ }
314
+
315
+ function padRight(value: string, width: number): string {
316
+ const visibleLength = stripAnsi(value).length;
317
+ if (visibleLength >= width) {
318
+ return value;
319
+ }
320
+
321
+ return `${value}${" ".repeat(width - visibleLength)}`;
322
+ }
323
+
324
+ function renderPanelLines(title: string, lines: string[], width?: number): string[] {
325
+ const safeLines = lines.length > 0 ? lines : [""];
326
+ const targetWidth = width ?? Math.max(title.length + 4, ...safeLines.map((line) => stripAnsi(line).length)) + 2;
327
+ const border = `${c.cyan}+${"-".repeat(targetWidth + 2)}+${c.reset}`;
328
+
329
+ return [
330
+ border,
331
+ `${c.cyan}|${c.reset} ${padRight(`${c.bold}${title}${c.reset}`, targetWidth)} ${c.cyan}|${c.reset}`,
332
+ border,
333
+ ...safeLines.map((line) => `${c.cyan}|${c.reset} ${padRight(line, targetWidth)} ${c.cyan}|${c.reset}`),
334
+ border,
335
+ ];
336
+ }
337
+
338
+ function joinPanelColumns(columns: string[][], gap = 2): string[] {
339
+ const maxHeight = Math.max(...columns.map((column) => column.length));
340
+ const widths = columns.map((column) => Math.max(...column.map((line) => stripAnsi(line).length)));
341
+
342
+ return Array.from({ length: maxHeight }, (_, index) => columns
343
+ .map((column, columnIndex) => padRight(column[index] ?? "", widths[columnIndex] ?? 0))
344
+ .join(" ".repeat(gap)));
345
+ }
346
+
347
+ function countRunningContainers(): number {
348
+ try {
349
+ const output = sh('docker ps --format "{{.Names}}"', { silent: true });
350
+ if (!output) {
351
+ return 0;
352
+ }
353
+
354
+ return output.split("\n").filter(Boolean).length;
355
+ } catch {
356
+ return 0;
357
+ }
358
+ }
359
+
360
+ function formatEventLine(icon: string, msg: string): string {
361
+ const tone =
362
+ icon === "[error]" || icon === "[fail]" || icon === "[abort]"
363
+ ? c.red
364
+ : icon === "[warn]" || icon === "[pause]"
365
+ ? c.yellow
366
+ : icon === "[ok]" || icon === "[done]"
367
+ ? c.green
368
+ : c.white;
369
+ const ts = new Date().toISOString().slice(11, 19);
370
+ return `${c.dim}${ts}${c.reset} ${tone}${msg}${c.reset}`;
371
+ }
372
+
373
+ function compactIssueLabel(label: string): string {
374
+ const match = label.match(/([^/]+\/)?([^#]+)#(\d+)$/);
375
+ if (!match) {
376
+ return label;
377
+ }
378
+
379
+ return `${match[2]} #${match[3]}`;
380
+ }
381
+
382
+ function compactStateLabel(state: IssueUiState): string {
383
+ switch (state) {
384
+ case "planning":
385
+ return "agent planning";
386
+ case "implementing":
387
+ return "agent implementing";
388
+ case "reviewing":
389
+ return "agent reviewing";
390
+ case "done":
391
+ return "done";
392
+ case "failed":
393
+ return "failed";
394
+ case "timed-out":
395
+ return "timed out";
396
+ default:
397
+ return "queued";
398
+ }
399
+ }
400
+
401
+ function renderRunDashboard(): string {
402
+ const opts = runDashboardState.opts;
403
+ if (!opts) {
404
+ return "";
405
+ }
406
+
407
+ const contexts = repositoryContextsForRun(opts);
408
+ const repoLabel = opts.repositoryContexts.length > 0
409
+ ? contexts.length === 1
410
+ ? contexts[0]!.repoFullName
411
+ : `${contexts.length} repositories`
412
+ : opts.repo || "(current gh context)";
413
+ const scopeLabel = opts.repositoryContexts.length > 0
414
+ ? "task-scoped PRDs from checkout"
415
+ : opts.prd
416
+ ? `PRD #${opts.prd}`
417
+ : opts.allPrds
418
+ ? "all PRDs"
419
+ : "select PRD on startup";
420
+ const activityLines = runDashboardState.events.length > 0
421
+ ? runDashboardState.events
422
+ : [`${c.dim}Waiting for activity...${c.reset}`];
423
+ const liveStates = [...runDashboardState.liveStates.values()]
424
+ .filter((entry) => entry.state === "planning" || entry.state === "implementing" || entry.state === "reviewing" || entry.state === "queued")
425
+ .slice(0, 6)
426
+ .map((entry) => ` ${compactIssueLabel(entry.label)}: ${compactStateLabel(entry.state)}`);
427
+
428
+ return [
429
+ "",
430
+ `${fiftthMark()} ${c.bold}fiftth nexus${c.reset}`,
431
+ `${c.dim}repo${c.reset} ${repoLabel}`,
432
+ `${c.dim}scope${c.reset} ${scopeLabel}`,
433
+ `${c.dim}workspace${c.reset} ${process.env.FIFTTH_WORKSPACE || "(none)"} ${c.dim}model${c.reset} ${opts.model} ${c.dim}mode${c.reset} ${opts.worktree ? "worktree" : "literal checkout"}`,
434
+ `${c.dim}parallel${c.reset} ${opts.worktree ? opts.maxParallel : 1} ${c.dim}containers${c.reset} ${countRunningContainers()}`,
435
+ "",
436
+ `${c.cyan}. . . ....${c.reset} ${runDashboardState.loadingText}`,
437
+ "",
438
+ `${c.bold}status${c.reset}`,
439
+ `${runDashboardState.statsLine || `${c.dim}No pipeline stats yet${c.reset}`}`,
440
+ "",
441
+ `${c.bold}active agents${c.reset}`,
442
+ ...(liveStates.length > 0 ? liveStates : [` ${c.dim}No active agents${c.reset}`]),
443
+ "",
444
+ `${c.bold}recent${c.reset}`,
445
+ ...activityLines.map((line) => ` ${line}`),
446
+ "",
447
+ ].join("\n");
448
+ }
449
+
450
+ function startRunDashboard(opts: CLIOptions): void {
451
+ if (!process.stdout.isTTY || !process.stdin.isTTY || currentUiMode !== "run-minimal") {
452
+ return;
453
+ }
454
+
455
+ runDashboardState.active = true;
456
+ runDashboardState.opts = opts;
457
+ runDashboardState.events = [];
458
+ runDashboardState.statsLine = "warming the orchestration pipeline";
459
+ runDashboardState.loadingText = "warming nexus status";
460
+ process.stdout.write("\n");
461
+ process.stdout.write("\x1b[s");
462
+ process.stdout.write(renderRunDashboard());
463
+ }
464
+
465
+ function refreshRunDashboard(): void {
466
+ if (!runDashboardState.active) {
467
+ return;
468
+ }
469
+
470
+ process.stdout.write("\x1b[u\x1b[J\x1b[s");
471
+ process.stdout.write(renderRunDashboard());
472
+ }
473
+
474
+ function stopRunDashboard(printSnapshot = true): void {
475
+ if (!runDashboardState.active) {
476
+ return;
477
+ }
478
+
479
+ const snapshot = renderRunDashboard();
480
+ runDashboardState.active = false;
481
+ process.stdout.write("\x1b[u\x1b[J");
482
+ if (printSnapshot) {
483
+ console.log(snapshot);
484
+ }
485
+ }
486
+
487
+ function inferGitHubRepo(): string {
488
+ const fromEnv = process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY;
489
+ if (fromEnv) {
490
+ return fromEnv;
491
+ }
492
+
493
+ try {
494
+ const remote = sh("git remote get-url origin", { silent: true });
495
+ if (!remote) {
496
+ return "";
497
+ }
498
+
499
+ const httpsMatch = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/i);
500
+ if (httpsMatch) {
501
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
502
+ }
503
+ } catch {
504
+ // ignore and fallback to empty
505
+ }
506
+
507
+ return "";
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Helpers: Shell
512
+ // ---------------------------------------------------------------------------
513
+ function sh(cmd: string, opts?: { cwd?: string; silent?: boolean }): string {
514
+ try {
515
+ return execSync(cmd, {
516
+ encoding: "utf-8",
517
+ cwd: opts?.cwd,
518
+ stdio: opts?.silent ? "pipe" : ["pipe", "pipe", "pipe"],
519
+ maxBuffer: 10 * 1024 * 1024,
520
+ env: { ...process.env },
521
+ }).trim();
522
+ } catch (err: unknown) {
523
+ if (!opts?.silent) {
524
+ throw err;
525
+ }
526
+ if (err && typeof err === "object" && "stdout" in err) {
527
+ const stdout = (err as { stdout?: unknown }).stdout;
528
+ if (stdout && typeof stdout === "object" && "toString" in stdout) {
529
+ return String((stdout as { toString: () => string }).toString()).trim();
530
+ }
531
+ }
532
+ return "";
533
+ }
534
+ }
535
+
536
+ // Environment for gh subprocesses — strips token vars so gh always uses its keyring.
537
+ function ghEnv(): NodeJS.ProcessEnv {
538
+ const env = { ...process.env };
539
+ delete env["GH_TOKEN"];
540
+ delete env["GITHUB_TOKEN"];
541
+ return env;
542
+ }
543
+
544
+ function gh(args: string, repo?: string): string {
545
+ const repoArg = repo ? `-R "${repo}" ` : "";
546
+ return execSync(`"${GH_EXEC}" ${repoArg}${args}`, {
547
+ encoding: "utf-8",
548
+ stdio: ["pipe", "pipe", "pipe"],
549
+ maxBuffer: 10 * 1024 * 1024,
550
+ env: ghEnv(),
551
+ }).trim();
552
+ }
553
+
554
+ function ghJson<T>(args: string, repo?: string): T {
555
+ const repoArg = repo ? `-R "${repo}" ` : "";
556
+ const cmd = `"${GH_EXEC}" ${repoArg}${args}`;
557
+ let result = "";
558
+ try {
559
+ result = execSync(cmd, {
560
+ encoding: "utf-8",
561
+ stdio: ["pipe", "pipe", "pipe"],
562
+ maxBuffer: 10 * 1024 * 1024,
563
+ env: ghEnv(),
564
+ }).trim();
565
+ } catch (err: unknown) {
566
+ const stderr =
567
+ err && typeof err === "object" && "stderr" in err
568
+ ? String((err as { stderr: unknown }).stderr ?? "").trim()
569
+ : "";
570
+ const stdout =
571
+ err && typeof err === "object" && "stdout" in err
572
+ ? String((err as { stdout: unknown }).stdout ?? "").trim()
573
+ : "";
574
+ if (stderr) {
575
+ console.error(`${c.red}[gh error]${c.reset} ${stderr}`);
576
+ }
577
+ // gh returns exit 1 on empty list in some versions — try stdout
578
+ if (stdout && stdout.startsWith("[")) {
579
+ result = stdout;
580
+ } else {
581
+ return [] as unknown as T;
582
+ }
583
+ }
584
+ if (!result) {
585
+ return [] as unknown as T;
586
+ }
587
+ try {
588
+ return JSON.parse(result) as T;
589
+ } catch {
590
+ console.error(`${c.red}[gh error]${c.reset} Failed to parse JSON: ${result.slice(0, 200)}`);
591
+ return [] as unknown as T;
592
+ }
593
+ }
594
+
595
+ function docker(args: string): string {
596
+ return sh(`docker ${args}`);
597
+ }
598
+
599
+ function parseCommandArgs(command: string): string[] {
600
+ const args: string[] = [];
601
+ let current = "";
602
+ let quote: '"' | "'" | null = null;
603
+
604
+ for (let index = 0; index < command.length; index++) {
605
+ const char = command[index]!;
606
+
607
+ if (quote) {
608
+ if (char === quote) {
609
+ quote = null;
610
+ continue;
611
+ }
612
+
613
+ if (char === "\\" && quote === '"' && index + 1 < command.length) {
614
+ current += command[index + 1]!;
615
+ index++;
616
+ continue;
617
+ }
618
+
619
+ current += char;
620
+ continue;
621
+ }
622
+
623
+ if (char === '"' || char === "'") {
624
+ quote = char;
625
+ continue;
626
+ }
627
+
628
+ if (/\s/.test(char)) {
629
+ if (current) {
630
+ args.push(current);
631
+ current = "";
632
+ }
633
+ continue;
634
+ }
635
+
636
+ current += char;
637
+ }
638
+
639
+ if (quote) {
640
+ throw new Error(`Unterminated quote in command: ${command}`);
641
+ }
642
+
643
+ if (current) {
644
+ args.push(current);
645
+ }
646
+
647
+ return args;
648
+ }
649
+
650
+ function runDirectiveCommand(command: string, cwd?: string): string {
651
+ const parsed = parseCommandArgs(command);
652
+ if (parsed.length === 0) {
653
+ return "";
654
+ }
655
+
656
+ const [bin, ...args] = parsed;
657
+ const executable = bin === "gh" ? GH_EXEC : bin;
658
+ const env = bin === "gh" ? ghEnv() : { ...process.env };
659
+
660
+ return execFileSync(executable, args, {
661
+ encoding: "utf-8",
662
+ timeout: 30000,
663
+ maxBuffer: 5 * 1024 * 1024,
664
+ cwd: cwd ?? process.cwd(),
665
+ env,
666
+ }).trim();
667
+ }
668
+
669
+ function emitIntegratedEvent(icon: string, message: string): void {
670
+ if (!IS_INTEGRATED_UI) {
671
+ return;
672
+ }
673
+
674
+ process.stdout.write(`${NEXUS_EVENT_PREFIX}${JSON.stringify({ icon, message })}\n`);
675
+ }
676
+
677
+ function emitIntegratedState(
678
+ key: string,
679
+ label: string,
680
+ state: IssueUiState,
681
+ repoFullName?: string,
682
+ worktreeKey?: string,
683
+ issueNumber?: number,
684
+ ): void {
685
+ if (!IS_INTEGRATED_UI) {
686
+ return;
687
+ }
688
+
689
+ process.stdout.write(`${NEXUS_STATE_PREFIX}${JSON.stringify({ key, label, state, repoFullName, worktreeKey, issueNumber })}\n`);
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // Helpers: Logging
694
+ // ---------------------------------------------------------------------------
695
+ function log(icon: string, msg: string): void {
696
+ if (IS_INTEGRATED_UI) {
697
+ emitIntegratedEvent(icon, msg);
698
+ return;
699
+ }
700
+
701
+ if (currentUiMode === "run-minimal" && QUIET_RUN_ICONS.has(icon)) {
702
+ return;
703
+ }
704
+
705
+ if (currentUiMode === "run-minimal") {
706
+ if (runDashboardState.active) {
707
+ runDashboardState.events = [
708
+ ...runDashboardState.events.slice(-(RUN_DASHBOARD_LIMIT - 1)),
709
+ formatEventLine(icon, msg),
710
+ ];
711
+ runDashboardState.loadingText = msg;
712
+ refreshRunDashboard();
713
+ return;
714
+ }
715
+
716
+ switch (icon) {
717
+ case "[error]":
718
+ console.log(`${c.red}${msg}${c.reset}`);
719
+ return;
720
+ case "[warn]":
721
+ case "[fail]":
722
+ case "[abort]":
723
+ console.log(`${c.yellow}${msg}${c.reset}`);
724
+ return;
725
+ case "[ok]":
726
+ console.log(`${c.green}${msg}${c.reset}`);
727
+ return;
728
+ default:
729
+ console.log(msg);
730
+ return;
731
+ }
732
+ }
733
+
734
+ const ts = new Date().toISOString().slice(11, 19);
735
+ console.log(`${c.dim}[${ts}]${c.reset} ${icon} ${msg}`);
736
+ }
737
+
738
+ function header(text: string): void {
739
+ const line = "=".repeat(60);
740
+ console.log(`\n${c.cyan}${line}${c.reset}`);
741
+ console.log(`${c.cyan}|${c.reset} ${c.bold}${text}${c.reset}`);
742
+ console.log(`${c.cyan}${line}${c.reset}\n`);
743
+ }
744
+
745
+ function box(lines: string[]): void {
746
+ const maxLen = Math.max(...lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").length));
747
+ const border = "-".repeat(maxLen + 2);
748
+ console.log(`${c.dim}+${border}+${c.reset}`);
749
+ for (const line of lines) {
750
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
751
+ const pad = " ".repeat(maxLen - plain.length);
752
+ console.log(`${c.dim}|${c.reset} ${line}${pad} ${c.dim}|${c.reset}`);
753
+ }
754
+ console.log(`${c.dim}+${border}+${c.reset}`);
755
+ }
756
+
757
+ function fiftthMark(color = c.cyan): string {
758
+ return `${color}■ ■ ■ ■■■■${c.reset}`;
759
+ }
760
+
761
+ function printRunIntro(opts: CLIOptions): void {
762
+ if (IS_INTEGRATED_UI) {
763
+ log("[run]", "nexus dashboard ready");
764
+ return;
765
+ }
766
+
767
+ if (currentUiMode === "run-minimal") {
768
+ startRunDashboard(opts);
769
+ log("[run]", "nexus dashboard ready");
770
+ return;
771
+ }
772
+
773
+ const contexts = repositoryContextsForRun(opts);
774
+ const scopedByCheckout = opts.repositoryContexts.length > 0;
775
+ const repoLabel = scopedByCheckout
776
+ ? contexts.length === 1
777
+ ? contexts[0]!.repoFullName
778
+ : `${contexts.length} repositories`
779
+ : opts.repo || "(current gh context)";
780
+ const scopeLabel = scopedByCheckout
781
+ ? "task-scoped PRDs from checkout"
782
+ : opts.prd
783
+ ? `PRD #${opts.prd}`
784
+ : opts.allPrds
785
+ ? "all PRDs"
786
+ : "select PRD on startup";
787
+
788
+ console.log();
789
+ console.log(`${fiftthMark()} ${c.bold}fiftth nexus${c.reset}`);
790
+ console.log(`${c.dim}repo${c.reset} ${repoLabel}`);
791
+ console.log(`${c.dim}scope${c.reset} ${scopeLabel}`);
792
+ console.log(`${c.dim}mode${c.reset} ${opts.worktree ? "worktree" : "literal checkout"}`);
793
+ if (opts.targetBranch) {
794
+ console.log(`${c.dim}target branch${c.reset} ${opts.targetBranch}`);
795
+ }
796
+ console.log(`${c.dim}parallel${c.reset} ${opts.worktree ? opts.maxParallel : 1} ${c.dim}model${c.reset} ${opts.model} ${c.dim}log${c.reset} ${opts.verbose ? "verbose" : "minimal"}`);
797
+ console.log();
798
+ }
799
+
800
+ // ---------------------------------------------------------------------------
801
+ // Helpers: Slug / Names
802
+ // ---------------------------------------------------------------------------
803
+ function slugify(title: string): string {
804
+ return title
805
+ .toLowerCase()
806
+ .replace(/[^a-z0-9]+/g, "-")
807
+ .replace(/^-|-$/g, "")
808
+ .slice(0, 40);
809
+ }
810
+
811
+ function branchName(issue: GitHubIssue): string {
812
+ const parentPrdNumber = parseParentPrdNumber(issue.body);
813
+ if (parentPrdNumber !== null) {
814
+ return `orchestrator/prd-${parentPrdNumber}`;
815
+ }
816
+
817
+ return `orchestrator/issue-${issue.number}-${slugify(issue.title)}`;
818
+ }
819
+
820
+ function activeBranch(issue: GitHubIssue, opts: CLIOptions): string {
821
+ if (!opts.worktree && opts.targetBranch) {
822
+ return opts.targetBranch;
823
+ }
824
+
825
+ return branchName(issue);
826
+ }
827
+
828
+ function parseTaskIdMetadata(body: string | null): string | null {
829
+ if (!body) {
830
+ return null;
831
+ }
832
+
833
+ const match = body.match(/^task-id:\s*(.+)$/im);
834
+ const taskId = match?.[1]?.trim();
835
+ return taskId ? taskId : null;
836
+ }
837
+
838
+ function issueKey(repoFullName: string, issueNumber: number): string {
839
+ return `${repoFullName}#${issueNumber}`;
840
+ }
841
+
842
+ function containerName(issue: Pick<GitHubIssue, "repoFullName" | "number">): string {
843
+ const repoSlug = issue.repoFullName.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase();
844
+ return `${CONTAINER_PREFIX}${repoSlug}-${issue.number}`;
845
+ }
846
+
847
+ function worktreePath(issue: Pick<RankedIssue, "number" | "worktreeKey" | "repoPath">): string {
848
+ return resolve(issue.repoPath, WORKTREE_DIR, issue.worktreeKey || `issue-${issue.number}`);
849
+ }
850
+
851
+ function resolveStartPoint(targetBranch: string, repoPath = process.cwd()): string {
852
+ if (!targetBranch) {
853
+ return "HEAD";
854
+ }
855
+
856
+ if (sh(`git rev-parse --verify --quiet "refs/heads/${targetBranch}"`, { cwd: repoPath, silent: true })) {
857
+ return targetBranch;
858
+ }
859
+
860
+ if (sh(`git rev-parse --verify --quiet "refs/remotes/origin/${targetBranch}"`, { cwd: repoPath, silent: true })) {
861
+ return `origin/${targetBranch}`;
862
+ }
863
+
864
+ throw new Error(`Target branch not found locally or on origin: ${targetBranch}`);
865
+ }
866
+
867
+ function repositoryContextsForRun(opts: CLIOptions): RepositoryExecutionContext[] {
868
+ if (opts.repositoryContexts.length > 0) {
869
+ return opts.repositoryContexts;
870
+ }
871
+
872
+ return [{
873
+ repoFullName: opts.repo,
874
+ repoPath: process.cwd(),
875
+ prdNumber: opts.prd ?? undefined,
876
+ taskId: undefined,
877
+ targetBranch: opts.targetBranch,
878
+ }];
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // Helpers: Confirmation prompt
883
+ // ---------------------------------------------------------------------------
884
+ async function confirm(message: string): Promise<boolean> {
885
+ if (process.env.FIFTTH_NEXUS_AUTO_CONFIRM === "true") {
886
+ return true;
887
+ }
888
+
889
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
890
+ return new Promise((resolveAnswer) => {
891
+ rl.question(`\n${c.yellow}?${c.reset} ${message} ${c.dim}(y/N)${c.reset} `, (answer) => {
892
+ rl.close();
893
+ const normalized = answer.trim().toLowerCase();
894
+ resolveAnswer(normalized === "y" || normalized === "yes");
895
+ });
896
+ });
897
+ }
898
+
899
+ async function pickPrdKey(options: string[]): Promise<string | "all" | null> {
900
+ const sorted = [...options].sort((a, b) => {
901
+ const aNum = parseInt(a.replace("prd-", ""), 10);
902
+ const bNum = parseInt(b.replace("prd-", ""), 10);
903
+ return aNum - bNum;
904
+ });
905
+
906
+ console.log();
907
+ console.log(`${c.bold}select a PRD${c.reset}`);
908
+ for (let i = 0; i < sorted.length; i++) {
909
+ console.log(` ${c.cyan}${i + 1}.${c.reset} ${sorted[i]}`);
910
+ }
911
+ console.log(` ${c.cyan}a.${c.reset} all PRDs`);
912
+ console.log(` ${c.cyan}q.${c.reset} quit`);
913
+
914
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
915
+ return new Promise((resolveChoice) => {
916
+ rl.question(`\n${c.yellow}?${c.reset} Select option: `, (answer) => {
917
+ rl.close();
918
+ const normalized = answer.trim().toLowerCase();
919
+ if (normalized === "a" || normalized === "all") {
920
+ resolveChoice("all");
921
+ return;
922
+ }
923
+ if (normalized === "q" || normalized === "quit") {
924
+ resolveChoice(null);
925
+ return;
926
+ }
927
+ const idx = parseInt(normalized, 10);
928
+ if (Number.isNaN(idx) || idx < 1 || idx > sorted.length) {
929
+ resolveChoice(null);
930
+ return;
931
+ }
932
+ resolveChoice(sorted[idx - 1]!);
933
+ });
934
+ });
935
+ }
936
+
937
+ function uiStateLabel(state: IssueUiState): string {
938
+ switch (state) {
939
+ case "planning":
940
+ return "fiftth planning";
941
+ case "implementing":
942
+ return "fiftth implementing";
943
+ case "reviewing":
944
+ return "fiftth reviewing";
945
+ case "done":
946
+ return "fiftth done";
947
+ case "timed-out":
948
+ return "fiftth timed out";
949
+ case "failed":
950
+ return "fiftth failed";
951
+ default:
952
+ return "fiftth queued";
953
+ }
954
+ }
955
+
956
+ function startLiveTicker(
957
+ getLine: () => string,
958
+ intervalMs = 130,
959
+ ): { stop: () => void } {
960
+ const frames = ["■ ■ ■ ■■■■", "■ ■ ■■ ■■■", "■ ■■■■ ■ ■", "■■■■ ■ ■ ■"];
961
+ let frameIndex = 0;
962
+ let timer: NodeJS.Timeout | null = null;
963
+
964
+ const render = () => {
965
+ const frame = frames[frameIndex % frames.length]!;
966
+ frameIndex++;
967
+ const line = `${c.dim}${frame}${c.reset} ${getLine()}`;
968
+ process.stdout.write(`\r${line} `);
969
+ };
970
+
971
+ render();
972
+ timer = setInterval(render, intervalMs);
973
+
974
+ return {
975
+ stop: () => {
976
+ if (timer) clearInterval(timer);
977
+ process.stdout.write("\r\x1b[K");
978
+ },
979
+ };
980
+ }
981
+
982
+ // ---------------------------------------------------------------------------
983
+ // Phase 1: Collect & Parse Issues
984
+ // ---------------------------------------------------------------------------
985
+ function collectIssues(label: string, opts: CLIOptions): GitHubIssue[] {
986
+ const collected: GitHubIssue[] = [];
987
+
988
+ for (const context of repositoryContextsForRun(opts)) {
989
+ const labelArg = label ? ` --label "${label}"` : "";
990
+ const issues = ghJson<Array<{
991
+ number: number;
992
+ title: string;
993
+ body: string;
994
+ labels: { name: string }[];
995
+ assignees: { login: string }[];
996
+ state: string;
997
+ }>>(
998
+ `issue list${labelArg} --state open --json number,title,body,labels,assignees,state --limit 100`,
999
+ context.repoFullName,
1000
+ );
1001
+
1002
+ const filtered = issues
1003
+ .filter((issue) => !issue.labels.some((entry) => entry.name === "donne"))
1004
+ .filter((issue) => {
1005
+ if (context.taskId && parseTaskIdMetadata(issue.body) !== context.taskId) {
1006
+ return false;
1007
+ }
1008
+
1009
+ if (typeof context.prdNumber === "number" && parseParentPrdNumber(issue.body) !== context.prdNumber) {
1010
+ return false;
1011
+ }
1012
+
1013
+ return true;
1014
+ })
1015
+ .map((issue) => ({
1016
+ ...issue,
1017
+ repoFullName: context.repoFullName,
1018
+ repoPath: context.repoPath,
1019
+ targetBranch: context.targetBranch ?? "",
1020
+ selectedPrdNumber: context.prdNumber ?? null,
1021
+ taskId: context.taskId ?? null,
1022
+ key: issueKey(context.repoFullName, issue.number),
1023
+ }));
1024
+
1025
+ log(
1026
+ "[list]",
1027
+ `Found ${c.bold}${filtered.length}${c.reset} eligible issue(s) in ${c.cyan}${context.repoFullName}${c.reset}`,
1028
+ );
1029
+
1030
+ collected.push(...filtered);
1031
+ }
1032
+
1033
+ return collected;
1034
+ }
1035
+
1036
+ function parseDependencies(body: string | null): number[] {
1037
+ if (!body) {
1038
+ return [];
1039
+ }
1040
+
1041
+ const deps: number[] = [];
1042
+ const patterns = [/depends[- ]on:\s*#(\d+)/gi, /blocked[- ]by:\s*#(\d+)/gi, /requires:\s*#(\d+)/gi];
1043
+
1044
+ for (const pattern of patterns) {
1045
+ let match: RegExpExecArray | null;
1046
+ while ((match = pattern.exec(body)) !== null) {
1047
+ deps.push(parseInt(match[1]!, 10));
1048
+ }
1049
+ }
1050
+
1051
+ return [...new Set(deps)];
1052
+ }
1053
+
1054
+ function parseParentPrdNumber(body: string | null): number | null {
1055
+ if (!body) {
1056
+ return null;
1057
+ }
1058
+
1059
+ const match = body.match(/##\s*Parent\s+PRD\s*\r?\n\s*#(\d+)/i) ?? body.match(/parent\s+prd\s*#(\d+)/i);
1060
+ if (!match) {
1061
+ return null;
1062
+ }
1063
+
1064
+ const parsed = parseInt(match[1] ?? "", 10);
1065
+ return Number.isNaN(parsed) ? null : parsed;
1066
+ }
1067
+
1068
+ function worktreeKeyForIssue(issue: Pick<GitHubIssue, "number" | "body">): string {
1069
+ const parentPrdNumber = parseParentPrdNumber(issue.body);
1070
+ if (parentPrdNumber !== null) {
1071
+ return `prd-${parentPrdNumber}`;
1072
+ }
1073
+
1074
+ return `issue-${issue.number}`;
1075
+ }
1076
+
1077
+ function parsePriority(labels: { name: string }[]): number {
1078
+ for (const label of labels) {
1079
+ const match = label.name.match(/^P(\d)/);
1080
+ if (match) {
1081
+ return parseInt(match[1]!, 10);
1082
+ }
1083
+ }
1084
+ return 9;
1085
+ }
1086
+
1087
+ function resolveDepGraph(issues: GitHubIssue[]): RankedIssue[] {
1088
+ const openNumbersByRepo = new Map<string, Set<number>>();
1089
+ for (const issue of issues) {
1090
+ const openNumbers = openNumbersByRepo.get(issue.repoFullName) ?? new Set<number>();
1091
+ openNumbers.add(issue.number);
1092
+ openNumbersByRepo.set(issue.repoFullName, openNumbers);
1093
+ }
1094
+
1095
+ return issues
1096
+ .map((issue): RankedIssue => {
1097
+ const priority = parsePriority(issue.labels);
1098
+ const dependsOn = parseDependencies(issue.body);
1099
+ const openNumbers = openNumbersByRepo.get(issue.repoFullName) ?? new Set<number>();
1100
+ const isBlocked = dependsOn.some((dep) => openNumbers.has(dep));
1101
+ const isInProgress = issue.labels.some((l) => l.name === "in-progress");
1102
+ const slug = slugify(issue.title);
1103
+ const parentPrdNumber = parseParentPrdNumber(issue.body);
1104
+ const worktreeKey = worktreeKeyForIssue(issue);
1105
+
1106
+ return {
1107
+ ...issue,
1108
+ priority,
1109
+ dependsOn,
1110
+ isBlocked,
1111
+ isInProgress,
1112
+ slug,
1113
+ parentPrdNumber,
1114
+ worktreeKey,
1115
+ };
1116
+ })
1117
+ .sort((a, b) => {
1118
+ if (a.isInProgress !== b.isInProgress) {
1119
+ return a.isInProgress ? 1 : -1;
1120
+ }
1121
+ if (a.isBlocked !== b.isBlocked) {
1122
+ return a.isBlocked ? 1 : -1;
1123
+ }
1124
+ return a.priority - b.priority;
1125
+ });
1126
+ }
1127
+
1128
+ // ---------------------------------------------------------------------------
1129
+ // Phase 1.5: Validate CLI dependencies
1130
+ // ---------------------------------------------------------------------------
1131
+ function validateGhCLI(repo: string): string {
1132
+ log("[gh]", `Using gh CLI at: ${c.dim}${GH_EXEC}${c.reset}`);
1133
+
1134
+ // Verify auth and extract the stored token so headless subprocesses can use it.
1135
+ try {
1136
+ // gh auth status exits 0 when logged in (writes to stderr in some versions)
1137
+ execSync(`"${GH_EXEC}" auth status`, {
1138
+ encoding: "utf-8",
1139
+ stdio: "pipe",
1140
+ env: ghEnv(),
1141
+ });
1142
+ } catch (err: unknown) {
1143
+ // Exit code 1 means not logged in
1144
+ const stderr =
1145
+ err && typeof err === "object" && "stderr" in err
1146
+ ? String((err as { stderr: unknown }).stderr ?? "")
1147
+ : String(err);
1148
+ console.error(`${c.red}[gh error]${c.reset} gh auth status failed:\n${stderr.trim()}`);
1149
+ console.error(`${c.yellow}Run: gh auth login${c.reset} to authenticate the gh CLI.`);
1150
+ process.exit(1);
1151
+ }
1152
+
1153
+ // Extract token from gh keyring so it can be forwarded to Docker containers.
1154
+ let ghToken = "";
1155
+ try {
1156
+ ghToken = execSync(`"${GH_EXEC}" auth token`, {
1157
+ encoding: "utf-8",
1158
+ stdio: "pipe",
1159
+ env: ghEnv(),
1160
+ }).trim();
1161
+ log("[gh]", `${c.green}Authenticated${c.reset} via gh keyring`);
1162
+ } catch {
1163
+ log("[gh]", `${c.yellow}Could not read token from gh keyring${c.reset}`);
1164
+ }
1165
+
1166
+ if (repo) {
1167
+ log("[gh]", `Target repository: ${c.cyan}${repo}${c.reset}`);
1168
+ }
1169
+
1170
+ return ghToken;
1171
+ }
1172
+
1173
+ function ensureRepositoryLabels(repo: string): void {
1174
+ for (const label of REQUIRED_REPOSITORY_LABELS) {
1175
+ try {
1176
+ gh(
1177
+ `label create "${label.name}" --description "${label.description}" --color "${label.color}" --force`,
1178
+ repo,
1179
+ );
1180
+ } catch (err: unknown) {
1181
+ const message = err instanceof Error ? err.message : String(err);
1182
+ log("[warn]", `Could not ensure label ${label.name} in ${repo}: ${message}`);
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ function ensureLabelsForRun(opts: CLIOptions): void {
1188
+ for (const context of repositoryContextsForRun(opts)) {
1189
+ ensureRepositoryLabels(context.repoFullName);
1190
+ }
1191
+ }
1192
+
1193
+ // ---------------------------------------------------------------------------
1194
+ // Phase 2: Docker Image
1195
+ // ---------------------------------------------------------------------------
1196
+ function ensureDockerImage(): void {
1197
+ try {
1198
+ sh(`docker image inspect ${DOCKER_IMAGE}`, { silent: true });
1199
+ log("[docker]", `Docker image "${DOCKER_IMAGE}" found`);
1200
+ } catch {
1201
+ log("[docker]", `Building Docker image "${DOCKER_IMAGE}"...`);
1202
+
1203
+ const dockerDir = resolve(ORCHESTRATOR_ROOT, DOCKERFILE_DIR);
1204
+ const dockerfilePath = resolve(dockerDir, "Dockerfile");
1205
+
1206
+ if (!existsSync(dockerfilePath)) {
1207
+ throw new Error(`Dockerfile not found at ${dockerfilePath}`);
1208
+ }
1209
+
1210
+ docker(`build -t ${DOCKER_IMAGE} ${dockerDir}`);
1211
+ log("[ok]", "Docker image built successfully");
1212
+ }
1213
+ }
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // Phase 3: Worktree Management
1217
+ // ---------------------------------------------------------------------------
1218
+ function ensureWorktreeDir(repoPath = process.cwd()): void {
1219
+ const dir = resolve(repoPath, WORKTREE_DIR);
1220
+ if (!existsSync(dir)) {
1221
+ mkdirSync(dir, { recursive: true });
1222
+
1223
+ // Add to .gitignore if not present.
1224
+ const gitignorePath = resolve(repoPath, ".gitignore");
1225
+ if (existsSync(gitignorePath)) {
1226
+ const content = readFileSync(gitignorePath, "utf-8");
1227
+ if (!content.includes(WORKTREE_DIR)) {
1228
+ writeFileSync(gitignorePath, `${content.trimEnd()}\n${WORKTREE_DIR}/\n`);
1229
+ log("[gitignore]", `Added ${WORKTREE_DIR}/ to .gitignore`);
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ function createWorktree(issue: RankedIssue, opts: CLIOptions): string {
1236
+ const wPath = worktreePath(issue);
1237
+ const branch = branchName(issue);
1238
+ const startPoint = resolveStartPoint(issue.targetBranch, issue.repoPath);
1239
+
1240
+ // Prune stale git worktree metadata before checking filesystem paths.
1241
+ sh("git worktree prune", { cwd: issue.repoPath, silent: true });
1242
+
1243
+ // If this branch is already attached to another registered worktree, remove it.
1244
+ // Skip removal if the existing worktree IS the path we intend to reuse (shared PRD).
1245
+ try {
1246
+ const porcelain = sh("git worktree list --porcelain", { cwd: issue.repoPath, silent: true });
1247
+ const blocks = porcelain.split("\n\n").filter(Boolean);
1248
+ for (const block of blocks) {
1249
+ if (!block.includes(`branch refs/heads/${branch}`)) {
1250
+ continue;
1251
+ }
1252
+
1253
+ const worktreeLine = block
1254
+ .split("\n")
1255
+ .find((line) => line.startsWith("worktree "));
1256
+ const registeredPath = worktreeLine?.replace("worktree ", "").trim();
1257
+
1258
+ // If the branch is already on the worktree we want, keep it (PRD reuse).
1259
+ if (registeredPath && resolve(registeredPath) !== resolve(wPath)) {
1260
+ try {
1261
+ sh(`git worktree remove "${registeredPath}" --force`, { silent: true });
1262
+ log("[worktree]", `Removed stale worktree for ${c.green}${branch}${c.reset}`);
1263
+ } catch {
1264
+ // Keep going and let regular branch cleanup handle leftovers.
1265
+ }
1266
+ }
1267
+ }
1268
+ } catch {
1269
+ // Ignore parse/inspection errors.
1270
+ }
1271
+
1272
+ if (existsSync(wPath)) {
1273
+ log("[reuse]", `Worktree already exists for ${c.cyan}${issue.worktreeKey}${c.reset}`);
1274
+ return wPath;
1275
+ }
1276
+
1277
+ // Create branch from current HEAD.
1278
+ try {
1279
+ sh(`git branch -D "${branch}"`, { cwd: issue.repoPath, silent: true });
1280
+ } catch {
1281
+ // Branch does not exist; safe to ignore.
1282
+ }
1283
+
1284
+ sh(`git worktree add "${wPath}" -b "${branch}" "${startPoint}"`, { cwd: issue.repoPath });
1285
+ log("[worktree]", `Created worktree: ${c.dim}${wPath}${c.reset} -> ${c.green}${branch}${c.reset}`);
1286
+
1287
+ return wPath;
1288
+ }
1289
+
1290
+ function prepareLiteralWorkspace(issue: RankedIssue, opts: CLIOptions): string {
1291
+ const repoPath = issue.repoPath;
1292
+ const branch = activeBranch(issue, opts);
1293
+ const startPoint = resolveStartPoint(branch, repoPath);
1294
+
1295
+ sh(`git checkout -B "${branch}" "${startPoint}"`, { cwd: repoPath });
1296
+ log("[checkout]", `Checked out ${c.green}${branch}${c.reset} from ${c.cyan}${startPoint}${c.reset}`);
1297
+
1298
+ return repoPath;
1299
+ }
1300
+
1301
+ function prepareWorkspace(issue: RankedIssue, opts: CLIOptions): string {
1302
+ if (opts.worktree) {
1303
+ return createWorktree(issue, opts);
1304
+ }
1305
+
1306
+ return prepareLiteralWorkspace(issue, opts);
1307
+ }
1308
+
1309
+ function selectBatch(issues: RankedIssue[], maxParallel: number): RankedIssue[] {
1310
+ const selected: RankedIssue[] = [];
1311
+ const activeKeys = new Set<string>();
1312
+
1313
+ for (const issue of issues) {
1314
+ const repoScopedKey = `${issue.repoFullName}:${issue.worktreeKey}`;
1315
+ if (activeKeys.has(repoScopedKey)) {
1316
+ continue;
1317
+ }
1318
+
1319
+ selected.push(issue);
1320
+ activeKeys.add(repoScopedKey);
1321
+
1322
+ if (selected.length >= maxParallel) {
1323
+ break;
1324
+ }
1325
+ }
1326
+
1327
+ return selected;
1328
+ }
1329
+
1330
+ function selectRunnableBatch(issues: RankedIssue[], opts: CLIOptions): RankedIssue[] {
1331
+ return selectRunnableBatchWithLimit(issues, opts, opts.maxParallel);
1332
+ }
1333
+
1334
+ function selectRunnableBatchWithLimit(issues: RankedIssue[], opts: CLIOptions, maxParallel: number): RankedIssue[] {
1335
+ if (!opts.worktree) {
1336
+ const selected: RankedIssue[] = [];
1337
+ const activeRepos = new Set<string>();
1338
+
1339
+ for (const issue of issues) {
1340
+ if (activeRepos.has(issue.repoFullName)) {
1341
+ continue;
1342
+ }
1343
+
1344
+ selected.push(issue);
1345
+ activeRepos.add(issue.repoFullName);
1346
+
1347
+ if (selected.length >= maxParallel) {
1348
+ break;
1349
+ }
1350
+ }
1351
+
1352
+ return selected;
1353
+ }
1354
+
1355
+ return selectBatch(issues, maxParallel);
1356
+ }
1357
+
1358
+ // ---------------------------------------------------------------------------
1359
+ // Phase 4: Prompt System
1360
+ // ---------------------------------------------------------------------------
1361
+ function loadPrompt(filePath: string): string {
1362
+ const fullPath = resolve(ORCHESTRATOR_ROOT, filePath);
1363
+ if (!existsSync(fullPath)) {
1364
+ throw new Error(`Prompt file not found: ${fullPath}`);
1365
+ }
1366
+ return readFileSync(fullPath, "utf-8");
1367
+ }
1368
+
1369
+ function interpolatePrompt(
1370
+ template: string,
1371
+ issue: RankedIssue,
1372
+ branch: string,
1373
+ extra?: Record<string, string>,
1374
+ cwd?: string,
1375
+ ): string {
1376
+ let result = template
1377
+ .replace(/\{\{ISSUE_TITLE\}\}/g, issue.title)
1378
+ .replace(/\{\{ISSUE_BODY\}\}/g, issue.body ?? "(no body)")
1379
+ .replace(/\{\{ISSUE_NUMBER\}\}/g, String(issue.number))
1380
+ .replace(/\{\{BRANCH\}\}/g, branch);
1381
+
1382
+ if (extra) {
1383
+ for (const [key, value] of Object.entries(extra)) {
1384
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
1385
+ }
1386
+ }
1387
+
1388
+ // Expand shell directives: !`command` → command output
1389
+ // Moved from copilot-agent.mjs to run on host (has git, gh, worktree context)
1390
+ result = result.replace(/!\`([^`]+)\`/g, (_match: string, cmd: string) => {
1391
+ const trimmedCmd = cmd.trim();
1392
+ try {
1393
+ return runDirectiveCommand(trimmedCmd, cwd) || "(no output)";
1394
+ } catch (err: unknown) {
1395
+ const e = err as { stdout?: Buffer | string };
1396
+ return e?.stdout?.toString?.()?.trim?.() || `(command failed: ${trimmedCmd})`;
1397
+ }
1398
+ });
1399
+
1400
+ return result;
1401
+ }
1402
+
1403
+ function ensureAgentOutputDir(wPath: string): string {
1404
+ const agentDir = resolve(wPath, AGENT_OUTPUT_DIR);
1405
+ if (!existsSync(agentDir)) {
1406
+ mkdirSync(agentDir, { recursive: true });
1407
+ }
1408
+ return agentDir;
1409
+ }
1410
+
1411
+ function saveStageOutput(wPath: string, filename: string, content: string): void {
1412
+ const agentDir = ensureAgentOutputDir(wPath);
1413
+ const filePath = resolve(agentDir, filename);
1414
+ writeFileSync(filePath, content, "utf-8");
1415
+ log("[save]", `Saved ${c.dim}${AGENT_OUTPUT_DIR}/${filename}${c.reset}`);
1416
+ }
1417
+
1418
+ // ---------------------------------------------------------------------------
1419
+ // Phase 5: Pipeline Stage Runner (Copilot Agent via Docker)
1420
+ // ---------------------------------------------------------------------------
1421
+ type StageName = "plan" | "implement" | "review";
1422
+ const AGENT_STAGE_MARKER = "__FIFTTH_AGENT_STAGE__";
1423
+
1424
+ interface IssuePipelineResult {
1425
+ plan: StageResult;
1426
+ implement?: StageResult;
1427
+ review?: StageResult;
1428
+ }
1429
+
1430
+ function failureStateFor(result: StageResult): IssueUiState {
1431
+ return /timeout/i.test(result.errorText) ? "timed-out" : "failed";
1432
+ }
1433
+
1434
+ function logSuccessfulStage(issue: RankedIssue, stage: StageName, result: StageResult, wPath: string): void {
1435
+ switch (stage) {
1436
+ case "plan":
1437
+ saveStageOutput(wPath, `plan-${issue.number}.md`, result.output);
1438
+ log("[ok]", `fiftth planning done #${issue.number}`);
1439
+ break;
1440
+ case "implement":
1441
+ saveStageOutput(wPath, `implementation-${issue.number}.md`, result.output);
1442
+ log("[ok]", `fiftth implementing done #${issue.number} (${result.writes} files changed)`);
1443
+ break;
1444
+ case "review":
1445
+ saveStageOutput(wPath, `review-${issue.number}.md`, result.output);
1446
+ log("[ok]", `fiftth reviewing done #${issue.number}`);
1447
+ break;
1448
+ }
1449
+ }
1450
+
1451
+ function logFailedStage(issue: RankedIssue, stage: StageName, result: StageResult): void {
1452
+ switch (stage) {
1453
+ case "plan":
1454
+ log("[fail]", `fiftth planning failed #${issue.number}`);
1455
+ break;
1456
+ case "implement":
1457
+ log("[fail]", `fiftth implementing failed #${issue.number}`);
1458
+ break;
1459
+ case "review":
1460
+ log("[fail]", `fiftth reviewing failed #${issue.number}`);
1461
+ break;
1462
+ }
1463
+
1464
+ if (result.errorText) {
1465
+ log("[error]", result.errorText);
1466
+ }
1467
+ }
1468
+
1469
+ function runIssuePipeline(
1470
+ issue: RankedIssue,
1471
+ wPath: string,
1472
+ opts: CLIOptions,
1473
+ onStageStart?: (stage: StageName) => void,
1474
+ ): Promise<IssuePipelineResult> {
1475
+ const name = `${containerName(issue)}-pipeline`;
1476
+
1477
+ // Kill existing container with same name.
1478
+ try {
1479
+ docker(`rm -f ${name}`);
1480
+ } catch {
1481
+ // Container may not exist.
1482
+ }
1483
+
1484
+ const planTemplate = loadPrompt(PROMPT_PLAN);
1485
+ const implementTemplate = loadPrompt(PROMPT_IMPLEMENT);
1486
+ const reviewTemplate = loadPrompt(PROMPT_REVIEW);
1487
+ const branch = activeBranch(issue, opts);
1488
+
1489
+ const planPrompt = interpolatePrompt(planTemplate, issue, branch, undefined, wPath);
1490
+ const implementPrompt = interpolatePrompt(implementTemplate, issue, branch, undefined, wPath);
1491
+ const reviewPrompt = interpolatePrompt(reviewTemplate, issue, branch, undefined, wPath);
1492
+
1493
+ // Write prompts to files in the worktree so the container can read them.
1494
+ const agentDir = ensureAgentOutputDir(wPath);
1495
+ const planPromptFile = resolve(agentDir, `plan-prompt-${issue.number}.md`);
1496
+ const implementPromptFile = resolve(agentDir, `implement-prompt-${issue.number}.md`);
1497
+ const reviewPromptFile = resolve(agentDir, `review-prompt-${issue.number}.md`);
1498
+ writeFileSync(planPromptFile, planPrompt, "utf-8");
1499
+ writeFileSync(implementPromptFile, implementPrompt, "utf-8");
1500
+ writeFileSync(reviewPromptFile, reviewPrompt, "utf-8");
1501
+
1502
+ // Git env vars for the container to use git inside Docker
1503
+ const mainGitDir = resolve(issue.repoPath, ".git");
1504
+ const worktreeName = issue.worktreeKey || `issue-${issue.number}`;
1505
+ const gitDir = opts.worktree ? `/repo-git/worktrees/${worktreeName}` : "/repo-git";
1506
+
1507
+ return new Promise((resolvePromise) => {
1508
+ const dockerArgs = [
1509
+ "run",
1510
+ "--name",
1511
+ name,
1512
+ "-e",
1513
+ `GITHUB_TOKEN=${opts.ghToken}`,
1514
+ "-e",
1515
+ `GIT_DIR=${gitDir}`,
1516
+ "-e",
1517
+ `GIT_WORK_TREE=/workspace`,
1518
+ "-v",
1519
+ `${wPath}:/workspace`,
1520
+ "-v",
1521
+ `${mainGitDir}:/repo-git`,
1522
+ "-w",
1523
+ "/workspace",
1524
+ DOCKER_IMAGE,
1525
+ "--plan-prompt",
1526
+ `/workspace/${AGENT_OUTPUT_DIR}/plan-prompt-${issue.number}.md`,
1527
+ "--implement-prompt",
1528
+ `/workspace/${AGENT_OUTPUT_DIR}/implement-prompt-${issue.number}.md`,
1529
+ "--review-prompt",
1530
+ `/workspace/${AGENT_OUTPUT_DIR}/review-prompt-${issue.number}.md`,
1531
+ "--model",
1532
+ opts.model,
1533
+ ];
1534
+
1535
+ if (opts.verbose) {
1536
+ dockerArgs.push("--verbose");
1537
+ }
1538
+
1539
+ const proc = spawn("docker", dockerArgs, {
1540
+ stdio: ["ignore", "pipe", "pipe"],
1541
+ });
1542
+
1543
+ const stdoutChunks: string[] = [];
1544
+ const stderrLines: string[] = [];
1545
+ const logPrefix = `${c.dim}[#${issue.number}:pipeline]${c.reset}`;
1546
+
1547
+ proc.stdout?.on("data", (data: Buffer) => {
1548
+ const text = data.toString();
1549
+ stdoutChunks.push(text);
1550
+ });
1551
+
1552
+ proc.stderr?.on("data", (data: Buffer) => {
1553
+ const text = data.toString();
1554
+ const lines = text.split("\n").filter(Boolean);
1555
+ for (const line of lines) {
1556
+ const clean = line.replace(/\r/g, "").trim();
1557
+ if (!clean) continue;
1558
+ const markerIndex = clean.indexOf(AGENT_STAGE_MARKER);
1559
+ if (markerIndex >= 0) {
1560
+ const payload = clean.slice(markerIndex + AGENT_STAGE_MARKER.length).trim();
1561
+ const [stageName, eventName] = payload.split(":", 2) as [StageName, string | undefined];
1562
+ if (eventName === "start") {
1563
+ onStageStart?.(stageName);
1564
+ }
1565
+ continue;
1566
+ }
1567
+
1568
+ stderrLines.push(clean);
1569
+
1570
+ if (opts.verbose) {
1571
+ console.log(`${logPrefix} ${clean}`);
1572
+ }
1573
+ }
1574
+ });
1575
+
1576
+ proc.on("close", (code) => {
1577
+ const output = stdoutChunks.join("").trim();
1578
+ const errorText = stderrLines.slice(-12).join("\n");
1579
+ try {
1580
+ docker(`rm -f ${name}`);
1581
+ } catch {
1582
+ // ignore
1583
+ }
1584
+
1585
+ if (!output) {
1586
+ resolvePromise({
1587
+ plan: {
1588
+ success: false,
1589
+ output: "",
1590
+ toolCalls: 0,
1591
+ writes: 0,
1592
+ errorText: errorText || `Pipeline container exited with code ${code ?? "unknown"}.`,
1593
+ },
1594
+ });
1595
+ return;
1596
+ }
1597
+
1598
+ try {
1599
+ resolvePromise(JSON.parse(output) as IssuePipelineResult);
1600
+ } catch {
1601
+ resolvePromise({
1602
+ plan: {
1603
+ success: false,
1604
+ output: output,
1605
+ toolCalls: 0,
1606
+ writes: 0,
1607
+ errorText: errorText || "Failed to parse pipeline output from agent container.",
1608
+ },
1609
+ });
1610
+ }
1611
+ });
1612
+
1613
+ proc.on("error", (err) => {
1614
+ log("[error]", `Failed to start pipeline for #${issue.number}: ${err.message}`);
1615
+ resolvePromise({
1616
+ plan: {
1617
+ success: false,
1618
+ output: "",
1619
+ toolCalls: 0,
1620
+ writes: 0,
1621
+ errorText: err.message,
1622
+ },
1623
+ });
1624
+ });
1625
+ });
1626
+ }
1627
+
1628
+ function commitOutstandingCodeChanges(issue: RankedIssue, wPath: string): void {
1629
+ try {
1630
+ const uncommitted = sh(`git status --porcelain`, { cwd: wPath, silent: true });
1631
+ const codeChanges = uncommitted
1632
+ .split("\n")
1633
+ .filter((line) => line.trim() && !line.includes(".agent/"));
1634
+ if (codeChanges.length > 0) {
1635
+ log("[commit]", `Agent left ${codeChanges.length} uncommitted file(s) — committing from orchestrator`);
1636
+ sh(`git add -A -- . ':!.agent'`, { cwd: wPath, silent: true });
1637
+ sh(`git commit -m "RALPH: implement #${issue.number}: ${issue.title}"`, { cwd: wPath, silent: true });
1638
+ }
1639
+ } catch {
1640
+ // If commit fails, proceed anyway — there may already be commits.
1641
+ }
1642
+ }
1643
+
1644
+ function finalizeSuccessfulIssue(issue: RankedIssue, wPath: string, opts: CLIOptions, reviewCompleted: boolean): void {
1645
+ const branch = activeBranch(issue, opts);
1646
+
1647
+ commitOutstandingCodeChanges(issue, wPath);
1648
+
1649
+ if (opts.push) {
1650
+ try {
1651
+ sh(`git push origin ${branch} --force`, { cwd: wPath });
1652
+ log("[push]", `pushed ${branch}`);
1653
+
1654
+ try {
1655
+ const reviewStageLine = reviewCompleted
1656
+ ? "- [x] Review"
1657
+ : "- [ ] Review (timed out locally after implementation)";
1658
+ gh(
1659
+ `pr create --head "${branch}" --title "Fix #${issue.number}: ${issue.title}" --body "Closes #${issue.number}\n\nAutomatically generated by the local Docker orchestrator using Copilot SDK.\n\n## Pipeline Stages\n- [x] Plan\n- [x] Implement\n${reviewStageLine}" --fill`,
1660
+ issue.repoFullName,
1661
+ );
1662
+ log("[pr]", `created PR for #${issue.number}`);
1663
+ } catch {
1664
+ log("[warn]", `PR may already exist for #${issue.number}`);
1665
+ }
1666
+ } catch (err) {
1667
+ log("[error]", `Failed to push/create PR for #${issue.number}: ${String(err)}`);
1668
+ }
1669
+ } else {
1670
+ log("[hint]", `Use ${c.cyan}--push${c.reset} to push branch and create PR, or manually:`);
1671
+ log(" ", `cd ${wPath} && git push origin ${branch}`);
1672
+ }
1673
+
1674
+ cleanupIssueLabels(issue.number, issue.repoFullName);
1675
+ markIssueDone(issue.number, issue.repoFullName);
1676
+ }
1677
+
1678
+ function recoverTimedOutReview(issue: RankedIssue, wPath: string, opts: CLIOptions): boolean {
1679
+ commitOutstandingCodeChanges(issue, wPath);
1680
+
1681
+ log("[warn]", `Review timed out for #${issue.number}, but implementation changes were detected. Finalizing issue to avoid reprocessing loop.`);
1682
+ finalizeSuccessfulIssue(issue, wPath, opts, false);
1683
+ return true;
1684
+ }
1685
+
1686
+ interface RunningAgentResult {
1687
+ issue: RankedIssue;
1688
+ success: boolean;
1689
+ }
1690
+
1691
+ function startAgentRun(
1692
+ issue: RankedIssue,
1693
+ opts: CLIOptions,
1694
+ liveStates: Map<string, { label: string; state: IssueUiState }>,
1695
+ ): Promise<RunningAgentResult> {
1696
+ liveStates.set(issue.key, {
1697
+ label: `${issue.repoFullName}#${issue.number}`,
1698
+ state: "queued",
1699
+ });
1700
+
1701
+ return runAgent(issue, opts, (currentIssueKey, state) => {
1702
+ const previous = liveStates.get(currentIssueKey);
1703
+ const nextLabel = previous?.label ?? currentIssueKey;
1704
+ liveStates.set(currentIssueKey, {
1705
+ label: nextLabel,
1706
+ state,
1707
+ });
1708
+ emitIntegratedState(
1709
+ currentIssueKey,
1710
+ nextLabel,
1711
+ state,
1712
+ issue.repoFullName,
1713
+ issue.worktreeKey,
1714
+ issue.number,
1715
+ );
1716
+ refreshRunDashboard();
1717
+ }).then((success) => ({ issue, success }));
1718
+ }
1719
+
1720
+ async function waitForNextRunningAgent(
1721
+ runningAgents: Map<string, Promise<RunningAgentResult>>,
1722
+ liveStates: Map<string, { label: string; state: IssueUiState }>,
1723
+ counts: { doneCount: number; failedCount: number },
1724
+ opts: CLIOptions,
1725
+ stats: { prdsOpen: number; unblockedCount: number },
1726
+ ): Promise<boolean> {
1727
+ const pending = [...runningAgents.values()];
1728
+ if (pending.length === 0) {
1729
+ return false;
1730
+ }
1731
+
1732
+ const ticker = !runDashboardState.active ? startLiveTicker(() => {
1733
+ const activeEntries = [...liveStates.values()]
1734
+ .filter((entry) => entry.state === "planning" || entry.state === "implementing" || entry.state === "reviewing")
1735
+ .slice(0, 2);
1736
+ const active = activeEntries
1737
+ .map((entry) => `${entry.label} ${uiStateLabel(entry.state)}`)
1738
+ .join(" | ");
1739
+ return `prds:${stats.prdsOpen} ready:${stats.unblockedCount} running:${activeEntries.length} done:${counts.doneCount} failed:${counts.failedCount}${active ? ` | ${active}` : ""}`;
1740
+ }) : { stop: () => undefined };
1741
+
1742
+ let result: RunningAgentResult;
1743
+ try {
1744
+ result = await Promise.race(pending);
1745
+ } finally {
1746
+ ticker.stop();
1747
+ if (!runDashboardState.active) {
1748
+ process.stdout.write("\n");
1749
+ }
1750
+ }
1751
+
1752
+ runningAgents.delete(result.issue.key);
1753
+ if (result.success) {
1754
+ if (opts.worktree && !opts.push) {
1755
+ log(
1756
+ "[hint]",
1757
+ `Keeping worktree for ${result.issue.worktreeKey} - review changes at: ${c.dim}${worktreePath(result.issue)}${c.reset}`,
1758
+ );
1759
+ }
1760
+ }
1761
+
1762
+ log("[iter]", `issue ${result.issue.repoFullName}#${result.issue.number}: ${result.success ? "ok" : "fail"}`);
1763
+ return result.success;
1764
+ }
1765
+
1766
+ // ---------------------------------------------------------------------------
1767
+ // Phase 7: Run Agent (3-stage pipeline)
1768
+ // ---------------------------------------------------------------------------
1769
+ async function runAgent(
1770
+ issue: RankedIssue,
1771
+ opts: CLIOptions,
1772
+ onStateChange?: (currentIssueKey: string, state: IssueUiState) => void,
1773
+ ): Promise<boolean> {
1774
+ const wPath = prepareWorkspace(issue, opts);
1775
+ onStateChange?.(issue.key, "queued");
1776
+
1777
+ // Mark as in-progress.
1778
+ try {
1779
+ gh(`issue edit ${issue.number} --add-label "in-progress"`, issue.repoFullName);
1780
+ } catch (err: unknown) {
1781
+ const message = err instanceof Error ? err.message : String(err);
1782
+ log("[warn]", `Could not add in-progress label to ${issue.repoFullName}#${issue.number}: ${message}`);
1783
+ }
1784
+
1785
+ log("[start]", `${issue.worktreeKey} -> #${issue.number}`);
1786
+
1787
+ // Stage 1: Plan
1788
+ log("[plan]", `fiftth planning #${issue.number}`);
1789
+ onStateChange?.(issue.key, "planning");
1790
+ const pipelineResult = await runIssuePipeline(issue, wPath, opts, (stage) => {
1791
+ if (stage === "implement") {
1792
+ log("[impl]", `fiftth implementing #${issue.number}`);
1793
+ onStateChange?.(issue.key, "implementing");
1794
+ }
1795
+
1796
+ if (stage === "review") {
1797
+ log("[review]", `fiftth reviewing #${issue.number}`);
1798
+ onStateChange?.(issue.key, "reviewing");
1799
+ }
1800
+ });
1801
+ const planResult = pipelineResult.plan;
1802
+ if (planResult.success) {
1803
+ logSuccessfulStage(issue, "plan", planResult, wPath);
1804
+ }
1805
+ if (!planResult.success) {
1806
+ const failedState = failureStateFor(planResult);
1807
+ onStateChange?.(issue.key, failedState);
1808
+ logFailedStage(issue, "plan", planResult);
1809
+ log("[abort]", `Pipeline aborted at plan stage for #${issue.number}`);
1810
+ afterAgentFailure(issue, issue.repoFullName);
1811
+ return false;
1812
+ }
1813
+
1814
+ // Stage 2: Implement
1815
+ const implResult = pipelineResult.implement;
1816
+ if (implResult?.success) {
1817
+ logSuccessfulStage(issue, "implement", implResult, wPath);
1818
+ }
1819
+ if (!implResult.success) {
1820
+ const failedState = failureStateFor(implResult);
1821
+ onStateChange?.(issue.key, failedState);
1822
+ logFailedStage(issue, "implement", implResult);
1823
+ log("[abort]", `Pipeline aborted at implement stage for #${issue.number}`);
1824
+ afterAgentFailure(issue, issue.repoFullName);
1825
+ return false;
1826
+ }
1827
+
1828
+ // Stage 3: Review
1829
+ const reviewResult = pipelineResult.review;
1830
+ if (reviewResult?.success) {
1831
+ logSuccessfulStage(issue, "review", reviewResult, wPath);
1832
+ }
1833
+ if (!reviewResult.success) {
1834
+ const failedState = failureStateFor(reviewResult);
1835
+ if (failedState === "timed-out" && recoverTimedOutReview(issue, wPath, opts)) {
1836
+ onStateChange?.(issue.key, "done");
1837
+ log("[ok]", `fiftth done #${issue.number} after review timeout fallback`);
1838
+ return true;
1839
+ }
1840
+ onStateChange?.(issue.key, failedState);
1841
+ logFailedStage(issue, "review", reviewResult);
1842
+ log("[abort]", `Pipeline aborted at review stage for #${issue.number}`);
1843
+ afterAgentFailure(issue, issue.repoFullName);
1844
+ return false;
1845
+ }
1846
+
1847
+ onStateChange?.(issue.key, "done");
1848
+ log("[ok]", `fiftth done #${issue.number}`);
1849
+ afterAgentSuccess(issue, wPath, opts);
1850
+ return true;
1851
+ }
1852
+
1853
+ // ---------------------------------------------------------------------------
1854
+ // Phase 8: Post-Agent Handlers
1855
+ // ---------------------------------------------------------------------------
1856
+ function afterAgentSuccess(issue: RankedIssue, wPath: string, opts: CLIOptions): void {
1857
+ finalizeSuccessfulIssue(issue, wPath, opts, true);
1858
+ }
1859
+
1860
+ function afterAgentFailure(issue: RankedIssue, repo: string): void {
1861
+ try {
1862
+ gh(`issue edit ${issue.number} --remove-label "in-progress"`, repo);
1863
+ } catch {
1864
+ // ignore
1865
+ }
1866
+ }
1867
+
1868
+ function cleanupIssueLabels(issueNumber: number, repo: string): void {
1869
+ try {
1870
+ gh(`issue edit ${issueNumber} --remove-label "in-progress"`, repo);
1871
+ } catch {
1872
+ // ignore
1873
+ }
1874
+
1875
+ try {
1876
+ gh(`issue edit ${issueNumber} --remove-label "blocked"`, repo);
1877
+ } catch {
1878
+ // ignore
1879
+ }
1880
+ }
1881
+
1882
+ function markIssueDone(issueNumber: number, repo: string): void {
1883
+ try {
1884
+ gh(`issue edit ${issueNumber} --add-label "donne"`, repo);
1885
+ log("[label]", `Added ${c.green}donne${c.reset} label to #${issueNumber}`);
1886
+ } catch {
1887
+ log("[warn]", `Could not add 'donne' label to #${issueNumber}`);
1888
+ }
1889
+ }
1890
+
1891
+ // ---------------------------------------------------------------------------
1892
+ // Mode: Status
1893
+ // ---------------------------------------------------------------------------
1894
+ function showStatus(opts: CLIOptions): void {
1895
+ currentUiMode = "run-minimal";
1896
+ try {
1897
+ const contexts = repositoryContextsForRun(opts);
1898
+ opts.ghToken = validateGhCLI(opts.repo);
1899
+ const issues = collectIssues(ORCHESTRATOR_LABEL, opts);
1900
+
1901
+ console.log();
1902
+ console.log(`${c.bold}fiftth status${c.reset}`);
1903
+ console.log(
1904
+ `${c.dim}repo${c.reset} ${contexts.length === 1 ? contexts[0]!.repoFullName : `${contexts.length} repositories`}`,
1905
+ );
1906
+ console.log(`${c.dim}label${c.reset} ${ORCHESTRATOR_LABEL}`);
1907
+
1908
+ if (issues.length === 0) {
1909
+ console.log();
1910
+ console.log(`${c.green}no issues found${c.reset}`);
1911
+ return;
1912
+ }
1913
+
1914
+ const ranked = resolveDepGraph(issues);
1915
+ printIssueTable(ranked);
1916
+
1917
+ console.log();
1918
+ try {
1919
+ const containers = sh(
1920
+ `docker ps --filter "name=${CONTAINER_PREFIX}" --format "{{.Names}}\t{{.Status}}\t{{.RunningFor}}"`,
1921
+ { silent: true },
1922
+ );
1923
+
1924
+ if (containers) {
1925
+ console.log(`${c.bold}running containers${c.reset}`);
1926
+ for (const line of containers.split("\n")) {
1927
+ console.log(` ${line}`);
1928
+ }
1929
+ } else {
1930
+ console.log(`${c.dim}running containers${c.reset} none`);
1931
+ }
1932
+ } catch {
1933
+ console.log(`${c.dim}running containers${c.reset} none`);
1934
+ }
1935
+
1936
+ console.log();
1937
+ try {
1938
+ const orchestratorTrees = contexts.flatMap((context) => {
1939
+ const worktrees = sh("git worktree list", { cwd: context.repoPath, silent: true });
1940
+ return worktrees
1941
+ .split("\n")
1942
+ .filter((line) => line.includes(WORKTREE_DIR))
1943
+ .map((line) => `${context.repoFullName}: ${line}`);
1944
+ });
1945
+
1946
+ if (orchestratorTrees.length > 0) {
1947
+ console.log(`${c.bold}active worktrees${c.reset}`);
1948
+ for (const line of orchestratorTrees) {
1949
+ console.log(` ${c.dim}${line}${c.reset}`);
1950
+ }
1951
+ } else {
1952
+ console.log(`${c.dim}active worktrees${c.reset} none`);
1953
+ }
1954
+ } catch {
1955
+ console.log(`${c.dim}active worktrees${c.reset} none`);
1956
+ }
1957
+ } finally {
1958
+ currentUiMode = "default";
1959
+ }
1960
+ }
1961
+
1962
+ // ---------------------------------------------------------------------------
1963
+ // Mode: Cleanup
1964
+ // ---------------------------------------------------------------------------
1965
+ async function cleanup(opts: CLIOptions): Promise<void> {
1966
+ header("CLEANUP");
1967
+ const contexts = repositoryContextsForRun(opts);
1968
+
1969
+ const ok = await confirm("Remove all orchestrator worktrees and containers?");
1970
+ if (!ok) {
1971
+ log("[stop]", "Cancelled.");
1972
+ return;
1973
+ }
1974
+
1975
+ try {
1976
+ const containers = sh(`docker ps -a --filter "name=${CONTAINER_PREFIX}" --format "{{.Names}}"`, {
1977
+ silent: true,
1978
+ });
1979
+ if (containers) {
1980
+ for (const name of containers.split("\n").filter(Boolean)) {
1981
+ try {
1982
+ docker(`rm -f ${name}`);
1983
+ log("[delete]", `Removed container: ${name}`);
1984
+ } catch {
1985
+ // ignore
1986
+ }
1987
+ }
1988
+ }
1989
+ } catch {
1990
+ // no containers
1991
+ }
1992
+
1993
+ for (const context of contexts) {
1994
+ try {
1995
+ const worktrees = sh("git worktree list --porcelain", { cwd: context.repoPath, silent: true });
1996
+ const paths = worktrees
1997
+ .split("\n")
1998
+ .filter((line) => line.startsWith("worktree "))
1999
+ .map((line) => line.replace("worktree ", ""))
2000
+ .filter((worktreePathValue) => worktreePathValue.includes(WORKTREE_DIR));
2001
+
2002
+ for (const wPath of paths) {
2003
+ try {
2004
+ sh(`git worktree remove "${wPath}" --force`, { cwd: context.repoPath });
2005
+ log("[delete]", `Removed worktree: ${context.repoFullName}/${basename(wPath)}`);
2006
+ } catch {
2007
+ // ignore
2008
+ }
2009
+ }
2010
+
2011
+ sh("git worktree prune", { cwd: context.repoPath, silent: true });
2012
+ } catch {
2013
+ // no worktrees for this repo
2014
+ }
2015
+ }
2016
+ log("[ok]", "Cleanup complete");
2017
+ }
2018
+
2019
+ // ---------------------------------------------------------------------------
2020
+ // Mode: Reset Labels
2021
+ // ---------------------------------------------------------------------------
2022
+ async function resetLabels(opts: CLIOptions): Promise<void> {
2023
+ header("RESET LABELS");
2024
+
2025
+ const issues = collectIssues(ORCHESTRATOR_LABEL, opts);
2026
+ if (issues.length === 0) {
2027
+ log("[ok]", "No issues found.");
2028
+ return;
2029
+ }
2030
+
2031
+ const ok = await confirm(`Remove in-progress/blocked labels from ${issues.length} issues?`);
2032
+ if (!ok) {
2033
+ log("[stop]", "Cancelled.");
2034
+ return;
2035
+ }
2036
+
2037
+ for (const issue of issues) {
2038
+ cleanupIssueLabels(issue.number, issue.repoFullName);
2039
+ log("[reset]", `Reset labels on ${issue.repoFullName}#${issue.number}`);
2040
+ }
2041
+
2042
+ log("[ok]", "All labels reset");
2043
+ }
2044
+
2045
+ // ---------------------------------------------------------------------------
2046
+ // Issue Table
2047
+ // ---------------------------------------------------------------------------
2048
+ function printIssueTable(ranked: RankedIssue[]): void {
2049
+ const colNum = 6;
2050
+ const colPri = 12;
2051
+ const colStatus = 14;
2052
+ const colDeps = 16;
2053
+ const colTitle = 40;
2054
+
2055
+ const padR = (s: string, len: number) => {
2056
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
2057
+ return s + " ".repeat(Math.max(0, len - plain.length));
2058
+ };
2059
+
2060
+ const headerRow = `${padR("#", colNum)} ${padR("Priority", colPri)} ${padR("Status", colStatus)} ${padR("Deps", colDeps)} ${padR("Title", colTitle)}`;
2061
+ const separator = "-".repeat(colNum + colPri + colStatus + colDeps + colTitle + 4);
2062
+
2063
+ console.log();
2064
+ console.log(`${c.bold}${headerRow}${c.reset}`);
2065
+ console.log(separator);
2066
+
2067
+ for (const issue of ranked) {
2068
+ const num = `#${issue.number}`;
2069
+ const pri = `P${issue.priority}`;
2070
+ const priColor =
2071
+ issue.priority === 0 ? c.red : issue.priority === 1 ? c.yellow : issue.priority === 2 ? c.blue : c.dim;
2072
+
2073
+ let status: string;
2074
+ if (issue.isInProgress) {
2075
+ status = `${c.magenta}in-progress${c.reset}`;
2076
+ } else if (issue.isBlocked) {
2077
+ status = `${c.yellow}blocked${c.reset}`;
2078
+ } else {
2079
+ status = `${c.green}ready${c.reset}`;
2080
+ }
2081
+
2082
+ const deps = issue.dependsOn.length > 0 ? issue.dependsOn.map((d) => `#${d}`).join(", ") : `${c.dim}none${c.reset}`;
2083
+ const title = issue.title.length > colTitle - 2 ? issue.title.slice(0, colTitle - 5) + "..." : issue.title;
2084
+
2085
+ console.log(
2086
+ `${padR(num, colNum)} ${padR(`${priColor}${pri}${c.reset}`, colPri + 9)} ${padR(status, colStatus + 9)} ${padR(deps, colDeps)} ${title}`,
2087
+ );
2088
+ }
2089
+
2090
+ console.log();
2091
+ }
2092
+
2093
+ // ---------------------------------------------------------------------------
2094
+ // Main: Run Loop
2095
+ // ---------------------------------------------------------------------------
2096
+ async function runLoop(opts: CLIOptions): Promise<void> {
2097
+ // validateGhCLI uses gh keyring — no env tokens needed
2098
+ opts.ghToken = validateGhCLI(opts.repo);
2099
+ currentUiMode = opts.verbose ? "run-verbose" : "run-minimal";
2100
+
2101
+ ensureLabelsForRun(opts);
2102
+ ensureDockerImage();
2103
+ for (const context of repositoryContextsForRun(opts)) {
2104
+ ensureWorktreeDir(context.repoPath);
2105
+ }
2106
+
2107
+ let selectedPrdKeys: Set<string> | null = null;
2108
+ let askedForPrdSelection = false;
2109
+ let doneCount = 0;
2110
+ let failedCount = 0;
2111
+ const liveStates = new Map<string, { label: string; state: IssueUiState }>();
2112
+ const runningAgents = new Map<string, Promise<RunningAgentResult>>();
2113
+ runDashboardState.liveStates = liveStates;
2114
+
2115
+ printRunIntro(opts);
2116
+
2117
+ for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
2118
+ const issues = collectIssues(ORCHESTRATOR_LABEL, opts);
2119
+ if (issues.length === 0) {
2120
+ log("[ok]", "No more orchestrator issues. All done!");
2121
+ break;
2122
+ }
2123
+
2124
+ const rankedAll = resolveDepGraph(issues);
2125
+ const prdKeys = [...new Set(rankedAll.map((i) => i.worktreeKey).filter((k) => k.startsWith("prd-")))];
2126
+
2127
+ if (opts.prd !== null) {
2128
+ selectedPrdKeys = new Set([`prd-${opts.prd}`]);
2129
+ askedForPrdSelection = true;
2130
+ }
2131
+
2132
+ if (
2133
+ opts.repositoryContexts.length === 0 &&
2134
+ !opts.allPrds &&
2135
+ opts.prd === null &&
2136
+ !askedForPrdSelection &&
2137
+ prdKeys.length > 1
2138
+ ) {
2139
+ if (runDashboardState.active) {
2140
+ stopRunDashboard(false);
2141
+ }
2142
+ const picked = await pickPrdKey(prdKeys);
2143
+ if (currentUiMode === "run-minimal") {
2144
+ startRunDashboard(opts);
2145
+ }
2146
+ askedForPrdSelection = true;
2147
+ if (picked === null) {
2148
+ log("[stop]", "Cancelled by user.");
2149
+ break;
2150
+ }
2151
+ if (picked !== "all") {
2152
+ selectedPrdKeys = new Set([picked]);
2153
+ }
2154
+ }
2155
+
2156
+ let ranked = rankedAll;
2157
+ if (selectedPrdKeys && selectedPrdKeys.size > 0) {
2158
+ ranked = rankedAll.filter((i) => selectedPrdKeys!.has(i.worktreeKey));
2159
+ }
2160
+
2161
+ if (opts.verbose) {
2162
+ header(`Iteration ${iteration}/${MAX_ITERATIONS}`);
2163
+ printIssueTable(ranked);
2164
+ }
2165
+
2166
+ if (ranked.length === 0) {
2167
+ log("[wait]", "No issues matched current PRD scope.");
2168
+ break;
2169
+ }
2170
+
2171
+ const unblocked = ranked.filter((i) => !i.isBlocked && !i.isInProgress);
2172
+ const blocked = ranked.filter((i) => i.isBlocked);
2173
+ const inProgress = ranked.filter((i) => i.isInProgress);
2174
+ const prdsOpen = new Set(ranked.map((i) => i.worktreeKey).filter((k) => k.startsWith("prd-"))).size;
2175
+
2176
+ runDashboardState.statsLine = `prds:${prdsOpen} ready:${unblocked.length} blocked:${blocked.length} running:${inProgress.length} done:${doneCount} failed:${failedCount}`;
2177
+ refreshRunDashboard();
2178
+ log("[stats]", runDashboardState.statsLine);
2179
+
2180
+ const availableSlots = Math.max(opts.maxParallel - runningAgents.size, 0);
2181
+
2182
+ if (unblocked.length === 0 || availableSlots === 0) {
2183
+ if (runningAgents.size > 0) {
2184
+ log("[wait]", "Waiting for a running issue to free a slot...");
2185
+ await waitForNextRunningAgent(
2186
+ runningAgents,
2187
+ liveStates,
2188
+ { doneCount, failedCount },
2189
+ opts,
2190
+ { prdsOpen, unblockedCount: unblocked.length },
2191
+ ).then((succeeded) => {
2192
+ if (succeeded) {
2193
+ doneCount += 1;
2194
+ } else {
2195
+ failedCount += 1;
2196
+ }
2197
+ });
2198
+ continue;
2199
+ }
2200
+
2201
+ if (inProgress.length > 0) {
2202
+ log("[wait]", "All remaining issues are blocked or in-progress. Waiting...");
2203
+ } else {
2204
+ log("[pause]", "All issues are blocked. Nothing can proceed.");
2205
+ }
2206
+ break;
2207
+ }
2208
+
2209
+ const batch = selectRunnableBatchWithLimit(unblocked, opts, availableSlots);
2210
+
2211
+ if (batch.length === 0) {
2212
+ if (runningAgents.size > 0) {
2213
+ log("[wait]", "No runnable slot available yet. Waiting for an active issue...");
2214
+ await waitForNextRunningAgent(
2215
+ runningAgents,
2216
+ liveStates,
2217
+ { doneCount, failedCount },
2218
+ opts,
2219
+ { prdsOpen, unblockedCount: unblocked.length },
2220
+ ).then((succeeded) => {
2221
+ if (succeeded) {
2222
+ doneCount += 1;
2223
+ } else {
2224
+ failedCount += 1;
2225
+ }
2226
+ });
2227
+ continue;
2228
+ }
2229
+
2230
+ log("[pause]", "No runnable issues could be scheduled.");
2231
+ break;
2232
+ }
2233
+
2234
+ log("[run]", `launching ${batch.length} issue(s)`);
2235
+ for (const issue of batch) {
2236
+ runningAgents.set(issue.key, startAgentRun(issue, opts, liveStates));
2237
+ }
2238
+
2239
+ await waitForNextRunningAgent(
2240
+ runningAgents,
2241
+ liveStates,
2242
+ { doneCount, failedCount },
2243
+ opts,
2244
+ { prdsOpen, unblockedCount: unblocked.length },
2245
+ ).then((succeeded) => {
2246
+ if (succeeded) {
2247
+ doneCount += 1;
2248
+ } else {
2249
+ failedCount += 1;
2250
+ }
2251
+ });
2252
+ }
2253
+
2254
+ while (runningAgents.size > 0) {
2255
+ await waitForNextRunningAgent(
2256
+ runningAgents,
2257
+ liveStates,
2258
+ { doneCount, failedCount },
2259
+ opts,
2260
+ { prdsOpen: 0, unblockedCount: 0 },
2261
+ ).then((succeeded) => {
2262
+ if (succeeded) {
2263
+ doneCount += 1;
2264
+ } else {
2265
+ failedCount += 1;
2266
+ }
2267
+ });
2268
+ }
2269
+
2270
+ if (!runDashboardState.active) {
2271
+ console.log();
2272
+ }
2273
+ log("[done]", "Orchestrator finished.");
2274
+ stopRunDashboard();
2275
+ currentUiMode = "default";
2276
+ }
2277
+
2278
+ // ---------------------------------------------------------------------------
2279
+ // Entry Point
2280
+ // ---------------------------------------------------------------------------
2281
+ async function main(): Promise<void> {
2282
+ const opts = parseArgs();
2283
+
2284
+ switch (opts.mode) {
2285
+ case "status":
2286
+ showStatus(opts);
2287
+ break;
2288
+ case "cleanup":
2289
+ await cleanup(opts);
2290
+ break;
2291
+ case "reset":
2292
+ await resetLabels(opts);
2293
+ break;
2294
+ case "run":
2295
+ await runLoop(opts);
2296
+ break;
2297
+ }
2298
+ }
2299
+
2300
+ main().catch((err) => {
2301
+ console.error(`${c.red}Fatal error:${c.reset}`, err);
2302
+ process.exit(1);
2303
+ });
2304
+