ticketlens 0.5.1 → 0.7.0

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/README.md CHANGED
@@ -133,10 +133,15 @@ ticketlens triage --stale=3 # Aging threshold: 3 days (defaul
133
133
  ticketlens triage --status="Code Review,QA" # Override statuses to scan
134
134
  ticketlens triage --assignee="Jane Dev" # Another dev's tickets [Team]
135
135
  ticketlens triage --sprint="Sprint 12" # Filter by sprint [Team]
136
+ ticketlens triage --project=MYPROJ # Scope to a Jira project key [Team]
137
+ ticketlens triage --label=Bug,P1 # Filter by label(s) [Team]
138
+ ticketlens triage --priority=High # Filter by priority level [Team]
136
139
  ticketlens triage --export=csv # Export results to CSV [Team]
137
140
  ticketlens triage --export=json # Export results to JSON [Team]
138
141
  ticketlens triage --push # Push snapshot to Console queue [Team]
139
142
  ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
143
+ ticketlens triage --all # Triage all profiles at once, merged output [Pro]
144
+ ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
140
145
  ticketlens triage --digest # POST scored results to digest endpoint [Pro]
141
146
  ticketlens triage --plain # Plain markdown — pipe to file or LLM
142
147
  ticketlens triage --static # Static table, no interactive mode
@@ -331,12 +336,48 @@ Cache locations:
331
336
  ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
332
337
  ticketlens schedule --stop # Cancel the scheduled digest
333
338
  ticketlens schedule --status # Show current schedule
339
+ ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
334
340
  ```
335
341
 
336
342
  Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
337
343
 
338
344
  ---
339
345
 
346
+ ### History
347
+
348
+ ```bash
349
+ ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
350
+ ```
351
+
352
+ Reads from your local `~/.ticketlens/triage-history/` snapshots (written automatically on each `ticketlens triage` run) and renders a day-by-day urgency timeline for the requested ticket. Entries where urgency changed direction are flagged as "bounced" — useful for spotting tickets that keep reverting between Code Review and In Progress.
353
+
354
+ Requires a Pro license. No network call — reads local snapshots only.
355
+
356
+ ---
357
+
358
+ ### Custom Attention Rules
359
+
360
+ Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
361
+
362
+ ```json
363
+ {
364
+ "profiles": {
365
+ "work": {
366
+ "baseUrl": "https://jira.example.com",
367
+ "attentionRules": [
368
+ { "match": { "priority": "Highest" }, "action": "force-urgent", "reason": "P1 always urgent" },
369
+ { "match": { "label": "backlog" }, "action": "ignore", "reason": "skip backlog" },
370
+ { "match": { "status": "Parked" }, "action": "ignore", "reason": "parked tickets" }
371
+ ]
372
+ }
373
+ }
374
+ }
375
+ ```
376
+
377
+ Rules are evaluated in order — first match wins. Supported `match` keys: `priority`, `label`, `status`, `keyPrefix`. Supported `action` values: `force-urgent` (bumps to needs-response) and `ignore` (excludes from output). Requires a Pro license.
378
+
379
+ ---
380
+
340
381
  ### Login
341
382
 
342
383
  ```bash
@@ -481,11 +522,17 @@ ticketlens triage --static # Static table output (no interacti
481
522
  ticketlens triage --plain # Plain markdown — pipe to LLM or file
482
523
  ticketlens triage --assignee="Jane Dev" # View another dev's tickets [Team]
483
524
  ticketlens triage --sprint="Sprint 12" # Filter by sprint name [Team]
525
+ ticketlens triage --project=MYPROJ # Scope to a Jira project key [Team]
526
+ ticketlens triage --label=Bug,P1 # Filter by label(s) [Team]
527
+ ticketlens triage --priority=High # Filter by priority level [Team]
528
+ ticketlens triage --project=MYPROJ --label=Bug --priority=High # Combined [Team]
484
529
  ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
485
530
  ticketlens triage --export=csv # Export to CSV [Team]
486
531
  ticketlens triage --export=json # Export to JSON [Team]
487
532
  ticketlens triage --push # Push snapshot to Console queue [Team]
488
533
  ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
534
+ ticketlens triage --all # Triage all profiles at once, merged output [Pro]
535
+ ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
489
536
  ticketlens triage --digest # POST results to digest endpoint [Pro]
490
537
  ticketlens triage --profile=acme --stale=3 --static # Combine flags
491
538
 
@@ -537,6 +584,10 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
537
584
  ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
538
585
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
539
586
  ticketlens schedule --status # Show current schedule [Pro]
587
+ ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
588
+
589
+ # ── History ───────────────────────────────────────────────────────────────────
590
+ ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
540
591
 
541
592
  # ── Compliance ────────────────────────────────────────────────────────────────
542
593
  ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
