forgedock 1.7.1 → 1.8.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/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.mjs +26 -17
- package/bin/forgedock.mjs +15 -2
- 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 +47 -13
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +30 -4
- 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 +141 -13
- package/commands/orchestrate/phase-4-execution.md +660 -70
- 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 +1 -1
- package/commands/review-pr-staging.md +100 -18
- package/commands/review-pr.md +78 -24
- package/commands/upgrade-deps.md +1 -1
- 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 +45 -10
- package/docs/OPENCODE.md +21 -6
- 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
package/bin/opencode-adapter.mjs
CHANGED
|
@@ -198,16 +198,43 @@ function openCodeReviewDispatchContract() {
|
|
|
198
198
|
return [
|
|
199
199
|
"OpenCode review-dispatch override:",
|
|
200
200
|
"Before applying the workflow's Claude-specific Task/Agent availability check, treat an OpenCode runtime marker (`FORGE_RUNTIME=opencode`, `OPENCODE_SESSION_ID`, `OPENCODE_PID`, or `OPENCODE`) as native capability context and set `DISPATCH_TOOL=task`.",
|
|
201
|
-
"Do not enter the `Neither tool is available` branch solely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background
|
|
202
|
-
"
|
|
201
|
+
"Do not enter the `Neither tool is available` branch solely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background }`; use `general` for implementation/review and `explore` for read-only discovery.",
|
|
202
|
+
"Native tasks are foreground by default. Review, quality, remediation, and any other load-bearing child must await its completed task result and propagate its structured `*_RESULT` block before the parent continues. A progress message is not a result. `background: true` is reserved for the Phase 4 OpenCode ready-issue dispatcher, which persists the returned child-session id before handling its completion event.",
|
|
203
|
+
"For a `work-on/remediate` task, preserve the remediation state transition: after FIXABLE classification, replace `needs-human` with `workflow:in-review`; restore `needs-human` only for a fresh block or an UNFIXABLE policy escalation. Never retain both labels during automated remediation or re-review.",
|
|
203
204
|
"If lowercase native `task` is genuinely absent from the current tool registry, post `FORGE:REVIEW_BLOCKED` and stop; never replace the required isolated review with inline work or another controller.",
|
|
204
205
|
].join("\n");
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
function openCodeOrchestrateContract(forgeHome) {
|
|
209
|
+
const preflightPath = portablePath(join(forgeHome, "bin", "orchestrate-preflight.mjs"));
|
|
210
|
+
return [
|
|
211
|
+
"OpenCode orchestration fast path:",
|
|
212
|
+
`Before reading config.md, the authoritative workflow, or any phase file, run node "$FORGE_HOME/bin/orchestrate-preflight.mjs" --repo "$GH_REPO" --args "$ARGUMENTS" from the target repository and parse its JSON output. The installed implementation is ${preflightPath}. The helper resolves the repository from forge.yaml when GH_REPO is empty.`,
|
|
213
|
+
"The preflight is deterministic and only handles issue resolution, eligibility filtering, explicit dependencies, scoped issue-body file overlap, database serialization, and the initial ready queue.",
|
|
214
|
+
"When supported is true, requiresDeepPlan is false, and confirmed is true, launch dispatchNow immediately with native task calls using the exact work-on skill contract. Without explicit --auto or --confirm, present the compact plan and ask for one confirmation; after the user confirms, launch the plan's ready queue without re-reading the large phase files. Do not load the full phase-3 or phase-4 files just to ask that question.",
|
|
215
|
+
"If requiresDeepPlan is true, the input is unsupported, preflight fails, or a task completion needs recovery, continue from the authoritative shared phase files. The fast path never closes, deduplicates, or edits issue bodies.",
|
|
216
|
+
"Treat queued as deferred by the concurrency cap and use task-result events to dispatch the next ready issue. Keep the full shared workflow's labels, annotations, leases, and terminal-state rules.",
|
|
217
|
+
].join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function openCodeBuildStageContract(forgeHome) {
|
|
221
|
+
const implementPath = portablePath(join(forgeHome, "commands", "work-on", "build", "implement.md"));
|
|
222
|
+
const validatePath = portablePath(join(forgeHome, "commands", "work-on", "build", "validate.md"));
|
|
223
|
+
return [
|
|
224
|
+
"OpenCode sequential build dispatch:",
|
|
225
|
+
"The B5 and B6 calls in the authoritative workflow are serialized control-flow boundaries. Do not load either stage with the native `skill` tool: that tool only injects instructions and does not return a child-session result to this dispatcher.",
|
|
226
|
+
"For B5, call native `task` with `subagent_type: \"general\"` and `background: false`. Its prompt must load and execute `" + implementPath + "` with the exact build arguments, stage changes, and return only `IMPLEMENT_RESULT`. Wait for the completed task result before continuing.",
|
|
227
|
+
"Parse `IMPLEMENT_RESULT` from the completed B5 task. Continue to B6 only for `COMPLETE` or `ALREADY_DONE`; preserve the workflow's existing terminal handling for every other status.",
|
|
228
|
+
"For B6, call native `task` with `subagent_type: \"general\"` and `background: false`. Its prompt must load and execute `" + validatePath + "` with the worktree and B5 changed-file list, then return only `VALIDATE_RESULT`. Wait for completion before the acceptance gate.",
|
|
229
|
+
"Foreground is the native default for every load-bearing child. Only the Phase 4 OpenCode ready-issue dispatcher uses `background: true`.",
|
|
230
|
+
].join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
207
233
|
export function renderOpenCodeCommand({ description, forgeHome, command }) {
|
|
208
234
|
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
209
235
|
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
210
236
|
const nativeSkillExpression = '${x.replaceAll(":", "-").replaceAll("/", "-")}';
|
|
237
|
+
const isOrchestrate = command === "orchestrate";
|
|
211
238
|
return [
|
|
212
239
|
"---",
|
|
213
240
|
`description: ${yamlString(`ForgeDock: ${description}`)}`,
|
|
@@ -215,16 +242,20 @@ export function renderOpenCodeCommand({ description, forgeHome, command }) {
|
|
|
215
242
|
"---",
|
|
216
243
|
COMMAND_SENTINEL,
|
|
217
244
|
"",
|
|
218
|
-
|
|
245
|
+
(isOrchestrate
|
|
246
|
+
? "Run the OpenCode preflight before loading the authoritative ForgeDock workflow at `" + specPath + "` with these exact arguments:"
|
|
247
|
+
: "Run the authoritative ForgeDock workflow at `" + specPath + "` with these exact arguments:"),
|
|
219
248
|
"",
|
|
220
249
|
"$ARGUMENTS",
|
|
221
250
|
"",
|
|
222
|
-
|
|
251
|
+
...(isOrchestrate
|
|
252
|
+
? ["Do not use `read` to load the authoritative spec yet. Run the deterministic preflight first; only load the shared spec if the preflight requires the full workflow.", "", openCodeOrchestrateContract(forgeHome)]
|
|
253
|
+
: ["Use `read` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation."]),
|
|
223
254
|
"",
|
|
224
255
|
"OpenCode runtime mapping:",
|
|
225
256
|
"",
|
|
226
257
|
"- `Skill(skill=\"x\", args=\"y\")` means use the registered native OpenCode skill named `" + nativeSkillExpression + "` in the current context with the exact arguments. Its authoritative source is `" + commandsPath + "/${x.replaceAll(\":\", \"/\")}.md`. If the native skill or source is unavailable, stop with `FORGE_OPENCODE_CAPABILITY_ERROR` and an actionable path; never invoke `forgedock run-issue`, `npx forgedock run-issue`, or recursive `opencode run` as a fallback.",
|
|
227
|
-
"- `Task(...)` or a permitted `Agent(...)` means use OpenCode's `task` tool with a top-level argument object shaped like `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background
|
|
258
|
+
"- `Task(...)` or a permitted `Agent(...)` means use OpenCode's `task` tool with a top-level argument object shaped like `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background }`. `subagent_type` is mandatory: map Claude `general-purpose` to `general` for implementation/review and `codebase-explorer` to `explore` for read-only discovery. If the source omits a type, set `subagent_type: \"general\"` before calling the tool; never emit a call containing only `description` and `prompt`. Omitted `background` means foreground and the parent must await the result. Set `background: true` only for Phase 4 OpenCode ready-issue dispatch after persisting its returned child-session id. Unsupported types must stop with `FORGE_OPENCODE_CAPABILITY_ERROR`. Preserve requested isolation and parallelism, and never inline a required isolated review.",
|
|
228
259
|
openCodeReviewDispatchContract(),
|
|
229
260
|
"- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.",
|
|
230
261
|
"- OpenCode injects `FORGE_HOME` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.",
|
|
@@ -236,6 +267,7 @@ export function renderOpenCodeSkill({ description, forgeHome, command }) {
|
|
|
236
267
|
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
237
268
|
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
238
269
|
const name = normalizeOpenCodeSkillName(command);
|
|
270
|
+
const isOrchestrate = command === "orchestrate";
|
|
239
271
|
return `---
|
|
240
272
|
name: ${name}
|
|
241
273
|
description: ${yamlString(`ForgeDock: ${description}`)}
|
|
@@ -246,11 +278,13 @@ metadata:
|
|
|
246
278
|
---
|
|
247
279
|
${SKILL_SENTINEL}
|
|
248
280
|
|
|
249
|
-
|
|
281
|
+
${isOrchestrate
|
|
282
|
+
? `Run the deterministic OpenCode preflight before loading the authoritative ForgeDock workflow at \`${specPath}\` in the current context.`
|
|
283
|
+
: `Load and execute the authoritative ForgeDock workflow at \`${specPath}\` in the current context.`}
|
|
250
284
|
|
|
251
|
-
The parent workflow's exact arguments are already present in the current context. Preserve them; do not invent new arguments or launch a second controller. Keep loading token-efficient: read only this workflow and the next spec explicitly reached by its dispatcher.
|
|
285
|
+
The parent workflow's exact arguments are already present in the current context. Preserve them; do not invent new arguments or launch a second controller. Keep loading token-efficient: ${isOrchestrate ? "do not read the shared orchestrate spec until preflight routes to the full workflow." : "read only this workflow and the next spec explicitly reached by its dispatcher."}
|
|
252
286
|
|
|
253
|
-
${openCodeReviewDispatchContract()}
|
|
287
|
+
${command === "orchestrate" ? `${openCodeOrchestrateContract(forgeHome)}\n\n` : ""}${command === "work-on/build" ? `${openCodeBuildStageContract(forgeHome)}\n\n` : ""}${openCodeReviewDispatchContract()}
|
|
254
288
|
|
|
255
289
|
If the workflow source or a required native capability is unavailable, stop and report exactly:
|
|
256
290
|
\`FORGE_OPENCODE_CAPABILITY_ERROR\`: ForgeDock workflow \`${command}\` is unavailable at \`${specPath}\`.
|
|
@@ -258,7 +292,7 @@ Do not invoke \`forgedock run-issue\`, \`npx forgedock run-issue\`, or recursive
|
|
|
258
292
|
OpenCode runtime mapping:
|
|
259
293
|
|
|
260
294
|
- \`Skill(skill="x", args="y")\` means lazily read \`${commandsPath}/\${x.replaceAll(":", "/")}.md\` and execute that workflow in the current context with the exact arguments. Colon separators become slash separators; existing slash separators remain unchanged. This matches Claude Code Skill's in-conversation loading; it is not a reason to spawn a subagent.
|
|
261
|
-
- \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool with \`{ description, prompt, subagent_type, background }\`. Translate \`general-purpose\` to \`general\` and \`codebase-explorer\` to \`explore\`; never omit \`subagent_type\`.
|
|
295
|
+
- \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool with \`{ description, prompt, subagent_type, background }\`. Translate \`general-purpose\` to \`general\` and \`codebase-explorer\` to \`explore\`; never omit \`subagent_type\`. Omitted \`background\` is foreground and must be awaited. Only Phase 4 OpenCode ready-issue dispatch sets \`background: true\`; it persists the returned child-session id, then processes each later \`state="completed"\` or \`state="error"\` event independently.
|
|
262
296
|
- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
|
|
263
297
|
- OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
|
|
264
298
|
- If a Claude-version, Claude-transcript, or Claude-cache rule has no OpenCode equivalent, ignore only that runtime-specific optimization and preserve the workflow invariant it was intended to protect.
|
|
@@ -312,10 +346,10 @@ function normalizeTaskArgs(args) {
|
|
|
312
346
|
}
|
|
313
347
|
|
|
314
348
|
args.subagent_type = subagentType
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
if (process.env[BACKGROUND_FLAG]
|
|
349
|
+
// A parent may only complete after load-bearing child work completes. The
|
|
350
|
+
// orchestration dispatcher is the sole caller that opts into background work.
|
|
351
|
+
if (args.background === undefined || args.background === null) args.background = false
|
|
352
|
+
if (process.env[BACKGROUND_FLAG] === "false") args.background = false
|
|
319
353
|
}
|
|
320
354
|
`;
|
|
321
355
|
return `${PLUGIN_SENTINEL}
|
|
@@ -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
|
|
@@ -239,6 +242,27 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
239
242
|
}
|
|
240
243
|
}
|
|
241
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
|
+
|
|
242
266
|
// ---------------------------------------------------------------------------
|
|
243
267
|
// Backend selection (issue #2003) — CLI vs API execution backend
|
|
244
268
|
// ---------------------------------------------------------------------------
|
|
@@ -1079,8 +1103,10 @@ export function runCliBackend({
|
|
|
1079
1103
|
spawnFn = spawnSync,
|
|
1080
1104
|
}) {
|
|
1081
1105
|
const rawTimeout = parseInt(process.env.FORGEDOCK_CLI_TIMEOUT_MS, 10);
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1106
|
+
const configuredTimeout = resolveConfiguredCliTimeout(cwd);
|
|
1107
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0
|
|
1108
|
+
? rawTimeout
|
|
1109
|
+
: configuredTimeout ?? DEFAULT_CLI_TIMEOUT_MS;
|
|
1084
1110
|
|
|
1085
1111
|
// Write the system prompt (command spec + framing) to a private temp file
|
|
1086
1112
|
// rather than passing it inline as an argv string — see the SYSTEM PROMPT
|