ticketlens 0.1.24 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/bin/ticketlens.mjs +8 -1
- package/package.json +5 -2
- package/scripts/dev-license.mjs +61 -0
- package/scripts/postinstall.mjs +62 -0
- package/skills/jtb/SKILL.md +246 -0
- package/skills/jtb/scripts/fetch-my-tickets.mjs +26 -8
- package/skills/jtb/scripts/fetch-ticket.mjs +17 -4
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/handoff-assembler.mjs +68 -7
- package/skills/jtb/scripts/lib/help.mjs +43 -0
- package/skills/jtb/scripts/lib/triage-share.mjs +89 -0
- package/skills/jtb/scripts/lib/update-skill.mjs +116 -0
package/README.md
CHANGED
|
@@ -136,6 +136,7 @@ ticketlens triage --sprint="Sprint 12" # Filter by sprint [Team]
|
|
|
136
136
|
ticketlens triage --export=csv # Export results to CSV [Team]
|
|
137
137
|
ticketlens triage --export=json # Export results to JSON [Team]
|
|
138
138
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
139
|
+
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
139
140
|
ticketlens triage --digest # POST scored results to digest endpoint [Pro]
|
|
140
141
|
ticketlens triage --plain # Plain markdown — pipe to file or LLM
|
|
141
142
|
ticketlens triage --static # Static table, no interactive mode
|
|
@@ -375,6 +376,7 @@ ticketlens triage --assignee="Jane Dev" --sprint="Sprint 12" # Combined [Team]
|
|
|
375
376
|
ticketlens triage --export=csv # Export to CSV [Team]
|
|
376
377
|
ticketlens triage --export=json # Export to JSON [Team]
|
|
377
378
|
ticketlens triage --push # Push snapshot to Console queue [Team]
|
|
379
|
+
ticketlens triage --share # Generate 24h share URL (no login for recipient) [Team]
|
|
378
380
|
ticketlens triage --digest # POST results to digest endpoint [Pro]
|
|
379
381
|
ticketlens triage --profile=acme --stale=3 --static # Combine flags
|
|
380
382
|
|
|
@@ -447,7 +449,7 @@ ticketlens cache clear --help # Cache clear help
|
|
|
447
449
|
|
|
448
450
|
Start free, upgrade when you need it — `ticketlens activate <key>`
|
|
449
451
|
|
|
450
|
-
### Pro — $
|
|
452
|
+
### Pro — $9/mo
|
|
451
453
|
|
|
452
454
|
<div align="center">
|
|
453
455
|
<img src="docs/demos/pro-triage.gif" alt="ticketlens --summarize AI summary demo" width="700" />
|
|
@@ -465,13 +467,27 @@ ticketlens schedule # Set up a scheduled daily digest
|
|
|
465
467
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
466
468
|
```
|
|
467
469
|
|
|
468
|
-
**`--
|
|
470
|
+
**`--summarize`** generates a 3-sentence AI summary of the ticket. The AI receives the full ticket context: description, comments, linked Confluence pages, and any text-readable attachments.
|
|
471
|
+
|
|
472
|
+
**`--handoff`** synthesizes the ticket into a structured one-pager for the developer picking up the work. The AI receives the same full context and returns:
|
|
469
473
|
|
|
470
474
|
- **What was attempted** — concrete work already done
|
|
471
475
|
- **Current blockers** — unresolved issues
|
|
472
476
|
- **Open questions** — decisions not yet made
|
|
473
477
|
- **Recommendation** — where to start
|
|
474
478
|
|
|
479
|
+
**What the AI can read:**
|
|
480
|
+
|
|
481
|
+
| Content | Included |
|
|
482
|
+
|---|---|
|
|
483
|
+
| Description | ✅ Always |
|
|
484
|
+
| Comments | ✅ Always |
|
|
485
|
+
| Linked Confluence pages | ✅ Jira only, same-origin |
|
|
486
|
+
| Text files (`.txt`, `.md`, `.log`, `.csv`, `.json`, `.yaml`, etc.) | ✅ Up to 4 KB per file, 12 KB total |
|
|
487
|
+
| Screenshots (`.png`, `.jpg`, `.gif`, etc.) | ❌ Binary — images require multimodal API |
|
|
488
|
+
| PDFs | ❌ Binary — no parser included (zero-dependency) |
|
|
489
|
+
| Office documents (`.docx`, `.xlsx`) | ❌ Binary — no parser included |
|
|
490
|
+
|
|
475
491
|
Add one of the following to `~/.ticketlens/credentials.json` for BYOK, or use `--cloud` to route through the TicketLens API:
|
|
476
492
|
|
|
477
493
|
| Key | Provider | Cost |
|
|
@@ -501,7 +517,7 @@ ticketlens CNV1-2 --handoff --provider=openai
|
|
|
501
517
|
|
|
502
518
|
Pro also unlocks configurable brief cache TTL per profile — set `cacheTtl` to `4h`, `1d`, `7d`, `30d`, or `0` (disable) via `ticketlens config`. Free tier is fixed at 4h.
|
|
503
519
|
|
|
504
|
-
### Team — $
|
|
520
|
+
### Team — $19/seat/mo
|
|
505
521
|
|
|
506
522
|
<div align="center">
|
|
507
523
|
<img src="docs/demos/teams-digest.gif" alt="ticketlens triage --plain digest pipeline demo" width="700" />
|
|
@@ -513,10 +529,13 @@ ticketlens triage --sprint="Sprint 12" # Filter by sprint name
|
|
|
513
529
|
ticketlens triage --export=csv # Export triage to CSV for standups and reports
|
|
514
530
|
ticketlens triage --export=json # Machine-readable export for dashboards
|
|
515
531
|
ticketlens triage --push # Push snapshot to the Console queue
|
|
532
|
+
ticketlens triage --share # Generate a 24h share URL — paste into Slack, no login needed for recipients
|
|
516
533
|
```
|
|
517
534
|
|
|
518
535
|
`--push` syncs the scored snapshot to the TicketLens Console after each triage run. The queue page at `/console/queue` shows the latest snapshot for every team profile — no manual refresh needed.
|
|
519
536
|
|
|
537
|
+
`--share` generates a signed URL valid for 24 hours. Recipients open it in any browser — no account, no install. The asymmetry is the product: you run one command, everyone sees the same snapshot.
|
|
538
|
+
|
|
520
539
|
Automate a morning digest with cron — no open terminal required:
|
|
521
540
|
|
|
522
541
|
```bash
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
25
25
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
26
|
-
printReviewHelp, printStandupHelp,
|
|
26
|
+
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
27
27
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
28
28
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
29
29
|
import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
@@ -314,6 +314,13 @@ switch (command) {
|
|
|
314
314
|
});
|
|
315
315
|
break;
|
|
316
316
|
|
|
317
|
+
case 'update-skill': {
|
|
318
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateSkillHelp(); break; }
|
|
319
|
+
const { updateSkill } = await import('../skills/jtb/scripts/lib/update-skill.mjs');
|
|
320
|
+
await updateSkill(cmdArgs);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
317
324
|
case 'login': {
|
|
318
325
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLoginHelp(); break; }
|
|
319
326
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
+
"scripts/",
|
|
12
|
+
"skills/jtb/SKILL.md",
|
|
11
13
|
"skills/jtb/scripts/lib/",
|
|
12
14
|
"skills/jtb/scripts/fetch-ticket.mjs",
|
|
13
15
|
"skills/jtb/scripts/fetch-my-tickets.mjs"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
16
|
-
"test": "node --test skills/jtb/scripts/test/*.test.mjs"
|
|
18
|
+
"test": "node --test skills/jtb/scripts/test/*.test.mjs",
|
|
19
|
+
"postinstall": "node scripts/postinstall.mjs"
|
|
17
20
|
},
|
|
18
21
|
"keywords": [
|
|
19
22
|
"jira",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dev helper — set the local TicketLens license tier for testing.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/dev-license.mjs # show current tier
|
|
7
|
+
* node scripts/dev-license.mjs free # remove license (free tier)
|
|
8
|
+
* node scripts/dev-license.mjs pro # write Pro license
|
|
9
|
+
* node scripts/dev-license.mjs team # write Team license
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { unlinkSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { readLicense, writeLicense } from '../skills/jtb/scripts/lib/license.mjs';
|
|
15
|
+
import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
|
|
16
|
+
|
|
17
|
+
const TIERS = ['free', 'pro', 'team'];
|
|
18
|
+
const tier = process.argv[2]?.toLowerCase();
|
|
19
|
+
|
|
20
|
+
if (!tier) {
|
|
21
|
+
const current = readLicense();
|
|
22
|
+
if (!current) {
|
|
23
|
+
console.log('Current tier: free (no license file)');
|
|
24
|
+
} else {
|
|
25
|
+
console.log(`Current tier: ${current.tier}`);
|
|
26
|
+
console.log(` key: ${current.key}`);
|
|
27
|
+
console.log(` email: ${current.email}`);
|
|
28
|
+
console.log(` validatedAt: ${current.validatedAt}`);
|
|
29
|
+
console.log(` expiresAt: ${current.expiresAt ?? 'none'}`);
|
|
30
|
+
}
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!TIERS.includes(tier)) {
|
|
35
|
+
console.error(`Unknown tier: "${tier}". Valid values: ${TIERS.join(', ')}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (tier === 'free') {
|
|
40
|
+
const licensePath = join(DEFAULT_CONFIG_DIR, 'license.json');
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(licensePath);
|
|
43
|
+
console.log('License removed — now running as free tier.');
|
|
44
|
+
} catch {
|
|
45
|
+
console.log('Already free tier (no license file found).');
|
|
46
|
+
}
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
writeLicense({
|
|
51
|
+
key: `dev-${tier}`,
|
|
52
|
+
tier,
|
|
53
|
+
email: `dev-${tier}@test.local`,
|
|
54
|
+
provider: 'dev',
|
|
55
|
+
instanceId: 'dev-local',
|
|
56
|
+
validatedAt: new Date().toISOString(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
console.log(`License set to: ${tier}`);
|
|
60
|
+
console.log(` key: dev-${tier}`);
|
|
61
|
+
console.log(` email: dev-${tier}@test.local`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runs automatically after `npm install -g ticketlens`.
|
|
4
|
+
* Copies the latest SKILL.md into every detected AI assistant command directory.
|
|
5
|
+
* Silent on failure — never breaks the install.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { copyFileSync, existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const SKILL_SRC = join(__dirname, '..', 'skills', 'jtb', 'SKILL.md');
|
|
15
|
+
|
|
16
|
+
if (!existsSync(SKILL_SRC)) process.exit(0);
|
|
17
|
+
|
|
18
|
+
const HOME = homedir();
|
|
19
|
+
|
|
20
|
+
// Known command directories per AI assistant.
|
|
21
|
+
// Each entry: { label, path } — path must exist and contain jtb.md to be updated.
|
|
22
|
+
const TARGETS = [
|
|
23
|
+
{ label: 'Claude Code', path: join(HOME, '.claude', 'commands', 'jtb.md') },
|
|
24
|
+
{ label: 'Claude Code (work)', path: join(HOME, '.claude-work', 'commands', 'jtb.md') },
|
|
25
|
+
{ label: 'Gemini CLI', path: join(HOME, '.gemini', 'commands', 'jtb.md') },
|
|
26
|
+
{ label: 'Copilot CLI', path: join(HOME, '.copilot-cli', 'commands', 'jtb.md') },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function skillVersion(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const line = readFileSync(filePath, 'utf8').split('\n')[0];
|
|
32
|
+
const m = line.match(/jtb-skill-version:\s*([\d.]+)/);
|
|
33
|
+
return m ? m[1] : 'unknown';
|
|
34
|
+
} catch {
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let updated = 0;
|
|
40
|
+
let skipped = 0;
|
|
41
|
+
|
|
42
|
+
for (const { label, path } of TARGETS) {
|
|
43
|
+
if (!existsSync(path)) { skipped++; continue; }
|
|
44
|
+
try {
|
|
45
|
+
const before = skillVersion(path);
|
|
46
|
+
copyFileSync(SKILL_SRC, path);
|
|
47
|
+
const after = skillVersion(SKILL_SRC);
|
|
48
|
+
if (before === after) {
|
|
49
|
+
console.log(` ✔ ${label}: already at v${after}`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(` ✔ ${label}: updated v${before} → v${after}`);
|
|
52
|
+
}
|
|
53
|
+
updated++;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn(` ⚠ ${label}: could not update — ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (updated === 0 && skipped === TARGETS.length) {
|
|
60
|
+
console.log(' ℹ /jtb skill not installed in any known location.');
|
|
61
|
+
console.log(' To install: ticketlens update-skill');
|
|
62
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<!-- jtb-skill-version: 0.2.0 -->
|
|
2
|
+
---
|
|
3
|
+
name: jtb
|
|
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.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Jira TicketBrief
|
|
8
|
+
|
|
9
|
+
Fetches a Jira ticket and produces a structured brief with code references, then enters plan mode.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
/jtb PROD-1234 # fetch a ticket brief
|
|
15
|
+
/jtb PROD-1234 --depth=0 # target ticket only (fast)
|
|
16
|
+
/jtb PROD-1234 --depth=2 # include linked-of-linked tickets
|
|
17
|
+
/jtb PROD-1234 --profile=acme # force a specific connection profile
|
|
18
|
+
/jtb PROD-1234 --no-cache # re-fetch from Jira (bypass local cache)
|
|
19
|
+
/jtb PROD-1234 --no-attachments # skip attachment download
|
|
20
|
+
/jtb PROD-1234 --plain # plain text output (no ANSI colours)
|
|
21
|
+
/jtb PROD-1234 --check # coverage review: ACs vs local diff
|
|
22
|
+
/jtb PROD-1234 --compliance # formal compliance check (tier-gated)
|
|
23
|
+
/jtb PROD-1234 --summarize # AI summary of the brief (Pro)
|
|
24
|
+
/jtb PROD-1234 --summarize --cloud # summary via TicketLens cloud (Pro)
|
|
25
|
+
/jtb PROD-1234 --handoff # structured handoff brief from comments (Pro)
|
|
26
|
+
/jtb triage # scan your assigned tickets for attention
|
|
27
|
+
/jtb triage --stale=3 # custom aging threshold (days)
|
|
28
|
+
/jtb triage --status=CR,QA # only check specific statuses
|
|
29
|
+
/jtb triage --profile=acme # explicit profile override
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
TicketLens supports two connection methods — check in this order:
|
|
35
|
+
|
|
36
|
+
**1. Profile config (recommended):** If `~/.ticketlens/profiles.json` exists, no env vars
|
|
37
|
+
are needed. Profile resolution is automatic (by ticket prefix, project path, or `--profile`).
|
|
38
|
+
Setup via `ticketlens init`.
|
|
39
|
+
|
|
40
|
+
**2. Env var fallback:** If no profile config exists, these must be set:
|
|
41
|
+
- `JIRA_BASE_URL` — e.g. `https://yourteam.atlassian.net`
|
|
42
|
+
- **Cloud:** `JIRA_EMAIL` + `JIRA_API_TOKEN`
|
|
43
|
+
- **Server/DC:** `JIRA_PAT`
|
|
44
|
+
|
|
45
|
+
If neither profiles nor env vars are configured, tell the user:
|
|
46
|
+
"No Jira connection found. Run `ticketlens init` to set up your connection,
|
|
47
|
+
or set JIRA_BASE_URL + auth credentials as environment variables."
|
|
48
|
+
|
|
49
|
+
## Workflow
|
|
50
|
+
|
|
51
|
+
### Triage subcommand
|
|
52
|
+
|
|
53
|
+
If the first argument is `triage`:
|
|
54
|
+
|
|
55
|
+
Run:
|
|
56
|
+
```bash
|
|
57
|
+
node ~/.agents/skills/jtb/scripts/fetch-my-tickets.mjs $EXTRA_ARGS
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
|
|
61
|
+
|
|
62
|
+
**IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### Fetch ticket workflow (default)
|
|
67
|
+
|
|
68
|
+
### Step 1: Validate environment
|
|
69
|
+
|
|
70
|
+
Follow the Prerequisites section above:
|
|
71
|
+
- If `~/.ticketlens/profiles.json` exists → proceed to Step 2. No env vars needed.
|
|
72
|
+
- If no profile exists → check `JIRA_BASE_URL` and auth vars. If missing, list them and stop.
|
|
73
|
+
- If neither is configured → tell the user: "No Jira connection found. Run `ticketlens init` to set up your connection, or set `JIRA_BASE_URL` + auth credentials as environment variables."
|
|
74
|
+
|
|
75
|
+
### Step 2: Fetch the ticket
|
|
76
|
+
|
|
77
|
+
Run:
|
|
78
|
+
```bash
|
|
79
|
+
node ~/.agents/skills/jtb/scripts/fetch-ticket.mjs "$TICKET_KEY" $EXTRA_ARGS
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
|
|
83
|
+
|
|
84
|
+
The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
|
|
85
|
+
|
|
86
|
+
### Step 2b: Read attached files
|
|
87
|
+
|
|
88
|
+
Check if the TicketBrief contains an `## Attachments` section. If it does, for each line containing a backtick-quoted absolute path, call the Read tool on that path based on file type:
|
|
89
|
+
|
|
90
|
+
- **Images** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`): Read it — Claude receives it as multimodal visual context.
|
|
91
|
+
- **PDFs** (`.pdf`): Read it — Claude receives the extracted text content.
|
|
92
|
+
- **Text files** (`.txt`, `.csv`, `.md`, `.log`): Read it — Claude receives the raw text.
|
|
93
|
+
- **Other files** (`.zip`, `.docx`, `.xlsx`, etc.): Note they exist at the listed path but do not attempt to read them.
|
|
94
|
+
|
|
95
|
+
Read all eligible files before proceeding. Do not describe images unprompted — hold them in context for Step 5.
|
|
96
|
+
|
|
97
|
+
If there is no `## Attachments` section, skip this step.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### Step 3: Detect VCS and enrich
|
|
102
|
+
|
|
103
|
+
Detect the VCS in the current working directory and run enrichment commands:
|
|
104
|
+
|
|
105
|
+
**Git:**
|
|
106
|
+
```bash
|
|
107
|
+
git log --all --grep="$TICKET_KEY" --oneline --max-count=20
|
|
108
|
+
git branch -a | grep "$TICKET_KEY"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**SVN:**
|
|
112
|
+
```bash
|
|
113
|
+
svn log --limit 50 | grep -A5 "$TICKET_KEY"
|
|
114
|
+
svn ls ^/branches | grep "$TICKET_KEY"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Hg:**
|
|
118
|
+
```bash
|
|
119
|
+
hg log -k "$TICKET_KEY" --limit 20
|
|
120
|
+
hg branches | grep "$TICKET_KEY"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
If no VCS is detected, skip this step.
|
|
124
|
+
|
|
125
|
+
### Step 4: Resolve code references
|
|
126
|
+
|
|
127
|
+
From the TicketBrief output, look at the **Code References** section:
|
|
128
|
+
|
|
129
|
+
- For each **file path**: use Glob to check if it exists in the current repo
|
|
130
|
+
- For each **class name**: use Grep to find its definition (`class ClassName`)
|
|
131
|
+
- For each **branch**: note if it was found in step 3
|
|
132
|
+
- For each **SHA/revision**: note if it appeared in the VCS log
|
|
133
|
+
|
|
134
|
+
### Step 5: Plan the implementation
|
|
135
|
+
|
|
136
|
+
Enter plan mode with all gathered context:
|
|
137
|
+
- The TicketBrief markdown
|
|
138
|
+
- VCS commits and branches related to the ticket
|
|
139
|
+
- Which referenced files/classes exist locally
|
|
140
|
+
- Linked ticket summaries and their comments
|
|
141
|
+
|
|
142
|
+
Present a clear implementation plan for the user to approve.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## --check: Acceptance Criteria Coverage Review
|
|
147
|
+
|
|
148
|
+
When `--check` is appended to any ticket fetch (`/jtb PROJ-123 --check`):
|
|
149
|
+
|
|
150
|
+
### With VCS (git/svn/hg detected)
|
|
151
|
+
1. The brief includes a `--- DIFF ---` section with the current local diff
|
|
152
|
+
2. After reading the brief, evaluate coverage:
|
|
153
|
+
- Identify acceptance criteria from the ticket description and comments
|
|
154
|
+
- For each AC, check whether the diff addresses it
|
|
155
|
+
- Report: ✔ FOUND (with file:line reference) or ✗ NOT FOUND
|
|
156
|
+
- Show: `Coverage: N/M (X%) — N items outstanding`
|
|
157
|
+
|
|
158
|
+
### Without VCS (no git/svn/hg in cwd)
|
|
159
|
+
Use this evaluation order:
|
|
160
|
+
1. **Session context** — review files you Read/Edited this session; compare against ACs
|
|
161
|
+
2. **claude-mem** — if available, call `get_observations` searching for `{ticketKey}` to find prior session work
|
|
162
|
+
3. **context7** — if available, validate that changed files use correct library/framework APIs
|
|
163
|
+
4. **fs.stat() fallback** — read files modified in the last 4 hours in cwd; compare against ACs
|
|
164
|
+
5. **Manual checklist** — if none of the above apply, list the ACs for the developer to review manually
|
|
165
|
+
|
|
166
|
+
### Privacy
|
|
167
|
+
`--check` never sends data anywhere. The diff stays local. Claude Code provides the intelligence using its existing session context.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## --compliance: Formal Compliance Check
|
|
172
|
+
|
|
173
|
+
When `--compliance` is appended to any ticket fetch (`/jtb PROJ-123 --compliance`):
|
|
174
|
+
|
|
175
|
+
**Tier gate:** Free tier allows 3 compliance checks per month. Pro tier is unlimited.
|
|
176
|
+
If the user is on Free and has exhausted their quota, show the upgrade prompt returned by the script and stop.
|
|
177
|
+
|
|
178
|
+
### With VCS (git/svn/hg detected)
|
|
179
|
+
1. The brief includes a `--- DIFF ---` section with the current local diff
|
|
180
|
+
2. After reading the brief, evaluate each requirement formally:
|
|
181
|
+
- Extract every stated requirement, acceptance criterion, and definition-of-done item from the ticket description and all comments
|
|
182
|
+
- For each requirement, assess whether the diff satisfies it:
|
|
183
|
+
- `✔ COMPLIANT` — fully addressed, cite file:line
|
|
184
|
+
- `✖ NON-COMPLIANT` — not addressed at all
|
|
185
|
+
- `~ PARTIAL` — partially addressed, describe the gap
|
|
186
|
+
- Show a compliance summary: `Compliance: N/M (X%) — N items non-compliant, N partial`
|
|
187
|
+
- List all non-compliant and partial items with actionable notes
|
|
188
|
+
|
|
189
|
+
### Without VCS (no git/svn/hg in cwd)
|
|
190
|
+
Use this evaluation order:
|
|
191
|
+
1. **Session context** — review files you Read/Edited this session; compare against requirements
|
|
192
|
+
2. **claude-mem** — if available, call `get_observations` searching for `{ticketKey}`
|
|
193
|
+
3. **Manual checklist** — list each requirement for the developer to verify manually
|
|
194
|
+
|
|
195
|
+
### Privacy
|
|
196
|
+
`--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Advanced Options
|
|
201
|
+
|
|
202
|
+
These flags are available on any ticket fetch and can be combined.
|
|
203
|
+
|
|
204
|
+
### --summarize (Pro)
|
|
205
|
+
|
|
206
|
+
Generates an AI-powered summary of the full brief, collapsing verbose descriptions into a concise implementation overview. Useful for large tickets with many comments.
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
/jtb PROD-1234 --summarize # BYOK — uses your own API key
|
|
210
|
+
/jtb PROD-1234 --summarize --cloud # uses TicketLens cloud summariser
|
|
211
|
+
/jtb PROD-1234 --summarize --provider=openai # use a specific AI provider
|
|
212
|
+
/jtb PROD-1234 --summarize --budget=2000 # limit output to ~2000 tokens
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **BYOK (default):** reads your AI API key from `~/.ticketlens/credentials.json`. First use will prompt for consent.
|
|
216
|
+
- **`--cloud`:** routes through TicketLens cloud API (no local key needed, requires Pro).
|
|
217
|
+
- **`--provider=NAME`:** override the AI provider. Supported values depend on your credentials (e.g. `claude`, `openai`).
|
|
218
|
+
- **`--budget=N`:** prune the brief to approximately N tokens before summarising. Forces plain-text output.
|
|
219
|
+
|
|
220
|
+
### --handoff (Pro)
|
|
221
|
+
|
|
222
|
+
Generates a structured handoff brief synthesised from the ticket's full comment thread. Designed for developer-to-developer handoffs — includes open questions, current state, and next steps.
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
/jtb PROD-1234 --handoff
|
|
226
|
+
/jtb PROD-1234 --handoff --cloud
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Output is a concise markdown document, not a full TicketBrief. No plan mode — output is displayed and the workflow stops.
|
|
230
|
+
|
|
231
|
+
### --plain / --styled
|
|
232
|
+
|
|
233
|
+
Control output formatting:
|
|
234
|
+
|
|
235
|
+
- **`--plain`** — strip all ANSI colour codes. Useful when piping to a file or another tool.
|
|
236
|
+
- **`--styled`** — force ANSI-styled output even in non-TTY contexts (e.g. when piped).
|
|
237
|
+
|
|
238
|
+
Default: styled when stdout is a TTY, plain otherwise.
|
|
239
|
+
|
|
240
|
+
### --no-cache
|
|
241
|
+
|
|
242
|
+
Forces a fresh fetch from Jira, bypassing the local brief cache (4-hour TTL by default). Use when the ticket was recently updated and the cached brief is stale.
|
|
243
|
+
|
|
244
|
+
### --no-attachments
|
|
245
|
+
|
|
246
|
+
Skip downloading and reading ticket attachments. Speeds up the fetch for tickets with large or irrelevant file attachments.
|
|
@@ -74,7 +74,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
74
74
|
|
|
75
75
|
const validatedArgs = await handleUnknownFlags(
|
|
76
76
|
args,
|
|
77
|
-
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push'],
|
|
77
|
+
['--help', '-h', '--static', '--plain', '--styled', '--profile=', '--stale=', '--status=', '--assignee=', '--sprint=', '--export=', '--digest', '--push', '--share'],
|
|
78
78
|
{ hints: ['--depth=', '--no-attachments', '--no-cache'] } // fetch-only flags — shown as hints, not applied
|
|
79
79
|
);
|
|
80
80
|
if (validatedArgs === null) { process.exitCode = 1; return; }
|
|
@@ -92,6 +92,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
92
92
|
const exportArg = args.find(a => a.startsWith('--export='))?.split('=')[1] ?? null;
|
|
93
93
|
const digestFlag = args.includes('--digest');
|
|
94
94
|
const pushFlag = args.includes('--push');
|
|
95
|
+
const shareFlag = args.includes('--share');
|
|
95
96
|
|
|
96
97
|
if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
|
|
97
98
|
process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
|
|
@@ -341,15 +342,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
341
342
|
const cleanArgs = args.filter(a => !a.startsWith('--profile=') && !a.startsWith('--project='));
|
|
342
343
|
return run(cleanArgs, { ...opts, env, fetcher, configDir });
|
|
343
344
|
}
|
|
344
|
-
|
|
345
|
+
// Fall through to push/share if those flags were passed
|
|
346
|
+
if (!pushFlag && !shareFlag) return;
|
|
347
|
+
} else {
|
|
348
|
+
const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
|
|
349
|
+
const summary = useStyled
|
|
350
|
+
? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
|
|
351
|
+
: assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
|
|
352
|
+
process.stdout.write(summary + '\n');
|
|
345
353
|
}
|
|
346
354
|
|
|
347
|
-
const useStyled = args.includes('--styled') || (!args.includes('--plain') && process.stdout.isTTY);
|
|
348
|
-
const summary = useStyled
|
|
349
|
-
? styleTriageSummary(sorted, { styled: true, staleDays, baseUrl: conn.baseUrl })
|
|
350
|
-
: assembleTriageSummary(sorted, { staleDays, baseUrl: conn.baseUrl });
|
|
351
|
-
process.stdout.write(summary + '\n');
|
|
352
|
-
|
|
353
355
|
if (pushFlag) {
|
|
354
356
|
const { pushTriageSnapshot } = await import('./lib/triage-push.mjs');
|
|
355
357
|
const pushFn = opts.pushFn ?? pushTriageSnapshot;
|
|
@@ -365,6 +367,22 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
365
367
|
print: printFn,
|
|
366
368
|
});
|
|
367
369
|
}
|
|
370
|
+
|
|
371
|
+
if (shareFlag) {
|
|
372
|
+
const { shareTriageSnapshot } = await import('./lib/triage-share.mjs');
|
|
373
|
+
const shareFn = opts.shareFn ?? shareTriageSnapshot;
|
|
374
|
+
const licenseKey = readLicense(configDir)?.key ?? null;
|
|
375
|
+
const printFn = opts.print ?? ((s) => process.stdout.write(s));
|
|
376
|
+
await shareFn({
|
|
377
|
+
sorted,
|
|
378
|
+
rawTicketMap,
|
|
379
|
+
profile: profileName ?? 'default',
|
|
380
|
+
baseUrl: conn.baseUrl,
|
|
381
|
+
licenseKey,
|
|
382
|
+
fetcher,
|
|
383
|
+
print: printFn,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
368
386
|
}
|
|
369
387
|
|
|
370
388
|
// Run if invoked directly
|
|
@@ -19,6 +19,7 @@ import { classifyError } from './lib/error-classifier.mjs';
|
|
|
19
19
|
import { promptProfileSelect, promptProfileMismatch, promptSwitchProfile, promptMultipleMatches } from './lib/profile-picker.mjs';
|
|
20
20
|
import { promptSelect } from './lib/select-prompt.mjs';
|
|
21
21
|
import { printFetchHelp } from './lib/help.mjs';
|
|
22
|
+
import { readTextAttachments } from './lib/handoff-assembler.mjs';
|
|
22
23
|
import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
23
24
|
import { TICKET_KEY_PATTERN } from './lib/cli.mjs';
|
|
24
25
|
import { downloadAttachments } from './lib/attachment-downloader.mjs';
|
|
@@ -104,11 +105,22 @@ function resolveAiProvider(args, credentials) {
|
|
|
104
105
|
return credentials?.aiProvider ?? undefined;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Append text-readable attachment content to a brief for AI consumption only.
|
|
110
|
+
* The returned string is only sent to the AI — the displayed brief is unchanged.
|
|
111
|
+
*/
|
|
112
|
+
function augmentBriefForAi(brief, localAttachments) {
|
|
113
|
+
const textFiles = readTextAttachments(localAttachments);
|
|
114
|
+
if (textFiles.length === 0) return brief;
|
|
115
|
+
const sections = textFiles.map(({ filename, content }) => `=== Attachment: ${filename} ===\n${content}`);
|
|
116
|
+
return brief + `\n\n--- Attached Documents (${textFiles.length} text-readable) ---\n\n${sections.join('\n\n')}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
/**
|
|
108
120
|
* Apply --summarize to a brief string.
|
|
109
121
|
* Returns the modified brief, or null if the caller should exit (license gate / consent refused).
|
|
110
122
|
*/
|
|
111
|
-
async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn) {
|
|
123
|
+
async function applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, ticket) {
|
|
112
124
|
if (!licensedFn('pro', configDir)) {
|
|
113
125
|
upgradeFn('pro', '--summarize');
|
|
114
126
|
process.exitCode = 1;
|
|
@@ -142,7 +154,8 @@ async function applySummarize(brief, args, opts, configDir, conn, licensedFn, up
|
|
|
142
154
|
const credentials = opts.credentials ?? loadCredentials(configDir);
|
|
143
155
|
const licenseKey = readLicense(configDir)?.key;
|
|
144
156
|
const provider = opts.provider ?? resolveAiProvider(args, credentials);
|
|
145
|
-
const
|
|
157
|
+
const aiInput = augmentBriefForAi(brief, ticket?.localAttachments);
|
|
158
|
+
const summary = await summarizerFn({ brief: aiInput, mode, credentials, licenseKey, provider });
|
|
146
159
|
const divider = '─'.repeat(60);
|
|
147
160
|
return brief + `\n\n${divider}\n─── AI Summary ${'─'.repeat(45)}\n${summary}\n${divider}\n`;
|
|
148
161
|
} catch (err) {
|
|
@@ -955,7 +968,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
955
968
|
if (args.includes('--check')) brief = applyCheck(brief, opts);
|
|
956
969
|
|
|
957
970
|
if (args.includes('--summarize')) {
|
|
958
|
-
brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn);
|
|
971
|
+
brief = await applySummarize(brief, args, opts, configDir, conn, licensedFn, upgradeFn, cached.ticket);
|
|
959
972
|
if (brief === null) return;
|
|
960
973
|
}
|
|
961
974
|
|
|
@@ -1141,7 +1154,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1141
1154
|
if (args.includes('--check')) output = applyCheck(output, opts);
|
|
1142
1155
|
|
|
1143
1156
|
if (args.includes('--summarize')) {
|
|
1144
|
-
output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn);
|
|
1157
|
+
output = await applySummarize(output, args, opts, configDir, conn, licensedFn, upgradeFn, ticket);
|
|
1145
1158
|
if (output === null) return;
|
|
1146
1159
|
}
|
|
1147
1160
|
|
|
@@ -102,6 +102,10 @@ export function parseCommand(args) {
|
|
|
102
102
|
return { command: 'sync', args: args.slice(1) };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
if (first === 'update-skill') {
|
|
106
|
+
return { command: 'update-skill', args: args.slice(1) };
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
106
110
|
return { command: 'fetch', args };
|
|
107
111
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HANDOFF_PROMPT = `Build a structured handoff brief from this Jira ticket.
|
|
2
5
|
The developer receiving this ticket has never seen it before — they need to get up to speed immediately.
|
|
6
|
+
Use the description, comments, attached documents, and linked Confluence pages as context.
|
|
3
7
|
|
|
4
8
|
Respond in exactly this format (use the exact headings):
|
|
5
9
|
|
|
6
10
|
### What was attempted
|
|
7
|
-
[2–5 bullet points of concrete work already done
|
|
11
|
+
[2–5 bullet points of concrete work already done. Be specific — mention code paths, methods, or files if referenced.]
|
|
8
12
|
|
|
9
13
|
### Current blockers
|
|
10
|
-
[Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the
|
|
14
|
+
[Bullet points of unresolved issues, errors, or dependencies blocking progress. Write "None identified" if the path is clear.]
|
|
11
15
|
|
|
12
16
|
### Open questions
|
|
13
17
|
[Bullet points of unanswered questions or decisions not yet made. Write "None identified" if everything is resolved.]
|
|
@@ -16,16 +20,46 @@ Respond in exactly this format (use the exact headings):
|
|
|
16
20
|
[1–2 sentences on the best starting point for the incoming developer.]
|
|
17
21
|
|
|
18
22
|
Rules:
|
|
19
|
-
- Be specific and factual. Reference actual details from the
|
|
20
|
-
- Do not invent anything not present in the
|
|
23
|
+
- Be specific and factual. Reference actual details from the ticket content.
|
|
24
|
+
- Do not invent anything not present in the provided context.
|
|
21
25
|
- Keep each bullet under 20 words.
|
|
22
|
-
- If there are no comments,
|
|
26
|
+
- If there are no comments, base your analysis on the description and attached documents.
|
|
23
27
|
|
|
24
28
|
`;
|
|
25
29
|
|
|
30
|
+
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.log', '.csv', '.json', '.xml', '.html', '.htm', '.rst', '.yaml', '.yml']);
|
|
31
|
+
const MAX_CHARS_PER_FILE = 4000;
|
|
32
|
+
const MAX_TOTAL_CHARS = 12000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read text content from downloaded local attachments.
|
|
36
|
+
* Skips binary files (images, PDFs, Office docs). Caps content per file and in total.
|
|
37
|
+
* @param {Array} localAttachments - from ticket.localAttachments
|
|
38
|
+
* @returns {Array<{filename: string, content: string}>}
|
|
39
|
+
*/
|
|
40
|
+
export function readTextAttachments(localAttachments = []) {
|
|
41
|
+
let totalChars = 0;
|
|
42
|
+
const result = [];
|
|
43
|
+
for (const att of localAttachments) {
|
|
44
|
+
if (!att.localPath || att.skipReason === 'error') continue;
|
|
45
|
+
if (!TEXT_EXTENSIONS.has(extname(att.filename).toLowerCase())) continue;
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(att.localPath, 'utf8');
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (!trimmed) continue;
|
|
50
|
+
const remaining = MAX_TOTAL_CHARS - totalChars;
|
|
51
|
+
if (remaining <= 0) break;
|
|
52
|
+
const content = trimmed.slice(0, Math.min(MAX_CHARS_PER_FILE, remaining));
|
|
53
|
+
totalChars += content.length;
|
|
54
|
+
result.push({ filename: att.filename, content });
|
|
55
|
+
} catch { /* unreadable — skip */ }
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
/**
|
|
27
61
|
* Build the text input sent to the AI for handoff analysis.
|
|
28
|
-
*
|
|
62
|
+
* Includes description, Confluence pages, text-readable attachments, and comment thread.
|
|
29
63
|
*
|
|
30
64
|
* @param {object} ticket - Normalized ticket object from jira-client.normalizeTicket
|
|
31
65
|
* @returns {string}
|
|
@@ -40,6 +74,33 @@ export function buildHandoffInput(ticket) {
|
|
|
40
74
|
if (ticket.reporter) lines.push(`Reporter: ${ticket.reporter}`);
|
|
41
75
|
lines.push('');
|
|
42
76
|
|
|
77
|
+
if (ticket.description) {
|
|
78
|
+
lines.push('--- Description ---');
|
|
79
|
+
lines.push(ticket.description.replace(/\r/g, ''));
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (ticket.confluencePages?.length > 0) {
|
|
84
|
+
lines.push(`--- Confluence Pages (${ticket.confluencePages.length}) ---`);
|
|
85
|
+
for (const p of ticket.confluencePages) {
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push(`### ${p.title ?? p.url}`);
|
|
88
|
+
if (p.text) lines.push(p.text);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const textAttachments = readTextAttachments(ticket.localAttachments);
|
|
94
|
+
if (textAttachments.length > 0) {
|
|
95
|
+
lines.push(`--- Attached Documents (${textAttachments.length} text-readable) ---`);
|
|
96
|
+
for (const { filename, content } of textAttachments) {
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(`=== ${filename} ===`);
|
|
99
|
+
lines.push(content);
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
}
|
|
103
|
+
|
|
43
104
|
const comments = ticket.comments ?? [];
|
|
44
105
|
lines.push(`--- Comments (${comments.length} total) ---`);
|
|
45
106
|
|
|
@@ -47,6 +47,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
47
47
|
` ${s.brand('ticketlens')} license Show license status`,
|
|
48
48
|
` ${s.brand('ticketlens')} cache ${s.dim('[size|clear]')} Manage attachment cache ${s.dim('(try cache --help)')}`,
|
|
49
49
|
` ${s.brand('ticketlens')} schedule ${s.dim('[--stop|--status]')} Manage digest schedule ${s.dim('[Pro]')}`,
|
|
50
|
+
` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
|
|
50
51
|
'',
|
|
51
52
|
` ${s.bold('FETCH OPTIONS')}`,
|
|
52
53
|
'',
|
|
@@ -63,6 +64,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
63
64
|
` ${s.brand('--summarize')} Generate AI summary ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
64
65
|
` ${s.brand('--handoff')} AI handoff brief from comment thread ${s.dim('(BYOK or --cloud) [Pro]')}`,
|
|
65
66
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
67
|
+
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
66
68
|
'',
|
|
67
69
|
` ${s.bold('TRIAGE OPTIONS')}`,
|
|
68
70
|
'',
|
|
@@ -74,6 +76,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
74
76
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
75
77
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
76
78
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
79
|
+
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
80
|
+
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
77
81
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
78
82
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
79
83
|
` ${s.brand('--plain')} Plain markdown output ${s.dim('(for piping / LLM)')}`,
|
|
@@ -102,6 +106,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
102
106
|
` ${s.dim(' ')} anthropicApiKey ${s.dim('→ Claude (paid)')}`,
|
|
103
107
|
` ${s.dim(' ')} openaiApiKey ${s.dim('→ GPT-4o mini (paid)')}`,
|
|
104
108
|
` ${s.dim(' ')} groqApiKey ${s.dim('→ Llama 3.1 (free tier — console.groq.com)')}`,
|
|
109
|
+
` ${s.dim(' ')} ${s.dim('Set default:')} ticketlens config set aiProvider ${s.dim('<anthropic|openai|groq>')}`,
|
|
105
110
|
'',
|
|
106
111
|
'',
|
|
107
112
|
];
|
|
@@ -541,6 +546,8 @@ export function printTriageHelp({ stream = process.stdout } = {}) {
|
|
|
541
546
|
` ${s.brand('--assignee')}=${s.dim('NAME')} Triage another dev's tickets ${s.dim('[Team]')}`,
|
|
542
547
|
` ${s.brand('--sprint')}=${s.dim('NAME')} Filter by sprint name ${s.dim('[Team]')}`,
|
|
543
548
|
` ${s.brand('--export')}=${s.dim('FORMAT')} Export results to file ${s.dim('(csv|json) [Team]')}`,
|
|
549
|
+
` ${s.brand('--push')} Push snapshot to Console queue ${s.dim('[Team]')}`,
|
|
550
|
+
` ${s.brand('--share')} Generate a 24h share URL ${s.dim('(no login required) [Team]')}`,
|
|
544
551
|
` ${s.brand('--digest')} POST scored results to digest endpoint ${s.dim('[Pro]')}`,
|
|
545
552
|
` ${s.brand('--static')} Static table output ${s.dim('(skip interactive mode)')}`,
|
|
546
553
|
` ${s.brand('--plain')} Plain markdown output`,
|
|
@@ -600,6 +607,42 @@ export function printReviewHelp({ stream = process.stdout } = {}) {
|
|
|
600
607
|
stream.write(lines.join('\n') + '\n');
|
|
601
608
|
}
|
|
602
609
|
|
|
610
|
+
export function printUpdateSkillHelp({ stream = process.stdout } = {}) {
|
|
611
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
612
|
+
const lines = [
|
|
613
|
+
'',
|
|
614
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('update-skill')} ${s.dim('[--dry-run] [--path=DIR] [--quiet]')}`,
|
|
615
|
+
'',
|
|
616
|
+
` Copy the latest /jtb SKILL.md into every detected AI assistant command directory.`,
|
|
617
|
+
` Runs automatically on ${s.dim('npm install -g ticketlens')} for existing installs.`,
|
|
618
|
+
'',
|
|
619
|
+
` ${s.bold('SUPPORTED ASSISTANTS')}`,
|
|
620
|
+
'',
|
|
621
|
+
` Claude Code ${s.dim('~/.claude/commands/jtb.md')}`,
|
|
622
|
+
` Claude Code (work) ${s.dim('~/.claude-work/commands/jtb.md')}`,
|
|
623
|
+
` Gemini CLI ${s.dim('~/.gemini/commands/jtb.md')}`,
|
|
624
|
+
` Copilot CLI ${s.dim('~/.copilot-cli/commands/jtb.md')}`,
|
|
625
|
+
'',
|
|
626
|
+
` Only targets where ${s.dim('jtb.md')} already exists are updated. Use ${s.dim('--path')} to install`,
|
|
627
|
+
` into a new location (the directory must exist).`,
|
|
628
|
+
'',
|
|
629
|
+
` ${s.bold('OPTIONS')}`,
|
|
630
|
+
'',
|
|
631
|
+
` ${s.brand('--dry-run')} Show what would change without writing any files`,
|
|
632
|
+
` ${s.brand('--path')}=${s.dim('DIR')} Write to a specific commands directory instead`,
|
|
633
|
+
` ${s.brand('--quiet')} Suppress all output except errors`,
|
|
634
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
635
|
+
'',
|
|
636
|
+
` ${s.bold('EXAMPLES')}`,
|
|
637
|
+
'',
|
|
638
|
+
` ${s.dim('$')} ticketlens update-skill`,
|
|
639
|
+
` ${s.dim('$')} ticketlens update-skill --dry-run`,
|
|
640
|
+
` ${s.dim('$')} ticketlens update-skill --path=~/.config/my-ai/commands`,
|
|
641
|
+
'',
|
|
642
|
+
];
|
|
643
|
+
stream.write(lines.join('\n') + '\n');
|
|
644
|
+
}
|
|
645
|
+
|
|
603
646
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
604
647
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
605
648
|
const lines = [
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST triage snapshot to /v1/triage/share and return a 24h signed URL.
|
|
3
|
+
* Errors are non-fatal — share failure must never suppress triage output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SHARE_PATH = '/v1/triage/share';
|
|
7
|
+
const DEFAULT_API_BASE = 'http://ticketlens.test';
|
|
8
|
+
|
|
9
|
+
function apiBase() {
|
|
10
|
+
return process.env?.TICKETLENS_API_URL ?? DEFAULT_API_BASE;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildTicketPayload(scored, rawMap, baseUrl) {
|
|
14
|
+
const raw = rawMap?.get(scored.ticketKey);
|
|
15
|
+
return {
|
|
16
|
+
key: scored.ticketKey,
|
|
17
|
+
summary: scored.summary ?? null,
|
|
18
|
+
status: scored.status ?? null,
|
|
19
|
+
assignee: raw?.assignee ?? null,
|
|
20
|
+
attention_score: null,
|
|
21
|
+
flags: scored.urgency === 'clear' ? [] : [scored.urgency],
|
|
22
|
+
compliance_coverage: null,
|
|
23
|
+
compliance_status: 'unknown',
|
|
24
|
+
url: baseUrl ? `${baseUrl}/browse/${scored.ticketKey}` : null,
|
|
25
|
+
last_updated: raw?.updated ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {object[]} [opts.sorted] - Scored tickets
|
|
32
|
+
* @param {Map} [opts.rawTicketMap] - Map<key, normalizedTicket>
|
|
33
|
+
* @param {string} [opts.profile] - Resolved profile name (max 100 chars)
|
|
34
|
+
* @param {string} [opts.baseUrl] - Jira base URL
|
|
35
|
+
* @param {string} [opts.licenseKey] - Bearer token
|
|
36
|
+
* @param {string} [opts.capturedAt] - ISO 8601 timestamp
|
|
37
|
+
* @param {Function} [opts.fetcher] - Injectable fetch
|
|
38
|
+
* @param {Function} [opts.print] - Output fn
|
|
39
|
+
* @returns {Promise<{ ok: boolean, status?: number }>}
|
|
40
|
+
*/
|
|
41
|
+
export async function shareTriageSnapshot({
|
|
42
|
+
sorted = [],
|
|
43
|
+
rawTicketMap = new Map(),
|
|
44
|
+
profile,
|
|
45
|
+
baseUrl,
|
|
46
|
+
licenseKey,
|
|
47
|
+
capturedAt,
|
|
48
|
+
fetcher = globalThis.fetch,
|
|
49
|
+
print = (s) => process.stdout.write(s),
|
|
50
|
+
} = {}) {
|
|
51
|
+
if (!licenseKey) {
|
|
52
|
+
print('✗ --share requires an active Team license (ticketlens activate <key>)\n');
|
|
53
|
+
return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const payload = {
|
|
57
|
+
profile: String(profile ?? 'default').slice(0, 100),
|
|
58
|
+
captured_at: capturedAt ?? new Date().toISOString(),
|
|
59
|
+
tickets: sorted.map(t => buildTicketPayload(t, rawTicketMap, baseUrl)),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetcher(`${apiBase()}${SHARE_PATH}`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
'Authorization': `Bearer ${licenseKey}`,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify(payload),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (res.ok) {
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
print(`✓ Share link (expires in 24h):\n ${data.url}\n`);
|
|
75
|
+
return { ok: true, status: res.status };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (res.status === 403) {
|
|
79
|
+
print('✗ --share requires a Team license\n');
|
|
80
|
+
return { ok: false, status: res.status };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
print(`⚠ Share failed (${res.status}) — triage output unaffected\n`);
|
|
84
|
+
return { ok: false, status: res.status };
|
|
85
|
+
} catch {
|
|
86
|
+
print('⚠ Share failed (network error) — triage output unaffected\n');
|
|
87
|
+
return { ok: false };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-skill — copies SKILL.md to known AI assistant command directories.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ticketlens update-skill # update all detected installs
|
|
6
|
+
* ticketlens update-skill --dry-run # show what would change, no writes
|
|
7
|
+
* ticketlens update-skill --path=/some/dir # write to a custom target directory
|
|
8
|
+
* ticketlens update-skill --quiet # suppress non-error output
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const SKILL_SRC = join(__dirname, '..', '..', 'SKILL.md');
|
|
18
|
+
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TARGETS = [
|
|
22
|
+
{ label: 'Claude Code', path: join(HOME, '.claude', 'commands') },
|
|
23
|
+
{ label: 'Claude Code (work)', path: join(HOME, '.claude-work', 'commands') },
|
|
24
|
+
{ label: 'Gemini CLI', path: join(HOME, '.gemini', 'commands') },
|
|
25
|
+
{ label: 'Copilot CLI', path: join(HOME, '.copilot-cli', 'commands') },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function skillVersion(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
const line = readFileSync(filePath, 'utf8').split('\n')[0];
|
|
31
|
+
const m = line.match(/jtb-skill-version:\s*([\d.]+)/);
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function updateSkill(args = []) {
|
|
39
|
+
const dryRun = args.includes('--dry-run');
|
|
40
|
+
const quiet = args.includes('--quiet');
|
|
41
|
+
const pathArg = args.find(a => a.startsWith('--path='));
|
|
42
|
+
|
|
43
|
+
const log = (...msg) => { if (!quiet) process.stdout.write(msg.join(' ') + '\n'); };
|
|
44
|
+
const err = (...msg) => process.stderr.write(msg.join(' ') + '\n');
|
|
45
|
+
|
|
46
|
+
if (!existsSync(SKILL_SRC)) {
|
|
47
|
+
err(`✖ SKILL.md not found at ${SKILL_SRC}`);
|
|
48
|
+
err(' Reinstall TicketLens: npm install -g ticketlens@latest');
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const srcVersion = skillVersion(SKILL_SRC) ?? 'unknown';
|
|
54
|
+
if (dryRun) log(`Dry run — skill source: v${srcVersion} (${SKILL_SRC})\n`);
|
|
55
|
+
|
|
56
|
+
let targets;
|
|
57
|
+
if (pathArg) {
|
|
58
|
+
const dir = pathArg.slice('--path='.length);
|
|
59
|
+
targets = [{ label: 'Custom path', path: dir }];
|
|
60
|
+
} else {
|
|
61
|
+
targets = DEFAULT_TARGETS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let updated = 0;
|
|
65
|
+
let skipped = 0;
|
|
66
|
+
let notFound = 0;
|
|
67
|
+
|
|
68
|
+
for (const { label, path: dir } of targets) {
|
|
69
|
+
const dest = join(dir, 'jtb.md');
|
|
70
|
+
|
|
71
|
+
if (!existsSync(dir)) { notFound++; continue; }
|
|
72
|
+
if (!existsSync(dest)) { notFound++; continue; }
|
|
73
|
+
|
|
74
|
+
const destVersion = skillVersion(dest);
|
|
75
|
+
|
|
76
|
+
if (!dryRun && destVersion === srcVersion) {
|
|
77
|
+
log(` ✔ ${label}: already at v${srcVersion}`);
|
|
78
|
+
skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
const fromVer = destVersion ?? 'unversioned';
|
|
84
|
+
log(` → ${label}: ${dest}`);
|
|
85
|
+
log(` ${fromVer} → ${srcVersion} (would update)`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
copyFileSync(SKILL_SRC, dest);
|
|
91
|
+
const fromVer = destVersion ?? 'unversioned';
|
|
92
|
+
log(` ✔ ${label}: updated v${fromVer} → v${srcVersion}`);
|
|
93
|
+
updated++;
|
|
94
|
+
} catch (copyErr) {
|
|
95
|
+
err(` ✖ ${label}: ${copyErr.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!dryRun) {
|
|
100
|
+
if (updated === 0 && notFound === targets.length) {
|
|
101
|
+
log('\n /jtb is not installed in any known AI assistant.');
|
|
102
|
+
log('\n To install for Claude Code:');
|
|
103
|
+
log(' mkdir -p ~/.claude/commands');
|
|
104
|
+
log(` cp "${SKILL_SRC}" ~/.claude/commands/jtb.md`);
|
|
105
|
+
log('\n Then restart your Claude Code session and use /jtb TICKET-KEY.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (updated > 0) {
|
|
109
|
+
log(`\n ${updated} installation(s) updated to v${srcVersion}.`);
|
|
110
|
+
log(' Restart your AI assistant session to pick up the new skill.');
|
|
111
|
+
}
|
|
112
|
+
if (skipped > 0 && !quiet) {
|
|
113
|
+
log(` ${skipped} installation(s) already up to date.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|