Skip to content

Move the Session Handoff to a Chain of Labeled Issues - #1501

Merged
ptr727 merged 21 commits into
developfrom
feature/session-handoff-chain
Sep 10, 2026
Merged

ptr727 merged 21 commits into
developfrom
feature/session-handoff-chain

Conversation

@ptr727

@ptr727 ptr727 commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Implements #1470. The session handoff moves from a scratch file to a chain of GitHub issues, one open link per track per repository.

Why

A handoff written to a scratch file fails three ways a chain closes. The file is not found where the next session looks. More than one candidate is found and nothing says which is current. And it holds no history, so a later round re-runs a path an earlier round already tried and already wrote down. The value of a handoff is the record of what has been attempted, and one overwritten file on one machine is the shape that cannot hold it.

What ships

Surface Change
repo-config/labels.json handoff declared beside the other sixteen
repo-config/README.md "Labels" surface marker, never a triage label, excluded from backlog counts
scripts/handoff.py six subcommands, a three-value exit contract, --dry-run on both writers
scripts/tests/test_handoff.py 114 cases, 99% coverage, every guard held by a revert check
.agents/skills/session-handoff/ the judgment half, carrying GOVERNANCE.md "Communicating with the User" as a generated include
AGENTS.md "Session Scope" the file rule replaced by the issue rule, still setting a size rule for GOVERNANCE.md to name
.agents/skills/backlog-burndown/ "Ranking" handoff filtered out of the ranking and every backlog count

The chain is machine-readable from one HTML comment on the body's last line, so a retitled or hand-edited issue still chains and nothing parses the title. The invariant is one open link per track, read from that block rather than from the label. Two on a track is reported, never resolved by picking.

adopt is not in v1

#1470 specified a seventh subcommand, adopt, to put the label and the block on an issue predating both. It was built, and then removed. #1499 records the reasoning and what a returning design must answer.

The short version is that it was measured rather than argued. A randomised search over reachable stores, built only from command sequences that exited 0, found 60 distinct all-green sequences that fork or orphan a track in 20000 trials, and every one required adopt. Two controls restricted to new and link found none in 400 trials and none in a further 150. An issue predating the chain joins it by hand now, and the tool's refusals name the edit.

Verification

Fifteen adversarial review passes ran against this branch, at the strongest tier available, and the branch answers all of them. Defects found per pass: 8, 8, 7, 4, 5, 2, 5, 2, 2, 4, 3, 6, 4, 4, 1. Every one was the same class, a multi-step write leaving the chain forked while each step exits 0.

Three things beyond the ordinary gates are worth naming:

  • A revert check holds every guard. 61 guards are reverted one at a time and the suite must fail on each. It does, 61 of 61. This exists because a pass found that six of one round's seven fixes could be reverted with the suite still green.
  • The test double models gh rather than a superset of it. It projects --json, honors --label, --state and --limit, and refuses any call carrying no --repo. Before that, a caller reading a field it never requested passed the suite and raised against the real tool, which is exactly what happened once.
  • The reachable-state question was searched rather than reasoned. Exhaustive breadth-first search over the six subcommands, seeded with irregular states, because link exists for lanes that are already irregular and a healthy-state search provably cannot reach its defects.

Gates: ruff, ruff format, mypy, build_dist.py --check, 1282 tests, repo_gate.py --check eol, spec/validate.py, whole-tree prose_lint.py, markdownlint, cspell, and 14 of 14 changed canonical units covered by a recorded review pass.

Live

repo-config/configure.sh check ptr727/ProjectTemplate release passes, which also cleared two pre-existing drifts: has_projects and the decision label's description. #1469 was marked by hand and now reads as this repository's round 1 on track default.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V1MHrN5KP2ZV1VfEgbE4nr

ptr727 and others added 18 commits September 9, 2026 08:56
A handoff written to a scratch file fails three ways a chain of issues
closes. The file is not found where the next session looks, more than one
candidate is found and nothing says which is current, and it holds no
history, so a later round re-runs a path an earlier round already tried
and already wrote down.

