@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,36 +1,36 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate triage output
3
- description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- issue-number:
9
- description: Issue number that every triage outcome must target.
10
- required: true
11
- outputs:
12
- valid:
13
- description: Whether the output contains a usable triage comment with a verdict.
14
- value: ${{ steps.validate.outputs.valid }}
15
- outcome:
16
- description: "Deterministic triage outcome: pass, needs-info, needs-maintainer, block, or invalid."
17
- value: ${{ steps.validate.outputs.outcome }}
18
- runs:
19
- using: composite
20
- steps:
21
- - name: Validate triage outcome
22
- id: validate
23
- shell: bash
24
- env:
25
- ISSUE_NUMBER: ${{ inputs.issue-number }}
26
- OUTPUT_FILE: ${{ inputs.output-file }}
27
- run: |
28
- set -euo pipefail
29
-
30
- outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
-
34
- if [ "$outcome" = 'invalid' ]; then
35
- echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
- fi
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate triage output
3
+ description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ issue-number:
9
+ description: Issue number that every triage outcome must target.
10
+ required: true
11
+ outputs:
12
+ valid:
13
+ description: Whether the output contains a usable triage comment with a verdict.
14
+ value: ${{ steps.validate.outputs.valid }}
15
+ outcome:
16
+ description: "Deterministic triage outcome: pass, needs-info, needs-maintainer, block, or invalid."
17
+ value: ${{ steps.validate.outputs.outcome }}
18
+ runs:
19
+ using: composite
20
+ steps:
21
+ - name: Validate triage outcome
22
+ id: validate
23
+ shell: bash
24
+ env:
25
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
26
+ OUTPUT_FILE: ${{ inputs.output-file }}
27
+ run: |
28
+ set -euo pipefail
29
+
30
+ outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
+
34
+ if [ "$outcome" = 'invalid' ]; then
35
+ echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
+ fi
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify composite actions
3
- description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Validate composite action manifests
8
- shell: bash
9
- run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify composite actions
3
+ description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Validate composite action manifests
8
+ shell: bash
9
+ run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify refine output validation
3
- description: Exercise deterministic Refine output validation regressions.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Verify refinement outcomes
8
- shell: bash
9
- run: bash "${{ github.action_path }}/verify-refine-output.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify refine output validation
3
+ description: Exercise deterministic Refine output validation regressions.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Verify refinement outcomes
8
+ shell: bash
9
+ run: bash "${{ github.action_path }}/verify-refine-output.sh"
@@ -52,6 +52,23 @@ assert_output 'question with an update is complete' complete \
52
52
  '{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"add_comment","item_number":42,"body":"Which users need this feature?"}]}'
53
53
  assert_output 'question with a blank update is invalid' invalid \
54
54
  '{"items":[{"type":"update_issue","item_number":42,"body":" "},{"type":"add_comment","item_number":42,"body":"Which users need this feature?"}]}'
55
+ # Lyceum #44: the safe-outputs bridge passed 19 bytes, {"issue_number":44}, instead of the
56
+ # 16,175 the agent piped in, so update_issue arrived with no body key at all. The run ended
57
+ # green and the issue parked waiting for an answer to a question nobody asked. A missing key
58
+ # is not the same shape as a blank string, so it is pinned separately from the case above.
59
+ assert_output 'update with no body key is invalid' invalid \
60
+ '{"items":[{"type":"update_issue","item_number":42}]}'
61
+ assert_output 'update with no body key plus a comment is invalid' invalid \
62
+ '{"items":[{"type":"update_issue","item_number":42},{"type":"add_comment","item_number":42,"body":"Refinement complete"}]}'
63
+ # A stack split opens the root pull request alongside its children. Lyceum #45: the agent
64
+ # emitted the parent body, two children and the root, and the validator called it invalid
65
+ # because a pull request was not a source item, so no stack split had ever been accepted.
66
+ assert_output 'stack split with a root pull request is a split' split \
67
+ '{"items":[{"type":"update_issue","item_number":42,"body":"# Parent"},{"type":"create_issue","title":"A","body":"child A"},{"type":"create_issue","title":"B","body":"child B"},{"type":"create_pull_request","title":"Root","body":"Fixes #42","branch":"feat/42"}]}'
68
+ assert_output 'a split with two root pull requests is invalid' invalid \
69
+ '{"items":[{"type":"update_issue","item_number":42,"body":"# Parent"},{"type":"create_issue","title":"A","body":"child A"},{"type":"create_issue","title":"B","body":"child B"},{"type":"create_pull_request","title":"Root","body":"x","branch":"feat/42"},{"type":"create_pull_request","title":"Again","body":"y","branch":"feat/43"}]}'
70
+ assert_output 'a pull request on a complete refinement is invalid' invalid \
71
+ '{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"create_pull_request","title":"Root","body":"x","branch":"feat/42"}]}'
55
72
  assert_output 'wrong issue output is invalid' invalid \
