From 91b5fb9b8467e1ecb793f9667e58e8defcda8142 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sat, 22 Aug 2026 10:50:12 -0500 Subject: [PATCH 1/2] fix(journal): keep prose whole on write, lead with open threads on read Two defects in the journal store, both of which fail toward looking fine. WRITE. The three journal fields were each split on ";", so a semicolon used as ordinary punctuation fragmented a sentence and stripped its subject. The result reads as a list of terse notes rather than as damage, which is why it survived: nothing looks broken. Measured on a real entry, one field produced seven items, six of which began mid-sentence, and one read in full as "the fix on grip dev is 4d11834e" -- true, unattached, meaningless to the reader it was written for. Splitting now prefers newlines and falls back to semicolons only when the text has none. That is how these fields are actually written -- one item per line -- and it keeps every existing single-line caller working unchanged, so `--done "a; b; c"` behaves exactly as before. A single-line field containing prose semicolons is still ambiguous by construction; the fallback is what preserves the documented CLI convention, and that trade is deliberate. The split also existed in six places across two modules, which is its own latent defect: fixing one path would have left the other mangling prose. It is now one function, called from both. READ. The session-start read is BOUNDED, and it rendered Done, then Decisions, then Next -- so completed work consumed the window before open threads were reached. Ordering is not a matter of taste on a truncated surface; whatever leads is what survives. Open threads now lead. Completed work is recoverable from git and the board; an unrecorded open question is recoverable from nowhere. The unbounded display is reordered to match. Nothing forces it there, but two surfaces that teach different priorities are their own defect, and a human reading a full entry also reads top-down and stops. DOCUMENTATION. The author-facing text said "Semicolon-separated list" in six places -- three in the MCP tool description and three in CLI help -- and after this change that is wrong for the multi-line case and right for the single-line one. A half-true rule is worse than the original defect, because it is confidently wrong in one direction. Both surfaces now state the real behaviour at the point of writing, along with the fact that the session-start read is truncated and leads with next_steps, since that changes how an author composes an entry. The two genuinely semicolon-delimited options elsewhere are left alone. Both witnesses assert what the READER RECEIVES rather than what was written, which is the only form that catches this class -- the same move as verifying a published artifact against what the server serves rather than against what was uploaded. Each is killed by exactly one mutation: reversing the ordering tuple reddens the ordering witness alone, and restoring the semicolon-only split reddens the prose witness alone. Co-Authored-By: Claude --- src/synapt/recall/cli.py | 13 ++++--- src/synapt/recall/journal.py | 32 ++++++++++++++-- src/synapt/recall/server.py | 26 +++++++++---- tests/recall/test_journal.py | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/synapt/recall/cli.py b/src/synapt/recall/cli.py index 6c92e761..33ffaf56 100644 --- a/src/synapt/recall/cli.py +++ b/src/synapt/recall/cli.py @@ -85,6 +85,7 @@ _read_all_session_ids, auto_extract_entry, append_entry, + split_journal_field, ) @@ -1722,12 +1723,12 @@ def cmd_journal(args: argparse.Namespace) -> None: if args.focus: entry.focus = args.focus if args.done: - entry.done = [d.strip() for d in args.done.split(";")] + entry.done = split_journal_field(args.done) if args.decisions: - entry.decisions = [d.strip() for d in args.decisions.split(";")] + entry.decisions = split_journal_field(args.decisions) explicit_next_steps = list(entry.next_steps) if args.next: - entry.next_steps = [n.strip() for n in args.next.split(";")] + entry.next_steps = split_journal_field(args.next) explicit_next_steps = list(entry.next_steps) entry.next_steps = merge_carried_forward_next_steps( entry.next_steps, @@ -3181,9 +3182,9 @@ def make_parser() -> argparse.ArgumentParser: journal_parser.add_argument("--list", action="store_true", help="List recent journal entries") journal_parser.add_argument("--show", type=int, default=None, help="Show Nth most recent entry") journal_parser.add_argument("--focus", default=None, help="What this session was about") - journal_parser.add_argument("--done", default=None, help="What got done (semicolon-separated)") - journal_parser.add_argument("--decisions", default=None, help="Key decisions (semicolon-separated)") - journal_parser.add_argument("--next", default=None, help="Next steps (semicolon-separated)") + journal_parser.add_argument("--done", default=None, help="What got done (one per line; a single-line value falls back to semicolon-separated)") + journal_parser.add_argument("--decisions", default=None, help="Key decisions (one per line; a single-line value falls back to semicolon-separated)") + journal_parser.add_argument("--next", default=None, help="Next steps (one per line; a single-line value falls back to semicolon-separated)") journal_parser.add_argument("--repair", action="store_true", help="Recover fields swallowed by an unclosed tool-call parameter (append-only)") journal_parser.add_argument("--dry-run", action="store_true", diff --git a/src/synapt/recall/journal.py b/src/synapt/recall/journal.py index 32ee6201..fee5c5c4 100644 --- a/src/synapt/recall/journal.py +++ b/src/synapt/recall/journal.py @@ -119,6 +119,24 @@ def _entry_collapses(entry: JournalEntry) -> list[tuple[str, str]]: return found +def split_journal_field(text: str) -> list[str]: + """Split a journal field into items, preferring newlines over semicolons. + + Semicolons are ordinary punctuation in the prose these fields carry, so + splitting on them silently fragments sentences and strips their subject -- + an entry that reads as terse notes rather than as damage. Newlines are how + the fields are actually written, one item per line. + + Newline-first keeps every existing single-line caller working unchanged: + with no newline present the semicolon behaviour is exactly what it was. + """ + if "\n" in text: + parts = text.split("\n") + else: + parts = text.split(";") + return [item.strip() for item in parts if item.strip()] + + def _served(values: list[str]) -> list[str]: """Drop collapsed values from anything about to be displayed. @@ -446,10 +464,13 @@ def format_for_session_start(entry: JournalEntry) -> str: if entry.focus and not is_collapsed(entry.focus): lines.append(f"Last session ({ts}): {entry.focus}") + # Open threads FIRST. This read is BOUNDED, so ordering is not taste here -- + # whatever leads consumes the window. Completed work is recoverable from git + # and the board; an unrecorded open question is recoverable from nowhere. for label, items in ( - ("Done:", _served(entry.done)), - ("Decisions:", _served(entry.decisions)), ("Next steps:", _served(entry.next_steps)), + ("Decisions:", _served(entry.decisions)), + ("Done:", _served(entry.done)), ): if items: lines.append(label) @@ -465,10 +486,13 @@ def format_entry_full(entry: JournalEntry) -> str: lines.append(f"**Branch:** {entry.branch}") if entry.focus and not is_collapsed(entry.focus): lines.append(f"**Focus:** {entry.focus}") + # Same order as the session-start read. This surface is unbounded, so the + # ordering is not forced here -- but two surfaces that teach different + # priorities are their own defect, and a human reading top-down also stops. for heading, items in ( - ("\n### Done", _served(entry.done)), - ("\n### Decisions", _served(entry.decisions)), ("\n### Next", _served(entry.next_steps)), + ("\n### Decisions", _served(entry.decisions)), + ("\n### Done", _served(entry.done)), ): if items: lines.append(heading) diff --git a/src/synapt/recall/server.py b/src/synapt/recall/server.py index cc10e515..42506fe5 100644 --- a/src/synapt/recall/server.py +++ b/src/synapt/recall/server.py @@ -146,7 +146,7 @@ def _get_index(use_embeddings: bool = True) -> TranscriptIndex | None: try: from synapt.recall.journal import ( extract_session_id, latest_transcript_path, - ) + ) live_path = latest_transcript_path() if live_path: _cached_index._current_session_id = extract_session_id(live_path) @@ -1638,9 +1638,20 @@ def recall_journal( action: "read" (latest entry), "write" (create entry), "list" (recent entries), or "pending" (unresolved carry-forward next steps only). focus: What this session was about (write only). - done: Semicolon-separated list of accomplishments (write only). - decisions: Semicolon-separated list of key decisions (write only). - next_steps: Semicolon-separated list of next steps (write only). + done: Accomplishments, ONE PER LINE (write only). + decisions: Key decisions, ONE PER LINE (write only). + next_steps: Next steps, ONE PER LINE (write only). + + Item separation: these three fields split on NEWLINES, so a semicolon inside + a multi-line field is ordinary punctuation and the sentence stays whole. If a + field contains NO newline it falls back to splitting on semicolons, which + keeps older single-line callers working -- so a semicolon inside a + single-line item WILL still split it. Write one item per line and this never + bites you. + + Ordering: the session-start read is TRUNCATED, and it leads with next_steps. + Put what is unresolved there; completed work is recoverable from version + control and the tracker, an unrecorded open question is not. """ try: from synapt.recall.journal import ( @@ -1655,6 +1666,7 @@ def recall_journal( read_entries, read_latest, read_previous_meaningful, + split_journal_field, ) if action == "read": @@ -1687,12 +1699,12 @@ def recall_journal( if focus: entry.focus = focus if done: - entry.done = [d.strip() for d in done.split(";")] + entry.done = split_journal_field(done) if decisions: - entry.decisions = [d.strip() for d in decisions.split(";")] + entry.decisions = split_journal_field(decisions) explicit_next_steps = list(entry.next_steps) if next_steps: - entry.next_steps = [n.strip() for n in next_steps.split(";")] + entry.next_steps = split_journal_field(next_steps) explicit_next_steps = list(entry.next_steps) entry.next_steps = merge_carried_forward_next_steps( entry.next_steps, diff --git a/tests/recall/test_journal.py b/tests/recall/test_journal.py index 36331daa..6b79a15d 100644 --- a/tests/recall/test_journal.py +++ b/tests/recall/test_journal.py @@ -8,6 +8,7 @@ from synapt.recall.journal import ( JournalEntry, + split_journal_field, _dedup_entries, append_entry, auto_extract_entry, @@ -727,3 +728,73 @@ def test_all_done_returns_empty(self): if __name__ == "__main__": unittest.main() + + +class TestJournalFieldSurvivesTheReader(unittest.TestCase): + """The claim is not "what I wrote" -- it is "what the reader receives".""" + + def test_prose_with_a_semicolon_reaches_the_reader_whole(self): + # Written the way agents actually write these fields: one item per line, + # with semicolons as ordinary punctuation inside a sentence. + written = ( + "LIVE HAZARD: do not run the cleanup; the installed binary predates the fix\n" + "second item" + ) + entry = JournalEntry(timestamp="2026-01-01T00:00", next_steps=split_journal_field(written)) + + # Read back what a FRESH SESSION RECEIVES, not what was written. + served = format_for_session_start(entry) + + self.assertIn( + "do not run the cleanup; the installed binary predates the fix", + served, + "the sentence must reach the reader whole -- a fragment reads as a terse " + "note rather than as damage, so nothing looks broken", + ) + self.assertEqual(len(entry.next_steps), 2, "one item per line, not per clause") + + def test_single_line_semicolons_still_split_for_existing_callers(self): + # Control, and a documented limit: with no newline the semicolon behaviour + # is exactly what it was, so `--done "a; b; c"` keeps working. + self.assertEqual(split_journal_field("a; b; c"), ["a", "b", "c"]) + + +class TestBoundedReadLeadsWithOpenThreads(unittest.TestCase): + """This read is BOUNDED, so whatever leads consumes the window.""" + + def test_open_threads_precede_completed_work(self): + entry = JournalEntry( + timestamp="2026-01-01T00:00", + done=["shipped the thing"], + decisions=["chose the approach"], + next_steps=["LIVE HAZARD: unresolved"], + ) + served = format_for_session_start(entry) + + hazard = served.index("LIVE HAZARD: unresolved") + completed = served.index("shipped the thing") + self.assertLess( + hazard, + completed, + "completed work is recoverable from git and the board; an unrecorded " + "open question is recoverable from nowhere, so it must not be the part " + "that gets truncated away", + ) + + # Positive control: both are actually present, so the ordering assertion + # is about ORDER and cannot pass by one of them simply being absent. + self.assertIn("shipped the thing", served) + self.assertIn("chose the approach", served) + + def test_full_display_teaches_the_same_priority(self): + entry = JournalEntry( + timestamp="2026-01-01T00:00", + done=["shipped the thing"], + next_steps=["still open"], + ) + text = format_entry_full(entry) + self.assertLess( + text.index("### Next"), + text.index("### Done"), + "two surfaces that teach different priorities are their own defect", + ) From ccc823d7779fa838ee08db366ee3f43acd99c971 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sat, 22 Aug 2026 12:02:28 -0500 Subject: [PATCH 2/2] chore(release): bump version to 0.19.1 Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- src/synapt/recall/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f9551a08..0a47611b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "synapt" -version = "0.19.0" +version = "0.19.1" description = "Persistent conversational memory for AI coding assistants" readme = "README.md" license = "MIT" diff --git a/src/synapt/recall/__init__.py b/src/synapt/recall/__init__.py index 9140704a..432ce8fe 100644 --- a/src/synapt/recall/__init__.py +++ b/src/synapt/recall/__init__.py @@ -1,6 +1,6 @@ """synapt.recall — persistent conversational memory for Claude Code and ChatGPT sessions.""" -__version__ = "0.19.0" +__version__ = "0.19.1" from synapt.recall.core import ( TranscriptChunk,