@plainconceptsplatform/workflows 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/action-validation.test.d.ts +1 -0
  2. package/dist/action-validation.test.js +87 -0
  3. package/dist/catalog-installation.d.ts +30 -0
  4. package/dist/catalog-installation.js +422 -0
  5. package/dist/catalog-installation.test.d.ts +1 -0
  6. package/dist/catalog-installation.test.js +485 -0
  7. package/dist/catalog-listing.d.ts +13 -0
  8. package/dist/catalog-listing.js +70 -0
  9. package/dist/catalog-listing.test.d.ts +1 -0
  10. package/dist/catalog-listing.test.js +150 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +218 -0
  13. package/dist/index.test.d.ts +1 -0
  14. package/dist/index.test.js +273 -0
  15. package/dist/repository-inspection.d.ts +23 -0
  16. package/dist/repository-inspection.js +113 -0
  17. package/dist/repository-inspection.test.d.ts +1 -0
  18. package/dist/repository-inspection.test.js +77 -0
  19. package/dist/route-processing.d.ts +6 -0
  20. package/dist/route-processing.js +149 -0
  21. package/dist/route-processing.test.d.ts +1 -0
  22. package/dist/route-processing.test.js +283 -0
  23. package/dist/stack-defaults.d.ts +11 -0
  24. package/dist/stack-defaults.js +104 -0
  25. package/dist/stack-defaults.test.d.ts +1 -0
  26. package/dist/stack-defaults.test.js +266 -0
  27. package/dist/tui.d.ts +25 -0
  28. package/dist/tui.js +287 -0
  29. package/dist/tui.test.d.ts +1 -0
  30. package/dist/tui.test.js +249 -0
  31. package/dist/workflow-catalog.d.ts +25 -0
  32. package/dist/workflow-catalog.js +55 -0
  33. package/dist/workflow-catalog.test.d.ts +1 -0
  34. package/dist/workflow-catalog.test.js +29 -0
  35. package/loops/actions/add-issue-labels/action.yml +2 -2
  36. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  37. package/loops/actions/classify-route/action.yml +7 -0
  38. package/loops/actions/classify-route/classify-route.sh +20 -5
  39. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  40. package/loops/actions/identify-gate-subject/action.yml +3 -1
  41. package/loops/actions/remove-issue-labels/action.yml +4 -2
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  43. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  44. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +300 -16
  45. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  46. package/loops/scripts/merge-changelog.mjs +76 -0
  47. package/loops/templates/agentics/actionlint.yaml +13 -0
  48. package/loops/templates/agentics/agentics-checks.yml +203 -86
  49. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  50. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  51. package/loops/templates/opencode/opencode.ci.json +2 -2
  52. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  53. package/loops/workflows/agent-apply-review.md +5 -1
  54. package/loops/workflows/agent-implement.md +105 -3
  55. package/loops/workflows/agent-merge-gate.md +139 -25
  56. package/loops/workflows/agent-refine.md +26 -2
  57. package/loops/workflows/work-router.yml +227 -51
  58. package/package.json +1 -1
@@ -91,18 +91,19 @@ on:
91
91
  # moved and previously-mergeable sibling bot PRs may now conflict.
92
92
  types: [opened, synchronize, reopened, labeled, closed]
93
93
 
94
- # CI completion on a pull request. Enabled so a FAILED CI run dispatches the Merge Gate
95
- # immediately instead of waiting for the hourly reconcile. The classifier admits only
96
- # conclusion == failure with an attached pull request; a green run still reaches merge
97
- # through app-ci's dispatch-merge-gate and the reconcile belt, so routing success here
98
- # would double-fire the gate on every passing PR.
94
+ # CI completion on a pull request. The classifier admits only conclusion == failure with an
95
+ # attached pull request; a green run reaches the gate through the consumer CI's
96
+ # dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
97
+ # the gate on every passing PR.
99
98
  #
