From 160b488d4fa0a0749c136e427cc816e3f3daa077 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:21:26 -0500 Subject: [PATCH 1/4] feat(session-flow): add save_point.py fill for one-call slot filling `fill --slots ` replaces every `` 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 `` plus the whole second slot. The separator is +# U+2014. +FILL_SLOT_RE = re.compile(r"") COPY_LINE = "`/clear`, then copy everything between the dashed lines:" DIRECTIVE_CLAUSE = "invoke /session-flow:handoff via the Skill tool" DIRECTIVE_TAIL = ( @@ -215,6 +233,16 @@ def _read_lines(path: Path) -> list[str]: return [line.rstrip("\r\n") for line in text.splitlines()] +def _line_parts(line: str) -> tuple[str, str]: + """One `splitlines(keepends=True)` element split into its content and its own + terminator, so a substitution reattaches the terminator that line had and a + CRLF file never goes mixed.""" + for terminator in ("\r\n", "\n", "\r"): + if line.endswith(terminator): + return line[: -len(terminator)], terminator + return line, "" + + def _posix(path: Path) -> str: return path.resolve().as_posix() @@ -894,6 +922,122 @@ def cmd_emit(args: argparse.Namespace) -> int: return 0 +# --- fill ------------------------------------------------------------------------- + + +def cmd_fill(args: argparse.Namespace) -> int: + """Replace every slot in one pass and one write. Substitution only, with the + single exception the closing handoff needs: a `next` value of exactly + NEXT_CLOSED rewrites the bare `Next:` line above the slot and deletes the + slot line, which is the only shape `validate` accepts as closed. + + Nothing reuses `_read_lines` or `parse_doc`: both apply universal-newline + translation and would rewrite a CRLF target to LF. Every refusal happens + before the write, so a refused target is byte-identical. + """ + path = Path(args.file) + if not path.is_file(): + return _die(2, f"not a file: {path}") + try: + text = path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError) as exc: + return _die(2, f"cannot read {path}: {exc}") + + slots_path = Path(args.slots) + try: + raw = slots_path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError) as exc: + return _die(2, f"cannot read slots file {slots_path}: {exc}") + try: + values = json.loads(raw) + except ValueError as exc: + return _die(2, f"slots file is not valid JSON ({exc}): {_posix(slots_path)}") + if not isinstance(values, dict): + return _die(2, f"slots file must hold a JSON object keyed by slot name, not a {type(values).__name__}: {_posix(slots_path)}") + for key, value in values.items(): + if not isinstance(value, str): + return _die(2, f"slots value for {key!r} must be a string, not a {type(value).__name__}") + try: + value.encode("utf-8") + except UnicodeEncodeError as exc: + return _die(2, f"slots value for {key!r} is not encodable as UTF-8 ({exc}); rewrite it in {_posix(slots_path)}") + if FILL_MARK in value: + # Landing this verbatim makes `validate` name the wrong line as an + # unfilled slot and `emit` refuse the file as a skeleton. + return _die(1, f"slots value for {key!r} carries the literal {FILL_MARK!r}; rewrite the value without it") + + lines = text.splitlines(keepends=True) + slot_line: dict[str, int] = {} + optional: set[str] = set() + for index, line in enumerate(lines): + content, _ = _line_parts(line) + for match in FILL_SLOT_RE.finditer(content): + name = match.group(1) + if name in slot_line: + return _die(1, f"slot {name!r} occurs twice in {_posix(path)}; one value cannot resolve a duplicated name, so fix the file first") + slot_line[name] = index + # Optional-ness is read off the instruction, keeping `_fill()` the + # single source of the slot shape. + if match.group(2).startswith("optional:"): + optional.add(name) + if not slot_line: + return _die(1, f"no {FILL_MARK} slot in {_posix(path)}; a filled file is validated, never filled again") + + unknown = sorted(key for key in values if key not in slot_line) + if unknown: + return _die(1, f"key {unknown[0]!r} names no slot in {_posix(path)}; take the slot names from the skeleton `new` wrote, never from a remembered template") + absent = sorted( + name for name in slot_line if name not in values and name not in optional + ) + if absent: + return _die(1, f"required slot {absent[0]!r} is absent from {_posix(slots_path)}; key it with its value and re-run") + + closing = -1 + if values.get("next") == NEXT_CLOSED: + index = slot_line["next"] + above = _line_parts(lines[index - 1])[0] if index else "" + if not index or above != "Next:": + return _die(1, f"a {NEXT_CLOSED!r} value rewrites the line above the `next` slot, which must be exactly 'Next:' (got {above!r}); fix the file or pass headline lines instead") + closing = index + + out: list[str] = [] + for index, line in enumerate(lines): + content, terminator = _line_parts(line) + if index == closing: + continue + if closing > 0 and index == closing - 1: + out.append(NEXT_CLOSED + terminator) + continue + matches = list(FILL_SLOT_RE.finditer(content)) + if not matches: + out.append(line) + continue + # A line goes only when every slot on it is optional and unkeyed; the + # `did` / `left` line carries two required slots and can never go. + if all(m.group(1) in optional and m.group(1) not in values for m in matches): + continue + rebuilt = "" + cursor = 0 + for match in matches: + rebuilt += content[cursor : match.start()] + # `\n` only, never `str.splitlines()`, which also breaks on a lone + # `\r`, a form feed, and U+2028 / U+2029 that a JSON string carries. + rebuilt += values.get(match.group(1), "").replace("\r\n", "\n") + cursor = match.end() + rebuilt += content[cursor:] + parts = rebuilt.split("\n") + inner = terminator or "\n" + for position, part in enumerate(parts): + out.append(part + (inner if position < len(parts) - 1 else terminator)) + + try: + payload = "".join(out).encode("utf-8") + except UnicodeEncodeError as exc: + return _die(2, f"the filled content is not encodable as UTF-8 ({exc}); nothing written") + path.write_bytes(payload) + return 0 + + # --- new -------------------------------------------------------------------------- @@ -1289,7 +1433,7 @@ def _parse_now(raw: str | None) -> datetime | None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="save_point.py", - description="Shape-2 handoff save-point engine: new / validate / emit.", + description="Shape-2 handoff save-point engine: new / fill / validate / emit.", ) sub = parser.add_subparsers(dest="command", required=True) @@ -1305,6 +1449,11 @@ def build_parser() -> argparse.ArgumentParser: p_new.add_argument("--now", help="ISO-8601 UTC timestamp override (tests)") p_new.set_defaults(func=cmd_new) + p_fill = sub.add_parser("fill", help="replace every slot from one JSON object, in one write") + p_fill.add_argument("file") + p_fill.add_argument("--slots", required=True, help="JSON object keyed by slot name; an optional slot left out has its line deleted") + p_fill.set_defaults(func=cmd_fill) + p_val = sub.add_parser("validate", help="check a handoff file; PASS/WARN/FAIL lines on stdout") p_val.add_argument("file") p_val.add_argument("--projects-root", help="transcript root; an 'unresolved (…)' transcript is re-globbed here and the located path named in the finding") diff --git a/plugins/session-flow/scripts/tests/test_save_point.py b/plugins/session-flow/scripts/tests/test_save_point.py index 1041802c51..1053b29898 100644 --- a/plugins/session-flow/scripts/tests/test_save_point.py +++ b/plugins/session-flow/scripts/tests/test_save_point.py @@ -1,4 +1,4 @@ -"""Contract tests for save_point.py (new / validate / emit). +"""Contract tests for save_point.py (new / fill / validate / emit). Runs the script as a subprocess to test the CLI interface, not internals, following the retro skill's test_parse_transcript.py precedent. @@ -15,6 +15,7 @@ import functools import importlib.util +import json import os import re import shutil @@ -80,7 +81,8 @@ SID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" SID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" RAIL_RE = re.compile(r"^─{10,}$") -FILL_RE = re.compile(r"") +FILL_RE = re.compile(r"") +NEXT_CLOSED = "Next: none (closed)" def rail_lines(text: str) -> int: @@ -542,7 +544,7 @@ def new_args(repo: Path, tmp_path: Path, *extra: str, sid: str = SID_A, now: str ] -def fill(text: str) -> str: +def model_fill(text: str) -> str: """Fill every reasoning slot the way a well-behaved model would: optional slots deleted, cumulative slots given one tagged entry, the rest prose.""" filled: list[str] = [] @@ -604,7 +606,7 @@ def test_new_hop1_filled_skeleton_validates_clean(tmp_path): repo = make_repo(tmp_path) run(*new_args(repo, tmp_path, "--no-previous")).check_returncode() target = repo / ".work" / "handoffs" / HOP1 - target.write_text(fill(target.read_text(encoding="utf-8")), encoding="utf-8", newline="\n") + target.write_text(model_fill(target.read_text(encoding="utf-8")), encoding="utf-8", newline="\n") result = run("validate", str(target), "--strict-transcript") assert result.returncode == 0, out(result) + err(result) assert "FAIL" not in out(result) and "WARN" not in out(result) @@ -618,7 +620,7 @@ def test_new_hop2_from_shape2_carries_chain_rows_and_tags(tmp_path): handoffs = repo / ".work" / "handoffs" run(*new_args(repo, tmp_path, "--no-previous")).check_returncode() hop1 = handoffs / HOP1 - hop1.write_text(fill(hop1.read_text(encoding="utf-8")), encoding="utf-8", newline="\n") + hop1.write_text(model_fill(hop1.read_text(encoding="utf-8")), encoding="utf-8", newline="\n") result = run(*new_args(repo, tmp_path, "--previous", str(hop1), sid=SID_B, now="2026-09-02T10:00:00Z")) assert result.returncode == 0, err(result) hop2 = handoffs / HOP2 @@ -631,7 +633,7 @@ def test_new_hop2_from_shape2_carries_chain_rows_and_tags(tmp_path): assert " slot" + slots = slots_file(tmp_path, payload) + elif case == "malformed-json": + slots = raw_slots_file(tmp_path, '{"brief": "x",}') + elif case == "non-object-json": + slots = raw_slots_file(tmp_path, '["brief"]') + elif case == "non-string-value": + payload["brief"] = 7 + slots = slots_file(tmp_path, payload) + else: + payload["brief"] = "\ud800" + slots = slots_file(tmp_path, payload) + before = target.read_bytes() + result = run("fill", str(target), "--slots", slots) + assert result.returncode == expected, out(result) + err(result) + assert needle in err(result), err(result) + assert target.read_bytes() == before + + +def test_fill_refuses_a_target_with_no_slots(tmp_path): + target = new_skeleton(tmp_path) + payload = required_slots(target.read_text(encoding="utf-8")) + run("fill", str(target), "--slots", slots_file(tmp_path, payload)).check_returncode() + before = target.read_bytes() + again = run( + "fill", str(target), "--slots", slots_file(tmp_path, {}, name="empty.json") + ) + assert again.returncode == 1, out(again) + err(again) + assert "` slot, up to twenty-two of them on + a first hop, so an interrupt mid-batch left a partly filled skeleton. `fill` reads one JSON + object keyed by slot name and applies every value in a single write, after every check has + passed. An inline prefix on a slot's line is preserved, a multi-line value lands as those lines + in place, and an optional slot left out of the object has its line deleted. A closing handoff is + written by giving the `next` slot the value `Next: none (closed)` exactly, which `fill` moves + onto the `Next:` line above before deleting the slot line, since that is the only shape the + validator accepts as closed. A required slot absent, a key naming no slot in the file, a slot + name occurring twice, a value that itself carries a `FILL` slot marker, and a target with no + slot left are each refused by name with the file left byte-identical. A missing or unreadable + target, and a slots file that is missing, unreadable, not a JSON object, or holds a non-string + or non-UTF-8-encodable value, exit 2. The target's own line endings survive, so a CRLF handoff + stays CRLF. `new`, `validate`, and `emit` keep their behavior and exit codes, and the Edit tool + is now only the repair path after a failed `validate`. + ## [0.35.12] ### Fixed diff --git a/plugins/session-flow/README.md b/plugins/session-flow/README.md index ce4853575b..46c577ef38 100644 --- a/plugins/session-flow/README.md +++ b/plugins/session-flow/README.md @@ -88,10 +88,11 @@ the whole chain (`chain:` frontmatter plus a `## Prior sessions` table), the ses path, the user's verbatim goal and opening ask, the cumulative sections (constraints, side effects, decisions, abandoned approaches, findings) copied forward with `[hN]` provenance tags, a one-line `## This session` record, and, as its final section, the resume prompt itself. A stdlib-only -Python script, `scripts/save_point.py`, writes every deterministic field (`new`), validates the -finished file before the rails are shown (`validate`, exit 0 gates the prompt), and prints the -stored prompt (`emit`) so the on-screen rails and the file are the same bytes. The model fills -only the reasoning slots. The resume prompt tells the next session to invoke this skill for its +Python script, `scripts/save_point.py`, writes every deterministic field (`new`), replaces the +reasoning slots from one JSON object in a single write (`fill`), validates the finished file +before the rails are shown (`validate`, exit 0 gates the prompt), and prints the stored prompt +(`emit`) so the on-screen rails and the file are the same bytes. The model supplies only the +reasoning slot values. The resume prompt tells the next session to invoke this skill for its own save-point rather than writing a handoff file free-hand. Older shape-1 files are read as before and never rewritten. diff --git a/plugins/session-flow/reference/save-point.md b/plugins/session-flow/reference/save-point.md index f6832d4d70..ac96e87df7 100644 --- a/plugins/session-flow/reference/save-point.md +++ b/plugins/session-flow/reference/save-point.md @@ -199,7 +199,7 @@ The body sections, the TaskList reconstitute format, and the frontmatter shape ( Walk it while writing the file; never write the section list from memory. **The file is shape 2, and a script owns its deterministic tier.** -`${CLAUDE_PLUGIN_ROOT}/scripts/save_point.py` has three subcommands, run through the interpreter +`${CLAUDE_PLUGIN_ROOT}/scripts/save_point.py` has four subcommands, run through the interpreter ladder the structure doc's write procedure shows (`"$PY" -X utf8 …`, Python 3.10+, stdlib only): - `save_point.py new --topic --memory-dir (--previous | --no-previous)` @@ -208,9 +208,22 @@ ladder the structure doc's write procedure shows (`"$PY" -X utf8 …`, Python 3. 17 headings in order, the goal and amendments and the five cumulative sections copied off the predecessor with their `[hN]` tags, the `## Prior sessions` table, and the whole `## Resume prompt` block except its `Next:` headlines) and prints the file's absolute - forward-slash path. Only the `` slots are the model's; - every optional slot (`goal-rearm`, `below-rail`, `
-new`) is deleted when it does not - apply. It never overwrites an existing file. + forward-slash path. Only the `` slots are the model's, and + `fill` is what applies them. It never overwrites an existing file. +- `save_point.py fill --slots ` replaces every slot in the file from one JSON object + keyed by slot name, in a single write, and prints nothing on success. An inline prefix on a + slot's line (`**Amended:**`, `**Next action serves it by:**`, `did:`, the `left:` separator) is + preserved; a multi-line value is one JSON string with escaped newlines and lands as those lines + in place, each taking the file's own line terminator; an optional slot (`goal-rearm`, + `below-rail`, `
-new`) left out of the object has its line deleted. It exits 1 on a + required slot absent from the object, a key naming no slot in this file, a slot name occurring + twice in the file, a value that itself carries a `FILL` slot marker, a file with no slot left to + fill, and a closing `next` value whose line above is not exactly `Next:`; 2 on a missing or unreadable + target and on a slots file that is missing, unreadable, not valid JSON, not a JSON object, or + holding a non-string value. Every refusal names the slot or key and leaves the file + byte-identical, so nothing is ever half-applied. The slots JSON lives beside the handoff as + `.slots.json` and is left in place. `fill` never judges a value against its slot's + instruction; `validate` is the gate. - `save_point.py validate ` prints PASS/WARN/FAIL lines and exits 0 on pass (shape 1: one WARN, exit 0), 1 on a validation failure, 2 on usage, 3 on a `handoff_shape` newer than it knows ("read it, do not rewrite it"). A leftover `FILL` slot, a prefixed `previous_handoff`, a @@ -509,9 +522,10 @@ are untouched: prompt-only writes no file, so nothing here has a file to validat `Remaining actions, in order`. Headlines yes, detail no: the file `@`-referenced on line 1 holds the sequence, and the between-rails text is what a resuming session or a background agent sees first. The last headline may be `Then: /`, the fully-qualified skill the next stage - starts with, at a stage boundary only, never mid-stage. A closing handoff writes - `Next: none (closed)` and no headlines. The validator refuses a sixth line, a bullet, and a - `Then:` that is not last. + starts with, at a stage boundary only, never mid-stage. A closing handoff is written by giving + `fill` the `next` value `Next: none (closed)` exactly: `fill` puts that text on the `Next:` line + and deletes the slot line, so the closed form carries no headlines. The validator refuses a + sixth line, a bullet, and a `Then:` that is not last. - **Below the bottom rail, first line:** the sentence `Or reopen the producing session in place:` followed by `claude --resume ` in a code span and a period, the alternative to `/clear`-and-paste when the producing session is still worth reopening. The `/goal` and `/loop` re-arm notes the rules above prescribe follow diff --git a/plugins/session-flow/reference/structure.md b/plugins/session-flow/reference/structure.md index 2fd5504580..45024b4484 100644 --- a/plugins/session-flow/reference/structure.md +++ b/plugins/session-flow/reference/structure.md @@ -9,10 +9,11 @@ costs the next session a re-investigation, which is the cost this document exist **Shape 2.** A handoff file written by this procedure carries `handoff_shape: 2` in its frontmatter. Every deterministic field of a shape-2 file is written by the engine script -`${CLAUDE_PLUGIN_ROOT}/scripts/save_point.py` (`new` writes the skeleton, `validate` gates it, -`emit` prints its resume prompt); the model fills only the reasoning slots the skeleton leaves as -``. The write procedure below is the one path that produces a -shape-2 file. Files written before shape 2 (no `handoff_shape` key) are shape 1: read normally, +`${CLAUDE_PLUGIN_ROOT}/scripts/save_point.py` (`new` writes the skeleton, `fill` replaces its +slots from one JSON object, `validate` gates it, `emit` prints its resume prompt); the model +supplies only the reasoning slots the skeleton leaves as +``, as the values in that object. The write procedure below +is the one path that produces a shape-2 file. Files written before shape 2 (no `handoff_shape` key) are shape 1: read normally, tolerated by the validator with one WARN, and never rewritten. ## Contents @@ -465,9 +466,11 @@ drifts silently, and it has before. ## Full-path write procedure Write the file into the handoff location (`save-point.md` "Where save-points live"). The -procedure is: resolve the memory root, run the existing guards, run `new`, fill the slots, run -`validate`, then `emit`. Every step that needs no judgment is the script's; the model touches only -the `` slots. +procedure is: resolve the memory root, run the existing guards, run `new`, write the slots JSON, +run `fill`, run `validate`, then `emit`. Every step that needs no judgment is the script's; the +model supplies only the values for the `` slots, and `fill` applies all of them in +one write, so an interrupt mid-batch cannot leave a partly filled skeleton. The Edit tool is the +repair path after a failed `validate`, not a step of the procedure. ```bash TOPIC= # e.g. plan-rev2, retry-loop, post-merge @@ -529,7 +532,7 @@ done # 5. Skeleton. Exactly one of --previous / --no-previous (see "Chain continuity"). # `new` reads CLAUDE_CODE_SESSION_ID itself and prints the file's absolute -# forward-slash path: reuse THAT string for every later step (Edit, validate, +# forward-slash path: reuse THAT string for every later step (fill, validate, # emit, the directive); never recompute the path in bash. SAVE_POINT="${CLAUDE_PLUGIN_ROOT}/scripts/save_point.py" FILE=$("$PY" -X utf8 "$SAVE_POINT" new --topic "$TOPIC" --memory-dir "$MEMORY_ROOT" --no-previous) @@ -537,8 +540,19 @@ FILE=$("$PY" -X utf8 "$SAVE_POINT" new --topic "$TOPIC" --memory-dir "$MEMORY_RO # FILE=$("$PY" -X utf8 "$SAVE_POINT" new --topic "$TOPIC" --memory-dir "$MEMORY_ROOT" \ # --previous "$DIR/-handoff-.md") -# 6. Fill every `` slot in $FILE with the Edit tool (delete the -# optional ones: goal-rearm, below-rail,
-new). Touch nothing else. +# 6. Slot values, then one fill. Read the slot names out of $FILE itself: the set +# is branch-dependent (goal, amended, opening-ask and the bare cumulative +# slots only on a first hop; the
-new slots only on a continuation), +# so a remembered template hits fill's unknown-key refusal. Write ONE JSON +# object keyed by those names, every value a string; a multi-line value is one +# string with escaped newlines ("First headline\nSecond headline"), which the +# next slot and the cumulative slots need. Leave an optional slot out +# (goal-rearm, below-rail,
-new) and fill deletes its line. For a +# closing handoff the next value is exactly "Next: none (closed)", which fill +# puts on the line above before deleting the slot line. +SLOTS="${FILE%.md}.slots.json" # beside the handoff, same stem +# Write $SLOTS with the Write tool, then apply every slot in a single write: +"$PY" -X utf8 "$SAVE_POINT" fill "$FILE" --slots "$SLOTS" # 7. Validate; exit 0 gates the rails (save-point.md "Emit the copy/paste resume prompt"). "$PY" -X utf8 "$SAVE_POINT" validate "$FILE" # 8. Print the stored resume prompt; paste its output on screen verbatim. @@ -552,12 +566,22 @@ predecessor flags). It never overwrites. A refusal for a missing or non-UUID ses save-point to the prompt-only path with that reason stated (`save-point.md` "Choosing the path"); every other refusal names its fix. +`fill` prints nothing and exits 0 once every required slot is keyed and no key names a slot the +file does not carry. It exits 1 when it refuses: a required slot absent from the JSON, a key +naming no slot in the file, a slot name occurring twice in the file, a value that itself carries a +`FILL` slot marker, no slot in the file at all, or a closing `next` value whose line above is not +exactly `Next:`. It exits 2 on usage, on a missing or unreadable target, and on a slots file that is +missing, unreadable, not valid JSON, not a JSON object, or holds a non-string value. Every refusal +names the offending slot or key and leaves the target byte-identical, so a corrected JSON re-runs +cleanly; nothing is half-applied. + **Python-absent fallback.** When the ladder finds no Python 3.10+, say so in one line (`validator unavailable: no python3/python on PATH`), write the shape-2 file by hand from this document (frontmatter below, the 17 headings in order, the `## Resume prompt` section in the engine doc's full-path form), mark the checklist box `validate: SKIPPED (no interpreter)`, and still emit the rails from the file's `## Resume prompt` section. Never a shape-1 file, never a -silent skip. +silent skip. This path is unchanged by `fill`: with no interpreter there is no `fill` either, so +the hand-written file needs no slots JSON. ### Frontmatter shape 2 diff --git a/plugins/session-flow/skills/handoff/SKILL.md b/plugins/session-flow/skills/handoff/SKILL.md index 4da466ef3d..8933bee488 100644 --- a/plugins/session-flow/skills/handoff/SKILL.md +++ b/plugins/session-flow/skills/handoff/SKILL.md @@ -165,9 +165,11 @@ Walk it top to bottom; do not restate or improvise any of its steps. On the full path the file is shape 2 and a script owns every deterministic field (engine doc, "Writing the handoff file"; procedure in its structure doc): resolve `memory_dir`, -run the guards, run `save_point.py new` through the interpreter ladder with `-X utf8`, fill only -the `` slots, run `save_point.py validate` until it exits 0, then paste the -`save_point.py emit` output as the rails block. The screen and the file's `## Resume prompt` +run the guards, run `save_point.py new` through the interpreter ladder with `-X utf8`, write one JSON object +holding the values for the `` slots that skeleton carries, apply them all with +`save_point.py fill --slots `, run `save_point.py validate` until it exits 0, then +paste the `save_point.py emit` output as the rails block. The Edit tool is the repair path after a +failed `validate`, never the way the slots are filled. The screen and the file's `## Resume prompt` section are the same bytes by construction. Two refusals route elsewhere and are stated, never worked around: no Python 3.10+ on PATH takes the engine doc's Python-absent fallback (`validator unavailable`, file hand-written per the structure doc, `validate: SKIPPED`); no @@ -207,14 +209,20 @@ ticked. Emit the rails block before ending the turn, always. the literal `.work` assumed), the root-equivalence refusal and the self-ignore guard run, and `save_point.py new` invoked through the interpreter ladder as `"$PY" -X utf8 …` with `--previous ` or `--no-previous`. The path `new` printed is the ONE path used for every - later step (Edit, `validate`, `emit`, the directive), never recomputed in bash. `new` refused + later step (`fill`, `validate`, `emit`, the directive), never recomputed in bash. `new` refused for a missing or non-UUID session id → prompt-only path, reason stated; no interpreter → `validator unavailable: no python3/python on PATH` said in one line, the shape-2 file written by hand per the structure doc, and the `validate` box below reads `SKIPPED (no interpreter)` -- [ ] Only `` slots edited; every deterministic field left as `new` wrote it - (frontmatter, `chain:`, the carried `[hN]` sections, the `## Prior sessions` table, the rails - block minus `Next:`); the optional slots (`goal-rearm`, `below-rail`, `
-new`) deleted - when they do not apply, so no `FILL` text remains +- [ ] Slot values written as ONE JSON object beside the handoff (`.slots.json`, left in + place afterwards) and applied in a single + `save_point.py fill "$FILE" --slots "$SLOTS"` call, its slot names read off the skeleton `new` + just wrote rather than a remembered template (the set is branch-dependent, and an unknown key + is refused); `fill` exited 0, so no `FILL` text remains and every deterministic field is still + as `new` wrote it (frontmatter, `chain:`, the carried `[hN]` sections, the `## Prior sessions` + table, the rails block minus `Next:`). An optional slot (`goal-rearm`, `below-rail`, + `
-new`) that does not apply is left OUT of the object, which is how `fill` deletes its + line; a refusal names the slot or key and leaves the file byte-identical, so the fix is the JSON + and a re-run, never a hand-edit around it - [ ] `previous_handoff` present IF this session continued a prior handoff's task (chain continuity per the structure doc, `--previous` passed explicitly, never auto-picked); omitted otherwise (`--no-previous`), including when the directory holds only unrelated-task handoffs. When @@ -266,7 +274,8 @@ ticked. Emit the rails block before ending the turn, always. verbatim (copy instruction, rails, directive, `Prior session:`, `Handoff origin:`, `Next:` headlines, the below-rail `claude --resume` line), never retyped or regenerated, so the screen equals the file's `## Resume prompt` section byte for byte; `Next:` holds 1 to 5 plain - headlines from `Remaining actions, in order` (or `Next: none (closed)`), with `Then: /` + headlines from `Remaining actions, in order` (or, for a closing handoff, the `next` value + `Next: none (closed)` exactly, which `fill` moves onto the `Next:` line), with `Then: /` last only at a stage boundary. The directive `@`-references the file by its **absolute**, forward-slash-normalized path, never the bare `/handoffs/…` segment, which resolves against the resuming session's cwd, and carries the invoke-the-skill sentence; the diff --git a/plugins/session-flow/skills/handoff/evals/evals.json b/plugins/session-flow/skills/handoff/evals/evals.json index a7352f32d6..bca9b920ca 100644 --- a/plugins/session-flow/skills/handoff/evals/evals.json +++ b/plugins/session-flow/skills/handoff/evals/evals.json @@ -219,25 +219,26 @@ }, { "id": 16, - "name": "skeleton-comes-from-new-and-only-fill-slots-are-edited", + "name": "skeleton-comes-from-new-and-slots-are-filled-in-one-call", "prompt": "Handoff. This is the first save-point of this task; python3 is on PATH.", - "expected_output": "The full path runs the shape-2 procedure: memory_dir resolved through parse-concern-value.sh, the guards run, then `save_point.py new --topic --memory-dir --no-previous` invoked through the interpreter ladder with `-X utf8`. The path `new` prints is reused for every later step. The model edits only the `` slots, deleting the optional ones (goal-rearm, below-rail,
-new) that do not apply, and leaves every deterministic field (frontmatter, chain, the 17 headings, the rails block minus `Next:`) exactly as written. `save_point.py validate` exits 0, the outcome is quoted in the checklist, and the rails block on screen is the `save_point.py emit` output pasted verbatim.", + "expected_output": "The full path runs the shape-2 procedure: memory_dir resolved through parse-concern-value.sh, the guards run, then `save_point.py new --topic --memory-dir --no-previous` invoked through the interpreter ladder with `-X utf8`. The path `new` prints is reused for every later step. The model writes ONE JSON object holding the values for the `` slots that skeleton actually carries, leaving the optional ones (goal-rearm, below-rail,
-new) that do not apply out of it, and applies them all with a single `save_point.py fill --slots ` call, so every deterministic field (frontmatter, chain, the 17 headings, the rails block minus `Next:`) stays exactly as written. `save_point.py validate` exits 0, the outcome is quoted in the checklist, and the rails block on screen is the `save_point.py emit` output pasted verbatim.", "files": [], "expectations": [ "`save_point.py new` is invoked (with `--no-previous`, since no prior handoff of this task exists) rather than the file being written from scratch by Write or Edit", - "The absolute path `new` printed is the one path used for the Edit calls, `validate`, `emit`, and the directive — never recomputed or re-derived in bash", - "Only `` slots are edited; deterministic fields such as `handoff_shape`, `session_id`, `transcript`, `chain:`, the heading scaffold, `Prior session:`, and `Handoff origin:` are left as the script wrote them", - "Optional slots that do not apply are deleted so no `FILL` text remains, and `validate` is run and reported as `validate: exit 0` before the rails are printed", + "The absolute path `new` printed is the one path used for `fill`, `validate`, `emit`, and the directive, never recomputed or re-derived in bash", + "The slots are applied by one `save_point.py fill --slots ` call over a JSON object written beside the handoff, not by a run of Edit calls; deterministic fields such as `handoff_shape`, `session_id`, `transcript`, `chain:`, the heading scaffold, `Prior session:`, and `Handoff origin:` are left as the script wrote them", + "The slot names come from the skeleton `new` just wrote rather than a remembered template, optional slots that do not apply are left OUT of the object so `fill` deletes their lines, `fill` exits 0 so no `FILL` text remains, and `validate` is run and reported as `validate: exit 0` before the rails are printed", "The on-screen rails block is the `emit` output pasted verbatim, and the `Next:` line carries one to five plain headline lines with no bullets" ] }, { "id": 17, "name": "validator-failure-is-fixed-then-loud-never-silent", - "prompt": "Handoff. (Note: after you fill the skeleton, `save_point.py validate` exits 1 reporting a leftover FILL slot in Open questions and six lines under Next:; suppose that after three fix attempts it is still failing on something you cannot resolve.)", - "expected_output": "The first failure is fixed, not ignored: the named slot is filled, `Next:` is cut to five headlines, and `validate` is re-run. When the file still fails after three attempts, the skill neither withholds the resume prompt nor claims success: it prints an `UNVALIDATED: ` banner ABOVE the copy instruction (outside the copy region), marks the checklist box `validate: FAILED`, and still emits the rails from the file's `## Resume prompt` section verbatim. It never regenerates a prompt by hand to route around the validator, and never presents the file as validated.", + "prompt": "Handoff. (Note: `save_point.py fill` exits 1 saying the required slot 'open-questions' is absent from your slots JSON; then, once that is keyed and `fill` exits 0, `save_point.py validate` exits 1 reporting six lines under Next:; suppose that after three fix attempts it is still failing on something you cannot resolve.)", + "expected_output": "The `fill` refusal is read and fixed at its source: the named slot is added to the slots JSON and `fill` re-run, never worked around with an Edit, and the target was left byte-identical so the re-run is clean. The `validate` failure is then fixed the same way, not ignored: `Next:` is cut to five headlines and `validate` re-run. When the file still fails after three attempts, the skill neither withholds the resume prompt nor claims success: it prints an `UNVALIDATED: ` banner ABOVE the copy instruction (outside the copy region), marks the checklist box `validate: FAILED`, and still emits the rails from the file's `## Resume prompt` section verbatim. It never regenerates a prompt by hand to route around the validator, and never presents the file as validated.", "files": [], "expectations": [ + "The `fill` exit 1 is treated as a defect in the slots JSON: the named slot is keyed and `fill` re-run, rather than the slot being hand-edited into the file or the refusal ignored", "A non-zero `validate` exit is treated as a defect to fix: the slots the FAIL lines name are corrected and `validate` is re-run, up to three attempts, rather than the failure being ignored or the rails printed immediately", "After three failed attempts the rails are STILL emitted (a resume prompt is always emitted), taken from the file's `## Resume prompt` section, not retyped from memory", "An `UNVALIDATED: ` banner appears above the copy instruction, outside the region between the rails, and the checklist box reads `validate: FAILED` rather than a ticked pass", From 1cbe744eedf8f3bbea18a401f41633f29c133cb0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:26:36 -0500 Subject: [PATCH 3/4] fix(session-flow): make fill write atomically and refuse a non-handoff 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 --- plugins/session-flow/CHANGELOG.md | 9 ++- plugins/session-flow/reference/save-point.md | 11 ++-- plugins/session-flow/reference/structure.md | 15 +++-- plugins/session-flow/scripts/save_point.py | 44 ++++++++++--- .../scripts/tests/test_save_point.py | 66 +++++++++++++++++-- .../skills/handoff/evals/evals.json | 2 +- 6 files changed, 116 insertions(+), 31 deletions(-) diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 46a0617cec..be2d65a6b2 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -16,9 +16,12 @@ name occurring twice, a value that itself carries a `FILL` slot marker, and a target with no slot left are each refused by name with the file left byte-identical. A missing or unreadable target, and a slots file that is missing, unreadable, not a JSON object, or holds a non-string - or non-UTF-8-encodable value, exit 2. The target's own line endings survive, so a CRLF handoff - stays CRLF. `new`, `validate`, and `emit` keep their behavior and exit codes, and the Edit tool - is now only the repair path after a failed `validate`. + or non-UTF-8-encodable value, exit 2, as does a target that is not a handoff file, the same + `type: handoff` guard `validate` and `emit` apply. The write goes to a temporary file in the + target's own directory and is replaced into place, so an interrupted write cannot truncate the + handoff. The target's own line endings survive, so a CRLF handoff stays CRLF. `new`, `validate`, + and `emit` keep their behavior and exit codes, and the Edit tool is now only the repair path + after a failed `validate`. ## [0.35.12] diff --git a/plugins/session-flow/reference/save-point.md b/plugins/session-flow/reference/save-point.md index ac96e87df7..ae327f5b16 100644 --- a/plugins/session-flow/reference/save-point.md +++ b/plugins/session-flow/reference/save-point.md @@ -218,10 +218,13 @@ ladder the structure doc's write procedure shows (`"$PY" -X utf8 …`, Python 3. `below-rail`, `
-new`) left out of the object has its line deleted. It exits 1 on a required slot absent from the object, a key naming no slot in this file, a slot name occurring twice in the file, a value that itself carries a `FILL` slot marker, a file with no slot left to - fill, and a closing `next` value whose line above is not exactly `Next:`; 2 on a missing or unreadable - target and on a slots file that is missing, unreadable, not valid JSON, not a JSON object, or - holding a non-string value. Every refusal names the slot or key and leaves the file - byte-identical, so nothing is ever half-applied. The slots JSON lives beside the handoff as + fill, and a closing `next` value whose line above is not exactly `Next:`; 2 on a target that is + missing, unreadable, or not a handoff file (no `type: handoff` frontmatter, the same guard + `validate` and `emit` apply) and on a slots file that is missing, unreadable, not valid JSON, not + a JSON object, or holding a value that is not a string or not encodable as UTF-8. Every refusal + names the slot or key and leaves the file byte-identical, and the write itself goes to a + temporary file in the target's own directory replaced into place, so nothing is ever + half-applied. The slots JSON lives beside the handoff as `.slots.json` and is left in place. `fill` never judges a value against its slot's instruction; `validate` is the gate. - `save_point.py validate ` prints PASS/WARN/FAIL lines and exits 0 on pass (shape 1: one diff --git a/plugins/session-flow/reference/structure.md b/plugins/session-flow/reference/structure.md index 45024b4484..4a0893f104 100644 --- a/plugins/session-flow/reference/structure.md +++ b/plugins/session-flow/reference/structure.md @@ -13,8 +13,8 @@ frontmatter. Every deterministic field of a shape-2 file is written by the engin slots from one JSON object, `validate` gates it, `emit` prints its resume prompt); the model supplies only the reasoning slots the skeleton leaves as ``, as the values in that object. The write procedure below -is the one path that produces a shape-2 file. Files written before shape 2 (no `handoff_shape` key) are shape 1: read normally, -tolerated by the validator with one WARN, and never rewritten. +is the one path that produces a shape-2 file. Files written before shape 2 (no `handoff_shape` +key) are shape 1: read normally, tolerated by the validator with one WARN, and never rewritten. ## Contents @@ -570,10 +570,13 @@ every other refusal names its fix. file does not carry. It exits 1 when it refuses: a required slot absent from the JSON, a key naming no slot in the file, a slot name occurring twice in the file, a value that itself carries a `FILL` slot marker, no slot in the file at all, or a closing `next` value whose line above is not -exactly `Next:`. It exits 2 on usage, on a missing or unreadable target, and on a slots file that is -missing, unreadable, not valid JSON, not a JSON object, or holds a non-string value. Every refusal -names the offending slot or key and leaves the target byte-identical, so a corrected JSON re-runs -cleanly; nothing is half-applied. +exactly `Next:`. It exits 2 on usage, on a target that is missing, unreadable, or not a handoff +file (no `type: handoff` frontmatter, the same guard `validate` and `emit` apply), and on a slots +file that is missing, unreadable, not valid JSON, not a JSON object, or holds a value that is not a +string or not encodable as UTF-8. Every refusal names the offending slot or key and leaves the +target byte-identical, so a corrected JSON re-runs cleanly; nothing is half-applied. The write +itself goes to a temporary file in the target's own directory and is then replaced into place, so +an interrupted write cannot leave a truncated handoff. **Python-absent fallback.** When the ladder finds no Python 3.10+, say so in one line (`validator unavailable: no python3/python on PATH`), write the shape-2 file by hand from this diff --git a/plugins/session-flow/scripts/save_point.py b/plugins/session-flow/scripts/save_point.py index 307d11ebbf..d563e0e50f 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -29,9 +29,10 @@ value carrying the literal ` verbatim" + del payload["did"] + payload["did"] = 7 + slots = slots_file(tmp_path, payload) else: payload["brief"] = "\ud800" slots = slots_file(tmp_path, payload) @@ -1225,14 +1233,30 @@ def test_fill_refuses_a_missing_target(tmp_path): assert "not a file" in err(result) -def test_fill_refuses_an_unreadable_target(tmp_path): - directory = tmp_path / "target-is-a-directory.md" - directory.mkdir() - result = run( - "fill", str(directory), "--slots", slots_file(tmp_path, {"brief": "x"}) +def test_fill_refuses_an_undecodable_target(tmp_path): + """A directory here would only re-run the missing-target branch; invalid + UTF-8 is what exercises the decode refusal.""" + target = tmp_path / "not-utf8.md" + target.write_bytes(b"---\ntype: handoff\n---\n\xff\xfe not utf-8\n") + before = target.read_bytes() + result = run("fill", str(target), "--slots", slots_file(tmp_path, {"brief": "x"})) + assert result.returncode == 2, out(result) + err(result) + assert "cannot read" in err(result) + assert target.read_bytes() == before + + +def test_fill_refuses_a_file_that_is_not_a_handoff(tmp_path): + target = tmp_path / "not-a-handoff.md" + target.write_text( + "---\ntype: note\n---\n\n\n", + encoding="utf-8", + newline="\n", ) + before = target.read_bytes() + result = run("fill", str(target), "--slots", slots_file(tmp_path, {"brief": "x"})) assert result.returncode == 2, out(result) + err(result) - assert "not a file" in err(result) + assert "not a handoff file" in err(result) + assert target.read_bytes() == before def test_fill_refuses_an_unreadable_slots_file(tmp_path): @@ -1262,6 +1286,20 @@ def test_fill_closing_value_rewrites_next_and_deletes_the_slot(tmp_path): assert NEXT_CLOSED in out(emitted) +def test_fill_closing_value_into_crlf_keeps_crlf(tmp_path): + target = new_skeleton(tmp_path) + target.write_bytes(target.read_bytes().replace(b"\n", b"\r\n")) + payload = required_slots(target.read_text(encoding="utf-8")) + payload["next"] = NEXT_CLOSED + run("fill", str(target), "--slots", slots_file(tmp_path, payload)).check_returncode() + data = target.read_bytes() + assert f"\r\n{NEXT_CLOSED}\r\n─".encode("utf-8") in data + assert b"\r\r" not in data + assert b"\n" not in data.replace(b"\r\n", b"") + validated = run("validate", str(target), "--strict-transcript") + assert validated.returncode == 0, out(validated) + err(validated) + + def test_fill_closing_value_works_with_goal_rearm_present(tmp_path): target = new_skeleton(tmp_path) payload = required_slots(target.read_text(encoding="utf-8")) @@ -1312,7 +1350,12 @@ def test_fill_an_ordinary_next_value_leaves_next_bare(tmp_path): def test_fill_slot_pattern_round_trips_through_the_fill_helper(tmp_path): """The pattern and `_fill()` are one grammar: every slot the skeleton carries re-renders to the exact span the pattern matched. Comparing whole - lines would be false for the prefixed lines and for the two-slot line.""" + lines would be false for the prefixed lines and for the two-slot line. + + The line that pins non-greediness is `len(matches) > len(carrying)`. The + round trip alone does not: a greedy instruction group swallows the rest of + the two-slot line INTO the group, so `_fill()` re-renders it identically and + every assertion in the loop still passes on one match instead of two.""" module = _save_point_module() target = new_skeleton(tmp_path) text = target.read_text(encoding="utf-8") @@ -1351,6 +1394,15 @@ def test_fill_preserves_a_file_with_no_trailing_newline(tmp_path): assert after.endswith(b"the loop is retired.") +def test_fill_leaves_no_other_file_beside_the_target(tmp_path): + """The write goes through a temporary file in the handoffs dir; a run that + leaves one behind would put a stray file where handoffs are swept for.""" + target = new_skeleton(tmp_path) + payload = required_slots(target.read_text(encoding="utf-8")) + run("fill", str(target), "--slots", slots_file(tmp_path, payload)).check_returncode() + assert sorted(p.name for p in target.parent.iterdir()) == [target.name] + + def test_fill_value_with_a_lone_cr_is_not_split(tmp_path): target = new_skeleton(tmp_path) payload = required_slots(target.read_text(encoding="utf-8")) diff --git a/plugins/session-flow/skills/handoff/evals/evals.json b/plugins/session-flow/skills/handoff/evals/evals.json index bca9b920ca..2787246a75 100644 --- a/plugins/session-flow/skills/handoff/evals/evals.json +++ b/plugins/session-flow/skills/handoff/evals/evals.json @@ -235,7 +235,7 @@ "id": 17, "name": "validator-failure-is-fixed-then-loud-never-silent", "prompt": "Handoff. (Note: `save_point.py fill` exits 1 saying the required slot 'open-questions' is absent from your slots JSON; then, once that is keyed and `fill` exits 0, `save_point.py validate` exits 1 reporting six lines under Next:; suppose that after three fix attempts it is still failing on something you cannot resolve.)", - "expected_output": "The `fill` refusal is read and fixed at its source: the named slot is added to the slots JSON and `fill` re-run, never worked around with an Edit, and the target was left byte-identical so the re-run is clean. The `validate` failure is then fixed the same way, not ignored: `Next:` is cut to five headlines and `validate` re-run. When the file still fails after three attempts, the skill neither withholds the resume prompt nor claims success: it prints an `UNVALIDATED: ` banner ABOVE the copy instruction (outside the copy region), marks the checklist box `validate: FAILED`, and still emits the rails from the file's `## Resume prompt` section verbatim. It never regenerates a prompt by hand to route around the validator, and never presents the file as validated.", + "expected_output": "The `fill` refusal is read and fixed at its source: the named slot is added to the slots JSON and `fill` re-run, never worked around with an Edit, and the target was left byte-identical so the re-run is clean. The `validate` failure is then repaired with Edit on the line it names, since a filled file carries no slot left for `fill` to re-apply: `Next:` is cut to five headlines and `validate` re-run. When the file still fails after three attempts, the skill neither withholds the resume prompt nor claims success: it prints an `UNVALIDATED: ` banner ABOVE the copy instruction (outside the copy region), marks the checklist box `validate: FAILED`, and still emits the rails from the file's `## Resume prompt` section verbatim. It never regenerates a prompt by hand to route around the validator, and never presents the file as validated.", "files": [], "expectations": [ "The `fill` exit 1 is treated as a defect in the slots JSON: the named slot is keyed and `fill` re-run, rather than the slot being hand-edited into the file or the refusal ignored", From 7437e7c667b27c9ea5790579626d4953bacdfa2b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:37:48 -0500 Subject: [PATCH 4/4] fix(session-flow): fill refuses a handoff whose shape is not 2 `cmd_fill` guarded only `type: handoff`, so a skeleton written by a newer engine was accepted: a `handoff_shape: 3` file carrying a familiar `` 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 --- plugins/session-flow/CHANGELOG.md | 12 ++++---- plugins/session-flow/reference/save-point.md | 9 ++++-- plugins/session-flow/scripts/save_point.py | 29 ++++++++++++++----- .../scripts/tests/test_save_point.py | 29 +++++++++++++++++++ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index be2d65a6b2..db334c2d0a 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -17,11 +17,13 @@ slot left are each refused by name with the file left byte-identical. A missing or unreadable target, and a slots file that is missing, unreadable, not a JSON object, or holds a non-string or non-UTF-8-encodable value, exit 2, as does a target that is not a handoff file, the same - `type: handoff` guard `validate` and `emit` apply. The write goes to a temporary file in the - target's own directory and is replaced into place, so an interrupted write cannot truncate the - handoff. The target's own line endings survive, so a CRLF handoff stays CRLF. `new`, `validate`, - and `emit` keep their behavior and exit codes, and the Edit tool is now only the repair path - after a failed `validate`. + `type: handoff` guard `validate` and `emit` apply, and a target whose shape is not 2, since + substitutions belong to the shape this engine writes. A shape newer than 2 exits 3 with + `validate`'s wording, read it and do not rewrite it, so version skew cannot corrupt a + future-format handoff. The write goes to a temporary file in the target's own directory and is + replaced into place, so an interrupted write cannot truncate the handoff. The target's own line + endings survive, so a CRLF handoff stays CRLF. `new`, `validate`, and `emit` keep their behavior + and exit codes, and the Edit tool is now only the repair path after a failed `validate`. ## [0.35.12] diff --git a/plugins/session-flow/reference/save-point.md b/plugins/session-flow/reference/save-point.md index ae327f5b16..256424ed48 100644 --- a/plugins/session-flow/reference/save-point.md +++ b/plugins/session-flow/reference/save-point.md @@ -219,9 +219,12 @@ ladder the structure doc's write procedure shows (`"$PY" -X utf8 …`, Python 3. required slot absent from the object, a key naming no slot in this file, a slot name occurring twice in the file, a value that itself carries a `FILL` slot marker, a file with no slot left to fill, and a closing `next` value whose line above is not exactly `Next:`; 2 on a target that is - missing, unreadable, or not a handoff file (no `type: handoff` frontmatter, the same guard - `validate` and `emit` apply) and on a slots file that is missing, unreadable, not valid JSON, not - a JSON object, or holding a value that is not a string or not encodable as UTF-8. Every refusal + missing, unreadable, not a handoff file (no `type: handoff` frontmatter, the same guard + `validate` and `emit` apply), or not shape 2 (shape 1, no `handoff_shape` key, a shape below 1, + or a `handoff_shape` that is not an integer), and on a slots file that is missing, unreadable, + not valid JSON, not a JSON object, or holding a value that is not a string or not encodable as + UTF-8; 3 on a `handoff_shape` newer than the engine knows, `validate`'s own verdict, read it and + do not rewrite it. Every refusal names the slot or key and leaves the file byte-identical, and the write itself goes to a temporary file in the target's own directory replaced into place, so nothing is ever half-applied. The slots JSON lives beside the handoff as diff --git a/plugins/session-flow/scripts/save_point.py b/plugins/session-flow/scripts/save_point.py index d563e0e50f..41dbe67ed0 100755 --- a/plugins/session-flow/scripts/save_point.py +++ b/plugins/session-flow/scripts/save_point.py @@ -29,10 +29,12 @@ value carrying the literal `