forgedock 1.2.0 → 1.4.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 +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- 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 {
|
|
@@ -35,6 +35,17 @@ import {
|
|
|
35
35
|
persistHome,
|
|
36
36
|
PIPELINE_SCRIPTS,
|
|
37
37
|
} from "./journey.mjs";
|
|
38
|
+
import {
|
|
39
|
+
deriveWorkflowLabels,
|
|
40
|
+
FINDINGS_LANE_LABEL,
|
|
41
|
+
classifyFindingStatus,
|
|
42
|
+
hasWorkflowLabel,
|
|
43
|
+
normalizePriority,
|
|
44
|
+
parseSatelliteRepos,
|
|
45
|
+
buildHeartbeatBatchQuery,
|
|
46
|
+
parseHeartbeatBatchResponse,
|
|
47
|
+
chunkArray,
|
|
48
|
+
} from "./watch-utils.mjs";
|
|
38
49
|
import {
|
|
39
50
|
removeSessionStartHook,
|
|
40
51
|
removeSubagentStopHook,
|
|
@@ -1475,17 +1486,289 @@ async function printVersionAvailableChangelog(localVersion, latestVersion) {
|
|
|
1475
1486
|
}
|
|
1476
1487
|
}
|
|
1477
1488
|
|
|
1489
|
+
/**
|
|
1490
|
+
* Resolve the absolute path npm's global install tree would use for a
|
|
1491
|
+
* `forgedock` package, i.e. `{npm root -g}/forgedock`. Shared by
|
|
1492
|
+
* `isGlobalNpmInstall()` and `findSeparateGlobalInstall()` (forge#2273) so
|
|
1493
|
+
* the `npm root -g` shell-out and path-join logic exists in exactly one
|
|
1494
|
+
* place instead of being duplicated across both callers.
|
|
1495
|
+
*
|
|
1496
|
+
* Fails closed (returns null) on any error, timeout, or empty `npm root -g`
|
|
1497
|
+
* output — callers treat null identically to their pre-existing "detection
|
|
1498
|
+
* inconclusive" fallback (isGlobalNpmInstall: false; findSeparateGlobalInstall: null).
|
|
1499
|
+
*
|
|
1500
|
+
* @returns {string|null} absolute path to `{globalRoot}/forgedock`, or null
|
|
1501
|
+
*/
|
|
1502
|
+
function resolveGlobalNpmForgedockDir() {
|
|
1503
|
+
try {
|
|
1504
|
+
const globalRoot = execSync("npm root -g", {
|
|
1505
|
+
encoding: "utf-8",
|
|
1506
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1507
|
+
timeout: 5000,
|
|
1508
|
+
}).trim();
|
|
1509
|
+
if (!globalRoot) return null;
|
|
1510
|
+
return resolve(join(globalRoot, "forgedock"));
|
|
1511
|
+
} catch {
|
|
1512
|
+
return null;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
|
|
1518
|
+
* this process is running as a globally-installed `forgedock` package (not
|
|
1519
|
+
* an ephemeral npx/dlx cache extraction). Used by update()'s npm-mode branch
|
|
1520
|
+
* (forge#2133) to decide whether a newer published version can be installed
|
|
1521
|
+
* in place with `npm install -g forgedock@latest`, versus the npx-cache case
|
|
1522
|
+
* where there is nothing durable to reinstall over.
|
|
1523
|
+
*
|
|
1524
|
+
* Resolution: `resolveGlobalNpmForgedockDir()` (forge#2273) reports where npm's
|
|
1525
|
+
* global install tree would place a `forgedock` package; if FORGE_HOME sits
|
|
1526
|
+
* under that path, this is a global install. Fails closed (returns false) on
|
|
1527
|
+
* any error — the pre-existing advisory-only behavior is always a safe
|
|
1528
|
+
* fallback when detection is inconclusive.
|
|
1529
|
+
*
|
|
1530
|
+
* @returns {boolean}
|
|
1531
|
+
*/
|
|
1532
|
+
function isGlobalNpmInstall() {
|
|
1533
|
+
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1534
|
+
if (!globalPkgDir) return false;
|
|
1535
|
+
const home = resolve(FORGE_HOME);
|
|
1536
|
+
return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Detect a separate global npm install of `forgedock` that is NOT the
|
|
1541
|
+
* `FORGE_HOME` this process resolved to (forge#2220). This is the classic
|
|
1542
|
+
* `npx`-shadowing scenario: running `npx forgedock update` from inside a
|
|
1543
|
+
* ForgeDock git clone resolves `FORGE_HOME` to the clone (because the clone
|
|
1544
|
+
* itself is the `forgedock` package on disk), so the git-clone update path
|
|
1545
|
+
* runs and updates the checkout — while a genuinely separate global npm
|
|
1546
|
+
* install elsewhere on disk is silently left untouched.
|
|
1547
|
+
*
|
|
1548
|
+
* Resolution mirrors `isGlobalNpmInstall()` via the shared
|
|
1549
|
+
* `resolveGlobalNpmForgedockDir()` helper (forge#2273): if a `forgedock`
|
|
1550
|
+
* package exists at the resolved global path AND it is a different path than
|
|
1551
|
+
* the current `FORGE_HOME`, a separate global install exists. Fails closed
|
|
1552
|
+
* (returns null) on any error or timeout — this is an advisory notice only
|
|
1553
|
+
* and must never block or alter the update itself.
|
|
1554
|
+
*
|
|
1555
|
+
* @returns {string|null} absolute path to the separate global install, or null
|
|
1556
|
+
*/
|
|
1557
|
+
function findSeparateGlobalInstall() {
|
|
1558
|
+
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1559
|
+
if (!globalPkgDir) return null;
|
|
1560
|
+
const home = resolve(FORGE_HOME);
|
|
1561
|
+
if (home === globalPkgDir || home.startsWith(globalPkgDir + sep)) {
|
|
1562
|
+
// Current FORGE_HOME already IS the global install — nothing separate.
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
return existsSync(globalPkgDir) ? globalPkgDir : null;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Maximum number of additional self-update re-exec attempts allowed after the
|
|
1570
|
+
* first (i.e. up to MAX_SELF_UPDATE_ATTEMPTS + 1 total `npm install -g`
|
|
1571
|
+
* attempts across the parent process and its re-exec'd children). Guards
|
|
1572
|
+
* against unbounded recursion when `npm install -g` reports success but the
|
|
1573
|
+
* resolved version never actually advances (stale registry mirror/proxy,
|
|
1574
|
+
* cache issue, mismatched FORGE_HOME resolution) — see forge#2158.
|
|
1575
|
+
*/
|
|
1576
|
+
const MAX_SELF_UPDATE_ATTEMPTS = 1;
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Strict semver-shape check for a version string before it is interpolated
|
|
1580
|
+
* into a shell-invoked command (see forge#2180). Intentionally narrow —
|
|
1581
|
+
* digits-dot-digits-dot-digits with an optional `-prerelease`/`+build`
|
|
1582
|
+
* suffix restricted to `[0-9A-Za-z.-]` — so it accepts every real value
|
|
1583
|
+
* `fetchLatestVersion()` can return from the npm registry's `version` field,
|
|
1584
|
+
* while rejecting anything containing shell metacharacters
|
|
1585
|
+
* (`; | & $ \` ( ) < > " ' \n` etc.) that `shell: true` would otherwise
|
|
1586
|
+
* hand straight to cmd.exe/`/bin/sh`.
|
|
1587
|
+
*
|
|
1588
|
+
* The regex above is fully anchored (`^...$`) but its quantifiers (`\d+`,
|
|
1589
|
+
* `[0-9A-Za-z.-]+`) are unbounded, so shape validity alone does not bound
|
|
1590
|
+
* string *length* — a pathologically long value could still match. This
|
|
1591
|
+
* is defense-in-depth only (see forge#2195): the character-class
|
|
1592
|
+
* restriction already rules out shell metacharacters at any length, and
|
|
1593
|
+
* `fetchLatestVersion()`'s 65536-byte HTTP response cap already bounds
|
|
1594
|
+
* `version` in practice. The explicit length cap below rejects oversized
|
|
1595
|
+
* input before the regex ever runs, closing the gap outright rather than
|
|
1596
|
+
* relying on an upstream cap this function has no visibility into.
|
|
1597
|
+
*
|
|
1598
|
+
* @param {string} version
|
|
1599
|
+
* @returns {boolean}
|
|
1600
|
+
*/
|
|
1601
|
+
function isValidSemverShape(version) {
|
|
1602
|
+
const MAX_VERSION_LENGTH = 100;
|
|
1603
|
+
return (
|
|
1604
|
+
typeof version === "string" &&
|
|
1605
|
+
version.length <= MAX_VERSION_LENGTH &&
|
|
1606
|
+
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Install the newer published version globally via `npm install -g
|
|
1612
|
+
* forgedock@{version}`, then re-exec this same file path (which npm has just
|
|
1613
|
+
* overwritten with the new package's contents) so the rest of update()'s
|
|
1614
|
+
* persist/relink phase runs against the *new* payload instead of the stale
|
|
1615
|
+
* in-memory one (forge#2133 — previously the advisory-only branch would
|
|
1616
|
+
* persist/relink from whatever was already resolved, silently downgrading
|
|
1617
|
+
* a newer persisted ~/.forge/ or leaving the global install stuck on the
|
|
1618
|
+
* old version indefinitely).
|
|
1619
|
+
*
|
|
1620
|
+
* Depth guard (forge#2158): the re-exec'd child process could, in principle,
|
|
1621
|
+
* observe the same "newer version available" condition again (e.g. a stale
|
|
1622
|
+
* registry mirror/proxy reports success without the resolved version ever
|
|
1623
|
+
* advancing) and recurse indefinitely, each cycle blocking up to ~125s. The
|
|
1624
|
+
* attempt count is propagated across the re-exec via the
|
|
1625
|
+
* `FORGEDOCK_SELF_UPDATE_ATTEMPT` environment variable (parent process env is
|
|
1626
|
+
* never mutated — the counter is only passed to the spawned child's own
|
|
1627
|
+
* environment). Once `MAX_SELF_UPDATE_ATTEMPTS` is reached, this function
|
|
1628
|
+
* fails loudly with an actionable message and returns `false` instead of
|
|
1629
|
+
* attempting another install/re-exec cycle.
|
|
1630
|
+
*
|
|
1631
|
+
* Fail-open: any error during install or re-exec falls back to the
|
|
1632
|
+
* pre-existing advisory message — the caller must treat a `false` return as
|
|
1633
|
+
* "could not self-update, printed manual instructions instead."
|
|
1634
|
+
*
|
|
1635
|
+
* Windows note (forge#2180): `npm` is a `.cmd` batch launcher on Windows, and
|
|
1636
|
+
* Node's `execFileSync`/`spawnSync` cannot invoke `.cmd`/`.bat` files without
|
|
1637
|
+
* `shell: true` (nodejs/node#3675) — confirmed empirically on a real Windows
|
|
1638
|
+
* 11 machine: `execFileSync("npm", [...])` ENOENTs, and even the exact
|
|
1639
|
+
* `npm.cmd` filename EINVALs, without a shell. `shell: true` is required
|
|
1640
|
+
* here so the install actually runs on Windows. Because that widens the
|
|
1641
|
+
* command line to shell interpretation, `version` — sourced from an
|
|
1642
|
+
* unauthenticated `https://registry.npmjs.org` response in
|
|
1643
|
+
* `fetchLatestVersion()` — is validated against a strict semver shape
|
|
1644
|
+
* (`isValidSemverShape`) before it is ever interpolated into the command,
|
|
1645
|
+
* closing the injection surface `shell: true` would otherwise open (see the
|
|
1646
|
+
* repo-wide execSync→execFileSync shell-injection hardening precedent in
|
|
1647
|
+
* forge#1703/forge#413).
|
|
1648
|
+
*
|
|
1649
|
+
* @param {string} version - target version to install (e.g. "1.2.0")
|
|
1650
|
+
* @returns {boolean} true if re-exec was launched (process will exit before
|
|
1651
|
+
* returning in the success path); false if self-update failed, was capped
|
|
1652
|
+
* by the depth guard, and the caller should fall back to advisory-only
|
|
1653
|
+
* behavior.
|
|
1654
|
+
*/
|
|
1655
|
+
function selfUpdateGlobalInstall(version) {
|
|
1656
|
+
// Math.max(0, ...) clamps out negative values: `Number.parseInt(x, 10) || 0`
|
|
1657
|
+
// only substitutes 0 for NaN/0/"" — a negative numeric string (e.g. a
|
|
1658
|
+
// corrupted or manually-set FORGEDOCK_SELF_UPDATE_ATTEMPT="-1") is truthy
|
|
1659
|
+
// and passes through unclamped, which would let a negative attempt count
|
|
1660
|
+
// stay perpetually below MAX_SELF_UPDATE_ATTEMPTS. Defense-in-depth only —
|
|
1661
|
+
// no known path sets this env var to a negative value today. (forge#2168)
|
|
1662
|
+
const attempt = Math.max(
|
|
1663
|
+
0,
|
|
1664
|
+
Number.parseInt(process.env.FORGEDOCK_SELF_UPDATE_ATTEMPT, 10) || 0,
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
// `attempt` counts completed installs so far (0 = no installs yet). The
|
|
1668
|
+
// guard must allow MAX_SELF_UPDATE_ATTEMPTS + 1 total installs (see the
|
|
1669
|
+
// doc comment above this function) — i.e. it should only trip once
|
|
1670
|
+
// `attempt` has already reached that total, not one call early. Using
|
|
1671
|
+
// `>=` here tripped after only 1 install instead of the documented 2
|
|
1672
|
+
// (forge#2203 — the retry this guard exists to allow never actually ran).
|
|
1673
|
+
if (attempt > MAX_SELF_UPDATE_ATTEMPTS) {
|
|
1674
|
+
console.log(
|
|
1675
|
+
` ${YELLOW}Self-update did not converge after ${attempt} attempt(s) — the installed version may not be advancing (stale registry mirror/cache?).${RESET}`,
|
|
1676
|
+
);
|
|
1677
|
+
console.log(
|
|
1678
|
+
` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
|
|
1679
|
+
);
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Validate before this value ever reaches the shell-invoked install below
|
|
1684
|
+
// (forge#2180) — fail open to the same advisory message a failed install
|
|
1685
|
+
// would print, rather than throwing or passing an unvalidated string to
|
|
1686
|
+
// `shell: true`.
|
|
1687
|
+
if (!isValidSemverShape(version)) {
|
|
1688
|
+
console.log(
|
|
1689
|
+
` ${YELLOW}Self-update aborted: received an unexpected version string ("${version}").${RESET}`,
|
|
1690
|
+
);
|
|
1691
|
+
console.log(
|
|
1692
|
+
` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
|
|
1693
|
+
);
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
try {
|
|
1698
|
+
console.log(` Installing ${CYAN}forgedock@${version}${RESET} globally...`);
|
|
1699
|
+
// shell: true is required to resolve npm.cmd on Windows (see the
|
|
1700
|
+
// Windows note in this function's doc comment, forge#2180). `version`
|
|
1701
|
+
// is validated as strict semver above before this point.
|
|
1702
|
+
execFileSync("npm", ["install", "-g", `forgedock@${version}`], {
|
|
1703
|
+
stdio: "inherit",
|
|
1704
|
+
timeout: 120000,
|
|
1705
|
+
shell: true,
|
|
1706
|
+
});
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
console.log(
|
|
1709
|
+
` ${YELLOW}Global self-update failed: ${err && err.message ? err.message : String(err)}${RESET}`,
|
|
1710
|
+
);
|
|
1711
|
+
console.log(
|
|
1712
|
+
` Run ${CYAN}npm install -g forgedock@latest${RESET} manually, then ${CYAN}npx forgedock update${RESET} again.`,
|
|
1713
|
+
);
|
|
1714
|
+
return false;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
console.log(
|
|
1718
|
+
` ${GREEN}Installed v${version}.${RESET} Re-running update to finish persist/relink...`,
|
|
1719
|
+
);
|
|
1720
|
+
try {
|
|
1721
|
+
const result = spawnSync(process.execPath, [__filename, "update"], {
|
|
1722
|
+
stdio: "inherit",
|
|
1723
|
+
env: {
|
|
1724
|
+
...process.env,
|
|
1725
|
+
FORGEDOCK_SELF_UPDATE_ATTEMPT: String(attempt + 1),
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
// `result.signal` is set (non-null) when the re-exec'd child was
|
|
1729
|
+
// terminated by a signal (e.g. SIGTERM/SIGKILL) rather than exiting
|
|
1730
|
+
// cleanly — in that case `result.status` is `null`, and
|
|
1731
|
+
// `result.status ?? 0` would silently collapse a killed child into a
|
|
1732
|
+
// reported "success" (exit 0). Exit non-zero explicitly for the
|
|
1733
|
+
// signal-killed case; the non-signaled path is unchanged. (forge#2159)
|
|
1734
|
+
process.exit(result.signal ? 1 : (result.status ?? 0));
|
|
1735
|
+
// Unreachable — process.exit() above terminates the process. Present
|
|
1736
|
+
// only to satisfy linters expecting a return on every path.
|
|
1737
|
+
return true;
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
console.log(
|
|
1740
|
+
` ${YELLOW}Could not re-run update after install: ${err && err.message ? err.message : String(err)}${RESET}`,
|
|
1741
|
+
);
|
|
1742
|
+
console.log(` Run ${CYAN}npx forgedock update${RESET} again manually.`);
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1478
1747
|
async function update() {
|
|
1479
1748
|
console.log("");
|
|
1480
1749
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
1481
1750
|
console.log("");
|
|
1482
1751
|
|
|
1483
|
-
console.log(` Mode: global (~/.claude)`);
|
|
1484
1752
|
console.log(` ${dim(getLogoTagline("update"))}`);
|
|
1485
1753
|
|
|
1486
1754
|
// Check if installed via npm (no .git directory) or via git clone
|
|
1487
1755
|
const gitDir = join(FORGE_HOME, ".git");
|
|
1488
1756
|
if (existsSync(gitDir)) {
|
|
1757
|
+
// forge#2220 — state the actual install being updated. Printing this
|
|
1758
|
+
// only after the git-dir check (rather than an unconditional "global"
|
|
1759
|
+
// line before it) is the fix: a clone resolved via npx-shadowing was
|
|
1760
|
+
// previously reported as "Mode: global (~/.claude)", which reads as
|
|
1761
|
+
// "your global install was updated" when in fact only this checkout was.
|
|
1762
|
+
console.log(` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`);
|
|
1763
|
+
const separateGlobalInstall = findSeparateGlobalInstall();
|
|
1764
|
+
if (separateGlobalInstall) {
|
|
1765
|
+
console.log(
|
|
1766
|
+
` ${YELLOW}Note: this updates the git clone above. Your global npm install at ${separateGlobalInstall} was NOT touched.${RESET}`,
|
|
1767
|
+
);
|
|
1768
|
+
console.log(
|
|
1769
|
+
` ${YELLOW}Run 'npm update -g forgedock', or 'npx forgedock update' from outside a clone, to update it.${RESET}`,
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1489
1772
|
try {
|
|
1490
1773
|
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1491
1774
|
cwd: FORGE_HOME,
|
|
@@ -1498,6 +1781,25 @@ async function update() {
|
|
|
1498
1781
|
const isDetached = branch === "HEAD";
|
|
1499
1782
|
const needsCheckout = branch !== "main";
|
|
1500
1783
|
if (needsCheckout) {
|
|
1784
|
+
// Dirty-tree guard: refuse to move HEAD in this clone if there are
|
|
1785
|
+
// uncommitted TRACKED changes. Untracked files are intentionally
|
|
1786
|
+
// excluded (--untracked-files=no) so a clean checkout with stray
|
|
1787
|
+
// scratch/log files does not spuriously block the update.
|
|
1788
|
+
const porcelain = execFileSync(
|
|
1789
|
+
"git",
|
|
1790
|
+
["status", "--porcelain", "--untracked-files=no"],
|
|
1791
|
+
{ cwd: FORGE_HOME, encoding: "utf-8" },
|
|
1792
|
+
).trim();
|
|
1793
|
+
if (porcelain) {
|
|
1794
|
+
console.log(
|
|
1795
|
+
` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping update.${RESET}`,
|
|
1796
|
+
);
|
|
1797
|
+
console.log(
|
|
1798
|
+
` ${YELLOW}Commit or stash your changes before updating, or run from a clean clone. HEAD was NOT moved.${RESET}`,
|
|
1799
|
+
);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1501
1803
|
if (isDetached) {
|
|
1502
1804
|
console.log(
|
|
1503
1805
|
` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
|
|
@@ -1507,7 +1809,7 @@ async function update() {
|
|
|
1507
1809
|
` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
|
|
1508
1810
|
);
|
|
1509
1811
|
}
|
|
1510
|
-
|
|
1812
|
+
execFileSync("git", ["checkout", "main", "--quiet"], { cwd: FORGE_HOME });
|
|
1511
1813
|
}
|
|
1512
1814
|
|
|
1513
1815
|
const before = execSync("git rev-parse HEAD", {
|
|
@@ -1534,7 +1836,7 @@ async function update() {
|
|
|
1534
1836
|
// Restore original branch after a successful update.
|
|
1535
1837
|
if (needsCheckout && !isDetached) {
|
|
1536
1838
|
try {
|
|
1537
|
-
|
|
1839
|
+
execFileSync("git", ["checkout", branch, "--quiet"], { cwd: FORGE_HOME });
|
|
1538
1840
|
console.log(
|
|
1539
1841
|
` Restored branch ${CYAN}${branch}${RESET}.`,
|
|
1540
1842
|
);
|
|
@@ -1554,6 +1856,18 @@ async function update() {
|
|
|
1554
1856
|
// cache path (`npx forgedock`), which was never globally installed, so
|
|
1555
1857
|
// "npm update -g forgedock" is a no-op. Check the actual published
|
|
1556
1858
|
// version instead and give advice that reflects the real install model.
|
|
1859
|
+
//
|
|
1860
|
+
// forge#2220 — state the actual resolved install kind + path up front,
|
|
1861
|
+
// computed once and reused below, so the mode line can never drift from
|
|
1862
|
+
// the branch actually taken (isGlobalNpmInstall() is also consulted
|
|
1863
|
+
// later to decide whether to self-update in place).
|
|
1864
|
+
const globalInstall = isGlobalNpmInstall();
|
|
1865
|
+
console.log(
|
|
1866
|
+
globalInstall
|
|
1867
|
+
? ` Mode: global npm install at ${CYAN}${FORGE_HOME}${RESET}`
|
|
1868
|
+
: ` Mode: npx (ad-hoc, not installed) — resolved from ${CYAN}${FORGE_HOME}${RESET}`,
|
|
1869
|
+
);
|
|
1870
|
+
|
|
1557
1871
|
const localVersion = getVersion();
|
|
1558
1872
|
const latestVersion = await fetchLatestVersion();
|
|
1559
1873
|
|
|
@@ -1569,8 +1883,25 @@ async function update() {
|
|
|
1569
1883
|
console.log(
|
|
1570
1884
|
` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}).`,
|
|
1571
1885
|
);
|
|
1572
|
-
console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
|
|
1573
1886
|
await printVersionAvailableChangelog(localVersion, latestVersion);
|
|
1887
|
+
|
|
1888
|
+
// forge#2133 — a global npm install can actually be upgraded in place;
|
|
1889
|
+
// do that instead of only printing an advisory (which previously left
|
|
1890
|
+
// global installs stuck on whatever version was first installed,
|
|
1891
|
+
// since every subsequent `npx forgedock update` re-resolves the same
|
|
1892
|
+
// stale global binary). selfUpdateGlobalInstall() re-execs and exits
|
|
1893
|
+
// the process on success, so anything after this block only runs when
|
|
1894
|
+
// self-update wasn't attempted or failed.
|
|
1895
|
+
if (globalInstall) {
|
|
1896
|
+
selfUpdateGlobalInstall(latestVersion);
|
|
1897
|
+
// selfUpdateGlobalInstall() only returns (rather than exiting the
|
|
1898
|
+
// process) when the install or re-exec failed — it already printed
|
|
1899
|
+
// manual-recovery instructions, so fall through to the persist
|
|
1900
|
+
// refresh below using the still-stale local payload (fail-open,
|
|
1901
|
+
// matches pre-existing behavior for this failure case).
|
|
1902
|
+
} else {
|
|
1903
|
+
console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
|
|
1904
|
+
}
|
|
1574
1905
|
} else {
|
|
1575
1906
|
console.log(` Already up to date (v${localVersion}).`);
|
|
1576
1907
|
}
|
|
@@ -2937,22 +3268,37 @@ async function demo() {
|
|
|
2937
3268
|
/**
|
|
2938
3269
|
* `forgedock watch` — live per-agent orchestration view
|
|
2939
3270
|
*
|
|
2940
|
-
* Polls GitHub every 5 seconds for in-flight issues (those
|
|
2941
|
-
*
|
|
2942
|
-
*
|
|
2943
|
-
*
|
|
3271
|
+
* Polls GitHub every 5 seconds for in-flight issues (those carrying any
|
|
3272
|
+
* label in the workflow-prefixed / needs-human family, derived from
|
|
3273
|
+
* bin/labels.json — see deriveWorkflowLabels() in bin/watch-utils.mjs,
|
|
3274
|
+
* forge#2235) and renders
|
|
3275
|
+
* a per-agent table with issue#, title, phase, elapsed time, and staleness
|
|
3276
|
+
* status. All data is sourced from FORGE:HEARTBEAT comments written by the
|
|
3277
|
+
* pipeline at each phase boundary.
|
|
3278
|
+
*
|
|
3279
|
+
* A second, independent "Findings" lane renders review-finding issues
|
|
3280
|
+
* regardless of workflow:* state (forge#2235) — a finding is born without
|
|
3281
|
+
* any workflow:* label and may never acquire one (deferred findings), so it
|
|
3282
|
+
* must not depend on the in-flight label set to be visible. Findings render
|
|
3283
|
+
* with a distinct queued/deferred/validated status, never "running".
|
|
2944
3284
|
*
|
|
2945
3285
|
* Stalled agents (no HEARTBEAT update within pipeline.stall_timeout_minutes)
|
|
2946
3286
|
* are highlighted in yellow.
|
|
2947
3287
|
*
|
|
3288
|
+
* If `forge.yaml` defines `repos.satellites`, each satellite repo is watched
|
|
3289
|
+
* in its own section in addition to the default/`--repo` repo.
|
|
3290
|
+
*
|
|
2948
3291
|
* Run: npx forgedock watch [--repo owner/repo]
|
|
2949
3292
|
* Exit: Ctrl+C
|
|
2950
3293
|
*/
|
|
2951
3294
|
async function watch() {
|
|
2952
3295
|
const POLL_INTERVAL_MS = 5000;
|
|
2953
3296
|
const USE_ANSI_WATCH = process.stdout.isTTY === true && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
3297
|
+
const ISSUE_LIST_LIMIT = 500; // forge#2235 — was 30; matches phase-1-resolve.md's --limit 500 mandate
|
|
3298
|
+
const HEARTBEAT_BATCH_CHUNK_SIZE = 25; // keep GraphQL query size/complexity bounded
|
|
2954
3299
|
|
|
2955
|
-
// ── Resolve repo
|
|
3300
|
+
// ── Resolve repo(s) ────────────────────────────────────────────────────────
|
|
3301
|
+
const explicitRepoFlag = restArgs.includes("--repo");
|
|
2956
3302
|
const watchRepo = resolveLabelsRepo(restArgs);
|
|
2957
3303
|
if (!watchRepo) {
|
|
2958
3304
|
process.stderr.write(
|
|
@@ -2963,6 +3309,22 @@ async function watch() {
|
|
|
2963
3309
|
return;
|
|
2964
3310
|
}
|
|
2965
3311
|
|
|
3312
|
+
// Satellite repos (forge.yaml → repos.satellites) are watched in addition
|
|
3313
|
+
// to the primary repo, unless the caller explicitly overrode --repo (an
|
|
3314
|
+
// explicit --repo means "just this one repo"). Returns [] when no
|
|
3315
|
+
// satellites section is present, so single-repo behavior is unchanged.
|
|
3316
|
+
let satelliteRepos = [];
|
|
3317
|
+
if (!explicitRepoFlag) {
|
|
3318
|
+
const forgeYamlPathForSatellites = join(process.cwd(), "forge.yaml");
|
|
3319
|
+
if (existsSync(forgeYamlPathForSatellites)) {
|
|
3320
|
+
try {
|
|
3321
|
+
const raw = readFileSync(forgeYamlPathForSatellites, "utf-8");
|
|
3322
|
+
satelliteRepos = parseSatelliteRepos(raw).filter((r) => r !== watchRepo);
|
|
3323
|
+
} catch { /* ignore — stay default-repo-only */ }
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
const watchedRepos = [watchRepo, ...satelliteRepos];
|
|
3327
|
+
|
|
2966
3328
|
// ── Validate gh CLI auth ───────────────────────────────────────────────────
|
|
2967
3329
|
try {
|
|
2968
3330
|
execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -3026,26 +3388,88 @@ async function watch() {
|
|
|
3026
3388
|
}
|
|
3027
3389
|
|
|
3028
3390
|
// ── Render loop ────────────────────────────────────────────────────────────
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3391
|
+
// Derived from bin/labels.json (forge#2235) instead of a hardcoded literal,
|
|
3392
|
+
// so a newly-added workflow:* label (e.g. workflow:engine-error) is watched
|
|
3393
|
+
// automatically without a code change. Falls back to a conservative
|
|
3394
|
+
// built-in list if the manifest is missing/unreadable.
|
|
3395
|
+
const WORKFLOW_LABELS = deriveWorkflowLabels(join(__dirname, "labels.json"));
|
|
3396
|
+
|
|
3397
|
+
// Batch-fetch the latest FORGE:HEARTBEAT comment for a set of issue numbers
|
|
3398
|
+
// in ONE `gh api graphql` round-trip (chunked) instead of one REST call per
|
|
3399
|
+
// issue (forge#2235 — de-N+1). Falls back to the old per-issue REST fetch
|
|
3400
|
+
// for the whole set on any GraphQL error, so this optimization can never
|
|
3401
|
+
// make watch() go blind.
|
|
3402
|
+
async function fetchHeartbeatsBatch(repo, issueNumbers) {
|
|
3403
|
+
const result = new Map();
|
|
3404
|
+
if (issueNumbers.length === 0) return result;
|
|
3405
|
+
const [owner, name] = repo.split("/");
|
|
3406
|
+
if (!owner || !name) return fetchHeartbeatsPerIssueFallback(repo, issueNumbers);
|
|
3036
3407
|
|
|
3037
|
-
|
|
3038
|
-
|
|
3408
|
+
try {
|
|
3409
|
+
for (const chunk of chunkArray(issueNumbers, HEARTBEAT_BATCH_CHUNK_SIZE)) {
|
|
3410
|
+
const query = buildHeartbeatBatchQuery(owner, name, chunk);
|
|
3411
|
+
const raw = execFileSync(
|
|
3412
|
+
"gh",
|
|
3413
|
+
["api", "graphql", "-f", `query=${query}`],
|
|
3414
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3415
|
+
);
|
|
3416
|
+
const parsed = parseHeartbeatBatchResponse(JSON.parse(raw));
|
|
3417
|
+
for (const [num, body] of parsed) result.set(num, body);
|
|
3418
|
+
}
|
|
3419
|
+
return result;
|
|
3420
|
+
} catch {
|
|
3421
|
+
// GraphQL batch failed (auth scope gap, malformed query, rate limit) —
|
|
3422
|
+
// fall back to the pre-forge#2235 per-issue REST behavior so watch()
|
|
3423
|
+
// degrades gracefully instead of losing heartbeat data entirely.
|
|
3424
|
+
return fetchHeartbeatsPerIssueFallback(repo, issueNumbers);
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
function fetchHeartbeatsPerIssueFallback(repo, issueNumbers) {
|
|
3429
|
+
const result = new Map();
|
|
3430
|
+
for (const num of issueNumbers) {
|
|
3431
|
+
try {
|
|
3432
|
+
const commentsJson = execFileSync(
|
|
3433
|
+
"gh",
|
|
3434
|
+
["api", `repos/${repo}/issues/${num}/comments`,
|
|
3435
|
+
"--jq", "[.[] | select(.body | contains(\"FORGE:HEARTBEAT\"))] | last | .body // \"\""],
|
|
3436
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3437
|
+
).trim();
|
|
3438
|
+
if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
|
|
3439
|
+
result.set(num, commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n"));
|
|
3440
|
+
} else {
|
|
3441
|
+
result.set(num, null);
|
|
3442
|
+
}
|
|
3443
|
+
} catch {
|
|
3444
|
+
result.set(num, null);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return result;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
// Renders a normalized priority for the "Pri" column, accepting both the
|
|
3451
|
+
// canonical `priority:P<n>` label form and the bare `P<n>` form used by
|
|
3452
|
+
// some consumer repos (forge#2232 / forge#2235).
|
|
3453
|
+
function priorityCell(issue) {
|
|
3454
|
+
return normalizePriority(issue.labels || []) || "—";
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
async function renderRepo(repo) {
|
|
3458
|
+
const rows = [["#", "Pri", "Title", "Phase", "Elapsed", "Status"]];
|
|
3459
|
+
const findingsRows = [["#", "Pri", "Title", "Status"]];
|
|
3039
3460
|
let anyIssues = false;
|
|
3461
|
+
const inFlightNumbers = new Set();
|
|
3462
|
+
const issuesByLabel = [];
|
|
3040
3463
|
|
|
3464
|
+
// ── In-flight lane: one query per workflow:*/needs-human label ──────────
|
|
3041
3465
|
for (const label of WORKFLOW_LABELS) {
|
|
3042
3466
|
let issueJson;
|
|
3043
3467
|
try {
|
|
3044
|
-
// Use execFileSync with argument array to avoid shell injection via
|
|
3468
|
+
// Use execFileSync with argument array to avoid shell injection via repo
|
|
3045
3469
|
issueJson = execFileSync(
|
|
3046
3470
|
"gh",
|
|
3047
|
-
["issue", "list", "-R",
|
|
3048
|
-
"--label", label, "--limit",
|
|
3471
|
+
["issue", "list", "-R", repo, "--state", "open",
|
|
3472
|
+
"--label", label, "--limit", String(ISSUE_LIST_LIMIT), "--json", "number,title,labels"],
|
|
3049
3473
|
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3050
3474
|
);
|
|
3051
3475
|
} catch {
|
|
@@ -3055,26 +3479,32 @@ async function watch() {
|
|
|
3055
3479
|
let issues;
|
|
3056
3480
|
try { issues = JSON.parse(issueJson); } catch { continue; }
|
|
3057
3481
|
|
|
3482
|
+
if (issues.length === ISSUE_LIST_LIMIT) {
|
|
3483
|
+
process.stderr.write(
|
|
3484
|
+
`${YELLOW}WARNING: label '${label}' on ${repo} returned exactly ${ISSUE_LIST_LIMIT} issues — ` +
|
|
3485
|
+
`results may be truncated.${RESET}\n`,
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
issuesByLabel.push({ label, issues });
|
|
3490
|
+
for (const issue of issues) inFlightNumbers.add(issue.number);
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
// Batch-fetch heartbeats once for every in-flight issue across all labels
|
|
3494
|
+
const heartbeats = await fetchHeartbeatsBatch(repo, [...inFlightNumbers]);
|
|
3495
|
+
|
|
3496
|
+
for (const { label, issues } of issuesByLabel) {
|
|
3058
3497
|
for (const issue of issues) {
|
|
3059
3498
|
anyIssues = true;
|
|
3060
3499
|
let phase = label.replace("workflow:", "");
|
|
3061
3500
|
let elapsed = null;
|
|
3062
3501
|
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3070
|
-
).trim();
|
|
3071
|
-
if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
|
|
3072
|
-
const body = commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n");
|
|
3073
|
-
phase = extractPhase(body) || phase;
|
|
3074
|
-
const ts = extractTimestamp(body);
|
|
3075
|
-
elapsed = elapsedMin(ts);
|
|
3076
|
-
}
|
|
3077
|
-
} catch { /* use label-derived phase */ }
|
|
3502
|
+
const body = heartbeats.get(issue.number);
|
|
3503
|
+
if (body) {
|
|
3504
|
+
phase = extractPhase(body) || phase;
|
|
3505
|
+
const ts = extractTimestamp(body);
|
|
3506
|
+
elapsed = elapsedMin(ts);
|
|
3507
|
+
}
|
|
3078
3508
|
|
|
3079
3509
|
const isStalled = elapsed !== null && elapsed >= stallMinutes;
|
|
3080
3510
|
const isBlocked = label === "needs-human";
|
|
@@ -3093,6 +3523,7 @@ async function watch() {
|
|
|
3093
3523
|
|
|
3094
3524
|
rows.push([
|
|
3095
3525
|
`#${issue.number}`,
|
|
3526
|
+
priorityCell(issue),
|
|
3096
3527
|
titleColored,
|
|
3097
3528
|
phaseShort,
|
|
3098
3529
|
elapsedStr(elapsed),
|
|
@@ -3101,7 +3532,60 @@ async function watch() {
|
|
|
3101
3532
|
}
|
|
3102
3533
|
}
|
|
3103
3534
|
|
|
3535
|
+
// ── Findings lane: review-finding issues, regardless of workflow:* state ─
|
|
3536
|
+
// A finding is born without any workflow:* label and may never acquire
|
|
3537
|
+
// one (deferred/PERMANENT_DEFERRED findings) — this lane makes them
|
|
3538
|
+
// visible from creation instead of only during the narrow in-flight
|
|
3539
|
+
// window (forge#2235).
|
|
3540
|
+
let anyFindings = false;
|
|
3541
|
+
try {
|
|
3542
|
+
const findingsJson = execFileSync(
|
|
3543
|
+
"gh",
|
|
3544
|
+
["issue", "list", "-R", repo, "--state", "open",
|
|
3545
|
+
"--label", FINDINGS_LANE_LABEL, "--limit", String(ISSUE_LIST_LIMIT), "--json", "number,title,labels"],
|
|
3546
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
3547
|
+
);
|
|
3548
|
+
const findings = JSON.parse(findingsJson);
|
|
3549
|
+
if (findings.length === ISSUE_LIST_LIMIT) {
|
|
3550
|
+
process.stderr.write(
|
|
3551
|
+
`${YELLOW}WARNING: label '${FINDINGS_LANE_LABEL}' on ${repo} returned exactly ${ISSUE_LIST_LIMIT} issues — ` +
|
|
3552
|
+
`results may be truncated.${RESET}\n`,
|
|
3553
|
+
);
|
|
3554
|
+
}
|
|
3555
|
+
for (const issue of findings) {
|
|
3556
|
+
// Findings that already carry a workflow:* label are in-flight and
|
|
3557
|
+
// already rendered above — do not double-render them here.
|
|
3558
|
+
if (hasWorkflowLabel(issue.labels || [], WORKFLOW_LABELS)) continue;
|
|
3559
|
+
|
|
3560
|
+
anyFindings = true;
|
|
3561
|
+
const findingStatus = classifyFindingStatus(issue.labels || []); // validated | false-positive | queued | deferred
|
|
3562
|
+
const statusDisplay = findingStatus.toUpperCase();
|
|
3563
|
+
const statusColored = USE_ANSI_WATCH
|
|
3564
|
+
? (findingStatus === "validated" ? `\x1b[32m${statusDisplay}\x1b[0m`
|
|
3565
|
+
: findingStatus === "false-positive" ? `\x1b[90m${statusDisplay}\x1b[0m`
|
|
3566
|
+
: `\x1b[36m${statusDisplay}\x1b[0m`) // queued / deferred
|
|
3567
|
+
: statusDisplay;
|
|
3568
|
+
|
|
3569
|
+
const titleTrunc = issue.title.length > 38 ? issue.title.slice(0, 35) + "..." : issue.title;
|
|
3570
|
+
|
|
3571
|
+
findingsRows.push([
|
|
3572
|
+
`#${issue.number}`,
|
|
3573
|
+
priorityCell(issue),
|
|
3574
|
+
titleTrunc,
|
|
3575
|
+
statusColored,
|
|
3576
|
+
]);
|
|
3577
|
+
}
|
|
3578
|
+
} catch { /* findings lane best-effort — never blocks the in-flight table */ }
|
|
3579
|
+
|
|
3580
|
+
return { repo, rows, anyIssues, findingsRows, anyFindings };
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
async function render() {
|
|
3104
3584
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
3585
|
+
const results = [];
|
|
3586
|
+
for (const repo of watchedRepos) {
|
|
3587
|
+
results.push(await renderRepo(repo));
|
|
3588
|
+
}
|
|
3105
3589
|
|
|
3106
3590
|
if (USE_ANSI_WATCH) {
|
|
3107
3591
|
process.stdout.write("\x1b[2J\x1b[H"); // clear screen + home
|
|
@@ -3109,12 +3593,26 @@ async function watch() {
|
|
|
3109
3593
|
process.stdout.write(`\n${"─".repeat(60)}\n`);
|
|
3110
3594
|
}
|
|
3111
3595
|
|
|
3112
|
-
|
|
3596
|
+
const repoLabel = watchedRepos.length > 1 ? `${watchedRepos.length} repos` : watchRepo;
|
|
3597
|
+
process.stdout.write(`${BOLD}ForgeDock Watch${RESET} — ${dim(repoLabel)} ${dim(now)}\n\n`);
|
|
3113
3598
|
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3599
|
+
for (const { repo, rows, anyIssues, findingsRows, anyFindings } of results) {
|
|
3600
|
+
if (watchedRepos.length > 1) {
|
|
3601
|
+
process.stdout.write(`${BOLD}${repo}${RESET}\n`);
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
if (!anyIssues || rows.length === 1) {
|
|
3605
|
+
process.stdout.write(` ${dim("No in-flight issues. All quiet.")}\n`);
|
|
3606
|
+
} else {
|
|
3607
|
+
process.stdout.write(table(rows, { header: true }));
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
if (anyFindings && findingsRows.length > 1) {
|
|
3611
|
+
process.stdout.write(`\n${BOLD}Findings${RESET} ${dim("(review-finding — queued/deferred/validated)")}\n`);
|
|
3612
|
+
process.stdout.write(table(findingsRows, { header: true }));
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
if (watchedRepos.length > 1) process.stdout.write("\n");
|
|
3118
3616
|
}
|
|
3119
3617
|
|
|
3120
3618
|
if (USE_ANSI_WATCH) {
|
|
@@ -3238,7 +3736,17 @@ switch (command) {
|
|
|
3238
3736
|
case "run-issue": {
|
|
3239
3737
|
const { runFromCli } = await import("./engine-cli.mjs");
|
|
3240
3738
|
try {
|
|
3241
|
-
await runFromCli(restArgs);
|
|
3739
|
+
const result = await runFromCli(restArgs);
|
|
3740
|
+
// forge#2175: make a needs-human escalation distinguishable from a
|
|
3741
|
+
// successful run by exit code, mirroring the resume-stalled case's
|
|
3742
|
+
// existing convention below (result.failed.length > 0 -> exitCode 1).
|
|
3743
|
+
// forge#2261: "engine-error" (the engine/tool itself broke — a fail-fast
|
|
3744
|
+
// CLI_BACKEND_FAILED/NO_API_KEY/NO_SDK, or an exhausted retry loop where
|
|
3745
|
+
// the runner never once succeeded) previously reached this same case via
|
|
3746
|
+
// an uncaught throw, which already set exitCode = 1 below. Now that
|
|
3747
|
+
// runIssue() resolves cleanly with a terminalReason instead of throwing,
|
|
3748
|
+
// preserve that same non-zero exit code for this reason too.
|
|
3749
|
+
if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error")) exitCode = 1;
|
|
3242
3750
|
} catch (err) {
|
|
3243
3751
|
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
3244
3752
|
exitCode = 1;
|