@plainconceptsplatform/workflows 0.4.34 → 0.5.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 (73) hide show
  1. package/README.md +88 -88
  2. package/loops/actions/agent-output.cjs +17 -17
  3. package/loops/actions/audit-close/action.yml +24 -0
  4. package/loops/actions/classify-route/action.yml +1 -3
  5. package/loops/actions/classify-route/classify-route.sh +40 -35
  6. package/loops/actions/update-changelog/action.yml +113 -0
  7. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  8. package/loops/actions/validate-refine-output/validate-refine-output.sh +16 -3
  9. package/loops/actions/validate-review-output/action.yml +35 -35
  10. package/loops/actions/validate-triage-output/action.yml +36 -36
  11. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +91 -36
  12. package/loops/scripts/compile-agent-workflows.mjs +195 -6
  13. package/loops/templates/agentics/agentics-checks.yml +86 -86
  14. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  15. package/loops/templates/ci/app-ci-dotnet-next.yml +268 -171
  16. package/loops/templates/ci/app-ci-node-monorepo.yml +208 -178
  17. package/loops/templates/issues/bug_report.yml +109 -109
  18. package/loops/templates/issues/feature_request.yml +75 -75
  19. package/loops/templates/opencode/opencode.ci.json +49 -47
  20. package/loops/templates/opencode/opencode.ci.json.md +46 -41
  21. package/loops/templates/release/github-release.yml +30 -30
  22. package/loops/workflows/agent-apply-review.md +465 -443
  23. package/loops/workflows/agent-audit.md +213 -197
  24. package/loops/workflows/agent-implement.md +538 -414
  25. package/loops/workflows/agent-merge-gate.md +730 -584
  26. package/loops/workflows/agent-refine.md +609 -418
  27. package/loops/workflows/agent-release.md +258 -0
  28. package/loops/workflows/agent-triage.md +447 -439
  29. package/loops/workflows/authorize-bot-work.yml +85 -82
  30. package/loops/workflows/shared/opencode-ci.md +206 -197
  31. package/loops/workflows/shared/platform-defaults.md +19 -16
  32. package/loops/workflows/work-router.yml +862 -632
  33. package/package.json +7 -8
  34. package/dist/action-validation.test.d.ts +0 -1
  35. package/dist/action-validation.test.js +0 -84
  36. package/dist/catalog-installation.d.ts +0 -30
  37. package/dist/catalog-installation.js +0 -379
  38. package/dist/catalog-installation.test.d.ts +0 -1
  39. package/dist/catalog-installation.test.js +0 -448
  40. package/dist/catalog-listing.d.ts +0 -13
  41. package/dist/catalog-listing.js +0 -70
  42. package/dist/catalog-listing.test.d.ts +0 -1
  43. package/dist/catalog-listing.test.js +0 -150
  44. package/dist/index.d.ts +0 -2
  45. package/dist/index.js +0 -218
  46. package/dist/index.test.d.ts +0 -1
  47. package/dist/index.test.js +0 -273
  48. package/dist/repository-inspection.d.ts +0 -23
  49. package/dist/repository-inspection.js +0 -113
  50. package/dist/repository-inspection.test.d.ts +0 -1
  51. package/dist/repository-inspection.test.js +0 -77
  52. package/dist/repository-state.d.ts +0 -18
  53. package/dist/repository-state.js +0 -77
  54. package/dist/repository-state.test.d.ts +0 -1
  55. package/dist/repository-state.test.js +0 -96
  56. package/dist/route-processing.d.ts +0 -6
  57. package/dist/route-processing.js +0 -150
  58. package/dist/route-processing.test.d.ts +0 -1
  59. package/dist/route-processing.test.js +0 -350
  60. package/dist/stack-defaults.d.ts +0 -11
  61. package/dist/stack-defaults.js +0 -104
  62. package/dist/stack-defaults.test.d.ts +0 -1
  63. package/dist/stack-defaults.test.js +0 -266
  64. package/dist/tui.d.ts +0 -25
  65. package/dist/tui.js +0 -287
  66. package/dist/tui.test.d.ts +0 -1
  67. package/dist/tui.test.js +0 -246
  68. package/dist/workflow-catalog.d.ts +0 -25
  69. package/dist/workflow-catalog.js +0 -59
  70. package/dist/workflow-catalog.test.d.ts +0 -1
  71. package/dist/workflow-catalog.test.js +0 -29
  72. package/loops/workflows/agent-direct.md +0 -375
  73. package/loops/workflows/agent-propose.md +0 -342
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-refine-output/validate-refine-output.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
- # Print the deterministic Refine outcome: complete, questions, or invalid.
3
+ # Print the deterministic Refine outcome: complete, split, questions, or invalid.
4
4
 
5
5
  set -euo pipefail
6
6
 
@@ -23,6 +23,13 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
23
23
  def has_update:
24
24
  any(.items[]; .type == "update_issue");
25
25
 
26
+ # A split writes children, which are the only items allowed to target something other than
27
+ # the source issue: they do not exist yet, so they carry no number at all.
28
+ def child_count:
29
+ [.items[] | select(.type == "create_issue" and
30
+ (.body | type == "string") and (.body | test("[^[:space:]]")) and
31
+ (.title | type == "string") and (.title | test("[^[:space:]]")))] | length;
32
+
26
33
  def has_only_source_items:
