forgedock 1.2.0 → 1.3.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 (53) hide show
  1. package/README.md +8 -2
  2. package/bin/engine/phases.mjs +178 -28
  3. package/bin/engine/state.mjs +39 -7
  4. package/bin/engine-cli.mjs +63 -2
  5. package/bin/engine.mjs +25 -4
  6. package/bin/forgedock.mjs +235 -4
  7. package/bin/hooks/pre-tool-use.mjs +26 -12
  8. package/bin/journey.mjs +103 -11
  9. package/commands/forge-stats.md +5 -4
  10. package/commands/issue.md +5 -1
  11. package/commands/orchestrate/phase-4-execution.md +2 -0
  12. package/commands/qa-sweep.md +17 -0
  13. package/commands/replay.md +16 -2
  14. package/commands/review-pr-agents/spec-cli.md +179 -0
  15. package/commands/review-pr.md +49 -0
  16. package/commands/upgrade-deps.md +518 -0
  17. package/commands/work-on/build/validate.md +61 -1
  18. package/commands/work-on/build.md +9 -1
  19. package/commands/work-on/decompose.md +8 -1
  20. package/docs/AIDER.md +264 -0
  21. package/docs/AUTOPILOT-CRON.md +130 -0
  22. package/docs/CONFIG.md +36 -0
  23. package/docs/CURSOR.md +135 -0
  24. package/docs/OPENCODE.md +312 -0
  25. package/docs/demo.gif +0 -0
  26. package/docs/eval/README.md +4 -4
  27. package/docs/launch/show-hn.md +3 -3
  28. package/docs/site/architecture.md +180 -0
  29. package/docs/site/command-reference.md +17 -0
  30. package/docs/site/configuration.md +243 -0
  31. package/docs/site/for-companies.md +4 -4
  32. package/docs/site/forge-annotation-protocol.md +1 -1
  33. package/docs/site/github-is-the-memory.md +2 -2
  34. package/docs/site/how-it-works.md +1 -1
  35. package/docs/site/public/llms-full.txt +380 -0
  36. package/docs/site/public/llms.txt +35 -0
  37. package/docs/spec-graph-schema.md +3 -3
  38. package/package.json +2 -2
  39. package/packages/protocol/README.md +75 -0
  40. package/packages/protocol/src/card.js +114 -0
  41. package/packages/protocol/src/cli.js +4 -77
  42. package/packages/protocol/src/emit.js +14 -3
  43. package/packages/protocol/src/index.js +2 -1
  44. package/packages/protocol/src/parse.js +17 -8
  45. package/packages/protocol/src/types.js +1 -1
  46. package/packages/protocol/src/validate.js +11 -0
  47. package/scripts/check-command-side-effects.sh +261 -0
  48. package/scripts/check-spec-markers.sh +273 -0
  49. package/scripts/classify-lane.sh +29 -7
  50. package/scripts/forge-run.sh +322 -0
  51. package/templates/cross-repo-trigger.yml +282 -0
  52. package/templates/cursor/forge.mdc +148 -0
  53. package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/forgedock.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
5
  import { fileURLToPath } from "url";
6
- import { dirname, join, relative, resolve } from "path";
6
+ import { dirname, join, relative, resolve, sep } from "path";
7
7
  import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
