@plainconceptsplatform/workflows 1.6.23 → 1.6.30

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.
@@ -61,9 +61,9 @@ runs:
61
61
  run: |
62
62
  set -euo pipefail
63
63
  # The agent job belongs to this same run: a called workflow shares the caller's run id.
64
- read -r job_id started finished <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
64
+ read -r job_id started finished conclusion <<<"$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
65
65
  --jq '[.jobs[] | select(.name | endswith("agent"))] | last // empty
66
- | "\(.id // "") \(.started_at // "") \(.completed_at // "")"')"
66
+ | "\(.id // "") \(.started_at // "") \(.completed_at // "") \(.conclusion // "")"')"
67
67
  minutes=-1
68
68
  if [ -n "${started:-}" ] && [ -n "${finished:-}" ]; then
69
69
  minutes=$(( ( $(date -u -d "$finished" +%s) - $(date -u -d "$started" +%s) ) / 60 ))
@@ -91,6 +91,14 @@ runs:
91
91
  fi
92
92
  rm -rf "$work"
93
93
  fi
94
+ # A cancelled agent job is a person or a newer run taking the work back, not an outage.
95
+ # Pliny-Bot #816: refine was cancelled by hand and its label removed, then this step read
96
+ # the eight-minute run as a provider failure, sent it back through the router and parked
97
+ # the issue as stalled.
98
+ if [ "${conclusion:-}" = cancelled ]; then
99
+ retry=false
100
+ cause=cancelled
101
+ fi
94
102
  {
95
103
  echo "retry=$retry"
96
104
  echo "next=$((attempts + 1))"
@@ -28,6 +28,10 @@ inputs:
28
28
  description: How many times a blocked verdict, or a merge that did not happen, is retried after the base branch moves.
29
29
  required: false
30
30
  default: '3'
31
+ stack-root-automerge:
32
+ description: A stack root whose children have all landed is merged by the gate like any other pull request, unless its blast radius or a finding sends it to a person. false keeps every root for a human.
33
+ required: false
34
+ default: 'true'
31
35
  max-repairs:
32
36
  description: How many pull requests may be in repair at once. Merges stay one at a time.
33
37
  required: false
@@ -69,6 +73,7 @@ runs:
69
73
  HOLD_LABEL: ${{ inputs.hold-label }}
70
74
  MAX_ATTEMPTS: ${{ inputs.max-gate-attempts }}
71
75
  MAX_RETRIES: ${{ inputs.max-retries }}
76
+ STACK_ROOT_AUTOMERGE: ${{ inputs.stack-root-automerge }}
72
77
  STUCK_HOURS: ${{ inputs.stuck-hours }}
73
78
  MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
74
79
  MAX_REPAIRS: ${{ inputs.max-repairs }}
@@ -114,14 +114,19 @@ train_move() {
114
114
  # BASE_MOVED true when the base branch has a commit newer than the verdict
115
115
  # RETRIES blocked or failed-merge verdicts already given on this head
116
116
  # MAX_RETRIES how many of those the train retries before leaving it to a person
117
- # STACK_ROOT true for a stack's root pull request, which a person always merges
117
+ # STACK_ROOT true for a stack's root pull request, which a person merges unless it is ready
118
+ # STACK_ROOT_READY true for a root whose stack was announced complete after the last verdict, with
119
+ # STACK_ROOT_AUTOMERGE on: the train gives it one more gate run, which merges it
120
+ # unless the blast radius or a finding says a person must
118
121
  train_park() {
119
122
  local park=none
120
123
  if [ "${VERDICT_NEWER:-false}" = true ]; then
121
124
  case "${VERDICT:-}" in
122
125
  remediated | "") park=none ;;
123
126
  blocked | auto-merge)
124
- if [ "${STACK_ROOT:-false}" = true ]; then
127
+ if [ "${STACK_ROOT:-false}" = true ] && [ "${STACK_ROOT_READY:-false}" = true ]; then
128
+ park=none
129
+ elif [ "${STACK_ROOT:-false}" = true ]; then
125
130
  park=held
126
131
  elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
127
132
  park=none
@@ -360,15 +365,23 @@ for line in "${candidates[@]}"; do
360
365
  fi
361
366
 
362
367
  stack_root=false
368
+ stack_root_ready=false
363
369
  if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
364
370
  if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
365
371
  grep -q "<!-- stack-root-pr: $pr -->"; then
366
372
  stack_root=true
373
+ if [ "${STACK_ROOT_AUTOMERGE:-false}" = true ]; then
374
+ announced_at=$(gh api --paginate "repos/$REPO/issues/$pr/comments?per_page=100" \
375
+ --jq '.[] | select(.body | contains("<!-- stack-complete -->")) | .created_at' 2>/dev/null | sort | tail -1)
376
+ if [ -n "$announced_at" ] && [ "$announced_at" \> "$verdict_at" ]; then
377
+ stack_root_ready=true
378
+ fi
379
+ fi
367
380
  fi
368
381
  fi
369
382
 
370
383
  park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
371
- RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
384
+ RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" STACK_ROOT_READY="$stack_root_ready" train_park)
372
385
 
373
386
  attempts=0
374
387
  silent_gate=false
@@ -41,6 +41,9 @@ outputs:
41
41
  pushed:
42
42
  description: Whether the agent proposed a push to the pull request branch, applied or not.
43
43
  value: ${{ steps.validate.outputs.pushed }}
44
+ empty:
45
+ description: Whether the agent produced no safe output at all, which is a run that quit, not a report that was refused.
46
+ value: ${{ steps.validate.outputs.empty }}
44
47
  runs:
45
48
  using: composite
46
49
  steps:
@@ -66,6 +69,8 @@ runs:
66
69
  echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
67
70
  pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
68
71
  echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
72
+ items=$(jq -r '[.items[]?] | length' "$OUTPUT_FILE" 2>/dev/null || echo 0)
73
+ echo "empty=$([ "${items:-0}" = 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
69
74
 
70
75
  if [ "$outcome" = 'invalid' ]; then
71
76
  echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
@@ -2041,6 +2041,8 @@ if worker_installed merge-gate; then
2041
2041
  assert "blocked holds once its retries are spent" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=3 MAX_RETRIES=3)"