27
34
  all(.items[];
28
35
  (
@@ -32,7 +39,8 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
32
39
  (
33
40
  .type == "add_comment" and
34
41
  (.item_number | tostring) == $issue
35
- ));
42
+ ) or
43
+ .type == "create_issue");
36
44
 
37
45
  def has_clarification:
38
46
  any(.items[]; .type == "add_comment" and (.item_number | tostring) == $issue and
@@ -40,7 +48,12 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
40
48
  (.body | split($marker) | join("") | split($prefix) | join("") |
41
49
  gsub("<[^>]*>"; "") | test("[[:alnum:]]")));
42
50
 
43
- if has_replacement_body and has_only_source_items then "complete"
51
+ # Order matters: a run that wrote children is a split even though it also replaced the
52
+ # parent body, and a lone child with no parent update is an incomplete split, not a
53
+ # complete refinement.
54
+ if child_count >= 2 and has_replacement_body and has_only_source_items then "split"
55
+ elif child_count > 0 then "invalid"
56
+ elif has_replacement_body and has_only_source_items then "complete"
44
57
  elif has_clarification and (has_update | not) and has_only_source_items then "questions"
45
58
  else "invalid"
46
59
  end
@@ -1,35 +1,35 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate review output
3
- description: Verify an apply-review outcome accounts for every unresolved review thread.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- pr-number:
9
- description: Pull request receiving the review feedback.
10
- required: true
11
- review-threads-file:
12
- description: JSON file containing unresolved review threads.
13
- required: true
14
- outputs:
15
- valid:
16
- description: Whether the agent output is a valid review outcome.
17
- value: ${{ steps.validate.outputs.valid }}
18
- outcome:
19
- description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
- value: ${{ steps.validate.outputs.outcome }}
21
- runs:
22
- using: composite
23
- steps:
24
- - name: Validate review outcome
25
- id: validate
26
- shell: bash
27
- env:
28
- OUTPUT_FILE: ${{ inputs.output-file }}
29
- PR_NUMBER: ${{ inputs.pr-number }}
30
- REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
- run: |
32
- set -euo pipefail
33
- outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate review output
3
+ description: Verify an apply-review outcome accounts for every unresolved review thread.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ pr-number:
9
+ description: Pull request receiving the review feedback.
10
+ required: true
11
+ review-threads-file:
12
+ description: JSON file containing unresolved review threads.
13
+ required: true
14
+ outputs:
15
+ valid:
16
+ description: Whether the agent output is a valid review outcome.
17
+ value: ${{ steps.validate.outputs.valid }}
18
+ outcome:
19
+ description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
+ value: ${{ steps.validate.outputs.outcome }}
21
+ runs:
22
+ using: composite
23
+ steps:
24
+ - name: Validate review outcome
25
+ id: validate
26
+ shell: bash
27
+ env:
28
+ OUTPUT_FILE: ${{ inputs.output-file }}
29
+ PR_NUMBER: ${{ inputs.pr-number }}
30
+ REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
+ run: |
32
+ set -euo pipefail
33
+ outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
@@ -1,36 +1,36 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate triage output
3
- description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- issue-number:
9
- description: Issue number that every triage outcome must target.
10
- required: true
11
- outputs:
12
- valid:
13
- description: Whether the output contains a usable triage comment with a verdict.
14
- value: ${{ steps.validate.outputs.valid }}
15
- outcome:
16
- description: "Deterministic triage outcome: pass, needs-info, block, or invalid."
17
- value: ${{ steps.validate.outputs.outcome }}
18
- runs:
19
- using: composite
20
- steps:
21
- - name: Validate triage outcome
22
- id: validate
23
- shell: bash
24
- env:
25
- ISSUE_NUMBER: ${{ inputs.issue-number }}
26
- OUTPUT_FILE: ${{ inputs.output-file }}
27
- run: |
28
- set -euo pipefail
29
-
30
- outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
-
34
- if [ "$outcome" = 'invalid' ]; then
35
- echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
- fi
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate triage output
3
+ description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ issue-number:
9
+ description: Issue number that every triage outcome must target.
10
+ required: true
11
+ outputs:
12
+ valid:
13
+ description: Whether the output contains a usable triage comment with a verdict.
14
+ value: ${{ steps.validate.outputs.valid }}
15
+ outcome:
16
+ description: "Deterministic triage outcome: pass, needs-info, block, or invalid."
17
+ value: ${{ steps.validate.outputs.outcome }}
18
+ runs:
19
+ using: composite
20
+ steps:
21
+ - name: Validate triage outcome
22
+ id: validate
23
+ shell: bash
24
+ env:
25
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
26
+ OUTPUT_FILE: ${{ inputs.output-file }}
27
+ run: |
28
+ set -euo pipefail
29
+
30
+ outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
+
34
+ if [ "$outcome" = 'invalid' ]; then
35
+ echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
+ fi
@@ -7,6 +7,7 @@ set -euo pipefail
7
7
 
8
8
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
9
  ROUTER_YML="${HERE}/../../workflows/work-router.yml"
10
+ AUTHORIZE_YML="${HERE}/../../workflows/authorize-bot-work.yml"
10
11
  IMPLEMENT_WORKER_MD="${HERE}/../../workflows/agent-implement.md"
11
12
  MERGE_GATE_WORKER_MD="${HERE}/../../workflows/agent-merge-gate.md"
12
13
 
@@ -59,24 +60,32 @@ assert_route() {
59
60
  }
60
61
 
61
62
  echo "── Label events ──────────────────────────────────────────────────────────"
