forgedock 1.16.2 → 1.18.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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -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 +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -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 +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -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}}
@@ -0,0 +1,5 @@
1
+ {"seq":1,"event":"RUN_START","issue":6,"run":"r_6_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}}
3
+ {"seq":3,"event":"RUN_TERMINAL","reason":"needs-human"}
4
+ {"seq":4,"event":"RUN_REOPEN","issue":6}
5
+ {"seq":5,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":9999000}}
@@ -95,8 +95,8 @@ fi
95
95
  # forge#3758 (hgate is a fixture of its own: the loop runs once per shell and h14 is reused below with other versions): deploy-gate script resolves on a plugin-only install (cache only, no symlink, no FORGE_* env).
96
96
  # The gate-script line is taken verbatim from the spec so the test exercises exactly what ships.
97
97
  mkscripts "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0"; : > "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/resolve-deploy-gate.sh"
98
- # review-pr.md resolves the gate script through the forge_resolve stub (#3780): the locator and the call line are taken verbatim from the spec.
99
- sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr.md" | grep '^GATE_SCRIPT=\$(forge_resolve resolve-deploy-gate.sh)$' | sort -u > "$T/gs"
98
+ # review-finalize.sh (the review-pr.md Phase 8 gate) resolves the gate script through forge_resolve (#3780): the locator and the call line are taken verbatim from the script.
99
+ sed 's/^[[:space:]]*//' "$ROOT/scripts/review-finalize.sh" | grep '^GATE_SCRIPT=\$(forge_resolve resolve-deploy-gate.sh)$' | sort -u > "$T/gs"
100
100
  expect "gate-script resolver line is identical across sites" 1 "$(wc -l < "$T/gs" | tr -d ' ')"
101
101
  { sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr.md" | grep -m1 '^type forge_resolve >/dev/null'; cat "$T/gs"; } > "$T/gs_full"
102
102
  gate_run() { ( cd "$T/consumer" && env -i PATH="$PATH" HOME="$1" "$SH" -c "$(cat "$T/gs_full"); printf %s \"\$GATE_SCRIPT\"" ); }
@@ -271,23 +271,28 @@ extract_helper_block() { # file var -> block on stdout
271
271
  extract_helper_fn() { # file -> function on stdout
272
272
  awk '/^resolve_extract_affected_files\(\) \{/ {inb=1} inb {print} inb && /^\}$/ {exit}' "$1"
273
273
  }
274
- # review-pr.md resolves its helpers through the forge_resolve stub (#3780): the pinned locator plus the call line, taken from the spec.
274
+ # review-pr.md resolves its helpers through the forge_resolve stub (#3780); since #3841 the classify, cap, milestone, priority and dedup helpers are resolved inside
275
+ # scripts/review-finalize.sh and the code-index helper inside scripts/review-prepare.sh: the pinned locator plus the call line, taken from those scripts.
275
276
  stub_block() { # file var -> locator + the first `var=$(forge_resolve ...)` line, indentation stripped
276
277
  sed 's/^[[:space:]]*//' "$1" | grep -m1 '^type forge_resolve >/dev/null'; sed 's/^[[:space:]]*//' "$1" | grep -m1 "^$2=\$(forge_resolve "
277
278
  }
278
- HR_CLASSIFY="$ROOT/commands/review-pr.md"
279
+ HR_CLASSIFY="$ROOT/scripts/review-finalize.sh"
280
+ HR_PREPARE="$ROOT/scripts/review-prepare.sh"
279
281
  HR_AMP="$ROOT/commands/orchestrate/phase-4-execution.md"
280
282
  HR_EX3="$ROOT/commands/orchestrate/phase-3-dependency.md"
281
283
  stub_block "$HR_CLASSIFY" CLASSIFY_SCRIPT > "$T/hr_classify.sh"
284
+ stub_block "$HR_CLASSIFY" CAP_SCRIPT > "$T/hr_cap.sh"
282
285
  extract_helper_block "$HR_AMP" AMP_SCRIPT > "$T/hr_amp.sh"
283
286
  extract_helper_fn "$HR_AMP" > "$T/hr_ex4.sh"
284
287
  extract_helper_fn "$HR_EX3" > "$T/hr_ex3.sh"
285
288
  expect "CLASSIFY_SCRIPT resolver stub found" 1 "$(grep -c '^CLASSIFY_SCRIPT=\$(forge_resolve classify-finding.sh)$' "$T/hr_classify.sh")"
289
+ expect "CAP_SCRIPT resolver stub found" 1 "$(grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$' "$T/hr_cap.sh")"
290
+ expect "review-pr-staging.md no longer carries a CAP_SCRIPT resolver stub (review-finalize.sh owns the cap helper, #3841)" 0 "$(sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr-staging.md" | grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$')"
286
291
  expect "AMP_SCRIPT resolver block found" 1 "$(grep -c '^AMP_SCRIPT=""' "$T/hr_amp.sh")"
287
292
  expect "extract-affected-files resolver found in phase-4" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex4.sh")"
288
293
  expect "extract-affected-files resolver found in phase-3" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex3.sh")"
289
294
  cmp -s "$T/hr_ex3.sh" "$T/hr_ex4.sh" && ok || bad 'resolve_extract_affected_files copies differ between phase-3 and phase-4'
290
- for hr in classify amp ex3 ex4; do
295
+ for hr in classify cap amp ex3 ex4; do
291
296
  grep -qF 'plugins/cache/forgedock/forgedock' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks the plugin-cache scan"
292
297
  grep -qF 'CLAUDE_CONFIG_DIR' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks CLAUDE_CONFIG_DIR"
293
298
  grep -qE '\$PWD|\$REPO_PATH|\$\{PWD|\$\{REPO_PATH' "$T/hr_$hr.sh" && bad "$hr helper resolver lists the working directory or repo path" || ok
@@ -298,7 +303,7 @@ if grep -rnE 'PWD/scripts/|^\$PWD$|REPO_PATH/scripts/extract' "$ROOT/commands" >
298
303
  # Behavior (#3483): a planted scripts/<helper>.sh in the cwd must never be selected; a cache-only install must resolve.
299
304
  mkdir -p "$T/hrwork/scripts" "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/lib"
300
305
  cp "$ROOT/scripts/lib/forge-resolve.sh" "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/lib/forge-resolve.sh"
301
- for h in classify-finding amplification-breaker extract-affected-files; do
306
+ for h in classify-finding cap-findings amplification-breaker extract-affected-files; do
302
307
  : > "$T/hrwork/scripts/$h.sh"; : > "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/$h.sh"
303
308
  done
304
309
  CACHE_SCRIPTS="$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts"
@@ -307,6 +312,8 @@ hr_run() { # home blockfile expr
307
312
  }
308
313
  expect "CLASSIFY_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/classify-finding.sh" "$(hr_run "$T/hrhome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
309
314
  expect "CLASSIFY_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
315
+ expect "CAP_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/cap-findings.sh" "$(hr_run "$T/hrhome" "$T/hr_cap.sh" 'printf %s "$CAP_SCRIPT"')"
316
+ expect "CAP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_cap.sh" 'printf %s "$CAP_SCRIPT"')"
310
317
  expect "AMP_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/amplification-breaker.sh" "$(hr_run "$T/hrhome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
311
318
  expect "AMP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
312
319
  expect "extract-affected-files: cache-only install resolves (cwd and REPO_PATH copies ignored)" "$CACHE_SCRIPTS/extract-affected-files.sh" "$(hr_run "$T/hrhome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
@@ -332,7 +339,7 @@ hr_check_stub() { # label file var script: the spec calls the script through the
332
339
  }
333
340
  hr_check_stub review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
334
341
  hr_check_stub review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
335
- hr_check_stub review-pr "$HR_CLASSIFY" CODE_INDEX_SCRIPT code-index.sh
342
+ hr_check_stub review-prepare "$HR_PREPARE" CODE_INDEX_SCRIPT code-index.sh
336
343
  hr_check_stub review-pr "$HR_CLASSIFY" DEDUP_SCRIPT issue-dedup.sh
337
344
  hr_check_block issue "$ROOT/commands/issue.md" DEDUP_SCRIPT issue-dedup.sh
338
345
  stub_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
@@ -409,7 +416,7 @@ FR="$ROOT/scripts/lib/forge-resolve.sh"; FB="$ROOT/scripts/lib/fence-bootstrap.s
409
416
  [ -f "$FR" ] && [ -f "$FB" ] && ok || bad "scripts/lib/forge-resolve.sh and scripts/lib/fence-bootstrap.sh must exist"
410
417
  sed 's/^[[:space:]]*//' "$STG_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/loc_uniq"
411
418
  [ "$(wc -l < "$T/loc_uniq" | tr -d ' ')" = 1 ] && ok || bad "forge_resolve locator line must be byte-identical across every stub in review-pr-staging.md"
412
- [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 30 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (11) stub"
419
+ [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 25 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (5) stub"
413
420
  grep -qE 'PWD|\$\(pwd\)|REPO_PATH' "$T/locator.sh" && bad "stub locator must not consult the cwd or repo under review" || ok
414
421
  [ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 21 ] && ok || bad "expected 21 fence bootstrap stubs"
415
422
  [ "$(grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sort -u | wc -l | tr -d ' ')" = 1 ] && ok || bad "fence bootstrap stubs must be byte-identical"
@@ -419,11 +426,11 @@ grep -qE '^[[:space:]]*(TRUSTED_SCRIPT|[A-Z_]+_SCRIPT)=""$|_tc="\$\(printf' "$ST
419
426
  # no $PWD / $(pwd) tier in the helper (comments aside)
420
427
  grep -nE '\$PWD|\$\(pwd\)|\$\{PWD' "$FR" | grep -vE '^[0-9]+:[[:space:]]*#' | grep -q . && bad "forge-resolve.sh must have no PWD tier" || ok
421
428
 
422
- # Guard (#3779): remediate.md carries the same byte-identical locator stub (7 fences: 5 gate sites, trusted-comments, CI gate) and none of the old inlined resolvers.
429
+ # Guard (#3779): remediate.md carries the same byte-identical locator stub (9 fences: M4 ancestry, merged-first transition-label (the merge-branch one reuses the CI-gate fence locator), 5 gate sites, trusted-comments, CI gate) and none of the old inlined resolvers.
423
430
  REM_SPEC="$ROOT/commands/work-on/remediate.md"
424
431
  sed 's/^[[:space:]]*//' "$REM_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/rem_uniq"
425
432
  [ "$(wc -l < "$T/rem_uniq" | tr -d ' ')" = 1 ] && ok || bad "remediate.md locator lines must be byte-identical"
426
- expect "remediate.md locator count (one per fence that resolves a script)" 7 "$(awk '{print $1}' "$T/rem_uniq")"
433
+ expect "remediate.md locator count (one per fence that resolves a script)" 9 "$(awk '{print $1}' "$T/rem_uniq")"
427
434
  [ "$(sed 's/^[[:space:]]*//' "$REM_SPEC" | grep '^type forge_resolve >/dev/null' | sed 1q)" = "$(cat "$T/locator.sh")" ] && ok || bad "remediate.md locator differs from the pinned review-pr-staging.md locator"
428
435
  expect "remediate.md inlined FORGE_ROOT bootstrap copies" 0 "$(grep -c '# FORGE_ROOT bootstrap' "$REM_SPEC" || true)"
429
436
  grep -qE '^[[:space:]]*(TRUSTED_SCRIPT|[A-Z_]+_SCRIPT)=""|_tc="\$\(printf|^[[:space:]]*GATE_SCRIPT=""' "$REM_SPEC" && bad "remediate.md still carries an inlined resolver block" || ok
@@ -436,7 +443,7 @@ expect "remediate.md: forge_resolve calls without a preceding locator in the sam
436
443
  RP_SPEC="$ROOT/commands/review-pr.md"
437
444
  sed 's/^[[:space:]]*//' "$RP_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/rp_uniq"
438
445
  [ "$(wc -l < "$T/rp_uniq" | tr -d ' ')" = 1 ] && ok || bad "review-pr.md locator lines must be byte-identical"
439
- [ "$(awk '{print $1}' "$T/rp_uniq")" -ge 15 ] && ok || bad "expected a locator in every review-pr.md fence that resolves a script (>=15)"
446
+ [ "$(awk '{print $1}' "$T/rp_uniq")" -ge 10 ] && ok || bad "expected a locator in every review-pr.md fence that resolves a script (>=10; the classify/file/prepare helpers resolve inside the review scripts since #3841)"
440
447
  [ "$(sed 's/^[[:space:]]*//' "$RP_SPEC" | grep '^type forge_resolve >/dev/null' | sed 1q)" = "$(cat "$T/locator.sh")" ] && ok || bad "review-pr.md locator differs from the pinned review-pr-staging.md locator"
441
448
  expect "review-pr.md inlined FORGE_ROOT bootstrap copies" 0 "$(grep -c 'FORGE_ROOT bootstrap' "$RP_SPEC" || true)"
442
449
  # (SCOPE_SCRIPT="" is a sibling path derived from the resolved trusted script's own directory, not a resolver accumulator)
@@ -10,7 +10,8 @@ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
10
10
  [ -n "${GH_REPO:-}" ] || GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE" 2>/dev/null)
11
11
  [ -n "${DEFAULT_BRANCH:-}" ] || DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE" 2>/dev/null)
12
12
  [ -n "${STAGING_BRANCH:-}" ] || STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE" 2>/dev/null)
13
- # numeric-validated: a tampered file never reaches gh
14
- [ -n "${PR_NUMBER:-}" ] || { [ -r "$_SD/staging_pr_number" ] && PR_NUMBER=$(sed -n '1{/^[0-9][0-9]*$/p;q}' "$_SD/staging_pr_number"); }
13
+ # run-keyed pointer (concurrent runs share $_SD); numeric-validated: a tampered file never reaches gh
14
+ FORGE_RUN_KEY="${FORGE_RUN_KEY:-${FORGE_RUN_ID:-${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-${CODEX_THREAD_ID:-${OPENCODE_SESSION_ID:-$PPID}}}}}}"; FORGE_RUN_KEY=$(printf '%s' "$FORGE_RUN_KEY" | tr -c 'A-Za-z0-9_-' '_')
15
+ [ -n "${PR_NUMBER:-}" ] || { [ -r "$_SD/staging_pr_number.${FORGE_RUN_KEY}" ] && PR_NUMBER=$(sed -n '1{/^[0-9][0-9]*$/p;q}' "$_SD/staging_pr_number.${FORGE_RUN_KEY}"); }
15
16
  [ -n "${ALL_PR_NUMBERS:-}" ] || { [ -n "${PR_NUMBER:-}" ] && [ -r "$_SD/${PR_NUMBER}_bundle-prs.txt" ] && ALL_PR_NUMBERS=$(grep -E '^[0-9]+$' "$_SD/${PR_NUMBER}_bundle-prs.txt"); }
16
- [ -n "${PR_NUMBER:-}" ] || echo "WARNING: PR_NUMBER is empty after reload (no staging_pr_number in $_SD): this fence cannot key its scratch files or post to a PR."
17
+ [ -n "${PR_NUMBER:-}" ] || echo "WARNING: PR_NUMBER is empty after reload (no staging_pr_number.${FORGE_RUN_KEY} in $_SD): this fence cannot key its scratch files or post to a PR."
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # pr-issue-number.sh: sourced helper — the ONE numeric-validated "Closes #N" extractor for a PR body.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Source it, never exec it (resolve with `forge_resolve lib/pr-issue-number.sh`).
7
+ # pr_issue_number reads the PR body on stdin
8
+ # pr_issue_number_of <body> same, body as an argument
9
+ # Prints the number of the FIRST `Closes|Fixes|Resolves #N` reference (any keyword form, any case), or NOTHING when the body
10
+ # has none. The output is always ^[0-9]+$ or empty, so it is safe to place in an API path. A hand-rolled jq `gsub` returns the
11
+ # whole input when the pattern does not match, which is why every extraction goes through this helper instead.
12
+ pr_issue_number() {
13
+ local n
14
+ n=$(grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?):?[[:space:]]+#[0-9]+' 2>/dev/null | head -n 1 | grep -oE '[0-9]+$' | head -n 1)
15
+ case "$n" in ''|*[!0-9]*) return 0 ;; esac
16
+ printf '%s\n' "$n"
17
+ }
18
+ pr_issue_number_of() { printf '%s\n' "${1:-}" | pr_issue_number; }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # review-template-resolve.sh: sourced helper — the ONE resolution chain for the review-pr-agents persona templates.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Source it, never exec it. `review_template_resolve [repo_path]` sets:
7
+ # TEMPLATE_SOURCE plugin_root | forge_home | repo_path | monolithic_catalog | none
8
+ # TEMPLATE_BASE directory holding protocols.md and the persona files (empty for monolithic_catalog / none)
9
+ # MONOLITHIC_CATALOG path of the last-resort catalog (monolithic_catalog only)
10
+ # Chain: the running plugin root (CLAUDE_PLUGIN_ROOT, absolute only), then FORGE_HOME, then the trusted install root from
11
+ # forge_root when the resolver is loaded, then the repo path argument, then the monolithic catalog (it must contain `### Agent:`
12
+ # headers: a post-split repo ships a router stub at that path that points back to the missing directory). `none` is a fatal setup
13
+ # error: the caller must stop and never review inline. The repo path is data, only ever read for template files.
14
+ review_template_resolve() {
15
+ local _plugin _root _repo="${1:-}"
16
+ TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""
17
+ _plugin="${CLAUDE_PLUGIN_ROOT:-}"; case "$_plugin" in /*) ;; *) _plugin="" ;; esac
18
+ if [ -n "$_plugin" ] && [ -f "$_plugin/commands/review-pr-agents/protocols.md" ]; then
19
+ TEMPLATE_BASE="$_plugin/commands/review-pr-agents"; TEMPLATE_SOURCE="plugin_root"; return 0
20
+ fi
21
+ case "${FORGE_HOME:-}" in
22
+ /*) if [ -f "$FORGE_HOME/commands/review-pr-agents/protocols.md" ]; then
23
+ TEMPLATE_BASE="$FORGE_HOME/commands/review-pr-agents"; TEMPLATE_SOURCE="forge_home"; return 0
24
+ fi ;;
25
+ esac
26
+ if type forge_root >/dev/null 2>&1; then
27
+ _root=$(forge_root)
28
+ if [ -n "$_root" ] && [ -f "$_root/commands/review-pr-agents/protocols.md" ]; then
29
+ TEMPLATE_BASE="$_root/commands/review-pr-agents"; TEMPLATE_SOURCE="forge_home"; return 0
30
+ fi
31
+ fi
32
+ if [ -n "$_repo" ]; then
33
+ if [ -f "$_repo/commands/review-pr-agents/protocols.md" ]; then
34
+ TEMPLATE_BASE="$_repo/commands/review-pr-agents"; TEMPLATE_SOURCE="repo_path"; return 0
35
+ fi
36
+ if [ -f "$_repo/commands/review-pr-agents.md" ] && grep -q "^### Agent:" "$_repo/commands/review-pr-agents.md" 2>/dev/null; then
37
+ MONOLITHIC_CATALOG="$_repo/commands/review-pr-agents.md"; TEMPLATE_SOURCE="monolithic_catalog"; return 0
38
+ fi
39
+ fi
40
+ return 0
41
+ }
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # telemetry.sh — fail-safe structured telemetry emitter (sourced helper, or exec'd as a CLI).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # . scripts/lib/telemetry.sh; forge_emit <event> key=value... # sourced
7
+ # bash scripts/lib/telemetry.sh <event> key=value... # exec'd (spec call sites)
8
+ #
9
+ # One compact JSON line per call: v, ts, forge_version, repo, issue, pr, run, phase, event + payload. GitHub is the
10
+ # canonical store: the line is appended to the trusted `<!-- FORGE:TELEMETRY v1 -->` comment on the issue (or PR for
11
+ # review-side events), edited in place (JSONL inside a fenced block), rolling over to `... v1 part=N` near 60000 chars.
12
+ # A failed write is appended to a local buffer and flushed by the next emit for the same target; the buffer is only
13
+ # a cache, everything flushed is recoverable from GitHub alone. The emitter NEVER fails or changes the caller:
14
+ # always returns 0, all output is discarded, `FORGE_TELEMETRY=off` disables it. It records ids, counts and codes only,
15
+ # never comment bodies, file contents or secrets (values are charset-restricted and truncated).
16
+ #
17
+ # Context env: FORGE_REPO (default: parsed from `git remote get-url origin`), FORGE_ISSUE, FORGE_PR, FORGE_RUN,
18
+ # FORGE_PHASE; null when unset. With no repo or no numeric target the record is dropped (nothing to write to).
19
+ # Test hooks: FORGE_TELEMETRY_GH (gh replacement), FORGE_TELEMETRY_BUFFER_DIR, FORGE_TELEMETRY_MAX_CHARS.
20
+ # Comment trust is decided by scripts/trusted-comments.sh (the one trust predicate), resolved next to this file.
21
+ # bash 3.2 portable.
22
+
23
+ _FORGE_TEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
24
+
25
+ _forge_tel_clean() { # sanitize a payload value: restricted charset, max 200 chars
26
+ printf '%s' "$1" | tr '\n\r\t' ' ' | tr -c 'A-Za-z0-9 _.:/#@+=,-' '_' | cut -c1-200
27
+ }
28
+
29
+ _forge_tel_record() { # event k=v... -> one JSON line on stdout
30
+ local ev="$1"; shift
31
+ local ver="" repo="${FORGE_REPO:-}" args=() kv k v
32
+ if [ -z "$repo" ]; then
33
+ repo=$(git remote get-url origin 2>/dev/null | sed -E 's#^.*[:/]([^/:]+/[^/]+)$#\1#; s#\.git$##')
34
+ fi
35
+ ver=$(jq -r '.version // empty' "$_FORGE_TEL_DIR/../../package.json" 2>/dev/null)
36
+ for kv in "$@"; do
37
+ k="${kv%%=*}"; v="${kv#*=}"
38
+ case "$kv" in *=*) ;; *) continue ;; esac
39
+ case "$k" in ''|*[!A-Za-z0-9_]*) continue ;; esac
40
+ case "$k" in v|ts|forge_version|repo|issue|pr|run|phase|event|eid) continue ;; esac
41
+ args+=("$k" "$(_forge_tel_clean "$v")")
42
+ done
43
+ jq -nc --arg ev "$ev" --arg ver "$ver" --arg repo "$repo" --arg issue "${FORGE_ISSUE:-}" --arg pr "${FORGE_PR:-}" \
44
+ --arg run "${FORGE_RUN:-}" --arg phase "${FORGE_PHASE:-}" --arg eid "$(date +%s)-$$-${RANDOM}${RANDOM}${RANDOM}" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
45
+ def num: if test("^[0-9]+$") then tonumber else null end;
46
+ def str: if . == "" then null else . end;
47
+ ($ARGS.positional | [range(0; length; 2) as $i | {key: .[$i], value: .[$i+1]}]
48
+ | map(.value |= (if test("^-?[0-9]+$") then tonumber elif . == "true" then true elif . == "false" then false else . end))
49
+ | from_entries) as $p
50
+ | {v: 1, ts: $ts, forge_version: ($ver | str), repo: ($repo | str), issue: ($issue | num), pr: ($pr | num),
51
+ run: ($run | str), phase: ($phase | str), event: $ev, eid: $eid} + $p' --args "${args[@]}"
52
+ }
53
+
54
+ # Prints "<number> <repo>" of the comment target (issue or PR) for this event, or nothing when none is usable.
55
+ _forge_tel_target() {
56
+ local ev="$1" repo="$2" n
57
+ case "$ev" in
58
+ review_panel|finding_disposition|ci_gate) n="${FORGE_PR:-}"; case "$n" in ''|*[!0-9]*) n="${FORGE_ISSUE:-}" ;; esac ;;
59
+ *) n="${FORGE_ISSUE:-}"; case "$n" in ''|*[!0-9]*) n="${FORGE_PR:-}" ;; esac ;;
60
+ esac
61
+ case "$n" in ''|*[!0-9]*) return 0 ;; esac
62
+ case "$repo" in */*) ;; *) return 0 ;; esac
63
+ printf '%s %s\n' "$n" "$repo"
64
+ }
65
+
66
+ # Appends the JSONL lines on stdin to the trusted telemetry comment of <repo>#<n>; returns non-zero on any failure.
67
+ _forge_tel_upsert() {
68
+ local repo="$1" n="$2" gh="${FORGE_TELEMETRY_GH:-gh}" max="${FORGE_TELEMETRY_MAX_CHARS:-60000}"
69
+ local nl fence new comments cand id body part best_id="" best_part=0 best_body="" all_bodies="" lines hdr out tc
70
+ new=$(cat); [ -n "$new" ] || return 0
71
+ tc="$_FORGE_TEL_DIR/../trusted-comments.sh"; [ -f "$tc" ] || return 1
72
+ command -v jq >/dev/null 2>&1 || return 1
73
+ local to=""; command -v timeout >/dev/null 2>&1 && to="timeout 30"
74
+ comments=$($to "$gh" api --paginate "repos/$repo/issues/$n/comments" 2>/dev/null) || return 1
75
+ [ -n "$comments" ] || comments='[]'
76
+ # Candidates carry the marker; each is then checked individually against the trust predicate.
77
+ while IFS= read -r cand; do
78
+ [ -n "$cand" ] || continue
79
+ id=$(printf '%s' "$cand" | jq -r '.id')
80
+ [ "$(printf '[%s]' "$cand" | bash "$tc" count '^<!-- FORGE:TELEMETRY v1( part=[0-9]+)? -->' 2>/dev/null)" = "1" ] || continue
81
+ body=$(printf '%s' "$cand" | jq -r '.body')
82
+ all_bodies="${all_bodies}${body}"$'\n'
83
+ part=$(printf '%s' "$body" | sed -n '1s/^<!-- FORGE:TELEMETRY v1 part=\([0-9]*\) -->.*/\1/p'); part="${part:-1}"
84
+ if [ "$part" -ge "$best_part" ]; then best_part="$part"; best_id="$id"; best_body="$body"; fi
85
+ done < <(printf '%s' "$comments" | jq -c -s '.[][] | select((.body // "") | test("^<!-- FORGE:TELEMETRY v1( part=[0-9]+)? -->")) | {id, body, user, author_association}' 2>/dev/null)
86
+ # Drop lines any part already holds (a PATCH whose reply was lost, or a re-flushed buffer). Every record carries a
87
+ # unique eid, so two distinct same-second events are never byte-identical and are never dropped by this check.
88
+ lines=""
89
+ while IFS= read -r l; do
90
+ [ -n "$l" ] || continue
91
+ case "$all_bodies" in *"$l"*) continue ;; esac
92
+ lines="${lines}${l}"$'\n'
93
+ done <<< "$new"
94
+ [ -n "$lines" ] || return 0
95
+ # bash 3.2: $'..' inside a double-quoted ${..%..} is NOT expanded (and backticks there start a command
96
+ # substitution), so the newline and fence live in variables and the pattern quotes them.
97
+ nl=$'\n'; fence='```'
98
+ lines="${lines%"$nl"}"
99
+ if [ -n "$best_id" ]; then
100
+ local cur="${best_body%"$nl$fence"*}" # body up to the closing fence
101
+ out="${cur}"$'\n'"${lines}"$'\n```'
102
+ if [ "${#out}" -le "$max" ]; then
103
+ $to "$gh" api -X PATCH "repos/$repo/issues/comments/$best_id" -f body="$out" >/dev/null 2>&1; return $?
104
+ fi
105
+ hdr="<!-- FORGE:TELEMETRY v1 part=$((best_part + 1)) -->"
106
+ else
107
+ hdr="<!-- FORGE:TELEMETRY v1 -->"
108
+ fi
109
+ out="${hdr}"$'\n```jsonl\n'"${lines}"$'\n```'
110
+ $to "$gh" api -X POST "repos/$repo/issues/$n/comments" -f body="$out" >/dev/null 2>&1
111
+ }
112
+
113
+ _forge_tel_impl() {
114
+ local ev="${1:-}"; shift
115
+ [ "${FORGE_TELEMETRY:-on}" = "off" ] && return 0
116
+ case "$ev" in ''|*[!A-Za-z0-9_.-]*) return 0 ;; esac
117
+ command -v jq >/dev/null 2>&1 || return 0
118
+ local rec repo tgt n buf bdir pending
119
+ rec=$(_forge_tel_record "$ev" "$@") && [ -n "$rec" ] || return 0
120
+ repo=$(printf '%s' "$rec" | jq -r '.repo // ""')
121
+ tgt=$(_forge_tel_target "$ev" "$repo"); [ -n "$tgt" ] || return 0
122
+ n="${tgt%% *}"
123
+ bdir="${FORGE_TELEMETRY_BUFFER_DIR:-$HOME/.forge/telemetry-buffer}"
124
+ buf="$bdir/$(printf '%s' "$repo" | tr '/' '_')__${n}.jsonl"
125
+ # Serialize emitters on this host per target: the GitHub read-modify-write has no version check, so two overlapping
126
+ # emitters would each PATCH a stale snapshot (lost record) or both POST a first/rollover part (duplicate comments).
127
+ # Bounded wait (every loop pass counts, 50 x 0.1s); a lock over 5 min old (longer than any bounded hold: two 30s gh
128
+ # calls plus trust checks) is stale and reclaimed by an atomic rename so only one waiter wins; the holder writes an
129
+ # owner token and releases only its own lock. A non-writable buffer dir skips locking. On timeout the record is
130
+ # buffered, never dropped.
131
+ local lock="$bdir/.lock-$(printf '%s' "$repo" | tr '/' '_')__${n}" tries=0 got="" token="$$.${RANDOM}${RANDOM}"
132
+ if ! { mkdir -p "$bdir" 2>/dev/null && [ -w "$bdir" ]; }; then tries=50; got=nolock; fi
133
+ while [ "$tries" -lt 50 ]; do
134
+ if mkdir "$lock" 2>/dev/null; then printf '%s' "$token" > "$lock/owner" 2>/dev/null; got=1; break; fi
135
+ if [ -n "$(find "$lock" -maxdepth 0 -mmin +5 2>/dev/null)" ]; then
136
+ mv "$lock" "$lock.stale.$token" 2>/dev/null && rm -rf "$lock.stale.$token" 2>/dev/null
137
+ else
138
+ sleep 0.1 2>/dev/null || sleep 1
139
+ fi
140
+ tries=$((tries + 1))
141
+ done
142
+ if [ -z "$got" ]; then printf '%s\n' "$rec" >> "$buf" 2>/dev/null; return 0; fi
143
+ _forge_tel_unlock() { [ "$got" = 1 ] && [ "$(cat "$lock/owner" 2>/dev/null)" = "$token" ] && rm -rf "$lock" 2>/dev/null; got=""; }
144
+ trap '_forge_tel_unlock' EXIT; trap '_forge_tel_unlock; exit 1' HUP INT TERM
145
+ # Claim the buffer by renaming it first: a record appended by another emitter after this point lands in a fresh
146
+ # buffer file and survives. Holding the lock, any other "$buf.flush.*" is an orphan of a killed emitter: fold it in.
147
+ local claimed="$buf.flush.$token" f
148
+ pending="$rec"
149
+ if [ -s "$buf" ]; then mv "$buf" "$claimed" 2>/dev/null; fi
150
+ for f in "$buf".flush.*; do
151
+ [ -f "$f" ] && [ "$f" != "$claimed" ] && cat "$f" >> "$claimed" 2>/dev/null && rm -f "$f" 2>/dev/null
152
+ done
153
+ if [ -s "$claimed" ]; then pending="$(cat "$claimed")"$'\n'"$rec"; fi
154
+ if printf '%s\n' "$pending" | _forge_tel_upsert "$repo" "$n"; then
155
+ rm -f "$claimed" 2>/dev/null
156
+ else
157
+ if [ -f "$claimed" ]; then cat "$claimed" >> "$buf" 2>/dev/null; rm -f "$claimed" 2>/dev/null; fi
158
+ printf '%s\n' "$rec" >> "$buf" 2>/dev/null
159
+ fi
160
+ _forge_tel_unlock
161
+ return 0
162
+ }
163
+
164
+ # Subshell + discarded output: no option, variable, trap or stream of the caller can be changed or polluted.
165
+ forge_emit() { ( set +eu +o pipefail 2>/dev/null; _forge_tel_impl "$@" ) >/dev/null 2>&1 </dev/null || true; return 0; }
166
+
167
+ # Exec'd as a CLI (spec call sites); sourcing only defines functions.
168
+ if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" = "$0" ]; then forge_emit "$@"; exit 0; fi
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695)
3
+ # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions, no network.
4
+ # Usage: bash scripts/publish-staging-sync.test.sh
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+
8
+ set -uo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+ PUB="$ROOT/.github/workflows/publish.yml"
12
+ DEP="$ROOT/commands/deploy-pr.md"
13
+
14
+ PASS=0; FAILN=0
15
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
16
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
17
+ has() { if grep -qF -e "$2" "$3"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
18
+
19
+ # Extract the sync step block (from its name line to the next step's name line).
20
+ STEP=$(awk '/- name: Sync version to staging/{f=1;print;next} f&&/^ - name: /{exit} f{print}' "$PUB")
21
+ [ -n "$STEP" ] && ok "publish.yml has the Sync version to staging step" || bad "publish.yml has the Sync version to staging step"
22
+
23
+ L_PUSH=$(grep -n -- '- name: Push version bump' "$PUB" | head -1 | cut -d: -f1)
24
+ L_SYNC=$(grep -n -- '- name: Sync version to staging' "$PUB" | head -1 | cut -d: -f1)
25
+ L_REL=$(grep -n -- '- name: Create GitHub Release' "$PUB" | head -1 | cut -d: -f1)
26
+ if [ -n "$L_PUSH" ] && [ -n "$L_SYNC" ] && [ -n "$L_REL" ] && [ "$L_PUSH" -lt "$L_SYNC" ] && [ "$L_SYNC" -lt "$L_REL" ]; then
27
+ ok "sync step sits between Push version bump and Create GitHub Release"
28
+ else bad "sync step sits between Push version bump and Create GitHub Release"; fi
29
+
30
+ stepHas() { if printf '%s\n' "$STEP" | grep -qF -e "$2"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
31
+ stepHas "step fetches origin/staging" "origin/staging"
32
+ stepHas "step pushes HEAD to staging" "git push origin HEAD:staging"
33
+ stepHas "step is non-fatal" "continue-on-error: true"
34
+ stepHas "step is guarded by SKIP_PUBLISH" "env.SKIP_PUBLISH != 'true'"
35
+ stepHas "commit carries [skip ci]" "[skip ci]"
36
+ stepHas "only version files are staged" "git add package.json .claude-plugin/plugin.json"
37
+ stepHas "failure emits a warning" "::warning::"
38
+ if printf '%s\n' "$STEP" | grep -qE 'git (merge|pull)'; then bad "step must not merge main (version-only commit)"; else ok "step does not merge main"; fi
39
+
40
+ has "deploy-pr has the 2A.5 pre-flight" "### 2A.5: Version Sync Pre-flight" "$DEP"
41
+ has "deploy-pr pre-flight compares package.json" "package.json" "$DEP"
42
+
43
+ if git -C "$ROOT" rev-parse --verify -q origin/main >/dev/null 2>&1; then
44
+ if git -C "$ROOT" diff --quiet origin/main -- .github/workflows/version-guard.yml; then ok "version-guard.yml unchanged"; else bad "version-guard.yml unchanged"; fi
45
+ fi
46
+
47
+ echo "passed=$PASS failed=$FAILN"
48
+ [ "$FAILN" -eq 0 ]
@@ -12,7 +12,9 @@
12
12
  # lease.by that is not the dispatching holder and not a provably dead same-host engine pid. An until past
13
13
  # the TTL cap is not a lease an engine could have written (the issue body is author-editable), so it is dead.
14
14
  # (b) review: the newest trusted FORGE:REVIEW_ROUTE comment is younger than REVIEW_PENDING_MAX_S and no trusted
15
- # FORGE:REVIEW-AGENT:* comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction).
15
+ # FORGE:REVIEW-AGENT:* or FORGE:REVIEW-CARRIED comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction),
16
+ # and no trusted FORGE:{CI|INPR|BASESYNC}_REMEDIATION: pr=<pr> bound marker on the issue is at or after that route
17
+ # (a review that handed off to remediation, even before dispatching a panel, is never in flight).
16
18
  # A deferral is bounded: after MAX_DEFERRALS trusted FORGE:REMEDIATION_DEFERRED records for this PR the answer is
17
19
  # EXHAUSTED (a human is needed), so a flapping lease or a dead review can never park a PR forever.
18
20
  #
@@ -23,6 +25,10 @@
23
25
  # LIVENESS: ERROR <why> exit 2 unreadable state; fail closed (never "no lease", never delete)
24
26
  # Trust predicate for comments: Bot, or OWNER/MEMBER/COLLABORATOR (same default as scripts/trusted-comments.sh).
25
27
  set -uo pipefail
28
+ # Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
29
+ _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
30
+ type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
31
+ [ -z "${DRY_RUN:-}" ] || FORGE_TELEMETRY=off
26
32
 
27
33
  PR="${1:-}"; ISSUE="${2:-}"; REPO="${3:-}"; HOLDER="${4:-}"
28
34
  MAX_LEASE_TTL_MS=1800000 # 3x the engine's 10 min lease TTL
@@ -39,7 +45,28 @@ NOW_S=$(( NOW_MS / 1000 ))
39
45
 
40
46
  TRUST='((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))'
41
47
 
42
- ISSUE_JSON=$(gh issue view "$ISSUE" -R "$REPO" --json body 2>/dev/null) || err "issue unreadable"
48
+ # Body + provenance (issue-body FORGE:STATE trust gate): the LAST EDITOR (editor // author) must be a Bot,
49
+ # or the creator with OWNER/MEMBER/COLLABORATOR, or a distinct editor with write/maintain/admin permission.
50
+ # Unreadable provenance fails closed. An untrusted body is treated as having no FORGE:STATE (no lease).
51
+ OWNER_PART="${REPO%%/*}"; NAME_PART="${REPO#*/}"
52
+ PROV_Q='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}'
53
+ PROV=$(gh api graphql -f query="$PROV_Q" -F owner="$OWNER_PART" -F name="$NAME_PART" -F number="$ISSUE" 2>/dev/null) || err "issue unreadable"
54
+ ISSUE_JSON=$(printf '%s' "$PROV" | jq -c '.data.repository.issue | {body: (.body // "")}' 2>/dev/null) || err "issue unreadable"
55
+ ACTOR_ROW=$(printf '%s' "$PROV" | jq -r '.data.repository.issue | (.editor // .author) as $a | [($a.__typename // ""), ($a.login // ""), (if .editor != null and .editor.login != .author.login then "EDITOR" else (.authorAssociation // "NONE") end)] | @tsv' 2>/dev/null) || err "issue unreadable"
56
+ A_TYPE=$(printf '%s' "$ACTOR_ROW" | cut -f1); A_LOGIN=$(printf '%s' "$ACTOR_ROW" | cut -f2); A_ASSOC=$(printf '%s' "$ACTOR_ROW" | cut -f3)
57
+ BODY_TRUSTED=false
58
+ if [ "$A_TYPE" = "Bot" ]; then BODY_TRUSTED=true
59
+ else
60
+ case "$A_ASSOC" in
61
+ OWNER|MEMBER|COLLABORATOR) BODY_TRUSTED=true ;;
62
+ EDITOR)
63
+ if printf '%s' "$A_LOGIN" | grep -qE '^[A-Za-z0-9-]{1,39}$'; then
64
+ PERM=$(gh api "repos/$REPO/collaborators/$A_LOGIN/permission" --jq .permission 2>/dev/null || true)
65
+ case "$PERM" in admin|maintain|write) BODY_TRUSTED=true ;; esac
66
+ fi ;;
67
+ esac
68
+ fi
69
+ [ "$BODY_TRUSTED" = "true" ] || ISSUE_JSON='{"body":""}'
43
70
  ISSUE_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments" 2>/dev/null) || err "issue comments unreadable"