2042
2042
  assert "a merge that did not happen is retried once the base moves" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=1)"
2043
2043
  assert "a stack root is never retried into a merge" held "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=0 STACK_ROOT=true)"
2044
+ assert "a root whose stack was announced complete gets one more gate run" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=false RETRIES=0 STACK_ROOT=true STACK_ROOT_READY=true)"
2045
+ assert "a ready root whose merge failed is held again, not looped" held "$(park VERDICT=owner-review VERDICT_NEWER=true STACK_ROOT=true STACK_ROOT_READY=true)"
2044
2046
 
2045
2047
  assert "hold keeps a pull request off the train" skip "$(move HOLD=true CI_STATUS=completed CI_CONCLUSION=success)"
2046
2048
  assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
@@ -2738,6 +2740,70 @@ if worker_installed implement; then
2738
2740
  echo "FAIL: the implement prompt does not forbid running commands in the background and ending the turn to wait for them" >&2
2739
2741
  fi
2740
2742
  fi
2743
+ # The bash tool moves any command past its timeout to the background and says so. Pliny-Bot #774 took
2744
+ # that for an end, wrote "I'll wait for the verify command" and stopped after sixty-seven minutes. The
2745
+ # prompt must say that the message is not the end and to poll with tool calls.
2746
+ if worker_installed implement; then
2747
+ if grep -qF 'answers "moved to the background"' "${WORKFLOWS_DIR}/agent-implement.md" && grep -qF "sleep 40; tail -n 15" "${WORKFLOWS_DIR}/agent-implement.md"; then
2748
+ PASS=$((PASS + 1))
2749
+ else
2750
+ FAIL=$((FAIL + 1))
2751
+ echo "FAIL: the implement prompt does not tell the agent to keep polling when bash moves a command to the background" >&2
2752
+ fi
2753
+ fi
2754
+ # Every worker is told that a turn ending in text ends the run. The Pliny-Bot merge gate said "I'll read
2755
+ # the required context files first." and exited cleanly after twenty-three seconds, twice on one head.
2756
+ PLATFORM_DEFAULTS_MD="${WORKFLOWS_DIR}/shared/platform-defaults.md"
2757
+ if [ -f "$PLATFORM_DEFAULTS_MD" ]; then
2758
+ if grep -qF "A turn that ends with text and no tool call ends the whole run" "$PLATFORM_DEFAULTS_MD" && grep -qF "every message you write carries a tool call" "$PLATFORM_DEFAULTS_MD"; then
2759
+ PASS=$((PASS + 1))
2760
+ else
2761
+ FAIL=$((FAIL + 1))
2762
+ echo "FAIL: the shared worker defaults do not say that a text-only turn ends the run" >&2
2763
+ fi
2764
+ fi
2765
+ # A gate agent that produced no output at all quit; it did not write a report the validator refused.
2766
+ # It gets the machine budget, not the two-strike human-review of an unusable report.
2767
+ if worker_installed merge-gate; then
2768
+ if grep -qF 'OUTPUT_EMPTY: ${{ needs.validate_output.outputs.empty }}' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF '[ "${OUTPUT_EMPTY:-false}" != true ]' "${WORKFLOWS_DIR}/agent-merge-gate.md" && grep -qF 'echo "empty=' "${HERE}/../validate-merge-gate-output/action.yml"; then
2769
+ PASS=$((PASS + 1))
2770
+ else
2771
+ FAIL=$((FAIL + 1))
2772
+ echo "FAIL: a gate agent that produced nothing is sent to a person instead of being retried" >&2
2773
+ fi
2774
+ fi
2775
+ # A cancelled agent job is work taken back, not an outage: no retry, no stalled flag. Pliny-Bot #816 was
2776
+ # cancelled by hand, re-dispatched as "a provider outage" and parked as stalled.
2777
+ DECIDE_YML="${HERE}/../decide-agent-retry/action.yml"
2778
+ if [ -f "$DECIDE_YML" ] && grep -qF 'cause=cancelled' "$DECIDE_YML" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-refine.md" && grep -qF "steps.decide.outputs.cause != 'cancelled'" "${WORKFLOWS_DIR}/agent-implement.md"; then
2779
+ PASS=$((PASS + 1))
2780
+ else
2781
+ FAIL=$((FAIL + 1))
2782
+ echo "FAIL: a cancelled agent run is retried or parked as stalled" >&2
2783
+ fi
2784
+ # No prompt rule stopped the model ending a turn in text; the compiled engine command continues the
2785
+ # session when no safe output exists. Pliny-Bot #771 passed verify and exited with nothing delivered.
2786
+ COMPILE_MJS="${HERE}/../../scripts/compile-agent-workflows.mjs"
2787
+ if [ -f "$COMPILE_MJS" ] && grep -qF 'opencode run --continue' "$COMPILE_MJS" && grep -qF 'gh-aw/safeoutputs/outputs.jsonl' "$COMPILE_MJS"; then
2788
+ PASS=$((PASS + 1))
2789
+ else
2790
+ FAIL=$((FAIL + 1))
2791
+ echo "FAIL: the compiled engine command does not continue a session that delivered nothing" >&2
2792
+ fi
2793
+ # A stack root merges itself once every child has landed, through the same gate as any pull request.
2794
+ # The people it still stops are the gate's own: a blast radius over the limit, a protected path, a
2795
+ # finding. The gate must read the router's completion announcement, and the train must give the root
2796
+ # one more run after it.
2797
+ GATE_MD="${WORKFLOWS_DIR}/agent-merge-gate.md"
2798
+ TRAIN_SH="${HERE}/../merge-train/merge-train.sh"
2799
+ if worker_installed merge-gate && [ -f "$TRAIN_SH" ]; then
2800
+ if grep -qF 'root_complete=$root_complete' "$GATE_MD" && grep -qF "needs.subject.outputs.root_complete == 'true'" "$GATE_MD" && grep -qF 'STACK_ROOT_AUTOMERGE:' "$GATE_MD" && grep -qF 'STACK_ROOT_READY' "$TRAIN_SH"; then
2801
+ PASS=$((PASS + 1))
2802
+ else
2803
+ FAIL=$((FAIL + 1))
2804
+ echo "FAIL: a completed stack root is not merged by the gate and re-run by the train" >&2
2805
+ fi
2806
+ fi
2741
2807
  # The merge train wakes on a finished CI run only. Pliny-Bot had four green pull requests wait an hour