62
- assert_route "refine label routes to refine" refine \
63
- EVENT=issues ACTION=labeled LABEL=refine EVENT_ISSUE_NUMBER=42
64
- assert_route "implement label routes to implement" implement \
65
- EVENT=issues ACTION=labeled LABEL=implement EVENT_ISSUE_NUMBER=42
63
+ assert_route "human refine label waits for authorization" none \
64
+ EVENT=issues ACTION=labeled LABEL=refine ACTOR=maintainer EVENT_ISSUE_NUMBER=42
65
+ assert_route "bot refine label routes to refine" refine \
66
+ EVENT=issues ACTION=labeled LABEL=refine ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
67
+ assert_route "human implement label waits for authorization" none \
68
+ EVENT=issues ACTION=labeled LABEL=implement ACTOR=maintainer EVENT_ISSUE_NUMBER=42
69
+ assert_route "bot implement label routes to implement" implement \
70
+ EVENT=issues ACTION=labeled LABEL=implement ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
71
+ assert_route "human feature label waits for authorization" none \
72
+ EVENT=issues ACTION=labeled LABEL=feature ACTOR=maintainer EVENT_ISSUE_NUMBER=350
66
73
  assert_route "unrelated label routes nowhere" none \
67
74
  EVENT=issues ACTION=labeled LABEL=documentation EVENT_ISSUE_NUMBER=42
68
- assert_route "a non-label issue action routes to triage" triage \
75
+ assert_route "issue opened without labels routes to triage" triage \
69
76
  EVENT=issues ACTION=opened EVENT_ISSUE_NUMBER=42
77
+ assert_route "issue opened with refine label skips triage" none \
78
+ EVENT=issues ACTION=opened 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
79
+ assert_route "issue opened with implement label skips triage" none \
80
+ EVENT=issues ACTION=opened 'ISSUE_LABELS=["implement"]' EVENT_ISSUE_NUMBER=42
70
81
  assert_route "a human triage label routes to triage" triage \
71
82
  EVENT=issues ACTION=labeled LABEL=triage ACTOR=maintainer EVENT_ISSUE_NUMBER=42
72
83
  assert_route "a bot triage label routes nowhere" none \
73
84
  EVENT=issues ACTION=labeled LABEL=triage ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42
85
+ assert_route "implement + bot-working without feature routes to implement" implement \
86
+ EVENT=issues ACTION=labeled LABEL=bot-working 'ISSUE_LABELS=["implement","bot-working"]' EVENT_ISSUE_NUMBER=300
74
87
  assert "refine label starts a first pass" first \
75
- "$(route_field refine-mode EVENT=issues ACTION=labeled LABEL=refine EVENT_ISSUE_NUMBER=42)"
76
- assert_route "direct label routes to direct" direct \
77
- EVENT=issues ACTION=labeled LABEL=direct EVENT_ISSUE_NUMBER=42
78
- assert "direct label starts a first pass" first \
79
- "$(route_field direct-mode EVENT=issues ACTION=labeled LABEL=direct EVENT_ISSUE_NUMBER=42)"
88
+ "$(route_field refine-mode EVENT=issues ACTION=labeled LABEL=refine ACTOR=platform-devbox[bot] EVENT_ISSUE_NUMBER=42)"
80
89
 
81
90
  echo "── Comment events ────────────────────────────────────────────────────────"
82
91
  assert_route "a comment on a pull request routes to apply-review" apply-review \
@@ -93,12 +102,6 @@ assert_route "the bot's own comment never re-enters refine" none \
93
102
  assert_route "a comment on an issue without refine routes nowhere" none \
94
103
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User \
95
104
  'ISSUE_LABELS=["bug"]' EVENT_ISSUE_NUMBER=42
96
- assert_route "an author reply on a direct issue continues" direct \
97
- EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User \
98
- 'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42
99
- assert "a direct reply is a continue pass" continue \
100
- "$(route_field direct-mode EVENT=issue_comment COMMENT_ON_PR=false \
101
- COMMENT_SENDER_TYPE=User 'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42)"
102
105
  assert_route "the bot's own comment never re-enters direct" none \
103
106
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=Bot \
104
107
  'ISSUE_LABELS=["direct"]' EVENT_ISSUE_NUMBER=42
@@ -112,6 +115,14 @@ assert_route "the bot's own comment never re-enters triage" none \
112
115
  EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=Bot \
113
116
  'ISSUE_LABELS=["triage"]' EVENT_ISSUE_NUMBER=42
114
117
 
118
+ echo "── Closed issues ─────────────────────────────────────────────────────────"
119
+ assert_route "a closing comment on a refine issue does not re-refine" none EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=closed 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
120
+ assert_route "a comment on a closed issue never re-triages" none EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=closed 'ISSUE_LABELS=["triage"]' EVENT_ISSUE_NUMBER=42
121
+ assert_route "a work label added to a closed issue routes nowhere" none EVENT=issues ACTION=labeled LABEL=bot-working ISSUE_STATE=closed 'ISSUE_LABELS=["implement"]' EVENT_ISSUE_NUMBER=42
122
+ assert_route "a closed issue reopened as opened still routes nowhere while closed" none EVENT=issues ACTION=opened ISSUE_STATE=closed 'ISSUE_LABELS=[]' EVENT_ISSUE_NUMBER=42
123
+ assert_route "a comment on a closed pull request still routes to apply-review" apply-review EVENT=issue_comment COMMENT_ON_PR=true ISSUE_STATE=closed EVENT_ISSUE_NUMBER=7
124
+ assert_route "an open refine issue is unaffected by the closed guard" refine EVENT=issue_comment COMMENT_ON_PR=false COMMENT_SENDER_TYPE=User ISSUE_STATE=open 'ISSUE_LABELS=["refine"]' EVENT_ISSUE_NUMBER=42
125
+
115
126
  echo "── Review events ─────────────────────────────────────────────────────────"
