forgedock 1.3.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.
Files changed (58) 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/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
package/bin/labels.json CHANGED
@@ -64,6 +64,11 @@
64
64
  "color": "E4E669",
65
65
  "description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
66
66
  },
67
+ {
68
+ "name": "workflow:engine-error",
69
+ "color": "B60205",
70
+ "description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
71
+ },
67
72
  {
68
73
  "name": "blocked-on-human-merge",
69
74
  "color": "006B75",
package/bin/runner.mjs CHANGED
@@ -431,9 +431,17 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
431
431
  * Model selection is intentionally NOT forwarded to the CLI in this first
432
432
  * increment — the CLI backend uses whatever model the `claude` CLI itself is
433
433
  * configured for. `opts.model`/FORGEDOCK_MODEL only affects the API backend.
434
- * Likewise, structured token usage is not available the same way the
435
- * Anthropic SDK exposes it; `usage` is reported as `null` (an already
436
- * fully-supported value throughout renderSummaryCard/renderDryRun).
434
+ *
435
+ * Token usage: the invocation requests `--output-format json`, and on a
436
+ * successful exit the captured stdout is parsed as the CLI's single-result
437
+ * JSON envelope. When parsing succeeds and the envelope carries a `usage`
438
+ * object, it is normalized to the same shape the API backend returns
439
+ * (`{input_tokens, output_tokens, cache_creation_input_tokens,
440
+ * cache_read_input_tokens}`, each field `?? 0`). When the output isn't valid
441
+ * JSON (older CLI versions, or a CLI that ignores `--output-format`) or the
442
+ * envelope has no `usage` field, this degrades gracefully to `usage: null`
443
+ * (an already fully-supported value throughout renderSummaryCard/renderDryRun)
444
+ * — this parsing never throws on the success path.
437
445
  *
438
446
  * @param {object} opts
439
447
  * @param {{path: string, name: string, content: string}} opts.spec
@@ -464,8 +472,292 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd() } = {})
464
472
  * This is additive to the existing `bin` override seam above — tests may
465
473
  * still use a real fake-binary-on-disk + real `spawnSync` (the original
466
474
  * seam), or fully mock the call via `spawnFn` (no fake binary needed).
