@plainconceptsplatform/workflows 1.3.1 → 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.
Files changed (41) hide show
  1. package/dist/worker-env.js +2 -3
  2. package/loops/actions/add-issue-labels/action.yml +50 -50
  3. package/loops/actions/agent-output.cjs +17 -17
  4. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  5. package/loops/actions/apply-agent-comments/action.yml +42 -42
  6. package/loops/actions/apply-agent-labels/action.yml +55 -55
  7. package/loops/actions/apply-agent-output/action.yml +5 -0
  8. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  9. package/loops/actions/close-agent-issues/action.yml +43 -43
  10. package/loops/actions/create-agent-issues/action.yml +64 -3
  11. package/loops/actions/create-issue-comment/action.yml +29 -29
  12. package/loops/actions/download-agent-output/action.yml +53 -53
  13. package/loops/actions/housekeeping/action.yml +95 -1
  14. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  15. package/loops/actions/list-open-issues/action.yml +33 -33
  16. package/loops/actions/load-issue-context/action.yml +45 -45
  17. package/loops/actions/merge-agent-pr/action.yml +49 -49
  18. package/loops/actions/push-agent-branch/action.yml +45 -45
  19. package/loops/actions/remove-issue-labels/action.yml +37 -37
  20. package/loops/actions/update-agent-issues/action.yml +58 -58
  21. package/loops/actions/validate-refine-output/validate-refine-output.sh +30 -1
  22. package/loops/actions/validate-review-output/action.yml +35 -35
  23. package/loops/actions/validate-triage-output/action.yml +36 -36
  24. package/loops/actions/verify-composite-actions/action.yml +9 -9
  25. package/loops/actions/verify-refine-output/action.yml +9 -9
  26. package/loops/actions/verify-refine-output/verify-refine-output.sh +17 -0
  27. package/loops/actions/verify-route-matrix/action.yml +9 -9
  28. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +214 -7
  29. package/loops/docs/diagrams.md +90 -12
  30. package/loops/scripts/compile-agent-workflows.mjs +76 -2
  31. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  32. package/loops/templates/issues/bug_report.yml +109 -109
  33. package/loops/templates/issues/feature_request.yml +75 -75
  34. package/loops/templates/release/github-release.yml +30 -30
  35. package/loops/workflows/agent-implement.md +37 -7
  36. package/loops/workflows/agent-merge-gate.md +74 -6
  37. package/loops/workflows/agent-refine.md +152 -5
  38. package/loops/workflows/authorize-bot-work.yml +105 -105
  39. package/loops/workflows/shared/platform-defaults.md +23 -23
  40. package/loops/workflows/work-router.yml +330 -2
  41. package/package.json +1 -1
