forgedock 1.5.1 → 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.
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,23 @@ 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 `opencode.json`:
235
+
236
+ ```bash
237
+ npx forgedock opencode install
238
+ # restart OpenCode, then: /forge/work-on <issue>
239
+ ```
240
+
241
+ See [OpenCode support](docs/OPENCODE.md) for architecture, token-loading rules,
242
+ current parity boundaries, and lifecycle commands.
243
+
233
244
  **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
245
 
235
246
  > **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
+ });
package/bin/forgedock.mjs CHANGED
@@ -59,8 +59,8 @@ import {
59
59
  detectClaudeVersion,
60
60
  loadBreakpoints,
61
61
  hasFeature,
62
- getPersistedHomeState,
63
62
  setPersistedHomeState,
63
+ getInstalledCommandsDriftStatus,
64
64
  } from "./registry.mjs";
65
65
  import { renderMark, ember } from "./cinema.mjs";
66
66
  import {
@@ -2740,27 +2740,16 @@ async function doctor(fix = false) {
2740
2740
  `incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
2741
2741
  );
2742
2742
  } else {
2743
- // Prefer the version registry.mjs last recorded for this exact
2744
- // write (getPersistedHomeState()) — the single source of truth
2745
- // update()/install also write through — falling back to reading
2746
- // ~/.forge/version directly on disk when the registry has no entry
2747
- // (e.g. an older ForgeDock version wrote ~/.forge/ before #1943
2748
- // added registry tracking). Reading both keeps doctor() honest
2749
- // even if the registry ever drifts from what's actually on disk.
2750
- const recorded = getPersistedHomeState();
2751
- let persistedVersion = recorded?.version || "";
2752
- if (!persistedVersion) {
2753
- try {
2754
- persistedVersion = readFileSync(versionPath, "utf-8").trim();
2755
- } catch {
2756
- // version file missing/unreadable — reported below as "unknown"
2757
- }
2758
- }
2759
- const sourceVersion = getVersion();
2760
- if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
2743
+ // Package-vs-installed-commands drift comparison is factored into
2744
+ // a single shared helper (bin/registry.mjs's
2745
+ // getInstalledCommandsDriftStatus, forge#2719) so doctor, the
2746
+ // `version` command, and the SessionStart hook all agree on one
2747
+ // source of truth instead of re-deriving this logic independently.
2748
+ const { persistedVersion, isStale, unknown } = getInstalledCommandsDriftStatus(FORGE_HOME);
2749
+ if (!unknown && isStale) {
2761
2750
  warn(
2762
2751
  "Persisted toolset home (~/.forge)",
2763
- `v${persistedVersion} — source is v${sourceVersion}. Run: npx forgedock update`,
2752
+ `v${persistedVersion} — source is v${getVersion()}. Run: npx forgedock update`,
2764
2753
  );
2765
2754
  } else {
2766
2755
  pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
@@ -3161,6 +3150,9 @@ function help() {
3161
3150
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
3162
3151
  ["npx forgedock update", "Pull latest & reinstall"],
3163
3152
  ["npx forgedock uninstall", "Remove commands"],
3153
+ ["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
3154
+ ["npx forgedock opencode status", "Check the OpenCode adapter"],
3155
+ ["npx forgedock opencode uninstall", "Remove ForgeDock-owned OpenCode files"],
3164
3156
  ["npx forgedock version", "Print the installed version and check for updates"],
3165
3157
  ["npx forgedock help", "Show this help"],
3166
3158
  ];
@@ -3722,7 +3714,7 @@ async function watch() {
3722
3714
  const SPLASH_COMMANDS = new Set(["run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3723
3715
  const KNOWN_COMMANDS = new Set([
3724
3716
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3725
- "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3717
+ "run", "backend-check", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "help", "--help", "-h",
3726
3718
  "version", "--version", "-v",
3727
3719
  ]);
3728
3720
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3812,9 +3804,75 @@ switch (command) {
3812
3804
  case "update":
3813
3805
  await update();
3814
3806
  break;
3807
+ case "opencode": {
3808
+ const action = restArgs[0] || "install";
3809
+ const {
3810
+ getOpenCodeAdapterStatus,
3811
+ installOpenCodeAdapter,
3812
+ uninstallOpenCodeAdapter,
3813
+ } = await import("./opencode-adapter.mjs");
3814
+ if (action === "install") {
3815
+ const persist = await persistHome({ forgeHome: FORGE_HOME, home: HOME });
3816
+ if (persist.forgeHome === FORGE_HOME && isEphemeralCachePath(FORGE_HOME)) {
3817
+ throw new Error(
3818
+ `Cannot install OpenCode commands from ephemeral package path ${FORGE_HOME}: ` +
3819
+ `${persist.reason || "failed to persist ForgeDock under ~/.forge"}.`,
3820
+ );
3821
+ }
3822
+ const result = await installOpenCodeAdapter({
3823
+ forgeHome: persist.forgeHome || FORGE_HOME,
3824
+ home: HOME,
3825
+ includeExtras: restArgs.includes("--extras"),
3826
+ });
3827
+ process.stdout.write(
3828
+ `Installed ${result.commandCount} OpenCode commands under ${result.configDir}.\n` +
3829
+ "Restart OpenCode, then run /forge/work-on <issue>.\n",
3830
+ );
3831
+ if (result.migration.removedInstructionsFile || result.migration.removedConfigEntries > 0) {
3832
+ process.stdout.write(
3833
+ `Removed legacy OpenCode adapter artifacts (${result.migration.removedConfigEntries} config entries).\n`,
3834
+ );
3835
+ }
3836
+ for (const warning of result.migration.warnings) process.stderr.write(`Warning: ${warning}\n`);
3837
+ } else if (action === "status") {
3838
+ const result = await getOpenCodeAdapterStatus({ home: HOME });
3839
+ if (!result.installed) {
3840
+ process.stdout.write(`OpenCode adapter is not installed under ${result.configDir}.\n`);
3841
+ exitCode = 1;
3842
+ } else if (!result.healthy) {
3843
+ process.stdout.write(
3844
+ `OpenCode adapter is incomplete (${result.missing.length} missing file(s)). ` +
3845
+ "Re-run: npx forgedock opencode install\n",
3846
+ );
3847
+ exitCode = 1;
3848
+ } else {
3849
+ process.stdout.write(
3850
+ `OpenCode adapter is healthy (${result.manifest.commandCount} commands) under ${result.configDir}.\n`,
3851
+ );
3852
+ }
3853
+ } else if (action === "uninstall") {
3854
+ const result = await uninstallOpenCodeAdapter({ home: HOME });
3855
+ process.stdout.write(`Removed ${result.removed} ForgeDock-owned OpenCode file(s).\n`);
3856
+ } else {
3857
+ process.stderr.write(
3858
+ `Unknown OpenCode action: ${action}. Use install, status, or uninstall.\n`,
3859
+ );
3860
+ exitCode = 1;
3861
+ }
3862
+ break;
3863
+ }
3815
3864
  case "run":
3816
3865
  await run();
3817
3866
  break;
3867
+ case "backend-check": {
3868
+ const { checkExecutionBackend } = await import("./runner.mjs");
3869
+ const result = checkExecutionBackend();
3870
+ if (!restArgs.includes("--quiet")) {
3871
+ process.stdout.write(`${result.ready ? "ready" : "unavailable"}: ${result.backend} (${result.reason})\n`);
3872
+ }
3873
+ if (!result.ready) exitCode = 1;
3874
+ break;
3875
+ }
3818
3876
  case "run-issue": {
3819
3877
  const { runFromCli } = await import("./engine-cli.mjs");
3820
3878
  try {
@@ -3959,6 +4017,18 @@ switch (command) {
3959
4017
  `Run ${CYAN}npx forgedock update${RESET} to fetch it.`,
3960
4018
  );
3961
4019
  }
4020
+
4021
+ // forge#2719 — surface package-vs-installed-commands drift (previously
4022
+ // only visible via `doctor` Check 5d). Uses the same shared helper doctor
4023
+ // calls, so both surfaces agree. Silent when versions match or when
4024
+ // either version is unknown (e.g. a git-clone dev install, which never
4025
+ // persists to ~/.forge — see registry.mjs's getInstalledCommandsDriftStatus).
4026
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
4027
+ if (!driftStatus.unknown && driftStatus.isStale) {
4028
+ console.log(
4029
+ ` ${YELLOW}Installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion}${RESET} — run: ${CYAN}npx forgedock update${RESET}`,
4030
+ );
4031
+ }
3962
4032
  break;
3963
4033
  }
3964
4034
  case "help":
@@ -51,6 +51,8 @@ const FORGE_HOME = resolve(__dirname, "..", "..");
51
51
  let resolveState;
52
52
  let nudgeSeen;
53
53
  let markNudgeSeen;
54
+ // Assigned alongside the other registry.mjs helpers below — see forge#2719.
55
+ let getInstalledCommandsDriftStatus;
54
56
 
55
57
  // ---------------------------------------------------------------------------
56
58
  // forge-utils helpers — populated inside the try block to honour fail-open
@@ -88,7 +90,7 @@ try {
88
90
  // that import() rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME.
89
91
  /** @type {import('../registry.mjs')} */
90
92
  (
91
- { resolveState, nudgeSeen, markNudgeSeen } = await import(
93
+ { resolveState, nudgeSeen, markNudgeSeen, getInstalledCommandsDriftStatus } = await import(
92
94
  pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
93
95
  )
94
96
  );
@@ -126,6 +128,25 @@ try {
126
128
  const cwd = process.cwd();
127
129
  const state = resolveState(cwd);
128
130
 
131
+ // Surface package-vs-installed-commands drift as a single non-fatal
132
+ // advisory line (forge#2719). Uses the same shared helper doctor's
133
+ // Check 5d and the `version` command call, so all three surfaces agree.
134
+ // Best-effort: any failure here must never block the session, and
135
+ // managed-optedout directories stay completely silent per this hook's
136
+ // existing contract for that state (see the switch below).
137
+ if (state !== "managed-optedout") {
138
+ try {
139
+ const driftStatus = getInstalledCommandsDriftStatus(FORGE_HOME);
140
+ if (!driftStatus.unknown && driftStatus.isStale) {
141
+ console.log(
142
+ `ForgeDock: installed commands are stale (v${driftStatus.persistedVersion}) vs package v${driftStatus.sourceVersion} — run: npx forgedock update`,
143
+ );
144
+ }
145
+ } catch {
146
+ // Fail open — never let a drift-check error affect session start.
147
+ }
148
+ }
149
+
129
150
  switch (state) {
130
151
  case "managed-active":
131
152
  await handleManagedActive(cwd);