forgedock 1.6.0 → 1.7.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.
@@ -352,14 +352,39 @@ This step is the deterministic counterpart to fix #2 (atomic create-if-absent in
352
352
  Before building agent prompts, run `classify-lane.sh` for every issue in the current dispatch group to compute `{LANE}` and `{PR_BASE}` deterministically. The script output is authoritative — the LLM MUST NOT override or reason around it.
353
353
 
354
354
  ```bash
355
- # Requires classify-lane.sh to be available at ~/.claude/scripts/classify-lane.sh
356
- # (installed by `npx forgedock` — see bin/journey.mjs: forge()'s linkPipelineScripts() step)
357
- # NOTE: the actual invocations below (and in the review-finding sweep) hardcode this
358
- # ~/.claude path directly — there is no $FORGE_HOME-based fallback implemented, so this
359
- # call site is not exposed to the bare-unset-$FORGE_HOME → root-anchored-path footgun
360
- # that affected commands/review-pr.md (see forge#1984, forge#2035 audit). If
361
- # ~/.claude/scripts/ is genuinely unavailable, classify-lane.sh hard-fails per the
362
- # phantom-slug gate above — it does NOT fall through to a filesystem search.
355
+ # Resolve the helper once and reuse it for the initial dispatch and all finding
356
+ # classification loops. Claude keeps its installed path as the default, while
357
+ # OpenCode and Codex can use ForgeDock's repository-local scripts.
358
+ resolve_classify_lane() {
359
+ local candidates=()
360
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
361
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
362
+ [ -n "${OPENCODE_PID:-}" ] ||
363
+ [ -n "${OPENCODE:-}" ]; then
364
+ [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
365
+ [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
366
+ candidates+=("$HOME/.opencode/scripts/classify-lane.sh")
367
+ else
368
+ [ -n "${FORGE_HOME:-}" ] && candidates+=("$FORGE_HOME/scripts/classify-lane.sh")
369
+ candidates+=("$HOME/.claude/scripts/classify-lane.sh")
370
+ [ -n "${REPO_PATH:-}" ] && candidates+=("$REPO_PATH/scripts/classify-lane.sh")
371
+ fi
372
+
373
+ local candidate
374
+ for candidate in "${candidates[@]}"; do
375
+ if [ -f "$candidate" ]; then
376
+ printf '%s\n' "$candidate"
377
+ return 0
378
+ fi
379
+ done
380
+ echo "ERROR: classify-lane.sh is not installed in any configured runtime path." >&2
381
+ return 1
382
+ }
383
+
384
+ CLASSIFY_LANE_SCRIPT=$(resolve_classify_lane) || {
385
+ echo "ERROR: cannot classify lanes without classify-lane.sh" >&2
386
+ exit 1
387
+ }
363
388
 
364
389
  declare -A ISSUE_LANE
365
390
  declare -A ISSUE_PR_BASE
@@ -498,7 +523,7 @@ SURFACE_BATCHED_FINDINGS=() # all member issue numbers absorbed into a batch a
498
523
  SURFACE_BATCH_COUNT=0 # count of batch issues created across the run
499
524
 
500
525
  for NUM in {ready_issue_numbers}; do
501
- PR_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$NUM" -R {GH_REPO}) || {
526
+ PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$NUM" -R {GH_REPO}) || {
502
527
  echo "ERROR: classify-lane.sh failed for #$NUM — adding needs-human label and skipping" >&2
503
528
  gh issue edit "$NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
504
529
  continue
@@ -521,9 +546,34 @@ Use `${ISSUE_LANE[$NUM]}` and `${ISSUE_PR_BASE[$NUM]}` to populate `{LANE}` and
521
546
 
522
547
  **Engine-first dispatch (default)**: When `forgedock` is in PATH, dispatch each ready issue via the durable engine rather than spawning prose Agent sub-agents. The engine's phase table enforces gate semantics in code — its fail-closed review gate and deterministic phase ordering are not subject to LLM interpretation.
523
548
 
549
+ **OpenCode dispatch (runtime-neutral adapter)**: When `FORGE_RUNTIME=opencode` or
550
+ an OpenCode runtime marker is present, do not invoke the engine's Claude-backed
551
+ runner and do not use the Claude `Agent(...)` fallback. Dispatch each ready
552
+ issue through OpenCode's native `task` tool, loading the nested
553
+ `commands/work-on.md` pipeline. Use GitHub labels and `FORGE:*` comments as the
554
+ resume state. After each task continuation, re-read the issue workflow label
555
+ and continue until it reaches
556
+ `workflow:merged`, `workflow:invalid`, `needs-human`, or
557
+ `workflow:awaiting-merge`.
558
+
559
+ ```bash
560
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ]; then
561
+ echo "OpenCode native task dispatch selected — load commands/work-on.md for each ready issue."
562
+ echo "Do not call forgedock run-issue or the Claude Agent(...) fallback in this branch."
563
+ echo "If native task dispatch cannot run, post FORGE:OPENCODE_BLOCKED with the missing capability and add needs-human."
564
+ fi
565
+ ```
566
+
567
+ This branch is additive. When OpenCode is not selected, continue through the
568
+ existing engine-first and Claude Agent-spawn paths below without modification.
569
+
524
570
  **CRITICAL — never background via shell `&`/`wait`** (fixed forge#2466): A single `forgedock run-issue` invocation drives an issue through investigate → build → review → close and routinely runs 30+ minutes. Backgrounding the process at the *shell* level (`cmd &` … `wait`) does not escape the Bash tool's own per-invocation ceiling — `wait` is itself the foreground command the tool watches, and it blocks for the combined duration of every process in the chunk. This made engine-first dispatch effectively dead code: any chunk running longer than the ceiling was always killed, so every run silently fell through to the Agent-spawn fallback below. The fix: dispatch each `forgedock run-issue` invocation as its **own `Bash` tool call with `run_in_background=true`** — the harness's native "start it, don't wait, notify me on completion" primitive — never with shell-level `&`/`wait`. This is exactly the same async model the Agent-spawn-fallback path already uses (and Step 4B's notification-driven completion loop already expects), so one monitoring loop now covers both dispatch styles.
525
571
 
526
572
  ```bash
