forgedock 1.4.0 → 1.5.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.
@@ -57,7 +57,7 @@ export function deriveState(events) {
57
57
  /** @type {import("./phases.mjs").RunState} */
58
58
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
59
  phase: null, branch: null, pr: null, terminal: false,
60
- terminalReason: null, lease: null };
60
+ terminalReason: null, lease: null, lastRateLimit: null };
61
61
  for (const e of events) {
62
62
  switch (e.event) {
63
63
  case "RUN_START":
@@ -72,6 +72,16 @@ export function deriveState(events) {
72
72
  case "RUN_TERMINAL":
73
73
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
74
74
  break;
75
+ // forge#2524: a session-limit pause is purely informational — it does
76
+ // NOT touch committed/terminal/terminalReason. The phase that hit the
77
+ // pause is, by construction, not yet committed (the engine is about to
78
+ // retry it from scratch once the wait elapses), so a resumed/hydrated
79
+ // run correctly re-enters pickPhase at the same phase with no special
80
+ // casing needed here beyond recording the diagnostic below.
81
+ case "PHASE_RATE_LIMITED":
82
+ s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null };
83
+ s.v = e.seq;
84
+ break;
75
85
  // PHASE_START / PHASE_FAILED do not change committed state
76
86
  }
77
87
  }
@@ -365,6 +365,12 @@ export async function runFromCli(argv, deps = {}) {
365
365
  if (e.event === "phase_enter") console.log(`→ phase ${e.phase} started`);
366
366
  else if (e.event === "phase_exit" && e.status === "committed") console.log(`✓ phase ${e.phase} committed`);
367
367
  else if (e.event === "phase_exit" && e.status === "blocked") console.log(`✗ phase ${e.phase} blocked: ${e.detail ?? "no detail"}`);
368
+ // forge#2524: session-limit pause — the engine is waiting in-process
369
+ // until the reported reset time before retrying this same phase, rather
370
+ // than terminating. Distinct glyph from the exit/blocked lines above so
371
+ // an operator tailing this stdout can tell "still alive, just waiting"
372
+ // from "phase actually failed".
373
+ else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
368
374
  };
