forgedock 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/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
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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 = [
|
|
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 ?? "?"}.
|
|
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
|
|
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
|
|
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
|
+
}
|