uv-suite 0.27.0 → 0.29.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.
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # UV Suite Hook: SessionStart — when UVS_RESTORE_FROM is set, inject a
3
+ # system-context instruction telling Claude to /restore that session as
4
+ # the first action of the new session.
5
+ #
6
+ # This is how the Watchtower's "Open in new terminal" restore button hooks
7
+ # back into the new uvs session — the env var is set by the spawned terminal
8
+ # command, propagates through uvs/claude, and lands here.
9
+
10
+ if [ -z "$UVS_RESTORE_FROM" ]; then
11
+ exit 0
12
+ fi
13
+
14
+ # Sanity check — sid must look like a UUID prefix or our ad-hoc-<ts>
15
+ case "$UVS_RESTORE_FROM" in
16
+ [a-zA-Z0-9-]*) : ;;
17
+ *)
18
+ echo "[auto-restore] ignoring malformed UVS_RESTORE_FROM" >&2
19
+ exit 0
20
+ ;;
21
+ esac
22
+
23
+ MSG="[uv-suite auto-restore] This session was opened via the Watchtower restore flow. Your FIRST action must be to run \`/restore $UVS_RESTORE_FROM\` to load the prior session's latest checkpoint. After /restore completes, summarize what you picked up in 1-2 sentences, then wait for the user's next instruction."
24
+
25
+ if command -v jq >/dev/null 2>&1; then
26
+ jq -nc --arg ctx "$MSG" '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$ctx}}'
27
+ else
28
+ ESCAPED=$(printf '%s' "$MSG" | sed 's/\\/\\\\/g; s/"/\\"/g')
29
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}' "$ESCAPED"
30
+ fi
@@ -0,0 +1,56 @@
1
+ #!/bin/bash
2
+ # UV Suite helper: explicit session termination.
3
+ # Used by the /session-end slash command.
4
+ #
5
+ # Writes a "terminated_at" timestamp to the session metadata, fires a
6
+ # SessionEnd event to the Watchtower so the dashboard updates the status
7
+ # badge, and prints a one-line confirmation.
8
+
9
+ STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
10
+ SESSIONS_DIR="$STATE_DIR/sessions"
11
+ mkdir -p "$SESSIONS_DIR"
12
+
13
+ SID="${UVS_SESSION_ID:-}"
14
+ if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
15
+ SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
16
+ fi
17
+
18
+ if [ -z "$SID" ]; then
19
+ echo "No active UV Suite session to end."
20
+ exit 1
21
+ fi
22
+
23
+ META="$SESSIONS_DIR/$SID.json"
24
+ NOW=$(date +%s)
25
+ NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
26
+
27
+ if [ -f "$META" ]; then
28
+ META_PATH="$META" NOW_VAL="$NOW" NOW_ISO_VAL="$NOW_ISO" python3 - <<'PY'
29
+ import json, os
30
+ p = os.environ["META_PATH"]
31
+ d = json.load(open(p))
32
+ d["terminated_at"] = int(os.environ["NOW_VAL"])
33
+ d["terminated_at_iso"] = os.environ["NOW_ISO_VAL"]
34
+ d["lifecycle"] = "terminated"
35
+ json.dump(d, open(p, "w"), indent=2)
36
+ PY
37
+ fi
38
+
39
+ # Fire SessionEnd event so the dashboard flips the status badge to Terminated.
40
+ if [ -x "$CLAUDE_PROJECT_DIR/.claude/hooks/watchtower-send.sh" ]; then
41
+ PAYLOAD=$(SID_VAL="$SID" NOW_VAL="$NOW" CWD_VAL="${CLAUDE_PROJECT_DIR:-$(pwd)}" python3 -c '
42
+ import json, os
43
+ print(json.dumps({
44
+ "uvs_session_id": os.environ["SID_VAL"],
45
+ "session_id": os.environ["SID_VAL"],
46
+ "cwd": os.environ["CWD_VAL"],
47
+ "lifecycle": "terminated",
48
+ "terminated_at": int(os.environ["NOW_VAL"]),
49
+ "terminated_by": "user",
50
+ }))
51
+ ')
52
+ printf '%s' "$PAYLOAD" | "$CLAUDE_PROJECT_DIR/.claude/hooks/watchtower-send.sh" SessionEnd 2>/dev/null
53
+ fi
54
+
55
+ echo "Session ${SID:0:8} marked terminated at $NOW_ISO."
56
+ echo "Run /checkpoint first if you want a final state snapshot, then exit the terminal."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
5
5
  "author": "Utsav Anand",
6
6
  "license": "MIT",
@@ -70,6 +70,11 @@
70
70
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
71
71
  "timeout": 2,
72
72
  "async": true
73
+ },
74
+ {
75
+ "type": "command",
76
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
77
+ "timeout": 3
73
78
  }
