forgedock 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ $ /work-on #1230 "orchestrate: Layer 5 co-change signal is dead code"
56
56
 
57
57
  ### Try it in 30 seconds — on a throwaway repo, nothing to lose
58
58
 
59
- **Requires:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
59
+ **Requires for this demo:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
60
60
 
61
61
  ```bash
62
62
  npx forgedock demo # spins up a risk-free demo repo and shows you the pipeline end to end
@@ -224,12 +224,24 @@ More ship today (web-property analytics, browser QA sweeps, self-benchmarking)
224
224
 
225
225
  ## Install
226
226
 
227
- **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
227
+ **Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai/) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
228
228
 
229
229
  ```bash
230
230
  npx forgedock # checks your environment, installs commands into ~/.claude/commands/ (available in every Claude Code session on this machine), detects your repo, and hands you a reviewed forge.yaml
231
231
  ```
232
232
 
233
+ For OpenCode, install namespaced native commands without changing your provider
234
+ or user-owned settings. When migrating an older ForgeDock adapter, it removes
235
+ only exact ForgeDock-managed legacy entries from `opencode.json`:
236
+
237
+ ```bash
238
+ npx forgedock opencode install
239
+ # restart OpenCode, then: /forge/work-on <issue>
240
+ ```
241
+
242
+ See [OpenCode support](docs/OPENCODE.md) for architecture, token-loading rules,
243
+ current parity boundaries, and lifecycle commands.
244
+
233
245
  **Install is always global**, to `~/.claude/commands/`. `--global` is still accepted on the command line for backward compatibility but has no effect — there's no other install location to opt out of.
234
246
 
235
247
  > **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI contract for the standing-query re-resolution canary in the orchestrator.
4
+ */
5
+ import { shouldReResolve } from "./resolve.mjs";
6
+
7
+ const [kind, pattern, enabled, maxRounds, rounds] = process.argv.slice(2);
8
+
9
+ const classified = { kind, pattern, args: [] };
10
+ const config = {
11
+ enabled: enabled === "false" ? false : enabled,
12
+ maxRounds: maxRounds === "unbounded" ? undefined : Number(maxRounds),
13
+ };
14
+
15
+ process.stdout.write(`${JSON.stringify(shouldReResolve(classified, config, Number(rounds)))}\n`);
@@ -0,0 +1,28 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const canary = join(here, "orchestrate-canary.mjs");
9
+
10
+ function runCanary(...args) {
11
+ const result = spawnSync(process.execPath, [canary, ...args], { encoding: "utf8" });
12
+ assert.equal(result.status, 0, result.stderr);
13
+ return JSON.parse(result.stdout);
14
+ }
15
+
16
+ describe("orchestrate re-resolution canary CLI", () => {
17
+ it("re-resolves a query with the documented argument contract", () => {
18
+ assert.deepEqual(runCanary("query", "priority", "true", "unbounded", "0"), {
19
+ reResolve: true,
20
+ reason: 'query pattern "priority" is a standing predicate — re-resolving',
21
+ });
22
+ });
23
+
24
+ it("preserves literal-set and max-round termination", () => {
25
+ assert.equal(runCanary("literal", "literal-numbers", "true", "3", "0").reResolve, false);
26
+ assert.equal(runCanary("query", "milestone", "true", "3", "3").reResolve, false);
27
+ });
28
+ });
@@ -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/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);
@@ -1346,6 +1420,7 @@ async function relinkAndHint() {
1346
1420
  // (that path calls statusScreen(), never relinkAndHint()) — 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;
@@ -2717,7 +2776,6 @@ async function doctor(fix = false) {
2717
2776
  // absence that isn't a problem should not read as one.
2718
2777
  {
2719
2778
  try {
2720
- const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
2721
2779
  if (isGitCloneInstall) {
2722
2780
  pass("Persisted toolset home (~/.forge)", "skipped — git-clone install links directly from the clone");
2723
2781
  } else {
@@ -3150,6 +3208,9 @@ function help() {
3150
3208
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
3151
3209
  ["npx forgedock update", "Pull latest & reinstall"],
3152
3210
  ["npx forgedock uninstall", "Remove commands"],
3211
+ ["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
3212
+ ["npx forgedock opencode status", "Check the OpenCode adapter"],
3213
+ ["npx forgedock opencode uninstall", "Remove ForgeDock-owned OpenCode files"],
3153
3214
  ["npx forgedock version", "Print the installed version and check for updates"],
3154
3215
  ["npx forgedock help", "Show this help"],
3155
3216
  ];
@@ -3711,7 +3772,7 @@ async function watch() {
3711
3772
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3712
3773
  const KNOWN_COMMANDS = new Set([
3713
3774
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3714
- "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3775
+ "run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3715
3776
  "version", "--version", "-v",
3716
3777
  ]);
3717
3778
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3801,9 +3862,80 @@ switch (command) {
3801
3862
  case "update":
3802
3863
  await update();
3803
3864
  break;
3865
+ case "opencode": {
3866
+ const action = restArgs[0] || "install";
3867
+ const {
3868
+ getOpenCodeAdapterStatus,
3869
+ installOpenCodeAdapter,
3870
+ uninstallOpenCodeAdapter,
3871
+ } = await import("./opencode-adapter.mjs");
3872
+ if (action === "install") {
3873
+ const persist = await persistHome({ forgeHome: FORGE_HOME, home: HOME });
3874
+ if (persist.forgeHome === FORGE_HOME && isEphemeralCachePath(FORGE_HOME)) {
3875
+ throw new Error(
3876
+ `Cannot install OpenCode commands from ephemeral package path ${FORGE_HOME}: ` +
3877
+ `${persist.reason || "failed to persist ForgeDock under ~/.forge"}.`,
3878
+ );
3879
+ }
3880
+ const result = await installOpenCodeAdapter({
3881
+ forgeHome: persist.forgeHome || FORGE_HOME,
3882
+ home: HOME,
3883
+ includeExtras: restArgs.includes("--extras"),
3884
+ });
3885
+ process.stdout.write(
3886
+ `Installed ${result.commandCount} OpenCode commands and ${result.skillCount} skills under ${result.configDir}.\n` +
3887
+ "Restart OpenCode, then run /forge/work-on <issue>.\n",
3888
+ );
3889
+ if (result.migration.removedInstructionsFile || result.migration.removedConfigEntries > 0) {
3890
+ process.stdout.write(
3891
+ `Removed legacy OpenCode adapter artifacts (${result.migration.removedConfigEntries} config entries).\n`,
3892
+ );
3893
+ }
3894
+ for (const warning of result.migration.warnings) process.stderr.write(`Warning: ${warning}\n`);
3895
+ } else if (action === "status") {
3896
+ const result = await getOpenCodeAdapterStatus({ home: HOME });
3897
+ if (result.legacy) {
3898
+ process.stdout.write(
3899
+ "A legacy ForgeDock OpenCode adapter is installed. Re-run: npx forgedock opencode install\n",
3900
+ );
3901
+ exitCode = 1;
3902
+ } else if (!result.installed) {
3903
+ process.stdout.write(`OpenCode adapter is not installed under ${result.configDir}.\n`);
3904
+ exitCode = 1;
3905
+ } else if (!result.healthy) {
3906
+ process.stdout.write(
3907
+ `OpenCode adapter is incomplete (${result.missing.length} missing file(s)). ` +
3908
+ "Re-run: npx forgedock opencode install\n",
3909
+ );
3910
+ exitCode = 1;
3911
+ } else {
3912
+ process.stdout.write(
3913
+ `OpenCode adapter is healthy (${result.manifest.commandCount} commands, ${result.manifest.skillCount ?? 0} skills) under ${result.configDir}.\n`,
3914
+ );
3915
+ }
3916
+ } else if (action === "uninstall") {
3917
+ const result = await uninstallOpenCodeAdapter({ home: HOME });
3918
+ process.stdout.write(`Removed ${result.removed} ForgeDock-owned OpenCode file(s).\n`);
3919
+ } else {
3920
+ process.stderr.write(
3921
+ `Unknown OpenCode action: ${action}. Use install, status, or uninstall.\n`,
3922
+ );
3923
+ exitCode = 1;
3924
+ }
3925
+ break;
3926
+ }
3804
3927
  case "run":
3805
3928
  await run();
3806
3929
  break;
3930
+ case "backend-check": {
3931
+ const { checkExecutionBackend } = await import("./runner.mjs");
3932
+ const result = checkExecutionBackend();
3933
+ if (!restArgs.includes("--quiet")) {
3934
+ process.stdout.write(`${result.ready ? "ready" : "unavailable"}: ${result.backend} (${result.reason})\n`);
3935
+ }
3936
+ if (!result.ready) exitCode = 1;
3937
+ break;
3938
+ }
3807
3939
  case "run-issue": {
3808
3940
  const { runFromCli } = await import("./engine-cli.mjs");
3809
3941
  try {