2742
2808
  # because a repair gate dispatched for red CI kept them marked as having a gate, and nothing called the
2743
2809
  # train again once it was cancelled. The hourly sweep is now a heartbeat, and a repair gate whose CI has
@@ -184,9 +184,10 @@ flowchart TD
184
184
 
185
185
  The stack exists only in markers on the issues — `stack-root-branch` and `stack-root-pr` on the
186
186
  feature issue, `stack-root-branch` stamped on each child at hand-over — so a repository with none
187
- of them runs exactly the pipeline above and nothing else. The root is never auto-merged: a person
188
- merges it after every child has landed and the feature branch is green, which is the point where
189
- the feature's own acceptance journey, not one child's scope, is the thing being verified.
187
+ of them runs exactly the pipeline above and nothing else. The root is held while children land.
188
+ Once every child has landed and the feature branch is green, the gate decides it like any pull
189
+ request and merges it, unless the blast radius, a protected path or a finding sends it to a person
190
+ (`STACK_ROOT_AUTOMERGE: "false"` restores a human merge for every root).
190
191
 
191
192
  ---
192
193
 
@@ -385,7 +386,7 @@ flowchart TD
385
386
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
386
387
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
387
388
  gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
388
- gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root never auto-merges,<br/>a person merges it once every<br/>child has landed and it is green"))
389
+ gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root waits for its children,<br/>then merges like any pull request<br/>unless the gate sends it to a person"))
389
390
  gateHuman(("HUMAN REVIEW<br/>review label,<br/>implement label kept"))