74
79
  ]
75
80
  }
@@ -70,6 +70,11 @@
70
70
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
71
71
  "timeout": 2,
72
72
  "async": true
73
+ },
74
+ {
75
+ "type": "command",
76
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
77
+ "timeout": 3
73
78
  }
74
79
  ]
75
80
  }
@@ -63,6 +63,11 @@
63
63
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
64
64
  "timeout": 2,
65
65
  "async": true
66
+ },
67
+ {
68
+ "type": "command",
69
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
70
+ "timeout": 3
66
71
  }
67
72
  ]
68
73
  }
@@ -52,6 +52,11 @@
52
52
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
53
53
  "timeout": 2,
54
54
  "async": true
55
+ },
56
+ {
57
+ "type": "command",
58
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
59
+ "timeout": 3
55
60
  }
56
61
  ]
57
62
  }
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: session-end
3
+ description: >
4
+ Cleanly close the current UV Suite session: write a final manual checkpoint
5
+ (full conversation context), then mark the session as terminated so the
6
+ Watchtower dashboard flips its lifecycle badge to Terminated. Use before
7
+ closing the terminal when you want a deliberate end-of-session beat.
8
+ argument-hint: "[optional-label]"
9
+ user-invocable: true
10
+ allowed-tools:
11
+ - Write(*)
12
+ - Read(*)
13
+ - Bash(git status *)
14
+ - Bash(git diff *)
15
+ - Bash(git log *)
16
+ - Bash(git branch *)
17
+ - Bash(git rev-parse *)
18
+ - Bash(date *)
19
+ - Bash(ls *)
20
+ - Bash(mkdir *)
21
+ - Bash(cat *)
22
+ - Bash(echo *)
23
+ - Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh *)
24
+ - Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/session-end-helper.sh *)
25
+ ---
26
+
27
+ ## Resolve checkpoint directory + session metadata
28
+
29
+ !`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh dir`
30
+
31
+ !`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh frontmatter`
32
+
33
+ ## Git state
34
+
35
+ !`git branch --show-current 2>/dev/null || echo "(not a git repo)"`
36
+
37
+ !`git status --short 2>/dev/null | head -20 || echo "(no git)"`
38
+
39
+ !`git log --oneline -5 2>/dev/null || echo "(no git history)"`
40
+
41
+ ## Step 1 — Write the final checkpoint
42
+
43
+ Write a file at `<checkpoint-dir>/final-YYYY-MM-DD-HHMM.md` using the current
44
+ timestamp. **Override** the frontmatter line `checkpoint_kind: auto-mechanical`
45
+ to read `checkpoint_kind: final-manual` so it's distinguishable from the
46
+ auto-checkpoints. Also write/overwrite `<checkpoint-dir>/latest.md` with the
47
+ same content so `/restore` picks it up.
48
+
49
+ If a label was provided, include it in the filename:
50
+ `<checkpoint-dir>/final-YYYY-MM-DD-HHMM-[label].md`
51
+
52
+ ### Label
53
+
54
+ $ARGUMENTS
55
+
56
+ ### Body structure (after the frontmatter)
57
+
58
+ ```markdown
59
+ # Final checkpoint: [date] [time] [label if provided]
60
+
61
+ ## What was accomplished
62
+ - Concrete things done across the whole session — files, commits, decisions
63
+ - One bullet per significant artifact; don't pad
64
+
65
+ ## Key decisions made
66
+ - Decision: Why — what was considered, what was rejected
67
+
68
+ ## Current state
69
+ - Branch / uncommitted changes / tests status / blockers
70
+
71
+ ## Open threads
72
+ - Anything left in flight that the next session needs to pick up
73
+ - Pending reviews, awaiting input, deferred work
74
+
75
+ ## Context the next session needs
76
+ - Non-obvious facts, workarounds, "this looks wrong but it's intentional because…"
77
+ ```
78
+
79
+ Be specific. This is the last record before the session closes — anything
80
+ not written here is lost unless it's in code or the auto-checkpoints.
81
+
82
+ ## Step 2 — Mark the session terminated
83
+
84
+ Once the file is written, run:
85
+
86
+ !`"$CLAUDE_PROJECT_DIR"/.claude/hooks/session-end-helper.sh`
87
+
88
+ Show the user the two-line output. Don't add commentary — the file write
89
+ and the termination signal together speak for themselves.
90
+
91
+ ## Notes
92
+
93
+ - `/session-end` does **not** close the terminal or exit Claude Code. After
94
+ it runs, the badge flips to Terminated in the Watchtower; the user
95
+ closes the terminal manually.
96
+ - The final checkpoint uses the live session's full conversation context —
97
+ higher fidelity than the auto-checkpoint summaries because Claude has
98
+ everything in working memory at this moment.
99
+ - If you only want to terminate without a checkpoint, run
100
+ `hooks/session-end-helper.sh` directly from bash and skip this skill.
@@ -1,6 +1,6 @@
1
- You are writing an auto-checkpoint for a UV Suite coding session. Be specific
2
- and tight this is a state snapshot, not a narrative. Don't speculate beyond
3
- what the events show.
1
+ You are writing a one-paragraph summary for the UV Suite auto-checkpoint
2
+ system. The actual conversation transcript is provided below base the
3
+ summary on what was actually said and done, not on inference.
4
4
 
