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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/bin/engine/admission.mjs +242 -7
  3. package/bin/engine/admission.test.mjs +183 -2
  4. package/bin/engine/phases.mjs +31 -22
  5. package/bin/engine/resolve.mjs +8 -1
  6. package/bin/engine/resolve.test.mjs +5 -0
  7. package/bin/engine-cli.mjs +2 -0
  8. package/bin/engine.mjs +26 -17
  9. package/bin/forgedock.mjs +124 -48
  10. package/bin/hooks/pre-tool-use.mjs +573 -2
  11. package/bin/journey.mjs +11 -7
  12. package/bin/labels.json +5 -0
  13. package/bin/opencode-adapter.mjs +652 -92
  14. package/bin/orchestrate-preflight.mjs +434 -0
  15. package/bin/runner.mjs +77 -9
  16. package/commands/audit-agents.md +75 -26
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +10 -5
  19. package/commands/issue.md +95 -35
  20. package/commands/orchestrate/phase-1-resolve.md +70 -32
  21. package/commands/orchestrate/phase-3-dependency.md +167 -22
  22. package/commands/orchestrate/phase-4-execution.md +691 -62
  23. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  24. package/commands/orchestrate/phase-6-report.md +27 -1
  25. package/commands/orchestrate/safety.md +1 -1
  26. package/commands/orchestrate.md +39 -1
  27. package/commands/quality-gate.md +30 -10
  28. package/commands/review-pr-agents/protocols.md +13 -0
  29. package/commands/review-pr-agents.md +8 -0
  30. package/commands/review-pr-staging.md +117 -17
  31. package/commands/review-pr.md +95 -23
  32. package/commands/work-on/build/validate.md +25 -4
  33. package/commands/work-on/close.md +40 -10
  34. package/commands/work-on/decompose.md +33 -1
  35. package/commands/work-on/investigate.md +33 -0
  36. package/commands/work-on/remediate.md +40 -5
  37. package/commands/work-on/review.md +27 -0
  38. package/commands/work-on.md +48 -5
  39. package/docs/CONFIG.md +51 -15
  40. package/docs/OPENCODE.md +128 -25
  41. package/docs/spec/review-protocol.md +7 -0
  42. package/package.json +1 -1
  43. package/scripts/check-admission-parity.mjs +1 -0
  44. package/scripts/check-command-side-effects.sh +14 -13
  45. package/scripts/check-shell-portability.sh +36 -0
  46. package/scripts/check-spec-markers.sh +21 -11
  47. package/scripts/checkbox-sections.test.sh +67 -0
  48. package/scripts/extract-affected-files.sh +85 -5
  49. package/scripts/extract-affected-files.test.sh +154 -2
@@ -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, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
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 { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
- import { parseInstallTier } from "./journey.mjs";
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
- function isLegacyCommandDefinition(name, definition) {
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
- return (
92
- keys.length === 2 &&
93
- keys[0] === "description" &&
94
- keys[1] === "template" &&
95
- typeof definition.template === "string" &&
96
- definition.template.includes(`/commands/${name}.md`) &&
97
- String(definition.description || "").includes("ForgeDock")
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
- agent: build
274
+ compatibility: opencode
275
+ metadata:
276
+ forgedock: "managed"
277
+ source: ${yamlString(command)}
114
278
  ---
115
- ${COMMAND_SENTINEL}
279
+ ${SKILL_SENTINEL}
116
280
 
117
- Run the authoritative ForgeDock workflow at \`${specPath}\` with these exact arguments:
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
- $ARGUMENTS
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
- Use \`read\` to load that spec, then execute it. Keep loading token-efficient: do not preload sibling specs, catalogs, adapters, or documentation.
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. Preserve requested isolation and parallelism, use \`general\` for implementation/review and \`explore\` for read-only discovery, and resume by task ID when requested. If background tasks are unavailable, launch independent foreground tasks concurrently where possible and use the workflow's GitHub-label polling fallback; never inline a required isolated review.
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
- async function atomicWrite(path, content) {
167
- await mkdir(dirname(path), { recursive: true });
168
- const tmp = `${path}.forgedock.tmp-${process.pid}`;
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, path);
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 value = JSON.parse(await readFile(path, "utf8"));
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 pathInside(root, candidate) {
205
- const rel = relative(resolve(root), resolve(candidate));
206
- return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
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
- try {
211
- const content = await readFile(path, "utf8");
212
- return content.includes(COMMAND_SENTINEL) || content.includes(PLUGIN_SENTINEL);
213
- } catch {
214
- return false;
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
- if (!pathInside(configDir, path) || !(await isManagedFile(path))) continue;
223
- await unlink(path).catch((error) => {
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 entries = await readdir(commandsDir, { withFileTypes: true });
641
+ const sources = await findMarkdownFiles(commandsDir, { includeExtras });
234
642
  const commands = [];
235
- for (const entry of entries) {
236
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
237
- const source = join(commandsDir, entry.name);
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: entry.name.slice(0, -3),
658
+ name: sourcePath,
659
+ nativeName,
245
660
  description,
246
- tier,
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
- try {
258
- const content = await readFile(legacyInstructions, "utf8");
259
- if (content.includes(LEGACY_SENTINEL)) {
260
- legacyInstructionsOwned = true;
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
- if (!existsSync(configPath)) continue;
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(configPath, "utf8")));
697
+ config = JSON.parse(stripJsonc(await readFile(path, "utf8")));
278
698
  } catch (error) {
279
- result.warnings.push(`Could not migrate legacy entries in ${configPath}: ${error.message}`);
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 (config.command && typeof config.command === "object" && !Array.isArray(config.command)) {
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: configPath,
310
- original: await readFile(configPath, "utf8"),
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) await atomicWrite(item.path, item.original).catch(() => {});
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
- await unlink(legacyInstructions);
329
- result.removedInstructionsFile = true;
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
- const previous = (await readManifest(manifestPath)) || { files: [] };
348
- const commands = await discoverEntrypoints(forgeHome, includeExtras);
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
- if (existsSync(path) && !(await isManagedFile(path))) {
370
- throw new Error(`Refusing to overwrite user-owned OpenCode file: ${path}`);
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 stale = previous.files.filter((file) => !nextFiles.includes(file));
377
- const removed = await removeOwnedFiles(configDir, stale);
378
- const digest = digestFiles(rendered);
379
- const manifest = {
380
- version: MANIFEST_VERSION,
381
- forgeHome,
382
- includeExtras,
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
- files: nextFiles,
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
- if (!existsSync(manifestPath)) return { installed: false, healthy: false, configDir, missing: [] };
397
- const manifest = await readManifest(manifestPath);
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
- if (!existsSync(path) || !(await isManagedFile(path))) {
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: await readFile(path, "utf8") });
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 unlink(manifestPath).catch((error) => {
428
- if (error.code !== "ENOENT") throw error;
429
- });
430
- await rmdir(join(configDir, "forgedock")).catch((error) => {
431
- if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
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
  }