@paths.design/caws-cli 8.3.0 → 9.1.0

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 (56) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/parallel.d.ts +7 -0
  3. package/dist/commands/parallel.d.ts.map +1 -0
  4. package/dist/commands/parallel.js +238 -0
  5. package/dist/commands/session.d.ts +7 -0
  6. package/dist/commands/session.d.ts.map +1 -0
  7. package/dist/commands/specs.d.ts +6 -0
  8. package/dist/commands/specs.d.ts.map +1 -1
  9. package/dist/commands/specs.js +55 -2
  10. package/dist/commands/status.d.ts.map +1 -1
  11. package/dist/commands/status.js +13 -3
  12. package/dist/commands/tutorial.js +0 -2
  13. package/dist/commands/waivers.d.ts.map +1 -1
  14. package/dist/constants/spec-types.d.ts +52 -0
  15. package/dist/constants/spec-types.d.ts.map +1 -1
  16. package/dist/constants/spec-types.js +25 -2
  17. package/dist/index.js +43 -2
  18. package/dist/parallel/parallel-manager.d.ts +67 -0
  19. package/dist/parallel/parallel-manager.d.ts.map +1 -0
  20. package/dist/parallel/parallel-manager.js +440 -0
  21. package/dist/scaffold/claude-hooks.d.ts.map +1 -1
  22. package/dist/scaffold/claude-hooks.js +78 -2
  23. package/dist/scaffold/git-hooks.d.ts.map +1 -1
  24. package/dist/scaffold/git-hooks.js +237 -73
  25. package/dist/scaffold/index.d.ts.map +1 -1
  26. package/dist/session/session-manager.d.ts +94 -0
  27. package/dist/session/session-manager.d.ts.map +1 -0
  28. package/dist/session/session-manager.js +14 -0
  29. package/dist/templates/.claude/hooks/scope-guard.sh +67 -21
  30. package/dist/templates/.claude/hooks/session-caws-status.sh +117 -0
  31. package/dist/templates/.claude/hooks/session-log.sh +528 -0
  32. package/dist/templates/.claude/hooks/stop-worktree-check.sh +46 -0
  33. package/dist/templates/.claude/hooks/worktree-guard.sh +207 -0
  34. package/dist/templates/.claude/hooks/worktree-write-guard.sh +84 -0
  35. package/dist/templates/.claude/rules/git-safety.md +26 -0
  36. package/dist/templates/.claude/rules/worktree-isolation.md +51 -0
  37. package/dist/templates/.claude/settings.json +15 -0
  38. package/dist/utils/gitignore-updater.d.ts +1 -1
  39. package/dist/utils/gitignore-updater.d.ts.map +1 -1
  40. package/dist/utils/gitignore-updater.js +3 -0
  41. package/dist/utils/ide-detection.d.ts +89 -0
  42. package/dist/utils/ide-detection.d.ts.map +1 -0
  43. package/dist/validation/spec-validation.d.ts.map +1 -1
  44. package/dist/validation/spec-validation.js +16 -0
  45. package/dist/worktree/worktree-manager.d.ts.map +1 -1
  46. package/dist/worktree/worktree-manager.js +31 -21
  47. package/package.json +2 -2
  48. package/templates/.claude/hooks/scope-guard.sh +67 -21
  49. package/templates/.claude/hooks/session-caws-status.sh +117 -0
  50. package/templates/.claude/hooks/session-log.sh +528 -0
  51. package/templates/.claude/hooks/stop-worktree-check.sh +46 -0
  52. package/templates/.claude/hooks/worktree-guard.sh +207 -0
  53. package/templates/.claude/hooks/worktree-write-guard.sh +84 -0
  54. package/templates/.claude/rules/git-safety.md +26 -0
  55. package/templates/.claude/rules/worktree-isolation.md +51 -0
  56. package/templates/.claude/settings.json +15 -0
