@plainconceptsplatform/workflows 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/catalog-installation.d.ts +2 -0
  2. package/dist/catalog-installation.js +44 -2
  3. package/dist/index.js +3 -0
  4. package/dist/stack-defaults.d.ts +1 -0
  5. package/dist/stack-defaults.js +13 -1
  6. package/dist/worker-env.js +2 -3
  7. package/loops/actions/add-issue-labels/action.yml +50 -50
  8. package/loops/actions/agent-output.cjs +17 -17
  9. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  10. package/loops/actions/apply-agent-comments/action.yml +42 -42
  11. package/loops/actions/apply-agent-labels/action.yml +55 -55
  12. package/loops/actions/apply-agent-output/action.yml +5 -0
  13. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  14. package/loops/actions/close-agent-issues/action.yml +43 -43
  15. package/loops/actions/create-agent-issues/action.yml +64 -3
  16. package/loops/actions/create-issue-comment/action.yml +29 -29
  17. package/loops/actions/download-agent-output/action.yml +53 -53
  18. package/loops/actions/housekeeping/action.yml +95 -1
  19. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  20. package/loops/actions/list-open-issues/action.yml +33 -33
  21. package/loops/actions/load-issue-context/action.yml +45 -45
  22. package/loops/actions/merge-agent-pr/action.yml +49 -49
  23. package/loops/actions/push-agent-branch/action.yml +45 -45
  24. package/loops/actions/remove-issue-labels/action.yml +37 -37
  25. package/loops/actions/update-agent-issues/action.yml +58 -58
  26. package/loops/actions/validate-merge-gate-output/action.yml +5 -0
  27. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +8 -2
  28. package/loops/actions/validate-refine-output/validate-refine-output.sh +30 -1
  29. package/loops/actions/validate-review-output/action.yml +35 -35
  30. package/loops/actions/validate-triage-output/action.yml +36 -36
  31. package/loops/actions/verify-composite-actions/action.yml +9 -9
  32. package/loops/actions/verify-refine-output/action.yml +9 -9
  33. package/loops/actions/verify-refine-output/verify-refine-output.sh +17 -0
  34. package/loops/actions/verify-route-matrix/action.yml +9 -9
  35. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +221 -7
  36. package/loops/docs/diagrams.md +90 -12
  37. package/loops/scripts/compile-agent-workflows.mjs +76 -2
  38. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/release/github-release.yml +30 -30
  42. package/loops/workflows/agent-implement.md +43 -8
  43. package/loops/workflows/agent-merge-gate.md +102 -6
  44. package/loops/workflows/agent-refine.md +152 -5
  45. package/loops/workflows/authorize-bot-work.yml +105 -105
  46. package/loops/workflows/shared/platform-defaults.md +23 -23
  47. package/loops/workflows/work-router.yml +345 -2
  48. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-implement.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
3
  env:
4
- VERIFY_COMMANDS: "dotnet restore && dotnet build -c Release --no-restore && dotnet test -c Release --no-build"
4
+ VERIFY_COMMANDS: ""
5
5
  REPO_RULES: "Implement only the selected issue. Follow repository documentation and existing conventions. Do not weaken tests, lower coverage thresholds, or bypass checks."
6
6
  # Split out of REPO_RULES because one field asked to carry architecture, testing, coverage and
7
7
  # conventions together, and measured on 2026-09-07 three of the four consuming repositories had
@@ -77,8 +77,8 @@ description: |
77
77
  Waiting on CI inside this run would hold a runner doing nothing, which is why the gate is
78
78
  a separate workflow rather than a later step.
79
79
 
80
- Router-only worker: triggered exclusively via workflow_call from work-router.yml.
81
- Contract input: issue-number.
80
+ Router-first worker: triggered via workflow_call from work-router.yml, or via workflow_dispatch
81
+ from the router's stack path (`--ref` = the feature branch). Contract input: issue-number.
82
82
 
83
83
  name: "Agent: Implement Issue"
84
84
 
@@ -101,6 +101,23 @@ on:
101
101
  required: false
102
102
  type: string
103
103
  default: '0'