5
5
  ## Session
6
6
 
@@ -11,41 +11,32 @@ what the events show.
11
11
  - purpose: {{purpose}}
12
12
  - elapsed since last semantic checkpoint: {{elapsed_min}} min
13
13
 
14
- ## Activity (last {{interval_min}} min, from the dashboard)
14
+ ## Conversation (verbatim, last {{interval_min}} min)
15
15
 
16
- {{event_list}}
16
+ {{conversation}}
17
17
 
18
- ## Git
19
-
20
- ```
21
- {{git_branch}}
22
- {{git_status}}
23
- {{git_log}}
24
- ```
18
+ ## Mechanical (from the dashboard event log)
25
19
 
26
- ## Write the checkpoint
20
+ {{mechanical}}
27
21
 
28
- Output **only** the markdown body below — no preamble, no closing remarks.
29
- Use this exact shape, max 30 lines total:
30
-
31
- ```markdown
32
- # Auto-checkpoint: {{timestamp}}
22
+ ## Git
33
23
 
34
- ## Done in the last {{interval_min}} min
35
- - 2-4 bullets — concrete: file edited, command run, decision visible in events.
36
- Skip vague verbs ("worked on"); name the artifact and the change.
24
+ Branch: {{git_branch}}
25
+ Status: {{git_status}}
26
+ Recent commits: {{git_log}}
37
27
 
38
- ## Files touched
39
- - list (omit section if empty)
28
+ ## Write the summary
40
29
 
41
- ## In progress
42
- - 1-2 bullets what the session appears to be working on right now, based
43
- on the latest events.
30
+ Output **only** a single paragraph, 3-6 sentences, no headers, no bullets.
31
+ The paragraph should answer, in order:
44
32
 
45
- ## Notable
46
- - only include this section if something stands out (a failure, a long pause
47
- followed by a burst, a clear pattern shift). Otherwise omit the section.
48
- ```
33
+ 1. What was the user trying to do this window?
34
+ 2. What did Claude actually do concrete files / commands / decisions?
35
+ 3. Where does the session stand right now (in progress / blocked / awaiting input)?
49
36
 
