ticketlens 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,6 +99,8 @@ ticketlens CNV1-2 --check # Append local VCS diff + Claude Code review
99
99
  ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
100
100
  ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK) [Pro]
101
101
  ticketlens CNV1-2 --summarize --cloud # AI summary routed through TicketLens API [Pro]
102
+ ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
103
+ ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
102
104
  ```
103
105
 
104
106
  | `--depth` | Scope |
@@ -201,6 +203,7 @@ ticketlens standup --help # Standup subcommand help
201
203
 
202
204
  Scans `git log` for the configured window, extracts ticket keys from commit messages, and groups commits by ticket. Optionally fetches ticket summaries from your Jira profile to add context. Outputs a dated standup brief or a PR body depending on `--format`.
203
205
 
206
+ **`--format=standup` (default)**
204
207
  ```
205
208
  ## Standup — Mon, May 18, 2026
206
209
 
@@ -214,6 +217,21 @@ Scans `git log` for the configured window, extracts ticket keys from commit mess
214
217
  jkl3456 chore: bump deps
215
218
  ```
216
219
 
220
+ **`--format=pr`** — paste directly into a GitHub/GitLab PR description
221
+ ```
222
+ ## What changed
223
+
224
+ - **PROJ-123** — Fix payment validation
225
+
226
+ ## Commits (3)
227
+
228
+ - `abc1234` feat: PROJ-123 add payment validation check
229
+ - `def5678` test: PROJ-123 payment validation tests
230
+ - `jkl3456` chore: bump deps
231
+ ```
232
+
233
+ When no commits reference a ticket key, `## What changed` shows `_No ticket references found in commits._` instead of a blank section.
234
+
217
235
  ---
218
236
 
219
237
  ### Cache
@@ -331,6 +349,8 @@ ticketlens CNV1-2 --check # Append local VCS diff + Claude Co
331
349
  ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Pro/Free 3/mo]
332
350
  ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK) [Pro]
333
351
  ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API [Pro]
352
+ ticketlens CNV1-2 --handoff # AI handoff brief from comment thread (BYOK) [Pro]
353
+ ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API [Pro]
334
354
  ticketlens CNV1-2 --depth=2 --profile=acme --plain # Combine flags freely
335
355
 
336
356
  # Pipe plain output to clipboard, LLM, or file
@@ -433,6 +453,8 @@ Start free, upgrade when you need it — `ticketlens activate <key>`
433
453
  ```bash
434
454
  ticketlens CNV1-2 --summarize # AI summary via your own API key (BYOK)
435
455
  ticketlens CNV1-2 --summarize --cloud # AI summary via TicketLens API (no local key needed)
456
+ ticketlens CNV1-2 --handoff # AI handoff brief from the ticket's comment thread (BYOK)
457
+ ticketlens CNV1-2 --handoff --cloud # AI handoff brief via TicketLens API
436
458
  ticketlens CNV1-2 --compliance # Check ticket requirements against local diff [Free 3/mo]
437
459
  ticketlens triage --stale=3 # Custom stale threshold (default is 5)
438
460
  ticketlens triage --digest # POST scored triage results to digest endpoint
@@ -440,6 +462,15 @@ ticketlens schedule # Set up a scheduled daily digest
440
462
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
441
463
  ```
442
464
 
