Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "session-handoff",
"source": "./plugins/session-handoff",
"description": "End-of-session handoff with bucket-aware dispatch across the canonical 7-bucket docs/ taxonomy (aligned with memory-hygiene v3.3), doc-freshness reverse-lint + skill-freshness audit, and future-to-do emission as GitHub issues. Captures knowledge, routes artifacts to decisions/runbooks/analysis/references/reviews/handoffs/deliverables, updates memory, prepares next-session prompts, and catches stale normative guidance.",
"version": "1.23.0"
"version": "1.24.0"
}
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ hook in a genuine emergency with `git push --no-verify` (the server gate still a

## Version History

- **1.24.0** — **The step that verifies a rebuilt docs branch was comparing the wrong two things, and cried wolf twice in one afternoon.** Step 20's rebuild path (`git reset --hard origin/main` + cherry-pick, for when the feature branch was already squash-merged) ended with `git diff --stat origin/main..HEAD` to confirm only the docs files remained. **Two dots compare tip to tip**, so every file `main` gained after you branched renders as a deletion *you* appear to be making. That is correct only in the instant of the hard reset and wrong the moment a parallel session merges before you push — which is the exact situation the step exists for. In the repo where this was found `main` moved four times in two days; the line fired spuriously **twice**, each time triggering an investigation into a data loss that had not happened, one of them large enough to get its own exploration prompt written for it. Verified against GitHub this session: for a real PR, GitHub reported 5 files / 219 insertions / 16 deletions and `git diff --stat <base>...<head>` reproduced it exactly, while the two-dot form returned a different file set and invented deletions. Now three dots, which diff from the fork point and answer *what does this branch propose*. Alongside it, a second line the step never had: `git diff --diff-filter=D --name-only origin/main...HEAD`, **which must be empty.** The hazard it catches is real and was reproduced here — a branch whose *tree* is stale (from `git reset --soft`, or an old worktree committed with `git add -A`) still passes `git merge-base --is-ancestor`, still reports 0 commits behind, and a fast-forward push then replaces `main`'s tree wholesale; in the source repo one such PR deleted a 322-line client-facing file while describing itself as a copy fix, and a follow-up PR restored it 17 minutes later. Also hardened, deliberately: the two `"$BASE"..HEAD` ranges in step 24b and `reverse_lint_step.sh`. `BASE` is contractually a SHA on the branch's own history — where that holds, two dots and three dots are byte-identical — but nothing enforces it, and step 20's own rebuild destroys the ancestor property outright. Both now use three dots and both gain a `git merge-base` guard, so a `BASE` from an unrelated history reports **SKIPPED** with its reason instead of letting an erroring diff read as "nothing changed". Left alone on purpose: `git log --oneline origin/BRANCH..HEAD` in step 19 — in `git log`, two dots mean "commits on B not on A", which is exactly what "my unpushed commits" wants; three dots there would be the symmetric difference and wrong.
- **1.23.0** — **A prompt INDEX row rots on a different clock from the page it points at, and the row is what gets read first.** Step 26a makes the next prompt reachable; nothing made it *true*. Many repos keep a one-row-per-prompt index whose rows carry a summary and a status — two copies of the same claim, maintained by different sessions at different times, with nothing forcing agreement. Measured 2026-08-07, **both rows on the same index were wrong in opposite directions while both prompt pages were current**: one said *"what is left is four files and five ledger entries, verified absent from `main`"* when all four were present and the page's own foot already said *"the file restoration is finished"* — so it **advertised finished work as the remaining work** and the actual remainder went unadvertised; the other said *"expect `main` to be red for reasons of its own (#839)"* when that issue was closed and `main` measured green. **The second is the dangerous direction**, because a stale "expect it to be broken" does not merely misinform, it disables a check — the reader has been pre-authorised to ignore red. New **step 25d** sits beside 25c (which checks your own work survived) and checks the pointer to the NEXT work: re-derive the row's headline claim rather than re-reading it, check the row agrees with its own page (a page corrected at its foot while the row keeps the superseded summary is the normal failure), and grep the figure across the repo because the same sentence is usually pasted into a ledger entry too — it was. It also covers closing the row when you close the item (a row still marked live for a merged PR sends the next session to a deleted branch) and one structural check: **is the page's remaining work at the TOP?** A prompt that grows by appending dated corrections becomes a long finished job with the live work in a coda after the archive, and a reader working top-down re-runs the completed part — measured at 200 lines of finished work ahead of a ten-line remainder.
- **1.22.0** — **Two steps that reported success having done nothing — the exact failure this skill exists to catch, in its own text.** (1) **Step 24c silently destroyed step 24d's work.** 24c writes the usage record with `>`, 24d then merges `review_findings` / `review_summary` into that same JSON and appends a table to the same `.md`, and 24c's own sanity-check tells you to "redirect its output over the fork's files" — so a refresh, which the numbers moving mid-session make the normal thing to do, replaced both files wholesale. Nothing warned. The record still had tokens, models and transcript counts; it just had no findings, and the cross-session roll-up they exist for was gone. On 2026-08-07 a session refreshed near the end to pick up later subagents and wiped **fifteen merged findings**, caught only by listing the JSON's top-level keys afterwards. 24c now names the overwrite, fixes the order (24c then 24d, never the reverse), **carries 24d's two fields across the refresh in the fence itself**, and ends by printing the top-level keys — the print is the verification, and `review_findings` must be in it. The Markdown table has no carry and must be re-appended by hand; that is said, not glossed. (2) **The skill-freshness audit passes without opening the file you edited.** Step 24b's audit scans the two roots a skill is *installed* under; a plugin skill's SOURCE — the marketplace checkout or clone that **step 24e explicitly tells you to edit** — is a third place it never looks. Measured on the author's machine: 247 skills audited, `session-handoff` reported from the plugin cache at **1.20.0**, zero results from any `marketplaces/` path, while the repo being edited was at **1.21.0**. It passed, on the wrong file, one version behind. Two more reasons the pass means little: the trigger greps `git diff` in the CURRENT repo, so editing a plugin skill in a separate repo never fires it; and the cache copy's age comes from `mtime`, the install time, so it reads `0d` regardless of contents. 24b now states its scope, gives the direct check that closes the gap (version bumped in every place that repo records it — verified by grep, not memory, five places here — and that repo's own gates run), and states the general form: **an audit that reports "all clean" without naming what it examined has told you nothing.** Prefer a check that prints its scope and compare that scope against what you changed; `--human` prints its roots on line one. **And a third of the same shape, found by running this repo's own gate to ship the other two: the pre-push hook was corrupting the repo it guards.** Git exports `GIT_DIR` / `GIT_INDEX_FILE` into every hook, and a child's `cwd` does not override an inherited `GIT_DIR` — so `npm test` run from `.githooks/pre-push` handed those variables to the suite's fixture repos, whose `git init` / `config` / `commit` then wrote into the **real** repo. One green push left `core.bare=true`, `user.name=t`, the pushed branch ref pointing at a fixture commit, and an empty index; `git status` afterwards answered `fatal: this operation must be run in a work tree`. Nothing was lost — the push succeeded and the commit was intact on `origin` — but the tests were green, the hook printed "tests passed", and the damage was invisible until the next git command. The fixture helper now scrubs every `GIT_*` variable before spawning git, and the hook unsets them as a second layer; reproduced against a throwaway repo and confirmed fixed against the same.
- **1.21.0** — **A green merge is not evidence your work survived it.** New **step 25c**, a third closing check beside 25a/25b and non-blankable like them: after the LAST merge of the session, verify your own work still exists on `main`. On 2026-08-07 a PR whose parent WAS current `main` — so every "am I behind?" check passed — deleted **59 files and 5,081 lines** already merged, belonging to four sessions, with no conflict and every gate green including the project's own validator. **One session reported its wrap-up complete while four of its files and six of its ledger entries were already deleted**, and did not find out for 3½ hours. No gate catches it because a deletion is a valid state of a file. Three passes, because each catches what the previous cannot: files exist · content survived (a file can exist and be rolled back) · **ledger TEXT, not just ids** — a task can survive by `id` while its `detail` is reverted to pre-session wording byte for byte, which no id check and no validator sees. Plus splice-never-revert recovery and the broadcast step, since losses are per-session and a wrapped session's are found by nobody.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "session-handoff",
"version": "1.23.0",
"version": "1.24.0",
"private": true,
"description": "End-of-session handoff with bucket-aware dispatch across the canonical 7-bucket docs/ taxonomy (aligned with memory-hygiene v3.3) and doc-freshness reverse-lint. Captures knowledge, routes artifacts across decisions/runbooks/analysis/references/reviews/handoffs/deliverables, updates memory, prepares next-session prompts, and catches stale normative guidance.",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/session-handoff/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "session-handoff",
"description": "End-of-session handoff with bucket-aware dispatch across the canonical 7-bucket docs/ taxonomy (aligned with memory-hygiene v3.3), doc-freshness reverse-lint + skill-freshness audit, and future-to-do emission as GitHub issues. Captures knowledge, routes artifacts to decisions/runbooks/analysis/references/reviews/handoffs/deliverables, updates memory, prepares next-session prompts, and catches stale normative guidance.",
"version": "1.23.0",
"version": "1.24.0",
"author": {
"name": "Huiyan Wan"
},
Expand Down
20 changes: 16 additions & 4 deletions plugins/session-handoff/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: session-handoff
description: "End-of-session handoff that captures session knowledge, dispatches output across the canonical 7-bucket docs/ taxonomy (decisions/runbooks/analysis/references/reviews/handoffs/deliverables — aligned with memory-hygiene v3.3), triggers a doc-freshness reverse-lint + skill-freshness audit to catch stale normative guidance, emits the future-to-do plan's follow-up items as GitHub issues, updates memory, and prepares next-session prompts. Use when: (1) user says 'wrap up', 'hand over', 'create handoff', 'end of session', 'write handoff', 'session handoff'; (2) non-trivial work session (3+ tasks) is ending; (3) context window is approaching limits; (4) user says 'consolidate', 'what's the current state', 'start here document' after parallel sessions; (5) the session produced artifacts that belong in more than one docs/ bucket (ADR + analysis + runbook + review). Includes cross-session consolidation when 3+ handoffs accumulate and a mandatory reverse-lint verify step against any lessons.md / feedback_*.md touched this session."
version: 1.23.0
version: 1.24.0
triggers:
- "wrap up"
- "session handoff"
Expand Down Expand Up @@ -495,7 +495,14 @@ skipped: gh unavailable" in the handoff doc — never block the handoff on it.
20. **Commit all session work** — stage and commit in logical groups:
- **Code changes first:** feature code, bug fixes, tests (one commit with descriptive message)
- **Docs second:** handoff doc, next-session prompt, plan updates, lessons (separate commit)
- If the session already has multiple commits on a feature branch, add docs as a new commit on the same branch — **unless that branch was already squash-merged.** Check first: `gh pr list --head <branch> --state merged`. If it merged, the branch still carries its pre-squash commits (squash-merge never marks them merged locally), so a docs PR from its HEAD shows the WHOLE feature diff and can conflict with parallel streams that touched the same files after the squash. Instead: commit the docs where you are, then rebuild — `git reset --hard origin/main` + `git cherry-pick <docs-sha>...` onto a fresh docs branch (docs files rarely overlap the feature files, so the picks are clean). Verify with `git diff --stat origin/main..HEAD` that ONLY the docs files remain before pushing.
- If the session already has multiple commits on a feature branch, add docs as a new commit on the same branch — **unless that branch was already squash-merged.** Check first: `gh pr list --head <branch> --state merged`. If it merged, the branch still carries its pre-squash commits (squash-merge never marks them merged locally), so a docs PR from its HEAD shows the WHOLE feature diff and can conflict with parallel streams that touched the same files after the squash. Instead: commit the docs where you are, then rebuild — `git reset --hard origin/main` + `git cherry-pick <docs-sha>...` onto a fresh docs branch (docs files rarely overlap the feature files, so the picks are clean). Verify before pushing — **three dots, not two**:

```bash
git diff --stat origin/main...HEAD # only the docs files
git diff --diff-filter=D --name-only origin/main...HEAD # must be empty
```

`A..B` compares tip to tip, so everything `main` gained after you branched renders as deletions *you* appear to be making; two dots are right only in the instant after the reset and wrong the moment a parallel session merges — which is exactly the situation this step exists for. `A...B` diffs from the fork point and reproduces what GitHub shows on the PR. Run the deletion line even when the stat looks fine: a branch whose *tree* is stale (a `git reset --soft`, or an old worktree committed with `git add -A`) is still a clean fast-forward reporting 0 commits behind, and the push then replaces `main`'s tree wholesale. Measured: one such PR deleted a 322-line client-facing file while describing itself as a copy fix. This is the one command that sees it.
- If uncommitted work is on `main`, create a feature branch first: `git checkout -b feat/sN-description`

21. **Push and create PR:**
Expand Down Expand Up @@ -579,7 +586,8 @@ skipped: gh unavailable" in the handoff doc — never block the handoff on it.
- ≥1 candidate → add a **"Stale docs to review"** section to `session_N_handoff.md` with
`file:line` references and the triggering rule. **Never auto-edit** the flagged docs; the
human decides what to update.
- If the resolver fails, or `BASE` is not a real revision, report the step as **skipped** in
- If the resolver fails, or `BASE` is not a real revision (or shares no history with
`HEAD`), report the step as **skipped** in
the summary table — **never as clean**. "Clean" and "never ran" must not look alike.
- **A benign SKIPPED is common and is not a defect — say which kind it is.** The
lint scans lesson/axiom/feedback files tracked *in the repo*. In a project whose
Expand All @@ -601,7 +609,11 @@ skipped: gh unavailable" in the handoff doc — never block the handoff on it.

if ! git rev-parse --verify --quiet "$BASE" >/dev/null; then
echo "skill-freshness: SKIPPED — BASE '$BASE' is not a revision"
elif git diff --name-only "$BASE"..HEAD | grep -qE '(^|/)SKILL\.md$'; then
elif ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then
# Three dots need a fork point. No shared history means no answer — say so
# rather than let an erroring diff read as "no SKILL.md was touched".
echo "skill-freshness: SKIPPED — BASE '$BASE' shares no history with HEAD"
elif git diff --name-only "$BASE"...HEAD | grep -qE '(^|/)SKILL\.md$'; then
# Resolve the bundled script — plugin install first, then git-clone install:
SFA="${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT}/scripts/skill_freshness_audit.py}"
[ -f "$SFA" ] || SFA="$HOME/.claude/skills/session-handoff/scripts/skill_freshness_audit.py"
Expand Down
15 changes: 14 additions & 1 deletion plugins/session-handoff/scripts/reverse_lint_step.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,27 @@ if [ -z "$BASE" ] || ! git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; t
exit 0
fi

# BASE resolving is not the same as BASE being on this branch's history. Step 20's
# rebuild (reset --hard + cherry-pick) destroys that property outright, and nothing
# here can restore it — so the diff below uses three dots, which asks "what has this
# branch done since the fork point" instead of "how do these two tips differ". The two
# forms are identical whenever BASE IS an ancestor, which is the contracted case; where
# they differ, two dots reports main's own newer files as though this session deleted
# them. Three dots need a fork point to exist at all, so check for one first rather than
# let an erroring diff pass for an empty one.
if ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then
echo "reverse-lint: SKIPPED — BASE '$BASE' shares no history with HEAD"
exit 0
fi

# Three sources, because a lessons file written this session may be in any of them:
# committed since BASE · modified but not committed · created and never added.
# The third is the most common case for a brand-new lessons.md, and `git diff` does not
# list untracked files — omitting it made this step scan nothing on exactly the session
# that had the most to check.
FILES=$(
{
git diff --name-only "$BASE"..HEAD 2>/dev/null
git diff --name-only "$BASE"...HEAD 2>/dev/null
git diff --name-only 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null
} |
Expand Down
Loading