ticketlens 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point: fetches a Jira ticket and outputs a TicketBrief to stdout.
5
+ * Usage: node fetch-ticket.mjs TICKET-KEY [--depth=N] [--profile=NAME]
6
+ */
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { extractCodeReferences } from './lib/code-ref-parser.mjs';
12
+ import { assembleBrief } from './lib/brief-assembler.mjs';
13
+ import { styleBrief } from './lib/styled-assembler.mjs';
14
+ import { resolveConnection, loadProfiles, loadCredentials, saveProfile } from './lib/profile-resolver.mjs';
15
+ import { buildJiraEnv } from './lib/config.mjs';
16
+ import { resolveAdapter } from './lib/resolve-adapter.mjs';
17
+ import { createSession } from './lib/banner.mjs';
18
+ import { classifyError } from './lib/error-classifier.mjs';
19
+ import { promptProfileSelect, promptProfileMismatch, promptSwitchProfile, promptMultipleMatches } from './lib/profile-picker.mjs';
20
+ import { promptSelect } from './lib/select-prompt.mjs';
21
+ import { printFetchHelp } from './lib/help.mjs';
22
+ import { handleUnknownFlags } from './lib/arg-validator.mjs';
23
+ import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
24
+ import { downloadAttachments } from './lib/attachment-downloader.mjs';
25
+ import { readBriefCache, writeBriefCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
26
+ import { parseAge } from './lib/cache-manager.mjs';
27
+ import { createStyler } from './lib/ansi.mjs';
28
+ import { isLicensed, showUpgradePrompt, readLicense } from './lib/license.mjs';
29
+ import { detectVcs } from './lib/vcs-detector.mjs';
30
+ import { runComplianceCheck } from './lib/compliance-checker.mjs';
31
+
32
+ /**
33
+ * Get the local diff using spawn with explicit arg arrays (never shell interpolation).
34
+ * @param {string} [cwd]
35
+ * @returns {string|null}
36
+ */
37
+ function getDiff(cwd = process.cwd()) {
38
+ const vcsResult = detectVcs(cwd);
39
+ const vcs = typeof vcsResult === 'string' ? vcsResult : vcsResult.type;
40
+ if (vcs === 'none') return null;
41
+
42
+ const commands = {
43
+ git: ['git', ['diff', 'HEAD']],
44
+ svn: ['svn', ['diff']],
45
+ hg: ['hg', ['diff']],
46
+ };
47
+ const entry = commands[vcs];
48
+ if (!entry) return null;
49
+ const [cmd, args] = entry;
50
+
51
+ const which = spawnSync('which', [cmd], { encoding: 'utf8' });
52
+ if (which.status !== 0 || !which.stdout.trim()) return null;
53
+
54
+ const result = spawnSync(which.stdout.trim(), args, { cwd, encoding: 'utf8', timeout: 10_000 });
55
+ return result.status === 0 ? (result.stdout || null) : null;
56
+ }
57
+
58
+ /**
59
+ * Append --check section (diff + instructions) to a brief string.
60
+ * @param {string} brief
61
+ * @param {object} opts — may contain detectVcs and getDiff overrides
62
+ * @returns {string}
63
+ */
64
+ function applyCheck(brief, opts) {
65
+ const vcsDetector = opts.detectVcs ?? ((cwd) => { const r = detectVcs(cwd); return typeof r === 'string' ? r : r.type; });
66
+ const diffRunner = opts.getDiff ?? getDiff;
67
+ const vcs = vcsDetector(process.cwd());
68
+
69
+ if (vcs === 'none') {
70
+ brief += '\n\n⚠ No VCS detected in this directory.\n Claude Code will evaluate coverage using this session\'s context.\n';
71
+ brief += '\n--- CHECK INSTRUCTIONS ---\n';
72
+ brief += 'No diff available. Use session context, claude-mem, context7, or fs.stat() fallback to evaluate coverage.\n';
73
+ return brief;
74
+ }
75
+
76
+ const diff = diffRunner(process.cwd());
77
+ if (diff) brief += '\n\n--- DIFF ---\n' + diff;
78
+ brief += '\n--- CHECK INSTRUCTIONS ---\n';
79
+ brief += 'Identify acceptance criteria from the ticket above. Evaluate whether the diff covers each one.\n';
80
+ brief += 'Report: ✔ FOUND (with file:line reference) or ✗ NOT FOUND. Show coverage percentage.\n';
81
+ return brief;
82
+ }
83
+
84
+ function hasCloudConsent(configDir, profileName) {
85
+ try {
86
+ const data = JSON.parse(readFileSync(`${configDir}/profiles.json`, 'utf8'));
87
+ return data.profiles?.[profileName]?.cloudSummarizeConsent === true;
88
+ } catch { return false; }
89
+ }
90
+
91
+ function saveCloudConsent(configDir, profileName) {
92
+ try {
93
+ const config = loadProfiles(configDir);
94
+ if (!config?.profiles[profileName]) return;
95
+ saveProfile(profileName, { ...config.profiles[profileName], cloudSummarizeConsent: true }, null, configDir);
96
+ } catch { /* non-fatal */ }
97
+ }
98
+
99
+ /**
100
+ * Apply --summarize to a brief string.
101
+ * Returns the modified brief, or null if the caller should exit (license gate / consent refused).
102
+ */
103
+ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn) {
104
+ if (!licensedFn('pro', configDir)) {
105
+ upgradeFn('pro', '--summarize');
106
+ process.exitCode = 1;
107
+ return null;
108
+ }
109
+
110
+ const mode = args.includes('--cloud') ? 'cloud' : 'byok';
111
+ const profileName = conn.profileName ?? 'default';
112
+
113
+ // Cloud consent check (skipped when summarizer is injected, e.g. tests)
114
+ if (mode === 'cloud' && !opts.summarizer && !hasCloudConsent(configDir, profileName)) {
115
+ let consentGiven = !process.stdin.isTTY;
116
+ if (!consentGiven && process.stdout.isTTY) {
117
+ process.stdout.write(
118
+ '\n Cloud summary sends your ticket content to api.ticketlens.dev for processing.\n' +
119
+ ' TicketLens calls Claude and returns a summary. No data stored after the request.\n' +
120
+ ' Proceed? (y/N) '
121
+ );
122
+ const rl = (await import('node:readline')).createInterface({ input: process.stdin, output: process.stdout });
123
+ consentGiven = await new Promise(resolve => rl.question('', ans => { rl.close(); resolve(ans.trim().toLowerCase() === 'y'); }));
124
+ }
125
+ if (!consentGiven) { process.exitCode = 1; return null; }
126
+ saveCloudConsent(configDir, profileName);
127
+ }
128
+
129
+ try {
130
+ const summarizerFn = opts.summarizer ?? (async (sumOpts) => {
131
+ const { summarize } = await import('./lib/summarizer.mjs');
132
+ return summarize(sumOpts);
133
+ });
134
+ const credentials = opts.credentials ?? loadCredentials(configDir);
135
+ const licenseKey = readLicense(configDir)?.key;
136
+ const summary = await summarizerFn({ brief, mode, credentials, licenseKey });
137
+ const divider = '─'.repeat(60);
138
+ return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
139
+ } catch (err) {
140
+ const onErrorFn = opts.onError ?? ((msg) => process.stderr.write(msg + '\n'));
141
+ onErrorFn(`Could not generate summary: ${err.message}`);
142
+ return brief;
143
+ }
144
+ }
145
+
146
+ const RETRY_OPTIONS = [
147
+ { label: 'Retry', sublabel: 'Try again — e.g. VPN just connected', value: 'retry' },
148
+ { label: 'Switch profile', sublabel: 'Use a different Jira profile', value: 'switch' },
149
+ { label: 'Cancel', sublabel: '', value: 'cancel' },
150
+ ];
151
+
152
+ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
153
+ // Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print })
154
+ let env, opts;
155
+ if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
156
+ opts = envOrOpts;
157
+ env = opts.env ?? process.env;
158
+ fetcher = opts.fetcher ?? globalThis.fetch;
159
+ configDir = opts.configDir ?? undefined;
160
+ } else {
161
+ opts = {};
162
+ env = envOrOpts;
163
+ }
164
+
165
+ const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
166
+
167
+ if (args.includes('--help') || args.includes('-h')) {
168
+ printFetchHelp();
169
+ return;
170
+ }
171
+
172
+ // Early dispatch for non-ticket subcommands
173
+ if (args[0] === 'install-hooks') {
174
+ const { installHook } = await import('./lib/hook-installer.mjs');
175
+ try {
176
+ const result = await installHook({ cwd: process.cwd() });
177
+ if (result.skipped) {
178
+ process.stderr.write(` Hook install skipped: ${result.reason}\n`);
179
+ } else {
180
+ process.stdout.write(` Hook installed: ${result.path} (threshold: 80%)\n`);
181
+ }
182
+ } catch (err) {
183
+ process.stderr.write(` Error installing hook: ${err.message}\n`);
184
+ process.exitCode = 1;
185
+ }
186
+ return;
187
+ }
188
+
189
+ if (args[0] === 'pr') {
190
+ const { assemblePr } = await import('./lib/pr-assembler.mjs');
191
+ const ticketKeyArg = args[1];
192
+ if (!ticketKeyArg) {
193
+ process.stderr.write('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
198
+ process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+ const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
203
+
204
+ // Resolve connection (same pattern as compliance dispatch)
205
+ const profileArgPr = args.find(a => a.startsWith('--profile='));
206
+ const profileNamePr = profileArgPr ? profileArgPr.split('=')[1] : undefined;
207
+ const connPr = resolveConnection(ticketKeyArg, {
208
+ env,
209
+ configDir: resolvedConfigDir,
210
+ profileName: profileNamePr,
211
+ cwd: process.cwd(),
212
+ onWarning: (w) => process.stderr.write(w + '\n'),
213
+ onProfileNotFound: () => {},
214
+ });
215
+
216
+ const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
217
+ if (!connPr.baseUrl || !hasAuthPr) {
218
+ process.stderr.write('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+
223
+ const adapterPr = resolveAdapter(connPr, { fetcher });
224
+
225
+ try {
226
+ const md = await assemblePr(ticketKeyArg, {
227
+ configDir: resolvedConfigDir,
228
+ fetchTicketFn: (key, fOpts = {}) => adapterPr.fetchTicket(key, fOpts),
229
+ });
230
+ printFn(md + '\n');
231
+ } catch (err) {
232
+ process.stderr.write(`Error: ${err.message}\n`);
233
+ process.exitCode = 1;
234
+ }
235
+ return;
236
+ }
237
+
238
+ if (args[0] === 'ledger') {
239
+ const { exportLedger } = await import('./lib/ledger.mjs');
240
+ const { isLicensed: isLic, showUpgradePrompt: showUpgrade } = await import('./lib/license.mjs');
241
+ const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
242
+ if (!isLic('pro', resolvedConfigDir)) {
243
+ showUpgrade('pro', 'ledger', { stream: process.stderr });
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+ const formatArg = args.slice(1).find(a => a.startsWith('--format='));
248
+ const format = formatArg ? formatArg.split('=')[1] : 'json';
249
+ const result = exportLedger(format, { configDir: resolvedConfigDir });
250
+ if (format === 'csv') {
251
+ printFn(result + '\n');
252
+ } else {
253
+ printFn(JSON.stringify(result, null, 2) + '\n');
254
+ process.stderr.write(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
255
+ }
256
+ return;
257
+ }
258
+
259
+ if (args[0] === 'compliance') {
260
+ const ticketKeyArg = args[1];
261
+ if (!ticketKeyArg) {
262
+ process.stderr.write('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+ if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
267
+ process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+
272
+ const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
273
+
274
+ // Read threshold from .ticketlens-hooks.json (written by install-hooks); default 80%
275
+ let threshold = 80;
276
+ const cwdForHooks = opts.cwdForHooks ?? process.cwd();
277
+ try {
278
+ const hooksJson = JSON.parse(readFileSync(join(cwdForHooks, '.ticketlens-hooks.json'), 'utf8'));
279
+ const t = Number(hooksJson.complianceThreshold);
280
+ if (Number.isFinite(t)) threshold = Math.max(0, Math.min(100, t));
281
+ } catch { /* absent or unreadable — use default */ }
282
+
283
+ // Resolve Jira connection (non-interactive: hooks are non-TTY)
284
+ const profileArgC = args.find(a => a.startsWith('--profile='));
285
+ const profileNameC = profileArgC ? profileArgC.split('=')[1] : undefined;
286
+ const connC = resolveConnection(ticketKeyArg, {
287
+ env,
288
+ configDir: resolvedConfigDir,
289
+ profileName: profileNameC,
290
+ cwd: process.cwd(),
291
+ onWarning: (w) => process.stderr.write(w + '\n'),
292
+ onProfileNotFound: () => {},
293
+ });
294
+
295
+ const hasAuthC = connC.pat || (connC.email && connC.apiToken);
296
+ if (!connC.baseUrl || !hasAuthC) {
297
+ process.stderr.write('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
298
+ process.exitCode = 1;
299
+ return;
300
+ }
301
+
302
+ const adapterC = resolveAdapter(connC, { fetcher });
303
+
304
+ let ticketC;
305
+ try {
306
+ ticketC = await adapterC.fetchTicket(ticketKeyArg, { depth: 0 });
307
+ } catch (err) {
308
+ process.stderr.write(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+
313
+ const allTextC = [ticketC.description, ...ticketC.comments.map(c => c.body)].filter(Boolean).join('\n');
314
+ const codeRefsC = extractCodeReferences(allTextC);
315
+ const briefC = assembleBrief(ticketC, codeRefsC);
316
+
317
+ const complianceRunner = opts.runComplianceCheck ?? runComplianceCheck;
318
+ const complianceResult = await complianceRunner({
319
+ brief: briefC,
320
+ ticketKey: ticketKeyArg,
321
+ configDir: resolvedConfigDir,
322
+ });
323
+
324
+ if (complianceResult === null) {
325
+ // License/usage gate — showUpgradePrompt already wrote to stderr
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+
330
+ printFn(complianceResult.report + '\n');
331
+
332
+ // No acceptance criteria in ticket → pass (nothing to fail on)
333
+ if (complianceResult.noCriteria) {
334
+ return;
335
+ }
336
+
337
+ if (complianceResult.coveragePercent < threshold) {
338
+ process.exitCode = 1;
339
+ }
340
+ return;
341
+ }
342
+
343
+ const ticketKey = args.find(a => !a.startsWith('--'));
344
+ if (!ticketKey) {
345
+ printFetchHelp({ stream: process.stderr });
346
+ process.exitCode = 1;
347
+ return;
348
+ }
349
+ if (!TICKET_KEY_PATTERN.test(ticketKey)) {
350
+ process.stderr.write(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
351
+ process.exitCode = 1;
352
+ return;
353
+ }
354
+
355
+ // Normalize --project= alias once at entry so all recursive calls only see --profile=
356
+ const projectArg = args.find(a => a.startsWith('--project='));
357
+ if (projectArg) {
358
+ process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
359
+ args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
360
+ }
361
+
362
+ const profileArg = args.find(a => a.startsWith('--profile='));
363
+ const profileName = profileArg ? profileArg.split('=')[1] : undefined;
364
+
365
+ const validatedArgs = await handleUnknownFlags(
366
+ args,
367
+ ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget='],
368
+ { hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
369
+ );
370
+ if (validatedArgs === null) { process.exitCode = 1; return; }
371
+ args = validatedArgs;
372
+
373
+ // When multiple profiles share the same ticket prefix and we're in a TTY,
374
+ // ask the user to choose rather than silently picking the first match.
375
+ if (!profileName && process.stderr.isTTY && process.stdin.setRawMode) {
376
+ const prefix = ticketKey.split('-')[0];
377
+ const config = loadProfiles(configDir);
378
+ const multiMatches = Object.entries(config?.profiles ?? {})
379
+ .filter(([, p]) => p.ticketPrefixes?.includes(prefix))
380
+ .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
381
+ if (multiMatches.length > 1) {
382
+ const picked = await promptMultipleMatches(ticketKey, multiMatches);
383
+ if (!picked) { process.exitCode = 1; return; }
384
+ const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
385
+ return run(withProfile, env, fetcher, configDir);
386
+ }
387
+ }
388
+
389
+ let profileError = null;
390
+ const conn = resolveConnection(ticketKey, {
391
+ env,
392
+ configDir,
393
+ profileName,
394
+ cwd: process.cwd(),
395
+ onWarning: (w) => process.stderr.write(w + '\n'),
396
+ onProfileNotFound: (info) => { profileError = info; },
397
+ });
398
+
399
+ const hasAuth = conn.pat || (conn.email && conn.apiToken);
400
+ if (!conn.baseUrl || !hasAuth) {
401
+ if (profileError) {
402
+ const picked = await promptProfileSelect(profileError);
403
+ if (picked) {
404
+ const newArgs = args.filter(a => !a.startsWith('--profile='));
405
+ newArgs.push(`--profile=${picked}`);
406
+ return run(newArgs, env, fetcher, configDir);
407
+ }
408
+ } else {
409
+ const missing = [];
410
+ if (!conn.baseUrl) missing.push('JIRA_BASE_URL');
411
+ if (!hasAuth) missing.push('JIRA_PAT or (JIRA_EMAIL + JIRA_API_TOKEN)');
412
+ const hint = conn.source === 'env'
413
+ ? `Missing env vars: ${missing.join(', ')}`
414
+ : `Missing config in profile "${conn.profileName}": ${missing.join(', ')}`;
415
+ const noProfiles = !loadProfiles(configDir)?.profiles;
416
+ const initHint = noProfiles ? '\nRun `ticketlens init` to set up your connection.' : '';
417
+ process.stderr.write(`Error: ${hint}${initHint}\n`);
418
+ }
419
+ process.exitCode = 1;
420
+ return;
421
+ }
422
+
423
+ // If no --profile was given and the resolved profile doesn't have this prefix
424
+ // configured, prompt the user to pick the right profile.
425
+ if (!profileName && conn.source === 'profile') {
426
+ const prefix = ticketKey.split('-')[0];
427
+ const config = loadProfiles(configDir);
428
+ const resolvedProfile = config?.profiles[conn.profileName];
429
+ if (!resolvedProfile?.ticketPrefixes?.includes(prefix)) {
430
+ const allProfiles = Object.entries(config?.profiles ?? {})
431
+ .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
432
+ if (allProfiles.length > 1) {
433
+ const picked = await promptProfileMismatch(ticketKey, conn.profileName, allProfiles);
434
+ if (picked && picked !== conn.profileName) {
435
+ const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
436
+ return run(withProfile, env, fetcher, configDir);
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ const adapter = resolveAdapter(conn, { fetcher });
443
+
444
+ const depthArg = args.find(a => a.startsWith('--depth='));
445
+ const depth = depthArg ? parseInt(depthArg.split('=')[1], 10) : 1;
446
+
447
+ const licensedFn = opts.isLicensed ?? isLicensed;
448
+ const upgradeFn = opts.showUpgradePrompt ?? showUpgradePrompt;
449
+
450
+ const noCache = args.includes('--no-cache');
451
+
452
+ // Resolve brief cache TTL: configurable for Pro tier only, else fixed 4h default
453
+ const resolvedProfile = loadProfiles(configDir)?.profiles?.[conn.profileName];
454
+ const ttlMs = (resolvedProfile?.cacheTtl && licensedFn('pro', configDir))
455
+ ? (parseAge(resolvedProfile.cacheTtl) ?? BRIEF_TTL_MS)
456
+ : BRIEF_TTL_MS;
457
+
458
+ // ── Brief cache check ──────────────────────────────────────────────────────
459
+ // Skip the Jira API call entirely if we have a fresh cached brief.
460
+ if (!noCache) {
461
+ const cached = readBriefCache(ticketKey, conn.profileName, depth, configDir, ttlMs);
462
+ if (cached) {
463
+ const s = createStyler({ isTTY: process.stderr.isTTY });
464
+ const age = briefCacheAge(cached.fetchedAt);
465
+ process.stderr.write(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
466
+
467
+ const allText = [cached.ticket.description, ...cached.ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
468
+ const codeRefs = extractCodeReferences(allText);
469
+ const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
470
+
471
+ // Apply --budget pruning on the plain brief before styling (Pro only).
472
+ // When --budget is active, always output plain text (pruning operates on unescaped chars).
473
+ let plainBrief = assembleBrief(cached.ticket, codeRefs);
474
+ const budgetArgCached = args.find(a => a.startsWith('--budget='));
475
+ if (budgetArgCached) {
476
+ const budgetN = parseInt(budgetArgCached.split('=')[1], 10);
477
+ if (licensedFn('pro', configDir)) {
478
+ const budgetPruner = opts.budgetPruner ?? (await import('./lib/budget-pruner.mjs'));
479
+ const result = budgetPruner.pruneBrief(plainBrief, { budget: budgetN, stream: process.stderr });
480
+ plainBrief = result.pruned;
481
+ } else {
482
+ upgradeFn('pro', '--budget');
483
+ }
484
+ }
485
+ let brief = (budgetArgCached && licensedFn('pro', configDir))
486
+ ? plainBrief
487
+ : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true }) : plainBrief);
488
+
489
+ if (args.includes('--check')) brief = applyCheck(brief, opts);
490
+
491
+ if (args.includes('--summarize')) {
492
+ brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn);
493
+ if (brief === null) return;
494
+ }
495
+
496
+ if (args.includes('--compliance')) {
497
+ const checkResult = await runComplianceCheck({
498
+ brief,
499
+ ticketKey,
500
+ configDir,
501
+ });
502
+ if (checkResult === null) {
503
+ process.exitCode = 1;
504
+ return;
505
+ } else {
506
+ brief += '\n' + checkResult.report;
507
+ }
508
+ }
509
+
510
+ printFn(brief + '\n');
511
+ return;
512
+ }
513
+ }
514
+ // ──────────────────────────────────────────────────────────────────────────
515
+
516
+ const session = createSession(conn);
517
+
518
+ // Load all profiles once for use in the switch-profile retry option.
519
+ const allProfiles = Object.entries(loadProfiles(configDir)?.profiles ?? {})
520
+ .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
521
+
522
+ let ticket;
523
+ let isRetry = false;
524
+ while (true) {
525
+ session.spin(isRetry ? `Retrying ${session.label}…` : `Connecting to ${session.label}…`);
526
+ try {
527
+ ticket = await adapter.fetchTicket(ticketKey, { depth });
528
+ break;
529
+ } catch (err) {
530
+ const classified = classifyError(err, conn);
531
+ session.failed();
532
+ session.footer(classified.message, 'error', classified.hint);
533
+
534
+ if (!process.stderr.isTTY || !process.stdin.setRawMode) {
535
+ process.exitCode = 1;
536
+ return;
537
+ }
538
+
539
+ process.stderr.write('\n');
540
+ const retryIndex = await promptSelect(RETRY_OPTIONS, {
541
+ stream: process.stderr,
542
+ hint: '↑/↓ select Enter confirm',
543
+ });
544
+
545
+ if (retryIndex === null || RETRY_OPTIONS[retryIndex].value === 'cancel') {
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+
550
+ if (RETRY_OPTIONS[retryIndex].value === 'switch') {
551
+ const picked = await promptSwitchProfile(conn.profileName, allProfiles);
552
+ if (picked && picked !== conn.profileName) {
553
+ const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
554
+ return run(withProfile, env, fetcher, configDir);
555
+ }
556
+ // Cancelled switch — exit
557
+ process.exitCode = 1;
558
+ return;
559
+ }
560
+
561
+ // 'retry' — loop with updated spinner message
562
+ isRetry = true;
563
+ process.stderr.write('\n');
564
+ }
565
+ }
566
+ session.connected();
567
+ process.stderr.write('\n');
568
+
569
+ // Save to brief cache for future requests
570
+ if (!noCache) {
571
+ writeBriefCache(ticketKey, conn.profileName, depth, ticket, configDir);
572
+ }
573
+
574
+ // ── Spec drift detection ───────────────────────────────────────────────────
575
+ try {
576
+ const dtm = opts.driftTrackerModule ?? await import('./lib/drift-tracker.mjs');
577
+ const branch = dtm.getCurrentBranch();
578
+ if (branch !== '') {
579
+ const { createHash } = await import('node:crypto');
580
+ const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
581
+ const profileName = conn.profileName ?? 'default';
582
+ const prior = dtm.readSnapshot(ticketKey, { profile: profileName, configDir: resolvedConfigDir });
583
+ if (prior) {
584
+ const desc = ticket.fields?.description ?? '';
585
+ const { extractRequirements } = await import('./lib/requirement-extractor.mjs');
586
+ const current = {
587
+ status: ticket.fields?.status?.name ?? '',
588
+ descriptionHash: createHash('sha256').update(desc).digest('hex'),
589
+ requirements: extractRequirements(desc),
590
+ };
591
+ const result = dtm.detectDrift(current, prior);
592
+ if (result.drifted) process.stderr.write(dtm.formatDriftWarning(ticketKey, result.changes));
593
+ }
594
+ dtm.writeSnapshot(ticketKey, ticket, { profile: profileName, configDir: resolvedConfigDir, branch });
595
+ }
596
+ } catch { /* drift errors are non-fatal */ }
597
+ // ──────────────────────────────────────────────────────────────────────────
598
+
599
+ if (!args.includes('--no-attachments')) {
600
+ const downloadable = (ticket.attachments ?? []).filter(a => a.content);
601
+ if (downloadable.length > 0) {
602
+ const noun = downloadable.length === 1 ? 'attachment' : 'attachments';
603
+ process.stderr.write(`Downloading ${downloadable.length} ${noun}…\n`);
604
+ // attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
605
+ const jiraEnv = buildJiraEnv(conn);
606
+ ticket.localAttachments = await downloadAttachments(ticket, {
607
+ env: jiraEnv,
608
+ fetcher,
609
+ noCache: args.includes('--no-cache'),
610
+ onProgress: (msg) => process.stderr.write(msg + '\n'),
611
+ });
612
+ const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
613
+ const cached = ticket.localAttachments.filter(r => r.skipReason === 'cached').length;
614
+ const parts = [];
615
+ if (downloaded > 0) parts.push(`${downloaded} downloaded`);
616
+ if (cached > 0) parts.push(`${cached} cached`);
617
+ if (parts.length > 0) process.stderr.write(` ✓ ${parts.join(', ')}\n`);
618
+ process.stderr.write('\n');
619
+ }
620
+ }
621
+
622
+ const allText = [ticket.description, ...ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
623
+ const codeRefs = extractCodeReferences(allText);
624
+
625
+ const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
626
+
627
+ // Apply --budget pruning on the plain brief before styling (Pro only).
628
+ // When --budget is active, always output plain text (pruning operates on unescaped chars).
629
+ let plainOutput = assembleBrief(ticket, codeRefs);
630
+ const budgetArg = args.find(a => a.startsWith('--budget='));
631
+ if (budgetArg) {
632
+ const budgetN = parseInt(budgetArg.split('=')[1], 10);
633
+ if (licensedFn('pro', configDir)) {
634
+ const budgetPruner = opts.budgetPruner ?? (await import('./lib/budget-pruner.mjs'));
635
+ const result = budgetPruner.pruneBrief(plainOutput, { budget: budgetN, stream: process.stderr });
636
+ plainOutput = result.pruned;
637
+ } else {
638
+ upgradeFn('pro', '--budget');
639
+ }
640
+ }
641
+ let output = (budgetArg && licensedFn('pro', configDir))
642
+ ? plainOutput
643
+ : (useStyled ? styleBrief(ticket, codeRefs, { styled: true }) : plainOutput);
644
+
645
+ if (args.includes('--check')) output = applyCheck(output, opts);
646
+
647
+ if (args.includes('--summarize')) {
648
+ output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn);
649
+ if (output === null) return;
650
+ }
651
+
652
+ if (args.includes('--compliance')) {
653
+ const checkResult = await runComplianceCheck({
654
+ brief: output,
655
+ ticketKey,
656
+ configDir,
657
+ });
658
+ if (checkResult === null) {
659
+ process.exitCode = 1;
660
+ return;
661
+ } else {
662
+ output += '\n' + checkResult.report;
663
+ }
664
+ }
665
+
666
+ printFn(output + '\n');
667
+
668
+ // Contextual upsell: after a deep traversal with a substantial graph, nudge toward --summarize
669
+ if (depth > 1 && !args.includes('--summarize') && (ticket.linked?.length ?? 0) >= 2) {
670
+ const s = createStyler({ isTTY: process.stderr.isTTY });
671
+ process.stderr.write(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
672
+ }
673
+ }
674
+
675
+ // Run if invoked directly
676
+ const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, ''));
677
+ if (isMain) {
678
+ run(process.argv.slice(2)).catch(err => {
679
+ process.stderr.write(`Error: ${err.message}\n`);
680
+ process.exitCode = 1;
681
+ });
682
+ }