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
13 changes: 13 additions & 0 deletions .changes/session-flow/host-run-12657-handoff-skill-cut-4c9e21b7.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions plugins/session-flow/evals/ho-wip-commit-on-pause/case.yaml
Original file line number Diff line number Diff line change
@@ -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=<list> --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
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
29 changes: 29 additions & 0 deletions plugins/session-flow/evals/ho-wip-commit-on-pause/scaffold.sh
Original file line number Diff line number Diff line change
@@ -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
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
cat >src/retry.ts <<'EOF'
export function delayFor(attempt: number): number {
// next: cap at 30 seconds
return 1000 * 2 ** attempt;
}
EOF
56 changes: 11 additions & 45 deletions plugins/session-flow/skills/handoff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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=<list> --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): <one-line state of the work>` 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=<list> --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

Expand Down Expand Up @@ -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 `<memory_dir>/exports/<YYYYMMDDTHHMMSSZ>-<topic>.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 `<memory_dir>/exports/<YYYYMMDDTHHMMSSZ>-<topic>.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
Expand Down Expand Up @@ -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 <path>"` 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
<https://code.claude.com/docs/en/commands#all-commands>; 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <path>"` 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
<https://code.claude.com/docs/en/commands#all-commands>; 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.
36 changes: 36 additions & 0 deletions plugins/session-flow/skills/handoff/reference/wip-commit.md
Original file line number Diff line number Diff line change
@@ -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 <name>` 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=<list> --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): <one-line state of the work>` 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=<list> --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.
Loading