465
+ **`--handoff`** synthesizes the comment thread into a structured one-pager for the developer picking up the ticket. No context-reading required — the AI reads the full comment history and returns:
466
+
467
+ - **What was attempted** — concrete work already done
468
+ - **Current blockers** — unresolved issues
469
+ - **Open questions** — decisions not yet made
470
+ - **Recommendation** — where to start
471
+
472
+ Add `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API.
473
+
443
474
  <div align="center">
444
475
  <img src="docs/demos/pro-triage.gif" alt="ticketlens triage --stale=3 demo" width="700" />
445
476
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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": {
@@ -145,6 +145,38 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
145
145
  }
146
146
  }
147
147
 
148
+ /**
149
+ * Apply --handoff: generate a structured handoff brief from the ticket's comment thread.
150
+ * Returns the handoff brief string, or null if the caller should exit (license gate / error).
151
+ */
152
+ async function applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn) {
153
+ if (!licensedFn('pro', configDir)) {
154
+ upgradeFn('pro', '--handoff');
155
+ process.exitCode = 1;
156
+ return null;
157
+ }
158
+
159
+ const mode = args.includes('--cloud') ? 'cloud' : 'byok';
160
+
161
+ try {
162
+ const { buildHandoffInput, HANDOFF_PROMPT } = await import('./lib/handoff-assembler.mjs');
163
+ const summarizerFn = opts.summarizer ?? (async (sumOpts) => {
164
+ const { summarize } = await import('./lib/summarizer.mjs');
165
+ return summarize(sumOpts);
166
+ });
167
+ const credentials = opts.credentials ?? loadCredentials(configDir);
168
+ const licenseKey = readLicense(configDir)?.key;
169
+ const input = buildHandoffInput(ticket);
170
+ const body = await summarizerFn({ brief: input, mode, credentials, licenseKey, prompt: HANDOFF_PROMPT, maxTokens: 512 });
171
+ return `## Handoff Brief — ${ticket.key}\n\n${body}\n`;
172
+ } catch (err) {
173
+ const onErrorFn = opts.onError ?? ((msg) => process.stderr.write(msg + '\n'));
174
+ onErrorFn(`Could not generate handoff brief: ${err.message}`);
175
+ process.exitCode = 1;
176
+ return null;
177
+ }
178
+ }
179
+
148
180
  function makeSpinner(s) {
149
181
  // setInterval won't fire while spawnSync blocks the event loop, so we draw
150
182
  // synchronously on update() and only use setInterval during async fetch phases.
@@ -783,7 +815,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
783
815
 
784
816
  const validatedArgs = await handleUnknownFlags(
785
817
  args,
786
- ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget='],
818
+ ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff'],
787
819
  { hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
788
820
  );
789
821
  if (validatedArgs === null) { process.exitCode = 1; return; }
@@ -905,6 +937,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
905
937
  ? plainBrief
906
938
  : (useStyled ? styleBrief(cached.ticket, codeRefs, { styled: true }) : plainBrief);
907
939
 
940
+ if (args.includes('--handoff')) {
941
+ const handoffResult = await applyHandoff(cached.ticket, args, opts, configDir, licensedFn, upgradeFn);
942
+ if (handoffResult === null) return;
943
+ printFn(handoffResult);
944
+ return;
945
+ }
946
+
908
947
  if (args.includes('--check')) brief = applyCheck(brief, opts);
909
948
 
910
949
  if (args.includes('--summarize')) {
@@ -1084,6 +1123,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1084
1123
  ? plainOutput
1085
1124
  : (useStyled ? styleBrief(ticket, codeRefs, { styled: true }) : plainOutput);
1086
1125
 
1126
+ if (args.includes('--handoff')) {
1127
+ const handoffResult = await applyHandoff(ticket, args, opts, configDir, licensedFn, upgradeFn);
1128
+ if (handoffResult === null) return;
1129
+ printFn(handoffResult);
1130
+ return;
1131
+ }
1132
+
1087
1133
  if (args.includes('--check')) output = applyCheck(output, opts);
1088
1134
 
1089
1135
  if (args.includes('--summarize')) {
@@ -0,0 +1,59 @@
1
+ export const HANDOFF_PROMPT = `Build a structured handoff brief from this Jira ticket's comment thread.
2
+ The developer receiving this ticket has never seen it before — they need to get up to speed immediately.
3
+
4
+ Respond in exactly this format (use the exact headings):
5
+
6
+ ### What was attempted
7
+ [2–5 bullet points of concrete work already done, based on the comments. Be specific — mention code paths, methods, or files if the comments reference them.]
8
+
9
+ ### Current blockers
10
+ [Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the comments suggest the path is clear.]
11
+
12
+ ### Open questions
13
+ [Bullet points of unanswered questions or decisions not yet made. Write "None identified" if everything is resolved.]
14
+
15
+ ### Recommendation
16
+ [1–2 sentences on the best starting point for the incoming developer.]
17
+
18
+ Rules:
19
+ - Be specific and factual. Reference actual details from the comments.
20
+ - Do not invent anything not present in the comments.
21
+ - Keep each bullet under 20 words.
22
+ - If there are no comments, state that clearly in each section.
23
+
24
+ `;
25
+
26
+ /**
27
+ * Build the text input sent to the AI for handoff analysis.
28
+ * Contains the ticket header and full comment thread.
29
+ *
30
+ * @param {object} ticket - Normalized ticket object from jira-client.normalizeTicket
31
+ * @returns {string}
32
+ */
33
+ export function buildHandoffInput(ticket) {
34
+ const lines = [];
35
+
36
+ const summary = ticket.summary ?? '(no summary)';
37
+ lines.push(`Ticket: ${ticket.key} — ${summary}`);
38
+ lines.push(`Status: ${ticket.status ?? 'Unknown'}`);
39
+ lines.push(`Assignee: ${ticket.assignee ?? 'Unassigned'}`);
40
+ if (ticket.reporter) lines.push(`Reporter: ${ticket.reporter}`);
41
+ lines.push('');
42
+
43
+ const comments = ticket.comments ?? [];
44
+ lines.push(`--- Comments (${comments.length} total) ---`);
45
+
46
+ if (comments.length === 0) {
47
+ lines.push('(no comments)');
48
+ } else {
49
+ for (let i = 0; i < comments.length; i++) {
50
+ const c = comments[i];
51
+ const dateStr = c.created ? c.created.slice(0, 10) : 'unknown date';
52
+ lines.push('');
53
+ lines.push(`[${i + 1}] ${c.author ?? 'Unknown'} — ${dateStr}`);
54
+ lines.push(c.body ?? '');
55
+ }
56
+ }
57
+
58
+ return lines.join('\n');
59
+ }
@@ -61,7 +61,8 @@ export function printHelp({ stream = process.stdout } = {}) {
61
61
  ` ${s.brand('--check')} Append VCS diff + review instructions for Claude Code`,
62
62
  ` ${s.brand('--compliance')} Check ticket requirements against local diff ${s.dim('[Pro/Free 3/mo]')}`,
63
63
  ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
64
- ` ${s.brand('--cloud')} Route summary through TicketLens API ${s.dim('[Pro]')}`,
64
+ ` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
65
+ ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
65
66
  '',
66
67
  ` ${s.bold('TRIAGE OPTIONS')}`,
67
68
  '',
@@ -128,7 +129,8 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
128
129
  ` ${s.brand('--check')} Append VCS diff + review instructions for Claude Code`,
129
130
  ` ${s.brand('--compliance')} Check ticket requirements against local diff ${s.dim('[Pro/Free 3/mo]')}`,
130
131
  ` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
131
- ` ${s.brand('--cloud')} Route summary through TicketLens API ${s.dim('[Pro]')}`,
132
+ ` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
133
+ ` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
132
134
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
133
135
  '',
134
136
  ` ${s.bold('EXAMPLES')}`,
@@ -136,6 +138,8 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
136
138
  ` ${s.dim('$')} ticketlens PROJ-123`,
137
139
  ` ${s.dim('$')} ticketlens PROJ-123 --depth=0`,
138
140
  ` ${s.dim('$')} ticketlens PROJ-123 --profile=acme --depth=2`,
141
+ ` ${s.dim('$')} ticketlens PROJ-123 --handoff`,
142
+ ` ${s.dim('$')} ticketlens PROJ-123 --handoff --cloud`,
139
143
  '',
140
144
  ];
141
145
  stream.write(lines.join('\n') + '\n');
@@ -25,18 +25,28 @@ export function groupCommitsByTicket(logLines) {
25
25
  function assemblePrBody(groups, ticketMap) {
26
26
  const lines = ['## What changed', ''];
27
27
  const keyedGroups = [...groups.entries()].filter(([k]) => k !== '__no_key__');
28
- for (const [key] of keyedGroups) {
29
- const ticket = ticketMap.get(key);
30
- const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
31
- lines.push(summary ? `- ${key}: ${summary}` : `- ${key}`);
28
+ if (keyedGroups.length === 0) {
29
+ lines.push('_No ticket references found in commits._');
30
+ } else {
31
+ for (const [key] of keyedGroups) {
32
+ const ticket = ticketMap.get(key);
33
+ const summary = ticket?.fields?.summary ?? ticket?.summary ?? null;
34
+ lines.push(summary ? `- **${key}** — ${summary}` : `- **${key}**`);
35
+ }
32
36
  }
33
- lines.push('', '## Commits', '');
34
37
  const seen = new Set();
38
+ const allCommits = [];
35
39
  for (const commits of groups.values()) {
36
40
  for (const c of commits) {
37
- if (!seen.has(c)) { seen.add(c); lines.push(`- ${c.trim()}`); }
41
+ if (!seen.has(c)) { seen.add(c); allCommits.push(c); }
38
42
  }
39
43
  }
44
+ lines.push('', `## Commits (${allCommits.length})`, '');
45
+ for (const c of allCommits) {
46
+ const trimmed = c.trim();
47
+ const shaM = trimmed.match(/^([0-9a-f]{6,})\s(.+)$/);
48
+ lines.push(shaM ? `- \`${shaM[1]}\` ${shaM[2]}` : `- ${trimmed}`);
49
+ }
40
50
  return lines.join('\n');
41
51
  }
42
52
 
@@ -97,6 +107,12 @@ export function styleStandupMd(md, s) {
97
107
  if (line.startsWith('## Standup')) {
98
108
  return `\n ${s.brand(s.bold('◆ ' + line.slice(3)))}`;
99
109
  }
110
+ if (line.startsWith('## What changed')) {
111
+ return `\n ${s.brand(s.bold('◆ What changed'))}`;
112
+ }
113
+ if (line.match(/^## Commits \(\d+\)/)) {
114
+ return `\n ${s.bold(line.slice(3))}`;
115
+ }
100
116
  if (line.startsWith('### ')) {
101
117
  return `\n ${s.bold(line.slice(4))}`;
102
118
  }
@@ -1,7 +1,8 @@
1
1
  const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
2
2
  const OPENAI_URL = 'https://api.openai.com/v1/chat/completions';
3
3
  const CLOUD_URL = 'https://api.ticketlens.dev/v1/summarize';
4
- const PROMPT = 'Summarize this Jira ticket in 3 sentences. Focus on what matters most for implementation. Be concrete.\n\n';
4
+ const DEFAULT_PROMPT = 'Summarize this Jira ticket in 3 sentences. Focus on what matters most for implementation. Be concrete.\n\n';
5
+ const DEFAULT_MAX_TOKENS = 256;
5
6
 
6
7
  /**
7
8
  * Summarize a ticket brief using BYOK or cloud mode.
@@ -14,14 +15,16 @@ const PROMPT = 'Summarize this Jira ticket in 3 sentences. Focus on what matters
14
15
  * @param {number} [opts.timeoutMs]
15
16
  * @returns {Promise<string>} Summary text
16
17
  */
17
- export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000 }) {
18
+ export async function summarize({ brief, mode, credentials = null, licenseKey = null, fetcher = globalThis.fetch, timeoutMs = 30_000, prompt, maxTokens }) {
19
+ const effectivePrompt = prompt ?? DEFAULT_PROMPT;
20
+ const effectiveMaxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
18
21
  if (mode === 'byok') {
19
- return byok({ brief, credentials, fetcher, timeoutMs });
22
+ return byok({ brief, credentials, fetcher, timeoutMs, prompt: effectivePrompt, maxTokens: effectiveMaxTokens });
20
23
  }
21
24
  return cloud({ brief, licenseKey, fetcher, timeoutMs });
22
25
  }
23
26
 
24
- async function byok({ brief, credentials, fetcher, timeoutMs }) {
27
+ async function byok({ brief, credentials, fetcher, timeoutMs, prompt, maxTokens }) {
25
28
  const anthropicKey = credentials?.anthropicApiKey;
26
29
  const openaiKey = credentials?.openaiApiKey;
27
30
 
@@ -30,13 +33,13 @@ async function byok({ brief, credentials, fetcher, timeoutMs }) {
30
33
  }
31
34
 
32
35
  if (anthropicKey) {
33
- return callAnthropic({ brief, apiKey: anthropicKey, fetcher, timeoutMs });
36
+ return callAnthropic({ brief, apiKey: anthropicKey, fetcher, timeoutMs, prompt, maxTokens });
34
37
  }
35
38
 
36
- return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs });
39
+ return callOpenAi({ brief, apiKey: openaiKey, fetcher, timeoutMs, prompt, maxTokens });
37
40
  }
38
41
 
39
- async function callAnthropic({ brief, apiKey, fetcher, timeoutMs }) {
42
+ async function callAnthropic({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }) {
40
43
  const res = await fetcher(ANTHROPIC_URL, {
41
44
  method: 'POST',
42
45
  signal: AbortSignal.timeout(timeoutMs),
@@ -47,8 +50,8 @@ async function callAnthropic({ brief, apiKey, fetcher, timeoutMs }) {
47
50
  },
48
51
  body: JSON.stringify({
49
52
  model: 'claude-haiku-4-5-20251001',
50
- max_tokens: 256,
51
- messages: [{ role: 'user', content: PROMPT + brief }],
53
+ max_tokens: maxTokens,
54
+ messages: [{ role: 'user', content: prompt + brief }],
52
55
  }),
53
56
  });
54
57
 
@@ -62,7 +65,7 @@ async function callAnthropic({ brief, apiKey, fetcher, timeoutMs }) {
62
65
  return data.content[0].text;
63
66
  }
64
67
 
65
- async function callOpenAi({ brief, apiKey, fetcher, timeoutMs }) {
68
+ async function callOpenAi({ brief, apiKey, fetcher, timeoutMs, prompt, maxTokens }) {
66
69
  const res = await fetcher(OPENAI_URL, {
67
70
  method: 'POST',
68
71
  signal: AbortSignal.timeout(timeoutMs),
@@ -72,8 +75,8 @@ async function callOpenAi({ brief, apiKey, fetcher, timeoutMs }) {
72
75
  },
73
76
  body: JSON.stringify({
74
77
  model: 'gpt-4o-mini',
75
- max_tokens: 256,
76
- messages: [{ role: 'user', content: PROMPT + brief }],
78
+ max_tokens: maxTokens,
79
+ messages: [{ role: 'user', content: prompt + brief }],
77
80
  }),
78
81
  });
79
82