104
+ # Stack dispatch (work-router.yml reads `<!-- stack-root-branch: feat/N -->` on the issue and
105
+ # runs `gh workflow run agent-implement.lock.yml --ref feat/N`). Under workflow_call
106
+ # GITHUB_SHA is the router event's SHA -- global default-branch state, not the feature
107
+ # branch -- which widens the patch window; dispatch pins the SHA to the feature tip and makes
108
+ # the generator produce exactly-the-child's-commits patches. Workflow-level inputs resolve
109
+ # identically from both triggers, so every job below reads inputs.issue-number unchanged.
110
+ workflow_dispatch:
111
+ inputs:
112
+ issue-number:
113
+ description: Issue number to implement.
114
+ required: true
115
+ type: string
116
+ attempts_so_far:
117
+ description: Failed implement runs already made for this issue. Parked when it reaches the cap.
118
+ required: false
119
+ type: string
120
+ default: '0'
104
121
  # The gate job that the top-level `if:` reads. gh-aw folds that `if:` into the generated
105
122
  # activation job but gives activation no dependency on the job, so the reference resolves
106
123
  # to '' and the clause is false -- the agent would never run. The package's own validator
@@ -619,6 +636,17 @@ safe-outputs:
619
636
  protected-files: allowed
620
637
  allowed-files:
621
638
  - "**"
639
+ # The pull request targets the branch this run was started on, which is the branch the work
640
+ # was cut from: `main` when the router calls this worker, `feat/N` when it dispatches a stack
641
+ # child with `--ref feat/N`. Taken from the run rather than asked of the agent, because the
642
+ # agent was asked and did not: on Lyceum #72 it emitted branch and title with no base, the
643
+ # handler fell back to the default branch, and a bundle built on feat/44 could not be replayed
644
+ # onto main (run 38694942046). A repository with no stack markers is never dispatched, so its
645
+ # base stays `main` exactly as before.
646
+ base-branch: ${{ github.ref_name }}
647
+ allowed-base-branches:
648
+ - "main"
649
+ - "feat/*"
622
650
 
623
651
  # The fleet is two machines, so this clock is also how long a stuck run can hold half of it.
624
652
  # 240 went on to every worker at once when the provider was slow, which fixed the deaths and
@@ -713,6 +741,11 @@ timeout-minutes: 180
713
741
  change and makes you clean the files you touched; fix every line it lists. The merge gate
714
742
  runs it again and does not merge while it fails.
715
743
 
744
+ Anything you change to satisfy `check-diff` is a change: run the verification commands
745
+ again afterwards, and open the pull request only from a tree where both pass. Do not delete
746
+ a test that covers a branch you added to fit the budget; the complexity gate in CI measures
747
+ that coverage.
748
+
716
749
  5. Do not touch `changelog.json`. The workflow records the change itself once the work is on
717
750
  the default branch. Every implement used to edit that one file, so two runs whose branches
718
751
  were cut before the other merged conflicted on it and failed to open a pull request with the
@@ -728,12 +761,14 @@ timeout-minutes: 180
728
761
 
729
762
  Choose exactly one:
730
763
 
731
- - **`safeoutputs/create_pull_request`** , the normal path. Propose a pull request against
732
- `main` with the verified changes. Its `body` must close the issue
764
+ - **`safeoutputs/create_pull_request`** , the normal path. Propose a pull request with the
765
+ verified changes. Do not pass `base`: the workflow sets it to the branch this run started
766
+ on, which is the branch you are working from, so a stack child lands on its feature branch
767
+ without you doing anything. The `body` **must close exactly this issue and nothing else**
733
768
  (`Closes #${{ inputs.issue-number }}`) and summarise what changed and why. Its `branch` is
734
- the branch you are on, the one named in step 1; do not invent another name. You do not need
735
- to check whether a pull request already exists for this issue: the router does that before
736
- dispatching you and does not start this workflow when one does.
769
+ the branch you are on, the one named in step 1; do not invent another name. You do not
770
+ need to check whether a pull request already exists for this issue: the router does
771
+ that before dispatching you and does not start this workflow when one does.
737
772
  - **`safeoutputs/report_incomplete`** , only when infrastructure or tooling prevents you
738
773
  from completing the task, such as a pre-existing build failure you cannot fix. Provide a
739
774
  specific `reason`.
@@ -89,7 +89,9 @@ description: |
89
89
  risk assessment is clean, hand to a human when it is not, fix CI when it failed. Called by
90
90
  the Work Router; does not trigger on public events.
91
91
 
92
- The router supplies the CI conclusion and run ID as facts, so there is no polling and no
92
+ In a stack, a child pull request (its base is the feature branch) merges into that feature
93
+ branch and its child issue closes with it; the root pull request is held for a person. The
94
+ router supplies the CI conclusion and run ID as facts, so there is no polling and no
93
95
  timeout branch. The conclusion is read from the inputs instead of filtered at the trigger.