44
71
  PR_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null) || err "PR comments unreadable"
45
72
  [ -n "$PR_COMMENTS" ] || err "PR comments unreadable"
@@ -63,22 +90,30 @@ if [ -n "$STATE_JSON" ]; then
63
90
  fi
64
91
  fi
65
92
 
66
- # (b) a routed review that is still inside its window and has produced no verdict for the routed sha.
93
+ # (b) a routed review that is still inside its window and has produced no verdict (FORGE:REVIEW-AGENT or, for BASE_SYNC_ONLY, FORGE:REVIEW-CARRIED) for the routed sha.
67
94
  ROUTE=$(printf '%s' "$PR_COMMENTS" | jq -rs "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW_ROUTE mode=[^ ]+ spec=review-pr.md sha=[0-9a-f]{7,40} -->\")) | select((.body | test(\"mode=spec-evolution-blocked\")) | not)] | last | if . == null then \"\" else ((.body | capture(\"sha=(?<s>[0-9a-f]{7,40})\").s) + \" \" + ((.created_at // \"\") | fromdateiso8601? // 0 | tostring)) end" 2>/dev/null) \
68
95
  || err "review comments unparseable"
69
96
  ROUTE_SHA="${ROUTE%% *}"; ROUTE_AT="${ROUTE##* }"; REVIEW_PENDING=false
