ticketlens 0.3.0 → 0.4.1

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
@@ -211,6 +211,53 @@ Flag validation provides actionable hints:
211
211
 
212
212
  ---
213
213
 
214
+ ### Compliance
215
+
216
+ ```bash
217
+ ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
218
+ ticketlens compliance <TICKET-KEY> --profile=acme # Specify a profile
219
+ ticketlens compliance <TICKET-KEY> --plain # Plain markdown output
220
+ ```
221
+
222
+ Runs the same compliance check as `ticketlens CNV1-2 --compliance` but as a dedicated subcommand — useful when you want to check compliance without fetching the full ticket brief. Free accounts get 3 checks per month; Pro is unlimited.
223
+
224
+ ---
225
+
226
+ ### Compliance Ledger
227
+
228
+ ```bash
229
+ ticketlens ledger # View the local compliance audit ledger [Pro]
230
+ ticketlens ledger --plain # Plain markdown output
231
+ ```
232
+
233
+ Displays the append-only local ledger of all compliance checks run on this machine. Useful for SOC 2 / HIPAA audit trails. Requires a Pro license.
234
+
235
+ ---
236
+
237
+ ### Git Hook
238
+
239
+ ```bash
240
+ ticketlens install-hooks # Install pre-push compliance gate [Pro]
241
+ ticketlens install-hooks --uninstall # Remove installed hooks
242
+ ```
243
+
244
+ Installs a `pre-push` git hook that runs `ticketlens compliance` on every push. Blocks the push if compliance coverage falls below the configured threshold. Requires a Pro license.
245
+
246
+ ---
247
+
248
+ ### PR Description
249
+
250
+ ```bash
251
+ ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
252
+ ticketlens pr <TICKET-KEY> --profile=acme # Specify a profile
253
+ ticketlens pr <TICKET-KEY> --plain # Plain markdown output
254
+ ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard
255
+ ```
256
+
257
+ Generates a PR description template pre-filled with the ticket summary, acceptance criteria, and compliance coverage. Requires a Pro license.
258
+
259
+ ---
260
+
214
261
  ### Standup
215
262
 
216
263
  ```bash
@@ -295,12 +342,16 @@ Stores the schedule as a cron entry. Delivers your triage digest at the configur
295
342
  ```bash
296
343
  ticketlens login # Open browser → authorize in Console → token saved automatically
297
344
  ticketlens login --manual # Paste a token instead (CI/headless environments)
