@plainconceptsplatform/workflows 1.3.0 → 1.4.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.
@@ -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.
@@ -461,9 +461,160 @@ jobs:
461
461
  echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
462
462
  fi
463
463
 
464
+ # A stack child implements on the feature branch its parent issue names. The agent sandbox
465
+ # cannot resolve that branch itself -- agents have no git credentials and checkout a pinned
466
+ # default-branch SHA -- so the router reads the marker and resolves the dispatch ref before
467
+ # the worker starts. Refine stamps `<!-- stack-root-branch: feat/N -->` both on the PARENT
468
+ # feature issue and on every child body at hand-over, so the child is self-describing for
469
+ # the implement prompt, which reads only the child's context. The split-parent chain is the
470
+ # fallback when a child predates the stamp (retries of issues split by an earlier refine).
471
+ # Missing, empty or garbled markers fail closed: the child takes the flat call-implement
472
+ # path and lands on the default branch, exactly what a repo without stacks does today.
473
+ # The in-flight check exists because a dispatched agent-implement run is invisible to
474
+ # check-implement-pr's older-run dupe check: this router run completes as soon as
475
+ # `gh workflow run` returns (fire-and-forget), while a workflow_call caller stays
476
+ # in_progress -- and the dispatch run's title "Working (Implement)" and the App-token
477
+ # label events' actor "app/..." both miss the title conditions that check matches.
478
+ # head_branch is the discriminator: a dispatched implement's run records the dispatch ref,
479
+ # feat/N, while a workflow_call run's head_branch is the default branch. Filtering
480
+ # in_progress/queued agent-implement.lock.yml runs by head_branch therefore isolates stack
481
+ # implements with zero effect on flat runs.
482
+ check-stack-branch:
483
+ needs: [classify, authorize]
484
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
485
+ runs-on: agents-arc
486
+ timeout-minutes: 3
487
+ permissions:
488
+ contents: read
489
+ issues: write
490
+ actions: read
491
+ outputs:
492
+ is-stack: ${{ steps.stack.outputs.is-stack }}
493
+ stack-branch: ${{ steps.stack.outputs.stack-branch }}
494
+ stack-in-flight: ${{ steps.inflight.outputs.stack-in-flight }}
495
+ stack-blocked: ${{ steps.blocked.outputs.stack-blocked }}
496
+ steps:
497
+ - name: Resolve the feature branch from the stack markers
498
+ id: stack
499
+ env:
500
+ GH_TOKEN: ${{ github.token }}
501
+ REPO: ${{ github.repository }}
502
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
503
+ run: |
504
+ set -euo pipefail
505
+ echo "is-stack=false" >> "$GITHUB_OUTPUT"
506
+ echo "stack-branch=" >> "$GITHUB_OUTPUT"
507
+
508
+ # Read failures degrade to the flat path, never to a failed job: call-implement
509
+ # names this job in its needs, so a failure here would block a perfectly ordinary
510
+ # flat implement run. A repo without stack markers must behave exactly as before.
511
+ body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
512
+ # Marker-first: the child may carry its own resolved marker from an earlier hop.
513
+ # `|| true` on both pipelines: under `set -o pipefail`, grep's exit 1 on no match
514
+ # (the common flat case) or head's SIGPIPE on a multi-marker body must not fail
515
+ # the job -- call-implement needs this job, so a failure here would block flat
516
+ # implements too.
517
+ branch=$(printf '%s' "$body" | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
518
+
519
+ if [ -z "$branch" ]; then
520
+ # No marker on the child: follow the split-parent chain to the feature issue,
521
+ # where refine stamps the stack root.
522
+ parent=$(printf '%s' "$body" \
523
+ | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 \
524
+ | grep -oE '[0-9]+' || true)
525
+ if [ -n "$parent" ]; then
526
+ parent_body=$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
527
+ branch=$(printf '%s' "$parent_body" \
528
+ | grep -oE '<!-- stack-root-branch: [^ ]+ -->' | head -1 \
529
+ | sed -E 's/<!-- stack-root-branch: ([^ ]+) -->/\1/' || true)
530
+ fi
531
+ fi
532
+
533
+ # Fail closed on anything unreadable or unsafe: a missing marker, a garbled
534
+ # split parent, or a marker naming a deleted branch all take the flat path.
535
+ if ! printf '%s' "$branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then
536
+ echo "::notice::Issue #$ISSUE_NUMBER carries no readable stack-root-branch marker; taking the flat path."
537
+ exit 0
538
+ fi
539
+ if ! gh api "repos/$REPO/branches/$(printf '%s' "$branch" | sed 's|/|%2F|g')" --jq '.name' >/dev/null 2>&1; then
540
+ echo "::notice::Stack root branch $branch no longer exists on the remote; taking the flat path."
541
+ echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
542
+ exit 0
543
+ fi
544
+
545
+ echo "is-stack=true" >> "$GITHUB_OUTPUT"
546
+ echo "stack-branch=$branch" >> "$GITHUB_OUTPUT"
547
+
548
+ # A child whose `Blocked by #N` sibling has not landed is held, not dispatched: dispatched now
549
+ # it would cut from a feature branch that lacks the sibling's work. Lyceum #112 (units,
550
+ # blocked by #111, modules) was dispatched alongside #111 and would have built units on a
551
+ # feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
552
+ # before either dispatch existed, and serialising runs would still start #112 before #111's
553
+ # pull request merged. The landing of the blocker releases the child (the stack block in
554
+ # detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
555
+ # waiting rather than stuck.
556
+ - name: Hold a child whose blockers have not landed
557
+ id: blocked
558
+ if: steps.stack.outputs.is-stack == 'true'
559
+ env:
560
+ GH_TOKEN: ${{ github.token }}
561
+ REPO: ${{ github.repository }}
562
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
563
+ STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
564
+ run: |
565
+ set -euo pipefail
566
+ echo "stack-blocked=false" >> "$GITHUB_OUTPUT"
567
+ body=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')
568
+ waiting=""
569
+ for b in $(printf '%s' "$body" | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
570
+ gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
571
+ # Open, but landed if a pull request that closes it has merged into the feature branch:
572
+ # the release fires on that merge and the gate closes the issue a moment later, or not
573
+ # at all when it fails. Reading the issue state alone would hold the child again with
574
+ # no later landing left to release it.
575
+ landed=$(gh pr list --repo "$REPO" --state merged --base "$STACK_BRANCH" --search "#$b in:body" --json body --jq "[.[] | select(.body | test(\"(?i)(close[sd]?|fix(e[sd])?|resolve[sd]?) #${b}([^0-9]|$)\"))] | length" 2>/dev/null || echo 0)
576
+ [ "${landed:-0}" -gt 0 ] && continue
577
+ waiting="$waiting #$b"
578
+ done
579
+ [ -n "$waiting" ] || exit 0
580
+ echo "::notice::#$ISSUE_NUMBER is blocked by$waiting, not yet landed on $STACK_BRANCH; holding it."
581
+ echo "stack-blocked=true" >> "$GITHUB_OUTPUT"
582
+ gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
583
+ marker='<!-- stack-held -->'
584
+ if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
585
+ {
586
+ printf '%s\n' "$marker"
587
+ printf "Held until%s have landed on \`%s\`: this child builds on them. It keeps \`implement\` and is released when they merge.\n" "$waiting" "$STACK_BRANCH"
588
+ } > /tmp/held.md
589
+ gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
590
+ fi
591
+
592
+ - name: Detect an in-flight implement on the feature branch
593
+ id: inflight
594
+ if: steps.stack.outputs.is-stack == 'true'
595
+ env:
596
+ GH_TOKEN: ${{ github.token }}
597
+ REPO: ${{ github.repository }}
598
+ STACK_BRANCH: ${{ steps.stack.outputs.stack-branch }}
599
+ run: |
600
+ set -euo pipefail
601
+ echo "stack-in-flight=false" >> "$GITHUB_OUTPUT"
602
+ # Only dispatched stack implements run with head_branch = feat/N; a workflow_call
603
+ # (flat) implement run's head_branch is the default branch, so this filter never
604
+ # sees it. This closes the dupe-visibility gap left by fire-and-forget dispatch.
605
+ for status in in_progress queued; do
606
+ live=$(gh api "repos/$REPO/actions/workflows/agent-implement.lock.yml/runs?status=$status&per_page=100" \
607
+ --jq "[.workflow_runs[] | select(.head_branch == \"${STACK_BRANCH}\")] | length" 2>/dev/null || echo 0)
608
+ if [ "${live:-0}" -gt 0 ]; then
609
+ echo "::notice::An implement run is already in flight on $STACK_BRANCH; skipping dispatch."
610
+ echo "stack-in-flight=true" >> "$GITHUB_OUTPUT"
611
+ exit 0
612
+ fi
613
+ done
614
+
464
615
  call-implement:
465
- needs: [classify, authorize, check-implement-pr]
466
- if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true'
616
+ needs: [classify, authorize, check-implement-pr, check-stack-branch]
617
+ if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true' && needs.check-implement-pr.outputs.has-open-pr != 'true' && needs.check-stack-branch.outputs.is-stack != 'true'
467
618
  uses: ./.github/workflows/agent-implement.lock.yml
468
619
  concurrency:
469
620
  group: implement-global
@@ -483,6 +634,50 @@ jobs:
483
634
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
484
635
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
485
636
 
637
+ # Stack children dispatch agent-implement by `gh workflow run --ref feat/N` instead of calling
638
+ # it: a workflow_call would pin the callee to THIS event's SHA, which for a label event is
639
+ # the default branch, not the feature branch. Dispatch on the feature ref makes the callee's
640
+ # GITHUB_SHA the feature tip, so the agent patches exactly the tree its PR will target, and
641
+ # the compile-time checkout ternary keys off `workflow_dispatch` to checkout github.ref_name.
642
+ # The dispatch itself carries no secrets (gh workflow run cannot forward them); the
643
+ # dispatched run reads REPO-LEVEL OPENAI_API_KEY / CODEX_API_KEY / COPILOT_GITHUB_TOKEN, the
644
+ # stack-activation prerequisite documented alongside the markers. BOT_APP_ID / BOT_PRIVATE_KEY
645
+ # are already repo-level. dispatch-triage proves the pattern: same app-token mint, same
646
+ # `actions: write` scope needed for `gh workflow run`.
647
+ dispatch-implement:
648
+ needs: [classify, authorize, check-implement-pr, check-stack-branch]
649
+ if: >
650
+ needs.classify.outputs.route == 'implement' &&
651
+ needs.authorize.outputs.trusted == 'true' &&
652
+ needs.check-implement-pr.outputs.has-open-pr != 'true' &&
653
+ needs.check-stack-branch.outputs.is-stack == 'true' &&
654
+ needs.check-stack-branch.outputs.stack-in-flight != 'true' &&
655
+ needs.check-stack-branch.outputs.stack-blocked != 'true'
656
+ runs-on: agents-arc
657
+ timeout-minutes: 5
658
+ permissions:
659
+ actions: write
660
+ contents: read
661
+ steps:
662
+ - name: Create bot token
663
+ id: app-token
664
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
665
+ with:
666
+ client-id: ${{ secrets.BOT_APP_ID }}
667
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
668
+ - name: Dispatch stack implement on the feature branch
669
+ env:
670
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
671
+ REPO: ${{ github.repository }}
672
+ STACK_BRANCH: ${{ needs.check-stack-branch.outputs.stack-branch }}
673
+ ISSUE_NUMBER: ${{ needs.classify.outputs.issue-number }}
674
+ ATTEMPTS: ${{ needs.classify.outputs.attempts }}
675
+ run: |
676
+ set -euo pipefail
677
+ gh workflow run agent-implement.lock.yml --repo "$REPO" --ref "$STACK_BRANCH" \
678
+ -f issue-number="$ISSUE_NUMBER" \
679
+ -f attempts_so_far="$ATTEMPTS"
680
+
486
681
  dispatch-triage:
487
682
  needs: [classify, authorize]
488
683
  if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
@@ -973,6 +1168,139 @@ jobs:
973
1168
  while IFS= read -r pr; do
974
1169
  maybe_dispatch "$pr"
975
1170
  done
1171
+
1172
+ # Stacked delivery: the merged pull request merged into a feature branch, not the
1173
+ # default branch, so this is a child landing. If the feature branch has no open
1174
+ # children left and its root pull request is green, the stack is complete and a
1175
+ # comment on the root pull request tells the human it is ready for the root merge.
1176
+ # Repos without stack markers never reach the interesting part: the event PR's base
1177
+ # was the default branch, so the guard below fails immediately. Fully idempotent:
1178
+ # the completion comment carries its own marker, and a run that finds it already
1179
+ # present exits without writing anything.
1180
+ event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1181
+ if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1182
+ echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1183
+ # The implement worker's pull request closes exactly its own child issue, so
1184
+ # the closed issue comes from the linked closing references (the resolved form
1185
+ # GitHub computes whatever keyword the agent wrote), with the body text as
1186
+ # fallback for a PR created before that guarantee.
1187
+ closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1188
+ --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1189
+ [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1190
+ | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1191
+ if [ -z "$closed_issue" ]; then
1192
+ echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
1193
+ else
1194
+ # Refine stamps the split-parent marker on every child body it hands over, so
1195
+ # the child's own body names the feature issue. The reverse sub-issues
1196
+ # listing has no equivalent per-child lookup: the sub-issues API only walks
1197
+ # downward from the parent.
1198
+ parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1199
+ | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1200
+ if [ -z "$parent" ]; then
1201
+ echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
1202
+ else
1203
+ root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1204
+ | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1205
+ # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1206
+ # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1207
+ # exact `open` never matched, so no root was ever open and no child was ever counted
1208
+ # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1209
+ # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1210
+ root_open=false
1211
+ [ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
1212
+ if [ "$root_open" != true ]; then
1213
+ echo "Stack #$parent has no open root pull request; nothing to announce."
1214
+ else
1215
+ # The same child listing refine uses to hand work out: the sub-issues
1216
+ # link safe_outputs writes, with the body-marker search as fallback. A
1217
+ # stack parent always has children, so a listing that finds none means
1218
+ # the lookups failed, and announcing completion of a stack we cannot
1219
+ # see would be wrong -- stay silent and let a rerun decide.
1220
+ children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1221
+ if [ -z "$children" ]; then
1222
+ children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1223
+ --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1224
+ fi
1225
+ if [ -z "$children" ]; then
1226
+ echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1227
+ else
1228
+ # Release the children this landing unblocks. check-stack-branch holds a child
1229
+ # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1230
+ # so the landing does: re-adding `implement` with the App token is a bot label
1231
+ # event, which the router routes straight to implement. The child that just
1232
+ # landed counts as done even before the gate closes its issue a moment later.
1233
+ for child in $children; do
1234
+ [ "$child" = "$closed_issue" ] && continue
1235
+ meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1236
+ printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1237
+ printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1238
+ blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1239
+ [ -n "$blockers" ] || continue
1240
+ waiting=""
1241
+ for b in $blockers; do
1242
+ [ "$b" = "$closed_issue" ] && continue
1243
+ if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1244
+ waiting="$waiting #$b"
1245
+ fi
1246
+ done
1247
+ if [ -n "$waiting" ]; then
1248
+ echo "#$child still waits on$waiting."
1249
+ continue
1250
+ fi
1251
+ gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1252
+ gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1253
+ echo "Released #$child: everything it was blocked by has landed."
1254
+ done
1255
+ open_children=0
1256
+ for child in $children; do
1257
+ # Landed, though the gate may not have closed its issue yet.
1258
+ [ "$child" = "$closed_issue" ] && continue
1259
+ if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1260
+ open_children=$((open_children + 1))
1261
+ fi
1262
+ done
1263
+ if [ "$open_children" -gt 0 ]; then
1264
+ echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
1265
+ else
1266
+ # The root pull request must be green and mergeable. Green: a successful
1267
+ # CI run on its head SHA. Mergeable: the same on-demand computation
1268
+ # every gate dispatch already relies on; UNKNOWN settles in the
1269
+ # background, so ask until it does, like mergeable_state() does.
1270
+ root_state=UNKNOWN
1271
+ for _ in 1 2 3 4 5 6; do
1272
+ root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1273
+ [ "$root_state" = "UNKNOWN" ] || break
1274
+ sleep 5
1275
+ done
1276
+ root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1277
+ root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1278
+ --jq '[.workflow_runs[]
1279
+ | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1280
+ and .conclusion == "success")]
1281
+ | sort_by(.created_at) | last // empty')
1282
+ if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1283
+ echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
1284
+ else
1285
+ completion_marker='<!-- stack-complete -->'
1286
+ # The announcement is a pull request comment, and pull request
1287
+ # comments live at the issue endpoint for the pull request number,
1288
+ # so the marker is looked for where the comment was posted.
1289
+ already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1290
+ --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1291
+ if [ "${already:-0}" -gt 0 ]; then
1292
+ echo "Stack #$parent already has a completion announcement; staying silent."
1293
+ else
1294
+ 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"
1295
+ echo "Stack #$parent complete: announced root PR #$root_pr."
1296
+ fi
1297
+ fi
1298
+ fi
1299
+ fi
1300
+ fi
1301
+ fi
1302
+ fi
1303
+ fi
976
1304
  else
977
1305
  maybe_dispatch "$EVENT_PR"
978
1306
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",