70
97
  if [ -n "$ROUTE_SHA" ]; then
71
98
  case "$ROUTE_AT" in ''|*[!0-9]*) ROUTE_AT=0 ;; esac
72
- VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
73
- [ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] && REVIEW_PENDING=true
99
+ VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-(AGENT:[a-z-]+|CARRIED) -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
100
+ # Handoff: the dispatcher posts FORGE:CI_REMEDIATION / FORGE:INPR_REMEDIATION / FORGE:BASESYNC_REMEDIATION: pr=N on the issue right before dispatching remediation, so a
101
+ # trusted marker at or after the route means the review handed off (possibly before any panel) and nothing is in flight. A route newer than the marker is a re-review.
102
+ HANDOFF_Q='[.[][] | select('"$TRUST"') | select((.body // "") | test("(^|\\n)[ \\t]*<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: " + $m + " -->[ \\t\\r]*(\\n|$)")) | ((.created_at // "") | fromdateiso8601? // 0)] | max // 0'
103
+ HANDOFF_AT=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg m "pr=$PR" "$HANDOFF_Q" 2>/dev/null) || err "handoff markers unreadable"
104
+ case "$HANDOFF_AT" in ''|*[!0-9]*) HANDOFF_AT=0 ;; esac
105
+ [ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] \
106
+ && { [ "$HANDOFF_AT" -eq 0 ] || [ "$HANDOFF_AT" -lt "$ROUTE_AT" ]; } && REVIEW_PENDING=true
74
107
  fi