573
+ if [ "${FORGE_RUNTIME:-}" != "opencode" ] &&
574
+ [ -z "${OPENCODE_SESSION_ID:-}" ] &&
575
+ [ -z "${OPENCODE_PID:-}" ] &&
576
+ [ -z "${OPENCODE:-}" ]; then
527
577
  # Engine-first dispatch: check CLI availability, then dispatch ready issues in score order
528
578
  # Uses SORTED_READY_SET[] from Step 3E.5 (descending value/cost) and budget gate from Step 4A-pre.0
529
579
  FORGEDOCK_AVAILABLE=$(command -v forgedock >/dev/null 2>&1 && echo "true" || echo "false")
@@ -536,20 +586,13 @@ FORGEDOCK_AVAILABLE=$(command -v forgedock >/dev/null 2>&1 && echo "true" || ech
536
586
  # `forgedock run-issue` call fails within seconds, and nothing re-routes the batch — a human has
537
587
  # to notice and manually re-dispatch via the Agent-spawn path below.
538
588
  #
539
- # CRITICAL — do NOT use a shell-resolved probe here (`command -v claude`, `claude --version` run
540
- # directly by bash, `which claude`, etc.). forge#2741's entire root cause is that a shell-resolved
541
- # probe and the engine's actual invocation resolve the `claude` binary differently: bash's builtin
542
- # PATH lookup (and `execSync("claude --version", ...)`, used deliberately by
543
- # `bin/runner.mjs`'s `isClaudeCliAvailable()` for this exact reason) can find and run an
544
- # npx-transient/extensionless shim that `bin/runner.mjs`'s actual backend call —
545
- # `runCliBackend()`'s `spawnFn(bin, cliArgs, { shell: false, ... })` with bare `bin = "claude"`
546
- # (no shell resolution) — cannot. A canary that shell-resolves `claude` reproduces the exact
547
- # blind spot it exists to catch and reports the backend healthy in precisely forge#2741's failure
548
- # scenario. The canary MUST reproduce `runCliBackend()`'s exact invocation shape — bare binary
549
- # name, `shell: false` — via a one-off Node `spawnSync` call, not a bash-native probe:
589
+ # Use the runner's tested backend policy rather than duplicating it as shell/Node prose. This
590
+ # reuses the production resolved-CLI path check (including transient-shim refresh), honors an
591
+ # explicit FORGEDOCK_BACKEND, and accepts API fallback only when ANTHROPIC_API_KEY is configured.
592
+ # It performs no paid model request.
550
593
  if [ "$FORGEDOCK_AVAILABLE" = "true" ]; then
551
- if ! node -e "const r = require('node:child_process').spawnSync('claude', ['--version'], { shell: false }); process.exit(r.error ? 1 : 0);" 2>/dev/null; then
552
- echo "WARNING: forgedock CLI is on PATH, but its execution backend is not invocable via the engine's own spawn shape (bare 'claude', shell:false) in this environment. Downgrading to the Agent-spawn fallback path for this entire run — see forge#2743. (If forge#2741's ENOENT root cause is fixed, this canary will pass again on the next /orchestrate invocation.)"
594
+ if ! forgedock backend-check --quiet; then
595
+ echo "WARNING: forgedock CLI is on PATH, but no configured execution backend is locally usable. Downgrading to the Agent-spawn fallback path for this entire run — see forge#2743."
553
596
  FORGEDOCK_AVAILABLE="false"
554
597
  fi
555
598
  fi
@@ -624,6 +667,7 @@ else
624
667
  # Fall through to Agent-spawn template below. The SubagentStop hook (bin/hooks/interactive-engine.mjs)
625
668
  # bridges these runs to the engine run-log for state persistence even on the fallback path.
626
669
  fi
670
+ fi
627
671
  ```
628
672
 
629
673
  **Dispatch each issue in `DISPATCH_NOW` via its own backgrounded `Bash` call (MANDATORY when `FORGEDOCK_AVAILABLE=true`) — never shell `&`/`wait`.** Issue one `Bash(...)` call per issue in `DISPATCH_NOW`, all in the same message, so they run concurrently within the headroom already computed above:
@@ -1132,10 +1176,6 @@ done
1132
1176
 
1133
1177
  **2b. Engine-first-dispatched issue (`ENGINE_DISPATCH_MAP[{NUMBER}]` set, no `AGENT_ISSUE_MAP` entry)** — `Agent(resume=...)` has nothing to resume here; the completed task was a backgrounded `Bash(command="forgedock run-issue ...")` call, not an `Agent()`. Re-issuing the identical `forgedock run-issue` command would just reproduce the same environmental failure. Instead, fall back to the Agent-spawn path for this ONE issue — but only when it is safe to do so:
1134
1178
  ```bash
1135
- # Idempotency guard: only fall back once per issue per engine-error occurrence.
1136
- ALREADY_FALLEN_BACK=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1137
- --jq '[.[] | select(.body | contains("FORGE:ENGINE_FALLBACK"))] | length' 2>/dev/null || echo "0")
1138
-
1139
1179
  # Safety gate: only fall back when the engine run committed NOTHING — no branch, no PR, no
