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
package/bin/opencode-adapter.mjs
CHANGED
|
@@ -1,22 +1,77 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
|
-
import { mkdir, readFile,
|
|
6
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
basename,
|
|
10
|
+
dirname,
|
|
11
|
+
isAbsolute,
|
|
12
|
+
join,
|
|
13
|
+
posix,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
sep,
|
|
17
|
+
win32,
|
|
18
|
+
} from "node:path";
|
|
19
|
+
import { findMarkdownFiles } from "./journey.mjs";
|
|
10
20
|
|
|
11
21
|
const COMMAND_SENTINEL = "<!-- forgedock:managed-opencode-command -->";
|
|
22
|
+
const SKILL_SENTINEL = "<!-- forgedock:managed-opencode-skill -->";
|
|
12
23
|
const PLUGIN_SENTINEL = "// forgedock:managed-opencode-plugin";
|
|
13
|
-
const LEGACY_SENTINEL = "<!-- ForgeDock managed";
|
|
24
|
+
const LEGACY_SENTINEL = "<!-- ForgeDock managed — do not remove this line -->";
|
|
14
25
|
const MANIFEST_VERSION = 1;
|
|
26
|
+
const ADAPTER_LOCK_STALE_AGE_MS = 30_000;
|
|
27
|
+
const ADAPTER_LOCK_HEARTBEAT_MS = 10_000;
|
|
28
|
+
const ADAPTER_LOCK_RETRY_DELAYS_MS = [10, 20, 40, 80, 150];
|
|
29
|
+
const LEGACY_COMMAND_CONTRACTS = {
|
|
30
|
+
"work-on": {
|
|
31
|
+
description: "Run the ForgeDock full issue pipeline (investigate \u2192 build \u2192 review \u2192 merge)",
|
|
32
|
+
templateSuffix: " and execute the pipeline for issue {{args}}.",
|
|
33
|
+
},
|
|
34
|
+
"review-pr": {
|
|
35
|
+
description: "Run the ForgeDock PR review pipeline",
|
|
36
|
+
templateSuffix: " and execute the PR review for PR {{args}}.",
|
|
37
|
+
},
|
|
38
|
+
"quality-gate": {
|
|
39
|
+
description: "Run ForgeDock pre-commit quality checks",
|
|
40
|
+
templateSuffix: " and run all quality gate checks.",
|
|
41
|
+
},
|
|
42
|
+
orchestrate: {
|
|
43
|
+
description: "Run ForgeDock parallel multi-issue orchestration",
|
|
44
|
+
templateSuffix: " and orchestrate the issues: {{args}}.",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
15
47
|
|
|
16
48
|
function portablePath(path) {
|
|
17
49
|
return path.replaceAll("\\", "/");
|
|
18
50
|
}
|
|
19
51
|
|
|
52
|
+
function legacyPathFlavor(path) {
|
|
53
|
+
if (typeof path !== "string") return null;
|
|
54
|
+
if (posix.isAbsolute(path)) return "posix";
|
|
55
|
+
if (win32.isAbsolute(path)) return "win32";
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeLegacyPath(path, flavor) {
|
|
60
|
+
const pathFlavor = flavor || legacyPathFlavor(path);
|
|
61
|
+
if (!pathFlavor) return null;
|
|
62
|
+
const pathApi = pathFlavor === "win32" ? win32 : posix;
|
|
63
|
+
if (!pathApi.isAbsolute(path)) return null;
|
|
64
|
+
|
|
65
|
+
let normalized = pathApi.normalize(path);
|
|
66
|
+
if (pathFlavor === "win32") {
|
|
67
|
+
normalized = portablePath(normalized).toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
if (normalized !== "/" && !/^[a-z]:\/$/i.test(normalized)) {
|
|
70
|
+
normalized = normalized.replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
|
|
20
75
|
function stripJsonc(raw) {
|
|
21
76
|
let result = "";
|
|
22
77
|
let i = 0;
|
|
@@ -85,17 +140,51 @@ function parseDescription(content) {
|
|
|
85
140
|
return "";
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Map a source workflow path to OpenCode's native skill-name contract.
|
|
145
|
+
* OpenCode skill names cannot contain path separators, so nested source paths
|
|
146
|
+
* use a stable hyphen separator (for example, work-on/investigate).
|
|
147
|
+
*/
|
|
148
|
+
export function normalizeOpenCodeSkillName(command) {
|
|
149
|
+
const name = portablePath(command)
|
|
150
|
+
.replace(/\.md$/i, "")
|
|
151
|
+
.split("/")
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.map((segment) => segment.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join("-")
|
|
156
|
+
.replace(/-{2,}/g, "-");
|
|
157
|
+
if (!name) throw new Error(`Cannot register empty OpenCode skill name for workflow: ${command}`);
|
|
158
|
+
if (name.length > 64) {
|
|
159
|
+
throw new Error(`OpenCode skill name exceeds 64 characters for workflow: ${command}`);
|
|
160
|
+
}
|
|
161
|
+
return name;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isLegacyCommandDefinition(name, definition, forgeHome) {
|
|
89
165
|
if (!definition || typeof definition !== "object" || Array.isArray(definition)) return false;
|
|
90
166
|
const keys = Object.keys(definition).sort();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
keys
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
167
|
+
const contract = LEGACY_COMMAND_CONTRACTS[name];
|
|
168
|
+
if (
|
|
169
|
+
!contract ||
|
|
170
|
+
keys.length !== 2 ||
|
|
171
|
+
keys[0] !== "description" ||
|
|
172
|
+
keys[1] !== "template" ||
|
|
173
|
+
definition.description !== contract.description ||
|
|
174
|
+
typeof definition.template !== "string"
|
|
175
|
+
) return false;
|
|
176
|
+
|
|
177
|
+
const template = portablePath(definition.template);
|
|
178
|
+
const suffix = `/commands/${name}.md${contract.templateSuffix}`;
|
|
179
|
+
if (!template.startsWith("Read ") || !template.endsWith(suffix)) return false;
|
|
180
|
+
const pathFlavor = legacyPathFlavor(forgeHome);
|
|
181
|
+
if (!pathFlavor) return false;
|
|
182
|
+
const rawSuffix = definition.template.slice(-suffix.length);
|
|
183
|
+
if (portablePath(rawSuffix) !== suffix || (pathFlavor === "posix" && rawSuffix !== suffix)) return false;
|
|
184
|
+
const expectedHome = normalizeLegacyPath(forgeHome);
|
|
185
|
+
if (!expectedHome) return false;
|
|
186
|
+
const templateHome = definition.template.slice("Read ".length, -suffix.length);
|
|
187
|
+
return normalizeLegacyPath(templateHome, pathFlavor) === expectedHome;
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
|
|
@@ -105,25 +194,105 @@ export function resolveOpenCodeConfigDir({ home, env = process.env } = {}) {
|
|
|
105
194
|
return join(resolvedHome, ".config", "opencode");
|
|
106
195
|
}
|
|
107
196
|
|
|
197
|
+
function openCodeReviewDispatchContract() {
|
|
198
|
+
return [
|
|
199
|
+
"OpenCode review-dispatch override:",
|
|
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 }`; 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.",
|
|
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.",
|
|
205
|
+
].join("\n");
|
|
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
|
+
|
|
108
233
|
export function renderOpenCodeCommand({ description, forgeHome, command }) {
|
|
109
234
|
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
110
235
|
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
236
|
+
const nativeSkillExpression = '${x.replaceAll(":", "-").replaceAll("/", "-")}';
|
|
237
|
+
const isOrchestrate = command === "orchestrate";
|
|
238
|
+
return [
|
|
239
|
+
"---",
|
|
240
|
+
`description: ${yamlString(`ForgeDock: ${description}`)}`,
|
|
241
|
+
"agent: build",
|
|
242
|
+
"---",
|
|
243
|
+
COMMAND_SENTINEL,
|
|
244
|
+
"",
|
|
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:"),
|
|
248
|
+
"",
|
|
249
|
+
"$ARGUMENTS",
|
|
250
|
+
"",
|
|
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."]),
|
|
254
|
+
"",
|
|
255
|
+
"OpenCode runtime mapping:",
|
|
256
|
+
"",
|
|
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.",
|
|
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.",
|
|
259
|
+
openCodeReviewDispatchContract(),
|
|
260
|
+
"- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.",
|
|
261
|
+
"- OpenCode injects `FORGE_HOME` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.",
|
|
262
|
+
"- 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.",
|
|
263
|
+
].join("\n") + "\n";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function renderOpenCodeSkill({ description, forgeHome, command }) {
|
|
267
|
+
const specPath = portablePath(join(forgeHome, "commands", `${command}.md`));
|
|
268
|
+
const commandsPath = portablePath(join(forgeHome, "commands"));
|
|
269
|
+
const name = normalizeOpenCodeSkillName(command);
|
|
270
|
+
const isOrchestrate = command === "orchestrate";
|
|
111
271
|
return `---
|
|
272
|
+
name: ${name}
|
|
112
273
|
description: ${yamlString(`ForgeDock: ${description}`)}
|
|
113
|
-
|
|
274
|
+
compatibility: opencode
|
|
275
|
+
metadata:
|
|
276
|
+
forgedock: "managed"
|
|
277
|
+
source: ${yamlString(command)}
|
|
114
278
|
---
|
|
115
|
-
${
|
|
279
|
+
${SKILL_SENTINEL}
|
|
116
280
|
|
|
117
|
-
|
|
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.`}
|
|
118
284
|
|
|
119
|
-
$
|
|
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."}
|
|
120
286
|
|
|
121
|
-
|
|
287
|
+
${command === "orchestrate" ? `${openCodeOrchestrateContract(forgeHome)}\n\n` : ""}${command === "work-on/build" ? `${openCodeBuildStageContract(forgeHome)}\n\n` : ""}${openCodeReviewDispatchContract()}
|
|
122
288
|
|
|
289
|
+
If the workflow source or a required native capability is unavailable, stop and report exactly:
|
|
290
|
+
\`FORGE_OPENCODE_CAPABILITY_ERROR\`: ForgeDock workflow \`${command}\` is unavailable at \`${specPath}\`.
|
|
291
|
+
Do not invoke \`forgedock run-issue\`, \`npx forgedock run-issue\`, or recursive \`opencode run\` to recover.
|
|
123
292
|
OpenCode runtime mapping:
|
|
124
293
|
|
|
125
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.
|
|
126
|
-
- \`Task(...)\` or a permitted \`Agent(...)\` means use OpenCode's \`task\` tool
|
|
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.
|
|
127
296
|
- Map Claude tool names to the corresponding OpenCode tools. Do not skip a step merely because its source uses Claude-style invocation syntax.
|
|
128
297
|
- OpenCode injects \`FORGE_HOME\` into shell commands through the ForgeDock plugin. GitHub labels, FORGE annotations, worktree isolation, and terminal-state rules remain unchanged.
|
|
129
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.
|
|
@@ -132,17 +301,85 @@ OpenCode runtime mapping:
|
|
|
132
301
|
|
|
133
302
|
export function renderOpenCodePlugin(forgeHome) {
|
|
134
303
|
const gitBashHome = shellPath(forgeHome, "win32");
|
|
304
|
+
const runtimeGuard = String.raw`
|
|
305
|
+
const FORGE_OPENCODE_CAPABILITY_ERROR = "FORGE_OPENCODE_CAPABILITY_ERROR"
|
|
306
|
+
|
|
307
|
+
function commandPattern(executable, subcommand) {
|
|
308
|
+
const assignments = "(?:(?:[A-Za-z_][A-Za-z0-9_]*)=(?:\"[^\"]*\"|'[^']*'|[^\\s;&|]+)\\s+)*"
|
|
309
|
+
const wrappers = "(?:(?:env|command|exec)\\s+)*"
|
|
310
|
+
const npx = "(?:npx(?:\\s+--[^\\s;&|]+)*\\s+)?"
|
|
311
|
+
const path = "(?:(?:[^\\s;&|/\\\\]+[/\\\\])+)?"
|
|
312
|
+
const suffix = subcommand ? "\\s+" + subcommand + "(?:\\s|$)" : "(?:\\s|$)"
|
|
313
|
+
return new RegExp("(?:^|[;&|]\\s*)" + assignments + wrappers + npx + path + executable + "(?:\\.cmd|\\.exe)?" + suffix, "i")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function blockedOperation(command) {
|
|
317
|
+
const normalized = String(command || "").replace(/\\r?\\n/g, " ")
|
|
318
|
+
if (commandPattern("claude", "").test(normalized)) return "claude"
|
|
319
|
+
if (commandPattern("forgedock", "run-issue").test(normalized)) return "forgedock run-issue"
|
|
320
|
+
if (commandPattern("opencode", "run").test(normalized)) return "opencode run"
|
|
321
|
+
return ""
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function capabilityError(operation) {
|
|
325
|
+
const error = new Error(FORGE_OPENCODE_CAPABILITY_ERROR + ": " + operation + " is unavailable in an OpenCode ForgeDock workflow. Use native Skill/Task dispatch instead.")
|
|
326
|
+
error.code = FORGE_OPENCODE_CAPABILITY_ERROR
|
|
327
|
+
return error
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function normalizeTaskArgs(args) {
|
|
331
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
332
|
+
throw capabilityError("task arguments are invalid")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const sourceType = args.subagent_type
|
|
336
|
+
const subagentType = sourceType === undefined || sourceType === null || sourceType === ""
|
|
337
|
+
? "general"
|
|
338
|
+
: sourceType === "general-purpose"
|
|
339
|
+
? "general"
|
|
340
|
+
: sourceType === "codebase-explorer"
|
|
341
|
+
? "explore"
|
|
342
|
+
: sourceType
|
|
343
|
+
|
|
344
|
+
if (subagentType !== "general" && subagentType !== "explore") {
|
|
345
|
+
throw capabilityError("task subagent_type " + String(sourceType) + " is unsupported")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
args.subagent_type = subagentType
|
|
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
|
|
353
|
+
}
|
|
354
|
+
`;
|
|
135
355
|
return `${PLUGIN_SENTINEL}
|
|
136
356
|
import { existsSync } from "node:fs"
|
|
137
357
|
import { join } from "node:path"
|
|
138
358
|
|
|
139
359
|
const NATIVE_FORGE_HOME = ${JSON.stringify(forgeHome)}
|
|
140
360
|
const GIT_BASH_FORGE_HOME = ${JSON.stringify(gitBashHome)}
|
|
361
|
+
const BACKGROUND_FLAG = "OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"
|
|
362
|
+
|
|
363
|
+
// ForgeDock orchestration relies on per-task completion events rather than wave barriers.
|
|
364
|
+
// Keep an explicit false opt-out for users who need the experimental feature disabled.
|
|
365
|
+
if (process.env[BACKGROUND_FLAG] === undefined) process.env[BACKGROUND_FLAG] = "true"
|
|
366
|
+
|
|
141
367
|
let shellForgeHome = NATIVE_FORGE_HOME
|
|
368
|
+
${runtimeGuard}
|
|
142
369
|
|
|
143
370
|
export const ForgeDockPlugin = async () => ({
|
|
144
371
|
config: async (config) => {
|
|
372
|
+
// Two nested subagent levels cover orchestrate -> work-on -> review/gate
|
|
373
|
+
// without enabling unnecessary recursive fan-out.
|
|
145
374
|
if (config.subagent_depth === undefined) config.subagent_depth = 2
|
|
375
|
+
// OpenCode otherwise adds task: deny to every subagent session, which prevents
|
|
376
|
+
// an orchestrated work-on child from spawning its isolated review agents.
|
|
377
|
+
config.agent ??= {}
|
|
378
|
+
config.agent.general ??= {}
|
|
379
|
+
config.agent.general.permission ??= {}
|
|
380
|
+
if (config.agent.general.permission.task === undefined && config.permission?.task === undefined) {
|
|
381
|
+
config.agent.general.permission.task = "allow"
|
|
382
|
+
}
|
|
146
383
|
if (process.platform === "win32" && !config.shell) {
|
|
147
384
|
const candidates = [
|
|
148
385
|
process.env.ProgramFiles && join(process.env.ProgramFiles, "Git", "bin", "bash.exe"),
|
|
@@ -155,29 +392,182 @@ export const ForgeDockPlugin = async () => ({
|
|
|
155
392
|
shellForgeHome = GIT_BASH_FORGE_HOME
|
|
156
393
|
}
|
|
157
394
|
},
|
|
395
|
+
"tool.execute.before": async (input, output) => {
|
|
396
|
+
if (input.tool === "task") {
|
|
397
|
+
normalizeTaskArgs(output?.args)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (input.tool !== "bash") return
|
|
401
|
+
const operation = blockedOperation(output?.args?.command)
|
|
402
|
+
if (operation) throw capabilityError(operation)
|
|
403
|
+
},
|
|
158
404
|
"shell.env": async (_input, output) => {
|
|
159
405
|
output.env.FORGE_HOME = shellForgeHome
|
|
160
406
|
output.env.FORGE_RUNTIME = "opencode"
|
|
407
|
+
output.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS =
|
|
408
|
+
process.env[BACKGROUND_FLAG] ?? "true"
|
|
161
409
|
},
|
|
162
410
|
})
|
|
163
411
|
`;
|
|
164
412
|
}
|
|
165
413
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
414
|
+
function pathInside(root, candidate) {
|
|
415
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
416
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function assertSafePath(root, candidate) {
|
|
420
|
+
const resolvedRoot = resolve(root);
|
|
421
|
+
const resolvedCandidate = resolve(candidate);
|
|
422
|
+
if (!pathInside(resolvedRoot, resolvedCandidate)) {
|
|
423
|
+
throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let current = resolvedCandidate;
|
|
427
|
+
while (true) {
|
|
428
|
+
let stat;
|
|
429
|
+
try {
|
|
430
|
+
stat = await lstat(current);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error.code !== "ENOENT") throw error;
|
|
433
|
+
}
|
|
434
|
+
if (stat?.isSymbolicLink()) {
|
|
435
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${current}`);
|
|
436
|
+
}
|
|
437
|
+
if (current === resolvedRoot) return;
|
|
438
|
+
const parent = dirname(current);
|
|
439
|
+
if (parent === current || !pathInside(resolvedRoot, parent)) {
|
|
440
|
+
throw new Error(`Refusing to access path outside OpenCode config directory: ${candidate}`);
|
|
441
|
+
}
|
|
442
|
+
current = parent;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function isSafePath(root, candidate) {
|
|
447
|
+
try {
|
|
448
|
+
await assertSafePath(root, candidate);
|
|
449
|
+
return true;
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function resolveSafePath(root, candidate, { createParent = false } = {}) {
|
|
456
|
+
await assertSafePath(root, candidate);
|
|
457
|
+
if (createParent) await mkdir(dirname(candidate), { recursive: true });
|
|
458
|
+
await assertSafePath(root, candidate);
|
|
459
|
+
|
|
460
|
+
let resolvedRoot;
|
|
461
|
+
let resolvedParent;
|
|
462
|
+
try {
|
|
463
|
+
resolvedRoot = await realpath(resolve(root));
|
|
464
|
+
resolvedParent = await realpath(dirname(candidate));
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (error.code === "ENOENT") return null;
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
if (!pathInside(resolvedRoot, resolvedParent)) {
|
|
470
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${dirname(candidate)}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const safeCandidate = join(resolvedParent, basename(candidate));
|
|
474
|
+
let stat;
|
|
475
|
+
try {
|
|
476
|
+
stat = await lstat(safeCandidate);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (error.code !== "ENOENT") throw error;
|
|
479
|
+
}
|
|
480
|
+
if (stat?.isSymbolicLink()) {
|
|
481
|
+
throw new Error(`Refusing to access symlinked OpenCode path: ${safeCandidate}`);
|
|
482
|
+
}
|
|
483
|
+
return { path: safeCandidate, root: resolvedRoot };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function tryResolveSafePath(root, candidate) {
|
|
487
|
+
try {
|
|
488
|
+
return await resolveSafePath(root, candidate);
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function acquireAdapterLock(configDir) {
|
|
495
|
+
const lockPath = join(configDir, "forgedock", "install.lock");
|
|
496
|
+
const safeLock = await resolveSafePath(configDir, lockPath, { createParent: true });
|
|
497
|
+
if (!safeLock) throw new Error(`Unable to resolve safe OpenCode lock path: ${lockPath}`);
|
|
498
|
+
|
|
499
|
+
for (let attempt = 0; attempt <= ADAPTER_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
500
|
+
try {
|
|
501
|
+
return { handle: await open(safeLock.path, "wx"), path: safeLock.path };
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (error.code !== "EEXIST") throw error;
|
|
504
|
+
let reclaimed = false;
|
|
505
|
+
try {
|
|
506
|
+
const stat = await lstat(safeLock.path);
|
|
507
|
+
if (Date.now() - stat.mtimeMs >= ADAPTER_LOCK_STALE_AGE_MS) {
|
|
508
|
+
const current = await lstat(safeLock.path);
|
|
509
|
+
if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
|
|
510
|
+
await unlink(safeLock.path);
|
|
511
|
+
reclaimed = true;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// The lock may have been released or replaced between checks.
|
|
516
|
+
}
|
|
517
|
+
if (reclaimed) continue;
|
|
518
|
+
if (attempt === ADAPTER_LOCK_RETRY_DELAYS_MS.length) break;
|
|
519
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, ADAPTER_LOCK_RETRY_DELAYS_MS[attempt]));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
throw new Error("Another OpenCode adapter operation is in progress; try again shortly");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function withAdapterLock(configDir, operation) {
|
|
526
|
+
const lock = await acquireAdapterLock(configDir);
|
|
527
|
+
const heartbeat = setInterval(() => {
|
|
528
|
+
lock.handle.utimes(new Date(), new Date()).catch(() => {});
|
|
529
|
+
}, ADAPTER_LOCK_HEARTBEAT_MS);
|
|
530
|
+
heartbeat.unref?.();
|
|
531
|
+
try {
|
|
532
|
+
return await operation();
|
|
533
|
+
} finally {
|
|
534
|
+
clearInterval(heartbeat);
|
|
535
|
+
await lock.handle.close().catch(() => {});
|
|
536
|
+
await unlink(lock.path).catch(() => {});
|
|
537
|
+
await rmdir(dirname(lock.path)).catch(() => {});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function atomicWrite(path, content, root) {
|
|
542
|
+
const safe = root ? await resolveSafePath(root, path, { createParent: true }) : null;
|
|
543
|
+
if (root && !safe) throw new Error(`Unable to resolve safe OpenCode path: ${path}`);
|
|
544
|
+
const target = safe?.path || path;
|
|
545
|
+
if (!root) await mkdir(dirname(target), { recursive: true });
|
|
546
|
+
const tmp = `${target}.forgedock.tmp-${process.pid}-${randomUUID()}`;
|
|
169
547
|
try {
|
|
170
548
|
await writeFile(tmp, content, "utf8");
|
|
171
|
-
await rename(tmp,
|
|
549
|
+
await rename(tmp, target);
|
|
172
550
|
} catch (error) {
|
|
173
551
|
await rm(tmp, { force: true }).catch(() => {});
|
|
174
552
|
throw error;
|
|
175
553
|
}
|
|
176
554
|
}
|
|
177
555
|
|
|
178
|
-
async function readManifest(path) {
|
|
556
|
+
async function readManifest(path, root) {
|
|
557
|
+
let target = path;
|
|
558
|
+
if (root) {
|
|
559
|
+
try {
|
|
560
|
+
const safe = await resolveSafePath(root, path);
|
|
561
|
+
if (!safe) return null;
|
|
562
|
+
target = safe.path;
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
179
567
|
try {
|
|
180
|
-
const
|
|
568
|
+
const raw = await readRegularFile(target);
|
|
569
|
+
if (raw === null) return null;
|
|
570
|
+
const value = JSON.parse(raw);
|
|
181
571
|
if (
|
|
182
572
|
value?.version === MANIFEST_VERSION &&
|
|
183
573
|
Array.isArray(value.files) &&
|
|
@@ -201,17 +591,33 @@ function digestFiles(files) {
|
|
|
201
591
|
.digest("hex");
|
|
202
592
|
}
|
|
203
593
|
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
594
|
+
async function readRegularFile(path) {
|
|
595
|
+
try {
|
|
596
|
+
const stat = await lstat(path);
|
|
597
|
+
if (!stat.isFile()) return null;
|
|
598
|
+
return await readFile(path, "utf8");
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function hasManagedSentinel(content) {
|
|
605
|
+
return content.includes(COMMAND_SENTINEL) || content.includes(SKILL_SENTINEL) || content.includes(PLUGIN_SENTINEL);
|
|
207
606
|
}
|
|
208
607
|
|
|
209
608
|
async function isManagedFile(path) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
609
|
+
const content = await readRegularFile(path);
|
|
610
|
+
return content !== null && hasManagedSentinel(content);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function removeEmptyParentDirs(configDir, filePath) {
|
|
614
|
+
const root = resolve(configDir);
|
|
615
|
+
let directory = dirname(filePath);
|
|
616
|
+
while (resolve(directory) !== root && pathInside(root, directory)) {
|
|
617
|
+
await rmdir(directory).catch((error) => {
|
|
618
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
619
|
+
});
|
|
620
|
+
directory = dirname(directory);
|
|
215
621
|
}
|
|
216
622
|
}
|
|
217
623
|
|
|
@@ -219,10 +625,12 @@ async function removeOwnedFiles(configDir, files) {
|
|
|
219
625
|
let removed = 0;
|
|
220
626
|
for (const rel of files) {
|
|
221
627
|
const path = join(configDir, rel);
|
|
222
|
-
|
|
223
|
-
await
|
|
628
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
629
|
+
if (!safe || !(await isManagedFile(safe.path))) continue;
|
|
630
|
+
await unlink(safe.path).catch((error) => {
|
|
224
631
|
if (error.code !== "ENOENT") throw error;
|
|
225
632
|
});
|
|
633
|
+
await removeEmptyParentDirs(safe.root, safe.path);
|
|
226
634
|
removed++;
|
|
227
635
|
}
|
|
228
636
|
return removed;
|
|
@@ -230,37 +638,47 @@ async function removeOwnedFiles(configDir, files) {
|
|
|
230
638
|
|
|
231
639
|
async function discoverEntrypoints(forgeHome, includeExtras) {
|
|
232
640
|
const commandsDir = join(forgeHome, "commands");
|
|
233
|
-
const
|
|
641
|
+
const sources = await findMarkdownFiles(commandsDir, { includeExtras });
|
|
234
642
|
const commands = [];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const tier = parseInstallTier(source);
|
|
239
|
-
if (tier === "internal" || (tier === "extras" && !includeExtras)) continue;
|
|
643
|
+
const skillNames = new Map();
|
|
644
|
+
for (const source of sources) {
|
|
645
|
+
const sourcePath = portablePath(relative(commandsDir, source)).replace(/\.md$/i, "");
|
|
240
646
|
const content = await readFile(source, "utf8");
|
|
241
647
|
const description = parseDescription(content);
|
|
242
648
|
if (!description) continue;
|
|
649
|
+
const nativeName = normalizeOpenCodeSkillName(sourcePath);
|
|
650
|
+
const existing = skillNames.get(nativeName);
|
|
651
|
+
if (existing && existing !== sourcePath) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`OpenCode skill name collision: ${nativeName} maps both ${existing} and ${sourcePath}`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
skillNames.set(nativeName, sourcePath);
|
|
243
657
|
commands.push({
|
|
244
|
-
name:
|
|
658
|
+
name: sourcePath,
|
|
659
|
+
nativeName,
|
|
245
660
|
description,
|
|
246
|
-
|
|
661
|
+
topLevel: !sourcePath.includes("/"),
|
|
247
662
|
});
|
|
248
663
|
}
|
|
249
664
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
250
665
|
}
|
|
251
666
|
|
|
252
|
-
async function migrateLegacyAdapter({ configDir, home }) {
|
|
667
|
+
async function migrateLegacyAdapter({ configDir, home, forgeHome }) {
|
|
253
668
|
const resolvedHome = home || process.env.HOME || process.env.USERPROFILE || homedir();
|
|
254
669
|
const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
|
|
255
670
|
const result = { removedInstructionsFile: false, removedConfigEntries: 0, warnings: [] };
|
|
671
|
+
const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
|
|
256
672
|
let legacyInstructionsOwned = false;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
673
|
+
if (safeLegacyInstructions) {
|
|
674
|
+
try {
|
|
675
|
+
const content = await readRegularFile(safeLegacyInstructions.path);
|
|
676
|
+
if (content?.split(/\r?\n/).some((line) => line.trim() === LEGACY_SENTINEL)) {
|
|
677
|
+
legacyInstructionsOwned = true;
|
|
678
|
+
}
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
|
|
261
681
|
}
|
|
262
|
-
} catch (error) {
|
|
263
|
-
if (error.code !== "ENOENT") result.warnings.push(`Could not inspect ${legacyInstructions}: ${error.message}`);
|
|
264
682
|
}
|
|
265
683
|
|
|
266
684
|
const configPaths = new Set([
|
|
@@ -271,12 +689,14 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
271
689
|
const staged = [];
|
|
272
690
|
let parseFailed = false;
|
|
273
691
|
for (const configPath of configPaths) {
|
|
274
|
-
|
|
692
|
+
const safeConfig = await tryResolveSafePath(dirname(configPath), configPath);
|
|
693
|
+
if (!safeConfig || !existsSync(safeConfig.path)) continue;
|
|
694
|
+
const path = safeConfig.path;
|
|
275
695
|
let config;
|
|
276
696
|
try {
|
|
277
|
-
config = JSON.parse(stripJsonc(await readFile(
|
|
697
|
+
config = JSON.parse(stripJsonc(await readFile(path, "utf8")));
|
|
278
698
|
} catch (error) {
|
|
279
|
-
result.warnings.push(`Could not migrate legacy entries in ${
|
|
699
|
+
result.warnings.push(`Could not migrate legacy entries in ${path}: ${error.message}`);
|
|
280
700
|
parseFailed = true;
|
|
281
701
|
continue;
|
|
282
702
|
}
|
|
@@ -293,10 +713,15 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
293
713
|
changed ||= removed > 0;
|
|
294
714
|
if (config.instructions.length === 0) delete config.instructions;
|
|
295
715
|
}
|
|
296
|
-
if (
|
|
716
|
+
if (
|
|
717
|
+
legacyInstructionsOwned &&
|
|
718
|
+
config.command &&
|
|
719
|
+
typeof config.command === "object" &&
|
|
720
|
+
!Array.isArray(config.command)
|
|
721
|
+
) {
|
|
297
722
|
for (const name of legacyCommands) {
|
|
298
723
|
const definition = config.command[name];
|
|
299
|
-
if (isLegacyCommandDefinition(name, definition)) {
|
|
724
|
+
if (isLegacyCommandDefinition(name, definition, forgeHome)) {
|
|
300
725
|
delete config.command[name];
|
|
301
726
|
result.removedConfigEntries++;
|
|
302
727
|
changed = true;
|
|
@@ -306,8 +731,8 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
306
731
|
}
|
|
307
732
|
if (changed) {
|
|
308
733
|
staged.push({
|
|
309
|
-
path
|
|
310
|
-
original: await readFile(
|
|
734
|
+
path,
|
|
735
|
+
original: await readFile(path, "utf8"),
|
|
311
736
|
content: `${JSON.stringify(config, null, 2)}\n`,
|
|
312
737
|
});
|
|
313
738
|
}
|
|
@@ -316,21 +741,61 @@ async function migrateLegacyAdapter({ configDir, home }) {
|
|
|
316
741
|
const written = [];
|
|
317
742
|
try {
|
|
318
743
|
for (const item of staged) {
|
|
319
|
-
await atomicWrite(item.path, item.content);
|
|
744
|
+
await atomicWrite(item.path, item.content, dirname(item.path));
|
|
320
745
|
written.push(item);
|
|
321
746
|
}
|
|
322
747
|
} catch (error) {
|
|
323
|
-
for (const item of written)
|
|
748
|
+
for (const item of written) {
|
|
749
|
+
await atomicWrite(item.path, item.original, dirname(item.path)).catch(() => {});
|
|
750
|
+
}
|
|
324
751
|
result.warnings.push(`Could not write legacy migration: ${error.message}`);
|
|
325
752
|
return result;
|
|
326
753
|
}
|
|
327
|
-
if (legacyInstructionsOwned) {
|
|
328
|
-
|
|
329
|
-
|
|
754
|
+
if (legacyInstructionsOwned && safeLegacyInstructions) {
|
|
755
|
+
try {
|
|
756
|
+
await unlink(safeLegacyInstructions.path);
|
|
757
|
+
result.removedInstructionsFile = true;
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (error.code !== "ENOENT") {
|
|
760
|
+
result.warnings.push(`Could not remove legacy instructions file ${legacyInstructions}: ${error.message}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
330
763
|
}
|
|
331
764
|
return result;
|
|
332
765
|
}
|
|
333
766
|
|
|
767
|
+
async function snapshotAdapterFiles(configDir, files) {
|
|
768
|
+
const snapshots = new Map();
|
|
769
|
+
for (const rel of files) {
|
|
770
|
+
const path = join(configDir, rel);
|
|
771
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
772
|
+
const content = safe ? await readRegularFile(safe.path) : null;
|
|
773
|
+
snapshots.set(rel, content !== null && hasManagedSentinel(content) ? content : null);
|
|
774
|
+
}
|
|
775
|
+
return snapshots;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function restoreAdapterState({ configDir, manifestPath, files, manifestContent }) {
|
|
779
|
+
for (const [rel, content] of files) {
|
|
780
|
+
const path = join(configDir, rel);
|
|
781
|
+
if (content !== null) {
|
|
782
|
+
await atomicWrite(path, content, configDir).catch(() => {});
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
786
|
+
if (!safe || !(await isManagedFile(safe.path))) continue;
|
|
787
|
+
await unlink(safe.path).catch(() => {});
|
|
788
|
+
await removeEmptyParentDirs(safe.root, safe.path).catch(() => {});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (manifestContent !== null) {
|
|
792
|
+
await atomicWrite(manifestPath, manifestContent, configDir).catch(() => {});
|
|
793
|
+
} else {
|
|
794
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
795
|
+
if (safeManifest) await unlink(safeManifest.path).catch(() => {});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
334
799
|
export async function installOpenCodeAdapter({
|
|
335
800
|
forgeHome,
|
|
336
801
|
home,
|
|
@@ -344,8 +809,19 @@ export async function installOpenCodeAdapter({
|
|
|
344
809
|
|
|
345
810
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
346
811
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
347
|
-
|
|
348
|
-
|
|
812
|
+
return withAdapterLock(configDir, () => installOpenCodeAdapterLocked({
|
|
813
|
+
forgeHome,
|
|
814
|
+
home,
|
|
815
|
+
includeExtras,
|
|
816
|
+
configDir,
|
|
817
|
+
manifestPath,
|
|
818
|
+
}));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function installOpenCodeAdapterLocked({ forgeHome, home, includeExtras, configDir, manifestPath }) {
|
|
822
|
+
const previous = (await readManifest(manifestPath, configDir)) || { files: [] };
|
|
823
|
+
const workflows = await discoverEntrypoints(forgeHome, includeExtras);
|
|
824
|
+
const commands = workflows.filter((workflow) => workflow.topLevel);
|
|
349
825
|
const rendered = [];
|
|
350
826
|
|
|
351
827
|
for (const command of commands) {
|
|
@@ -358,43 +834,110 @@ export async function installOpenCodeAdapter({
|
|
|
358
834
|
rendered.push({ rel, content });
|
|
359
835
|
}
|
|
360
836
|
|
|
837
|
+
for (const workflow of workflows) {
|
|
838
|
+
const rel = portablePath(join("skills", workflow.nativeName, "SKILL.md"));
|
|
839
|
+
const content = renderOpenCodeSkill({
|
|
840
|
+
command: workflow.name,
|
|
841
|
+
description: workflow.description,
|
|
842
|
+
forgeHome,
|
|
843
|
+
});
|
|
844
|
+
rendered.push({ rel, content });
|
|
845
|
+
}
|
|
846
|
+
|
|
361
847
|
const pluginRel = portablePath(join("plugins", "forgedock.js"));
|
|
362
848
|
const pluginContent = renderOpenCodePlugin(forgeHome);
|
|
363
849
|
rendered.push({ rel: pluginRel, content: pluginContent });
|
|
364
850
|
|
|
851
|
+
await assertSafePath(configDir, manifestPath);
|
|
365
852
|
// Preflight every collision before the first write so a rejected install
|
|
366
853
|
// cannot leave unmanifested command files behind.
|
|
367
854
|
for (const item of rendered) {
|
|
368
855
|
const path = join(configDir, item.rel);
|
|
369
|
-
|
|
370
|
-
|
|
856
|
+
await assertSafePath(configDir, path);
|
|
857
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
858
|
+
if (safe && existsSync(safe.path) && !(await isManagedFile(safe.path))) {
|
|
859
|
+
throw new Error(`Refusing to overwrite user-owned OpenCode file: ${safe.path}`);
|
|
371
860
|
}
|
|
372
861
|
}
|
|
373
|
-
for (const item of rendered) await atomicWrite(join(configDir, item.rel), item.content);
|
|
374
|
-
|
|
375
862
|
const nextFiles = rendered.map((item) => item.rel).sort();
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
863
|
+
const trackedFiles = [...new Set([...previous.files, ...nextFiles])];
|
|
864
|
+
const snapshots = await snapshotAdapterFiles(configDir, trackedFiles);
|
|
865
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
866
|
+
const previousManifestContent = safeManifest ? await readRegularFile(safeManifest.path) : null;
|
|
867
|
+
let removed;
|
|
868
|
+
let digest;
|
|
869
|
+
let migration;
|
|
870
|
+
try {
|
|
871
|
+
for (const item of rendered) {
|
|
872
|
+
await atomicWrite(join(configDir, item.rel), item.content, configDir);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const stale = previous.files.filter((file) => !nextFiles.includes(file));
|
|
876
|
+
removed = await removeOwnedFiles(configDir, stale);
|
|
877
|
+
digest = digestFiles(rendered);
|
|
878
|
+
const manifest = {
|
|
879
|
+
version: MANIFEST_VERSION,
|
|
880
|
+
forgeHome,
|
|
881
|
+
includeExtras,
|
|
882
|
+
commandCount: commands.length,
|
|
883
|
+
skillCount: workflows.length,
|
|
884
|
+
files: nextFiles,
|
|
885
|
+
digest,
|
|
886
|
+
};
|
|
887
|
+
await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, configDir);
|
|
888
|
+
migration = await migrateLegacyAdapter({ configDir, home, forgeHome });
|
|
889
|
+
} catch (error) {
|
|
890
|
+
await restoreAdapterState({
|
|
891
|
+
configDir,
|
|
892
|
+
manifestPath,
|
|
893
|
+
files: snapshots,
|
|
894
|
+
manifestContent: previousManifestContent,
|
|
895
|
+
});
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return {
|
|
900
|
+
configDir,
|
|
901
|
+
manifestPath,
|
|
383
902
|
commandCount: commands.length,
|
|
384
|
-
|
|
903
|
+
skillCount: workflows.length,
|
|
904
|
+
removed,
|
|
385
905
|
digest,
|
|
906
|
+
migration,
|
|
386
907
|
};
|
|
387
|
-
await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
388
|
-
const migration = await migrateLegacyAdapter({ configDir, home });
|
|
389
|
-
|
|
390
|
-
return { configDir, manifestPath, commandCount: commands.length, removed, digest, migration };
|
|
391
908
|
}
|
|
392
909
|
|
|
393
910
|
export async function getOpenCodeAdapterStatus({ home, env = process.env } = {}) {
|
|
394
911
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
395
912
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
396
|
-
|
|
397
|
-
const
|
|
913
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
914
|
+
const resolvedHome = home || env.HOME || env.USERPROFILE || homedir();
|
|
915
|
+
const legacyInstructions = join(resolvedHome, ".opencode-forge.md");
|
|
916
|
+
const safeLegacyInstructions = await tryResolveSafePath(dirname(legacyInstructions), legacyInstructions);
|
|
917
|
+
const legacyContent = safeLegacyInstructions
|
|
918
|
+
? await readRegularFile(safeLegacyInstructions.path)
|
|
919
|
+
: null;
|
|
920
|
+
const legacyInstalled = legacyContent?.split(/\r?\n/).some(
|
|
921
|
+
(line) => line.trim() === LEGACY_SENTINEL,
|
|
922
|
+
) === true;
|
|
923
|
+
|
|
924
|
+
if (!safeManifest || !existsSync(safeManifest.path)) {
|
|
925
|
+
if (legacyInstalled) {
|
|
926
|
+
return {
|
|
927
|
+
installed: true,
|
|
928
|
+
healthy: false,
|
|
929
|
+
legacy: true,
|
|
930
|
+
configDir,
|
|
931
|
+
missing: [],
|
|
932
|
+
integrity: "legacy-adapter",
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return { installed: false, healthy: false, configDir, missing: [] };
|
|
936
|
+
}
|
|
937
|
+
if (!(await isSafePath(configDir, manifestPath))) {
|
|
938
|
+
return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
|
|
939
|
+
}
|
|
940
|
+
const manifest = await readManifest(manifestPath, configDir);
|
|
398
941
|
if (!manifest) {
|
|
399
942
|
return { installed: true, healthy: false, configDir, missing: [], integrity: "invalid-manifest" };
|
|
400
943
|
}
|
|
@@ -402,11 +945,13 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
|
|
|
402
945
|
const current = [];
|
|
403
946
|
for (const rel of manifest.files) {
|
|
404
947
|
const path = join(configDir, rel);
|
|
405
|
-
|
|
948
|
+
const safe = await tryResolveSafePath(configDir, path);
|
|
949
|
+
const content = safe ? await readRegularFile(safe.path) : null;
|
|
950
|
+
if (!safe || content === null || !hasManagedSentinel(content)) {
|
|
406
951
|
missing.push(rel);
|
|
407
952
|
continue;
|
|
408
953
|
}
|
|
409
|
-
current.push({ rel, content
|
|
954
|
+
current.push({ rel, content });
|
|
410
955
|
}
|
|
411
956
|
const integrity = missing.length === 0 && digestFiles(current) === manifest.digest;
|
|
412
957
|
return {
|
|
@@ -421,15 +966,30 @@ export async function getOpenCodeAdapterStatus({ home, env = process.env } = {})
|
|
|
421
966
|
|
|
422
967
|
export async function uninstallOpenCodeAdapter({ home, env = process.env } = {}) {
|
|
423
968
|
const configDir = resolveOpenCodeConfigDir({ home, env });
|
|
969
|
+
return withAdapterLock(configDir, () => uninstallOpenCodeAdapterLocked({ home, env, configDir }));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function uninstallOpenCodeAdapterLocked({ home, env, configDir }) {
|
|
424
973
|
const manifestPath = join(configDir, "forgedock", "manifest.json");
|
|
425
|
-
const manifest = (await readManifest(manifestPath)) || { files: [] };
|
|
974
|
+
const manifest = (await readManifest(manifestPath, configDir)) || { files: [] };
|
|
426
975
|
const removed = await removeOwnedFiles(configDir, manifest.files);
|
|
427
|
-
await
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
976
|
+
const safeManifest = await tryResolveSafePath(configDir, manifestPath);
|
|
977
|
+
if (safeManifest) {
|
|
978
|
+
await unlink(safeManifest.path).catch((error) => {
|
|
979
|
+
if (error.code !== "ENOENT") throw error;
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const manifestDir = join(configDir, "forgedock");
|
|
983
|
+
const safeManifestDir = await tryResolveSafePath(configDir, manifestDir);
|
|
984
|
+
if (safeManifestDir) {
|
|
985
|
+
await rmdir(safeManifestDir.path).catch((error) => {
|
|
986
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
const migration = await migrateLegacyAdapter({
|
|
990
|
+
configDir,
|
|
991
|
+
home,
|
|
992
|
+
forgeHome: typeof manifest.forgeHome === "string" ? manifest.forgeHome : undefined,
|
|
432
993
|
});
|
|
433
|
-
const migration = await migrateLegacyAdapter({ configDir, home });
|
|
434
994
|
return { configDir, removed, migration };
|
|
435
995
|
}
|