@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.
Files changed (158) hide show
  1. package/dist/index.js +55 -58
  2. package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
  3. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
  4. package/dist/init/hook-packs/manifest-claude-code.js +260 -2
  5. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
  6. package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
  7. package/dist/shell/binding/resolve-binding.js +105 -1
  8. package/dist/shell/binding/resolve-binding.js.map +1 -1
  9. package/dist/shell/binding/types.d.ts +47 -3
  10. package/dist/shell/binding/types.d.ts.map +1 -1
  11. package/dist/shell/command-metadata.d.ts +93 -0
  12. package/dist/shell/command-metadata.d.ts.map +1 -0
  13. package/dist/shell/command-metadata.js +687 -0
  14. package/dist/shell/command-metadata.js.map +1 -0
  15. package/dist/shell/commands/agents.d.ts +1 -2
  16. package/dist/shell/commands/agents.d.ts.map +1 -1
  17. package/dist/shell/commands/claim.d.ts +16 -0
  18. package/dist/shell/commands/claim.d.ts.map +1 -1
  19. package/dist/shell/commands/claim.js +85 -26
  20. package/dist/shell/commands/claim.js.map +1 -1
  21. package/dist/shell/commands/events.d.ts +106 -0
  22. package/dist/shell/commands/events.d.ts.map +1 -0
  23. package/dist/shell/commands/events.js +510 -0
  24. package/dist/shell/commands/events.js.map +1 -0
  25. package/dist/shell/commands/gates.d.ts +2 -2
  26. package/dist/shell/commands/gates.d.ts.map +1 -1
  27. package/dist/shell/commands/gates.js +106 -25
  28. package/dist/shell/commands/gates.js.map +1 -1
  29. package/dist/shell/commands/init.d.ts.map +1 -1
  30. package/dist/shell/commands/init.js +26 -0
  31. package/dist/shell/commands/init.js.map +1 -1
  32. package/dist/shell/commands/prepush.d.ts +26 -0
  33. package/dist/shell/commands/prepush.d.ts.map +1 -0
  34. package/dist/shell/commands/prepush.js +373 -0
  35. package/dist/shell/commands/prepush.js.map +1 -0
  36. package/dist/shell/commands/scope.d.ts.map +1 -1
  37. package/dist/shell/commands/scope.js +31 -1
  38. package/dist/shell/commands/scope.js.map +1 -1
  39. package/dist/shell/commands/specs.d.ts +44 -3
  40. package/dist/shell/commands/specs.d.ts.map +1 -1
  41. package/dist/shell/commands/specs.js +411 -15
  42. package/dist/shell/commands/specs.js.map +1 -1
  43. package/dist/shell/commands/worktree.d.ts.map +1 -1
  44. package/dist/shell/commands/worktree.js +51 -1
  45. package/dist/shell/commands/worktree.js.map +1 -1
  46. package/dist/shell/gates/disposition.d.ts.map +1 -1
  47. package/dist/shell/gates/disposition.js +43 -2
  48. package/dist/shell/gates/disposition.js.map +1 -1
  49. package/dist/shell/index.d.ts +10 -4
  50. package/dist/shell/index.d.ts.map +1 -1
  51. package/dist/shell/index.js +22 -2
  52. package/dist/shell/index.js.map +1 -1
  53. package/dist/shell/legacy-command-map.js +832 -0
  54. package/dist/shell/push-range/classify-range.d.ts +99 -0
  55. package/dist/shell/push-range/classify-range.d.ts.map +1 -0
  56. package/dist/shell/push-range/classify-range.js +155 -0
  57. package/dist/shell/push-range/classify-range.js.map +1 -0
  58. package/dist/shell/push-range/scope-match.d.ts +13 -0
  59. package/dist/shell/push-range/scope-match.d.ts.map +1 -0
  60. package/dist/shell/push-range/scope-match.js +53 -0
  61. package/dist/shell/push-range/scope-match.js.map +1 -0
  62. package/dist/shell/register.d.ts.map +1 -1
  63. package/dist/shell/register.js +263 -228
  64. package/dist/shell/register.js.map +1 -1
  65. package/dist/shell/registered-command-groups.js +48 -0
  66. package/dist/shell/rules.d.ts +19 -0
  67. package/dist/shell/rules.d.ts.map +1 -1
  68. package/dist/shell/rules.js +27 -0
  69. package/dist/shell/rules.js.map +1 -1
  70. package/dist/shell/session/resolve-session.d.ts +29 -1
  71. package/dist/shell/session/resolve-session.d.ts.map +1 -1
  72. package/dist/shell/session/resolve-session.js +817 -11
  73. package/dist/shell/session/resolve-session.js.map +1 -1
  74. package/dist/shell/session/types.d.ts +127 -1
  75. package/dist/shell/session/types.d.ts.map +1 -1
  76. package/dist/shell/session/types.js +10 -4
  77. package/dist/shell/session/types.js.map +1 -1
  78. package/dist/store/doctor-snapshot.d.ts.map +1 -1
  79. package/dist/store/doctor-snapshot.js +26 -0
  80. package/dist/store/doctor-snapshot.js.map +1 -1
  81. package/dist/store/events-migration.d.ts +207 -0
  82. package/dist/store/events-migration.d.ts.map +1 -0
  83. package/dist/store/events-migration.js +358 -0
  84. package/dist/store/events-migration.js.map +1 -0
  85. package/dist/store/events-store.d.ts +47 -1
  86. package/dist/store/events-store.d.ts.map +1 -1
  87. package/dist/store/events-store.js +278 -0
  88. package/dist/store/events-store.js.map +1 -1
  89. package/dist/store/git-autocommit.d.ts +46 -0
  90. package/dist/store/git-autocommit.d.ts.map +1 -0
  91. package/dist/store/git-autocommit.js +198 -0
  92. package/dist/store/git-autocommit.js.map +1 -0
  93. package/dist/store/index.d.ts +4 -1
  94. package/dist/store/index.d.ts.map +1 -1
  95. package/dist/store/index.js +7 -1
  96. package/dist/store/index.js.map +1 -1
  97. package/dist/store/leases-store.d.ts.map +1 -1
  98. package/dist/store/leases-store.js +58 -0
  99. package/dist/store/leases-store.js.map +1 -1
  100. package/dist/store/rules.d.ts +53 -0
  101. package/dist/store/rules.d.ts.map +1 -1
  102. package/dist/store/rules.js +54 -0
  103. package/dist/store/rules.js.map +1 -1
  104. package/dist/store/specs-migration.d.ts +128 -0
  105. package/dist/store/specs-migration.d.ts.map +1 -0
  106. package/dist/store/specs-migration.js +481 -0
  107. package/dist/store/specs-migration.js.map +1 -0
  108. package/dist/store/specs-store.d.ts.map +1 -1
  109. package/dist/store/specs-store.js +14 -2
  110. package/dist/store/specs-store.js.map +1 -1
  111. package/dist/store/specs-writer.d.ts +130 -3
  112. package/dist/store/specs-writer.d.ts.map +1 -1
  113. package/dist/store/specs-writer.js +941 -102
  114. package/dist/store/specs-writer.js.map +1 -1
  115. package/dist/store/types.d.ts +6 -0
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/waivers-store.d.ts.map +1 -1
  118. package/dist/store/waivers-store.js +8 -1
  119. package/dist/store/waivers-store.js.map +1 -1
  120. package/dist/store/worktrees-writer.d.ts +28 -0
  121. package/dist/store/worktrees-writer.d.ts.map +1 -1
  122. package/dist/store/worktrees-writer.js +110 -12
  123. package/dist/store/worktrees-writer.js.map +1 -1
  124. package/package.json +5 -2
  125. package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
  126. package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
  127. package/templates/hook-packs/claude-code/agent-register.sh +1 -1
  128. package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
  129. package/templates/hook-packs/claude-code/audit.sh +1 -1
  130. package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
  131. package/templates/hook-packs/claude-code/classify_command.py +1 -1
  132. package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
  133. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
  134. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
  135. package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
  136. package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
  137. package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
  138. package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
  139. package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
  140. package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
  141. package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
  142. package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
  143. package/templates/hook-packs/claude-code/naming-check.sh +128 -0
  144. package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
  145. package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
  146. package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
  147. package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
  148. package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
  149. package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
  150. package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
  151. package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
  152. package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
  153. package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
  154. package/templates/hook-packs/claude-code/session-log.sh +1 -1
  155. package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
  156. package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
  157. package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
  158. 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())