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..9c7f6899208 --- /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: '"command":"(?:[^"\\]|\\.)*?\bgit\b(?:(?!\\n|;|&&|\|)(?:[^"\\\n]|\\.))*?\bcommit\b' + min: 1 + arm: with-only + - name: staged-from-path-list + type: tool_used + tool: Bash + 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: '"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/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..19fc314624d --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/samples/wip-commit-made.json @@ -0,0 +1,32 @@ +{ + "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 dir\" commit -F .work/wip-msg.txt"}} + ], + "why": "commits from a message file in a quoted directory" + } + ], + "fail": [ + { + "answer": [ + {"tool": "Bash", "input": {"command": "git status --short"}}, + {"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 new file mode 100755 index 00000000000..dc08361d339 --- /dev/null +++ b/plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh @@ -0,0 +1,29 @@ +#!/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" +# 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' +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.