elliot-stack 1.0.39 → 1.0.40

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 (55) hide show
  1. package/package.json +1 -1
  2. package/skills/estack-migrate-claude-session-history/SKILL.md +3 -2
  3. package/skills/estack-migrate-claude-session-history/scripts/__pycache__/validate-migration.cpython-313.pyc +0 -0
  4. package/skills/estack-read-claude-session-history/SKILL.md +27 -3
  5. package/skills/estack-read-claude-session-history/references/modes.md +52 -7
  6. package/skills/estack-read-claude-session-history/references/recipes.md +7 -1
  7. package/skills/estack-read-claude-session-history/scripts/__pycache__/read_transcript.cpython-313.pyc +0 -0
  8. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/parser.cpython-313.pyc +0 -0
  10. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  11. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/search.cpython-313.pyc +0 -0
  12. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/subagents.cpython-313.pyc +0 -0
  13. package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/tools.cpython-313.pyc +0 -0
  14. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +140 -9
  15. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +0 -48
  16. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +0 -326
  17. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +0 -40
  18. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +0 -20
  19. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +0 -4
  20. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +0 -2
  21. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +0 -9
  22. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +0 -7
  23. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +0 -3
  24. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +0 -3
  25. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +0 -5
  26. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +0 -2
  27. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +0 -8
  28. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +0 -2
  29. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +0 -2
  30. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +0 -2
  31. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +0 -2
  32. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +0 -1
  33. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +0 -4
  34. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +0 -6
  35. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +0 -5
  36. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +0 -3
  37. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +0 -3
  38. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +0 -10
  39. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +0 -3
  40. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +0 -2
  41. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +0 -3
  42. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +0 -5
  43. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +0 -2
  44. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +0 -56
  45. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +0 -239
  46. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +0 -201
  47. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +0 -323
  48. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +0 -204
  49. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +0 -133
  50. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +0 -94
  51. package/skills/estack-read-claude-session-history/scripts/tests/test_search_output.py +0 -161
  52. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +0 -43
  53. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +0 -179
  54. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +0 -225
  55. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +0 -80
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elliot-stack",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "description": "Elliot's skill stack for Claude Code — install via npx elliot-stack@latest",
5
5
  "bin": {
6
6
  "elliot-stack": "bin/install.cjs"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: estack-migrate-claude-session-history
3
- version: 1.0.1
3
+ version: 1.0.2
4
4
  description: >-
5
5
  (migrate-claude-session-history) Use whenever the user wants to move a Claude
6
6
  Code session (the .jsonl transcript plus its subagent sidecar files) from one
@@ -204,7 +204,8 @@ These are the failure modes that have actually happened — read `references/tro
204
204
 
205
205
  - `scripts/migrate-claude-history.js` — the migration script. Supports full-project and single-session modes, CLI overrides for old/new repo, auto-append of the migration note (on by default), `--no-migration-note` opt-out, and `--dry-run`. Run `node scripts/migrate-claude-history.js --help` for the full CLI.
206
206
  - `scripts/validate-migration.py` — post-migration validator. Runs structural, schema, path-consistency, sidecar, and (optional) backup-cross-validation checks on a migrated `.jsonl`. Exits 0 if every check passes, 1 otherwise. Run `python scripts/validate-migration.py --help` for the full CLI. Use this in step 7 instead of writing ad-hoc Python.
207
- - `scripts/test-append-note.js` — a self-contained smoke test for the note-append routine. Run with `node scripts/test-append-note.js`. Useful if you edit the migration script and want to sanity-check the duplicate-detection and non-meta entry shape.
207
+ - `tests/estack-migrate-claude-session-history/test-append-note.js` — smoke test for the note-append routine (path relative to repo root). Run with `node tests/estack-migrate-claude-session-history/test-append-note.js` from the repo root. Useful if you edit the migration script and want to sanity-check the duplicate-detection and non-meta entry shape.
208
+ - `tests/estack-migrate-claude-session-history/test-validate-migration.py` — self-test for the validator (path relative to repo root). Runs 27 synthetic cases covering every check. Run with `python tests/estack-migrate-claude-session-history/test-validate-migration.py` from the repo root. Exit 0 on full pass, 1 if any case fails.
208
209
  - `references/path-encoding.md` — the 9 path-encoding variants, why each exists, which entries use which form.
209
210
  - `references/troubleshooting.md` — recoveries for stale-reference false positives, missed sidecars, ambiguous lookups, and the true-stale grep recipe.
210
211
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: estack-read-claude-session-history
3
- version: 1.2.1
3
+ version: 1.3.0
4
4
  description: >-
5
5
  (read-claude-session-history) Invoke for ANY task involving Claude Code
6
6
  session history, transcripts, or .jsonl files - this is the only way to read,
@@ -60,6 +60,12 @@ python "$PY" --mode timeline --date yesterday
60
60
  # How much focused time did today actually consume? (your attention, not Claude's)
61
61
  python "$PY" --mode engagement --date today
62
62
 
63
+ # Per-session "what did I do" report — one block each: active+ran time, you/assistant
64
+ # message counts, intent, last message. The whole "review my day" answer in one call.
65
+ python "$PY" --mode session-report --date yesterday
66
+ # Scope to part of a day with --since/--until (omit --date — date wins over since):
67
+ python "$PY" --mode session-report --since "2026-06-19 19:00" --until "2026-06-20 00:00"
68
+
63
69
  # Any mode as structured JSON for piping into the next step
64
70
  python "$PY" --mode list --project keel --since 7d --format json
65
71
  ```
@@ -97,7 +103,7 @@ What are you trying to do?
97
103
  │ ├─ One project ─────────────────────────────── --mode search --cwd …
98
104
  │ ├─ All projects ────────────────────────────── --mode search --all-projects
99
105
  │ ├─ Expand a wide search to full windows ───── --full
100
- │ └─ Filter to user msgs / tool-use inputs ──── --role user --in tool_use
106
+ │ └─ Filter by role / content channel ──────── --role user --in tool_use|tool_result|thinking|all
101
107
 
102
108
  ├─ Forensics on a session
103
109
  │ ├─ Chronological tool-call log ────────────── --mode changelog
@@ -111,6 +117,7 @@ What are you trying to do?
111
117
  │ └─ Forensics on one subagent ──────────────── --mode subagent-tools|subagent-files --subagent …
112
118
 
113
119
  ├─ Cross-cutting reporting
120
+ │ ├─ "What did I do, per session?" (day review) ─ --mode session-report --date … | --since/--until …
114
121
  │ ├─ "What did I do this week?" ──────────────── --mode journal --since 7d
115
122
  │ ├─ "What was I doing, when?" / day map ─────── --mode timeline --date yesterday
116
123
  │ ├─ "How long did X actually take ME?" ──────── --mode engagement --date … | --project … | --file …
@@ -148,7 +155,8 @@ What are you trying to do?
148
155
  | `count` | `--query` (+ scope) | `<N>` to stdout, summary to stderr |
149
156
  | `journal` | `--since` (+ scope) | Per-session 5-line block: date·uuid / prompt / ended / edits / tools |
150
157
  | `timeline` | `--date` or `--since/--until` (defaults: today, all projects) | Map of WHAT was active WHEN: blocks + idle gaps (no attention claim — that's `engagement`) |
151
- | `engagement` | `--date` or `--since/--until` or `--file` (defaults: today, all projects) | YOUR attention time: active vs elapsed + ratio per session, parallel-chat-safe totals, breaks |
158
+ | `engagement` | `--date` or `--since/--until` or `--file` (defaults: today, all projects) | YOUR attention time: active vs elapsed + ratio per session, **you/assistant message counts**, parallel-chat-safe totals, breaks |
159
+ | `session-report` | `--date` or `--since/--until` (defaults: today, all projects) | Per-session day review, chronological: one numbered block each with both clocks (ran = own span, overlaps OK; active = deduped attention), you/assistant message counts, intent, last message, files edited. All windowed to the range. The one-call "review my day" answer. |
152
160
  | `diff` | `--file-a` + `--file-b` OR `--subagents-of` | Timestamp-interleaved A>/B> output |
153
161
 
154
162
  ## Global flags
@@ -203,6 +211,8 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
203
211
  | Find "that session where I asked about supabase rate limits" | `--mode search --all-projects --query "supabase rate limits"` |
204
212
  | Resume a project after a few days away | `--mode resume-prev --cwd "<project path>"` |
205
213
  | Daily/weekly journal | `--mode journal --since 7d --all-projects` |
214
+ | "What did I do yesterday, per session?" (day review) | `--mode session-report --date yesterday` |
215
+ | "Break down what I did from 7pm on" | `--mode session-report --since "<date> 19:00" --until "<date+1> 00:00"` |
206
216
  | "Where did yesterday go?" (map of activity) | `--mode timeline --date yesterday` |
207
217
  | "How much did I actually work today?" | `--mode engagement --date today` |
208
218
  | "How much time on Keel today?" | `--mode engagement --project keel --date today` |
@@ -212,6 +222,20 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
212
222
 
213
223
  See `references/recipes.md` for fuller multi-step workflows.
214
224
 
225
+ ## Presentation defaults for a human day-review
226
+
227
+ When the user asks a natural-language "what did I do" / "review my day" / "break down what I worked on" question (as opposed to feeding JSON to a script), lead with `session-report` — it carries everything one such answer needs in a single call — and present it this way unless the user says otherwise:
228
+
229
+ - **Number the sessions** and separate them into clear blocks. Users read a numbered, sectioned list far more easily than a wall of prose.
230
+ - **Drop UUIDs.** They are noise in a human review; only surface them if the user is going to resume or look one up.
231
+ - **One to two sentences per session** describing what they did and why — synthesized from the `intent` + `last` + files, not a raw dump of either. The mode hands you the inputs; you write the sentence.
232
+ - **Show both clocks and name the overlap.** `active` is deduped attention (parallel chats never double-counted); `ran`/elapsed is the session's own first→last span and *will* overlap others. Say so once — users work on several things at once and want the overlap reflected in the span but removed from active minutes.
233
+ - **Counts are clean already.** `you N msgs` is real typed prompts (tool-results and hook/skill injections excluded); `assistant N msgs` is text replies (tool-only turns excluded). Do NOT hand-count raw `type:user`/`type:assistant` entries from the .jsonl — that over-counts both (tool-result envelopes inflate "user"; multi-block turns inflate "assistant"). Trust the mode's numbers.
234
+ - **24-hour time** as the CLI emits it; convert only if the user asks.
235
+ - **Scope to a sub-window with `--since/--until`** (omit `--date` — `--date` overrides `--since`). The metrics are windowed to the range, so "from 7pm" counts and active-time reflect only that slice.
236
+
237
+ For a normal day (16–20 sessions) `session-report` text output stays well within the read budget; prefer it over `--mode list --format json`, which dumps full per-session arrays and can overflow into a persisted-file round-trip.
238
+
215
239
  ## Windows notes
216
240
 
217
241
  - Use `python` (not `python3`) on this Windows setup.
@@ -352,11 +352,17 @@ How it works — three deterministic rules over ONE merged stream:
352
352
  3. Everything else is a break and contributes zero. A session left open with no
353
353
  prompts accrues nothing.
354
354
 
355
- Output: one row per session (active, ratio = active/elapsed, prompt count,
356
- first–last), a totals line (already interval-merged — safe to quote), breaks in
357
- the merged stream, and (single-session view) median/p90 prompt gaps. Ratio is
358
- capped at 1.0: composing time leading into a chat's first prompt is credited to
359
- that chat, so raw active can slightly exceed its first–last span.
355
+ Output: one row per session (active, ratio = active/elapsed, `you`/`ai` message
356
+ counts, first–last), a totals line (already interval-merged — safe to quote),
357
+ breaks in the merged stream, and (single-session view) median/p90 prompt gaps.
358
+ Ratio is capped at 1.0: composing time leading into a chat's first prompt is
359
+ credited to that chat, so raw active can slightly exceed its first–last span.
360
+
361
+ Message counts are honest, not raw entry counts: `you` (`user_messages`) is real
362
+ typed prompts only — tool-result envelopes, hook/skill `isMeta` injections, and
363
+ compact continuations are excluded; `ai` (`assistant_messages`) is assistant
364
+ turns bearing visible text — tool-only turns don't count. Both are windowed to
365
+ `[since, until)`.
360
366
 
361
367
  Scoping caveat: `--project`/`--cwd`/`--file` filter which sessions are
362
368
  *reported*; the stream is always computed across all projects under `--root` so
@@ -372,8 +378,47 @@ Flags:
372
378
 
373
379
  JSON shape: `{since, until, break_minutes, sessions: [{uuid, project, title,
374
380
  path, first, last, elapsed_minutes, active_minutes, active_seconds, ratio,
375
- user_messages}], totals: {sessions, active_minutes, active_seconds,
376
- span_minutes}, stream_breaks: [{start, end, minutes}]}`.
381
+ user_messages, assistant_messages}], totals: {sessions, active_minutes,
382
+ active_seconds, span_minutes}, stream_breaks: [{start, end, minutes}]}`.
383
+
384
+ ### `session-report`
385
+
386
+ The per-session "what did I do" day review. Same windowed, overlap-safe
387
+ attention engine as `engagement`, but rendered as one numbered block per
388
+ session, **chronological** (oldest first by first prompt), carrying everything a
389
+ human review needs in a single call — so a "break down my day" answer doesn't
390
+ require stitching `timeline` + `lookup` + `engagement` + raw message counts by
391
+ hand.
392
+
393
+ ```bash
394
+ # Yesterday, every project
395
+ python read_transcript.py --mode session-report --date yesterday
396
+
397
+ # Just the evening — omit --date so --since/--until take effect
398
+ python read_transcript.py --mode session-report --since "2026-06-19 19:00" --until "2026-06-20 00:00"
399
+
400
+ # One project
401
+ python read_transcript.py --mode session-report --project keel --date today
402
+ ```
403
+
404
+ Each block shows: title, project, first–last span with **both clocks** —
405
+ `ran` (the session's own first→last elapsed, which can overlap other sessions)
406
+ and `active` (deduped attention, parallel chats never double-counted) — then
407
+ `you`/`assistant` message counts (same honest definitions as `engagement`),
408
+ files edited, the `intent` (first prompt) and `last` (final assistant message).
409
+ The intent/last are raw inputs for you to synthesize a one-sentence description
410
+ from, not the description itself. A totals line closes with deduped active time
411
+ and the overlap-inclusive span.
412
+
413
+ Flags: same as `engagement` (`--break`, `--date`/`--since`/`--until`,
414
+ `--cwd`/`--project`/`--all-projects`, `--tz`, `--exclude-current`). As with
415
+ `engagement`, `--date` takes precedence over `--since`/`--until`; to scope to a
416
+ sub-window of a day, pass `--since`/`--until` and omit `--date`.
417
+
418
+ JSON shape: `{since, until, break_minutes, sessions: [{uuid, project, title,
419
+ path, first, last, elapsed_minutes, active_minutes, user_messages,
420
+ assistant_messages, edits, intent, last_message}], totals: {sessions,
421
+ active_minutes, span_minutes}}`. Sessions are ordered chronologically.
377
422
 
378
423
  ### `tool-usage`
379
424
 
@@ -236,13 +236,19 @@ python "$PY" --file <unknown-session>.jsonl --mode changelog | tail -30
236
236
  ## 8. Tool-call forensics ("when did I last `git push --force`?")
237
237
 
238
238
  ```bash