100
- # Caveat that still holds (2026-08-28): GitHub does not deliver workflow_run for CI runs
101
- # whose originating event came from GITHUB_TOKEN, so a bot-opened PR's own CI completion
102
- # may never arrive here — reconcile-bot-pr-runs remains the belt for that gap. Security:
103
- # workflow_run runs from the default branch with secrets, but the gate's subject job
104
- # rejects any PR that is not open and bot-authored, so a fork PR's failing CI cannot
105
- # start privileged work.
99
+ # Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
100
+ # whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
101
+ # follow have the bot as their actor, and no workflow_run arrives for any of them (four
102
+ # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
103
+ # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
104
+ # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
105
+ # branch with secrets, but the gate's subject job rejects any PR that is not open and
106
+ # bot-authored, so a fork PR's failing CI cannot start privileged work.
106
107
  workflow_run:
107
108
  workflows: ["App: CI"]
108
109
  types: [completed]
@@ -118,7 +119,6 @@ on:
118
119
  - cron: "43 3 * * *"
119
120
  - cron: "0 6 * * *"
120
121
  - cron: "17 * * * *"
121
- - cron: "29 7 * * *"
122
122
 
123
123
  workflow_dispatch:
124
124
  inputs:
@@ -165,6 +165,11 @@ on:
165
165
  description: "CI workflow run ID for merge-gate dispatch"
166
166
  required: false
167
167
  type: string
168
+ attempts_so_far:
169
+ description: "Failed merge-gate attempts already made against this CI verdict (filled by the router, not by humans)"
170
+ required: false
171
+ type: string
172
+ default: '0'
168
173
  trigger-kind:
169
174
  description: "Audit trigger: scheduled or manual"
170
175
  required: false
@@ -274,6 +279,8 @@ jobs:
274
279
  linked-issue: ${{ steps.route.outputs.linked-issue }}
275
280
  ci-conclusion: ${{ steps.route.outputs.ci-conclusion }}
276
281
  ci-run-id: ${{ steps.route.outputs.ci-run-id }}
282
+ merge-gate-attempts: ${{ steps.route.outputs.merge-gate-attempts }}
283
+ implement-attempts: ${{ steps.route.outputs.implement-attempts }}
277
284
  refine-mode: ${{ steps.route.outputs.refine-mode }}
278
285
  triage-mode: ${{ steps.route.outputs.triage-mode }}
279
286
  trigger-kind: ${{ steps.route.outputs.trigger-kind }}
@@ -396,6 +403,7 @@ jobs:
396
403
  permissions: write-all
397
404
  with:
398
405
  issue-number: ${{ needs.classify.outputs.issue-number }}
406
+ attempts_so_far: ${{ needs.classify.outputs.implement-attempts }}
399
407
  secrets:
400
408
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
401
409
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
@@ -474,14 +482,18 @@ jobs:
474
482
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
475
483
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
476
484
 
477
- # Shares the write-pipeline group with implement so the two never race on one issue.
478
- # Falls back to the pull request number only when the closing issue cannot be resolved.
485
+ # The merge belt is one lock for the whole repository, not one group per pull request.
486
+ # A batch of six overnight pull requests means every merge moves the default branch
487
+ # under the other five; gates running at once would each rebase onto a base another
488
+ # gate is about to invalidate. Serialising the belt means each gate sees a main that
489
+ # only changes between gates, every merge re-arms the siblings through
490
+ # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
479
491
  call-merge-gate:
480
492
  needs: classify
481
493
  if: needs.classify.outputs.route == 'merge-gate'
482
494
  uses: ./.github/workflows/agent-merge-gate.lock.yml
483
495
  concurrency:
484
- group: write-pipeline-${{ needs.classify.outputs.linked-issue || format('pr-{0}', needs.classify.outputs.pr-number) }}
496
+ group: merge-belt
485
497
  cancel-in-progress: false
486
498
  permissions: write-all
487
499
  with:
@@ -489,6 +501,7 @@ jobs:
489
501
  linked-issue: ${{ needs.classify.outputs.linked-issue }}
490
502
  ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
491
503
  ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
504
+ attempts_so_far: ${{ needs.classify.outputs.merge-gate-attempts }}
492
505
  secrets:
493
506
  OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
494
507
  CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
@@ -614,8 +627,41 @@ jobs:
614
627
  run: |
615
628
  set -euo pipefail
616
629
 