Four surfaces carry the replacement. `repo-config/labels.json` declares a
`handoff` label, a surface marker rather than a triage label, and
`repo-config/README.md` records why a handoff issue carries no triage
label at all. `scripts/handoff.py` is the mechanics: seven subcommands, a
three-value exit contract separating a refusal from the command not
having run, and a `--dry-run` on each of the three that write.
`.agents/skills/session-handoff/` is the judgment, carrying
`GOVERNANCE.md` "Communicating with the User" whole as a generated
include. And `AGENTS.md` "Session Scope" replaces the file rule with the
issue rule, keeping a size rule for `GOVERNANCE.md` to point at.

`backlog-burndown` now filters the label out of its ranking and its
backlog counts, since a track in use always has an open link.

Closes #1470

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review rounds over the fourteen canonical units this change moves
found sixty-one defects, and this settles the ones that survived triage.

Six were in the script. `new` on a track whose lane was closed out filed
round 1 with `previous=none` beside the existing chain, orphaning every
link already written, and `newest_closed` had no full-window refusal, so
the same orphan was reachable one page further out. Exit 2 collided with
argparse's usage code while an unmodeled exception fell through to
CPython's exit 1, so a refusal and a failure to reach one shared codes
both ways. A walk that stopped at a link carrying no metadata block
returned no notice, and under `--grep` the terminating link was filtered
out of the listing, so a search that stopped three links in read as
exhaustive. `adopt --previous` stamped a caller-typed issue number into
the block with nothing having read it. And the cap message named
`--limit` on `resume`, which rejects that flag.

The rest were prose. The load-bearing ones: the carried rule left an
unattended session required to file, forbidden the fallback, and unable
to be authorized, so filing a link is now authorized standing and bounded
to the chain, with a report as the one alternative where no link can be
filed at all. The size rule now resolves against the `GOVERNANCE.md`
sentence that points at it. And the skill no longer reads its own trigger
as a grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test double returned every field of every issue whatever `--json`
asked for, so a caller reading a field it never requested passed here and
raised against the real tool. That is not hypothetical: `open_handoffs`
did not request `state`, `cmd_new` compared it, and the primary path
therefore raised `KeyError` after the create and the comment had landed
and before the close, leaving two open handoffs on the track and every
later command refusing. Sixty-five green cases said nothing.

The stub now honors `--json`, `--label`, `--state`, and `--limit`, and
reverting the field makes the suite fail on exactly the two cases that
name the three-step order.

Five more defects the same pass found. The marker's line anchor rejected
a CRLF body, so one press of GitHub's Edit button unchained a handoff,
which is the one property the block exists for. A body written on Windows
would have been CRLF for the same reason `build_dist.py` already guards.
`link` validated one field of one of its two issues, so it would close
another lane's open handoff, or make an issue its own predecessor, and
exit 0. `adopt` wrote a track without reading that track's one-open
invariant, manufacturing the ambiguity every other subcommand refuses
over. And the label read was the one window with no full-page guard,
failing toward a refusal that sends the caller to re-apply a set that is
already applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`link` guarded self-linking and a track mismatch but not direction, so
transposing its two numbers passed every check: the older link was
rewritten to name the newer, the newer was closed, and the run exited 0
over a two-link cycle. A chain runs one way, so the round numbers settle
which of the two is the successor, and the direct back-edge is refused
beside it. Transposing them is the ordinary slip under the one condition
`link` exists for.

`adopt` wrote the label before it read `--previous`, so a number that did
not resolve left a labeled issue carrying no metadata block, and
`require_adopted` then refused every read on every track in the
repository until someone re-ran it. Every read a write depends on now
happens before the first write.

The closed-side read window was a wall a working repository reaches by
construction, since closed links accumulate one per round while open ones
are bounded by the one-per-track invariant. It is now its own much larger
window, and it refuses only where the page filled and the track was not
found in it, rather than on a full page regardless.

`tracks` is documented as the survey that reports what the others refuse
over, and a double-marked issue took it down along with `adopt`, which is
the way out. The refusal now rides on the row.