94
96
 
95
97
  name: "Agent: Merge Gate"
@@ -155,6 +157,8 @@ jobs:
155
157
  conclusion: ${{ steps.subject.outputs.conclusion }}
156
158
  run-id: ${{ steps.subject.outputs.run-id }}
157
159
  review_blocked: ${{ steps.review.outputs.review_blocked }}
160
+ pr_base: ${{ steps.stack.outputs.pr_base }}
161
+ stack_root: ${{ steps.stack.outputs.stack_root }}
158
162
  steps:
159
163
  - name: Checkout workflow actions
160
164
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -189,6 +193,37 @@ jobs:
189
193
  sleep 5
190
194
  done
191
195
  echo "review_blocked=$([ "$decision" = 'CHANGES_REQUESTED' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
196
+ # A child PR in a stack has its base on the feature branch, not main. The rest of the
197
+ # gate needs both facts measured here, once: which branch this PR merges into, and
198
+ # whether this PR is the root of a stack (its linked feature issue carries this PR's
199
+ # number in the `<!-- stack-root-pr: N -->` marker refine stamped when it created the
200
+ # root PR). The base is always known; the marker only exists in stack repos, so
201
+ # repos without stacks see no change at all in either output ('' and 'false').
202
+ - name: Detect the pull request's base and its role in a stack
203
+ id: stack
204
+ # Only for a resolved subject. Without one, PR is empty and `gh pr view ""` falls back to
205
+ # the checked-out branch and fails, so every gate dispatched for a pull request that is
206
+ # not a subject -- a held stack root whose CI went green, for one -- ended red (Lyceum
207
+ # #106, run 38750643587: "no pull requests found for branch main"). Skipped, both outputs
208
+ # are empty, which everything downstream already reads as "not a root".
209
+ if: steps.subject.outputs.found == 'true'
210
+ env:
211
+ GH_TOKEN: ${{ github.token }}
212
+ REPO: ${{ github.repository }}
213
+ PR: ${{ steps.subject.outputs.pr }}
214
+ ISSUE: ${{ steps.subject.outputs.issue }}
215
+ run: |
216
+ set -euo pipefail
217
+ pr_base=$(gh pr view "$PR" --repo "$REPO" --json baseRefName --jq '.baseRefName // ""')
218
+ stack_root=false
219
+ if [ -n "$ISSUE" ]; then
220
+ body=$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo "")
221
+ if echo "$body" | grep -q "<!-- stack-root-pr: $PR -->"; then
222
+ stack_root=true
223
+ fi
224
+ fi
225
+ echo "pr_base=$pr_base" >> "$GITHUB_OUTPUT"
226
+ echo "stack_root=$stack_root" >> "$GITHUB_OUTPUT"
192
227
 
193
228
  # Rung 4b. Everything the merge decision needs that a shell can establish, measured once,
194
229
  # before any model reads the diff. The job kept its name and its `requires_review` and
@@ -420,6 +455,7 @@ jobs:
420
455
  outputs:
421
456
  valid: ${{ steps.validate.outputs.valid }}
422
457
  outcome: ${{ steps.validate.outputs.outcome }}
458
+ pushed: ${{ steps.validate.outputs.pushed }}
423
459
  steps:
424
460
  - name: Checkout workflow actions
425
461
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -525,7 +561,7 @@ jobs:
525
561
  Findings and verification on the linked issue: #${{ needs.subject.outputs.issue }}. [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