8
8
  import {
9
9
  existsSync,
@@ -13,7 +13,7 @@ import {
13
13
  renameSync,
14
14
  unlinkSync,
15
15
  } from "fs";
16
- import { execSync, execFileSync } from "child_process";
16
+ import { execSync, execFileSync, spawnSync } from "child_process";
17
17
  import { homedir } from "os";
18
18
  import https from "https";
19
19
  import {
@@ -1475,6 +1475,216 @@ async function printVersionAvailableChangelog(localVersion, latestVersion) {
1475
1475
  }
1476
1476
  }
1477
1477
 
1478
+ /**
1479
+ * Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
1480
+ * this process is running as a globally-installed `forgedock` package (not
1481
+ * an ephemeral npx/dlx cache extraction). Used by update()'s npm-mode branch
1482
+ * (forge#2133) to decide whether a newer published version can be installed
1483
+ * in place with `npm install -g forgedock@latest`, versus the npx-cache case
1484
+ * where there is nothing durable to reinstall over.
1485
+ *
1486
+ * Resolution: `npm root -g` reports the global node_modules directory; if
1487
+ * FORGE_HOME sits under `{globalRoot}/forgedock`, this is a global install.
1488
+ * Fails closed (returns false) on any error — the pre-existing advisory-only
1489
+ * behavior is always a safe fallback when detection is inconclusive.
1490
+ *
1491
+ * @returns {boolean}
1492
+ */
1493
+ function isGlobalNpmInstall() {
1494
+ try {
1495
+ const globalRoot = execSync("npm root -g", {
1496
+ encoding: "utf-8",
1497
+ stdio: ["pipe", "pipe", "pipe"],
1498
+ timeout: 5000,
1499
+ }).trim();
1500
+ if (!globalRoot) return false;
1501
+ const globalPkgDir = resolve(join(globalRoot, "forgedock"));
1502
+ const home = resolve(FORGE_HOME);
1503
+ return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
1504
+ } catch {
1505
+ return false;
1506
+ }
1507
+ }
1508
+
1509
+ /**
1510
+ * Maximum number of additional self-update re-exec attempts allowed after the
1511
+ * first (i.e. up to MAX_SELF_UPDATE_ATTEMPTS + 1 total `npm install -g`
1512
+ * attempts across the parent process and its re-exec'd children). Guards
1513
+ * against unbounded recursion when `npm install -g` reports success but the
1514
+ * resolved version never actually advances (stale registry mirror/proxy,
1515
+ * cache issue, mismatched FORGE_HOME resolution) — see forge#2158.
1516
+ */
1517
+ const MAX_SELF_UPDATE_ATTEMPTS = 1;
1518
+
1519
+ /**
1520
+ * Strict semver-shape check for a version string before it is interpolated
1521
+ * into a shell-invoked command (see forge#2180). Intentionally narrow —
1522
+ * digits-dot-digits-dot-digits with an optional `-prerelease`/`+build`
1523
+ * suffix restricted to `[0-9A-Za-z.-]` — so it accepts every real value
1524
+ * `fetchLatestVersion()` can return from the npm registry's `version` field,
1525
+ * while rejecting anything containing shell metacharacters
1526
+ * (`; | & $ \` ( ) < > " ' \n` etc.) that `shell: true` would otherwise
1527
+ * hand straight to cmd.exe/`/bin/sh`.
1528
+ *
1529
+ * The regex above is fully anchored (`^...$`) but its quantifiers (`\d+`,
1530
+ * `[0-9A-Za-z.-]+`) are unbounded, so shape validity alone does not bound
1531
+ * string *length* — a pathologically long value could still match. This
1532
+ * is defense-in-depth only (see forge#2195): the character-class
1533
+ * restriction already rules out shell metacharacters at any length, and
1534
+ * `fetchLatestVersion()`'s 65536-byte HTTP response cap already bounds
1535
+ * `version` in practice. The explicit length cap below rejects oversized
1536
+ * input before the regex ever runs, closing the gap outright rather than
1537
+ * relying on an upstream cap this function has no visibility into.
1538
+ *
1539
+ * @param {string} version
1540
+ * @returns {boolean}
1541
+ */
1542
+ function isValidSemverShape(version) {
1543
+ const MAX_VERSION_LENGTH = 100;
1544
+ return (
1545
+ typeof version === "string" &&
1546
+ version.length <= MAX_VERSION_LENGTH &&
1547
+ /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
1548
+ );
1549
+ }
1550
+
1551
+ /**
1552
+ * Install the newer published version globally via `npm install -g
1553
+ * forgedock@{version}`, then re-exec this same file path (which npm has just
1554
+ * overwritten with the new package's contents) so the rest of update()'s
1555
+ * persist/relink phase runs against the *new* payload instead of the stale
1556
+ * in-memory one (forge#2133 — previously the advisory-only branch would
1557
+ * persist/relink from whatever was already resolved, silently downgrading
1558
+ * a newer persisted ~/.forge/ or leaving the global install stuck on the
1559
+ * old version indefinitely).
1560
+ *
1561
+ * Depth guard (forge#2158): the re-exec'd child process could, in principle,
1562
+ * observe the same "newer version available" condition again (e.g. a stale
1563
+ * registry mirror/proxy reports success without the resolved version ever
1564
+ * advancing) and recurse indefinitely, each cycle blocking up to ~125s. The
1565
+ * attempt count is propagated across the re-exec via the
1566
+ * `FORGEDOCK_SELF_UPDATE_ATTEMPT` environment variable (parent process env is
1567
+ * never mutated — the counter is only passed to the spawned child's own
1568
+ * environment). Once `MAX_SELF_UPDATE_ATTEMPTS` is reached, this function
1569
+ * fails loudly with an actionable message and returns `false` instead of
1570
+ * attempting another install/re-exec cycle.
1571
+ *
1572
+ * Fail-open: any error during install or re-exec falls back to the
1573
+ * pre-existing advisory message — the caller must treat a `false` return as
1574
+ * "could not self-update, printed manual instructions instead."
1575
+ *
1576
+ * Windows note (forge#2180): `npm` is a `.cmd` batch launcher on Windows, and
1577
+ * Node's `execFileSync`/`spawnSync` cannot invoke `.cmd`/`.bat` files without
1578
+ * `shell: true` (nodejs/node#3675) — confirmed empirically on a real Windows
1579
+ * 11 machine: `execFileSync("npm", [...])` ENOENTs, and even the exact
1580
+ * `npm.cmd` filename EINVALs, without a shell. `shell: true` is required
1581
+ * here so the install actually runs on Windows. Because that widens the
1582
+ * command line to shell interpretation, `version` — sourced from an
1583
+ * unauthenticated `https://registry.npmjs.org` response in
1584
+ * `fetchLatestVersion()` — is validated against a strict semver shape
1585
+ * (`isValidSemverShape`) before it is ever interpolated into the command,
1586
+ * closing the injection surface `shell: true` would otherwise open (see the
1587
+ * repo-wide execSync→execFileSync shell-injection hardening precedent in
1588
+ * forge#1703/forge#413).
1589
+ *
1590
+ * @param {string} version - target version to install (e.g. "1.2.0")
1591
+ * @returns {boolean} true if re-exec was launched (process will exit before
1592
+ * returning in the success path); false if self-update failed, was capped
1593
+ * by the depth guard, and the caller should fall back to advisory-only
1594
+ * behavior.
1595
+ */
1596
+ function selfUpdateGlobalInstall(version) {
1597
+ // Math.max(0, ...) clamps out negative values: `Number.parseInt(x, 10) || 0`
1598
+ // only substitutes 0 for NaN/0/"" — a negative numeric string (e.g. a
1599
+ // corrupted or manually-set FORGEDOCK_SELF_UPDATE_ATTEMPT="-1") is truthy
1600
+ // and passes through unclamped, which would let a negative attempt count
1601
+ // stay perpetually below MAX_SELF_UPDATE_ATTEMPTS. Defense-in-depth only —
1602
+ // no known path sets this env var to a negative value today. (forge#2168)
1603
+ const attempt = Math.max(
1604
+ 0,
1605
+ Number.parseInt(process.env.FORGEDOCK_SELF_UPDATE_ATTEMPT, 10) || 0,
1606
+ );
1607
+
1608
+ // `attempt` counts completed installs so far (0 = no installs yet). The
1609
+ // guard must allow MAX_SELF_UPDATE_ATTEMPTS + 1 total installs (see the
1610
+ // doc comment above this function) — i.e. it should only trip once
1611
+ // `attempt` has already reached that total, not one call early. Using
1612
+ // `>=` here tripped after only 1 install instead of the documented 2
1613
+ // (forge#2203 — the retry this guard exists to allow never actually ran).
1614
+ if (attempt > MAX_SELF_UPDATE_ATTEMPTS) {
1615
+ console.log(
1616
+ ` ${YELLOW}Self-update did not converge after ${attempt} attempt(s) — the installed version may not be advancing (stale registry mirror/cache?).${RESET}`,
1617
+ );
1618
+ console.log(
1619
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1620
+ );
1621
+ return false;
1622
+ }
1623
+
1624
+ // Validate before this value ever reaches the shell-invoked install below
1625
+ // (forge#2180) — fail open to the same advisory message a failed install
1626
+ // would print, rather than throwing or passing an unvalidated string to
1627
+ // `shell: true`.
1628
+ if (!isValidSemverShape(version)) {
1629
+ console.log(
1630
+ ` ${YELLOW}Self-update aborted: received an unexpected version string ("${version}").${RESET}`,
1631
+ );
1632
+ console.log(
1633
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1634
+ );
1635
+ return false;
1636
+ }
1637
+
1638
+ try {
1639
+ console.log(` Installing ${CYAN}forgedock@${version}${RESET} globally...`);
1640
+ // shell: true is required to resolve npm.cmd on Windows (see the
1641
+ // Windows note in this function's doc comment, forge#2180). `version`
1642
+ // is validated as strict semver above before this point.
1643
+ execFileSync("npm", ["install", "-g", `forgedock@${version}`], {
1644
+ stdio: "inherit",
1645
+ timeout: 120000,
1646
+ shell: true,
1647
+ });
1648
+ } catch (err) {
1649
+ console.log(
1650
+ ` ${YELLOW}Global self-update failed: ${err && err.message ? err.message : String(err)}${RESET}`,
1651
+ );
1652
+ console.log(
1653
+ ` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
1654
+ );
1655
+ return false;
1656
+ }
1657
+
1658
+ console.log(
1659
+ ` ${GREEN}Installed v${version}.${RESET} Re-running update to finish persist/relink...`,
1660
+ );
1661
+ try {
1662
+ const result = spawnSync(process.execPath, [__filename, "update"], {
1663
+ stdio: "inherit",
1664
+ env: {
1665
+ ...process.env,
1666
+ FORGEDOCK_SELF_UPDATE_ATTEMPT: String(attempt + 1),
1667
+ },
1668
+ });
1669
+ // `result.signal` is set (non-null) when the re-exec'd child was
1670
+ // terminated by a signal (e.g. SIGTERM/SIGKILL) rather than exiting
1671
+ // cleanly — in that case `result.status` is `null`, and
1672
+ // `result.status ?? 0` would silently collapse a killed child into a
1673
+ // reported "success" (exit 0). Exit non-zero explicitly for the
1674
+ // signal-killed case; the non-signaled path is unchanged. (forge#2159)
1675
+ process.exit(result.signal ? 1 : (result.status ?? 0));
1676
+ // Unreachable — process.exit() above terminates the process. Present
1677
+ // only to satisfy linters expecting a return on every path.
1678
+ return true;
1679
+ } catch (err) {
1680
+ console.log(
1681
+ ` ${YELLOW}Could not re-run update after install: ${err && err.message ? err.message : String(err)}${RESET}`,
1682
+ );
1683
+ console.log(` Run ${CYAN}npx forgedock update${RESET} again manually.`);
1684
+ return false;
1685
+ }
1686
+ }
1687
+
1478
1688
  async function update() {
1479
1689
  console.log("");
1480
1690
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
@@ -1569,8 +1779,25 @@ async function update() {
1569
1779
  console.log(
1570
1780
  ` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}).`,
1571
1781
  );
1572
- console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
1573
1782
  await printVersionAvailableChangelog(localVersion, latestVersion);
1783
+
1784
+ // forge#2133 — a global npm install can actually be upgraded in place;
1785
+ // do that instead of only printing an advisory (which previously left
1786
+ // global installs stuck on whatever version was first installed,
1787
+ // since every subsequent `npx forgedock update` re-resolves the same
1788
+ // stale global binary). selfUpdateGlobalInstall() re-execs and exits
1789
+ // the process on success, so anything after this block only runs when
1790
+ // self-update wasn't attempted or failed.
1791
+ if (isGlobalNpmInstall()) {
1792
+ selfUpdateGlobalInstall(latestVersion);
1793
+ // selfUpdateGlobalInstall() only returns (rather than exiting the
1794
+ // process) when the install or re-exec failed — it already printed
1795
+ // manual-recovery instructions, so fall through to the persist
1796
+ // refresh below using the still-stale local payload (fail-open,
1797
+ // matches pre-existing behavior for this failure case).
1798
+ } else {
1799
+ console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
1800
+ }
1574
1801
  } else {
1575
1802
  console.log(` Already up to date (v${localVersion}).`);
1576
1803
  }
@@ -3238,7 +3465,11 @@ switch (command) {
3238
3465
  case "run-issue": {
3239
3466
  const { runFromCli } = await import("./engine-cli.mjs");
3240
3467
  try {
3241
- await runFromCli(restArgs);
3468
+ const result = await runFromCli(restArgs);
3469
+ // forge#2175: make a needs-human escalation distinguishable from a
3470
+ // successful run by exit code, mirroring the resume-stalled case's
3471
+ // existing convention below (result.failed.length > 0 -> exitCode 1).
3472
+ if (result && result.terminalReason === "needs-human") exitCode = 1;
3242
3473
  } catch (err) {
3243
3474
  process.stderr.write(`${RED}${err.message}${RESET}\n`);
3244
3475
  exitCode = 1;
@@ -180,13 +180,23 @@ const WORKFLOW_LABEL_PREFIX = "workflow:";
180
180
  // ---------------------------------------------------------------------------
181
181
 
182
182
  /**
183
- * Match a `find` search-root token that is exactly the filesystem root (`/`)
184
- * or a bare Git-Bash drive mount (`/c`, `/d`, `/C`, ... — a single letter,
185
- * case-insensitive, optionally followed by nothing else). Deliberately does
186
- * NOT match `/c/Users/...` or any other scoped absolute path — only the bare
187
- * root/drive-mount token itself.
183
+ * Match a `find` search-root token that reaches the exact same whole-drive,
184
+ * non-terminating scan on Git Bash as the bare filesystem root (`/`) or a
185
+ * bare drive mount (`/c`, `/d`, ...). This includes trailing-slash drive
186
+ * mounts (`/c/`) — a natural way to write the same root, since `/c` and
187
+ * `/c/` are the identical directory — and the dot/double-slash root
188
+ * variants `/.`, `/..`, `//`, which likewise resolve to (or immediately
189
+ * above, which Git Bash still normalizes to) the same whole-drive tree
190
+ * (issue #2113 — PR #2112 review finding SEC-1: `find /c/` was silently
191
+ * ALLOWED because the prior regex had no trailing-slash/dot alternative).
192
+ *
193
+ * Deliberately does NOT match `/c/Users/...`, `/./foo`, `/../foo`, or any
194
+ * other scoped absolute path — only the bare root/drive-mount token itself,
195
+ * optionally followed by exactly one trailing slash (drive-letter case) or
196
+ * standing alone (dot/double-slash case). Case-insensitive on the drive
197
+ * letter only (POSIX paths are otherwise case-sensitive).
188
198
  */
189
- const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z])?$/;
199
+ const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}|\/)?$/;
190
200
 