Also: `gh` stderr ran on into this tool's own message with no newline and
counted characters while saying bytes, and the below-1 refusal for
`--new` and `--previous` named them without their dashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review pass reverted six of the previous round's seven fixes one at a
time and the suite stayed green through all six, so nothing in it held
that work in place. Two of its cases could not have failed at all: both
built their target with the helper's default labels, so `adopt` took the
already-labeled branch, never issued a label write, and the assertion
that no write preceded the failing read held whichever order the two
steps ran in.

Every guard in the tool now has a case that fails when the guard is
removed, checked by mechanically reverting all twenty-one of them one at
a time. That is the check that was missing, rather than more cases.

Three defects the same pass found. `parse_marker`'s refusal was caught at
one of its three call sites, so one hand-edited issue anywhere in the
repository refused `new` and `chain` on every unrelated lane, and aborted
`chain` and `resume` mid-output. `adopt --previous` got none of the three
guards `link` applies to the same field, so it would write a self-cycle
or a backwards cross-track predecessor and exit 0. And the marker grammar
admitted `previous=0`, which reached `gh` as an issue number and exited 2
for a block the caller can fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every case replaced `run_gh` wholesale, so its own body ran in no test at
all, and neither did the confirmations the write helpers make on top of
it. Four guards could be deleted outright with the suite green: the
return-code check, the stderr cut notice, the JSON decode guard whose
failure mode is the degraded empty answer the label refusal exists to
prevent, and the close read-back that `GOVERNANCE.md` "Repository
Boundaries and Write Safety" requires. The matrix now runs to
thirty-one and covers all of them.

Three more defects the same pass reproduced. `newest_closed` skipped an
unreadable closed link and returned an older match, forking the chain
silently inside the one function written to prevent chain forks, so a
link met before a match is now refused over rather than passed: the scan
runs newest first, which makes anything skipped newer than the match.
`adopt` still wrote the label before the body, so a failed body edit left
a labeled block-less issue that refused every read on every track, where
the other order leaves an unlabeled issue carrying a block that blocks
nothing. And its occupied-track guard read only rows whose block parsed,
so a malformed standing link was invisible to the one check whose whole
job is keeping two off a lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tter

The previous round moved `adopt`'s body write ahead of its label write on
the grounds that an unlabeled issue carrying a block blocks nothing. It
does block something: every read here is label-filtered, so that issue is
invisible to `current`, `resume`, `tracks` and `chain`, the re-run was
refused as already carrying a block, and the next `new` then filed round
1 with `previous=none` beside a chain that existed. That is a silent fork
where the old order gave a loud, self-healing refusal. The order stays,
and a block matching what this run would write now falls through to the
label step, which is what makes body-first recoverable.

`link` tested the predecessor's state before commenting where `new`
comments before testing it, so on an already-closed predecessor the one
write `link` exists to finish was the one write it could never make, and
it reported the chain finished. `walk` gained a phrase for its fifth
early end, an ancestor `gh` cannot resolve, which previously discarded
every link already read.

Two fixture defects mattered more than any of those. `FakeGh` ignored
`--repo`, so it could be dropped from any of nine call sites with every
case green, and `gh` would then resolve the repository from the working
directory, which is the failure this whole script is built against.
`_create` hardcoded the label rather than reading argv, so `create` could
stop labeling and nothing would notice, while every handoff filed would
be invisible to every read. The suite's green was not evidence for either
property. The revert matrix now runs to forty-five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anches

`newest_closed` refused over a closed link whose block could not be read,
because either could be the track's real newest and chaining past it
forks the chain. A closed link carrying no block at all is the same
hazard for the same reason, and it fell through the track test with
nothing recorded: a lane whose newest link had its block edited out filed
its round number twice and orphaned the newer one, exit 0, silent. The
search side lied to match, reporting no result over a walk that never
reached the link holding the text.

`adopt`'s half-applied recovery returned before the guards its
first-order branch runs, so a lane that acquired an open link between the
half-applied run and the re-run got a second one, turning a silent fork
into a hard ambiguity that every later read refuses over. Both branches
now run the same check, which is its own function rather than a block one
path happens to reach.