@@ -657,6 +708,9 @@ Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to
657
708
  ```bash
658
709
  ticketlens triage --assignee="Jane Dev" # View another dev's tickets
659
710
  ticketlens triage --sprint="Sprint 12" # Filter by sprint name
711
+ ticketlens triage --project=MYPROJ # Scope to a Jira project key
712
+ ticketlens triage --label=Bug,P1 # Filter by label(s)
713
+ ticketlens triage --priority=High # Filter by priority level
660
714
  ticketlens triage --export=csv # Export triage to CSV for standups and reports
661
715
  ticketlens triage --export=json # Machine-readable export for dashboards
662
716
  ticketlens triage --push # Push snapshot to the Console queue
@@ -78,6 +78,31 @@ switch (command) {
78
78
  break;
79
79
  }
80
80
 
81
+ case 'history': {
82
+ if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
83
+ const ticketKey = cmdArgs[0];
84
+ if (!ticketKey || ticketKey.startsWith('-')) {
85
+ process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
86
+ process.exitCode = 1;
87
+ break;
88
+ }
89
+ const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
90
+ const entries = queryTicketHistory(ticketKey);
91
+ if (entries.length === 0) {
92
+ process.stdout.write(`No triage history found for ${ticketKey}.\n`);
93
+ break;
94
+ }
95
+ const hs = createStyler({ isTTY: process.stdout.isTTY });
96
+ process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
97
+ for (const e of entries) {
98
+ const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
99
+ const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
100
+ process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
101
+ }
102
+ process.stdout.write('\n');
103
+ break;
104
+ }
105
+
81
106
  case 'init':
82
107
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
83
108
  runInit().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.4.2 -->
1
+ <!-- jtb-skill-version: 0.7.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -27,8 +27,14 @@ Fetches a Jira ticket and produces a structured brief with code references, then
27
27
  /jtb triage --stale=3 # custom aging threshold (days)
28
28
  /jtb triage --status=CR,QA # only check specific statuses
29
29
  /jtb triage --profile=acme # explicit profile override
30
+ /jtb triage --all # triage all configured profiles at once, merged (Pro)
31
+ /jtb triage --save=~/triage.txt # save ANSI-stripped output to file (Pro)
32
+ /jtb triage --project=MYPROJ # scope to a Jira project key (Team)
33
+ /jtb triage --label=Bug,P1 # filter by label(s) (Team)
34
+ /jtb triage --priority=High # filter by priority level (Team)
30
35
  /jtb triage --push # push snapshot + git branches to Console (Team)
31
36
  /jtb triage --share # generate 24h share URL (Team)
37
+ /jtb history PROD-1234 # show urgency timeline for a ticket (Pro)
32
38
  /jtb collisions # show branch collisions with teammates (Team)
33
39
  /jtb collisions --json # machine-readable output
34
40
  ```
@@ -10,6 +10,8 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
10
10
  import { styleTriageSummary } from './lib/styled-assembler.mjs';
11
11
  import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
12
12
  import { resolveAdapter } from './lib/resolve-adapter.mjs';
13
+ import { writeFileSync, mkdirSync, statSync } from 'node:fs';
14
+ import { resolve as resolvePath, dirname } from 'node:path';
13
15
  import { createSpinner } from './lib/spinner.mjs';
14
16
  import { createSession } from './lib/banner.mjs';
15
17
  import { classifyError } from './lib/error-classifier.mjs';
@@ -20,6 +22,7 @@ import { handleUnknownFlags } from './lib/arg-validator.mjs';
20
22
  import { isLicensed, showUpgradePrompt, revalidateIfStale } from './lib/license.mjs';
21
23
  import { readCliToken } from './lib/cli-auth.mjs';
22
24
  import { apiBase } from './lib/api-utils.mjs';
25
+ import { stripAnsi, bold, cyan, dim, red, green } from './lib/ansi.mjs';
23
26
 
24
27
  const DEFAULT_STATUSES = ['In Progress', 'Code Review', 'QA'];
25
28
 
@@ -62,20 +65,15 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
62
65
  printTriageHelp();
63
66
  return;
64
67
  }
65
- // Normalize --project= alias once at entry so all recursive calls only see --profile=
66
- const projectArg = args.find(a => a.startsWith('--project='));
67
- if (projectArg) {
68
- process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
69
- args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
70
- }
71
-
72
68
  const profileArg = args.find(a => a.startsWith('--profile='));
73
69
  const profileName = profileArg ? profileArg.split('=')[1] : undefined;
74
70
 
75
71
  const validatedArgs = await handleUnknownFlags(
76
72
  args,
77
- ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share'],
78
- { hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
73
+ ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=',
74
+ '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save=',
75
+ '--project=', '--label=', '--priority='],
76
+ { hints: ['--depth=', '--no-attachments', '--no-cache'] }
79
77
  );
80
78
  if (validatedArgs === null) { process.exitCode = 1; return; }
81
79
  args = validatedArgs;
@@ -86,13 +84,18 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
86
84
  const staleArg = args.find(a => a.startsWith('--stale='));
87
85
  const staleDays = staleArg ? parseInt(staleArg.split('=')[1], 10) : 5;
88
86
 
89
- const statusArg = args.find(a => a.startsWith('--status='));
87
+ const statusArg = args.find(a => a.startsWith('--status='));
90
88
  const assigneeArg = args.find(a => a.startsWith('--assignee='));
91
- const sprintArg = args.find(a => a.startsWith('--sprint='));
89
+ const sprintArg = args.find(a => a.startsWith('--sprint='));
90
+ const projectArg = args.find(a => a.startsWith('--project='));
91
+ const labelArg = args.find(a => a.startsWith('--label='));
92
+ const priorityArg = args.find(a => a.startsWith('--priority='));
92
93
  const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
93
94
  const digestFlag = args.includes('--digest');
94
95
  const pushFlag = args.includes('--push');
95
96
  const shareFlag = args.includes('--share');
97
+ const allFlag = args.includes('--all');
98
+ const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
96
99
 
97
100
  if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
98
101
  process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
@@ -103,6 +106,121 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
103
106
  const licensedFn = opts.isLicensed ?? isLicensed;
104
107
  const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
105
108
 
