@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.
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/parallel.d.ts +7 -0
- package/dist/commands/parallel.d.ts.map +1 -0
- package/dist/commands/parallel.js +238 -0
- package/dist/commands/session.d.ts +7 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/specs.d.ts +6 -0
- package/dist/commands/specs.d.ts.map +1 -1
- package/dist/commands/specs.js +55 -2
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +13 -3
- package/dist/commands/tutorial.js +0 -2
- package/dist/commands/waivers.d.ts.map +1 -1
- package/dist/constants/spec-types.d.ts +52 -0
- package/dist/constants/spec-types.d.ts.map +1 -1
- package/dist/constants/spec-types.js +25 -2
- package/dist/index.js +43 -2
- package/dist/parallel/parallel-manager.d.ts +67 -0
- package/dist/parallel/parallel-manager.d.ts.map +1 -0
- package/dist/parallel/parallel-manager.js +440 -0
- package/dist/scaffold/claude-hooks.d.ts.map +1 -1
- package/dist/scaffold/claude-hooks.js +78 -2
- package/dist/scaffold/git-hooks.d.ts.map +1 -1
- package/dist/scaffold/git-hooks.js +237 -73
- package/dist/scaffold/index.d.ts.map +1 -1
- package/dist/session/session-manager.d.ts +94 -0
- package/dist/session/session-manager.d.ts.map +1 -0
- package/dist/session/session-manager.js +14 -0
- package/dist/templates/.claude/hooks/scope-guard.sh +67 -21
- package/dist/templates/.claude/hooks/session-caws-status.sh +117 -0
- package/dist/templates/.claude/hooks/session-log.sh +528 -0
- package/dist/templates/.claude/hooks/stop-worktree-check.sh +46 -0
- package/dist/templates/.claude/hooks/worktree-guard.sh +207 -0
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +84 -0
- package/dist/templates/.claude/rules/git-safety.md +26 -0
- package/dist/templates/.claude/rules/worktree-isolation.md +51 -0
- package/dist/templates/.claude/settings.json +15 -0
- package/dist/utils/gitignore-updater.d.ts +1 -1
- package/dist/utils/gitignore-updater.d.ts.map +1 -1
- package/dist/utils/gitignore-updater.js +3 -0
- package/dist/utils/ide-detection.d.ts +89 -0
- package/dist/utils/ide-detection.d.ts.map +1 -0
- package/dist/validation/spec-validation.d.ts.map +1 -1
- package/dist/validation/spec-validation.js +16 -0
- package/dist/worktree/worktree-manager.d.ts.map +1 -1
- package/dist/worktree/worktree-manager.js +31 -21
- package/package.json +2 -2
- package/templates/.claude/hooks/scope-guard.sh +67 -21
- package/templates/.claude/hooks/session-caws-status.sh +117 -0
- package/templates/.claude/hooks/session-log.sh +528 -0
- package/templates/.claude/hooks/stop-worktree-check.sh +46 -0
- package/templates/.claude/hooks/worktree-guard.sh +207 -0
- package/templates/.claude/hooks/worktree-write-guard.sh +84 -0
- package/templates/.claude/rules/git-safety.md +26 -0
- package/templates/.claude/rules/worktree-isolation.md +51 -0
- 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
|