Three fixtures proved less than their names. The newer-than-the-match
case only ever used a doubled block, which is why the bare-block half
went unseen. The half-applied-adopt case held only the issue being
adopted, so the competing link its own docstring describes was never
there. And the label check looped two subcommands rather than the seven
the parser declares.

The revert matrix reaches fifty-four, including the seven guards it could
not previously see: the below-one check whose absence would stamp
`round=0` and make an issue permanently unreadable, the three shape
guards, the OWNER/NAME slash count, and the two Windows-only behaviors,
which are asserted on the argument rather than the bytes since on Linux
the two are identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An issue carrying a metadata block and no label is invisible to every
read here, since all of them filter on the label. Two rounds closed that
hazard on the `newest_closed` and `adopt` sides and left it open on the
third. `link` guarded its successor for a block and never for the label,
so handing the lane to a half-applied `adopt` closed the predecessor and
left the track with no findable head, and the next `new` then filed a
second live head naming the same predecessor at the same round. Every
step reported success. `link` now refuses and prints the `adopt` command
that finishes the labeling.

`adopt` shared one refusal between two states, so an issue with a block
and no label was told there was nothing to adopt when the label was the
whole remaining step. The two now say what they mean, and the second
names the values the block actually holds so a re-run can match them.

The suite had one systematic hole behind three of those findings: no case
asserted that a write carries the prose it moves. `new` could file a body
holding nothing but the marker and `link` could erase the handoff it was
pointing at, both with every test green. `require_adopted` was also
covered at one of its three call sites, and two early returns whose
absence duplicates a write were invisible. The revert matrix reaches
sixty-two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous round taught `link` to refuse a successor carrying a block
and no label, and to print the `adopt` command that finishes labeling it.
That command always refused in the one state that printed it: the
predecessor is still open and labeled on the track, so the occupied-track
guard counted it as a competitor. The predecessor a successor's own block
names is the link it replaces rather than a second one competing with it,
so the guard now excludes it. While stuck, every read reported the lane
healthy and `new` filed a second head naming the same predecessor at the
same round, which is the fork the round set out to close, re-entered
through the round's own new refusal.

`new` consults the closed side so a closed-out lane is chained onto
rather than restarted, and `adopt` is the other write that can start a
chain. With no `--previous` it wrote `previous=none` beside an existing
chain and exited 0, and because that walk ends cleanly at the block,
nothing told a later `--grep` its search had never reached the chain
holding the text. It now refuses and names the newest closed link. An
issue no longer counts as an unreadable link blocking its own adoption.

The case that stood over the first defect asserted the recovery string
rather than that the command it names can run, so it proved the refusal
and not the recovery. It now runs the printed command through to a
labeled issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous round taught `adopt` to refuse starting a second chain
beside a closed one, and read only the closed side to decide it. A lane's
whole chain can be one open head with nothing closed behind it, which is
the ordinary state after the first `new`, so adopting onto exactly that
lane printed "this track has no chain" and filed a second root. Where the
adopted issue's number is higher than the head's, the next `new` then
chains onto the stray and orphans the real round one, every step exiting
0. `track_links` reads open and closed together, and the refusal names
the newest link of either.

The `--previous` path had the sibling gap: it verified the predecessor's
track and round direction and never that the predecessor already had a
successor, so two links could name one predecessor and the second was
unreachable from `chain`.

`--track` was validated with `match` rather than `fullmatch`, so a
trailing newline passed while the block parsed it back without one, and
the two then read as different tracks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten adversarial passes ran over this branch. The first six each found a
new defect in `adopt`, always the same shape: a multi-step write leaving
the chain forked, orphaned, or unrecoverable while every step reports
success. Each round closed the member it found and the next round found
another.