467
- * @returns {{status: string, command: string, iterations: number, stopReason: string, usage: null, model: string, backend: "cli"}}
475
+ * @returns {{status: string, command: string, iterations: number, stopReason: string, usage: ({input_tokens: number, output_tokens: number, cache_creation_input_tokens: number, cache_read_input_tokens: number}|null), model: string, backend: "cli"}}
476
+ */
477
+
478
+ // Diagnostic-only bound on each logged argv element (see sanitizeArgvForLog
479
+ // below) — independent of DEFAULT_SPAWN_MAX_BUFFER_BYTES, which governs the
480
+ // actual child process's stdout/stderr capture, not this summary string.
481
+ const MAX_LOGGED_ARGV_ELEMENT_LEN = 200;
482
+
483
+ // Diagnostic-only bound on the captured-output excerpt embedded directly in
484
+ // a CLI_BACKEND_FAILED error message (forge#2355). Deliberately much larger
485
+ // than MAX_LOGGED_ARGV_ELEMENT_LEN (200 chars) — that bound exists to keep a
486
+ // single argv element's *summary* short, but a truncated-to-200-chars
487
+ // captured-output excerpt would defeat the point of this fix (the whole goal
488
+ // is to give an operator reading the persisted `~/.forge/runs/*.jsonl`
489
+ // run-log enough of the CLI's actual failure text to diagnose it without
490
+ // re-running). 4000 chars is generous enough to carry a real stack
491
+ // trace/error while still bounding JSONL run-log line growth for very
492
+ // verbose CLI failures (issue #2355 AC4).
493
+ const MAX_LOGGED_OUTPUT_EXCERPT_LEN = 4000;
494
+
495
+ /**
496
+ * Escape control/bidi-override characters and cap length, without joining
497
+ * multiple elements. Shared core of `sanitizeArgvForLog` (which maps this
498
+ * over an argv array with a small per-element bound) and
499
+ * `sanitizeOutputExcerptForLog` (which applies it once to a full captured
500
+ * stdout+stderr blob with a much larger bound) — both need the identical
501
+ * escaping and surrogate-pair-safe truncation behavior hardened across
502
+ * #2277/#2292/#2293; extracting it here keeps that behavior in one place
503
+ * instead of duplicating it per bound.
504
+ *
505
+ * @param {string} str
506
+ * @param {number} maxLen
507
+ * @returns {string}
508
+ */
509
+ function sanitizeAndCap(str, maxLen) {
510
+ // eslint-disable-next-line no-control-regex -- intentional: neutralizing C0/DEL/C1 control chars and Unicode bidi-override/format chars is the point of this function
511
+ const escaped = str.replace(/[\x00-\x1F\x7F-\x9F\u202A-\u202E\u2066-\u2069]/g, (ch) => {
512
+ const code = ch.charCodeAt(0);
513
+ return code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u${code.toString(16).padStart(4, "0")}`;
514
+ });
515
+ if (escaped.length <= maxLen) return escaped;
516
+ // `.slice()` counts UTF-16 code units, not Unicode code points. If the cut
517
+ // lands between a high surrogate (\uD800-\uDBFF) and its paired low
518
+ // surrogate (\uDC00-\uDFFF) — e.g. an astral-plane emoji straddling the
519
+ // boundary — a plain slice bisects the pair and leaves a lone/unpaired
520
+ // surrogate at the tail, which can render as U+FFFD or otherwise
521
+ // mis-encode downstream (#2293). Trim one additional unit in that case so
522
+ // the cut never lands mid-pair.
523
+ let cutLen = maxLen;
524
+ const lastCode = escaped.charCodeAt(cutLen - 1);
525
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff) cutLen -= 1;
526
+ return `${escaped.slice(0, cutLen)}…[truncated, ${escaped.length} chars]`;
527
+ }
528
+
529
+ /**
530
+ * Build a safe-for-logs/error-messages summary of a CLI argv array.
531
+ *
532
+ * `cliArgs` (see `runCliBackend` below) includes `userMessage` verbatim, and
533
+ * `userMessage` is built from untrusted third-party content (issue/PR bodies
534
+ * fetched via `gh` — see the SECURITY note in `bin/engine.mjs`). The
535
+ * non-zero-exit diagnostic added in #2258 embeds `cliArgs.join(" ")` directly
536
+ * into a thrown `Error.message`, which `bin/forgedock.mjs`'s `run-issue` case
537
+ * later writes verbatim to stderr (`process.stderr.write(err.message)`) — so
538
+ * a crafted issue/PR body could otherwise inject raw ANSI escape sequences or
539
+ * newline-heavy content into operator-visible CI/terminal logs, or simply
540
+ * balloon log size with an arbitrarily large body (#2277).
541
+ *
542
+ * This function is used ONLY to build the human-readable diagnostic string —
543
+ * it must NEVER be applied to the actual `cliArgs` array passed to `spawnFn`,
544
+ * which must remain byte-exact for the real invocation. It intentionally
545
+ * keeps (rather than removes) the argv/cwd context: that context is exactly
546
+ * what #2258 added to fix a prior defect where a real CLI failure produced a
547
+ * diagnostic pointing at output that didn't exist. The goal here is to bound
548
+ * and neutralize each element, not to delete the context.
549
+ *
550
+ * - Control characters (`\x00`-`\x1F`, `\x7F`-`\x9F` — C0/DEL and C1) are
551
+ * escaped to a visible `\xHH` form, neutralizing ANSI escape sequences and
552
+ * log-line spoofing.
553
+ * - Unicode bidirectional-override/format characters (`‪`-`‮`
554
+ * LRE/RLE/PDF/LRO/RLO and `⁦`-`⁩` LRI/RLI/FSI/PDI) are escaped to
555
+ * a visible `\uHHHH` form, neutralizing visual reordering of the diagnostic
556
+ * line in terminals/log viewers that render bidi controls (#2292).
557
+ * - Each element is independently truncated to `MAX_LOGGED_ARGV_ELEMENT_LEN`
558
+ * characters, with an explicit `…[truncated, N chars]` marker appended
559
+ * when truncation occurs, so log growth is bounded regardless of how large
560
+ * the untrusted `userMessage` is.
561
+ *
562
+ * @param {string[]} cliArgs
563
+ * @returns {string} space-joined, sanitized argv summary
564
+ */
565
+ export function sanitizeArgvForLog(cliArgs) {
566
+ return cliArgs.map((arg) => sanitizeAndCap(String(arg), MAX_LOGGED_ARGV_ELEMENT_LEN)).join(" ");
567
+ }
568
+
569
+ /**
570
+ * Build a safe-for-error-messages excerpt of the CLI's captured stdout+stderr
571
+ * output, for embedding directly in a CLI_BACKEND_FAILED error's message
572
+ * (forge#2355).
573
+ *
574
+ * Prior to this fix, the non-zero-exit diagnostic only *logged* the captured
575
+ * output (`logger.log(diagnostic)` in `runCliBackend` below) and threw a
576
+ * self-referential message pointing at that log line ("See captured output
577
+ * above."). `logger.log()` writes to the orchestrating process's own
578
+ * console/CI stream, which is never persisted into the durable
579
+ * `~/.forge/runs/*.jsonl` run-log — only the thrown `Error.message` (via
580
+ * `bin/engine.mjs`'s `reason: e.message` / fail-fast `detail` string) reaches
581
+ * that persisted record. As a result, the single dominant engine failure mode
582
+ * (50 of 69 `PHASE_FAILED` events in a 52-run-log audit) carried a reason that
583
+ * pointed at output the run-log never captured, leaving operators nothing to
584
+ * diagnose post-hoc. This function embeds a bounded excerpt of the real
585
+ * output directly in the message instead.
586
+ *
587
+ * `output` is raw, untrusted CLI stdout/stderr — the CLI itself echoes
588
+ * untrusted issue/PR body content it was fed (see the SECURITY note on
589
+ * `sanitizeArgvForLog` above) — so it carries the identical injection risk
590
+ * class already hardened for `argvSummary` across #2277/#2292/#2293. This
591
+ * function reuses the exact same `sanitizeAndCap` escaping/truncation core,
592
+ * just with a much larger bound (`MAX_LOGGED_OUTPUT_EXCERPT_LEN`) appropriate
593
+ * for a diagnostic excerpt rather than a short argv summary.
594
+ *
595
+ * @param {string} output - combined, already-captured stdout+stderr text
596
+ * @returns {string} bounded, sanitized excerpt safe to embed in Error.message
597
+ */
598
+ export function sanitizeOutputExcerptForLog(output) {
599
+ return sanitizeAndCap(String(output), MAX_LOGGED_OUTPUT_EXCERPT_LEN);
600
+ }
601
+
602
+ /**
603
+ * Best-effort extraction of a Claude CLI session-limit reset time from
604
+ * captured stdout/stderr (forge#2241). The CLI's own session-limit message
605
+ * looks like `You've hit your session limit · resets 12:50am (Asia/Calcutta)`
606
+ * — when present, this lets a non-zero-exit CLI_BACKEND_FAILED surface *when*
607
+ * the quota resets instead of forcing a human to read raw logs (issue #2241
608
+ * AC3). Deliberately narrow and case-insensitive: anchored on the literal
609
+ * "session limit" phrase plus a trailing "resets ..." clause, so it can never
610
+ * misattribute an unrelated crash's output as a reset time (see #2258/#2277/
611
+ * #2292/#2293 — this same diagnostic branch has repeatedly needed defensive
612
+ * narrowing around untrusted CLI output).
613
+ *
614
+ * @param {string} output - combined, already-captured stdout+stderr text
615
+ * @returns {string|undefined} the reset-time text (trimmed), or undefined if
616
+ * no session-limit pattern was found — callers must not fabricate a value
617
+ * when this returns undefined.
618
+ */
619
+ export function extractSessionLimitResetTime(output) {
620
+ if (!output) return undefined;
621
+ const match = /session limit[^\n]*?resets?\s+([^\n]+?)\s*$/im.exec(output);
622
+ if (!match) return undefined;
623
+ const resetAt = match[1].trim();
624
+ if (resetAt.length === 0) return undefined;
625
+ // Security review finding (forge#2241, PR #2323): `resetAt` is extracted
626
+ // from raw, untrusted CLI stdout/stderr — the same `output` that #2277/
627
+ // #2292/#2293 fixed control-char/bidi-override/surrogate-pair injection
628
+ // for in the neighboring CLI_BACKEND_FAILED diagnostic. Route it through
629
+ // the same sanitizeArgvForLog() escaping + length-capping used for
630
+ // argvSummary before it can reach err.resetAt -> the engine-error
631
+ // terminate detail -> operator-visible terminal/CI logs, so this new call
632
+ // site inherits the identical defensive posture instead of reopening that
633
+ // threat model via a fresh, unsanitized path.
634
+ return sanitizeArgvForLog([resetAt]);
635
+ }
636
+
637
+ /**
638
+ * Computes the UTC offset (in ms) `timeZone` observes at `instantMs`, defined
639
+ * such that `wallClockAsUTC = instantMs + offset` — i.e. a positive offset
640
+ * means the zone is ahead of UTC at that instant. Used by
641
+ * `wallTimeInZoneToEpochMs()` below to convert a local wall-clock time back
642
+ * into a real UTC epoch, correctly handling DST for zones that observe it
643
+ * (though the only zone shape actually seen in production CLI output today —
644
+ * `Asia/Calcutta`, a fixed +5:30 IANA link name with no DST — never needs the
645
+ * iteration this enables; the general form is kept so any zone name the CLI
646
+ * reports is handled correctly, not just the one observed so far).
647
+ *
648
+ * @param {number} instantMs - a real UTC epoch instant
649
+ * @param {string} timeZone - IANA zone name (e.g. "Asia/Calcutta")
650
+ * @returns {number} offset in ms
651
+ */
652
+ function tzOffsetMsAtInstant(instantMs, timeZone) {
653
+ const dtf = new Intl.DateTimeFormat("en-US", {
654
+ timeZone,
655
+ hourCycle: "h23",
656
+ year: "numeric", month: "2-digit", day: "2-digit",
657
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
658
+ });
659
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(instantMs)).map((p) => [p.type, p.value]));
660
+ const localAsUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
661
+ return localAsUTC - instantMs;
662
+ }
663
+
664
+ /**
665
+ * Converts a local wall-clock time `(y, mo, d, h, mi, s)` *in* `timeZone*
666
+ * into the real UTC epoch ms instant it represents. Two-iteration
667
+ * fixed-point refinement using `tzOffsetMsAtInstant()` above — sufficient
668
+ * for every real-world zone (including DST transitions, which shift the
669
+ * offset by at most a couple of hours, well within one refinement step).
670
+ *
671
+ * @returns {number} UTC epoch ms
672
+ */
673
+ function wallTimeInZoneToEpochMs(y, mo, d, h, mi, s, timeZone) {
674
+ const naiveUtc = Date.UTC(y, mo - 1, d, h, mi, s);
675
+ let guess = naiveUtc;
676
+ for (let i = 0; i < 2; i++) {
677
+ guess = naiveUtc - tzOffsetMsAtInstant(guess, timeZone);
678
+ }
679
+ return guess;
680
+ }
681
+
682
+ /**
683
+ * Parses the CLI's session-limit reset-time text (forge#2241's
684
+ * `extractSessionLimitResetTime()` output — e.g. `"12:50am (Asia/Calcutta)"`)
685
+ * into a machine-usable UTC epoch ms timestamp (forge#2524 AC5). The CLI only
686
+ * ever reports a bare time-of-day plus an IANA zone name, never a date — the
687
+ * reset is always the *next* occurrence of that wall-clock time from `nowMs`,
688
+ * so this resolves "today" in the target zone and rolls forward one day if
689
+ * that candidate has already passed.
690
+ *
691
+ * Deliberately narrow, mirroring `extractSessionLimitResetTime()`'s own
692
+ * "never fabricate a value" contract: returns `undefined` on any input that
693
+ * doesn't match the exact expected shape, or names a timezone
694
+ * `Intl.DateTimeFormat` doesn't recognize — callers must treat `undefined` as
695
+ * "could not compute a wait duration", never substitute a guessed default.
696
+ *
697
+ * @param {string|undefined} resetAtText - the (already-sanitized) display
698
+ * string from `extractSessionLimitResetTime()`
699
+ * @param {number} [nowMs] - current time; defaults to `Date.now()`, overridable for tests
700
+ * @returns {number|undefined} UTC epoch ms of the next occurrence of that
701
+ * wall-clock time in that zone, or `undefined` if unparseable
468
702
  */
703
+ export function parseSessionLimitResetEpochMs(resetAtText, nowMs = Date.now()) {
704
+ if (!resetAtText) return undefined;
705
+ const match = /^(\d{1,2}):(\d{2})\s*([ap]m)\s*\(([^)]+)\)\s*$/i.exec(resetAtText.trim());
706
+ if (!match) return undefined;
707
+ const [, hourStr, minuteStr, ampm, timeZone] = match;
708
+ const rawHour = parseInt(hourStr, 10);
709
+ // Reject out-of-range hours BEFORE the `% 12` conversion below — otherwise
710
+ // malformed input like "13:30pm" or "99:30pm" silently aliases to a
711
+ // plausible-but-wrong hour (13 % 12 = 1, 99 % 12 = 3) instead of being
712
+ // rejected, violating this function's own "never fabricate a value"
713
+ // contract. Mirrors the `minute > 59` guard further down.
714
+ if (rawHour < 1 || rawHour > 12) return undefined;
715
+ let hour = rawHour % 12;
716
+ if (/pm/i.test(ampm)) hour += 12;
717
+ const minute = parseInt(minuteStr, 10);
718
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || minute > 59) return undefined;
719
+
720
+ let todayParts;
721
+ try {
722
+ const dtf = new Intl.DateTimeFormat("en-US", {
723
+ timeZone, year: "numeric", month: "2-digit", day: "2-digit",
724
+ });
725
+ todayParts = Object.fromEntries(dtf.formatToParts(new Date(nowMs)).map((p) => [p.type, p.value]));
726
+ } catch {
727
+ // Unrecognized IANA zone name — Intl throws a RangeError. Do not fabricate
728
+ // a value; the caller must treat this identically to any other parse failure.
729
+ return undefined;
730
+ }
731
+
732
+ const y = +todayParts.year, mo = +todayParts.month, d = +todayParts.day;
733
+ let epoch = wallTimeInZoneToEpochMs(y, mo, d, hour, minute, 0, timeZone);
734
+ // The CLI reports a reset time that is, by definition, in the future
735
+ // relative to when it printed the message — if today's candidate has
736
+ // already passed `nowMs`, the real reset is tomorrow at the same wall-clock time.
737
+ if (epoch <= nowMs) {
738
+ epoch = wallTimeInZoneToEpochMs(y, mo, d + 1, hour, minute, 0, timeZone);
739
+ }
740
+ return epoch;
741
+ }
742
+
743
+ /**
744
+ * Coerce a single CLI-reported `usage.*` field to a finite number, or `0`
745
+ * when it isn't one (forge#2424). `?? 0` only replaces `null`/`undefined` —
746
+ * it does not coerce or reject other non-numeric values (e.g. a string), so
747
+ * a malformed/unexpected field previously flowed through unchanged and later
748
+ * string-concatenated instead of numerically adding in
749
+ * bin/batch-runner.mjs's `tokenCost()` (`input + output`). Applied to all
750
+ * four `usage.*` fields parsed from the CLI's `--output-format json`
751
+ * envelope, which — unlike the API backend's SDK-typed `response.usage` — is
752
+ * arbitrary CLI stdout and must not be trusted to already be well-typed.
753
+ *
754
+ * @param {unknown} value - raw field value from the parsed CLI JSON envelope
755
+ * @returns {number} a finite number, or `0` when `value` is not one
756
+ */
757
+ function toFiniteUsageNumber(value) {
758
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
759
+ }
760
+
469
761
  export function runCliBackend({
470
762
  spec,
471
763
  userMessage,
@@ -495,7 +787,13 @@ export function runCliBackend({
495
787
  // `writeFileSync` still leaves `tmpDir` set and the already-created
496
788
  // directory gets cleaned up.
497
789
  let tmpDir = null;
498
- let cliArgs = ["--print", userMessage, "--dangerously-skip-permissions"];
790
+ let cliArgs = [
791
+ "--print",
792
+ userMessage,
793
+ "--output-format",
794
+ "json",
795
+ "--dangerously-skip-permissions",
796
+ ];
499
797
 
500
798
  try {
501
799
  if (systemPrompt) {
@@ -506,6 +804,8 @@ export function runCliBackend({
506
804
  cliArgs = [
507
805
  "--print",
508
806
  userMessage,
807
+ "--output-format",
808
+ "json",
509
809
  "--append-system-prompt-file",
510
810
  systemPromptPath,
511
811
  "--dangerously-skip-permissions",
@@ -545,9 +845,25 @@ export function runCliBackend({
545
845
  const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
546
846
  if (timedOut) {
547
847
  const timeoutSecs = Math.round(timeoutMs / 1000);
848
+ // forge#2360: spawnSync's `timeout` option kills the child outright on
849
+ // expiry, but any stdout/stderr the CLI had already produced up to
850
+ // that point is still populated on `result` — surface it rather than
851
+ // silently discarding it, mirroring both the `run_bash` timeout path
852
+ // below (which appends its own captured `partial` output) and the
853
+ // sibling `result.status !== 0` branch just below this one (forge#2355
854
+ // / PR #2374), which embeds a bounded/sanitized excerpt of captured
855
+ // output via `sanitizeOutputExcerptForLog()` for the same reason: only
856
+ // `err.message` is persisted into the durable `~/.forge/runs/*.jsonl`
857
+ // run-log (via bin/engine.mjs's `reason: e.message`), never the
858
+ // `logger.log()` call. Reuses the exact same hardened helper — do NOT
859
+ // add a new unsanitized/unbounded path here (this file has 4 prior
860
+ // review findings — #2277, #2292, #2293, #2355 — for that defect
861
+ // class).
862
+ const partial = (stdout + stderr).trim();
548
863
  throw new Error(
549
864
  `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
550
- `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.`,
865
+ `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
866
+ (partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
551
867
  );
552
868
  }
553
869
  if (result.error) {
@@ -555,23 +871,175 @@ export function runCliBackend({
555
871
  }
556
872
 
557
873
  const output = (stdout + stderr).trim();
558
- if (output) logger.log(output);
559
874
 
560
875
  if (result.status !== 0) {
876
+ // Non-zero exit: always emit a self-contained diagnostic, regardless of
877
+ // whether stdout/stderr captured anything. Previously this branch threw
878
+ // a self-referential message that unconditionally pointed to
879
+ // previously-logged output, even when `output` was empty (the
880
+ // success-path log call above was gated on `if (output)` and never
881
+ // ran) -- leaving the operator with nothing to consult. See issue
882
+ // #2258 / parent #2244.
883
+ const hadOutput = output.length > 0;
884
+ const signalPart = result.signal ? `, signal ${result.signal}` : "";
885
+ // Sanitized for the diagnostic string ONLY — the actual `cliArgs` array
886
+ // above (passed to spawnFn) is untouched. See sanitizeArgvForLog's doc
887
+ // comment for why (#2277 — untrusted userMessage content must not reach
888
+ // stderr/CI logs unbounded/unescaped).
889
+ const argvSummary = sanitizeArgvForLog(cliArgs);
890
+ const diagnostic = hadOutput
891
+ ? `Captured output (stdout+stderr):\n${output}`
892
+ : "No output was captured on stdout or stderr.";
893
+ logger.log(diagnostic);
894
+
895
+ // forge#2355: when `hadOutput` is true, embed a bounded/sanitized
896
+ // excerpt of the actual captured output directly in the thrown
897
+ // Error's message, rather than only pointing at the `logger.log()`
898
+ // call above. `logger.log()` writes to the orchestrating process's
899
+ // console/CI stream — it is NEVER persisted into the durable
900
+ // `~/.forge/runs/*.jsonl` run-log. Only `e.message` (via
901
+ // `bin/engine.mjs`'s `reason: e.message` / fail-fast `detail` string,
902
+ // both of which already interpolate `e.message` verbatim) reaches that
903
+ // persisted record. Without this, the run-log's `PHASE_FAILED.reason`
904
+ // carried a self-referential pointer to output that was already gone
905
+ // by the time anyone read the log — this was the single dominant
906
+ // engine failure mode (50 of 69 `PHASE_FAILED` events in a 52-run-log
907
+ // audit — see issue #2355). The `!hadOutput` branch below is
908
+ // intentionally left unchanged: it was already fixed by #2258/PR #2276
909
+ // to be self-contained.
910
+ const outputExcerpt = hadOutput ? sanitizeOutputExcerptForLog(output) : "";
561
911
  const err = new Error(
562
- `claude CLI exited with status ${result.status ?? "?"}. See output above for details.`,
912
+ `claude CLI exited with status ${result.status ?? "?"}${signalPart}. ` +
913
+ (hadOutput
914
+ ? `Output: ${outputExcerpt}`
915
+ : "No output was captured (stdout and stderr were both empty).") +
916
+ ` Invocation: ${bin} ${argvSummary} (cwd: ${cwd})`,
563
917
  );
564
918
  err.code = "CLI_BACKEND_FAILED";
919
+ // forge#2241: attach the CLI's reported session-limit reset time (when
920
+ // present) so callers (bin/engine.mjs) can surface *when* a
921
+ // quota-exhaustion failure will clear without reading raw logs. Only
922
+ // ever set when the pattern actually matches — never fabricated.
923
+ const resetAt = extractSessionLimitResetTime(output);
924
+ if (resetAt) {
925
+ err.resetAt = resetAt;
926
+ // forge#2524: also attach a machine-usable epoch-ms timestamp so
927
+ // bin/engine.mjs can compute an actual wait duration and pause
928
+ // in-process instead of merely displaying the reset time. Only ever
929
+ // set when parsing succeeds — never fabricated (mirrors resetAt's
930
+ // own contract). A future/unparseable reset time simply leaves this
931
+ // field absent, and the caller's fail-fast/terminate behavior is
932
+ // unaffected (see bin/engine.mjs's runIssue() pause-loop guard).
933
+ const resetAtEpochMs = parseSessionLimitResetEpochMs(resetAt, Date.now());
934
+ if (resetAtEpochMs !== undefined) err.resetAtEpochMs = resetAtEpochMs;
935
+ }
565
936
  throw err;
566
937
  }
567
938
 
939
+ // Parse the `--output-format json` single-result envelope requested
940
+ // above. `claude --print --output-format json` emits a JSON object with
941
+ // (among other fields) a top-level `result` string — the same
942
+ // human-readable text the CLI would otherwise print in plain-text mode —
943
+ // and a top-level `usage` object shaped like the Anthropic SDK's
944
+ // `response.usage` (`input_tokens`/`output_tokens`/
945
+ // `cache_creation_input_tokens`/`cache_read_input_tokens`, plus extra
946
+ // fields we don't need). Older CLI versions that ignore
947
+ // `--output-format` (or any other non-JSON stdout) are handled
948
+ // defensively: any parse failure, or a parsed value missing `usage`,
949
+ // degrades to the pre-existing `usage: null` behavior — this must never
950
+ // throw on the success path.
951
+ //
952
+ // forge#2422: parse `stdout` ALONE here, not `output` (the combined
953
+ // `stdout + stderr` string used above for the timeout/non-zero-exit
954
+ // diagnostics). `--output-format json` writes exactly one JSON object to
955
+ // stdout; any warning/banner text a CLI version writes to stderr on an
956
+ // otherwise-clean, zero-exit run (deprecation notice, Node
957
+ // `--trace-warnings`, etc.) would corrupt the combined string and break
958
+ // `JSON.parse`, silently degrading `usage` to `null` even though valid
959
+ // JSON was on stdout. `output` itself is untouched — the diagnostic
960
+ // branches above still need the combined stream.
961
+ //
962
+ // forge#2424: each usage field is coerced with `toFiniteUsageNumber()`
963
+ // rather than `?? 0`. `?? 0` only replaces `null`/`undefined` — a
964
+ // non-numeric value (e.g. a string) would pass through unchanged and
965
+ // later string-concatenate instead of numerically add in
966
+ // bin/batch-runner.mjs's `tokenCost()` (`input + output`).
967
+ const stdoutTrimmed = stdout.trim();
968
+ let parsedResult = null;
969
+ let usage = null;
970
+ try {
971
+ const parsed = JSON.parse(stdoutTrimmed);
972
+ if (parsed && typeof parsed === "object") {
973
+ parsedResult = typeof parsed.result === "string" ? parsed.result : null;
974
+ if (parsed.usage && typeof parsed.usage === "object") {
975
+ usage = {
976
+ input_tokens: toFiniteUsageNumber(parsed.usage.input_tokens),
977
+ output_tokens: toFiniteUsageNumber(parsed.usage.output_tokens),
978
+ cache_creation_input_tokens: toFiniteUsageNumber(
979
+ parsed.usage.cache_creation_input_tokens,
980
+ ),
981
+ cache_read_input_tokens: toFiniteUsageNumber(parsed.usage.cache_read_input_tokens),
982
+ };
983
+ }
984
+ }
985
+ } catch {
986
+ // Non-JSON output (older CLI, or --output-format was ignored) —
987
+ // parsedResult/usage stay null; fall back to raw output below.
988
+ }
989
+
990
+ // Prefer the parsed envelope's human-readable `.result` string so
991
+ // console output stays prose, not a raw JSON blob; fall back to the raw
992
+ // captured output when parsing failed or `.result` was absent.
993
+ //
994
+ // forge#2456: `.result` alone would otherwise silently drop any non-empty
995
+ // `stderr` on an exit-0 run (deprecation notice, Node runtime banner,
996
+ // etc.) — a real behavior change from the pre-#2398 baseline, where the
997
+ // combined stdout+stderr stream was always logged. Append trimmed
998
+ // `stderr` after the parsed result when present, so operators still see
999
+ // it; when `stderr` is empty (the common case) the logged string is
1000
+ // unchanged. This mirrors the same "combine streams instead of dropping
1001
+ // one" fix already applied to the `run_bash` tool handler's success path
1002
+ // in #1229. `JSON.parse` itself still targets `stdout` alone (forge#2422)
1003
+ // — only the logged/displayed string composition changes here.
1004
+ //
1005
+ // forge#2483/forge#2484 (batch #2522): two follow-up fixes to the
1006
+ // forge#2456 composition above, applied together since they share the
1007
+ // exact same lines:
1008
+ // - forge#2483: `stderrTrimmed` is untrusted subprocess output (the CLI
1009
+ // echoes untrusted issue/PR body content — the same threat class
1010
+ // `sanitizeArgvForLog`/`sanitizeOutputExcerptForLog` already harden
1011
+ // for the timeout/non-zero-exit diagnostic paths above). It is now
1012
+ // routed through the same `sanitizeOutputExcerptForLog()` helper
1013
+ // before being concatenated into `humanOutput`, instead of reaching
1014
+ // `logger.log()` raw.
1015
+ // - forge#2484: `parsedResult !== null` is true even when
1016
+ // `parsedResult === ""` (the earlier `typeof parsed.result ===
1017
+ // "string"` check accepts `""`). Previously that produced
1018
+ // `"" + "\n" + stderrTrimmed` — a leading-newline-only artifact.
1019
+ // `parsedResult` (truthy check, not `!== null`) now gates the
1020
+ // "prefix with parsedResult" behavior, so an empty `.result` falls
1021
+ // through to the sanitized stderr alone, with no separator.
1022
+ // The `parsedResult === null` fallback branch (raw `output`) is
1023
+ // untouched by either fix — out of scope for both findings.
1024
+ const stderrTrimmed = stderr.trim();
1025
+ const sanitizedStderr = stderrTrimmed ? sanitizeOutputExcerptForLog(stderrTrimmed) : "";
1026
+ const humanOutput =
1027
+ parsedResult !== null
1028
+ ? parsedResult
1029
+ ? sanitizedStderr
1030
+ ? `${parsedResult}\n${sanitizedStderr}`
1031
+ : parsedResult
1032
+ : sanitizedStderr
1033
+ : output;
1034
+ if (humanOutput) logger.log(humanOutput);
1035
+
568
1036
  logger.log(
569
1037
  renderSummaryCard({
570
1038
  command: spec.name,
571
1039
  args,
572
1040
  iterations: 1,
573
1041
  stopReason: "cli_exit_0",
574
- usage: null,
1042
+ usage,
575
1043
  }),
576
1044
  );
577
1045
 
@@ -580,7 +1048,7 @@ export function runCliBackend({
580
1048
  command: spec.name,
581
1049
  iterations: 1,
582
1050
  stopReason: "cli_exit_0",
583
- usage: null,
1051
+ usage,
584
1052
  model: "cli",
585
1053
  backend: "cli",
586
1054
  };