1140
1180
  # committed phases. Primary source: the `FORGE:STATE` HTML-comment block the engine already
1141
1181
  # mirrors onto the issue BODY on every phase transition (`bin/engine/state.mjs`/`projector.mjs`
@@ -1149,13 +1189,22 @@ done
1149
1189
  # the real multi-line shape correctly:
1150
1190
  STATE_BLOCK=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
1151
1191
  | sed -n '/<!-- FORGE:STATE/,/-->/p')
1152
- STATE_JSON=$(echo "$STATE_BLOCK" | sed '1d;$d')
1153
- if [ -n "$STATE_JSON" ]; then
1154
- COMMITTED_COUNT=$(echo "$STATE_JSON" | jq -r '.committed | length' 2>/dev/null)
1155
- STATE_BRANCH=$(echo "$STATE_JSON" | jq -r '.branch' 2>/dev/null)
1156
- STATE_PR=$(echo "$STATE_JSON" | jq -r '.pr' 2>/dev/null)
1157
- [ "$COMMITTED_COUNT" = "0" ] && [ "$STATE_BRANCH" = "null" ] && [ "$STATE_PR" = "null" ] \
1158
- && EMPTY_COMMITTED_STATE="true" || EMPTY_COMMITTED_STATE="false"
1192
+ STATE_JSON=$(echo "$STATE_BLOCK" | sed '1d;$d')
1193
+ if [ -n "$STATE_JSON" ]; then
1194
+ # Missing or schema-invalid fields are unverifiable, not empty. In particular, jq's
1195
+ # `length` is also zero for null, {}, and "", so require the committed array type first.
1196
+ # The persisted engine run ID and state version are also required: together they give every
1197
+ # orchestrator handling this same failure one stable scope while allowing a later failure
1198
+ # for the same issue/run name to elect a new claimant.
1199
+ if echo "$STATE_JSON" | jq -e \
1200
+ '(.run | type == "string" and test("^[A-Za-z0-9._:/-]+$")) and (.v | type == "number" and . >= 0 and floor == .) and (.committed | type == "array") and (.committed | length == 0) and (.branch == null) and (.pr == null)' \
1201
+ >/dev/null 2>&1; then
1202
+ STATE_RUN=$(echo "$STATE_JSON" | jq -r '.run')
1203
+ STATE_VERSION=$(echo "$STATE_JSON" | jq -r '.v')
1204
+ EMPTY_COMMITTED_STATE="true"
1205
+ else
1206
+ EMPTY_COMMITTED_STATE="false"
1207
+ fi
1159
1208
  else