The tenth pass measured it rather than arguing it. A randomised search
over reachable stores, built only from command sequences that exited 0,
found sixty distinct all-green sequences that fork or orphan a track in
twenty thousand trials, and every one of them requires `adopt`. Two
independent controls restricted to `new` and `link` found none in four
hundred trials and none in a further hundred and fifty. The shortest fork
is three commands with no malformed data and no failed write, after which
`chain --grep` answers "no match" over a chain that contains the text.

So `adopt` goes, along with `require_track_free` and `track_links`, which
existed only for it. `new` and `link` write the chain forward from state
the tool itself produced. `adopt` wrote it from state a human typed, and
every guard it needed was discovered one pass at a time rather than
designed.

An issue that predates the chain joins it by hand now, adding the block
to its body and the label to the issue, which the refusals already name.
`require_adopted` becomes `require_marked` so no vocabulary points at a
command that no longer exists.

Five review passes over the skill's own section caught the residue: a
`link` bullet that omitted which of its writes the interrupted-run case
can actually reach, an authorization paragraph that counted a label
riding inside `gh issue create` as a write of its own and so read the
standing grant wider than the rule it cites, a `tracks` bullet justifying
a hand edit with a claim `link` falsifies, a usage line using one
placeholder for two values the tool refuses as equal, and a closed-set
claim about the read side that `chain` and `tracks` contradict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pass that first called the remaining risk ordinary still found one
write that destroys data. `link`'s body edit replaced the whole body from
a copy read earlier in the run, so an edit landing between the two was
discarded with no warning and exit 0, and `link` runs in the recovery
window, which is exactly when a maintainer is most likely editing the
handoff by hand. It now re-reads immediately before writing and refuses
where the body moved.

Three pieces of residue from dropping the seventh subcommand: the walk's
final return was unreachable, since its counter starts as an int and is
only reassigned from a digit capture, so every exit is one of the returns
inside the loop; the test double still handled a label flag no call site
passes; and the track argument still carried a required-mode parameter
whose only caller was removed.

Three paths had no case. The guard that refuses to hand a lane to an
issue carrying a block and no label could be deleted with the suite
green, which is the one guard on the anti-orphan path. `link --dry-run`
was the preview a caller is told to read before trusting this to close an
issue, and no case ran it. And the re-read above needed one. The revert
matrix reaches fifty-four, and one of its entries was mislabelled as
`link`'s where it anchors on a variable in `new`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the seventh subcommand deleted the guard that refuses a
predecessor which already has a successor, and `link` is now the only
path that adopts an issue into a lane. Pointing an orphan at a mid-chain
link therefore gave one predecessor two successors, made the skipped
branch unreachable from every walk, and exited 0 at each step, after
which `chain --grep` answered no match over a chain holding the text with
no stop notice printed. That is a regression this branch's own history
names: the round before last identified the same gap on the same field,
fixed it in the subcommand that has since been removed, and left the
sibling untouched.

`with_marker` stripped with a bare `rstrip()`, which removes all
twenty-five characters Python calls whitespace where the marker grammar
tolerates three. A marker line ending in anything else, a non-breaking
space being the ordinary artifact of pasting back the line `resume`
prints, parses as text to every guard and is then revived by the strip,
so the body ends with two blocks and no command can read the track again.
Reachable through `new` and through `link`, and invisible to `--dry-run`,
which never prints a body.

The refusal listing unreadable closed links was unbounded while this
module caps gh's own stderr at two thousand characters. A measured case
reached twenty-five thousand on one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard added last round answered None both for "no successor" and for
"could not tell", so it passed wherever it was blind. Every other reader
in this module separates those two states. Three ways through it, each
exiting 0 and each forking the lane: a closed side that filled its read
window, a successor whose block could not be parsed, and a successor
carrying no label. The first two are now refusals naming what could not
be read. The third is answered at the walk instead, which stops at a link
carrying no label, since the chain is indexed by that label and a walk
reaching what the list reads cannot see makes the two disagree about what
the chain contains.

`new` had no such guard at all, while resolving a lane's head by issue
number where `link` orders by round. Two commands, both exiting 0, then
forked a fully readable three-issue lane and regressed its round. It runs
the same guard now, before the create rather than after, since a refusal
that leaves an issue behind is a refusal that changed something.