239
- # Find every tool_use whose JSON args contain a substring, across all sessions
239
+ # Find which sessions contain a matching tool_use, across all projects
240
+ # (returns a per-session summary by default: one line per session with hit count + snippet)
240
241
  python "$PY" --all-projects --mode search --query "git push --force" --in tool_use
241
242
 
243
+ # Add --full to expand to match windows instead of the per-session summary
244
+ python "$PY" --all-projects --mode search --query "git push --force" --in tool_use --full
245
+
242
246
  # Get full forensics on the session that matched
243
247
  python "$PY" --file <matching-session>.jsonl --mode tool-calls --tool Bash
244
248
  ```
245
249
 
250
+ Wide-scope search (`--all-projects`, `--cwd`, `--project`) returns a **per-session summary** by default — one line per matching session with a hit count and first snippet — so the output stays under the harness's Read cap. Add `--full` to expand to match windows (bounded by a ~10k-token budget; degrades back to summary with a note if it overflows). Single-file search (`--file`) always returns full windows.
251
+
246
252
  ---
247
253
 
248
254
  ## 8b. "Which skills (or tools) do I actually use?" — true invocation counts
@@ -1202,17 +1202,38 @@ def _is_real_user_prompt(obj: dict) -> bool:
1202
1202
  return False
1203
1203
 
1204
1204
 
1205
+ def _assistant_has_text(obj: dict) -> bool:
1206
+ """True if an assistant entry carries human-visible text, not just tool_use.
1207
+
1208
+ A turn that only fires tools (no text block) is plumbing, not a reply the
1209
+ user reads, so it does not count as an assistant message.
1210
+ """
1211
+ content = obj.get("message", {}).get("content", "")
1212
+ if isinstance(content, str):
1213
+ return bool(content.strip())
1214
+ if isinstance(content, list):
1215
+ return any(
1216
+ isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
1217
+ for b in content
1218
+ )
1219
+ return False
1220
+
1221
+
1205
1222
  def _engagement_event_streams(
1206
1223
  path: Path, since: datetime | None, until: datetime | None
1207
- ) -> tuple[list[datetime], list[datetime]]:
1208
- """One session's (user_events, claude_events) inside [since, until).
1224
+ ) -> tuple[list[datetime], list[datetime], list[datetime]]:
1225
+ """One session's (user_events, claude_events, assistant_events) in [since, until).
1209
1226
 
1210
1227
  user_events — real user prompts only (see _is_real_user_prompt).
1211
1228
  claude_events — assistant messages and tool results: evidence Claude was
1212
1229
  working. Used only to grant waiting-on-Claude credit for long gaps.
1230
+ assistant_events — assistant turns bearing visible text (see
1231
+ _assistant_has_text): the replies the user actually reads, counted clean of
1232
+ tool-only turns and tool-result envelopes.
1213
1233
  """
1214
1234
  user_ev: list[datetime] = []
1215
1235
  claude_ev: list[datetime] = []
1236
+ assistant_ev: list[datetime] = []
1216
1237
  for obj in PR.parse_lines(path):
1217
1238
  cls = PR.classify_entry(obj)
1218
1239
  if cls in ("noise", "title", "compact"):
@@ -1229,9 +1250,12 @@ def _engagement_event_streams(
1229
1250
  claude_ev.append(ts) # tool_result entries
1230
1251
  else: # assistant
1231
1252
  claude_ev.append(ts)
1253
+ if _assistant_has_text(obj):
1254
+ assistant_ev.append(ts)
1232
1255
  user_ev.sort()
1233
1256
  claude_ev.sort()
1234
- return user_ev, claude_ev
1257
+ assistant_ev.sort()
1258
+ return user_ev, claude_ev, assistant_ev
1235
1259
 
1236
1260
 
1237
1261
  def build_engagement(
@@ -1265,6 +1289,7 @@ def build_engagement(
1265
1289
 
1266
1290
  user_events: dict[Path, list[datetime]] = {}
1267
1291
  claude_events: dict[Path, list[datetime]] = {}
1292
+ assistant_events: dict[Path, list[datetime]] = {}
1268
1293
  walk_dirs = P.list_projects(root)
1269
1294
  files: list[Path] = []
1270
1295
  for pd in walk_dirs:
@@ -1279,10 +1304,11 @@ def build_engagement(
1279
1304
  for f in files:
1280
1305
  if exclude_current and current_uuid and f.stem == current_uuid:
1281
1306
  continue
1282
- u, c = _engagement_event_streams(f, since, until)
1307
+ u, c, a = _engagement_event_streams(f, since, until)
1283
1308
  if u or c:
1284
1309
  user_events[f] = u
1285
1310
  claude_events[f] = c
1311
+ assistant_events[f] = a
1286
1312
 
1287
1313
  stream = sorted(
1288
1314
  (ts, f) for f, evs in user_events.items() for ts in evs
@@ -1320,6 +1346,7 @@ def build_engagement(
1320
1346
  "first": evs[0],
1321
1347
  "last": evs[-1],
1322
1348
  "user_messages": len(evs),
1349
+ "assistant_messages": len(assistant_events.get(f, [])),
1323
1350
  "active": active.get(f, timedelta()),
1324
1351
  }
1325
1352
 
@@ -1367,7 +1394,8 @@ def render_engagement(data: dict, tz_label: str) -> str:
1367
1394
  if elapsed.total_seconds() > 0 else " — "
1368
1395
  )
1369
1396
  out.append(
1370
- f"{_fmt_dur(s['active']):>7} ratio {ratio} msgs {s['user_messages']:<4} "
1397
+ f"{_fmt_dur(s['active']):>7} ratio {ratio} "
1398
+ f"you {s['user_messages']:<3} ai {s['assistant_messages']:<4} "
1371
1399
  f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
1372
1400
  f"{_session_label(s['summary'])}"
1373
1401
  )
@@ -1394,7 +1422,7 @@ def render_engagement(data: dict, tz_label: str) -> str:
1394
1422
  (f, s), = sessions.items()
1395
1423
  # recompute the session's own user events from the stored bounds is not
1396
1424
  # enough — pull them again (cached parse, cheap)
1397
- evs, _ = _engagement_event_streams(f, data["since"], data["until"])
1425
+ evs, _, _ = _engagement_event_streams(f, data["since"], data["until"])
1398
1426
  pct = _gap_percentiles(evs)
1399
1427
  if pct:
1400
1428
  out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
@@ -1432,6 +1460,7 @@ def engagement_json(data: dict) -> dict:
1432
1460
  if elapsed.total_seconds() > 0 else None
1433
1461
  ),
1434
1462
  "user_messages": s["user_messages"],
1463
+ "assistant_messages": s["assistant_messages"],
1435
1464
  })
1436
1465
  span_min = 0
1437
1466
  if data["sessions"]:
@@ -1460,6 +1489,102 @@ def engagement_json(data: dict) -> dict:
1460
1489
  }
1461
1490
 
1462
1491
 
1492
+ # ─────────────────────────────────────────────────────────────────────────────
1493
+ # Session-report mode — the per-session "what did I do" view
1494
+ # ─────────────────────────────────────────────────────────────────────────────
1495
+ # Reuses the engagement engine (windowed, overlap-safe attention time) but
1496
+ # renders one numbered block per session, chronological, with both clocks
1497
+ # (ran = own first→last span, which overlaps others; active = deduped
1498
+ # attention), per-role message counts, and the intent/last-message inputs a
1499
+ # human day-review is written from.
1500
+
1501
+ def render_session_report(data: dict, tz_label: str) -> str:
1502
+ since, until = data["since"], data["until"]
1503
+ sessions = data["sessions"]
1504
+ multi_day = (until - since) > timedelta(days=1)
1505
+ tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
1506
+ head = (
1507
+ f"=== Session report {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
1508
+ f"(times: {tz_label}, break={data['break_minutes']}m) ==="
1509
+ )
1510
+ if not sessions:
1511
+ return head + "\n\n(no user activity in range)"
1512
+ out = [head, ""]
1513
+ rows = sorted(sessions.items(), key=lambda kv: kv[1]["first"]) # chronological
1514
+ for i, (f, s) in enumerate(rows, 1):
1515
+ summary = s["summary"]
1516
+ elapsed = s["last"] - s["first"]
1517
+ title = summary.get("title") or summary.get("first_prompt") or "(untitled)"
1518
+ out.append(f"{i}. {title}")
1519
+ out.append(
1520
+ f" {summary['decoded_project']} · "
1521
+ f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
1522
+ f"(ran {_fmt_dur(elapsed)} · active {_fmt_dur(s['active'])})"
1523
+ )
1524
+ out.append(
1525
+ f" you {s['user_messages']} msgs · "
1526
+ f"assistant {s['assistant_messages']} msgs · "
1527
+ f"{summary['edit_count']} files edited"
1528
+ )
1529
+ out.append(f" intent: {summary.get('first_prompt') or '(no user prompt)'}")
1530
+ out.append(f" last: {summary.get('last_assistant') or '(no assistant message)'}")
1531
+ out.append("")
1532
+ total_active = sum((s["active"] for s in sessions.values()), timedelta())
1533
+ first = min(s["first"] for s in sessions.values())
1534
+ last = max(s["last"] for s in sessions.values())
1535
+ out.append(
1536
+ f"Total: {len(sessions)} session(s) · {_fmt_dur(total_active)} active "
1537
+ f"(overlap removed) across a {_fmt_dur(last - first)} span "
1538
+ f"({first.strftime(tfmt)}–{last.strftime('%H:%M')})."
1539
+ )
1540
+ out.append(
1541
+ "(active = your attention, parallel chats never double-counted; "
1542
+ "ran = each session's own first→last span, which can overlap others.)"
1543
+ )
1544
+ return "\n".join(out)
1545
+
1546
+
1547
+ def session_report_json(data: dict) -> dict:
1548
+ rows = sorted(data["sessions"].items(), key=lambda kv: kv[1]["first"])
1549
+ sessions_out = []
1550
+ total_active = timedelta()
1551
+ for f, s in rows:
1552
+ summary = s["summary"]
1553
+ elapsed = s["last"] - s["first"]
1554
+ total_active += s["active"]
1555
+ sessions_out.append({
1556
+ "uuid": summary["uuid"],
1557
+ "project": summary["decoded_project"],
1558
+ "title": summary.get("title") or summary.get("first_prompt") or "",
1559
+ "path": str(f),
1560
+ "first": s["first"].isoformat(),
1561
+ "last": s["last"].isoformat(),
1562
+ "elapsed_minutes": int(elapsed.total_seconds() // 60),
1563
+ "active_minutes": int(s["active"].total_seconds() // 60),
1564
+ "user_messages": s["user_messages"],
1565
+ "assistant_messages": s["assistant_messages"],
1566
+ "edits": summary["edit_count"],
1567
+ "intent": summary.get("first_prompt") or "",
1568
+ "last_message": summary.get("last_assistant") or "",
1569
+ })
1570
+ span_min = 0
1571
+ if data["sessions"]:
1572
+ first = min(s["first"] for s in data["sessions"].values())
1573
+ last = max(s["last"] for s in data["sessions"].values())
1574
+ span_min = int((last - first).total_seconds() // 60)
1575
+ return {
1576
+ "since": data["since"].isoformat(),
1577
+ "until": data["until"].isoformat(),
1578
+ "break_minutes": data["break_minutes"],
1579
+ "sessions": sessions_out,
1580
+ "totals": {
1581
+ "sessions": len(sessions_out),
1582
+ "active_minutes": int(total_active.total_seconds() // 60),
1583
+ "span_minutes": span_min,
1584
+ },
1585
+ }
1586
+
1587
+
1463
1588
  # ─────────────────────────────────────────────────────────────────────────────
1464
1589
  # JSON builders for legacy single-file modes
1465
1590
  # ─────────────────────────────────────────────────────────────────────────────
@@ -1552,6 +1677,7 @@ NEW_MODES = {
1552
1677
  "changelog", "file-edits", "tool-calls", "tool-usage",
1553
1678
  "subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
1554
1679
  "resume-prev", "count", "journal", "diff", "timeline", "engagement",
1680
+ "session-report",
1555
1681
  }
1556
1682
 
1557
1683
  ALL_MODES = LEGACY_MODES | NEW_MODES
@@ -1767,7 +1893,7 @@ def main() -> int:
1767
1893
  else:
1768
1894
  print(render_timeline(data, tz_label=args.tz or "local"))
1769
1895
  return 0
1770
- if mode == "engagement":
1896
+ if mode in ("engagement", "session-report"):
1771
1897
  try:
1772
1898
  break_minutes = _parse_gap(args.break_spec, default=10)
1773
1899
  if args.date:
@@ -1786,7 +1912,7 @@ def main() -> int:
1786
1912
  return 1
1787
1913
  if report_file and e_since is None:
1788
1914
  # Window defaults to the file's own first→last user prompt.
1789
- evs, _ = _engagement_event_streams(report_file, None, None)
1915
+ evs, _, _ = _engagement_event_streams(report_file, None, None)
1790
1916
  if not evs:
1791
1917
  print("(no user messages in this session)")
1792
1918
  return 0
@@ -1805,7 +1931,12 @@ def main() -> int:
1805
1931
  root, report_dirs, report_file, e_since, e_until, break_minutes,
1806
1932
  current_uuid, exclude_current=args.exclude_current,
1807
1933
  )
1808
- if fmt == "json":
1934
+ if mode == "session-report":
1935
+ if fmt == "json":
1936
+ _print_json(session_report_json(data))
1937
+ else:
1938
+ print(render_session_report(data, tz_label=args.tz or "local"))
1939
+ elif fmt == "json":
1809
1940
  _print_json(engagement_json(data))
1810
1941
  else:
1811
1942
  print(render_engagement(data, tz_label=args.tz or "local"))
@@ -1,48 +0,0 @@
1
- 'use strict';
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
-
6
- const mod = require('./migrate-claude-history.js');
7
-
8
- // Set up a tiny synthetic .jsonl in a temp dir
9
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'migrate-test-'));
10
- const testFile = path.join(tmp, 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl');
11
- const sampleEntries = [
12
- { type: 'permission-mode', permissionMode: 'default', sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' },
13
- { type: 'user', message: { role: 'user', content: 'Hello' }, uuid: '11111111-1111-1111-1111-111111111111', parentUuid: null, sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', timestamp: '2026-05-24T18:00:00.000Z', cwd: 'C:\\fake\\old', version: '2.1.0' },
14
- ];
15
- fs.writeFileSync(testFile, sampleEntries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
16
-
17
- const oldRepo = mod.parseWindowsRepoPath('C:\\fake\\old', 'old');
18
- const newRepo = mod.parseWindowsRepoPath('C:\\fake\\new', 'new');
19
-
20
- const summary = { migrationNotesAppended: 0, migrationNotesSkipped: 0 };
21
-
22
- // First call — should append
23
- mod.appendMigrationNote({ filePath: testFile, oldRepo, newRepo, dryRun: false, summary });
24
- console.log('After first call:', summary);
25
-
26
- // Second call — should detect duplicate and skip
27
- mod.appendMigrationNote({ filePath: testFile, oldRepo, newRepo, dryRun: false, summary });
28
- console.log('After second call:', summary);
29
-
30
- // Inspect the appended entry
31
- const lines = fs.readFileSync(testFile, 'utf8').split('\n').filter((l) => l.trim());
32
- const appended = JSON.parse(lines[lines.length - 1]);
33
-
34
- console.log('');
35
- console.log('=== Appended entry shape ===');
36
- console.log('type: ', appended.type);
37
- console.log('isMeta: ', 'isMeta' in appended ? appended.isMeta : '<not set>');
38
- console.log('parent: ', appended.parentUuid);
39
- console.log('uuid: ', appended.uuid);
40
- console.log('cwd: ', appended.cwd);
41
- console.log('');
42
- console.log('=== content (first 200 chars) ===');
43
- console.log(appended.message.content.slice(0, 200) + '...');
44
-
45
- // Cleanup
46
- fs.rmSync(tmp, { recursive: true, force: true });
47
- console.log('');
48
- console.log('Test passed:', summary.migrationNotesAppended === 1 && summary.migrationNotesSkipped === 1 && !('isMeta' in appended) ? 'YES' : 'NO');