@plainconceptsplatform/workflows 1.7.1 → 1.8.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.
@@ -27,6 +27,7 @@ head_ref=$(jq -r .headRefName <<<"$pr_json")
27
27
 
28
28
  owner=${REPO%%/*}
29
29
  name=${REPO#*/}
30
+ # shellcheck disable=SC2016
30
31
  queue=$(gh api graphql -f query='query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}' \
31
32
  -f o="$owner" -f n="$name" -f b="$base" --jq '.data.repository.mergeQueue.id // ""' 2>/dev/null || true)
32
33
 
@@ -50,6 +51,7 @@ pr_id=$(gh pr view "$PR" --repo "$REPO" --json id --jq .id)
50
51
  # GraphQL first: the asynchronous merge endpoint refuses a GitHub App with "Enqueuer is not
51
52
  # authorized to merge" (Agentic-Workflow-Dogfood, 2026-10-20), while enqueuePullRequest takes the
52
53
  # same token. A stacked pull request refuses GraphQL and only the asynchronous endpoint takes it.
54
+ # shellcheck disable=SC2016
53
55
  enqueued=$(gh api graphql -f query='mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}' \
54
56
  -f id="$pr_id" -f sha="$HEAD_SHA" 2>&1 || true)
55
57
  if grep -q '"mergeQueueEntry":{' <<<"$enqueued"; then
@@ -219,8 +219,7 @@ deepen_ci() {
219
219
  [ -n "$run" ] || return 1
220
220
  mark_full_ci "$pr" || return 1
221
221
  # A CI handover reaches the train from inside the run, and a run still going cannot be re-run.
222
- local i
223
- for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
222
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
224
223
  [ "$(gh api "repos/$REPO/actions/runs/$run" --jq .status 2>/dev/null)" = completed ] && break
225
224
  sleep 10
226
225
  done
@@ -231,6 +230,7 @@ deepen_ci() {
231
230
  # UNMERGEABLE or MERGEABLE, or empty when it is not in one.
232
231
  queue_state() {
233
232
  local pr="$1"
233
+ # shellcheck disable=SC2016
234
234
  gh api graphql -f query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){mergeQueueEntry{state}}}}' \
235
235
  -f o="${REPO%%/*}" -f n="${REPO#*/}" -F p="$pr" \
236
236
  --jq '.data.repository.pullRequest.mergeQueueEntry.state // ""' 2>/dev/null || true
@@ -76,6 +76,7 @@ fi
76
76
  git push --quiet "$remote" "HEAD:refs/heads/${rescued_branch}"
77
77
 
78
78
  grep -qiE "(closes|fixes|resolves) #${ISSUE}\b" "$work/body.md" || printf '\n\nCloses #%s\n' "$ISSUE" >> "$work/body.md"
79
+ # shellcheck disable=SC2016
79
80
  printf '\n\nRebased onto `%s` by the workflow: the agent'"'"'s commits no longer applied after `%s` moved during the run.\n' \
80
81
  "$base" "$base" >> "$work/body.md"
81
82
  [[ "$title" == "[bot] "* ]] || title="[bot] $title"
@@ -31,7 +31,14 @@ inputs:
31
31
  description: "check-diff result on the pull request: pass, fail or absent. Empty means it did not report."
32
32
  required: false
33
33
  default: absent
34
+ unattended:
35
+ description: "true lets a judgement call (low confidence, unmet acceptance criteria, hard to undo) merge instead of going to a person. Facts the workflow measured still hold the pull request."
36
+ required: false
37
+ default: 'false'
34
38
  outputs:
39
+ flags:
40
+ description: "What an unattended merge accepted, on one line, for the pull request and the final report. Empty when nothing was."
41
+ value: ${{ steps.validate.outputs.flags }}
35
42
  valid:
36
43
  description: Whether the output produced a usable disposition.
37
44
  value: ${{ steps.validate.outputs.valid }}
@@ -59,13 +66,29 @@ runs:
59
66
  OWNER_HIT: ${{ inputs.owner-hit }}