75
108
 
76
109
  if [ "$LEASE_LIVE" != "true" ] && [ "$REVIEW_PENDING" != "true" ]; then
110
+ FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=PROCEED why=dead
77
111
  echo "LIVENESS: DEAD"; exit 0
78
112
  fi
79
113
 
80
114
  PRIOR=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg pr "pr=$PR" "[.[][] | select($TRUST) | select((.body // \"\") | startswith(\"<!-- FORGE:REMEDIATION_DEFERRED -->\")) | select((.body | split(\"\\n\") | index(\$pr)) != null)] | length" 2>/dev/null) || err "deferral records unreadable"
81
- if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
115
+ if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=EXHAUSTED why=deferrals-${PRIOR}
116
+ echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
82
117
 
83
118
  if [ "$LEASE_LIVE" = "true" ]; then KIND=lease; DETAIL="lease held by ${LEASE_BY} until ${LEASE_UNTIL}"
84
119
  else KIND=review; DETAIL="re-review of ${ROUTE_SHA} has no reviewer verdicts yet"; fi
@@ -93,4 +128,5 @@ lease_by=${LEASE_BY:-none}
93
128
  lease_until=${LEASE_UNTIL}"
94
129
  if [ -n "${DRY_RUN:-}" ]; then printf 'DRY_RUN: would post:\n%s\n' "$BODY"
95
130
  else gh issue comment "$ISSUE" -R "$REPO" --body "$BODY" >/dev/null 2>&1 || err "could not post deferral record"; fi
131
+ FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=DEFER why="$KIND"
96
132
  echo "LIVENESS: DEFER ${KIND} ${DETAIL}"; exit 10