Skip to content

feat(session-flow): add save_point.py fill so the handoff fills every slot in one call - #4153

Open
kyle-sexton wants to merge 4 commits into
mainfrom
session-flow-handoff-one-edit-per-fill-s
Open

kyle-sexton wants to merge 4 commits into
mainfrom
session-flow-handoff-one-edit-per-fill-s

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Closes #4156

Summary

save_point.py new writes a shape-2 handoff skeleton with 19 to 22 <!-- FILL: ... --> slots, and the write procedure told the model to fill each one with the Edit tool. That made the slot count the write count: one message carrying twenty Edits against one file, and an interrupt mid-batch left a partly filled skeleton that took more turns to find and repair. The fill step had no script surface, so Edit was the only tool that fit a slot.

Fix

  • New save_point.py fill <file> --slots <json>: one JSON object keyed by slot name, every value a string, applied to every slot in one atomic write (temp file beside the target, then rename). A slot line's own terminator is kept, so a CRLF skeleton stays CRLF and a multi-line value takes the replaced line's ending per line. An inline prefix on a slot's line (**Amended:**, **Next action serves it by:**, the did: ... · left: ... pair on one line) is preserved, because only the slot span is replaced.
  • An optional slot (instruction starting optional:) left out of the JSON has its line deleted. A required slot absent, a key naming no slot, a slot name occurring twice, a value carrying the literal slot marker, or a file with no slot exits 1 naming it; a missing or unreadable target, a non-handoff file, a target whose handoff_shape is not 2 (shape 1, no key, below 1, or non-integer), or a slots file that is missing, unreadable, not a JSON object, or holds a non-string or non-UTF-8-encodable value exits 2; a shape newer than 2 exits 3 with validate's read-it-do-not-rewrite-it verdict, so version skew cannot corrupt a future-format handoff. Every refusal leaves the target byte-identical. fill prints nothing on success.
  • Closing handoff: a next value of exactly Next: none (closed) rewrites the bare Next: line above the slot and deletes the slot line, the only shape validate accepts as closed; it refuses when that line is not exactly Next:.
  • reference/structure.md step 6, reference/save-point.md, skills/handoff/SKILL.md, and README.md now describe new, one Write of <same stem>.slots.json beside the handoff, fill, validate, emit; the Edit tool is the repair path after a failed validate, never the fill step. The handoff evals case for a leftover slot is rewritten to the reachable failure (a required slot omitted from the JSON). Session-flow 0.36.0, ### Added.

Verification

  • scripts/tests/test_save_point.py through save_point.test.sh: red first (31 failures on invalid choice: 'fill'), then green; 106 passed and 1 skipped after the review-fix commit, 112 passed and 1 skipped after the shape-guard commit (six shape cases red first against fa7799b). Cases cover every acceptance criterion: full fill then validate exit 0, inline-prefix and two-slots-on-one-line preservation, multi-line values, optional-slot deletion, required-slot and unknown-key refusal, duplicate slot name, marker-in-value, malformed and non-object JSON, non-string and lone-surrogate values, missing and invalid-UTF-8 target, non-handoff file, every non-2 shape, CRLF preservation with \r\n inside a value, the closing rewrite (LF and CRLF, with and without goal-rearm, refusal when the line above is not Next:), and no temp file left behind.
  • End-to-end probe on this host: new (22 slots), one JSON, fill exit 0, validate PASS, emit clean, no FILL text left, the did · left line intact.
  • Ruff through the pinned wrapper clean; scoped em-dash gate over the seven session-flow allowlist globs reports none; check-changed-skills.sh reports handoff PASS; check-changelog-parity.sh --check-bump, --check-preserved, --check-order pass; markdownlint 0 issues on the touched docs.
  • Plan status: locked unattended by the inbox loop, stress-tested by a fresh-context plan reviewer (14 findings, all folded in, six of them one design thread on the closing handoff), reviewed by a fresh-context code reviewer (11 findings, one Important: the non-atomic in-place write, fixed), unapproved by a human. Decisions a reviewer may override: minor bump 0.36.0 for a new subcommand; the slots JSON is left in place beside the handoff after fill; stdin input is out of scope.

Related

  • Inbox item handoff-inbox/in-progress/20260911-052500-session-flow-handoff-one-edit-per-fill-slot.md (local queue).
  • hop_chain.py carries a sibling slot parse for its own skeletons; fill copies the shape rather than importing, per the house copy-not-import rule stated in save_point.py.

🤖 Generated with Claude Code

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 13, 2026 02:33
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T02:38:50.134420Z fa7799b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 53s —— View job


CI code review (/review:code-review)

  • Gather context (PR body, changed files, custom instructions)
  • Run /review:code-review per custom instructions
  • Post inline findings (none survived review)
  • Final summary

