Skip to content

Commit f43db19

Browse files
authored
Merge pull request #358 from PyAutoLabs/claude/pyautomind-pr-automation-lcu0s5
feat(mind): auto-merge ledger branches, so Mind's own work stops stranding
2 parents fcb0ae3 + 92cbce2 commit f43db19

6 files changed

Lines changed: 659 additions & 0 deletions

File tree

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
name: Mind Ledger Merge
2+
3+
# Lands PyAutoMind's own ledger work without anyone having to ask for it.
4+
#
5+
# THE PROBLEM. A branch-scoped session — the phone, claude.ai/code, any
6+
# `claude/**` flow — pushes its Mind changes to a feature branch and they stop
7+
# there. `prompt_sync.sh` pushes HEAD on purpose (a cloud session must not push
8+
# straight to main), but nothing downstream moves the branch on: no workflow so
9+
# much as *looks* at a `claude/**` push, because lifecycle_drift,
10+
# dashboard_refresh, firewall_gate and spawn_drift all trigger on `push: main`
11+
# or `pull_request` only. So a filed prompt, a shipped task moved to
12+
# `complete/`, a regenerated dashboard — all of it waits for a human to write an
13+
# explicit "merge that branch" prompt, and the dashboard renders a stale
14+
# backlog until they do.
15+
#
16+
# WHY A WORKFLOW AND NOT THE SESSION. branch_sweep.yml's reasoning, again: a
17+
# workflow's GITHUB_TOKEN is a *different* credential from the session's, and
18+
# this repo already trusts it with `contents: write` (dashboard_refresh.yml
19+
# commits to main with it). Running the merge here means the ledger lands from
20+
# any surface that can push — including a chat on a phone — and it lands
21+
# whether or not the session that pushed it ever comes back.
22+
#
23+
# WHAT IT WILL AND WILL NOT MERGE. Only a branch whose whole diff is *ledger*:
24+
# `draft/`, `active/`, `complete/`, the root registry files and the generated
25+
# dashboard pages. The line is drawn by scripts/ledger_merge.py — a script, so
26+
# it is testable and a session can predict the verdict — and it is DEFAULT
27+
# DENY: anything under `scripts/`, `tests/`, `.github/`, `skills/`, `policy/`,
28+
# `docs/`, `repos.yaml`, the prose pages, or any path nobody has classified,
29+
# stops the merge and leaves the branch for a human. This workflow's own file
30+
# is on the code side of that line, which is the intended self-consistency: it
31+
# cannot merge a change to itself.
32+
#
33+
# NOT A REVIEW BYPASS. Review adds nothing to a moved prompt file; it adds
34+
# everything to a changed script. The gate encodes precisely that distinction
35+
# and nothing else.
36+
37+
on:
38+
push:
39+
branches: ["claude/**"]
40+
workflow_dispatch:
41+
inputs:
42+
branch:
43+
description: "Branch to judge (default: the caller's ref)"
44+
type: string
45+
default: ""
46+
mode:
47+
description: "audit = classify and report only · merge = land it"
48+
type: choice
49+
options: [audit, merge]
50+
default: audit
51+
delete_branch:
52+
description: "Delete the branch after a direct (non-PR) merge"
53+
type: boolean
54+
default: true
55+
56+
# contents: write → the merge push to main and the branch delete.
57+
# pull-requests: write → merge through an open PR when the branch has one, so
58+
# the PR records as MERGED rather than being orphaned by a direct push.
59+
# actions: write → re-dispatch the self-healing workflows on main. A push made
60+
# with GITHUB_TOKEN triggers nothing, so without this the generated pages
61+
# stay stale until the nightly cron (dashboard_refresh.yml learned this the
62+
# hard way on 2026-08-21).
63+
permissions:
64+
contents: write
65+
pull-requests: write
66+
actions: write
67+
68+
# Every run of this workflow ends in a push to main, so runs must not overlap:
69+
# two branches merging concurrently would race, and the loser would spend its
70+
# three attempts re-merging. Queue rather than cancel — a cancelled run can
71+
# leave a branch merged and undeleted.
72+
concurrency:
73+
group: mind-ledger-merge
74+
cancel-in-progress: false
75+
76+
jobs:
77+
merge:
78+
# Never in a fork or a spawned template: this job writes to main, and an
79+
# adopter re-adds that deliberately (spawn.py DROPs this file for the same
80+
# reason it DROPs dashboard_refresh.yml).
81+
if: github.repository == 'PyAutoLabs/PyAutoMind' && github.event.deleted != true
82+
runs-on: ubuntu-latest
83+
steps:
84+
- name: Resolve the branch
85+
id: target
86+
run: |
87+
BRANCH="${{ inputs.branch }}"
88+
[ -n "$BRANCH" ] || BRANCH="${{ github.ref_name }}"
89+
case "$BRANCH" in
90+
main|"") echo "::error::refusing to operate on '$BRANCH'"; exit 1 ;;
91+
archive/condemned/*) echo "::error::'$BRANCH' is a Gut transit ref"; exit 1 ;;
92+
esac
93+
MODE="${{ inputs.mode }}"
94+
# A push is the automatic door and merges; the manual door defaults to
95+
# audit, so a human dispatching it to look never merges by accident.
96+
[ "${{ github.event_name }}" = "push" ] && MODE="merge"
97+
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
98+
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
99+
echo "Branch: \`$BRANCH\` · mode: \`$MODE\`" >> "$GITHUB_STEP_SUMMARY"
100+
101+
- uses: actions/checkout@v4
102+
with:
103+
ref: ${{ steps.target.outputs.branch }}
104+
# Full history: the classifier diffs against the merge base with main,
105+
# and `merge-base --is-ancestor` cannot answer on a shallow clone —
106+
# it reports "not an ancestor" for ancestry that is merely absent.
107+
fetch-depth: 0
108+
path: PyAutoMind
109+
110+
# The dashboard renderer lives with the intake conductor, not here — the
111+
# Mind holds the state, the Brain reasons over it (ORGANISM.md).
112+
- uses: actions/checkout@v4
113+
with:
114+
repository: PyAutoLabs/PyAutoBrain
115+
path: PyAutoBrain
116+
117+
- name: Is there anything to merge?
118+
id: ahead
119+
working-directory: PyAutoMind
120+
run: |
121+
git fetch --quiet origin main
122+
HEAD_SHA=$(git rev-parse HEAD)
123+
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
124+
if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then
125+
echo "already in main — nothing to merge" >> "$GITHUB_STEP_SUMMARY"
126+
echo "merged=already" >> "$GITHUB_OUTPUT"
127+
else
128+
echo "merged=no" >> "$GITHUB_OUTPUT"
129+
fi
130+
131+
- name: Classify the diff — ledger, or code?
132+
id: classify
133+
if: steps.ahead.outputs.merged == 'no'
134+
working-directory: PyAutoMind
135+
run: |
136+
set +e
137+
OUT=$(python3 scripts/ledger_merge.py classify --base origin/main)
138+
RC=$?
139+
set -e
140+
echo "$OUT"
141+
{
142+
echo ""
143+
echo "<details><summary>Classification</summary>"
144+
echo ""
145+
echo '```'
146+
echo "$OUT"
147+
echo '```'
148+
echo ""
149+
echo "</details>"
150+
} >> "$GITHUB_STEP_SUMMARY"
151+
# Exit 2 is the gate failing to run, which is not "a human's turn" —
152+
# it is a broken gate, and it must fail the job rather than read as a
153+
# quiet decline to merge.
154+
if [ "$RC" -eq 2 ]; then
155+
echo "::error::ledger_merge.py could not classify the diff"
156+
exit 1
157+
fi
158+
if [ "$RC" -ne 0 ]; then
159+
echo "verdict=code" >> "$GITHUB_OUTPUT"
160+
echo "" >> "$GITHUB_STEP_SUMMARY"
161+
echo "**Left for a human** — this branch changes code, not just the ledger." >> "$GITHUB_STEP_SUMMARY"
162+
else
163+
echo "verdict=ledger" >> "$GITHUB_OUTPUT"
164+
fi
165+
166+
# Structural drift BLOCKS. `lifecycle.py check` catches a real
167+
# inconsistency — a prompt in `active/` with no `active.md` entry, a
168+
# record whose front matter contradicts the ledger — and nothing heals
169+
# that automatically. Merging it would put the contradiction on main.
170+
- name: Structural drift (blocking)
171+
if: steps.classify.outputs.verdict == 'ledger'
172+
working-directory: PyAutoMind
173+
run: python3 scripts/lifecycle.py check
174+
175+
# Generated-page staleness DOES NOT block. `complete/index.md`, the
176+
# registry contents blocks and the dashboard pages are all renders with
177+
# self-healing workflows on main, so a stale render is a reason to heal
178+
# after the merge, never a reason to strand the ledger. Reported, not
179+
# enforced.
180+
- name: Generated-page freshness (reported, healed after the merge)
181+
if: steps.classify.outputs.verdict == 'ledger'
182+
continue-on-error: true
183+
working-directory: PyAutoMind
184+
run: |
185+
STALE=""
186+
python3 scripts/lifecycle.py index --check || STALE="$STALE complete/index.md"
187+
python3 scripts/registry_toc.py --check || STALE="$STALE registry-contents"
188+
python3 ../PyAutoBrain/agents/conductors/intake/_intake.py --mind . dashboard --check \
189+
|| STALE="$STALE dashboard"
190+
if [ -n "$STALE" ]; then
191+
echo "stale renders (will be healed on main):$STALE" >> "$GITHUB_STEP_SUMMARY"
192+
fi
193+
194+
- name: Merge into main
195+
id: merge
196+
if: steps.classify.outputs.verdict == 'ledger' && steps.target.outputs.mode == 'merge'
197+
working-directory: PyAutoMind
198+
env:
199+
GH_TOKEN: ${{ github.token }}
200+
BRANCH: ${{ steps.target.outputs.branch }}
201+
HEAD_SHA: ${{ steps.ahead.outputs.head_sha }}
202+
run: |
203+
git config user.name "github-actions[bot]"
204+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
205+
206+
# An open PR is merged THROUGH the PR, so it records as MERGED and
207+
# its review thread closes properly. A direct push would land the
208+
# same commits and leave the PR reading "closed", which is a lie
209+
# about what happened to the work.
210+
PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
211+
if [ -n "$PR" ]; then
212+
echo "open PR #$PR on this branch — merging through it"
213+
gh pr merge "$PR" --merge
214+
echo "via=pr#$PR" >> "$GITHUB_OUTPUT"
215+
echo "Merged **#$PR** (\`$BRANCH\` → main)." >> "$GITHUB_STEP_SUMMARY"
216+
exit 0
217+
fi
218+
219+
# No PR: merge the ref directly. Each attempt rebuilds on the current
220+
# tip of main, so a concurrent push never needs a rebase — the same
221+
# convergence the self-heal loops use.
222+
for attempt in 1 2 3; do
223+
git fetch --quiet origin main
224+
if git merge-base --is-ancestor "$HEAD_SHA" origin/main; then
225+
echo "landed by a concurrent run"
226+
echo "via=concurrent" >> "$GITHUB_OUTPUT"
227+
exit 0
228+
fi
229+
git checkout -B _ledger_merge origin/main
230+
if ! git merge --no-ff "$HEAD_SHA" \
231+
-m "mind: auto-merge ledger branch $BRANCH" \
232+
-m "Ledger-only diff (scripts/ledger_merge.py), structural checks clean." ; then
233+
git merge --abort || true
234+
echo "::error::'$BRANCH' conflicts with main — resolve it by hand"
235+
echo "**Conflict** — \`$BRANCH\` does not merge cleanly; left for a human." >> "$GITHUB_STEP_SUMMARY"
236+
exit 1
237+
fi
238+
if git push origin HEAD:main; then
239+
echo "merged on attempt $attempt"
240+
echo "via=direct" >> "$GITHUB_OUTPUT"
241+
echo "Merged \`$BRANCH\` → main (no PR; direct merge commit)." >> "$GITHUB_STEP_SUMMARY"
242+
exit 0
243+
fi
244+
echo "push rejected (attempt $attempt) — retrying on the new tip of main"
245+
done
246+
echo "::error::could not push the merge after 3 attempts"
247+
exit 1
248+
249+
# A PR merge deletes its own head where the repo has "Automatically
250+
# delete head branches" on; a DIRECT merge has no such hook, which is how
251+
# 188 provably-spent branches piled up before branch_sweep.yml. Delete it
252+
# here, on the same proof branch_sweep uses — main must actually contain
253+
# the head sha — so a failed or partial merge never loses a branch.
254+
- name: Delete the merged branch
255+
if: steps.merge.outputs.via != '' && inputs.delete_branch != false
256+
working-directory: PyAutoMind
257+
env:
258+
BRANCH: ${{ steps.target.outputs.branch }}
259+
HEAD_SHA: ${{ steps.ahead.outputs.head_sha }}
260+
run: |
261+
git fetch --quiet origin main
262+
if ! git merge-base --is-ancestor "$HEAD_SHA" origin/main; then
263+
echo "::warning::main does not contain $HEAD_SHA — keeping '$BRANCH'"
264+
exit 0
265+
fi
266+
if git push origin --delete "$BRANCH"; then
267+
echo "Deleted \`$BRANCH\`." >> "$GITHUB_STEP_SUMMARY"
268+
else
269+
# Already gone (a PR merge with auto-delete on) is the common case
270+
# and is not a failure — branch_sweep.yml collects anything else.
271+
echo "::warning::could not delete '$BRANCH' — it may already be gone"
272+
fi
273+
274+
# The merge push above was made with GITHUB_TOKEN, which triggers no
275+
# workflows, so main's self-healing legs must be asked explicitly or the
276+
# generated pages sit stale until the nightly cron.
277+
- name: Heal the generated pages on main
278+
if: steps.merge.outputs.via != ''
279+
env:
280+
GH_TOKEN: ${{ github.token }}
281+
run: |
282+
for wf in dashboard_refresh.yml lifecycle_drift.yml; do
283+
gh workflow run "$wf" --repo "${{ github.repository }}" --ref main \
284+
|| echo "::warning::could not dispatch $wf — its next scheduled run will heal main"
285+
done

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ For the full workflow narrative, conventions, and registry schemas, read
7373
- **Skills**`skills/<name>/` are agent skills and command bodies tightly
7474
coupled to the registry. Claude and Codex discovery is installed by
7575
PyAutoBrain; they source `scripts/prompt_sync.sh` for commit/push.
76+
- **Ledger auto-merge** — a push to `claude/**` whose whole diff is *ledger*
77+
(`draft/`, `active/`, `complete/`, the root registry files, the dashboard
78+
pages) is merged into `main` by `.github/workflows/mind_ledger_merge.yml` and
79+
the branch deleted — no PR, no session step, no "please merge that" prompt.
80+
Anything touching `scripts/`, `tests/`, `.github/`, `skills/`, `policy/`,
81+
`docs/`, `repos.yaml` or the prose pages is left for a human, as is anything
82+
unclassified (the gate is default deny). So: **push your Mind work and move
83+
on** — do not leave a ledger branch hanging, and do not expect a code branch
84+
to land by itself. `python3 scripts/ledger_merge.py classify --base
85+
origin/main` tells you which side you are on before you push; the full
86+
contract is in [REFERENCE.md](REFERENCE.md) "How the ledger lands".
7687
- **Scripts**`scripts/status.sh` (inventory), `scripts/prompt_sync.sh`
7788
(commit/push helpers), `scripts/lifecycle.py` (state moves + drift checks;
7889
`lifecycle.py dates [--write]` reports/backfills the date every registry

REFERENCE.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,59 @@ its `z_`-prefixed home redundant. The per-task completion records live in
475475

476476
---
477477

478+
## How the ledger lands (`mind_ledger_merge.yml`)
479+
480+
A branch-scoped session — the phone, claude.ai/code, any `claude/**` flow —
481+
pushes its Mind changes to a feature branch, never to `main`
482+
(`prompt_sync.sh` pushes HEAD deliberately, so a cloud session cannot bypass
483+
review). Nothing downstream used to move that branch on: no workflow so much as
484+
*looks* at a `claude/**` push, because `lifecycle_drift`, `dashboard_refresh`,
485+
`firewall_gate` and `spawn_drift` all trigger on `push: main` or
486+
`pull_request` only. A filed prompt, a task moved to `complete/`, a regenerated
487+
dashboard — all of it waited for a human to write an explicit "merge that
488+
branch" prompt, and the dashboard rendered a stale backlog until they did.
489+
490+
`.github/workflows/mind_ledger_merge.yml` closes that seam. On every push to
491+
`claude/**` it classifies the branch's diff against `main` and, when the whole
492+
diff is **ledger**, merges it and deletes the branch. No session step, no PR,
493+
no prompt.
494+
495+
**Ledger** is drawn by `scripts/ledger_merge.py`, and it is **default deny**:
496+
497+
| Ledger — merged automatically | Code — always a human |
498+
|---|---|
499+
| `draft/**`, `active/**`, `complete/**` | `scripts/`, `tests/`, `.github/`, `skills/`, `policy/`, `docs/` |
500+
| `active.md`, `planned.md`, `parked.md`, `condemned.md`, `epics.md`, `ideas.md`, `autonomy_log.md` | `repos.yaml`, `README.md`, `AGENTS.md`, `REFERENCE.md`, `ROUTING.md`, … |
501+
| `dashboard.md`, `dashboard.html` | anything unclassified — a new root file, a new top-level folder |
502+
503+
Two exceptions inside the ledger dirs: a **dot-path** anywhere, and a file
504+
pytest would **collect** (`conftest.py`, `test_*.py`, `*_test.py`) — inert
505+
prompt assets like `draft/bug/autofit/*_assets/run_once.py` ride along, a file
506+
CI would execute does not. The workflow's own file and the gate script are on
507+
the code side of the line, so neither can auto-merge a change to itself.
508+
509+
Predict the verdict before you push:
510+
511+
```bash
512+
python3 scripts/ledger_merge.py classify --base origin/main # exit 0 = will auto-merge
513+
```
514+
515+
What blocks, and what does not:
516+
517+
- **`lifecycle.py check` blocks.** Structural drift — a prompt in `active/`
518+
with no `active.md` entry — is a real contradiction and nothing heals it.
519+
- **Stale renders do not block.** `complete/index.md`, the registry contents
520+
blocks and the dashboard pages all self-heal on `main`, so the workflow
521+
merges and then dispatches `dashboard_refresh.yml` and `lifecycle_drift.yml`
522+
(a `GITHUB_TOKEN` push triggers no workflows, so they must be asked).
523+
- **A conflict blocks**, and the branch is left untouched.
524+
525+
An open PR on the branch is merged **through** the PR, so it records as
526+
`MERGED`; a branch with no PR gets a direct merge commit and is then deleted on
527+
the same proof `branch_sweep.yml` uses — `main` must actually contain the head
528+
sha. `workflow_dispatch` runs the same gate in `audit` mode by default, so a
529+
manual look never merges by accident.
530+
478531
## Tracking and inspection
479532

480533
### Quick inventory

0 commit comments

Comments
 (0)