1160
1209
  # No FORGE:STATE block was ever written to the issue body (e.g. the run failed before
1161
1210
  # phase 1 committed anything). There is no reliable structured signal available in this
@@ -1175,32 +1224,53 @@ done
1175
1224
  # dispatch could collide with or duplicate — that case is NOT auto-fallen-back; it surfaces
1176
1225
  # via the existing stall-detection alert / needs-human path instead, exactly as before this fix.
1177
1226
 
1178
- if [ "$ALREADY_FALLEN_BACK" -eq 0 ] && [ "$EMPTY_COMMITTED_STATE" = "true" ]; then
1227
+ if [ "$EMPTY_COMMITTED_STATE" = "true" ]; then
1228
+ # Atomic-enough cross-session claim: post first, then elect the lowest server-assigned
1229
+ # comment ID for this batch. Concurrent claimants may both post, but exactly one can win.
1230
+ # Pagination is mandatory; any POST/list/parse failure fails closed and dispatches nothing.
1231
+ CLAIM_SCOPE="${STATE_RUN}:${STATE_VERSION}"
1232
+ CLAIM_TOKEN="${CLAIM_SCOPE}-{NUMBER}-$$-${RANDOM}"
1179
1233
  ENGINE_FALLBACK_BODY="<!-- FORGE:ENGINE_FALLBACK -->
1180
1234
  ## Engine-First Dispatch Failed — Falling Back to Agent-Spawn
1181
1235
 
