elliot-stack 1.0.38 → 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 (56) 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 +30 -4
  5. package/skills/estack-read-claude-session-history/references/modes.md +65 -9
  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/lib/parser.py +2 -1
  15. package/skills/estack-read-claude-session-history/scripts/lib/search.py +27 -9
  16. package/skills/estack-read-claude-session-history/scripts/read_transcript.py +267 -84
  17. package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +0 -48
  18. package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +0 -326
  19. package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +0 -40
  20. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +0 -20
  21. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +0 -4
  22. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +0 -2
  23. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +0 -9
  24. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +0 -7
  25. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +0 -3
  26. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +0 -3
  27. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +0 -5
  28. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +0 -2
  29. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +0 -8
  30. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +0 -2
  31. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +0 -2
  32. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +0 -2
  33. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +0 -2
  34. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +0 -1
  35. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +0 -4
  36. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +0 -6
  37. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +0 -5
  38. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +0 -3
  39. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +0 -3
  40. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +0 -10
  41. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +0 -3
  42. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +0 -2
  43. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +0 -3
  44. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +0 -5
  45. package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +0 -2
  46. package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +0 -56
  47. package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +0 -239
  48. package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +0 -201
  49. package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +0 -323
  50. package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +0 -195
  51. package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +0 -133
  52. package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +0 -78
  53. package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +0 -43
  54. package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +0 -179
  55. package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +0 -212
  56. package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +0 -80
@@ -33,6 +33,16 @@ from lib import search as S # noqa: E402
33
33
  from lib import subagents as SA # noqa: E402
34
34
 
35
35
 
36
+ # Wide-scope search output budget. Full match windows across many sessions can
37
+ # balloon past the harness's ~25k-token Read cap, forcing a write-then-can't-read
38
+ # round trip. We summarize by default and degrade --full back to a summary once
39
+ # the rendered text would exceed this many characters (~10k tokens at ~4 ch/tok).
40
+ SEARCH_CHAR_BUDGET = 40_000
41
+ # Cap session lines in the summary view so the summary itself stays bounded.
42
+ # Overflow is counted and noted, never silently dropped.
43
+ SEARCH_SUMMARY_SESSION_CAP = 200
44
+
45
+
36
46
  # ─────────────────────────────────────────────────────────────────────────────
37
47
  # Legacy mode implementations (kept byte-identical to v1 for backwards-compat)
38
48
  # ─────────────────────────────────────────────────────────────────────────────
@@ -107,24 +117,6 @@ def mode_dump(lines, limit=80):
107
117
  return "\n\n".join(output)
108
118
 
109
119
 
110
- def mode_search_legacy(lines, query: str):
111
- """Legacy single-file search: assistant text only, case-insensitive."""
112
- messages = PR.get_messages(lines)
113
- results = []
114
- q = query.lower()
115
- for m in messages:
116
- if m["role"] == "assistant":
117
- combined = " ".join(m["texts"])
118
- if q in combined.lower():
119
- results.append(combined)
120
- if not results:
121
- return None
122
- output = [f"=== {len(results)} match(es) for '{query}' ===\n"]
123
- for i, r in enumerate(results, 1):
124
- output.append(f"--- Match #{i} ---\n{r[:1500]}")
125
- return "\n\n".join(output)
126
-
127
-
128
120
  def mode_debug(lines):
129
121
  output = []
130
122
  type_counts: dict[str, int] = {}
@@ -579,6 +571,81 @@ def mode_tool_calls(path: Path, tool_filter: set[str] | None, fmt: str = "text")
579
571
  return "\n".join(out).lstrip("\n")
580
572
 
581
573
 
