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.
Files changed (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
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,186 @@ 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
+ * Coerce a single CLI-reported `usage.*` field to a finite number, or `0`
639
+ * when it isn't one (forge#2424). `?? 0` only replaces `null`/`undefined` —
640
+ * it does not coerce or reject other non-numeric values (e.g. a string), so
641
+ * a malformed/unexpected field previously flowed through unchanged and later
642
+ * string-concatenated instead of numerically adding in
643
+ * bin/batch-runner.mjs's `tokenCost()` (`input + output`). Applied to all
644
+ * four `usage.*` fields parsed from the CLI's `--output-format json`
645
+ * envelope, which — unlike the API backend's SDK-typed `response.usage` — is
646
+ * arbitrary CLI stdout and must not be trusted to already be well-typed.
647
+ *
648
+ * @param {unknown} value - raw field value from the parsed CLI JSON envelope
649
+ * @returns {number} a finite number, or `0` when `value` is not one
468
650
  */
651
+ function toFiniteUsageNumber(value) {
652
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
653
+ }
654
+
469
655
  export function runCliBackend({
470
656
  spec,
471
657
  userMessage,
@@ -495,7 +681,13 @@ export function runCliBackend({
495
681
  // `writeFileSync` still leaves `tmpDir` set and the already-created
496
682
  // directory gets cleaned up.
497
683
  let tmpDir = null;
498
- let cliArgs = ["--print", userMessage, "--dangerously-skip-permissions"];
684
+ let cliArgs = [
685
+ "--print",
686
+ userMessage,
687
+ "--output-format",
688
+ "json",
689
+ "--dangerously-skip-permissions",
690
+ ];
499
691
 
500
692
  try {
501
693
  if (systemPrompt) {
@@ -506,6 +698,8 @@ export function runCliBackend({
506
698
  cliArgs = [
507
699
  "--print",
508
700
  userMessage,
701
+ "--output-format",
702
+ "json",
509
703
  "--append-system-prompt-file",
510
704
  systemPromptPath,
511
705
  "--dangerously-skip-permissions",
@@ -545,9 +739,25 @@ export function runCliBackend({
545
739
  const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
546
740
  if (timedOut) {
547
741
  const timeoutSecs = Math.round(timeoutMs / 1000);
742
+ // forge#2360: spawnSync's `timeout` option kills the child outright on
743
+ // expiry, but any stdout/stderr the CLI had already produced up to
744
+ // that point is still populated on `result` — surface it rather than
745
+ // silently discarding it, mirroring both the `run_bash` timeout path
746
+ // below (which appends its own captured `partial` output) and the
747
+ // sibling `result.status !== 0` branch just below this one (forge#2355
748
+ // / PR #2374), which embeds a bounded/sanitized excerpt of captured
749
+ // output via `sanitizeOutputExcerptForLog()` for the same reason: only
750
+ // `err.message` is persisted into the durable `~/.forge/runs/*.jsonl`
751
+ // run-log (via bin/engine.mjs's `reason: e.message`), never the
752
+ // `logger.log()` call. Reuses the exact same hardened helper — do NOT
753
+ // add a new unsanitized/unbounded path here (this file has 4 prior
754
+ // review findings — #2277, #2292, #2293, #2355 — for that defect
755
+ // class).
756
+ const partial = (stdout + stderr).trim();
548
757
  throw new Error(
549
758
  `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
550
- `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.`,
759
+ `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
760
+ (partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
551
761
  );
552
762
  }
553
763
  if (result.error) {
@@ -555,23 +765,124 @@ export function runCliBackend({
555
765
  }
556
766
 
557
767
  const output = (stdout + stderr).trim();
558
- if (output) logger.log(output);
559
768
 
560
769
  if (result.status !== 0) {
770
+ // Non-zero exit: always emit a self-contained diagnostic, regardless of
771
+ // whether stdout/stderr captured anything. Previously this branch threw
772
+ // a self-referential message that unconditionally pointed to
773
+ // previously-logged output, even when `output` was empty (the
774
+ // success-path log call above was gated on `if (output)` and never
775
+ // ran) -- leaving the operator with nothing to consult. See issue
776
+ // #2258 / parent #2244.
777
+ const hadOutput = output.length > 0;
778
+ const signalPart = result.signal ? `, signal ${result.signal}` : "";
779
+ // Sanitized for the diagnostic string ONLY — the actual `cliArgs` array
780
+ // above (passed to spawnFn) is untouched. See sanitizeArgvForLog's doc
781
+ // comment for why (#2277 — untrusted userMessage content must not reach
782
+ // stderr/CI logs unbounded/unescaped).
783
+ const argvSummary = sanitizeArgvForLog(cliArgs);
784
+ const diagnostic = hadOutput
785
+ ? `Captured output (stdout+stderr):\n${output}`
786
+ : "No output was captured on stdout or stderr.";
787
+ logger.log(diagnostic);
788
+
789
+ // forge#2355: when `hadOutput` is true, embed a bounded/sanitized
790
+ // excerpt of the actual captured output directly in the thrown
791
+ // Error's message, rather than only pointing at the `logger.log()`
792
+ // call above. `logger.log()` writes to the orchestrating process's
793
+ // console/CI stream — it is NEVER persisted into the durable
794
+ // `~/.forge/runs/*.jsonl` run-log. Only `e.message` (via
795
+ // `bin/engine.mjs`'s `reason: e.message` / fail-fast `detail` string,
796
+ // both of which already interpolate `e.message` verbatim) reaches that
797
+ // persisted record. Without this, the run-log's `PHASE_FAILED.reason`
798
+ // carried a self-referential pointer to output that was already gone
799
+ // by the time anyone read the log — this was the single dominant
800
+ // engine failure mode (50 of 69 `PHASE_FAILED` events in a 52-run-log
801
+ // audit — see issue #2355). The `!hadOutput` branch below is
802
+ // intentionally left unchanged: it was already fixed by #2258/PR #2276
803
+ // to be self-contained.
804
+ const outputExcerpt = hadOutput ? sanitizeOutputExcerptForLog(output) : "";
561
805
  const err = new Error(
562
- `claude CLI exited with status ${result.status ?? "?"}. See output above for details.`,
806
+ `claude CLI exited with status ${result.status ?? "?"}${signalPart}. ` +
807
+ (hadOutput
808
+ ? `Output: ${outputExcerpt}`
809
+ : "No output was captured (stdout and stderr were both empty).") +
810
+ ` Invocation: ${bin} ${argvSummary} (cwd: ${cwd})`,
563
811
  );
564
812
  err.code = "CLI_BACKEND_FAILED";
813
+ // forge#2241: attach the CLI's reported session-limit reset time (when
814
+ // present) so callers (bin/engine.mjs) can surface *when* a
815
+ // quota-exhaustion failure will clear without reading raw logs. Only
816
+ // ever set when the pattern actually matches — never fabricated.
817
+ const resetAt = extractSessionLimitResetTime(output);
818
+ if (resetAt) err.resetAt = resetAt;
565
819
  throw err;
566
820
  }
567
821
 
822
+ // Parse the `--output-format json` single-result envelope requested
823
+ // above. `claude --print --output-format json` emits a JSON object with
824
+ // (among other fields) a top-level `result` string — the same
825
+ // human-readable text the CLI would otherwise print in plain-text mode —
826
+ // and a top-level `usage` object shaped like the Anthropic SDK's
827
+ // `response.usage` (`input_tokens`/`output_tokens`/
828
+ // `cache_creation_input_tokens`/`cache_read_input_tokens`, plus extra
829
+ // fields we don't need). Older CLI versions that ignore
830
+ // `--output-format` (or any other non-JSON stdout) are handled
831
+ // defensively: any parse failure, or a parsed value missing `usage`,
832
+ // degrades to the pre-existing `usage: null` behavior — this must never
833
+ // throw on the success path.
834
+ //
835
+ // forge#2422: parse `stdout` ALONE here, not `output` (the combined
836
+ // `stdout + stderr` string used above for the timeout/non-zero-exit
837
+ // diagnostics). `--output-format json` writes exactly one JSON object to
838
+ // stdout; any warning/banner text a CLI version writes to stderr on an
839
+ // otherwise-clean, zero-exit run (deprecation notice, Node
840
+ // `--trace-warnings`, etc.) would corrupt the combined string and break
841
+ // `JSON.parse`, silently degrading `usage` to `null` even though valid
842
+ // JSON was on stdout. `output` itself is untouched — the diagnostic
843
+ // branches above still need the combined stream.
844
+ //
845
+ // forge#2424: each usage field is coerced with `toFiniteUsageNumber()`
846
+ // rather than `?? 0`. `?? 0` only replaces `null`/`undefined` — a
847
+ // non-numeric value (e.g. a string) would pass through unchanged and
848
+ // later string-concatenate instead of numerically add in
849
+ // bin/batch-runner.mjs's `tokenCost()` (`input + output`).
850
+ const stdoutTrimmed = stdout.trim();
851
+ let parsedResult = null;
852
+ let usage = null;
853
+ try {
854
+ const parsed = JSON.parse(stdoutTrimmed);
855
+ if (parsed && typeof parsed === "object") {
856
+ parsedResult = typeof parsed.result === "string" ? parsed.result : null;
857
+ if (parsed.usage && typeof parsed.usage === "object") {
858
+ usage = {
859
+ input_tokens: toFiniteUsageNumber(parsed.usage.input_tokens),
860
+ output_tokens: toFiniteUsageNumber(parsed.usage.output_tokens),
861
+ cache_creation_input_tokens: toFiniteUsageNumber(
862
+ parsed.usage.cache_creation_input_tokens,
863
+ ),
864
+ cache_read_input_tokens: toFiniteUsageNumber(parsed.usage.cache_read_input_tokens),
865
+ };
866
+ }
867
+ }
868
+ } catch {
869
+ // Non-JSON output (older CLI, or --output-format was ignored) —
870
+ // parsedResult/usage stay null; fall back to raw output below.
871
+ }
872
+
873
+ // Prefer the parsed envelope's human-readable `.result` string so
874
+ // console output stays prose, not a raw JSON blob; fall back to the raw
875
+ // captured output when parsing failed or `.result` was absent.
876
+ const humanOutput = parsedResult ?? output;
877
+ if (humanOutput) logger.log(humanOutput);
878
+
568
879
  logger.log(
569
880
  renderSummaryCard({
570
881
  command: spec.name,
571
882
  args,
572
883
  iterations: 1,
573
884
  stopReason: "cli_exit_0",
574
- usage: null,
885
+ usage,
575
886
  }),
576
887
  );
577
888
 
@@ -580,7 +891,7 @@ export function runCliBackend({
580
891
  command: spec.name,
581
892
  iterations: 1,
582
893
  stopReason: "cli_exit_0",
583
- usage: null,
894
+ usage,
584
895
  model: "cli",
585
896
  backend: "cli",
586
897
  };
@@ -0,0 +1,255 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ /**
5
+ * Pure, independently-testable helpers for `forgedock watch` (bin/forgedock.mjs).
6
+ *
7
+ * Extracted out of watch()/render() (forge#2235) so the label-derivation,
8
+ * findings-lane classification, priority normalization, satellite-repo
9
+ * parsing, and heartbeat-batching logic can be unit tested without spawning
10
+ * `gh` subprocesses. None of these functions perform I/O themselves — the
11
+ * caller in forgedock.mjs reads files / shells out and passes the results in.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "fs";
15
+
16
+ /**
17
+ * Conservative built-in fallback for the watched label set, used only when
18
+ * bin/labels.json is missing or unreadable. Historically this was the ONLY
19
+ * label set watch() ever used (forge#2235) — kept here so a broken/missing
20
+ * manifest degrades to the old (narrow but working) behavior instead of
21
+ * watch() going fully blind.
22
+ */
23
+ export const FALLBACK_WORKFLOW_LABELS = [
24
+ "workflow:investigating",
25
+ "workflow:ready-to-build",
26
+ "workflow:building",
27
+ "workflow:in-review",
28
+ "needs-human",
29
+ ];
30
+
31
+ /**
32
+ * The label that marks a review-finding issue, watched in its own lane
33
+ * (forge#2235) independent of workflow:* state — a finding is born without
34
+ * any workflow:* label and may never acquire one (deferred/PERMANENT_DEFERRED
35
+ * findings), so it must not depend on the in-flight label set to be visible.
36
+ */
37
+ export const FINDINGS_LANE_LABEL = "review-finding";
38
+
39
+ /**
40
+ * Derive the watched "in-flight" label set from the canonical bin/labels.json
41
+ * manifest instead of a hardcoded literal (forge#2235). Returns every label
42
+ * whose name starts with "workflow:", plus "needs-human" (the terminal-stall
43
+ * pipeline label, not itself prefixed workflow: but part of the same family).
44
+ *
45
+ * Falls back to FALLBACK_WORKFLOW_LABELS if the manifest is absent, unreadable,
46
+ * or malformed, so a broken manifest never makes watch() go silent.
47
+ *
48
+ * @param {string} labelsJsonPath - absolute path to bin/labels.json
49
+ * @returns {string[]}
50
+ */
51
+ export function deriveWorkflowLabels(labelsJsonPath) {
52
+ try {
53
+ if (!existsSync(labelsJsonPath)) return FALLBACK_WORKFLOW_LABELS;
54
+ const raw = readFileSync(labelsJsonPath, "utf-8");
55
+ const all = JSON.parse(raw);
56
+ if (!Array.isArray(all)) return FALLBACK_WORKFLOW_LABELS;
57
+ const derived = all
58
+ .map((l) => l && l.name)
59
+ .filter((name) => typeof name === "string" && (name.startsWith("workflow:") || name === "needs-human"));
60
+ return derived.length > 0 ? derived : FALLBACK_WORKFLOW_LABELS;
61
+ } catch {
62
+ return FALLBACK_WORKFLOW_LABELS;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Normalize a mixed label array (strings or {name} objects, as returned by
68
+ * `gh issue list --json labels`) into a plain array of name strings.
69
+ * @param {Array<string|{name: string}>} labels
70
+ * @returns {string[]}
71
+ */
72
+ function labelNames(labels) {
73
+ if (!Array.isArray(labels)) return [];
74
+ return labels.map((l) => (typeof l === "string" ? l : l && l.name)).filter(Boolean);
75
+ }
76
+
77
+ /**
78
+ * Classify a review-finding issue's render status in the findings lane from
79
+ * its full label set (forge#2235). Callers should only invoke this for
80
+ * issues that do NOT carry a workflow:* label — those are in-flight and
81
+ * belong in the main table, not the findings lane, to avoid double-rendering
82
+ * the same issue in two places.
83
+ *
84
+ * - "validated" — confirmed as a real issue (label: validated)
85
+ * - "false-positive" — dismissed (label: false-positive)
86
+ * - "queued" — awaiting human validation (label: needs-validation)
87
+ * - "deferred" — bare review-finding with none of the above; this is
88
+ * the previously-invisible "PERMANENT_DEFERRED" bucket
89
+ *
90
+ * @param {Array<string|{name: string}>} labels
91
+ * @returns {"validated"|"false-positive"|"queued"|"deferred"}
92
+ */
93
+ export function classifyFindingStatus(labels) {
94
+ const names = labelNames(labels);
95
+ if (names.includes("validated")) return "validated";
96
+ if (names.includes("false-positive")) return "false-positive";
97
+ if (names.includes("needs-validation")) return "queued";
98
+ return "deferred";
99
+ }
100
+
101
+ /**
102
+ * True if the given label set carries any workflow:*-family label (i.e. the
103
+ * issue is "in-flight" per deriveWorkflowLabels()'s family, not just the
104
+ * literal "workflow:" prefix check — needs-human counts too).
105
+ *
106
+ * @param {Array<string|{name: string}>} labels
107
+ * @param {string[]} workflowLabels - the label set returned by deriveWorkflowLabels()
108
+ * @returns {boolean}
109
+ */
110
+ export function hasWorkflowLabel(labels, workflowLabels) {
111
+ const names = new Set(labelNames(labels));
112
+ return workflowLabels.some((wl) => names.has(wl));
113
+ }
114
+
115
+ /**
116
+ * Normalize a priority label to a short display form ("P0".."P3"), accepting
117
+ * both the canonical `priority:P<n>` form and the bare `P<n>` form used by
118
+ * some consumer repos (forge#2232 established this same dual-form handling
119
+ * for priority extraction elsewhere in the pipeline; forge#2235 applies it to
120
+ * watch's render rows).
121
+ *
122
+ * Canonical `priority:P<n>` wins if both happen to be present.
123
+ *
124
+ * @param {Array<string|{name: string}>} labels
125
+ * @returns {string|null} e.g. "P1", or null if no priority label found
126
+ */
127
+ export function normalizePriority(labels) {
128
+ const names = labelNames(labels);
129
+ const canonical = names.find((n) => /^priority:P[0-3]$/.test(n));
130
+ if (canonical) return canonical.replace("priority:", "");
131
+ const bare = names.find((n) => /^P[0-3]$/.test(n));
132
+ return bare || null;
133
+ }
134
+
135
+ /**
136
+ * Parse `repos.satellites[].repo` entries out of a forge.yaml file's raw
137
+ * text, without a YAML parser dependency — consistent with the rest of
138
+ * forgedock.mjs's regex-based forge.yaml reads (resolveLabelsRepo,
139
+ * stall_timeout_minutes, etc.). Returns [] when no `satellites:` section is
140
+ * present, so callers degrade to exactly the previous single-repo behavior.
141
+ *
142
+ * @param {string} forgeYamlText
143
+ * @returns {string[]} "owner/repo" strings
144
+ */
145
+ export function parseSatelliteRepos(forgeYamlText) {
146
+ if (typeof forgeYamlText !== "string") return [];
147
+ const idx = forgeYamlText.indexOf("satellites:");
148
+ if (idx === -1) return [];
149
+ const tail = forgeYamlText.slice(idx + "satellites:".length);
150
+ // Stop at the next non-indented (top-level) key so an unrelated later
151
+ // section's `repo:` field is never picked up as a satellite.
152
+ const stopMatch = tail.match(/\n[^\s#][^\n]*/);
153
+ const section = stopMatch ? tail.slice(0, stopMatch.index) : tail;
154
+ const repos = [];
155
+ const re = /repo:\s*["']?([^\s"'#]+)["']?/g;
156
+ let m;
157
+ while ((m = re.exec(section)) !== null) {
158
+ repos.push(m[1]);
159
+ }
160
+ return repos;
161
+ }
162
+
163
+ /**
164
+ * Escape a value for safe interpolation inside a double-quoted GraphQL
165
+ * string literal (forge#2307). Backslash MUST be escaped first — escaping
166
+ * `"` before `\` would double-escape the backslashes just introduced by the
167
+ * quote-escaping step. A value with no special characters round-trips
168
+ * unchanged, so this is a no-op for the overwhelming common case (plain
169
+ * alphanumeric owner/repo names).
170
+ *
171
+ * @param {string} value
172
+ * @returns {string}
173
+ */
174
+ function escapeGraphQLString(value) {
175
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
176
+ }
177
+
178
+ /**
179
+ * Build a single GraphQL query batching FORGE:HEARTBEAT comment lookups for
180
+ * many issues into one `gh api graphql` round-trip instead of one REST call
181
+ * per issue (forge#2235 — de-N+1). The caller is responsible for chunking
182
+ * issueNumbers to stay under GraphQL query complexity limits (a chunk size
183
+ * of ~25 is safe).
184
+ *
185
+ * Fetches only the last 20 comments per issue (heartbeats are appended, so
186
+ * the latest one is always near the end) rather than the full comment
187
+ * history, to keep the response small.
188
+ *
189
+ * `owner`/`repo` are escaped (forge#2307) before interpolation into the
190
+ * double-quoted GraphQL string literal so a value containing `"` or `\`
191
+ * cannot break out of the literal and alter the query structure. In
192
+ * practice these values come from trusted local input (forge.yaml or a
193
+ * `--repo` CLI flag — see bin/forgedock.mjs resolveLabelsRepo/watch()), not
194
+ * issue/PR content, but the query builder itself should not depend on that
195
+ * being true forever.
196
+ *
197
+ * @param {string} owner
198
+ * @param {string} repo
199
+ * @param {number[]} issueNumbers
200
+ * @returns {string} GraphQL query document
201
+ */
202
+ export function buildHeartbeatBatchQuery(owner, repo, issueNumbers) {
203
+ const aliases = issueNumbers
204
+ .map(
205
+ (n, i) =>
206
+ `i${i}: issue(number: ${Number(n)}) { number comments(last: 20) { nodes { body } } }`,
207
+ )
208
+ .join("\n ");
209
+ return `query {
210
+ repository(owner: "${escapeGraphQLString(owner)}", name: "${escapeGraphQLString(repo)}") {
211
+ ${aliases}
212
+ }
213
+ }`;
214
+ }
215
+
216
+ /**
217
+ * Extract the latest FORGE:HEARTBEAT comment body per issue number from a
218
+ * parsed `gh api graphql` response built against buildHeartbeatBatchQuery().
219
+ *
220
+ * @param {object} graphqlJson - parsed JSON response from `gh api graphql`
221
+ * @returns {Map<number, string|null>} issue number -> latest heartbeat body, or null if none found
222
+ */
223
+ export function parseHeartbeatBatchResponse(graphqlJson) {
224
+ const result = new Map();
225
+ const repoData = graphqlJson && graphqlJson.data && graphqlJson.data.repository;
226
+ if (!repoData) return result;
227
+ for (const key of Object.keys(repoData)) {
228
+ const issue = repoData[key];
229
+ if (!issue || typeof issue.number !== "number") continue;
230
+ const nodes = (issue.comments && issue.comments.nodes) || [];
231
+ let heartbeat = null;
232
+ for (let i = nodes.length - 1; i >= 0; i--) {
233
+ if (nodes[i] && typeof nodes[i].body === "string" && nodes[i].body.includes("FORGE:HEARTBEAT")) {
234
+ heartbeat = nodes[i].body;
235
+ break;
236
+ }
237
+ }
238
+ result.set(issue.number, heartbeat);
239
+ }
240
+ return result;
241
+ }
242
+
243
+ /**
244
+ * Chunk an array into fixed-size slices (helper for GraphQL batch sizing).
245
+ * @param {Array<*>} arr
246
+ * @param {number} size
247
+ * @returns {Array<Array<*>>}
248
+ */
249
+ export function chunkArray(arr, size) {
250
+ const out = [];
251
+ for (let i = 0; i < arr.length; i += size) {
252
+ out.push(arr.slice(i, i + size));
253
+ }
254
+ return out;
255
+ }