ticketlens 0.5.0 → 0.6.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
@@ -137,6 +137,8 @@ ticketlens triage --export=csv # Export results to CSV [Team]
137
137
  ticketlens triage --export=json # Export results to JSON [Team]
138
138
  ticketlens triage --push # Push snapshot to Console queue [Team]
139
139
  ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
140
+ ticketlens triage --all # Triage all profiles at once, merged output [Pro]
141
+ ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
140
142
  ticketlens triage --digest # POST scored results to digest endpoint [Pro]
141
143
  ticketlens triage --plain # Plain markdown — pipe to file or LLM
142
144
  ticketlens triage --static # Static table, no interactive mode
@@ -331,12 +333,48 @@ Cache locations:
331
333
  ticketlens schedule # Interactive wizard — set digest time, timezone, profile [Pro]
332
334
  ticketlens schedule --stop # Cancel the scheduled digest
333
335
  ticketlens schedule --status # Show current schedule
336
+ ticketlens schedule --local # Local-only cron (no Console auth) — saves triage to file [Pro]
334
337
  ```
335
338
 
336
339
  Stores the schedule as a cron entry. Delivers your triage digest at the configured time without an open terminal. Requires a Pro license.
337
340
 
338
341
  ---
339
342
 
343
+ ### History
344
+
345
+ ```bash
346
+ ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
347
+ ```
348
+
349
+ 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.
350
+
351
+ Requires a Pro license. No network call — reads local snapshots only.
352
+
353
+ ---
354
+
355
+ ### Custom Attention Rules
356
+
357
+ Add an `attentionRules` array to any profile in `~/.ticketlens/profiles.json` to override how `ticketlens triage` scores specific tickets:
358
+
359
+ ```json
360
+ {
361
+ "profiles": {
362
+ "work": {
363
+ "baseUrl": "https://jira.example.com",
364
+ "attentionRules": [
365
+ { "match": { "priority": "Highest" }, "action": "force-urgent", "reason": "P1 always urgent" },
366
+ { "match": { "label": "backlog" }, "action": "ignore", "reason": "skip backlog" },
367
+ { "match": { "status": "Parked" }, "action": "ignore", "reason": "parked tickets" }
368
+ ]
369
+ }
370
+ }
371
+ }
372
+ ```
373
+
374
+ 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.
375
+
376
+ ---
377
+
340
378
  ### Login
341
379
 
342
380
  ```bash
@@ -486,6 +524,8 @@ ticketlens triage --export=csv # Export to CSV [Team]
486
524
  ticketlens triage --export=json # Export to JSON [Team]
487
525
  ticketlens triage --push # Push snapshot to Console queue [Team]
488
526
  ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
527
+ ticketlens triage --all # Triage all profiles at once, merged output [Pro]
528
+ ticketlens triage --save=~/triage.txt # Save ANSI-stripped output to file [Pro]
489
529
  ticketlens triage --digest # POST results to digest endpoint [Pro]
490
530
  ticketlens triage --profile=acme --stale=3 --static # Combine flags
491
531
 
@@ -537,6 +577,10 @@ ticketlens cache clear --older-than=30d --yes # Skip confirmation (CI
537
577
  ticketlens schedule # Interactive wizard — set time, timezone, profile [Pro]
538
578
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
539
579
  ticketlens schedule --status # Show current schedule [Pro]
580
+ ticketlens schedule --local # Local-only cron/LaunchAgent — no Console auth needed [Pro]
581
+
582
+ # ── History ───────────────────────────────────────────────────────────────────
583
+ ticketlens history <TICKET-KEY> # Show urgency timeline for a ticket [Pro]
540
584
 
541
585
  # ── Compliance ────────────────────────────────────────────────────────────────
542
586
  ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
@@ -31,12 +31,27 @@ import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/script
31
31
  import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
32
32
  import { syncProfiles, getApiBase, getConsoleBase } from '../skills/jtb/scripts/lib/sync.mjs';
33
33
  import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
34
+ import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
34
35
 
35
36
  const args = process.argv.slice(2);
36
37
  const { command, args: cmdArgs } = parseCommand(args);
37
38
 
38
39
  // Fire-and-forget: silently refresh license.json at startup if >7 days since last validation
39
40
  revalidateIfStale();