574
+ def _one_line(text: str, n: int) -> str:
575
+ """Collapse whitespace to a single line and truncate to n chars."""
576
+ s = " ".join((text or "").split())
577
+ return s[:n] + ("…" if len(s) > n else "")
578
+
579
+
580
+ def _sessions_by_mtime(matches) -> list:
581
+ """Group matches by session, return (session_path, matches) sorted newest first."""
582
+ by_session: dict[Path, list] = {}
583
+ for m in matches:
584
+ by_session.setdefault(m.session_path, []).append(m)
585
+ return sorted(by_session.items(), key=lambda kv: kv[1][0].mtime, reverse=True)
586
+
587
+
588
+ def _render_search_full(matches) -> str:
589
+ """Full per-match windows grouped by session, newest first (the detailed view)."""
590
+ out = []
591
+ for sp, ms in _sessions_by_mtime(matches):
592
+ out.append(f"{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
593
+ for i, m in enumerate(ms, 1):
594
+ label = f"--- Match #{i} [{m.role}/{m.where}] ---"
595
+ out.append(f"{label}\n{m.window_text[:1500]}")
596
+ return "\n\n".join(out)
597
+
598
+
599
+ def _render_search_summary(query: str, matches, already_full: bool = False) -> str:
600
+ """One line per session: mtime · uuid8 · project · hits · first snippet.
601
+
602
+ Every hit is counted in the header; sessions past the cap are counted in a
603
+ footer note, never silently dropped. When ``already_full`` is set (the
604
+ summary is a degraded ``--full`` result), the footer drops the "use --full"
605
+ suggestion the caller already tried.
606
+ """
607
+ sessions = _sessions_by_mtime(matches)
608
+ total_hits = len(matches)
609
+ total_sessions = len(sessions)
610
+ shown = sessions[:SEARCH_SUMMARY_SESSION_CAP]
611
+ hit_w = "match" if total_hits == 1 else "matches"
612
+ sess_w = "session" if total_sessions == 1 else "sessions"
613
+ lines = [f'=== "{query}": {total_hits} {hit_w} across {total_sessions} {sess_w} ===']
614
+ for sp, ms in shown:
615
+ uuid8 = sp.stem[:8]
616
+ project = P.decode_project_name(sp.parent.name)
617
+ snippet = _one_line(ms[0].window_text, 120)
618
+ n = len(ms)
619
+ # Pad the unit so singular ("hit ") and plural ("hits") align the snippet column.
620
+ hits = f'{n:>4} hit' + ('s' if n != 1 else ' ')
621
+ lines.append(f'{_fmt_mtime(ms[0].mtime)} {uuid8} {project[:28]:<28} {hits} · "{snippet}"')
622
+ if total_sessions > len(shown):
623
+ lines.append(
624
+ f"… and {total_sessions - len(shown)} more session(s) not shown — "
625
+ f"narrow with --project or a tighter --query."
626
+ )
627
+ # On a degrade (already_full) the [note] prefix already carries the actionable
628
+ # hint, so don't append a second — possibly conflicting — guidance line.
629
+ if not already_full:
630
+ lines.append("Use --full for match windows, or narrow with --project / a tighter --query.")
631
+ return "\n".join(lines)
632
+
633
+
634
+ def _search_summary_json(matches) -> list:
635
+ """Compact per-session metadata (no windows) for structured consumers."""
636
+ return [
637
+ {
638
+ "session": str(sp),
639
+ "uuid": sp.stem,
640
+ "project": P.decode_project_name(sp.parent.name),
641
+ "mtime_iso": _fmt_mtime(ms[0].mtime),
642
+ "hits": len(ms),
643
+ "first_snippet": _one_line(ms[0].window_text, 120),
644
+ }
645
+ for sp, ms in _sessions_by_mtime(matches)
646
+ ]
647
+
648
+
582
649
  def mode_search_v2(
583
650
  root: Path,
584
651
  cwd: str | None,
@@ -593,6 +660,7 @@ def mode_search_v2(
593
660
  fmt: str = "text",
594
661
  exclude_current: bool = False,
595
662
  current_uuid: str | None = None,
663
+ full: bool = False,
596
664
  ):
597
665
  """Cross-scope search with role/in-channel filters."""
598
666
  matches: list = []
@@ -607,12 +675,21 @@ def mode_search_v2(
607
675
  pd = P.find_project_dir(cwd, root)
608
676
  matches = list(S.search_project(pd, query, role, in_channel, since, until))
609
677
  else:
610
- return "Provide --file, --cwd, --project, or --all-projects"
678
+ # Unreachable from the CLI (the dispatch guarantees a scope flag), but keep
679
+ # the direct-call contract honest: JSON callers get a JSON object.
680
+ msg = "Provide --file, --cwd, --project, or --all-projects"
681
+ return {"error": msg} if fmt == "json" else msg
611
682
 
612
683
  if exclude_current and current_uuid:
613
684
  matches = [m for m in matches if m.session_path.stem != current_uuid]
614
685
 
686
+ # Single-file scope is narrow and won't overflow — render full by default.
687
+ # Wide scope (cwd / project / all-projects) summarizes unless --full is set.
688
+ wide = file_path is None
689
+
615
690
  if fmt == "json":
691
+ if wide and not full:
692
+ return _search_summary_json(matches)
616
693
  return [
617
694
  {
618
695
  "session": str(m.session_path),
@@ -628,17 +705,25 @@ def mode_search_v2(
628
705
  if not matches:
629
706
  return f"No matches for '{query}'."
630
707
 
631
- # Group by session for readable output
632
- by_session: dict[Path, list] = {}
633
- for m in matches:
634
- by_session.setdefault(m.session_path, []).append(m)
635
- out = []
636
- for sp, ms in by_session.items():
637
- out.append(f"\n{'=' * 60}\nSession: {sp.name} ({_fmt_mtime(ms[0].mtime)})\n{'=' * 60}")
638
- for i, m in enumerate(ms, 1):
639
- label = f"--- Match #{i} [{m.role}/{m.where}] ---"
640
- out.append(f"{label}\n{m.window_text[:1500]}")
641
- return "\n\n".join(out)
708
+ if wide and not full:
709
+ return _render_search_summary(query, matches)
710
+
711
+ # Full render (single-file, or wide + --full), bounded by the char budget so
712
+ # the output never exceeds what the reader accepts. Degrade to summary if it would.
713
+ full_text = _render_search_full(matches)
714
+ if len(full_text) > SEARCH_CHAR_BUDGET:
715
+ # Ceiling-round the size so it always reads as strictly over the budget.
716
+ size_k = (len(full_text) + 999) // 1000
717
+ if wide:
718
+ hint = "Use a tighter --query / --role / --in, or --file <session> to read one session in full."
719
+ else:
720
+ hint = "This one session has many matches; use a tighter --query / --role / --in to narrow."
721
+ note = (
722
+ f"[note: full output is ~{size_k}K chars (> "
723
+ f"{SEARCH_CHAR_BUDGET // 1000}K budget) — showing a summary instead. {hint}]"
724
+ )
725
+ return note + "\n" + _render_search_summary(query, matches, already_full=True)
726
+ return full_text
642
727
 
643
728
 
644
729
  def mode_subagent_list(path: Path, fmt: str = "text"):
@@ -774,9 +859,10 @@ def _tally_tool_usage(
774
859
  continue
775
860
  if ts.tzinfo is not None:
776
861
  ts = ts.replace(tzinfo=None)
862
+ # Half-open window [since, until): since inclusive, until exclusive.
777
863
  if since is not None and ts < since:
778
864
  continue
779
- if until is not None and ts > until:
865
+ if until is not None and ts >= until:
780
866
  continue
781
867
  name = c["name"] or "(unknown)"
782
868
  tool_counts[name] = tool_counts.get(name, 0) + 1
@@ -1116,17 +1202,38 @@ def _is_real_user_prompt(obj: dict) -> bool:
1116
1202
  return False
1117
1203
 
1118
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
+
1119
1222
  def _engagement_event_streams(
1120
1223
  path: Path, since: datetime | None, until: datetime | None
1121
- ) -> tuple[list[datetime], list[datetime]]:
1122
- """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).
1123
1226
 
1124
1227
  user_events — real user prompts only (see _is_real_user_prompt).
1125
1228
  claude_events — assistant messages and tool results: evidence Claude was
1126
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.
1127
1233
  """
1128
1234
  user_ev: list[datetime] = []
1129
1235
  claude_ev: list[datetime] = []
1236
+ assistant_ev: list[datetime] = []
1130
1237
  for obj in PR.parse_lines(path):
1131
1238
  cls = PR.classify_entry(obj)
1132
1239
  if cls in ("noise", "title", "compact"):
@@ -1143,9 +1250,12 @@ def _engagement_event_streams(
1143
1250
  claude_ev.append(ts) # tool_result entries
1144
1251
  else: # assistant
1145
1252
  claude_ev.append(ts)
1253
+ if _assistant_has_text(obj):
1254
+ assistant_ev.append(ts)
1146
1255
  user_ev.sort()
1147
1256
  claude_ev.sort()
1148
- return user_ev, claude_ev
1257
+ assistant_ev.sort()
1258
+ return user_ev, claude_ev, assistant_ev
1149
1259
 
1150
1260
 
1151
1261
  def build_engagement(
@@ -1179,6 +1289,7 @@ def build_engagement(
1179
1289
 
1180
1290
  user_events: dict[Path, list[datetime]] = {}
1181
1291
  claude_events: dict[Path, list[datetime]] = {}
1292
+ assistant_events: dict[Path, list[datetime]] = {}
1182
1293
  walk_dirs = P.list_projects(root)
1183
1294
  files: list[Path] = []
1184
1295
  for pd in walk_dirs:
@@ -1193,10 +1304,11 @@ def build_engagement(
1193
1304
  for f in files:
1194
1305
  if exclude_current and current_uuid and f.stem == current_uuid:
1195
1306
  continue
1196
- u, c = _engagement_event_streams(f, since, until)
1307
+ u, c, a = _engagement_event_streams(f, since, until)
1197
1308
  if u or c:
1198
1309
  user_events[f] = u
1199
1310
  claude_events[f] = c
1311
+ assistant_events[f] = a
1200
1312
 
1201
1313
  stream = sorted(
1202
1314
  (ts, f) for f, evs in user_events.items() for ts in evs
@@ -1234,6 +1346,7 @@ def build_engagement(
1234
1346
  "first": evs[0],
1235
1347
  "last": evs[-1],
1236
1348
  "user_messages": len(evs),
1349
+ "assistant_messages": len(assistant_events.get(f, [])),
1237
1350
  "active": active.get(f, timedelta()),
1238
1351
  }
1239
1352
 
@@ -1281,7 +1394,8 @@ def render_engagement(data: dict, tz_label: str) -> str:
1281
1394
  if elapsed.total_seconds() > 0 else " — "
1282
1395
  )
1283
1396
  out.append(
1284
- 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} "
1285
1399
  f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
1286
1400
  f"{_session_label(s['summary'])}"
1287
1401
  )
@@ -1308,7 +1422,7 @@ def render_engagement(data: dict, tz_label: str) -> str:
1308
1422
  (f, s), = sessions.items()
1309
1423
  # recompute the session's own user events from the stored bounds is not
1310
1424
  # enough — pull them again (cached parse, cheap)
1311
- evs, _ = _engagement_event_streams(f, data["since"], data["until"])
1425
+ evs, _, _ = _engagement_event_streams(f, data["since"], data["until"])
1312
1426
  pct = _gap_percentiles(evs)
1313
1427
  if pct:
1314
1428
  out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
@@ -1346,6 +1460,7 @@ def engagement_json(data: dict) -> dict:
1346
1460
  if elapsed.total_seconds() > 0 else None
1347
1461
  ),
1348
1462
  "user_messages": s["user_messages"],
1463
+ "assistant_messages": s["assistant_messages"],
1349
1464
  })
1350
1465
  span_min = 0
1351
1466
  if data["sessions"]:
@@ -1374,6 +1489,102 @@ def engagement_json(data: dict) -> dict:
1374
1489
  }
1375
1490
 
1376
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
+
1377
1588
  # ─────────────────────────────────────────────────────────────────────────────
1378
1589
  # JSON builders for legacy single-file modes
1379
1590
  # ─────────────────────────────────────────────────────────────────────────────
@@ -1466,6 +1677,7 @@ NEW_MODES = {
1466
1677
  "changelog", "file-edits", "tool-calls", "tool-usage",
1467
1678
  "subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
1468
1679
  "resume-prev", "count", "journal", "diff", "timeline", "engagement",
1680
+ "session-report",
1469
1681
  }
1470
1682
 
1471
1683
  ALL_MODES = LEGACY_MODES | NEW_MODES
@@ -1509,6 +1721,9 @@ def build_parser() -> argparse.ArgumentParser:
1509
1721
 
1510
1722
  # Mode-specific flags
1511
1723
  p.add_argument("--query", help="Search query (for search/count modes)")
1724
+ p.add_argument("--full", action="store_true",
1725
+ help="Wide-scope search: expand to full match windows instead of "
1726
+ "the per-session summary (bounded; degrades back to summary if oversized)")
1512
1727
  p.add_argument("--uuid", help="UUID prefix (for lookup/resume-cmd modes)")
1513
1728
  p.add_argument("--title", help="Title substring (for find mode)")
1514
1729
  p.add_argument("--first-prompt", dest="first_prompt", help="First-prompt substring (for find mode)")
@@ -1590,40 +1805,6 @@ def main() -> int:
1590
1805
  since = _resolve_time(args.since)
1591
1806
  until = _resolve_time(args.until)
1592
1807
 
1593
- # Legacy --mode search with --cwd (no --file) preserved byte-for-byte.
1594
- if args.mode == "search" and args.cwd and not args.file and not args.all_projects \
1595
- and not args.project and fmt == "text" \
1596
- and args.role == "both" and args.in_channel == "text":
1597
- if not args.query:
1598
- print("--query required with --mode search", file=sys.stderr)
1599
- return 1
1600
- files = P.list_transcripts(P.find_project_dir(args.cwd, root))
1601
- if not files:
1602
- print("No transcript files found.")
1603
- return 0
1604
- total_matches = 0
1605
- for i, f in enumerate(files, 1):
1606
- print(f"Searching {i}/{len(files)}: {f.name}...", file=sys.stderr, end="\r")
1607
- try:
1608
- lines = PR.parse_lines(f)
1609
- result = mode_search_legacy(lines, args.query)
1610
- except Exception as e:
1611
- print(f"\nError reading {f.name}: {e}", file=sys.stderr)
1612
- continue
1613
- if result is not None:
1614
- mtime = _fmt_mtime(f.stat().st_mtime)
1615
- print(f"\n{'=' * 60}")
1616
- print(f"Session: {f.name} ({mtime})")
1617
- print("=" * 60)
1618
- print(result)
1619
- total_matches += 1
1620
- print(file=sys.stderr)
1621
- if total_matches == 0:
1622
- print(f"No matches for '{args.query}' found across {len(files)} session(s).")
1623
- else:
1624
- print(f"\n--- Found matches in {total_matches}/{len(files)} session(s) ---")
1625
- return 0
1626
-
1627
1808
  mode = args.mode
1628
1809
 
1629
1810
  # Discovery modes — don't need --file
@@ -1712,7 +1893,7 @@ def main() -> int:
1712
1893
  else:
1713
1894
  print(render_timeline(data, tz_label=args.tz or "local"))
1714
1895
  return 0
1715
- if mode == "engagement":
1896
+ if mode in ("engagement", "session-report"):
1716
1897
  try:
1717
1898
  break_minutes = _parse_gap(args.break_spec, default=10)
1718
1899
  if args.date:
@@ -1731,7 +1912,7 @@ def main() -> int:
1731
1912
  return 1
1732
1913
  if report_file and e_since is None:
1733
1914
  # Window defaults to the file's own first→last user prompt.
1734
- evs, _ = _engagement_event_streams(report_file, None, None)
1915
+ evs, _, _ = _engagement_event_streams(report_file, None, None)
1735
1916
  if not evs:
1736
1917
  print("(no user messages in this session)")
1737
1918
  return 0
@@ -1750,7 +1931,12 @@ def main() -> int:
1750
1931
  root, report_dirs, report_file, e_since, e_until, break_minutes,
1751
1932
  current_uuid, exclude_current=args.exclude_current,
1752
1933
  )
1753
- 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":
1754
1940
  _print_json(engagement_json(data))
1755
1941
  else:
1756
1942
  print(render_engagement(data, tz_label=args.tz or "local"))
@@ -1769,8 +1955,8 @@ def main() -> int:
1769
1955
  return 1
1770
1956
  _emit(mode_diff(Path(args.file_a), Path(args.file_b), fmt=fmt))
1771
1957
  return 0
1772
- # (cwd-scoped searches with non-default role/in/json land here — the
1773
- # byte-compat legacy path above already handled the default-flag case.)
1958
+ # All scoped searches (--file / --cwd / --project / --all-projects) route here.
1959
+ # Wide scope summarizes by default; --full expands to bounded match windows.
1774
1960
  if mode == "search" and (args.file or args.all_projects or args.project or args.cwd):
1775
1961
  if not args.query:
1776
1962
  print("--query required", file=sys.stderr)
@@ -1780,8 +1966,12 @@ def main() -> int:
1780
1966
  args.role, args.in_channel, since, until,
1781
1967
  project=args.project, fmt=fmt,
1782
1968
  exclude_current=args.exclude_current,
1783
- current_uuid=current_uuid))
1969
+ current_uuid=current_uuid, full=args.full))
1784
1970
  return 0
1971
+ if mode == "search":
1972
+ # Reached only when no scope flag was given — search needs one of these.
1973
+ print("search requires --file, --cwd, --project, or --all-projects", file=sys.stderr)
1974
+ return 1
1785
1975
 
1786
1976
  # File-required modes
1787
1977
  if mode == "subagent-tools":
@@ -1874,13 +2064,6 @@ def main() -> int:
1874
2064
  if args.include_subagents:
1875
2065
  body += _append_subagents(path)
1876
2066
  print(body)
1877
- elif mode == "search":
1878
- if not args.query:
1879
- print("--query required with --mode search", file=sys.stderr)
1880
- return 1
1881
- result = mode_search_legacy(lines, args.query)
1882
- print(result if result is not None
1883
- else f"No assistant messages containing '{args.query}' found.")
1884
2067
  elif mode == "debug":
1885
2068
  print(mode_debug(lines))
1886
2069
 
@@ -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');