60
67
  CONFIDENCE_THRESHOLD: ${{ inputs.confidence-threshold }}
61
68
  CODE_RULES: ${{ inputs.code-rules }}
69
+ UNATTENDED: ${{ inputs.unattended }}
62
70
  run: |
63
71
  set -euo pipefail
64
72
 
65
73
  outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" \
66
74
  "$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION" \
67
- "$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES")"
75
+ "$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES" "$UNATTENDED")"
68
76
  echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
77
+
78
+ # What the unattended merge let through, named so the person reading the report later knows
79
+ # where to look. It is read from the same report the decision used, never from the prose.
80
+ flags=""
81
+ if [ "$UNATTENDED" = true ] && [ "$outcome" = auto-merge ]; then
82
+ flags=$(jq -r --arg threshold "$CONFIDENCE_THRESHOLD" --arg blast "$BLAST_LEVEL" '
83
+ [.items[]? | select(.type == "add_comment") | .body | strings
84
+ | [scan("```json\\s*(.*?)```"; "m") | .[0]] | last | select(. != null) | fromjson?] | last // {}
85
+ | [ (if (.confidence // 1) < ($threshold | tonumber) then "confidence \(.confidence)" else empty end),
86
+ (if .acceptanceCriteriaMet == false then "acceptance criteria reported unmet" else empty end),
87
+ (if ((.recoverability // "") | ascii_downcase) == "low" then "hard to undo" else empty end),
88
+ (if $blast == "medium" then "medium blast radius" else empty end) ]
89
+ | join("; ")' "$OUTPUT_FILE" 2>/dev/null || true)
90
+ fi
91
+ echo "flags=$flags" >> "$GITHUB_OUTPUT"
69
92
  echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
70
93
  pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
71
94
  echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
@@ -19,7 +19,7 @@
19
19
  # Usage:
20
20
  # validate-merge-gate-output.sh OUTPUT_FILE ISSUE CI_CONCLUSION \
21
21
  # BLAST_LEVEL PROTECTED_HIT OWNER_HIT CONFIDENCE_THRESHOLD \
22
- # CODE_RULES
22
+ # CODE_RULES [UNATTENDED]
23
23
 
24
24
  set -euo pipefail
25
25
 
@@ -37,6 +37,12 @@ confidence_threshold="${7-0.8}"
37
37
  # pass, fail or absent (the repository has no check-diff). An empty value is a job that did not
38
38
  # report, and like every other unmeasured fact it goes to a person.
39
39
  code_rules="${8-absent}"
40
+ # "true" lets a pull request whose only reason for a person is a judgement call merge anyway: low
41
+ # agent confidence, acceptance criteria reported unmet, or a change reported hard to undo. The
42
+ # facts that stop a merge stay: CI, verified critical or high findings, protected and owner paths,
43
+ # a high blast radius, a failing check-diff and anything the workflow could not measure. The
44
+ # caller records what was accepted so a person can read it afterwards.
45
+ unattended="${9-false}"
40
46
 
41
47
  # An empty measured level is not a low-risk pull request, it is a job that did not report. The
42
48
  # defaults above exist for a caller that genuinely has nothing to say; an empty string arriving
@@ -61,7 +67,7 @@ jq -r \
61
67
  --arg protected "$protected_hit" \
62
68
  --arg owner "$owner_hit" \
63
69
  --arg threshold "$confidence_threshold" \
64
- --arg code "$code_rules" '
70
+ --arg code "$code_rules" --arg unattended "$unattended" '
65
71
 
66
72
  def level_rank: {"low": 0, "medium": 1, "high": 2}[.];
67
73
  def is_bool: type == "boolean";
@@ -161,9 +167,10 @@ jq -r \
161
167
  # pull request goes to a person rather than through on a default.
162
168
  elif $protected != "false" or $owner != "false" then "human-review"
163
169
  elif $code != "pass" and $code != "absent" then "human-review"
164
- elif $level == 1 and $recoverability == "low" then "human-review"
165
- elif ($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false then "human-review"
166
- elif $confidence < ($threshold | tonumber) then "human-review"
170
+ elif ($level == 1 and $recoverability == "low")
171
+ or (($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false)
172
+ or $confidence < ($threshold | tonumber)
173
+ then (if $unattended == "true" then "auto-merge" else "human-review" end)
167
174
  else "auto-merge"
168
175
  end
169
176
  end
@@ -1608,10 +1608,10 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1608
1608
 
1609
1609
  gate_case() {
1610
1610
  local name="$1" want="$2" json="$3" conclusion="$4"
1611
- local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}"
1611
+ local blast="${5:-low}" protected="${6:-false}" owner="${7:-false}" code="${8-pass}" unattended="${9:-false}"
1612
1612
  printf '%s' "$json" > "$gate_fixture"
1613
1613
  local got
1614
- got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" 2>&1)
1614
+ got=$(bash "$GATE_VALIDATOR" "$gate_fixture" 7 "$conclusion" "$blast" "$protected" "$owner" 0.8 "$code" "$unattended" 2>&1)
1615
1615
  if [ "$got" != "$want" ]; then
1616
1616
  VALIDATOR_OK=0
1617
1617
  echo "FAIL: the merge-gate validator called '${name}' ${got}, expected ${want}" >&2
@@ -1681,6 +1681,21 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1681
1681
  gate_case "confidence below the threshold needs a person" \
1682
1682
  human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low
1683
1683
 
1684
+ # Unattended, a judgement call merges and is reported; a measured fact still stops the merge.
1685
+ gate_case "unattended, low confidence merges" auto-merge "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false pass true
1686
+ gate_case "unattended, an unmet criterion merges" \
1687
+ auto-merge "$(gate_items "$(gate_comment assessed "$gate_unmet")")" success low false false pass true
1688
+ gate_case "unattended, medium risk that cannot be undone merges" \
1689
+ auto-merge "$(gate_items "$(gate_comment assessed "$gate_fragile")")" success medium false false pass true
1690
+ gate_case "unattended, a verified critical finding still blocks" \
1691
+ blocked "$(gate_items "$(gate_comment assessed "$gate_verified")")" success low false false pass true
1692
+ gate_case "unattended, a protected path still needs the owner" \
1693
+ owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low true false pass true
1694
+ gate_case "unattended, a high blast radius still needs the owner" \
1695
+ owner-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success high false false pass true
1696
+ gate_case "unattended, a check-diff that did not report still needs a person" \
1697
+ human-review "$(gate_items "$(gate_comment assessed "$gate_unsure")")" success low false false '' true
1698
+
1684
1699
  # The agent may raise the measured blast radius when it sees something the path rules could
1685
1700
  # not. It may never lower it, which is the only direction that can turn a person's review into
1686
1701
  # a machine merge.
@@ -2066,6 +2081,7 @@ if worker_installed merge-gate; then
2066
2081
  assert "a conflict is still the gate's even when main is red" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=completed CI_CONCLUSION=failure SAME_AS_MAIN=true)"
2067
2082
  # The wait for a red base is bounded. Pliny-Bot's main was red from 19:30 and the train waited
2068
2083
  # on it until morning, saying nothing, while five pull requests sat with their repairs unspent.
2084
+ # shellcheck disable=SC2015
2069
2085
  grep -q 'MAIN_RED_WAIT_HOURS' "$TRAIN_SH" && grep -q 'red_for_hours' "$TRAIN_SH" &&
2070
2086
  grep -q 'main-red-wait-hours' "${HERE}/../merge-train/action.yml" ||
2071
2087
  { FAIL=$((FAIL + 1)); echo "FAIL: the merge train waits on a red base branch with no time limit" >&2; }
@@ -23,7 +23,9 @@ Every diagram uses the same shapes and the same palette, so reading one teaches
23
23
  | Dotted arrow | the unhappy path |
24
24
 
25
25
  Each worker diagram also names the rung each stage sits on, from the determinism ladder in
26
- `skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision.
26
+ `skills/workflow-author`: the lower the rung, the cheaper and more reproducible the decision. A
27
+ rung is a step on that ladder, numbered 0 to 6 from the router's trigger to the safe outputs. The
28
+ term is ours, not gh-aw's, so it does not appear in the gh-aw documentation.
27
29
 
28
30
  ---
29
31
 
@@ -188,6 +190,9 @@ of them runs exactly the pipeline above and nothing else. The root is held while
188
190
  Once every child has landed and the feature branch is green, the gate decides it like any pull
189
191
  request and merges it, unless the blast radius, a protected path or a finding sends it to a person
190
192
  (`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
193
+ With `UNATTENDED: "true"` a feature blocked by another waits for that feature's root to merge;
194
+ the router's `stack-landed` job then brings the default branch into its branch, releases its
195
+ children and opens an Unattended report issue.
191
196
 
192
197
  ---
193
198
 
@@ -66,6 +66,14 @@ env:
66
66
  # Agent confidence below which the pull request goes to a human. The agent reports the number;
67
67
  # this decides what it means.
68
68
  CONFIDENCE_THRESHOLD: "0.8"
69
+ # "true" is for a repository that is left to work alone, overnight or across a stack of features.
70
+ # A pull request whose only reason for a person is a judgement call (agent confidence under the
71
+ # threshold, acceptance criteria reported unmet, a change reported hard to undo) then merges, and
72
+ # the pull request and the stack's final report name what was accepted. What still stops a merge
73
+ # is what was measured, not judged: CI, a verified critical or high finding, a protected or owner
74
+ # path, a high blast radius, a failing check-diff and anything the workflow could not measure.
75
+ # "false" sends every one of them to a person.
76
+ UNATTENDED: "false"
69
77
  # Replay the verification plan when the gate hands the pull request to a person, so the
70
78
  # reviewer sees the screenshots before deciding. "false" leaves evidence to the merge.
71
79
  EVIDENCE_BEFORE_REVIEW: "true"
@@ -501,6 +509,7 @@ jobs:
501
509
  outcome: ${{ steps.validate.outputs.outcome }}
502
510
  pushed: ${{ steps.validate.outputs.pushed }}
503
511
  empty: ${{ steps.validate.outputs.empty }}
512
+ flags: ${{ steps.validate.outputs.flags }}
504
513
  steps:
505
514
  - name: Checkout workflow actions
506
515
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -525,6 +534,7 @@ jobs:
525
534
  owner-hit: ${{ needs.protected_changes.outputs.owner_hit }}
526
535
  confidence-threshold: ${{ env.CONFIDENCE_THRESHOLD }}
527
536
  code-rules: ${{ needs.code_rules.outputs.result }}
537
+ unattended: ${{ env.UNATTENDED }}
528
538
  conclude:
529
539
  needs: [activation, subject, protected_changes, agent, safe_outputs, validate_output]
530
540
  # `protected_changes.result == 'success'` is stated rather than relied on. GitHub skips a job
@@ -599,6 +609,9 @@ jobs:
599
609
  | Files / lines | ${{ needs.protected_changes.outputs.files_changed }} / ${{ needs.protected_changes.outputs.lines_changed }} counted, ${{ needs.protected_changes.outputs.files_total }} / ${{ needs.protected_changes.outputs.lines_total }} in the diff |
600
610
  | Protected paths | ${{ needs.protected_changes.outputs.requires_review == 'true' && 'yes' || 'no' }} |
601
611
  | Owner paths | ${{ needs.protected_changes.outputs.owner_hit == 'true' && 'yes' || 'no' }} |
612
+ ${{ needs.validate_output.outputs.flags != ''
613
+ && format('| Accepted unattended | {0} |', needs.validate_output.outputs.flags)
614
+ || '' }}
602
615
  ${{ needs.validate_output.outputs.outcome == 'owner-review'
603
616
  && format('| Required owners | `{0}` |', needs.protected_changes.outputs.required_owners != '' && needs.protected_changes.outputs.required_owners || 'none configured, see the matched paths above')
604
617
  || '' }}
@@ -161,6 +161,13 @@ env:
161
161
  # same gate as any pull request: a change over the blast radius limit, a protected path or an
162
162
  # unresolved finding still goes to a person. "false" keeps every root for a human.
163
163
  MERGE_TRAIN_STACK_ROOT_AUTOMERGE: "true"
164
+ # "true" lets a chain of features run without a person between them. A feature whose issue is
165
+ # blocked by another (GitHub's "blocked by" relation, or a `Blocked by #N` line in its body) keeps
166
+ # its children held until that feature's stack root merges; the merge brings the default branch
167
+ # into the waiting feature's branch, releases its children, and leaves an "Unattended report"
168
+ # issue naming every pull request that merged on a judgement call. Set the merge gate's own
169
+ # UNATTENDED to the same value. "false" leaves features to be started and read by a person.
170
+ UNATTENDED: "false"
164
171
  # "true" logs the move the train would make and makes none. For trying the train on a
165
172
  # repository before letting it act.
166
173
  MERGE_TRAIN_DRY_RUN: "false"
@@ -535,6 +542,7 @@ jobs:
535
542
  stack-branch: ${{ steps.stack.outputs.stack-branch }}
536
543
  stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
537
544
  stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
545
+ flat-blocked: ${{ steps.flatblocked.outputs.flat-blocked }}
538
546
  implement-queue: ${{ steps.queue.outputs.queue }}
539
547
  steps:
540
548
  # Which implement queue this issue waits in. The children of one refine share a queue,
@@ -660,19 +668,66 @@ jobs:
660
668
  [ "${landed:-0}" -gt 0 ] && continue
661
669
  waiting="$waiting #$b"
662
670
  done
671
+ # Unattended, a whole feature can wait on another one: its issue is blocked by a feature
672
+ # whose stack root has not merged into the default branch. Every child of it waits, whatever
673
+ # its own siblings say, and stack-landed releases them when that root merges.
674
+ if [ "${UNATTENDED:-false}" = true ]; then
675
+ feature=$(printf '%s' "$body" | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
676
+ if [ -n "$feature" ]; then
677
+ for b in $( { gh api "repos/$REPO/issues/$feature/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
678
+ gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
679
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
680
+ } | sort -u ); do
681
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
682
+ waiting="$waiting #$b"
683
+ done
684
+ fi
685
+ fi
663
686
  [ -n "$waiting" ] || exit 0
664
- echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed on $STACK_BRANCH; holding it."
687
+ echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed; holding it."
665
688
  echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
666
689
  gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
667
690
  marker='<!-- stack-held -->'
668
691
  if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
669
692
  {
670
693
  printf '%s\n' "$marker"
671
- printf "Held until%s have landed on \`%s\`: this child builds on them. It keeps \`implement\` and is released when they merge.\n" "$waiting" "$STACK_BRANCH"
694
+ printf "Held until%s have landed: this child builds on them. It keeps \`implement\` and is released when they merge (a sibling into \`%s\`, a feature it waits on into the default branch).\n" "$waiting" "$STACK_BRANCH"
672
695
  } > /tmp/held.md
673
696
  gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
674
697
  fi
675
698
 
699
+ # Unattended, a feature refine did not split takes the flat path, which has no stack to wait
700
+ # in. Blocked by a feature that has not landed, it would build on a default branch without
701
+ # that feature. It is held the same way a child is, and stack-landed releases it.
702
+ - name: Hold an unsplit feature whose blockers have not landed
703
+ id: flatblocked
704
+ if: steps.stack.outputs.is-stack != 'true'
705
+ env:
706
+ GH_TOKEN: ${{ github.token }}
707
+ REPO: ${{ github.repository }}
708
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
709
+ run: |
710
+ set -euo pipefail
711
+ echo "flat-blocked=false" >> "$GITHUB_OUTPUT"
712
+ [ "${UNATTENDED:-false}" = true ] || exit 0
713
+ waiting=""
714
+ for b in $( { gh api "repos/$REPO/issues/$ISSUE_NUMBER/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
715
+ gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
716
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
717
+ } | sort -u ); do
718
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
719
+ waiting="$waiting #$b"
720
+ done
721
+ [ -n "$waiting" ] || exit 0
722
+ echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed; holding it."
723
+ echo "flat-blocked=true" >> "$GITHUB_OUTPUT"
724
+ gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
725
+ marker='<!-- stack-held -->'
726
+ if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
727
+ printf '%s\nHeld until%s have landed on the default branch: this feature builds on them. It keeps `implement` and is released when they merge.\n' "$marker" "$waiting" > /tmp/held.md
728
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
729
+ fi
730
+
676
731
  - name: Detect an in-flight implement on the feature branch
677
732
  id: inflight
678
733
  if: steps.stack.outputs.is-stack == 'true'
@@ -698,7 +753,7 @@ jobs:
698
753
 
699
754
  call-implement:
700
755
  needs: [classify, authorize, check-implement-pr, check-stack-branch]
701
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
756
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true' && needs.check-stack-branch.outputs.flat-blocked != 'true'
702
757
  uses: ./.github/workflows/agent-implement.lock.yml
703
758
  # One FIFO queue per IMPLEMENT_PARALLELISM slot, chosen by check-stack-branch: the children of
704
759
  # one refine always share a queue, independent issues spread across them.
@@ -1292,6 +1347,195 @@ jobs:
1292
1347
  fi
1293
1348
  fi
1294
1349
 
1350
+ # A stack root merged into the default branch: the feature is in. When the repository works
1351
+ # unattended two things follow: the next feature in a chain starts, and a report is left for the
1352
+ # person who comes back. Event-gated, like land-stack-child, because a merge already routes to
1353
+ # evidence. It NEVER checks out or runs pull request code; it reads and writes through the API.
1354
+ stack-landed:
1355
+ needs: classify
1356
+ if: >
1357
+ github.event_name == 'pull_request_target' &&
1358
+ github.event.action == 'closed' &&
1359
+ github.event.pull_request.merged == true &&
1360
+ github.event.pull_request.base.ref == github.event.repository.default_branch
1361
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
1362
+ timeout-minutes: 10
1363
+ permissions:
1364
+ contents: read
1365
+ pull-requests: read
1366
+ steps:
1367
+ - name: Checkout for App token action
1368
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1369
+ with:
1370
+ persist-credentials: false
1371
+ - name: Generate App token
1372
+ id: app-token
1373
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1374
+ with:
1375
+ app-id: ${{ secrets.BOT_APP_ID }}
1376
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1377
+ - name: Start the next feature in the chain and write the report
1378
+ env:
1379
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
1380
+ REPO: ${{ github.repository }}
1381
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1382
+ EVENT_PR: ${{ github.event.pull_request.number }}
1383
+ run: |
1384
+ set -euo pipefail
1385
+ if [ "${UNATTENDED:-false}" != true ]; then
1386
+ echo "UNATTENDED is not true: features are chained and reported by a person."
1387
+ exit 0
1388
+ fi
1389
+
1390
+ # The feature this root delivered: a closed issue that names this pull request as its
1391
+ # stack root. A merge that is not a stack root (every ordinary pull request) ends here.
1392
+ parent=""
1393
+ for issue in $(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1394
+ --jq '.closingIssuesReferences[].number' 2>/dev/null || true); do
1395
+ root=$(gh issue view "$issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1396
+ | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1397
+ if [ "$root" = "$EVENT_PR" ]; then parent="$issue"; break; fi
1398
+ done
1399
+ if [ -z "$parent" ]; then
1400
+ echo "PR #$EVENT_PR is not a stack root; nothing to do."
1401
+ exit 0
1402
+ fi
1403
+ root_branch=$(gh pr view "$EVENT_PR" --repo "$REPO" --json headRefName --jq '.headRefName')
1404
+ echo "Stack #$parent landed on $DEFAULT_BRANCH through #$EVENT_PR ($root_branch)."
1405
+
1406
+ # What a feature waits on is read from the issue's native "blocked by" relation and from
1407
+ # `Blocked by #N` lines in its body, because refine rewrites a body and the relation
1408
+ # survives that. Open ones only; the feature that just landed counts as done even before
1409
+ # GitHub has closed its issue.
1410
+ blockers_of() {
1411
+ {
1412
+ gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[] | select(.state == "open") | .number' 2>/dev/null || true
1413
+ for b in $(gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1414
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
1415
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo "$b" || true
1416
+ done
1417
+ } | grep -vx "$parent" | sort -u || true
1418
+ }
1419
+ depends_on_parent() {
1420
+ { gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[].number' 2>/dev/null || true
1421
+ gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1422
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
1423
+ } | grep -qx "$parent"
1424
+ }
1425
+
1426
+ # The held children of every feature that was waiting on this one.
1427
+ declare -A next_branch
1428
+ declare -A next_children
1429
+ candidates=$(gh issue list --repo "$REPO" --state open --label implement --limit 200 \
1430
+ --json number,labels,body \
1431
+ --jq '.[] | select([.labels[].name] | index("bot-working") | not)
1432
+ | select(.body | test("<!-- split-parent: [0-9]+ -->"))
1433
+ | [.number, (.body | capture("<!-- split-parent: (?<p>[0-9]+) -->").p)] | @tsv')
1434
+ while IFS=$'\t' read -r child feature; do
1435
+ [ -n "${child:-}" ] || continue
1436
+ depends_on_parent "$feature" || continue
1437
+ [ -z "$(blockers_of "$feature")" ] || { echo "Feature #$feature still waits on other work; leaving #$child held."; continue; }
1438
+ if [ -z "${next_branch[$feature]:-}" ]; then
1439
+ next_branch[$feature]=$(gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' \
1440
+ | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
1441
+ fi
1442
+ next_children[$feature]="${next_children[$feature]:-} $child"
1443
+ done <<< "$candidates"
1444
+
1445
+ for feature in "${!next_children[@]}"; do
1446
+ branch="${next_branch[$feature]:-}"
1447
+ if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
1448
+ echo "Feature #$feature has no readable stack branch; its children stay held."
1449
+ continue
1450
+ fi
1451
+ # The feature branch was cut from a default branch that did not have the feature that
1452
+ # just landed, and its children build on it. Bring the default branch in first. A
1453
+ # conflict is a person's.
1454
+ # 201 merged, 204 already up to date; both exit 0. A conflict (409) or a missing branch
1455
+ # exits non-zero with the reason on stderr.
1456
+ if ! answer=$(gh api -X POST "repos/$REPO/merges" -f base="$branch" -f head="$DEFAULT_BRANCH" \
1457
+ -f commit_message="Merge $DEFAULT_BRANCH into $branch: #$parent landed" 2>&1 >/dev/null); then
1458
+ answer=$(printf '%s' "$answer" | tr '\n' ' ' | cut -c1-200)
1459
+ echo "::warning::Merging $DEFAULT_BRANCH into $branch failed ($answer); feature #$feature stays held."
1460
+ gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
1461
+ gh issue edit "$feature" --repo "$REPO" --add-label needs-human >/dev/null 2>&1 || true
1462
+ gh issue comment "$feature" --repo "$REPO" --body "Feature #$parent landed on $DEFAULT_BRANCH, but \`$DEFAULT_BRANCH\` could not be merged into \`$branch\` ($answer). The children of this feature stay held until a person resolves the branch and re-adds \`implement\` to the first one." >/dev/null || true
1463
+ continue
1464
+ fi
1465
+ for child in ${next_children[$feature]}; do
1466
+ own=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1467
+ | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1468
+ waiting=""
1469
+ for b in $own; do
1470
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && waiting="$waiting #$b" || true
1471
+ done
1472
+ if [ -n "$waiting" ]; then
1473
+ echo "#$child still waits on$waiting inside its feature."
1474
+ continue
1475
+ fi
1476
+ gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1477
+ gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1478
+ echo "Released #$child of feature #$feature."
1479
+ done
1480
+ done
1481
+
1482
+ # A feature refine did not split waits on the issue itself, held by check-stack-branch with
1483
+ # `implement` kept. It cuts from the default branch, which now has what it waited on.
1484
+ flat=$(gh issue list --repo "$REPO" --state open --label implement --limit 200 \
1485
+ --json number,labels,body \
1486
+ --jq '.[] | select([.labels[].name] | index("bot-working") | not)
1487
+ | select(.body | test("<!-- split-parent: [0-9]+ -->") | not) | .number')
1488
+ for issue in $flat; do
1489
+ depends_on_parent "$issue" || continue
1490
+ [ -z "$(blockers_of "$issue")" ] || { echo "#$issue still waits on other work."; continue; }
1491
+ gh issue edit "$issue" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1492
+ gh issue edit "$issue" --repo "$REPO" --add-label implement >/dev/null
1493
+ echo "Released unsplit feature #$issue."
1494
+ done
1495
+
1496
+ # The report: what merged without a person, and what the gate noted on the way. One issue
1497
+ # per feature, written once; its marker makes a rerun a no-op.
1498
+ marker="<!-- unattended-report: $parent -->"
1499
+ if [ -n "$(gh issue list --repo "$REPO" --state all --search "\"$marker\" in:body" --json number --jq '.[].number')" ]; then
1500
+ echo "Report for #$parent already exists."
1501
+ exit 0
1502
+ fi
1503
+ title=$(gh issue view "$parent" --repo "$REPO" --json title --jq '.title')
1504
+ {
1505
+ echo "$marker"
1506
+ echo "Feature #$parent was delivered without a person: root pull request #$EVENT_PR is in \`$DEFAULT_BRANCH\`."
1507
+ echo
1508
+ echo "## Merged pull requests"
1509
+ echo
1510
+ } > /tmp/report.md
1511
+ flagged=0
1512
+ for pr in $(gh pr list --repo "$REPO" --state merged --base "$root_branch" --limit 100 --json number --jq '.[].number') "$EVENT_PR"; do
1513
+ line=$(gh pr view "$pr" --repo "$REPO" --json title,url --jq '"- [#\(.url | split("/") | last)](\(.url)) \(.title)"')
1514
+ notes=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" --paginate \
1515
+ --jq '.[].body | select(contains("Accepted unattended")) | capture("Accepted unattended \\| (?<f>[^|]+) \\|").f' 2>/dev/null | tail -1 || true)
1516
+ if [ -n "$notes" ]; then
1517
+ flagged=$((flagged + 1))
1518
+ printf '%s: **%s**\n' "$line" "$notes" >> /tmp/report.md
1519
+ else
1520
+ printf '%s\n' "$line" >> /tmp/report.md
1521
+ fi
1522
+ done
1523
+ {
1524
+ echo
1525
+ if [ "$flagged" -gt 0 ]; then
1526
+ echo "## Look at these"
1527
+ echo
1528
+ echo "$flagged pull request(s) above merged on a judgement call, not on a measured fact. Each was bold above with what the gate accepted. Nothing here was a verified critical or high finding, a protected path, a failing check or a high blast radius, because those still stop a merge. Read them when you can, and revert any you would not have merged."
1529
+ else
1530
+ echo "Nothing was merged on a judgement call. Every pull request passed the gate on measured facts."
1531
+ fi
1532
+ } >> /tmp/report.md
1533
+ label=unattended-report
1534
+ [ "$flagged" -gt 0 ] && label=needs-human
1535
+ gh label create needs-human --repo "$REPO" --color B60205 --description "A person has to look" >/dev/null 2>&1 || true
1536
+ gh label create unattended-report --repo "$REPO" --color 0E8A16 --description "What an unattended feature delivered" >/dev/null 2>&1 || true
1537
+ gh issue create --repo "$REPO" --title "Unattended report: $title" --label "$label" --body-file /tmp/report.md
1538
+
1295
1539
  reconcile-bot-pr-runs:
1296
1540
  needs: classify
1297
1541
  if: needs.classify.outputs.route == 'reconcile-bot-pr-runs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",