elliot-stack 1.0.37 → 1.0.38
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-read-claude-session-history/SKILL.md +10 -6
- package/skills/estack-read-claude-session-history/references/modes.md +33 -1
- package/skills/estack-read-claude-session-history/references/recipes.md +29 -0
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +141 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent/subagents/agent-sub1.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: estack-read-claude-session-history
|
|
3
|
-
version: 1.0
|
|
3
|
+
version: 1.1.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,
|
|
@@ -9,7 +9,8 @@ description: >-
|
|
|
9
9
|
compact"), advisor response retrieval ("what did the advisor say"), subagent
|
|
10
10
|
output collection ("get all subagent finals"), cross-project session search by
|
|
11
11
|
keyword, session listing and triage, UUID and title lookup, resume-command
|
|
12
|
-
generation, file-edit and tool-call forensics,
|
|
12
|
+
generation, file-edit and tool-call forensics, tool- and skill-usage tallies
|
|
13
|
+
("which skills do I actually use"), session diff between two
|
|
13
14
|
sessions or subagents, weekly work journal, day timeline of activity blocks
|
|
14
15
|
and idle gaps, engagement/attention-time accounting (active vs elapsed time,
|
|
15
16
|
break detection, parallel-chat-safe totals), recovering from .claude-backups
|
|
@@ -100,7 +101,8 @@ What are you trying to do?
|
|
|
100
101
|
├─ Forensics on a session
|
|
101
102
|
│ ├─ Chronological tool-call log ────────────── --mode changelog
|
|
102
103
|
│ ├─ Every file touched ─────────────────────── --mode file-edits
|
|
103
|
-
│
|
|
104
|
+
│ ├─ Every tool call (optionally filtered) ──── --mode tool-calls --tool Bash,Edit
|
|
105
|
+
│ └─ Which tools/skills do I actually use? ───── --mode tool-usage (+ scope; --tool Skill)
|
|
104
106
|
│
|
|
105
107
|
├─ Subagent (fan-out) work
|
|
106
108
|
│ ├─ List spawned subagents ─────────────────── --mode subagent-list
|
|
@@ -136,6 +138,7 @@ What are you trying to do?
|
|
|
136
138
|
| `changelog` | `--file` | `HH:MM:SS TOOL one-line-summary`, day-grouped |
|
|
137
139
|
| `file-edits` | `--file` | Unique paths sorted with op tags |
|
|
138
140
|
| `tool-calls` | `--file` (+ `--tool` filter) | Timestamped per-call blocks |
|
|
141
|
+
| `tool-usage` | `--file` or scope (+ `--tool` filter, `--include-subagents`) | Tool-call tallies by name; `Skill` calls sub-tallied by skill name (counts real invocations, not text) |
|
|
139
142
|
| `subagent-list` | `--file` | List sibling subagents with agentType + description |
|
|
140
143
|
| `subagent-finals` | `--file` | Every subagent's final assistant message |
|
|
141
144
|
| `subagent-tools` | `--subagent` | Forensics on one subagent |
|
|
@@ -152,7 +155,7 @@ What are you trying to do?
|
|
|
152
155
|
- `--root {live|mirror|snapshot-24h|snapshot-1w|snapshot-1mo|<abs-path>}` — read from a `.claude-backups` mirror or snapshot instead of live. Default `live`.
|
|
153
156
|
- `--cwd <path>` — single-project scope. Use the original working directory (e.g. `"C:\Users\2supe\Other Claude Code"`).
|
|
154
157
|
- `--all-projects` — walk every project under `--root`.
|
|
155
|
-
- `--project <name>` — filter projects by name substring, case-insensitive, matches encoded or decoded form (`--project keel`, `--project "Other Claude Code"`). Works on `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`. Use this instead of `--cwd` when you know the project's name but not its exact path. (Note: for `engagement`, scope filters which sessions are *reported* — the attention stream is always computed across all projects so parallel chats never double-count.)
|
|
158
|
+
- `--project <name>` — filter projects by name substring, case-insensitive, matches encoded or decoded form (`--project keel`, `--project "Other Claude Code"`). Works on `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`, `tool-usage`. Use this instead of `--cwd` when you know the project's name but not its exact path. (Note: for `engagement`, scope filters which sessions are *reported* — the attention stream is always computed across all projects so parallel chats never double-count.)
|
|
156
159
|
- `--file <path>` — single-session scope.
|
|
157
160
|
- `--since <spec>` / `--until <spec>` — accepts ISO date, ISO datetime, relative (`30m`, `24h`, `7d`, `1w`, `1mo`), named (`today`, `yesterday`, `now`).
|
|
158
161
|
- `--date <spec>` — single-day window for `timeline` (`--date yesterday`, `--date 2026-06-01`).
|
|
@@ -160,8 +163,8 @@ What are you trying to do?
|
|
|
160
163
|
- `--break <spec>` — break threshold for `engagement` (`10m` default; `5m` strict, `20m` forgiving). Gaps between your prompts longer than this count as breaks unless you replied right after Claude finished working.
|
|
161
164
|
- `--tz <spec>` — display timezone override (IANA name, `UTC`, or offset like `-4`). Default: system local time.
|
|
162
165
|
- `--format json` (or `--json`) — structured JSON output on every mode (except the legacy `--list`/`--list-subagents` aliases). Pipe-friendly: paths are strings, timestamps ISO.
|
|
163
|
-
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, `timeline`, and `
|
|
164
|
-
- `--include-subagents` — fold subagent finals into `brief`, `last`, `dump` output, each tagged `[subagent <id-short> · <agentType>]`.
|
|
166
|
+
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, `timeline`, `engagement`, and `tool-usage`. Useful for `tool-usage` so the very commands you're running now don't skew the tally.
|
|
167
|
+
- `--include-subagents` — fold subagent finals into `brief`, `last`, `dump` output, each tagged `[subagent <id-short> · <agentType>]`. For `tool-usage`, folds each session's subagent `tool_use` calls into the tally (the subagent is not counted as a separate session).
|
|
165
168
|
- `--force-dump` — bypass the 5 MB `dump` guard.
|
|
166
169
|
- `-n N` — count modifier (default 5 for `last`, 80 for `dump`, 10 for `resume-prev`).
|
|
167
170
|
|
|
@@ -202,6 +205,7 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
|
|
|
202
205
|
| "How much did I actually work today?" | `--mode engagement --date today` |
|
|
203
206
|
| "How much time on Keel today?" | `--mode engagement --project keel --date today` |
|
|
204
207
|
| "How long did that session take me?" | `--mode engagement --file <session.jsonl>` |
|
|
208
|
+
| "Which skills do I actually use?" | `--mode tool-usage --all-projects --tool Skill` |
|
|
205
209
|
| Feed session data into a script | any mode + `--format json` |
|
|
206
210
|
|
|
207
211
|
See `references/recipes.md` for fuller multi-step workflows.
|
|
@@ -15,7 +15,7 @@ python read_transcript.py [--root <root>] [--cwd <path> | --all-projects | --pro
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Global flag notes:
|
|
18
|
-
- `--project <name>` — case-insensitive substring filter on project directory names (encoded or decoded form). Applies to `list`, `journal`, `search`, `count`, `find`, `timeline`, `engagement`. Exit 1 when nothing matches.
|
|
18
|
+
- `--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.
|
|
19
19
|
- `--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.
|
|
20
20
|
- `--format json` (alias `--json`) — structured output on every mode except the legacy `--list`/`--list-subagents` aliases. Shapes per mode are listed below.
|
|
21
21
|
|
|
@@ -364,6 +364,37 @@ path, first, last, elapsed_minutes, active_minutes, active_seconds, ratio,
|
|
|
364
364
|
user_messages}], totals: {sessions, active_minutes, active_seconds,
|
|
365
365
|
span_minutes}, stream_breaks: [{start, end, minutes}]}`.
|
|
366
366
|
|
|
367
|
+
### `tool-usage`
|
|
368
|
+
|
|
369
|
+
Tally tool calls by tool name; `Skill` calls are sub-tallied by skill name. Answers "which tools / skills do I actually use".
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
# Every tool across one session
|
|
373
|
+
python read_transcript.py --file <path> --mode tool-usage
|
|
374
|
+
|
|
375
|
+
# Skill usage across every project (the "what skills do I actually use" question)
|
|
376
|
+
python read_transcript.py --all-projects --mode tool-usage --tool Skill
|
|
377
|
+
|
|
378
|
+
# One project, last 30 days
|
|
379
|
+
python read_transcript.py --project keel --mode tool-usage --since 30d
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Scope is `--file` (one session) or `--cwd` / `--project` / `--all-projects`. `--tool` narrows to a comma-separated subset (e.g. `--tool Skill` for a skills-only view, `--tool Bash,Edit`). `--since/--until` bound the calls by their own timestamp (not file mtime — a session modified after `--until` is still read so in-window calls are counted). `--exclude-current` drops the current session so the commands you're running now don't skew the count. `--include-subagents` folds each session's `agent-*.jsonl` tool calls into its tally — needed to capture skills invoked inside fan-out subagents (the subagent is not counted as a separate session).
|
|
383
|
+
|
|
384
|
+
Why this exists: it counts real **invocations** — a `tool_use` block whose `name` is the tool (and, for skills, `input.skill`). Text-based modes like `search --in tool_use` and `count` match the *string* "ast-grep" wherever it appears (a `CLAUDE.md` instruction, a bash command, even your own search commands this session), so they over-count. `tool-usage` keys on structure and is immune to that.
|
|
385
|
+
|
|
386
|
+
Text output: one `<count> <ToolName>` row per tool, sorted by count descending; under the `Skill` row, a tree of `<count> <skill-name>` sub-rows. A leading line gives the grand total and number of sessions with calls.
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
Tool calls (66 total across 43 session(s)):
|
|
390
|
+
66 Skill
|
|
391
|
+
├ 38 manage-e-stack
|
|
392
|
+
├ 9 commit
|
|
393
|
+
└ ...
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
JSON shape: `{total, sessions, tools: [{tool, count}], skills: [{skill, count}]}` (both lists sorted by count descending; `skills` is empty unless `Skill` calls were counted).
|
|
397
|
+
|
|
367
398
|
---
|
|
368
399
|
|
|
369
400
|
## Comparison modes
|
|
@@ -401,6 +432,7 @@ Output is prefixed `A>` / `B>` (or with subagent id shorts).
|
|
|
401
432
|
| `resume-cmd` | `{uuid, path, project, encoded, command}` |
|
|
402
433
|
| `changelog` | `[{timestamp, tool, summary}]` |
|
|
403
434
|
| `tool-calls` / `subagent-tools` | `[{timestamp, tool, summary, input}]` |
|
|
435
|
+
| `tool-usage` | `{total, sessions, tools: [{tool, count}], skills: [{skill, count}]}` |
|
|
404
436
|
| `file-edits` / `subagent-files` | `[{path, ops}]` |
|
|
405
437
|
| `search` | `[{session, mtime_iso, role, where, timestamp, window}]` |
|
|
406
438
|
| `count` | `{sessions, messages, matches}` |
|
|
@@ -245,6 +245,35 @@ python "$PY" --file <matching-session>.jsonl --mode tool-calls --tool Bash
|
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
248
|
+
## 8b. "Which skills (or tools) do I actually use?" — true invocation counts
|
|
249
|
+
|
|
250
|
+
To decide which skills to keep or prune, you need real usage counts. Do **not**
|
|
251
|
+
use `search`/`count` for this: they match the *string* (a skill name in a
|
|
252
|
+
`CLAUDE.md` instruction, a bash command, even the search commands you're running
|
|
253
|
+
right now), so they over-count. `tool-usage` counts real `tool_use` invocations —
|
|
254
|
+
for skills, the `Skill` block's `input.skill` — and is immune to that.
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
# Skills you actually invoked, ranked, across every project
|
|
258
|
+
python "$PY" --all-projects --mode tool-usage --tool Skill
|
|
259
|
+
|
|
260
|
+
# All tools (skills broken out under the Skill row)
|
|
261
|
+
python "$PY" --all-projects --mode tool-usage
|
|
262
|
+
|
|
263
|
+
# One project, last 30 days, excluding this very session
|
|
264
|
+
python "$PY" --project keel --mode tool-usage --tool Skill --since 30d --exclude-current
|
|
265
|
+
|
|
266
|
+
# Machine-readable: skills sorted by count
|
|
267
|
+
python "$PY" --all-projects --mode tool-usage --tool Skill --format json \
|
|
268
|
+
| python -c "import json,sys; [print(s['count'], s['skill']) for s in json.load(sys.stdin)['skills']]"
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A skill missing from the output was never invoked in that scope — a positive
|
|
272
|
+
inventory ("here is everything I used, and X isn't in it") is stronger evidence
|
|
273
|
+
of non-use than a search that simply returns nothing.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
248
277
|
## 9. Resume previous session in the current project
|
|
249
278
|
|
|
250
279
|
If you just `cd`'d into a project and want to pick up where you left off:
|
|
@@ -737,6 +737,130 @@ def mode_count(
|
|
|
737
737
|
return {"sessions": sessions, "messages": total_msgs, "matches": matches}
|
|
738
738
|
|
|
739
739
|
|
|
740
|
+
def _tally_tool_usage(
|
|
741
|
+
groups: list[list[Path]],
|
|
742
|
+
tool_filter: set[str] | None,
|
|
743
|
+
since: datetime | None,
|
|
744
|
+
until: datetime | None,
|
|
745
|
+
) -> tuple[dict[str, int], dict[str, int], int, int]:
|
|
746
|
+
"""Tally tool_use blocks by name (Skill sub-tallied by skill) over session groups.
|
|
747
|
+
|
|
748
|
+
Each group is the files of ONE logical session — the parent transcript plus,
|
|
749
|
+
when subagents are folded in, its `agent-*.jsonl` siblings. A group counts as
|
|
750
|
+
one session-with-calls if any of its files contributed a call.
|
|
751
|
+
|
|
752
|
+
Returns (tool_counts, skill_counts, total_calls, sessions_with_calls).
|
|
753
|
+
Keys on invocation STRUCTURE (a tool_use block's name / input.skill), so it
|
|
754
|
+
is immune to the substring false-positives that plague text search — the
|
|
755
|
+
word "ast-grep" in a CLAUDE.md or a bash command never counts as a call.
|
|
756
|
+
"""
|
|
757
|
+
tool_counts: dict[str, int] = {}
|
|
758
|
+
skill_counts: dict[str, int] = {}
|
|
759
|
+
total = 0
|
|
760
|
+
sessions_with = 0
|
|
761
|
+
window = since is not None or until is not None
|
|
762
|
+
for group in groups:
|
|
763
|
+
hit = False
|
|
764
|
+
for f in group:
|
|
765
|
+
try:
|
|
766
|
+
lines = PR.parse_lines(f)
|
|
767
|
+
except Exception as e: # noqa: BLE001 — one bad file shouldn't abort the tally
|
|
768
|
+
print(f"Error reading {f.name}: {e}", file=sys.stderr)
|
|
769
|
+
continue
|
|
770
|
+
for c in T.extract_tool_calls(lines, tool_filter):
|
|
771
|
+
if window:
|
|
772
|
+
ts = PR._parse_timestamp(c.get("timestamp"))
|
|
773
|
+
if ts is None:
|
|
774
|
+
continue
|
|
775
|
+
if ts.tzinfo is not None:
|
|
776
|
+
ts = ts.replace(tzinfo=None)
|
|
777
|
+
if since is not None and ts < since:
|
|
778
|
+
continue
|
|
779
|
+
if until is not None and ts > until:
|
|
780
|
+
continue
|
|
781
|
+
name = c["name"] or "(unknown)"
|
|
782
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
783
|
+
total += 1
|
|
784
|
+
hit = True
|
|
785
|
+
if name == "Skill":
|
|
786
|
+
sk = (c.get("input") or {}).get("skill") or "(unnamed)"
|
|
787
|
+
skill_counts[sk] = skill_counts.get(sk, 0) + 1
|
|
788
|
+
if hit:
|
|
789
|
+
sessions_with += 1
|
|
790
|
+
return tool_counts, skill_counts, total, sessions_with
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def mode_tool_usage(
|
|
794
|
+
root: Path,
|
|
795
|
+
cwd: str | None,
|
|
796
|
+
all_projects: bool,
|
|
797
|
+
file_path: Path | None,
|
|
798
|
+
since: datetime | None,
|
|
799
|
+
until: datetime | None,
|
|
800
|
+
tool_filter: set[str] | None = None,
|
|
801
|
+
project: str | None = None,
|
|
802
|
+
exclude_current: bool = False,
|
|
803
|
+
include_subagents: bool = False,
|
|
804
|
+
current_uuid: str | None = None,
|
|
805
|
+
fmt: str = "text",
|
|
806
|
+
):
|
|
807
|
+
"""Tally tool_use blocks by tool name; Skill calls sub-tallied by skill name.
|
|
808
|
+
|
|
809
|
+
Answers "which tools/skills do I actually use" by counting real invocations,
|
|
810
|
+
not text occurrences. Scope is --file (one session) or the usual
|
|
811
|
+
--cwd/--project/--all-projects. --tool narrows to a subset (e.g. --tool Skill).
|
|
812
|
+
--include-subagents folds each session's agent-*.jsonl tool calls into its tally.
|
|
813
|
+
"""
|
|
814
|
+
if file_path:
|
|
815
|
+
parents = [file_path]
|
|
816
|
+
else:
|
|
817
|
+
project_dirs = _scoped_project_dirs(root, cwd, all_projects, project)
|
|
818
|
+
if project_dirs is None:
|
|
819
|
+
return "--file, --cwd, --project, or --all-projects required"
|
|
820
|
+
parents = []
|
|
821
|
+
for pd in project_dirs:
|
|
822
|
+
# `since` is a safe lower-bound mtime pre-filter (a session whose last
|
|
823
|
+
# event predates `since` holds no in-window calls). `until` is NOT a
|
|
824
|
+
# safe mtime filter — a session modified after `until` can still hold
|
|
825
|
+
# calls inside the window — so the upper bound is applied per-call in
|
|
826
|
+
# _tally_tool_usage, mirroring timeline/engagement.
|
|
827
|
+
parents.extend(P.list_transcripts(pd, since=since))
|
|
828
|
+
if exclude_current and current_uuid:
|
|
829
|
+
parents = [f for f in parents if f.stem != current_uuid]
|
|
830
|
+
|
|
831
|
+
groups = []
|
|
832
|
+
for p in parents:
|
|
833
|
+
group = [p]
|
|
834
|
+
if include_subagents:
|
|
835
|
+
group.extend(P.list_subagents(p))
|
|
836
|
+
groups.append(group)
|
|
837
|
+
|
|
838
|
+
tool_counts, skill_counts, total, sessions_with = _tally_tool_usage(
|
|
839
|
+
groups, tool_filter, since, until
|
|
840
|
+
)
|
|
841
|
+
tools_sorted = sorted(tool_counts.items(), key=lambda x: (-x[1], x[0]))
|
|
842
|
+
skills_sorted = sorted(skill_counts.items(), key=lambda x: (-x[1], x[0]))
|
|
843
|
+
|
|
844
|
+
if fmt == "json":
|
|
845
|
+
return {
|
|
846
|
+
"total": total,
|
|
847
|
+
"sessions": sessions_with,
|
|
848
|
+
"tools": [{"tool": k, "count": v} for k, v in tools_sorted],
|
|
849
|
+
"skills": [{"skill": k, "count": v} for k, v in skills_sorted],
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if total == 0:
|
|
853
|
+
return "No tool calls found."
|
|
854
|
+
out = [f"Tool calls ({total} total across {sessions_with} session(s)):"]
|
|
855
|
+
for name, count in tools_sorted:
|
|
856
|
+
out.append(f" {count:5d} {name}")
|
|
857
|
+
if name == "Skill" and skills_sorted:
|
|
858
|
+
for i, (sk, n) in enumerate(skills_sorted):
|
|
859
|
+
glyph = "└" if i == len(skills_sorted) - 1 else "├"
|
|
860
|
+
out.append(f" {glyph} {n} {sk}")
|
|
861
|
+
return "\n".join(out)
|
|
862
|
+
|
|
863
|
+
|
|
740
864
|
def mode_journal(
|
|
741
865
|
root: Path,
|
|
742
866
|
cwd: str | None,
|
|
@@ -1339,7 +1463,7 @@ LEGACY_MODES = {"last", "advisor", "pre-compact", "dump", "search", "debug"}
|
|
|
1339
1463
|
|
|
1340
1464
|
NEW_MODES = {
|
|
1341
1465
|
"list", "lookup", "find", "resume-cmd", "brief",
|
|
1342
|
-
"changelog", "file-edits", "tool-calls",
|
|
1466
|
+
"changelog", "file-edits", "tool-calls", "tool-usage",
|
|
1343
1467
|
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1344
1468
|
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1345
1469
|
}
|
|
@@ -1391,7 +1515,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1391
1515
|
p.add_argument("--role", default="both", choices=["user", "assistant", "both"])
|
|
1392
1516
|
p.add_argument("--in", dest="in_channel", default="text",
|
|
1393
1517
|
choices=["text", "tool_use", "tool_result", "thinking", "all"])
|
|
1394
|
-
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls)")
|
|
1518
|
+
p.add_argument("--tool", help="Comma-separated tool names (for tool-calls / tool-usage)")
|
|
1395
1519
|
p.add_argument("--subagent", help="Subagent file path (for subagent-tools/files)")
|
|
1396
1520
|
p.add_argument("--file-a", dest="file_a", help="First file for diff mode")
|
|
1397
1521
|
p.add_argument("--file-b", dest="file_b", help="Second file for diff mode")
|
|
@@ -1401,7 +1525,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1401
1525
|
p.add_argument("--exclude-current", action="store_true",
|
|
1402
1526
|
help="Drop the current session (via CLAUDE_SESSION_ID) from output")
|
|
1403
1527
|
p.add_argument("--include-subagents", action="store_true",
|
|
1404
|
-
help="Fold subagent finals into brief/last/dump output"
|
|
1528
|
+
help="Fold subagent finals into brief/last/dump output; "
|
|
1529
|
+
"fold subagent tool calls into tool-usage tallies")
|
|
1405
1530
|
p.add_argument("--force-dump", action="store_true",
|
|
1406
1531
|
help="Bypass the 5MB dump-size guard")
|
|
1407
1532
|
p.add_argument("--format", default="text", choices=["text", "json"],
|
|
@@ -1544,6 +1669,19 @@ def main() -> int:
|
|
|
1544
1669
|
)
|
|
1545
1670
|
print(counts["sessions"])
|
|
1546
1671
|
return 0
|
|
1672
|
+
if mode == "tool-usage":
|
|
1673
|
+
fp = Path(args.file) if args.file else None
|
|
1674
|
+
if fp and not fp.exists():
|
|
1675
|
+
print(f"File not found: {fp}", file=sys.stderr)
|
|
1676
|
+
return 1
|
|
1677
|
+
_emit(mode_tool_usage(
|
|
1678
|
+
root, args.cwd, args.all_projects, fp, since, until,
|
|
1679
|
+
tool_filter=_split_tools(args.tool), project=args.project,
|
|
1680
|
+
exclude_current=args.exclude_current,
|
|
1681
|
+
include_subagents=args.include_subagents,
|
|
1682
|
+
current_uuid=current_uuid, fmt=fmt,
|
|
1683
|
+
))
|
|
1684
|
+
return 0
|
|
1547
1685
|
if mode == "journal":
|
|
1548
1686
|
_emit(mode_journal(root, args.cwd, args.all_projects, since, until,
|
|
1549
1687
|
current_uuid, project=args.project, fmt=fmt,
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:03Z","message":{"role":"user","content":"sub task"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:04Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"s1","name":"Skill","input":{"skill":"estack-repo-search"}}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:05Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"s2","name":"Bash","input":{"command":"ls"}}]}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-usage-parent.jsonl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"do it"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"p1","name":"Skill","input":{"skill":"commit"}}]}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-05-01T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"p2","name":"Agent","input":{"description":"sub","subagent_type":"Explore"}}]}}
|
|
@@ -110,6 +110,130 @@ def test_tool_calls_filter(cli_path, fixtures_dir):
|
|
|
110
110
|
assert "Glob" not in r.stdout
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_tool_usage_file(cli_path, fixtures_dir):
|
|
114
|
+
r = _run_cli(cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"), "--mode", "tool-usage")
|
|
115
|
+
assert r.returncode == 0
|
|
116
|
+
assert "Tool calls" in r.stdout
|
|
117
|
+
assert "Bash" in r.stdout
|
|
118
|
+
assert "Skill" in r.stdout
|
|
119
|
+
# Skill calls are sub-tallied by the actual skill name (input.skill).
|
|
120
|
+
assert "using-superpowers" in r.stdout
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_tool_usage_skill_filter(cli_path, fixtures_dir):
|
|
124
|
+
r = _run_cli(
|
|
125
|
+
cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"),
|
|
126
|
+
"--mode", "tool-usage", "--tool", "Skill",
|
|
127
|
+
)
|
|
128
|
+
assert r.returncode == 0
|
|
129
|
+
assert "Skill" in r.stdout
|
|
130
|
+
assert "using-superpowers" in r.stdout
|
|
131
|
+
# Filtering to Skill must drop every other tool.
|
|
132
|
+
assert "Bash" not in r.stdout
|
|
133
|
+
assert "Glob" not in r.stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_tool_usage_json(cli_path, fixtures_dir):
|
|
137
|
+
r = _run_cli(
|
|
138
|
+
cli_path, "--file", str(fixtures_dir / "tool-zoo.jsonl"),
|
|
139
|
+
"--mode", "tool-usage", "--format", "json",
|
|
140
|
+
)
|
|
141
|
+
assert r.returncode == 0
|
|
142
|
+
data = json.loads(r.stdout)
|
|
143
|
+
assert data["total"] == 9
|
|
144
|
+
assert data["sessions"] == 1
|
|
145
|
+
tools = {t["tool"]: t["count"] for t in data["tools"]}
|
|
146
|
+
assert tools["Skill"] == 1
|
|
147
|
+
assert tools["Bash"] == 1
|
|
148
|
+
skills = {s["skill"]: s["count"] for s in data["skills"]}
|
|
149
|
+
assert skills == {"using-superpowers": 1}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_tool_usage_scope_aggregates(cli_path, fixtures_dir, tmp_path):
|
|
153
|
+
# Two sessions in one project → counts add up across files.
|
|
154
|
+
fake_root = tmp_path / "projects"
|
|
155
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
156
|
+
fake_proj.mkdir(parents=True)
|
|
157
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "a.jsonl")
|
|
158
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "b.jsonl")
|
|
159
|
+
r = _run_cli(
|
|
160
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
161
|
+
"--mode", "tool-usage", "--format", "json",
|
|
162
|
+
)
|
|
163
|
+
assert r.returncode == 0
|
|
164
|
+
data = json.loads(r.stdout)
|
|
165
|
+
assert data["sessions"] == 2
|
|
166
|
+
assert data["total"] == 18
|
|
167
|
+
skills = {s["skill"]: s["count"] for s in data["skills"]}
|
|
168
|
+
assert skills == {"using-superpowers": 2}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_tool_usage_missing_file(cli_path, tmp_path):
|
|
172
|
+
r = _run_cli(cli_path, "--file", str(tmp_path / "nope.jsonl"), "--mode", "tool-usage")
|
|
173
|
+
assert r.returncode == 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_tool_usage_until_keeps_in_window_calls(cli_path, fixtures_dir, tmp_path):
|
|
177
|
+
# The tool-zoo calls are stamped 2026-05-01T10:00:0x. Copy the fixture, then
|
|
178
|
+
# bump its mtime far past --until. A naive mtime filter would drop the file
|
|
179
|
+
# and report zero; per-call timestamp filtering must still count the calls.
|
|
180
|
+
import os
|
|
181
|
+
fake_root = tmp_path / "projects"
|
|
182
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
183
|
+
fake_proj.mkdir(parents=True)
|
|
184
|
+
target = fake_proj / "a.jsonl"
|
|
185
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", target)
|
|
186
|
+
future = 1_900_000_000 # ~2030, well after the --until bound below
|
|
187
|
+
os.utime(target, (future, future))
|
|
188
|
+
r = _run_cli(
|
|
189
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
190
|
+
"--mode", "tool-usage", "--until", "2026-05-02", "--format", "json",
|
|
191
|
+
)
|
|
192
|
+
assert r.returncode == 0
|
|
193
|
+
data = json.loads(r.stdout)
|
|
194
|
+
assert data["total"] == 9, data # all 9 calls are inside the window
|
|
195
|
+
assert data["sessions"] == 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_tool_usage_exclude_current_drops_session(cli_path, fixtures_dir, tmp_path):
|
|
199
|
+
fake_root = tmp_path / "projects"
|
|
200
|
+
fake_proj = fake_root / "C--fake-proj"
|
|
201
|
+
fake_proj.mkdir(parents=True)
|
|
202
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "cur.jsonl")
|
|
203
|
+
shutil.copy(fixtures_dir / "tool-zoo.jsonl", fake_proj / "other.jsonl")
|
|
204
|
+
# Without exclusion: 2 sessions, 18 calls. Excluding "cur" leaves 1 session, 9.
|
|
205
|
+
r = _run_cli(
|
|
206
|
+
cli_path, "--root", str(fake_root), "--cwd", "C:\\fake\\proj",
|
|
207
|
+
"--mode", "tool-usage", "--exclude-current", "--format", "json",
|
|
208
|
+
env_overrides={"CLAUDE_SESSION_ID": "cur"},
|
|
209
|
+
)
|
|
210
|
+
assert r.returncode == 0
|
|
211
|
+
data = json.loads(r.stdout)
|
|
212
|
+
assert data["sessions"] == 1
|
|
213
|
+
assert data["total"] == 9
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_tool_usage_include_subagents(cli_path, fixtures_dir):
|
|
217
|
+
# Parent calls: Skill(commit) + Agent. Subagent calls: Skill(estack-repo-search) + Bash.
|
|
218
|
+
# --include-subagents must fold the subagent's calls in, without counting the
|
|
219
|
+
# subagent as its own session.
|
|
220
|
+
parent = fixtures_dir / "tool-usage-parent.jsonl"
|
|
221
|
+
without = json.loads(_run_cli(
|
|
222
|
+
cli_path, "--file", str(parent), "--mode", "tool-usage", "--format", "json",
|
|
223
|
+
).stdout)
|
|
224
|
+
assert without["total"] == 2
|
|
225
|
+
assert without["sessions"] == 1
|
|
226
|
+
assert {s["skill"] for s in without["skills"]} == {"commit"}
|
|
227
|
+
|
|
228
|
+
with_sub = json.loads(_run_cli(
|
|
229
|
+
cli_path, "--file", str(parent), "--mode", "tool-usage",
|
|
230
|
+
"--include-subagents", "--format", "json",
|
|
231
|
+
).stdout)
|
|
232
|
+
assert with_sub["total"] == 4 # +Skill(estack-repo-search) +Bash
|
|
233
|
+
assert with_sub["sessions"] == 1 # subagent is not a separate session
|
|
234
|
+
assert {s["skill"] for s in with_sub["skills"]} == {"commit", "estack-repo-search"}
|
|
235
|
+
|
|
236
|
+
|
|
113
237
|
def test_subagent_list(cli_path, fixtures_dir):
|
|
114
238
|
r = _run_cli(
|
|
115
239
|
cli_path, "--file", str(fixtures_dir / "subagent-parent.jsonl"),
|