50
- If the activity log is sparse or ambiguous, say so plainly in "In progress"
51
- rather than inventing details.
37
+ Rules:
38
+ - Use the user's own words for the topic when possible ("user asked about X")
39
+ - Name the artifacts (file paths, command names, function names) — no vague verbs
40
+ - If the conversation is sparse, say "Quiet window — only X tool calls, no
41
+ substantive exchange" and stop. Don't invent activity.
42
+ - Don't restate the session metadata; it's already in the frontmatter.
@@ -1,17 +1,30 @@
1
1
  // UV Suite — Tier B auto-checkpoint runner.
2
2
  // Called from watchtower/server.js on a setInterval. For each active session
3
- // (one with at least one event in the last interval), shells out to
4
- // `claude -p --bare --model haiku` to write a semantic summary.
3
+ // whose interval has elapsed, reads the Claude Code transcript JSONL at
4
+ // ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl, extracts the
5
+ // conversation in the window, and writes a self-contained checkpoint:
6
+ //
7
+ // ## Summary — one paragraph from `claude -p --bare --model haiku`
8
+ // using the transcript as input
9
+ // ## Conversation — raw extract: user prompts verbatim + assistant
10
+ // response openings (~250 chars each) + tool calls
11
+ // ## Mechanical — git state + tool counts + files touched
12
+ //
13
+ // The transcript is copied into our checkpoint, so the file stands alone
14
+ // even if Claude Code later deletes its source JSONL.
5
15
 
6
16
  const fs = require("fs");
7
17
  const path = require("path");
18
+ const os = require("os");
8
19
  const { spawn } = require("child_process");
9
20
 
10
21
  const PROMPT_TEMPLATE_PATH = path.join(__dirname, "auto-checkpoint-prompt.md");
11
22
  const DEFAULT_INTERVAL_MIN = 10;
12
- const POLL_INTERVAL_MS = 60 * 1000; // wake up every 60s; per-session cadence is honored individually
23
+ const POLL_INTERVAL_MS = 60 * 1000;
13
24
  const MAX_BUDGET_USD = "0.05";
14
25
  const MODEL = "haiku";
26
+ const MAX_ASSISTANT_PREVIEW_CHARS = 250;
27
+ const MAX_CONVERSATION_LINES = 200;
15
28
 