617
- # Dispatch the Merge Gate for one pull request when it is a conflicting, open,
618
- # non-draft, bot-authored pull request on this repository whose CI has concluded.
630
+ # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft,
631
+ # bot-authored pull request on this repository whose CI has concluded — the
632
+ # conflict case is one arm of that, because a conflicting PR never gets a fresh
633
+ # CI run of its own; a merge conflict with red or concluded CI is the same
634
+ # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
635
+ # to pick up when its checks finish (bot-approve / reconcile).
636
+ # GitHub computes mergeability on demand. The first read of a pull request nobody has
637
+ # opened recently answers UNKNOWN through the API and null through REST, while it works
638
+ # the answer out in the background. Reading once therefore reports "not conflicting"
639
+ # for precisely the stale conflicting pull requests that need the gate. Ask until it
640
+ # settles.
641
+ mergeable_state() {
642
+ local pr="$1" state=UNKNOWN
643
+ for _ in 1 2 3 4 5 6; do
644
+ state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
645
+ [ "$state" = "UNKNOWN" ] || break
646
+ sleep 5
647
+ done
648
+ echo "$state"
649
+ }
650
+
651
+ # A gate already queued or running for this pull request will read the same CI verdict;
652
+ # a second dispatch queues behind merge-belt and the single-slot queue cancels it. Same
653
+ # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
654
+ # title run-name gives a gate dispatch.
655
+ gate_is_live() {
656
+ local pr="$1" status live
657
+ for status in queued in_progress waiting pending; do
658
+ live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
659
+ --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
660
+ [ "${live:-0}" -eq 0 ] || return 0
661
+ done
662
+ return 1
663
+ }
664
+
619
665
  maybe_dispatch() {
620
666
  pr="$1"
621
667
  [ -n "$pr" ] || return 0
@@ -624,21 +670,10 @@ jobs:
624
670
  --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
625
671
  2>/dev/null || echo '')
626
672
  [ -n "$meta" ] || return 0
627
- [ "$(jq -r '.state' <<<"$meta")" = "open" ] || return 0
628
- [ "$(jq -r '.draft' <<<"$meta")" = "false" ] || return 0
629
- [ "$(jq -r '.bot' <<<"$meta")" = "Bot" ] || return 0
630
- [ "$(jq -r '.head' <<<"$meta")" = "$REPO" ] || return 0
631
-
632
- # GitHub computes mergeability asynchronously; poll until it is known (not null).
633
- mergeable=""
634
- for _ in 1 2 3 4 5 6; do
635
- mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
636
- [ "$mergeable" = "null" ] || break
637
- sleep 5
638
- done
639
- # mergeable == false means a git-level conflict (mergeable_state "dirty").
640
- if [ "$mergeable" != "false" ]; then
641
- echo "PR #$pr mergeable=$mergeable; no conflict dispatch."
673
+ if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
674
+ || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
675
+ || [ "$(jq -r '.bot' <<<"$meta")" != "Bot" ] \
676
+ || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
642
677
  return 0
643
678
  fi
644
679
 
@@ -652,20 +687,88 @@ jobs:
652
687
  and (.conclusion == "success" or .conclusion == "failure"))]
653
688
  | sort_by(.created_at) | last // empty')
654
689
  if [ -z "$ci" ]; then
655
- echo "PR #$pr conflicts but CI has not concluded; deferring to the CI-completion route."
656
- return 0
690
+ # A conflicting pull request has no merge ref for GitHub to build, so a
691
+ # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
692
+ # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
693
+ # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
694
+ # back to the branch's last verdict so the gate runs; it stays on the remediation
695
+ # path and cannot merge on a stale conclusion.
696
+ head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
697
+ mergeable_now=$(mergeable_state "$pr")
698
+ if [ "$mergeable_now" != "CONFLICTING" ]; then
699
+ echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
700
+ return 0
701
+ fi
702
+ ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
703
+ --jq '[.workflow_runs[]
704
+ | select(.name == "App: CI" and .status == "completed"
705
+ and (.conclusion == "success" or .conclusion == "failure"))]
706
+ | sort_by(.created_at) | last // empty')
707
+ if [ -z "$ci" ]; then
708
+ echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
709
+ return 0
710
+ fi
711
+ echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
657
712
  fi
658
713
  ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
714
+ ci_finished=$(jq -r '.updated_at' <<<"$ci")
659
715
  ci_run_id=$(jq -r '.id' <<<"$ci")
