chsum 3.1.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {chsum-3.1.0 → chsum-3.2.0}/.gitignore +1 -0
  2. {chsum-3.1.0 → chsum-3.2.0}/PKG-INFO +13 -3
  3. {chsum-3.1.0 → chsum-3.2.0}/README.md +12 -2
  4. {chsum-3.1.0 → chsum-3.2.0}/chsum/checkpoints.py +207 -64
  5. {chsum-3.1.0 → chsum-3.2.0}/chsum/core.py +2544 -150
  6. chsum-3.2.0/chsum/observe.py +2064 -0
  7. chsum-3.2.0/chsum/sessions.py +52 -0
  8. {chsum-3.1.0 → chsum-3.2.0}/pyproject.toml +1 -1
  9. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/SKILL.md +68 -2
  10. chsum-3.2.0/skills/chsum/references/observe.md +103 -0
  11. {chsum-3.1.0 → chsum-3.2.0}/tests/test_branches.py +2 -2
  12. chsum-3.2.0/tests/test_cache.py +213 -0
  13. chsum-3.2.0/tests/test_rewind.py +356 -0
  14. {chsum-3.1.0 → chsum-3.2.0}/CONTRIBUTING.md +0 -0
  15. {chsum-3.1.0 → chsum-3.2.0}/LICENSE +0 -0
  16. {chsum-3.1.0 → chsum-3.2.0}/chsum/__init__.py +0 -0
  17. {chsum-3.1.0 → chsum-3.2.0}/chsum/__main__.py +0 -0
  18. {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/__init__.py +0 -0
  19. {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/claude.py +0 -0
  20. {chsum-3.1.0 → chsum-3.2.0}/chsum/sources/codex.py +0 -0
  21. {chsum-3.1.0 → chsum-3.2.0}/chsum.py +0 -0
  22. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/checkpoints.md +0 -0
  23. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/debugging.md +0 -0
  24. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/naming.md +0 -0
  25. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/noting.md +0 -0
  26. {chsum-3.1.0 → chsum-3.2.0}/skills/chsum/references/recap.md +0 -0
  27. {chsum-3.1.0 → chsum-3.2.0}/tests/harness.py +0 -0
  28. {chsum-3.1.0 → chsum-3.2.0}/tests/test_checkpoints.py +0 -0
  29. {chsum-3.1.0 → chsum-3.2.0}/tests/test_codex_source.py +0 -0
  30. {chsum-3.1.0 → chsum-3.2.0}/tests/test_handbacks.py +0 -0
  31. {chsum-3.1.0 → chsum-3.2.0}/tests/test_undo.py +0 -0
  32. {chsum-3.1.0 → chsum-3.2.0}/tests/test_views.py +0 -0
@@ -11,6 +11,7 @@ dist/
11
11
  # Bench outputs: per-run artefacts, read locally. The material and variants
12
12
  # are committed, which is what makes two runs comparable.
13
13
  bench/out/
14
+ bench/observe/work/
14
15
  meta/*.mov
15
16
  meta/*.mp4
16
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chsum
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented.
5
5
  Author: InDate
6
6
  License-Expression: MIT
@@ -501,7 +501,11 @@ chsum warm [ref] # ping an idle session before expiry, until Ctrl-C; -
501
501
  agent with a one-hour cache (`cacheTtl: 1h`); long work you return to suits
502
502
  its own session, which keeps the hour, takes messages and takes `chsum warm`.
503
503
  - **`chsum warm`** resumes the session without saving a turn, so the transcript
504
- stays as it was. Each ping draws on your usage limits. Agents fall outside it.
504
+ stays as it was. It pings after a random wait of 30 to 58 minutes on
505
+ one-hour entries (half to 58/60 of the lifetime), counted from the last
506
+ request or ping. A ping that fails, a hook block included, stops it with the
507
+ reply's own error text. Each ping draws on your usage limits. Agents fall
508
+ outside it.
505
509
 
506
510
  The plugin's hooks put the same figures where the choice is made: a message
507
511
  that would resume a cold agent, or wake an idle session with a cold cache,
@@ -679,7 +683,13 @@ command, and nothing reaches its context between runs.
679
683
 
680
684
  Every response in a transcript records the cache it read and wrote, split into
681
685
  one-hour and five-minute writes, so a session's or an agent's cache expiry is
682
- computed from the transcript with no request sent. Measured on a subscription:
686
+ computed from the transcript with no request sent. The clock runs from the
687
+ time the request went out, the record the reply hangs off, since an entry's
688
+ lifetime counts from the request's start and the reply's own records are stamped
689
+ as it streams. A `chsum warm` ping leaves no record in the transcript, so each
690
+ ping that hits is logged in `pings/<session>.jsonl` in chsum's data directory;
691
+ `chsum cache` and both hooks count the later of the last request and the last
692
+ logged ping. Measured on a subscription:
683
693
  the main session's cache outlived a five-minute gap; an agent's did not, and
684
694
  `subagentPromptCacheTtl: "1h"` held it; a resumed agent read its cache only if
685
695
  it had made a tool call; a message to another idle session read that
@@ -490,7 +490,11 @@ chsum warm [ref] # ping an idle session before expiry, until Ctrl-C; -
490
490
  agent with a one-hour cache (`cacheTtl: 1h`); long work you return to suits
491
491
  its own session, which keeps the hour, takes messages and takes `chsum warm`.
492
492
  - **`chsum warm`** resumes the session without saving a turn, so the transcript
493
- stays as it was. Each ping draws on your usage limits. Agents fall outside it.
493
+ stays as it was. It pings after a random wait of 30 to 58 minutes on
494
+ one-hour entries (half to 58/60 of the lifetime), counted from the last
495
+ request or ping. A ping that fails, a hook block included, stops it with the
496
+ reply's own error text. Each ping draws on your usage limits. Agents fall
497
+ outside it.
494
498
 
495
499
  The plugin's hooks put the same figures where the choice is made: a message
496
500
  that would resume a cold agent, or wake an idle session with a cold cache,
@@ -668,7 +672,13 @@ command, and nothing reaches its context between runs.
668
672
 
669
673
  Every response in a transcript records the cache it read and wrote, split into
670
674
  one-hour and five-minute writes, so a session's or an agent's cache expiry is
671
- computed from the transcript with no request sent. Measured on a subscription:
675
+ computed from the transcript with no request sent. The clock runs from the
676
+ time the request went out, the record the reply hangs off, since an entry's
677
+ lifetime counts from the request's start and the reply's own records are stamped
678
+ as it streams. A `chsum warm` ping leaves no record in the transcript, so each
679
+ ping that hits is logged in `pings/<session>.jsonl` in chsum's data directory;
680
+ `chsum cache` and both hooks count the later of the last request and the last
681
+ logged ping. Measured on a subscription:
672
682
  the main session's cache outlived a five-minute gap; an agent's did not, and
673
683
  `subagentPromptCacheTtl: "1h"` held it; a resumed agent read its cache only if
674
684
  it had made a tool call; a message to another idle session read that
@@ -40,6 +40,13 @@ LOCK_NAME = "chsum-checkpoint.lock"
40
40
  _LOCK_WAIT = 20.0
41
41
  _LOCK_POLL = 0.05
42
42
  _CHECKPOINT_PREFIX = "chsum-checkpoint: "
43
+ # The action of a checkpoint holding what changed between two of a session's
44
+ # calls by any other writer. It carries no call and no step.
45
+ OUTSIDE = "outside"
46
+ # The trees `record_pre` writes, one file per call, under the worktree's git
47
+ # directory, and how long one outlives a call that never reached PostToolUse.
48
+ _PRE_DIR = "chsum-pre"
49
+ _PRE_LIFE = 86400
43
50
  _HOOK_GIT_TIMEOUT = 30 # seconds per git call — this must never be what hangs a turn
44
51
  # The subject's tail is the call that wrote the change, or, for a checkpoint
45
52
  # `chsum undo` or `chsum redo` wrote, that action and the stamp of the step it
@@ -47,7 +54,8 @@ _HOOK_GIT_TIMEOUT = 30 # seconds per git call — this must never be what hangs
47
54
  # step. `_checkpoint_message` builds what this parses.
48
55
  _CHECKPOINT_RE = re.compile(
49
56
  "^" + re.escape(_CHECKPOINT_PREFIX) + r"(?P<session>\S+) @ (?P<when>\S+)"
50
- r"(?: (?P<action>undo|redo) (?P<step>[^\s/]+)(?:/(?P<part>[a-z]+))?| (?P<call>\S+))?$")
57
+ r"(?: (?P<action>undo|redo) (?P<step>[^\s/]+)(?:/(?P<part>[a-z]+))?| (?P<outside>outside)"
58
+ r"| (?P<call>\S+))?$")
51
59
  _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
52
60
 
53
61
  # `core` installs its tracer here at import. Left None, every call below runs
@@ -57,7 +65,7 @@ TRACER = None
57
65
 
58
66
  def _checkpoint_message(session_id: str, when: str, call: str = "",
59
67
  action: str = "", step: str = "", part: str = "") -> str:
60
- tail = (f" {action} {step}" + (f"/{part}" if part else "") if action
68
+ tail = (f" {action}" + (f" {step}" if step else "") + (f"/{part}" if part else "") if action
61
69
  else f" {call}" if call else "")
62
70
  return f"{_CHECKPOINT_PREFIX}{session_id} @ {when}{tail}"
63
71
 
@@ -66,8 +74,10 @@ def _checkpoint_message(session_id: str, when: str, call: str = "",
66
74
  class ChainEntry:
67
75
  """One checkpoint on a session's chain. `action` is "undo" or "redo" on a
68
76
  checkpoint those commands wrote, with `step` the stamp of the checkpoint
69
- they reversed or re-applied; both are "" on one the hook wrote. `part` is
70
- the letter of the one file of that step it acted on, "" for the whole step."""
77
+ they reversed or re-applied; both are "" on one the hook wrote. `action` is
78
+ `OUTSIDE` on a checkpoint holding other writers' changes, with no step and
79
+ no call. `part` is the letter of the one file of that step it acted on, ""
80
+ for the whole step."""
71
81
  when: str
72
82
  sha: str
73
83
  call: str = ""
@@ -203,13 +213,16 @@ def _tree_of(cwd: pathlib.Path, commit: str) -> str:
203
213
 
204
214
  def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
205
215
  ref: str = "", action: str = "", step: str = "",
206
- part: str = "") -> str:
216
+ part: str = "", pre_tree: str = "") -> str:
207
217
  """One checkpoint per tool call that changed the tree, chained under this
208
218
  session's ref. `ref` is the `tool_use` id of the call that caused it, "" where
209
219
  the payload carries none. `action` and `step` are set by `chsum undo` and
210
220
  `chsum redo`: the action, and the stamp of the checkpoint it reversed or
211
- re-applied, with `part` the letter of the one file it acted on. Returns the
212
- checkpoint written, "" where none was.
221
+ re-applied, with `part` the letter of the one file it acted on. `pre_tree`
222
+ is the tree `record_pre` wrote before the call: with it, changes made since
223
+ the last checkpoint by anything other than the call go into an `outside`
224
+ checkpoint beneath the call's, so the call's diff holds its own paths only
225
+ (`_outside_commit`). Returns the checkpoint written, "" where none was.
213
226
 
214
227
  `HEAD` is never written. The commit object is built by `commit-tree` from a
215
228
  tree and a parent, and only `update-ref` publishes it, so no step of this
@@ -235,43 +248,14 @@ def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
235
248
  if not status.stdout.strip() and not action:
236
249
  return ""
237
250
 
238
- try:
239
- index = _git(["git", "rev-parse", "--git-path", _INDEX_NAME], cwd)
240
- except (OSError, subprocess.SubprocessError):
251
+ tree_sha = _worktree_tree(cwd)
252
+ if not tree_sha:
241
253
  return ""
242
- if index.returncode != 0:
254
+ # A tree equal to the one before the call means the call wrote nothing,
255
+ # whatever else changed since the last checkpoint: those changes wait for
256
+ # the next call that writes, and go into its `outside` checkpoint.
257
+ if pre_tree == tree_sha and not action:
243
258
  return ""
244
- # Absolute, so the environment holds one path whatever `cwd` git resolves to.
245
- index_path = pathlib.Path(index.stdout.strip())
246
- if not index_path.is_absolute():
247
- index_path = (cwd / index_path).resolve()
248
- env = {**os.environ, "GIT_INDEX_FILE": str(index_path)}
249
-
250
- try:
251
- staged = _git(["git", "add", "-A"], cwd, env=env)
252
- if staged.returncode != 0:
253
- return ""
254
- tree = _git(["git", "write-tree"], cwd, env=env)
255
- except (OSError, subprocess.SubprocessError):
256
- return ""
257
- finally:
258
- # The throwaway index is rebuilt from scratch on the next call, so a
259
- # stale one costs nothing; leaving it does not touch the user's.
260
- pass
261
- if tree.returncode != 0:
262
- return ""
263
- tree_sha = tree.stdout.strip()
264
-
265
- session_ref = checkpoint_ref(session_id)
266
- tip = _ref_tip(cwd, session_ref)
267
- # A call that wrote nothing leaves the tree equal to the last checkpoint's:
268
- # the working tree differs from `HEAD` for the whole session, so the status
269
- # check above passes on every call and a `Read` would otherwise chain a copy.
270
- if tip and _tree_of(cwd, tip) == tree_sha:
271
- return ""
272
- parent = tip or _head_sha(cwd)
273
- if not parent:
274
- return "" # no commits yet — nothing to parent the first checkpoint on
275
259
 
276
260
  now = datetime.now(timezone.utc)
277
261
  # Millisecond precision, matching Claude Code's transcript timestamps: every
@@ -283,37 +267,185 @@ def _write_checkpoint(cwd: pathlib.Path, git_dir: pathlib.Path, session_id: str,
283
267
  # that address at read time — the transcript record holding the call is
284
268
  # flushed after the hook fires, so the row does not exist yet here.
285
269
  message = _checkpoint_message(session_id, ts, ref, action, step, part)
286
-
287
- try:
288
- made = _git(["git", "commit-tree", tree_sha, "-p", parent, "-m", message], cwd)
289
- except (OSError, subprocess.SubprocessError):
290
- return ""
291
- if made.returncode != 0:
292
- return ""
293
- commit = made.stdout.strip()
270
+ session_ref = checkpoint_ref(session_id)
294
271
 
295
272
  # Compare-and-swap on the tip: parallel subagents share one worktree, and two
296
273
  # hooks reading the same tip would otherwise fork the chain. The loser sees
297
274
  # its expected old value fail and takes the retry, which re-reads the tip and
298
275
  # chains onto the winner.
299
276
  for _ in range(2):
277
+ tip = _ref_tip(cwd, session_ref)
278
+ # A call that wrote nothing leaves the tree equal to the last checkpoint's:
279
+ # the working tree differs from `HEAD` for the whole session, so the status
280
+ # check above passes on every call and a `Read` would otherwise chain a copy.
281
+ if tip and _tree_of(cwd, tip) == tree_sha:
282
+ return ""
283
+ parent = tip or _head_sha(cwd)
284
+ if not parent:
285
+ return "" # no commits yet — nothing to parent the first checkpoint on
286
+ if pre_tree:
287
+ parent = _outside_commit(cwd, session_id, ts, parent, pre_tree, tree_sha)
288
+ if parent and _tree_of(cwd, parent) == tree_sha:
289
+ # Every change since the last checkpoint came from outside the call:
290
+ # the `outside` checkpoint alone records them.
291
+ commit, made_one = parent, False
292
+ else:
293
+ try:
294
+ made = _git(["git", "commit-tree", tree_sha, "-p", parent, "-m", message], cwd)
295
+ except (OSError, subprocess.SubprocessError):
296
+ return ""
297
+ if made.returncode != 0:
298
+ return ""
299
+ commit, made_one = made.stdout.strip(), True
300
+ if commit == tip:
301
+ return ""
300
302
  try:
301
- done = _git(["git", "update-ref", session_ref, commit,
302
- tip or _EMPTY_SHA], cwd)
303
+ done = _git(["git", "update-ref", session_ref, commit, tip or _EMPTY_SHA], cwd)
303
304
  except (OSError, subprocess.SubprocessError):
304
305
  return ""
305
306
  if done.returncode == 0:
306
- return commit
307
- tip = _ref_tip(cwd, session_ref)
308
- if not tip or _tree_of(cwd, tip) == tree_sha:
309
- return "" # the winner wrote this same tree; nothing left to record
310
- try:
311
- again = _git(["git", "commit-tree", tree_sha, "-p", tip, "-m", message], cwd)
312
- except (OSError, subprocess.SubprocessError):
307
+ return commit if made_one else ""
308
+ return ""
309
+
310
+
311
+ def _git_path(cwd: pathlib.Path, name: str) -> pathlib.Path | None:
312
+ """`name` under this worktree's git directory, absolute, or None where git
313
+ returns none. Per worktree, so two worktrees never share a staging file."""
314
+ try:
315
+ proc = _git(["git", "rev-parse", "--git-path", name], cwd)
316
+ except (OSError, subprocess.SubprocessError):
317
+ return None
318
+ if proc.returncode != 0:
319
+ return None
320
+ path = pathlib.Path(proc.stdout.strip())
321
+ return path if path.is_absolute() else (cwd / path).resolve()
322
+
323
+
324
+ def _index_env(cwd: pathlib.Path) -> dict | None:
325
+ """The environment that points git at the throwaway index, so the user's
326
+ staging area is never in the sequence."""
327
+ index_path = _git_path(cwd, _INDEX_NAME)
328
+ return {**os.environ, "GIT_INDEX_FILE": str(index_path)} if index_path else None
329
+
330
+
331
+ def _worktree_tree(cwd: pathlib.Path) -> str:
332
+ """The tree of the working tree as it stands, every file `git add -A`
333
+ stages, written through the throwaway index; "" where git fails."""
334
+ env = _index_env(cwd)
335
+ if env is None:
336
+ return ""
337
+ try:
338
+ staged = _git(["git", "add", "-A"], cwd, env=env)
339
+ if staged.returncode != 0:
340
+ return ""
341
+ tree = _git(["git", "write-tree"], cwd, env=env)
342
+ except (OSError, subprocess.SubprocessError):
343
+ return ""
344
+ return tree.stdout.strip() if tree.returncode == 0 else ""
345
+
346
+
347
+ def _changed_paths(cwd: pathlib.Path, old: str, new: str) -> set[str]:
348
+ try:
349
+ proc = _git(["git", "diff-tree", "-r", "--name-only", "-z", old, new], cwd)
350
+ except (OSError, subprocess.SubprocessError):
351
+ return set()
352
+ return {p for p in proc.stdout.split("\0") if p} if proc.returncode == 0 else set()
353
+
354
+
355
+ def _tree_with(cwd: pathlib.Path, tree: str, source: str, paths: set[str]) -> str:
356
+ """`tree` with each of `paths` set to its entry in `source`, and removed
357
+ where `source` holds none; "" where git fails."""
358
+ env = _index_env(cwd)
359
+ if env is None:
360
+ return ""
361
+ try:
362
+ if _git(["git", "read-tree", tree], cwd, env=env).returncode != 0:
363
+ return ""
364
+ listed = _git(["git", "ls-tree", "-r", "-z", source, "--", *sorted(paths)], cwd)
365
+ if listed.returncode != 0:
313
366
  return ""
314
- if again.returncode != 0:
367
+ entries = {}
368
+ for row in listed.stdout.split("\0"):
369
+ meta, _, path = row.partition("\t")
370
+ if path:
371
+ mode, _, sha = meta.split(" ")
372
+ entries[path] = f"{mode} {sha}"
373
+ info = "".join(f"{entries.get(p, '0 ' + _EMPTY_SHA)}\t{p}\0" for p in sorted(paths))
374
+ if subprocess.run(["git", "update-index", "-z", "--index-info"], cwd=cwd, input=info,
375
+ capture_output=True, text=True, timeout=_HOOK_GIT_TIMEOUT,
376
+ env=env).returncode != 0:
315
377
  return ""
316
- commit = again.stdout.strip()
378
+ made = _git(["git", "write-tree"], cwd, env=env)
379
+ except (OSError, subprocess.SubprocessError, ValueError):
380
+ return ""
381
+ return made.stdout.strip() if made.returncode == 0 else ""
382
+
383
+
384
+ def _outside_commit(cwd: pathlib.Path, session_id: str, ts: str, parent: str,
385
+ pre_tree: str, post_tree: str) -> str:
386
+ """The commit a call's checkpoint is parented on: `parent` where the tree
387
+ before the call matches it, and otherwise an `outside` checkpoint on
388
+ `parent` holding what changed between them, written by another session,
389
+ the person, or a call of this session running in parallel.
390
+
391
+ The call's own paths are those that differ both from before the call to
392
+ after it and from `parent` to after it. The second condition drops a path a
393
+ parallel call of this session wrote and checkpointed first: `parent`
394
+ already holds it as it stands. The `outside` tree is the tree after the
395
+ call with the call's own paths set back to `parent`'s, so the call's
396
+ checkpoint diffs against it with its own paths only. Returns `parent`
397
+ where git fails, which leaves the outside changes in the call's diff, as
398
+ every checkpoint carried them before."""
399
+ base = _tree_of(cwd, parent)
400
+ if not base or base == pre_tree:
401
+ return parent
402
+ own = _changed_paths(cwd, pre_tree, post_tree) & _changed_paths(cwd, base, post_tree)
403
+ tree = _tree_with(cwd, post_tree, base, own) if own else post_tree
404
+ if not tree or tree == base:
405
+ return parent
406
+ try:
407
+ made = _git(["git", "commit-tree", tree, "-p", parent, "-m",
408
+ _checkpoint_message(session_id, ts, action=OUTSIDE)], cwd)
409
+ except (OSError, subprocess.SubprocessError):
410
+ return parent
411
+ return made.stdout.strip() if made.returncode == 0 else parent
412
+
413
+
414
+ def record_pre(cwd: pathlib.Path, git_dir: pathlib.Path, call: str) -> None:
415
+ """PreToolUse: the tree before call `call`, which `_write_checkpoint`
416
+ compares against the tree after it. One small file per call under the
417
+ worktree's git directory; `take_pre` removes it."""
418
+ if not call or not _enabled(git_dir):
419
+ return
420
+ tree = _worktree_tree(cwd)
421
+ folder = _git_path(cwd, _PRE_DIR)
422
+ if not tree or folder is None:
423
+ return
424
+ folder.mkdir(exist_ok=True)
425
+ (folder / call).write_text(tree)
426
+
427
+
428
+ def take_pre(cwd: pathlib.Path, call: str) -> str:
429
+ """The tree `record_pre` wrote for `call`, removed as it is read; "" for
430
+ none. A call that never reaches PostToolUse, one a hook denied, leaves its
431
+ file; any file older than `_PRE_LIFE` goes on the next read."""
432
+ folder = _git_path(cwd, _PRE_DIR)
433
+ if not call or folder is None or not folder.is_dir():
434
+ return ""
435
+ now = time.time()
436
+ for f in folder.iterdir():
437
+ try:
438
+ if now - f.stat().st_mtime > _PRE_LIFE:
439
+ f.unlink()
440
+ except OSError:
441
+ pass
442
+ f = folder / call
443
+ try:
444
+ tree = f.read_text().strip()
445
+ f.unlink()
446
+ except OSError:
447
+ return ""
448
+ return tree
317
449
 
318
450
 
319
451
  def _head_sha(cwd: pathlib.Path) -> str:
@@ -465,7 +597,8 @@ def chain_entries(project_dir: pathlib.Path,
465
597
  m = _CHECKPOINT_RE.match(subject)
466
598
  if m and m["session"] == session_uuid:
467
599
  out.append(ChainEntry(m["when"], sha, m["call"] or "",
468
- m["action"] or "", m["step"] or "", m["part"] or ""))
600
+ m["action"] or m["outside"] or "", m["step"] or "",
601
+ m["part"] or ""))
469
602
  out.reverse()
470
603
  TRACER and TRACER.step("chain_entries", repo=str(project_dir), session=session_uuid[:8],
471
604
  checkpoints=len(out))
@@ -475,8 +608,18 @@ def chain_entries(project_dir: pathlib.Path,
475
608
  def _chain_shas(project_dir: pathlib.Path,
476
609
  session_uuid: str) -> list[tuple[str, str, str]]:
477
610
  """The session's checkpoints off its own ref, oldest first, in the shape
478
- `_checkpoint_shas` returns."""
479
- return [(e.when, e.sha, e.call) for e in chain_entries(project_dir, session_uuid)]
611
+ `_checkpoint_shas` returns. An `outside` checkpoint holds other writers'
612
+ changes, not the session's, and is left out."""
613
+ return [(e.when, e.sha, e.call) for e in chain_entries(project_dir, session_uuid)
614
+ if e.action != OUTSIDE]
615
+
616
+
617
+ def outside_parented(project_dir: pathlib.Path, session_uuid: str) -> set[str]:
618
+ """The checkpoints parented on an `outside` one. Each diffs against its own
619
+ parent; measured against the session's previous checkpoint, its diff would
620
+ take in the outside changes as well."""
621
+ entries = chain_entries(project_dir, session_uuid)
622
+ return {e.sha for prev, e in zip(entries, entries[1:]) if prev.action == OUTSIDE}
480
623
 
481
624
 
482
625
  def _checkpoint_shas(project_dir: pathlib.Path | None,