16
29
  function readJsonSafe(p) {
17
30
  try {
@@ -64,6 +77,136 @@ function groupActiveSessions(events, windowMs) {
64
77
  return [...bySession.values()];
65
78
  }
66
79
 
80
+ // Claude Code stores transcripts at ~/.claude/projects/<encoded>/<sid>.jsonl
81
+ // where <encoded> is the project path with "/" replaced by "-".
82
+ function transcriptPathFor(cwd, ccSessionId) {
83
+ if (!ccSessionId) return null;
84
+ const encoded = cwd.replace(/\//g, "-");
85
+ return path.join(
86
+ os.homedir(),
87
+ ".claude",
88
+ "projects",
89
+ encoded,
90
+ `${ccSessionId}.jsonl`,
91
+ );
92
+ }
93
+
94
+ // Defensive parser: Claude Code's JSONL format is internal and may change.
95
+ // We pull out user prompts, assistant responses, and tool calls — skipping
96
+ // anything we can't interpret rather than blowing up.
97
+ function extractTextFromContent(content) {
98
+ if (typeof content === "string") return content;
99
+ if (Array.isArray(content)) {
100
+ return content
101
+ .map((block) => {
102
+ if (typeof block === "string") return block;
103
+ if (block && block.type === "text" && typeof block.text === "string")
104
+ return block.text;
105
+ return "";
106
+ })
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ }
110
+ return "";
111
+ }
112
+
113
+ // Read transcript messages whose timestamp falls in [sinceMs, +inf).
114
+ // Returns a flat array of { role, text, ts, tool? } records, oldest first.
115
+ function readTranscriptMessages(transcriptPath, sinceMs) {
116
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
117
+ if (transcriptPath) {
118
+ console.warn(`[auto-checkpoint] transcript not found: ${transcriptPath}`);
119
+ }
120
+ return null;
121
+ }
122
+ let raw;
123
+ try {
124
+ raw = fs.readFileSync(transcriptPath, "utf-8");
125
+ } catch (err) {
126
+ console.warn(`[auto-checkpoint] failed to read transcript: ${err.message}`);
127
+ return null;
128
+ }
129
+ const out = [];
130
+ let totalLines = 0;
131
+ let parseFailures = 0;
132
+ let recognizedShapes = 0;
133
+ for (const line of raw.split("\n")) {
134
+ if (!line.trim()) continue;
135
+ totalLines++;
136
+ let msg;
137
+ try {
138
+ msg = JSON.parse(line);
139
+ } catch {
140
+ parseFailures++;
141
+ continue;
142
+ }
143
+ const tsStr = msg.timestamp || msg.ts || msg.createdAt || msg.created_at;
144
+ const ts = tsStr ? Date.parse(tsStr) : NaN;
145
+ if (Number.isFinite(ts) && ts < sinceMs) continue;
146
+
147
+ // Tolerate several shapes Claude Code has used:
148
+ // { type: "user"|"assistant", message: { role, content } }
149
+ // { role, content }
150
+ // { sender: "user"|"assistant", text } (older)
151
+ // { event: "message", role, content } (variant)
152
+ const role =
153
+ msg.role ||
154
+ msg.message?.role ||
155
+ msg.sender ||
156
+ (msg.type === "user" || msg.type === "assistant" ? msg.type : null);
157
+ const content = msg.message?.content ?? msg.content ?? msg.text ?? msg.body;
158
+ const text = extractTextFromContent(content).trim();
159
+
160
+ if (role === "user" && text) {
161
+ out.push({ role: "user", text, ts });
162
+ recognizedShapes++;
163
+ } else if (role === "assistant" && text) {
164
+ out.push({ role: "assistant", text, ts });
165
+ recognizedShapes++;
166
+ }
167
+ }
168
+ // Surface format drift loudly: if the file has content but nothing parsed
169
+ // as a recognizable message, the Claude Code schema has probably changed.
170
+ if (totalLines > 0 && recognizedShapes === 0) {
171
+ console.warn(
172
+ `[auto-checkpoint] transcript at ${transcriptPath}: ${totalLines} lines, ` +
173
+ `${parseFailures} JSON-parse failures, 0 recognized user/assistant messages. ` +
174
+ `Claude Code's transcript format may have changed — please file an issue.`,
175
+ );
176
+ }
177
+ return out;
178
+ }
179
+
180
+ // Build the ## Conversation extract markdown block. Trims long assistant
181
+ // turns; caps total lines.
182
+ function buildConversationExtract(messages) {
183
+ if (!messages || messages.length === 0) return "";
184
+ const lines = [];
185
+ for (const m of messages) {
186
+ if (m.role === "user") {
187
+ lines.push(`**You:**`);
188
+ for (const ln of m.text.split("\n")) lines.push(`> ${ln}`);
189
+ } else {
190
+ let preview = m.text;
191
+ if (preview.length > MAX_ASSISTANT_PREVIEW_CHARS) {
192
+ preview =
193
+ preview.slice(0, MAX_ASSISTANT_PREVIEW_CHARS).trimEnd() + " …";
194
+ }
195
+ lines.push(`**Claude:** ${preview.replace(/\n+/g, " ")}`);
196
+ }
197
+ lines.push("");
198
+ }
199
+ if (lines.length > MAX_CONVERSATION_LINES) {
200
+ const trimmed = lines.slice(-MAX_CONVERSATION_LINES);
201
+ trimmed.unshift(
202
+ `_(earlier turns truncated; showing last ${MAX_CONVERSATION_LINES} lines)_`,
203
+ "",
204
+ );
205
+ return trimmed.join("\n");
206
+ }
207
+ return lines.join("\n");
208
+ }
209
+
67
210
  function eventToCompactLine(ev) {
68
211
  const t = ev.event_type || ev.hook_event_name || "?";
69
212
  const tool = ev.tool_name || "";
@@ -170,41 +313,94 @@ async function processSession(session, broadcast) {
170
313
  .sort((a, b) => (a._ts || 0) - (b._ts || 0));
171
314
  if (recent.length === 0) return;
172
315
 
173
- const template = loadPromptTemplate();
174
- if (!template) {
175
- console.warn("[auto-checkpoint] prompt template missing; skipping");
176
- return;
316
+ // Find the Claude Code session id from the most recent event (it differs
317
+ // from uvs_session_id) and read the conversation transcript.
318
+ const ccSessionId =
319
+ [...recent].reverse().find((e) => e.session_id)?.session_id || null;
320
+ const transcriptPath = transcriptPathFor(cwd, ccSessionId);
321
+ const sinceMs = lastTs > 0 ? lastTs * 1000 : now - intervalMs;
322
+ const transcriptMessages = readTranscriptMessages(transcriptPath, sinceMs);
323
+
324
+ const conversationExtract =
325
+ buildConversationExtract(transcriptMessages) ||
326
+ "_(no transcript content found; only mechanical activity captured below)_";
327
+
328
+ // Mechanical breakdown — tool counts and files touched.
329
+ const toolCounts = {};
330
+ const fileCounts = {};
331
+ for (const e of recent) {
332
+ const t = e.tool_name;
333
+ if (t) toolCounts[t] = (toolCounts[t] || 0) + 1;
334
+ const fp = e.tool_input?.file_path;
335
+ if (fp && (t === "Edit" || t === "Write" || t === "Read")) {
336
+ fileCounts[fp] = (fileCounts[fp] || 0) + 1;
337
+ }
338
+ }
339
+ const mechanicalLines = [];
340
+ mechanicalLines.push("### Tool calls");
341
+ Object.entries(toolCounts)
342
+ .sort((a, b) => b[1] - a[1])
343
+ .slice(0, 8)
344
+ .forEach(([t, n]) => mechanicalLines.push(`- ${n}× ${t}`));
345
+ if (Object.keys(fileCounts).length) {
346
+ mechanicalLines.push("", "### Files touched");
347
+ Object.entries(fileCounts)
348
+ .sort((a, b) => b[1] - a[1])
349
+ .slice(0, 8)
350
+ .forEach(([f, n]) => mechanicalLines.push(`- ${f} (${n})`));
177
351
  }
352
+ const mechanicalBlock = mechanicalLines.join("\n");
178
353
 
179
354
  const git = await gitState(cwd);
180
- const eventList = recent.slice(-40).map(eventToCompactLine).join("\n");
181
- const elapsedMin =
182
- lastTs === 0
183
- ? state.interval_minutes
184
- : Math.round((now - lastTs * 1000) / 60000);
185
-
186
- const prompt = buildPrompt(template, {
187
- name: session.session_name || "(unset)",
188
- kind: session.session_kind || "(unset)",
189
- priority: session.session_priority || "(unset)",
190
- persona: session.persona || "(unset)",
191
- purpose: session.session_purpose || "(unset)",
192
- elapsed_min: String(elapsedMin),
193
- interval_min: String(state.interval_minutes),
194
- event_list: eventList,
195
- git_branch: git.branch ? `Branch: ${git.branch}` : "(not a git repo)",
196
- git_status: git.status || "(no changes)",
197
- git_log: git.log || "",
198
- timestamp: new Date(now).toISOString(),
199
- });
355
+ const gitBlock = [
356
+ git.branch ? `**Branch:** ${git.branch}` : "_(not a git repo)_",
357
+ git.status
358
+ ? "**Status:**\n```\n" + git.status + "\n```"
359
+ : "**Status:** clean",
360
+ git.log ? "**Recent commits:**\n```\n" + git.log + "\n```" : "",
361
+ ]
362
+ .filter(Boolean)
363
+ .join("\n\n");
200
364
 
201
- const result = await runClaudeP(prompt);
202
- if (!result.ok || !result.stdout.trim()) {
203
- console.warn(
204
- `[auto-checkpoint] claude -p failed for ${sid.slice(0, 8)}:`,
205
- result.error || result.stderr?.slice(0, 200) || `exit ${result.code}`,
206
- );
207
- return;
365
+ // Summary via claude -p, fed the actual conversation extract instead of
366
+ // just the event log. Falls back to a one-line stub if the call fails or
367
+ // the transcript is empty.
368
+ let summary = "";
369
+ const template = loadPromptTemplate();
370
+ if (template && transcriptMessages && transcriptMessages.length > 0) {
371
+ const elapsedMin =
372
+ lastTs === 0
373
+ ? state.interval_minutes
374
+ : Math.round((now - lastTs * 1000) / 60000);
375
+ const prompt = buildPrompt(template, {
376
+ name: session.session_name || "(unset)",
377
+ kind: session.session_kind || "(unset)",
378
+ priority: session.session_priority || "(unset)",
379
+ persona: session.persona || "(unset)",
380
+ purpose: session.session_purpose || "(unset)",
381
+ elapsed_min: String(elapsedMin),
382
+ interval_min: String(state.interval_minutes),
383
+ conversation: conversationExtract,
384
+ mechanical: mechanicalBlock,
385
+ git_branch: git.branch || "(not a git repo)",
386
+ git_status: git.status || "(no changes)",
387
+ git_log: git.log || "",
388
+ timestamp: new Date(now).toISOString(),
389
+ });
390
+ const result = await runClaudeP(prompt);
391
+ if (result.ok && result.stdout.trim()) {
392
+ summary = result.stdout.trim();
393
+ } else {
394
+ console.warn(
395
+ `[auto-checkpoint] summary call failed for ${sid.slice(0, 8)}:`,
396
+ result.error || result.stderr?.slice(0, 200) || `exit ${result.code}`,
397
+ );
398
+ }
399
+ }
400
+ if (!summary) {
401
+ summary = transcriptMessages
402
+ ? "_(summary generation failed; raw conversation below)_"
403
+ : "_(no conversation transcript available; only mechanical activity below)_";
208
404
  }
209
405
 
210
406
  // Write the checkpoint file
@@ -227,15 +423,37 @@ async function processSession(session, broadcast) {
227
423
  `persona: ${session.persona || ""}`,
228
424
  `checkpoint_at: ${new Date(now).toISOString()}`,
229
425
  `checkpoint_kind: auto-semantic`,
426
+ `transcript_messages: ${transcriptMessages ? transcriptMessages.length : 0}`,
427
+ `tool_calls_in_window: ${recent.length}`,
230
428
  "---",
231
429
  "",
232
430
  ].join("\n");
233
431
 
234
- fs.writeFileSync(cpFile, frontmatter + result.stdout.trim() + "\n");
432
+ const body = [
433
+ `# Auto-checkpoint (semantic): ${new Date(now).toISOString()}`,
434
+ "",
435
+ "## Summary",
436
+ "",
437
+ summary,
438
+ "",
439
+ "## Conversation",
440
+ "",
441
+ conversationExtract,
442
+ "",
443
+ "## Mechanical",
444
+ "",
445
+ mechanicalBlock,
446
+ "",
447
+ "## Git",
448
+ "",
449
+ gitBlock,
450
+ "",
451
+ ].join("\n");
452
+
453
+ fs.writeFileSync(cpFile, frontmatter + body);
235
454
  fs.writeFileSync(lastFile, String(Math.floor(now / 1000)));
236
455
 
237
- // Broadcast as AutoCheckpoint event
238
- const preview = (frontmatter + result.stdout).slice(0, 2000);
456
+ // Broadcast the dashboard's expand-on-click body uses the summary.
239
457
  const event = {
240
458
  event_type: "AutoCheckpoint",
241
459
  source_app: path.basename(cwd),
@@ -248,9 +466,11 @@ async function processSession(session, broadcast) {
248
466
  persona: session.persona,
249
467
  checkpoint_kind: "auto-semantic",
250
468
  checkpoint_path: cpFile,
251
- checkpoint_preview: preview,
469
+ checkpoint_summary: summary,
470
+ checkpoint_preview: (frontmatter + body).slice(0, 2000),
252
471
  interval_minutes: state.interval_minutes,
253
472
  tool_calls_in_window: recent.length,
473
+ transcript_messages: transcriptMessages ? transcriptMessages.length : 0,
254
474
  _ts: now,
255
475
  };
256
476
  broadcast(event);