forgedock 1.3.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.
Files changed (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
package/bin/forgedock.mjs CHANGED
@@ -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,
@@ -1476,36 +1487,84 @@ async function printVersionAvailableChangelog(localVersion, latestVersion) {
1476
1487
  }
1477
1488
 
1478
1489
  /**
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.
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.
1485
1495
  *
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.
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).
1490
1499
  *
1491
- * @returns {boolean}
1500
+ * @returns {string|null} absolute path to `{globalRoot}/forgedock`, or null
1492
1501
  */
1493
- function isGlobalNpmInstall() {
1502
+ function resolveGlobalNpmForgedockDir() {
1494
1503
  try {
1495
1504
  const globalRoot = execSync("npm root -g", {
1496
1505
  encoding: "utf-8",
1497
1506
  stdio: ["pipe", "pipe", "pipe"],
1498
1507
  timeout: 5000,
1499
1508
  }).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);
1509
+ if (!globalRoot) return null;
1510
+ return resolve(join(globalRoot, "forgedock"));
1504
1511
  } catch {
1505
- return false;
1512
+ return null;
1506
1513
  }
1507
1514
  }
1508
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
+
1509
1568
  /**
1510
1569
  * Maximum number of additional self-update re-exec attempts allowed after the
1511
1570
  * first (i.e. up to MAX_SELF_UPDATE_ATTEMPTS + 1 total `npm install -g`
@@ -1690,12 +1749,26 @@ async function update() {
1690
1749
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
1691
1750
  console.log("");
1692
1751
 
1693
- console.log(` Mode: global (~/.claude)`);
1694
1752
  console.log(` ${dim(getLogoTagline("update"))}`);
1695
1753
 
1696
1754
  // Check if installed via npm (no .git directory) or via git clone
1697
1755
  const gitDir = join(FORGE_HOME, ".git");
1698
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
+ }
1699
1772
  try {
1700
1773
  const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1701
1774
  cwd: FORGE_HOME,
@@ -1708,6 +1781,25 @@ async function update() {
1708
1781
  const isDetached = branch === "HEAD";
1709
1782
  const needsCheckout = branch !== "main";
1710
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
+
1711
1803
  if (isDetached) {
1712
1804
  console.log(
1713
1805
  ` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
@@ -1717,7 +1809,7 @@ async function update() {
1717
1809
  ` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
1718
1810
  );
1719
1811
  }
1720
- execSync("git checkout main --quiet", { cwd: FORGE_HOME });
1812
+ execFileSync("git", ["checkout", "main", "--quiet"], { cwd: FORGE_HOME });
1721
1813
  }
1722
1814
 
1723
1815
  const before = execSync("git rev-parse HEAD", {
@@ -1744,7 +1836,7 @@ async function update() {
1744
1836
  // Restore original branch after a successful update.
1745
1837
  if (needsCheckout && !isDetached) {
1746
1838
  try {
1747
- execSync(`git checkout ${branch} --quiet`, { cwd: FORGE_HOME });
1839
+ execFileSync("git", ["checkout", branch, "--quiet"], { cwd: FORGE_HOME });
1748
1840
  console.log(
1749
1841
  ` Restored branch ${CYAN}${branch}${RESET}.`,
1750
1842
  );
@@ -1764,6 +1856,18 @@ async function update() {
1764
1856
  // cache path (`npx forgedock`), which was never globally installed, so
1765
1857
  // "npm update -g forgedock" is a no-op. Check the actual published
1766
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
+
1767
1871
  const localVersion = getVersion();
1768
1872
  const latestVersion = await fetchLatestVersion();
1769
1873
 
@@ -1788,7 +1892,7 @@ async function update() {
1788
1892
  // stale global binary). selfUpdateGlobalInstall() re-execs and exits
1789
1893
  // the process on success, so anything after this block only runs when
1790
1894
  // self-update wasn't attempted or failed.
1791
- if (isGlobalNpmInstall()) {
1895
+ if (globalInstall) {
1792
1896
  selfUpdateGlobalInstall(latestVersion);
1793
1897
  // selfUpdateGlobalInstall() only returns (rather than exiting the
1794
1898
  // process) when the install or re-exec failed — it already printed
@@ -3164,22 +3268,37 @@ async function demo() {
3164
3268
  /**
3165
3269
  * `forgedock watch` — live per-agent orchestration view
3166
3270
  *
3167
- * Polls GitHub every 5 seconds for in-flight issues (those with workflow:*
3168
- * labels) and renders a per-agent table with issue#, title, phase, elapsed
3169
- * time, and staleness status. All data is sourced from FORGE:HEARTBEAT
3170
- * comments written by the pipeline at each phase boundary.
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".
3171
3284
  *
3172
3285
  * Stalled agents (no HEARTBEAT update within pipeline.stall_timeout_minutes)
3173
3286
  * are highlighted in yellow.
3174
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
+ *
3175
3291
  * Run: npx forgedock watch [--repo owner/repo]
3176
3292
  * Exit: Ctrl+C
3177
3293
  */
3178
3294
  async function watch() {
3179
3295
  const POLL_INTERVAL_MS = 5000;
3180
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
3181
3299
 
3182
- // ── Resolve repo ──────────────────────────────────────────────────────────
3300
+ // ── Resolve repo(s) ────────────────────────────────────────────────────────
3301
+ const explicitRepoFlag = restArgs.includes("--repo");
3183
3302
  const watchRepo = resolveLabelsRepo(restArgs);
3184
3303
  if (!watchRepo) {
3185
3304
  process.stderr.write(
@@ -3190,6 +3309,22 @@ async function watch() {
3190
3309
  return;
3191
3310
  }
3192
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
+
3193
3328
  // ── Validate gh CLI auth ───────────────────────────────────────────────────
3194
3329
  try {
3195
3330
  execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
@@ -3253,26 +3388,88 @@ async function watch() {
3253
3388
  }
3254
3389
 
3255
3390
  // ── Render loop ────────────────────────────────────────────────────────────
3256
- const WORKFLOW_LABELS = [
3257
- "workflow:investigating",
3258
- "workflow:ready-to-build",
3259
- "workflow:building",
3260
- "workflow:in-review",
3261
- "needs-human",
3262
- ];
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);
3263
3407
 
3264
- async function render() {
3265
- const rows = [["#", "Title", "Phase", "Elapsed", "Status"]];
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"]];
3266
3460
  let anyIssues = false;
3461
+ const inFlightNumbers = new Set();
3462
+ const issuesByLabel = [];
3267
3463
 
3464
+ // ── In-flight lane: one query per workflow:*/needs-human label ──────────
3268
3465
  for (const label of WORKFLOW_LABELS) {
3269
3466
  let issueJson;
3270
3467
  try {
3271
- // Use execFileSync with argument array to avoid shell injection via watchRepo
3468
+ // Use execFileSync with argument array to avoid shell injection via repo
3272
3469
  issueJson = execFileSync(
3273
3470
  "gh",
3274
- ["issue", "list", "-R", watchRepo, "--state", "open",
3275
- "--label", label, "--limit", "30", "--json", "number,title"],
3471
+ ["issue", "list", "-R", repo, "--state", "open",
3472
+ "--label", label, "--limit", String(ISSUE_LIST_LIMIT), "--json", "number,title,labels"],
3276
3473
  { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
3277
3474
  );
3278
3475
  } catch {
@@ -3282,26 +3479,32 @@ async function watch() {
3282
3479
  let issues;
3283
3480
  try { issues = JSON.parse(issueJson); } catch { continue; }
3284
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) {
3285
3497
  for (const issue of issues) {
3286
3498
  anyIssues = true;
3287
3499
  let phase = label.replace("workflow:", "");
3288
3500
  let elapsed = null;
3289
3501
 
3290
- // Fetch latest FORGE:HEARTBEAT comment for phase + timestamp
3291
- try {
3292
- const commentsJson = execFileSync(
3293
- "gh",
3294
- ["api", `repos/${watchRepo}/issues/${issue.number}/comments`,
3295
- "--jq", "[.[] | select(.body | contains(\"FORGE:HEARTBEAT\"))] | last | .body // \"\""],
3296
- { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
3297
- ).trim();
3298
- if (commentsJson && commentsJson !== '""' && commentsJson !== "") {
3299
- const body = commentsJson.replace(/^"|"$/g, "").replace(/\\n/g, "\n");
3300
- phase = extractPhase(body) || phase;
3301
- const ts = extractTimestamp(body);
3302
- elapsed = elapsedMin(ts);
3303
- }
3304
- } 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
+ }
3305
3508
 
3306
3509
  const isStalled = elapsed !== null && elapsed >= stallMinutes;
3307
3510
  const isBlocked = label === "needs-human";
@@ -3320,6 +3523,7 @@ async function watch() {
3320
3523
 
3321
3524
  rows.push([
3322
3525
  `#${issue.number}`,
3526
+ priorityCell(issue),
3323
3527
  titleColored,
3324
3528
  phaseShort,
3325
3529
  elapsedStr(elapsed),
@@ -3328,7 +3532,60 @@ async function watch() {
3328
3532
  }
3329
3533
  }
3330
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() {
3331
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
+ }
3332
3589
 
3333
3590
  if (USE_ANSI_WATCH) {
3334
3591
  process.stdout.write("\x1b[2J\x1b[H"); // clear screen + home
@@ -3336,12 +3593,26 @@ async function watch() {
3336
3593
  process.stdout.write(`\n${"─".repeat(60)}\n`);
3337
3594
  }
3338
3595
 
3339
- process.stdout.write(`${BOLD}ForgeDock Watch${RESET} — ${dim(watchRepo)} ${dim(now)}\n\n`);
3596
+ const repoLabel = watchedRepos.length > 1 ? `${watchedRepos.length} repos` : watchRepo;
3597
+ process.stdout.write(`${BOLD}ForgeDock Watch${RESET} — ${dim(repoLabel)} ${dim(now)}\n\n`);
3340
3598
 
3341
- if (!anyIssues || rows.length === 1) {
3342
- process.stdout.write(` ${dim("No in-flight issues. All quiet.")}\n`);
3343
- } else {
3344
- process.stdout.write(table(rows, { header: true }));
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");
3345
3616
  }
3346
3617
 
3347
3618
  if (USE_ANSI_WATCH) {
@@ -3469,7 +3740,13 @@ switch (command) {
3469
3740
  // forge#2175: make a needs-human escalation distinguishable from a
3470
3741
  // successful run by exit code, mirroring the resume-stalled case's
3471
3742
  // existing convention below (result.failed.length > 0 -> exitCode 1).
3472
- if (result && result.terminalReason === "needs-human") 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;
3473
3750
  } catch (err) {
3474
3751
  process.stderr.write(`${RED}${err.message}${RESET}\n`);
3475
3752
  exitCode = 1;