369
375
  const res = await runIssueFn({ issue, dir, agentId, lane, io,
370
376
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
package/bin/engine.mjs CHANGED
@@ -20,65 +20,31 @@ export const DEFAULT_MAX_ATTEMPTS = 3;
20
20
  export const DEFAULT_LEASE_TTL_MS = 600000;
21
21
  export const DEFAULT_LEASE_RENEW_INTERVAL_MS = 240000;
22
22
 
23
+ // forge#2524: bounds how many consecutive session-limit pauses a single
24
+ // phase attempt will ride out before falling through to the existing
25
+ // engine-error terminate path. Exists purely as a defensive backstop against
26
+ // a persistently-misparsed/misreported reset time (clock skew, a CLI that
27
+ // always claims "resets in 1 minute" for some unrelated reason) spinning the
28
+ // engine forever — a genuine multi-hour session-limit reset is expected to
29
+ // need at most one or two pauses in practice.
30
+ export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
31
+
23
32
  /**
24
- * @param {object} opts
25
- * @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
26
- * to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
27
- * default ("auto" ladder — probes the `claude` CLI, falls back to the API).
28
- * An invalid value throws synchronously (see below) rather than being forwarded.
29
- * @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
30
- * `runner()` call when supplied; only applies on the "api" backend. Omit to keep
31
- * runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
32
- * @param {number} [opts.leaseTtlMs] - forge#2239: how long a claimed lease is
33
- * valid for. Defaults to DEFAULT_LEASE_TTL_MS. Overridable for tests.
34
- * @param {number} [opts.leaseRenewIntervalMs] - forge#2239: how often the lease
35
- * is re-written while an unsatisfied phase's runner is executing. Defaults to
36
- * DEFAULT_LEASE_RENEW_INTERVAL_MS. Overridable for tests.
37
- * @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} [opts.onProgress] -
38
- * forge#2240: optional phase-boundary observer. Called with
39
- * `{event: "phase_enter", phase}` right before a phase's runner is about to
40
- * execute (i.e. the phase was NOT already satisfied on reconcile), and with
41
- * `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
42
- * once that phase's outcome is known. Defaults to a no-op so every existing
43
- * caller/test is unaffected. Deliberately engine.mjs's ONLY new surface for
44
- * this issue — no `console.log`/stdout write is added here; printing is the
45
- * CLI layer's job (bin/engine-cli.mjs), preserving the io-injection/testability
46
- * convention this module already follows. Invocations are wrapped so a
47
- * throwing callback can never crash a run.
33
+ * Validates the subset of `runIssue()`'s options that must fail fast,
34
+ * synchronously, before any state I/O or timer creation — `backend` and the
35
+ * lease-timing pair. Extracted from `runIssue()` (forge#2452) purely for
36
+ * readability; call-order and thrown-error shape are unchanged from the
37
+ * inline block this replaces, and MUST stay that way — see the per-check
38
+ * rationale comments below, all of which trace back to real review findings.
39
+ *
40
+ * @param {object} params
41
+ * @param {string} [params.backend] - "cli" | "api" | "auto" (forge#2028).
42
+ * @param {number} params.leaseTtlMs - forge#2239: how long a claimed lease is valid for.
43
+ * @param {number} params.leaseRenewIntervalMs - forge#2239: how often the lease is re-written.
44
+ * @param {number} params.maxSessionLimitPauses - forge#2524: bound on consecutive session-limit pauses.
45
+ * @throws {Error & {code: "INVALID_BACKEND"|"INVALID_LEASE_CONFIG"|"INVALID_SESSION_LIMIT_CONFIG"}}
48
46
  */
49
- export async function runIssue(opts) {
50
- const { issue, dir, agentId, lane = "staging", io, runner,
51
- now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
52
- commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
53
- // Optional execution-backend override for every phase's `runner()`
54
- // call (forge#2028 / MAT-3). Left undefined by default so existing
55
- // callers keep runner.mjs's own "auto" ladder default unchanged —
56
- // this is purely additive pass-through, not a new default.
57
- backend, model,
58
- leaseTtlMs = DEFAULT_LEASE_TTL_MS,
59
- leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
60
- onProgress = () => {} } = opts;
61
-
62
- // forge#2240: defensive wrapper — a caller's onProgress must never be able
63
- // to crash an otherwise-healthy run. A plain try/catch only guards a
64
- // *synchronous* throw — if onProgress is (or becomes) async and its
65
- // returned promise rejects, that rejection is never awaited/caught here,
66
- // producing an unhandled promise rejection that terminates the whole
67
- // Node process (Node >=15 default behavior) well after runIssue() itself
68
- // has already resolved. That is strictly worse than the silent-hang bug
69
- // this issue fixes. Detect a thenable return value and attach a no-op
70
- // .catch() to it (fire-and-forget — onProgress is not awaited either way,
71
- // consistent with its documented synchronous-observer contract) so a
72
- // rejection can never escape as unhandled. (review finding, #2240)
73
- const emitProgress = (event) => {
74
- try {
75
- const result = onProgress(event);
76
- if (result && typeof result.then === "function") {
77
- result.catch(() => { /* best-effort observer, never fatal */ });
78
- }
79
- } catch { /* best-effort observer, never fatal */ }
80
- };
81
-
47
+ function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses }) {
82
48
  // forge#2054: validate `backend` before anything else — before state is
83
49
  // read/written and before the phase/retry loop begins below. An invalid
84
50
  // value must fail fast and non-retryably. Without this check, an invalid
@@ -147,6 +113,125 @@ export async function runIssue(opts) {
147
113
  );
148
114
  }
149
115
 
116
+ // forge#2524: same finite-number + non-negative guard precedent as the
117
+ // lease-timing pair above (forge#2329) — a NaN/negative pause bound would
118
+ // either silently disable the pause-count backstop (every relational
119
+ // comparison against NaN is false, so `pauseCount < maxSessionLimitPauses`
120
+ // would never trip) or reject every pause immediately (a negative bound).
121
+ // Validated eagerly here, synchronously, before any state I/O or timer
122
+ // creation — identical placement rationale to the checks above.
123
+ if (typeof maxSessionLimitPauses !== "number" || !Number.isFinite(maxSessionLimitPauses) || maxSessionLimitPauses < 0) {
124
+ throw Object.assign(
125
+ new Error(
126
+ `Invalid session-limit config: maxSessionLimitPauses must be a finite number >= 0, got ${typeof maxSessionLimitPauses === "number" ? maxSessionLimitPauses : `${typeof maxSessionLimitPauses} (${JSON.stringify(maxSessionLimitPauses)})`}.`,
127
+ ),
128
+ { code: "INVALID_SESSION_LIMIT_CONFIG" },
129
+ );
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Builds the defensive `emitProgress` wrapper around a caller-supplied
135
+ * `onProgress` observer. Extracted from `runIssue()` (forge#2452) — purely a
136
+ * factory, no shared state beyond the `onProgress` closure itself, so this
137
+ * carries no behavioral risk.
138
+ *
139
+ * forge#2240: a caller's onProgress must never be able to crash an otherwise
140
+ * healthy run. A plain try/catch only guards a *synchronous* throw — if
141
+ * onProgress is (or becomes) async and its returned promise rejects, that
142
+ * rejection is never awaited/caught here, producing an unhandled promise
143
+ * rejection that terminates the whole Node process (Node >=15 default
144
+ * behavior) well after runIssue() itself has already resolved. That is
145
+ * strictly worse than the silent-hang bug this issue fixes. Detect a
146
+ * thenable return value and attach a no-op .catch() to it (fire-and-forget —
147
+ * onProgress is not awaited either way, consistent with its documented
148
+ * synchronous-observer contract) so a rejection can never escape as
149
+ * unhandled. (review finding, #2240)
150
+ *
151
+ * @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} onProgress
152
+ * @returns {(event: object) => void}
153
+ */
154
+ function makeProgressEmitter(onProgress) {
155
+ return (event) => {
156
+ try {
157
+ const result = onProgress(event);
158
+ if (result && typeof result.then === "function") {
159
+ result.catch(() => { /* best-effort observer, never fatal */ });
160
+ }
161
+ } catch { /* best-effort observer, never fatal */ }
162
+ };
163
+ }
164
+
165
+ /**
166
+ * @param {object} opts
167
+ * @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
168
+ * to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
169
+ * default ("auto" ladder — probes the `claude` CLI, falls back to the API).
170
+ * An invalid value throws synchronously (see below) rather than being forwarded.
171
+ * @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
172
+ * `runner()` call when supplied; only applies on the "api" backend. Omit to keep
173
+ * runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
174
+ * @param {number} [opts.leaseTtlMs] - forge#2239: how long a claimed lease is
175
+ * valid for. Defaults to DEFAULT_LEASE_TTL_MS. Overridable for tests.
176
+ * @param {number} [opts.leaseRenewIntervalMs] - forge#2239: how often the lease
177
+ * is re-written while an unsatisfied phase's runner is executing. Defaults to
178
+ * DEFAULT_LEASE_RENEW_INTERVAL_MS. Overridable for tests.
179
+ * @param {(event: {event: string, phase: string, status?: string, detail?: string, resetAt?: number}) => void} [opts.onProgress] -
180
+ * forge#2240: optional phase-boundary observer. Called with
181
+ * `{event: "phase_enter", phase}` right before a phase's runner is about to
182
+ * execute (i.e. the phase was NOT already satisfied on reconcile), with
183
+ * `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
184
+ * once that phase's outcome is known, and (forge#2524) with
185
+ * `{event: "phase_paused", phase, reason: "session-limit", resetAt, detail}`
186
+ * when a session-limit hit pauses in-process (see the pause/retry loop
187
+ * below). Defaults to a no-op so every existing caller/test is unaffected.
188
+ * Deliberately engine.mjs's ONLY new surface for this issue — no
189
+ * `console.log`/stdout write is added here; printing is the CLI layer's job
190
+ * (bin/engine-cli.mjs), preserving the io-injection/testability convention
191
+ * this module already follows. Invocations are wrapped so a throwing
192
+ * callback can never crash a run.
193
+ * @param {() => number} [opts.now] - clock, overridable for tests.
194
+ * @param {(ms: number) => Promise<void>} [opts.sleep] - forge#2524: the wait
195
+ * primitive used by the session-limit pause loop below. Defaults to a real
196
+ * `setTimeout`-based sleep. Overridable for tests so the suite never
197
+ * actually waits out a real (potentially multi-hour) session-limit reset —
198
+ * a test injects a fast/no-op sleep and asserts the *requested* duration
199
+ * instead of waiting for it. Mirrors the existing `now`/`onProgress`
200
+ * injectability convention.
201
+ * @param {number} [opts.maxSessionLimitPauses] - forge#2524: caps how many
202
+ * consecutive session-limit pauses a single phase attempt will ride out
203
+ * before falling through to the existing engine-error terminate path.
204
+ * Defaults to DEFAULT_MAX_SESSION_LIMIT_PAUSES. Purely a defensive backstop
205
+ * against a persistently-misreported reset time; a genuine reset is
206
+ * expected to need at most one or two pauses in practice.
207
+ */
208
+ export async function runIssue(opts) {
209
+ const { issue, dir, agentId, lane = "staging", io, runner,
210
+ now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
211
+ commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
212
+ // Optional execution-backend override for every phase's `runner()`
213
+ // call (forge#2028 / MAT-3). Left undefined by default so existing
214
+ // callers keep runner.mjs's own "auto" ladder default unchanged —
215
+ // this is purely additive pass-through, not a new default.
216
+ backend, model,
217
+ leaseTtlMs = DEFAULT_LEASE_TTL_MS,
218
+ leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
219
+ // forge#2524: real default is a genuine setTimeout-based wait —
220
+ // tests inject a fast/no-op replacement (see opts.sleep doc above).
221
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
222
+ maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
223
+ onProgress = () => {} } = opts;
224
+
225
+ // forge#2452: extracted into makeProgressEmitter() — see its docstring for
226
+ // the forge#2240 rationale this wrapper implements.
227
+ const emitProgress = makeProgressEmitter(onProgress);
228
+
229
+ // forge#2452: extracted into validateRunIssueOptions() — see its docstring
230
+ // for the forge#2054/#2313/#2329 rationale and required call-order (before
231
+ // any state I/O or timer creation, unchanged from the inline block this
232
+ // replaces).
233
+ validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses });
234
+
150
235
  const projector = makeProjector(io);
151
236
 
152
237
  // 1. Load + reconcile (GitHub wins).
@@ -328,7 +413,56 @@ export async function runIssue(opts) {
328
413
  const renewTimer = setInterval(renewLease, leaseRenewIntervalMs);
329
414
  if (typeof renewTimer.unref === "function") renewTimer.unref();
330
415
  try {
331
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
416
+ // forge#2524: bounded pause/retry loop for a genuine, still-future
417
+ // session-limit hit. `runPhaseWithRetry()` itself remains fail-fast
418
+ // for CLI_BACKEND_FAILED (forge#2259 — a deterministic CLI crash
419
+ // reproduces identically on every attempt within one call) — this
420
+ // loop operates one layer up, deciding whether to call it again from
421
+ // scratch (fresh attempt budget) after waiting out a temporary usage
422
+ // cap, rather than treating every CLI_BACKEND_FAILED as equally
423
+ // fatal. `renewTimer` (started above) keeps renewing the lease for
424
+ // the entire duration of this loop, including any `sleep()` below —
425
+ // no separate heartbeat mechanism is needed for the wait itself.
426
+ let pauseCount = 0;
427
+ for (;;) {
428
+ try {
429
+ outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
430
+ break;
431
+ } catch (e) {
432
+ const resetAtMs = e.resetAtEpochMs;
433
+ const canPause =
434
+ e.code === "CLI_BACKEND_FAILED" &&
435
+ typeof resetAtMs === "number" && Number.isFinite(resetAtMs) &&
436
+ resetAtMs > now() &&
437
+ pauseCount < maxSessionLimitPauses;
438
+ if (!canPause) throw e; // falls through to the unchanged outer catch below
439
+ pauseCount += 1;
440
+ const waitMs = Math.max(0, resetAtMs - now());
441
+ // forge#2524 AC4: append a run-log event so a crash mid-wait
442
+ // leaves a durable record of the pause (deriveState() folds this
443
+ // into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
444
+ // does NOT touch committed/terminal state: the phase genuinely
445
+ // hasn't committed yet, so a resumed run correctly re-enters
446
+ // pickPhase at this same phase regardless of whether this event
447
+ // was ever written.
448
+ appendEvent(dir, issue, {
449
+ event: "PHASE_RATE_LIMITED", phase: phase.id,
450
+ resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
451
+ });
452
+ // forge#2524 AC6: progress observer sees the pause so a caller
453
+ // (bin/engine-cli.mjs) can render the wait state instead of the
454
+ // process going silent for the duration.
455
+ emitProgress({
456
+ event: "phase_paused", phase: phase.id, reason: "session-limit",
457
+ resetAt: resetAtMs,
458
+ detail: `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`,
459
+ });
460
+ await sleep(waitMs);
461
+ // Loop back and retry the same phase with a fresh attempt budget
462
+ // (a brand-new runPhaseWithRetry() call, not a continuation of
463
+ // the exhausted one) — AC2.
464
+ }
465
+ }
332
466
  } catch (e) {
333
467
  // forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
334
468
  // rethrown by runPhaseWithRetry() (see its own catch below) instead
@@ -569,5 +703,6 @@ function eventsFromIndex(idx) {
569
703
 
570
704
  function freshState(issue, lane) {
571
705
  return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
572
- branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
706
+ branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
707
+ lastRateLimit: null };
573
708
  }
package/bin/forgedock.mjs CHANGED
@@ -1792,11 +1792,47 @@ async function update() {
1792
1792
  ).trim();
1793
1793
  if (porcelain) {
1794
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}`,
1795
+ ` ${YELLOW}Working tree has uncommitted changes on branch ${branch} — skipping git-based update.${RESET}`,
1799
1796
  );
1797
+
1798
+ // forge#2460 — distinguish "developing on this checkout" from a
1799
+ // dirty git-clone install elsewhere with unrelated changes. The
1800
+ // former (npx-shadowing: FORGE_HOME resolves to the directory the
1801
+ // user is physically running the CLI from) has a git-independent
1802
+ // fallback available — relinkAndHint() only copies commands/hooks
1803
+ // off disk, it never touches git state, so it's safe to run on a
1804
+ // dirty tree. HEAD is still never moved in either case.
1805
+ const cwdResolved = resolve(process.cwd());
1806
+ const forgeHomeResolved = resolve(FORGE_HOME);
1807
+ const isSourceRepoSelf =
1808
+ cwdResolved === forgeHomeResolved ||
1809
+ cwdResolved.startsWith(forgeHomeResolved + sep);
1810
+
1811
+ if (isSourceRepoSelf) {
1812
+ console.log(
1813
+ ` ${YELLOW}This looks like the ForgeDock source repo itself — syncing commands/hooks from the working tree instead (HEAD was NOT moved).${RESET}`,
1814
+ );
1815
+ // forge#2493 — relinkAndHint() is wrapped in its own try/catch
1816
+ // here so a failure inside it (forge()/writeInstallReceipt()
1817
+ // disk-write errors) doesn't fall through to the outer catch
1818
+ // below, which prints a fast-forward/merge message that would be
1819
+ // actively wrong for this path — no fast-forward or merge is
1820
+ // ever attempted when isSourceRepoSelf is true.
1821
+ try {
1822
+ await relinkAndHint();
1823
+ } catch (relinkErr) {
1824
+ console.log(
1825
+ ` ${YELLOW}Could not sync commands/hooks from the working tree: ${relinkErr.message}${RESET}`,
1826
+ );
1827
+ }
1828
+ console.log(
1829
+ ` To pull the latest published release instead: ${CYAN}npm install -g forgedock@latest && npx forgedock doctor --fix${RESET}`,
1830
+ );
1831
+ } else {
1832
+ console.log(
1833
+ ` ${YELLOW}Commit or stash your changes before updating, or run from a clean clone. HEAD was NOT moved.${RESET}`,
1834
+ );
1835
+ }
1800
1836
  return;
1801
1837
  }
1802
1838
 
@@ -3845,6 +3881,31 @@ switch (command) {
3845
3881
  const localVersion = getVersion();
3846
3882
  console.log(`forgedock ${localVersion ? `v${localVersion}` : `${YELLOW}version unknown${RESET}`}`);
3847
3883
 
3884
+ // forge#2460 — getVersion() always reads FORGE_HOME/package.json. In a
3885
+ // git-clone install (including the npx-shadowing case where FORGE_HOME
3886
+ // resolves to whatever checkout the CLI is being run from), that's this
3887
+ // checkout's own version — not necessarily what a separate global npm
3888
+ // install elsewhere would report. Surface that ambiguity explicitly
3889
+ // rather than letting this read as "the" installed version, mirroring
3890
+ // the disambiguation messaging update() already prints (forge#2220).
3891
+ // WIRE:PROVEN — manual: every existing "version command" test in
3892
+ // bin/tests/router.test.mjs spawns this actual bin/forgedock.mjs file,
3893
+ // whose FORGE_HOME resolves to this repo's own git-clone root (a real
3894
+ // .git directory), so this guard evaluates true on every run of that
3895
+ // suite (`node --test bin/tests/router.test.mjs`, confirmed passing).
3896
+ // findSeparateGlobalInstall() itself fails closed to null on any error
3897
+ // (see its JSDoc) and is exercised by the "update — global npm install
3898
+ // self-update" suite below via the same resolveGlobalNpmForgedockDir()
3899
+ // helper it shares with isGlobalNpmInstall().
3900
+ if (existsSync(join(FORGE_HOME, ".git"))) {
3901
+ const separateGlobalInstall = findSeparateGlobalInstall();
3902
+ if (separateGlobalInstall) {
3903
+ console.log(
3904
+ ` ${YELLOW}Note: this is the git clone at ${FORGE_HOME}. Your global npm install at ${separateGlobalInstall} may report a different version.${RESET}`,
3905
+ );
3906
+ }
3907
+ }
3908
+
3848
3909
  const latestVersion = await fetchLatestVersion();
3849
3910
  if (latestVersion && localVersion && compareVersions(latestVersion, localVersion) > 0) {
3850
3911
  console.log(
@@ -720,12 +720,34 @@ function checkLabelTransition(command) {
720
720
 
721
721
  // Evidence precondition (#2326): reaching workflow:invalid from
722
722
  // ready-to-build/building/in-review is structurally allowed above, but
723
- // still gated against *casual* invalidation — a bare relabel with no
724
- // paper trail is blocked. Require a posted reversal comment (a second
725
- // FORGE:INVESTIGATOR annotation carrying "**Verdict**: INVALID") already
726
- // on the issue before allowing the transition through, from a trusted
727
- // author (issue #2332 — marker text alone is forgeable by anyone who can
728
- // comment on a public issue; see TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS).
723
+ // still gated against *casual* invalidation — see checkInvalidReversalEvidence()
724
+ // below for the full rationale and evidence requirements.
725
+ const evidenceError = checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments);
726
+ if (evidenceError) return evidenceError;
727
+
728
+ return null; // valid transition — allow
729
+ }
730
+
731
+ /**
732
+ * Evidence precondition (#2326): reaching workflow:invalid from
733
+ * ready-to-build/building/in-review is structurally allowed by the label
734
+ * transition state machine, but still gated against *casual* invalidation —
735
+ * a bare relabel with no paper trail is blocked. Require a posted reversal
736
+ * comment (a second FORGE:INVESTIGATOR annotation carrying "**Verdict**:
737
+ * INVALID") already on the issue before allowing the transition through,
738
+ * from a trusted author (issue #2332 — marker text alone is forgeable by
739
+ * anyone who can comment on a public issue; see
740
+ * TRUSTED_REVERSAL_AUTHOR_ASSOCIATIONS).
741
+ *
742
+ * Extracted from checkLabelTransition() (issue #2453) as a separable
743
+ * sub-concern — same inputs/outputs, no behavior change.
744
+ *
745
+ * @param {string} newLabel
746
+ * @param {string|null} currentWorkflowLabel
747
+ * @param {Array|null} comments
748
+ * @returns {string|null} Error message to show, or null if allowed.
749
+ */
750
+ function checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments) {
729
751
  if (newLabel === "workflow:invalid" && EVIDENCE_REQUIRED_FOR_INVALID_FROM.has(currentWorkflowLabel)) {
730
752
  if (!hasInvalidReversalEvidence(comments)) {
731
753
  return [
@@ -747,7 +769,7 @@ function checkLabelTransition(command) {
747
769
  }
748
770
  }
749
771
 
750
- return null; // valid transition — allow
772
+ return null; // no evidence required, or evidence present — allow
751
773
  }
752
774
 
753
775
  // ---------------------------------------------------------------------------