390
391
  gateOwner(("OWNER REVIEW<br/>review + owner-review,<br/>CODEOWNERS asked when named"))
391
392
  gateHuman --> gatePreview
@@ -3,6 +3,14 @@
3
3
  const OPENCODE_VERSION = '2.0.22'
4
4
  const OPENCODE_PACKAGE = '@opencode/cli'
5
5
 
6
+ const OPENCODE_NUDGE =
7
+ '; rc=$?; for nudge in 1 2; do [ -s "${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" ] && break; ' +
8
+ 'echo "No safe output yet, continuing the session ($nudge of 2)"; ' +
9
+ 'OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --continue --print-logs --log-level info ' +
10
+ '"Your last message ended without a tool call, so nothing has been delivered and this run is about to end. ' +
11
+ 'Do not explain or plan: continue the task now and finish with the safe-output call the task names."; ' +
12
+ 'rc=$?; done; exit $rc'
13
+
6
14
  // The install the compiled locks carry, shared so both rewrite sites emit exactly
7
15
  // the same command.
8
16
  //
@@ -153,6 +161,11 @@ for (const file of readdirSync(workflowDirectory)) {
153
161
  // "Invalid value for flag --log-level", aborting before the agent runs.
154
162
  // The old upper-case spelling was valid in v1 and is not in v2.
155
163
  .replace(/opencode run(?: --print-logs)? --log-level (?:DEBUG|ERROR|INFO|debug|error|info)/g, "OPENCODE_DISABLE_MODELS_FETCH=1 opencode run --print-logs --log-level info")
164
+ // A turn that ends in text ends `opencode run`, whatever the text promised, and no prompt rule
165
+ // stopped glm-5-2 doing it: Pliny-Bot #771 passed verify, wrote "let me discover the exact
166
+ // safeoutputs command" and exited with nothing delivered. When no safe output exists yet, the
167
+ // same session is continued, twice at most, and told to finish.
168
+ .replace(/("\$\(cat \/tmp\/gh-aw\/aw-prompts\/prompt\.txt\)")'/g, (_, prompt) => prompt + OPENCODE_NUDGE + "'")
156
169
  .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
157
170
  .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
158
171
  .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
@@ -381,7 +381,7 @@ jobs:
381
381
  issue-number: ${{ needs.subject.outputs.issue }}
382
382
  labels: ${{ env.WORKING_LABEL }}
383
383
  - name: Flag for human review
384
- if: steps.decide.outputs.retry != 'true'
384
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
385
385
  uses: ./.github/actions/add-issue-labels
386
386
  with:
387
387
  token: ${{ steps.app-token.outputs.token }}
@@ -390,7 +390,7 @@ jobs:
390
390
  ${{ env.REVIEW_LABEL }}
391
391
  ${{ env.STALLED_LABEL }}
392
392
  - name: Report missing review feedback outcome
393
- if: steps.decide.outputs.retry != 'true'
393
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
394
394
  uses: ./.github/actions/create-issue-comment
395
395
  with:
396
396
  token: ${{ steps.app-token.outputs.token }}
@@ -593,7 +593,7 @@ jobs:
593
593
  ${{ env.WORKING_LABEL }}
594
594
  implement
595
595
  - name: Flag for human review
596
- if: steps.decide.outputs.retry != 'true'
596
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
597
597
  uses: ./.github/actions/add-issue-labels
598
598
  with:
599
599
  token: ${{ steps.app-token.outputs.token }}
@@ -602,7 +602,7 @@ jobs:
602
602
  ${{ env.REVIEW_LABEL }}
603
603
  ${{ env.STALLED_LABEL }}
604
604
  - name: Report missing implementation outcome
605
- if: steps.decide.outputs.retry != 'true'
605
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
606
606
  uses: ./.github/actions/create-issue-comment
607
607
  with:
608
608
  token: ${{ steps.app-token.outputs.token }}
@@ -844,6 +844,16 @@ timeout-minutes: 180
844
844
  so every part finishes inside one call. The only valid way to end the run is the safe-output
845
845
  call in step 6.
846
846
 
847
+ **If the bash tool answers "moved to the background", the run is not over and you are not
848
+ waiting: keep going with tool calls.** It does that to any command that passes its timeout, even
849
+ when you passed none, and Pliny-Bot #774 ended that way on `pnpm verify --quick` after
850
+ sixty-seven minutes with no pull request. Poll it: call bash with `sleep 40; tail -n 15` on the
851
+ command's output file, again and again, until the output shows its exit status, and only then
852
+ go on. Each of those calls is a tool call, so the turn never ends. To avoid it from the start,
853
+ run a command that may pass two minutes as
854
+ `(<command>) > /tmp/gh-aw/agent/step.log 2>&1; echo "exit status $?" >> /tmp/gh-aw/agent/step.log` with the bash tool's
855
+ `timeout` at 600000, and read `/tmp/gh-aw/agent/step.log` afterwards.
856
+
847
857
  **Two things the unit tests do not catch, and CI will.** When you add a dependency, take the
848
858
  latest released version and run the repository's audit for it (`pnpm audit --audit-level high`
849
859
  for the web, `dotnet list package --vulnerable` for the backend): Pliny-Bot #752 added a PDF
@@ -49,6 +49,9 @@ env:
49
49
  # translations and generated code excluded) is too big to merge unseen. It was a thousand, which
50
50
  # is a normal feature once the tests are left out. A file count alone never decides, so the high
51
51
  # file threshold is set out of reach; spread still raises medium.
52
+ # "true" lets a stack root merge once every child has landed, through the same blast radius and
53
+ # findings as any pull request. "false" holds every root for a person.
54
+ STACK_ROOT_AUTOMERGE: "true"
52
55
  BLAST_HIGH_FILES: "100000"
53
56
  BLAST_HIGH_LINES: "10000"
54
57
  BLAST_MEDIUM_FILES: "5"
@@ -177,6 +180,7 @@ jobs:
177
180
  review_blocked: ${{ steps.review.outputs.review_blocked }}
178
181
  pr_base: ${{ steps.stack.outputs.pr_base }}
179
182
  stack_root: ${{ steps.stack.outputs.stack_root }}
183
+ root_complete: ${{ steps.stack.outputs.root_complete }}
180
184
  steps:
181
185
  - name: Checkout workflow actions
182
186
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -230,18 +234,28 @@ jobs:
230
234
  REPO: ${{ github.repository }}
231
235
  PR: ${{ steps.subject.outputs.pr }}
232
236
  ISSUE: ${{ steps.subject.outputs.issue }}
237
+ STACK_ROOT_AUTOMERGE: ${{ env.STACK_ROOT_AUTOMERGE }}
233
238
  run: |
234
239
  set -euo pipefail
235
240
  pr_base=$(gh pr view "$PR" --repo "$REPO" --json baseRefName --jq '.baseRefName // ""')
236
241
  stack_root=false
242
+ root_complete=false
237
243
  if [ -n "$ISSUE" ]; then
238
244
  body=$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo "")