660
716
 
661
- echo "PR #$pr is conflicting (CI $ci_conclusion); dispatching Merge Gate."
717
+ # GitHub computes mergeability asynchronously; poll until it is known (not null).
718
+ mergeable=""
719
+ for _ in 1 2 3 4 5 6; do
720
+ mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
721
+ [ "$mergeable" = "null" ] || break
722
+ sleep 5
723
+ done
724
+
725
+ if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
726
+ # Mergeable and green: the reconcile belt and the PR's own checks finish it.
727
+ return 0
728
+ fi
729
+
730
+ # A terminal verdict newer than this CI run means a gate already decided;
731
+ # failed attempts (the attempt marker) deliberately do not count.
732
+ verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
733
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
734
+ linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
735
+ if [ -n "$linked_issue" ]; then
736
+ issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
737
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
738
+ if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
739
+ verdict="$issue_verdict"
740
+ fi
741
+ fi
742
+ if [ -n "$verdict" ] && [ "$verdict" \> "$ci_finished" ]; then
743
+ echo "PR #$pr already has a gate verdict newer than CI; skipping."
744
+ return 0
745
+ fi
746
+
747
+ attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
748
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
749
+ if [ -n "$linked_issue" ]; then
750
+ issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
751
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
752
+ attempts=$((attempts + issue_attempts))
753
+ fi
754
+ if [ "$attempts" -ge 6 ]; then
755
+ echo "PR #$pr already consumed $attempts gate attempts on this CI verdict, parking for review."
756
+ return 0
757
+ fi
758
+ if gate_is_live "$pr"; then
759
+ echo "PR #$pr: a merge-gate run is already live; not dispatching another."
760
+ return 0
761
+ fi
762
+
763
+ echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of 6)."
662
764
  gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
663
765
  --method POST \
664
766
  -f ref="$DEFAULT_BRANCH" \
665
767
  -f "inputs[operation]=merge-gate" \
666
768
  -f "inputs[pr-number]=$pr" \
667
769
  -f "inputs[ci-conclusion]=$ci_conclusion" \
668
- -f "inputs[ci-run-id]=$ci_run_id"
770
+ -f "inputs[ci-run-id]=$ci_run_id" \
771
+ -f "inputs[attempts_so_far]=$attempts"
669
772
  }
670
773
 
671
774
  if [ "$ACTION" = "closed" ]; then
@@ -715,6 +818,33 @@ jobs:
715
818
  run: |
716
819
  set -euo pipefail
717
820
 
821
+ # GitHub computes mergeability on demand. The first read of a pull request nobody has
822
+ # opened recently answers UNKNOWN through the API and null through REST, while it works
823
+ # the answer out in the background. Reading once therefore reports "not conflicting"
824
+ # for precisely the stale conflicting pull requests that need the gate. Ask until it
825
+ # settles.
826
+ mergeable_state() {
827
+ local pr="$1" state=UNKNOWN
828
+ for _ in 1 2 3 4 5 6; do
829
+ state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
830
+ [ "$state" = "UNKNOWN" ] || break
831
+ sleep 5
832
+ done
833
+ echo "$state"
834
+ }
835
+
836
+ # A gate already queued or running for this pull request will read the same CI verdict;
837
+ # a second dispatch queues behind merge-belt and the single-slot queue cancels it.
838
+ gate_is_live() {
839
+ local pr="$1" status live
840
+ for status in queued in_progress waiting pending; do
841
+ live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
842
+ --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
843
+ [ "${live:-0}" -eq 0 ] || return 0
844
+ done
845
+ return 1
846
+ }
847
+
718
848
  # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
719
849
  # because authorize-bot-work is keyed per issue, while implement-global lets exactly
720
850
  # one run at a time. A queued run that is cancelled therefore leaves its issue
@@ -772,37 +902,79 @@ jobs:
772
902
  )
773
903
  ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
774
904
  if [ -z "$ci" ]; then