191
201
  /**
192
202
  * Shell metacharacters that can glue a command name to an adjacent token
@@ -682,12 +692,16 @@ function extractLogicalTokens(command) {
682
692
 
683
693
  /**
684
694
  * Check whether a Bash command contains a `find` invocation whose search
685
- * root is the filesystem root (`/`) or a bare Git-Bash drive mount (`/c`,
686
- * `/d`, ...). On Windows Git Bash, `/` spans every mounted drive, so a
687
- * root-anchored `find` never terminates and accumulates as an orphaned,
688
- * CPU-exhausting process (issue #2034 — #1984 was a narrow precursor fix
689
- * that only patched a single call site + added a prose guardrail; this is
690
- * the systemic, deterministic follow-up).
695
+ * root is the filesystem root (`/`), a bare Git-Bash drive mount (`/c`,
696
+ * `/d`, ...), a trailing-slash drive mount (`/c/`), or a dot/double-slash
697
+ * root variant (`/.`, `/..`, `//`). On Windows Git Bash, `/` spans every
698
+ * mounted drive, so a root-anchored `find` never terminates and accumulates
699
+ * as an orphaned, CPU-exhausting process (issue #2034 — #1984 was a narrow
700
+ * precursor fix that only patched a single call site + added a prose
701
+ * guardrail; this is the systemic, deterministic follow-up. Issue #2113
702
+ * widened the root-token match to also cover the trailing-slash/dot
703
+ * variants, which reach the identical whole-drive scan but were not
704
+ * originally matched by `FIND_ROOT_TOKEN_RE`).
691
705
  *
692
706
  * Scans ALL logical tokens for a `find` occurrence (not just the first word
693
707
  * of the command), so it catches `find` appearing after `&&`, `;`, `|`, a
package/bin/journey.mjs CHANGED
@@ -747,7 +747,8 @@ export async function preflight(ctx) {
747
747
  // Act II — Forging: command symlinks + SessionStart hook (Task 6)
748
748
  // ---------------------------------------------------------------------------
749
749
 
750
- import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink } from "fs/promises";
750
+ import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm } from "fs/promises";
751
+ import { compareVersions } from "./registry.mjs";
751
752
  import { relative, dirname as pathDirname } from "path";
752
753
  import {
753
754
  installSessionStartHook,
@@ -1073,6 +1074,54 @@ async function copyDirIfChanged(srcDir, destDir) {
1073
1074
  return { copied, unchanged };
1074
1075
  }
1075
1076
 
1077
+ /**
1078
+ * Recursively delete entries in `destDir` that no longer exist in `srcDir`.
1079
+ * The mirror-image of copyDirIfChanged(): that function is additive-only (it
1080
+ * copies new/changed files but never removes anything), so a file dropped or
1081
+ * renamed upstream would otherwise linger in the persisted `~/.forge/` copy
1082
+ * forever (forge#2133). A missing `srcDir` or `destDir` is treated as
1083
+ * "nothing to reconcile" rather than an error — mirrors copyDirIfChanged()'s
1084
+ * own ENOENT handling.
1085
+ *
1086
+ * @param {string} srcDir
1087
+ * @param {string} destDir
1088
+ * @returns {Promise<{ removed: number }>}
1089
+ */
1090
+ async function removeOrphans(srcDir, destDir) {
1091
+ let destEntries;
1092
+ try {
1093
+ destEntries = await readdir(destDir, { withFileTypes: true });
1094
+ } catch (err) {
1095
+ if (err.code === "ENOENT") return { removed: 0 };
1096
+ throw err;
1097
+ }
1098
+
1099
+ let srcNames = null;
1100
+ try {
1101
+ const srcEntries = await readdir(srcDir, { withFileTypes: true });
1102
+ srcNames = new Set(srcEntries.map((e) => e.name));
1103
+ } catch (err) {
1104
+ if (err.code !== "ENOENT") throw err;
1105
+ // srcDir missing entirely — every dest entry is an orphan.
1106
+ srcNames = new Set();
1107
+ }
1108
+
1109
+ let removed = 0;
1110
+ for (const entry of destEntries) {
1111
+ const destPath = join(destDir, entry.name);
1112
+ if (!srcNames.has(entry.name)) {
1113
+ await rm(destPath, { recursive: true, force: true });
1114
+ removed++;
1115
+ continue;
1116
+ }
1117
+ if (entry.isDirectory()) {
1118
+ const sub = await removeOrphans(join(srcDir, entry.name), destPath);
1119
+ removed += sub.removed;
1120
+ }
1121
+ }
1122
+ return { removed };
1123
+ }
1124
+
1076
1125
  /**
1077
1126
  * Copy ForgeDock's own installable payload (bin/, commands/, scripts/,
1078
1127
  * templates/) from wherever the package currently resolved — npm global
@@ -1115,6 +1164,7 @@ async function copyDirIfChanged(srcDir, destDir) {
1115
1164
  * version: string,
1116
1165
  * filesCopied?: number,
1117
1166
  * filesUnchanged?: number,
1167
+ * filesRemoved?: number,
1118
1168
  * }>}
1119
1169
  */
