From 6eee88193076b9764ea785bfec56d269012df4d6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:24:57 -0500 Subject: [PATCH 1/2] perf(session-flow): move handoff WIP-commit mechanics to a reference file The /session-flow:handoff hub loaded the WIP commit's refusal checks, staging steps and record rules, and the /export verification record, on every invocation, though only a pause with wip_commit on needs the first and the second is provenance. Both move verbatim to reference files; the hub keeps when the commit applies, how the setting resolves, and a pointer, plus a reference index with a row per file. Body bytes (frontmatter stripped): 36,785 on main, 34,609 here. No case read the moved WIP-commit text, so ho-wip-commit-on-pause turns wip_commit on in the scaffold and checks for one local commit staged with --pathspec-from-file and no push. Refs: #12657 Co-Authored-By: Claude Opus 5.5 --- ...st-run-12657-handoff-skill-cut-4c9e21b7.md | 13 +++++ .../evals/ho-wip-commit-on-pause/case.yaml | 33 +++++++++++ .../samples/no-push.json | 19 +++++++ .../samples/staged-from-path-list.json | 24 ++++++++ .../samples/wip-commit-made.json | 26 +++++++++ .../evals/ho-wip-commit-on-pause/scaffold.sh | 26 +++++++++ plugins/session-flow/skills/handoff/SKILL.md | 56 ++++--------------- .../handoff/reference/export-verification.md | 14 +++++ .../skills/handoff/reference/wip-commit.md | 36 ++++++++++++ 9 files changed, 202 insertions(+), 45 deletions(-) create mode 100644 .changes/session-flow/host-run-12657-handoff-skill-cut-4c9e21b7.md create mode 100644 plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml create mode 100644 plugins/session-flow/evals/ho-wip-commit-on-pause/samples/no-push.json create mode 100644 plugins/session-flow/evals/ho-wip-commit-on-pause/samples/staged-from-path-list.json create mode 100644 plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json create mode 100755 plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh create mode 100644 plugins/session-flow/skills/handoff/reference/export-verification.md create mode 100644 plugins/session-flow/skills/handoff/reference/wip-commit.md diff --git a/.changes/session-flow/host-run-12657-handoff-skill-cut-4c9e21b7.md b/.changes/session-flow/host-run-12657-handoff-skill-cut-4c9e21b7.md new file mode 100644 index 00000000000..4daf4cb774e --- /dev/null +++ b/.changes/session-flow/host-run-12657-handoff-skill-cut-4c9e21b7.md @@ -0,0 +1,13 @@ +--- +bump: patch +--- + +### Changed + +- move the handoff skill's WIP-commit mechanics and /export record out of its hub + + The /session-flow:handoff SKILL.md body drops from 36,785 to 34,609 + bytes (frontmatter stripped). The WIP commit's refusal checks, staging + and record, and the /export verification record, move verbatim to + reference files read on demand, listed in a new reference index. A new + ho-wip-commit-on-pause eval case covers the moved WIP-commit text. diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml b/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml new file mode 100644 index 00000000000..5d5e92574cb --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml @@ -0,0 +1,33 @@ +schema_version: "1.1" +name: ho-wip-commit-on-pause +description: 'Handoff WIP commit: in a repository whose docs/conventions/session-flow.yaml sets wip_commit: true, a user-typed /session-flow:handoff that asks to pause makes one local chore(wip): commit of the tracked change, staged from a NUL-separated path list with git add -u --pathspec-from-file, and never pushes. A run needs --scaffold and --allow-tools Bash Write Edit.' +tags: [handoff, wip-commit] +plugins: ["../.."] +expected_outcome: 'The run resolves wip_commit to true from the conventions file, runs the refusal checks (none holds on the feature branch), stages src/retry.ts with git add -u --pathspec-from-file= --pathspec-file-nul, commits it with a chore(wip): subject, records the SHA in the save-point, and ends with the railed resume prompt. It never pushes.' +context: + scaffold_script: scaffold.sh +execution: + prompt: '/session-flow:handoff retry-backoff I am pausing this for the night, save my place' + allowed_tools: [Bash, Read, Write, Edit, Glob, Grep, Skill] + max_turns: 30 + timeout_seconds: 600 +graders: + - name: wip-commit-made + type: tool_used + tool: Bash + input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\bcommit\b' + min: 1 + arm: with-only + - name: staged-from-path-list + type: tool_used + tool: Bash + input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\badd\b(?:(?!\\n|;|&&|\|)[^\n])*?--pathspec-from-file' + min: 1 + arm: with-only + - name: no-push + type: tool_used + tool: Bash + input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\bpush\b' + min: 0 + max: 0 + arm: both diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/no-push.json b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/no-push.json new file mode 100644 index 00000000000..d66c88a2e8c --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/no-push.json @@ -0,0 +1,19 @@ +{ + "pass": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git commit -m \"chore(wip): exponential retry delay\""}}, + {"tool": "Bash", "input": {"command": "git rev-parse HEAD"}} + ], + "why": "commits locally and stops" + } + ], + "fail": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git push -u origin feat/retry-backoff"}} + ], + "why": "pushes the pause commit" + } + ] +} diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/staged-from-path-list.json b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/staged-from-path-list.json new file mode 100644 index 00000000000..25c79c71e0b --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/staged-from-path-list.json @@ -0,0 +1,24 @@ +{ + "pass": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git add -u --pathspec-from-file=.work/wip-paths --pathspec-file-nul"}} + ], + "why": "stages from the NUL-separated list" + } + ], + "fail": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git add src/retry.ts"}} + ], + "why": "types the path on the command line" + }, + { + "answer": [ + {"tool": "Bash", "input": {"command": "git add -A"}} + ], + "why": "stages everything" + } + ] +} diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json new file mode 100644 index 00000000000..9ce6076cb81 --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json @@ -0,0 +1,26 @@ +{ + "pass": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git add -u --pathspec-from-file=.work/wip-paths --pathspec-file-nul"}}, + {"tool": "Bash", "input": {"command": "git commit -m \"chore(wip): exponential retry delay, cap not done\""}} + ], + "why": "commits with a chore(wip) subject" + }, + { + "answer": [ + {"tool": "Bash", "input": {"command": "git -C /tmp/case commit -F .work/wip-msg.txt"}} + ], + "why": "commits from a message file" + } + ], + "fail": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git status --short"}}, + {"tool": "Bash", "input": {"command": "git log --oneline -5"}} + ], + "why": "reads state, never commits" + } + ] +} diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh b/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh new file mode 100755 index 00000000000..d4ba01d1cac --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# A feature branch with an uncommitted half-change to a retry loop, in a repository whose +# session-flow conventions file turns wip_commit on, so a user's pause request commits it. +set -euo pipefail + +git init -q -b main . +git config user.email eval@example.invalid +git config user.name eval + +mkdir -p src docs/conventions +cat >src/retry.ts <<'EOF' +export function delayFor(attempt: number): number { + return 1000; +} +EOF +printf 'wip_commit: true\n' >docs/conventions/session-flow.yaml +git add src docs +git commit -q -m "feat: fixed retry delay" + +git checkout -q -b feat/retry-backoff +cat >src/retry.ts <<'EOF' +export function delayFor(attempt: number): number { + // next: cap at 30 seconds + return 1000 * 2 ** attempt; +} +EOF diff --git a/plugins/session-flow/skills/handoff/SKILL.md b/plugins/session-flow/skills/handoff/SKILL.md index 3d1c4239a01..8bc1e08cb7a 100644 --- a/plugins/session-flow/skills/handoff/SKILL.md +++ b/plugins/session-flow/skills/handoff/SKILL.md @@ -202,37 +202,8 @@ named with its file or option, the key and the value, and that layer is dropped: layer still wins, otherwise `false`. The handoff never stops on it. Report one line, for example `wip_commit: true (docs/conventions/session-flow.yaml)`. Resolved `false`: commit nothing. -**Refuse, commit nothing, and say why in the save-point** when any of these holds. Read each -from git, one command per call: - -- A sequencer operation is in progress: `git rev-parse --git-path ` names an existing path - for `MERGE_HEAD`, `REBASE_HEAD`, `CHERRY_PICK_HEAD` or `REVERT_HEAD`, or for the `rebase-merge` - or `rebase-apply` directory. A commit now would conclude that operation under a pause subject. -- HEAD is detached: `git symbolic-ref -q HEAD` prints nothing. -- HEAD is the default branch: `git symbolic-ref refs/remotes/origin/HEAD` names the same branch - as HEAD. When that command fails (no origin HEAD), the default branch is unknown: refuse. -- A path is partially staged: it appears in both `git diff --cached --name-only -z` and - `git diff --name-only -z`. The unstaged hunks are a choice the user made; staging them undoes it. -- A path outside this work holds staged changes. A commit takes the whole index, so it would - carry that path along. - -**Otherwise commit.** The paths are the tracked files this work changed, the ones the save-point's -"File roles in this work" section names. Paths are data: never type one into a command line. -Write the list NUL-separated to a file with the Write tool, check each entry against -`git diff --name-only -z` output, and stage with -`git add -u --pathspec-from-file= --pathspec-file-nul`, which never picks up an untracked -file. A tracked file changed outside this work stays out of the commit, and the save-point lists it -as left uncommitted. Commit with the subject `chore(wip): ` and the -repository's usual body and trailer rules, with every commit hook running. When a hook rejects the -commit, do not retry it with hooks skipped and do not reword it to get past the hook: unstage the -paths this step staged (`git restore --staged --pathspec-from-file= --pathspec-file-nul`, the -list holding only the paths that had no staged change before), leave the work in the working tree, -and state the hook's message in the save-point. Never push. - -**Record it.** Put the commit SHA and subject under "Side effects already applied", or the refusal -and its reason there instead, and list every untracked path under "File roles in this work", -since the commit left them out. On the prompt-only path, which writes no file, the same facts go -into the resume prompt as one bullet. +Resolved `true`: run the refusal checks, stage and commit, and record the result per +[`reference/wip-commit.md`](reference/wip-commit.md). Never push. ## Hand worktree claims to the resuming session @@ -425,7 +396,7 @@ to wait for dependent work ("Settle in-flight work before writing") wrote no sav remaining-work bullet: path, this session's id, handover before `check-enter` (OR none held) - [ ] `wip_commit` line reported, as on the full path; a commit, refusal or hook rejection is one bullet in the prompt, nothing pushed -- [ ] If /export is available in your session (gate basis: **Verification record: `/export`** below), suggest that the person run it for a durable conversation copy at `/exports/-.txt` after verifying the memory root's self-ignore guard (a `.gitignore` containing `*`; create it and announce it when absent). This skill never invokes `/export` itself. Prompt-only writes no file. **`--unattended`:** record the suggestion; do not ask. +- [ ] If /export is available in your session (gate basis: [`reference/export-verification.md`](reference/export-verification.md)), suggest that the person run it for a durable conversation copy at `/exports/-.txt` after verifying the memory root's self-ignore guard (a `.gitignore` containing `*`; create it and announce it when absent). This skill never invokes `/export` itself. Prompt-only writes no file. **`--unattended`:** record the suggestion; do not ask. - [ ] Purpose text (when the invocation carried any) travels inline as the `Purpose:` line below the goal quote and above the remaining-work bullets (engine doc, "The purpose argument tailors emphasis only"), never discarded; a goal-conflicting purpose flagged rather than obeyed. No @@ -455,20 +426,15 @@ to wait for dependent work ("Settle in-flight work before writing") wrote no sav enough to skip `/clear` and finish in-session"; the rails prompt and its below-rail notes follow these ticks as the response's final text (see "Output order is fixed" above) -## Verification record: `/export` +## Reference index. Load on demand -This skill suggests `/export` to the person and never runs it, and gates the suggestion on the -command being available in the person's session. Our probes back this: on Claude Code 2.1.241 -(2026-08-24), `claude --bare -p "/export "` refused the command as unavailable in that -environment, and on 2.1.263 (2026-09-11) it registered as an interactive local-jsx command, not a -prompt, so the Skill tool never lists it. We found no switch that disables it. - -- **Pointer**: for the `/export` command, see - ; for the headless refusal and the - invocation mode, the probes above. -- **As of**: 2026-09-29 -- **Recheck trigger**: a Claude Code release note or the commands page adds an `/export` format or - redaction flag, a headless or programmatic form, or an official conversation-sharing surface. +| File | Load when | +|------|-----------| +| [reference/settle-in-flight.md](reference/settle-in-flight.md) | Off-thread work is still running before `save_point.py new` or the prompt-only rails prompt. | +| [reference/wip-commit.md](reference/wip-commit.md) | `wip_commit` resolved `true` for a qualifying pause: the refusal checks, staging, the hook-rejection path and what the save-point records. | +| [reference/pending-ci-caveat.md](reference/pending-ci-caveat.md) | Applying claim provenance to a fix still in CI, merge or another unreturned check. | +| [reference/export-verification.md](reference/export-verification.md) | Deciding whether to suggest `/export`, or rechecking the probes behind that gate. | +| [reference/phase-boundary-owner.md](reference/phase-boundary-owner.md) | A resident `/implementation:implement-dispatch` boundary asks who writes the handoff. | ## Next diff --git a/plugins/session-flow/skills/handoff/reference/export-verification.md b/plugins/session-flow/skills/handoff/reference/export-verification.md new file mode 100644 index 00000000000..629818dded3 --- /dev/null +++ b/plugins/session-flow/skills/handoff/reference/export-verification.md @@ -0,0 +1,14 @@ +# Verification record: `/export` + +This skill suggests `/export` to the person and never runs it, and gates the suggestion on the +command being available in the person's session. Our probes back this: on Claude Code 2.1.241 +(2026-08-24), `claude --bare -p "/export "` refused the command as unavailable in that +environment, and on 2.1.263 (2026-09-11) it registered as an interactive local-jsx command, not a +prompt, so the Skill tool never lists it. We found no switch that disables it. + +- **Pointer**: for the `/export` command, see + ; for the headless refusal and the + invocation mode, the probes above. +- **As of**: 2026-09-29 +- **Recheck trigger**: a Claude Code release note or the commands page adds an `/export` format or + redaction flag, a headless or programmatic form, or an official conversation-sharing surface. diff --git a/plugins/session-flow/skills/handoff/reference/wip-commit.md b/plugins/session-flow/skills/handoff/reference/wip-commit.md new file mode 100644 index 00000000000..3143df67cc1 --- /dev/null +++ b/plugins/session-flow/skills/handoff/reference/wip-commit.md @@ -0,0 +1,36 @@ +# WIP commit: refusal checks, staging and record + +Read this once `wip_commit` resolves `true` for a handoff that qualifies ("WIP commit on an +explicit pause" in `SKILL.md`). + +**Refuse, commit nothing, and say why in the save-point** when any of these holds. Read each +from git, one command per call: + +- A sequencer operation is in progress: `git rev-parse --git-path ` names an existing path + for `MERGE_HEAD`, `REBASE_HEAD`, `CHERRY_PICK_HEAD` or `REVERT_HEAD`, or for the `rebase-merge` + or `rebase-apply` directory. A commit now would conclude that operation under a pause subject. +- HEAD is detached: `git symbolic-ref -q HEAD` prints nothing. +- HEAD is the default branch: `git symbolic-ref refs/remotes/origin/HEAD` names the same branch + as HEAD. When that command fails (no origin HEAD), the default branch is unknown: refuse. +- A path is partially staged: it appears in both `git diff --cached --name-only -z` and + `git diff --name-only -z`. The unstaged hunks are a choice the user made; staging them undoes it. +- A path outside this work holds staged changes. A commit takes the whole index, so it would + carry that path along. + +**Otherwise commit.** The paths are the tracked files this work changed, the ones the save-point's +"File roles in this work" section names. Paths are data: never type one into a command line. +Write the list NUL-separated to a file with the Write tool, check each entry against +`git diff --name-only -z` output, and stage with +`git add -u --pathspec-from-file= --pathspec-file-nul`, which never picks up an untracked +file. A tracked file changed outside this work stays out of the commit, and the save-point lists it +as left uncommitted. Commit with the subject `chore(wip): ` and the +repository's usual body and trailer rules, with every commit hook running. When a hook rejects the +commit, do not retry it with hooks skipped and do not reword it to get past the hook: unstage the +paths this step staged (`git restore --staged --pathspec-from-file= --pathspec-file-nul`, the +list holding only the paths that had no staged change before), leave the work in the working tree, +and state the hook's message in the save-point. Never push. + +**Record it.** Put the commit SHA and subject under "Side effects already applied", or the refusal +and its reason there instead, and list every untracked path under "File roles in this work", +since the commit left them out. On the prompt-only path, which writes no file, the same facts go +into the resume prompt as one bullet. From 5d0fbd7246da2cbc4b2cff60235ba3410ecdc580 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:46:00 -0500 Subject: [PATCH 2/2] test(session-flow): give the wip-commit case a default branch The first run on main's skill refused the WIP commit, correctly: the scaffold had no origin/HEAD, and an unknown default branch is a refusal check. The scaffold now points origin/HEAD at main. The graders also matched a Bash call's description field; they now read only its command. Refs: #12657 Co-Authored-By: Claude Opus 5.5 --- .../evals/ho-wip-commit-on-pause/case.yaml | 6 +++--- .../samples/wip-commit-made.json | 10 ++++++++-- .../evals/ho-wip-commit-on-pause/scaffold.sh | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml b/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml index 5d5e92574cb..9c7f6899208 100644 --- a/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml @@ -15,19 +15,19 @@ graders: - name: wip-commit-made type: tool_used tool: Bash - input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\bcommit\b' + input_match: '"command":"(?:[^"\\]|\\.)*?\bgit\b(?:(?!\\n|;|&&|\|)(?:[^"\\\n]|\\.))*?\bcommit\b' min: 1 arm: with-only - name: staged-from-path-list type: tool_used tool: Bash - input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\badd\b(?:(?!\\n|;|&&|\|)[^\n])*?--pathspec-from-file' + input_match: '"command":"(?:[^"\\]|\\.)*?\bgit\b(?:(?!\\n|;|&&|\|)(?:[^"\\\n]|\\.))*?\badd\b(?:(?!\\n|;|&&|\|)(?:[^"\\\n]|\\.))*?--pathspec-from-file' min: 1 arm: with-only - name: no-push type: tool_used tool: Bash - input_match: '\bgit\b(?:(?!\\n|;|&&|\|)[^\n])*?\bpush\b' + input_match: '"command":"(?:[^"\\]|\\.)*?\bgit\b(?:(?!\\n|;|&&|\|)(?:[^"\\\n]|\\.))*?\bpush\b' min: 0 max: 0 arm: both diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json index 9ce6076cb81..19fc314624d 100644 --- a/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json @@ -9,9 +9,9 @@ }, { "answer": [ - {"tool": "Bash", "input": {"command": "git -C /tmp/case commit -F .work/wip-msg.txt"}} + {"tool": "Bash", "input": {"command": "git -C \"/tmp/case dir\" commit -F .work/wip-msg.txt"}} ], - "why": "commits from a message file" + "why": "commits from a message file in a quoted directory" } ], "fail": [ @@ -21,6 +21,12 @@ {"tool": "Bash", "input": {"command": "git log --oneline -5"}} ], "why": "reads state, never commits" + }, + { + "answer": [ + {"tool": "Bash", "input": {"command": "git rev-parse --git-path MERGE_HEAD", "description": "Check for a merge before the WIP commit"}} + ], + "why": "only the description mentions a commit" } ] } diff --git a/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh b/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh index d4ba01d1cac..dc08361d339 100755 --- a/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh @@ -16,6 +16,9 @@ EOF printf 'wip_commit: true\n' >docs/conventions/session-flow.yaml git add src docs git commit -q -m "feat: fixed retry delay" +# The refusal checks read the default branch from origin/HEAD; without it the commit is refused. +git update-ref refs/remotes/origin/main HEAD +git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main git checkout -q -b feat/retry-backoff cat >src/retry.ts <<'EOF'