forgedock 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +124 -48
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +652 -92
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +77 -9
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +167 -22
- package/commands/orchestrate/phase-4-execution.md +691 -62
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +117 -17
- package/commands/review-pr.md +95 -23
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +51 -15
- package/docs/OPENCODE.md +128 -25
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compact, deterministic preflight for OpenCode orchestration.
|
|
5
|
+
*
|
|
6
|
+
* The shared orchestration specs remain authoritative for the complete
|
|
7
|
+
* workflow. This module only handles the mechanical work needed before the
|
|
8
|
+
* first native task can start: issue resolution, eligibility filtering, basic
|
|
9
|
+
* dependency edges, and the initial ready queue.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { classifyInputPattern } from "./engine/resolve.mjs";
|
|
17
|
+
|
|
18
|
+
const VERSION = "opencode-preflight-v1";
|
|
19
|
+
const IN_FLIGHT_LABELS = new Set(["workflow:building", "workflow:in-review"]);
|
|
20
|
+
const EXCLUDED_LABELS = new Set([
|
|
21
|
+
"needs-human",
|
|
22
|
+
"workflow:decomposed",
|
|
23
|
+
"workflow:merged",
|
|
24
|
+
"workflow:invalid",
|
|
25
|
+
"workflow:awaiting-merge",
|
|
26
|
+
"blocked-on-human-merge",
|
|
27
|
+
"workflow:engine-error",
|
|
28
|
+
"epic",
|
|
29
|
+
]);
|
|
30
|
+
const FILE_EXTENSION = /\.(?:py|tsx?|jsx?|sql|json|ya?ml|mjs|js|sh|md)$/i;
|
|
31
|
+
const DOMAIN_RULES = [
|
|
32
|
+
["BILLING", /credit|billing|pricing|stripe|charge|refund/i],
|
|
33
|
+
["AUTH", /auth|session|jwt|login|permission|oauth/i],
|
|
34
|
+
["WORKER", /worker|queue|job|task|background|consumer/i],
|
|
35
|
+
["DATABASE", /migration|\.sql\b|database|postgres|alembic/i],
|
|
36
|
+
["FRONTEND", /component|page|layout|dashboard|ui|ux|frontend|web\/src/i],
|
|
37
|
+
["INFRA", /docker|deploy|traefik|nginx|ci|cd|infra|github\.action/i],
|
|
38
|
+
["AI", /llm|extract|schema|format|embedding|model/i],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function labelsOf(issue) {
|
|
42
|
+
return (issue?.labels || [])
|
|
43
|
+
.map((label) => (typeof label === "string" ? label : label?.name))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeSlug(value) {
|
|
48
|
+
return String(value || "")
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
51
|
+
.replace(/^-+|-+$/g, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function priorityWeight(labels) {
|
|
55
|
+
if (labels.some((label) => label === "priority:P0" || label === "P0")) return 4;
|
|
56
|
+
if (labels.some((label) => label === "priority:P1" || label === "P1")) return 3;
|
|
57
|
+
if (labels.some((label) => label === "priority:P2" || label === "P2")) return 2;
|
|
58
|
+
if (labels.some((label) => label === "priority:P3" || label === "P3")) return 1;
|
|
59
|
+
return 1.5;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isInvestigation(issue) {
|
|
63
|
+
const title = String(issue?.title || "");
|
|
64
|
+
const body = String(issue?.body || "");
|
|
65
|
+
return /investigate|audit|research|evaluate|assess|deep dive/i.test(title) ||
|
|
66
|
+
(/(- \[ \]|\* \[ \])/.test(body) && !/affected files|acceptance criteria/i.test(body)) ||
|
|
67
|
+
/deliverable:\s*execution plan|create issues/i.test(body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scopedSection(body) {
|
|
71
|
+
const source = String(body || "");
|
|
72
|
+
const heading = /^(?:##|###)\s+(Affected Files|Deliverables|Files to change)\s*$/im;
|
|
73
|
+
const match = heading.exec(source);
|
|
74
|
+
if (!match) return "";
|
|
75
|
+
const start = match.index + match[0].length;
|
|
76
|
+
const rest = source.slice(start);
|
|
77
|
+
const end = rest.search(/^#{1,6}\s+/m);
|
|
78
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function affectedFiles(body) {
|
|
82
|
+
const section = scopedSection(body);
|
|
83
|
+
if (!section) return [];
|
|
84
|
+
return [...section.matchAll(/`([^`\r\n]+)`/g)]
|
|
85
|
+
.map((match) => match[1].trim())
|
|
86
|
+
.filter((file) => FILE_EXTENSION.test(file))
|
|
87
|
+
.filter((file, index, files) => files.indexOf(file) === index)
|
|
88
|
+
.sort();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function domainsFor(issue) {
|
|
92
|
+
const text = [issue?.title, issue?.body, ...labelsOf(issue)].join(" ");
|
|
93
|
+
const domains = DOMAIN_RULES.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
94
|
+
return domains.length ? domains : ["NONE"];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function explicitDependencies(body) {
|
|
98
|
+
const dependencies = new Set();
|
|
99
|
+
const source = String(body || "");
|
|
100
|
+
const pattern = /(?:depends on|blocked by|after)\s+#?(\d+)\b/gi;
|
|
101
|
+
for (const match of source.matchAll(pattern)) dependencies.add(Number(match[1]));
|
|
102
|
+
return [...dependencies].sort((a, b) => a - b);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function controlFlags(input) {
|
|
106
|
+
const source = String(input || "");
|
|
107
|
+
const includeInFlight = /(?:^|\s)(?:--include-in-flight|--recover-in-flight)(?:\s|$)/i.test(source);
|
|
108
|
+
const deepPlan = /(?:^|\s)--deep-plan(?:\s|$)/i.test(source);
|
|
109
|
+
const includeBacklog = /(?:^|\s)--include-backlog(?:\s|$)/i.test(source);
|
|
110
|
+
const confirmed = /(?:^|\s)(?:--auto|--confirm)(?:\s|$)/i.test(source);
|
|
111
|
+
const cleaned = source
|
|
112
|
+
.replace(/(?:^|\s)(?:--include-in-flight|--recover-in-flight|--deep-plan|--include-backlog)(?=\s|$)/gi, " ")
|
|
113
|
+
.replace(/(?:^|\s)(?:--auto|--confirm)(?=\s|$)/gi, " ")
|
|
114
|
+
.replace(/(?:^|\s)--max-concurrent(?:=|\s+)[1-9]\d*(?=\s|$)/gi, " ")
|
|
115
|
+
.replace(/\s+/g, " ")
|
|
116
|
+
.trim();
|
|
117
|
+
const maxMatch = source.match(/(?:^|\s)--max-concurrent(?:=|\s+)([1-9]\d*)(?=\s|$)/i);
|
|
118
|
+
return {
|
|
119
|
+
input: cleaned,
|
|
120
|
+
includeInFlight,
|
|
121
|
+
deepPlan,
|
|
122
|
+
includeBacklog,
|
|
123
|
+
confirmed,
|
|
124
|
+
maxConcurrent: maxMatch ? Number(maxMatch[1]) : undefined,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveQuery(input, issues, { includeInFlight }) {
|
|
129
|
+
const trimmed = String(input || "").trim();
|
|
130
|
+
const classified = classifyInputPattern(trimmed);
|
|
131
|
+
const lower = trimmed.toLowerCase();
|
|
132
|
+
let pattern = classified.pattern;
|
|
133
|
+
let selected = [...issues];
|
|
134
|
+
const deferred = [];
|
|
135
|
+
let supported = true;
|
|
136
|
+
let reason = "";
|
|
137
|
+
|
|
138
|
+
if (!trimmed) {
|
|
139
|
+
supported = false;
|
|
140
|
+
reason = "empty input has no safe issue-set predicate";
|
|
141
|
+
} else if (/^no:milestone$|^no-milestone$/i.test(trimmed)) {
|
|
142
|
+
pattern = "no-milestone";
|
|
143
|
+
selected = selected.filter((issue) => !issue.milestone);
|
|
144
|
+
} else if (classified.kind === "literal") {
|
|
145
|
+
const requested = new Set();
|
|
146
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
147
|
+
const match = token.match(/^(?:[a-zA-Z0-9_-]+:)?#?(\d+)$/);
|
|
148
|
+
if (!match || token.includes(":")) {
|
|
149
|
+
supported = false;
|
|
150
|
+
reason = "satellite-prefixed literal issues require the full multi-repo resolver";
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
requested.add(Number(match[1]));
|
|
154
|
+
}
|
|
155
|
+
selected = selected.filter((issue) => requested.has(Number(issue.number)));
|
|
156
|
+
const found = new Set(selected.map((issue) => Number(issue.number)));
|
|
157
|
+
for (const number of requested) {
|
|
158
|
+
if (!found.has(number)) selected.push({ number, title: `Issue #${number}`, body: "", labels: [], milestone: null });
|
|
159
|
+
}
|
|
160
|
+
} else if (classified.pattern === "milestone") {
|
|
161
|
+
const requested = normalizeSlug(classified.args.join("-"));
|
|
162
|
+
selected = selected.filter((issue) => normalizeSlug(issue.milestone?.title) === requested);
|
|
163
|
+
} else if (classified.pattern === "next-n") {
|
|
164
|
+
const count = Number(classified.args[0]);
|
|
165
|
+
selected = selected
|
|
166
|
+
.sort((a, b) => priorityWeight(labelsOf(b)) - priorityWeight(labelsOf(a)) || Number(a.number) - Number(b.number))
|
|
167
|
+
.slice(0, Number.isFinite(count) && count > 0 ? count : 0);
|
|
168
|
+
} else if (classified.pattern === "fast-lane") {
|
|
169
|
+
selected = selected.filter((issue) => {
|
|
170
|
+
const labels = labelsOf(issue);
|
|
171
|
+
return !issue.milestone && (labels.includes("bug") || labels.includes("fix") || /^fix[:(]/i.test(issue.title || ""));
|
|
172
|
+
});
|
|
173
|
+
} else if (classified.pattern === "priority") {
|
|
174
|
+
const requested = lower.split(":").at(-1);
|
|
175
|
+
selected = selected.filter((issue) => labelsOf(issue).some((label) => label.toLowerCase() === `priority:${requested}` || label.toLowerCase() === requested));
|
|
176
|
+
} else if (classified.pattern === "bare-slug") {
|
|
177
|
+
const requested = normalizeSlug(trimmed);
|
|
178
|
+
selected = selected.filter((issue) =>
|
|
179
|
+
normalizeSlug(issue.milestone?.title) === requested || labelsOf(issue).some((label) => normalizeSlug(label) === requested),
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
supported = false;
|
|
183
|
+
reason = `input pattern "${classified.pattern}" needs the full multi-phase resolver`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!includeInFlight) {
|
|
187
|
+
for (const issue of selected) {
|
|
188
|
+
if (labelsOf(issue).some((label) => IN_FLIGHT_LABELS.has(label))) {
|
|
189
|
+
deferred.push({ number: Number(issue.number), reason: "in-flight" });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
selected = selected.filter((issue) => !labelsOf(issue).some((label) => IN_FLIGHT_LABELS.has(label)));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { supported, reason, pattern, classified, selected, deferred };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function addEdge(predecessors, predecessor, successor, kind, edges) {
|
|
199
|
+
if (predecessor === successor || !predecessors.has(successor) || !predecessors.has(predecessor)) return;
|
|
200
|
+
predecessors.get(successor).add(predecessor);
|
|
201
|
+
edges.push({ predecessor, successor, kind });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function buildPreflightPlan({ input, repo = "", issues = [], maxConcurrent = 12 } = {}) {
|
|
205
|
+
const flags = controlFlags(input);
|
|
206
|
+
const query = resolveQuery(flags.input, issues, flags);
|
|
207
|
+
if (!query.supported) {
|
|
208
|
+
return {
|
|
209
|
+
version: VERSION,
|
|
210
|
+
supported: false,
|
|
211
|
+
mode: "full-spec-required",
|
|
212
|
+
input: flags.input,
|
|
213
|
+
repo,
|
|
214
|
+
reason: query.reason,
|
|
215
|
+
pattern: query.pattern,
|
|
216
|
+
confirmed: flags.confirmed,
|
|
217
|
+
dispatchNow: [],
|
|
218
|
+
deferred: query.deferred,
|
|
219
|
+
warnings: [query.reason],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const excluded = [];
|
|
224
|
+
const deferred = [...query.deferred];
|
|
225
|
+
const admitted = [];
|
|
226
|
+
for (const issue of query.selected) {
|
|
227
|
+
const labels = labelsOf(issue);
|
|
228
|
+
const excludedLabel = labels.find((label) => EXCLUDED_LABELS.has(label));
|
|
229
|
+
if (excludedLabel || String(issue.state || "").toUpperCase() === "CLOSED") {
|
|
230
|
+
excluded.push({ number: Number(issue.number), reason: excludedLabel || "closed" });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
admitted.push(issue);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const numbers = new Set(admitted.map((issue) => Number(issue.number)));
|
|
237
|
+
const predecessors = new Map(admitted.map((issue) => [Number(issue.number), new Set()]));
|
|
238
|
+
const edges = [];
|
|
239
|
+
const externalDependencies = new Map();
|
|
240
|
+
|
|
241
|
+
for (const issue of admitted) {
|
|
242
|
+
const number = Number(issue.number);
|
|
243
|
+
const dependencies = explicitDependencies(issue.body);
|
|
244
|
+
const external = dependencies.filter((dependency) => !numbers.has(dependency));
|
|
245
|
+
if (external.length) externalDependencies.set(number, external);
|
|
246
|
+
for (const dependency of dependencies) addEdge(predecessors, dependency, number, "explicit", edges);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const filesByIssue = new Map(admitted.map((issue) => [Number(issue.number), affectedFiles(issue.body)]));
|
|
250
|
+
const owners = new Map();
|
|
251
|
+
for (const [number, files] of filesByIssue) {
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
const existing = owners.get(file) || [];
|
|
254
|
+
for (const other of existing) {
|
|
255
|
+
const predecessor = Math.min(number, other);
|
|
256
|
+
const successor = Math.max(number, other);
|
|
257
|
+
addEdge(predecessors, predecessor, successor, "same-file", edges);
|
|
258
|
+
}
|
|
259
|
+
existing.push(number);
|
|
260
|
+
owners.set(file, existing);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const databaseIssues = admitted
|
|
265
|
+
.filter((issue) => domainsFor(issue).includes("DATABASE"))
|
|
266
|
+
.map((issue) => Number(issue.number))
|
|
267
|
+
.sort((a, b) => a - b);
|
|
268
|
+
for (let index = 1; index < databaseIssues.length; index++) {
|
|
269
|
+
addEdge(predecessors, databaseIssues[index - 1], databaseIssues[index], "database", edges);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const records = admitted
|
|
273
|
+
.map((issue) => {
|
|
274
|
+
const number = Number(issue.number);
|
|
275
|
+
const labels = labelsOf(issue);
|
|
276
|
+
return {
|
|
277
|
+
number,
|
|
278
|
+
title: issue.title || `Issue #${number}`,
|
|
279
|
+
labels,
|
|
280
|
+
classification: isInvestigation(issue) ? "INVESTIGATION" : "IMPLEMENTATION",
|
|
281
|
+
domain: domainsFor(issue),
|
|
282
|
+
files: filesByIssue.get(number) || [],
|
|
283
|
+
predecessors: [...(predecessors.get(number) || [])].sort((a, b) => a - b),
|
|
284
|
+
externalDependencies: externalDependencies.get(number) || [],
|
|
285
|
+
priority: priorityWeight(labels),
|
|
286
|
+
inFlight: labels.some((label) => IN_FLIGHT_LABELS.has(label)),
|
|
287
|
+
};
|
|
288
|
+
})
|
|
289
|
+
.sort((a, b) => b.priority - a.priority || a.number - b.number);
|
|
290
|
+
|
|
291
|
+
const investigations = records.filter((issue) => issue.classification === "INVESTIGATION");
|
|
292
|
+
const ready = records
|
|
293
|
+
.filter((issue) => issue.predecessors.length === 0 && issue.externalDependencies.length === 0)
|
|
294
|
+
.sort((a, b) => b.priority - a.priority || a.number - b.number)
|
|
295
|
+
.map((issue) => issue.number);
|
|
296
|
+
const effectiveMax = Number.isInteger(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 12;
|
|
297
|
+
const deepPlan = flags.deepPlan || query.pattern === "cascade" || query.pattern === "repo-scoped";
|
|
298
|
+
const requiresDeepPlan = deepPlan || investigations.length > 0;
|
|
299
|
+
const warnings = [
|
|
300
|
+
"Compact preflight uses explicit dependencies, scoped issue-body files, and the database serialization rule.",
|
|
301
|
+
"The full Phase 3 conflict/history analysis remains available with --deep-plan or when this preflight is unsupported.",
|
|
302
|
+
];
|
|
303
|
+
if (flags.includeInFlight) warnings.push("In-flight issues were explicitly admitted for recovery.");
|
|
304
|
+
if (deferred.length) warnings.push(`${deferred.length} in-flight issue(s) were deferred; use --include-in-flight for explicit recovery.`);
|
|
305
|
+
if (flags.includeBacklog) warnings.push("Review-finding backlog scope requires the full cascade resolver.");
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
version: VERSION,
|
|
309
|
+
supported: true,
|
|
310
|
+
mode: requiresDeepPlan ? "compact-with-full-spec-followup" : "compact",
|
|
311
|
+
input: flags.input,
|
|
312
|
+
repo,
|
|
313
|
+
pattern: query.pattern,
|
|
314
|
+
total: records.length,
|
|
315
|
+
issues: records,
|
|
316
|
+
investigations: investigations.map((issue) => issue.number),
|
|
317
|
+
inFlight: records.filter((issue) => issue.inFlight).map((issue) => issue.number),
|
|
318
|
+
excluded,
|
|
319
|
+
deferred,
|
|
320
|
+
edges,
|
|
321
|
+
ready,
|
|
322
|
+
dispatchNow: requiresDeepPlan || !flags.confirmed ? [] : ready.slice(0, effectiveMax),
|
|
323
|
+
queued: requiresDeepPlan ? ready : ready.slice(effectiveMax),
|
|
324
|
+
confirmed: flags.confirmed,
|
|
325
|
+
requiresConfirmation: !flags.confirmed,
|
|
326
|
+
requiresDeepPlan,
|
|
327
|
+
warnings,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ghJson(cwd, args) {
|
|
332
|
+
const result = spawnSync("gh", args, {
|
|
333
|
+
cwd,
|
|
334
|
+
encoding: "utf8",
|
|
335
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
336
|
+
windowsHide: true,
|
|
337
|
+
});
|
|
338
|
+
if (result.error || result.status !== 0) {
|
|
339
|
+
const detail = result.error?.message || String(result.stderr || "gh command failed").trim();
|
|
340
|
+
throw new Error(`GitHub preflight failed: ${detail}`);
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
return JSON.parse(result.stdout || "[]");
|
|
344
|
+
} catch {
|
|
345
|
+
throw new Error("GitHub preflight returned invalid JSON");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function forgeConfigPath(cwd) {
|
|
350
|
+
let current = resolve(cwd);
|
|
351
|
+
while (true) {
|
|
352
|
+
const path = join(current, "forge.yaml");
|
|
353
|
+
if (existsSync(path)) return path;
|
|
354
|
+
const parent = dirname(current);
|
|
355
|
+
if (parent === current) return "";
|
|
356
|
+
current = parent;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function configuredConcurrency(cwd) {
|
|
361
|
+
const path = forgeConfigPath(cwd);
|
|
362
|
+
if (!path) return 12;
|
|
363
|
+
const source = readFileSync(path, "utf8");
|
|
364
|
+
const match = source.match(/^\s*max_concurrent\s*:\s*([1-9]\d*)\s*$/m);
|
|
365
|
+
return match ? Number(match[1]) : 12;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function configuredRepo(cwd) {
|
|
369
|
+
const path = forgeConfigPath(cwd);
|
|
370
|
+
if (!path) return "";
|
|
371
|
+
const source = readFileSync(path, "utf8");
|
|
372
|
+
const owner = source.match(/^\s*owner\s*:\s*["']?([^\s"'#]+)["']?\s*$/m)?.[1];
|
|
373
|
+
const name = source.match(/^\s*repo\s*:\s*["']?([^\s"'#]+)["']?\s*$/m)?.[1];
|
|
374
|
+
return owner && name ? `${owner}/${name}` : "";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function parseCli(argv) {
|
|
378
|
+
const options = { cwd: process.cwd(), repo: "", input: "" };
|
|
379
|
+
for (let index = 0; index < argv.length; index++) {
|
|
380
|
+
const arg = argv[index];
|
|
381
|
+
if (arg === "--cwd" || arg === "--repo" || arg === "--args") {
|
|
382
|
+
options[arg.slice(2) === "args" ? "input" : arg.slice(2)] = argv[++index] || "";
|
|
383
|
+
} else if (!options.input) {
|
|
384
|
+
options.input = arg;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return options;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function runPreflight({ cwd = process.cwd(), repo, input, gh = ghJson } = {}) {
|
|
391
|
+
repo ||= configuredRepo(cwd);
|
|
392
|
+
if (!repo) throw new Error("--repo is required or must be present in forge.yaml");
|
|
393
|
+
const flags = controlFlags(input);
|
|
394
|
+
const issueArgs = [
|
|
395
|
+
"issue",
|
|
396
|
+
"list",
|
|
397
|
+
"-R",
|
|
398
|
+
repo,
|
|
399
|
+
"--state",
|
|
400
|
+
"open",
|
|
401
|
+
"--limit",
|
|
402
|
+
"500",
|
|
403
|
+
"--json",
|
|
404
|
+
"number,title,body,labels,milestone,state,createdAt,updatedAt",
|
|
405
|
+
];
|
|
406
|
+
let issues = gh(cwd, issueArgs);
|
|
407
|
+
if (!Array.isArray(issues)) issues = [];
|
|
408
|
+
|
|
409
|
+
if (classifyInputPattern(flags.input).kind === "literal") {
|
|
410
|
+
const requested = [...flags.input.matchAll(/(?:^|\s)(?:[a-zA-Z0-9_-]+:)?#?(\d+)/g)].map((match) => Number(match[1]));
|
|
411
|
+
const found = new Set(issues.map((issue) => Number(issue.number)));
|
|
412
|
+
for (const number of requested.filter((item) => !found.has(item))) {
|
|
413
|
+
issues.push(gh(cwd, ["issue", "view", String(number), "-R", repo, "--json", "number,title,body,labels,milestone,state,createdAt,updatedAt"]));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return buildPreflightPlan({
|
|
418
|
+
input,
|
|
419
|
+
repo,
|
|
420
|
+
issues,
|
|
421
|
+
maxConcurrent: flags.maxConcurrent || configuredConcurrency(cwd),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
426
|
+
try {
|
|
427
|
+
const options = parseCli(process.argv.slice(2));
|
|
428
|
+
const plan = runPreflight(options);
|
|
429
|
+
process.stdout.write(`${JSON.stringify(plan)}\n`);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
process.stderr.write(`${error.message}\n`);
|
|
432
|
+
process.exitCode = 1;
|
|
433
|
+
}
|
|
434
|
+
}
|
package/bin/runner.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* renderDryRun(ctx) → string
|
|
26
26
|
* renderSummaryCard(ctx) → string
|
|
27
27
|
* resolveConfiguredDefaultModel(cwd) → string|null (forge.yaml agents.default_model, resolved)
|
|
28
|
+
* resolveConfiguredCliTimeout(cwd) → number|null (forge.yaml pipeline.cli_timeout_minutes, ms)
|
|
28
29
|
* runCommand(opts) → Promise<{status, ...}>
|
|
29
30
|
*
|
|
30
31
|
* Design notes:
|
|
@@ -64,8 +65,10 @@ const DEFAULT_BASH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
64
65
|
// Default wall-clock limit for a single `claude --print` CLI-backend
|
|
65
66
|
// invocation (issue #2003). This bounds an entire command run (the CLI's own
|
|
66
67
|
// internal tool-use loop), not one bash step, so it is deliberately larger
|
|
67
|
-
// than DEFAULT_BASH_TIMEOUT_MS.
|
|
68
|
-
|
|
68
|
+
// than DEFAULT_BASH_TIMEOUT_MS. This is a safety ceiling, not a cap on normal
|
|
69
|
+
// build work. Override via FORGEDOCK_CLI_TIMEOUT_MS (ms) or forge.yaml's
|
|
70
|
+
// pipeline.cli_timeout_minutes.
|
|
71
|
+
const DEFAULT_CLI_TIMEOUT_MS = 60 * 60 * 1000;
|
|
69
72
|
// Short bound for the `claude --version` presence probe used by backend
|
|
70
73
|
// auto-detection — this must never make --dry-run (or a live run) hang, so
|
|
71
74
|
// it is far shorter than DEFAULT_CLI_TIMEOUT_MS. Mirrors the timeout already
|
|
@@ -127,6 +130,35 @@ function readOnlySet(values) {
|
|
|
127
130
|
* simultaneously. Treat it as immutable; mutation attempts throw.
|
|
128
131
|
*/
|
|
129
132
|
export const VALID_BACKENDS = readOnlySet(["cli", "api", "auto"]);
|
|
133
|
+
|
|
134
|
+
export const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether the caller is an OpenCode session. The adapter sets this marker on
|
|
138
|
+
* every shell environment; keeping the predicate small makes all Claude
|
|
139
|
+
* backend entrypoints agree on the same runtime boundary.
|
|
140
|
+
*/
|
|
141
|
+
export function isOpenCodeRuntime(env = process.env) {
|
|
142
|
+
return String(env?.FORGE_RUNTIME || "").toLowerCase() === "opencode";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Refuse Claude-backed execution from an OpenCode workflow. OpenCode has its
|
|
147
|
+
* own Skill/Task execution path; falling through to this runner would create a
|
|
148
|
+
* competing controller and may consume Claude credentials or quota.
|
|
149
|
+
*/
|
|
150
|
+
export function assertOpenCodeNativeRuntime({
|
|
151
|
+
env = process.env,
|
|
152
|
+
operation = "Claude-backed ForgeDock execution",
|
|
153
|
+
} = {}) {
|
|
154
|
+
if (!isOpenCodeRuntime(env)) return;
|
|
155
|
+
const error = new Error(
|
|
156
|
+
`${FORGE_OPENCODE_CAPABILITY_ERROR}: ${operation} is unavailable when FORGE_RUNTIME=opencode. Use native ForgeDock Skill/Task dispatch instead.`,
|
|
157
|
+
);
|
|
158
|
+
error.code = FORGE_OPENCODE_CAPABILITY_ERROR;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
|
|
130
162
|
// Per-process memoization for isClaudeCliAvailable(), keyed by `cwd` (issue
|
|
131
163
|
// #2011). `runCommand()` calls resolveBackend() — and therefore, for the
|
|
132
164
|
// default "auto" backend, isClaudeCliAvailable() — unconditionally on every
|
|
@@ -210,6 +242,27 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
210
242
|
}
|
|
211
243
|
}
|
|
212
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Resolve the CLI backend timeout from forge.yaml's
|
|
247
|
+
* `pipeline.cli_timeout_minutes` field, if it is a positive integer.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} cwd - Working directory to look for forge.yaml in.
|
|
250
|
+
* @returns {number|null} Timeout in milliseconds, or null when not configured.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveConfiguredCliTimeout(cwd) {
|
|
253
|
+
try {
|
|
254
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
255
|
+
if (!existsSync(forgeYamlPath)) return null;
|
|
256
|
+
const raw = readFileSync(forgeYamlPath, "utf-8");
|
|
257
|
+
const minutes = Number(parseForgeYaml(raw)?.pipeline?.cli_timeout_minutes);
|
|
258
|
+
return Number.isFinite(minutes) && Number.isInteger(minutes) && minutes > 0
|
|
259
|
+
? minutes * 60 * 1000
|
|
260
|
+
: null;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
213
266
|
// ---------------------------------------------------------------------------
|
|
214
267
|
// Backend selection (issue #2003) — CLI vs API execution backend
|
|
215
268
|
// ---------------------------------------------------------------------------
|
|
@@ -491,9 +544,10 @@ export function runCliWithStalePathRecovery({
|
|
|
491
544
|
* API checks verify that a key is configured, not that the key is accepted.
|
|
492
545
|
*/
|
|
493
546
|
export function checkExecutionBackend({
|
|
494
|
-
|
|
547
|
+
env = process.env,
|
|
548
|
+
requested = env.FORGEDOCK_BACKEND || "auto",
|
|
495
549
|
cwd = process.cwd(),
|
|
496
|
-
apiKey =
|
|
550
|
+
apiKey = env.ANTHROPIC_API_KEY,
|
|
497
551
|
resolveCliFn = resolveClaudeCliBinary,
|
|
498
552
|
spawnImpl = spawnSync,
|
|
499
553
|
sdkAvailableFn = () => {
|
|
@@ -509,6 +563,10 @@ export function checkExecutionBackend({
|
|
|
509
563
|
return { ready: false, backend: requested, reason: "invalid-backend" };
|
|
510
564
|
}
|
|
511
565
|
|
|
566
|
+
if (isOpenCodeRuntime(env)) {
|
|
567
|
+
return { ready: false, backend: "opencode", reason: "native-opencode-required" };
|
|
568
|
+
}
|
|
569
|
+
|
|
512
570
|
if (requested !== "api") {
|
|
513
571
|
const cliPath = resolveCliFn(cwd);
|
|
514
572
|
if (cliPath) {
|
|
@@ -606,14 +664,16 @@ export function resolveBackendLadder({
|
|
|
606
664
|
* @param {object} [opts]
|
|
607
665
|
* @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
|
|
608
666
|
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
667
|
+
* @param {object} [opts.env] - Runtime environment used for capability checks.
|
|
609
668
|
* @returns {"cli"|"api"}
|
|
610
669
|
*/
|
|
611
|
-
export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {}) {
|
|
670
|
+
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
|
|
612
671
|
if (!VALID_BACKENDS.has(requested)) {
|
|
613
672
|
throw new Error(
|
|
614
673
|
`Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
|
|
615
674
|
);
|
|
616
675
|
}
|
|
676
|
+
assertOpenCodeNativeRuntime({ env, operation: "Claude-backed backend resolution" });
|
|
617
677
|
return resolveBackendLadder({
|
|
618
678
|
override: requested === "auto" ? undefined : requested,
|
|
619
679
|
validOverrides: new Set(["cli", "api"]),
|
|
@@ -1043,8 +1103,10 @@ export function runCliBackend({
|
|
|
1043
1103
|
spawnFn = spawnSync,
|
|
1044
1104
|
}) {
|
|
1045
1105
|
const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1106
|
+
const configuredTimeout = resolveConfiguredCliTimeout(cwd);
|
|
1107
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0
|
|
1108
|
+
? rawTimeout
|
|
1109
|
+
: configuredTimeout ?? DEFAULT_CLI_TIMEOUT_MS;
|
|
1048
1110
|
|
|
1049
1111
|
// Write the system prompt (command spec + framing) to a private temp file
|
|
1050
1112
|
// rather than passing it inline as an argv string — see the SYSTEM PROMPT
|
|
@@ -2086,9 +2148,15 @@ export async function runCommand(opts = {}) {
|
|
|
2086
2148
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
2087
2149
|
dryRun = false,
|
|
2088
2150
|
backend = process.env.FORGEDOCK_BACKEND || "auto",
|
|
2151
|
+
runtimeEnv = process.env,
|
|
2089
2152
|
logger = console,
|
|
2090
2153
|
} = opts;
|
|
2091
2154
|
|
|
2155
|
+
assertOpenCodeNativeRuntime({
|
|
2156
|
+
env: runtimeEnv,
|
|
2157
|
+
operation: "Claude-backed ForgeDock runner",
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2092
2160
|
const spec = loadCommandSpec(commandsDir, commandName);
|
|
2093
2161
|
const systemPrompt = buildSystemPrompt(spec, { repoRoot: cwd });
|
|
2094
2162
|
// CLI-backend-specific prompt (issue #2019) — see buildCliSystemPrompt's
|
|
@@ -2104,10 +2172,10 @@ export async function runCommand(opts = {}) {
|
|
|
2104
2172
|
// misbehaving. An explicitly invalid `backend` value DOES throw here —
|
|
2105
2173
|
// surfacing a bad --backend/FORGEDOCK_BACKEND value immediately, before any
|
|
2106
2174
|
// work is attempted, rather than silently ignoring it.
|
|
2107
|
-
let resolvedBackend = resolveBackend({ requested: backend, cwd });
|
|
2175
|
+
let resolvedBackend = resolveBackend({ requested: backend, cwd, env: runtimeEnv });
|
|
2108
2176
|
|
|
2109
2177
|
if (resolvedBackend === "cli" && backend === "auto") {
|
|
2110
|
-
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey });
|
|
2178
|
+
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
|
|
2111
2179
|
if (readiness.backend === "api") resolvedBackend = "api";
|
|
2112
2180
|
}
|
|
2113
2181
|
|