109
+ // --save=FILE: Pro gate + validate path is not a directory
110
+ if (saveArg) {
111
+ if (!licensedFn('pro', configDir)) {
112
+ upgradeFn('pro', '--save');
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ const resolvedSave = resolvePath(saveArg);
117
+ try {
118
+ if (statSync(resolvedSave).isDirectory()) {
119
+ process.stderr.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ } catch { /* doesn't exist yet — ok */ }
124
+ }
125
+
126
+ // --project / --label / --priority: Team gate
127
+ if (projectArg || labelArg || priorityArg) {
128
+ if (!licensedFn('team', configDir)) {
129
+ const flag = projectArg ? '--project' : labelArg ? '--label' : '--priority';
130
+ upgradeFn('team', flag);
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ }
135
+
136
+ // --all: triage all configured profiles in parallel with live status block
137
+ if (allFlag) {
138
+ if (!licensedFn('pro', configDir)) {
139
+ upgradeFn('pro', '--all');
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const profilesConfig = loadProfiles(configDir);
144
+ const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
145
+ if (profileNames.length === 0) {
146
+ process.stderr.write('Error: No profiles configured. Run `ticketlens init` first.\n');
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+
151
+ const usePlain = args.includes('--plain');
152
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
153
+ const argsBase = args.filter(a => a !== '--all' && !a.startsWith('--profile='));
154
+ // --static disables interactive mode in sub-runs (isTTY is still true but we're capturing output)
155
+ const modeFlags = usePlain ? ['--plain'] : ['--styled', '--static'];
156
+ const isTTY = process.stderr.isTTY;
157
+ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
158
+ const COL = Math.max(...profileNames.map(n => n.length)) + 2;
159
+
160
+ // Per-profile state
161
+ const entries = profileNames.map(name => ({ name, state: 'pending', output: [], summary: '' }));
162
+
163
+ const statusLine = (e, frame = 0) => {
164
+ const nameCol = e.name.padEnd(COL);
165
+ if (e.state === 'pending') return ` ${SPIN[frame % SPIN.length]} ${nameCol}${dim('fetching...')}`;
166
+ if (e.state === 'done') return ` ${green('✔')} ${nameCol}${dim(e.summary)}`;
167
+ return ` ${red('✗')} ${nameCol}${dim('could not connect')}`;
168
+ };
169
+
170
+ // Suppress all sub-run stderr so banners/spinners don't corrupt the status block.
171
+ // Status updates bypass suppression via `toStatus` which holds the original reference.
172
+ const toStatus = isTTY ? process.stderr.write.bind(process.stderr) : null;
173
+ if (isTTY) {
174
+ process.stderr.write = () => true; // silence sub-runs
175
+ toStatus('\x1b[?25l');
176
+ for (const e of entries) toStatus(statusLine(e) + '\n');
177
+ }
178
+
179
+ let frame = 0;
180
+ const timer = isTTY ? setInterval(() => {
181
+ frame++;
182
+ toStatus(`\x1b[${entries.length}A`);
183
+ for (const e of entries) toStatus(`\r\x1b[K${statusLine(e, frame)}\n`);
184
+ }, 80) : null;
185
+
186
+ await Promise.allSettled(entries.map((e, idx) =>
187
+ run([...argsBase, `--profile=${e.name}`, ...modeFlags],
188
+ { ...opts, env, fetcher, configDir, print: s => e.output.push(s) })
189
+ .then(() => {
190
+ const combined = e.output.join('');
191
+ const m = combined.match(/(\d+) found/);
192
+ entries[idx].state = e.output.length === 0 ? 'error' : 'done';
193
+ entries[idx].summary = m ? `${m[1]} ticket${m[1] === '1' ? '' : 's'}` : 'done';
194
+ })
195
+ .catch(() => { entries[idx].state = 'error'; })
196
+ ));
197
+
198
+ if (isTTY) {
199
+ clearInterval(timer);
200
+ toStatus(`\x1b[${entries.length}A`);
201
+ for (const e of entries) toStatus(`\r\x1b[K${statusLine(e)}\n`);
202
+ toStatus('\x1b[?25h\n');
203
+ process.stderr.write = toStatus; // restore
204
+ }
205
+
206
+ // Render results in profile order
207
+ for (const e of entries) {
208
+ printFn(usePlain
209
+ ? `\n── ${e.name} ──\n`
210
+ : `\n${dim('──')} ${bold(cyan(e.name))} ${dim('──')}\n`);
211
+
212
+ if (e.output.length === 0) {
213
+ const hint = `ticketlens triage --profile=${e.name}`;
214
+ printFn(usePlain
215
+ ? ` [could not connect — run: ${hint}]\n`
216
+ : ` ${red('✗')} ${dim('Could not connect — run')} ${cyan(hint)} ${dim('for details')}\n`);
217
+ } else {
218
+ printFn(e.output.join(''));
219
+ }
220
+ }
221
+ return;
222
+ }
223
+
106
224
  // Team-tier gate: --assignee and --sprint require a Team license
107
225
  if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
108
226
  upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
@@ -160,10 +278,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
160
278
  : conn.triageStatuses || DEFAULT_STATUSES;
161
279
 
162
280
  // Build JQL before any I/O — pure computation, no dependency on currentUser
163
- const statusList = statuses.map(s => `"${escapeJql(s)}"`).join(',');
281
+ const statusList = statuses.map(s => `"${escapeJql(s)}"`).join(',');
164
282
  const assigneeClause = assigneeName ? `assignee = "${escapeJql(assigneeName)}"` : `assignee = currentUser()`;
165
283
  const sprintClause = sprintName ? ` AND sprint = "${escapeJql(sprintName)}"` : '';
166
- const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause} ORDER BY updated DESC`;
284
+ const projectClause = projectArg ? ` AND project = "${escapeJql(projectArg.split('=')[1])}"` : '';
285
+ const labelValues = labelArg ? labelArg.split('=')[1].split(',').map(l => l.trim()).filter(Boolean) : [];
286
+ const labelClause = labelValues.length > 1
287
+ ? ` AND labels IN (${labelValues.map(l => `"${escapeJql(l)}"`).join(',')})`
288
+ : labelValues.length === 1 ? ` AND labels = "${escapeJql(labelValues[0])}"` : '';
289
+ const priorityClause = priorityArg ? ` AND priority = "${escapeJql(priorityArg.split('=')[1])}"` : '';
290
+ const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause}${projectClause}${labelClause}${priorityClause} ORDER BY updated DESC`;
167
291
 
168
292
  const session = createSession(conn);
169
293
  session.spin(`Connecting to ${session.label}…`);
@@ -283,11 +407,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
283
407
  process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
284
408
  }
285
409
 
286
- const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays }));
287
- const actionable = scored.filter(s => s.urgency !== 'clear');
410
+ const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules }));
411
+ const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
288
412
  const sorted = sortByUrgency(actionable);