1236
+ **Batch**: ${CLAIM_SCOPE}
1237
+ **Claim**: ${CLAIM_TOKEN}
1238
+
1182
1239
  \`forgedock run-issue\` ended at \`workflow:engine-error\` with no committed phases, branch, or PR
1183
1240
  (\`committed=[] branch=null pr=null\`) — this is an environmental/tool failure (forge#2261), not a
1184
1241
  per-issue content failure, and nothing was committed that a fresh dispatch could collide with.
1185
1242
  Auto-falling back to the Agent-spawn \`/work-on\` path for this issue. See forge#2743."
1186
- gh issue comment {NUMBER} {GH_FLAG} --body "$ENGINE_FALLBACK_BODY" # <!-- allowlist:check-command-side-effects — intentional pipeline status annotation, mirrors work-on/build/architect.md:700 -->
1243
+ CLAIM_ID=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --method POST \
1244
+ --raw-field body="$ENGINE_FALLBACK_BODY" --jq '.id' 2>/dev/null) || CLAIM_ID=""
1245
+ CLAIM_LIST_OK="true"
1246
+ CLAIM_IDS=$(gh api "repos/{GH_REPO}/issues/{NUMBER}/comments" --paginate \
1247
+ --jq ".[] | select(.body | split(\"\\n\") | (any(. == \"<!-- FORGE:ENGINE_FALLBACK -->\") and any(. == \" **Batch**: ${CLAIM_SCOPE}\"))) | .id" \
1248
+ 2>/dev/null) || CLAIM_LIST_OK="false"
1249
+ WINNER_CLAIM_ID=$(printf '%s\n' "$CLAIM_IDS" | grep -E '^[0-9]+$' | sort -n | head -1)
1250
+ CLAIM_WON="false"
1251
+ [ "$CLAIM_LIST_OK" = "true" ] && [ -n "$CLAIM_ID" ] && \
1252
+ [ "$CLAIM_ID" = "$WINNER_CLAIM_ID" ] && CLAIM_WON="true"
1187
1253
 
1188
1254
  # Reuse the SAME Agent-spawn template as Step 4A's "Agent-spawn path (fallback when forgedock
1189
1255
  # CLI unavailable)" section above (the `Agent(subagent_type="general-purpose", ...)` block
1190
1256
  # under "Copy this template. Fill in variables. Do not modify the structure:") verbatim —
1191
1257
  # HARD RULE 1 preserved. This is a fresh dispatch, not a resume: capture its returned agent
1192
1258
  # ID into AGENT_ISSUE_MAP so subsequent completions for this issue route through 2a above.
1193
- Agent(
1259
+ if [ "$CLAIM_WON" = "true" ]; then
1260
+ Agent(
1194
1261
  subagent_type="general-purpose",
1195
1262
  model="{SUBAGENT_MODEL}",
1196
1263
  description="Work on {PROJECT_PREFIX}#{NUMBER} (engine-error fallback)",
1197
1264
  run_in_background=true,
1198
1265
  prompt="<same template body as Step 4A's Agent-spawn path — see the 'Agent-spawn path (fallback when forgedock CLI unavailable)' section above; fill {NUMBER}/{GH_REPO}/{REPO_PATH}/{LANE}/{PR_BASE}/{ISSUE_TITLE} exactly as that template does>"
1199
- )
1200
- AGENT_ISSUE_MAP[{NUMBER}] = <agent_id returned by the Agent() call above>
1201
- # Re-occupies the worker slot this completion just freed (see "Concurrency slot release" above).
1202
- ACTIVE_DISPATCH_COUNT=$((ACTIVE_DISPATCH_COUNT + 1))
1203
- elif [ "$ALREADY_FALLEN_BACK" -eq 0 ]; then
1266
+ )
1267
+ AGENT_ISSUE_MAP[{NUMBER}] = <agent_id returned by the Agent() call above>
1268
+ # Re-occupies the worker slot this completion just freed (see "Concurrency slot release" above).
1269
+ ACTIVE_DISPATCH_COUNT=$((ACTIVE_DISPATCH_COUNT + 1))
1270
+ else
1271
+ echo "#{NUMBER}: engine fallback claim lost or could not be verified — no duplicate Agent dispatch."
1272
+ fi
1273
+ else
1204
1274
  echo "#{NUMBER}: engine-error with non-empty committed state (partial work exists) — NOT auto-falling back. Surfaces via standard stall-detection alert; forgedock resume-stalled remains available for manual/scripted recovery."
1205
1275
  fi
1206
1276
  ```
@@ -1501,17 +1571,9 @@ Gating predecessor #${NUM} reached \`workflow:merged\` — dispatching now. (Was
1501
1571
  RERESOLVE_ENABLED=$(yq '.orchestration.reresolve.enabled // true' forge.yaml 2>/dev/null || echo "true")
1502
1572
  RERESOLVE_MAX_ROUNDS=$(yq '.orchestration.reresolve.max_rounds // "unbounded"' forge.yaml 2>/dev/null || echo "unbounded")
1503
1573
 
1504
- node -e '
1505
- import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ shouldReResolve }) => {
1506
- const classified = { kind: process.argv[1], pattern: process.argv[2], args: [] };
1507
- const config = {
1508
- enabled: process.argv[3] === "false" ? false : process.argv[3],
1509
- maxRounds: process.argv[4] === "unbounded" ? undefined : Number(process.argv[4]),
1510
- };
1511
- const rounds = Number(process.argv[5]);
1512
- console.log(JSON.stringify(shouldReResolve(classified, config, rounds)));
1513
- }, () => process.exit(0));
1514
- ' "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1574
+ node "{REPO_PATH}/bin/engine/orchestrate-canary.mjs" \
1575
+ "$ORIGINATING_QUERY_KIND" "$ORIGINATING_QUERY_PATTERN" \
1576
+ "$RERESOLVE_ENABLED" "$RERESOLVE_MAX_ROUNDS" "$RERESOLVE_ROUNDS_SO_FAR"
1515
1577
  ```
1516
1578
 
1517
1579
  If the result's `reResolve` is `false` (off switch, or `RERESOLVE_ROUNDS_SO_FAR` has reached `max_rounds` — bounded termination per the issue's acceptance criteria), skip this step for the current cycle and log the reason. `RERESOLVE_ROUNDS_SO_FAR` starts at 0 for the batch and increments once per cycle this step actually runs (declare it alongside the other Step 4A.pre batch-scope accumulators — do not re-initialize per completion).
@@ -2105,7 +2167,7 @@ Cascade-dispatched findings inherit NO lane assumption from the parent batch. St
2105
2167
 
2106
2168
  ```bash
