forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # cost-report.sh — pipeline token cost per ForgeDock version and repo (forge#3824).
6
+ #
7
+ # Reads the engine run logs (<runs-dir>/*.jsonl) and prints a markdown table, one row per
8
+ # (version, repo): finished-run count n, mean/median cache-read tokens per issue, mean per
9
+ # phase, remediated %, merged %, and cache-read tokens per merged issue.
10
+ #
11
+ # Rules:
12
+ # - A run counts only when finished: the last RUN_START..end slice of a log has a
13
+ # RUN_TERMINAL after any RUN_REOPEN / RUN_NEXT_CYCLE. A log is counted once.
14
+ # - Runs with no usage events, or with a hydrated (reconstructed) RUN_START, are excluded:
15
+ # they carry no usage and would drag the means down.
16
+ # - Tokens = usage.cache_read_input_tokens summed per phase over PHASE_ATTEMPT_USAGE when the
17
+ # phase has any (PHASE_COMMIT.usage is then cumulative, so it is not added again), else over
18
+ # PHASE_COMMIT and PHASE_FAILED (older logs).
19
+ # Per-phase means divide by n (a run that skipped a phase counts 0), so the phase
20
+ # columns add up to the per-issue mean. "merged" is RUN_TERMINAL.reason == "merged".
21
+ # - Version/repo come from RUN_START. Older logs lack them: repo becomes "unknown", and the
22
+ # version is estimated from the log's mtime against release times (marked with "~").
23
+ # - Groups with n < --min-n (default 10) are flagged low-n.
24
+ #
25
+ # Usage: bash scripts/cost-report.sh [--runs-dir DIR] [--last N] [--versions a,b] [--min-n N]
26
+ # [--release-times FILE]
27
+ # --runs-dir default $FORGE_RUNS_DIR, else ~/.forge/runs
28
+ # --last N only the N highest versions (unknown excluded)
29
+ # --versions comma list of versions (a trailing "~" in the data is ignored)
30
+ # --release-times file of "<version> <epoch-seconds>" lines; default: git tags v* of this repo
31
+ # Exit: 0 report printed (also when there are no finished runs) | 2 bad usage / missing runs dir
32
+ # Portable: bash 3.2, jq, POSIX stat/git.
33
+
34
+ set -u
35
+
36
+ RUNS_DIR="${FORGE_RUNS_DIR:-$HOME/.forge/runs}"
37
+ LAST=""
38
+ VERSIONS=""
39
+ MIN_N=10
40
+ REL_FILE=""
41
+
42
+ die() { echo "cost-report: $1" >&2; exit 2; }
43
+ is_uint() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
44
+
45
+ while [ $# -gt 0 ]; do
46
+ case "$1" in
47
+ --runs-dir) [ $# -ge 2 ] || die "--runs-dir needs a value"; RUNS_DIR="$2"; shift 2 ;;
48
+ --last) [ $# -ge 2 ] || die "--last needs a value"; LAST="$2"; shift 2 ;;
49
+ --versions) [ $# -ge 2 ] || die "--versions needs a value"; VERSIONS="$2"; shift 2 ;;
50
+ --min-n) [ $# -ge 2 ] || die "--min-n needs a value"; MIN_N="$2"; shift 2 ;;
51
+ --release-times) [ $# -ge 2 ] || die "--release-times needs a value"; REL_FILE="$2"; shift 2 ;;
52
+ -h|--help) sed -n '2,32p' "$0"; exit 0 ;;
53
+ *) die "unknown argument: $1" ;;
54
+ esac
55
+ done
56
+
57
+ [ -d "$RUNS_DIR" ] || die "runs dir not found: $RUNS_DIR"
58
+ command -v jq >/dev/null 2>&1 || die "jq is required"
59
+ is_uint "$MIN_N" || die "--min-n must be a non-negative integer"
60
+ if [ -n "$LAST" ]; then is_uint "$LAST" || die "--last must be a non-negative integer"; fi
61
+ if [ -n "$REL_FILE" ] && [ ! -f "$REL_FILE" ]; then die "release-times file not found: $REL_FILE"; fi
62
+
63
+ # Release times: "<version> <epoch>" lines, from the file or this repo's v* tags (fail-soft: none).
64
+ if [ -n "$REL_FILE" ]; then
65
+ REL_RAW=$(cat "$REL_FILE")
66
+ else
67
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
68
+ REL_RAW=$(git -C "$ROOT" for-each-ref refs/tags --format='%(refname:short) %(creatordate:unix)' 2>/dev/null \
69
+ | sed -n 's/^v\([0-9][0-9.]*\) /\1 /p')
70
+ fi
71
+ REL_JSON=$(printf '%s\n' "$REL_RAW" | jq -R -s -c '
72
+ split("\n") | map(split(" ") | select(length == 2 and (.[1] | test("^[0-9]+$"))) | {v: .[0], t: (.[1] | tonumber)})
73
+ | sort_by(.t)')
74
+ [ -n "$REL_JSON" ] || REL_JSON='[]'
75
+
76
+ mtime_of() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
77
+
78
+ # Pass 1: one summary object per log (empty for logs that cannot be parsed or have no RUN_START).
79
+ ROWS=""
80
+ for f in "$RUNS_DIR"/*.jsonl; do
81
+ [ -f "$f" ] || continue
82
+ mt=$(mtime_of "$f")
83
+ row=$(jq -c -s --argjson mt "$mt" '
84
+ . as $all
85
+ | ([range(0; length) | select($all[.].event == "RUN_START")] | last) as $s
86
+ | if $s == null then empty else
87
+ $all[$s:] as $ev
88
+ | $ev[0] as $start
89
+ | ([range(0; $ev | length) | select($ev[.].event == "RUN_TERMINAL")] | last) as $t
90
+ | ([range(0; $ev | length) | select($ev[.].event == "RUN_REOPEN" or $ev[.].event == "RUN_NEXT_CYCLE")] | last) as $r
91
+ | ([$ev[] | select(.event == "PHASE_ATTEMPT_USAGE") | .phase] | unique) as $ap
92
+ | [$ev[] | select(((.event == "PHASE_ATTEMPT_USAGE")
93
+ or ((.event == "PHASE_COMMIT" or .event == "PHASE_FAILED") and ((.phase as $p | $ap | index($p)) == null)))
94
+ and (.usage | type) == "object")] as $u
95
+ | {
96
+ version: ($start.version // null), repo: ($start.repo // null), mt: $mt,
97
+ hydrated: ($start.hydrated == true),
98
+ finished: ($t != null and ($r == null or $t > $r)),
99
+ reason: (if $t == null then null else $ev[$t].reason end),
100
+ usage_events: ($u | length),
101
+ remediated: ([$ev[] | select(.event == "PHASE_COMMIT" and .phase == "remediate")] | length > 0),
102
+ cr: ([$u[] | .usage.cache_read_input_tokens // 0] | add // 0),
103
+ out: ([$u[] | .usage.output_tokens // 0] | add // 0),
104
+ phase: ($u | group_by(.phase) | map({key: (.[0].phase // "other"), value: ([.[] | .usage.cache_read_input_tokens // 0] | add)}) | from_entries)
105
+ }
106
+ end' "$f" 2>/dev/null) || row=""
107
+ [ -n "$row" ] && ROWS="$ROWS$row
108
+ "
109
+ done
110
+
111
+ printf '%s' "$ROWS" | jq -s -r \
112
+ --argjson rel "$REL_JSON" --argjson minn "$MIN_N" \
113
+ --arg last "$LAST" --arg versions "$VERSIONS" '
114
+ def vkey: (sub("~$"; "") | split(".") | map(tonumber? // 0));
115
+ def mean: if length == 0 then 0 else add / length end;
116
+ def median: sort | if length == 0 then 0 elif length % 2 == 1 then .[length / 2 | floor] else (.[length / 2 - 1] + .[length / 2]) / 2 end;
117
+ def k: ((. / 1000) | round | tostring) + "k";
118
+ def pct: ((. * 100) | round | tostring) + "%";
119
+ def ver($mt): if .version != null then .version
120
+ else ([$rel[] | select(.t <= $mt)] | last | if . == null then "unknown" else .v + "~" end) end;
121
+
122
+ . as $rows
123
+ | ($rows | map(select(.finished | not)) | length) as $unfinished
124
+ | ($rows | map(select(.finished and (.hydrated or .usage_events == 0))) | length) as $nousage
125
+ | [ $rows[] | select(.finished and (.hydrated | not) and .usage_events > 0)
126
+ | . as $r | $r + {ver: ($r | ver($r.mt)), repo: ($r.repo // "unknown")} ] as $runs
127
+ | ($versions | split(",") | map(select(length > 0))) as $vf
128
+ | ($runs | map(.ver | select(. != "unknown")) | unique | sort_by(vkey)) as $vers
129
+ | (if $last == "" then $vers else ($vers | .[(length - ($last | tonumber) | if . < 0 then 0 else . end):]) end) as $keep
130
+ | [ $runs[] | select((.ver | IN($keep[])) or ($last == "" and .ver == "unknown"))
131
+ | select($vf == [] or ((.ver | sub("~$"; "")) | IN($vf[]))) ]
132
+ | group_by([.ver, .repo]) | sort_by(.[0].ver | vkey) as $groups
133
+ | "| Version | Repo | n | Cache-read/issue mean | median | investigate | build | review | remediate | close | Remediated | Merged | Per merged issue | Confidence |",
134
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
135
+ ( $groups[] | . as $g | length as $n
136
+ | ([.[] | select(.reason == "merged")] | length) as $m
137
+ | (map(.cr) | add) as $tot
138
+ | def pm($p): (map(.phase[$p] // 0) | add) / $n;
139
+ "| \($g[0].ver) | \($g[0].repo) | \($n) | \((map(.cr) | mean) | k) | \((map(.cr) | median) | k)"
140
+ + " | \(pm("investigate") | k) | \(pm("build") | k) | \(pm("review") | k) | \(pm("remediate") | k) | \(pm("close") | k)"
141
+ + " | \(([.[] | select(.remediated)] | length) / $n | pct) | \($m / $n | pct)"
142
+ + " | \(if $m == 0 then "n/a" else ($tot / $m | k) end)"
143
+ + " | \(if $n < $minn then "low-n (<\($minn))" else "ok" end) |" ),
144
+ "",
145
+ "Finished runs only; tokens are cache-read per issue. Excluded: \($unfinished) unfinished, \($nousage) without usage data (hydrated or empty). \"~\" = version estimated from log mtime; \"unknown\" = no version available."
146
+ '
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # cost-report.test.sh — cases for scripts/cost-report.sh (forge#3824).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ S="$HERE/cost-report.sh"
8
+ FIX="$HERE/fixtures/cost-report"
9
+ PASS=0; FAILN=0
10
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ has() { # name text needle
14
+ case "$2" in *"$3"*) ok ;; *) bad "$1 (missing: $3)"; printf '%s\n' "$2" | sed 's/^/ /' ;; esac
15
+ }
16
+ hasnt() { # name text needle
17
+ case "$2" in *"$3"*) bad "$1 (unexpected: $3)" ;; *) ok ;; esac
18
+ }
19
+
20
+ RUNS="$TMP/runs"; mkdir -p "$RUNS"
21
+ cp "$FIX"/*.jsonl "$RUNS"/
22
+ # Old logs carry no version: their mtime (UTC) is mapped to the release active at that time.
23
+ TZ=UTC touch -t 200201010000 "$RUNS/old-log-no-version.jsonl"
24
+ printf '1.0.0 1000000000\n2.0.0 1100000000\n' > "$TMP/releases.txt"
25
+
26
+ OUT=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt"); rc=$?
27
+ [ "$rc" -eq 0 ] && ok || bad "exit 0 on a valid runs dir (rc=$rc)"
28
+
29
+ # Repo split: o/a (3 finished runs) and o/b (1) are separate rows.
30
+ ROW_A=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/a |')
31
+ ROW_B=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/b |')
32
+ [ -n "$ROW_A" ] && ok || bad "row for 2.0.0 o/a"
33
+ [ -n "$ROW_B" ] && ok || bad "row for 2.0.0 o/b"
34
+
35
+ # o/a: unfinished (no terminal, reopened) and hydrated runs are excluded, so n=3.
36
+ has "o/a n=3 (unfinished and hydrated excluded)" "$ROW_A" "| 2.0.0 | o/a | 3 |"
37
+ # totals 330k, 560k, 100k: mean 330k, median 330k
38
+ has "o/a mean and median" "$ROW_A" "| 330k | 330k |"
39
+ # per-phase means divide by n: investigate 30k/3, build 390k/3, review 500k/3, remediate 40k/3, close 30k/3
40
+ has "o/a per-phase means" "$ROW_A" "| 10k | 130k | 167k | 13k | 10k |"
41
+ # 1 of 3 remediated, 2 of 3 merged (needs-human is not merged)
42
+ has "o/a remediated and merged rates" "$ROW_A" "| 33% | 67% |"
43
+ # per merged issue: 990k total over 2 merged runs
44
+ has "o/a tokens per merged issue" "$ROW_A" "| 495k |"
45
+ has "o/a low-n flag" "$ROW_A" "low-n (<10)"
46
+ has "o/b single run per-merged math" "$ROW_B" "| 2.0.0 | o/b | 1 | 1000k | 1000k |"
47
+ has "o/b per merged" "$ROW_B" "| 100% | 1000k |"
48
+ hasnt "unfinished usage never counted" "$OUT" "9999k"
49
+
50
+ # o/c: new-style log (PHASE_ATTEMPT_USAGE). Per-phase attempts are summed once: investigate 10k + build 100k + 50k
51
+ # = 160k; the cumulative PHASE_COMMIT (150k) and the PHASE_FAILED (100k) must not be added on top.
52
+ ROW_C=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/c |')
53
+ has "o/c attempt events not double counted" "$ROW_C" "| 2.0.0 | o/c | 1 | 160k | 160k |"
54
+
55
+ # Old log: version estimated from mtime (marked ~), repo unknown.
56
+ has "old-log fallback" "$OUT" "| 1.0.0~ | unknown | 1 | 70k |"
57
+ has "excluded footer" "$OUT" "Excluded: 2 unfinished, 1 without usage data"
58
+
59
+ # --min-n lowers the flag threshold.
60
+ OUT2=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --min-n 3)
61
+ has "min-n 3 marks o/a ok" "$(printf '%s\n' "$OUT2" | grep '^| 2\.0\.0 | o/a |')" "| ok |"
62
+
63
+ # --last / --versions filtering.
64
+ OUT3=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --last 1)
65
+ hasnt "--last 1 drops the older estimated version" "$OUT3" "1.0.0~"
66
+ has "--last 1 keeps the newest" "$OUT3" "| 2.0.0 | o/a |"
67
+ OUT4=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --versions 1.0.0)
68
+ has "--versions matches an estimated version" "$OUT4" "| 1.0.0~ |"
69
+ hasnt "--versions excludes others" "$OUT4" "| 2.0.0 |"
70
+
71
+ # Fail closed: missing runs dir exits 2; empty dir still reports (header only).
72
+ bash "$S" --runs-dir "$TMP/nope" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "missing runs dir exits 2"
73
+ bash "$S" --bogus >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown flag exits 2"
74
+ mkdir "$TMP/empty"
75
+ OUT5=$(bash "$S" --runs-dir "$TMP/empty" --release-times "$TMP/releases.txt"); rc=$?
76
+ [ "$rc" -eq 0 ] && ok || bad "empty runs dir exits 0"
77
+ has "empty dir footer" "$OUT5" "Excluded: 0 unfinished"
78
+
79
+ echo "cost-report.test: $PASS passed, $FAILN failed"
80
+ [ "$FAILN" -eq 0 ]
@@ -30,6 +30,9 @@
30
30
  # Exit: 0 measured (even when over); 2 usage or git failure with EMPTY stdout (fail closed: unknown is not zero).
31
31
 
32
32
  set -euo pipefail
33
+ # Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
34
+ _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
35
+ type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
33
36
 
34
37
  REPO_PATH=""; BASE=""; THRESHOLD="1000"; EXTRA=()
35
38
  die() { echo "diff-size.sh: $*" >&2; exit 2; }
@@ -112,6 +115,8 @@ echo "new_lines=$NEWL"
112
115
  echo "test_lines=$TESTL"
113
116
  echo "cohesive_lines=$COHL"
114
117
  echo "cohesive=$COHESIVE"
118
+ _res=OK; if [ "$COHESIVE" = true ]; then _res=COHESIVE; elif [ "$OVER" = true ]; then _res=OVER; fi
119
+ forge_emit diff_size lines="$TOTAL" excluded="$EXCL" threshold="$THRESHOLD" result="$_res"
115
120
  if [ -n "$LIST" ]; then
116
121
  printf '%s' "$LIST" | sort -t' ' -k1,1nr | head -n 10 | sed 's/^/top=/' || true
117
122
  fi
@@ -49,6 +49,8 @@
49
49
  # PRIOR_GIST annotations in recent issue comments)
50
50
  # I9 Issues stuck in workflow:remediating beyond threshold (the state must
51
51
  # never outlive its remediation run)
52
+ # I10 Issues in workflow:engine-error that also carry another in-flight
53
+ # workflow:* label, or that sit there beyond threshold
52
54
  #
53
55
  # /pipeline-health should consume this script's JSON output rather than
54
56
  # re-discovering state from scratch.
@@ -357,6 +359,47 @@ while IFS= read -r row; do
357
359
  fi
358
360
  done < <(echo "$REMEDIATING" | jq -c '.[]')
359
361
 
362
+ # ---------------------------------------------------------------------------
363
+ # I10: Issues stuck in workflow:engine-error
364
+ # ---------------------------------------------------------------------------
365
+ # engine-error is a terminal projection: it must not coexist with another
366
+ # in-flight workflow:* label (stranded state) and must not sit unrecovered past
367
+ # the threshold. Same fail-inconclusive pattern as I9.
368
+ if ! ENGINE_ERRORS=$(gh issue list $GH_FLAG \
369
+ --state open --label "workflow:engine-error" \
370
+ --limit 100 \
371
+ --json number,title,updatedAt,labels 2>"$GH_STDERR_TMP"); then
372
+ echo "WARNING: gh issue list failed for workflow:engine-error — I10 check inconclusive, skipping (not treated as empty): $(cat "$GH_STDERR_TMP")" >&2
373
+ add_skip "I10" "all" "gh issue list failed: $(bounded_reason "$(cat "$GH_STDERR_TMP")")"
374
+ ENGINE_ERRORS="[]"
375
+ fi
376
+
377
+ while IFS= read -r row; do
378
+ num=$(echo "$row" | jq -r '.number')
379
+ labels=$(echo "$row" | jq -c '[.labels[].name]')
380
+ if has_terminal_label "$labels"; then
381
+ continue
382
+ fi
383
+ updated=$(echo "$row" | jq -r '.updatedAt')
384
+ hours=$(hours_since "$updated")
385
+ other=$(echo "$labels" | jq -r '[.[] | select(startswith("workflow:") and . != "workflow:engine-error")] | first // empty')
386
+ if [ -n "$other" ]; then
387
+ add_finding \
388
+ "stuck_engine_error" "warning" \
389
+ "$num" "workflow:engine-error" "$hours" \
390
+ "FORGE:STATE" \
391
+ "forgedock run-issue $num --retry" \
392
+ "Issue #$num carries workflow:engine-error together with $other (stranded state). Remove the stale label or recover with forgedock run-issue $num --retry"
393
+ elif [ "$hours" -ge "$STUCK_HOURS" ]; then
394
+ add_finding \
395
+ "stuck_engine_error" "warning" \
396
+ "$num" "workflow:engine-error" "$hours" \
397
+ "FORGE:STATE" \
398
+ "forgedock run-issue $num --retry" \
399
+ "Issue #$num stuck in workflow:engine-error for ${hours}h (threshold: ${STUCK_HOURS}h). Recover with forgedock run-issue $num --retry"
400
+ fi
401
+ done < <(echo "$ENGINE_ERRORS" | jq -c '.[]')
402
+
360
403
  # ---------------------------------------------------------------------------
361
404
  # I4: Issues in workflow:building with no FORGE:BUILDER annotation
362
405
  # ---------------------------------------------------------------------------
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # file-deferred-followups.sh — file ALL deferred out-of-scope items of one build as ONE follow-up issue.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # The builder decides fold-vs-defer (judgment, build.md B2.0); this script does the mechanics for the items
7
+ # that stay deferred: trusted lookup of an open follow-up carrying <!-- FORGE:DEFERRED_FROM: #N -->, append the
8
+ # missing items to it as checklist entries (exact path match), or create ONE issue with a checklist.
9
+ #
10
+ # Usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]
11
+ # items TSV, one per line: <path> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty>
12
+ # Env: DRY_RUN (non-empty: no create/edit, issue number 0), FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default
13
+ # OWNER,MEMBER,COLLABORATOR), FORGE_TRAIL_TRUSTED_LOGINS (default empty), FORGE_DEFERRED_RETRY_SLEEP (secs).
14
+ # Stdout: `path<TAB>issue` per item, then DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N.
15
+ # Exit: 0 ok | 2 usage | 3 lookup failed (DEFERRED_LOOKUP=FAILED) | 4 create/append failed (DEFERRED_FILE=FAILED).
16
+ # A failed lookup is never "none found". bash 3.2 portable.
17
+
18
+ set -uo pipefail
19
+
20
+ PARENT=""; REPO=""; ITEMS="-"; FOLDED=0
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ --parent|--repo|--items|--folded)
24
+ [ $# -ge 2 ] || { echo "file-deferred-followups: $1 requires a value" >&2; exit 2; }
25
+ case "$1" in --parent) PARENT="$2" ;; --repo) REPO="$2" ;; --items) ITEMS="$2" ;; --folded) FOLDED="$2" ;; esac
26
+ shift 2 ;;
27
+ *) echo "usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+ case "$PARENT" in ''|*[!0-9]*) echo "file-deferred-followups: --parent must be a number" >&2; exit 2 ;; esac
31
+ case "$FOLDED" in ''|*[!0-9]*) FOLDED=0 ;; esac
32
+ [ -n "$REPO" ] || { echo "file-deferred-followups: --repo required" >&2; exit 2; }
33
+ if [ "$ITEMS" != "-" ] && [ ! -f "$ITEMS" ]; then echo "file-deferred-followups: items file not found: $ITEMS" >&2; exit 2; fi
34
+
35
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/deferred-followups.XXXXXX") || exit 2
36
+ trap 'rm -rf "$TMP"' EXIT
37
+ if [ "$ITEMS" = "-" ]; then cat > "$TMP/items.raw"; else cat "$ITEMS" > "$TMP/items.raw"; fi
38
+
39
+ # Normalise: drop blank lines and duplicate paths (first wins), strip backticks from the path.
40
+ # Internal file uses a non-whitespace separator (US, \x1f): a tab IFS collapses an empty `why` and drops the known-flaw flag.
41
+ SEP=$(printf '\037')
42
+ : > "$TMP/items"
43
+ while IFS= read -r line || [ -n "$line" ]; do
44
+ # Split on TAB by hand: `IFS=$'\t' read` would collapse an empty why field and shift the flag into it.
45
+ p="${line%%$'\t'*}"; rest=""; w=""; k=""
46
+ case "$line" in *$'\t'*) rest="${line#*$'\t'}"; w="${rest%%$'\t'*}"; case "$rest" in *$'\t'*) k="${rest#*$'\t'}" ;; esac ;; esac
47
+ p=$(printf '%s' "${p:-}" | tr -d '`' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
48
+ [ -n "$p" ] || continue
49
+ if cut -d "$SEP" -f1 "$TMP/items" | grep -qxF -- "$p"; then continue; fi
50
+ case "${k:-}" in known-flaw|1|yes|true) k="known-flaw" ;; *) k="" ;; esac
51
+ w=$(printf '%s' "${w:-}" | tr '\r\n' ' ')
52
+ printf '%s%s%s%s%s\n' "$p" "$SEP" "$w" "$SEP" "$k" >> "$TMP/items"
53
+ done < "$TMP/items.raw"
54
+ TOTAL=$(wc -l < "$TMP/items" | tr -d ' ')
55
+
56
+ MARKER="<!-- FORGE:DEFERRED_FROM: #${PARENT} -->"
57
+ TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
58
+ TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
59
+
60
+ finish() { # $1 = batched_into number or empty, $2 = filed count
61
+ local into="none"; [ -n "$1" ] && into="#$1"
62
+ local line="DEFERRED_TELEMETRY deferred_items=${TOTAL} folded=${FOLDED} batched_into=${into} filed=$2"
63
+ echo "$line"
64
+ # #3834 emitter (scripts/lib/telemetry.sh): optional, never blocks, never changes stdout or the exit code.
65
+ local d; d=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
66
+ if [ -f "$d/lib/telemetry.sh" ]; then
67
+ FORGE_ISSUE="${FORGE_ISSUE:-$PARENT}" FORGE_REPO="${FORGE_REPO:-$REPO}" \
68
+ bash "$d/lib/telemetry.sh" deferred_followups deferred_items="$TOTAL" folded="$FOLDED" \
69
+ batched_into="${1:-none}" filed="$2" >/dev/null 2>&1 </dev/null || true
70
+ fi
71
+ }
72
+
73
+ # --- Trusted lookup (author predicate applied before path match), one retry ---------------------------------
74
+ do_lookup() {
75
+ gh api -X GET search/issues -f per_page=50 \
76
+ -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue is:open" 2>/dev/null \
77
+ | jq -c --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" '
78
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
79
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
80
+ | [.items[]
81
+ | select(((.author_association // "") as $x | $A | index($x) != null)
82
+ or ((.user.type // "") == "Bot")
83
+ or ((.user.login // "") as $x | $L | index($x) != null))
84
+ | select((.body // "") | contains($m))
85
+ | {number, body: (.body // "")}]
86
+ | sort_by(.number)' 2>/dev/null
87
+ }
88
+ LOOKUP=""
89
+ for attempt in 1 2; do
90
+ LOOKUP=$(do_lookup) && [ -n "$LOOKUP" ] && break
91
+ LOOKUP=""
92
+ [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
93
+ done
94
+ if [ -z "$LOOKUP" ]; then
95
+ echo "DEFERRED_LOOKUP=FAILED"
96
+ exit 3
97
+ fi
98
+
99
+ # Checklist line for one item. Path always backticked so review-pr 6B.5 `contains` verification holds.
100
+ entry() { # path why flag
101
+ local l="- [ ] \`$1\`"
102
+ [ -n "$3" ] && l="$l **known-flaw**"
103
+ [ -n "$2" ] && l="$l — $2"
104
+ printf '%s\n' "$l"
105
+ }
106
+ has_entry() { # body-file path : exact checklist-line match, not substring
107
+ grep -qE '^- \[[ xX]\] `' "$1" 2>/dev/null || return 1
108
+ grep -F -- "\`$2\`" "$1" | grep -qE "^- \[[ xX]\] \`$(printf '%s' "$2" | sed 's/[][\.*^$+?(){}|]/\\&/g')\`( |\$)"
109
+ }
110
+
111
+ NUM=$(printf '%s' "$LOOKUP" | jq -r 'first | .number // empty' 2>/dev/null)
112
+
113
+ if [ -n "$NUM" ]; then
114
+ # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
115
+ if ! gh api "repos/${REPO}/issues/${NUM}" --jq '.body // ""' > "$TMP/body" 2>/dev/null; then
116
+ echo "DEFERRED_FILE=FAILED"; exit 4
117
+ fi
118
+ : > "$TMP/add"
119
+ while IFS="$SEP" read -r p w k; do
120
+ has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
121
+ done < "$TMP/items"
122
+ if [ -s "$TMP/add" ]; then
123
+ { cat "$TMP/body"; printf '\n### Added by a later build of #%s\n\n' "$PARENT"; cat "$TMP/add"; } > "$TMP/newbody"
124
+ if [ -n "${DRY_RUN:-}" ]; then
125
+ echo "DRY_RUN: gh issue edit $NUM (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
126
+ elif ! gh issue edit "$NUM" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1; then
127
+ echo "DEFERRED_FILE=FAILED"; exit 4
128
+ fi
129
+ fi
130
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
131
+ finish "$NUM" 0
132
+ exit 0
133
+ fi
134
+
135
+ [ "$TOTAL" -gt 0 ] || { finish "" 0; exit 0; }
136
+
137
+ # --- Create ONE issue with a checklist for every item ---------------------------------------------------------
138
+ {
139
+ printf '## Problem\n\n'
140
+ printf 'Deferred from #%s: the items below were declared out of scope in its contract and are tracked here, in one issue, instead of surfacing later as review findings.\n\n' "$PARENT"
141
+ printf '## Affected Files\n\n'
142
+ while IFS="$SEP" read -r p w k; do printf -- '- `%s`\n' "$p"; done < "$TMP/items"
143
+ printf '\n## Acceptance Criteria\n\n'
144
+ while IFS="$SEP" read -r p w k; do entry "$p" "$w" "$k"; done < "$TMP/items"
145
+ printf '\n%s\n' "$MARKER"
146
+ } > "$TMP/newissue"
147
+
148
+ if [ -n "${DRY_RUN:-}" ]; then
149
+ echo "DRY_RUN: gh issue create (follow-up: ${TOTAL} deferred item(s) from #${PARENT})" >&2
150
+ NUM=0
151
+ else
152
+ URL=$(gh issue create -R "$REPO" --title "follow-up: ${TOTAL} deferred item(s) (deferred from #${PARENT})" \
153
+ --body-file "$TMP/newissue" 2>/dev/null) || URL=""
154
+ NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
155
+ [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
156
+ fi
157
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
158
+ finish "$NUM" 1
159
+ exit 0
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # file-deferred-followups.test.sh — stubbed-gh tests for scripts/file-deferred-followups.sh
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ S="$HERE/file-deferred-followups.sh"
8
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
9
+ mkdir "$T/bin"
10
+ export FORGE_DEFERRED_RETRY_SLEEP=0
11
+ export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body"
12
+
13
+ cat > "$T/bin/gh" <<'MOCK'
14
+ #!/usr/bin/env bash
15
+ echo "$*" >> "$GH_LOG"
16
+ case "$1 $2" in
17
+ "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
+ "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
19
+ "issue edit")
20
+ [ -n "${EDIT_FAIL:-}" ] && exit 1
21
+ while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
22
+ esac
23
+ case "$1" in api) cat "$ISSUE_BODY"; exit 0 ;; esac
24
+ exit 1
25
+ MOCK
26
+ chmod +x "$T/bin/gh"; export PATH="$T/bin:$PATH"
27
+
28
+ PASS=0; FAIL=0
29
+ ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
30
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
31
+ check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
32
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL DRY_RUN; echo '{"items":[]}' > "$SEARCH_JSON"; }
33
+ printf 'a/x.sh\twhy a\t\nb/y.sh\twhy b\tknown-flaw\nc/z.sh\twhy c\t\n' > "$T/items.tsv"
34
+
35
+ # 1. N items -> exactly one create
36
+ reset
37
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" --folded 2); RC=$?
38
+ check "create: rc 0" "[ $RC -eq 0 ]"
39
+ check "create: one issue create for 3 items" "[ \$(grep -c '^issue create' '$GH_LOG') -eq 1 ]"
40
+ check "create: rows share issue" "[ \$(printf '%s\n' \"\$OUT\" | grep -c '901\$') -ge 3 ]"
41
+ check "create: telemetry" "printf '%s' \"\$OUT\" | grep -q 'DEFERRED_TELEMETRY deferred_items=3 folded=2 batched_into=#901 filed=1'"
42
+
43
+ # 2. append to existing trusted open issue; exact path match
44
+ reset
45
+ printf '## Affected Files\n- `a/x.sh`\n\n- [ ] `a/x.sh.bak` — other\n<!-- FORGE:DEFERRED_FROM: #10 -->\n' > "$ISSUE_BODY"
46
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
47
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
48
+ check "append: rc 0, no create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG'"
49
+ check "append: telemetry batched into 77 filed=0" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
50
+ check "append: all three added (substring .bak does not dedup a/x.sh)" "[ \$(grep -c '^- \[ \] \`' '$ISSUE_BODY.edited') -eq 4 ]"
51
+ check "append: known-flaw flagged" "grep -q 'b/y.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
52
+
53
+ # 2b. empty why + known-flaw: the flag must survive (a whitespace IFS would collapse the empty field)
54
+ reset
55
+ printf 'd/w.sh\t\tknown-flaw\n' > "$T/emptywhy.tsv"
56
+ bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv" >/dev/null; RC=$?
57
+ rm -f "$ISSUE_BODY.edited"; printf 'x\n' > "$ISSUE_BODY"
58
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
59
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv"); RC=$?
60
+ check "empty-why: append keeps known-flaw flag" "[ $RC -eq 0 ] && grep -q 'd/w.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
61
+
62
+ # 2c. missing option value -> usage exit 2
63
+ bash "$S" --parent >/dev/null 2>&1; RC=$?
64
+ check "missing option value: rc 2" "[ $RC -eq 2 ]"
65
+
66
+ # 3. already-listed path is not duplicated; nothing to add -> no edit
67
+ reset
68
+ printf -- '- [ ] `a/x.sh` — w\n- [x] `b/y.sh`\n- [ ] `c/z.sh`\n' > "$ISSUE_BODY"
69
+ echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
70
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
71
+ check "idempotent: no edit, no create on resume" "[ $RC -eq 0 ] && ! grep -qE '^issue (edit|create)' '$GH_LOG'"
72
+
73
+ # 4. untrusted match ignored -> new issue
74
+ reset
75
+ echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
76
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv")
77
+ check "untrusted: ignored, create filed" "grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
78
+
79
+ # 5. lookup failure -> FAILED, nothing filed
80
+ reset; export LOOKUP_FAIL=1
81
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
82
+ check "lookup failure: rc 3, DEFERRED_LOOKUP=FAILED, no create" "[ $RC -eq 3 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_LOOKUP=FAILED && ! grep -q '^issue create' '$GH_LOG'"
83
+
84
+ # 6. create / edit failure not counted as success
85
+ reset; export CREATE_FAIL=1
86
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
87
+ check "create failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
88
+ reset; export EDIT_FAIL=1
89
+ printf 'x\n' > "$ISSUE_BODY"
90
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
91
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
92
+ check "edit failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
93
+
94
+ # 7. DRY_RUN
95
+ reset; export DRY_RUN=1
96
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null)
97
+ check "dry-run: no create" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
98
+
99
+ # 8. empty items, usage errors
100
+ reset
101
+ OUT=$(printf '' | bash "$S" --parent 10 --repo o/r)
102
+ check "empty: no create, deferred_items=0" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'deferred_items=0'"
103
+ bash "$S" --repo o/r >/dev/null 2>&1; check "usage: missing parent rc 2" "[ $? -eq 2 ]"
104
+
105
+ echo "passed=$PASS failed=$FAIL"
106
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,6 @@
1
+ {"seq":1,"event":"RUN_START","issue":1,"run":"r_1_staging","lane":"staging","version":"2.0.0","repo":"o/a"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"investigate","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":10000}}
3
+ {"seq":3,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":100000}}
4
+ {"seq":4,"event":"PHASE_COMMIT","phase":"review","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":200000}}
5
+ {"seq":5,"event":"PHASE_COMMIT","phase":"close","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":20000}}
6
+ {"seq":6,"event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,7 @@
1
+ {"seq":1,"event":"RUN_START","issue":2,"run":"r_2_staging","lane":"staging","version":"2.0.0","repo":"o/a"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"investigate","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":10000}}
3
+ {"seq":3,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":200000}}
4
+ {"seq":4,"event":"PHASE_COMMIT","phase":"review","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":300000}}
5
+ {"seq":5,"event":"PHASE_COMMIT","phase":"remediate","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":40000}}
6
+ {"seq":6,"event":"PHASE_COMMIT","phase":"close","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":10000}}
7
+ {"seq":7,"event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,4 @@
1
+ {"seq":1,"event":"RUN_START","issue":3,"run":"r_3_staging","lane":"staging","version":"2.0.0","repo":"o/a"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"investigate","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":10000}}
3
+ {"seq":3,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":90000}}
4
+ {"seq":4,"event":"RUN_TERMINAL","reason":"needs-human"}
@@ -0,0 +1,3 @@
1
+ {"seq":1,"event":"RUN_START","issue":4,"run":"r_4_staging","lane":"staging","version":"2.0.0","repo":"o/b"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":1000000}}
3
+ {"seq":3,"event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,8 @@
1
+ {"seq":1,"ts":"2026-10-10T00:00:00.000Z","event":"RUN_START","issue":5,"run":"r_5_staging","lane":"staging","version":"2.0.0","forge_version":"2.0.0","repo":"o/c"}
2
+ {"seq":2,"ts":"2026-10-10T00:00:01.000Z","event":"PHASE_ATTEMPT_USAGE","phase":"investigate","attempt":1,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000},"duration_ms":900,"cost_usd":null,"model":null,"turns":null}
3
+ {"seq":3,"ts":"2026-10-10T00:00:02.000Z","event":"PHASE_COMMIT","phase":"investigate","outputs":{},"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000},"last_attempt_usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000}}
4
+ {"seq":4,"ts":"2026-10-10T00:00:03.000Z","event":"PHASE_ATTEMPT_USAGE","phase":"build","attempt":1,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":100000},"duration_ms":900,"cost_usd":null,"model":null,"turns":null}
5
+ {"seq":5,"ts":"2026-10-10T00:00:04.000Z","event":"PHASE_FAILED","phase":"build","attempt":1,"reason":"no commits","maxAttempts":3,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":100000}}
6
+ {"seq":6,"ts":"2026-10-10T00:00:05.000Z","event":"PHASE_ATTEMPT_USAGE","phase":"build","attempt":2,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":50000},"duration_ms":900,"cost_usd":null,"model":null,"turns":null}
7
+ {"seq":7,"ts":"2026-10-10T00:00:06.000Z","event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":150000},"last_attempt_usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":50000}}
8
+ {"seq":8,"ts":"2026-10-10T00:00:07.000Z","event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,3 @@
1
+ {"seq":1,"event":"RUN_START","issue":7,"run":"r_7_staging","lane":"staging","version":"2.0.0","repo":"o/a","hydrated":true}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"build","outputs":{}}
3
+ {"seq":3,"event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,3 @@
1
+ {"seq":1,"event":"RUN_START","issue":8,"run":"r_8_staging","lane":"staging"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":70000}}
3
+ {"seq":3,"event":"RUN_TERMINAL","reason":"merged"}
@@ -0,0 +1,2 @@
1
+ {"seq":1,"event":"RUN_START","issue":5,"run":"r_5_staging","lane":"staging","version":"2.0.0","repo":"o/a"}
2
+ {"seq":2,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":9999000}}