116
127
  assert_route "a review comment routes to apply-review" apply-review \
117
128
  EVENT=pull_request_review_comment EVENT_PR_NUMBER=7
@@ -121,13 +132,20 @@ assert_route "a pull_request_target routes to bot-approve" bot-approve \
121
132
  EVENT=pull_request_target ACTION=opened
122
133
 
123
134
  echo "── CI completion ─────────────────────────────────────────────────────────"
124
- assert_route "App CI completion routes to merge-gate" merge-gate \
125
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
126
- assert "merge-gate carries the CI conclusion" failure \
135
+ assert_route "a failed App CI run on a pull request routes to merge-gate" merge-gate \
136
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
137
+ assert "merge-gate carries the failing CI conclusion" failure \
127
138
  "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 \
128
139
  RUN_CONCLUSION=failure RUN_ID=99)"
129
- assert_route "a CI run with no pull request routes nowhere" none \
130
- EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=success RUN_ID=99
140
+ assert "merge-gate carries the failing CI run id" 99 \
141
+ "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 \
142
+ RUN_CONCLUSION=failure RUN_ID=99)"
143
+ assert_route "a green App CI run does not auto-trigger the gate" none \
144
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
145
+ assert_route "a cancelled App CI run does not auto-trigger the gate" none \
146
+ EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
147
+ assert_route "a failed CI run with no pull request routes nowhere" none \
148
+ EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
131
149
 
132
150
  echo "── Schedules ─────────────────────────────────────────────────────────────"
133
151
  while read -r cron; do
@@ -151,8 +169,6 @@ assert_route "refine dispatch rejects a non-numeric issue" none \
151
169
  EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=abc
152
170
  assert_route "refine dispatch accepts a positive issue" refine \
153
171
  EVENT=workflow_dispatch OPERATION=refine INPUT_ISSUE_NUMBER=42
154
- assert_route "direct dispatch accepts a positive issue" direct \
155
- EVENT=workflow_dispatch OPERATION=direct INPUT_ISSUE_NUMBER=42
156
172
  assert_route "direct dispatch needs an issue number" none \
157
173
  EVENT=workflow_dispatch OPERATION=direct INPUT_ISSUE_NUMBER=
158
174
  assert_route "triage dispatch accepts a positive issue" triage \
@@ -161,41 +177,80 @@ assert_route "triage dispatch needs an issue number" none \
161
177
  EVENT=workflow_dispatch OPERATION=triage INPUT_ISSUE_NUMBER=
162
178
  assert "triage dispatch defaults to first pass" first \
163
179
  "$(route_field triage-mode EVENT=workflow_dispatch OPERATION=triage INPUT_ISSUE_NUMBER=42)"
180
+ assert_route "batch dispatch needs an issue number" none \
181
+ EVENT=workflow_dispatch OPERATION=batch INPUT_ISSUE_NUMBER=
164
182
  assert_route "merge-gate dispatch needs a pull request number" none \
165
183
  EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=0
166
184
  assert_route "merge-gate dispatch accepts a positive pull request" merge-gate \
167
185
  EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7
168
- assert_route "stale-recovery dispatch needs no numbers" stale-recovery \
169
- EVENT=workflow_dispatch OPERATION=stale-recovery
170
186
  assert_route "reconcile-bot-pr-runs dispatch needs no numbers" reconcile-bot-pr-runs \
171
187
  EVENT=workflow_dispatch OPERATION=reconcile-bot-pr-runs
172
188
  assert_route "an unknown operation routes nowhere" none \
173
189
  EVENT=workflow_dispatch OPERATION=deploy-everything
174
190
  assert "a scheduled audit reports its trigger kind" scheduled \
175
191
  "$(route_field trigger-kind EVENT=schedule "SCHEDULE=17 1 * * 1")"
192
+
176
193
  assert "a dispatched audit reports its trigger kind" manual \
177
194
  "$(route_field trigger-kind EVENT=workflow_dispatch OPERATION=audit INPUT_TRIGGER_KIND=manual)"
178
- assert_route "propose dispatch needs no numbers" propose \
179
- EVENT=workflow_dispatch OPERATION=propose
180
- assert "a scheduled propose reports its trigger kind" scheduled \
181
- "$(route_field trigger-kind EVENT=schedule "SCHEDULE=29 7 * * *")"
182
- assert "a dispatched propose reports its trigger kind" manual \
183
- "$(route_field trigger-kind EVENT=workflow_dispatch OPERATION=propose INPUT_TRIGGER_KIND=manual)"
184
195
 
185
196
  echo "── Router wiring ─────────────────────────────────────────────────────────"