239
245
  if echo "$body" | grep -q "<!-- stack-root-pr: $PR -->"; then
240
246
  stack_root=true
247
+ # The router announces a stack complete on the root once every child has landed and
248
+ # CI is green. A root without that comment is still collecting children.
249
+ if [ "$STACK_ROOT_AUTOMERGE" = true ] \
250
+ && gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" \
251
+ --jq '.[] | select(.body | contains("<!-- stack-complete -->")) | .id' 2>/dev/null | grep -q .; then
252
+ root_complete=true
253
+ fi
241
254
  fi
242
255
  fi
243
256
  echo "pr_base=$pr_base" >> "$GITHUB_OUTPUT"
244
257
  echo "stack_root=$stack_root" >> "$GITHUB_OUTPUT"
258
+ echo "root_complete=$root_complete" >> "$GITHUB_OUTPUT"
245
259
 
246
260
  # Rung 4b. Everything the merge decision needs that a shell can establish, measured once,
247
261
  # before any model reads the diff. The job kept its name and its `requires_review` and
@@ -479,6 +493,7 @@ jobs:
479
493
  valid: ${{ steps.validate.outputs.valid }}
480
494
  outcome: ${{ steps.validate.outputs.outcome }}
481
495
  pushed: ${{ steps.validate.outputs.pushed }}
