@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
@@ -1183,19 +1183,21 @@ if [ -f "$HOUSEKEEPING_YML" ]; then
1183
1183
  #
1184
1184
  # Counted on the close shape, not on the words. A bare `state: 'closed'` is also how you ask
1185
1185
  # the API for closed things, and the gate metrics list closed pull requests to find the merges:
1186
- # counting the string alone reported that listing as a third close. Both real closes state a
1186
+ # counting the string alone reported that listing as a third close. Every real close states a
1187
1187
  # reason, so that is what is counted, and the assertion below keeps the two from drifting apart
1188
- # by refusing any close that does not.
1188
+ # by refusing any close that does not. The counted set grew with stacks -- a landing child
1189
+ # whose issue close was lost, and a feature issue whose root merged -- and each is named in
1190
+ # the Stacked features section below, so the list here is four, not the original two.
1189
1191
  closes=$(count -cE "state: 'closed', state_reason:" "$HOUSEKEEPING_YML")
1190
1192
  if grep -nE "issues\.update\(.*state: 'closed'" "$HOUSEKEEPING_YML" | grep -qv "state_reason:"; then
1191
1193
  HK_OK=0
1192
1194
  echo "FAIL: housekeeping closes an issue without a state_reason; the close audit counts on it" >&2
1193
1195
  fi
1194
- if [ "$closes" -eq 2 ]; then
1196
+ if [ "$closes" -eq 4 ]; then
1195
1197
  PASS=$((PASS + 1))
1196
1198
  else
1197
1199
  HK_OK=0
1198
- echo "FAIL: housekeeping closes issues in ${closes} place(s); only the split parent and its own digest are allowed" >&2
1200
+ echo "FAIL: housekeeping closes issues in ${closes} place(s); the split parent, the stack child, the stack feature and the digest are the four allowed" >&2
1199
1201
  fi
1200
1202
 
1201
1203
  # A retry is a workflow_dispatch, and GitHub starts no workflow run from an event raised
@@ -1611,6 +1613,13 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
1611
1613
  human-review "$(gate_items "$(gate_comment assessed)")" success low false false ''
1612
1614
  gate_case "remediating check-diff is accepted" remediated "$(gate_items "$(gate_comment remediated),${gate_push}")" success low false false fail
1613
1615
 
1616
+ # The verdict line is read by its shape. Pliny-Bot #639 wrote the bold around the whole line,
1617
+ # and the repair it pushed was discarded twice.
1618
+ gate_bold_line='{"type":"add_comment","item_number":7,"body":"## Remediation Report\n\n**Verdict: remediated**\n"}'
1619
+ gate_prose='{"type":"add_comment","item_number":7,"body":"The verdict is remediated, see the push."}'
1620
+ gate_case "a verdict line bolded whole is read" remediated "$(gate_items "${gate_bold_line},${gate_push}")" failure low
1621
+ gate_case "a verdict in prose is not a verdict" invalid "$(gate_items "${gate_prose},${gate_push}")" failure low
1622
+
1614
1623
  # The agent's report decides the rest. This is the rule the whole redesign rests on: an
1615
1624
  # unverified finding is a warning whatever severity it claims, so a model cannot fail the gate
1616
1625
  # by asserting something it did not demonstrate, and cannot pass it by understating one it did.
@@ -2334,6 +2343,16 @@ if worker_installed refine; then
2334
2343
  echo "FAIL: the refine refusal never releases bot-working; a refused issue stays reserved with no run behind it" >&2
2335
2344
  fi
2336
2345
 
2346
+ # The split floor: a split child carries a whole stack's machinery (issue, feature branch,
2347
+ # PR into the branch), so a 1- or 2-point slice is a fragment and must be folded into a
2348
+ # sibling instead. The floor must be pinned through the env var the prompt interpolates,
2349
+ # so a consumer cannot lower it below 3 without editing the worker.
2350
+ if ! grep -qF 'MIN_CHILD_POINTS: "3"' "$REFINE_WORKER_MD" \
2351
+ || ! grep -qF 'child estimated below ${{ env.MIN_CHILD_POINTS }}' "$REFINE_WORKER_MD"; then
2352
+ SIZE_GATE_OK=0
2353
+ echo "FAIL: refine allows split children below 3 points; the floor must stay interpolated from MIN_CHILD_POINTS" >&2
2354
+ fi
2355
+
2337
2356
  if [ "$SIZE_GATE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2338
2357
  fi
2339
2358
 
@@ -2620,8 +2639,10 @@ if [ -f "$APP_ERRORS_MJS" ]; then
2620
2639
  # 3. The field allowlist is what actually holds the line, because a redaction rule filters
2621
2640
  # values and cannot see a field somebody adds next year. Compared as a set, so reordering is
2622
2641
  # fine and an addition is not. run_id is the one that matters: it is unhashed on this
2623
- # telemetry and joins to a conversation and to a person.
2624
- expected_fields="exceptionType frames fingerprint firstSeen lastSeen occurrences operationName operations problemId roleName topFrames message category"
2642
+ # telemetry and joins to a conversation and to a person. category, fingerprint, message and
2643
+ # topFrames joined in v0.28.7 to enrich the filed issues (a84928a); they are set-side
2644
+ # fields, never engine telemetry, so the allowlist grows with them.
2645
+ expected_fields="category exceptionType fingerprint firstSeen frames lastSeen message occurrences operationName operations problemId roleName topFrames"
2625
2646
  actual_fields="$(sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" |
2626
2647
  grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort | tr '\n' ' ' | sed 's/ $//')"
2627
2648
  if [ "$actual_fields" = "$(echo "$expected_fields" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')" ]; then
