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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elliot-stack",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
4
4
  "description": "Elliot's skill stack for Claude Code — install via npx elliot-stack@latest",
5
5
  "bin": {
6
6
  "elliot-stack": "bin/install.cjs"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: estack-read-claude-session-history
3
- version: 1.0.4
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, session diff between two
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
- └─ Every tool call (optionally filtered) ──── --mode tool-calls --tool Bash,Edit
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 `engagement`.
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"}}]}}
@@ -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"),