cc-continuity 0.1.0__py3-none-any.whl

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 (47) hide show
  1. cc_continuity/__init__.py +7 -0
  2. cc_continuity/__main__.py +8 -0
  3. cc_continuity/atomic.py +62 -0
  4. cc_continuity/bootstrap.py +274 -0
  5. cc_continuity/checkpoint.py +555 -0
  6. cc_continuity/cli.py +901 -0
  7. cc_continuity/config.py +258 -0
  8. cc_continuity/doctor.py +270 -0
  9. cc_continuity/gitsnap.py +141 -0
  10. cc_continuity/hooks/__init__.py +17 -0
  11. cc_continuity/hooks/common.py +159 -0
  12. cc_continuity/hooks/pre_compact.py +44 -0
  13. cc_continuity/hooks/pre_tool_use.py +59 -0
  14. cc_continuity/hooks/session_end.py +174 -0
  15. cc_continuity/hooks/session_start.py +182 -0
  16. cc_continuity/hooks/stop.py +155 -0
  17. cc_continuity/hooks/task.py +33 -0
  18. cc_continuity/hooks/tool_use.py +192 -0
  19. cc_continuity/hooks/user_prompt.py +114 -0
  20. cc_continuity/install.py +479 -0
  21. cc_continuity/launch.py +197 -0
  22. cc_continuity/ledger.py +67 -0
  23. cc_continuity/lock.py +139 -0
  24. cc_continuity/managed.py +91 -0
  25. cc_continuity/naming.py +81 -0
  26. cc_continuity/notify.py +324 -0
  27. cc_continuity/paths.py +64 -0
  28. cc_continuity/power_plug.py +404 -0
  29. cc_continuity/progress_pane.py +484 -0
  30. cc_continuity/project.py +110 -0
  31. cc_continuity/redact.py +48 -0
  32. cc_continuity/rotator.py +476 -0
  33. cc_continuity/settings_merge.py +164 -0
  34. cc_continuity/skills.py +94 -0
  35. cc_continuity/state.py +191 -0
  36. cc_continuity/statusline.py +722 -0
  37. cc_continuity/thresholds.py +234 -0
  38. cc_continuity/validate.py +142 -0
  39. cc_continuity/watchdog.py +526 -0
  40. cc_continuity/watchdog_digest.py +550 -0
  41. cc_continuity/watchdogd.py +1931 -0
  42. cc_continuity-0.1.0.dist-info/METADATA +7 -0
  43. cc_continuity-0.1.0.dist-info/RECORD +47 -0
  44. cc_continuity-0.1.0.dist-info/WHEEL +5 -0
  45. cc_continuity-0.1.0.dist-info/entry_points.txt +2 -0
  46. cc_continuity-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. cc_continuity-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """cc-continuity — zero-touch session rotation for Claude Code.
2
+
3
+ Spec: docs/PRD.md (v1.0). Platform facts verified in docs/VERIFICATION.md.
4
+ Standard library only at runtime; pytest is dev-only.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """``python -m cc_continuity`` → the cc-continuity CLI (PRD §16)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ """Race-safe file writes (PRD §14: write temp → fsync → os.replace).
2
+
3
+ Hooks run concurrently (a single model message can fire many parallel tool
4
+ calls — Phase 0, S1/N1), so every write goes through a unique temp file in the
5
+ same directory, is fsync'd, then swapped into place with ``os.replace``. A
6
+ reader always sees either the old or the new complete content, never a torn
7
+ mix. A crash may leave a ``.<name>.*.tmp`` residue; ``doctor`` cleans those.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+
18
+ def fsync_dir(path: Path) -> None:
19
+ """fsync a directory so the rename itself is durable (best effort)."""
20
+ try:
21
+ dfd = os.open(path, os.O_RDONLY)
22
+ except OSError:
23
+ return
24
+ try:
25
+ os.fsync(dfd)
26
+ except OSError:
27
+ pass
28
+ finally:
29
+ os.close(dfd)
30
+
31
+
32
+ def atomic_write(path: str | Path, data: bytes) -> None:
33
+ """Atomically write ``data`` to ``path``. Parent directory must exist."""
34
+ path = Path(path)
35
+ parent = path.parent
36
+ fd, tmp_name = tempfile.mkstemp(
37
+ prefix=f".{path.name}.", suffix=".tmp", dir=parent
38
+ )
39
+ try:
40
+ with os.fdopen(fd, "wb") as f:
41
+ f.write(data)
42
+ f.flush()
43
+ os.fsync(f.fileno())
44
+ os.replace(tmp_name, path)
45
+ except BaseException:
46
+ # "Crash" mid-write: never leave the target half-updated; drop the temp.
47
+ try:
48
+ os.unlink(tmp_name)
49
+ except OSError:
50
+ pass
51
+ raise
52
+ fsync_dir(parent)
53
+
54
+
55
+ def atomic_write_text(path: str | Path, text: str, encoding: str = "utf-8") -> None:
56
+ atomic_write(path, text.encode(encoding))
57
+
58
+
59
+ def atomic_write_json(path: str | Path, obj: object, indent: int = 2) -> None:
60
+ """Write a JSON document atomically (2-space indent, trailing newline)."""
61
+ text = json.dumps(obj, indent=indent, ensure_ascii=False) + "\n"
62
+ atomic_write_text(path, text)
@@ -0,0 +1,274 @@
1
+ """Bootstrap / claim in the new session (PRD §9 SessionStart, §12, §18).
2
+
3
+ ``try_claim`` (caller holds the project lock) finds the latest session in a
4
+ claimable state (``ROTATING`` / ``AWAITING_MANUAL_CLEAR`` /
5
+ ``CHECKPOINT_VERIFIED``, ``successor`` unset — plan P6-D2), creates the
6
+ successor session record (``BOOTSTRAPPING``), marks the old session claimed
7
+ (``successor`` set) and moves it to ``ROTATED`` (terminal), consumes the
8
+ matching checkpoint, runs ``validate_checkpoint`` (PRD §12) and returns the
9
+ C.4 bootstrap text. Called from the ``SessionStart(clear)`` hook and from
10
+ the ``UserPromptSubmit`` ``CONTINUITY RESUME`` fallback (P6-D5).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import subprocess
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from . import (atomic, checkpoint, ledger, naming, notify, paths,
22
+ project, state, thresholds, validate)
23
+
24
+
25
+ def _now() -> str:
26
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
27
+
28
+
29
+ #: States from which SessionStart(clear) may claim a rotation (P6-D2: CV
30
+ #: included so a manual /clear after checkpoint verification — e.g. after a
31
+ #: failed injection, PRD §18 — still bootstraps).
32
+ CLAIMABLE_STATES = frozenset({
33
+ "ROTATING", "AWAITING_MANUAL_CLEAR", "CHECKPOINT_VERIFIED",
34
+ })
35
+
36
+
37
+ def find_claimable(key: str) -> dict | None:
38
+ """The latest project session a claim may take over (P6-D2).
39
+
40
+ Caller holds the project lock. Returns the state record, or None.
41
+ A project with no record yet (first /clear before any registration,
42
+ P6-D3) and sessions whose state file is missing (crash residue) are
43
+ skipped.
44
+ """
45
+ pfile = paths.project_dir(key) / "project.json"
46
+ try:
47
+ pref = json.loads(pfile.read_text(encoding="utf-8"))
48
+ except (OSError, json.JSONDecodeError):
49
+ return None
50
+ best = None
51
+ for s in pref.get("sessions") or []:
52
+ try:
53
+ rec = state.load_state(key, int(s["nnn"]))
54
+ except (state.StateNotFoundError, ValueError, KeyError):
55
+ continue
56
+ if rec["state"] in CLAIMABLE_STATES and rec.get("successor") is None:
57
+ best = rec
58
+ return best
59
+
60
+
61
+ def write_started(session_id: str) -> Path:
62
+ """``runtime/<sid>.started`` marker for the rotator (PRD §14)."""
63
+ path = paths.runtime_dir() / f"{session_id}.started"
64
+ atomic.atomic_write_text(path, "started\n")
65
+ return path
66
+
67
+
68
+ def pending_checkpoint(key: str) -> dict | None:
69
+ """``latest_checkpoint`` while ``pending_consumption`` (else None)."""
70
+ try:
71
+ rec = json.loads(
72
+ (paths.project_dir(key) / "project.json").read_text(encoding="utf-8"))
73
+ except (OSError, json.JSONDecodeError):
74
+ return None
75
+ cp = rec.get("latest_checkpoint")
76
+ if isinstance(cp, dict) and cp.get("pending_consumption"):
77
+ return cp
78
+ return None
79
+
80
+
81
+ def _decide_name(key: str, slug: str,
82
+ allow_env: bool = True) -> tuple[str, int]:
83
+ """Wrapper-supplied name wins when it ends in ``-<digits>`` (PRD §13).
84
+
85
+ ``allow_env=False`` (Phase 27 resume/fork, Phase 28 CLI late attach):
86
+ the env name belongs to the ABANDONED startup process — the counter
87
+ name wins, or the dead startup's name would be reused.
88
+ """
89
+ if allow_env:
90
+ env_name = os.environ.get("CC_CONTINUITY_NAME", "").strip()
91
+ _base, seq = naming.split_name(env_name)
92
+ if seq is not None:
93
+ return env_name, project.next_counter(key, requested=seq)
94
+ nnn = project.next_counter(key)
95
+ return naming.logical_name(slug, nnn), nnn
96
+
97
+
98
+ def _tmux_bin() -> str:
99
+ return os.environ.get("CC_CONTINUITY_TMUX_BIN") or "tmux"
100
+
101
+
102
+ def record_tmux_session(key: str, nnn: int) -> None:
103
+ """P10-D9.1: record the pane's tmux session name (the watchdog's
104
+ primary nudge target).
105
+
106
+ Only when ``$TMUX`` is set; one fast subprocess (2 s timeout — the
107
+ hook keeps its ~100 ms budget in the common case); every failure
108
+ path is swallowed — this must never break the hook (P10-D12).
109
+ """
110
+ if not os.environ.get("TMUX"):
111
+ return
112
+ try:
113
+ proc = subprocess.run(
114
+ [_tmux_bin(), "display-message", "-p", "#{session_name}"],
115
+ capture_output=True, timeout=2)
116
+ except (OSError, subprocess.SubprocessError):
117
+ return
118
+ if proc.returncode != 0:
119
+ return
120
+ name = proc.stdout.decode(errors="replace").strip()
121
+ if not name:
122
+ return
123
+ try:
124
+ state.update_fields(key, nnn, tmux_session=name)
125
+ except Exception:
126
+ pass
127
+
128
+
129
+ def register_session(cwd, session_id: str, *, cfg: dict,
130
+ source: str = "manual",
131
+ allow_env: bool = False) -> tuple[str, int]:
132
+ """Register a conversation that has no record (late attach).
133
+
134
+ Phase 28: ``cc-continuity checkpoint`` / ``request-rotation`` run in
135
+ a session whose conversation was never registered (e.g. a process
136
+ started before the Phase 27 resume registration — 2026-10-03
137
+ connector a7c36f64, unmonitored at 72%). Creates its record the way
138
+ the SessionStart hook does — per-operation locking, since the helpers
139
+ take the project lock themselves and a wrapping ``lock.held`` would
140
+ self-deadlock (flock is not re-entrant): counter name (unless
141
+ ``allow_env``), state ACTIVE (BOOTSTRAPPING when a checkpoint is
142
+ pending), one ``session_start`` ledger entry, the
143
+ ``session_started`` push, tmux re-record.
144
+ Returns ``(key, nnn)``.
145
+ """
146
+ root = project.resolve_root(Path(cwd))
147
+ key = project.project_key_for_root(root)
148
+ record = project.ensure_project(str(cwd))
149
+ sessions = record.get("sessions") or []
150
+ predecessor = sessions[-1]["name"] if sessions else None
151
+ name, nnn = _decide_name(key, record["slug"], allow_env=allow_env)
152
+ initial = "BOOTSTRAPPING" if pending_checkpoint(key) else "ACTIVE"
153
+ state.create_session_state(key, name, nnn, session_id,
154
+ predecessor=predecessor,
155
+ initial_state=initial)
156
+ project.record_session(key, name, session_id, nnn)
157
+ ledger.append(paths.session_dir(key, nnn),
158
+ {"type": "session_start", "source": source,
159
+ "name": name, "nnn": nnn})
160
+ notify.safe_send(cfg, "session_started",
161
+ slug=notify.project_slug(key), nnn=nnn, name=name,
162
+ host=notify.host())
163
+ record_tmux_session(key, nnn)
164
+ return key, nnn
165
+
166
+
167
+ def _new_work_since_handoff(cp_dir: Path, key: str,
168
+ old_nnn: int) -> list[str]:
169
+ """The user prompts typed AFTER the checkpoint snapshot was written —
170
+ the rotation window (handoff finalizing / old session still live):
171
+ they land in the OLD session's live ledger, but the handoff is already
172
+ frozen and can no longer contain them, so they would be lost to the
173
+ successor (the rotation-window bug: new prompts landing after the handoff froze).
174
+
175
+ Marker: the snapshot's ``created`` (epoch; falls back to the
176
+ checkpoint dir mtime when missing). The snapshot's ``user_prompts``
177
+ marks what the handoff already captured, so a prompt present in BOTH
178
+ (``ts >= created`` and captured — the same-second race) is not
179
+ re-sent: no double-count. ``CONTINUITY RESUME`` prompts (bootstrap
180
+ hand-off) and subagent prompts (``agent_id``) are not user work.
181
+ Bounded: the last 5, each cut to the snapshot's own prompt width.
182
+ Never raises."""
183
+ try:
184
+ snap = json.loads((cp_dir / "snapshot.json")
185
+ .read_text(encoding="utf-8"))
186
+ except (OSError, ValueError):
187
+ snap = {}
188
+ captured = {str(p) for p in snap.get("user_prompts") or []}
189
+ cut = int(getattr(checkpoint, "PROMPT_CHARS", 500))
190
+ try:
191
+ created = datetime.fromisoformat(str(snap["created"])).timestamp()
192
+ except (KeyError, TypeError, ValueError):
193
+ try:
194
+ created = cp_dir.stat().st_mtime
195
+ except OSError:
196
+ return []
197
+ out: list[str] = []
198
+ try:
199
+ entries = ledger.read(paths.session_dir(key, old_nnn))
200
+ except OSError:
201
+ return []
202
+ for e in entries:
203
+ if e.get("type") != "user_prompt" or e.get("agent_id"):
204
+ continue
205
+ ts = e.get("ts")
206
+ if not isinstance(ts, (int, float)) or ts < created:
207
+ continue
208
+ text = str(e.get("text", ""))
209
+ if text.startswith("CONTINUITY RESUME"):
210
+ continue
211
+ if text[:cut] in captured:
212
+ continue
213
+ out.append(text[:cut])
214
+ return out[-5:]
215
+
216
+
217
+ def try_claim(key: str, *, new_session_id: str, cfg: dict) -> dict | None:
218
+ """Claim the latest claimable session for a fresh session (PRD §9).
219
+
220
+ Caller holds the project lock. Returns ``None`` when nothing is
221
+ claimable (idempotent: a second claim sees ``successor`` set). On
222
+ success returns ``{"old_name", "old_nnn", "new_name", "new_nnn",
223
+ "validation", "details", "bootstrap_text"}`` and has: created the
224
+ successor state (BOOTSTRAPPING, predecessor = old name, nnn = old+1 at
225
+ least), set the old session's ``successor`` and moved it to ROTATED,
226
+ cleared ``pending_consumption`` on the matching latest_checkpoint,
227
+ appended one ``session_start`` ledger entry to the successor, and
228
+ updated project.json (counter / sessions / checkpoint) in one atomic
229
+ write.
230
+ """
231
+ old = find_claimable(key)
232
+ if old is None:
233
+ return None
234
+ pfile = paths.project_dir(key) / "project.json"
235
+ pref = json.loads(pfile.read_text(encoding="utf-8"))
236
+ root = Path(pref["root"])
237
+ old_nnn = int(old["nnn"])
238
+ nnn_new = max(int(pref["counter"]), old_nnn) + 1
239
+ name_new = naming.logical_name(naming.split_name(old["name"])[0], nnn_new)
240
+ state.create_session_state(key, name_new, nnn_new, new_session_id,
241
+ predecessor=old["name"],
242
+ initial_state="BOOTSTRAPPING")
243
+ state.update_fields_unlocked(key, old_nnn, successor=name_new)
244
+ state._transition_unlocked(key, old_nnn, "ROTATED")
245
+ cp = pref.get("latest_checkpoint")
246
+ if isinstance(cp, dict) and cp.get("nnn") == old_nnn \
247
+ and cp.get("pending_consumption"):
248
+ cp = dict(cp)
249
+ cp["pending_consumption"] = False
250
+ cp["consumed_by"] = name_new
251
+ pref["latest_checkpoint"] = cp
252
+ pref["counter"] = nnn_new
253
+ pref["sessions"].append({"name": name_new, "session_id": new_session_id,
254
+ "nnn": nnn_new, "created": _now()})
255
+ atomic.atomic_write_json(pfile, pref)
256
+ cp_dir = paths.session_dir(key, old_nnn) / "checkpoint"
257
+ status, details = validate.validate_checkpoint(cp_dir, cfg, root=root)
258
+ handoff_file = cp_dir / "handoff.md"
259
+ try:
260
+ handoff = handoff_file.read_text(encoding="utf-8")
261
+ except OSError:
262
+ handoff = ""
263
+ text = thresholds.c4_text(
264
+ new_name=name_new, old_name=old["name"], status=status,
265
+ details=details, handoff=handoff, handoff_path=str(handoff_file),
266
+ inline_max=int(cfg.get("inline_handoff_max_chars", 12000)),
267
+ new_work=_new_work_since_handoff(cp_dir, key, old_nnn))
268
+ ledger.append(paths.session_dir(key, nnn_new),
269
+ {"type": "session_start", "source": "clear",
270
+ "claimed": old["name"], "validation": status})
271
+ return {"old_name": old["name"], "old_nnn": old_nnn,
272
+ "new_name": name_new, "new_nnn": nnn_new,
273
+ "validation": status, "details": details,
274
+ "bootstrap_text": text}