Skip to content

resume and status hold each leg to the branch bootstrap puts it on: the leg row's own branch:, else the root's tracking_branch (#235) - #238

Merged
brettheap merged 4 commits into
mainfrom
fix/resume-leg-own-branch-235
Oct 18, 2026
Merged

brettheap merged 4 commits into
mainfrom
fix/resume-leg-own-branch-235

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #235

The defect

resume's root_legs_ok is F3 of the review on #83: it refuses a root whose leg sits off the branch the root's own make bootstrap would move it onto. It took that branch from the ROOT (tracking_branch_of "$root") for every leg. But templates/assembly-root/scripts/bootstrap.py places a leg on str(leg.get("branch") or branch): the branch: the leg's own row in project.yaml declares, and the root's tracking_branch only when the row declares none. So for a leg with its own branch: the guard reasoned about the wrong branch, in both directions:

  • it let a move through: a leg on main whose row says branch: develop was moved onto develop by bootstrap and resume exited 0;
  • it refused a leg that was where it belongs: a leg on its own develop was refused as "not its tracking branch 'main'".

status had the same blind spot: it passed the root's branch to read_leg.

The gap is older than v1.1.0 (it happens with the bootstrap at 7f84ca4 too), and opensoft/openRepoShape#603 → opensoft/openRepoShape#615 widens it: a declared branch BEHIND the pin is now fast-forwarded and checked out, from that branch, from a HEAD detached at the pin, or from another branch.

The design

  • leg_branch_of <root> <leg path>, new, in the shared estate resolver (so all three copies stay byte-identical; park carries it unused, like legs_of). It reads a leg row's own branch: the way legs_of reads the same rows (role: before path:, the assembly row skipped), except that branch: may sit on either side of path:, and a row that does not open with - role: clears what the row before it left. It prints nothing when the row declares none. No branch is: an empty value, null/Null/NULL/~, once a trailing # comment is cut off. A leg is found by its path SPELLED EITHER WAY: a leading ./ and a trailing / come off both sides before they are compared, because status holds .gitmodules' path and resume the manifest's own text (the review's finding 2). The loader is not matched to the letter for a quoted "null"/'~' or false/0; no real branch is named so, and the comment says so.

  • root_legs_ok holds each leg to leg_branch_of, else the root's tracking_branch, which is bootstrap's or. The refusal names the leg, the branch it is on, the branch it should be on and where that comes from:

    • leg spec is on 'main', not its own branch 'develop' (the branch: of its row in project.yaml);
    • leg spec is on '001-my-feature', not its tracking branch 'main' (the root's tracking_branch);

    Detached at the pin is still not a refusal, and the dirty and every other check are untouched. A leg with no branch: is refused exactly as before; only the parenthetical is new.

  • The comment above root_legs_ok says what bootstrap does with a leg's own branch:, including bootstrap.py: a leg on another branch whose tracking branch is behind the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses no commit (#603) openRepoShape#615's fast-forward and checkout of a branch behind the pin, and the move back to the pin.

  • status: estate_repos gives each leg's row its own branch: (else the root's), so everything read under that row (the pin against origin/<branch>, the leg's fork check, the fetch line, the "at its pin, which is origin/'s tip" line) is about the branch the leg belongs on, and read_leg's finding names it: on branch main, not develop, the branch: this leg's own row declares in project.yaml (...). The finding for a leg with no branch: of its own is byte-identical to before.

  • Text that said the old rule is brought along: resume --help, AGENTS.md rule 1, and the comments above status's estate_repos and in park's report_repos.

Tests

seed_leg_member (resume's tests) and seed_root_with_leg (status's) take optional arguments (leg_branch, and for the first code_leg and spec_path); the defaults build the same estates as before. The new tests sit beside the F3 tests and beside read_leg's tests. Each estate is built with the suite's own helpers and the REAL bootstrap.py mounted at upstream/openRepoShape.

test what it holds
test_a_leg_on_its_own_declared_branch_is_not_refused (a) a leg declaring branch: develop, on develop at the pin: exit 0, no refusal, the verb runs, HEAD unchanged
test_a_leg_off_its_own_declared_branch_refuses_the_root_and_is_not_moved (b) the same leg on main with a local develop at the pin: refused by name, exit 1, HEAD unchanged, verb not run; then the root's real bootstrap, run by hand, moves the leg onto develop, so the refusal is not theatre
test_a_leg_with_no_branch_of_its_own_is_held_to_the_roots_and_says_so (c) a leg with no branch: on a feature branch: refused as before, naming the root's tracking_branch
test_a_leg_off_its_own_declared_branch_is_a_finding_that_names_it, test_a_leg_on_its_own_declared_branch_is_not_a_finding (d) status in both directions
test_leg_branch_of_reads_a_rows_own_branch_the_way_bootstrap_does (22 cases) the parsing, run against the real trim_unquote and leg_branch_of cut out of resume: branch: before and after path:, double and single quotes, a trailing comment (also after quotes), an empty value, only a comment, null/Null/~, a row with none beside one with one, the assembly row, paths sharing a prefix, ./spec, spec/, a ./spec asked for, and a row that does not open with - role:
test_each_leg_of_one_root_is_held_to_its_own_branch one root, a spec leg declaring develop and a code leg declaring nothing: neither is refused where it belongs; the code leg on develop is refused by name and the spec leg is left alone
test_a_leg_spelled_dot_slash_or_with_a_slash_is_one_leg_to_status_and_resume (2 spellings) the independent review's case: path: "./spec" or spec/, branch: develop, leg on main: status reports it, resume refuses it

Red/green. Every LANES_*/TMUX* variable unset, private HOME, TMPDIR, basetemp and pycache, under the pytest lock, mount at 190133d (v1.1.0):

  • The first 5 tests (a to d): 5 failed on the old park/resume/status, at both 7f84ca4 and 190133d; 5 passed on the branch. At v1.1.0 the (b) failure is resume exiting 0 over [spec] spec: on develop at 98154768e7a2 (branch tip == pin), the leg moved.
  • The review's mutants, applied identically to all three copies, each fail the new tests:
mutant failing new tests
drop the trailing-comment cut 3 (a-trailing-comment-is-cut, ...-after-quotes-is-cut, only-a-comment-is-no-branch)
drop the null/~ case 3 (null, Null, tilde)
branch reset when path: is read (branch before path) 1 (before-path)
drop the ./ and / normalisation 5 (3 table cases and both spellings of the status/resume test)
drop the "row that does not open with a role" clear 1
leg_branch_of always answers nothing (the old behaviour) 19

With the fix every one of these passes.

Modules, this branch merged with f5c3bcd, submodule at 190133d: tests/test_park_resume_commands.py (164 tests), tests/test_status_command.py (202) and tests/test_repo_hygiene.py (189, which holds the three-way byte-identity of the shared resolver and the host-path rule): 555 passed. The full suite was not run locally; CI's Linux jobs are the suite of record.

Not in this change

park's report_repos has the same assumption when it counts unpushed commits on a leg's tracking branch, so work on a leg's declared branch is never reported. The independent review of this PR found it (review 5561239715) and it is filed as #242. The comment there now says so.

🤖 Generated with Claude Code

Brett Heap and others added 2 commits October 18, 2026 02:29
…he leg row's own branch:, else the root's tracking_branch (#235)

root_legs_ok compared every leg with the ROOT's tracking_branch, but the root's
bootstrap.py places a leg on str(leg.get("branch") or branch). So a leg that
declares its own branch: develop was let through while bootstrap moved it (exit
0), and refused while it sat on develop. status passed the root's branch to
read_leg and had the same blind spot.

A shared leg_branch_of reads a leg row's own branch: the way legs_of reads the
rows. resume refuses a leg off that branch (naming the leg, where it is, where
it belongs and whether that is its own branch: or the root's tracking_branch);
status gives each leg's row, and so read_leg, that branch. A leg with no
branch: of its own is held to the root's branch exactly as before. The comment
above root_legs_ok now says what bootstrap does with a leg's own branch:,
including openRepoShape v1.1.0's fast-forward and checkout of a branch behind
the pin (opensoft/openRepoShape#615).

Fixes #235

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes branch selection for resume and status by matching each leg to the branch bootstrap will use—its row-level branch when declared, otherwise the root's tracking_branch—through a shared resolver, clearer diagnostics, and regression tests covering both declared and fallback cases.

File-Level Changes

Change Details Files
Resolve each leg's effective bootstrap branch from its own project manifest row, falling back to the root tracking branch.
  • Add a shared leg-branch resolver that parses row-level branch values and preserves byte identity across scripts.
  • Update resume's guard and diagnostics to validate each leg against its declared branch or the root fallback.
  • Update status to read and report each leg against that same effective branch.
  • Leave park's resolver copy aligned while intentionally not changing its reporting behavior.
park
resume
status
Add regression coverage for declared leg branches while preserving existing fallback behavior.
  • Extend test fixtures to create legs with optional declared branches and matching remote refs.
  • Verify resume accepts correctly placed legs, refuses and does not move misplaced legs, and retains existing no-branch behavior.
  • Verify status uses the declared branch for findings, pin comparisons, and clean output.
  • Retain repository hygiene coverage for shared resolver identity and add real-bootstrap premise checks.
tests/test_park_resume_commands.py
tests/test_status_command.py

Assessment against linked issues

Issue Objective Addressed Explanation
#235 Have resume compare each leg against the branch bootstrap would use: the leg row’s own branch: when declared, otherwise the root’s tracking_branch, and refuse before bootstrap can move the leg. ✅
#235 Make status use the same per-leg branch choice for its reads and findings, so a leg on its declared branch is not incorrectly reported as off-branch and findings name the expected branch. ✅
#235 Keep the shared resolver copies consistent and update the guard’s explanation and tests to cover declared branches and the fallback to the root branch. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap brettheap left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of #238 at fefeaca: LAND after fixes

The rule is the right one and the behaviour is correct for every manifest the scaffold writes. I found no blocking defect. Finding 1 should be fixed before this lands. The rest can go in the same push or be filed.

Run at fefeaca, submodule at 190133d (v1.1.0), with every LANES_* and TMUX* variable unset and a private HOME, TMPDIR, basetemp and pycache, under the pytest lock:

  • The 5 new tests pass on the head and all 5 fail on f595e44's park/resume/status. On the old scripts (b) exits 0 over [spec] spec: on develop at <sha> (branch tip == pin), and (d1) reports 0 findings. (c) fails on the old scripts only through the new parenthetical, as the PR says.
  • tests/test_repo_hygiene.py passes (189). The three resolver blocks have the same sha256.
  • shellcheck is clean on all three files.
  • No bash 3.2 was available. I ran bash 5.2 and read the new code for 3.2: no ${x,,}, mapfile, declare -A, local -n or multi-line awk -v. The # inside ${branch%%[[:space:]]#*} is not at a word start, so 3.2's $() parsing is not affected.
  • A full run of the two touched modules was cut at about 84% by my own timeout, with no failure shown. I did not complete it. CI's tests and tests-no-submodule were still pending when I looked, and CI is the record.

Findings

1. should-fix: nothing tests leg_branch_of's parsing, which the PR body and code comment claim.

All 5 tests use branch: develop on the line after path: spec. The comment and PR body claim four more things: branch: on either side of path:, an empty value, null/~, and a trailing # comment cut off. None of them is exercised.

I proved it with a mutant, applied identically to all three copies so byte-identity still holds. It dropped the comment cut, dropped the null/~ case, and reset branch when path: is read. That makes branch: develop # c return develop # c, branch: null return null, and branch-before-path return nothing. Result: the 5 new tests, test_repo_hygiene.py (189) and the 72 existing leg and refusal tests in test_park_resume_commands.py all still pass.

There is also no mixed case, one leg with its own branch: and one without. It works. I ran root_legs_ok over a two-leg root with tracking_branch: trunk and a branch: develop spec leg: both legs where they belong passed, and a code leg on develop was refused as "not its tracking branch 'trunk'". It is simply unguarded.

Fix: add a table-driven test that sources the shared block (sed -n '/BEGIN shared estate resolver/,/END shared estate resolver/p' resume) into bash and asserts leg_branch_of over:

row expected
branch: develop after path: develop
branch: develop before path: develop
branch: "develop" develop
branch: develop # own develop
empty branch: nothing
branch: null, branch: ~ nothing
branch: # none nothing
another row's branch:, and the assembly row's nothing
two rows whose paths share a prefix (spec, spec-extra) the exact row only

Then add one two-leg resume case.

2. should-fix: status and resume look a leg up under different path spellings.

status finds the leg's row by its .gitmodules path (declared_legs, then leg_branch_of "$root" "$leg"). resume uses the path: text from project.yaml (legs_of). The two are compared by exact string. validate-manifest accepts path: "./spec" and spec/ (only absolute paths, .. and . are refused), and status's own comment above physical_path says "./spec against spec is the same leg and two strings".

Repro: branch: develop, path: "./spec", .gitmodules path spec, leg on main.

  • status: leg main <leg> then at its pin, which is origin/main's tip as of the last fetch; clean. This is the blind spot the PR sets out to close.
  • resume (root_legs_ok): REFUSED R: leg ./spec is on 'main', not its own branch 'develop' (...), rc=1.

Fix, in the shared block so all three copies move together: normalise both sides in leg_branch_of before comparing (p="${path#./}"; p="${p%/}", the same for want). Or document the limitation next to leg_branch_of.

3. should-fix: the operator-facing text still states the old rule, and two comments now say something untrue.

  • resume --help (resume:99-102): "a root one of whose LEGS is off its tracking branch".
  • AGENTS.md:79-82, driving rule 1: "would walk the leg back onto its tracking branch".
  • status:703-704 still says estate_repos is "THE SAME ENUMERATION park's not-carried report makes". It no longer is: the branch column for a leg now differs.
  • park:584-585 still says "A LEG'S branch is the ROOT's tracking_branch: — that is the one value scripts/bootstrap.py places every leg on".

Fix: reword the first two to "off the branch bootstrap places it on (its row's branch:, else the root's tracking_branch)". Reword the two comments, with a pointer to leg_branch_of. The PR body says park's report_repos is "left alone" but names no issue. File one and link it. It is a data-loss-adjacent report: for a leg on its own branch, park counts unpushed commits against the root's branch, not the leg's.

4. note: for a few scalars the shell and the loader disagree, so the comment's claim is not exactly true.

The comment says "what the loader would call no branch is none here too". I checked repo_shape.parse_yaml and bootstrap.py's str(leg.get("branch") or branch):

YAML loader gives shell gives
"null", '~' the branch null, ~ none
false, 0 falsy, so the root's branch the branch false, 0
true True true

No real branch is named that, so this is cosmetic. Soften the comment to "null, Null, NULL, ~ unquoted, empty, or a comment".

5. note: a row that does not open with - role: does not reset branch.

This is the same limitation legs_of has for path:, but branch: is new. Probe: - role: spec / path: spec, then - path: other / branch: leak, with want=spec, returns leak.

The scaffold template always opens a row with - role:, so this is not reachable from a generated manifest. Hardening, if wanted: add a ' - '*) arm that clears role, path and branch before the more specific arms. That is one line in the shared block, and it changes nothing for role:-first rows.

Checked and not findings

Each is what I looked for in the checklist.

  • resume's refusal. It comes before pull_ff_only and make bootstrap (refresh_root). (b) asserts exit 1, the exact refusal line, HEAD name and sha unchanged, and that the verb did not run. Then it runs the real bootstrap.py by hand to show it would have moved the leg. Detached at the pin still passes. The detached-off-pin, dirty and root-branch checks are unchanged in the diff.
  • status consumers. The row header, the fetch line (fetch_estate_first, fetch_remote), read_fork, ask_github_fork, read_tracking, read_branches, no_origin_branch_finding and the "at its pin ... origin/'s tip" line all take the row's branch, so they are right for a leg with its own branch:. For a leg without one, or a non-leg .gitmodules entry, leg_branch_of returns nothing and the column is the root's, as before. holder, root and missing rows are untouched.
  • A pin ahead of origin/<own branch>. With a declared branch develop behind the pin, status now says pin <sha> is not on origin/develop: 1 commit(s) unpushed in the leg. That is correct for v1.1.0's fast-forward, which leaves local develop ahead of origin/develop.
  • Hygiene and body. The diff has no host-absolute path. The one ~/ is the existing workspace.yaml fixture line. The body has "Fixes #235" only, and "Bug Fixes:" is Sourcery's heading with no issue number after it. The openRepoShape references are opensoft/openRepoShape#603 and #615.
  • The v1.1.0 bootstrap behaviour the new resume comment describes. I read fast_forward_to_pin and move_back_to_pin in the mounted bootstrap.py, and the comment matches them.

🤖 Generated with Claude Code

…ind a leg by either spelling of its path, and fix the stale text (#235)

Review 5561239715 of #238:

1. leg_branch_of's parsing claims (branch: on either side of path:, an empty
   value, null and ~, a trailing # comment, a row with one branch: beside one
   without, prefix-sharing paths) are now a table of 22 cases run against the
   real function cut out of resume, plus a two-leg resume case.
2. status looked a leg up by .gitmodules' path and resume by the manifest's
   text, so a row spelling its path "./spec" or "spec/" was a leg on its
   tracking branch to status. leg_branch_of, in the shared block, drops a
   leading ./ and a trailing / from both sides before it compares them.
3. resume --help, AGENTS.md rule 1 and the comments in status and park no
   longer say a leg is held to the root's tracking branch; park's report_repos
   keeps that assumption, filed as #242.

Also: a row that does not open with `- role:` clears the row before it, and
the comment says where the loader and this reader differ.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Applied the independent review 5561239715 at 9be1a44: findings 1-3 (a 22-case table and a two-leg case for leg_branch_of's parsing, each failing the review's mutants; ./ and trailing / normalised in the shared block, with the review's status/resume case as a test; resume --help, AGENTS.md and the two comments reworded), and finding 5's row clear; finding 4's comment softened; follow-up #242.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap brettheap left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta re-check of #238 at 9be1a44: LAND

Findings 1, 2, 3 and 5 of review 5561239715 are fixed, and finding 4's comment is softened. I found nothing new. Landing still needs CI's tests and tests-no-submodule on 9be1a44 green; both were pending when I looked.

Finding 1, parsing tests.

  • shared_function reads resume as it is at test time, so it cannot hold stale code. It uses str.index, so a renamed or missing function fails the test with a ValueError instead of passing silently.
  • It cuts at the first \n}\n. Both trim_unquote and leg_branch_of end at column 0, and there is no inner column-0 brace.
  • The other two copies are covered by the existing byte-identity test.
  • I re-applied my three mutants identically to park, resume and status. Each now fails:
    • comment cut dropped: 3 failures (a-trailing-comment-is-cut, a-trailing-comment-after-quotes-is-cut, only-a-comment-is-no-branch)
    • null/Null/~ case dropped: 3 failures
    • branch reset at the path: line: 1 failure (before-path)
  • I added three more mutants. Dropping the ./ and trailing / normalisation gives 5 failures. Dropping the new row-clear arm gives 1 failure. Making leg_branch_of ignore which path was asked gives 5 failures, including test_each_leg_of_one_root_is_held_to_its_own_branch.
  • test_each_leg_of_one_root_is_held_to_its_own_branch fails on f5c3bcd's scripts. It passes with spec on develop and code on main. It then refuses code on develop by name, leaves spec alone, and leaves both HEADs and branches unchanged.

Finding 2, ./spec and spec/.

  • The new test_a_leg_spelled_dot_slash_or_with_a_slash_is_one_leg_to_status_and_resume is my probe as a test. status prints leg develop <leg> and the "on branch main, not develop" finding. resume refuses with exit 1, and HEAD is unchanged. It fails on the pre-PR scripts.
  • The normalisation strips one leading ./ and one trailing / from both sides.
  • Probes show it cannot join two different legs:
    • ../spec, sub/spec, .//spec, spec// and ./sub/../spec do not equal spec.
    • ../spec still matches itself.
  • A path of . is unchanged. The assembly row never emits, and resume never asks for ..
  • The shared block has the same sha256 in all three scripts, and tests/test_repo_hygiene.py passes.

Finding 3, stale text. resume --help, AGENTS.md rule 1, the status comment above estate_repos and the park comment in report_repos now describe the real rule and point at #242. A grep for the old wording finds no remaining copy.

Merge 7ae0f46 (main f5c3bcd).

  • It touches only lane-start, skills/handoff/SKILL.md, tests/test_lane_helpers.sh and tests/test_skill_shell_blocks.py.
  • git diff f5c3bcd 7ae0f46 is byte-for-byte the previous PR diff (git diff f595e44 fefeaca; sha256 398a799b… for both once index lines are dropped). So it adds nothing to park, resume, status or the test files.
  • The writer's own commit touches only AGENTS.md, park, resume, status and tests/test_park_resume_commands.py.

#242 is accurate. Against f5c3bcd's park: report_repos is at lines 526-548, the comment at 536-537, unpushed_on_tracking at 559 and not_parked_report at 579. The unpushed line prints the branch. The suggested fix works with the new normalisation. The test it asks for would fail without the change.

Runs at 9be1a44, submodule at 190133d, with every LANES_* and TMUX* variable unset, private dirs, under the pytest lock:

  • The 30 new and earlier branch tests pass.
  • tests/test_repo_hygiene.py passes (189).
  • tests/test_park_resume_commands.py passes in full (164 tests).
  • tests/test_status_command.py passes in full (202 tests). This completes the full-module run I cut short last time.
  • shellcheck is clean on all three scripts.
  • No bash 3.2 was available. The new constructs (${want#./}, ${want%/} and one more case arm) are plain parameter expansion and glob patterns, as the old code uses.

🤖 Generated with Claude Code

@brettheap brettheap added the ready landing gate: runs tests-macos once before the squash label Oct 18, 2026
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…AGENTS.md line net zero

The independent review of 016a07c:
- M1: README said the settings file is 600 "on every run". It is 600 on
  every run that writes it or finds an entry of ours in it. A deferred run
  with nothing of ours to add or take out leaves the file, and its mode, as
  they were. The install paragraph no longer claims 600 for the person's
  own hooks.
- S1: the `wip init` paragraph gives the exception where it gives the exit
  codes: a hook reconcile it cannot make is printed, and the exit stays 0.
- N8: ruling 9's condition, "and `~/projects/xFactory` exists".
- N3: "the launcher's probe, plus an execute bit", where it said "the
  launcher's own rule".
- S2: AGENTS.md's line is reworded for a `--install` that can now take our
  guard entry out ("Shared hooks stay installed while
  `~/projects/xFactory/lanes-edit.sh` answers `guard`; until then they are
  deferred and our guard entry taken out"). It is back to main's 321 lines,
  so #238's two lines still fit under the cap of 323.

README stays at main's 485 lines.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap merged commit 14239b9 into main Oct 18, 2026
8 checks passed
@brettheap
brettheap deleted the fix/resume-leg-own-branch-235 branch October 18, 2026 09:31
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…86, so CI tests this PR on today's main

Main moved past 2cb14a9 (#238, #240, #213 landed), and CI tests the PR's merge with main, so the condition below is tested on the main it will land on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…y/lanes-edit.sh answers guard, and take our guard entry back out until then, so a fresh install never leaves a prompt hook calling a missing command (#237) (#247)

* Give the hook tests a link_xfactory fixture before the #237 gate lands

`link_xfactory(home)` makes `~/projects/xFactory/lanes-edit.sh` the way
`link-estates` leaves it: a symlink to the bin directory's `lanes-edit.sh`.
The eleven tests in tests/test_install_skill_and_hook.py that assert the
SessionStart entry and the UserPromptSubmit name guard are placed, and
test_installing_twice_changes_nothing's thirty-one `unchanged`, link it
first, so they keep asserting the merged pair once `--install` merges it
only for a target that answers `guard`. `ALWAYS` (29) is named beside
`ARTIFACTS` (31) for the count the gate makes conditional.

tests/test_install_payload_source.py's
test_r12_an_adopted_install_keeps_modes_receipts_targets_and_what_is_not_ours
makes the same link to its own bin directory's `lanes-edit.sh`, since it
asserts the guard entry is merged (the lead's ruling 7).

The fixture changes nothing on the current code, which ignores the link.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge the #237 hook pair only once ~/projects/xFactory/lanes-edit.sh answers `guard`, in --install and in wip init

A fresh `--install` merged the SessionStart entry and Amendment 12's
UserPromptSubmit name guard before anything had placed the path both call,
`~/projects/xFactory/lanes-edit.sh`, which `link-estates` links and only
`openRepoTools wip init` runs, after the install (opensoft/openBenches#107).
The guard then failed every prompt with exit 127 instead of refusing with 2.

Approach (A), the claude-profile launcher's own rule, in both writers:

- `--install` (`plan_hook_merge`, `place_hook_pair`) stages both outcomes
  before anything is placed and picks one after the bin loop. The pair is
  merged only when that path exists, is executable and answers `guard`, by
  the launcher's awk probe (claude-profile:404-411), plus `-x`.
- When it does not answer, nothing is added, and our EXACT guard entry is
  removed from a file that carries it, filtered on each entry's nested
  array (workBenches#89, #90). The SessionStart entry, every foreign hook
  and every other key stay; the file is written back at mode 600.
- Every deferral and every removal is printed with its remedy.
- `wip init` reconciles the pair in step 10, after `link-estates`, and on
  step 1's "nothing to do" path, where it first re-runs `link-estates` if
  the path does not answer and ~/projects/xFactory exists. `--dry-run`
  prints "would merge". A refusal there is a note and the exit stays 0.

THE CONTRACT CHANGE: `--install` places 29 artifacts always, plus the two
hook entries once that path answers `guard` (it was 31 unconditionally).
Unchanged: the hook strings and their exact-string identity, the rival
refusal (still refuses while the pair is deferred), the receipt (no hook
rows), the launcher contract, and the guard's real exit 2 (no `|| true`).

tests/test_install_hook_gate.py holds it: fresh, interrupted, dangling-link
migration, a target with no `guard` verb or no execute bit, the bin-loop
ordering, rivals while deferred, `wip init` after `link-estates`, on its
nothing-to-do path and after a cut, `--dry-run`, a repeat install, and the
installed guard's real exit 2 before and after the workspace exists, and
`--install` with no `jq` still ending in the `jq` refusal whether or not
the target answers (006's FR-018).

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the #237 hook gate in README's install paragraph, its wip init note and AGENTS.md

README: the install paragraph says both entries wait for the path they
call, what a deferred run prints and removes, and the remedy; the count
line reads "Twenty-nine artifacts always, plus the two hook entries once
the path they call answers `guard`"; the `wip init` note and its
idempotence paragraph say it completes the pair after `link-estates`.
AGENTS.md's "Shared hooks stay installed" line carries the same count.
No line is added to README (485, cap 486); AGENTS.md gains one (322, cap
323).

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a guard entry #237 cannot take apart before anything is placed, and pin wip init's exit-0 reconcile

The independent review of 016a07c, M2: a guard entry in a shape the strip
filter does not take apart (a `UserPromptSubmit`, or an entry's `hooks`,
that is not an array), against a target that does not answer, was refused
only at placement, after the 29 artifacts were placed. The refusal is now
decided in `plan_hook_merge`, with "NOTHING was installed", like every other
file the merge cannot handle. The one target the bin loop can still repair,
a link to the bin directory's own `lanes-edit.sh`, is let through, and the
run merges once that loop has put the far end back. `hook_pair_defer` keeps
the same refusal as a backstop.

S1: `wip init` keeps exit 0 when the hook reconcile is refused, on step 10's
path and on step 1's, under one rule. `--help` now says so where it gives the
exit codes: "a hook reconcile it cannot make is printed, and the exit stays
0". It also gives ruling 9's condition, "and ~/projects/xFactory exists".

NITs taken:
- N2: an executable that cannot be read is named "is not readable", and a
  link that dangles or loops "does not resolve".
- N6: with no ~/projects/xFactory, the remedy first says that directory has
  to exist.

Tests:
- the odd shape now asserts nothing is placed;
- a new case: the odd shape beside a link the bin loop repairs is merged;
- a new parameter: a target that is not readable;
- S1: wip init's refused reconcile on both paths;
- N7: step 1 does not relink when the target answers or ~/projects/xFactory
  is absent;
- M1: a deferred run leaves a file of the person's own at its 644.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Correct #237's README mode sentence and wip init exits, and make its AGENTS.md line net zero

The independent review of 016a07c:
- M1: README said the settings file is 600 "on every run". It is 600 on
  every run that writes it or finds an entry of ours in it. A deferred run
  with nothing of ours to add or take out leaves the file, and its mode, as
  they were. The install paragraph no longer claims 600 for the person's
  own hooks.
- S1: the `wip init` paragraph gives the exception where it gives the exit
  codes: a hook reconcile it cannot make is printed, and the exit stays 0.
- N8: ruling 9's condition, "and `~/projects/xFactory` exists".
- N3: "the launcher's probe, plus an execute bit", where it said "the
  launcher's own rule".
- S2: AGENTS.md's line is reworded for a `--install` that can now take our
  guard entry out ("Shared hooks stay installed while
  `~/projects/xFactory/lanes-edit.sh` answers `guard`; until then they are
  deferred and our guard entry taken out"). It is back to main's 321 lines,
  so #238's two lines still fit under the cap of 323.

README stays at main's 485 lines.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brett Heap <service@opensoft.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… this PR on today's main

No conflict: of the three commits since 2cb14a9, #238 and #240 touch only park, resume, status and their tests, and #213 touches lanes-edit.sh (managed_seam_refuse, managed_local_hint, managed_local_remedy, lane_trees_list and the managed-projection and lane-trees verbs), docs/README-lanes.md and tests/test_lane_helpers.sh (its managed-seam cases) only away from this PR's functions and hunks, and all three shared files merged as exactly main's version plus this PR's own change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…ch its own row declares, else the root's tracking_branch (#242) (#249)

* park's not-carried report counts a leg's unpushed commits on the branch its own row declares, else the root's tracking_branch (#242)

report_repos gave every leg's row the ROOT's tracking_branch, but the root's
bootstrap.py places a leg on str(leg.get("branch") or branch). A leg whose row
in project.yaml declares its own branch: keeps its unpushed work on THAT
branch, so not_parked_report asked about the wrong one, found nothing ahead and
ended without naming the leg: a silent miss in the report that exists to say
what park did not carry.

Both loops of report_repos (the standalone root and a family member's) now take
each leg's branch from leg_branch_of, the shared helper #238 gave status and
resume, and fall back to the root's branch on an empty answer, as status's
estate_repos does. The false comment says the leg row's own branch:, else the
root's tracking_branch, and points at #235 and #238. The shared resolver block
is untouched.

Four tests beside the not-carried report's: a standalone root's spec leg on
branch: develop, the same inside a family member's clone, a leg with no
branch: unchanged, and one root with a leg of each kind. The first, second and
fourth fail on main's park and pass with this change.

Fixes #242

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Apply the independent review of #249: survive an unreadable member manifest, pin the family loop's fallback, and fix status's comment about park (#242)

Review 5563423499 of #249:

1. status's estate_repos header said park's report gives every leg the root's
   branch. It now gives every leg the same column as status does.
2. report_repos runs under set -e in a process substitution, and leg_branch_of
   fails when a project.yaml that exists cannot be read, so the bare assignment
   ended the subshell and dropped that leg's row and every row after it, with
   no word from park. Both loops now say || true and fall back to the root's
   branch; a test makes a family member's project.yaml mode 000 (skipped as
   root, which mode 000 does not bind) and fails without the guard.
3. The family loop's fallback to the member's tracking_branch was reached by no
   test: the member of test (b) now has a code leg declaring no branch:, and
   the report must name it on main. That fails with the family printf using
   "$legbranch" with no fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brett Heap <service@opensoft.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready landing gate: runs tests-macos once before the squash

Projects

None yet

1 participant