56
73
  '{"items":[{"type":"add_comment","item_number":7,"body":"Which users need this feature?"}]}'
57
74
  assert_output 'complete output cannot update another issue' invalid \
@@ -1,9 +1,9 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Verify route matrix
3
- description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
4
- runs:
5
- using: composite
6
- steps:
7
- - name: Run route matrix verification
8
- shell: bash
9
- run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Verify route matrix
3
+ description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Run route matrix verification
8
+ shell: bash
9
+ run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
@@ -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
@@ -2334,6 +2336,16 @@ if worker_installed refine; then
2334
2336
  echo "FAIL: the refine refusal never releases bot-working; a refused issue stays reserved with no run behind it" >&2
2335
2337
  fi
2336
2338
 
2339
+ # The split floor: a split child carries a whole stack's machinery (issue, feature branch,
2340
+ # PR into the branch), so a 1- or 2-point slice is a fragment and must be folded into a
2341
+ # sibling instead. The floor must be pinned through the env var the prompt interpolates,
2342
+ # so a consumer cannot lower it below 3 without editing the worker.
2343
+ if ! grep -qF 'MIN_CHILD_POINTS: "3"' "$REFINE_WORKER_MD" \
2344
+ || ! grep -qF 'child estimated below ${{ env.MIN_CHILD_POINTS }}' "$REFINE_WORKER_MD"; then
2345
+ SIZE_GATE_OK=0
2346
+ echo "FAIL: refine allows split children below 3 points; the floor must stay interpolated from MIN_CHILD_POINTS" >&2
2347
+ fi
2348
+
2337
2349
  if [ "$SIZE_GATE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2338
2350
  fi
2339
2351
 
@@ -2620,8 +2632,10 @@ if [ -f "$APP_ERRORS_MJS" ]; then
2620
2632
  # 3. The field allowlist is what actually holds the line, because a redaction rule filters
2621
2633
  # values and cannot see a field somebody adds next year. Compared as a set, so reordering is
2622
2634
  # 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"
2635
+ # telemetry and joins to a conversation and to a person. category, fingerprint, message and
2636
+ # topFrames joined in v0.28.7 to enrich the filed issues (a84928a); they are set-side
2637
+ # fields, never engine telemetry, so the allowlist grows with them.
2638
+ expected_fields="category exceptionType fingerprint firstSeen frames lastSeen message occurrences operationName operations problemId roleName topFrames"
2625
2639
  actual_fields="$(sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" |
2626
2640
  grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort | tr '\n' ' ' | sed 's/ $//')"
2627
2641
  if [ "$actual_fields" = "$(echo "$expected_fields" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')" ]; then