775
- continue
905
+ # A conflicting pull request has no merge ref for GitHub to build, so a
906
+ # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
907
+ # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
908
+ # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
909
+ # back to the branch's last verdict so the gate runs; it stays on the remediation
910
+ # path and cannot merge on a stale conclusion.
911
+ mergeable_now=$(mergeable_state "$pr_number")
912
+ if [ "$mergeable_now" = "CONFLICTING" ]; then
913
+ ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
914
+ --jq '[.workflow_runs[]
915
+ | select(
916
+ .name == "App: CI"
917
+ and .event == "pull_request"
918
+ and .status == "completed"
919
+ and (.conclusion == "success" or .conclusion == "failure")
920
+ )
921
+ ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
922
+ fi
923
+ if [ -z "$ci" ]; then
924
+ echo "::warning::PR #$pr_number head $head_sha has no completed App: CI run; the belt cannot gate it until CI runs."
925
+ continue
926
+ fi
927
+ echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
776
928
  fi
777
929
 
778
930
  IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
779
931
 
780
- # A recorded verdict only counts when it is NEWER than the latest CI run.
781
- # The old existence check made the gate one-shot per PR: after any verdict
782
- # (say remediated), a later CI completion could never reopen the gate, so
783
- # the fix-CI loop starved. ISO-8601 timestamps compare lexicographically.
784
- latest_marker=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
785
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate -->")) | .created_at] | max // empty')
932
+ # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
933
+ # line; progress comments and failed attempts deliberately carry neither
934
+ # matching pair, so a crashed or OOM-killed gate never masks a PR from
935
+ # later sweeps. A recorded verdict only counts when it is NEWER than the
936
+ # latest CI run; the old existence check made the gate one-shot per PR.
937
+ # Attempts are failed gates (the attempt marker) since this CI verdict.
938
+ latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
939
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
786
940
  linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
787
941
  if [ -n "$linked_issue" ]; then
788
- issue_marker=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
789
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate -->")) | .created_at] | max // empty')
790
- if [ -n "$issue_marker" ] && { [ -z "$latest_marker" ] || [ "$issue_marker" \> "$latest_marker" ]; }; then
791
- latest_marker="$issue_marker"
942
+ issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
943
+ --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
944
+ if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
945
+ latest_verdict="$issue_verdict"
792
946
  fi
793
947
  fi
794
- if [ -n "$latest_marker" ] && [ "$latest_marker" \> "$ci_finished" ]; then
795
- echo "Merge gate verdict ($latest_marker) is newer than CI ($ci_finished) for PR #$pr_number, skipping"
948
+ if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$ci_finished" ]; then
949
+ echo "Merge gate verdict ($latest_verdict) is newer than CI ($ci_finished) for PR #$pr_number, skipping"
950
+ continue
951
+ fi
952
+
953
+ attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
954
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
955
+ if [ -n "$linked_issue" ]; then
956
+ issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
957
+ --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$ci_finished"'") ] | length')
958
+ attempts=$((attempts + issue_attempts))
959
+ fi
960
+ if [ "$attempts" -ge 6 ]; then
961
+ echo "PR #$pr_number already consumed $attempts gate attempts on this CI verdict, parking for review"
796
962
  continue
797
963
  fi
798
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id)"
964
+ if gate_is_live "$pr_number"; then
965
+ echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
966
+ continue
967
+ fi
968
+
969
+ echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of 6)"
799
970
  gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
800
971
  --method POST \
801
972
  -f ref="$DEFAULT_BRANCH" \
802
973
  -f "inputs[operation]=merge-gate" \
803
974
  -f "inputs[pr-number]=$pr_number" \
804
975
  -f "inputs[ci-conclusion]=$ci_conclusion" \
805
- -f "inputs[ci-run-id]=$ci_run_id"
976
+ -f "inputs[ci-run-id]=$ci_run_id" \
977
+ -f "inputs[attempts_so_far]=$attempts"
806
978
  done
807
979
 
808
980
  audit-close:
@@ -816,6 +988,10 @@ jobs:
816
988
  permissions:
817
989
  contents: read
818
990
  issues: write
991
+ # It reads the open pull requests before closing a report, so a report whose
992
+ # implement workers are still in review is left alone. Without this the list
993
+ # call answers 403 and the nightly job fails having closed nothing.
994
+ pull-requests: read
819
995
  steps:
820
996
  - name: Checkout workflow actions
821
997
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",