@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.
- package/.fiftthnexus/.github/workflows/copilot-orchestrator.yml +78 -0
- package/.fiftthnexus/actions/Dockerfile +34 -0
- package/.fiftthnexus/actions/copilot-agent.mjs +269 -0
- package/.fiftthnexus/actions/package.json +8 -0
- package/.fiftthnexus/orchestrator.ts +2304 -0
- package/.fiftthnexus/skills/env-implement-prompt.md +65 -0
- package/.fiftthnexus/skills/env-plan-prompt.md +33 -0
- package/.fiftthnexus/skills/env-review-prompt.md +61 -0
- package/.fiftthnexus/skills/grill-me.md +9 -0
- package/.fiftthnexus/skills/prd-to-issues.md +150 -0
- package/.fiftthnexus/skills/write-prd.md +70 -0
- package/README.md +216 -25
- package/dist/api/client.d.ts +6 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +13 -2
- package/dist/api/client.js.map +1 -1
- package/dist/commands/checkout.d.ts +6 -1
- package/dist/commands/checkout.d.ts.map +1 -1
- package/dist/commands/checkout.js +415 -44
- package/dist/commands/checkout.js.map +1 -1
- package/dist/commands/login.d.ts +0 -2
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +83 -32
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/model.d.ts +2 -0
- package/dist/commands/model.d.ts.map +1 -0
- package/dist/commands/model.js +32 -0
- package/dist/commands/model.js.map +1 -0
- package/dist/commands/planningContext.d.ts +6 -0
- package/dist/commands/planningContext.d.ts.map +1 -0
- package/dist/commands/planningContext.js +91 -0
- package/dist/commands/planningContext.js.map +1 -0
- package/dist/commands/repo.d.ts.map +1 -1
- package/dist/commands/repo.js +38 -15
- package/dist/commands/repo.js.map +1 -1
- package/dist/commands/skills.d.ts +2 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +123 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/use.d.ts +1 -5
- package/dist/commands/use.d.ts.map +1 -1
- package/dist/commands/use.js +63 -48
- package/dist/commands/use.js.map +1 -1
- package/dist/index.js +86 -27
- package/dist/index.js.map +1 -1
- package/dist/services/nexusService.d.ts +30 -0
- package/dist/services/nexusService.d.ts.map +1 -0
- package/dist/services/nexusService.js +188 -0
- package/dist/services/nexusService.js.map +1 -0
- package/dist/services/prdService.d.ts +12 -0
- package/dist/services/prdService.d.ts.map +1 -0
- package/dist/services/prdService.js +103 -0
- package/dist/services/prdService.js.map +1 -0
- package/dist/services/taskSelection.d.ts +10 -0
- package/dist/services/taskSelection.d.ts.map +1 -0
- package/dist/services/taskSelection.js +112 -0
- package/dist/services/taskSelection.js.map +1 -0
- package/dist/services/taskService.d.ts +23 -1
- package/dist/services/taskService.d.ts.map +1 -1
- package/dist/services/taskService.js +118 -12
- package/dist/services/taskService.js.map +1 -1
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +20 -3
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/dashboard.d.ts +65 -0
- package/dist/utils/dashboard.d.ts.map +1 -0
- package/dist/utils/dashboard.js +205 -0
- package/dist/utils/dashboard.js.map +1 -0
- package/dist/utils/models.d.ts +14 -0
- package/dist/utils/models.d.ts.map +1 -0
- package/dist/utils/models.js +89 -0
- package/dist/utils/models.js.map +1 -0
- package/dist/utils/ui.d.ts +6 -0
- package/dist/utils/ui.d.ts.map +1 -1
- package/dist/utils/ui.js +22 -1
- package/dist/utils/ui.js.map +1 -1
- package/dist/utils/version.d.ts +4 -0
- package/dist/utils/version.d.ts.map +1 -0
- package/dist/utils/version.js +26 -0
- package/dist/utils/version.js.map +1 -0
- package/package.json +9 -4
- package/.github/workflows/publish-npm.yml +0 -62
- package/dist/commands/tasks.d.ts +0 -2
- package/dist/commands/tasks.d.ts.map +0 -1
- package/dist/commands/tasks.js +0 -69
- package/dist/commands/tasks.js.map +0 -1
- package/dist/context/runtimeContext.d.ts +0 -14
- package/dist/context/runtimeContext.d.ts.map +0 -1
- package/dist/context/runtimeContext.js +0 -21
- package/dist/context/runtimeContext.js.map +0 -1
- package/dist/services/taskContext.d.ts +0 -14
- package/dist/services/taskContext.d.ts.map +0 -1
- package/dist/services/taskContext.js +0 -15
- package/dist/services/taskContext.js.map +0 -1
- package/dist/utils/api.d.ts +0 -10
- package/dist/utils/api.d.ts.map +0 -1
- package/dist/utils/api.js +0 -25
- package/dist/utils/api.js.map +0 -1
- package/src/api/client.ts +0 -31
- package/src/commands/checkout.ts +0 -101
- package/src/commands/login.ts +0 -145
- package/src/commands/repo.ts +0 -113
- package/src/commands/tasks.ts +0 -86
- package/src/commands/use.ts +0 -149
- package/src/config/configService.ts +0 -56
- package/src/context/runtimeContext.ts +0 -42
- package/src/git/gitService.ts +0 -29
- package/src/index.ts +0 -133
- package/src/services/taskContext.ts +0 -32
- package/src/services/taskService.ts +0 -53
- package/src/utils/api.ts +0 -41
- package/src/utils/config.ts +0 -48
- package/src/utils/ui.ts +0 -46
- package/tsconfig.json +0 -18
- 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
|
+
|