@paths.design/caws-cli 11.1.7 → 11.1.8
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.
- package/dist/index.js +55 -58
- package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
- package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
- package/dist/init/hook-packs/manifest-claude-code.js +260 -2
- package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
- package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
- package/dist/shell/binding/resolve-binding.js +105 -1
- package/dist/shell/binding/resolve-binding.js.map +1 -1
- package/dist/shell/binding/types.d.ts +47 -3
- package/dist/shell/binding/types.d.ts.map +1 -1
- package/dist/shell/command-metadata.d.ts +93 -0
- package/dist/shell/command-metadata.d.ts.map +1 -0
- package/dist/shell/command-metadata.js +687 -0
- package/dist/shell/command-metadata.js.map +1 -0
- package/dist/shell/commands/agents.d.ts +1 -2
- package/dist/shell/commands/agents.d.ts.map +1 -1
- package/dist/shell/commands/claim.d.ts +16 -0
- package/dist/shell/commands/claim.d.ts.map +1 -1
- package/dist/shell/commands/claim.js +85 -26
- package/dist/shell/commands/claim.js.map +1 -1
- package/dist/shell/commands/events.d.ts +106 -0
- package/dist/shell/commands/events.d.ts.map +1 -0
- package/dist/shell/commands/events.js +510 -0
- package/dist/shell/commands/events.js.map +1 -0
- package/dist/shell/commands/gates.d.ts +2 -2
- package/dist/shell/commands/gates.d.ts.map +1 -1
- package/dist/shell/commands/gates.js +106 -25
- package/dist/shell/commands/gates.js.map +1 -1
- package/dist/shell/commands/init.d.ts.map +1 -1
- package/dist/shell/commands/init.js +26 -0
- package/dist/shell/commands/init.js.map +1 -1
- package/dist/shell/commands/prepush.d.ts +26 -0
- package/dist/shell/commands/prepush.d.ts.map +1 -0
- package/dist/shell/commands/prepush.js +373 -0
- package/dist/shell/commands/prepush.js.map +1 -0
- package/dist/shell/commands/scope.d.ts.map +1 -1
- package/dist/shell/commands/scope.js +31 -1
- package/dist/shell/commands/scope.js.map +1 -1
- package/dist/shell/commands/specs.d.ts +44 -3
- package/dist/shell/commands/specs.d.ts.map +1 -1
- package/dist/shell/commands/specs.js +411 -15
- package/dist/shell/commands/specs.js.map +1 -1
- package/dist/shell/commands/worktree.d.ts.map +1 -1
- package/dist/shell/commands/worktree.js +51 -1
- package/dist/shell/commands/worktree.js.map +1 -1
- package/dist/shell/gates/disposition.d.ts.map +1 -1
- package/dist/shell/gates/disposition.js +43 -2
- package/dist/shell/gates/disposition.js.map +1 -1
- package/dist/shell/index.d.ts +10 -4
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +22 -2
- package/dist/shell/index.js.map +1 -1
- package/dist/shell/legacy-command-map.js +832 -0
- package/dist/shell/push-range/classify-range.d.ts +99 -0
- package/dist/shell/push-range/classify-range.d.ts.map +1 -0
- package/dist/shell/push-range/classify-range.js +155 -0
- package/dist/shell/push-range/classify-range.js.map +1 -0
- package/dist/shell/push-range/scope-match.d.ts +13 -0
- package/dist/shell/push-range/scope-match.d.ts.map +1 -0
- package/dist/shell/push-range/scope-match.js +53 -0
- package/dist/shell/push-range/scope-match.js.map +1 -0
- package/dist/shell/register.d.ts.map +1 -1
- package/dist/shell/register.js +263 -228
- package/dist/shell/register.js.map +1 -1
- package/dist/shell/registered-command-groups.js +48 -0
- package/dist/shell/rules.d.ts +19 -0
- package/dist/shell/rules.d.ts.map +1 -1
- package/dist/shell/rules.js +27 -0
- package/dist/shell/rules.js.map +1 -1
- package/dist/shell/session/resolve-session.d.ts +29 -1
- package/dist/shell/session/resolve-session.d.ts.map +1 -1
- package/dist/shell/session/resolve-session.js +817 -11
- package/dist/shell/session/resolve-session.js.map +1 -1
- package/dist/shell/session/types.d.ts +127 -1
- package/dist/shell/session/types.d.ts.map +1 -1
- package/dist/shell/session/types.js +10 -4
- package/dist/shell/session/types.js.map +1 -1
- package/dist/store/doctor-snapshot.d.ts.map +1 -1
- package/dist/store/doctor-snapshot.js +26 -0
- package/dist/store/doctor-snapshot.js.map +1 -1
- package/dist/store/events-migration.d.ts +207 -0
- package/dist/store/events-migration.d.ts.map +1 -0
- package/dist/store/events-migration.js +358 -0
- package/dist/store/events-migration.js.map +1 -0
- package/dist/store/events-store.d.ts +47 -1
- package/dist/store/events-store.d.ts.map +1 -1
- package/dist/store/events-store.js +278 -0
- package/dist/store/events-store.js.map +1 -1
- package/dist/store/git-autocommit.d.ts +46 -0
- package/dist/store/git-autocommit.d.ts.map +1 -0
- package/dist/store/git-autocommit.js +198 -0
- package/dist/store/git-autocommit.js.map +1 -0
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +7 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/leases-store.d.ts.map +1 -1
- package/dist/store/leases-store.js +58 -0
- package/dist/store/leases-store.js.map +1 -1
- package/dist/store/rules.d.ts +53 -0
- package/dist/store/rules.d.ts.map +1 -1
- package/dist/store/rules.js +54 -0
- package/dist/store/rules.js.map +1 -1
- package/dist/store/specs-migration.d.ts +128 -0
- package/dist/store/specs-migration.d.ts.map +1 -0
- package/dist/store/specs-migration.js +481 -0
- package/dist/store/specs-migration.js.map +1 -0
- package/dist/store/specs-store.d.ts.map +1 -1
- package/dist/store/specs-store.js +14 -2
- package/dist/store/specs-store.js.map +1 -1
- package/dist/store/specs-writer.d.ts +130 -3
- package/dist/store/specs-writer.d.ts.map +1 -1
- package/dist/store/specs-writer.js +941 -102
- package/dist/store/specs-writer.js.map +1 -1
- package/dist/store/types.d.ts +6 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/waivers-store.d.ts.map +1 -1
- package/dist/store/waivers-store.js +8 -1
- package/dist/store/waivers-store.js.map +1 -1
- package/dist/store/worktrees-writer.d.ts +28 -0
- package/dist/store/worktrees-writer.d.ts.map +1 -1
- package/dist/store/worktrees-writer.js +110 -12
- package/dist/store/worktrees-writer.js.map +1 -1
- package/package.json +5 -2
- package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
- package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
- package/templates/hook-packs/claude-code/agent-register.sh +1 -1
- package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
- package/templates/hook-packs/claude-code/audit.sh +1 -1
- package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
- package/templates/hook-packs/claude-code/classify_command.py +1 -1
- package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
- package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
- package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
- package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
- package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
- package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
- package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
- package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
- package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
- package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
- package/templates/hook-packs/claude-code/naming-check.sh +128 -0
- package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
- package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
- package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
- package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
- package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
- package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
- package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
- package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
- package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
- package/templates/hook-packs/claude-code/session-log.sh +1 -1
- package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
- package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
- package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
- package/templates/hook-packs/claude-code/worktree-write-guard.sh +1 -1
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# CAWS-MANAGED-HOOK
|
|
3
|
+
# hook_pack: claude-code
|
|
4
|
+
# hook_pack_version: 11
|
|
5
|
+
# caws_min_major: 11
|
|
6
|
+
# lineage_refs: 10
|
|
7
|
+
# do_not_edit_directly: update via `caws init --agent-surface claude-code`
|
|
8
|
+
"""Render lean session artifacts from a Claude transcript JSONL.
|
|
9
|
+
|
|
10
|
+
This file is invoked by session-log.sh via `python3 <path>`. It is NOT
|
|
11
|
+
executable on its own; the pack manifest registers it with
|
|
12
|
+
`executable: false`. The CAWS-MANAGED-HOOK header above is parsed
|
|
13
|
+
by `caws init` to recognize this file as managed.
|
|
14
|
+
|
|
15
|
+
Bundled in v6 of the pack to fix CAWS-HOOK-PACK-RENDERER-MISSING-001:
|
|
16
|
+
session-log.sh's `RENDERER` path used to point at a file that was
|
|
17
|
+
not bundled, producing a crash on every invocation in fresh installs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
NOISE_PREFIXES = (
|
|
33
|
+
"<local-command",
|
|
34
|
+
"<command-name",
|
|
35
|
+
"<local-command-stdout",
|
|
36
|
+
"<local-command-caveat",
|
|
37
|
+
"This session is being continued",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
SESSION_EVENT_PREFIXES = (
|
|
41
|
+
"<task-notification>",
|
|
42
|
+
"[Request interrupted",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
NOTABLE_KW = (
|
|
46
|
+
"error",
|
|
47
|
+
"fail",
|
|
48
|
+
"failed",
|
|
49
|
+
"refusal",
|
|
50
|
+
"mismatch",
|
|
51
|
+
"passed",
|
|
52
|
+
"assert",
|
|
53
|
+
"traceback",
|
|
54
|
+
"exception",
|
|
55
|
+
"pytest",
|
|
56
|
+
"typedrefusal",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#
|
|
60
|
+
# MEANINGFUL_COMMAND_KW is a small, intentionally-generic baseline of
|
|
61
|
+
# substrings that mark "interesting" bash commands worth surfacing in
|
|
62
|
+
# session.txt. Consumers with project-specific toolchains (Rust:
|
|
63
|
+
# `cargo test`, `cargo build`; Python lint/typecheck: `ruff`, `mypy`;
|
|
64
|
+
# etc.) should NOT edit this file to add their entries — re-running
|
|
65
|
+
# `caws init --agent-surface claude-code` would refuse the merge as
|
|
66
|
+
# `unmanaged_collision`. Future work (CAWS-HOOK-PACK-RENDERER-CONFIG-001)
|
|
67
|
+
# will admit a sidecar config (e.g. `.caws/session-log.yaml`) for
|
|
68
|
+
# consumer extensions; until then the baseline is the only set.
|
|
69
|
+
MEANINGFUL_COMMAND_KW = (
|
|
70
|
+
"pytest",
|
|
71
|
+
"npm test",
|
|
72
|
+
"pnpm test",
|
|
73
|
+
"git log",
|
|
74
|
+
"git diff",
|
|
75
|
+
"git status",
|
|
76
|
+
"git add",
|
|
77
|
+
"git commit",
|
|
78
|
+
"git merge",
|
|
79
|
+
"caws ",
|
|
80
|
+
"pip install",
|
|
81
|
+
"make",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
DECISION_PATTERNS = [
|
|
85
|
+
re.compile(r"(?:^|\n)\s*(?:decision|decided|choosing|will use|going with)[:\s]+(.+?)(?:\n|$)", re.IGNORECASE),
|
|
86
|
+
re.compile(r"(?:^|\n)\s*(?:approach|plan|strategy)[:\s]+(.+?)(?:\n|$)", re.IGNORECASE),
|
|
87
|
+
]
|
|
88
|
+
NEXT_ACTION_PATTERNS = [
|
|
89
|
+
re.compile(r"(?:^|\n)\s*(?:next step|next action|next:|todo:|will now)[:\s]+(.+?)(?:\n|$)", re.IGNORECASE),
|
|
90
|
+
]
|
|
91
|
+
BLOCKING_PATTERNS = [
|
|
92
|
+
re.compile(r"(?:^|\n)\s*(?:blocked|blocking|cannot proceed|stuck)[:\s]+(.+?)(?:\n|$)", re.IGNORECASE),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rel_path(path: str | None, cwd: str) -> str:
|
|
97
|
+
if path and path.startswith(cwd + "/"):
|
|
98
|
+
return path[len(cwd) + 1 :]
|
|
99
|
+
return path or ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_timestamp(ts: Any) -> str | None:
|
|
103
|
+
if not ts:
|
|
104
|
+
return None
|
|
105
|
+
if isinstance(ts, str):
|
|
106
|
+
return ts
|
|
107
|
+
if isinstance(ts, (int, float)):
|
|
108
|
+
try:
|
|
109
|
+
return datetime.utcfromtimestamp(ts).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
110
|
+
except Exception:
|
|
111
|
+
return str(ts)
|
|
112
|
+
return str(ts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def seconds_between(ts1: str | None, ts2: str | None) -> float | None:
|
|
116
|
+
if not ts1 or not ts2:
|
|
117
|
+
return None
|
|
118
|
+
fmts = (
|
|
119
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
120
|
+
"%Y-%m-%dT%H:%M:%S.%fZ",
|
|
121
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
122
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
123
|
+
)
|
|
124
|
+
first = second = None
|
|
125
|
+
for fmt in fmts:
|
|
126
|
+
if first is None:
|
|
127
|
+
try:
|
|
128
|
+
first = datetime.strptime(ts1, fmt)
|
|
129
|
+
except ValueError:
|
|
130
|
+
pass
|
|
131
|
+
if second is None:
|
|
132
|
+
try:
|
|
133
|
+
second = datetime.strptime(ts2, fmt)
|
|
134
|
+
except ValueError:
|
|
135
|
+
pass
|
|
136
|
+
if first and second:
|
|
137
|
+
return round((second - first).total_seconds(), 2)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def truncate(text: str | None, limit: int) -> str:
|
|
142
|
+
value = text or ""
|
|
143
|
+
if len(value) <= limit:
|
|
144
|
+
return value
|
|
145
|
+
return value[:limit] + "..."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def compact_ws(text: str | None, limit: int = 160) -> str:
|
|
149
|
+
value = re.sub(r"\s+", " ", (text or "").strip())
|
|
150
|
+
if len(value) <= limit:
|
|
151
|
+
return value
|
|
152
|
+
return value[:limit] + "..."
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode_structured_text_payload(raw: str | None) -> str:
|
|
156
|
+
if not isinstance(raw, str):
|
|
157
|
+
return raw or ""
|
|
158
|
+
payload = raw.strip()
|
|
159
|
+
if not payload or payload[0] not in "[{":
|
|
160
|
+
return raw
|
|
161
|
+
try:
|
|
162
|
+
parsed = json.loads(payload)
|
|
163
|
+
except Exception:
|
|
164
|
+
return raw
|
|
165
|
+
items = parsed if isinstance(parsed, list) else [parsed]
|
|
166
|
+
blocks = []
|
|
167
|
+
for item in items:
|
|
168
|
+
if isinstance(item, dict):
|
|
169
|
+
text = item.get("text")
|
|
170
|
+
if isinstance(text, str) and text.strip():
|
|
171
|
+
blocks.append(text)
|
|
172
|
+
return "\n\n".join(blocks) if blocks else raw
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def extract_heuristic_fields(reasoning_texts: list[str]) -> tuple[list[dict[str, str]], str | None, str | None]:
|
|
176
|
+
decisions: list[dict[str, str]] = []
|
|
177
|
+
next_action = None
|
|
178
|
+
blocking_issue = None
|
|
179
|
+
full_text = "\n".join(reasoning_texts)
|
|
180
|
+
|
|
181
|
+
for pattern in DECISION_PATTERNS:
|
|
182
|
+
for match in pattern.finditer(full_text):
|
|
183
|
+
statement = match.group(1).strip()[:240]
|
|
184
|
+
if statement and statement not in {d["statement"] for d in decisions}:
|
|
185
|
+
decisions.append({"statement": statement, "source": "heuristic"})
|
|
186
|
+
|
|
187
|
+
for pattern in NEXT_ACTION_PATTERNS:
|
|
188
|
+
match = pattern.search(full_text)
|
|
189
|
+
if match:
|
|
190
|
+
next_action = match.group(1).strip()[:240]
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
for pattern in BLOCKING_PATTERNS:
|
|
194
|
+
match = pattern.search(full_text)
|
|
195
|
+
if match:
|
|
196
|
+
blocking_issue = match.group(1).strip()[:240]
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
return decisions, next_action, blocking_issue
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_control_event(text: str, ts: str | None) -> dict[str, Any]:
|
|
203
|
+
task_id = re.search(r"<task-id>(.*?)</task-id>", text, re.DOTALL)
|
|
204
|
+
summary = re.search(r"<summary>(.*?)</summary>", text, re.DOTALL)
|
|
205
|
+
command = re.search(r"<command-message>(.*?)</command-message>", text, re.DOTALL)
|
|
206
|
+
event_type = "task_notification" if text.startswith("<task-notification>") else "control"
|
|
207
|
+
preview = summary.group(1).strip() if summary else command.group(1).strip() if command else compact_ws(text, 180)
|
|
208
|
+
return {
|
|
209
|
+
"type": event_type,
|
|
210
|
+
"task_id": task_id.group(1).strip() if task_id else None,
|
|
211
|
+
"preview": preview,
|
|
212
|
+
"raw": truncate(text, 1200),
|
|
213
|
+
"ts": ts,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def new_turn(user_text: str | None = None, ts: str | None = None) -> dict[str, Any]:
|
|
218
|
+
return {
|
|
219
|
+
"user": user_text,
|
|
220
|
+
"user_ts": ts,
|
|
221
|
+
"timeline": [],
|
|
222
|
+
"edited_files": [],
|
|
223
|
+
"read_files": [],
|
|
224
|
+
"searches": [],
|
|
225
|
+
"commands": [],
|
|
226
|
+
"agent_runs": [],
|
|
227
|
+
"artifacts": [],
|
|
228
|
+
"control_events": [],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def append_unique(items: list[str], value: str) -> None:
|
|
233
|
+
if value and value not in items:
|
|
234
|
+
items.append(value)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def extract_text_from_content_blocks(content: Any) -> str:
|
|
238
|
+
if isinstance(content, str):
|
|
239
|
+
return content
|
|
240
|
+
if not isinstance(content, list):
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
blocks = []
|
|
244
|
+
for item in content:
|
|
245
|
+
if not isinstance(item, dict):
|
|
246
|
+
continue
|
|
247
|
+
text = item.get("text")
|
|
248
|
+
if isinstance(text, str) and text.strip():
|
|
249
|
+
blocks.append(text)
|
|
250
|
+
continue
|
|
251
|
+
if item.get("type") == "json":
|
|
252
|
+
value = item.get("json")
|
|
253
|
+
if value is not None:
|
|
254
|
+
blocks.append(json.dumps(value, ensure_ascii=False))
|
|
255
|
+
return "\n\n".join(blocks)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def extract_tool_result_content(entry: dict[str, Any]) -> str:
|
|
259
|
+
tool_use_result = entry.get("tool_use_result")
|
|
260
|
+
if isinstance(tool_use_result, dict):
|
|
261
|
+
file_payload = tool_use_result.get("file")
|
|
262
|
+
if isinstance(file_payload, dict):
|
|
263
|
+
content = file_payload.get("content")
|
|
264
|
+
if isinstance(content, str) and content.strip():
|
|
265
|
+
return content
|
|
266
|
+
text = tool_use_result.get("text")
|
|
267
|
+
if isinstance(text, str) and text.strip():
|
|
268
|
+
return text
|
|
269
|
+
item_content = extract_text_from_content_blocks(entry.get("content"))
|
|
270
|
+
if item_content.strip():
|
|
271
|
+
return item_content
|
|
272
|
+
return entry.get("content", "") or ""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_transcript_events(transcript_path: str) -> list[dict[str, Any]]:
|
|
276
|
+
events: list[dict[str, Any]] = []
|
|
277
|
+
with open(transcript_path, encoding="utf-8") as handle:
|
|
278
|
+
for raw_line in handle:
|
|
279
|
+
line = raw_line.strip()
|
|
280
|
+
if not line:
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
obj = json.loads(line)
|
|
284
|
+
except json.JSONDecodeError:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
ts = parse_timestamp(obj.get("timestamp"))
|
|
288
|
+
kind = obj.get("type")
|
|
289
|
+
|
|
290
|
+
if kind == "user":
|
|
291
|
+
content = obj.get("message", {}).get("content")
|
|
292
|
+
if isinstance(content, str):
|
|
293
|
+
events.append({"ev": "user_text", "text": content, "ts": ts})
|
|
294
|
+
elif isinstance(content, list):
|
|
295
|
+
for item in content:
|
|
296
|
+
if not isinstance(item, dict):
|
|
297
|
+
continue
|
|
298
|
+
if item.get("type") == "tool_result":
|
|
299
|
+
events.append(
|
|
300
|
+
{
|
|
301
|
+
"ev": "tool_result",
|
|
302
|
+
"id": item.get("tool_use_id", ""),
|
|
303
|
+
"content": extract_tool_result_content(item),
|
|
304
|
+
"tool_use_result": item.get("tool_use_result"),
|
|
305
|
+
"is_error": item.get("is_error", False),
|
|
306
|
+
"ts": ts,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
elif item.get("type") == "text":
|
|
310
|
+
events.append({"ev": "user_text", "text": item.get("text", ""), "ts": ts})
|
|
311
|
+
|
|
312
|
+
elif kind == "assistant":
|
|
313
|
+
content = obj.get("message", {}).get("content", [])
|
|
314
|
+
if not isinstance(content, list):
|
|
315
|
+
continue
|
|
316
|
+
for item in content:
|
|
317
|
+
if not isinstance(item, dict):
|
|
318
|
+
continue
|
|
319
|
+
if item.get("type") == "text":
|
|
320
|
+
events.append({"ev": "assistant_text", "text": item.get("text", ""), "ts": ts})
|
|
321
|
+
elif item.get("type") == "tool_use":
|
|
322
|
+
tool_input = item.get("input", {}) if isinstance(item.get("input"), dict) else {}
|
|
323
|
+
events.append(
|
|
324
|
+
{
|
|
325
|
+
"ev": "tool_use",
|
|
326
|
+
"name": item.get("name", ""),
|
|
327
|
+
"id": item.get("id", ""),
|
|
328
|
+
"input": tool_input,
|
|
329
|
+
"ts": ts,
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return events
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def accumulate_turns(events: list[dict[str, Any]], cwd: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
337
|
+
turns: list[dict[str, Any]] = []
|
|
338
|
+
session_events: list[dict[str, Any]] = []
|
|
339
|
+
current = new_turn()
|
|
340
|
+
pending_tools: dict[str, dict[str, Any]] = {}
|
|
341
|
+
|
|
342
|
+
for entry in events:
|
|
343
|
+
ev = entry.get("ev")
|
|
344
|
+
ts = parse_timestamp(entry.get("ts"))
|
|
345
|
+
|
|
346
|
+
if ev == "user_text":
|
|
347
|
+
text = entry.get("text", "")
|
|
348
|
+
if any(text.startswith(prefix) for prefix in NOISE_PREFIXES) or not text.strip():
|
|
349
|
+
continue
|
|
350
|
+
if any(text.startswith(prefix) for prefix in SESSION_EVENT_PREFIXES):
|
|
351
|
+
session_events.append(parse_control_event(text, ts))
|
|
352
|
+
continue
|
|
353
|
+
if current["user"] or current["timeline"] or current["control_events"]:
|
|
354
|
+
turns.append(current)
|
|
355
|
+
current = new_turn(text, ts)
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
if ev == "assistant_text":
|
|
359
|
+
text = entry.get("text", "")
|
|
360
|
+
if text.strip():
|
|
361
|
+
current["timeline"].append(
|
|
362
|
+
{
|
|
363
|
+
"kind": "reasoning",
|
|
364
|
+
"text": text,
|
|
365
|
+
"ts": ts,
|
|
366
|
+
"provenance": "assistant_reasoning",
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
if ev == "tool_use":
|
|
372
|
+
name = entry.get("name", "")
|
|
373
|
+
tool_input = entry.get("input", {})
|
|
374
|
+
tool_entry: dict[str, Any] = {
|
|
375
|
+
"kind": "tool_call",
|
|
376
|
+
"name": name,
|
|
377
|
+
"id": entry.get("id", ""),
|
|
378
|
+
"ts": ts,
|
|
379
|
+
"provenance": "tool_call",
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if name in ("Write", "Edit"):
|
|
383
|
+
file_path = rel_path(tool_input.get("file_path"), cwd)
|
|
384
|
+
tool_entry["file"] = file_path
|
|
385
|
+
append_unique(current["edited_files"], file_path)
|
|
386
|
+
if name == "Edit":
|
|
387
|
+
tool_entry["old_string"] = truncate(tool_input.get("old_string", ""), 1200)
|
|
388
|
+
tool_entry["new_string"] = truncate(tool_input.get("new_string", ""), 1200)
|
|
389
|
+
else:
|
|
390
|
+
content = tool_input.get("content", "") or ""
|
|
391
|
+
tool_entry["content_preview"] = truncate(content, 1600)
|
|
392
|
+
tool_entry["content_length"] = len(content)
|
|
393
|
+
current["artifacts"].append(
|
|
394
|
+
{
|
|
395
|
+
"type": "file_edit" if name == "Edit" else "file_write",
|
|
396
|
+
"path": file_path,
|
|
397
|
+
"ts": ts,
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
elif name == "Read":
|
|
402
|
+
file_path = rel_path(tool_input.get("file_path"), cwd)
|
|
403
|
+
tool_entry["file"] = file_path
|
|
404
|
+
append_unique(current["read_files"], file_path)
|
|
405
|
+
|
|
406
|
+
elif name in ("Grep", "Glob"):
|
|
407
|
+
pattern = tool_input.get("pattern") or tool_input.get("path") or ""
|
|
408
|
+
tool_entry["pattern"] = pattern
|
|
409
|
+
if pattern:
|
|
410
|
+
current["searches"].append({"tool": name, "query": pattern, "ts": ts})
|
|
411
|
+
|
|
412
|
+
elif name in ("WebSearch", "WebFetch"):
|
|
413
|
+
query = tool_input.get("query") or tool_input.get("url") or ""
|
|
414
|
+
tool_entry["query"] = query
|
|
415
|
+
if query:
|
|
416
|
+
current["searches"].append({"tool": name, "query": query, "ts": ts})
|
|
417
|
+
|
|
418
|
+
elif name == "Bash":
|
|
419
|
+
command = tool_input.get("command", "")
|
|
420
|
+
description = tool_input.get("description", "") or ""
|
|
421
|
+
tool_entry["command"] = command
|
|
422
|
+
tool_entry["description"] = description
|
|
423
|
+
tool_entry["run_in_background"] = tool_input.get("run_in_background")
|
|
424
|
+
current["commands"].append(
|
|
425
|
+
{
|
|
426
|
+
"command": command,
|
|
427
|
+
"description": description,
|
|
428
|
+
"ts": ts,
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
if "git commit" in command and "-m" in command:
|
|
432
|
+
current["artifacts"].append({"type": "git_commit", "command": command, "ts": ts})
|
|
433
|
+
# Test-runner detection: kept aligned with MEANINGFUL_COMMAND_KW.
|
|
434
|
+
# See the comment above that tuple for consumer-extension guidance.
|
|
435
|
+
if any(keyword in command for keyword in ("pytest", "npm test", "pnpm test")):
|
|
436
|
+
current["artifacts"].append({"type": "test_run", "command": command, "ts": ts})
|
|
437
|
+
|
|
438
|
+
elif name in ("Agent", "Task"):
|
|
439
|
+
prompt = tool_input.get("prompt", "") or ""
|
|
440
|
+
tool_entry["prompt"] = truncate(prompt, 1200)
|
|
441
|
+
tool_entry["subagent_type"] = tool_input.get("subagent_type")
|
|
442
|
+
tool_entry["run_in_background"] = tool_input.get("run_in_background")
|
|
443
|
+
tool_entry["isolation"] = tool_input.get("isolation")
|
|
444
|
+
tool_entry["provenance"] = "sub_agent_dispatch"
|
|
445
|
+
current["agent_runs"].append(
|
|
446
|
+
{
|
|
447
|
+
"id": entry.get("id", ""),
|
|
448
|
+
"tool": name,
|
|
449
|
+
"subagent_type": tool_input.get("subagent_type"),
|
|
450
|
+
"prompt_preview": compact_ws(prompt, 300),
|
|
451
|
+
"status": "launched",
|
|
452
|
+
"ts": ts,
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
elif name == "Skill":
|
|
457
|
+
tool_entry["skill"] = tool_input.get("skill", "")
|
|
458
|
+
tool_entry["args"] = tool_input.get("args", "")
|
|
459
|
+
|
|
460
|
+
elif name == "ExitPlanMode":
|
|
461
|
+
plan_text = tool_input.get("plan") or ""
|
|
462
|
+
tool_entry["plan"] = truncate(plan_text, 6000)
|
|
463
|
+
tool_entry["provenance"] = "plan_approval"
|
|
464
|
+
current["artifacts"].append(
|
|
465
|
+
{
|
|
466
|
+
"type": "plan",
|
|
467
|
+
"content": truncate(plan_text, 2500),
|
|
468
|
+
"ts": ts,
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
current["timeline"].append(tool_entry)
|
|
473
|
+
pending_tools[entry.get("id", "")] = tool_entry
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
if ev == "tool_result":
|
|
477
|
+
tool_id = entry.get("id", "")
|
|
478
|
+
tool_info = pending_tools.get(tool_id)
|
|
479
|
+
content = entry.get("content", "") or ""
|
|
480
|
+
if tool_info:
|
|
481
|
+
name = tool_info.get("name", "")
|
|
482
|
+
result_text = content
|
|
483
|
+
if name in ("Agent", "Task"):
|
|
484
|
+
result_text = decode_structured_text_payload(content)
|
|
485
|
+
if name == "Read":
|
|
486
|
+
result_text = content
|
|
487
|
+
if name in ("Bash", "Agent", "Task"):
|
|
488
|
+
pass
|
|
489
|
+
elif name in ("Read", "Write", "Edit"):
|
|
490
|
+
result_text = truncate(result_text, 2500)
|
|
491
|
+
else:
|
|
492
|
+
result_text = truncate(result_text, 3000)
|
|
493
|
+
|
|
494
|
+
duration = seconds_between(tool_info.get("ts"), ts)
|
|
495
|
+
tool_info["output"] = result_text
|
|
496
|
+
tool_info["is_error"] = bool(entry.get("is_error"))
|
|
497
|
+
tool_info["result_ts"] = ts
|
|
498
|
+
tool_info["duration_s"] = duration
|
|
499
|
+
|
|
500
|
+
if name == "Bash":
|
|
501
|
+
for command in reversed(current["commands"]):
|
|
502
|
+
if command.get("command") == tool_info.get("command") and "output_preview" not in command:
|
|
503
|
+
command["output"] = result_text
|
|
504
|
+
command["output_preview"] = compact_ws(result_text, 320)
|
|
505
|
+
command["duration_s"] = duration
|
|
506
|
+
command["is_error"] = bool(entry.get("is_error"))
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
if name in ("Agent", "Task"):
|
|
510
|
+
for agent_run in reversed(current["agent_runs"]):
|
|
511
|
+
if agent_run.get("id") == tool_id:
|
|
512
|
+
agent_run["status"] = "error" if entry.get("is_error") else "completed"
|
|
513
|
+
agent_run["output"] = result_text
|
|
514
|
+
agent_run["output_preview"] = compact_ws(result_text, 500)
|
|
515
|
+
agent_run["duration_s"] = duration
|
|
516
|
+
break
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
current["timeline"].append(
|
|
520
|
+
{
|
|
521
|
+
"kind": "tool_output",
|
|
522
|
+
"id": tool_id,
|
|
523
|
+
"content": truncate(content, 3000),
|
|
524
|
+
"is_error": bool(entry.get("is_error")),
|
|
525
|
+
"ts": ts,
|
|
526
|
+
"provenance": "tool_output_orphan",
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if current["user"] or current["timeline"] or current["control_events"]:
|
|
531
|
+
turns.append(current)
|
|
532
|
+
|
|
533
|
+
return turns, session_events
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def build_turn_payload(turn: dict[str, Any], number: int) -> dict[str, Any]:
|
|
537
|
+
reasoning_texts = [item["text"] for item in turn["timeline"] if item.get("kind") == "reasoning"]
|
|
538
|
+
decisions, next_action, blocking_issue = extract_heuristic_fields(reasoning_texts)
|
|
539
|
+
|
|
540
|
+
summary = None
|
|
541
|
+
for item in reversed(turn["timeline"]):
|
|
542
|
+
if item.get("kind") == "reasoning":
|
|
543
|
+
text = compact_ws(item.get("text", ""), 260)
|
|
544
|
+
if len(text) >= 80:
|
|
545
|
+
summary = text
|
|
546
|
+
break
|
|
547
|
+
|
|
548
|
+
turn_ended_in_error = False
|
|
549
|
+
for item in reversed(turn["timeline"]):
|
|
550
|
+
if item.get("kind") == "tool_call" and "is_error" in item:
|
|
551
|
+
turn_ended_in_error = bool(item["is_error"])
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
all_ts = [turn.get("user_ts")] if turn.get("user_ts") else []
|
|
555
|
+
for item in turn["timeline"]:
|
|
556
|
+
if item.get("ts"):
|
|
557
|
+
all_ts.append(item["ts"])
|
|
558
|
+
if item.get("result_ts"):
|
|
559
|
+
all_ts.append(item["result_ts"])
|
|
560
|
+
ts_values = [value for value in all_ts if value]
|
|
561
|
+
ts_start = min(ts_values) if ts_values else None
|
|
562
|
+
ts_end = max(ts_values) if ts_values else None
|
|
563
|
+
|
|
564
|
+
status = "error" if turn_ended_in_error else "blocked" if blocking_issue else "ok"
|
|
565
|
+
|
|
566
|
+
refs = {
|
|
567
|
+
"files": {
|
|
568
|
+
"edited": sorted(set(turn["edited_files"])),
|
|
569
|
+
"read": sorted(set(turn["read_files"])),
|
|
570
|
+
},
|
|
571
|
+
"searches": turn["searches"],
|
|
572
|
+
"commands": turn["commands"],
|
|
573
|
+
"agents": turn["agent_runs"],
|
|
574
|
+
"artifacts": turn["artifacts"],
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"schema_version": 2,
|
|
579
|
+
"turn": number,
|
|
580
|
+
"ts_start": ts_start,
|
|
581
|
+
"ts_end": ts_end,
|
|
582
|
+
"user": turn.get("user"),
|
|
583
|
+
"user_ts": turn.get("user_ts"),
|
|
584
|
+
"turn_summary": summary,
|
|
585
|
+
"status": status,
|
|
586
|
+
"decisions": decisions,
|
|
587
|
+
"next_action": next_action,
|
|
588
|
+
"blocking_issue": blocking_issue,
|
|
589
|
+
"refs": refs,
|
|
590
|
+
"timeline": turn["timeline"],
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def collect_indexes(turn_payloads: list[dict[str, Any]]) -> dict[str, Any]:
|
|
595
|
+
file_index: dict[str, dict[str, list[int]]] = {}
|
|
596
|
+
command_index = []
|
|
597
|
+
search_index = []
|
|
598
|
+
agent_index = []
|
|
599
|
+
artifact_index = []
|
|
600
|
+
|
|
601
|
+
for payload in turn_payloads:
|
|
602
|
+
turn_number = payload["turn"]
|
|
603
|
+
for path in payload["refs"]["files"]["edited"]:
|
|
604
|
+
file_index.setdefault(path, {"edited_in": [], "read_in": []})
|
|
605
|
+
file_index[path]["edited_in"].append(turn_number)
|
|
606
|
+
for path in payload["refs"]["files"]["read"]:
|
|
607
|
+
file_index.setdefault(path, {"edited_in": [], "read_in": []})
|
|
608
|
+
file_index[path]["read_in"].append(turn_number)
|
|
609
|
+
for command in payload["refs"]["commands"]:
|
|
610
|
+
command_index.append({"turn": turn_number, **command})
|
|
611
|
+
for search in payload["refs"]["searches"]:
|
|
612
|
+
search_index.append({"turn": turn_number, **search})
|
|
613
|
+
for agent in payload["refs"]["agents"]:
|
|
614
|
+
agent_index.append({"turn": turn_number, **agent})
|
|
615
|
+
for artifact in payload["refs"]["artifacts"]:
|
|
616
|
+
artifact_index.append({"turn": turn_number, **artifact})
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
"files": file_index,
|
|
620
|
+
"commands": command_index,
|
|
621
|
+
"searches": search_index,
|
|
622
|
+
"agents": agent_index,
|
|
623
|
+
"artifacts": artifact_index,
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def run_git(cwd: str, args: list[str], max_output: int = 12000) -> str:
|
|
628
|
+
try:
|
|
629
|
+
result = subprocess.run(
|
|
630
|
+
["git", *args],
|
|
631
|
+
capture_output=True,
|
|
632
|
+
text=True,
|
|
633
|
+
cwd=cwd,
|
|
634
|
+
timeout=10,
|
|
635
|
+
)
|
|
636
|
+
except Exception:
|
|
637
|
+
return ""
|
|
638
|
+
output = (result.stdout or "").strip()
|
|
639
|
+
if len(output) > max_output:
|
|
640
|
+
output = output[:max_output] + "\n...(truncated)"
|
|
641
|
+
return output
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def build_git_snapshot(cwd: str, start_sha: str, branch: str, head_sha: str, dirty_count: str) -> dict[str, Any]:
|
|
645
|
+
snapshot = {
|
|
646
|
+
"branch": branch,
|
|
647
|
+
"head_sha": head_sha,
|
|
648
|
+
"start_sha": start_sha,
|
|
649
|
+
"dirty_files": int(dirty_count or "0"),
|
|
650
|
+
"status": run_git(cwd, ["status", "--porcelain"], 6000),
|
|
651
|
+
"log": run_git(cwd, ["log", "--oneline", f"{start_sha}..HEAD"] if start_sha else ["log", "--oneline", "-10"], 5000),
|
|
652
|
+
"diff_stat": run_git(cwd, ["diff", "--stat", f"{start_sha}..HEAD"] if start_sha else ["diff", "--stat"], 5000),
|
|
653
|
+
"diff_excerpt": run_git(cwd, ["diff", f"{start_sha}..HEAD"] if start_sha else ["diff"], 8000),
|
|
654
|
+
}
|
|
655
|
+
if not snapshot["log"]:
|
|
656
|
+
snapshot["log"] = run_git(cwd, ["log", "--oneline", "-10"], 8000)
|
|
657
|
+
return snapshot
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def cleanup_generated_outputs(log_dir: Path) -> None:
|
|
661
|
+
for path in log_dir.iterdir():
|
|
662
|
+
if path.name in {"session.txt", "session.json", "handoff.json"}:
|
|
663
|
+
path.unlink(missing_ok=True)
|
|
664
|
+
continue
|
|
665
|
+
if re.match(r"turn-\d+\.(json|txt)$", path.name):
|
|
666
|
+
path.unlink(missing_ok=True)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def write_session_txt(
|
|
670
|
+
path: Path,
|
|
671
|
+
*,
|
|
672
|
+
project_name: str,
|
|
673
|
+
session_id: str,
|
|
674
|
+
started_at: str,
|
|
675
|
+
model: str,
|
|
676
|
+
branch: str,
|
|
677
|
+
head_sha: str,
|
|
678
|
+
start_sha: str,
|
|
679
|
+
turn_index: list[dict[str, Any]],
|
|
680
|
+
session_events: list[dict[str, Any]],
|
|
681
|
+
indexes: dict[str, Any],
|
|
682
|
+
) -> None:
|
|
683
|
+
lines = [
|
|
684
|
+
f"# Session Log: {project_name}",
|
|
685
|
+
"",
|
|
686
|
+
"| Field | Value |",
|
|
687
|
+
"|-------|-------|",
|
|
688
|
+
f"| Session ID | `{session_id}` |",
|
|
689
|
+
f"| Started | {started_at} |",
|
|
690
|
+
f"| Model | {model} |",
|
|
691
|
+
f"| Branch | `{branch}` @ `{head_sha}` |",
|
|
692
|
+
]
|
|
693
|
+
if start_sha:
|
|
694
|
+
lines.append(f"| Start SHA | `{start_sha}` |")
|
|
695
|
+
lines.extend(
|
|
696
|
+
[
|
|
697
|
+
f"| Substantive turns | {len(turn_index)} |",
|
|
698
|
+
f"| Session events | {len(session_events)} |",
|
|
699
|
+
"",
|
|
700
|
+
"Canonical JSON: `session.json` plus `turn-###.json` files.",
|
|
701
|
+
"",
|
|
702
|
+
"## Turns",
|
|
703
|
+
"",
|
|
704
|
+
]
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
for item in turn_index:
|
|
708
|
+
status = f" [{item['status']}]" if item["status"] != "ok" else ""
|
|
709
|
+
lines.append(
|
|
710
|
+
f"- **[Turn {item['turn']}](turn-{item['turn']:03d}.json)**{status} — {item['user_preview']}"
|
|
711
|
+
)
|
|
712
|
+
meta = f" _{item['reasoning_count']} msgs, {item['tool_count']} tools"
|
|
713
|
+
if item["edited_preview"]:
|
|
714
|
+
meta += f" | {item['edited_preview']}"
|
|
715
|
+
meta += "_"
|
|
716
|
+
lines.append(meta)
|
|
717
|
+
if item.get("summary_preview"):
|
|
718
|
+
lines.append(f" > {item['summary_preview']}")
|
|
719
|
+
|
|
720
|
+
if session_events:
|
|
721
|
+
lines.extend(["", "## Session Events", ""])
|
|
722
|
+
for event in session_events[-12:]:
|
|
723
|
+
prefix = f"`{event['type']}`"
|
|
724
|
+
task_tag = f" `{event['task_id']}`" if event.get("task_id") else ""
|
|
725
|
+
lines.append(f"- {prefix}{task_tag} {event['preview']}")
|
|
726
|
+
|
|
727
|
+
lines.extend(["", "## Focus", ""])
|
|
728
|
+
for path_key, refs in list(indexes["files"].items())[:20]:
|
|
729
|
+
markers = []
|
|
730
|
+
if refs["edited_in"]:
|
|
731
|
+
markers.append("edit " + ",".join(str(v) for v in refs["edited_in"][:4]))
|
|
732
|
+
if refs["read_in"]:
|
|
733
|
+
markers.append("read " + ",".join(str(v) for v in refs["read_in"][:4]))
|
|
734
|
+
lines.append(f"- `{path_key}` ({'; '.join(markers)})")
|
|
735
|
+
|
|
736
|
+
lines.extend(["", "## Commands", ""])
|
|
737
|
+
for command in indexes["commands"]:
|
|
738
|
+
text = command.get("command", "")
|
|
739
|
+
if any(keyword in text for keyword in MEANINGFUL_COMMAND_KW):
|
|
740
|
+
lines.append(f"- turn {command['turn']}: `{truncate(text, 120)}`")
|
|
741
|
+
|
|
742
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def build_handoff(
|
|
746
|
+
*,
|
|
747
|
+
session_id: str,
|
|
748
|
+
model: str,
|
|
749
|
+
turn_payloads: list[dict[str, Any]],
|
|
750
|
+
session_events: list[dict[str, Any]],
|
|
751
|
+
indexes: dict[str, Any],
|
|
752
|
+
git_snapshot: dict[str, Any],
|
|
753
|
+
transcript_path: str,
|
|
754
|
+
) -> dict[str, Any]:
|
|
755
|
+
decisions: list[dict[str, str]] = []
|
|
756
|
+
next_actions = []
|
|
757
|
+
blocking = []
|
|
758
|
+
for payload in turn_payloads:
|
|
759
|
+
decisions.extend(payload["decisions"])
|
|
760
|
+
if payload.get("next_action"):
|
|
761
|
+
next_actions.append({"turn": payload["turn"], "text": payload["next_action"]})
|
|
762
|
+
if payload.get("blocking_issue"):
|
|
763
|
+
blocking.append({"turn": payload["turn"], "text": payload["blocking_issue"]})
|
|
764
|
+
|
|
765
|
+
files_of_interest = sorted(
|
|
766
|
+
indexes["files"].items(),
|
|
767
|
+
key=lambda item: len(item[1]["edited_in"]) + len(item[1]["read_in"]),
|
|
768
|
+
reverse=True,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
"schema_version": 2,
|
|
773
|
+
"session_id": session_id,
|
|
774
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
775
|
+
"session_file": "session.json",
|
|
776
|
+
"transcript_path": transcript_path,
|
|
777
|
+
"model": model,
|
|
778
|
+
"git": git_snapshot,
|
|
779
|
+
"continuation": {
|
|
780
|
+
"recent_turns": [
|
|
781
|
+
{
|
|
782
|
+
"turn": payload["turn"],
|
|
783
|
+
"path": f"turn-{payload['turn']:03d}.json",
|
|
784
|
+
"status": payload["status"],
|
|
785
|
+
"summary": payload.get("turn_summary"),
|
|
786
|
+
}
|
|
787
|
+
for payload in turn_payloads[-8:]
|
|
788
|
+
],
|
|
789
|
+
"blocking_issues": blocking,
|
|
790
|
+
"next_actions": next_actions[-12:],
|
|
791
|
+
"decisions": decisions[-20:],
|
|
792
|
+
"files_of_interest": [
|
|
793
|
+
{
|
|
794
|
+
"path": path,
|
|
795
|
+
"edited_in": refs["edited_in"],
|
|
796
|
+
"read_in": refs["read_in"],
|
|
797
|
+
}
|
|
798
|
+
for path, refs in files_of_interest[:20]
|
|
799
|
+
],
|
|
800
|
+
"commands_of_interest": [
|
|
801
|
+
{
|
|
802
|
+
"turn": entry["turn"],
|
|
803
|
+
"command": entry.get("command"),
|
|
804
|
+
"output_preview": entry.get("output_preview"),
|
|
805
|
+
}
|
|
806
|
+
for entry in indexes["commands"]
|
|
807
|
+
if any(keyword in (entry.get("command") or "") for keyword in MEANINGFUL_COMMAND_KW)
|
|
808
|
+
][-20:],
|
|
809
|
+
"agent_reports": indexes["agents"][-20:],
|
|
810
|
+
"session_events": session_events[-20:],
|
|
811
|
+
},
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def render_session(
|
|
816
|
+
*,
|
|
817
|
+
log_dir: str,
|
|
818
|
+
cwd: str,
|
|
819
|
+
session_id: str,
|
|
820
|
+
started_at: str,
|
|
821
|
+
model: str,
|
|
822
|
+
branch: str,
|
|
823
|
+
head_sha: str,
|
|
824
|
+
dirty_count: str,
|
|
825
|
+
start_sha: str,
|
|
826
|
+
transcript_path: str,
|
|
827
|
+
) -> None:
|
|
828
|
+
output_dir = Path(log_dir)
|
|
829
|
+
cleanup_generated_outputs(output_dir)
|
|
830
|
+
|
|
831
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
832
|
+
session_payload = {
|
|
833
|
+
"schema_version": 2,
|
|
834
|
+
"session_id": session_id,
|
|
835
|
+
"started_at": started_at,
|
|
836
|
+
"model": model,
|
|
837
|
+
"cwd": cwd,
|
|
838
|
+
"branch": branch,
|
|
839
|
+
"head_sha": head_sha,
|
|
840
|
+
"start_sha": start_sha,
|
|
841
|
+
"transcript_path": transcript_path,
|
|
842
|
+
"turn_index": [],
|
|
843
|
+
"session_events": [],
|
|
844
|
+
"indexes": {"files": {}, "commands": [], "searches": [], "agents": [], "artifacts": []},
|
|
845
|
+
"git": build_git_snapshot(cwd, start_sha, branch, head_sha, dirty_count),
|
|
846
|
+
}
|
|
847
|
+
(output_dir / "session.json").write_text(json.dumps(session_payload, indent=2), encoding="utf-8")
|
|
848
|
+
write_session_txt(
|
|
849
|
+
output_dir / "session.txt",
|
|
850
|
+
project_name=os.path.basename(cwd),
|
|
851
|
+
session_id=session_id,
|
|
852
|
+
started_at=started_at,
|
|
853
|
+
model=model,
|
|
854
|
+
branch=branch,
|
|
855
|
+
head_sha=head_sha,
|
|
856
|
+
start_sha=start_sha,
|
|
857
|
+
turn_index=[],
|
|
858
|
+
session_events=[],
|
|
859
|
+
indexes=session_payload["indexes"],
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
turns, session_events = accumulate_turns(parse_transcript_events(transcript_path), cwd)
|
|
864
|
+
turn_payloads = [build_turn_payload(turn, idx + 1) for idx, turn in enumerate(turns)]
|
|
865
|
+
|
|
866
|
+
for payload in turn_payloads:
|
|
867
|
+
target = output_dir / f"turn-{payload['turn']:03d}.json"
|
|
868
|
+
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
869
|
+
|
|
870
|
+
indexes = collect_indexes(turn_payloads)
|
|
871
|
+
git_snapshot = build_git_snapshot(cwd, start_sha, branch, head_sha, dirty_count)
|
|
872
|
+
turn_index = []
|
|
873
|
+
for payload in turn_payloads:
|
|
874
|
+
edited = payload["refs"]["files"]["edited"]
|
|
875
|
+
edited_preview = ", ".join(f"`{path}`" for path in edited[:3])
|
|
876
|
+
if len(edited) > 3:
|
|
877
|
+
edited_preview += f" +{len(edited) - 3} more"
|
|
878
|
+
turn_index.append(
|
|
879
|
+
{
|
|
880
|
+
"turn": payload["turn"],
|
|
881
|
+
"path": f"turn-{payload['turn']:03d}.json",
|
|
882
|
+
"ts_start": payload["ts_start"],
|
|
883
|
+
"status": payload["status"],
|
|
884
|
+
"user_preview": compact_ws(payload.get("user"), 140) or "(no user message)",
|
|
885
|
+
"summary_preview": payload.get("turn_summary"),
|
|
886
|
+
"reasoning_count": sum(1 for item in payload["timeline"] if item.get("kind") == "reasoning"),
|
|
887
|
+
"tool_count": sum(1 for item in payload["timeline"] if item.get("kind") == "tool_call"),
|
|
888
|
+
"edited_preview": edited_preview,
|
|
889
|
+
}
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
session_payload = {
|
|
893
|
+
"schema_version": 2,
|
|
894
|
+
"session_id": session_id,
|
|
895
|
+
"started_at": started_at,
|
|
896
|
+
"model": model,
|
|
897
|
+
"cwd": cwd,
|
|
898
|
+
"branch": branch,
|
|
899
|
+
"head_sha": head_sha,
|
|
900
|
+
"start_sha": start_sha,
|
|
901
|
+
"transcript_path": transcript_path,
|
|
902
|
+
"turn_index": turn_index,
|
|
903
|
+
"session_events": session_events,
|
|
904
|
+
"indexes": indexes,
|
|
905
|
+
"git": git_snapshot,
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
(output_dir / "session.json").write_text(json.dumps(session_payload, indent=2), encoding="utf-8")
|
|
909
|
+
write_session_txt(
|
|
910
|
+
output_dir / "session.txt",
|
|
911
|
+
project_name=os.path.basename(cwd),
|
|
912
|
+
session_id=session_id,
|
|
913
|
+
started_at=started_at,
|
|
914
|
+
model=model,
|
|
915
|
+
branch=branch,
|
|
916
|
+
head_sha=head_sha,
|
|
917
|
+
start_sha=start_sha,
|
|
918
|
+
turn_index=turn_index,
|
|
919
|
+
session_events=session_events,
|
|
920
|
+
indexes=indexes,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
handoff_payload = build_handoff(
|
|
924
|
+
session_id=session_id,
|
|
925
|
+
model=model,
|
|
926
|
+
turn_payloads=turn_payloads,
|
|
927
|
+
session_events=session_events,
|
|
928
|
+
indexes=indexes,
|
|
929
|
+
git_snapshot=git_snapshot,
|
|
930
|
+
transcript_path=transcript_path,
|
|
931
|
+
)
|
|
932
|
+
(output_dir / "handoff.json").write_text(json.dumps(handoff_payload, indent=2), encoding="utf-8")
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def main() -> int:
|
|
936
|
+
if len(sys.argv) != 11:
|
|
937
|
+
print("usage: session_log_renderer.py <log_dir> <cwd> <session_id> <started_at> <model> <branch> <head_sha> <dirty_count> <start_sha> <transcript_path>", file=sys.stderr)
|
|
938
|
+
return 1
|
|
939
|
+
|
|
940
|
+
render_session(
|
|
941
|
+
log_dir=sys.argv[1],
|
|
942
|
+
cwd=sys.argv[2],
|
|
943
|
+
session_id=sys.argv[3],
|
|
944
|
+
started_at=sys.argv[4],
|
|
945
|
+
model=sys.argv[5],
|
|
946
|
+
branch=sys.argv[6],
|
|
947
|
+
head_sha=sys.argv[7],
|
|
948
|
+
dirty_count=sys.argv[8],
|
|
949
|
+
start_sha=sys.argv[9],
|
|
950
|
+
transcript_path=sys.argv[10],
|
|
951
|
+
)
|
|
952
|
+
return 0
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
if __name__ == "__main__":
|
|
956
|
+
raise SystemExit(main())
|