Last round's strip fix narrowed its class without closing it. A marker's
tail is an ordered suffix rather than a set, so removing any character
that suffix may itself hold can revive a line: `-->\r ` is not a block
and `-->\r` is. Only newlines are stripped now, which is the one thing
the tail can never hold, since the line anchor sits before it.

And the bound put on one refusal last round was left off its sibling one
function away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything about a chain is ordered by round, and `newest_closed` picked
a closed lane's head by issue number instead. The two agree only where
the links were filed in numeric order, and they disagree wherever a
lower-numbered issue joined the lane later.

The consequence took two commands, each exiting 0 and each doing the job
it exists for. `link` recovers an interrupted `new`, correctly. `new`
then resolves a different link as the head, files onto it, and the lane
ends with two links claiming one round, the real chain unreachable, and
`chain --grep` answering no match over text that is in the lane with no
stop notice printed. The guard added last round did not catch it, because
it asks whether anything succeeds the head it was handed, which is the
wrong question when the head itself is wrong.

The head is now the highest round, with the issue number breaking a tie
between two links claiming one round, which is itself a defect a reader
should not have to resolve. Three cases catch the regression, checked by
putting the old key back, and one of them is the two-command sequence end
to end with the searchability half asserted rather than only the shape.

An unreadable closed link is now refused outright rather than compared by
number. Its round compares with nothing, so it could outrank every
readable link, which makes naming a head unprovable rather than merely
uncertain. The previous behavior used the number as a proxy for that
comparison, and the number is exactly the key this commit removes.

`scripts/README.md` said settling an unmarked labeled issue is a hand
edit because no subcommand writes to an issue the chain did not create.
`link` does precisely that, which the same section says two paragraphs
later. The reason is now the one that holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eing True

The refusal over an unreadable closed link was added breadth-first and
named no way out, where its open-side twin ends by saying to add the
block by hand or take the label off. One closed issue carrying the label
and no block blocks every track in the repository until someone fixes it,
so the message now says which edit does that. Its possessive was also
built by interpolating a quoted slug, which rendered as `'lane''s`.

The comment above `new`'s anti-fork guard still explained it by the
head being resolved by issue number, which the commit before this one
removed. A head can already have a successor whatever key resolved it,
an interrupted run being the ordinary way, so the reason is now that
rather than a mechanism the tree no longer has.

The read path's own version of that check is filed as #1500 rather than
fixed here. Every write path refuses on the states it covers, so no wrong
write is reachable through it, and what is reachable is a wrong answer on
a lane that is already irregular.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remedy added last commit was lifted from `require_marked`, where it
is attached to the bodies carrying no block and deliberately not to the
bodies carrying two. `newest_closed` merged both classes into one list
and appended it to both, so the owner of a doubled block was told to add
a block, which gives it three, and the refusal that follows says so. The
per-issue message for that class already carries its own remedy, and the
`cut` bound makes the mismatch worse rather than better, since the wrong
appended sentence always survives truncation while the correct per-issue
ones do not. The two classes are now separate lists and the appended
sentence covers only the class it is true for.

The comment above `new`'s anti-fork guard named an interrupted run as a
route to it. That is not a route: an interrupted run leaves two open
links, and the ambiguity refusal fires before the guard is reached. A
hand-edited lane does reach it, on either side, which is the case the
existing tests build. The stated harm was also wrong on the closed-side
shape, where the rounds do not collide and what is lost is the skipped
branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 10, 2026 01:30
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: e0040229-b953-4a04-a1d8-7fd4a965c407


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

scripts/handoff.py has a real error-handling gap where unexpected gh label list --json name row shapes can raise unhelpful unmodeled exceptions instead of a clear execution failure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR implements issue-based session handoffs by replacing scratch-file handoffs with a chain of GitHub issues labeled handoff, supported by a new hub script (scripts/handoff.py) plus extensive unit tests and documentation updates across the repo’s governance, repo-config, and skills surfaces.

