cogsession 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.
- cogsession/__init__.py +2 -0
- cogsession/claims.py +276 -0
- cogsession/cli.py +142 -0
- cogsession/config.py +102 -0
- cogsession/distiller.py +96 -0
- cogsession/hook_entry.py +354 -0
- cogsession/injector.py +203 -0
- cogsession/installer.py +126 -0
- cogsession/journal.py +279 -0
- cogsession/mcp/__init__.py +2 -0
- cogsession/mcp/server.py +993 -0
- cogsession/sensor.py +142 -0
- cogsession/session/__init__.py +9 -0
- cogsession/session/loader.py +339 -0
- cogsession/session/models.py +316 -0
- cogsession/session/writer.py +466 -0
- cogsession-0.1.0.dist-info/METADATA +542 -0
- cogsession-0.1.0.dist-info/RECORD +21 -0
- cogsession-0.1.0.dist-info/WHEEL +4 -0
- cogsession-0.1.0.dist-info/entry_points.txt +4 -0
- cogsession-0.1.0.dist-info/licenses/LICENSE +201 -0
cogsession/__init__.py
ADDED
cogsession/claims.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cogsession/claims.py
|
|
3
|
+
|
|
4
|
+
Checkable claims: store the command that *proved* something, then re-run it.
|
|
5
|
+
|
|
6
|
+
Everything else in this package records what was believed at a moment —
|
|
7
|
+
decisions, dead ends, assumptions. None of it is ever re-checked, so the most
|
|
8
|
+
expensive recurring failure is not a wrong decision. It is a right one that
|
|
9
|
+
quietly stopped being true:
|
|
10
|
+
|
|
11
|
+
- a pull request description explaining a schema the code no longer has
|
|
12
|
+
- a source comment naming a constraint that moved
|
|
13
|
+
- a planning document one day old, read as current
|
|
14
|
+
- a test asserting a shape the implementation dropped
|
|
15
|
+
- a docstring contradicting its own function
|
|
16
|
+
|
|
17
|
+
Each reduces to one sentence: *something was true when it was written and
|
|
18
|
+
stopped being true.* A model cannot notice that from a transcript, and a human
|
|
19
|
+
notices it in review, which is the expensive place.
|
|
20
|
+
|
|
21
|
+
So a claim carries its own proof. Not a natural-language description of how to
|
|
22
|
+
check it — a command, its expected output, and the commit it held at. When the
|
|
23
|
+
files it touches change, re-run it. What comes back is not "you decided X" but
|
|
24
|
+
"what you wrote about X is now false", which is the only version that is
|
|
25
|
+
actionable.
|
|
26
|
+
|
|
27
|
+
## On running stored commands
|
|
28
|
+
|
|
29
|
+
Verification executes shell commands from `claims.json`. That is the same trust
|
|
30
|
+
boundary as `.git/hooks`: local, developer-owned, not shared. Two guards make
|
|
31
|
+
that boundary explicit rather than assumed:
|
|
32
|
+
|
|
33
|
+
- a claims file **tracked by git** is never executed, because a tracked file
|
|
34
|
+
can arrive from someone else
|
|
35
|
+
- every command runs with a timeout and its output is compared, never
|
|
36
|
+
interpreted
|
|
37
|
+
|
|
38
|
+
Keep commands to cheap, read-only checks — `grep -c`, `test -f`, a fast unit
|
|
39
|
+
test. A claim is a tripwire, not a build step.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import subprocess
|
|
46
|
+
from dataclasses import asdict, dataclass, field
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Optional
|
|
50
|
+
|
|
51
|
+
CLAIMS_FILE = "claims.json"
|
|
52
|
+
DEFAULT_TIMEOUT = 20
|
|
53
|
+
MAX_OUTPUT = 2000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _now() -> str:
|
|
57
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Claim:
|
|
62
|
+
"""One assertion, with the command that proves it.
|
|
63
|
+
|
|
64
|
+
`expect` is compared against stripped stdout. Leave it None to mean "the
|
|
65
|
+
command must merely succeed", which suits `test -f` or a test invocation
|
|
66
|
+
where the exit code is the whole signal.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
claim: str
|
|
70
|
+
verified_by: str
|
|
71
|
+
expect: Optional[str] = None
|
|
72
|
+
watches: list = field(default_factory=list) # paths whose change re-checks
|
|
73
|
+
asserted_in: str = "" # where the claim was made
|
|
74
|
+
at_commit: str = ""
|
|
75
|
+
recorded_at: str = field(default_factory=_now)
|
|
76
|
+
last_status: str = "unverified" # holds | broken | error | unverified
|
|
77
|
+
last_checked: str = ""
|
|
78
|
+
last_output: str = ""
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return asdict(self)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict) -> "Claim":
|
|
85
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
86
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class CheckResult:
|
|
91
|
+
claim: Claim
|
|
92
|
+
status: str # holds | broken | error
|
|
93
|
+
detail: str
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def broken(self) -> bool:
|
|
97
|
+
return self.status != "holds"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ClaimStore:
|
|
101
|
+
"""Claims for one session, persisted as `claims.json` in its folder."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, project_root: Path, session_id: str):
|
|
104
|
+
self.root = Path(project_root)
|
|
105
|
+
self.session_id = session_id
|
|
106
|
+
self.path = self.root / ".cogsessions" / session_id / CLAIMS_FILE
|
|
107
|
+
|
|
108
|
+
# ── Persistence ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def load(self) -> list[Claim]:
|
|
111
|
+
if not self.path.exists():
|
|
112
|
+
return []
|
|
113
|
+
try:
|
|
114
|
+
raw = json.loads(self.path.read_text())
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
return []
|
|
117
|
+
return [Claim.from_dict(c) for c in raw.get("claims", []) if isinstance(c, dict)]
|
|
118
|
+
|
|
119
|
+
def save(self, claims: list[Claim]) -> None:
|
|
120
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
self.path.write_text(
|
|
122
|
+
json.dumps(
|
|
123
|
+
{"session_id": self.session_id, "updated_at": _now(),
|
|
124
|
+
"claims": [c.to_dict() for c in claims]},
|
|
125
|
+
indent=2,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def add(self, claim: Claim) -> Claim:
|
|
130
|
+
"""Record a claim, replacing any earlier one with the same text.
|
|
131
|
+
|
|
132
|
+
Replacing rather than appending is deliberate: re-asserting something
|
|
133
|
+
means the current wording is what should be checked, and a store with
|
|
134
|
+
three versions of one claim cannot say which is live.
|
|
135
|
+
"""
|
|
136
|
+
claims = [c for c in self.load() if c.claim != claim.claim]
|
|
137
|
+
claims.append(claim)
|
|
138
|
+
self.save(claims)
|
|
139
|
+
return claim
|
|
140
|
+
|
|
141
|
+
# ── Verification ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def verify(self, only_if_changed: bool = False) -> list[CheckResult]:
|
|
144
|
+
"""Re-run each claim's proof. Returns one result per claim checked."""
|
|
145
|
+
if _is_git_tracked(self.root, self.path):
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
claims = self.load()
|
|
149
|
+
results: list[CheckResult] = []
|
|
150
|
+
for claim in claims:
|
|
151
|
+
if only_if_changed and not self._touched(claim):
|
|
152
|
+
continue
|
|
153
|
+
result = run_check(self.root, claim)
|
|
154
|
+
claim.last_status = result.status
|
|
155
|
+
claim.last_checked = _now()
|
|
156
|
+
claim.last_output = result.detail[:MAX_OUTPUT]
|
|
157
|
+
results.append(result)
|
|
158
|
+
if results:
|
|
159
|
+
self.save(claims)
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
def _touched(self, claim: Claim) -> bool:
|
|
163
|
+
"""Has anything this claim watches changed since it was recorded?
|
|
164
|
+
|
|
165
|
+
No commit and no watch list means "always check": a claim that cannot
|
|
166
|
+
say what would invalidate it is exactly the one worth re-running.
|
|
167
|
+
"""
|
|
168
|
+
if not claim.at_commit:
|
|
169
|
+
return True
|
|
170
|
+
changed = _changed_since(self.root, claim.at_commit)
|
|
171
|
+
if changed is None:
|
|
172
|
+
return True
|
|
173
|
+
if not claim.watches:
|
|
174
|
+
return True
|
|
175
|
+
return any(
|
|
176
|
+
any(path == w or path.startswith(w.rstrip("/") + "/") for w in claim.watches)
|
|
177
|
+
for path in changed
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_check(project_root: Path, claim: Claim) -> CheckResult:
|
|
182
|
+
"""Execute one claim's command and compare against `expect`."""
|
|
183
|
+
try:
|
|
184
|
+
proc = subprocess.run(
|
|
185
|
+
claim.verified_by,
|
|
186
|
+
shell=True,
|
|
187
|
+
cwd=str(project_root),
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True,
|
|
190
|
+
timeout=DEFAULT_TIMEOUT,
|
|
191
|
+
)
|
|
192
|
+
except subprocess.TimeoutExpired:
|
|
193
|
+
return CheckResult(claim, "error", f"timed out after {DEFAULT_TIMEOUT}s")
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
return CheckResult(claim, "error", f"could not run: {exc}")
|
|
196
|
+
|
|
197
|
+
got = (proc.stdout or "").strip()
|
|
198
|
+
|
|
199
|
+
if claim.expect is None:
|
|
200
|
+
if proc.returncode == 0:
|
|
201
|
+
return CheckResult(claim, "holds", "command succeeded")
|
|
202
|
+
return CheckResult(
|
|
203
|
+
claim, "broken",
|
|
204
|
+
f"exit {proc.returncode}: {(proc.stderr or got).strip()[:200]}",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if got == claim.expect.strip():
|
|
208
|
+
return CheckResult(claim, "holds", f"got {got!r}")
|
|
209
|
+
return CheckResult(
|
|
210
|
+
claim, "broken", f"expected {claim.expect.strip()!r}, got {got!r}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def format_report(results: list[CheckResult]) -> str:
|
|
215
|
+
"""One short block, and only when something moved.
|
|
216
|
+
|
|
217
|
+
Silence when everything holds is the point. A report that appears every
|
|
218
|
+
session becomes furniture, and furniture is not read.
|
|
219
|
+
"""
|
|
220
|
+
if not results:
|
|
221
|
+
return ""
|
|
222
|
+
broken = [r for r in results if r.broken]
|
|
223
|
+
if not broken:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
held = len(results) - len(broken)
|
|
227
|
+
lines = [
|
|
228
|
+
f"[CogSession] {len(broken)} claim(s) no longer hold"
|
|
229
|
+
+ (f", {held} still do" if held else "")
|
|
230
|
+
+ ":"
|
|
231
|
+
]
|
|
232
|
+
for r in broken:
|
|
233
|
+
lines.append(f" ✗ {r.claim.claim}")
|
|
234
|
+
lines.append(f" {r.detail}")
|
|
235
|
+
if r.claim.asserted_in:
|
|
236
|
+
lines.append(f" asserted in: {r.claim.asserted_in}")
|
|
237
|
+
lines.append(f" proof: {r.claim.verified_by}")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── git helpers ────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _git(project_root: Path, *args: str) -> Optional[subprocess.CompletedProcess]:
|
|
245
|
+
try:
|
|
246
|
+
return subprocess.run(
|
|
247
|
+
["git", "-C", str(project_root), *args],
|
|
248
|
+
capture_output=True, text=True, timeout=10,
|
|
249
|
+
)
|
|
250
|
+
except (OSError, subprocess.SubprocessError):
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _is_git_tracked(project_root: Path, path: Path) -> bool:
|
|
255
|
+
"""Refuse to execute a claims file that could have come from someone else."""
|
|
256
|
+
proc = _git(project_root, "ls-files", "--error-unmatch", str(path))
|
|
257
|
+
return bool(proc and proc.returncode == 0)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _changed_since(project_root: Path, commit: str) -> Optional[list[str]]:
|
|
261
|
+
"""Repo-relative paths changed since `commit`, including uncommitted work.
|
|
262
|
+
|
|
263
|
+
None means git could not answer, which callers treat as "check anyway".
|
|
264
|
+
Failing towards checking is right for a tripwire: a missed re-check is a
|
|
265
|
+
stale claim believed, while an extra one costs a `grep`.
|
|
266
|
+
"""
|
|
267
|
+
diff = _git(project_root, "diff", "--name-only", commit)
|
|
268
|
+
if diff is None or diff.returncode != 0:
|
|
269
|
+
return None
|
|
270
|
+
paths = {p for p in diff.stdout.splitlines() if p}
|
|
271
|
+
status = _git(project_root, "status", "--porcelain")
|
|
272
|
+
if status and status.returncode == 0:
|
|
273
|
+
for line in status.stdout.splitlines():
|
|
274
|
+
if len(line) > 3:
|
|
275
|
+
paths.add(line[3:].strip().split(" -> ")[-1])
|
|
276
|
+
return sorted(paths)
|
cogsession/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cogsession/cli.py
|
|
3
|
+
|
|
4
|
+
`cogsession install` — wire the hooks into Claude Code after a package install.
|
|
5
|
+
|
|
6
|
+
The MCP server gives an agent eleven tools it can call. The hooks are what make
|
|
7
|
+
CogSession work *without being asked*: the session opening itself, the journal
|
|
8
|
+
recording as work happens, claims re-checked when a session starts. Those live
|
|
9
|
+
in `~/.claude/settings.json`, which a package install cannot write on its own.
|
|
10
|
+
|
|
11
|
+
So this is the second half of `pip install cogsession`. Without it you get the
|
|
12
|
+
tools and none of the recording, which is a quieter and worse product than the
|
|
13
|
+
one described in the README.
|
|
14
|
+
|
|
15
|
+
It is idempotent, backs up the settings file first, and prints what it changed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from cogsession.installer import HOOKS, hook_command, is_installed_on_path, merge_settings
|
|
27
|
+
|
|
28
|
+
MCP_SERVER_NAME = "cogsession"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _register_mcp_server(repo_dir: Path | None) -> str:
|
|
32
|
+
"""Register the MCP server with the Claude Code CLI, if it is present.
|
|
33
|
+
|
|
34
|
+
A missing `claude` binary is not an error. Plenty of people wire MCP servers
|
|
35
|
+
up by hand or use another client, and failing the whole install over an
|
|
36
|
+
optional convenience would be wrong.
|
|
37
|
+
"""
|
|
38
|
+
claude = shutil.which("claude")
|
|
39
|
+
if claude is None:
|
|
40
|
+
return " ~ `claude` not on PATH — register the MCP server yourself (see README)"
|
|
41
|
+
|
|
42
|
+
if repo_dir is not None:
|
|
43
|
+
command = [claude, "mcp", "add", MCP_SERVER_NAME, "--",
|
|
44
|
+
"uv", "--directory", str(repo_dir), "run", MCP_SERVER_NAME]
|
|
45
|
+
else:
|
|
46
|
+
command = [claude, "mcp", "add", MCP_SERVER_NAME, "--", MCP_SERVER_NAME]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
|
|
50
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
51
|
+
return f" ~ could not register the MCP server: {exc}"
|
|
52
|
+
|
|
53
|
+
if result.returncode == 0:
|
|
54
|
+
return f" ✓ MCP server registered as `{MCP_SERVER_NAME}`"
|
|
55
|
+
# Already-registered is the common non-zero case and is not a failure.
|
|
56
|
+
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
57
|
+
return f" ~ MCP server not registered: {detail[0] if detail else 'unknown reason'}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install(argv: list[str] | None = None) -> int:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="cogsession install",
|
|
63
|
+
description="Wire CogSession's hooks into Claude Code.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--repo",
|
|
67
|
+
type=Path,
|
|
68
|
+
default=None,
|
|
69
|
+
metavar="DIR",
|
|
70
|
+
help="Run the hooks from this checkout via uv, instead of the installed "
|
|
71
|
+
"console script. Use when working on CogSession itself.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--claude-dir",
|
|
75
|
+
type=Path,
|
|
76
|
+
default=Path.home() / ".claude",
|
|
77
|
+
metavar="DIR",
|
|
78
|
+
help="Claude Code config directory (default: ~/.claude)",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--no-statusline",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Do not offer to set the status line.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--no-mcp",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Only write the hooks; skip registering the MCP server.",
|
|
89
|
+
)
|
|
90
|
+
args = parser.parse_args(argv)
|
|
91
|
+
|
|
92
|
+
repo_dir = args.repo.resolve() if args.repo else None
|
|
93
|
+
|
|
94
|
+
if repo_dir is None and not is_installed_on_path():
|
|
95
|
+
print(
|
|
96
|
+
"cogsession-hook is not on PATH, so the hooks would not be able to run.\n"
|
|
97
|
+
"Either install the package (`pip install cogsession`) or point at a\n"
|
|
98
|
+
"checkout with `cogsession install --repo /path/to/cogsession`.",
|
|
99
|
+
file=sys.stderr,
|
|
100
|
+
)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
settings = merge_settings(
|
|
104
|
+
args.claude_dir, repo_dir, set_statusline=not args.no_statusline
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print("CogSession installed.\n")
|
|
108
|
+
print(f" ✓ {len(HOOKS)} hooks written to {settings}")
|
|
109
|
+
print(f" they run: {hook_command('<event>', repo_dir)}")
|
|
110
|
+
|
|
111
|
+
if not args.no_mcp:
|
|
112
|
+
print(_register_mcp_server(repo_dir))
|
|
113
|
+
|
|
114
|
+
print(
|
|
115
|
+
"\n Add `.cogsessions/` to your .gitignore — that is where sessions are kept,\n"
|
|
116
|
+
" and it is yours, not something to commit.\n"
|
|
117
|
+
"\n Open a new session to pick it up. Existing sessions loaded their\n"
|
|
118
|
+
" settings at start and will not see these hooks."
|
|
119
|
+
)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
"""Entry point for the `cogsession-admin` console script."""
|
|
125
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
126
|
+
if argv and argv[0] == "install":
|
|
127
|
+
return install(argv[1:])
|
|
128
|
+
|
|
129
|
+
print(
|
|
130
|
+
"Usage: cogsession-admin install [options]\n"
|
|
131
|
+
"\n"
|
|
132
|
+
" install Wire CogSession's hooks into Claude Code.\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"The MCP server itself is the `cogsession` command, which an MCP client\n"
|
|
135
|
+
"launches for you — it is not meant to be run by hand.",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return 0 if not argv else 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
cogsession/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cogsession/config.py
|
|
3
|
+
|
|
4
|
+
All settings for CogSession.
|
|
5
|
+
Project-level config lives in .cogsession.json at project root.
|
|
6
|
+
Global config lives in ~/.cogsession/config.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Global defaults ───────────────────────────────────────────────────
|
|
16
|
+
SESSIONS_DIR_NAME = ".cogsessions"
|
|
17
|
+
CONFIG_FILE_NAME = ".cogsession.json"
|
|
18
|
+
|
|
19
|
+
# Where the handoff is delivered so the next session auto-loads it. Deliberately
|
|
20
|
+
# the .local variant: it is auto-loaded like CLAUDE.md but is not committed, so
|
|
21
|
+
# session state never enters a shared source file or dirties the working tree.
|
|
22
|
+
HANDOFF_TARGET_NAME = "CLAUDE.local.md"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_CONFIG = {
|
|
26
|
+
# Master switch — set false to completely disable
|
|
27
|
+
"enabled": True,
|
|
28
|
+
|
|
29
|
+
# Individual feature toggles — user can pick what they want
|
|
30
|
+
"features": {
|
|
31
|
+
"dead_ends": True, # Track approaches that failed
|
|
32
|
+
"assumptions": True, # Track what agent assumed but didn't verify
|
|
33
|
+
"token_map": True, # Tag decisions with context% when made
|
|
34
|
+
"error_graveyard": True, # Log errors + how they were resolved
|
|
35
|
+
"environment": True, # Snapshot run commands, ports, env vars
|
|
36
|
+
"file_cache": True, # Cache file reads to avoid duplicates
|
|
37
|
+
"danger_zones": True, # Flag files/areas that need care
|
|
38
|
+
"auto_diagram": True, # Generate Mermaid architecture diagram
|
|
39
|
+
"auto_handoff": True, # Auto-write handoff to CLAUDE.local.md (never a tracked file)
|
|
40
|
+
"session_search": True, # SQLite FTS across all sessions
|
|
41
|
+
"claim_checks": True, # Re-run the proof behind a recorded claim
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
# Context % thresholds
|
|
45
|
+
"thresholds": {
|
|
46
|
+
"warn_at": 65, # First gentle warning
|
|
47
|
+
"alert_at": 75, # Strong warning + suggestion
|
|
48
|
+
"checkpoint_at": 80, # Auto-checkpoint fires
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
# How often to auto-save running state (every N tool calls)
|
|
52
|
+
"autosave_every_n_tools": 10,
|
|
53
|
+
|
|
54
|
+
# Files/paths to exclude from tracking
|
|
55
|
+
"exclude": [
|
|
56
|
+
"*.log", ".env", ".env.*",
|
|
57
|
+
"node_modules/", "__pycache__/",
|
|
58
|
+
".git/", "*.pyc", "dist/", "build/"
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
# Where session folder lives relative to project root
|
|
62
|
+
"session_dir": ".cogsessions",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_project_config(project_root: Path) -> dict:
|
|
67
|
+
"""Load .cogsession.json from project root, merged with defaults."""
|
|
68
|
+
config = DEFAULT_CONFIG.copy()
|
|
69
|
+
config_file = project_root / CONFIG_FILE_NAME
|
|
70
|
+
|
|
71
|
+
if config_file.exists():
|
|
72
|
+
try:
|
|
73
|
+
user_config = json.loads(config_file.read_text())
|
|
74
|
+
# Deep merge features
|
|
75
|
+
if "features" in user_config:
|
|
76
|
+
config["features"].update(user_config["features"])
|
|
77
|
+
if "thresholds" in user_config:
|
|
78
|
+
config["thresholds"].update(user_config["thresholds"])
|
|
79
|
+
# Top-level overrides
|
|
80
|
+
for k, v in user_config.items():
|
|
81
|
+
if k not in ("features", "thresholds"):
|
|
82
|
+
config[k] = v
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"[CogSession] Config parse error: {e}, using defaults", file=sys.stderr, flush=True)
|
|
85
|
+
|
|
86
|
+
return config
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_enabled(project_root: Path) -> bool:
|
|
90
|
+
"""Quick check — is cogsession enabled for this project?"""
|
|
91
|
+
# Global disable via env var
|
|
92
|
+
if os.getenv("COGSESSION_DISABLED"):
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
config = load_project_config(project_root)
|
|
96
|
+
return config.get("enabled", True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def feature_enabled(project_root: Path, feature: str) -> bool:
|
|
100
|
+
"""Is a specific feature enabled?"""
|
|
101
|
+
config = load_project_config(project_root)
|
|
102
|
+
return config.get("features", {}).get(feature, True)
|
cogsession/distiller.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cogsession/distiller.py
|
|
3
|
+
|
|
4
|
+
Observation into memory.
|
|
5
|
+
Deterministic tier: extracts touched files, commands, test results, error text without LLM.
|
|
6
|
+
Inferential tier: best-effort LLM extraction for handoff prose/decisions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any, List
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from cogsession.session.models import Session, LogEntry, DeadEnd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Distiller:
|
|
18
|
+
"""Pure functions converting transcript & hook observations into structured memory."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def process_deterministic_tool_use(
|
|
22
|
+
session: Session, tool_name: str, tool_input: Dict[str, Any], tool_response: Dict[str, Any]
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Deterministic tier — runs on every tool call."""
|
|
25
|
+
ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
26
|
+
|
|
27
|
+
if tool_name == "Read":
|
|
28
|
+
fp = tool_input.get("file_path", "")
|
|
29
|
+
if fp and fp not in session.files_read:
|
|
30
|
+
session.files_read.append(fp)
|
|
31
|
+
session.append_log(LogEntry(type="file_read", content=fp))
|
|
32
|
+
|
|
33
|
+
elif tool_name in ("Write", "Edit", "MultiEdit"):
|
|
34
|
+
fp = tool_input.get("file_path", "")
|
|
35
|
+
if fp:
|
|
36
|
+
if tool_name == "Write" and fp not in session.files_created:
|
|
37
|
+
session.files_created.append(fp)
|
|
38
|
+
elif fp not in session.files_edited:
|
|
39
|
+
session.files_edited.append(fp)
|
|
40
|
+
session.append_log(LogEntry(type="file_edit", content=fp))
|
|
41
|
+
|
|
42
|
+
elif tool_name == "Bash":
|
|
43
|
+
cmd = tool_input.get("command", "")
|
|
44
|
+
exit_code = tool_response.get("exit_code", 0)
|
|
45
|
+
output = tool_response.get("output", "")
|
|
46
|
+
|
|
47
|
+
# Log git operations
|
|
48
|
+
if cmd.startswith("git "):
|
|
49
|
+
session.append_log(LogEntry(type="git_op", content=cmd))
|
|
50
|
+
|
|
51
|
+
# Log test runs
|
|
52
|
+
if any(test_runner in cmd for test_runner in ("pytest", "npm test", "jest", "go test")):
|
|
53
|
+
session.append_log(LogEntry(type="test_run", content=cmd))
|
|
54
|
+
if "passed" in output or "failed" in output:
|
|
55
|
+
# Update environment snapshot counts if present
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
pass_match = re.search(r"(\d+)\s+passed", output)
|
|
59
|
+
fail_match = re.search(r"(\d+)\s+failed", output)
|
|
60
|
+
if pass_match:
|
|
61
|
+
session.environment.test_passing = int(pass_match.group(1))
|
|
62
|
+
if fail_match:
|
|
63
|
+
session.environment.test_failing = int(fail_match.group(1))
|
|
64
|
+
session.environment.test_command = cmd
|
|
65
|
+
|
|
66
|
+
# Extract tool errors
|
|
67
|
+
if exit_code != 0 and output:
|
|
68
|
+
error_type = "general_error"
|
|
69
|
+
if "ModuleNotFoundError" in output or "ImportError" in output:
|
|
70
|
+
error_type = "import_error"
|
|
71
|
+
elif "SyntaxError" in output:
|
|
72
|
+
error_type = "syntax_error"
|
|
73
|
+
elif "PermissionError" in output or "Permission denied" in output:
|
|
74
|
+
error_type = "permission_error"
|
|
75
|
+
elif "FileNotFoundError" in output:
|
|
76
|
+
error_type = "file_not_found"
|
|
77
|
+
|
|
78
|
+
session.append_log(
|
|
79
|
+
LogEntry(
|
|
80
|
+
type="error",
|
|
81
|
+
content=cmd[:100],
|
|
82
|
+
context_pct=session.token_pct_at_close,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Append dead end preview if command failed twice or produced obvious error
|
|
87
|
+
if exit_code != 0 and len(output) > 20:
|
|
88
|
+
session.dead_ends.append(
|
|
89
|
+
DeadEnd(
|
|
90
|
+
timestamp=ts,
|
|
91
|
+
tried=cmd[:100],
|
|
92
|
+
why_failed=output[:150],
|
|
93
|
+
use_instead="",
|
|
94
|
+
context_pct=session.token_pct_at_close,
|
|
95
|
+
)
|
|
96
|
+
)
|