2107
2169
  for FINDING_NUM in "${QUEUED_FINDINGS[@]}"; do
2108
- PR_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
2170
+ PR_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$FINDING_NUM" -R {GH_REPO}) || {
2109
2171
  echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human label and removing from QUEUED_FINDINGS" >&2
2110
2172
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
2111
2173
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
@@ -2455,7 +2517,7 @@ declare -A SWEEP_LANE
2455
2517
  declare -A SWEEP_PR_BASE
2456
2518
 
2457
2519
  for FINDING_NUM in "${SWEEP_EXECUTE[@]}"; do
2458
- SWEEP_BASE=$(bash ~/.claude/scripts/classify-lane.sh "$FINDING_NUM" -R {GH_REPO}) || {
2520
+ SWEEP_BASE=$(bash "$CLASSIFY_LANE_SCRIPT" "$FINDING_NUM" -R {GH_REPO}) || {
2459
2521
  echo "ERROR: classify-lane.sh failed for #$FINDING_NUM — adding needs-human and skipping" >&2
2460
2522
  # GOVERNOR-exempt: intentional coordination side-effect (best-effort lease/board/finding post), DRY_RUN-safe — reviewed & accepted for the check-command-side-effects gate. Flagged only by the staging->main full-diff; passes on every feature PR. forge#2627
2461
2523
  gh issue edit "$FINDING_NUM" -R {GH_REPO} --add-label "needs-human" 2>/dev/null || true
@@ -2636,4 +2698,3 @@ fi
2636
2698
  ```
2637
2699
 
2638
2700
  ---
2639
-
@@ -125,7 +125,16 @@ Append `-{NUMBER}` to ensure uniqueness: e.g. `fix/work-on-build-landing-file-85
125
125
  ### B1C: Create worktree
126
126
 
127
127
  ```bash
128
- WORKTREE_PATH="/path/to/repo/.claude/worktrees/{BRANCH_SLUG}"
128
+ WORKTREE_ROOT="/path/to/repo/.claude/worktrees"
129
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
130
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
131
+ [ -n "${OPENCODE_PID:-}" ] ||
132
+ [ -n "${OPENCODE:-}" ]; then
133
+ WORKTREE_ROOT="/path/to/repo/.opencode/worktrees"
134
+ elif [ "${FORGE_RUNTIME:-}" = "codex" ]; then
135
+ WORKTREE_ROOT="/path/to/repo/.codex/worktrees"
136
+ fi
137
+ WORKTREE_PATH="${WORKTREE_ROOT}/{BRANCH_SLUG}"
129
138
  git worktree add {WORKTREE_PATH} -b {BRANCH} origin/{SOURCE_BRANCH}
130
139
  ```
131
140
 
@@ -1411,7 +1411,16 @@ esac
1411
1411
  cd {REPO_PATH}
1412
1412
  git fetch origin
1413
1413
  BRANCH="fix/{slug}-{NUMBER}"
1414
- WORKTREE_PATH="{REPO_PATH}/.claude/worktrees/{BRANCH_SLUG}"
1414
+ WORKTREE_ROOT="{REPO_PATH}/.claude/worktrees"
1415
+ if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
1416
+ [ -n "${OPENCODE_SESSION_ID:-}" ] ||
1417
+ [ -n "${OPENCODE_PID:-}" ] ||
1418
+ [ -n "${OPENCODE:-}" ]; then
1419
+ WORKTREE_ROOT="{REPO_PATH}/.opencode/worktrees"
1420
+ elif [ "${FORGE_RUNTIME:-}" = "codex" ]; then
1421
+ WORKTREE_ROOT="{REPO_PATH}/.codex/worktrees"
1422
+ fi
1423
+ WORKTREE_PATH="${WORKTREE_ROOT}/{BRANCH_SLUG}"
1415
1424
  git worktree add {WORKTREE_PATH} -b {BRANCH} origin/{PR_BASE}
1416
1425
  ```
1417
1426