345
+ ticketlens logout # Revoke and remove the stored CLI token
346
+ ticketlens sync # Pull your latest tracker profiles from the Console
298
347
  ```
299
348
 
300
349
  `ticketlens login` opens the TicketLens Console in your default browser. Click **Authorize**, and the CLI receives your token via a one-shot localhost callback — no copy-pasting. Cancelling in the browser exits the CLI cleanly.
301
350
 
302
351
  Use `--manual` when there is no GUI (CI runners, SSH sessions, containers).
303
352
 
353
+ `ticketlens sync` pulls any tracker profiles you have configured in the Console and writes them locally, keeping your CLI in sync with your team settings without re-running `init`.
354
+
304
355
  ---
305
356
 
306
357
  ### License
@@ -483,9 +534,21 @@ ticketlens schedule # Interactive wizard — set time,
483
534
  ticketlens schedule --stop # Cancel the scheduled digest [Pro]
484
535
  ticketlens schedule --status # Show current schedule [Pro]
485
536
 
537
+ # ── Compliance ────────────────────────────────────────────────────────────────
538
+ ticketlens compliance <TICKET-KEY> # Check ticket requirements against local diff [Pro/Free 3/mo]
539
+ ticketlens ledger # View local compliance audit ledger [Pro]
540
+ ticketlens install-hooks # Install pre-push compliance gate [Pro]
541
+ ticketlens install-hooks --uninstall # Remove installed hooks [Pro]
542
+
543
+ # ── PR Description ─────────────────────────────────────────────────────────────
544
+ ticketlens pr <TICKET-KEY> # Generate PR description from ticket [Pro]
545
+ ticketlens pr <TICKET-KEY> | pbcopy # Copy to clipboard [Pro]
546
+
486
547
  # ── Login ─────────────────────────────────────────────────────────────────────
487
548
  ticketlens login # Browser flow — opens Console, token saved automatically
488
549
  ticketlens login --manual # Paste flow — for CI/headless environments
550
+ ticketlens logout # Revoke and remove stored CLI token
551
+ ticketlens sync # Pull tracker profiles from the Console
489
552
 
490
553
  # ── License and account ────────────────────────────────────────────────────────
491
554
  ticketlens license # Show license tier and status
@@ -24,6 +24,7 @@ import {
24
24
  printProfilesHelp, printScheduleHelp,
25
25
  printInitHelp, printSwitchHelp, printConfigHelp,
26
26
  printReviewHelp, printStandupHelp, printUpdateSkillHelp,
27
+ printCollisionsHelp,
27
28
  } from '../skills/jtb/scripts/lib/help.mjs';
28
29
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
29
30
  import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
@@ -53,6 +54,7 @@ switch (command) {
53
54
  break;
54
55
 
55
56
  case 'collisions': {
57
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCollisionsHelp(); break; }
56
58
  const { runCollisions } = await import('../skills/jtb/scripts/lib/run-collisions.mjs');
57
59
  runCollisions(cmdArgs).catch(err => {
58
60
  process.stderr.write(`Error: ${err.message}\n`);
@@ -127,6 +129,9 @@ switch (command) {
127
129
  process.stderr.write(`\n ${s.red('✖')} Activation failed: ${result.error}\n\n`);
128
130
  process.exitCode = 1;
129
131
  }
132
+ }).catch(err => {
133
+ process.stderr.write(`\n ${s.red('✖')} Activation error: ${err.message}\n\n`);
134
+ process.exitCode = 1;
130
135
  });
131
136
  break;
132
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.3.0 -->
1
+ <!-- jtb-skill-version: 0.4.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.
@@ -249,6 +249,12 @@ Generates a structured handoff brief synthesised from the ticket's full comment
249
249
 
250
250
  Output is a concise markdown document, not a full TicketBrief. No plan mode — output is displayed and the workflow stops.
251
251
 
252
+ ### triage --push compliance enrichment (Pro)
253
+
254
+ When `--push` is run by a Pro-licensed user, the local compliance ledger (written by `--compliance` runs) is read and merged into the snapshot before sending. Each ticket's `compliance_status` (`pass`/`gap`) and `compliance_coverage` (%) are included in the push payload. This feeds the **Compliance Analytics** dashboard in the Console (`/console/admin/compliance-analytics`), which shows gap-rate trends by project, ticket status, and week.
255
+
256
+ Non-Pro users push with `compliance_status: unknown` — no data is lost, and the analytics page simply shows no compliance data for those snapshots.
257
+
252
258
  ### --plain / --styled
253
259
 
254
260
  Control output formatting:
@@ -47,7 +47,7 @@ export function readBriefCache(ticketKey, profileName, depth, configDir = DEFAUL
47
47
  }
48
48
 
49
49
  const age = Date.now() - new Date(data.fetchedAt).getTime();
50
- if (age > ttlMs) {
50
+ if (isNaN(age) || age > ttlMs) {
51
51
  try { fs.unlinkSync(filePath); } catch { /* non-fatal */ }
52
52
  return null;
53
53
  }
@@ -106,6 +106,10 @@ export function parseCommand(args) {
106
106
  return { command: 'update-skill', args: args.slice(1) };
107
107
  }
108
108
 
109
+ if (first === 'collisions') {
110
+ return { command: 'collisions', args: args.slice(1) };
111
+ }
112
+
109
113
  // Anything that looks like a ticket key or any non-flag arg → fetch
110
114
  return { command: 'fetch', args };
111
115
  }
@@ -71,7 +71,7 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
71
71
  if (status === 401) {
72
72
  return {
73
73
  message: `Authentication failed for ${profile}`,
74
- hint: 'Your credentials may have expired. Check your API token or PAT in ~/.ticketlens/credentials.json.',
74
+ hint: 'Your credentials may have expired. Check your API token or PAT in your TicketLens credentials file.',
75
75
  };
76
76
  }
77
77
 
@@ -644,6 +644,37 @@ export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
644
644
  stream.write(lines.join('\n') + '\n');
645
645
  }
646
646
 
647
+ export function printCollisionsHelp({ stream = process.stdout } = {}) {
648
+ const s = createStyler({ isTTY: stream.isTTY });
649
+ const lines = [
650
+ '',
651
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('collisions')} ${s.dim('[--json] [--plain]')}`,
652
+ '',
653
+ ` Show branches where your changed files overlap with a teammate's.`,
654
+ ` Reads the most recent snapshot pushed by you and each teammate (within 7 days).`,
655
+ ` Requires a ${s.bold('Team')} license and at least one teammate in your group.`,
656
+ '',
657
+ ` ${s.bold('OPTIONS')}`,
658
+ '',
659
+ ` ${s.brand('--json')} Output raw JSON array of collision objects`,
660
+ ` ${s.brand('--plain')} Plain text output ${s.dim('(no ANSI colour)')}`,
661
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
662
+ '',
663
+ ` ${s.bold('EXAMPLES')}`,
664
+ '',
665
+ ` ${s.dim('$')} ticketlens collisions`,
666
+ ` ${s.dim('$')} ticketlens collisions --json`,
667
+ ` ${s.dim('$')} ticketlens collisions --plain`,
668
+ '',
669
+ ` ${s.bold('NOTES')}`,
670
+ '',
671
+ ` Branch data is captured automatically when you run ${s.brand('ticketlens triage --push')}.`,
672
+ ` Snapshots older than 7 days are ignored.`,
673
+ '',
674
+ ];
675
+ stream.write(lines.join('\n') + '\n');
676
+ }
677
+
647
678
  export function printStandupHelp({ stream = process.stdout } = {}) {
648
679
  const s = createStyler({ isTTY: stream.isTTY });
649
680
  const lines = [
@@ -75,7 +75,9 @@ export async function fetchCurrentUser(opts = {}) {
75
75
  const response = await fetcher(url, fetchOpts);
76
76
 
77
77
  if (!response.ok) {
78
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching current user`);
78
+ const err = new Error(`Jira API error ${response.status} fetching current user`);
79
+ err.status = response.status;
80
+ throw err;
79
81
  }
80
82
 
81
83
  const raw = await response.json();
@@ -98,7 +100,9 @@ export async function fetchStatuses(opts = {}) {
98
100
  const response = await fetcher(url, fetchOpts);
99
101
 
100
102
  if (!response.ok) {
101
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching statuses`);
103
+ const err = new Error(`Jira API error ${response.status} fetching statuses`);
104
+ err.status = response.status;
105
+ throw err;
102
106
  }
103
107
 
104
108
  const raw = await response.json();
@@ -121,7 +125,7 @@ export async function searchTickets(jql, opts = {}) {
121
125
  if (!response.ok) {
122
126
  let detail = '';
123
127
  try { const body = await response.json(); detail = (body.errorMessages || []).join('; '); } catch {}
124
- const err = new Error(`Jira API error ${response.status} (${response.statusText}) searching tickets${detail ? ': ' + detail : ''}`);
128
+ const err = new Error(`Jira API error ${response.status} searching tickets`);
125
129
  err.status = response.status;
126
130
  err.detail = detail;
127
131
  throw err;
@@ -159,7 +163,9 @@ export async function fetchTicket(ticketKey, opts = {}) {
159
163
  const response = await fetcher(url, fetchOpts);
160
164
 
161
165
  if (!response.ok) {
162
- throw new Error(`Jira API error ${response.status} (${response.statusText}) fetching ${ticketKey}`);
166
+ const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
167
+ err.status = response.status;
168
+ throw err;
163
169
  }
164
170
 
165
171
  const raw = await response.json();
@@ -47,7 +47,8 @@ export function readLedger({ configDir = DEFAULT_CONFIG_DIR, fsModule = _fs, sin
47
47
  return raw
48
48
  .split('\n')
49
49
  .filter(line => line.trim().length > 0)
50
- .map(line => JSON.parse(line))
50
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
51
+ .filter(record => record !== null)
51
52
  .filter(record => sinceMs === null || new Date(record.ts).getTime() >= sinceMs);
52
53
  }
53
54
 
@@ -5,6 +5,9 @@ import crypto from 'node:crypto';
5
5
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
6
6
  import { createStyler } from './ansi.mjs';
7
7
 
8
+ export // Mixed into the HMAC key so that knowing the license key alone is not sufficient
9
+ // to forge a valid signature — an attacker also needs this constant from the source.
10
+ const LICENSE_HMAC_SALT = 'tl-lic-v1';
8
11
  export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
9
12
  const LICENSE_FILE = 'license.json';
10
13
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
@@ -24,9 +27,14 @@ export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
24
27
  const { sig, ...payload } = data;
25
28
  // Legacy unsigned files are trusted; will be re-signed on next write
26
29
  if (!sig) return payload;
27
- const expected = crypto.createHmac('sha256', payload.key || '')
30
+ const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
31
+ const expected = crypto.createHmac('sha256', sigKey)
28
32
  .update(JSON.stringify(payload)).digest('hex');
29
- return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ? payload : null;
33
+ const sigBuf = Buffer.from(sig, 'hex');
34
+ const expBuf = Buffer.from(expected, 'hex');
35
+ // timingSafeEqual requires equal-length buffers; unequal lengths mean tampered sig
36
+ if (sigBuf.length !== expBuf.length) return null;
37
+ return crypto.timingSafeEqual(sigBuf, expBuf) ? payload : null;
30
38
  } catch {
31
39
  return null;
32
40
  }
@@ -36,7 +44,8 @@ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
36
44
  fs.mkdirSync(configDir, { recursive: true });
37
45
  const filePath = path.join(configDir, LICENSE_FILE);
38
46
  const { sig: _, ...payload } = data; // strip any existing sig before re-signing
39
- const mac = crypto.createHmac('sha256', payload.key || '')
47
+ const sigKey = `${LICENSE_HMAC_SALT}:${payload.key || ''}`;
48
+ const mac = crypto.createHmac('sha256', sigKey)
40
49
  .update(JSON.stringify(payload)).digest('hex');
41
50
  fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), 'utf8');
42
51
  fs.chmodSync(filePath, 0o600);
@@ -3,7 +3,7 @@
3
3
  * Resolution order: --profile flag → ticket prefix match → default profile → env vars
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, chmodSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
@@ -51,8 +51,7 @@ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
51
51
  const profilesPath = join(configDir, 'profiles.json');
52
52
  const config = loadProfiles(configDir) || { profiles: {} };
53
53
  config.default = name;
54
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
55
- chmodSync(profilesPath, 0o600);
54
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
56
55
  invalidateProfilesCache(configDir);
57
56
  }
58
57
 
@@ -61,14 +60,12 @@ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CON
61
60
  const profilesPath = join(configDir, 'profiles.json');
62
61
  const config = loadProfiles(configDir) || { profiles: {} };
63
62
  config.profiles[name] = profileData;
64
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
65
- chmodSync(profilesPath, 0o600);
63
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
64
  if (credData && Object.keys(credData).length > 0) {
67
65
  const credPath = join(configDir, 'credentials.json');
68
66
  const creds = loadCredentials(configDir);
69
67
  creds[name] = credData;
70
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
71
- chmodSync(credPath, 0o600);
68
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
72
69
  }
73
70
  invalidateProfilesCache(configDir);
74
71
  }
@@ -81,8 +78,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
81
78
  delete config.profiles[name];
82
79
  if (config.default === name) delete config.default;
83
80
 
84
- writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
85
- chmodSync(profilesPath, 0o600);
81
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
86
82
 
87
83
  // Remove credentials entry
88
84
  const credPath = join(configDir, 'credentials.json');
@@ -90,8 +86,7 @@ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
90
86
  const creds = loadCredentials(configDir);
91
87
  if (creds[name]) {
92
88
  delete creds[name];
93
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
94
- chmodSync(credPath, 0o600);
89
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
95
90
  }
96
91
  }