@@ -0,0 +1,117 @@
1
+ #!/bin/bash
2
+ # CAWS Session Status Hook for Claude Code
3
+ # Reports project state at session start with worktree warnings
4
+ # @author @darianrosebrook
5
+
6
+ set -euo pipefail
7
+
8
+ # Read stdin (required by hook protocol)
9
+ INPUT=$(cat)
10
+
11
+ # Only run for session-start events
12
+ EVENT_TYPE="${1:-}"
13
+ if [ "$EVENT_TYPE" != "session-start" ]; then
14
+ exit 0
15
+ fi
16
+
17
+ # Check if this is a CAWS project
18
+ if [ ! -d "${CLAUDE_PROJECT_DIR:-.}/.caws" ]; then
19
+ exit 0
20
+ fi
21
+
22
+ cd "${CLAUDE_PROJECT_DIR:-.}"
23
+
24
+ # --- Resolve main repo root ---
25
+ CAWS_ROOT="."
26
+ if command -v git >/dev/null 2>&1; then
27
+ _GIT_COMMON=$(git rev-parse --git-common-dir 2>/dev/null || echo ".git")
28
+ if [ "$_GIT_COMMON" != ".git" ]; then
29
+ _CANDIDATE=$(cd "$_GIT_COMMON/.." 2>/dev/null && pwd || echo "")
30
+ if [ -n "$_CANDIDATE" ] && [ -d "$_CANDIDATE/.caws" ]; then
31
+ CAWS_ROOT="$_CANDIDATE"
32
+ fi
33
+ fi
34
+ fi
35
+
36
+ # --- Active worktree warning ---
37
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
38
+
39
+ if [ -f "$CAWS_ROOT/.caws/worktrees.json" ] && command -v node >/dev/null 2>&1; then
40
+ WT_INFO=$(node -e "
41
+ try {
42
+ var reg = JSON.parse(require('fs').readFileSync('$CAWS_ROOT/.caws/worktrees.json', 'utf8'));
43
+ var active = Object.values(reg.worktrees || {}).filter(function(w) { return w.status === 'active'; });
44
+ if (active.length > 0) {
45
+ var names = active.map(function(w) { return w.name + ' (' + w.branch + ')'; });
46
+ console.log(active.length + ':' + names.join(', '));
47
+ } else {
48
+ console.log('0:');
49
+ }
50
+ } catch(e) { console.log('0:'); }
51
+ " 2>/dev/null || echo "0:")
52
+
53
+ WT_COUNT=$(echo "$WT_INFO" | cut -d: -f1)
54
+ WT_NAMES=$(echo "$WT_INFO" | cut -d: -f2)
55
+
56
+ if [ "$WT_COUNT" -gt 0 ] 2>/dev/null; then
57
+ # Check if the agent is already in a worktree (not on the base branch)
58
+ BASE_BRANCH=$(node -e "
59
+ try {
60
+ var reg = JSON.parse(require('fs').readFileSync('$CAWS_ROOT/.caws/worktrees.json', 'utf8'));
61
+ var active = Object.values(reg.worktrees || {}).filter(function(w) { return w.status === 'active'; });
62
+ if (active.length > 0) console.log(active[0].baseBranch || '');
63
+ else console.log('');
64
+ } catch(e) { console.log(''); }
65
+ " 2>/dev/null || echo "")
66
+
67
+ echo ""
68
+ echo "================================================================"
69
+ echo " ACTIVE WORKTREES DETECTED: $WT_COUNT worktree(s)"
70
+ echo " $WT_NAMES"
71
+ echo "================================================================"
72
+
73
+ if [ -n "$BASE_BRANCH" ] && [ "$CURRENT_BRANCH" = "$BASE_BRANCH" ]; then
74
+ echo ""
75
+ echo " You MUST work in a worktree, not on $CURRENT_BRANCH."
76
+ echo ""
77
+ echo " If a worktree was created for your task:"
78
+ echo " cd $CAWS_ROOT/.caws/worktrees/<name>/"
79
+ echo ""
80
+ echo " If you need a new worktree:"
81
+ echo " caws worktree create <name>"
82
+ echo ""
83
+ echo " The only operations allowed on $CURRENT_BRANCH are:"
84
+ echo " - git merge --no-ff <branch> (merge completed worktree work)"
85
+ echo " - Commits with message: merge(worktree): <description>"
86
+ echo " - Commits with message: wip(checkpoint): <description>"
87
+ echo " (for committing prior-session dirty files)"
88
+ echo ""
89
+ echo " Writing or editing files on $CURRENT_BRANCH will be BLOCKED"
90
+ echo " by the PreToolUse hook while worktrees are active."
91
+ else
92
+ echo ""
93
+ echo " You are on branch '$CURRENT_BRANCH' (worktree). Good."
94
+ echo " Other active worktrees: $WT_NAMES"
95
+ fi
96
+ echo "================================================================"
97
+ echo ""
98
+ fi
99
+ fi
100
+
101
+ # Use caws session briefing for structured output
102
+ if command -v caws &>/dev/null; then
103
+ caws session briefing 2>/dev/null || {
104
+ echo "--- CAWS Session Briefing (fallback) ---"
105
+ HEAD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
106
+ BRANCH=$(git branch --show-current 2>/dev/null || echo "detached")
107
+ DIRTY_COUNT=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
108
+ echo "Git: ${BRANCH} @ ${HEAD_SHA} (${DIRTY_COUNT} dirty files)"
109
+ if [ "$DIRTY_COUNT" -gt 0 ]; then
110
+ echo "WARNING: Working tree has uncommitted changes from a prior session."
111
+ echo "Classify and commit or stash them before starting new work."
112
+ fi
113
+ echo "--- End CAWS Briefing ---"
114
+ }
115
+ fi
116
+
117
+ exit 0
@@ -0,0 +1,528 @@
1
+ #!/bin/bash
2
+ # Session Logger for Claude Code → ChatGPT Context Transfer
3
+ #
4
+ # On Stop/PreCompact: reads the full transcript from ~/.claude/ and generates:
5
+ # session.txt — lightweight index (header + turn list + exploration + audit)
6
+ # turn-001.txt — per-turn narrative (user message + reasoning + key tool output)
7
+ # turn-001.json — per-turn structured data (reasoning + tools + edits + results)
8
+ #
9
+ # Output: ./tmp/<session-id>/
10
+ #
11
+ # Wired into: SessionStart (metadata), Stop (generate), PreCompact (safety net)
12
+
13
+ set -euo pipefail
14
+
15
+ INPUT=$(cat)
16
+
17
+ # --- Parse common fields ---
18
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
19
+ HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
20
+ CWD=$(echo "$INPUT" | jq -r '.cwd // "."')
21
+ TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""')
22
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
23
+
24
+ # --- Log directory ---
25
+ LOG_DIR="${CWD}/tmp/${SESSION_ID}"
26
+ mkdir -p "$LOG_DIR"
27
+
28
+ SESSION_MD="$LOG_DIR/session.txt"
29
+ META_FILE="$LOG_DIR/.meta.json"
30
+
31
+ # ============================================================
32
+ # Helper: resolve transcript path
33
+ # ============================================================
34
+ resolve_transcript() {
35
+ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
36
+ echo "$TRANSCRIPT_PATH"
37
+ return
38
+ fi
39
+ local slug
40
+ slug=$(echo "$CWD" | sed 's|/|-|g; s|^-||')
41
+ local candidate="$HOME/.claude/projects/${slug}/${SESSION_ID}.jsonl"
42
+ if [ -f "$candidate" ]; then
43
+ echo "$candidate"
44
+ return
45
+ fi
46
+ candidate="$HOME/.claude/projects/-${slug}/${SESSION_ID}.jsonl"
47
+ if [ -f "$candidate" ]; then
48
+ echo "$candidate"
49
+ return
50
+ fi
51
+ echo ""
52
+ }
53
+
54
+ # ============================================================
55
+ # Helper: make path relative to project
56
+ # ============================================================
57
+ rel_path() {
58
+ echo "$1" | sed "s|${CWD}/||"
59
+ }
60
+
61
+ # ============================================================
62
+ # Generate per-turn files + session.md index from transcript
63
+ # ============================================================
64
+ generate_session_output() {
65
+ local transcript="$1"
66
+ local branch head_sha dirty_count
67
+ branch=$(cd "$CWD" 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
68
+ head_sha=$(cd "$CWD" 2>/dev/null && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
69
+ dirty_count=$(cd "$CWD" 2>/dev/null && git status --porcelain 2>/dev/null | wc -l | tr -d ' ' || echo "0")
70
+
71
+ # --- Read metadata if available ---
72
+ local started_at model start_sha
73
+ if [ -f "$META_FILE" ]; then
74
+ started_at=$(jq -r '.local_time // "unknown"' "$META_FILE")
75
+ model=$(jq -r '.model // "unknown"' "$META_FILE")
76
+ start_sha=$(jq -r '.head_sha // ""' "$META_FILE")
77
+ else
78
+ started_at="(resumed session)"
79
+ model="unknown"
80
+ start_sha=""
81
+ fi
82
+
83
+ if [ -z "$transcript" ] || [ ! -f "$transcript" ]; then
84
+ cat > "$SESSION_MD" << MDEOF
85
+ # Session Log: $(basename "$CWD")
86
+
87
+ | Field | Value |
88
+ |-------|-------|
89
+ | Session ID | \`${SESSION_ID}\` |
90
+ | Started | ${started_at} |
91
+ | Model | ${model} |
92
+ | Branch | \`${branch}\` @ \`${head_sha}\` |
93
+
94
+ ---
95
+
96
+ _No transcript found. Narrative extraction unavailable._
97
+ MDEOF
98
+ return
99
+ fi
100
+
101
+ # --- Generate per-turn files via python ---
102
+ # jq emits each content block as a separate chronological event.
103
+ # Python accumulates into turns and writes sequential timeline per turn.
104
+ jq -c '
105
+ if .type == "user" then
106
+ if (.message.content | type) == "string" then
107
+ {ev: "user_text", text: .message.content}
108
+ elif (.message.content | type) == "array" then
109
+ .message.content[]? |
110
+ if .type == "tool_result" then
111
+ {ev: "tool_result", id: .tool_use_id, content: ((.content // "") | tostring), is_error: (.is_error // false)}
112
+ else
113
+ empty
114
+ end
115
+ else
116
+ empty
117
+ end
118
+ elif .type == "assistant" then
119
+ .message.content[]? |
120
+ if .type == "text" then
121
+ {ev: "text", text: .text}
122
+ elif .type == "tool_use" then
123
+ {ev: "tool_use", name, id,
124
+ file: (.input.file_path // null),
125
+ command: (.input.command // null),
126
+ description: (.input.description // null),
127
+ pattern: (.input.pattern // null),
128
+ prompt: (.input.prompt // null),
129
+ subagent_type: (.input.subagent_type // null)}
130
+ else
131
+ empty
132
+ end
133
+ else
134
+ empty
135
+ end
136
+ ' "$transcript" 2>/dev/null > "$LOG_DIR/.events.jsonl"
137
+
138
+ # Write python script to temp file (can't pipe + heredoc simultaneously)
139
+ local pyscript
140
+ pyscript=$(mktemp "${TMPDIR:-/tmp}/session-log-XXXX.py")
141
+ trap "rm -f '$pyscript'" RETURN
142
+ cat > "$pyscript" << 'PYEOF'
143
+ import json, sys, os
144
+
145
+ log_dir = sys.argv[1]
146
+ cwd = sys.argv[2]
147
+ session_id = sys.argv[3]
148
+ started_at = sys.argv[4]
149
+ model = sys.argv[5]
150
+ branch = sys.argv[6]
151
+ head_sha = sys.argv[7]
152
+ dirty_count = sys.argv[8]
153
+ start_sha = sys.argv[9]
154
+
155
+ def rel(path):
156
+ if path and path.startswith(cwd + "/"):
157
+ return path[len(cwd) + 1:]
158
+ return path or ""
159
+
160
+ # ---- Accumulate turns as chronological event timelines ----
161
+ turns = []
162
+ # Each turn: {user, timeline: [{kind, ...}, ...], edits, reads, searches, commands}
163
+ current = {"user": None, "timeline": [], "edits": [], "reads": [], "searches": [], "commands": []}
164
+
165
+ def new_turn(user_text):
166
+ return {
167
+ "user": user_text if user_text else None,
168
+ "timeline": [], "edits": [], "reads": [], "searches": [], "commands": [],
169
+ }
170
+
171
+ # Track pending tool_use IDs to match with results
172
+ pending_tools = {} # id -> {name, ...}
173
+
174
+ NOISE_PREFIXES = ("<local-command", "<command-name", "<local-command-stdout",
175
+ "<local-command-caveat", "This session is being continued")
176
+
177
+ # Keywords that make a tool result "notable" (worth showing inline)
178
+ NOTABLE_KW = ["error", "fail", "refusal", "mismatch", "passed", "assert",
179
+ "traceback", "exception", "pytest", "PASSED", "FAILED", "TypedRefusal"]
180
+
181
+ for line in sys.stdin:
182
+ try:
183
+ entry = json.loads(line)
184
+ except json.JSONDecodeError:
185
+ continue
186
+
187
+ ev = entry.get("ev")
188
+
189
+ if ev == "user_text":
190
+ text = entry["text"]
191
+ if any(text.startswith(p) for p in NOISE_PREFIXES):
192
+ continue
193
+ if not text.strip():
194
+ continue
195
+ if current["user"] or current["timeline"]:
196
+ turns.append(current)
197
+ current = new_turn(text)
198
+
199
+ elif ev == "text":
200
+ text = entry.get("text", "")
201
+ if len(text) > 20:
202
+ current["timeline"].append({"kind": "reasoning", "text": text})
203
+
204
+ elif ev == "tool_use":
205
+ name = entry.get("name", "")
206
+ tid = entry.get("id", "")
207
+ tool_entry = {"kind": "tool_call", "name": name, "id": tid}
208
+
209
+ if name in ("Write", "Edit"):
210
+ f = rel(entry.get("file"))
211
+ tool_entry["file"] = f
212
+ if f and f not in current["edits"]:
213
+ current["edits"].append(f)
214
+ elif name == "Read":
215
+ f = rel(entry.get("file"))
216
+ tool_entry["file"] = f
217
+ if f and f not in current["reads"]:
218
+ current["reads"].append(f)
219
+ elif name in ("Grep", "Glob"):
220
+ pat = entry.get("pattern", "")
221
+ tool_entry["pattern"] = pat
222
+ if pat:
223
+ current["searches"].append(pat)
224
+ elif name == "Bash":
225
+ cmd = entry.get("command", "")
226
+ desc = entry.get("description", "")
227
+ tool_entry["command"] = cmd
228
+ tool_entry["description"] = desc or ""
229
+ if cmd:
230
+ current["commands"].append({"cmd": cmd, "desc": desc or ""})
231
+ elif name == "Task":
232
+ tool_entry["prompt"] = entry.get("prompt", "")
233
+ tool_entry["subagent_type"] = entry.get("subagent_type", "")
234
+
235
+ current["timeline"].append(tool_entry)
236
+ pending_tools[tid] = tool_entry
237
+
238
+ elif ev == "tool_result":
239
+ tid = entry.get("id", "")
240
+ content = entry.get("content", "")
241
+ is_error = entry.get("is_error", False)
242
+ tool_info = pending_tools.get(tid, {})
243
+ name = tool_info.get("name", "unknown")
244
+
245
+ # Decide if this result is notable enough to show inline
246
+ # Task results are always notable (subagent did substantive work)
247
+ notable = is_error or name == "Task"
248
+ if not notable and content:
249
+ content_lower = content.lower()
250
+ notable = any(kw.lower() in content_lower for kw in NOTABLE_KW)
251
+
252
+ if notable and content:
253
+ # Cap file-content tools (full file reads/writes blow out turn files)
254
+ display = content
255
+ if name in ("Read", "Write", "Edit") and len(content) > 2000:
256
+ display = content[:2000] + "\n...(file content truncated)"
257
+ # Graft result onto the original tool_call entry (not a separate timeline item)
258
+ if tool_info:
259
+ tool_info["output"] = display
260
+ tool_info["is_error"] = is_error
261
+ else:
262
+ # Orphan result (no matching call) — append standalone
263
+ current["timeline"].append({
264
+ "kind": "tool_output",
265
+ "name": name,
266
+ "content": display,
267
+ "is_error": is_error,
268
+ })
269
+
270
+ if current["user"] or current["timeline"]:
271
+ turns.append(current)
272
+
273
+ # ---- Write per-turn files ----
274
+ turn_index = []
275
+
276
+ for i, turn in enumerate(turns):
277
+ num = i + 1
278
+ padded = f"{num:03d}"
279
+
280
+ # --- Build per-turn markdown: chronological timeline ---
281
+ md_lines = [f"# Turn {num}", ""]
282
+
283
+ if turn["user"]:
284
+ md_lines.extend([f"> ---user---\n{turn['user']}\n---/user---", ""])
285
+
286
+ for event in turn["timeline"]:
287
+ kind = event["kind"]
288
+
289
+ if kind == "reasoning":
290
+ text = event["text"]
291
+ md_lines.append(text)
292
+ md_lines.extend(["", "---", ""])
293
+
294
+ elif kind == "tool_call":
295
+ name = event.get("name", "")
296
+ if name in ("Read", "Glob"):
297
+ f = event.get("file") or event.get("pattern", "")
298
+ md_lines.append(f"`{name}` {f}")
299
+ elif name in ("Write", "Edit"):
300
+ md_lines.append(f"`{name}` {event.get('file', '')}")
301
+ elif name == "Bash":
302
+ cmd = event.get("command", "")
303
+ desc = event.get("description", "")
304
+ header = f"`Bash` _{desc}_" if desc else "`Bash`"
305
+ if len(cmd) > 120:
306
+ md_lines.extend([header, "```", cmd, "```"])
307
+ else:
308
+ md_lines.append(f"{header} `{cmd}`" if cmd else header)
309
+ elif name in ("Grep",):
310
+ md_lines.append(f"`Grep` {event.get('pattern', '')}")
311
+ elif name == "Task":
312
+ sa = event.get("subagent_type", "subagent")
313
+ prompt = event.get("prompt", "")
314
+ header = f"`Task` ({sa})" if sa else "`Task` (subagent)"
315
+ if prompt:
316
+ # Show the dispatch prompt so readers know what the subagent was asked
317
+ short_prompt = prompt[:500]
318
+ if len(prompt) > 500:
319
+ short_prompt += "..."
320
+ md_lines.extend([header, "", f"> {short_prompt}", ""])
321
+ else:
322
+ md_lines.append(f"`{name}`")
323
+ md_lines.append("")
324
+
325
+ # If tool result was grafted onto this call, render it inline
326
+ if "output" in event:
327
+ output = event["output"]
328
+ is_error = event.get("is_error", False)
329
+ label = "error" if is_error else "output"
330
+ md_lines.extend([
331
+ f"**{name}** ({label}):",
332
+ "```",
333
+ output,
334
+ "```",
335
+ "",
336
+ ])
337
+
338
+ elif kind == "tool_output":
339
+ # Orphan result (no matching call found) — render standalone
340
+ content = event.get("content", "")
341
+ name = event.get("name", "")
342
+ is_error = event.get("is_error", False)
343
+ label = "error" if is_error else "output"
344
+ md_lines.extend([
345
+ f"**{name}** ({label}):",
346
+ "```",
347
+ content,
348
+ "```",
349
+ "",
350
+ ])
351
+
352
+ # Write turn markdown
353
+ with open(os.path.join(log_dir, f"turn-{padded}.txt"), "w") as f:
354
+ f.write("\n".join(md_lines))
355
+
356
+ # --- Build per-turn JSON: chronological timeline ---
357
+ tool_summary = {}
358
+ for event in turn["timeline"]:
359
+ if event["kind"] == "tool_call":
360
+ n = event.get("name", "")
361
+ tool_summary[n] = tool_summary.get(n, 0) + 1
362
+
363
+ def group_by_ext(paths):
364
+ groups = {}
365
+ for p in paths:
366
+ ext = os.path.splitext(p)[1] or "(no ext)"
367
+ groups.setdefault(ext, []).append(p)
368
+ return groups
369
+
370
+ turn_json = {
371
+ "turn": num,
372
+ "user": turn["user"],
373
+ "timeline": turn["timeline"],
374
+ "tool_summary": tool_summary,
375
+ "files_edited": group_by_ext(turn["edits"]),
376
+ "files_read": group_by_ext(turn["reads"]),
377
+ "searches": turn["searches"],
378
+ "commands": [c["cmd"] for c in turn["commands"]],
379
+ }
380
+
381
+ with open(os.path.join(log_dir, f"turn-{padded}.json"), "w") as f:
382
+ json.dump(turn_json, f, indent=2)
383
+
384
+ # Index entry
385
+ user_preview = (turn["user"] or "(no user message)")[:120]
386
+ reasoning_count = sum(1 for e in turn["timeline"] if e["kind"] == "reasoning")
387
+ tool_count = sum(1 for e in turn["timeline"] if e["kind"] == "tool_call")
388
+ turn_index.append({
389
+ "num": num,
390
+ "padded": padded,
391
+ "user_preview": user_preview,
392
+ "reasoning_count": reasoning_count,
393
+ "tool_count": tool_count,
394
+ "edits": turn["edits"],
395
+ })
396
+
397
+ # ---- Write session.md index ----
398
+ with open(os.path.join(log_dir, "session.txt"), "w") as f:
399
+ f.write(f"# Session Log: {os.path.basename(cwd)}\n\n")
400
+ f.write("| Field | Value |\n")
401
+ f.write("|-------|-------|\n")
402
+ f.write(f"| Session ID | `{session_id}` |\n")
403
+ f.write(f"| Started | {started_at} |\n")
404
+ f.write(f"| Model | {model} |\n")
405
+ f.write(f"| Branch | `{branch}` @ `{head_sha}` |\n")
406
+ f.write(f"| Turns | {len(turn_index)} |\n")
407
+ f.write("\n---\n\n")
408
+
409
+ f.write("## Turns\n\n")
410
+ for t in turn_index:
411
+ edits_str = ", ".join(f"`{e}`" for e in t["edits"][:3])
412
+ if len(t["edits"]) > 3:
413
+ edits_str += f" +{len(t['edits'])-3} more"
414
+ summary = f"{t['reasoning_count']} msgs, {t['tool_count']} tools"
415
+ if edits_str:
416
+ summary += f" | {edits_str}"
417
+ f.write(f"- **[Turn {t['num']}](turn-{t['padded']}.md)** — {t['user_preview']}\n")
418
+ f.write(f" _{summary}_\n")
419
+
420
+ f.write("\n---\n\n")
421
+
422
+ # Exploration summary (deduplicated across all turns)
423
+ all_reads = []
424
+ all_searches = []
425
+ all_edits = []
426
+ all_commands = []
427
+ for turn in turns:
428
+ all_reads.extend(turn["reads"])
429
+ all_searches.extend(turn["searches"])
430
+ all_edits.extend(turn["edits"])
431
+ all_commands.extend(turn["commands"])
432
+
433
+ f.write("## Exploration\n")
434
+ f.write("_Files read and searches performed (deduplicated)._\n\n")
435
+ for r in sorted(set(all_reads)):
436
+ f.write(f"- READ `{r}`\n")
437
+ for s in sorted(set(all_searches)):
438
+ f.write(f"- SEARCH `{s}`\n")
439
+ f.write("\n")
440
+
441
+ f.write("## Audit\n")
442
+ f.write("_Edits, commands, git activity._\n\n")
443
+ for e in sorted(set(all_edits)):
444
+ f.write(f"- EDIT `{e}`\n")
445
+ for cmd in all_commands:
446
+ short = cmd["cmd"][:120]
447
+ # Only log meaningful commands
448
+ meaningful = any(kw in short for kw in [
449
+ "pytest", "cargo test", "ruff", "mypy", "npm test",
450
+ "git log", "git diff", "git status", "git add", "git commit",
451
+ "git merge", "caws ", "pip install", "make", "cargo build"
452
+ ])
453
+ if meaningful:
454
+ if cmd["desc"]:
455
+ f.write(f"- BASH `{short}` — {cmd['desc']}\n")
456
+ else:
457
+ f.write(f"- BASH `{short}`\n")
458
+ f.write("\n")
459
+
460
+ f.write("## Session Snapshot\n\n")
461
+ f.write("| Field | Value |\n")
462
+ f.write("|-------|-------|\n")
463
+ f.write(f"| Branch | `{branch}` @ `{head_sha}` |\n")
464
+ f.write(f"| Dirty files | {dirty_count} |\n")
465
+ f.write(f"| Total turns | {len(turn_index)} |\n")
466
+
467
+ PYEOF
468
+
469
+ # Run the python script with events as input
470
+ python3 "$pyscript" "$LOG_DIR" "$CWD" "$SESSION_ID" "$started_at" "$model" "$branch" "$head_sha" "$dirty_count" "$start_sha" < "$LOG_DIR/.events.jsonl"
471
+ rm -f "$LOG_DIR/.events.jsonl"
472
+ }
473
+
474
+ # ============================================================
475
+ # EVENT: SessionStart — save metadata
476
+ # ============================================================
477
+ handle_session_start() {
478
+ local model source branch head_sha dirty_count full_time
479
+ model=$(echo "$INPUT" | jq -r '.model // "unknown"')
480
+ source=$(echo "$INPUT" | jq -r '.source // "unknown"')
481
+ branch=$(cd "$CWD" 2>/dev/null && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
482
+ head_sha=$(cd "$CWD" 2>/dev/null && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
483
+ dirty_count=$(cd "$CWD" 2>/dev/null && git status --porcelain 2>/dev/null | wc -l | tr -d ' ' || echo "0")
484
+ full_time=$(date +"%Y-%m-%d %H:%M:%S %Z")
485
+
486
+ jq -cn \
487
+ --arg sid "$SESSION_ID" \
488
+ --arg ts "$TIMESTAMP" \
489
+ --arg lt "$full_time" \
490
+ --arg model "$model" \
491
+ --arg source "$source" \
492
+ --arg branch "$branch" \
493
+ --arg head "$head_sha" \
494
+ --arg dirty "$dirty_count" \
495
+ --arg project "$(basename "$CWD")" \
496
+ --arg transcript "$TRANSCRIPT_PATH" \
497
+ '{session_id: $sid, started_at: $ts, local_time: $lt, model: $model, source: $source, branch: $branch, head_sha: $head, dirty_files: $dirty, project: $project, transcript_path: $transcript}' \
498
+ > "$META_FILE"
499
+
500
+ # Generate initial output (may be empty if transcript not ready)
501
+ generate_session_output "$(resolve_transcript)"
502
+ }
503
+
504
+ # ============================================================
505
+ # EVENT: Stop — regenerate from transcript
506
+ # ============================================================
507
+ handle_stop() {
508
+ generate_session_output "$(resolve_transcript)"
509
+ }
510
+
511
+ # ============================================================
512
+ # EVENT: PreCompact — safety net before context eviction
513
+ # ============================================================
514
+ handle_pre_compact() {
515
+ generate_session_output "$(resolve_transcript)"
516
+ }
517
+
518
+ # ============================================================
519
+ # DISPATCH
520
+ # ============================================================
521
+ case "$HOOK_EVENT" in
522
+ SessionStart) handle_session_start ;;
523
+ Stop) handle_stop ;;
524
+ PreCompact) handle_pre_compact ;;
525
+ *) ;; # Other events: no-op
526
+ esac
527
+
528
+ exit 0
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ # CAWS Worktree Cleanup Reminder for Claude Code
3
+ # Warns at session end if active worktrees remain
4
+ # @author @darianrosebrook
5
+
6
+ set -euo pipefail
7
+
8
+ # Read JSON input from Claude Code (required by hook protocol)
9
+ INPUT=$(cat)
10
+
11
+ # Resolve main repo root
12
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
13
+ if command -v git >/dev/null 2>&1; then
14
+ GIT_COMMON_DIR=$(cd "$PROJECT_DIR" && git rev-parse --git-common-dir 2>/dev/null || echo "")
15
+ if [[ -n "$GIT_COMMON_DIR" ]] && [[ "$GIT_COMMON_DIR" != ".git" ]]; then
16
+ CANDIDATE=$(cd "$PROJECT_DIR" && cd "$GIT_COMMON_DIR/.." 2>/dev/null && pwd || echo "")
17
+ if [[ -n "$CANDIDATE" ]] && [[ -d "$CANDIDATE/.caws" ]]; then
18
+ PROJECT_DIR="$CANDIDATE"
19
+ fi
20
+ fi
21
+ fi
22
+
23
+ # Check for active worktrees
24
+ if [[ -f "$PROJECT_DIR/.caws/worktrees.json" ]] && command -v node >/dev/null 2>&1; then
25
+ ACTIVE_INFO=$(node -e "
26
+ try {
27
+ var reg = JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.caws/worktrees.json', 'utf8'));
28
+ var active = Object.values(reg.worktrees || {}).filter(function(w) { return w.status === 'active'; });
29
+ if (active.length > 0) {
30
+ console.log(active.length + ':' + active.map(function(w) { return w.name; }).join(', '));
31
+ } else {
32
+ console.log('0:');
33
+ }
34
+ } catch(e) { console.log('0:'); }
35
+ " 2>/dev/null || echo "0:")
36
+
37
+ COUNT=$(echo "$ACTIVE_INFO" | cut -d: -f1)
38
+ NAMES=$(echo "$ACTIVE_INFO" | cut -d: -f2)
39
+
40
+ if [[ "$COUNT" -gt 0 ]] 2>/dev/null; then
41
+ echo "REMINDER: $COUNT active worktree(s) remain: $NAMES. Other agents cannot commit to the base branch until all worktrees are destroyed. If your work is complete, run: caws worktree destroy <name> --delete-branch" >&2
42
+ exit 0
43
+ fi
44
+ fi
45
+
46
+ exit 0