1120
1170
  export async function persistHome(ctx) {
@@ -1142,15 +1192,55 @@ export async function persistHome(ctx) {
1142
1192
  // proceed with version === ""
1143
1193
  }
1144
1194
 
1195
+ // Downgrade guard (forge#2133): if a newer version is already persisted at
1196
+ // ~/.forge/version than the source package we're about to copy from, skip
1197
+ // the copy entirely rather than silently overwriting newer files with
1198
+ // older ones. This happens when a stale resolved package (e.g. a plain
1199
+ // `npx forgedock update` that resolved an old global install) runs after
1200
+ // ~/.forge/ was already refreshed to a newer version by some other path
1201
+ // (e.g. `npx forgedock@latest`). Both `version` and the persisted value
1202
+ // must be non-empty for the guard to apply — an unreadable/missing
1203
+ // ~/.forge/version is treated as "no guard needed" so first-run persist
1204
+ // is never blocked.
1205
+ const persistedVersionPath = join(persistedHome, "version");
1206
+ let existingPersistedVersion = "";
1207
+ let persistedVersionFileExists = true;
1208
+ try {
1209
+ existingPersistedVersion = readFileSync(persistedVersionPath, "utf-8").trim();
1210
+ } catch {
1211
+ // missing/unreadable — proceed, nothing to guard against. Tracked
1212
+ // separately from existingPersistedVersion's "" default so the
1213
+ // versionChanged check below (a fresh persist with no prior version
1214
+ // file) is still correctly treated as "changed" even when the source
1215
+ // package's own version also resolves to "" (e.g. missing package.json).
1216
+ persistedVersionFileExists = false;
1217
+ }
1218
+ if (
1219
+ version &&
1220
+ existingPersistedVersion &&
1221
+ compareVersions(version, existingPersistedVersion) < 0
1222
+ ) {
1223
+ return {
1224
+ forgeHome: persistedHome,
1225
+ migrated: false,
1226
+ skipped: true,
1227
+ reason: `refusing to downgrade ~/.forge/ from v${existingPersistedVersion} to v${version} — source package is older than what's already persisted`,
1228
+ version: existingPersistedVersion,
1229
+ };
1230
+ }
1231
+
1145
1232
  try {
1146
1233
  await mkdir(persistedHome, { recursive: true });
1147
1234
 
1148
1235
  let filesCopied = 0;
1149
1236
  let filesUnchanged = 0;
1237
+ let filesRemoved = 0;
1150
1238
  for (const name of PERSIST_HOME_DIRS) {
1151
1239
  const res = await copyDirIfChanged(join(source, name), join(persistedHome, name));
1152
1240
  filesCopied += res.copied;
1153
1241
  filesUnchanged += res.unchanged;
1242
+ const pruned = await removeOrphans(join(source, name), join(persistedHome, name));
1243
+ filesRemoved += pruned.removed;
1154
1244
  }
1155
1245
 
1156
1246
  // Also persist package.json itself (not just PERSIST_HOME_DIRS) — several
@@ -1181,18 +1271,19 @@ export async function persistHome(ctx) {
1181
1271
 
1182
1272
  // Write ~/.forge/version — content-compared like everything else here so
1183
1273
  // an unchanged version doesn't touch the file's mtime on every re-run.
1184
- const versionPath = join(persistedHome, "version");
1185
- let versionChanged = true;
1186
- try {
1187
- versionChanged = readFileSync(versionPath, "utf-8").trim() !== version;
1188
- } catch {
1189
- // missing/unreadable — treat as changed
1190
- }
1274
+ // Reuses persistedVersionPath/existingPersistedVersion read above for the
1275
+ // downgrade guard rather than re-reading the same file a second time. A
1276
+ // version file that didn't exist yet (persistedVersionFileExists: false)
1277
+ // always counts as "changed", regardless of whether the source's own
1278
+ // version happens to also be "" (e.g. missing package.json) — otherwise
1279
+ // "" !== "" would wrongly read as unchanged on a fresh, never-persisted
1280
+ // ~/.forge/.
1281
+ let versionChanged = !persistedVersionFileExists || existingPersistedVersion !== version;
1191
1282
  if (versionChanged) {
1192
- const tmpVersionPath = versionPath + ".tmp";
1283
+ const tmpVersionPath = persistedVersionPath + ".tmp";
1193
1284
  try {
1194
1285
  writeFileSync(tmpVersionPath, version + "\n", "utf-8");
1195
- renameSync(tmpVersionPath, versionPath);
1286
+ renameSync(tmpVersionPath, persistedVersionPath);
1196
1287
  } catch (err) {
1197
1288
  try { unlinkSync(tmpVersionPath); } catch { /* best-effort cleanup */ }
1198
1289
  throw err;
@@ -1201,11 +1292,12 @@ export async function persistHome(ctx) {
1201
1292
 
1202
1293
  return {
1203
1294
  forgeHome: persistedHome,
1204
- migrated: filesCopied > 0 || versionChanged,
1295
+ migrated: filesCopied > 0 || filesRemoved > 0 || versionChanged,
1205
1296
  skipped: false,
1206
1297
  version,
1207
1298
  filesCopied,
1208
1299
  filesUnchanged,
1300
+ filesRemoved,
1209
1301
  };
1210
1302
  } catch (err) {
1211
1303
  // Fail-open (forge#383): a permission error, disk-full, etc. must never
@@ -34,8 +34,8 @@ Commands are classified by role. Each role has a max line count — beyond this,
34
34
  |------|-----------|-----------|
35
35
  | Orchestrator (work-on, orchestrate, review-pr) | 800 | Delegates to sub-agents; needs full pipeline logic |
36
36
  | Agent catalog (review-pr-agents, review-pr-staging) | 1000 | Reference file read on demand, not fed whole to one agent |
37
- | Specialist (milestone, analytics, validate, etc.) | 400 | Single-purpose; Sonnet attention degrades past this |
38
- | Utility (cleanup, sync-ecosystem, forge-stats) | 250 | Simple tasks; should stay lean |
37
+ | Specialist (milestone, analytics, sync-ecosystem, validate, etc.) | 400 | Single-purpose or multi-subcommand; Sonnet attention degrades past this |
38
+ | Utility (cleanup, forge-stats) | 250 | Simple tasks; should stay lean |
39
39
  | Sub-file (quality-gate, pipeline-health) | 350 | Invoked by parent; shares context budget |
40
40
 
41
41
  **Current overages (v1.1.0 baseline):**
@@ -46,7 +46,8 @@ Commands are classified by role. Each role has a max line count — beyond this,
46
46
  - `review-pr.md`: 796 lines (threshold 800) — OK
47
47
  - `validate.md`: 97 lines (threshold 400) — OK (compressed from 679)
48
48
  - `orchestrate.md`: 654 lines (threshold 800) — OK
49
- - `analytics.md`: 590 lines (threshold 400) — **48% over**
49
+ - `analytics.md`: 856 lines (threshold 400) — **114% over**
50
+ - `sync-ecosystem.md`: 799 lines (threshold 400) — **99% over**
50
51
 
51
52
  ---
52
53
 
@@ -75,7 +76,7 @@ for cmd in commands/*.md; do
75
76
  case "$name" in
76
77
  work-on.md|orchestrate.md|review-pr.md) threshold=800 ;;
77
78
  review-pr-agents.md|review-pr-staging.md) threshold=1000 ;;
78
- cleanup.md|sync-ecosystem.md|forge-stats.md) threshold=250 ;;
79
+ cleanup.md|forge-stats.md) threshold=250 ;;
79
80
  quality-gate.md|pipeline-health.md) threshold=350 ;;
80
81
  *) threshold=400 ;;
81
82
  esac
package/commands/issue.md CHANGED
@@ -13,7 +13,7 @@ You create GitHub issues with the exact structure the `/work-on` pipeline expect
13
13
 
14
14
  `/issue` is the structured create-hook for issue creation across the pipeline — it enforces the canonical template, reads code before drafting, runs dedup, and validates mandatory sections. Because those checks are deterministic, `/issue` **creates the issue by default once they pass** — it does not wait for a human to approve the draft. Pass `--dry-run` to review the draft without creating anything (see Argument Parsing below).
15
15
 
16
- `/issue` also accepts a **programmatic invocation form** for callers that have already composed a title, body, labels, and (optionally) a milestone — e.g. `Skill(skill="issue", args="--title \"fix: ...\" --body-file /tmp/body.md --label bug --label P2")`. This form skips the free-text parsing (Phase 1) and LLM drafting (Phase 3) entirely, but still runs the same dedup and body-validation correctness gates as the interactive path. See **Programmatic Invocation Contract** below.
16
+ `/issue` also accepts a **programmatic invocation form** for callers that have already composed a title, body, labels, and (optionally) a milestone — e.g. `Skill(skill="issue", args="--title \"fix: ...\" --body-file \"$(mktemp)\" --label bug --label P2")` (the caller writes the body to that path before invoking). This form skips the free-text parsing (Phase 1) and LLM drafting (Phase 3) entirely, but still runs the same dedup and body-validation correctness gates as the interactive path. See **Programmatic Invocation Contract** below — in particular, `--body-file` callers MUST use a `mktemp`-created path, never a fixed literal like `/tmp/body.md`; concurrent orchestrated agents share this host's `/tmp`, and a fixed path can be clobbered mid-flight by another agent (forge#2198).
17
17
 
18
18
  **Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
19
19
  **NEVER use plan mode (EnterPlanMode).**
@@ -181,6 +181,10 @@ For callers that have already composed a title, body, labels, and (optionally) a
181
181
 
182
182
  If `--title` is present but neither `--body` nor `--body-file` is supplied, or both are supplied, this is a usage error: print `ERROR: programmatic mode requires exactly one of --body or --body-file` and STOP — do not fall through to Phase 2D or Phase 4B.
183
183
 
184
+ **`--body-file` path requirement (MANDATORY for the caller)**: The path passed to `--body-file` MUST come from `mktemp` (e.g. `BODY_FILE="$(mktemp)"`), never a fixed literal such as `/tmp/body.md` or `/tmp/issue.json`. Concurrent orchestrated agents (`/orchestrate` dispatches up to `orchestration.max_concurrent` agents, default 12) share this host's `/tmp` — a fixed filename collides across agents, and a second agent's write to the same path can silently corrupt the first agent's staged content before it is read. `mktemp` gives every caller a collision-free path at effectively zero cost. This applies to every `--body-file` caller across the pipeline (`commands/work-on.md`, `commands/work-on/decompose.md`, `commands/review-pr*.md`, `commands/orchestrate/phase-1-resolve.md`, and all others) — all current callers already comply; this is the stated contract that keeps it that way (forge#2198).
185
+
186
+ **Fail-loud read ordering (the defensive mechanism against a clobbered body-file)**: `/issue` reads `$PROGRAMMATIC_BODY_FILE` exactly once, into memory, immediately below — it never re-reads the file later in the pipeline. This is deliberate: combined with a `mktemp`-unique path, it minimizes the window in which another process could overwrite the file between the caller's write and this read to effectively nil (no other agent has any reason to target a `mktemp`-generated path). If the file is missing or unreadable at read time, the check below fails loudly (`exit 1`) rather than silently proceeding with an empty or partial body — a clobbered/missing body-file is surfaced as an error, not pushed to GitHub.
187
+
184
188
  ```bash
185
189
  if [ "$PROGRAMMATIC_MODE" = "true" ]; then
186
190
  if [ -n "$PROGRAMMATIC_BODY" ] && [ -n "$PROGRAMMATIC_BODY_FILE" ]; then
@@ -425,6 +425,8 @@ Agent(
425
425
  - For satellite repo issues: `Skill(skill='work-on', args='{SATELLITE_PREFIX}:{NUMBER} --under-orchestration')` (prefix from forge.yaml → repos.satellites)
426
426
  - The `--under-orchestration` flag tells `/work-on` to post its phase-entry `FORGE:HEARTBEAT` comments (Phases 0/1/3/5) — this orchestrator's Step 4B.5 stall detector depends on those timestamps. A solo `/work-on` run omits the flag and skips those writes entirely (see `commands/work-on.md` → Orchestration Flag).
427
427
  - NEVER bypass /work-on with manual git/gh commands — the label updates and structured comments are critical for tracking
428
+ - **Temp files — ALWAYS use `mktemp`, NEVER a fixed path**: You are one of several agents running concurrently on this host and you share its `/tmp` with all of them. Any time you stage content in a temp file before passing it to `gh` (e.g. `--body-file`), create that path with `mktemp` (e.g. `BODY_FILE="$(mktemp)"`) — never a fixed literal like `/tmp/body.md` or `/tmp/issue.json`. A fixed path collides with another concurrently-running agent and can silently overwrite the content you staged (or you can silently overwrite theirs) before either of you reads it back — this has caused real cross-agent GitHub issue-body corruption. `mktemp` costs nothing and eliminates the collision. (forge#2198)
429
+ - **`docker cp` — NEVER write into a bind-mounted shared container**: If the project you're working on runs its dev/test containers with a bind mount to the main (non-worktree) checkout, `docker cp` into that container writes through the mount into the main checkout — not your isolated per-issue worktree. Before running `docker cp` into any container, confirm its mount source is your own worktree, not a shared/main one; if you can't confirm that, don't assume an in-container test run reflects your feature branch. (forge#2198)
428
430
  - NEVER target `main` for PRs targeting the default repo. Use `{STAGING_BRANCH}` for fast-lane issues, or `milestone/{slug}` for milestone issues.
429
431
  - Satellite repos (MCP, n8n) have no staging branch — fast-lane PRs go to `main` for those.
430
432
  - If the issue is INVALID after investigation, close it with a comment explaining why
@@ -36,6 +36,23 @@ See README.md Requirements section for full setup instructions.
36
36
 
37
37
  ---
38
38
 
39
+ ## Playwright MCP Prerequisite (MANDATORY — check before proceeding)
40
+
41
+ Playwright MCP is a **guaranteed ForgeDock dependency** for all browser automation commands. Before running any qa-sweep phase, verify it is available:
42
+
43
+ **Check**: Confirm `mcp__playwright__*` tools are available in your tool context (e.g., `mcp__playwright__browser_navigate`, `mcp__playwright__browser_snapshot`).
44
+
45
+ **If Playwright MCP tools are NOT available**: STOP immediately. Do not attempt to run qa-sweep — all browser automation steps will fail silently.
46
+
47
+ **Fix**:
48
+ 1. Register Playwright MCP: `claude mcp add playwright npx @playwright/mcp@latest`
49
+ 2. Restart Claude Code to reload MCP servers
50
+ 3. Verify with: `npx forgedock doctor` (Check 9 confirms registration)
51
+
52
+ See README.md Requirements section for full setup instructions.
53
+
54
+ ---
55
+
39
56
  ## Browser Tool Reference
40
57
 
41
58
  All browser automation uses Playwright MCP tools (`mcp__playwright__*`). Playwright MCP is a guaranteed ForgeDock dependency — see the Playwright MCP Prerequisite section above.
@@ -95,7 +95,7 @@ fi
95
95
 
96
96
  ```bash
97
97
  # Filter to comments that contain a FORGE pipeline annotation
98
- FORGE_COMMENTS=$(echo "$ALL_COMMENTS" | jq -c '[.[] | select(.body | test("<!-- FORGE:(INVESTIGATOR|CONTRACT|CONTEXT|ARCHITECT|BUILDER|DECOMPOSED|TRAJECTORY)"))]')
98
+ FORGE_COMMENTS=$(echo "$ALL_COMMENTS" | jq -c '[.[] | select(.body | test("<!-- FORGE:(INVESTIGATOR|CONTRACT|CONTEXT|ARCHITECT|BUILDER|DECOMPOSED|SUMMARY|TRAJECTORY)"))]')
99
99
  FORGE_COUNT=$(echo "$FORGE_COMMENTS" | jq 'length')
100
100
 
101
101
  if [ "$FORGE_COUNT" -eq 0 ]; then
@@ -115,7 +115,7 @@ echo ""
115
115
 
116
116
  ## Step 5: Display Each Annotation
117
117
 
118
- Phase mapping (inline): `INVESTIGATOR`→Phase 1, `CONTRACT`→Phase 3C, `CONTEXT`→Phase 3C.5, `ARCHITECT`→Phase 3C.6, `BUILDER`→Phase 3M, `DECOMPOSED`→Phase 2, `TRAJECTORY`→Phase 7.
118
+ Phase mapping (inline): `INVESTIGATOR`→Phase 1, `CONTRACT`→Phase 3C, `CONTEXT`→Phase 3C.5, `ARCHITECT`→Phase 3C.6, `BUILDER`→Phase 3M, `DECOMPOSED`→Phase 2, `SUMMARY`→Phase C4.5, `TRAJECTORY`→Phase 7.
119
119
 
120
120
  For each FORGE comment in chronological order, print a formatted block. Use Python for robust multiline extraction from the comment body.
121
121
 
@@ -136,6 +136,7 @@ echo "$FORGE_COMMENTS" | jq -c '.[]' | while IFS= read -r COMMENT; do
136
136
  "FORGE:ARCHITECT") PHASE_LABEL="Phase 3C.6 — Architecture Plan" ;;
137
137
  "FORGE:BUILDER") PHASE_LABEL="Phase 3M — Implementation" ;;
138
138
  "FORGE:DECOMPOSED") PHASE_LABEL="Phase 2 — Decomposition" ;;
139
+ "FORGE:SUMMARY") PHASE_LABEL="Phase C4.5 — Shareable Summary" ;;
139
140
  "FORGE:TRAJECTORY") PHASE_LABEL="Phase 7 — Pipeline Trajectory" ;;
140
141
  *) PHASE_LABEL="$ANNOTATION" ;;
141
142
  esac
@@ -216,6 +217,19 @@ elif annotation == "FORGE:DECOMPOSED":
216
217
  for s in subs[:5]:
217
218
  print(f"│ {s}")
218
219
 
220
+ elif annotation == "FORGE:SUMMARY":
221
+ lane = extract(r'\*\*Lane\*\*\s*\|\s*([^\|]+)')
222
+ verdict = extract(r'\*\*Investigation verdict\*\*\s*\|\s*([^\|]+)')
223
+ task_type = extract(r'\*\*Task type\*\*\s*\|\s*([^\|]+)')
224
+ review = extract(r'\*\*Review verdict\*\*\s*\|\s*([^\|]+)')
225
+ files = extract(r'\*\*Files changed\*\*\s*\|\s*([^\|]+)')
226
+ pr_link = extract(r'\[View PR #(\d+)\]')
227
+ print(f"│ Lane : {lane.strip()[:60]}")
228
+ print(f"│ Verdict : {verdict.strip()[:60]}")
229
+ print(f"│ Task type : {task_type.strip()[:60]}")
230
+ print(f"│ Review : {review.strip()[:60]}")
231
+ print(f"│ Files : {files.strip()[:30]} | PR: #{pr_link}")
232
+
219
233
  elif annotation == "FORGE:TRAJECTORY":
220
234
  # Print the phase table lines verbatim (they're already formatted)
221
235
  in_table = False