41
+ // Fire-and-forget: refresh the cached latest npm version once per 24h
42
+ checkForUpdate();
43
+
44
+ // Show a one-line update hint on stderr after the command exits (never blocks stdout)
45
+ process.on('exit', () => {
46
+ try {
47
+ const latest = getUpdateHint();
48
+ if (!latest) return;
49
+ const s = createStyler({ isTTY: process.stderr.isTTY });
50
+ process.stderr.write(`\n${s.brand('◆')} Update available ${s.dim('→')} ${s.bold(s.cyan(latest))} ${s.dim('npm install -g ticketlens')}\n`);
51
+ } catch {
52
+ // never let the update hint crash the process
53
+ }
54
+ });
40
55
 
41
56
  switch (command) {
42
57
  case 'fetch':
@@ -63,6 +78,31 @@ switch (command) {
63
78
  break;
64
79
  }
65
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
+
66
106
  case 'init':
67
107
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printInitHelp(); break; }
68
108
  runInit().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.5.0",
3
+ "version": "0.6.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.6.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,11 @@ 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)
30
32
  /jtb triage --push # push snapshot + git branches to Console (Team)
31
33
  /jtb triage --share # generate 24h share URL (Team)
34
+ /jtb history PROD-1234 # show urgency timeline for a ticket (Pro)
32
35
  /jtb collisions # show branch collisions with teammates (Team)
33
36
  /jtb collisions --json # machine-readable output
34
37
  ```
@@ -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
 
@@ -74,7 +77,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
74
77
 
75
78
  const validatedArgs = await handleUnknownFlags(
76
79
  args,
77
- ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share'],
80
+ ['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share', '--all', '--save='],
78
81
  { hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
79
82
  );
80
83
  if (validatedArgs === null) { process.exitCode = 1; return; }
@@ -93,6 +96,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
93
96
  const digestFlag = args.includes('--digest');
94
97
  const pushFlag = args.includes('--push');
95
98
  const shareFlag = args.includes('--share');
99
+ const allFlag = args.includes('--all');
100
+ const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
96
101
 
97
102
  if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
98
103
  process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
@@ -103,6 +108,111 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
103
108
  const licensedFn = opts.isLicensed ?? isLicensed;
104
109
  const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
105
110
 
111
+ // --save=FILE: Pro gate + validate path is not a directory
112
+ if (saveArg) {
113
+ if (!licensedFn('pro', configDir)) {
114
+ upgradeFn('pro', '--save');
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ const resolvedSave = resolvePath(saveArg);
119
+ try {
120
+ if (statSync(resolvedSave).isDirectory()) {
121
+ process.stderr.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ } catch { /* doesn't exist yet — ok */ }
126
+ }
127
+
128
+ // --all: triage all configured profiles in parallel with live status block
129
+ if (allFlag) {
130
+ if (!licensedFn('pro', configDir)) {
131
+ upgradeFn('pro', '--all');
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ const profilesConfig = loadProfiles(configDir);
136
+ const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
137
+ if (profileNames.length === 0) {
138
+ process.stderr.write('Error: No profiles configured. Run `ticketlens init` first.\n');
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+
143
+ const usePlain = args.includes('--plain');
144
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
145
+ const argsBase = args.filter(a => a !== '--all' && !a.startsWith('--profile='));
146
+ // --static disables interactive mode in sub-runs (isTTY is still true but we're capturing output)
147
+ const modeFlags = usePlain ? ['--plain'] : ['--styled', '--static'];
148
+ const isTTY = process.stderr.isTTY;
149
+ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
150
+ const COL = Math.max(...profileNames.map(n => n.length)) + 2;
151
+
152
+ // Per-profile state
153
+ const entries = profileNames.map(name => ({ name, state: 'pending', output: [], summary: '' }));
154
+
155
+ const statusLine = (e, frame = 0) => {
156
+ const nameCol = e.name.padEnd(COL);
157
+ if (e.state === 'pending') return ` ${SPIN[frame % SPIN.length]} ${nameCol}${dim('fetching...')}`;
158
+ if (e.state === 'done') return ` ${green('✔')} ${nameCol}${dim(e.summary)}`;
159
+ return ` ${red('✗')} ${nameCol}${dim('could not connect')}`;
160
+ };
161
+
162
+ // Suppress all sub-run stderr so banners/spinners don't corrupt the status block.
163
+ // Status updates bypass suppression via `toStatus` which holds the original reference.
164
+ const toStatus = isTTY ? process.stderr.write.bind(process.stderr) : null;
165
+ if (isTTY) {
166
+ process.stderr.write = () => true; // silence sub-runs
167
+ toStatus('\x1b[?25l');
168
+ for (const e of entries) toStatus(statusLine(e) + '\n');
169
+ }
170
+
171
+ let frame = 0;
172
+ const timer = isTTY ? setInterval(() => {
173
+ frame++;
174
+ toStatus(`\x1b[${entries.length}A`);
175
+ for (const e of entries) toStatus(`\r\x1b[K${statusLine(e, frame)}\n`);
176
+ }, 80) : null;
177
+
178
+ await Promise.allSettled(entries.map((e, idx) =>
179
+ run([...argsBase, `--profile=${e.name}`, ...modeFlags],
180
+ { ...opts, env, fetcher, configDir, print: s => e.output.push(s) })
181
+ .then(() => {
182
+ const combined = e.output.join('');
183
+ const m = combined.match(/(\d+) found/);
184
+ entries[idx].state = e.output.length === 0 ? 'error' : 'done';
185
+ entries[idx].summary = m ? `${m[1]} ticket${m[1] === '1' ? '' : 's'}` : 'done';
186
+ })
187
+ .catch(() => { entries[idx].state = 'error'; })
188
+ ));
189
+
190
+ if (isTTY) {
191
+ clearInterval(timer);
192
+ toStatus(`\x1b[${entries.length}A`);
193
+ for (const e of entries) toStatus(`\r\x1b[K${statusLine(e)}\n`);
194
+ toStatus('\x1b[?25h\n');
195
+ process.stderr.write = toStatus; // restore
196
+ }
197
+
198
+ // Render results in profile order
199
+ for (const e of entries) {
200
+ printFn(usePlain
201
+ ? `\n── ${e.name} ──\n`
202
+ : `\n${dim('──')} ${bold(cyan(e.name))} ${dim('──')}\n`);
203
+
204
+ if (e.output.length === 0) {
205
+ const hint = `ticketlens triage --profile=${e.name}`;
206
+ printFn(usePlain
207
+ ? ` [could not connect — run: ${hint}]\n`
208
+ : ` ${red('✗')} ${dim('Could not connect — run')} ${cyan(hint)} ${dim('for details')}\n`);
209
+ } else {
210
+ printFn(e.output.join(''));
211
+ }
212
+ }
213
+ return;
214
+ }
215
+
106
216
  // Team-tier gate: --assignee and --sprint require a Team license
107
217
  if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
108
218
  upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
@@ -283,11 +393,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
283
393
  process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
284
394
  }
285
395
 
286
- const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays }));
287
- const actionable = scored.filter(s => s.urgency !== 'clear');
396
+ const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules }));
397
+ const actionable = scored.filter(s => s.urgency !== 'clear' && s.urgency !== 'ignore');
288
398
  const sorted = sortByUrgency(actionable);
289
399
  const rawTicketMap = new Map(tickets.map(t => [t.key, t]));
290
400
 
401
+ // Always save a daily snapshot for history tracking (non-fatal)
402
+ try {
403
+ const { saveTriageSnapshot } = await import('./lib/triage-history.mjs');
404
+ saveTriageSnapshot(scored, { profile: profileName ?? 'default', configDir });
405
+ } catch { /* non-fatal */ }
406
+
291
407
  // --digest: POST scored results to the digest backend endpoint
292
408
  if (digestFlag) {
293
409
  if (!licensedFn('pro', configDir)) {
@@ -298,12 +414,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
298
414
  const deliverer = opts.digestDeliverer ?? defaultDigestDeliverer;
299
415
  const digestCliToken = opts.cliToken ?? readCliToken(configDir) ?? null;
300
416
 
301
- // Triage history delta (non-fatal — wrapped in try/catch)
417
+ // Triage history delta (non-fatal — snapshot already saved above)
302
418
  let delta = null;
303
419
  try {
304
- const { saveTriageSnapshot, loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
420
+ const { loadYesterdaySnapshot, diffSnapshots, buildDeltaSection } =
305
421
  await import('./lib/triage-history.mjs');
306
- saveTriageSnapshot(sorted, { profile: profileName ?? 'default', configDir });
307
422
  const yesterday = loadYesterdaySnapshot({ profile: profileName ?? 'default', configDir });
308
423
  if (yesterday) {
309
424
  const deltas = diffSnapshots(sorted, yesterday);
@@ -314,11 +429,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
314
429
  await deliverer({
315
430
  profile: profileName ?? 'default',
316
431
  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
- },
432
+ summary: (() => {
433
+ let needsResponse = 0, aging = 0;
434
+ for (const t of sorted) {
435
+ if (t.urgency === 'needs-response') needsResponse++;
436
+ else if (t.urgency === 'aging') aging++;
437
+ }
438
+ return { total: sorted.length, needsResponse, aging };
439
+ })(),
322
440
  tickets: sorted,
323
441
  delta,
324
442
  }, { cliToken: digestCliToken });
@@ -372,7 +490,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
372
490
  }
373
491
 
374
492
  // Interactive mode: TTY + not --plain + not --static
375
- const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static');
493
+ const wantInteractive = process.stdout.isTTY && !args.includes('--plain') && !args.includes('--static') && !saveArg;
376
494
  if (wantInteractive && process.stdin.setRawMode) {
377
495
  const result = await runInteractiveList(sorted, { baseUrl: conn.baseUrl, staleDays, styled: true });
378
496
  if (result === 'switch') {
@@ -386,7 +504,17 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
386
504
  const summary = useStyled
387
505
  ? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
388
506
  : assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
389
- process.stdout.write(summary + '\n');
507
+
508
+ // --save=FILE: write ANSI-stripped output to file
509
+ if (saveArg) {
510
+ const resolvedSave = resolvePath(saveArg);
511
+ mkdirSync(dirname(resolvedSave), { recursive: true });
512
+ const plain = stripAnsi(summary);
513
+ writeFileSync(resolvedSave, plain + '\n', 'utf8');
514
+ }
515
+
516
+ const printFn = opts.print ?? ((s) => process.stdout.write(s));
517
+ printFn(summary + '\n');
390
518
  }
391
519
 
392
520
  // 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`,
@@ -79,6 +80,8 @@ export function printHelp({ stream = process.stdout } = {}) {
79
80
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
80
81
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
81
82
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
83
+ ` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
84
+ ` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
82
85
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
83
86
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
84
87
  ` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
@@ -549,6 +552,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
549
552
  ` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
550
553
  ` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
551
554
  ` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
555
+ ` ${s.brand('--all')} Triage all configured profiles at once ${s.dim('[Pro]')}`,
556
+ ` ${s.brand('--save')}=${s.dim('FILE')} Save ANSI-stripped output to file ${s.dim('[Pro]')}`,
552
557
  ` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
553
558
  ` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
554
559
  ` ${s.brand('--plain')} Plain markdown output`,
@@ -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' });
@@ -162,3 +162,71 @@ export function buildDeltaSection(deltas) {
162
162
 
163
163
  return lines.join('\n') + '\n';
164
164
  }
165
+
166
+ /**
167
+ * Query the full history for a single ticket key across all dated snapshots.
168
+ *
169
+ * Reads ~/.ticketlens/triage-history/YYYY-MM-DD/*.json, finds entries matching
170
+ * the given ticketKey, and returns a chronologically sorted timeline.
171
+ * Entries with urgency oscillating in the same direction on consecutive days
172
+ * are flagged with `bounced: true`.
173
+ *
174
+ * @param {string} ticketKey
175
+ * @param {object} [opts]
176
+ * @param {string} [opts.configDir]
177
+ * @param {object} [opts.fsModule]
178
+ * @returns {{ date: string, profile: string, urgency: string, status: string, reason: string, bounced: boolean }[]}
179
+ */
180
+ export function queryTicketHistory(ticketKey, {
181
+ configDir = DEFAULT_CONFIG_DIR,
182
+ fsModule = defaultFs,
183
+ } = {}) {
184
+ const histDir = join(configDir, 'triage-history');
185
+ if (!fsModule.existsSync(histDir)) return [];
186
+
187
+ const dates = fsModule.readdirSync(histDir)
188
+ .filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
189
+ .sort(); // lexicographic = chronological for YYYY-MM-DD
190
+
191
+ const entries = [];
192
+ for (const date of dates) {
193
+ const dayDir = join(histDir, date);
194
+ let profileFiles;
195
+ try {
196
+ profileFiles = fsModule.readdirSync(dayDir).filter(f => f.endsWith('.json'));
197
+ } catch {
198
+ continue;
199
+ }
200
+ for (const file of profileFiles) {
201
+ const profile = file.slice(0, -5); // strip .json
202
+ let tickets;
203
+ try {
204
+ tickets = JSON.parse(fsModule.readFileSync(join(dayDir, file), 'utf8'));
205
+ } catch {
206
+ continue;
207
+ }
208
+ const found = Array.isArray(tickets) && tickets.find(t => t.ticketKey === ticketKey);
209
+ if (found) {
210
+ entries.push({
211
+ date,
212
+ profile,
213
+ urgency: found.urgency ?? 'unknown',
214
+ status: found.status ?? '',
215
+ reason: found.reason ?? '',
216
+ bounced: false,
217
+ });
218
+ }
219
+ }
220
+ }
221
+
222
+ // Detect bounces: flag entries where urgency changed direction on consecutive days
223
+ for (let i = 1; i < entries.length; i++) {
224
+ const prev = entries[i - 1];
225
+ const cur = entries[i];
226
+ if (cur.urgency !== prev.urgency && cur.profile === prev.profile) {
227
+ cur.bounced = true;
228
+ }
229
+ }
230
+
231
+ return entries;
232
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Background npm update check.
3
+ * Caches the latest published version in ~/.ticketlens/update-check.json with a 24h TTL.
4
+ * Never blocks the CLI — all failures are silently swallowed.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
10
+
11
+ export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
12
+ const REGISTRY_URL = 'https://registry.npmjs.org/ticketlens/latest';
13
+ const CHECK_FILE = 'update-check.json';
14
+
15
+ /** Path to the update-check cache file. */
16
+ function checkFilePath(configDir) {
17
+ return join(configDir, CHECK_FILE);
18
+ }
19
+
20
+ /**
21
+ * Returns the cached latestVersion string if the cache is present and within TTL,
22
+ * or null otherwise.
23
+ */
24
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
25
+
26
+ export function readUpdateCheck(configDir = DEFAULT_CONFIG_DIR) {
27
+ try {
28
+ const data = JSON.parse(readFileSync(checkFilePath(configDir), 'utf8'));
29
+ if (!data?.checkedAt || typeof data.latestVersion !== 'string') return null;
30
+ if (!SEMVER_RE.test(data.latestVersion)) return null;
31
+ if (Date.now() - new Date(data.checkedAt).getTime() > UPDATE_CHECK_TTL_MS) return null;
32
+ return data.latestVersion;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Writes { checkedAt, latestVersion } to the cache file. Non-fatal.
40
+ */
41
+ export function writeUpdateCheck(configDir = DEFAULT_CONFIG_DIR, latestVersion) {
42
+ try {
43
+ mkdirSync(configDir, { recursive: true });
44
+ writeFileSync(
45
+ checkFilePath(configDir),
46
+ JSON.stringify({ checkedAt: new Date().toISOString(), latestVersion }),
47
+ { mode: 0o644 },
48
+ );
49
+ } catch {
50
+ // non-fatal
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Returns true when latest is strictly greater than current.
56
+ * Strips leading 'v' from both before comparing.
57
+ */
58
+ export function isNewer(latest, current) {
59
+ const parse = v => v.replace(/^v/, '').split('.').map(Number);
60
+ const [la, lb, lc] = parse(latest);
61
+ const [ca, cb, cc] = parse(current);
62
+ if (la !== ca) return la > ca;
63
+ if (lb !== cb) return lb > cb;
64
+ return lc > cc;
65
+ }
66
+
67
+ /**
68
+ * Fetches the latest version from the npm registry.
69
+ * Returns the version string, or null on any failure.
70
+ */
71
+ export async function fetchLatestVersion({ fetcher = globalThis.fetch } = {}) {
72
+ try {
73
+ const res = await fetcher(REGISTRY_URL, { signal: AbortSignal.timeout(10_000) });
74
+ if (!res.ok) return null;
75
+ const data = await res.json();
76
+ return data?.version ?? null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Fire-and-forget: checks if the cache is expired and, if so, fetches the latest
84
+ * version from npm and writes it to the cache. Errors are silently swallowed.
85
+ * Call at CLI startup; never await.
86
+ */
87
+ export function checkForUpdate({ configDir = DEFAULT_CONFIG_DIR, fetcher = globalThis.fetch } = {}) {
88
+ if (readUpdateCheck(configDir) !== null) return Promise.resolve();
89
+ return fetchLatestVersion({ fetcher })
90
+ .then(v => { if (v) writeUpdateCheck(configDir, v); })
91
+ .catch(() => {});
92
+ }
93
+
94
+ /**
95
+ * Returns the cached latestVersion string if it is newer than the current installed
96
+ * version, or null if no update is available or the cache is absent/expired.
97
+ */
98
+ export function getUpdateHint({ configDir = DEFAULT_CONFIG_DIR, currentVersion = getVersion() } = {}) {
99
+ const latest = readUpdateCheck(configDir);
100
+ if (!latest) return null;
101
+ return isNewer(latest, currentVersion) ? latest : null;
102
+ }