Changes:

  • Add scripts/handoff.py (6 subcommands) to read/write the handoff chain with a strict 0/1/2 exit-code contract and --dry-run on writers.
  • Add a comprehensive stubbed-gh test suite (scripts/tests/test_handoff.py) validating ordering, refusals, and write confirmations.
  • Declare and document the new fleet handoff label and update the session-handoff/backlog-burndown guidance to exclude handoff issues from backlog ranking/counts.
File summaries
File Description
scripts/tests/test_handoff.py New unit test suite exercising handoff.py behavior against a stubbed gh.
scripts/README.md Documents handoff.py, adds local test invocation, and updates script taxonomy.
scripts/handoff.py New handoff-chain CLI implementing read/write subcommands and safety/validation rules.
reports/canonical-review.json Updates canonical review digests/stamps for modified/new canonical units.
repo-config/README.md Documents handoff as a surface marker and adds label taxonomy entry.
repo-config/labels.json Adds the handoff label to the fleet label set.
GOVERNANCE.md Updates “Communicating with the User” section references to include session-handoff.
AGENTS.md Replaces scratch-file handoff rule with chained-issue handoff rule.
.github/skills/session-handoff/SKILL.md Generated skill copy for session-handoff guidance.
.github/skills/backlog-burndown/SKILL.md Generated skill copy excluding handoff from ranking/counts.
.github/skills/agent-conduct/SKILL.md Generated skill copy updated to reference session-handoff.
.claude-plugin/fleet-skills/skills/session-handoff/SKILL.md Generated Claude plugin skill for session-handoff.
.claude-plugin/fleet-skills/skills/backlog-burndown/SKILL.md Generated Claude plugin skill with handoff exclusion guidance.
.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md Generated Claude plugin skill updated to reference session-handoff.
.claude-plugin/fleet-skills/.source-digests/session-handoff Records source digest for generated session-handoff skill.
.claude-plugin/fleet-skills/.source-digests/backlog-burndown Updates source digest for generated backlog-burndown skill.
.claude-plugin/fleet-skills/.source-digests/agent-conduct Updates source digest for generated agent-conduct skill.
.claude-plugin/fleet-skills/.claude-plugin/plugin.json Registers the new session-handoff skill in the plugin.
.agents/skills/session-handoff/SKILL.md Source skill defining the session-handoff contract and usage.
.agents/skills/backlog-burndown/SKILL.md Source skill updated to exclude handoff from ranking/counts.
.agents/skills/agent-conduct/SKILL.md Source skill updated to reference session-handoff at the handoff moment.
Review details
  • Files reviewed: 21/21 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/handoff.py Resolved
ptr727 and others added 3 commits September 9, 2026 19:06
Copilot found that `require_label` checked the outer list and then read
`row["name"]` off each row unguarded, so a row of another shape raises out
of the comprehension and lands on the blind catch, which reports exit 2
without saying what `gh` returned.

The same exposure sat in three sibling readers, each of which validated
the array and none of which validated a row, so this closes the class
rather than the member. `rows_of` takes the field its caller is about to
read and refuses anything else, naming the list and the row.

One case had to move: it produced its unmodeled exception with a row
missing `number`, which is now a named failure. It uses a timestamp `gh`
would never emit instead, which no reader validates because none could
name every field's own grammar, so the blind catch is still exercised.

Answers the review on #1501.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its docstring said each reader validates what it is about to subscript,
which is wider than what it does. The commands consuming those rows read
fields of their own, `updatedAt` and `title` and `url` and `state` among
them, and an absent one still lands on the blind catch. The promise now
stops at the field the reader asks `gh` for, and says where the rest go.

The case named for the type half of that check did not pin it: a plain
string fails the missing-field half on its own, so removing the type
check left the case green. It now drives a row that carries the field
name without being an object, which is what only the type check catches,
and it fails on all three shapes when that clause is removed.