526
562
  ${{ 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.' || '' }}
527
563
  - name: Merge approved pull request
528
- if: needs.validate_output.outputs.outcome == 'auto-merge'
564
+ if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root != 'true'
529
565
  env:
530
566
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
531
567
  REPO: ${{ github.repository }}
@@ -534,6 +570,20 @@ jobs:
534
570
  set -euo pipefail
535
571
  head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
536
572
  gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
573
+ # The root's own gate run stops here: children may still be landing on the feature
574
+ # branch, and the merge decision for main belongs to a person. The comment says only
575
+ # what a reader needs: the gate held the root, children merge into the feature branch,
576
+ # and a human merges the root when the stack completes.
577
+ - name: Hold a stack root for a human merge
578
+ if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.stack_root == 'true'
579
+ uses: ./.github/actions/create-issue-comment
580
+ with:
581
+ token: ${{ steps.app-token.outputs.token }}
582
+ issue-number: ${{ needs.subject.outputs.issue }}
583
+ body: |
584
+ **Stack root held.** This pull request is the root of a stack: its children merge
585
+ into the feature branch, and it merges into ${{ github.event.repository.default_branch }} only when a person
586
+ does it, after all children have landed and the feature branch validates.
537
587
  - name: Release remediated issue
538
588
  if: needs.validate_output.outputs.outcome == 'remediated'
539
589
  uses: ./.github/actions/remove-issue-labels
@@ -583,6 +633,22 @@ jobs:
583
633
  token: ${{ steps.app-token.outputs.token }}
584
634
  issue-number: ${{ needs.subject.outputs.issue }}
585
635
  labels: ${{ env.WORKING_LABEL }}
636
+ # GitHub's closing keywords never fire when a PR merges into a branch other than the
637
+ # default, so a child PR landing on the feature branch leaves its issue open — and
638
+ # "zero open children" would never be reachable. This step closes the child issue
639
+ # deterministically, exactly the issue this PR implements, with the App token: the
640
+ # close raises routed events only for issue lifecycle the router already understands,
641
+ # and `gh issue close` on an already-closed issue is a harmless no-op, making the
642
+ # step safe to re-run.
643
+ - name: Close the child issue when the pull request landed on a feature branch
644
+ if: needs.validate_output.outputs.outcome == 'auto-merge' && needs.subject.outputs.pr_base != '' && needs.subject.outputs.pr_base != github.event.repository.default_branch
645
+ env:
646
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
647
+ REPO: ${{ github.repository }}
648
+ ISSUE: ${{ needs.subject.outputs.issue }}
649
+ run: |
650
+ set -euo pipefail
651
+ gh issue close "$ISSUE" --repo "$REPO" --reason completed || true
586
652
  # A person decides this one, so the screenshots come before the decision, in a router run of
587
653
  # their own outside the belt. Never on auto-merge: that dispatch would race the merge.
588
654
  - name: Replay the verification plan for the reviewer
@@ -718,6 +784,26 @@ jobs:
718
784
 
719
785
  **Verdict:** human-review
720
786
  [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
787
+ # The issue is where the belt reads verdicts, but the pull request is where people look. A
788
+ # parked pull request with nothing on it reads as abandoned, and a repair the agent pushed
789
+ # that the gate refused to apply sits in the run artifact where nobody finds it.
790
+ - name: Tell the pull request it needs a person
791
+ if: steps.budget.outputs.park == 'true'
792
+ uses: ./.github/actions/create-issue-comment
793
+ with:
794
+ token: ${{ steps.app-token.outputs.token }}
795
+ issue-number: ${{ needs.subject.outputs.pr }}
796
+ body: |
797
+ The merge gate stopped on this pull request and handed it to a person (#${{ needs.subject.outputs.issue }}).
798
+ ${{ needs.validate_output.outputs.pushed == 'true' && 'The agent pushed a repair that was not applied because its report could not be read. The patch and bundle are in the `agent` artifact of the run below.' || '' }}
799
+ [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
800
+ - name: Label the pull request for review
801
+ if: steps.budget.outputs.park == 'true'
802
+ uses: ./.github/actions/add-issue-labels
803
+ with:
804
+ token: ${{ steps.app-token.outputs.token }}
805
+ issue-number: ${{ needs.subject.outputs.pr }}
806
+ labels: ${{ env.REVIEW_LABEL }}
721
807
  # `stalled` means a park the machine caused, and the janitor retries those. An unusable
722
808
  # report is a park the machine caused and retrying reproduces it, so it gets `review`
723
809
  # alone: the janitor's own rule is retry a failure, report a decision.
@@ -914,6 +1000,13 @@ timeout-minutes: 120
914
1000
  until it exits 0, then verify, push and report `remediated` as in step 6. An `assessed`
915
1001
  verdict while it fails blocks the pull request.
916
1002
 
1003
+ When CI failed as well, repair CI first and `check-diff` second, and verify again after the
1004
+ `check-diff` repair. The two pull in opposite directions: a complexity or coverage gate wants
1005
+ more code and tests, `check-diff` wants fewer lines. Trimming to fit `check-diff` is how a
1006
+ pull request that once passed broke the formatter, Semgrep and the complexity gate on its
1007
+ last commit (Pliny-Bot #633). Never delete a test that covers a branch the change added; if
1008
+ both cannot hold, keep CI green and say so in `reason`.
1009
+
917
1010
  - **success** → step 4, then step 5 (review the change).
918
1011
  - **failure** → step 4, then step 6 (CI remediation).
919
1012
  - **action_required, cancelled, timed_out, or anything else** → CI did not produce a usable
@@ -934,8 +1027,10 @@ timeout-minutes: 120
934
1027
  4b. **Merge conflict when CI is green.** If the conclusion is `success` and
935
1028
  `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`),
936
1029
  resolve the conflict before assessing risk. You are already on the PR branch.
937
- Merge `origin/${{ github.event.repository.default_branch }}` into it
938
- (`git merge origin/${{ github.event.repository.default_branch }}`), resolve every
1030
+ Merge `origin/${{ needs.subject.outputs.pr_base }}` into it
1031
+ (`git merge origin/${{ needs.subject.outputs.pr_base }}`; that is the branch this pull
1032
+ request merges into — the default branch for a flat pull request, the feature branch
1033
+ for a stack child), resolve every
939
1034
  conflict deliberately, commit the merge, and run the verification commands below. Do not
940
1035
  use `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the
941
1036
  intended behavior from both sides, and never rebase: the push is fast-forward only.
@@ -1098,8 +1193,9 @@ timeout-minutes: 120
1098
1193
  must not auto-merge the PR.
1099
1194
 
1100
1195
  **If `has_conflicts` is `true` (current value: `${{ needs.reserve.outputs.has_conflicts }}`):** You are already on the PR branch. Resolve the conflict;
1101
- it is not a reason to hand the PR to a human. Merge `origin/${{ github.event.repository.default_branch }}`
1102
- into the current branch, resolve every conflict deliberately, stage the resolutions, and
1196
+ it is not a reason to hand the PR to a human. Merge `origin/${{ needs.subject.outputs.pr_base }}`
1197
+ (the branch this pull request merges into) into the current branch, resolve every conflict
1198
+ deliberately, stage the resolutions, and
1103
1199
  commit the merge. Then run verification and push the resulting branch update. Do not use
1104
1200
  `--ours`, `--theirs`, or a blanket conflict-marker deletion without reviewing the intended
1105
1201
  behavior from both sides.
@@ -53,6 +53,7 @@ env:
53
53
  SPLIT_PARENT_PREFIX: "<!-- split-parent: "
54
54
  SPLIT_THRESHOLD: "8"
55
55
  MAX_SPLIT_CHILDREN: "6"
56
+ MIN_CHILD_POINTS: "3"
56
57
  INCOMPLETE_COMMENT: "Automated refinement ended without an outcome. The refine label remains for a retry."
57
58
  SAFE_OUTPUT_COMMENT_PREFIX: "Refinement update"
58
59
  ISSUE_CONTEXT_PATH: /tmp/gh-aw/agent/issue-context.json
@@ -396,6 +397,7 @@ jobs:
396
397
  update-issues: 'true'
397
398
  create-issues: 'true'
398
399
  fallback-issue-number: ${{ inputs.issue-number }}
400
+ parent-issue: ${{ needs.validate_output.outputs.outcome == 'split' && inputs.issue-number || '' }}
399
401
  - name: Apply complete refinement labels
400
402
  if: needs.validate_output.outputs.outcome == 'complete'
401
403
  uses: ./.github/actions/add-issue-labels
@@ -434,6 +436,83 @@ jobs:
434
436
  ${{ env.REFINE_LABEL }}
435
437
  ${{ env.WORKING_LABEL }}
436
438
  ${{ env.REVIEW_LABEL }}
439
+ # The stack root pull request. Every output in this worker is staged and applied by this
440
+ # job, and gh-aw's handler opens nothing while the run is staged: a per-output
441
+ # `staged: false` on create-pull-request compiles into the handler config and is ignored
442
+ # there (Lyceum #45, run 38740216266: "Pull request creation preview written to step
443
+ # summary"). So the root is opened here, from the agent's own create_pull_request request,
444
+ # once the validator has called the run a split. Refine writes no code, so the root carries
445
+ # none: an empty commit on the default branch's tip, created through the API, which is what
446
+ # gh-aw's allow-empty would have pushed. Idempotent: an open root on the feature branch is
447
+ # reused and an existing branch is not recreated, so a retry stamps the same root.
448
+ - name: Open the stack root pull request
449
+ id: root
450
+ if: needs.validate_output.outputs.outcome == 'split'
451
+ continue-on-error: true
452
+ env:
453
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
454
+ REPO: ${{ github.repository }}
455
+ ISSUE: ${{ inputs.issue-number }}
456
+ AGENT_OUTPUT: agent-artifact/agent_output.json
457
+ run: |
458
+ set -euo pipefail
459
+ branch="feat/${ISSUE}"
460
+ item=$(jq -c '[.items[] | select(.type == "create_pull_request")][0] // {}' "$AGENT_OUTPUT")
461
+ title=$(printf '%s' "$item" | jq -r '.title // empty')
462
+ body=$(printf '%s' "$item" | jq -r '.body // empty')
463
+ if [ -z "$title" ]; then
464
+ title=$(gh issue view "$ISSUE" --repo "$REPO" --json title --jq '.title')
465
+ fi
466
+ case "$title" in "[bot] "*) ;; *) title="[bot] ${title}" ;; esac
467
+ # The root's merge is what ends the feature, so it must close the feature issue.
468
+ if ! printf '%s' "$body" | grep -qiE "(fixes|closes|resolves) #${ISSUE}([^0-9]|$)"; then
469
+ body=$(printf '%s\n\nFixes #%s' "$body" "$ISSUE")
470
+ fi
471
+
472
+ existing=$(gh pr list --repo "$REPO" --state open --head "$branch" --json number --jq '.[0].number // empty')
473
+ if [ -n "$existing" ]; then
474
+ echo "Reusing open root #${existing} on ${branch}"
475
+ echo "pr_number=${existing}" >> "$GITHUB_OUTPUT"
476
+ exit 0
477
+ fi
478
+
479
+ default=$(gh api "repos/${REPO}" --jq '.default_branch')
480
+ if ! gh api "repos/${REPO}/git/ref/heads/${branch}" >/dev/null 2>&1; then
481
+ base_sha=$(gh api "repos/${REPO}/git/ref/heads/${default}" --jq '.object.sha')
482
+ tree=$(gh api "repos/${REPO}/git/commits/${base_sha}" --jq '.tree.sha')
483
+ commit=$(gh api "repos/${REPO}/git/commits" -f message="chore: open the stack root for #${ISSUE}" \
484
+ -f tree="$tree" -f "parents[]=${base_sha}" --jq '.sha')
485
+ gh api "repos/${REPO}/git/refs" -f ref="refs/heads/${branch}" -f sha="$commit" >/dev/null
486
+ fi
487
+ printf '%s' "$body" > /tmp/root-body.md
488
+ url=$(gh pr create --repo "$REPO" --base "$default" --head "$branch" --title "$title" --body-file /tmp/root-body.md)
489
+ echo "Opened root ${url} on ${branch}"
490
+ echo "pr_number=${url##*/}" >> "$GITHUB_OUTPUT"
491
+ # The stack markers. The feature branch and its root pull request are what every child
492
+ # builds on and what completion is counted against, so they are recorded on the feature
493
+ # issue itself, where the child implement runs and the router read them back with no new
494
+ # service: `<!-- stack-root-branch: ... -->` resolves each child's dispatch ref, and
495
+ # `<!-- stack-root-pr: ... -->` names the held root. The branch comes from the pull
496
+ # request itself, never from the name the agent chose, because what reached the remote
497
+ # is the only truth. Idempotent: old markers are replaced, so a re-split after a
498
+ # discarded first attempt re-stamps cleanly. Missing markers fail closed -- a child
499
+ # without them takes the flat path, never a silent feature-branch dispatch.
500
+ - name: Record the stack root on the issue
501
+ if: needs.validate_output.outputs.outcome == 'split' && (steps.root.outputs.pr_number != '' || needs.safe_outputs.outputs.created_pr_number != '')
502
+ continue-on-error: true
503
+ env:
504
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
505
+ REPO: ${{ github.repository }}
506
+ ISSUE: ${{ inputs.issue-number }}
507
+ PR_NUMBER: ${{ steps.root.outputs.pr_number || needs.safe_outputs.outputs.created_pr_number }}
508
+ run: |
509
+ set -euo pipefail
510
+ branch=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName --jq '.headRefName')
511
+ body=$(gh issue view "$ISSUE" --repo "$REPO" --json body --jq '.body // ""')
512
+ cleaned=$(printf '%s' "$body" | sed -E 's/<!-- stack-root-(branch|pr): [^ ]+ -->//g' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
513
+ printf '%s\n\n<!-- stack-root-branch: %s -->\n<!-- stack-root-pr: %s -->' "$cleaned" "$branch" "$PR_NUMBER" > /tmp/issue-body.md
514
+ gh issue edit "$ISSUE" --repo "$REPO" --body-file /tmp/issue-body.md
515
+ echo "Stamped stack root branch $branch and PR #$PR_NUMBER on issue #$ISSUE"
437
516
  # Estimates become labels here rather than in the agent, because labels are workflow-owned
438
517
  # state. Children were created moments ago by the same run, so they are labelled together
439
518
  # with the parent: each body carries its own marker.
@@ -507,6 +586,7 @@ jobs:
507
586
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
508
587
  REPO: ${{ github.repository }}
509
588
  PARENT: ${{ inputs.issue-number }}
589
+ ROOT_PR: ${{ steps.root.outputs.pr_number || needs.safe_outputs.outputs.created_pr_number }}
510
590
  REFINE_LABEL: ${{ env.REFINE_LABEL }}
511
591
  REFINED_LABEL: ${{ env.REFINED_LABEL }}
512
592
  IMPLEMENT_LABEL: ${{ env.IMPLEMENT_LABEL }}
@@ -518,6 +598,21 @@ jobs:
518
598
  children=$(gh issue list --repo "$REPO" --state open --limit 50 \
519
599
  --search "\"<!-- split-parent: ${PARENT} -->\" in:body" --json number --jq '.[].number' || true)
520
600
  fi
601
+
602
+ # Each child body also carries the resolved feature branch. The implement agent sees
603
+ # only the child issue's body in its context, never the parent's, so a marker that
604
+ # lives on the parent alone is invisible to the very prompt that must act on it.
605
+ # Stamping the branch the root pull request really runs on (headRefName, the same
606
+ # source of truth the parent marker uses) makes the child self-describing end to
607
+ # end: the router reads it to resolve the dispatch ref and the agent reads it to set
608
+ # its pull request base. Idempotent, and skipped entirely when the root pull request
609
+ # was not created -- without a feature branch there is no stack to describe, and the
610
+ # child simply takes the flat path.
611
+ STACK_BRANCH=''
612
+ if [ -n "$ROOT_PR" ]; then
613
+ STACK_BRANCH=$(gh pr view "$ROOT_PR" --repo "$REPO" --json headRefName --jq '.headRefName' 2>/dev/null || true)
614
+ fi
615
+
521
616
  for child in $children; do
522
617
  [ "$child" = "$PARENT" ] && continue
523
618
  # A child handed over earlier carries the refined label. Handing it again would start
@@ -527,6 +622,12 @@ jobs:
527
622
  echo "#$child was already handed over; leaving it alone"
528
623
  continue
529
624
  fi
625
+ if [ -n "$STACK_BRANCH" ]; then
626
+ child_body=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""')
627
+ cleaned=$(printf '%s' "$child_body" | sed -E 's/<!-- stack-root-branch: [^ ]+ -->//g' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
628
+ printf '%s\n\n<!-- stack-root-branch: %s -->' "$cleaned" "$STACK_BRANCH" > /tmp/child-body.md
629
+ gh issue edit "$child" --repo "$REPO" --body-file /tmp/child-body.md >/dev/null
630
+ fi
530
631
  gh issue edit "$child" --repo "$REPO" \
531
632
  --remove-label "$REFINE_LABEL" --remove-label "$IMPLEMENT_LABEL" >/dev/null 2>&1 || true
532
633
  gh issue edit "$child" --repo "$REPO" --add-label "$REFINED_LABEL" >/dev/null
@@ -744,6 +845,23 @@ safe-outputs:
744
845
  # one issue nobody can land; the cap stops a runaway decomposition.
745
846
  create-issue:
746
847
  max: 6
848
+ # The stack root. A split story integrates on a feature branch instead of the default
849
+ # branch: the children land there one by one and the root pull request carries the whole
850
+ # feature to the default branch once they are done, which is also the human validation
851
+ # point before main. The branch name is preserved rather than salted because it is a
852
+ # long-lived integration line that children, markers and prompts all address by name;
853
+ # a collision with an existing branch fails the push loudly, which is the correct
854
+ # outcome for a durable ref. The allowed-branches glob must stay in sync with the name
855
+ # the prompt tells the agent to choose (feat/ plus the parent issue number). Only the
856
+ # root targets the default branch, so allowed-base-branches admits nothing else.
857
+ create-pull-request:
858
+ # Staged like everything else, and opened by the `Open the stack root pull request` step:
859
+ # gh-aw ignores a per-output `staged: false` while the worker is staged.
860
+ allowed-branches: ["feat/*"]
861
+ allowed-base-branches: ["main"]
862
+ preserve-branch-name: true
863
+ title-prefix: "[bot] "
864
+ allow-empty: true
747
865
 
748
866
 
749
867
  # The fleet is two machines, so this clock is also how long a stuck run can hold half of it.
@@ -820,11 +938,24 @@ timeout-minutes: 90
820
938
  **If it splits:** write between two and ${{ env.MAX_SPLIT_CHILDREN }} children. Each child is
821
939
  a complete refined story wrapped in the same issue form, with its own
822
940
  acceptance criteria, its own tests section, and its own estimate of 5 or less. Never write a
823
- child estimated at 1: that is a fragment, so fold it into a sibling. Call `create_issue` once
941
+ child estimated below ${{ env.MIN_CHILD_POINTS }}: that is a fragment, not a story -- the whole
942
+ stack exists to carry real slices, so fold anything smaller into a sibling and give the
943
+ sibling the honest combined estimate. Call `create_issue` once
824
944
  per child, and in each child body include:
825
945
 
826
946
  - the line `${{ env.SPLIT_PARENT_PREFIX }}N -->` naming the parent issue number
827
- - a `Blocked by #M` line naming any sibling that must land first, when order genuinely matters
947
+ - a `Blocked by #aw_<id>` line for each sibling that must land first, when order genuinely
948
+ matters, one line per sibling. Siblings have no numbers yet, so give every child a
949
+ `temporary_id` of `aw_` plus 3 to 12 letters, digits or underscores (`aw_modules`, not
950
+ `aw_module_backend`, which gh-aw rejects as too long) and name siblings by it; the workflow rewrites each
951
+ `#aw_<id>` to the real issue number once the children exist. Write dependencies only as these
952
+ lines and never set the `blocked_by` field: it takes one reference, and a value naming two
953
+ rejected the item and discarded the whole refinement (Lyceum #45).
954
+ - a `Parent feature branch: feat/<parent issue number>` line (for parent #123,
955
+ `Parent feature branch: feat/123`) naming the branch the whole split integrates on:
956
+ each child's pull request targets that branch, never the default branch, the children
957
+ land there one at a time, and only the root pull request carries the finished feature
958
+ to the default branch
828
959
 
829
960
  Then call `update_issue` on the parent, replacing its body with a short summary of the whole
830
961
  piece of work, the reason it was split, and a checklist linking every child. The parent keeps
@@ -914,7 +1045,23 @@ timeout-minutes: 90
914
1045
 
915
1046
  **The story was split.** You estimated ${{ env.SPLIT_THRESHOLD }} or more and found real
916
1047
  seams. Call `create_issue` once per child, then `update_issue` on the parent with the
917
- summary and the checklist, then `add_comment` with `${{ env.REFINE_MARKER }}`, then
1048
+ summary and the checklist. Then open the stack's root pull request, in this order:
1049
+
1050
+ First write the feature's journey as one failing end-to-end test, following the
1051
+ repository's own end-to-end test conventions. That failing test is the specification, not
1052
+ the implementation: write test code only, never the production code that would make it
1053
+ pass. When the repository has no end-to-end test conventions at all, skip the test rather
1054
+ than inventing one -- the root pull request may be proposal-only.
1055
+
1056
+ Then call `create_pull_request` once, with the branch named `feat/` plus the parent issue
1057
+ number -- exactly the name you wrote into every child's `Parent feature branch:` line --
1058
+ a one-line title for the whole feature, and a body carrying that same summary and child
1059
+ checklist plus the closing line `Fixes #${{ inputs.issue-number }}`. Do not pass `base`:
1060
+ the root pull request targets the default branch, and its closing line fires only when it
1061
+ eventually merges there, after every child has landed on the feature branch.
1062
+
1063
+ Finally, call `add_comment` with `${{ env.REFINE_MARKER }}`, then
918
1064
  `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`, then one sentence naming the estimate you gave the
919
- whole and how many children you wrote. The children carry the work forward; the parent stays
920
- open as their tracker and is never implemented directly.
1065
+ whole, how many children you wrote, and the feature branch they integrate on. The children
1066
+ carry the work forward; the parent stays open as their tracker and is never implemented
1067
+ directly.