197
+
198
+ # GitHub evaluates every Actions expression in a workflow file, including ones written inside
199
+ # shell comments. An empty pair is not a valid expression and fails the whole file to parse,
200
+ # with an error that points at a line number rather than saying what is wrong. Prose about
201
+ # expressions must not contain one.
202
+ empty_expr=$(grep -rl -e '${{[[:space:]]*}}' "${HERE}/../../workflows"/*.yml "${HERE}/../../workflows"/*.md 2>/dev/null || true)
203
+ if [ -z "$empty_expr" ]; then
204
+ PASS=$((PASS + 1))
205
+ else
206
+ FAIL=$((FAIL + 1))
207
+ echo "FAIL: workflow files contain an empty Actions expression:" >&2
208
+ printf ' %s
209
+ ' $empty_expr >&2
210
+ fi
211
+
212
+
213
+ # A hyphen inside a ${{ }} property path is parsed as subtraction, so the reference silently
214
+ # resolves to nothing and the rendered prompt keeps the raw expression. Underscores only.
215
+ if ! grep -qE 'needs\.[a-z_]+\.outputs\.[a-zA-Z0-9_]*-' "$IMPLEMENT_WORKER_MD"; then
216
+ PASS=$((PASS + 1))
217
+ else
218
+ FAIL=$((FAIL + 1))
219
+ echo "FAIL: implement worker reads a hyphenated job output inside an expression" >&2
220
+ grep -nE 'needs\.[a-z_]+\.outputs\.[a-zA-Z0-9_]*-' "$IMPLEMENT_WORKER_MD" >&2
221
+ fi
222
+
223
+ # A worker that prints a ${VERIFY_COMMANDS_*} block without setting it renders an empty command
224
+ # block, and the model invents its own build line. That is how a child shipped `dotnet build
225
+ # --no-restore` against an unrestored workspace. The commands are split per area so a change
226
+ # that only touches apps/web does not pay for a cold Release build of the API.
227
+ VERIFY_OK=1
228
+ for VAR in VERIFY_COMMANDS_API VERIFY_COMMANDS_WEB; do
229
+ if grep -q "env\.$VAR" "$IMPLEMENT_WORKER_MD" && ! grep -q "^ $VAR:" "$IMPLEMENT_WORKER_MD"; then
230
+ VERIFY_OK=0
231
+ echo "FAIL: implement worker prints $VAR without defining it" >&2
232
+ fi
233
+ done
234
+ if grep -qE 'env\.VERIFY_COMMANDS[^_]' "$IMPLEMENT_WORKER_MD"; then
235
+ VERIFY_OK=0
236
+ echo "FAIL: implement worker still references the unscoped VERIFY_COMMANDS" >&2
237
+ fi
238
+ if [ "$VERIFY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
239
+
186
240
  if grep -Fq 'protected-files: allowed' "$IMPLEMENT_WORKER_MD" &&
187
- grep -Fq 'protected_changes:' "$MERGE_GATE_WORKER_MD"; then
241
+ grep -Fq 'protected-files: allowed' "$MERGE_GATE_WORKER_MD" &&
242
+ grep -Fq "needs.protected_changes.outputs.requires_review != 'true' || needs.subject.outputs.conclusion == 'failure'" "$MERGE_GATE_WORKER_MD"; then
188
243
  PASS=$((PASS + 1))
189
244
  else
190
245
  FAIL=$((FAIL + 1))
191
- echo "FAIL: protected changes are not held for Merge Gate review" >&2
246
+ echo "FAIL: protected changes must allow failed-CI repair while remaining held from merge" >&2
192
247
  fi
193
248
 
194
249
  # This repository is public. Every route a human can start from a comment, a review or a
195
250
  # label must pass the authorize gate, or anyone able to comment can start a model run that
196
251
  # writes code. Asserted here because removing the gate would otherwise be a silent, one-line
197
252
  # change that nothing fails on.
198
- for route in refine implement direct apply-review; do
253
+ for route in refine implement apply-review; do
199
254
  if grep -qE "route == '${route}'.*needs\.authorize\.outputs\.trusted == 'true'" "$ROUTER_YML"; then
200
255
  PASS=$((PASS + 1))
201
256
  else
@@ -215,8 +270,8 @@ else
215
270
  echo "FAIL: route 'triage' does not dispatch outside collaborators and require a trusted worker actor" >&2
216
271
  fi
217
272
 
218
- for route in refine implement direct triage apply-review merge-gate audit propose bot-approve \
219
- audit-close cleanup-artifacts reconcile-bot-pr-runs stale-recovery validate; do
273
+ for route in refine implement triage apply-review merge-gate audit bot-approve \
274
+ audit-close cleanup-artifacts reconcile-bot-pr-runs validate release; do
220
275
  if grep -q "route == '${route}'" "$ROUTER_YML"; then
221
276
  PASS=$((PASS + 1))
222
277
  else
@@ -1,4 +1,6 @@
1
1
  // Managed by @plainconceptsplatform/workflows. Source: loops/scripts/compile-agent-workflows.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
2
+
3
+ const OPENCODE_VERSION = '1.18.23'
2
4
  import { spawnSync } from "node:child_process";
3
5
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
6
  import { join } from "node:path";
@@ -35,13 +37,200 @@ for (const file of readdirSync(workflowDirectory)) {
35
37
  const path = join(workflowDirectory, file);
36
38
  const content = readFileSync(path, "utf8");
37
39
  const patched = content
38
- .replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --port 4096 --log-level ERROR")
39
- .replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
40
- .replaceAll("opencode run --log-level ERROR", "opencode run --port 4096 --log-level ERROR")
40
+ .replaceAll("opencode run --print-logs --log-level DEBUG", "opencode run --log-level ERROR")
41
+ .replaceAll("opencode run --print-logs --log-level ERROR", "opencode run --log-level ERROR")
42
+ .replaceAll("opencode run --log-level ERROR", "opencode run --log-level ERROR")
41
43
  .replaceAll("--log-level DEBUG", "--log-level ERROR")
42
44
  .replace(/GH_AW_INFO_MODEL: "[^"]*"/g, 'GH_AW_INFO_MODEL: "per-agent"')
43
45
  .replace(/OPENCODE_MODEL: [^\n]+/g, "OPENCODE_MODEL: ''")
44
- .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'');
46
+ .replace(/GH_AW_INFO_MODEL_COSTS: '[^']*'/g, 'GH_AW_INFO_MODEL_COSTS: \'{"providers":{}}\'')
47
+ // The MCP gateway is the only thing the agent publishes on the host loopback, so it is the
48
+ // one thing two runners on the same machine cannot share. Honour an inherited port so each
49
+ // runner service can pick its own; everything else is namespaced by its Docker daemon.
50
+ // awf names its containers awf-agent, awf-squid and awf-api-proxy with no way to change
51
+ // them, so two agent jobs on one host recreate each other's containers and the first dies
52
+ // with exit 137. Giving each runner its own user and daemon isolated the containers but
53
+ // broke everything the runners share through the filesystem: awf and gh-aw both write
54
+ // fixed-name files into /tmp, and a file one user creates cannot be overwritten by the
55
+ // next. Six separate failures came from that. One host lock is the smaller trade: agent
56
+ // jobs run one at a time, every other job still uses all the runners, and every user is
57
+ // the same again. The chain is serial anyway, so in practice this only queues one consumer behind
58
+ // the other. Held for the whole agent run, which is deliberate. The lock lives outside the
59
+ // /tmp/gh-aw namespace on purpose: the keying rewrite below would otherwise make the lock
60
+ // file per job, which is silently no lock at all.
61
+ // Agent jobs run in parallel again. Each runner has its own Docker-in-Docker daemon, so
62
+ // awf's fixed container names (awf-agent, awf-squid, awf-api-proxy, awmg-mcpg) live in
63
+ // separate namespaces and cannot recreate each other. gh-aw detects the tcp DOCKER_HOST
64
+ // and switches to its ARC/DinD path by itself, so no flag is needed here. The host-wide
65
+ // flock this replaces made every agent job wait for the previous one.
66
+ // Three host-global resources were left, and concurrent agent jobs fought over all of them.
67
+ // The global npm install rewrote the opencode binary another job was executing and killed it
68
+ // with SIGKILL mid-run; the warm server and its data directory were shared by every runner.
69
+ //
70
+ // All of these sites are step-level run: or env:, where the runner context is available. It
71
+ // is not available at workflow level, which is what broke the staging path earlier.
72
+ //
73
+ // Install only when the pinned version is missing, and take a lock so two jobs starting at
74
+ // once cannot both write the global prefix.
75
+ // gh-aw pins opencode-ai 1.2.14 at every release checked, up to v0.87.5. That version is
76
+ // from 2026-02-25. The harness expects 1.18.9 and this is the current release, so a gh-aw
77
+ // upgrade does not move it and the pin is ours to choose.
78
+ // gh-aw defaults NPM_CONFIG_MIN_RELEASE_AGE to 3 days, which blocks any package
79
+ // published in the last three days with ETARGET "No matching version found ... with a
80
+ // date before". Lowered to 1 so a fix released yesterday is usable. This is a
81
+ // supply-chain cooldown: shortening it accepts a newer package sooner.
82
+ .replace(/NPM_CONFIG_MIN_RELEASE_AGE: ['\"]?3['\"]?/g, "NPM_CONFIG_MIN_RELEASE_AGE: '1'")
83
+ // The MCP gateway runs inside a per-runner Docker-in-Docker daemon. gh-aw publishes it on
84
+ // 127.0.0.1, which is the DinD container's own loopback, while the outer publish forwards to
85
+ // that container's eth0, so the two never meet and gh-aw's health check fails 120 times with
86
+ // ECONNREFUSED. Binding 0.0.0.0 inside the daemon connects them. The host still exposes the
87
+ // port on loopback only, because the DinD container is published as 127.0.0.1:<port>.
88
+ .replaceAll("-p 127.0.0.1:'\"${MCP_GATEWAY_PORT}\"':", "-p '\"${MCP_GATEWAY_PORT}\"':")
89
+ // Each runner needs its own gateway port: four DinD daemons publish through to one host
90
+ // loopback, so two jobs on 8080 would collide. The runner sets it in .env.
91
+ // awf chroots into the Docker daemon's filesystem and requires it to be the runner's own
92
+ // glibc host. A Docker-in-Docker daemon fails with "Detected Alpine/musl host filesystem
93
+ // under /host", and no dind image fixes it: the agent needs the runner's toolchain and
94
+ // workspace, which a separate daemon container never has. So every agent job shares one
95
+ // daemon, awf's container names are fixed, and the jobs must be serialised.
96
+ .replaceAll(' awf --config', ' flock /tmp/agentic-awf.lock awf --config')
97
+ .replaceAll('export MCP_GATEWAY_PORT="8080"', 'export MCP_GATEWAY_PORT="${MCP_GATEWAY_PORT:-8080}"')
98
+ // The agent's safe outputs land in safeoutputs.jsonl, but the collector reads
99
+ // agent_output.json, and nothing in the generated workflow converts one to the other.
100
+ // With the OpenCode engine the tool calls go through the MCP server rather than stdout,
101
+ // so the log parser never produces agent_output.json and the placeholder step writes
102
+ // {"items":[]} over a run that did real work: the job goes green and the pull request
103
+ // is silently dropped. Build the collector's input from what the agent actually emitted.
104
+ .replaceAll("echo '{\"items\":[]}' > /tmp/gh-aw-${{ github.run_id }}/agent_output.json", "if [ -s /tmp/gh-aw-${{ github.run_id }}/safeoutputs.jsonl ]; then jq -s '{items: .}' /tmp/gh-aw-${{ github.run_id }}/safeoutputs.jsonl > /tmp/gh-aw-${{ github.run_id }}/agent_output.json; else echo '{\"items\":[]}' > /tmp/gh-aw-${{ github.run_id }}/agent_output.json; fi")
105
+ // The "Copy Safe Outputs" step runs on the host after the awf container exits. At that
106
+ // point /tmp/gh-aw/ IS the host's /tmp/gh-aw/, where the safeoutputs MCP container wrote
107
+ // patch and bundle files (via the MCP gateway's -v /tmp:/tmp:rw mount). Extend the step
108
+ // to also copy those files to RUNNER_TEMP/gh-aw/ so the upload step can include them.
109
+ // Without this, the upload step's glob /tmp/gh-aw/aw-*.patch runs inside the awf chroot
110
+ // (where /tmp/gh-aw is a different path) and finds nothing. The safe_outputs and conclude
111
+ // jobs then receive no patches in the artifact and silently skip the push.
112
+ .replace(
113
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n',
114
+ ' cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true\n' +
115
+ ' # Copy patch/bundle files generated by the safeoutputs MCP container to\n' +
116
+ ' # RUNNER_TEMP/gh-aw/ so the upload step includes them in the artifact.\n' +
117
+ ' mkdir -p "${RUNNER_TEMP}/gh-aw"\n' +
118
+ ' ls -la /tmp/gh-aw/ 2>/dev/null || true\n' +
119
+ ' cp /tmp/gh-aw/aw-*.patch "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
120
+ ' cp /tmp/gh-aw/aw-*.bundle "${RUNNER_TEMP}/gh-aw/" 2>/dev/null || true\n' +
121
+ ' ls -la "${RUNNER_TEMP}/gh-aw/"aw-* 2>/dev/null || true\n')
122
+ // Add patch/bundle paths to the upload glob so the agent artifact carries what
123
+ // push-agent-branch needs. The anchor is the two adjacent path lines of the
124
+ // "...agent" upload step; the "...agent-output-fallback" upload lists them in the
125
+ // opposite order, so it cannot match. Anchoring matters: the first bare
126
+ // '/tmp/gh-aw/agent_output.json\n' in the file is the placeholder step's echo line,
127
+ // and replacing there corrupted the placeholder into executing the globs as
128
+ // commands while the upload list stayed without bundles. The artifact then carried
129
+ // no bundle, push-agent-branch silently skipped the push, and the gate reported
130
+ // "remediated" on a pull request that was never touched.
131
+ // Only the /tmp/gh-aw globs may be added: a ${{ runner.temp }} path as well would
132
+ // move the upload's least-common-ancestor from /tmp/gh-aw to /, and every file in
133
+ // the artifact (agent_output.json included) would gain a prefix that
134
+ // download-agent-output does not look for.
135
+ .replace(
136
+ ' /tmp/gh-aw/safeoutputs.jsonl\n /tmp/gh-aw/agent_output.json\n',
137
+ ' /tmp/gh-aw/safeoutputs.jsonl\n' +
138
+ ' /tmp/gh-aw/aw-*.patch\n' +
139
+ ' /tmp/gh-aw/aw-*.bundle\n' +
140
+ ' /tmp/gh-aw/agent_output.json\n')
141
+
142
+ // v0.87.5's arc-dind mode stages the engine CLI to a daemon-visible path but assumes the
143
+ // Copilot engine: command -v copilot is empty under engine: opencode and cp "" fails the
144
+ // job before the agent starts. OpenCode needs no staging, because npm -g installs it under
145
+ // the tool-cache prefix inside _work, which the dind daemon shares.
146
+ // Centralised AgentMemory: an always-on App Service; the shim proxies to it when the
147
+ // URL is set and authenticates with the HMAC secret. Local development leaves both
148
+ // unset and keeps the local per-run store.
149
+ .replace(/^env:\n/m, 'env:\n AGENTMEMORY_URL: https://agentmemory-pro-01.azurewebsites.net\n')
150
+ // the secret is scoped to the one step that runs the agent (semgrep: a secret in
151
+ // workflow-level env is visible to every job and step)
152
+ .replaceAll(' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1',
153
+ ' OPENAI_BASE_URL: https://forge.plainconcepts.com/v1' + '\n' +
154
+ ' AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}')
155
+ // the router passes secrets explicitly (semgrep flags secrets: inherit as over-broad),
156
+ // so the callee must declare everything it accepts
157
+ .replace(/^ COPILOT_GITHUB_TOKEN:/m,
158
+ ' AGENTMEMORY_SECRET:' + '\n' + ' required: false' + '\n' + ' COPILOT_GITHUB_TOKEN:')
159
+ .replaceAll(' COPILOT_SRC="$(command -v copilot)"',
160
+ ' command -v copilot >/dev/null 2>&1 || { echo "no copilot binary (engine is opencode); skipping"; exit 0; }' + '\n' +
161
+ ' COPILOT_SRC="$(command -v copilot)"')
162
+ // "A failed run is already a red run": report-failure-as-issue: false silences one
163
+ // reporter, but gh-aw hardcodes a second one (report_failed_jobs) that files an
164
+ // "[aw] Failed jobs" issue per red run. Same philosophy, same off switch.
165
+ .replaceAll('GH_AW_REPORT_FAILED_JOBS: "true"', 'GH_AW_REPORT_FAILED_JOBS: "false"')
166
+ // gh-aw sees a custom step calling dotnet and injects its own setup-dotnet, which carries
167
+ // no DOTNET_INSTALL_DIR and so tries to write /usr/share/dotnet. The runner user has no
168
+ // sudo, so the job dies with "Permission denied" before the agent starts. The shared
169
+ // baseline step already redirects to the tool cache; this gives every other one the same
170
+ // env, including ones a future gh-aw release injects.
171
+ .replace(
172
+ /^( +)- name: Setup \.NET\n\1 uses: actions\/setup-dotnet@[^\n]*\n\1 with:\n((?:\1 [^\n]*\n)+)(?!\1 env:)/gm,
173
+ (_m, indent, withBody) =>
174
+ indent + '- name: Setup .NET\n' + indent + ' uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0\n' +
175
+ indent + ' with:\n' + withBody +
176
+ indent + ' env:\n' + indent + ' DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet\n')
177
+ .replace(/opencode-ai@[0-9][0-9.]*/g, 'opencode-ai@' + OPENCODE_VERSION)
178
+ // gh-aw installs with --ignore-scripts, which blocks opencode's own postinstall. That
179
+ // script downloads the platform binary, so 1.18 fails at first use with "opencode-ai's
180
+ // postinstall script was not run". 1.2.14 did not need it. Run only opencode's postinstall
181
+ // explicitly, so every other package's scripts stay blocked.
182
+ .replace(
183
+ /npm install --ignore-scripts -g opencode-ai@([0-9][0-9.]*)/g,
184
+ (_m, v) =>
185
+ "opencode --version 2>/dev/null | grep -qF '" + v + "' || " +
186
+ "flock /tmp/opencode-install.lock sh -c 'npm install --ignore-scripts -g opencode-ai@" + v +
187
+ " && cd \"$(npm root -g)/opencode-ai\" && node postinstall.mjs' # opencode-ai@" + v)
188
+ // One warm server per runner, on its own port, with its own data directory. Keyed on the
189
+ // runner rather than the run so a runner keeps its server warm between its own jobs.
190
+ // No double quotes here: the enclosing run: is a double-quoted YAML scalar, so a quote
191
+ // would have to be escaped. The default has no spaces, so bare is safe shell.
192
+ .replace(/\/tmp\/opencode-data(?!-\$\{\{)/g, () => '/tmp/opencode-data-${{ runner.name }}');
193
+
194
+ let rewritten = patched;
195
+ // A queued implement executes hours after its run was created, but actions/checkout
196
+ // without a ref checks out github.sha, which GitHub pins at run creation. Five children
197
+ // queued together at 02:01 all carried the 02:01 base; once the first sibling merged,
198
+ // every later push became a rebase onto a moved parent, which gh-aw's signed-commit
199
+ // push cannot do (it dies with "cannot rebase: You have unstaged changes" and files the
200
+ // pull request as an issue instead of a pull request). Checking out the branch tip at
201
+ // execution time makes the commit parent current main; the implement-global queue
202
+ // already keeps sibling merges out of the window while the agent runs.
203
+ if (file === "agent-implement.lock.yml") {
204
+ const start = rewritten.indexOf("\n agent:\n");
205
+ const rest = start === -1 ? "" : rewritten.slice(start + 1);
206
+ const next = rest.search(/\n [a-z_][a-zA-Z_-]*:\n/);
207
+ if (start !== -1 && next !== -1) {
208
+ const end = start + 1 + next;
209
+ const span = rewritten.slice(start, end).replace(
210
+ /(uses: actions\/checkout@[^\n]*\n(\s+)with:\n(?:\2 [^\n]+\n)*)/,
211
+ (whole, _block, indent) =>
212
+ whole.includes("ref:") ? whole : whole + indent + " ref: ${{ github.event.repository.default_branch }}\n",
213
+ );
214
+ rewritten = rewritten.slice(0, start) + span + rewritten.slice(end);
215
+ }
216
+ }
217
+
218
+ // Guard the two rewrites the compiled agent jobs cannot work without. The anchors are
219
+ // upstream template text: when a gh-aw upgrade reshapes either step, the silent
220
+ // failure mode returns (an artifact with no bundle, a skipped push, a green gate on
221
+ // an untouched pull request). Failing the compile is the only loud signal there is.
222
+ for (const check of [
223
+ { name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
224
+ { name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
225
+ ]) {
226
+ if (!check.ok.test(rewritten)) {
227
+ process.stderr.write(
228
+ `compile patch "${check.name}" no longer matches in ${file}: the gh-aw template changed. ` +
229
+ `Re-anchor the rewrite in loops/scripts/compile-agent-workflows.mjs, or agent pushes are silently dropped.\n`,
230
+ );
231
+ process.exitCode = 1;
232
+ }
233
+ }
45
234
 
46
- if (patched !== content) writeFileSync(path, patched);
47
- }
235
+ if (rewritten !== content) writeFileSync(path, rewritten);
236
+ }