289
413
  const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
290
414
 
415
+ // Always save a daily snapshot for history tracking (non-fatal)
416
+ try {
417
+ const { saveTriageSnapshot } = await import('./lib/triage-history.mjs');
418
+ saveTriageSnapshot(scored, { profile: profileName ?? 'default', configDir });
419
+ } catch { /* non-fatal */ }
420
+
291
421
  // --digest: POST scored results to the digest backend endpoint
292
422
  if (digestFlag) {
293
423
  if (!licensedFn('pro', configDir)) {
@@ -298,15 +428,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
298
428
  const deliverer = opts.digestDeliverer ?? defaultDigestDeliverer;
299
429
  const digestCliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
300
430
 
301
- // Triage history delta (non-fatal — wrapped in try/catch)
431
+ // Triage history delta (non-fatal — snapshot already saved above)
302
432
  let delta = null;
303
433
  try {
304
- const { saveTriageSnapshot, loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
434
+ const { loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
305
435
  await import('./lib/triage-history.mjs');
306
- saveTriageSnapshot(sorted, { profile: profileName ?? 'default', configDir });
307
436
  const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
308
437
  if (yesterday) {
309
- const deltas = diffSnapshots(sorted, yesterday);
438
+ const deltas = diffSnapshots(sorted, yesterday.tickets);
310
439
  delta = buildDeltaSection(deltas) || null;
311
440
  }
312
441
  } catch { /* non-fatal — digest still sends */ }
@@ -314,11 +443,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
314
443
  await deliverer({
315
444
  profile: profileName ?? 'default',
316
445
  staleDays,
317
- summary: {
318
- total: sorted.length,
319
- needsResponse: sorted.filter(t => t.urgency === 'needs-response').length,
320
- aging: sorted.filter(t => t.urgency === 'aging').length,
321
- },
446
+ summary: (() => {
447
+ let needsResponse = 0, aging = 0;
448
+ for (const t of sorted) {
449
+ if (t.urgency === 'needs-response') needsResponse++;
450
+ else if (t.urgency === 'aging') aging++;
451
+ }
452
+ return { total: sorted.length, needsResponse, aging };
453
+ })(),
322
454
  tickets: sorted,
323
455
  delta,
324
456
  }, { cliToken: digestCliToken });
@@ -372,7 +504,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
372
504
  }
373
505
 
374
506
  // Interactive mode: TTY + not --plain + not --static
375
- const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
507
+ const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static') && !saveArg;
376
508
  if (wantInteractive && process.stdin.setRawMode) {
377
509
  const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
378
510
  if (result === 'switch') {
@@ -386,7 +518,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
386
518
  const summary = useStyled
387
519
  ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
388
520
  : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
389
- process.stdout.write(summary + '\n');
521
+
522
+ // --save=FILE: write ANSI-stripped output to file
523
+ if (saveArg) {
524
+ const resolvedSave = resolvePath(saveArg);
525
+ mkdirSync(dirname(resolvedSave), { recursive: true });
526
+ const plain = stripAnsi(summary);
527
+ writeFileSync(resolvedSave, plain + '\n', 'utf8');
528
+ }
529
+
530
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
531
+ printFn(summary + '\n');
390
532
  }
391
533
 
392
534
  // Run if invoked directly
@@ -85,3 +85,6 @@ export const brightYellow = defaultStyler.brightYellow;
85
85
  export const brightCyan = defaultStyler.brightCyan;
86
86
  export const brand = defaultStyler.brand;
87
87
  export const isStyled = () => defaultStyler.enabled;
88
+
89
+ export const ANSI_RE = /\[[0-9;]*m/g;
90
+ export function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
@@ -75,8 +75,42 @@ export function findLastEffectiveComment(comments, currentUser) {
75
75
  return { comment: null, fromCurrentUser: false };
76
76
  }
77
77
 
78
+ /**
79
+ * Apply a single custom rule against a ticket.
80
+ * Returns 'force-urgent' | 'ignore' | null.
81
+ * A rule must have a valid `match` object and a known `action` to fire.
82
+ */
83
+ function applyCustomRule(ticket, rule) {
84
+ if (!rule || typeof rule.match !== 'object' || !rule.match) return null;
85
+ if (rule.action !== 'force-urgent' && rule.action !== 'ignore') return null;
86
+
87
+ const { priority, label, status, keyPrefix } = rule.match;
88
+ if (priority !== undefined && ticket.priority !== priority) return null;
89
+ if (status !== undefined && ticket.status !== status) return null;
90
+ if (label !== undefined) {
91
+ const labels = Array.isArray(ticket.labels) ? ticket.labels : [];
92
+ if (!labels.includes(label)) return null;
93
+ }
94
+ if (keyPrefix !== undefined && !String(ticket.key).startsWith(keyPrefix)) return null;
95
+
96
+ return rule.action;
97
+ }
98
+
78
99
  export function scoreAttention(ticket, currentUser, opts = {}) {
79
- const { staleDays = 5, now = new Date() } = opts;
100
+ const { staleDays = 5, now = new Date(), customRules } = opts;
101
+
102
+ // Custom rules evaluated first — first match wins
103
+ if (Array.isArray(customRules)) {
104
+ for (const rule of customRules) {
105
+ const action = applyCustomRule(ticket, rule);
106
+ if (action === 'ignore') {
107
+ return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'ignore', reason: rule.reason ?? 'Ignored by custom rule', lastComment: null };
108
+ }
109
+ if (action === 'force-urgent') {
110
+ return { ticketKey: ticket.key, summary: ticket.summary, status: ticket.status, urgency: 'needs-response', reason: rule.reason ?? 'Flagged by custom rule', lastComment: null };
111
+ }
112
+ }
113
+ }
80
114
 
81
115
  const { comment: lastComment, fromCurrentUser } = findLastEffectiveComment(
82
116
  ticket.comments || [], currentUser
@@ -145,8 +179,8 @@ export function sortByUrgency(scores) {
145
179
  const orderDiff = URGENCY_ORDER[a.urgency] - URGENCY_ORDER[b.urgency];
146
180
  if (orderDiff !== 0) return orderDiff;
147
181
  // Within same urgency, sort by most recent activity (lastComment date or ticket updated)
148
- const dateA = a.lastComment?.created ? new Date(a.lastComment.created) : new Date(0);
149
- const dateB = b.lastComment?.created ? new Date(b.lastComment.created) : new Date(0);
182
+ const dateA = a.lastComment?.created ? new Date(a.lastComment.created).getTime() : 0;
183
+ const dateB = b.lastComment?.created ? new Date(b.lastComment.created).getTime() : 0;
150
184
  return dateB - dateA; // most recent first
151
185
  });
152
186
  }
@@ -110,6 +110,10 @@ export function parseCommand(args) {
110
110
  return { command: 'collisions', args: args.slice(1) };
111
111
  }
112
112
 
113
+ if (first === 'history') {
114
+ return { command: 'history', args: args.slice(1) };
115
+ }
116
+
113
117
  // Anything that looks like a ticket key or any non-flag arg → fetch
114
118
  return { command: 'fetch', args };
115
119
  }
@@ -42,6 +42,7 @@ export function printHelp({ stream = process.stdout } = {}) {
42
42
  ` ${s.brand('ticketlens')} review ${s.dim('[--branch=BRANCH]')} Code-review context brief from current branch`,
43
43
  ` ${s.brand('ticketlens')} standup ${s.dim('[--since=N]')} Standup summary from git log ${s.dim('(last 24h by default)')}`,
44
44
  ` ${s.brand('ticketlens')} compliance ${s.dim('<TICKET-KEY>')} Check requirements coverage ${s.dim('[Pro/Free 3/mo]')}`,
45
+ ` ${s.brand('ticketlens')} history ${s.dim('<TICKET-KEY>')} Urgency timeline for a ticket ${s.dim('[Pro]')}`,
45
46
  '',
46
47
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
47
48
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -76,9 +77,14 @@ export function printHelp({ stream = process.stdout } = {}) {
76
77
  ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
77
78
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
78
79
  ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
80
+ ` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
81
+ ` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
82
+ ` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
79
83
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
80
84
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
81
85
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
86
+ ` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
87
+ ` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
82
88
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
83
89
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
84
90
  ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
@@ -546,9 +552,14 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
546
552
  ` ${s.brand('--status')}=${s.dim('X,Y')} Override statuses to scan`,
547
553
  ` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
548
554
  ` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
555
+ ` ${s.brand('--project')}=${s.dim('PROJ')} Filter by Jira project key ${s.dim('[Team]')}`,
556
+ ` ${s.brand('--label')}=${s.dim('X,Y')} Filter by label(s) ${s.dim('[Team]')}`,
557
+ ` ${s.brand('--priority')}=${s.dim('LEVEL')} Filter by priority ${s.dim('(e.g. High, Blocker) [Team]')}`,
549
558
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
550
559
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
551
560
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
561
+ ` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
562
+ ` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
552
563
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
553
564
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
554
565
  ` ${s.brand('--plain')} Plain markdown output`,
@@ -560,6 +571,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
560
571
  ` ${s.dim('$')} ticketlens triage --profile=acme`,
561
572
  ` ${s.dim('$')} ticketlens triage --stale=3 --status="Code Review,QA Testing"`,
562
573
  ` ${s.dim('$')} ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12"`,
574
+ ` ${s.dim('$')} ticketlens triage --project=MYPROJ --priority=High`,
575
+ ` ${s.dim('$')} ticketlens triage --label=Bug,P1`,
563
576
  ` ${s.dim('$')} ticketlens triage --static`,
564
577
  '',
565
578
  ` ${s.bold('INTERACTIVE MODE')}`,
@@ -231,6 +231,7 @@ export function resolveConnection(ticketKey, opts = {}) {
231
231
  pat: profileCreds.pat || null,
232
232
  triageStatuses: profile.triageStatuses || null,
233
233
  ticketPrefixes: profile.ticketPrefixes || null,
234
+ attentionRules: profile.attentionRules ?? null,
234
235
  source: 'profile',
235
236
  profileName: profile.name,
236
237
  };
@@ -7,13 +7,37 @@ import { apiBase, warnIfInsecure } from './api-utils.mjs';
7
7
 
8
8
  const SCHEDULE_PATH = '/v1/schedule';
9
9
 
10
+ // Safe path: printable ASCII, no shell metacharacters or XML special chars.
11
+ // Allows: letters, digits, /, ., _, -, ~, space (none of which are shell-special in this context).
12
+ const SAFE_PATH_RE = /^[A-Za-z0-9/._~-]+$/;
13
+
14
+ function xmlEscape(s) {
15
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
16
+ }
17
+
18
+ function validateOutputFile(outputFile) {
19
+ if (!outputFile || !SAFE_PATH_RE.test(outputFile)) {
20
+ throw new Error(`Invalid output file path: must contain only letters, digits, /, ., _, -, ~ (no spaces or shell metacharacters)`);
21
+ }
22
+ }
23
+
24
+ function validateTime(hour, minute) {
25
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) ||
26
+ hour < 0 || hour > 23 || minute < 0 || minute > 59) {
27
+ throw new Error(`Invalid time: hour must be 0–23, minute must be 0–59`);
28
+ }
29
+ }
30
+
31
+ function parseHHMM(time) {
32
+ const [hourStr, minuteStr] = time.split(':');
33
+ return { hour: parseInt(hourStr, 10), minute: parseInt(minuteStr, 10) };
34
+ }
35
+
10
36
  /**
11
37
  * Build a macOS LaunchAgent plist string.
12
38
  */
13
39
  export function buildPlist({ hour, minute, ticketlensBin }) {
14
- if (!Number.isInteger(hour) || !Number.isInteger(minute)) {
15
- throw new Error('hour and minute must be integers');
16
- }
40
+ validateTime(hour, minute);
17
41
  return `<?xml version="1.0" encoding="UTF-8"?>
18
42
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19
43
  <plist version="1.0">
@@ -68,9 +92,7 @@ export async function runScheduleWizard({
68
92
  return { ok: false };
69
93
  }
70
94
  const { time, email, timezone } = answers;
71
- const [hourStr, minuteStr] = time.split(':');
72
- const hour = parseInt(hourStr, 10);
73
- const minute = parseInt(minuteStr, 10);
95
+ const { hour, minute } = parseHHMM(time);
74
96
 
75
97
  try {
76
98
  const res = await fetcher(`${apiBase()}${SCHEDULE_PATH}`, {
@@ -146,7 +168,8 @@ export async function runScheduleStop({
146
168
  try { unlinkSync(plistPath); } catch { /* already removed */ }
147
169
  } else {
148
170
  const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
149
- const updated = existing.replace(/.*ticketlens triage --digest.*\n?/g, '');
171
+ // Remove both cloud-digest and local-save ticketlens cron lines
172
+ const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '');
150
173
  const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
151
174
  writeFileSync(tmp, updated, 'utf8');
152
175
  spawnSync('crontab', [tmp], { encoding: 'utf8' });
@@ -189,10 +212,93 @@ export async function runScheduleStatus({
189
212
  print('\n');
190
213
  }
191
214
 
215
+ /**
216
+ * Build a local-only cron/plist entry that runs triage --save=FILE.
217
+ * No cloud auth required — purely local scheduling.
218
+ */
219
+ export function buildLocalPlist({ hour, minute, ticketlensBin, outputFile }) {
220
+ const safeFile = xmlEscape(outputFile);
221
+ const safeBin = xmlEscape(ticketlensBin);
222
+ return `<?xml version="1.0" encoding="UTF-8"?>
223
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
224
+ <plist version="1.0">
225
+ <dict>
226
+ <key>Label</key>
227
+ <string>io.ticketlens.triage-local</string>
228
+ <key>ProgramArguments</key>
229
+ <array>
230
+ <string>${safeBin}</string>
231
+ <string>triage</string>
232
+ <string>--save=${safeFile}</string>
233
+ </array>
234
+ <key>StartCalendarInterval</key>
235
+ <dict>
236
+ <key>Hour</key>
237
+ <integer>${hour}</integer>
238
+ <key>Minute</key>
239
+ <integer>${minute}</integer>
240
+ </dict>
241
+ <key>StandardOutPath</key>
242
+ <string>${safeFile}</string>
243
+ <key>StandardErrorPath</key>
244
+ <string>${safeFile}.err</string>
245
+ </dict>
246
+ </plist>`;
247
+ }
248
+
249
+ export function buildLocalCronLine({ hour, minute, ticketlensBin, outputFile }) {
250
+ // Single-quote the --save argument to prevent shell word-splitting.
251
+ // SAFE_PATH_RE already guarantees no single-quote chars in outputFile.
252
+ return `${minute} ${hour} * * * ${ticketlensBin} triage '--save=${outputFile}' >> '${outputFile}.err' 2>&1`;
253
+ }
254
+
255
+ /**
256
+ * Set up a local-only scheduled triage (no Console auth, no cloud push).
257
+ * Writes a cron/LaunchAgent entry that runs `ticketlens triage --save=FILE`.
258
+ */
259
+ export async function runScheduleLocal({
260
+ answers,
261
+ platform = osPlatform(),
262
+ writeLocalJob = defaultWriteLocalJob,
263
+ print = s => process.stdout.write(s),
264
+ // fetcher is accepted but intentionally ignored — no network calls
265
+ }) {
266
+ const { time, outputFile } = answers ?? {};
267
+ if (!time || !outputFile) {
268
+ print(` ${red('✗')} Local schedule requires a time and output file.\n`);
269
+ return { ok: false };
270
+ }
271
+ const { hour, minute } = parseHHMM(time);
272
+ try {
273
+ validateTime(hour, minute);
274
+ validateOutputFile(outputFile);
275
+ } catch (err) {
276
+ print(` ${red('✗')} ${err.message}\n`);
277
+ return { ok: false };
278
+ }
279
+
280
+ const ticketlensBin = resolveTicketlensBin();
281
+ const content = platform === 'darwin'
282
+ ? buildLocalPlist({ hour, minute, ticketlensBin, outputFile })
283
+ : buildLocalCronLine({ hour, minute, ticketlensBin, outputFile });
284
+
285
+ writeLocalJob(content, platform);
286
+
287
+ print(`\n ${green('✔')} ${bold('Local triage scheduled')}\n`);
288
+ print(` ${dim('Time: ')} ${cyan(time)} daily\n`);
289
+ print(` ${dim('Output: ')} ${cyan(outputFile)}\n\n`);
290
+
291
+ return { ok: true, time, outputFile };
292
+ }
293
+
294
+ let _ticketlensBin;
192
295
  function resolveTicketlensBin() {
296
+ if (_ticketlensBin) return _ticketlensBin;
193
297
  const which = spawnSync('which', ['ticketlens'], { encoding: 'utf8' });
194
- if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
195
- return `${homedir()}/.npm/bin/ticketlens`;
298
+ _ticketlensBin = (which.status === 0 && which.stdout.trim())
299
+ ? which.stdout.trim()
300
+ : `${homedir()}/.npm/bin/ticketlens`;
301
+ return _ticketlensBin;
196
302
  }
197
303
 
198
304
  function defaultWriteLocalJob(content, platform) {
@@ -204,7 +310,8 @@ function defaultWriteLocalJob(content, platform) {
204
310
  spawnSync('launchctl', ['load', plistPath], { encoding: 'utf8' });
205
311
  } else {
206
312
  const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8' }).stdout ?? '';
207
- const updated = existing.replace(/.*ticketlens triage --digest.*/g, '').trimEnd() + '\n' + content + '\n';
313
+ // Remove both cloud-digest and local-save ticketlens cron lines before re-adding
314
+ const updated = existing.replace(/.*ticketlens triage --(digest|'--save=).*\n?/g, '').trimEnd() + '\n' + content + '\n';
208
315
  const tmp = `/tmp/ticketlens-crontab-${Date.now()}`;
209
316
  writeFileSync(tmp, updated, 'utf8');
210
317
  spawnSync('crontab', [tmp], { encoding: 'utf8' });
@@ -8,7 +8,7 @@ import * as defaultFs from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
 
11
- const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
11
+ export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
12
12
 
13
13
  const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
14
14
 
@@ -16,6 +16,17 @@ const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
16
16
  // Internal helpers
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
+ function median(arr) {
20
+ if (!arr.length) return null;
21
+ const sorted = [...arr].sort((a, b) => a - b);
22
+ const mid = Math.floor(sorted.length / 2);
23
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Internal helpers
28
+ // ---------------------------------------------------------------------------
29
+
19
30
  function validateProfile(profile) {
20
31
  if (!profile || /[/\\]/.test(profile) || profile === '..' || profile.includes('..')) {
21
32
  throw new Error('Invalid profile name');
@@ -59,7 +70,8 @@ export function saveTriageSnapshot(tickets, {
59
70
  const dir = join(configDir, 'triage-history', dateStr);
60
71
  fsModule.mkdirSync(dir, { recursive: true });
61
72
  const filePath = snapshotPath(configDir, dateStr, profile);
62
- fsModule.writeFileSync(filePath, JSON.stringify(tickets, null, 2), 'utf8');
73
+ const envelope = { captured_at: now.toISOString(), tickets };
74
+ fsModule.writeFileSync(filePath, JSON.stringify(envelope, null, 2), 'utf8');
63
75
  }
64
76
 
65
77
  /**
@@ -70,7 +82,7 @@ export function saveTriageSnapshot(tickets, {
70
82
  * @param {string} [opts.configDir]
71
83
  * @param {object} [opts.fsModule]
72
84
  * @param {Date} [opts.now]
73
- * @returns {object[]|null}
85
+ * @returns {{ captured_at: string|null, tickets: object[] }|null}
74
86
  */
75
87
  export function loadYesterdaySnapshot({
76
88
  profile,
@@ -85,7 +97,9 @@ export function loadYesterdaySnapshot({
85
97
  const filePath = snapshotPath(configDir, dateStr, profile);
86
98
  try {
87
99
  const raw = fsModule.readFileSync(filePath, 'utf8');
88
- return JSON.parse(raw);
100
+ const parsed = JSON.parse(raw);
101
+ if (Array.isArray(parsed)) return { captured_at: null, tickets: parsed };
102
+ return parsed;
89
103
  } catch {
90
104
  return null;
91
105
  }
@@ -162,3 +176,203 @@ export function buildDeltaSection(deltas) {
162
176
 
163
177
  return lines.join('\n') + '\n';
164
178
  }
179
+
180
+ /**
181
+ * Query the full history for a single ticket key across all dated snapshots.
182
+ *
183
+ * Reads ~/.ticketlens/triage-history/YYYY-MM-DD/*.json, finds entries matching
184
+ * the given ticketKey, and returns a chronologically sorted timeline.
185
+ * Entries with urgency oscillating in the same direction on consecutive days
186
+ * are flagged with `bounced: true`.
187
+ *
188
+ * @param {string} ticketKey
189
+ * @param {object} [opts]
190
+ * @param {string} [opts.configDir]
191
+ * @param {object} [opts.fsModule]
192
+ * @returns {{ date: string, profile: string, urgency: string, status: string, reason: string, bounced: boolean }[]}
193
+ */
194
+ export function queryTicketHistory(ticketKey, {
195
+ configDir = DEFAULT_CONFIG_DIR,
196
+ fsModule = defaultFs,
197
+ } = {}) {
198
+ const histDir = join(configDir, 'triage-history');
199
+ if (!fsModule.existsSync(histDir)) return [];
200
+
201
+ const dates = fsModule.readdirSync(histDir)
202
+ .filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
203
+ .sort(); // lexicographic = chronological for YYYY-MM-DD
204
+
205
+ const entries = [];
206
+ for (const date of dates) {
207
+ const dayDir = join(histDir, date);
208
+ let profileFiles;
209
+ try {
210
+ profileFiles = fsModule.readdirSync(dayDir).filter(f => f.endsWith('.json'));
211
+ } catch {
212
+ continue;
213
+ }
214
+ for (const file of profileFiles) {
215
+ const profile = file.slice(0, -5); // strip .json
216
+ let tickets;
217
+ try {
218
+ const raw = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
219
+ tickets = Array.isArray(raw) ? raw : (raw.tickets ?? []);
220
+ } catch {
221
+ continue;
222
+ }
223
+ const found = tickets.find(t => t.ticketKey === ticketKey);
224
+ if (found) {
225
+ entries.push({
226
+ date,
227
+ profile,
228
+ urgency: found.urgency ?? 'unknown',
229
+ status: found.status ?? '',
230
+ reason: found.reason ?? '',
231
+ bounced: false,
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ // Detect bounces: flag entries where urgency changed direction on consecutive days
238
+ for (let i = 1; i < entries.length; i++) {
239
+ const prev = entries[i - 1];
240
+ const cur = entries[i];
241
+ if (cur.urgency !== prev.urgency && cur.profile === prev.profile) {
242
+ cur.bounced = true;
243
+ }
244
+ }
245
+
246
+ return entries;
247
+ }
248
+
249
+ /**
250
+ * Load a snapshot file for an arbitrary date string. Returns { captured_at, tickets } or null.
251
+ *
252
+ * @param {string} dateStr - YYYY-MM-DD
253
+ * @param {string} profile
254
+ * @param {string} configDir
255
+ * @param {object} fsModule
256
+ * @returns {{ captured_at: string|null, tickets: object[] }|null}
257
+ */
258
+ function loadSnapshotForDate(dateStr, profile, configDir, fsModule) {
259
+ const filePath = snapshotPath(configDir, dateStr, profile);
260
+ try {
261
+ const parsed = JSON.parse(fsModule.readFileSync(filePath, 'utf8'));
262
+ if (Array.isArray(parsed)) return { captured_at: null, tickets: parsed };
263
+ return parsed;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Compute response-time and triage-cadence metrics from local triage history.
271
+ *
272
+ * A "response transition" is detected when a ticket appears as `needs-response`
273
+ * in snapshot D and `clear` in snapshot D+1. Response time = captured_at of the
274
+ * clear snapshot minus lastComment.created of the needs-response snapshot.
275
+ *
276
+ * @param {string} profile
277
+ * @param {object} [opts]
278
+ * @param {number} [opts.days=7] Lookback window in calendar days
279
+ * @param {string} [opts.configDir]
280
+ * @param {object} [opts.fsModule]
281
+ * @param {Date} [opts.now]
282
+ * @returns {{
283
+ * avgResponseHours: number|null,
284
+ * medianResponseHours: number|null,
285
+ * clearRate: number|null,
286
+ * triageRunCount: number,
287
+ * currentUrgency: { needsResponse: number, aging: number, clear: number }|null,
288
+ * windowDays: number,
289
+ * trendHours: number|null,
290
+ * }}
291
+ */
292
+ export function computeResponseMetrics(profile, {
293
+ days = 7,
294
+ configDir = DEFAULT_CONFIG_DIR,
295
+ fsModule = defaultFs,
296
+ now = new Date(),
297
+ } = {}) {
298
+ validateProfile(profile);
299
+
300
+ function windowSnapshots(offsetStart, count) {
301
+ const snaps = [];
302
+ for (let i = offsetStart + count - 1; i >= offsetStart; i--) {
303
+ const d = new Date(now);
304
+ d.setDate(d.getDate() - i);
305
+ const dateStr = toDateString(d);
306
+ const snap = loadSnapshotForDate(dateStr, profile, configDir, fsModule);
307
+ if (snap) snaps.push({ dateStr, ...snap });
308
+ }
309
+ return snaps; // chronological order (oldest first)
310
+ }
311
+
312
+ function computeFromSnaps(snaps) {
313
+ const durations = [];
314
+ let transitions = 0;
315
+ let fastTransitions = 0;
316
+
317
+ for (let i = 0; i + 1 < snaps.length; i++) {
318
+ const dayA = snaps[i];
319
+ const dayB = snaps[i + 1];
320
+ const mapA = new Map(dayA.tickets.map(t => [t.ticketKey, t]));
321
+
322
+ for (const tb of dayB.tickets) {
323
+ if (tb.urgency !== 'clear') continue;
324
+ const ta = mapA.get(tb.ticketKey);
325
+ if (!ta || ta.urgency !== 'needs-response') continue;
326
+
327
+ transitions++;
328
+ const commentCreated = ta.lastComment?.created;
329
+ const clearedAt = dayB.captured_at;
330
+ if (!commentCreated || !clearedAt) continue;
331
+
332
+ const ms = new Date(clearedAt).getTime() - new Date(commentCreated).getTime();
333
+ if (isNaN(ms) || ms < 0) continue;
334
+ const hours = ms / 3_600_000;
335
+ durations.push(hours);
336
+ if (hours <= 24) fastTransitions++;
337
+ }
338
+ }
339
+
340
+ return {
341
+ avgResponseHours: durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : null,
342
+ medianResponseHours: median(durations),
343
+ clearRate: durations.length > 0 ? fastTransitions / durations.length : null,
344
+ triageRunCount: snaps.length,
345
+ };
346
+ }
347
+
348
+ const currentSnaps = windowSnapshots(0, days);
349
+ const current = computeFromSnaps(currentSnaps);
350
+
351
+ // Current urgency from the most recent snapshot
352
+ const latest = currentSnaps[currentSnaps.length - 1] ?? null;
353
+ const currentUrgency = latest
354
+ ? {
355
+ needsResponse: latest.tickets.filter(t => t.urgency === 'needs-response').length,
356
+ aging: latest.tickets.filter(t => t.urgency === 'aging').length,
357
+ clear: latest.tickets.filter(t => t.urgency === 'clear').length,
358
+ }
359
+ : null;
360
+
361
+ // Trend: compare current window avg vs prior window avg
362
+ const priorSnaps = windowSnapshots(days, days);
363
+ const prior = computeFromSnaps(priorSnaps);
364
+ const trendHours =
365
+ current.avgResponseHours !== null && prior.avgResponseHours !== null
366
+ ? current.avgResponseHours - prior.avgResponseHours
367
+ : null;
368
+
369
+ return {
370
+ avgResponseHours: current.avgResponseHours,
371
+ medianResponseHours: current.medianResponseHours,
372
+ clearRate: current.clearRate,
373
+ triageRunCount: current.triageRunCount,
374
+ currentUrgency,
375
+ windowDays: days,
376
+ trendHours,
377
+ };
378
+ }