Scope. Reviewed the diff origin/main...HEAD for melodic-software/claude-code-plugins#4153 at fa7799b: the new save_point.py fill subcommand, its refusal/exit-code contract, the temp-file-then-os.replace atomic write, the closing-handoff (Next: none (closed)) rewrite, and the accompanying doc updates (reference/save-point.md, reference/structure.md, README.md, skills/handoff/SKILL.md, evals/evals.json, CHANGELOG.md).

Findings: none at the high-signal bar. I traced cmd_fill (plugins/session-flow/scripts/save_point.py:930-1062) line by line against the claimed contract:

  • Exit-2 checks (value type / UTF-8-encodability) run over every key before the exit-1 marker check, before the exit-1 duplicate/unknown/absent-slot checks, before the closing rewrite — matches the documented and tested precedence (test_fill_refusals_leave_the_target_byte_identical[both-defects]).
  • The closing index math (index - 1 guarded by if index else "") avoids the lines[-1] wraparound a naive version would hit when the next slot is line 0 — it isn't reachable here (frontmatter always precedes it), but the guard is correct as written.
  • CRLF/LF terminator handling in the per-line rebuild (_line_parts, the inner = terminator or "\n" split logic) is correct for both untouched lines and multi-line substituted values, including the deliberate non-splitting of a lone \r inside a value (tested, not a bug).
  • The atomic write (tempfile.mkstemp beside the target + os.replace) and the byte-identical-on-refusal property both check out against the code path — every refusal returns before the write section is reached.
  • Docs (reference/structure.md step 6, reference/save-point.md, SKILL.md, evals.json) describe the same exit codes, slot-deletion behavior, and closing-handoff shape the implementation actually has; I didn't find drift between prose and code.

Not independently verified: I don't have a shell in this lane beyond gh pr diff/view/comment/review, so I read test_save_point.py's new cases rather than executing scripts/tests/test_save_point.test.sh myself — the PR body's "106 passed and 1 skipped" and the ruff/em-dash/changelog-parity/markdownlint results are author-claimed, not something I ran.

No inline comments posted — nothing cleared the bar for a careful-senior-reviewer block/flag.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 37s —— View job