@@ -1,105 +1,105 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
- # Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
3
- # This ensures the bot is the actor for all agentic workflows.
4
- #
5
- # IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
6
- # it adds bot-working itself, so authorize-bot-work must not fire again.
7
- name: "Authorize Bot Work"
8
-
9
- run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
10
-
11
- on:
12
- issues:
13
- types: [labeled]
14
-
15
- permissions:
16
- contents: read
17
-
18
- jobs:
19
- authorize:
20
- # Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
21
- # already own the issue.
22
- #
23
- # `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
24
- # door: the classifier refuses to route while `review` is set, so a person adding `refine`
25
- # to a parked issue got no run, no comment and no error anywhere. Triage's own
26
- # needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
27
- #
28
- # A person adding a work label IS the human review the label was waiting for, so this
29
- # workflow clears it below before handing the issue to the bot. The classifier's own guard
30
- # stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
31
- # bot-working is added `review` is already gone.
32
- if: >
33
- (github.event.label.name == 'implement' ||
34
- github.event.label.name == 'refine') &&
35
- !contains(github.event.issue.labels.*.name, 'bot-working') &&
36
- !endsWith(github.actor, '[bot]')
37
- runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
38
- timeout-minutes: 5
39
- concurrency:
40
- group: authorize-${{ github.event.issue.number }}
41
- cancel-in-progress: false
42
- permissions:
43
- contents: read
44
- issues: write
45
- steps:
46
- - name: Check actor permission
47
- id: check
48
- env:
49
- GH_TOKEN: ${{ github.token }}
50
- ACTOR: ${{ github.actor }}
51
- run: |
52
- set -euo pipefail
53
-
54
- permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
55
- --jq '.permission' 2>/dev/null || echo "none")
56
-
57
- case "$permission" in
58
- admin | maintain | write)
59
- echo "authorized=true" >> "$GITHUB_OUTPUT"
60
- echo "::notice::$ACTOR has $permission permission - authorized"
61
- ;;
62
- *)
63
- echo "authorized=false" >> "$GITHUB_OUTPUT"
64
- echo "::warning::$ACTOR has '$permission' permission - not authorized"
65
- ;;
66
- esac
67
-
68
- - name: Checkout for App token action
69
- if: steps.check.outputs.authorized == 'true'
70
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
71
- with:
72
- persist-credentials: false
73
-
74
- - name: Generate App token
75
- if: steps.check.outputs.authorized == 'true'
76
- id: app-token
77
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
78
- with:
79
- app-id: ${{ secrets.BOT_APP_ID }}
80
- private-key: ${{ secrets.BOT_PRIVATE_KEY }}
81
-
82
- - name: Bot takes the issue over
83
- if: steps.check.outputs.authorized == 'true'
84
- env:
85
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
86
- ISSUE_NUMBER: ${{ github.event.issue.number }}
87
- LABEL: ${{ github.event.label.name }}
88
- run: |
89
- set -euo pipefail
90
-
91
- # Clear review FIRST. A person adding a work label is the human review the label was
92
- # waiting for, and the classifier refuses to route an issue that still carries it, so
93
- # removing it here is what makes the hand-off work at all. It also has to happen
94
- # before bot-working, because that is the event the classifier reads: adding
95
- # bot-working first would raise an event whose payload still shows review.
96
- # --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
97
- # `stalled` goes with it: a person taking the issue on restarts the work, so the
98
- # marker the janitor retries on must not survive into the new run.
99
- gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
100
- echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
101
-
102
- echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
103
- gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
104
-
105
- echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/authorize-bot-work.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # Human adds implement/refine/direct/feature label → validates permission → bot adds bot-working
3
+ # This ensures the bot is the actor for all agentic workflows.
4
+ #
5
+ # IMPORTANT: Only triggers for HUMAN actors. When the bot transitions refine→implement,
6
+ # it adds bot-working itself, so authorize-bot-work must not fire again.
7
+ name: "Authorize Bot Work"
8
+
9
+ run-name: "Authorizing: ${{ github.event.issue.title }} (#${{ github.event.issue.number }})"
10
+
11
+ on:
12
+ issues:
13
+ types: [labeled]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ authorize:
20
+ # Only trigger for work labels from HUMANS (not bots), and only if another bot run does not
21
+ # already own the issue.
22
+ #
23
+ # `review` is deliberately NOT excluded. It used to be, and that made the label a one-way
24
+ # door: the classifier refuses to route while `review` is set, so a person adding `refine`
25
+ # to a parked issue got no run, no comment and no error anywhere. Triage's own
26
+ # needs-maintainer verdict tells the maintainer to add `refine`, which could not work.
27
+ #
28
+ # A person adding a work label IS the human review the label was waiting for, so this
29
+ # workflow clears it below before handing the issue to the bot. The classifier's own guard
30
+ # stays as it is: it exists to stop the *bot* re-triggering itself, and by the time
31
+ # bot-working is added `review` is already gone.
32
+ if: >
33
+ (github.event.label.name == 'implement' ||
34
+ github.event.label.name == 'refine') &&
35
+ !contains(github.event.issue.labels.*.name, 'bot-working') &&
36
+ !endsWith(github.actor, '[bot]')
37
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
38
+ timeout-minutes: 5
39
+ concurrency:
40
+ group: authorize-${{ github.event.issue.number }}
41
+ cancel-in-progress: false
42
+ permissions:
43
+ contents: read
44
+ issues: write
45
+ steps:
46
+ - name: Check actor permission
47
+ id: check
48
+ env:
49
+ GH_TOKEN: ${{ github.token }}
50
+ ACTOR: ${{ github.actor }}
51
+ run: |
52
+ set -euo pipefail
53
+
54
+ permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
55
+ --jq '.permission' 2>/dev/null || echo "none")
56
+
57
+ case "$permission" in
58
+ admin | maintain | write)
59
+ echo "authorized=true" >> "$GITHUB_OUTPUT"
60
+ echo "::notice::$ACTOR has $permission permission - authorized"
61
+ ;;
62
+ *)
63
+ echo "authorized=false" >> "$GITHUB_OUTPUT"
64
+ echo "::warning::$ACTOR has '$permission' permission - not authorized"
65
+ ;;
66
+ esac
67
+
68
+ - name: Checkout for App token action
69
+ if: steps.check.outputs.authorized == 'true'
70
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
71
+ with:
72
+ persist-credentials: false
73
+
74
+ - name: Generate App token
75
+ if: steps.check.outputs.authorized == 'true'
76
+ id: app-token
77
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
78
+ with:
79
+ app-id: ${{ secrets.BOT_APP_ID }}
80
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
81
+
82
+ - name: Bot takes the issue over
83
+ if: steps.check.outputs.authorized == 'true'
84
+ env:
85
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
86
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
87
+ LABEL: ${{ github.event.label.name }}
88
+ run: |
89
+ set -euo pipefail
90
+
91
+ # Clear review FIRST. A person adding a work label is the human review the label was
92
+ # waiting for, and the classifier refuses to route an issue that still carries it, so
93
+ # removing it here is what makes the hand-off work at all. It also has to happen
94
+ # before bot-working, because that is the event the classifier reads: adding
95
+ # bot-working first would raise an event whose payload still shows review.
96
+ # --remove-label on an absent label is a no-op, so this is safe on a fresh issue.
97
+ # `stalled` goes with it: a person taking the issue on restarts the work, so the
98
+ # marker the janitor retries on must not survive into the new run.
99
+ gh issue edit "$ISSUE_NUMBER" --remove-label "review" --remove-label "stalled" 2>/dev/null ||
100
+ echo "::notice::no review or stalled label to clear on #$ISSUE_NUMBER"
101
+
102
+ echo "Adding bot-working label to issue #$ISSUE_NUMBER (triggered by $LABEL)"
103
+ gh issue edit "$ISSUE_NUMBER" --add-label "bot-working"
104
+
105
+ echo "::notice::bot-working label added - bot will now own the $LABEL workflow"
@@ -1,26 +1,26 @@
1
- ---
2
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
- description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
-
5
- network:
6
- allowed:
7
- - defaults
8
- - forge.plainconcepts.com
9
- # opencode fetches its model registry from models.opencode.ai at startup; without
10
- # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
- # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
- - models.opencode.ai
13
- - node
14
- - github
15
- - dotnet
16
- - fonts
17
- - login.microsoftonline.com
18
- # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
- - agentmemory-pro-01.azurewebsites.net
20
-
21
- safe-outputs:
22
- threat-detection: false
23
- ---
1
+ ---
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
+
5
+ network:
6
+ allowed:
7
+ - defaults
8
+ - forge.plainconcepts.com
9
+ # opencode fetches its model registry from models.opencode.ai at startup; without
10
+ # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
+ # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
+ - models.opencode.ai
13
+ - node
14
+ - github
15
+ - dotnet
16
+ - fonts
17
+ - login.microsoftonline.com
18
+ # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
+ - agentmemory-pro-01.azurewebsites.net
20
+
21
+ safe-outputs:
22
+ threat-detection: false
23
+ ---
24
24
 
25
25
  ## Verification, for every worker that builds or pushes
26
26
 
@@ -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.1",
3
+ "version": "1.4.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",