elliot-stack 1.0.19 → 1.0.21
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/bin/install.cjs +66 -5
- package/package.json +1 -1
- package/skills/estack-read-claude-session-history/SKILL.md +15 -8
- package/skills/estack-read-claude-session-history/references/modes.md +62 -5
- package/skills/estack-read-claude-session-history/references/recipes.md +39 -5
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +339 -11
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -0
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -0
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +5 -1
package/bin/install.cjs
CHANGED
|
@@ -11,13 +11,74 @@ const readline = require('readline');
|
|
|
11
11
|
const HOME = os.homedir();
|
|
12
12
|
const CLAUDE_DIR = path.join(HOME, '.claude');
|
|
13
13
|
const SKILLS_DIR = path.join(CLAUDE_DIR, 'skills');
|
|
14
|
-
const BACKUP_DIR = path.join(
|
|
14
|
+
const BACKUP_DIR = path.join(HOME, '.estack-backup');
|
|
15
15
|
const CHECKSUMS_FILE = path.join(CLAUDE_DIR, '.estack-checksums.json');
|
|
16
16
|
const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
|
|
17
17
|
const PACKAGE_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
18
18
|
const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
|
|
19
19
|
const PACKAGE_HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
20
20
|
|
|
21
|
+
// ── Migrate backup dir from old location (inside .claude) to user root ──────
|
|
22
|
+
(function migrateBackupDir() {
|
|
23
|
+
const OLD_BACKUP_DIR = path.join(CLAUDE_DIR, '.estack-backup');
|
|
24
|
+
if (!fs.existsSync(OLD_BACKUP_DIR)) return;
|
|
25
|
+
if (fs.existsSync(BACKUP_DIR)) return; // new location already exists, leave both alone
|
|
26
|
+
const silent = process.argv.includes('--silent');
|
|
27
|
+
const isDryRun = process.argv.includes('--dry-run') ||
|
|
28
|
+
(!__dirname.includes('node_modules') && !process.argv.includes('--install'));
|
|
29
|
+
if (isDryRun) {
|
|
30
|
+
if (!silent) {
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
'estack: [dry run] Would move backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
fs.renameSync(OLD_BACKUP_DIR, BACKUP_DIR);
|
|
39
|
+
if (!silent) {
|
|
40
|
+
process.stderr.write(
|
|
41
|
+
'estack: moved backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// rename across drives/filesystems — fall back to copy+delete
|
|
46
|
+
try {
|
|
47
|
+
copyDirRaw(OLD_BACKUP_DIR, BACKUP_DIR);
|
|
48
|
+
removeDirRaw(OLD_BACKUP_DIR);
|
|
49
|
+
if (!silent) {
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
'estack: migrated backup dir from ~/.claude/.estack-backup/ to ~/.estack-backup/\n'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} catch (e2) {
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
'estack: WARNING — could not migrate backup dir from ' + OLD_BACKUP_DIR +
|
|
57
|
+
' to ' + BACKUP_DIR + ': ' + e2.message + '\n'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
function copyDirRaw(src, dest) {
|
|
64
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
65
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
66
|
+
const s = path.join(src, entry.name);
|
|
67
|
+
const d = path.join(dest, entry.name);
|
|
68
|
+
if (entry.isDirectory()) copyDirRaw(s, d);
|
|
69
|
+
else fs.copyFileSync(s, d);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeDirRaw(dir) {
|
|
74
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
75
|
+
const full = path.join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) removeDirRaw(full);
|
|
77
|
+
else fs.unlinkSync(full);
|
|
78
|
+
}
|
|
79
|
+
fs.rmdirSync(dir);
|
|
80
|
+
}
|
|
81
|
+
|
|
21
82
|
// ── Flags ──────────────────────────────────────────────────────────────────
|
|
22
83
|
const SILENT = process.argv.includes('--silent');
|
|
23
84
|
const STARTUP = process.argv.includes('--startup');
|
|
@@ -560,7 +621,7 @@ async function main() {
|
|
|
560
621
|
if (modifiedAction === 'merge') {
|
|
561
622
|
if (!DRY_RUN) backupSkill(name);
|
|
562
623
|
mergedSkills.push(name);
|
|
563
|
-
console.log((DRY_RUN ? ' [dry run] Would back up ' : ' Backed up ') + name + ' → ~/.
|
|
624
|
+
console.log((DRY_RUN ? ' [dry run] Would back up ' : ' Backed up ') + name + ' → ~/.estack-backup/' + name);
|
|
564
625
|
}
|
|
565
626
|
// overwrite or merge — fall through to install
|
|
566
627
|
} else if (!needsUpdate.includes(name) && fs.existsSync(path.join(SKILLS_DIR, name))) {
|
|
@@ -595,7 +656,7 @@ async function main() {
|
|
|
595
656
|
if (modifiedAction === 'merge') {
|
|
596
657
|
if (!DRY_RUN) backupHook(filename);
|
|
597
658
|
mergedHooks.push(filename);
|
|
598
|
-
console.log((DRY_RUN ? ' [dry run] Would back up hook ' : ' Backed up hook ') + filename + ' → ~/.
|
|
659
|
+
console.log((DRY_RUN ? ' [dry run] Would back up hook ' : ' Backed up hook ') + filename + ' → ~/.estack-backup/hooks/' + filename);
|
|
599
660
|
}
|
|
600
661
|
// overwrite or merge — fall through to install
|
|
601
662
|
} else if (!hooksNeedingUpdate.includes(filename) && fs.existsSync(path.join(HOOKS_DIR, filename))) {
|
|
@@ -647,12 +708,12 @@ async function main() {
|
|
|
647
708
|
if (mergedSkills.length > 0) {
|
|
648
709
|
console.log('\nLocal changes backed up for: ' + mergedSkills.join(', '));
|
|
649
710
|
console.log('Ask Claude to merge your changes:');
|
|
650
|
-
console.log(' "Merge my estack changes from ~/.
|
|
711
|
+
console.log(' "Merge my estack changes from ~/.estack-backup/"');
|
|
651
712
|
}
|
|
652
713
|
|
|
653
714
|
if (mergedHooks.length > 0) {
|
|
654
715
|
console.log('\nLocal hook changes backed up for: ' + mergedHooks.join(', '));
|
|
655
|
-
console.log('Backed up to ~/.
|
|
716
|
+
console.log('Backed up to ~/.estack-backup/hooks/');
|
|
656
717
|
}
|
|
657
718
|
|
|
658
719
|
if (DRY_RUN) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: estack-read-claude-session-history
|
|
3
|
-
description: (read-claude-session-history) Invoke for ANY task involving Claude Code session history, transcripts, or .jsonl files — this is the only way to read, parse, or search them; do not attempt to use Bash or Read on .jsonl directly. Use for: recovering context after /compact ("what were we doing before compact"), advisor response retrieval ("what did the advisor say"), subagent output collection ("get all subagent finals"), cross-project session search by keyword, session listing and triage, UUID and title lookup, resume-command generation, file-edit and tool-call forensics, session diff between two sessions or subagents, weekly work journal, day timeline of activity blocks and idle gaps, recovering from .claude-backups after data loss, session count queries, and reading the last agent message before a crash or interrupt. Trigger phrases: "session history", "before compact", "what did claude do", "what did I work on", "search my sessions", "find that session", "what did the advisor say", "what did the agent edit", "from the backup", "list my sessions", "subagent outputs", "session journal", "resume previous", "which files did claude touch", "go back and look", "what did I do yesterday", "where did my day go", "timeline of my day", "how much time on".
|
|
3
|
+
description: (read-claude-session-history) Invoke for ANY task involving Claude Code session history, transcripts, or .jsonl files — this is the only way to read, parse, or search them; do not attempt to use Bash or Read on .jsonl directly. Use for: recovering context after /compact ("what were we doing before compact"), advisor response retrieval ("what did the advisor say"), subagent output collection ("get all subagent finals"), cross-project session search by keyword, session listing and triage, UUID and title lookup, resume-command generation, file-edit and tool-call forensics, session diff between two sessions or subagents, weekly work journal, day timeline of activity blocks and idle gaps, engagement/attention-time accounting (active vs elapsed time, break detection, parallel-chat-safe totals), recovering from .claude-backups after data loss, session count queries, and reading the last agent message before a crash or interrupt. Trigger phrases: "session history", "before compact", "what did claude do", "what did I work on", "search my sessions", "find that session", "what did the advisor say", "what did the agent edit", "from the backup", "list my sessions", "subagent outputs", "session journal", "resume previous", "which files did claude touch", "go back and look", "what did I do yesterday", "where did my day go", "timeline of my day", "how much time on", "how long did that actually take", "how much did I actually work", "active time", "time I spent".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Read Claude Session History
|
|
@@ -35,6 +35,9 @@ python "$PY" --file <parent.jsonl> --mode subagent-finals
|
|
|
35
35
|
# Block-grouped timeline of a whole day across all sessions, with idle gaps
|
|
36
36
|
python "$PY" --mode timeline --date yesterday
|
|
37
37
|
|
|
38
|
+
# How much focused time did today actually consume? (your attention, not Claude's)
|
|
39
|
+
python "$PY" --mode engagement --date today
|
|
40
|
+
|
|
38
41
|
# Any mode as structured JSON for piping into the next step
|
|
39
42
|
python "$PY" --mode list --project keel --since 7d --format json
|
|
40
43
|
```
|
|
@@ -85,8 +88,8 @@ What are you trying to do?
|
|
|
85
88
|
│
|
|
86
89
|
├─ Cross-cutting reporting
|
|
87
90
|
│ ├─ "What did I do this week?" ──────────────── --mode journal --since 7d
|
|
88
|
-
│ ├─ "
|
|
89
|
-
│ ├─ "How
|
|
91
|
+
│ ├─ "What was I doing, when?" / day map ─────── --mode timeline --date yesterday
|
|
92
|
+
│ ├─ "How long did X actually take ME?" ──────── --mode engagement --date … | --project … | --file …
|
|
90
93
|
│ ├─ Count sessions matching a query ─────────── --mode count --query …
|
|
91
94
|
│ └─ Resume where I left off in this project ─── --mode resume-prev --cwd …
|
|
92
95
|
│
|
|
@@ -119,7 +122,8 @@ What are you trying to do?
|
|
|
119
122
|
| `resume-prev` | `--cwd` | Banner + dump-style tail of last 10 exchanges |
|
|
120
123
|
| `count` | `--query` (+ scope) | `<N>` to stdout, summary to stderr |
|
|
121
124
|
| `journal` | `--since` (+ scope) | Per-session 5-line block: date·uuid / prompt / ended / edits / tools |
|
|
122
|
-
| `timeline` | `--date` or `--since/--until` (defaults: today, all projects) |
|
|
125
|
+
| `timeline` | `--date` or `--since/--until` (defaults: today, all projects) | Map of WHAT was active WHEN: blocks + idle gaps (no attention claim — that's `engagement`) |
|
|
126
|
+
| `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 |
|
|
123
127
|
| `diff` | `--file-a` + `--file-b` OR `--subagents-of` | Timestamp-interleaved A>/B> output |
|
|
124
128
|
|
|
125
129
|
## Global flags
|
|
@@ -127,14 +131,15 @@ What are you trying to do?
|
|
|
127
131
|
- `--root {live|mirror|snapshot-24h|snapshot-1w|snapshot-1mo|<abs-path>}` — read from a `.claude-backups` mirror or snapshot instead of live. Default `live`.
|
|
128
132
|
- `--cwd <path>` — single-project scope. Use the original working directory (e.g. `"C:\Users\2supe\Other Claude Code"`).
|
|
129
133
|
- `--all-projects` — walk every project under `--root`.
|
|
130
|
-
- `--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`. Use this instead of `--cwd` when you know the project's name but not its exact path.
|
|
134
|
+
- `--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.)
|
|
131
135
|
- `--file <path>` — single-session scope.
|
|
132
136
|
- `--since <spec>` / `--until <spec>` — accepts ISO date, ISO datetime, relative (`30m`, `24h`, `7d`, `1w`, `1mo`), named (`today`, `yesterday`, `now`).
|
|
133
137
|
- `--date <spec>` — single-day window for `timeline` (`--date yesterday`, `--date 2026-06-01`).
|
|
134
138
|
- `--gap <spec>` — idle-gap threshold for `timeline` blocks (`15m` default, `1h`).
|
|
139
|
+
- `--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.
|
|
135
140
|
- `--tz <spec>` — display timezone override (IANA name, `UTC`, or offset like `-4`). Default: system local time.
|
|
136
141
|
- `--format json` (or `--json`) — structured JSON output on every mode (except the legacy `--list`/`--list-subagents` aliases). Pipe-friendly: paths are strings, timestamps ISO.
|
|
137
|
-
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, and `
|
|
142
|
+
- `--exclude-current` — drop the current session (detected via `CLAUDE_SESSION_ID`) from `list`, `journal`, `search`, `count`, `timeline`, and `engagement`.
|
|
138
143
|
- `--include-subagents` — fold subagent finals into `brief`, `last`, `dump` output, each tagged `[subagent <id-short> · <agentType>]`.
|
|
139
144
|
- `--force-dump` — bypass the 5 MB `dump` guard.
|
|
140
145
|
- `-n N` — count modifier (default 5 for `last`, 80 for `dump`, 10 for `resume-prev`).
|
|
@@ -172,8 +177,10 @@ See `references/recipes.md` → "Deletion-incident recovery" for the full playbo
|
|
|
172
177
|
| Find "that session where I asked about supabase rate limits" | `--mode search --all-projects --query "supabase rate limits"` |
|
|
173
178
|
| Resume a project after a few days away | `--mode resume-prev --cwd "<project path>"` |
|
|
174
179
|
| Daily/weekly journal | `--mode journal --since 7d --all-projects` |
|
|
175
|
-
| "Where did yesterday go?" | `--mode timeline --date yesterday` |
|
|
176
|
-
| "How much
|
|
180
|
+
| "Where did yesterday go?" (map of activity) | `--mode timeline --date yesterday` |
|
|
181
|
+
| "How much did I actually work today?" | `--mode engagement --date today` |
|
|
182
|
+
| "How much time on Keel today?" | `--mode engagement --project keel --date today` |
|
|
183
|
+
| "How long did that session take me?" | `--mode engagement --file <session.jsonl>` |
|
|
177
184
|
| Feed session data into a script | any mode + `--format json` |
|
|
178
185
|
|
|
179
186
|
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`. 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`. 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
|
|
|
@@ -277,8 +277,9 @@ JSON shape: array of session-summary objects (same fields as `list`).
|
|
|
277
277
|
### `timeline`
|
|
278
278
|
|
|
279
279
|
Block-grouped activity timeline across all sessions in a time window, with idle
|
|
280
|
-
gaps
|
|
281
|
-
|
|
280
|
+
gaps. Answers "what was I doing, when?". It is a *map* of session activity —
|
|
281
|
+
Claude's work included — and makes no claim about attention time; for "how much
|
|
282
|
+
time did this consume?" use `engagement`. Defaults to all projects and today.
|
|
282
283
|
|
|
283
284
|
```bash
|
|
284
285
|
# Yesterday, everything
|
|
@@ -295,7 +296,7 @@ How it works: every signal-message timestamp in the window is an activity event;
|
|
|
295
296
|
events across all sessions are merged chronologically and grouped into blocks
|
|
296
297
|
separated by gaps longer than `--gap` (default 15m). Each block lists the sessions
|
|
297
298
|
active in it with message counts; idle gaps are printed between blocks; a totals
|
|
298
|
-
line gives block count,
|
|
299
|
+
line gives block count, span, and session count.
|
|
299
300
|
|
|
300
301
|
Flags:
|
|
301
302
|
- `--date <spec>` — single-day window (midnight to midnight). Wins over `--since/--until`.
|
|
@@ -306,7 +307,62 @@ Flags:
|
|
|
306
307
|
|
|
307
308
|
JSON shape: `{since, until, gap_minutes, blocks: [{start, end, duration_minutes,
|
|
308
309
|
sessions: [{uuid, project, title, path, events}]}], totals: {blocks,
|
|
309
|
-
|
|
310
|
+
span_minutes, sessions}}`.
|
|
311
|
+
|
|
312
|
+
### `engagement`
|
|
313
|
+
|
|
314
|
+
Attention-time accounting: how much focused time a window, project, or session
|
|
315
|
+
actually consumed — *your* time, not Claude's. Answers "how long did X actually
|
|
316
|
+
take me?". Defaults to all projects and today; with `--file` and no time flags,
|
|
317
|
+
the window is the session's own first→last prompt.
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
# Today's real work time, everything
|
|
321
|
+
python read_transcript.py --mode engagement --date today
|
|
322
|
+
|
|
323
|
+
# One project over a week
|
|
324
|
+
python read_transcript.py --mode engagement --project keel --since 7d
|
|
325
|
+
|
|
326
|
+
# One session, strict 5-minute break threshold
|
|
327
|
+
python read_transcript.py --mode engagement --file <session.jsonl> --break 5m
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
How it works — three deterministic rules over ONE merged stream:
|
|
331
|
+
|
|
332
|
+
1. Real user prompts (typed messages and slash commands — not tool results,
|
|
333
|
+
hook/skill injections, or compact continuations) from EVERY project are
|
|
334
|
+
merged into a single chronological stream. A gap between consecutive prompts
|
|
335
|
+
≤ `--break` (default 10m) counts fully as active time, attributed to the
|
|
336
|
+
session of the *later* prompt — the chat being read/typed in. One stream
|
|
337
|
+
means a moment of wall clock is never counted twice across parallel chats.
|
|
338
|
+
2. A longer gap still counts in full if Claude was working in that session
|
|
339
|
+
during the gap and the user replied within `--break` of Claude's last event
|
|
340
|
+
(sitting-there-waiting credit). Long runs you walked away from get nothing.
|
|
341
|
+
3. Everything else is a break and contributes zero. A session left open with no
|
|
342
|
+
prompts accrues nothing.
|
|
343
|
+
|
|
344
|
+
Output: one row per session (active, ratio = active/elapsed, prompt count,
|
|
345
|
+
first–last), a totals line (already interval-merged — safe to quote), breaks in
|
|
346
|
+
the merged stream, and (single-session view) median/p90 prompt gaps. Ratio is
|
|
347
|
+
capped at 1.0: composing time leading into a chat's first prompt is credited to
|
|
348
|
+
that chat, so raw active can slightly exceed its first–last span.
|
|
349
|
+
|
|
350
|
+
Scoping caveat: `--project`/`--cwd`/`--file` filter which sessions are
|
|
351
|
+
*reported*; the stream is always computed across all projects under `--root` so
|
|
352
|
+
parallel-chat math stays correct. A scoped total can be less than the global
|
|
353
|
+
total for the same window.
|
|
354
|
+
|
|
355
|
+
Flags:
|
|
356
|
+
- `--break <spec>` — break threshold: `5m`, `20m`, `1h` (default 10m).
|
|
357
|
+
- `--date` / `--since` / `--until` — window (same semantics as timeline).
|
|
358
|
+
- `--cwd` / `--project` / `--all-projects` / `--file` — reporting scope.
|
|
359
|
+
- `--tz` — display timezone.
|
|
360
|
+
- `--exclude-current` — drop the current session from stream and report.
|
|
361
|
+
|
|
362
|
+
JSON shape: `{since, until, break_minutes, sessions: [{uuid, project, title,
|
|
363
|
+
path, first, last, elapsed_minutes, active_minutes, active_seconds, ratio,
|
|
364
|
+
user_messages}], totals: {sessions, active_minutes, active_seconds,
|
|
365
|
+
span_minutes}, stream_breaks: [{start, end, minutes}]}`.
|
|
310
366
|
|
|
311
367
|
---
|
|
312
368
|
|
|
@@ -353,6 +409,7 @@ Output is prefixed `A>` / `B>` (or with subagent id shorts).
|
|
|
353
409
|
| `resume-prev` | `{session, path, mtime_iso, messages}` |
|
|
354
410
|
| `diff` | `{a, b, messages: [{source, role, timestamp, text}]}` |
|
|
355
411
|
| `timeline` | see the `timeline` section above |
|
|
412
|
+
| `engagement` | see the `engagement` section above |
|
|
356
413
|
|
|
357
414
|
Session-summary object fields: `path, uuid, mtime, mtime_iso, size, exists, title,
|
|
358
415
|
first_prompt, last_assistant, last_activity, msg_count, edit_count, tool_counts,
|
|
@@ -133,10 +133,44 @@ python "$PY" --mode timeline --since 2026-06-01 --until 2026-06-03
|
|
|
133
133
|
|
|
134
134
|
Reading the output: each block is a contiguous stretch of activity (events ≤ gap
|
|
135
135
|
apart); the sessions inside it are listed with message counts; `── idle Xm ──`
|
|
136
|
-
lines mark the breaks; the totals line gives
|
|
136
|
+
lines mark the breaks; the totals line gives block count, span, and session count.
|
|
137
137
|
|
|
138
|
-
Caveat:
|
|
139
|
-
attention
|
|
138
|
+
Caveat: timeline maps *session* activity (Claude's work included) — it makes no
|
|
139
|
+
claim about your attention time. For "how long did this actually take ME?", use
|
|
140
|
+
`--mode engagement` (recipe 5d).
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 5d. Attention accounting ("how long did X actually take me?")
|
|
145
|
+
|
|
146
|
+
`engagement` measures *your* time, not the session's. It merges your real
|
|
147
|
+
prompts from ALL projects into one stream, so two parallel chats split the
|
|
148
|
+
clock instead of double-counting it, and long Claude runs you sat waiting on
|
|
149
|
+
are credited (you replied right after Claude finished) while runs you walked
|
|
150
|
+
away from are not.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# How much focused time did today actually consume?
|
|
154
|
+
python "$PY" --mode engagement --date today
|
|
155
|
+
|
|
156
|
+
# One project over a week
|
|
157
|
+
python "$PY" --mode engagement --project keel --since 7d
|
|
158
|
+
|
|
159
|
+
# One session, window auto-derived from its first→last prompt
|
|
160
|
+
python "$PY" --mode engagement --file <session.jsonl>
|
|
161
|
+
|
|
162
|
+
# Strict mode: anything over 5 minutes quiet is a break
|
|
163
|
+
python "$PY" --mode engagement --date today --break 5m
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Reading the output: one row per session (`active / ratio / msgs / first–last`),
|
|
167
|
+
a total line (sum of attributed time — already interval-merged, safe to quote),
|
|
168
|
+
and the breaks in the merged stream. `ratio` = active/elapsed; 0.50 means half
|
|
169
|
+
the wall-clock span was real attention.
|
|
170
|
+
|
|
171
|
+
Scoping caveat: `--project`/`--file` filter which sessions are *reported*; the
|
|
172
|
+
attention stream is always computed across all projects so the math stays
|
|
173
|
+
honest. A scoped total can therefore be less than the same window's global total.
|
|
140
174
|
|
|
141
175
|
---
|
|
142
176
|
|
|
@@ -150,8 +184,8 @@ git-bash) — they work exactly as written:
|
|
|
150
184
|
python "$PY" --mode list --all-projects --since yesterday --format json \
|
|
151
185
|
| python -c "import json,sys; [print(s['path']) for s in json.load(sys.stdin)]"
|
|
152
186
|
|
|
153
|
-
# Machine-readable day totals
|
|
154
|
-
python "$PY" --mode
|
|
187
|
+
# Machine-readable day totals (attention time → engagement, not timeline)
|
|
188
|
+
python "$PY" --mode engagement --date yesterday --format json \
|
|
155
189
|
| python -c "import json,sys; t=json.load(sys.stdin)['totals']; print(t['active_minutes'], 'min across', t['sessions'], 'sessions')"
|
|
156
190
|
```
|
|
157
191
|
|
|
@@ -829,10 +829,10 @@ def _fmt_dur(td: timedelta) -> str:
|
|
|
829
829
|
_GAP_RE = re.compile(r"^(\d+)\s*(m|h)?$", re.IGNORECASE)
|
|
830
830
|
|
|
831
831
|
|
|
832
|
-
def _parse_gap(spec: str | None) -> int:
|
|
833
|
-
"""Parse a
|
|
832
|
+
def _parse_gap(spec: str | None, default: int = 15) -> int:
|
|
833
|
+
"""Parse a gap/break spec ('15m', '1h', '20') into minutes."""
|
|
834
834
|
if not spec:
|
|
835
|
-
return
|
|
835
|
+
return default
|
|
836
836
|
m = _GAP_RE.match(spec.strip())
|
|
837
837
|
if not m:
|
|
838
838
|
raise ValueError(f"Unrecognized gap spec: {spec!r}. Use forms like 15m or 1h.")
|
|
@@ -910,22 +910,21 @@ def render_timeline(data: dict, tz_label: str) -> str:
|
|
|
910
910
|
if not blocks:
|
|
911
911
|
return head + "\n\n(no activity in range)"
|
|
912
912
|
out = [head, ""]
|
|
913
|
-
active = timedelta()
|
|
914
913
|
prev_end: datetime | None = None
|
|
915
914
|
for b in blocks:
|
|
916
915
|
if prev_end is not None:
|
|
917
916
|
out.append(f" ── idle {_fmt_dur(b['start'] - prev_end)} ──")
|
|
918
917
|
dur = b["end"] - b["start"]
|
|
919
|
-
active += dur
|
|
920
918
|
out.append(f"{b['start'].strftime(tfmt)}–{b['end'].strftime('%H:%M')} ({_fmt_dur(dur)})")
|
|
921
919
|
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1]):
|
|
922
920
|
out.append(f" · {_session_label(sessions[f])} — {n} msgs")
|
|
923
921
|
prev_end = b["end"]
|
|
924
922
|
span = blocks[-1]["end"] - blocks[0]["start"]
|
|
925
923
|
out.append("")
|
|
924
|
+
# Timeline is a map of WHEN sessions were active (Claude included) — it makes
|
|
925
|
+
# no claim about user attention time. For that, use --mode engagement.
|
|
926
926
|
out.append(
|
|
927
|
-
f"Total: {len(blocks)}
|
|
928
|
-
f"within a {_fmt_dur(span)} span "
|
|
927
|
+
f"Total: {len(blocks)} block(s) across a {_fmt_dur(span)} span "
|
|
929
928
|
f"({blocks[0]['start'].strftime(tfmt)}–{blocks[-1]['end'].strftime('%H:%M')}), "
|
|
930
929
|
f"{len(sessions)} session(s)"
|
|
931
930
|
)
|
|
@@ -935,10 +934,8 @@ def render_timeline(data: dict, tz_label: str) -> str:
|
|
|
935
934
|
def timeline_json(data: dict) -> dict:
|
|
936
935
|
sessions = data["sessions"]
|
|
937
936
|
blocks_out = []
|
|
938
|
-
active_min = 0
|
|
939
937
|
for b in data["blocks"]:
|
|
940
938
|
dur_min = int((b["end"] - b["start"]).total_seconds() // 60)
|
|
941
|
-
active_min += dur_min
|
|
942
939
|
blocks_out.append({
|
|
943
940
|
"start": b["start"].isoformat(),
|
|
944
941
|
"end": b["end"].isoformat(),
|
|
@@ -954,6 +951,11 @@ def timeline_json(data: dict) -> dict:
|
|
|
954
951
|
for f, n in sorted(b["counts"].items(), key=lambda x: -x[1])
|
|
955
952
|
],
|
|
956
953
|
})
|
|
954
|
+
span_min = 0
|
|
955
|
+
if data["blocks"]:
|
|
956
|
+
span_min = int(
|
|
957
|
+
(data["blocks"][-1]["end"] - data["blocks"][0]["start"]).total_seconds() // 60
|
|
958
|
+
)
|
|
957
959
|
return {
|
|
958
960
|
"since": data["since"].isoformat(),
|
|
959
961
|
"until": data["until"].isoformat(),
|
|
@@ -961,12 +963,293 @@ def timeline_json(data: dict) -> dict:
|
|
|
961
963
|
"blocks": blocks_out,
|
|
962
964
|
"totals": {
|
|
963
965
|
"blocks": len(blocks_out),
|
|
964
|
-
"
|
|
966
|
+
"span_minutes": span_min,
|
|
965
967
|
"sessions": len(sessions),
|
|
966
968
|
},
|
|
967
969
|
}
|
|
968
970
|
|
|
969
971
|
|
|
972
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
973
|
+
# Engagement mode — user attention time, not session activity
|
|
974
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
def _is_real_user_prompt(obj: dict) -> bool:
|
|
977
|
+
"""True only for an actual human action: typed prompt or slash command.
|
|
978
|
+
|
|
979
|
+
Excludes tool results (user-role, no text blocks), hook/skill injections
|
|
980
|
+
(isMeta), and compact continuations (classified upstream).
|
|
981
|
+
"""
|
|
982
|
+
if obj.get("isMeta"):
|
|
983
|
+
return False
|
|
984
|
+
content = obj.get("message", {}).get("content", "")
|
|
985
|
+
if isinstance(content, str):
|
|
986
|
+
return bool(content.strip())
|
|
987
|
+
if isinstance(content, list):
|
|
988
|
+
return any(
|
|
989
|
+
isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()
|
|
990
|
+
for b in content
|
|
991
|
+
)
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _engagement_event_streams(
|
|
996
|
+
path: Path, since: datetime | None, until: datetime | None
|
|
997
|
+
) -> tuple[list[datetime], list[datetime]]:
|
|
998
|
+
"""One session's (user_events, claude_events) inside [since, until).
|
|
999
|
+
|
|
1000
|
+
user_events — real user prompts only (see _is_real_user_prompt).
|
|
1001
|
+
claude_events — assistant messages and tool results: evidence Claude was
|
|
1002
|
+
working. Used only to grant waiting-on-Claude credit for long gaps.
|
|
1003
|
+
"""
|
|
1004
|
+
user_ev: list[datetime] = []
|
|
1005
|
+
claude_ev: list[datetime] = []
|
|
1006
|
+
for obj in PR.parse_lines(path):
|
|
1007
|
+
cls = PR.classify_entry(obj)
|
|
1008
|
+
if cls in ("noise", "title", "compact"):
|
|
1009
|
+
continue
|
|
1010
|
+
ts = PR._parse_timestamp(obj.get("timestamp"))
|
|
1011
|
+
if ts is None or (since and ts < since) or (until and ts >= until):
|
|
1012
|
+
continue
|
|
1013
|
+
if cls == "user":
|
|
1014
|
+
if obj.get("isMeta"):
|
|
1015
|
+
continue
|
|
1016
|
+
if _is_real_user_prompt(obj):
|
|
1017
|
+
user_ev.append(ts)
|
|
1018
|
+
else:
|
|
1019
|
+
claude_ev.append(ts) # tool_result entries
|
|
1020
|
+
else: # assistant
|
|
1021
|
+
claude_ev.append(ts)
|
|
1022
|
+
user_ev.sort()
|
|
1023
|
+
claude_ev.sort()
|
|
1024
|
+
return user_ev, claude_ev
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def build_engagement(
|
|
1028
|
+
root: Path,
|
|
1029
|
+
report_dirs: list[Path] | None,
|
|
1030
|
+
report_file: Path | None,
|
|
1031
|
+
since: datetime,
|
|
1032
|
+
until: datetime,
|
|
1033
|
+
break_minutes: int,
|
|
1034
|
+
current_uuid: str | None,
|
|
1035
|
+
exclude_current: bool = False,
|
|
1036
|
+
) -> dict:
|
|
1037
|
+
"""Attention-time accounting over ONE merged user-prompt stream.
|
|
1038
|
+
|
|
1039
|
+
Real user prompts from EVERY project are merged into a single global
|
|
1040
|
+
stream, so a moment of wall-clock time is never counted twice across
|
|
1041
|
+
parallel chats. Three rules:
|
|
1042
|
+
|
|
1043
|
+
1. A gap between consecutive prompts ≤ break_minutes counts fully as
|
|
1044
|
+
active time, attributed to the session of the LATER prompt (that's
|
|
1045
|
+
the chat being read/typed in).
|
|
1046
|
+
2. A longer gap still counts in full if Claude was working in the later
|
|
1047
|
+
prompt's session during the gap AND the user replied within
|
|
1048
|
+
break_minutes of Claude's last event (sitting-there-waiting credit).
|
|
1049
|
+
3. Anything else is a break: contributes nothing.
|
|
1050
|
+
|
|
1051
|
+
report_dirs/report_file only filter which sessions are REPORTED — the
|
|
1052
|
+
stream itself always spans all projects under root for correctness.
|
|
1053
|
+
"""
|
|
1054
|
+
import bisect
|
|
1055
|
+
|
|
1056
|
+
user_events: dict[Path, list[datetime]] = {}
|
|
1057
|
+
claude_events: dict[Path, list[datetime]] = {}
|
|
1058
|
+
walk_dirs = P.list_projects(root)
|
|
1059
|
+
files: list[Path] = []
|
|
1060
|
+
for pd in walk_dirs:
|
|
1061
|
+
# mtime >= since only; a session still active after `until` may hold
|
|
1062
|
+
# events inside the window (same reasoning as timeline).
|
|
1063
|
+
files.extend(P.list_transcripts(pd, since=since))
|
|
1064
|
+
if report_file:
|
|
1065
|
+
report_file = report_file.resolve()
|
|
1066
|
+
files = [f.resolve() for f in files]
|
|
1067
|
+
if report_file not in files:
|
|
1068
|
+
files.append(report_file) # e.g. --file under a different root
|
|
1069
|
+
for f in files:
|
|
1070
|
+
if exclude_current and current_uuid and f.stem == current_uuid:
|
|
1071
|
+
continue
|
|
1072
|
+
u, c = _engagement_event_streams(f, since, until)
|
|
1073
|
+
if u or c:
|
|
1074
|
+
user_events[f] = u
|
|
1075
|
+
claude_events[f] = c
|
|
1076
|
+
|
|
1077
|
+
stream = sorted(
|
|
1078
|
+
(ts, f) for f, evs in user_events.items() for ts in evs
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
brk = timedelta(minutes=break_minutes)
|
|
1082
|
+
active: dict[Path, timedelta] = {}
|
|
1083
|
+
breaks: list[tuple[datetime, datetime]] = []
|
|
1084
|
+
for (t0, _f0), (t1, f1) in zip(stream, stream[1:]):
|
|
1085
|
+
gap = t1 - t0
|
|
1086
|
+
if gap <= brk:
|
|
1087
|
+
active[f1] = active.get(f1, timedelta()) + gap
|
|
1088
|
+
continue
|
|
1089
|
+
# Waiting-on-Claude credit: last Claude event in f1 inside the gap.
|
|
1090
|
+
cl = claude_events.get(f1, [])
|
|
1091
|
+
i = bisect.bisect_left(cl, t1)
|
|
1092
|
+
t_done = cl[i - 1] if i > 0 and cl[i - 1] > t0 else None
|
|
1093
|
+
if t_done is not None and (t1 - t_done) <= brk:
|
|
1094
|
+
active[f1] = active.get(f1, timedelta()) + gap
|
|
1095
|
+
else:
|
|
1096
|
+
breaks.append((t0, t1))
|
|
1097
|
+
|
|
1098
|
+
# Reporting scope
|
|
1099
|
+
report_dir_set = {d.resolve() for d in report_dirs} if report_dirs else None
|
|
1100
|
+
sessions: dict[Path, dict] = {}
|
|
1101
|
+
for f, evs in user_events.items():
|
|
1102
|
+
if not evs:
|
|
1103
|
+
continue
|
|
1104
|
+
if report_file and f != report_file:
|
|
1105
|
+
continue
|
|
1106
|
+
if report_dir_set is not None and f.parent.resolve() not in report_dir_set:
|
|
1107
|
+
continue
|
|
1108
|
+
sessions[f] = {
|
|
1109
|
+
"summary": PR.session_summary(f, current_session_id=current_uuid),
|
|
1110
|
+
"first": evs[0],
|
|
1111
|
+
"last": evs[-1],
|
|
1112
|
+
"user_messages": len(evs),
|
|
1113
|
+
"active": active.get(f, timedelta()),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"since": since,
|
|
1118
|
+
"until": until,
|
|
1119
|
+
"break_minutes": break_minutes,
|
|
1120
|
+
"sessions": sessions,
|
|
1121
|
+
"breaks": breaks,
|
|
1122
|
+
"stream_events": len(stream),
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _gap_percentiles(evs: list[datetime]) -> tuple[int, int] | None:
|
|
1127
|
+
"""(median, p90) of intra-session user-prompt gaps, in whole minutes."""
|
|
1128
|
+
if len(evs) < 2:
|
|
1129
|
+
return None
|
|
1130
|
+
gaps = sorted(
|
|
1131
|
+
(b - a).total_seconds() / 60 for a, b in zip(evs, evs[1:])
|
|
1132
|
+
)
|
|
1133
|
+
median = gaps[len(gaps) // 2]
|
|
1134
|
+
p90 = gaps[min(len(gaps) - 1, int(len(gaps) * 0.9))]
|
|
1135
|
+
return int(median), int(p90)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def render_engagement(data: dict, tz_label: str) -> str:
|
|
1139
|
+
since, until = data["since"], data["until"]
|
|
1140
|
+
sessions = data["sessions"]
|
|
1141
|
+
multi_day = (until - since) > timedelta(days=1)
|
|
1142
|
+
tfmt = "%Y-%m-%d %H:%M" if multi_day else "%H:%M"
|
|
1143
|
+
head = (
|
|
1144
|
+
f"=== Engagement {since:%Y-%m-%d %H:%M} → {until:%Y-%m-%d %H:%M} "
|
|
1145
|
+
f"(times: {tz_label}, break={data['break_minutes']}m) ==="
|
|
1146
|
+
)
|
|
1147
|
+
if not sessions:
|
|
1148
|
+
return head + "\n\n(no user messages in range)"
|
|
1149
|
+
out = [head, ""]
|
|
1150
|
+
rows = sorted(sessions.items(), key=lambda kv: -kv[1]["active"].total_seconds())
|
|
1151
|
+
for f, s in rows:
|
|
1152
|
+
elapsed = s["last"] - s["first"]
|
|
1153
|
+
# Composing time leading into a chat's first prompt is credited to it,
|
|
1154
|
+
# so active can slightly exceed first–last; cap the ratio at 1.0.
|
|
1155
|
+
ratio = (
|
|
1156
|
+
f"{min(1.0, s['active'].total_seconds() / elapsed.total_seconds()):.2f}"
|
|
1157
|
+
if elapsed.total_seconds() > 0 else " — "
|
|
1158
|
+
)
|
|
1159
|
+
out.append(
|
|
1160
|
+
f"{_fmt_dur(s['active']):>7} ratio {ratio} msgs {s['user_messages']:<4} "
|
|
1161
|
+
f"{s['first'].strftime(tfmt)}–{s['last'].strftime('%H:%M')} "
|
|
1162
|
+
f"{_session_label(s['summary'])}"
|
|
1163
|
+
)
|
|
1164
|
+
total_active = sum((s["active"] for s in sessions.values()), timedelta())
|
|
1165
|
+
first = min(s["first"] for s in sessions.values())
|
|
1166
|
+
last = max(s["last"] for s in sessions.values())
|
|
1167
|
+
out.append("")
|
|
1168
|
+
out.append(
|
|
1169
|
+
f"Total: {_fmt_dur(total_active)} active across {len(sessions)} session(s), "
|
|
1170
|
+
f"{first.strftime(tfmt)}–{last.strftime('%H:%M')} span ({_fmt_dur(last - first)})"
|
|
1171
|
+
)
|
|
1172
|
+
breaks = data["breaks"]
|
|
1173
|
+
if breaks:
|
|
1174
|
+
shown = breaks[:6]
|
|
1175
|
+
items = ", ".join(
|
|
1176
|
+
f"{a.strftime(tfmt)}→{b.strftime('%H:%M')} ({_fmt_dur(b - a)})"
|
|
1177
|
+
for a, b in shown
|
|
1178
|
+
)
|
|
1179
|
+
more = f" (+{len(breaks) - len(shown)} more)" if len(breaks) > len(shown) else ""
|
|
1180
|
+
out.append(f"Breaks >{data['break_minutes']}m in the merged stream: "
|
|
1181
|
+
f"{len(breaks)} — {items}{more}")
|
|
1182
|
+
# Single-session detail: prompt-gap percentiles
|
|
1183
|
+
if len(sessions) == 1:
|
|
1184
|
+
(f, s), = sessions.items()
|
|
1185
|
+
# recompute the session's own user events from the stored bounds is not
|
|
1186
|
+
# enough — pull them again (cached parse, cheap)
|
|
1187
|
+
evs, _ = _engagement_event_streams(f, data["since"], data["until"])
|
|
1188
|
+
pct = _gap_percentiles(evs)
|
|
1189
|
+
if pct:
|
|
1190
|
+
out.append(f"Prompt gaps: median {pct[0]}m, p90 {pct[1]}m")
|
|
1191
|
+
out.append(
|
|
1192
|
+
"(active time = your message cadence merged across ALL projects; "
|
|
1193
|
+
"parallel chats split the clock, never double-count. "
|
|
1194
|
+
"Long gaps count only when you replied right after Claude finished.)"
|
|
1195
|
+
)
|
|
1196
|
+
return "\n".join(out)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def engagement_json(data: dict) -> dict:
|
|
1200
|
+
sessions_out = []
|
|
1201
|
+
rows = sorted(
|
|
1202
|
+
data["sessions"].items(), key=lambda kv: -kv[1]["active"].total_seconds()
|
|
1203
|
+
)
|
|
1204
|
+
total_active = timedelta()
|
|
1205
|
+
for f, s in rows:
|
|
1206
|
+
elapsed = s["last"] - s["first"]
|
|
1207
|
+
active = s["active"]
|
|
1208
|
+
total_active += active
|
|
1209
|
+
summary = s["summary"]
|
|
1210
|
+
sessions_out.append({
|
|
1211
|
+
"uuid": summary["uuid"],
|
|
1212
|
+
"project": summary["decoded_project"],
|
|
1213
|
+
"title": summary.get("title") or summary.get("first_prompt") or "",
|
|
1214
|
+
"path": str(f),
|
|
1215
|
+
"first": s["first"].isoformat(),
|
|
1216
|
+
"last": s["last"].isoformat(),
|
|
1217
|
+
"elapsed_minutes": int(elapsed.total_seconds() // 60),
|
|
1218
|
+
"active_minutes": int(active.total_seconds() // 60),
|
|
1219
|
+
"active_seconds": int(active.total_seconds()),
|
|
1220
|
+
"ratio": (
|
|
1221
|
+
min(1.0, round(active.total_seconds() / elapsed.total_seconds(), 2))
|
|
1222
|
+
if elapsed.total_seconds() > 0 else None
|
|
1223
|
+
),
|
|
1224
|
+
"user_messages": s["user_messages"],
|
|
1225
|
+
})
|
|
1226
|
+
span_min = 0
|
|
1227
|
+
if data["sessions"]:
|
|
1228
|
+
first = min(s["first"] for s in data["sessions"].values())
|
|
1229
|
+
last = max(s["last"] for s in data["sessions"].values())
|
|
1230
|
+
span_min = int((last - first).total_seconds() // 60)
|
|
1231
|
+
return {
|
|
1232
|
+
"since": data["since"].isoformat(),
|
|
1233
|
+
"until": data["until"].isoformat(),
|
|
1234
|
+
"break_minutes": data["break_minutes"],
|
|
1235
|
+
"sessions": sessions_out,
|
|
1236
|
+
"totals": {
|
|
1237
|
+
"sessions": len(sessions_out),
|
|
1238
|
+
"active_minutes": int(total_active.total_seconds() // 60),
|
|
1239
|
+
"active_seconds": int(total_active.total_seconds()),
|
|
1240
|
+
"span_minutes": span_min,
|
|
1241
|
+
},
|
|
1242
|
+
"stream_breaks": [
|
|
1243
|
+
{
|
|
1244
|
+
"start": a.isoformat(),
|
|
1245
|
+
"end": b.isoformat(),
|
|
1246
|
+
"minutes": int((b - a).total_seconds() // 60),
|
|
1247
|
+
}
|
|
1248
|
+
for a, b in data["breaks"]
|
|
1249
|
+
],
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
|
|
970
1253
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
971
1254
|
# JSON builders for legacy single-file modes
|
|
972
1255
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1058,7 +1341,7 @@ NEW_MODES = {
|
|
|
1058
1341
|
"list", "lookup", "find", "resume-cmd", "brief",
|
|
1059
1342
|
"changelog", "file-edits", "tool-calls",
|
|
1060
1343
|
"subagent-list", "subagent-finals", "subagent-tools", "subagent-files",
|
|
1061
|
-
"resume-prev", "count", "journal", "diff", "timeline",
|
|
1344
|
+
"resume-prev", "count", "journal", "diff", "timeline", "engagement",
|
|
1062
1345
|
}
|
|
1063
1346
|
|
|
1064
1347
|
ALL_MODES = LEGACY_MODES | NEW_MODES
|
|
@@ -1086,6 +1369,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1086
1369
|
p.add_argument("--until", help="Upper time bound (same forms as --since)")
|
|
1087
1370
|
p.add_argument("--date", help="Single-day window for timeline mode (ISO date / yesterday / today)")
|
|
1088
1371
|
p.add_argument("--gap", help="Idle-gap threshold for timeline blocks (e.g. 15m, 1h; default 15m)")
|
|
1372
|
+
p.add_argument("--break", dest="break_spec",
|
|
1373
|
+
help="Break threshold for engagement mode (e.g. 5m, 20m; default 10m)")
|
|
1089
1374
|
p.add_argument(
|
|
1090
1375
|
"--tz", default=None,
|
|
1091
1376
|
help="Display timezone override: IANA name (America/New_York), UTC, or offset (+5, -4). "
|
|
@@ -1289,6 +1574,49 @@ def main() -> int:
|
|
|
1289
1574
|
else:
|
|
1290
1575
|
print(render_timeline(data, tz_label=args.tz or "local"))
|
|
1291
1576
|
return 0
|
|
1577
|
+
if mode == "engagement":
|
|
1578
|
+
try:
|
|
1579
|
+
break_minutes = _parse_gap(args.break_spec, default=10)
|
|
1580
|
+
if args.date:
|
|
1581
|
+
day = P.parse_timespec(args.date).replace(
|
|
1582
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
1583
|
+
)
|
|
1584
|
+
e_since, e_until = day, day + timedelta(days=1)
|
|
1585
|
+
else:
|
|
1586
|
+
e_since, e_until = since, until
|
|
1587
|
+
except ValueError as e:
|
|
1588
|
+
print(str(e), file=sys.stderr)
|
|
1589
|
+
return 1
|
|
1590
|
+
report_file = Path(args.file) if args.file else None
|
|
1591
|
+
if report_file and not report_file.exists():
|
|
1592
|
+
print(f"File not found: {report_file}", file=sys.stderr)
|
|
1593
|
+
return 1
|
|
1594
|
+
if report_file and e_since is None:
|
|
1595
|
+
# Window defaults to the file's own first→last user prompt.
|
|
1596
|
+
evs, _ = _engagement_event_streams(report_file, None, None)
|
|
1597
|
+
if not evs:
|
|
1598
|
+
print("(no user messages in this session)")
|
|
1599
|
+
return 0
|
|
1600
|
+
e_since = evs[0]
|
|
1601
|
+
e_until = e_until or evs[-1] + timedelta(seconds=1)
|
|
1602
|
+
else:
|
|
1603
|
+
e_since = e_since or P.parse_timespec("today")
|
|
1604
|
+
e_until = e_until or P.parse_timespec("now")
|
|
1605
|
+
# Scope filters reporting only; the attention stream is always global.
|
|
1606
|
+
report_dirs = None
|
|
1607
|
+
if not report_file:
|
|
1608
|
+
report_dirs = _scoped_project_dirs(
|
|
1609
|
+
root, args.cwd, args.all_projects, args.project, default_all=True
|
|
1610
|
+
)
|
|
1611
|
+
data = build_engagement(
|
|
1612
|
+
root, report_dirs, report_file, e_since, e_until, break_minutes,
|
|
1613
|
+
current_uuid, exclude_current=args.exclude_current,
|
|
1614
|
+
)
|
|
1615
|
+
if fmt == "json":
|
|
1616
|
+
_print_json(engagement_json(data))
|
|
1617
|
+
else:
|
|
1618
|
+
print(render_engagement(data, tz_label=args.tz or "local"))
|
|
1619
|
+
return 0
|
|
1292
1620
|
if mode == "diff":
|
|
1293
1621
|
if args.subagents_of:
|
|
1294
1622
|
parent = Path(args.subagents_of)
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"Start the analysis"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"On it."}]}}
|
|
3
|
+
{"type":"user","timestamp":"2026-05-01T10:05:00Z","message":{"role":"user","content":"Looks good, continue"}}
|
|
4
|
+
{"type":"assistant","timestamp":"2026-05-01T10:06:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Done with phase 1."}]}}
|
|
5
|
+
{"type":"user","timestamp":"2026-05-01T10:08:00Z","message":{"role":"user","content":"Next phase"}}
|
|
6
|
+
{"type":"assistant","timestamp":"2026-05-01T10:09:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Phase 2 complete."}]}}
|
|
7
|
+
{"type":"user","timestamp":"2026-05-01T10:40:00Z","message":{"role":"user","content":"Back from a break, keep going"}}
|
|
8
|
+
{"type":"assistant","timestamp":"2026-05-01T10:41:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Resumed."}]}}
|
|
9
|
+
{"type":"user","timestamp":"2026-05-01T10:45:00Z","message":{"role":"user","content":"Wrap it up"}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T09:00:00Z","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"SessionStart hook output — not a human action"}]}}
|
|
2
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"This session is being continued from a previous conversation. Summary: we were doing things."}}
|
|
3
|
+
{"type":"user","timestamp":"2026-05-01T10:01:00Z","message":{"role":"user","content":"Real prompt one"}}
|
|
4
|
+
{"type":"assistant","timestamp":"2026-05-01T10:02:00Z","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"x"},"id":"t9"}]}}
|
|
5
|
+
{"type":"user","timestamp":"2026-05-01T10:03:00Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t9","content":"file contents"}]}}
|
|
6
|
+
{"type":"user","timestamp":"2026-05-01T10:05:00Z","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"Skill expansion injected text"}]}}
|
|
7
|
+
{"type":"user","timestamp":"2026-05-01T10:06:00Z","message":{"role":"user","content":"Real prompt two"}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"Chat A start"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Working in A."}]}}
|
|
3
|
+
{"type":"user","timestamp":"2026-05-01T10:30:00Z","message":{"role":"user","content":"Chat A follow-up"}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:10:00Z","message":{"role":"user","content":"Chat B start"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:11:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Working in B."}]}}
|
|
3
|
+
{"type":"user","timestamp":"2026-05-01T10:20:00Z","message":{"role":"user","content":"Chat B follow-up"}}
|
package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"role":"user","content":"Run the long migration"}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-05-01T10:02:00Z","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"migrate"},"id":"t1"}]}}
|
|
3
|
+
{"type":"user","timestamp":"2026-05-01T10:15:00Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"step 1 done"}]}}
|
|
4
|
+
{"type":"assistant","timestamp":"2026-05-01T10:30:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Migration finished."}]}}
|
|
5
|
+
{"type":"user","timestamp":"2026-05-01T10:32:00Z","message":{"role":"user","content":"Great, now verify it"}}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Tests for the engagement mode (attention-time accounting)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import read_transcript as RT
|
|
13
|
+
from lib import parser as PR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_cli(cli_path, *args, env_overrides=None):
|
|
17
|
+
env = dict(os.environ)
|
|
18
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
19
|
+
if env_overrides:
|
|
20
|
+
env.update(env_overrides)
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
[sys.executable, str(cli_path), *args],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
env=env,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def utc_tz():
|
|
32
|
+
PR.set_timezone("UTC")
|
|
33
|
+
yield
|
|
34
|
+
PR.set_timezone(None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fake_root(tmp_path, **projects):
|
|
38
|
+
"""Build a fake projects root: {project_dir_name: [(fixture, uuid), ...]}."""
|
|
39
|
+
root = tmp_path / "projects"
|
|
40
|
+
for proj_name, files in projects.items():
|
|
41
|
+
pd = root / proj_name
|
|
42
|
+
pd.mkdir(parents=True)
|
|
43
|
+
for src, uuid in files:
|
|
44
|
+
shutil.copy(src, pd / f"{uuid}.jsonl")
|
|
45
|
+
return root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build(root, **kw):
|
|
49
|
+
defaults = dict(
|
|
50
|
+
report_dirs=None,
|
|
51
|
+
report_file=None,
|
|
52
|
+
since=datetime(2026, 5, 1),
|
|
53
|
+
until=datetime(2026, 5, 2),
|
|
54
|
+
break_minutes=10,
|
|
55
|
+
current_uuid=None,
|
|
56
|
+
)
|
|
57
|
+
defaults.update(kw)
|
|
58
|
+
return RT.build_engagement(root, **defaults)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── unit: gap math ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def test_gaps_straddle_threshold(fixtures_dir, tmp_path, utc_tz):
|
|
64
|
+
root = _fake_root(
|
|
65
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-gaps.jsonl", "aaaa1111")]}
|
|
66
|
+
)
|
|
67
|
+
data = _build(root)
|
|
68
|
+
(s,) = data["sessions"].values()
|
|
69
|
+
# 5m + 3m active, 32m break (Claude finished 10:09, reply 10:40 — too late),
|
|
70
|
+
# then 5m active = 13m.
|
|
71
|
+
assert s["active"] == timedelta(minutes=13)
|
|
72
|
+
assert s["user_messages"] == 5
|
|
73
|
+
assert len(data["breaks"]) == 1
|
|
74
|
+
assert data["breaks"][0][0] == datetime(2026, 5, 1, 10, 8)
|
|
75
|
+
assert data["breaks"][0][1] == datetime(2026, 5, 1, 10, 40)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_waiting_on_claude_credit(fixtures_dir, tmp_path, utc_tz):
|
|
79
|
+
root = _fake_root(
|
|
80
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-waiting.jsonl", "bbbb2222")]}
|
|
81
|
+
)
|
|
82
|
+
data = _build(root)
|
|
83
|
+
(s,) = data["sessions"].values()
|
|
84
|
+
# 32m gap, but Claude's last event was 10:30 and the user replied 10:32
|
|
85
|
+
# (2m ≤ 10m) — the whole gap counts as waiting-on-Claude.
|
|
86
|
+
assert s["active"] == timedelta(minutes=32)
|
|
87
|
+
assert data["breaks"] == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_noise_exclusion(fixtures_dir, tmp_path, utc_tz):
|
|
91
|
+
root = _fake_root(
|
|
92
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-noise.jsonl", "cccc3333")]}
|
|
93
|
+
)
|
|
94
|
+
data = _build(root)
|
|
95
|
+
(s,) = data["sessions"].values()
|
|
96
|
+
# Only the two real prompts (10:01, 10:06) count: compact continuation,
|
|
97
|
+
# isMeta injections, and tool results are all excluded from the user stream.
|
|
98
|
+
assert s["user_messages"] == 2
|
|
99
|
+
assert s["first"] == datetime(2026, 5, 1, 10, 1)
|
|
100
|
+
assert s["active"] == timedelta(minutes=5)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_parallel_sessions_never_double_count(fixtures_dir, tmp_path, utc_tz):
|
|
104
|
+
root = _fake_root(
|
|
105
|
+
tmp_path,
|
|
106
|
+
**{
|
|
107
|
+
"C--proj-a": [(fixtures_dir / "engagement-parallel-a.jsonl", "aaaa1111")],
|
|
108
|
+
"C--proj-b": [(fixtures_dir / "engagement-parallel-b.jsonl", "bbbb2222")],
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
data = _build(root)
|
|
112
|
+
by_uuid = {s["summary"]["uuid"]: s for s in data["sessions"].values()}
|
|
113
|
+
# Stream: 10:00 A, 10:10 B, 10:20 B, 10:30 A. Each segment goes to the
|
|
114
|
+
# session of the LATER prompt: B gets 10:00–10:20 (20m), A gets 10:20–10:30
|
|
115
|
+
# (10m). Total 30m == wall clock, not the naive 40m.
|
|
116
|
+
assert by_uuid["bbbb2222"]["active"] == timedelta(minutes=20)
|
|
117
|
+
assert by_uuid["aaaa1111"]["active"] == timedelta(minutes=10)
|
|
118
|
+
total = sum((s["active"] for s in data["sessions"].values()), timedelta())
|
|
119
|
+
assert total == timedelta(minutes=30)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_single_message_session(tmp_path, utc_tz):
|
|
123
|
+
root = tmp_path / "projects"
|
|
124
|
+
pd = root / "C--proj-a"
|
|
125
|
+
pd.mkdir(parents=True)
|
|
126
|
+
(pd / "dddd4444.jsonl").write_text(
|
|
127
|
+
'{"type":"user","timestamp":"2026-05-01T10:00:00Z",'
|
|
128
|
+
'"message":{"role":"user","content":"one and done"}}\n',
|
|
129
|
+
encoding="utf-8",
|
|
130
|
+
)
|
|
131
|
+
data = _build(root)
|
|
132
|
+
(s,) = data["sessions"].values()
|
|
133
|
+
assert s["active"] == timedelta(0)
|
|
134
|
+
assert s["user_messages"] == 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_report_scope_filters_but_stream_stays_global(fixtures_dir, tmp_path, utc_tz):
|
|
138
|
+
root = _fake_root(
|
|
139
|
+
tmp_path,
|
|
140
|
+
**{
|
|
141
|
+
"C--proj-a": [(fixtures_dir / "engagement-parallel-a.jsonl", "aaaa1111")],
|
|
142
|
+
"C--proj-b": [(fixtures_dir / "engagement-parallel-b.jsonl", "bbbb2222")],
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
data = _build(root, report_dirs=[root / "C--proj-a"])
|
|
146
|
+
# Only A is reported, but B's prompts still split the stream — A gets its
|
|
147
|
+
# interval-correct 10m, not a naive 30m.
|
|
148
|
+
(s,) = data["sessions"].values()
|
|
149
|
+
assert s["summary"]["uuid"] == "aaaa1111"
|
|
150
|
+
assert s["active"] == timedelta(minutes=10)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── CLI ──────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def test_engagement_cli_text(cli_path, fixtures_dir, tmp_path):
|
|
156
|
+
root = _fake_root(
|
|
157
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-gaps.jsonl", "aaaa1111")]}
|
|
158
|
+
)
|
|
159
|
+
r = _run_cli(
|
|
160
|
+
cli_path, "--root", str(root), "--tz", "UTC",
|
|
161
|
+
"--mode", "engagement", "--date", "2026-05-01",
|
|
162
|
+
)
|
|
163
|
+
assert r.returncode == 0
|
|
164
|
+
assert "13m" in r.stdout
|
|
165
|
+
assert "break=10m" in r.stdout
|
|
166
|
+
assert "Breaks >10m" in r.stdout
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_engagement_cli_json(cli_path, fixtures_dir, tmp_path):
|
|
170
|
+
root = _fake_root(
|
|
171
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-gaps.jsonl", "aaaa1111")]}
|
|
172
|
+
)
|
|
173
|
+
r = _run_cli(
|
|
174
|
+
cli_path, "--root", str(root), "--tz", "UTC",
|
|
175
|
+
"--mode", "engagement", "--date", "2026-05-01", "--format", "json",
|
|
176
|
+
)
|
|
177
|
+
assert r.returncode == 0
|
|
178
|
+
data = json.loads(r.stdout)
|
|
179
|
+
assert data["break_minutes"] == 10
|
|
180
|
+
assert data["totals"]["active_minutes"] == 13
|
|
181
|
+
assert data["totals"]["sessions"] == 1
|
|
182
|
+
s = data["sessions"][0]
|
|
183
|
+
assert s["uuid"] == "aaaa1111"
|
|
184
|
+
assert s["elapsed_minutes"] == 45
|
|
185
|
+
assert s["ratio"] == round(13 / 45, 2)
|
|
186
|
+
assert len(data["stream_breaks"]) == 1
|
|
187
|
+
assert data["stream_breaks"][0]["minutes"] == 32
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_engagement_cli_file_window_derived(cli_path, fixtures_dir, tmp_path):
|
|
191
|
+
root = _fake_root(
|
|
192
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-waiting.jsonl", "bbbb2222")]}
|
|
193
|
+
)
|
|
194
|
+
f = root / "C--proj-a" / "bbbb2222.jsonl"
|
|
195
|
+
r = _run_cli(
|
|
196
|
+
cli_path, "--root", str(root), "--tz", "UTC",
|
|
197
|
+
"--mode", "engagement", "--file", str(f),
|
|
198
|
+
)
|
|
199
|
+
assert r.returncode == 0
|
|
200
|
+
assert "32m" in r.stdout
|
|
201
|
+
assert "Prompt gaps" in r.stdout
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_engagement_cli_custom_break(cli_path, fixtures_dir, tmp_path):
|
|
205
|
+
root = _fake_root(
|
|
206
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-gaps.jsonl", "aaaa1111")]}
|
|
207
|
+
)
|
|
208
|
+
r = _run_cli(
|
|
209
|
+
cli_path, "--root", str(root), "--tz", "UTC",
|
|
210
|
+
"--mode", "engagement", "--date", "2026-05-01",
|
|
211
|
+
"--break", "1h", "--format", "json",
|
|
212
|
+
)
|
|
213
|
+
assert r.returncode == 0
|
|
214
|
+
data = json.loads(r.stdout)
|
|
215
|
+
# 1h threshold swallows the 32m gap — everything is active: 45m.
|
|
216
|
+
assert data["totals"]["active_minutes"] == 45
|
|
217
|
+
assert data["stream_breaks"] == []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_engagement_cli_invalid_break(cli_path, tmp_path):
|
|
221
|
+
root = tmp_path / "projects"
|
|
222
|
+
root.mkdir()
|
|
223
|
+
r = _run_cli(
|
|
224
|
+
cli_path, "--root", str(root),
|
|
225
|
+
"--mode", "engagement", "--break", "soon",
|
|
226
|
+
)
|
|
227
|
+
assert r.returncode == 1
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_engagement_cli_empty_range(cli_path, fixtures_dir, tmp_path):
|
|
231
|
+
root = _fake_root(
|
|
232
|
+
tmp_path, **{"C--proj-a": [(fixtures_dir / "engagement-gaps.jsonl", "aaaa1111")]}
|
|
233
|
+
)
|
|
234
|
+
r = _run_cli(
|
|
235
|
+
cli_path, "--root", str(root), "--tz", "UTC",
|
|
236
|
+
"--mode", "engagement", "--date", "2020-01-01",
|
|
237
|
+
)
|
|
238
|
+
assert r.returncode == 0
|
|
239
|
+
assert "no user messages" in r.stdout
|
|
@@ -112,7 +112,9 @@ def test_timeline_cli_text(cli_path, fake_root):
|
|
|
112
112
|
assert "10:00" in r.stdout
|
|
113
113
|
assert "12:02" in r.stdout
|
|
114
114
|
assert "idle" in r.stdout
|
|
115
|
-
assert "2
|
|
115
|
+
assert "2 block(s)" in r.stdout
|
|
116
|
+
# Timeline makes no attention claim — that's engagement mode's job.
|
|
117
|
+
assert "active" not in r.stdout
|
|
116
118
|
|
|
117
119
|
|
|
118
120
|
def test_timeline_cli_json(cli_path, fake_root):
|
|
@@ -124,6 +126,8 @@ def test_timeline_cli_json(cli_path, fake_root):
|
|
|
124
126
|
data = json.loads(r.stdout)
|
|
125
127
|
assert data["totals"]["blocks"] == 2
|
|
126
128
|
assert data["totals"]["sessions"] == 1
|
|
129
|
+
assert data["totals"]["span_minutes"] == 122 # 10:00 → 12:02
|
|
130
|
+
assert "active_minutes" not in data["totals"]
|
|
127
131
|
assert data["blocks"][0]["start"].endswith("10:00:00")
|
|
128
132
|
assert data["blocks"][0]["sessions"][0]["uuid"] == "abc12345"
|
|
129
133
|
|