ticketlens 0.1.20 → 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
|
@@ -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('--
|
|
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('--
|
|
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');
|
|
@@ -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
|
|
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:
|
|
51
|
-
messages: [{ role: 'user', content:
|
|
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:
|
|
76
|
-
messages: [{ role: 'user', content:
|
|
78
|
+
max_tokens: maxTokens,
|
|
79
|
+
messages: [{ role: 'user', content: prompt + brief }],
|
|
77
80
|
}),
|
|
78
81
|
});
|
|
79
82
|
|