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
@@ -116,6 +116,23 @@ async function commitsAhead(lane, branch, io) {
116
116
  */
117
117
  function has(blob, marker) { return blob.includes(marker); }
118
118
 
119
+ /**
120
+ * The interactive workflow persists its conservative complexity decision in a
121
+ * FORGE:FAST_PATH comment. The engine must consume that decision too; otherwise
122
+ * its separate context/architect phases negate the documented trivial path.
123
+ * Only an exact TRIVIAL value is eligible to skip work, so malformed or absent
124
+ * annotations continue through the full pipeline.
125
+ */
126
+ function complexityBand(comments) {
127
+ for (let i = comments.length - 1; i >= 0; i--) {
128
+ const body = comments[i];
129
+ if (!body?.includes("FORGE:FAST_PATH")) continue;
130
+ const match = body.match(/\*\*COMPLEXITY_BAND\*\*:\s*([A-Z_]+)/);
131
+ if (match) return match[1];
132
+ }
133
+ return null;
134
+ }
135
+
119
136
  /**
120
137
  * Fetch the issue's live `state` (OPEN/CLOSED) and `labels` in one call.
121
138
  *
@@ -254,10 +271,13 @@ export const PHASES = [
254
271
  command: "work-on/build/context",
255
272
  entryCondition: (s) => s.committed.includes("investigate"),
256
273
  async reconcile(state, io) {
274
+ const markers = await issueMarkers(state.issue, io);
275
+ if (complexityBand(markers.comments) === "TRIVIAL") {
276
+ return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "context" } };
277
+ }
257
278
  // Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
258
279
  // Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
259
- const { blob } = await issueMarkers(state.issue, io);
260
- return has(blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
280
+ return has(markers.blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
261
281
  },
262
282
  async detectOutcome(state, io) {
263
283
  const { blob } = await issueMarkers(state.issue, io);
@@ -271,10 +291,13 @@ export const PHASES = [
271
291
  command: "work-on/build/architect",
272
292
  entryCondition: (s) => s.committed.includes("context"),
273
293
  async reconcile(state, io) {
294
+ const markers = await issueMarkers(state.issue, io);
295
+ if (complexityBand(markers.comments) === "TRIVIAL") {
296
+ return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "architect" } };
297
+ }
274
298
  // Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
275
299
  // Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
276
- const { blob } = await issueMarkers(state.issue, io);
277
- return has(blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
300
+ return has(markers.blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
278
301
  },
279
302
  async detectOutcome(state, io) {
280
303
  const { blob } = await issueMarkers(state.issue, io);
@@ -356,7 +379,7 @@ export const PHASES = [
356
379
  if (!pr) return { status: "failed", detail: "no PR created" };
357
380
  if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
358
381
  if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
359
- return { status: "failed", detail: "PR open, not merged" };
382
+ return { status: "failed", detail: "PR open, not merged", retryable: false };
360
383
  },
361
384
  },
362
385
  {
@@ -370,23 +393,9 @@ export const PHASES = [
370
393
  // phase table (closing the literal "remediate appears nowhere in the
371
394
  // engine" gap) and is fully unit-tested via `pickPhase`/`detectOutcome`.
372
395
  //
373
- // KNOWN LIMITATION (documented, not fixed here — see forge#2379
374
- // investigation "What We Found"): `review`'s `"blocked"` outcome (the
375
- // needs-human escalation) causes `bin/engine.mjs`'s `runIssue()` to
376
- // `terminate()` immediately, before `review` is ever added to
377
- // `state.committed` — and the divergence guard just above that also
378
- // pauses before any non-`close` phase once the issue carries
379
- // `needs-human`. So a single continuous `runIssue()` walk cannot reach
380
- // this phase's `entryCondition` today. In practice `remediate.md` is
381
- // (correctly, per `work-on.md` Phase 0A.1) invoked as its own separate
382
- // top-level entry point (`/work-on <pr> --remediate`), not as a
383
- // continuation of the original run — this phase entry documents and
384
- // tests the target state shape for a future run that reconstructs
385
- // `committed`/`terminalReason` from live GitHub state (e.g. a dedicated
386
- // remediation-run entry point) rather than from a fresh local run-log.
387
- // Making that live wiring real is out of this issue's scope — it needs
388
- // changes to `review`'s `"blocked"` contract and to `bin/tests/engine.test.mjs`,
389
- // both outside this issue's declared file set.
396
+ // A blocked review is committed with terminalReason "needs-human" before
397
+ // the engine selects this phase, preserving the review verdict while
398
+ // handing the PR to remediation in the same run.
390
399
  id: "remediate",
391
400
  command: "work-on/remediate",
392
401
  entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
@@ -94,7 +94,14 @@
94
94
  * @returns {ClassifiedInput}
95
95
  */
96
96
  export function classifyInputPattern(input) {
97
- const trimmed = (input ?? "").trim();
97
+ // Orchestration control flags authorize or tune the dispatcher; they are not
98
+ // part of the issue-set predicate. Strip them here so a confirmed OpenCode
99
+ // fast path and the full shared resolver classify the same query.
100
+ const trimmed = String(input ?? "")
101
+ .replace(/(?:^|\s)(?:--auto|--confirm|--deep-plan)(?=\s|$)/gi, " ")
102
+ .replace(/(?:^|\s)--max-concurrent(?:=|\s+)[1-9]\d*(?=\s|$)/gi, " ")
103
+ .replace(/\s+/g, " ")
104
+ .trim();
98
105
  const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
99
106
 
100
107
  // literal-numbers: every token is a bare integer or a (optionally
@@ -55,6 +55,11 @@ describe("classifyInputPattern — query patterns", () => {
55
55
  assert.equal(classifyInputPattern("fast-lane").kind, "query");
56
56
  });
57
57
 
58
+ it("ignores orchestration control flags when classifying the issue predicate", () => {
59
+ assert.equal(classifyInputPattern("fast-lane --auto --max-concurrent 4").pattern, "fast-lane");
60
+ assert.equal(classifyInputPattern("1 2 --confirm").kind, "literal");
61
+ });
62
+
58
63
  it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
59
64
  assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
60
65
  assert.equal(classifyInputPattern("priority:P1").kind, "query");
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
  import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
14
  import { readLog, deriveState } from "./engine/runlog.mjs";
15
+ import { assertOpenCodeNativeRuntime } from "./runner.mjs";
15
16
 
16
17
  /**
17
18
  * Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
@@ -340,6 +341,7 @@ export function lastLocalRun(dir) {
340
341
  export async function runFromCli(argv, deps = {}) {
341
342
  const issue = parseInt(argv[0], 10);
342
343
  if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
344
+ assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
343
345
  const lane = flag(argv, "--lane");
344
346
  if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
345
347
  const repo = flag(argv, "--repo");
package/bin/engine.mjs CHANGED
@@ -331,7 +331,9 @@ export async function runIssue(opts) {
331
331
  // the same reason composes with that existing contract instead of
332
332
  // conflating "paused pending a human" with "dead". Deliberately does
333
333
  // NOT check `workflow:invalid`/CLOSED here — those are handled above.
334
- if (snap.labels.includes("needs-human")) {
334
+ // A blocked review is deliberately handed to remediation in this same
335
+ // run. All other needs-human states remain paused for human action.
336
+ if (snap.labels.includes("needs-human") && phase.id !== "remediate") {
335
337
  const detail = `issue ${issue} carries needs-human — pausing before phase ${phase.id}`;
336
338
  return await terminate(state, "needs-human", detail);
337
339
  }
@@ -520,21 +522,24 @@ export async function runIssue(opts) {
520
522
  }
521
523
  }
522
524
 
525
+ const isRemediationHandoff = outcome.status === "blocked" &&
526
+ phase.id === "review" && (outcome.reason || "needs-human") === "needs-human";
523
527
  if (outcome.status === "blocked") {
524
528
  emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
525
- return await terminate(state, outcome.reason || "needs-human", outcome.detail);
529
+ if (!isRemediationHandoff)
530
+ return await terminate(state, outcome.reason || "needs-human", outcome.detail);
531
+ } else {
532
+ // committed
533
+ // forge#2321: only report a phase_exit if a matching phase_enter was
534
+ // actually emitted for this phase this iteration. A reconcile-satisfied
535
+ // phase (short-circuited above without ever entering the `else` branch)
536
+ // never "started" this run — emitting phase_exit for it produced a
537
+ // dangling exit with no preceding enter (bin/engine-cli.mjs prints
538
+ // "✓ phase X committed" with no prior "→ phase X started" line).
539
+ // Suppressing the unmatched exit is correct here rather than fabricating
540
+ // a synthetic phase_enter, since the phase's runner genuinely never ran.
541
+ if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
526
542
  }
527
-
528
- // committed
529
- // forge#2321: only report a phase_exit if a matching phase_enter was
530
- // actually emitted for this phase this iteration. A reconcile-satisfied
531
- // phase (short-circuited above without ever entering the `else` branch)
532
- // never "started" this run — emitting phase_exit for it produced a
533
- // dangling exit with no preceding enter (bin/engine-cli.mjs prints
534
- // "✓ phase X committed" with no prior "→ phase X started" line).
535
- // Suppressing the unmatched exit is correct here rather than fabricating
536
- // a synthetic phase_enter, since the phase's runner genuinely never ran.
537
- if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
538
543
  // forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
539
544
  // from the injected runner()'s (== bin/runner.mjs's runCommand()) return
540
545
  // value — null when the backend doesn't report usage (CLI backend today)
@@ -544,7 +549,10 @@ export async function runIssue(opts) {
544
549
  // owned by phase.detectOutcome() (bin/engine/phases.mjs).
545
550
  appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
546
551
  state = deriveState(readLog(dir, issue));
547
- if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
552
+ const terminalReason = outcome.status === "blocked"
553
+ ? (outcome.reason || "needs-human")
554
+ : outcome.terminalReason;
555
+ if (terminalReason) state.terminalReason = terminalReason;
548
556
  await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
549
557
 
550
558
  // forge#2379: the "investigate" phase reporting terminalReason "decomposed"
@@ -564,9 +572,10 @@ export async function runIssue(opts) {
564
572
  // is seen), phase.id is "decompose" — not "investigate" — so this
565
573
  // exemption does not apply and the normal terminate() path below fires,
566
574
  // ending the run for real.
567
- const isDecomposeHandoff = phase.id === "investigate" && outcome.terminalReason === "decomposed";
568
- if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason) && !isDecomposeHandoff)
569
- return await terminate(state, outcome.terminalReason);
575
+ const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
576
+ if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
577
+ !isDecomposeHandoff && !isRemediationHandoff)
578
+ return await terminate(state, terminalReason, outcome.detail);
570
579
  if (phase.isTerminalAfter && phase.isTerminalAfter(state))
571
580
  return await terminate(state, state.terminalReason || "merged");
572
581
  }
package/bin/forgedock.mjs CHANGED
@@ -416,6 +416,40 @@ function getVersion() {
416
416
  }
417
417
  }
418
418
 
419
+ /**
420
+ * Read the branch that owns the source currently being executed.
421
+ *
422
+ * This is deliberately best-effort: diagnostics should still identify the
423
+ * source path and version when the checkout has no readable branch ref.
424
+ *
425
+ * @param {string} [dir]
426
+ * @returns {string|null}
427
+ */
428
+ function getGitBranch(dir = FORGE_HOME) {
429
+ try {
430
+ const branch = execFileSync(
431
+ "git",
432
+ ["rev-parse", "--abbrev-ref", "HEAD"],
433
+ { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
434
+ ).trim();
435
+ return branch || null;
436
+ } catch {
437
+ return null;
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Describe the source path the current CLI process resolves from.
443
+ *
444
+ * @param {string|null} [branch]
445
+ * @returns {string}
446
+ */
447
+ function formatSourceIdentity(branch = null) {
448
+ const version = getVersion() || "unknown";
449
+ const branchLabel = branch ? `, branch ${branch}` : "";
450
+ return `${FORGE_HOME} (v${version}${branchLabel})`;
451
+ }
452
+
419
453
  /**
420
454
  * Compare two dotted version strings component-wise (numeric, not
421
455
  * lexicographic — "1.9.0" must compare as older than "1.10.0").
@@ -1330,11 +1364,51 @@ async function uninstall() {
1330
1364
  console.log("");
1331
1365
  }
1332
1366
 
1333
- // Reinstall = relink commands + re-register the hook. Never the full
1334
- // journey: update must not reach read/review, which could overwrite a
1335
- // curated forge.yaml (and re-runs AI enrichment on every update). Also
1336
- // idempotent, so it's the repair path for a configured repo whose
1337
- // symlinks or hook registration got out of sync.
1367
+ // Reinstall = relink commands + re-register the hook + refresh an already
1368
+ // installed OpenCode adapter. Never the full journey: update must not reach
1369
+ // read/review, which could overwrite a curated forge.yaml (and re-runs AI
1370
+ // enrichment on every update). Also idempotent, so it's the repair path for a
1371
+ // configured repo whose symlinks, hook registration, or managed OpenCode files
1372
+ // got out of sync.
1373
+ async function refreshManagedOpenCodeAdapter() {
1374
+ try {
1375
+ const { getOpenCodeAdapterStatus, installOpenCodeAdapter } = await import("./opencode-adapter.mjs");
1376
+ const status = await getOpenCodeAdapterStatus({ home: HOME, env: process.env });
1377
+ if (!status.installed) return;
1378
+
1379
+ // npm/npx payloads must be persisted before the generated adapter points at
1380
+ // them; git-clone installs are already stable and persistHome returns them
1381
+ // unchanged.
1382
+ const persisted = await persistHome(ctx());
1383
+ const adapterForgeHome = persisted.forgeHome || FORGE_HOME;
1384
+ if (adapterForgeHome === FORGE_HOME && isEphemeralCachePath(FORGE_HOME)) {
1385
+ console.log(
1386
+ ` ${YELLOW}OpenCode adapter refresh skipped: the current ForgeDock payload is ephemeral.${RESET}`,
1387
+ );
1388
+ return;
1389
+ }
1390
+
1391
+ const result = await installOpenCodeAdapter({
1392
+ forgeHome: adapterForgeHome,
1393
+ home: HOME,
1394
+ env: process.env,
1395
+ includeExtras: status.manifest?.includeExtras === true,
1396
+ });
1397
+ console.log(
1398
+ ` ${GREEN}Refreshed managed OpenCode adapter (${result.commandCount} commands, ${result.skillCount} skills).${RESET}`,
1399
+ );
1400
+ for (const warning of result.migration.warnings) {
1401
+ console.log(` ${YELLOW}OpenCode adapter warning: ${warning}${RESET}`);
1402
+ }
1403
+ } catch (error) {
1404
+ // OpenCode refresh is best-effort, matching the existing update/relink
1405
+ // behavior: a permissions or ownership problem must not block Claude users.
1406
+ console.log(
1407
+ ` ${YELLOW}Could not refresh the managed OpenCode adapter: ${error?.message || String(error)}${RESET}`,
1408
+ );
1409
+ }
1410
+ }
1411
+
1338
1412
  async function relinkAndHint() {
1339
1413
  const c = ctx();
1340
1414
  const forged = await forge(c);
@@ -1343,9 +1417,10 @@ async function relinkAndHint() {
1343
1417
  // version-check path), so wiring it here covers "refreshed after every
1344
1418
  // successful update" without duplicating the call at each branch. Note:
1345
1419
  // this is NOT reached by install's already-managed-active short-circuit
1346
- // (that path calls statusScreen(), never relinkAndHint()) — see the
1420
+ // (that path is also used by managed-active install) — see the
1347
1421
  // writeInstallReceipt() JSDoc in journey.mjs for the full picture.
1348
1422
  await writeInstallReceipt(c, { forged });
1423
+ await refreshManagedOpenCodeAdapter();
1349
1424
  if (!existsSync(join(c.cwd, "forge.yaml"))) {
1350
1425
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
1351
1426
  c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
@@ -1808,24 +1883,18 @@ async function update() {
1808
1883
  const separateGlobalInstall = findSeparateGlobalInstall();
1809
1884
  if (separateGlobalInstall) {
1810
1885
  console.log(
1811
- ` ${YELLOW}Note: this updates the git clone above. Your global npm install at ${separateGlobalInstall} was NOT touched.${RESET}`,
1886
+ ` ${YELLOW}Note: this command resolves to the git clone above. Your global npm install at ${separateGlobalInstall} is NOT touched.${RESET}`,
1812
1887
  );
1813
1888
  console.log(
1814
1889
  ` ${YELLOW}Run 'npm update -g forgedock', or 'npx forgedock update' from outside a clone, to update it.${RESET}`,
1815
1890
  );
1816
1891
  }
1817
1892
  try {
1818
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1819
- cwd: FORGE_HOME,
1820
- encoding: "utf-8",
1821
- }).trim();
1893
+ const branch = getGitBranch();
1894
+ const branchLabel = branch || "unknown branch";
1895
+ console.log(` Source: ${formatSourceIdentity(branch)}`);
1822
1896
 
1823
- // If on a non-main branch, switch to main for the update then restore.
1824
- // Detached HEAD ("HEAD") is treated as non-restorable — we leave on main
1825
- // after the update and warn the user.
1826
- const isDetached = branch === "HEAD";
1827
- const needsCheckout = branch !== "main";
1828
- if (needsCheckout) {
1897
+ if (branch !== "main") {
1829
1898
  // Dirty-tree guard: refuse to move HEAD in this clone if there are
1830
1899
  // uncommitted TRACKED changes. Untracked files are intentionally
1831
1900
  // excluded (--untracked-files=no) so a clean checkout with stray
@@ -1837,7 +1906,7 @@ async function update() {
1837
1906
  ).trim();
1838
1907
  if (porcelain) {
1839
1908
  console.log(
1840
- ` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping git-based update.${RESET}`,
1909
+ ` ${YELLOW}Working tree has uncommitted changes on branch ${branchLabel} — skipping git-based update.${RESET}`,
1841
1910
  );
1842
1911
 
1843
1912
  // forge#2460 — distinguish "developing on this checkout" from a
@@ -1882,16 +1951,13 @@ async function update() {
1882
1951
  return;
1883
1952
  }
1884
1953
 
1885
- if (isDetached) {
1886
- console.log(
1887
- ` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
1888
- );
1889
- } else {
1890
- console.log(
1891
- ` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
1892
- );
1893
- }
1894
- execFileSync("git", ["checkout", "main", "--quiet"], { cwd: FORGE_HOME });
1954
+ console.log(
1955
+ ` ${YELLOW}Source checkout is on ${branchLabel}, not main; skipping update to preserve this source.${RESET}`,
1956
+ );
1957
+ console.log(
1958
+ ` Run 'npx forgedock update' from a main-branch checkout (or use 'npx forgedock@latest') to refresh ForgeDock.`,
1959
+ );
1960
+ return;
1895
1961
  }
1896
1962
 
1897
1963
  const before = execSync("git rev-parse HEAD", {
@@ -1914,20 +1980,6 @@ async function update() {
1914
1980
  printGitCloneChangelogSummary(before, after);
1915
1981
  }
1916
1982
  await relinkAndHint();
1917
-
1918
- // Restore original branch after a successful update.
1919
- if (needsCheckout && !isDetached) {
1920
- try {
1921
- execFileSync("git", ["checkout", branch, "--quiet"], { cwd: FORGE_HOME });
1922
- console.log(
1923
- ` Restored branch ${CYAN}${branch}${RESET}.`,
1924
- );
1925
- } catch {
1926
- console.log(
1927
- ` ${YELLOW}⚠ Could not restore branch ${branch} — you are now on main.${RESET}`,
1928
- );
1929
- }
1930
- }
1931
1983
  } catch (err) {
1932
1984
  console.log(
1933
1985
  ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
@@ -2190,7 +2242,14 @@ async function doctor(fix = false) {
2190
2242
  console.log("");
2191
2243
 
2192
2244
  const { targetDir: TARGET_DIR } = detectInstallPaths();
2193
- console.log(` Mode: global (~/.claude)`);
2245
+ const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
2246
+ const sourceBranch = isGitCloneInstall ? getGitBranch() : null;
2247
+ console.log(
2248
+ isGitCloneInstall
2249
+ ? ` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`
2250
+ : ` Mode: global (~/.claude)`,
2251
+ );
2252
+ console.log(` Source: ${formatSourceIdentity(sourceBranch)}`);
2194
2253
  console.log("");
2195
2254
 
2196
2255
  let failures = 0;
@@ -2357,6 +2416,16 @@ async function doctor(fix = false) {
2357
2416
  broken++;
2358
2417
  brokenLinks.push(rel);
2359
2418
  symlinkOk = false;
2419
+ } else {
2420
+ try {
2421
+ // readlink() only validates the stored target. Opening the
2422
+ // link also catches platform-specific traversal failures.
2423
+ readFileSync(tgt);
2424
+ } catch {
2425
+ broken++;
2426
+ brokenLinks.push(`${rel} (unreadable — link resolves but cannot be traversed)`);
2427
+ symlinkOk = false;
2428
+ }
2360
2429
  }
2361
2430
  } else {
2362
2431
  // Regular file — copy-mode install (Windows without Developer Mode).
@@ -2717,7 +2786,6 @@ async function doctor(fix = false) {
2717
2786
  // absence that isn't a problem should not read as one.
2718
2787
  {
2719
2788
  try {
2720
- const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
2721
2789
  if (isGitCloneInstall) {
2722
2790
  pass("Persisted toolset home (~/.forge)", "skipped — git-clone install links directly from the clone");
2723
2791
  } else {
@@ -3208,6 +3276,7 @@ function help() {
3208
3276
  * FORGEDOCK_BACKEND Default backend ("cli"|"api"|"auto") when --backend is omitted.
3209
3277
  * FORGEDOCK_MODEL Default model id when --model is omitted.
3210
3278
  * FORGEDOCK_CLI_TIMEOUT_MS Wall-clock timeout (ms) for the cli backend invocation.
3279
+ * Overrides forge.yaml pipeline.cli_timeout_minutes.
3211
3280
  * FORGEDOCK_SHELL Override the shell used by run_bash / the cli backend.
3212
3281
  * Defaults to bash when found (Git Bash / WSL on
3213
3282
  * Windows, /bin/bash on POSIX), falling back to the
@@ -3750,7 +3819,9 @@ switch (command) {
3750
3819
  }
3751
3820
  }
3752
3821
  if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
3753
- await statusScreen(c);
3822
+ // A configured directory may still have missing commands after uninstall.
3823
+ // Refresh managed files without re-running the configuration journey.
3824
+ await relinkAndHint();
3754
3825
  } else {
3755
3826
  exitCode = await runJourney(c);
3756
3827
  // Record persistHome()'s outcome (set on c.persistHomeResult inside
@@ -3825,7 +3896,7 @@ switch (command) {
3825
3896
  includeExtras: restArgs.includes("--extras"),
3826
3897
  });
3827
3898
  process.stdout.write(
3828
- `Installed ${result.commandCount} OpenCode commands under ${result.configDir}.\n` +
3899
+ `Installed ${result.commandCount} OpenCode commands and ${result.skillCount} skills under ${result.configDir}.\n` +
3829
3900
  "Restart OpenCode, then run /forge/work-on <issue>.\n",
3830
3901
  );
3831
3902
  if (result.migration.removedInstructionsFile || result.migration.removedConfigEntries > 0) {
@@ -3836,7 +3907,12 @@ switch (command) {
3836
3907
  for (const warning of result.migration.warnings) process.stderr.write(`Warning: ${warning}\n`);
3837
3908
  } else if (action === "status") {
3838
3909
  const result = await getOpenCodeAdapterStatus({ home: HOME });
3839
- if (!result.installed) {
3910
+ if (result.legacy) {
3911
+ process.stdout.write(
3912
+ "A legacy ForgeDock OpenCode adapter is installed. Re-run: npx forgedock opencode install\n",
3913
+ );
3914
+ exitCode = 1;
3915
+ } else if (!result.installed) {
3840
3916
  process.stdout.write(`OpenCode adapter is not installed under ${result.configDir}.\n`);
3841
3917
  exitCode = 1;
3842
3918
  } else if (!result.healthy) {
@@ -3847,7 +3923,7 @@ switch (command) {
3847
3923
  exitCode = 1;
3848
3924
  } else {
3849
3925
  process.stdout.write(
3850
- `OpenCode adapter is healthy (${result.manifest.commandCount} commands) under ${result.configDir}.\n`,
3926
+ `OpenCode adapter is healthy (${result.manifest.commandCount} commands, ${result.manifest.skillCount ?? 0} skills) under ${result.configDir}.\n`,
3851
3927
  );
3852
3928
  }
3853
3929
  } else if (action === "uninstall") {