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