elliot-stack 1.0.39 → 1.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/estack-migrate-claude-session-history/SKILL.md +3 -2
- package/skills/estack-migrate-claude-session-history/scripts/__pycache__/validate-migration.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/SKILL.md +34 -3
- package/skills/estack-read-claude-session-history/references/modes.md +53 -7
- package/skills/estack-read-claude-session-history/references/recipes.md +7 -1
- package/skills/estack-read-claude-session-history/scripts/__pycache__/read_transcript.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/parser.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/search.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/subagents.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/tools.cpython-313.pyc +0 -0
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +163 -20
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +0 -48
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +0 -326
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +0 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +0 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +0 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +0 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +0 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +0 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +0 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +0 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +0 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +0 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +0 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +0 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +0 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +0 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +0 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +0 -323
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +0 -204
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +0 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +0 -94
- package/skills/estack-read-claude-session-history/scripts/tests/test_search_output.py +0 -161
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +0 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +0 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +0 -225
- 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: estack-migrate-claude-session-history
|
|
3
|
-
version: 1.0.
|
|
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
|
-
- `
|
|
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
|
---
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: estack-read-claude-session-history
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.3.1
|
|
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
|
```
|
|
@@ -74,6 +80,13 @@ output. If you need a different zone, pass `--tz` (IANA name like
|
|
|
74
80
|
`America/New_York`, `UTC`, or an offset like `-4`) — never convert manually.
|
|
75
81
|
`--since/--until/--date` specs are interpreted in that same display timezone.
|
|
76
82
|
|
|
83
|
+
**Report times to the user in 12-hour format unless they ask otherwise.** The
|
|
84
|
+
report modes (`session-report`, `engagement`, `timeline`) already emit each clock
|
|
85
|
+
time as `7:00pm (19:00)` — 12-hour with the 24-hour value in parens — so quoting
|
|
86
|
+
them directly satisfies this. When you paraphrase or summarize a time instead of
|
|
87
|
+
quoting raw output, keep the 12-hour form (the parenthetical 24-hour is optional
|
|
88
|
+
in prose). Switch to 24-hour only if the user requests it.
|
|
89
|
+
|
|
77
90
|
## Decision tree
|
|
78
91
|
|
|
79
92
|
```
|
|
@@ -97,7 +110,7 @@ What are you trying to do?
|
|
|
97
110
|
│ ├─ One project ─────────────────────────────── --mode search --cwd …
|
|
98
111
|
│ ├─ All projects ────────────────────────────── --mode search --all-projects
|
|
99
112
|
│ ├─ Expand a wide search to full windows ───── --full
|
|
100
|
-
│ └─ Filter
|
|
113
|
+
│ └─ Filter by role / content channel ──────── --role user --in tool_use|tool_result|thinking|all
|
|
101
114
|
│
|
|
102
115
|
├─ Forensics on a session
|
|
103
116
|
│ ├─ Chronological tool-call log ────────────── --mode changelog
|
|
@@ -111,6 +124,7 @@ What are you trying to do?
|
|
|
111
124
|
│ └─ Forensics on one subagent ──────────────── --mode subagent-tools|subagent-files --subagent …
|
|
112
125
|
│
|
|
113
126
|
├─ Cross-cutting reporting
|
|
127
|
+
│ ├─ "What did I do, per session?" (day review) ─ --mode session-report --date … | --since/--until …
|
|
114
128
|
│ ├─ "What did I do this week?" ──────────────── --mode journal --since 7d
|
|
115
129
|
│ ├─ "What was I doing, when?" / day map ─────── --mode timeline --date yesterday
|
|
116
130
|
│ ├─ "How long did X actually take ME?" ──────── --mode engagement --date … | --project … | --file …
|
|
@@ -148,7 +162,8 @@ What are you trying to do?
|
|
|
148
162
|
| `count` | `--query` (+ scope) | `<N>` to stdout, summary to stderr |
|
|
149
163
|
| `journal` | `--since` (+ scope) | Per-session 5-line block: date·uuid / prompt / ended / edits / tools |
|
|
150
164
|
| `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 |
|
|
165
|
+
| `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 |
|
|
166
|
+
| `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
167
|
| `diff` | `--file-a` + `--file-b` OR `--subagents-of` | Timestamp-interleaved A>/B> output |
|
|
153
168
|
|
|
154
169
|
## Global flags
|
|
@@ -203,6 +218,8 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
|
|
|
203
218
|
| Find "that session where I asked about supabase rate limits" | `--mode search --all-projects --query "supabase rate limits"` |
|
|
204
219
|
| Resume a project after a few days away | `--mode resume-prev --cwd "<project path>"` |
|
|
205
220
|
| Daily/weekly journal | `--mode journal --since 7d --all-projects` |
|
|
221
|
+
| "What did I do yesterday, per session?" (day review) | `--mode session-report --date yesterday` |
|
|
222
|
+
| "Break down what I did from 7pm on" | `--mode session-report --since "<date> 19:00" --until "<date+1> 00:00"` |
|
|
206
223
|
| "Where did yesterday go?" (map of activity) | `--mode timeline --date yesterday` |
|
|
207
224
|
| "How much did I actually work today?" | `--mode engagement --date today` |
|
|
208
225
|
| "How much time on Keel today?" | `--mode engagement --project keel --date today` |
|
|
@@ -212,6 +229,20 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
|
|
|
212
229
|
|
|
213
230
|
See `references/recipes.md` for fuller multi-step workflows.
|
|
214
231
|
|
|
232
|
+
## Presentation defaults for a human day-review
|
|
233
|
+
|
|
234
|
+
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:
|
|
235
|
+
|
|
236
|
+
- **Number the sessions** and separate them into clear blocks. Users read a numbered, sectioned list far more easily than a wall of prose.
|
|
237
|
+
- **Drop UUIDs.** They are noise in a human review; only surface them if the user is going to resume or look one up.
|
|
238
|
+
- **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.
|
|
239
|
+
- **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.
|
|
240
|
+
- **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.
|
|
241
|
+
- **12-hour time** as the report modes emit it (`7:00pm (19:00)` — 12-hour with 24-hour in parens); keep the 12-hour form in prose, and switch to 24-hour only if the user asks.
|
|
242
|
+
- **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.
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
215
246
|
## Windows notes
|
|
216
247
|
|
|
217
248
|
- Use `python` (not `python3`) on this Windows setup.
|
|
@@ -18,6 +18,7 @@ Global flag notes:
|
|
|
18
18
|
- `--since <spec>` / `--until <spec>` — the time window is half-open `[since, until)`: `since` is inclusive, `until` is exclusive, so an event stamped exactly at `--until` is not included. This holds for every message-level mode (`search`, `timeline`, `engagement`, `tool-usage`). Session-level modes (`list`, `journal`, `count`) instead filter whole sessions by file mtime, so a session last written at or before `--until` is shown.
|
|
19
19
|
- `--project <name>` — case-insensitive substring filter on project directory names (encoded or decoded form). Applies to `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`, `tool-usage`. Exit 1 when nothing matches.
|
|
20
20
|
- `--tz <spec>` — display timezone: IANA name (`America/New_York`), `UTC`, or fixed offset (`+5`, `-4`, `+05:30`, `UTC-4`). Default is system local time. All displayed timestamps AND `--since/--until/--date` interpretation use this zone.
|
|
21
|
+
- **Clock format:** the report modes (`session-report`, `engagement`, `timeline`) render every time-of-day as 12-hour with the 24-hour value in parens — `7:00pm (19:00)` — and date-prefix it (`2026-06-19 7:00pm (19:00)`) when the window spans more than one day; their headers carry a `12h (24h)` label. Forensic modes (`changelog`, `tool-calls`) keep `HH:MM:SS` for density. JSON output is always ISO-8601 (unaffected by the 12-hour rendering).
|
|
21
22
|
- `--format json` (alias `--json`) — structured output on every mode except the legacy `--list`/`--list-subagents` aliases. Shapes per mode are listed below.
|
|
22
23
|
|
|
23
24
|
Legacy flags are preserved unchanged:
|
|
@@ -352,11 +353,17 @@ How it works — three deterministic rules over ONE merged stream:
|
|
|
352
353
|
3. Everything else is a break and contributes zero. A session left open with no
|
|
353
354
|
prompts accrues nothing.
|
|
354
355
|
|
|
355
|
-
Output: one row per session (active, ratio = active/elapsed,
|
|
356
|
-
first–last), a totals line (already interval-merged — safe to quote),
|
|
357
|
-
the merged stream, and (single-session view) median/p90 prompt gaps.
|
|
358
|
-
capped at 1.0: composing time leading into a chat's first prompt is
|
|
359
|
-
that chat, so raw active can slightly exceed its first–last span.
|
|
356
|
+
Output: one row per session (active, ratio = active/elapsed, `you`/`ai` message
|
|
357
|
+
counts, first–last), a totals line (already interval-merged — safe to quote),
|
|
358
|
+
breaks in the merged stream, and (single-session view) median/p90 prompt gaps.
|
|
359
|
+
Ratio is capped at 1.0: composing time leading into a chat's first prompt is
|
|
360
|
+
credited to that chat, so raw active can slightly exceed its first–last span.
|
|
361
|
+
|
|
362
|
+
Message counts are honest, not raw entry counts: `you` (`user_messages`) is real
|
|
363
|
+
typed prompts only — tool-result envelopes, hook/skill `isMeta` injections, and
|
|
364
|
+
compact continuations are excluded; `ai` (`assistant_messages`) is assistant
|
|
365
|
+
turns bearing visible text — tool-only turns don't count. Both are windowed to
|
|
366
|
+
`[since, until)`.
|
|
360
367
|
|
|
361
368
|
Scoping caveat: `--project`/`--cwd`/`--file` filter which sessions are
|
|
362
369
|
*reported*; the stream is always computed across all projects under `--root` so
|
|
@@ -372,8 +379,47 @@ Flags:
|
|
|
372
379
|
|
|
373
380
|
JSON shape: `{since, until, break_minutes, sessions: [{uuid, project, title,
|
|
374
381
|
path, first, last, elapsed_minutes, active_minutes, active_seconds, ratio,
|
|
375
|
-
user_messages}], totals: {sessions, active_minutes,
|
|
376
|
-
span_minutes}, stream_breaks: [{start, end, minutes}]}`.
|
|
382
|
+
user_messages, assistant_messages}], totals: {sessions, active_minutes,
|
|
383
|
+
active_seconds, span_minutes}, stream_breaks: [{start, end, minutes}]}`.
|
|
384
|
+
|
|
385
|
+
### `session-report`
|
|
386
|
+
|
|
387
|
+
The per-session "what did I do" day review. Same windowed, overlap-safe
|
|
388
|
+
attention engine as `engagement`, but rendered as one numbered block per
|
|
389
|
+
session, **chronological** (oldest first by first prompt), carrying everything a
|
|
390
|
+
human review needs in a single call — so a "break down my day" answer doesn't
|
|
391
|
+
require stitching `timeline` + `lookup` + `engagement` + raw message counts by
|
|
392
|
+
hand.
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
# Yesterday, every project
|
|
396
|
+
python read_transcript.py --mode session-report --date yesterday
|
|
397
|
+
|
|
398
|
+
# Just the evening — omit --date so --since/--until take effect
|
|
399
|
+
python read_transcript.py --mode session-report --since "2026-06-19 19:00" --until "2026-06-20 00:00"
|
|
400
|
+
|
|
401
|
+
# One project
|
|
402
|
+
python read_transcript.py --mode session-report --project keel --date today
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Each block shows: title, project, first–last span with **both clocks** —
|
|
406
|
+
`ran` (the session's own first→last elapsed, which can overlap other sessions)
|
|
407
|
+
and `active` (deduped attention, parallel chats never double-counted) — then
|
|
408
|
+
`you`/`assistant` message counts (same honest definitions as `engagement`),
|
|
409
|
+
files edited, the `intent` (first prompt) and `last` (final assistant message).
|
|
410
|
+
The intent/last are raw inputs for you to synthesize a one-sentence description
|
|
411
|
+
from, not the description itself. A totals line closes with deduped active time
|
|
412
|
+
and the overlap-inclusive span.
|
|
413
|
+
|
|
414
|
+
Flags: same as `engagement` (`--break`, `--date`/`--since`/`--until`,
|
|
415
|
+
`--cwd`/`--project`/`--all-projects`, `--tz`, `--exclude-current`). As with
|
|
416
|
+
`engagement`, `--date` takes precedence over `--since`/`--until`; to scope to a
|
|
417
|
+
sub-window of a day, pass `--since`/`--until` and omit `--date`.
|
|
418
|
+
|
|
419
|
+
JSON shape: `{since, until, break_minutes, sessions: [{uuid, project, title,
|
|
420
|
+
path, first, last, elapsed_minutes, active_minutes, user_messages,
|
|
421
|
+
assistant_messages, edits, intent, last_message}], totals: {sessions,
|
|
422
|
+
active_minutes, span_minutes}}`. Sessions are ordered chronologically.
|
|
377
423
|
|
|
378
424
|
### `tool-usage`
|
|
379
425
|
|
|
@@ -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
|
|
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
|
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/parser.cpython-313.pyc
ADDED
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/paths.cpython-313.pyc
ADDED
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/search.cpython-313.pyc
ADDED
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/subagents.cpython-313.pyc
ADDED
|
Binary file
|
package/skills/estack-read-claude-session-history/scripts/lib/__pycache__/tools.cpython-313.pyc
ADDED
|
Binary file
|
|
@@ -1036,6 +1036,21 @@ def _fmt_dur(td: timedelta) -> str:
|
|
|
1036
1036
|
return f"{h}h{m:02d}m" if h else f"{m}m"
|
|
1037
1037
|
|
|
1038
1038
|
|
|
1039
|
+
def _fmt_tod(dt: datetime) -> str:
|
|
1040
|
+
"""Time-of-day as 12-hour with 24-hour in parens: '7:00pm (19:00)'.
|
|
1041
|
+
|
|
1042
|
+
Computed by hand (not strftime %-I/%#I) so it's identical on every platform.
|
|
1043
|
+
"""
|
|
1044
|
+
h12 = dt.hour % 12 or 12
|
|
1045
|
+
ampm = "am" if dt.hour < 12 else "pm"
|
|
1046
|
+
return f"{h12}:{dt.minute:02d}{ampm} ({dt.hour:02d}:{dt.minute:02d})"
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _fmt_clock(dt: datetime, with_date: bool) -> str:
|
|
1050
|
+
"""A report clock value — `_fmt_tod`, date-prefixed when the window spans days."""
|
|
1051
|
+
return f"{dt:%Y-%m-%d} {_fmt_tod(dt)}" if with_date else _fmt_tod(dt)
|
|
1052
|
+
|
|
1053
|
+
|
|
1039
1054
|
_GAP_RE = re.compile(r"^(\d+)\s*(m|h)?$", re.IGNORECASE)
|
|
1040
1055
|
|
|
1041
1056
|
|
|
@@ -1112,10 +1127,9 @@ def render_timeline(data: dict, tz_label: str) -> str:
|
|
|
1112
1127
|
since, until = data["since"], data["until"]
|
|
1113
1128
|
blocks, sessions = data["blocks"], data["sessions"]
|
|
1114
1129
|
multi_day = (until - since) > timedelta(days=1)
|
|
1115
|
-
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1116
1130
|
head = (
|
|
1117
|
-
f"=== Timeline {since
|
|
1118
|
-
f"(times: {tz_label}, gap={data['gap_minutes']}m) ==="
|
|
1131
|
+
f"=== Timeline {_fmt_clock(since, True)} → {_fmt_clock(until, True)} "
|
|
1132
|
+
f"(times: {tz_label} 12h (24h), gap={data['gap_minutes']}m) ==="
|
|
1119
1133
|
)
|
|
1120
1134
|
if not blocks:
|
|
1121
1135
|
return head + "\n\n(no activity in range)"
|
|
@@ -1125,7 +1139,7 @@ def render_timeline(data: dict, tz_label: str) -> str:
|
|
|
1125
1139
|
if prev_end is not None:
|
|
1126
1140
|
out.append(f" ── idle {_fmt_dur(b['start'] - prev_end)} ──")
|
|
1127
1141
|
dur = b["end"] - b["start"]
|
|
1128
|
-
out.append(f"{b['start']
|
|
1142
|
+
out.append(f"{_fmt_clock(b['start'], multi_day)}–{_fmt_tod(b['end'])} ({_fmt_dur(dur)})")
|
|
1129
1143
|
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1]):
|
|
1130
1144
|
out.append(f" · {_session_label(sessions[f])} — {n} msgs")
|
|
1131
1145
|
prev_end = b["end"]
|
|
@@ -1135,7 +1149,7 @@ def render_timeline(data: dict, tz_label: str) -> str:
|
|
|
1135
1149
|
# no claim about user attention time. For that, use --mode engagement.
|
|
1136
1150
|
out.append(
|
|
1137
1151
|
f"Total: {len(blocks)} block(s) across a {_fmt_dur(span)} span "
|
|
1138
|
-
f"({blocks[0]['start']
|
|
1152
|
+
f"({_fmt_clock(blocks[0]['start'], multi_day)}–{_fmt_tod(blocks[-1]['end'])}), "
|
|
1139
1153
|
f"{len(sessions)} session(s)"
|
|
1140
1154
|
)
|
|
1141
1155
|
return "\n".join(out)
|
|
@@ -1202,17 +1216,38 @@ def _is_real_user_prompt(obj: dict) -> bool:
|
|
|
1202
1216
|
return False
|
|
1203
1217
|
|
|
1204
1218
|
|
|
1219
|
+
def _assistant_has_text(obj: dict) -> bool:
|
|
1220
|
+
"""True if an assistant entry carries human-visible text, not just tool_use.
|
|
1221
|
+
|
|
1222
|
+
A turn that only fires tools (no text block) is plumbing, not a reply the
|
|
1223
|
+
user reads, so it does not count as an assistant message.
|
|
1224
|
+
"""
|
|
1225
|
+
content = obj.get("message", {}).get("content", "")
|
|
1226
|
+
if isinstance(content, str):
|
|
1227
|
+
return bool(content.strip())
|
|
1228
|
+
if isinstance(content, list):
|
|
1229
|
+
return any(
|
|
1230
|
+
isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
|
|
1231
|
+
for b in content
|
|
1232
|
+
)
|
|
1233
|
+
return False
|
|
1234
|
+
|
|
1235
|
+
|
|
1205
1236
|
def _engagement_event_streams(
|
|
1206
1237
|
path: Path, since: datetime | None, until: datetime | None
|
|
1207
|
-
) -> tuple[list[datetime], list[datetime]]:
|
|
1208
|
-
"""One session's (user_events, claude_events)
|
|
1238
|
+
) -> tuple[list[datetime], list[datetime], list[datetime]]:
|
|
1239
|
+
"""One session's (user_events, claude_events, assistant_events) in [since, until).
|
|
1209
1240
|
|
|
1210
1241
|
user_events — real user prompts only (see _is_real_user_prompt).
|
|
1211
1242
|
claude_events — assistant messages and tool results: evidence Claude was
|
|
1212
1243
|
working. Used only to grant waiting-on-Claude credit for long gaps.
|
|
1244
|
+
assistant_events — assistant turns bearing visible text (see
|
|
1245
|
+
_assistant_has_text): the replies the user actually reads, counted clean of
|
|
1246
|
+
tool-only turns and tool-result envelopes.
|
|
1213
1247
|
"""
|
|
1214
1248
|
user_ev: list[datetime] = []
|
|
1215
1249
|
claude_ev: list[datetime] = []
|
|
1250
|
+
assistant_ev: list[datetime] = []
|
|
1216
1251
|
for obj in PR.parse_lines(path):
|
|
1217
1252
|
cls = PR.classify_entry(obj)
|
|
1218
1253
|
if cls in ("noise", "title", "compact"):
|
|
@@ -1229,9 +1264,12 @@ def _engagement_event_streams(
|
|
|
1229
1264
|
claude_ev.append(ts) # tool_result entries
|
|
1230
1265
|
else: # assistant
|
|
1231
1266
|
claude_ev.append(ts)
|
|
1267
|
+
if _assistant_has_text(obj):
|
|
1268
|
+
assistant_ev.append(ts)
|
|
1232
1269
|
user_ev.sort()
|
|
1233
1270
|
claude_ev.sort()
|
|
1234
|
-
|
|
1271
|
+
assistant_ev.sort()
|
|
1272
|
+
return user_ev, claude_ev, assistant_ev
|
|
1235
1273
|
|
|
1236
1274
|
|
|
1237
1275
|
def build_engagement(
|
|
@@ -1265,6 +1303,7 @@ def build_engagement(
|
|
|
1265
1303
|
|
|
1266
1304
|
user_events: dict[Path, list[datetime]] = {}
|
|
1267
1305
|
claude_events: dict[Path, list[datetime]] = {}
|
|
1306
|
+
assistant_events: dict[Path, list[datetime]] = {}
|
|
1268
1307
|
walk_dirs = P.list_projects(root)
|
|
1269
1308
|
files: list[Path] = []
|
|
1270
1309
|
for pd in walk_dirs:
|
|
@@ -1279,10 +1318,11 @@ def build_engagement(
|
|
|
1279
1318
|
for f in files:
|
|
1280
1319
|
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
1281
1320
|
continue
|
|
1282
|
-
u, c = _engagement_event_streams(f, since, until)
|
|
1321
|
+
u, c, a = _engagement_event_streams(f, since, until)
|
|
1283
1322
|
if u or c:
|
|
1284
1323
|
user_events[f] = u
|
|
1285
1324
|
claude_events[f] = c
|
|
1325
|
+
assistant_events[f] = a
|
|
1286
1326
|
|
|
1287
1327
|
stream = sorted(
|
|
1288
1328
|
(ts, f) for f, evs in user_events.items() for ts in evs
|
|
@@ -1320,6 +1360,7 @@ def build_engagement(
|
|
|
1320
1360
|
"first": evs[0],
|
|
1321
1361
|
"last": evs[-1],
|
|
1322
1362
|
"user_messages": len(evs),
|
|
1363
|
+
"assistant_messages": len(assistant_events.get(f, [])),
|
|
1323
1364
|
"active": active.get(f, timedelta()),
|
|
1324
1365
|
}
|
|
1325
1366
|
|
|
@@ -1349,10 +1390,9 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1349
1390
|
since, until = data["since"], data["until"]
|
|
1350
1391
|
sessions = data["sessions"]
|
|
1351
1392
|
multi_day = (until - since) > timedelta(days=1)
|
|
1352
|
-
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1353
1393
|
head = (
|
|
1354
|
-
f"=== Engagement {since
|
|
1355
|
-
f"(times: {tz_label}, break={data['break_minutes']}m) ==="
|
|
1394
|
+
f"=== Engagement {_fmt_clock(since, True)} → {_fmt_clock(until, True)} "
|
|
1395
|
+
f"(times: {tz_label} 12h (24h), break={data['break_minutes']}m) ==="
|
|
1356
1396
|
)
|
|
1357
1397
|
if not sessions:
|
|
1358
1398
|
return head + "\n\n(no user messages in range)"
|
|
@@ -1367,8 +1407,9 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1367
1407
|
if elapsed.total_seconds() > 0 else " — "
|
|
1368
1408
|
)
|
|
1369
1409
|
out.append(
|
|
1370
|
-
f"{_fmt_dur(s['active']):>7} ratio {ratio}
|
|
1371
|
-
f"{s['
|
|
1410
|
+
f"{_fmt_dur(s['active']):>7} ratio {ratio} "
|
|
1411
|
+
f"you {s['user_messages']:<3} ai {s['assistant_messages']:<4} "
|
|
1412
|
+
f"{_fmt_clock(s['first'], multi_day)}–{_fmt_tod(s['last'])} "
|
|
1372
1413
|
f"{_session_label(s['summary'])}"
|
|
1373
1414
|
)
|
|
1374
1415
|
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
@@ -1377,13 +1418,13 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1377
1418
|
out.append("")
|
|
1378
1419
|
out.append(
|
|
1379
1420
|
f"Total: {_fmt_dur(total_active)} active across {len(sessions)} session(s), "
|
|
1380
|
-
f"{first
|
|
1421
|
+
f"{_fmt_clock(first, multi_day)}–{_fmt_tod(last)} span ({_fmt_dur(last - first)})"
|
|
1381
1422
|
)
|
|
1382
1423
|
breaks = data["breaks"]
|
|
1383
1424
|
if breaks:
|
|
1384
1425
|
shown = breaks[:6]
|
|
1385
1426
|
items = ", ".join(
|
|
1386
|
-
f"{a
|
|
1427
|
+
f"{_fmt_clock(a, multi_day)}→{_fmt_tod(b)} ({_fmt_dur(b - a)})"
|
|
1387
1428
|
for a, b in shown
|
|
1388
1429
|
)
|
|
1389
1430
|
more = f" (+{len(breaks) - len(shown)} more)" if len(breaks) > len(shown) else ""
|
|
@@ -1394,7 +1435,7 @@ def render_engagement(data: dict, tz_label: str) -> str:
|
|
|
1394
1435
|
(f, s), = sessions.items()
|
|
1395
1436
|
# recompute the session's own user events from the stored bounds is not
|
|
1396
1437
|
# enough — pull them again (cached parse, cheap)
|
|
1397
|
-
evs, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1438
|
+
evs, _, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1398
1439
|
pct = _gap_percentiles(evs)
|
|
1399
1440
|
if pct:
|
|
1400
1441
|
out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
|
|
@@ -1432,6 +1473,7 @@ def engagement_json(data: dict) -> dict:
|
|
|
1432
1473
|
if elapsed.total_seconds() > 0 else None
|
|
1433
1474
|
),
|
|
1434
1475
|
"user_messages": s["user_messages"],
|
|
1476
|
+
"assistant_messages": s["assistant_messages"],
|
|
1435
1477
|
})
|
|
1436
1478
|
span_min = 0
|
|
1437
1479
|
if data["sessions"]:
|
|
@@ -1460,6 +1502,101 @@ def engagement_json(data: dict) -> dict:
|
|
|
1460
1502
|
}
|
|
1461
1503
|
|
|
1462
1504
|
|
|
1505
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1506
|
+
# Session-report mode — the per-session "what did I do" view
|
|
1507
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1508
|
+
# Reuses the engagement engine (windowed, overlap-safe attention time) but
|
|
1509
|
+
# renders one numbered block per session, chronological, with both clocks
|
|
1510
|
+
# (ran = own first→last span, which overlaps others; active = deduped
|
|
1511
|
+
# attention), per-role message counts, and the intent/last-message inputs a
|
|
1512
|
+
# human day-review is written from.
|
|
1513
|
+
|
|
1514
|
+
def render_session_report(data: dict, tz_label: str) -> str:
|
|
1515
|
+
since, until = data["since"], data["until"]
|
|
1516
|
+
sessions = data["sessions"]
|
|
1517
|
+
multi_day = (until - since) > timedelta(days=1)
|
|
1518
|
+
head = (
|
|
1519
|
+
f"=== Session report {_fmt_clock(since, True)} → {_fmt_clock(until, True)} "
|
|
1520
|
+
f"(times: {tz_label} 12h (24h), break={data['break_minutes']}m) ==="
|
|
1521
|
+
)
|
|
1522
|
+
if not sessions:
|
|
1523
|
+
return head + "\n\n(no user activity in range)"
|
|
1524
|
+
out = [head, ""]
|
|
1525
|
+
rows = sorted(sessions.items(), key=lambda kv: kv[1]["first"]) # chronological
|
|
1526
|
+
for i, (f, s) in enumerate(rows, 1):
|
|
1527
|
+
summary = s["summary"]
|
|
1528
|
+
elapsed = s["last"] - s["first"]
|
|
1529
|
+
title = summary.get("title") or summary.get("first_prompt") or "(untitled)"
|
|
1530
|
+
out.append(f"{i}. {title}")
|
|
1531
|
+
out.append(
|
|
1532
|
+
f" {summary['decoded_project']} · "
|
|
1533
|
+
f"{_fmt_clock(s['first'], multi_day)}–{_fmt_tod(s['last'])} "
|
|
1534
|
+
f"(ran {_fmt_dur(elapsed)} · active {_fmt_dur(s['active'])})"
|
|
1535
|
+
)
|
|
1536
|
+
out.append(
|
|
1537
|
+
f" you {s['user_messages']} msgs · "
|
|
1538
|
+
f"assistant {s['assistant_messages']} msgs · "
|
|
1539
|
+
f"{summary['edit_count']} files edited"
|
|
1540
|
+
)
|
|
1541
|
+
out.append(f" intent: {summary.get('first_prompt') or '(no user prompt)'}")
|
|
1542
|
+
out.append(f" last: {summary.get('last_assistant') or '(no assistant message)'}")
|
|
1543
|
+
out.append("")
|
|
1544
|
+
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
1545
|
+
first = min(s["first"] for s in sessions.values())
|
|
1546
|
+
last = max(s["last"] for s in sessions.values())
|
|
1547
|
+
out.append(
|
|
1548
|
+
f"Total: {len(sessions)} session(s) · {_fmt_dur(total_active)} active "
|
|
1549
|
+
f"(overlap removed) across a {_fmt_dur(last - first)} span "
|
|
1550
|
+
f"({_fmt_clock(first, multi_day)}–{_fmt_tod(last)})."
|
|
1551
|
+
)
|
|
1552
|
+
out.append(
|
|
1553
|
+
"(active = your attention, parallel chats never double-counted; "
|
|
1554
|
+
"ran = each session's own first→last span, which can overlap others.)"
|
|
1555
|
+
)
|
|
1556
|
+
return "\n".join(out)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def session_report_json(data: dict) -> dict:
|
|
1560
|
+
rows = sorted(data["sessions"].items(), key=lambda kv: kv[1]["first"])
|
|
1561
|
+
sessions_out = []
|
|
1562
|
+
total_active = timedelta()
|
|
1563
|
+
for f, s in rows:
|
|
1564
|
+
summary = s["summary"]
|
|
1565
|
+
elapsed = s["last"] - s["first"]
|
|
1566
|
+
total_active += s["active"]
|
|
1567
|
+
sessions_out.append({
|
|
1568
|
+
"uuid": summary["uuid"],
|
|
1569
|
+
"project": summary["decoded_project"],
|
|
1570
|
+
"title": summary.get("title") or summary.get("first_prompt") or "",
|
|
1571
|
+
"path": str(f),
|
|
1572
|
+
"first": s["first"].isoformat(),
|
|
1573
|
+
"last": s["last"].isoformat(),
|
|
1574
|
+
"elapsed_minutes": int(elapsed.total_seconds() // 60),
|
|
1575
|
+
"active_minutes": int(s["active"].total_seconds() // 60),
|
|
1576
|
+
"user_messages": s["user_messages"],
|
|
1577
|
+
"assistant_messages": s["assistant_messages"],
|
|
1578
|
+
"edits": summary["edit_count"],
|
|
1579
|
+
"intent": summary.get("first_prompt") or "",
|
|
1580
|
+
"last_message": summary.get("last_assistant") or "",
|
|
1581
|
+
})
|
|
1582
|
+
span_min = 0
|
|
1583
|
+
if data["sessions"]:
|
|
1584
|
+
first = min(s["first"] for s in data["sessions"].values())
|
|
1585
|
+
last = max(s["last"] for s in data["sessions"].values())
|
|
1586
|
+
span_min = int((last - first).total_seconds() // 60)
|
|
1587
|
+
return {
|
|
1588
|
+
"since": data["since"].isoformat(),
|
|
1589
|
+
"until": data["until"].isoformat(),
|
|
1590
|
+
"break_minutes": data["break_minutes"],
|
|
1591
|
+
"sessions": sessions_out,
|
|
1592
|
+
"totals": {
|
|
1593
|
+
"sessions": len(sessions_out),
|
|
1594
|
+
"active_minutes": int(total_active.total_seconds() // 60),
|
|
1595
|
+
"span_minutes": span_min,
|
|
1596
|
+
},
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
|
|
1463
1600
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1464
1601
|
# JSON builders for legacy single-file modes
|
|
1465
1602
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1552,6 +1689,7 @@ NEW_MODES = {
|
|
|
1552
1689
|
"changelog", "file-edits", "tool-calls", "tool-usage",
|
|
1553
1690
|
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1554
1691
|
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1692
|
+
"session-report",
|
|
1555
1693
|
}
|
|
1556
1694
|
|
|
1557
1695
|
ALL_MODES = LEGACY_MODES | NEW_MODES
|
|
@@ -1767,7 +1905,7 @@ def main() -> int:
|
|
|
1767
1905
|
else:
|
|
1768
1906
|
print(render_timeline(data, tz_label=args.tz or "local"))
|
|
1769
1907
|
return 0
|
|
1770
|
-
if mode
|
|
1908
|
+
if mode in ("engagement", "session-report"):
|
|
1771
1909
|
try:
|
|
1772
1910
|
break_minutes = _parse_gap(args.break_spec, default=10)
|
|
1773
1911
|
if args.date:
|
|
@@ -1786,7 +1924,7 @@ def main() -> int:
|
|
|
1786
1924
|
return 1
|
|
1787
1925
|
if report_file and e_since is None:
|
|
1788
1926
|
# Window defaults to the file's own first→last user prompt.
|
|
1789
|
-
evs, _ = _engagement_event_streams(report_file, None, None)
|
|
1927
|
+
evs, _, _ = _engagement_event_streams(report_file, None, None)
|
|
1790
1928
|
if not evs:
|
|
1791
1929
|
print("(no user messages in this session)")
|
|
1792
1930
|
return 0
|
|
@@ -1805,7 +1943,12 @@ def main() -> int:
|
|
|
1805
1943
|
root, report_dirs, report_file, e_since, e_until, break_minutes,
|
|
1806
1944
|
current_uuid, exclude_current=args.exclude_current,
|
|
1807
1945
|
)
|
|
1808
|
-
if
|
|
1946
|
+
if mode == "session-report":
|
|
1947
|
+
if fmt == "json":
|
|
1948
|
+
_print_json(session_report_json(data))
|
|
1949
|
+
else:
|
|
1950
|
+
print(render_session_report(data, tz_label=args.tz or "local"))
|
|
1951
|
+
elif fmt == "json":
|
|
1809
1952
|
_print_json(engagement_json(data))
|
|
1810
1953
|
else:
|
|
1811
1954
|
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');
|