97
92
 
@@ -103,9 +98,13 @@ export function loadProfiles(configDir = DEFAULT_CONFIG_DIR) {
103
98
  if (_profilesCache.has(configDir)) return _profilesCache.get(configDir);
104
99
  const profilesPath = join(configDir, 'profiles.json');
105
100
  if (!existsSync(profilesPath)) return null;
106
- const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
107
- _profilesCache.set(configDir, data);
108
- return data;
101
+ try {
102
+ const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
103
+ _profilesCache.set(configDir, data);
104
+ return data;
105
+ } catch {
106
+ return null;
107
+ }
109
108
  }
110
109
 
111
110
  export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
@@ -115,9 +114,14 @@ export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
115
114
  _credentialsCache.set(configDir, {});
116
115
  return {};
117
116
  }
118
- const data = JSON.parse(readFileSync(credPath, 'utf8'));
119
- _credentialsCache.set(configDir, data);
120
- return data;
117
+ try {
118
+ const data = JSON.parse(readFileSync(credPath, 'utf8'));
119
+ _credentialsCache.set(configDir, data);
120
+ return data;
121
+ } catch {
122
+ _credentialsCache.set(configDir, {});
123
+ return {};
124
+ }
121
125
  }
122
126
 
123
127
  export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
@@ -129,8 +133,7 @@ export function saveCredentialKey(key, value, configDir = DEFAULT_CONFIG_DIR) {
129
133
  } else {
130
134
  creds[key] = value;
131
135
  }