The message spent its bound on its own prefix rather than on the row, so
a long repository name left six characters of the row visible, and it
truncated silently. It goes through `cut` now, like every other bounded
message here, which says how much it left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docstring said a plain string proves nothing about the type check,
while the case's own second row is a plain string, picked because
`"name" not in "name"` is false and the membership half therefore lets it
through. The third row is not a container at all, so the membership test
raises rather than passing. Three routes to one clause, and the text
described one of them as the reason to disregard another. A later editor
following it would have dropped a row and un-pinned a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 10, 2026 02:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It introduces a new GitHub-writing operational workflow (creating/commenting/closing issues) and updates core governance/agent contracts, so it warrants final human review despite strong test and documentation coverage.

Review details
  • Files reviewed: 21/21 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@ptr727
ptr727 merged commit b48bbf7 into develop Sep 10, 2026
9 checks passed
@ptr727
ptr727 deleted the feature/session-handoff-chain branch September 10, 2026 04:11
ptr727 added a commit that referenced this pull request Sep 10, 2026
…g, and the Release-Trigger Retirement (#1489)

Promotes thirteen merged pull requests from `develop` to `main`.

## What this promotes

- **#1486** State the `host-tools.json` carry rule and stop the
empty-overlay
warning. `spec/files.json` now requires the declaration unconditionally,
and
  `host_gate.py` no longer warns on an empty one.
- **#1484** Fan the validator's pytest leg out over a `python-versions`
input,
so a caller with more than one interpreter no longer silently loses its
other
legs to a pinned 3.13, with an entry guard that refuses a value
`fromJson`
  would otherwise admit.
- **#1476** Retire the publish-on-merge release trigger, which
`spec/files.json`
applied to an interface contract whose task has no publish-on-merge
path.
- **#1479** Ship the release caller snippet and retire the
not-yet-released
  claim.
- **#1474** Exclude `node_modules` from markdownlint and Docker lint
discovery
  at any depth.
- **#1468** Move the carried-surface cluster off decision, recording the
  measurements and what stays unmeasured on the issues that hold them.
- **#1498** Correct the release-caller snippet's publish-on-merge claim,
raised by CodeRabbit against this promotion and fixed on its own branch,
  since a promotion PR's head is `develop`.
- **#1494** Settle the markdown lint exclusion seam as a nested config.
- **#1492** Check declared deployment environments and fix the
carried-payload
  rule.
- **#1501** Move the session handoff to a chain of labeled issues. Two
of its
units are verbatim fidelity, `AGENTS.md` "Session Scope" and
`GOVERNANCE.md`
"Communicating with the User", so every fleet repo picks them up on its
next
  resync.
- **#1505** Refuse a bare handoff link rather than reading it as no
successor,
  answering a finding raised against this promotion.
- **#1504** Reject a deployment environment name or branch carrying
surrounding
  whitespace, answering two more.
- **#1506** Encode an environment name before it becomes a path segment,
delete
a branch that cannot fire, and dedupe a registry name by case, answering
two
  more and declining one on a reproduction.

## Closing references

A `develop`-targeted pull request registers no closing reference, so the
issues
those pull requests settled close with this promotion.

#1470 is the one that needs a word. Two of its seven acceptance items
are not
met as literally written, and the maintainer's decision to close it here
anyway,
with the basis for both, is recorded on the issue itself.

Closes #1483
Closes #1473
Closes #1487
Closes #1482
Closes #1475
Closes #1470

## Left open deliberately

- **#1499**, **#1500** follow-ups #1501 filed rather than fixed.
- **#1503** the validator-looser-than-its-consumer class, filed from
findings on
this promotion and deliberately not closed by it. Five instances
recorded, and
  the fix is a design decision rather than a check.
- **#1497** the Docker delta list never says to remove the `push:`
block.
- **#1472** the fleet-level pin call, deliberately not pre-empted by
#1479.
- **#1321**, **#1464** carry #1468's decision record and remain in their
own
  scope.

## Verification

`origin/main..origin/develop` merges clean, no end-of-line conflict.
Each
promoted pull request ran the fleet gate set on its own branch before
merging;
this promotion adds no content of its own.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01817rckKywjfoaEV5WF9TKs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants