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.
- cc_continuity/__init__.py +7 -0
- cc_continuity/__main__.py +8 -0
- cc_continuity/atomic.py +62 -0
- cc_continuity/bootstrap.py +274 -0
- cc_continuity/checkpoint.py +555 -0
- cc_continuity/cli.py +901 -0
- cc_continuity/config.py +258 -0
- cc_continuity/doctor.py +270 -0
- cc_continuity/gitsnap.py +141 -0
- cc_continuity/hooks/__init__.py +17 -0
- cc_continuity/hooks/common.py +159 -0
- cc_continuity/hooks/pre_compact.py +44 -0
- cc_continuity/hooks/pre_tool_use.py +59 -0
- cc_continuity/hooks/session_end.py +174 -0
- cc_continuity/hooks/session_start.py +182 -0
- cc_continuity/hooks/stop.py +155 -0
- cc_continuity/hooks/task.py +33 -0
- cc_continuity/hooks/tool_use.py +192 -0
- cc_continuity/hooks/user_prompt.py +114 -0
- cc_continuity/install.py +479 -0
- cc_continuity/launch.py +197 -0
- cc_continuity/ledger.py +67 -0
- cc_continuity/lock.py +139 -0
- cc_continuity/managed.py +91 -0
- cc_continuity/naming.py +81 -0
- cc_continuity/notify.py +324 -0
- cc_continuity/paths.py +64 -0
- cc_continuity/power_plug.py +404 -0
- cc_continuity/progress_pane.py +484 -0
- cc_continuity/project.py +110 -0
- cc_continuity/redact.py +48 -0
- cc_continuity/rotator.py +476 -0
- cc_continuity/settings_merge.py +164 -0
- cc_continuity/skills.py +94 -0
- cc_continuity/state.py +191 -0
- cc_continuity/statusline.py +722 -0
- cc_continuity/thresholds.py +234 -0
- cc_continuity/validate.py +142 -0
- cc_continuity/watchdog.py +526 -0
- cc_continuity/watchdog_digest.py +550 -0
- cc_continuity/watchdogd.py +1931 -0
- cc_continuity-0.1.0.dist-info/METADATA +7 -0
- cc_continuity-0.1.0.dist-info/RECORD +47 -0
- cc_continuity-0.1.0.dist-info/WHEEL +5 -0
- cc_continuity-0.1.0.dist-info/entry_points.txt +2 -0
- cc_continuity-0.1.0.dist-info/licenses/LICENSE +21 -0
- cc_continuity-0.1.0.dist-info/top_level.txt +1 -0
cc_continuity/atomic.py
ADDED
|
@@ -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}
|