132
- writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
133
- chmodSync(credPath, 0o600);
136
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
134
137
  invalidateProfilesCache(configDir);
135
138
  }
136
139
 
@@ -34,6 +34,7 @@ export async function runCollisions(args = [], opts = {}) {
34
34
  try {
35
35
  const res = await fetcher(`${apiBase()}${COLLISIONS_PATH}`, {
36
36
  headers: { Authorization: `Bearer ${licenseKey}` },
37
+ signal: AbortSignal.timeout(15_000),
37
38
  });
38
39
 
39
40
  if (!res.ok) {
@@ -5,12 +5,10 @@
5
5
  */
6
6
 
7
7
  import * as defaultFs from 'node:fs';
8
+ import { homedir } from 'node:os';
8
9
  import { join } from 'node:path';
9
10
 
10
- const DEFAULT_CONFIG_DIR = join(
11
- process.env.HOME ?? process.env.USERPROFILE ?? '',
12
- '.ticketlens'
13
- );
11
+ const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
14
12
 
15
13
  const URGENCY_ORDER = { 'needs-response': 0, 'aging': 1, 'clear': 2 };
16
14
 
@@ -3,6 +3,10 @@
3
3
  * All errors are caught — push failure must never break triage output.
4
4
  */
5
5
 
6
+ import { readLedger } from './ledger.mjs';
7
+ import { isLicensed } from './license.mjs';
8
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
9
+
6
10
  const PUSH_PATH = '/v1/triage/push';
7
11
  // Local dev: http://ticketlens.test — production override via TICKETLENS_API_URL env var
8
12
  const DEFAULT_API_BASE = 'http://ticketlens.test';
@@ -12,10 +16,15 @@ function apiBase() {
12
16
  }
13
17
 
14
18
  // Derive console queue URL from API base:
15
- // http://ticketlens.test → http://ticketlens.test/console/queue
19
+ // http://api.ticketlens.test → http://ticketlens.test/console/queue
16
20
  // https://api.ticketlens.com → https://app.ticketlens.com/console/queue
17
- function queueUrl(base) {
18
- return base.replace(/^(https?:\/\/)api\./, '$1app.') + '/console/queue';
21
+ export function queueUrl(base) {
22
+ const noApi = base.replace(/^(https?:\/\/)api\./, '$1');
23
+ // Production: ticketlens.com → app.ticketlens.com
24
+ const console = /ticketlens\.com/.test(noApi)
25
+ ? noApi.replace('://', '://app.')
26
+ : noApi;
27
+ return console + '/console/queue';
19
28
  }
20
29
 
21
30
  function buildTicketPayload(scored, rawMap, baseUrl) {
@@ -59,16 +68,43 @@ export async function pushTriageSnapshot({
59
68
  gitBranches,
60
69
  fetcher = globalThis.fetch,
61
70
  print = (s) => process.stdout.write(s),
71
+ readLedgerFn = readLedger,
72
+ isLicensedFn = isLicensed,
73
+ configDir = DEFAULT_CONFIG_DIR,
62
74
  } = {}) {
63
75
  if (!licenseKey) {
64
76
  print('✗ --push requires an active Team license (ticketlens activate <key>)\n');
65
77
  return { ok: false };
66
78
  }
67
79
 
80
+ let tickets = sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl));
81
+
82
+ if (isLicensedFn('pro', configDir)) {
83
+ try {
84
+ const entries = readLedgerFn({ configDir });
85
+ const latestByKey = new Map();
86
+ for (const entry of entries) {
87
+ const prev = latestByKey.get(entry.ticketKey);
88
+ if (!prev || entry.ts > prev.ts) latestByKey.set(entry.ticketKey, entry);
89
+ }
90
+ tickets = tickets.map(t => {
91
+ const entry = latestByKey.get(t.key);
92
+ if (!entry) return t;
93
+ return {
94
+ ...t,
95
+ compliance_coverage: entry.coverage ?? null,
96
+ compliance_status: (entry.missing?.length ?? 0) === 0 ? 'pass' : 'gap',
97
+ };
98
+ });
99
+ } catch {
100
+ // non-fatal — ledger errors must not break triage push
101
+ }
102
+ }
103
+
68
104
  const payload = {
69
105
  profile: String(profile ?? 'default').slice(0, 100),
70
106
  captured_at: capturedAt ?? new Date().toISOString(),
71
- tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
107
+ tickets,
72
108
  ...(gitBranches != null && { git_branches: gitBranches }),
73
109
  };
74
110
 
@@ -80,6 +116,7 @@ export async function pushTriageSnapshot({
80
116
  'Authorization': `Bearer ${licenseKey}`,
81
117
  },
82
118
  body: JSON.stringify(payload),
119
+ signal: AbortSignal.timeout(15_000),
83
120
  });
84
121
 
85
122
  if (res.ok) {
@@ -67,6 +67,7 @@ export async function shareTriageSnapshot({
67
67
  'Authorization': `Bearer ${licenseKey}`,
68
68
  },
69
69
  body: JSON.stringify(payload),
70
+ signal: AbortSignal.timeout(15_000),
70
71
  });
71
72
 
72
73
  if (res.ok) {