@@ -2662,9 +2683,202 @@ if [ -f "$APP_ERRORS_MJS" ]; then
2662
2683
  if [ "$AE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2663
2684
  fi
2664
2685
 
2686
+ echo "── Stacked features ──────────────────────────────────────────────────────"
2687
+
2688
+ # Stacks are delivered by marker, not by label: every flat-mode rule still has to hold on a
2689
+ # repository that has never seen a marker. The assertions here are grouped as the two halves
2690
+ # of that contract. The first says the stack machinery exists and fails closed: an unreadable
2691
+ # or missing marker must route a child down the flat path, never dispatch at a branch nobody
2692
+ # resolved. The second says the flat path never learned about stacks: the default-path jobs
2693
+ # and prompts carry no stack condition that could fire on an issue without markers.
2694
+ STACK_OK=1
2695
+ st() {
2696
+ grep -qE "$1" "$2" || { STACK_OK=0; echo "FAIL: stacked features ${3}" >&2; }
2697
+ }
2698
+ # The router's stack job is a block of a derived file, not a file of its own, so its
2699
+ # assertions take the block on stdin: handing a multi-line string to grep as a filename
2700
+ # fails with "File name too long" and reads as a wiring failure that is not one.
2701
+ st_str() {
2702
+ printf '%s' "$2" | grep -qE "$1" || { STACK_OK=0; echo "FAIL: stacked features ${3}" >&2; }
2703
+ }
2704
+
2705
+ # 1. The resolver. check-stack-branch reads the child's own marker first, then the
2706
+ # split-parent chain; both are `|| true` so a flat body (no match) cannot fail the job --
2707
+ # call-implement names this job in its needs, so a hard failure would block flat implements.
2708
+ CHECK_STACK_JOB=$(sed -n '/^ check-stack-branch:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
2709
+ if [ -n "$CHECK_STACK_JOB" ]; then
2710
+ st "stack-root-branch: \[\\^ \]\+" "$ROUTER_YML" 'has no marker regex for the feature branch'
2711
+ st_str 'exit 0' "$CHECK_STACK_JOB" 'resolver hard-fails instead of taking the flat path'
2712
+ st_str 'sed .s\|/\|%2F\|g.' "$CHECK_STACK_JOB" 'does not URL-escape the stack branch before the existence check'
2713
+ st_str 'sed -E .s/<!-- stack-root-branch: \(\[\^ \]\+\) -->/\\1/.' "$CHECK_STACK_JOB" 'does not strip the marker syntax off the branch'
2714
+ # The dupe guard: only dispatched stack implements run with head_branch = feat/N, so a
2715
+ # workflow_call (flat) run whose head_branch is the default branch can never match it.
2716
+ st_str 'head_branch == ..\$\{STACK_BRANCH\}..' "$CHECK_STACK_JOB" 'does not detect an in-flight implement on the feature branch'
2717
+ # Dispatch happens on the resolved ref, never on the event SHA. The `gh workflow run`
2718
+ # lives in dispatch-implement, which check-stack-branch gates: the two jobs verify the
2719
+ # branch exists, then hand off. The run needs a fresh App token -- GITHUB_TOKEN can
2720
+ # start no workflow -- and it reads repo-level secrets because a dispatch cannot forward
2721
+ # workflow-call ones, which is the activation prerequisite ownership.md documents.
2722
+ DISPATCH_JOB=$(sed -n '/^ dispatch-implement:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
2723
+ if [ -n "$DISPATCH_JOB" ]; then
2724
+ st_str 'gh workflow run agent-implement\.lock\.yml .* --ref "\$STACK_BRANCH"' "$DISPATCH_JOB" 'does not dispatch the stack implement at all'
2725
+ st_str 'create-github-app-token' "$DISPATCH_JOB" 'dispatches with GITHUB_TOKEN, which can start no run'
2726
+ # A child blocked by an open sibling is held, and the sibling's landing releases it. Without
2727
+ # both halves a dependent child cuts from a feature branch that lacks its dependency (Lyceum
2728
+ # #112, dispatched alongside #111), or is held forever.
2729
+ st_str "stack-blocked != 'true'" "$DISPATCH_JOB" 'dispatches a child whose blockers have not landed'
2730
+ st 'name: Hold a child whose blockers have not landed' "$ROUTER_YML" 'never holds a child behind the siblings it is blocked by'
2731
+ st 'state merged --base "\$STACK_BRANCH"' "$ROUTER_YML" 'holds a child behind a blocker whose pull request already landed, which nothing releases again'
2732
+ st 'Released #\$child: everything it was blocked by has landed' "$ROUTER_YML" 'holds blocked children but never releases them'
2733
+ fi
2734
+ # The child's pull request targets the ref the run was dispatched on, taken from the run.
2735
+ # Asking the agent for it failed in practice (Lyceum #72, run 38694942046): it emitted no
2736
+ # base, the handler fell back to the default branch, and a bundle cut from the feature
2737
+ # branch would not replay there. Both halves are checked, because a prompt that asks again
2738
+ # would invite an explicit base that disagrees with the ref.
2739
+ st 'base-branch: \$\{\{ github\.ref_name \}\}' "$IMPLEMENT_WORKER_MD" 'leaves the pull request base to the agent instead of the dispatched ref'
2740
+ if grep -qE 'base="feat/N"|pass `base: "feat/N"`' "$IMPLEMENT_WORKER_MD"; then
2741
+ STACK_OK=0
2742
+ echo "FAIL: stacked features prompt asks the agent for the base again; the run already sets it" >&2
2743
+ fi
2744
+ else
2745
+ # check-stack-branch serves only the implement route, so the CLI drops it from a router
2746
+ # that did not install implement. Nothing to assert then, and the section above must not
2747
+ # claim a pass for a job that is legitimately absent.
2748
+ :
2749
+ fi
2750
+
2751
+ # 2. Completion. The merge of a child PR into a feature branch (base != default branch) is
2752
+ # the only event that can mean "one fewer open child". The router block must be idempotent:
2753
+ # the announcement carries <!-- stack-complete --> and a re-run that finds it stays silent.
2754
+ st 'stack-complete' "$ROUTER_YML" 'has no idempotent completion marker on the root pull request'
2755
+ st 'closingIssuesReferences\[0\]\.number // empty' "$ROUTER_YML" 'does not read the child PR to find the closed issue'
2756
+ st 'split-parent: \[0-9\]+' "$ROUTER_YML" 'does not walk from the child to its feature issue'
2757
+ st 'stack-root-pr: \[0-9\]+' "$ROUTER_YML" 'does not read the root pull request number off the feature issue'
2758
+ # gh pr view and gh issue view answer OPEN, not open. An exact lowercase match on their state
2759
+ # never matches, which left every root closed and every child done as far as completion knew.
2760
+ # The child whose pull request just landed is done even if the gate has not closed its issue yet.
2761
+ st '# Landed, though the gate may not have closed its issue yet' "$ROUTER_YML" 'counts the child that just landed as still open, so the stack never reads complete'
2762
+ if grep -nE "(gh pr view|gh issue view)[^|]*--json state[^|]*\| *grep -q?x open" "$ROUTER_YML" >/dev/null; then
2763
+ STACK_OK=0
2764
+ echo "FAIL: stacked features compares a GraphQL state (OPEN) to lowercase open; the root and children never read as open" >&2
2765
+ fi
2766
+
2767
+ # 3. The gate. A child PR that lands on a feature branch closes exactly its own child issue
2768
+ # there (GitHub's closing keywords do not fire off the default branch); the root is held
2769
+ # for a person with the App token, so anybody reading the timeline can replace it. The
2770
+ # marker test on the issue body is what makes both stack-specific and invisible flat.
2771
+ if worker_installed merge-gate; then
2772
+ st 'stack-root-pr: \$PR -->' "$MERGE_GATE_WORKER_MD" 'does not test the feature issue for the root marker'
2773
+ st 'needs\.subject\.outputs\.stack_root == .true.' "$MERGE_GATE_WORKER_MD" 'has no root-hold branch before the merge step'
2774
+ # The stack step reads the subject's PR; with no subject it fails on an empty number.
2775
+ gate_stack_step=$(sed -n '/- name: Detect the pull request.s base and its role in a stack/,/run: |/p' "$MERGE_GATE_WORKER_MD")
2776
+ st_str "if: steps.subject.outputs.found == 'true'" "$gate_stack_step" 'runs the stack step without a subject, so a gate dispatched for a held root fails'
2777
+ st '\*\*Stack root held\.\*\*' "$MERGE_GATE_WORKER_MD" 'posts no human-readable hold explanation on the root'
2778
+ st 'gh issue close "\$ISSUE" --repo "\$REPO" --reason completed \|\| true' "$MERGE_GATE_WORKER_MD" 'does not close the child issue when its PR lands off the default branch'
2779
+ st 'needs\.subject\.outputs\.pr_base != .. \&\& needs\.subject\.outputs\.pr_base != github\.event\.repository\.default_branch' "$MERGE_GATE_WORKER_MD" 'gates the child-issue close on the base not being the default branch'
2780
+ fi
2781
+
2782
+ # 4. The split. Refine creates the feature branch and the held root PR together with the
2783
+ # children, then stamps markers on parent and children before any hand-over: the markers are
2784
+ # what the resolver, the gate and the janitor read, so no new label exists to move. The root
2785
+ # PR body must close the feature issue -- that is what ends the feature when the human merges.
2786
+ if worker_installed refine; then
2787
+ REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
2788
+ st 'allowed-branches: \["feat/\*"\]' "$REFINE_WORKER_MD" 'does not constrain the feature branch name'
2789
+ st 'allowed-base-branches: \["main"\]' "$REFINE_WORKER_MD" 'lets the root target a branch other than the default'
2790
+ st 'preserve-branch-name: true' "$REFINE_WORKER_MD" 'does not preserve the feature branch name on root PR creation'
2791
+ # Refine stages its outputs and applies them itself, and gh-aw opens no pull request while a
2792
+ # worker is staged, not even one marked `staged: false`. The root has to be opened by the
2793
+ # workflow, and the stamp and the hand-over have to read that number, or every child goes flat.
2794
+ st '^ staged: true$' "$REFINE_WORKER_MD" 'no longer stages the body and children for validation before they are applied'
2795
+ st 'name: Open the stack root pull request' "$REFINE_WORKER_MD" 'never opens the root pull request; the staged handler only previews it'
2796
+ st 'PR_NUMBER: \$\{\{ steps\.root\.outputs\.pr_number' "$REFINE_WORKER_MD" 'stamps the stack markers without the root the workflow opened'
2797
+ st 'ROOT_PR: \$\{\{ steps\.root\.outputs\.pr_number' "$REFINE_WORKER_MD" 'hands the children over without the root the workflow opened'
2798
+ st 'stack-root-branch: %s' "$REFINE_WORKER_MD" 'does not stamp the feature branch on the parent issue'
2799
+ st 'Fixes #\$\{\{ inputs\.issue-number \}\}' "$REFINE_WORKER_MD" 'does not make the root PR close the feature issue'
2800
+ # The hand-over stamp: a child handed to implement carries the feature branch marker, so
2801
+ # the resolver reads it off the child body without chasing the chain first.
2802
+ st 'stack-root-branch: %s' "$REFINE_WORKER_MD" 'does not stamp the feature branch on each child at hand-over'
2803
+ # The children have to be found. gh-aw's sanitizer strips HTML comments from agent_output.json,
2804
+ # so the split-parent marker the agent writes never arrives, and staged, nothing links the
2805
+ # children as sub-issues either (Lyceum #45, run 38747143251: three children, none handed over).
2806
+ # The workflow tells the apply which issue the children belong to, and the apply stamps the
2807
+ # marker and links the sub-issue itself.
2808
+ CREATE_ISSUES_ACTION="${HERE}/../create-agent-issues/action.yml"
2809
+ # Dependencies travel as `Blocked by #aw_<id>` lines the apply resolves, never as the
2810
+ # blocked_by field: a value naming two siblings failed the staged preview and threw away a
2811
+ # finished refinement (Lyceum #45, run 38770811334).
2812
+ st 'never set the .blocked_by. field' "$REFINE_WORKER_MD" 'lets the agent express dependencies through blocked_by, which one bad value turns into a lost run'
2813
+ st 'Blocked by #aw_<id>' "$REFINE_WORKER_MD" 'asks for sibling numbers the agent cannot know yet'
2814
+ st 'parent-issue: \$\{\{ needs\.validate_output\.outputs\.outcome == .split.' "$REFINE_WORKER_MD" 'does not tell the apply which issue the split children belong to'
2815
+ st 'issues/\{issue_number\}/sub_issues' "$CREATE_ISSUES_ACTION" 'does not link split children as sub-issues of their parent'
2816
+ st 'split-parent: \$\{parent\}' "$CREATE_ISSUES_ACTION" 'does not stamp the split-parent marker that the sanitizer strips from the agent'
2817
+ # The guard against re-handing a child already handed over: it carries the refined label,
2818
+ # and handing it again would start a second implement on work in flight.
2819
+ st 'grep -qx "\$REFINED_LABEL"' "${WORKFLOWS_DIR}/agent-refine.md" 'does not skip an already-handed-over child'
2820
+ fi
2821
+
2822
+ # 5. Housekeeping. The janitor's stack section is the backstop for every event the router can
2823
+ # miss, and it ran unattended for months before stacks existed. Three of its guardrails are
2824
+ # asserted here the way section 3's are. The marker test fails first on a repository without
2825
+ # stacks, so the flat cost is one regex per open issue.
2826
+ if [ -f "$HOUSEKEEPING_YML" ]; then
2827
+ st 'stack-root-pr: \(\\d\+\)' "$HOUSEKEEPING_YML" 'does not find the feature issues to sweep'
2828
+ st 'implement-pr: \(\\d\+\)' "$HOUSEKEEPING_YML" 'does not read each child pull request number'
2829
+ st "merged_at" "$HOUSEKEEPING_YML" 'does not test whether the child pull request actually landed'
2830
+ # The orphan close: a child whose named PR merged but whose issue never closed is closed
2831
+ # here. Both its closes state a reason, like every other close in the file.
2832
+ if ! grep -qE "github\.rest\.issues\.update\(\{ \.\.\.context\.repo, issue_number: child\.number, state: 'closed', state_reason: 'completed' \}\)" "$HOUSEKEEPING_YML"; then
2833
+ STACK_OK=0
2834
+ echo "FAIL: housekeeping no longer closes an orphaned child whose pull request landed" >&2
2835
+ fi
2836
+ # The flat guarantees. Section 3 must still refuse to close a stack parent (the root PR
2837
+ # closing the feature issue is what ends it, not the sweep), and section 4 must still not
2838
+ # delete a branch whose pull request is open -- an open root PR protects feat/N. The skip
2839
+ # is asserted on its real shape: the marker test inside the issues.some() predicate.
2840
+ st 'stack-root-branch: \\S\+ -->/\.test\(i\.body' "$HOUSEKEEPING_YML" 'no longer skips stack parents in the split-parent close'
2841
+ st "p\.state === 'open'\)\) continue" "$HOUSEKEEPING_YML" 'can delete a feature branch whose root pull request is still open'
2842
+ fi
2843
+
2844
+ # 6. The compat guarantee, stated as absence. A repository without markers sees none of
2845
+ # this: the default path carries no stack condition that could fire. The implement worker's
2846
+ # prompt lines that name the markers are conditional on them being present on the issue.
2847
+ if worker_installed implement; then
2848
+ # The instruction naming the marker exists (the marker-gated base instruction), and the
2849
+ # flat instruction ("propose against main") is still in place alongside it.
2850
+ st 'stack-root-branch: feat/N -->' "$IMPLEMENT_WORKER_MD" 'does not tell stack children to base their PR on the feature branch'
2851
+ st 'allowed-base-branches:' "$IMPLEMENT_WORKER_MD" 'does not declare the base-branch allowlist for create-pull-request'
2852
+ # The base allowlist denies everything the marker test did not name. main stays first and
2853
+ # unconditional; the feature pattern is additive, never a replacement.
2854
+ if [ "$(count -c '^ allowed-base-branches:' "$IMPLEMENT_WORKER_MD")" -ne 1 ]; then
2855
+ STACK_OK=0
2856
+ echo "FAIL: agent-implement.md declares allowed-base-branches more than once or not at all" >&2
2857
+ fi
2858
+ if ! sed -n '/allowed-base-branches:/,/^ [a-z]/p' "$IMPLEMENT_WORKER_MD" | grep -q '"main"'; then
2859
+ STACK_OK=0
2860
+ echo "FAIL: agent-implement.md allowed-base-branches dropped the default branch" >&2
2861
+ fi
2862
+ # The dispatch trigger must stay optional and marker-gated: a flat implement is a
2863
+ # workflow_call, never a dispatch, so the checkout ternary keys off workflow_dispatch.
2864
+ st 'workflow_dispatch' "$IMPLEMENT_WORKER_MD" 'no longer accepts workflow_dispatch'
2865
+ fi
2866
+
2867
+ # 7. No stack label anywhere. Stack semantics live in markers on issues and pull requests;
2868
+ # a label would have to be moved by the App token to raise events, and every new label is a
2869
+ # new consumer-visible surface. Assert the absence of any stack-named label action.
2870
+ if grep -nE "add-label .*$|add-labels$" "$ROUTER_YML" "$MERGE_GATE_WORKER_MD" "${WORKFLOWS_DIR}/agent-refine.md" "$IMPLEMENT_WORKER_MD" 2>/dev/null |
2871
+ grep -qiE 'stack'; then
2872
+ STACK_OK=0
2873
+ echo "FAIL: a workflow adds a stack-named label; stack semantics belong to markers" >&2
2874
+ fi
2875
+
2876
+ if [ "$STACK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2877
+
2665
2878
  echo
2666
2879
  if [ "$FAIL" -eq 0 ]; then
2667
- echo "Route matrix: ${PASS} passed"
2880
+ TcMsg="Route matrix: ${PASS} passed"
2881
+ echo "$TcMsg"
2668
2882
  else
2669
2883
  echo "Route matrix: ${PASS} passed, ${FAIL} FAILED" >&2
2670
2884
  fi
@@ -57,7 +57,7 @@ flowchart TD
57
57
  wTriage("agent-triage")
58
58
  classify -.->|"cron or dispatch"| plumbing
59
59
  plumbing["deterministic jobs<br/>no model runs in any of these"] --> pJobs
60
- pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · detect-pr-conflicts")
60
+ pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · detect-pr-conflicts<br/>check-stack-branch · dispatch-implement")
61
61
  idle(("Idle<br/>run ends, ~10s, every job skipped"))
62
62
 
63
63
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -83,7 +83,7 @@ flowchart LR
83
83
  triage -.->|block| closed(("Closed"))
84
84
  triage -.->|needs-info| author("Author replies") --> triage
85
85
  write("Write+ user<br/>self-labels") --> refine
86
- refine{"refine<br/>estimate in points"} -->|"5 or less"| implement
86
+ refine{"refine<br/>estimate in points"} -->|"3 to 5"| implement
87
87
  refine -->|"8 or more"| split("Split into children") --> refine
88
88
  refine -.->|questions| author
89
89
  implement("implement<br/>branch, PR, closes the issue") --> ci("CI")
@@ -110,6 +110,42 @@ Audit creates work rather than consuming it, and files into `refine` rather than
110
110
  `implement`, so a report of several unrelated findings becomes one properly sized issue per finding
111
111
  instead of one pull request that has to fix them all.
112
112
 
113
+ ### How a feature stacks
114
+
115
+ When refine splits a story that carries real sequencing — a feature whose children build on each
116
+ other — the split can open a stack instead of independent work. The children stay ordinary issues
117
+ walking the normal pipeline above; only where they land changes: each child's pull request targets
118
+ the feature branch rather than the default branch, and one root pull request (spec plus the
119
+ feature's acceptance journey, open from the start) carries the whole feature to `main` at the end.
120
+
121
+ ```mermaid
122
+ flowchart TD
123
+ feat("Feature issue<br/>8+ points, ordered children") --> splitStack("refine split<br/>stamps stack-root-branch + stack-root-pr<br/>on the feature issue") --> rootPr("root pull request on feat/&lt;N&gt;<br/>spec + red journey e2e,<br/>base: main, HELD")
124
+ splitStack --> child1("child T1: implement<br/>PR base: feat/&lt;N&gt;")
125
+ child1 --> merge1("merge-gate<br/>auto-merge into feat/&lt;N&gt;<br/>closes T1") --> child2("child T2, T3 ...<br/>same shape, one at a time")
126
+ child2 --> stacked{"every child landed<br/>and the root is green?"}
127
+ stacked -.->|"no: a bug found"| fixChild("new child issue<br/>mid-stack insertion") --> child2
128
+ stacked -->|yes| announce("stack-complete announcement<br/>on the root pull request")
129
+ announce --> humanStack(("human merges the root<br/>feature issue closes with it"))
130
+ flat("story with no<br/>sequencing") -.->|"no stack markers:<br/>byte-for-byte the old path"| implement
131
+
132
+ classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
133
+ classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
134
+ classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
135
+ classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
136
+ classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
137
+ class feat,flat start
138
+ class splitStack,rootPr,child1,merge1,child2,announce,fixChild action
139
+ class stacked decision
140
+ class humanStack success
141
+ ```
142
+
143
+ The stack exists only in markers on the issues — `stack-root-branch` and `stack-root-pr` on the
144
+ feature issue, `stack-root-branch` stamped on each child at hand-over — so a repository with none
145
+ of them runs exactly the pipeline above and nothing else. The root is never auto-merged: a person
146
+ merges it after every child has landed and the feature branch is green, which is the point where
147
+ the feature's own acceptance journey, not one child's scope, is the thing being verified.
148
+
113
149
  ---
114
150
 
115
151
  ## agent-triage.md
@@ -165,7 +201,8 @@ flowchart TD
165
201
  ## agent-refine.md
166
202
 
167
203
  Decides the size of the work, which is the decision that determines whether it ever lands. An
168
- estimate of 8 or more is split into children of 5 or less, and each child walks the pipeline alone.
204
+ estimate of 8 or more is split into children of 3 to 5 points, and each child walks the pipeline
205
+ alone. A slice smaller than 3 points is a fragment and is folded into a sibling.
169
206
  The refined story is wrapped in the repository's own issue form, and when questions remain the
170
207
  worker leaves a temporal draft on the issue and asks everything in one batched comment.
171
208
 
@@ -188,7 +225,9 @@ flowchart TD
188
225
  refSplit -->|no| refOutcome
189
226
  refSplit -->|"yes, and it splits"| refChildren
190
227
  refSplit -->|"yes, indivisible"| refOutcome
191
- refChildren(("Split<br/>2-6 children, each 5 or less<br/>parent becomes their tracker"))
228
+ refChildren(("Split<br/>2-6 children, each 3 to 5<br/>parent becomes their tracker"))
229
+ refChildren -->|"children build on each other"| refStack
230
+ refStack("Stamp stack-root-branch + stack-root-pr<br/>on the feature issue, and open the<br/>held root pull request on feat/&lt;N&gt;") --> refOutcome
192
231
  refOutcome{"Questions left<br/>for the author?"}
193
232
  refOutcome -->|no| refDone
194
233
  refOutcome -->|yes| refAsk
@@ -203,7 +242,7 @@ flowchart TD
203
242
  classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
204
243
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
205
244
  class refStart start
206
- class refReserve,refFacts,refExplore,refTrivial,refStory,refWrap,refProse action
245
+ class refReserve,refFacts,refExplore,refTrivial,refStory,refWrap,refProse,refStack action
207
246
  class refClassify,refEstimate,refSplit,refOutcome decision
208
247
  class refFail failure
209
248
  class refDone,refAsk,refChildren success
@@ -242,6 +281,9 @@ flowchart TD
242
281
  implIdle(("Idle<br/>closed, or held by future"))
243
282
  implFail(("Parked<br/>review added, a rerun<br/>would fail the same way"))
244
283
 
284
+ implStackStart("check-stack-branch<br/>(router job, rung 1)<br/>the issue carries<br/>stack-root-branch?") -.->|"no: flat mode,<br/>nothing happens"| implIdle
285
+ implStackStart -->|"yes: the workflow dispatches<br/>this worker with ref: feat/N<br/>so the agent never fetches"| implStart
286
+
245
287
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
246
288
  classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
247
289
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
@@ -249,7 +291,7 @@ flowchart TD
249
291
  classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
250
292
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
251
293
  class implStart start
252
- class implReserve,implFacts,implTodos,implCode,implPr action
294
+ class implReserve,implFacts,implTodos,implCode,implPr,implStackStart action
253
295
  class implEligible,implCheck,implVerify,implRetry decision
254
296
  class implIdle idle
255
297
  class implFail failure
@@ -261,6 +303,13 @@ and that used to burn the issue and hand it to a human. A run that worked for ha
261
303
  failed produced an answer that was wrong; repeating it costs the whole fleet the same half hour to
262
304
  be wrong again, so only the short deaths are retried.
263
305
 
306
+ A stacked child enters the same worker through a different door: the router's `check-stack-branch`
307
+ job reads the issue's markers before anything runs, and dispatches the compiled workflow with
308
+ `ref:` set to the feature branch, because the agent sandbox has no credentials to fetch or choose
309
+ a branch itself. The prompt then tells `create_pull_request` its base: the feature branch for a
310
+ stacked child, the default branch for everything else. Nothing about the standard path above
311
+ changes for an issue without the markers.
312
+
264
313
  ---
265
314
 
266
315
  ## agent-merge-gate.md
@@ -287,11 +336,14 @@ flowchart TD
287
336
  gateProtected -->|"measured low, medium or high"| gateAssess
288
337
  gateAssess("Agent (rung 5)<br/>find defects, verify them,<br/>rate recoverability") --> gateDisposition
289
338
  gateDisposition{"Disposition<br/>computed in shell from<br/>the facts and the report"}
290
- gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateMerge
339
+ gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateRootCheck
340
+ gateRootCheck{"PR base is a<br/>stack feature branch<br/>and this PR is the root?"}
341
+ gateRootCheck -->|"child PR: no"| gateMerge
291
342
  gateDisposition -.->|"radius high, protected<br/>or owner path"| gateOwner
292
343
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
293
344
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
294
- gateMerge(("AUTO-MERGE<br/>squash, issue closed,<br/>pr-pending removed"))
345
+ gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
346
+ gateRootCheck -->|"yes: the root"| gateHeld(("HELD<br/>stack root never auto-merges,<br/>a person merges it once every<br/>child has landed and it is green"))
295
347
  gateHuman(("HUMAN REVIEW<br/>review label,<br/>implement label kept"))
296
348
  gateOwner(("OWNER REVIEW<br/>review + owner-review,<br/>CODEOWNERS asked when named"))
297
349
  gateHuman --> gatePreview
@@ -310,12 +362,20 @@ flowchart TD
310
362
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
311
363
  class gateStart start
312
364
  class gateBranch,gateFacts,gateFix,gateAssess,gatePreview action
313
- class gateSubject,gateCi,gateProtected,gateDisposition decision
365
+ class gateSubject,gateCi,gateProtected,gateDisposition,gateRootCheck decision
314
366
  class gateIdle idle
315
367
  class gateHuman,gateOwner,gateBlocked failure
316
368
  class gateMerge,gatePush,gateApproved success
317
369
  ```
318
370
 
371
+ For a child pull request — one whose base is a stack feature branch — everything above is
372
+ unchanged: the risk assessment sees the same diff it always would, and auto-merge means squash
373
+ into the feature branch, closing exactly that child issue. The root pull request is the one
374
+ exception in the whole system: it can be as clean as any merge that ever shipped and the gate
375
+ still will not take it, because a feature is only done when the whole branch is done. AABB holds
376
+ it with an App-token comment and a person takes it from there, after the router's stack-completion
377
+ check has announced that every child landed and the branch is green.
378
+
319
379
  A protected-path match holds the merge but does not stop the repair path: the agent may still fix
320
380
  failed CI on those files, and `conclude` is what refuses to merge them.
321
381
 
@@ -488,7 +548,16 @@ flowchart TB
488
548
  hkRetry --> hkStrand
489
549
 
490
550
  hkStrand("Strands: drop pr-pending<br/>where no open PR closes the issue") --> hkParent("Close a split parent<br/>once every child is closed")
491
- hkParent --> hkBranch{"Branch whose PRs<br/>are all finished<br/>and all bot-authored?"}
551
+ hkParent --> hkStack{"stack-root-pr<br/>marker on the<br/>feature issue?"}
552
+ hkStack -.->|"no: flat mode"| hkBranch
553
+ hkStack -->|"root open"| hkStackWait{"children left,<br/>or the completion<br/>announcement missing?"}
554
+ hkStackWait -->|"children open: mid-stack,<br/>the normal state"| hkBranch
555
+ hkStackWait -->|"all landed, never told"| hkDigest
556
+ hkStackWait -->|"told, but nobody merged for days"| hkDigest
557
+ hkStack -->|"root merged,<br/>feature issue still open"| hkStackClose("close the feature issue,<br/>close child issues whose<br/>PR merged but stayed open")
558
+ hkStack -->|"root closed unmerged"| hkDigest
559
+ hkStackClose --> hkBranch
560
+ hkBranch{"Branch whose PRs<br/>are all finished<br/>and all bot-authored?"}
492
561
  hkBranch -->|yes| hkDelete("Delete the branch")
493
562
  hkBranch -.->|"open PR, human PR,<br/>or the default branch"| hkDigest
494
563
  hkDelete --> hkDigest
@@ -500,8 +569,8 @@ flowchart TB
500
569
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
501
570
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
502
571
  class hkCron start
503
- class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest action
504
- class hkPark,hkWhy,hkBudget,hkBranch decision
572
+ class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose action
573
+ class hkPark,hkWhy,hkBudget,hkBranch,hkStack,hkStackWait decision
505
574
  class hkEnd success
506
575
  ```
507
576
 
@@ -512,6 +581,15 @@ would be a green no-op. Every write goes through one `act()` wrapper, which is t
512
581
  kinds of issue a split parent whose children are all done, and its own digest which the route
513
582
  matrix asserts by counting.
514
583
 
584
+ The stack section extends the same two rules to stacked features without adding a third closed
585
+ kind: a merged child whose issue stayed open is closed exactly like a flat split parent (its work
586
+ landed, the close was lost to a race), and a merged root closes the feature issue the same way
587
+ section 3 closes a split parent. Everything else about a stack is a *report*, never an action:
588
+ mid-stack is the normal state and stays silent; a root that finished but was never announced, one
589
+ held for days after its announcement, a root closed by accident with children open, and a missing
590
+ root pull request each get one line in the digest. No labels move, so nothing the router listens
591
+ for is raised by the sweep itself.
592
+
515
593
  ---
516
594
 
517
595
  ## agentics-app-errors
@@ -342,6 +342,13 @@ for (const file of readdirSync(workflowDirectory)) {
342
342
  // pull request as an issue instead of a pull request). Checking out the branch tip at
343
343
  // execution time makes the commit parent current main; the implement-global queue
344
344
  // already keeps sibling merges out of the window while the agent runs.
345
+ //
346
+ // A stack child dispatches this workflow directly (`gh workflow run
347
+ // agent-implement.lock.yml --ref feat/N`), and there the pin must select the feature
348
+ // branch tip, not main: github.ref_name is the ref the dispatch named. Under
349
+ // workflow_call the callee inherits the caller's event, and the router is only ever
350
+ // dispatched on the default branch, so the dispatch arm of the ternary resolves to
351
+ // main there too -- flat behavior is byte-identical.
345
352
  if (file === "agent-implement.lock.yml") {
346
353
  const start = rewritten.indexOf("\n agent:\n");
347
354
  const rest = start === -1 ? "" : rewritten.slice(start + 1);
@@ -351,12 +358,56 @@ for (const file of readdirSync(workflowDirectory)) {
351
358
  const span = rewritten.slice(start, end).replace(
352
359
  /(uses: actions\/checkout@[^\n]*\n(\s+)with:\n(?:\2 [^\n]+\n)*)/,
353
360
  (whole, _block, indent) =>
354
- whole.includes("ref:") ? whole : whole + indent + " ref: ${{ github.event.repository.default_branch }}\n",
361
+ whole.includes("ref:")
362
+ ? whole
363
+ : whole +
364
+ indent +
365
+ " ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || github.event.repository.default_branch }}\n",
355
366
  );
356
367
  rewritten = rewritten.slice(0, start) + span + rewritten.slice(end);
357
368
  }
358
369
  }
359
370
 
371
+ // update_issue declares no required field, so the safe-outputs MCP server accepts a call
372
+ // carrying only issue_number and answers success. An agent that drops the body - the
373
+ // documented way is omitting the trailing `.` so the bridge never reads stdin - loses its
374
+ // whole story, is told nothing, and goes on to claim completion. add_comment declares body
375
+ // required and rejects the identical mistake, which is how the agent recovers from it on
376
+ // the next turn. Measured on Lyceum before this: one refine run in five wrote its story,
377
+ // the other four ended green with the issue untouched (#44, #59 twice, #62), and Numa #698
378
+ // reached `refined, implement` still carrying its original form body.
379
+ //
380
+ // Only agent-refine calls update_issue, and always to replace the body; labels and status
381
+ // are workflow-owned and the prompt forbids the agent from touching them, so requiring the
382
+ // body costs no legitimate call. The check below fails the compile if this stops matching.
383
+ {
384
+ const toolStart = rewritten.indexOf('"update_issue": {');
385
+ if (toolStart !== -1) {
386
+ const bodyField = rewritten.indexOf('"body": {', toolStart);
387
+ if (bodyField !== -1) {
388
+ const insertAt = bodyField + '"body": {'.length;
389
+ if (!rewritten.slice(insertAt, insertAt + 80).includes('"required": true')) {
390
+ rewritten = rewritten.slice(0, insertAt) + '\n' + ' '.repeat(20) + '"required": true,' + rewritten.slice(insertAt);
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ // The pull request base belongs to the handler, never to the MCP server. agent-implement sets
397
+ // base-branch from the dispatched ref so a stack child lands on its feature branch, and gh-aw
398
+ // writes that base into both configs. The safe-outputs MCP server runs inside the sandbox,
399
+ // where remote refs are not visible, and given a base it insists on a merge-base against
400
+ // origin/<base>: on Lyceum #73 it answered "No remote refs available for merge-base
401
+ // calculation" twice and the run died at max-turns with the code already written (run
402
+ // 38737284343). With no base it patches from the pinned SHA, which the stack dispatch sets to
403
+ // the feature tip, and that is exactly the patch the handler then applies onto the base. So
404
+ // the MCP config loses the base and the handler config keeps it. The check below fails the
405
+ // compile if this stops matching.
406
+ rewritten = rewritten.replace(
407
+ /^([ \t]+GH_AW_SAFE_OUTPUTS_CONFIG: "[^\n]*?)\\"base_branch\\":\\"[^\\"]*\\",/m,
408
+ "$1",
409
+ );
410
+
360
411
  // Guard the two rewrites the compiled agent jobs cannot work without. The anchors are
361
412
  // upstream template text: when a gh-aw upgrade reshapes either step, the silent
362
413
  // failure mode returns (an artifact with no bundle, a skipped push, a green gate on
@@ -365,8 +416,31 @@ for (const file of readdirSync(workflowDirectory)) {
365
416
  { name: "bundle upload glob", ok: /\n( +)- name: Upload agent artifacts\n\1 if: always\(\)\n\1 continue-on-error: true\n\1 uses: actions\/upload-artifact@[^\n]*\n\1 with:\n\1 name: \$\{\{ needs\.activation\.outputs\.artifact_prefix \}\}agent\n\1 path: \|\n(?:\1 [^\n]*\n)*\1 \/tmp\/gh-aw\/aw-\*\.bundle\n/ },
366
417
  { name: "placeholder step intact", ok: /\n( +)- name: Write agent output placeholder if missing\n\1 if: always\(\)\n\1 run: \|\n\1 if \[ ! -f \/tmp\/gh-aw\/agent_output\.json \]; then\n\1 echo '\{"items":\[\]\}' > \/tmp\/gh-aw\/agent_output\.json\n\1 fi\n/ },
367
418
  { name: "failed-jobs reporter disabled", ok: /- name: Report failed jobs\n[ \t]*id: report_failed_jobs\n[ \t]*if: false[^\n]*\n/ },
419
+ // Only agent-refine declares update_issue; the other workers never carry the tool.
420
+ {
421
+ name: "update_issue requires a body",
422
+ ok: lock =>
423
+ !lock.includes('"update_issue": {') ||
424
+ /"update_issue": \{[\s\S]{0,3000}?"body": \{\s*\n\s*"required": true,/.test(lock),
425
+ },
426
+ // The base reaches the handler and never the MCP server; see the rewrite above. Anchored on
427
+ // the escaped quotes because allowed_base_branches contains the bare text base_branch.
428
+ {
429
+ name: "pull request base stays out of the MCP config",
430
+ ok: lock => {
431
+ const mcp = (lock.match(/^[ \t]+GH_AW_SAFE_OUTPUTS_CONFIG: "[^\n]*/m) || [""])[0];
432
+ const handler = (lock.match(/^[ \t]+GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "[^\n]*/m) || [""])[0];
433
+ if (mcp.includes('\\"base_branch\\"')) return false;
434
+ // The handler config keeps the expression for Actions to expand before the job starts;
435
+ // only the MCP config is rewritten to an env reference. Either form is the run's ref.
436
+ return !file.startsWith("agent-implement") ||
437
+ handler.includes('\\"base_branch\\":\\"${{ github.ref_name }}\\"') ||
438
+ handler.includes('\\"base_branch\\":\\"${GITHUB_REF_NAME}\\"');
439
+ },
440
+ },
368
441
  ]) {
369
- if (!check.ok.test(rewritten)) {
442
+ const passed = typeof check.ok === "function" ? check.ok(rewritten) : check.ok.test(rewritten);
443
+ if (!passed) {
370
444
  process.stderr.write(
371
445
  `compile patch "${check.name}" no longer matches in ${file}: the gh-aw template changed. ` +
372
446
  `Re-anchor the rewrite in loops/scripts/compile-agent-workflows.mjs, or agent pushes are silently dropped.\n`,