Security review (/review:security-review)

  • Gather context (PR diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Invoke /review:security-review skill and follow it exactly
  • Post findings

Skip-gate check: PR is open, not previously reviewed at this head, and the diff has a plausible security-relevant surface (new file-write path in save_point.py), so a full review ran rather than a skip.

Scope reviewed: all 9 changed files (gh pr diff 4153), focused on the new fill subcommand in
plugins/session-flow/scripts/save_point.py (cmd_fill, lines ~927–1069), since that's the only new logic doing file I/O; the doc/changelog/eval files carry no executable logic.

What I hunted for: path handling / traversal, TOCTOU and symlink races in the write path, injection surfaces (command/template), unsafe deserialization, and any new trust boundary the --slots <json> input crosses.

Findings: none.

Notes from the review (informational, not findings):

  • The write path (tempfile.mkstemp(dir=path.parent, ...)os.replace(tmp, path)) replaces the destination directory entry rather than following it, which is the safer of the two options if the target were ever a symlink — this closes off a TOCTOU write-through-symlink race rather than opening one.
  • Input parsing (json.loads, UTF-8 encode-checks, the FILL_SLOT_RE line-scoped regex) has no injection or ReDoS-relevant surface: no subprocess/shell invocation is added, and the regex is anchored per-line against locally-authored handoff files rather than adversarial/network input.
  • This tool has no authentication/authorization boundary or multi-tenant input — it's a local CLI operating on the invoking session's own files — so the categories this lane emphasizes (auth gaps, injection across a trust boundary, credential handling) don't have a boundary to apply to here.
  • No GitHub Actions workflow files are touched in this PR, so the zizmor-lane concerns (dangerous triggers, expression injection, permission widening, unpinned actions) are out of scope for this change.

No CRITICAL / IMPORTANT / SUGGESTION findings to report.

@github-actions

Copy link
Copy Markdown
Contributor

Last security-reviewed head: fa7799b65d9d1ce01861aca30874dced43bc4988. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@github-actions

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa7799b65d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/session-flow/scripts/save_point.py
kyle-sexton and others added 4 commits September 13, 2026 09:22
`fill <file> --slots <json>` replaces every `<!-- FILL: ... -->` slot in a
shape-2 handoff skeleton from one JSON object in one write, so filling a
skeleton no longer costs one Edit per slot and an interrupt mid-batch cannot
leave a partly filled file.

Substitution only, with the one exception a closing handoff needs: a `next`
value of exactly `Next: none (closed)` rewrites the bare `Next:` line above
the slot and deletes the slot line, which is the only shape `validate`
accepts as closed, and refuses when that line is not exactly `Next:`. An
optional slot left out of the JSON has its line deleted. A required slot
absent, a key naming no slot, a slot name occurring twice, and a value
carrying the literal `<!-- FILL` are each refused by name, and the target is
byte-identical on every refusal path.

The read and write go through bytes and `splitlines(keepends=True)` rather
than `_read_lines`, so a CRLF target keeps its line endings and a multi-line
value takes the replaced line's own terminator per line. Values are encoded
to UTF-8 before any write, so a lone surrogate refuses by key rather than
truncating the file.

The test helper `fill()` becomes `model_fill()`, since a subcommand named
`fill` now shares the suite with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
….36.0

The procedure is now `new`, one slots JSON, `fill`, `validate`, `emit`, and
the Edit tool is only the repair path after a failed `validate`.

`reference/structure.md` rewrites step 6 to assign `SLOTS` beside the handoff
and issue the `fill` call, teaches the JSON rather than only naming it (the
slot set is branch-dependent, so the names come out of the skeleton `new` just
wrote; a multi-line value is one string with escaped newlines; an optional slot
left out has its line deleted; a closing handoff passes the `next` value
`Next: none (closed)` exactly), adds the `fill` exit paragraph beside `new`'s,
and states that the Python-absent fallback is unchanged.

`reference/save-point.md` counts four subcommands rather than three, gains the
`fill` bullet with its exits, drops the `new` bullet's claim that the model
deletes the optional slots, and routes the closing form through the `next`
value instead of describing a hand-written line. `skills/handoff/SKILL.md` and
`README.md` follow, and the checklist box that read "only FILL slots edited"
becomes the one `fill` call with its refusal discipline.

`skills/handoff/evals/evals.json` case 16 grades the one-call fill, and case
17's premise is reachable again: a leftover FILL slot cannot survive `fill`, so
the refusal it exercises is a required slot omitted from the JSON, fixed at the
JSON and re-run before the `Next:` failure and the UNVALIDATED banner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…f file

Nine binding findings from a second review of the two landed commits:

- The final write goes to a `tempfile.mkstemp` file in the target's own
  directory and is `os.replace`d into place, unlinking the temp on any OSError
  and exiting 2. An interrupted write can no longer truncate a handoff, and
  `find-handoff` globs only `*.md`, so a stray temp is never read as one.
- `fill` exits 2 on a file whose frontmatter is not `type: handoff`, through
  the same `parse_doc` check `validate` and `emit` use, applied before the
  slots file is read.
- The unreadable-target test fed a directory, which only re-ran the
  missing-target branch; it now feeds invalid UTF-8 so the decode branch runs.
- The non-string and non-encodable checks run over every key before the
  `FILL_MARK` check runs over any, so a payload carrying both defects exits 2
  whatever order its keys arrive in.
- The final encode's try/except was unreachable (every value was already
  encode-checked and the source decoded cleanly) and is gone.
- A CRLF variant of the closing-handoff test asserts the rewritten
  `Next: none (closed)` line ends in CRLF with no `\r\r` and no bare `\n`.
- The round-trip test's docstring names `len(matches) > len(carrying)` as the
  line that actually pins non-greediness.
- The exit-2 enumerations in the module docstring, `reference/save-point.md`
  and `reference/structure.md` gain the non-UTF-8-encodable value and the
  non-handoff file, and both docs state the temp-file write. `evals.json`
  case 17 names Edit as the repair path after a post-fill `validate` failure,
  since no slot survives `fill`. `reference/structure.md:15` is reflowed.
- The `## [0.36.0]` entry gains one clause each for the atomic write and the
  handoff guard. No version bump.

Suite: 106 passed, 1 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`cmd_fill` guarded only `type: handoff`, so a skeleton written by a newer
engine was accepted: a `handoff_shape: 3` file carrying a familiar
`<!-- FILL: ... -->` slot was rewritten with exit 0, while `validate` exits 3
on the same file and says to read it and not rewrite it.

`fill` now runs `validate_doc`'s shape ladder before parsing any slot, in the
same order and for the same reason: `_UnparsableShape` first so no later
comparison meets a non-integer, then shape 1 or an absent key, then a shape
below 1, then a shape newer than 2. A newer shape exits 3 with `validate`'s
verdict; every other shape that is not 2 exits 2 beside the `type: handoff`
guard. Both refusals land before the write, so the target stays
byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton force-pushed the session-flow-handoff-one-edit-per-fill-s branch from 5c7837a to 7437e7c Compare September 13, 2026 14:28
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.

feat(session-flow): add save_point.py fill so the handoff fills every slot in one call

1 participant