@@ -2662,9 +2676,202 @@ if [ -f "$APP_ERRORS_MJS" ]; then
2662
2676
  if [ "$AE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2663
2677
  fi
2664
2678
 
2679
+ echo "── Stacked features ──────────────────────────────────────────────────────"
2680
+
2681
+ # Stacks are delivered by marker, not by label: every flat-mode rule still has to hold on a
2682
+ # repository that has never seen a marker. The assertions here are grouped as the two halves
2683
+ # of that contract. The first says the stack machinery exists and fails closed: an unreadable
2684
+ # or missing marker must route a child down the flat path, never dispatch at a branch nobody
2685
+ # resolved. The second says the flat path never learned about stacks: the default-path jobs
2686
+ # and prompts carry no stack condition that could fire on an issue without markers.
2687
+ STACK_OK=1
2688
+ st() {
2689
+ grep -qE "$1" "$2" || { STACK_OK=0; echo "FAIL: stacked features ${3}" >&2; }
2690
+ }
2691
+ # The router's stack job is a block of a derived file, not a file of its own, so its
2692
+ # assertions take the block on stdin: handing a multi-line string to grep as a filename
2693
+ # fails with "File name too long" and reads as a wiring failure that is not one.
2694
+ st_str() {
2695
+ printf '%s' "$2" | grep -qE "$1" || { STACK_OK=0; echo "FAIL: stacked features ${3}" >&2; }
2696
+ }
2697
+
2698
+ # 1. The resolver. check-stack-branch reads the child's own marker first, then the
2699
+ # split-parent chain; both are `|| true` so a flat body (no match) cannot fail the job --
2700
+ # call-implement names this job in its needs, so a hard failure would block flat implements.
2701
+ CHECK_STACK_JOB=$(sed -n '/^ check-stack-branch:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
2702
+ if [ -n "$CHECK_STACK_JOB" ]; then
2703
+ st "stack-root-branch: \[\\^ \]\+" "$ROUTER_YML" 'has no marker regex for the feature branch'
2704
+ st_str 'exit 0' "$CHECK_STACK_JOB" 'resolver hard-fails instead of taking the flat path'
2705
+ st_str 'sed .s\|/\|%2F\|g.' "$CHECK_STACK_JOB" 'does not URL-escape the stack branch before the existence check'
2706
+ st_str 'sed -E .s/<!-- stack-root-branch: \(\[\^ \]\+\) -->/\\1/.' "$CHECK_STACK_JOB" 'does not strip the marker syntax off the branch'
2707
+ # The dupe guard: only dispatched stack implements run with head_branch = feat/N, so a
2708
+ # workflow_call (flat) run whose head_branch is the default branch can never match it.
2709
+ st_str 'head_branch == ..\$\{STACK_BRANCH\}..' "$CHECK_STACK_JOB" 'does not detect an in-flight implement on the feature branch'
2710
+ # Dispatch happens on the resolved ref, never on the event SHA. The `gh workflow run`
2711
+ # lives in dispatch-implement, which check-stack-branch gates: the two jobs verify the
2712
+ # branch exists, then hand off. The run needs a fresh App token -- GITHUB_TOKEN can
2713
+ # start no workflow -- and it reads repo-level secrets because a dispatch cannot forward
2714
+ # workflow-call ones, which is the activation prerequisite ownership.md documents.
2715
+ DISPATCH_JOB=$(sed -n '/^ dispatch-implement:$/,/^ [a-z0-9_-]*:$/p' "$ROUTER_YML")
2716
+ if [ -n "$DISPATCH_JOB" ]; then
2717
+ st_str 'gh workflow run agent-implement\.lock\.yml .* --ref "\$STACK_BRANCH"' "$DISPATCH_JOB" 'does not dispatch the stack implement at all'
2718
+ st_str 'create-github-app-token' "$DISPATCH_JOB" 'dispatches with GITHUB_TOKEN, which can start no run'
2719
+ # A child blocked by an open sibling is held, and the sibling's landing releases it. Without
2720
+ # both halves a dependent child cuts from a feature branch that lacks its dependency (Lyceum
2721
+ # #112, dispatched alongside #111), or is held forever.
2722
+ st_str "stack-blocked != 'true'" "$DISPATCH_JOB" 'dispatches a child whose blockers have not landed'
2723
+ st 'name: Hold a child whose blockers have not landed' "$ROUTER_YML" 'never holds a child behind the siblings it is blocked by'
2724
+ st 'state merged --base "\$STACK_BRANCH"' "$ROUTER_YML" 'holds a child behind a blocker whose pull request already landed, which nothing releases again'
2725
+ st 'Released #\$child: everything it was blocked by has landed' "$ROUTER_YML" 'holds blocked children but never releases them'
2726
+ fi
2727
+ # The child's pull request targets the ref the run was dispatched on, taken from the run.
2728
+ # Asking the agent for it failed in practice (Lyceum #72, run 38694942046): it emitted no
2729
+ # base, the handler fell back to the default branch, and a bundle cut from the feature
2730
+ # branch would not replay there. Both halves are checked, because a prompt that asks again
2731
+ # would invite an explicit base that disagrees with the ref.
2732
+ st 'base-branch: \$\{\{ github\.ref_name \}\}' "$IMPLEMENT_WORKER_MD" 'leaves the pull request base to the agent instead of the dispatched ref'
2733
+ if grep -qE 'base="feat/N"|pass `base: "feat/N"`' "$IMPLEMENT_WORKER_MD"; then
2734
+ STACK_OK=0
2735
+ echo "FAIL: stacked features prompt asks the agent for the base again; the run already sets it" >&2
2736
+ fi
2737
+ else
2738
+ # check-stack-branch serves only the implement route, so the CLI drops it from a router
2739
+ # that did not install implement. Nothing to assert then, and the section above must not
2740
+ # claim a pass for a job that is legitimately absent.
2741
+ :
2742
+ fi
2743
+
2744
+ # 2. Completion. The merge of a child PR into a feature branch (base != default branch) is
2745
+ # the only event that can mean "one fewer open child". The router block must be idempotent:
2746
+ # the announcement carries <!-- stack-complete --> and a re-run that finds it stays silent.
2747
+ st 'stack-complete' "$ROUTER_YML" 'has no idempotent completion marker on the root pull request'
2748
+ st 'closingIssuesReferences\[0\]\.number // empty' "$ROUTER_YML" 'does not read the child PR to find the closed issue'
2749
+ st 'split-parent: \[0-9\]+' "$ROUTER_YML" 'does not walk from the child to its feature issue'
2750
+ st 'stack-root-pr: \[0-9\]+' "$ROUTER_YML" 'does not read the root pull request number off the feature issue'
2751
+ # gh pr view and gh issue view answer OPEN, not open. An exact lowercase match on their state
2752
+ # never matches, which left every root closed and every child done as far as completion knew.
2753
+ # The child whose pull request just landed is done even if the gate has not closed its issue yet.
2754
+ 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'
2755
+ if grep -nE "(gh pr view|gh issue view)[^|]*--json state[^|]*\| *grep -q?x open" "$ROUTER_YML" >/dev/null; then
2756
+ STACK_OK=0
2757
+ echo "FAIL: stacked features compares a GraphQL state (OPEN) to lowercase open; the root and children never read as open" >&2
2758
+ fi
2759
+
2760
+ # 3. The gate. A child PR that lands on a feature branch closes exactly its own child issue
2761
+ # there (GitHub's closing keywords do not fire off the default branch); the root is held
2762
+ # for a person with the App token, so anybody reading the timeline can replace it. The
2763
+ # marker test on the issue body is what makes both stack-specific and invisible flat.
2764
+ if worker_installed merge-gate; then
2765
+ st 'stack-root-pr: \$PR -->' "$MERGE_GATE_WORKER_MD" 'does not test the feature issue for the root marker'
2766
+ st 'needs\.subject\.outputs\.stack_root == .true.' "$MERGE_GATE_WORKER_MD" 'has no root-hold branch before the merge step'
2767
+ # The stack step reads the subject's PR; with no subject it fails on an empty number.
2768
+ gate_stack_step=$(sed -n '/- name: Detect the pull request.s base and its role in a stack/,/run: |/p' "$MERGE_GATE_WORKER_MD")
2769
+ 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'
2770
+ st '\*\*Stack root held\.\*\*' "$MERGE_GATE_WORKER_MD" 'posts no human-readable hold explanation on the root'
2771
+ 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'
2772
+ 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'
2773
+ fi
2774
+
2775
+ # 4. The split. Refine creates the feature branch and the held root PR together with the
2776
+ # children, then stamps markers on parent and children before any hand-over: the markers are
2777
+ # what the resolver, the gate and the janitor read, so no new label exists to move. The root
2778
+ # PR body must close the feature issue -- that is what ends the feature when the human merges.
2779
+ if worker_installed refine; then
2780
+ REFINE_WORKER_MD="${WORKFLOWS_DIR}/agent-refine.md"
2781
+ st 'allowed-branches: \["feat/\*"\]' "$REFINE_WORKER_MD" 'does not constrain the feature branch name'
2782
+ st 'allowed-base-branches: \["main"\]' "$REFINE_WORKER_MD" 'lets the root target a branch other than the default'
2783
+ st 'preserve-branch-name: true' "$REFINE_WORKER_MD" 'does not preserve the feature branch name on root PR creation'
2784
+ # Refine stages its outputs and applies them itself, and gh-aw opens no pull request while a
2785
+ # worker is staged, not even one marked `staged: false`. The root has to be opened by the
2786
+ # workflow, and the stamp and the hand-over have to read that number, or every child goes flat.
2787
+ st '^ staged: true$' "$REFINE_WORKER_MD" 'no longer stages the body and children for validation before they are applied'
2788
+ st 'name: Open the stack root pull request' "$REFINE_WORKER_MD" 'never opens the root pull request; the staged handler only previews it'
2789
+ st 'PR_NUMBER: \$\{\{ steps\.root\.outputs\.pr_number' "$REFINE_WORKER_MD" 'stamps the stack markers without the root the workflow opened'
2790
+ st 'ROOT_PR: \$\{\{ steps\.root\.outputs\.pr_number' "$REFINE_WORKER_MD" 'hands the children over without the root the workflow opened'
2791
+ st 'stack-root-branch: %s' "$REFINE_WORKER_MD" 'does not stamp the feature branch on the parent issue'
2792
+ st 'Fixes #\$\{\{ inputs\.issue-number \}\}' "$REFINE_WORKER_MD" 'does not make the root PR close the feature issue'
2793
+ # The hand-over stamp: a child handed to implement carries the feature branch marker, so
2794
+ # the resolver reads it off the child body without chasing the chain first.
2795
+ st 'stack-root-branch: %s' "$REFINE_WORKER_MD" 'does not stamp the feature branch on each child at hand-over'
2796
+ # The children have to be found. gh-aw's sanitizer strips HTML comments from agent_output.json,
2797
+ # so the split-parent marker the agent writes never arrives, and staged, nothing links the
2798
+ # children as sub-issues either (Lyceum #45, run 38747143251: three children, none handed over).
2799
+ # The workflow tells the apply which issue the children belong to, and the apply stamps the
2800
+ # marker and links the sub-issue itself.
2801
+ CREATE_ISSUES_ACTION="${HERE}/../create-agent-issues/action.yml"
2802
+ # Dependencies travel as `Blocked by #aw_<id>` lines the apply resolves, never as the
2803
+ # blocked_by field: a value naming two siblings failed the staged preview and threw away a
2804
+ # finished refinement (Lyceum #45, run 38770811334).
2805
+ 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'
2806
+ st 'Blocked by #aw_<id>' "$REFINE_WORKER_MD" 'asks for sibling numbers the agent cannot know yet'
2807
+ st 'parent-issue: \$\{\{ needs\.validate_output\.outputs\.outcome == .split.' "$REFINE_WORKER_MD" 'does not tell the apply which issue the split children belong to'
2808
+ st 'issues/\{issue_number\}/sub_issues' "$CREATE_ISSUES_ACTION" 'does not link split children as sub-issues of their parent'
2809
+ st 'split-parent: \$\{parent\}' "$CREATE_ISSUES_ACTION" 'does not stamp the split-parent marker that the sanitizer strips from the agent'
2810
+ # The guard against re-handing a child already handed over: it carries the refined label,
2811
+ # and handing it again would start a second implement on work in flight.
2812
+ st 'grep -qx "\$REFINED_LABEL"' "${WORKFLOWS_DIR}/agent-refine.md" 'does not skip an already-handed-over child'
2813
+ fi
2814
+
2815
+ # 5. Housekeeping. The janitor's stack section is the backstop for every event the router can
2816
+ # miss, and it ran unattended for months before stacks existed. Three of its guardrails are
2817
+ # asserted here the way section 3's are. The marker test fails first on a repository without
2818
+ # stacks, so the flat cost is one regex per open issue.
2819
+ if [ -f "$HOUSEKEEPING_YML" ]; then
2820
+ st 'stack-root-pr: \(\\d\+\)' "$HOUSEKEEPING_YML" 'does not find the feature issues to sweep'
2821
+ st 'implement-pr: \(\\d\+\)' "$HOUSEKEEPING_YML" 'does not read each child pull request number'
2822
+ st "merged_at" "$HOUSEKEEPING_YML" 'does not test whether the child pull request actually landed'
2823
+ # The orphan close: a child whose named PR merged but whose issue never closed is closed
2824
+ # here. Both its closes state a reason, like every other close in the file.
2825
+ if ! grep -qE "github\.rest\.issues\.update\(\{ \.\.\.context\.repo, issue_number: child\.number, state: 'closed', state_reason: 'completed' \}\)" "$HOUSEKEEPING_YML"; then
2826
+ STACK_OK=0
2827
+ echo "FAIL: housekeeping no longer closes an orphaned child whose pull request landed" >&2
2828
+ fi
2829
+ # The flat guarantees. Section 3 must still refuse to close a stack parent (the root PR
2830
+ # closing the feature issue is what ends it, not the sweep), and section 4 must still not
2831
+ # delete a branch whose pull request is open -- an open root PR protects feat/N. The skip
2832
+ # is asserted on its real shape: the marker test inside the issues.some() predicate.
2833
+ st 'stack-root-branch: \\S\+ -->/\.test\(i\.body' "$HOUSEKEEPING_YML" 'no longer skips stack parents in the split-parent close'
2834
+ st "p\.state === 'open'\)\) continue" "$HOUSEKEEPING_YML" 'can delete a feature branch whose root pull request is still open'
2835
+ fi
2836
+
2837
+ # 6. The compat guarantee, stated as absence. A repository without markers sees none of
2838
+ # this: the default path carries no stack condition that could fire. The implement worker's
2839
+ # prompt lines that name the markers are conditional on them being present on the issue.
2840
+ if worker_installed implement; then
2841
+ # The instruction naming the marker exists (the marker-gated base instruction), and the
2842
+ # flat instruction ("propose against main") is still in place alongside it.
2843
+ st 'stack-root-branch: feat/N -->' "$IMPLEMENT_WORKER_MD" 'does not tell stack children to base their PR on the feature branch'
2844
+ st 'allowed-base-branches:' "$IMPLEMENT_WORKER_MD" 'does not declare the base-branch allowlist for create-pull-request'
2845
+ # The base allowlist denies everything the marker test did not name. main stays first and
2846
+ # unconditional; the feature pattern is additive, never a replacement.
2847
+ if [ "$(count -c '^ allowed-base-branches:' "$IMPLEMENT_WORKER_MD")" -ne 1 ]; then
2848
+ STACK_OK=0
2849
+ echo "FAIL: agent-implement.md declares allowed-base-branches more than once or not at all" >&2
2850
+ fi
2851
+ if ! sed -n '/allowed-base-branches:/,/^ [a-z]/p' "$IMPLEMENT_WORKER_MD" | grep -q '"main"'; then
2852
+ STACK_OK=0
2853
+ echo "FAIL: agent-implement.md allowed-base-branches dropped the default branch" >&2
2854
+ fi
2855
+ # The dispatch trigger must stay optional and marker-gated: a flat implement is a
2856
+ # workflow_call, never a dispatch, so the checkout ternary keys off workflow_dispatch.
2857
+ st 'workflow_dispatch' "$IMPLEMENT_WORKER_MD" 'no longer accepts workflow_dispatch'
2858
+ fi
2859
+
2860
+ # 7. No stack label anywhere. Stack semantics live in markers on issues and pull requests;
2861
+ # a label would have to be moved by the App token to raise events, and every new label is a
2862
+ # new consumer-visible surface. Assert the absence of any stack-named label action.
2863
+ if grep -nE "add-label .*$|add-labels$" "$ROUTER_YML" "$MERGE_GATE_WORKER_MD" "${WORKFLOWS_DIR}/agent-refine.md" "$IMPLEMENT_WORKER_MD" 2>/dev/null |
2864
+ grep -qiE 'stack'; then
2865
+ STACK_OK=0
2866
+ echo "FAIL: a workflow adds a stack-named label; stack semantics belong to markers" >&2
2867
+ fi
2868
+
2869
+ if [ "$STACK_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2870
+
2665
2871
  echo
2666
2872
  if [ "$FAIL" -eq 0 ]; then
2667
- echo "Route matrix: ${PASS} passed"
2873
+ TcMsg="Route matrix: ${PASS} passed"
2874
+ echo "$TcMsg"
2668
2875
  else
2669
2876
  echo "Route matrix: ${PASS} passed, ${FAIL} FAILED" >&2
2670
2877
  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