496
+ empty: ${{ steps.validate.outputs.empty }}
482
497
  steps:
483
498
  - name: Checkout workflow actions
484
499
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -589,7 +604,7 @@ jobs:
589
604
  ${{ needs.subject.outputs.issue != '' && format('Findings and verification on the linked issue: #{0}.', needs.subject.outputs.issue) || 'Findings and verification are in the review comment above.' }} [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
590
605
  ${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan. Approving the pull request merges it.' || '' }}
591
606
  - name: Merge approved pull request
592
- if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root != 'true'
607
+ if: needs.validate_output.outputs.outcome == 'auto-merge' && (needs.subject.outputs.stack_root != 'true' || needs.subject.outputs.root_complete == 'true')
593
608
  env:
594
609
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
595
610
  REPO: ${{ github.repository }}
@@ -603,7 +618,7 @@ jobs:
603
618
  # what a reader needs: the gate held the root, children merge into the feature branch,
604
619
  # and a human merges the root when the stack completes.
605
620
  - name: Hold a stack root for a human merge
606
- if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root == 'true'
621
+ if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root == 'true' && needs.subject.outputs.root_complete != 'true'
607
622
  uses: ./.github/actions/create-issue-comment
608
623
  with:
609
624
  token: ${{ steps.app-token.outputs.token }}
@@ -755,6 +770,7 @@ jobs:
755
770
  AGENT_RESULT: ${{ needs.agent.result }}
756
771
  SAFE_RESULT: ${{ needs.safe_outputs.result }}
757
772
  OUTPUT_VALID: ${{ needs.validate_output.outputs.valid }}
773
+ OUTPUT_EMPTY: ${{ needs.validate_output.outputs.empty }}
758
774
  PARK_AT_ATTEMPT: ${{ env.PARK_AT_ATTEMPT }}
759
775
  PARK_AT_UNUSABLE_OUTPUT: ${{ env.PARK_AT_UNUSABLE_OUTPUT }}
760
776
  run: |
@@ -762,8 +778,12 @@ jobs:
762
778
  attempts=${ATTEMPTS:-0}
763
779
  # The agent ran, published, and produced something the validator refused. Repeating
764
780
  # that reproduces it; a person reading the comment costs less than three more runs
765
- # holding the merge belt.
766
- if [ "$AGENT_RESULT" = success ] && [ "$SAFE_RESULT" = success ] && [ "$OUTPUT_VALID" != true ]; then
781
+ # holding the merge belt. An agent that produced nothing at all is not that: it quit,
782
+ # as the Pliny-Bot gate did on #801 after one line of text and twenty-three seconds,
783
+ # twice, and went to a person with no finding. That is a machine failure and gets the
784
+ # machine budget.
785
+ if [ "$AGENT_RESULT" = success ] && [ "$SAFE_RESULT" = success ] && [ "$OUTPUT_VALID" != true ] \
786
+ && [ "${OUTPUT_EMPTY:-false}" != true ]; then
767
787
  threshold="$PARK_AT_UNUSABLE_OUTPUT"
768
788
  kind=unusable
769
789
  else
@@ -773,7 +773,7 @@ jobs:
773
773
  issue-number: ${{ inputs.issue-number }}
774
774
  labels: ${{ env.WORKING_LABEL }}
775
775
  - name: Flag for human review
776
- if: steps.decide.outputs.retry != 'true'
776
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
777
777
  uses: ./.github/actions/add-issue-labels
778
778
  with:
779
779
  token: ${{ steps.app-token.outputs.token }}
@@ -782,7 +782,7 @@ jobs:
782
782
  ${{ env.REVIEW_LABEL }}
783
783
  ${{ env.STALLED_LABEL }}
784
784
  - name: Report missing refinement outcome
785
- if: steps.decide.outputs.retry != 'true'
785
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
786
786
  uses: ./.github/actions/create-issue-comment
787
787
  with:
788
788
  token: ${{ steps.app-token.outputs.token }}
@@ -387,7 +387,7 @@ jobs:
387
387
  issue-number: ${{ inputs.issue-number }}
388
388
  labels: ${{ env.WORKING_LABEL }}
389
389
  - name: Flag for human review
390
- if: steps.decide.outputs.retry != 'true'
390
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
391
391
  uses: ./.github/actions/add-issue-labels
392
392
  with:
393
393
  token: ${{ steps.app-token.outputs.token }}
@@ -396,7 +396,7 @@ jobs:
396
396
  ${{ env.REVIEW_LABEL }}
397
397
  ${{ env.STALLED_LABEL }}
398
398
  - name: Report missing triage outcome
399
- if: steps.decide.outputs.retry != 'true'
399
+ if: steps.decide.outputs.retry != 'true' && steps.decide.outputs.cause != 'cancelled'
400
400
  uses: ./.github/actions/create-issue-comment
401
401
  with:
402
402
  token: ${{ steps.app-token.outputs.token }}
@@ -21,20 +21,33 @@ network:
21
21
  safe-outputs:
22
22
  threat-detection: false
23
23
  ---
24
-
25
- ## Verification, for every worker that builds or pushes
26
-
27
- - Scope every check to the files you changed. This is the runner, not a preference: a
28
- whole-repository lint, build or test run exhausts its memory and gets killed mid-run, which
29
- fails the job with no useful output. Pass the changed paths to the linter (`pnpm exec biome
30
- check <files>`), build only the projects containing them, and run only the test project that
31
- covers them. Escalate to the full suite only after the scoped run passes and only when the
32
- change crosses a project boundary.
33
- - Run nothing at all for a change that touches only documentation. A cold Release build on a
34
- shared runner is minutes the run does not get back.
35
- - Never make a check pass by weakening it: not a deleted test, not a lowered threshold, not a
36
- skipped step. Fix the cause and run it again.
37
- - Once the checks pass, run the project's lint fix command over the files you changed
38
- (`pnpm lint:fix`, `pnpm exec biome check --write <changed-files>`, or its equivalent), or
39
- correct what lint reports where no fix command exists. A branch that arrives with lint errors
40
- gets sent back for them.
24
+
25
+ ## These runs are not interactive
26
+
27
+ - Nothing resumes you. A turn that ends with text and no tool call ends the whole run, and the work
28
+ is discarded, whatever the text promised. "I'll read the context files first." as a last message
29
+ is a finished run: the Pliny-Bot merge gate did exactly that on #801, ended in twenty-three
30
+ seconds with exit code 0 and no outcome, and was retried twice before the pull request was handed
31
+ to a person for nothing.
32
+ - So every message you write carries a tool call until the last one, which is the safe-output call
33
+ the task names. Say what you are about to do and do it in the same message. Never announce a step
34
+ and stop, never wait for a command, a reviewer or a "next turn".
35
+ - A command the bash tool reports as moved to the background is still running: poll its output file
36
+ with tool calls (`sleep 40; tail -n 15 <file>`) until it shows an exit status, then go on.
37
+
38
+ ## Verification, for every worker that builds or pushes
39
+
40
+ - Scope every check to the files you changed. This is the runner, not a preference: a
41
+ whole-repository lint, build or test run exhausts its memory and gets killed mid-run, which
42
+ fails the job with no useful output. Pass the changed paths to the linter (`pnpm exec biome
43
+ check <files>`), build only the projects containing them, and run only the test project that
44
+ covers them. Escalate to the full suite only after the scoped run passes and only when the
45
+ change crosses a project boundary.
46
+ - Run nothing at all for a change that touches only documentation. A cold Release build on a
47
+ shared runner is minutes the run does not get back.
48
+ - Never make a check pass by weakening it: not a deleted test, not a lowered threshold, not a
49
+ skipped step. Fix the cause and run it again.
50
+ - Once the checks pass, run the project's lint fix command over the files you changed
51
+ (`pnpm lint:fix`, `pnpm exec biome check --write <changed-files>`, or its equivalent), or
52
+ correct what lint reports where no fix command exists. A branch that arrives with lint errors
53
+ gets sent back for them.
@@ -157,6 +157,10 @@ env:
157
157
  # How many times a `blocked` verdict, or a merge that did not happen, is retried once the base
158
158
  # branch has moved. After that the pull request waits for a person or a new commit.
159
159
  MERGE_TRAIN_MAX_RETRIES: "3"
160
+ # "true" lets a stack root merge itself once every child has landed and CI is green, through the
161
+ # same gate as any pull request: a change over the blast radius limit, a protected path or an
162
+ # unresolved finding still goes to a person. "false" keeps every root for a human.
163
+ MERGE_TRAIN_STACK_ROOT_AUTOMERGE: "true"
160
164
  # "true" logs the move the train would make and makes none. For trying the train on a
161
165
  # repository before letting it act.
162
166
  MERGE_TRAIN_DRY_RUN: "false"
@@ -922,6 +926,7 @@ jobs:
922
926
  hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
923
927
  max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
924
928
  max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
929
+ stack-root-automerge: ${{ env.MERGE_TRAIN_STACK_ROOT_AUTOMERGE }}
925
930
  max-repairs: ${{ env.MERGE_TRAIN_REPAIRS }}
926
931
  dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
927
932
  ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
@@ -1265,7 +1270,7 @@ jobs:
1265
1270
  if [ "${already:-0}" -gt 0 ]; then
1266
1271
  echo "Stack #$parent already has a completion announcement; staying silent."
1267
1272
  else
1268
- gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
1273
+ gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready to merge into $DEFAULT_BRANCH: the merge gate decides it next, and a person is asked only when its blast radius or a finding says so. $completion_marker"
1269
1274
  echo "Stack #$parent complete: announced root PR #$root_pr."
1270
1275
  fi
1271
1276
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.6.23",
3
+ "version": "1.6.30",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",