ticketlens 0.38.34 → 0.38.36
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 -2
- package/bin/ticketlens.mjs +11 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +28 -2
- package/skills/jtb/scripts/fetch-ticket.mjs +5 -6
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +51 -7
- package/skills/jtb/scripts/lib/mcp-server.mjs +110 -4
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +49 -0
- package/skills/jtb/scripts/lib/note-command.mjs +6 -2
- package/skills/jtb/scripts/lib/run-issue-types.mjs +134 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
- [Recall](#recall)
|
|
45
45
|
- [Comment, Transition, Assign, Duplicates, Link, Update & Create](#comment-transition-assign-duplicates-link-update--create)
|
|
46
46
|
- [Response-Time Stats](#response-time-stats)
|
|
47
|
+
- [Pre-fetch Issue Types](#pre-fetch-issue-types)
|
|
47
48
|
- [Doctor](#doctor)
|
|
48
49
|
- [Custom Attention Rules](#custom-attention-rules)
|
|
49
50
|
- [Login](#login)
|
|
@@ -453,7 +454,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
453
454
|
|
|
454
455
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
455
456
|
|
|
456
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `history`, `collisions`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; every other tool needs Pro. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `issue_types`, `history`, `collisions`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `issue_types` is Free and Jira-only — pre-fetches and caches a profile's real creatable projects and issue types ahead of a `ticket_create` attempt, sharing its cache with that tool's own reactive enrichment; Linear/GitHub profiles get a clear "not available" instead of an empty result; `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
457
458
|
|
|
458
459
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
459
460
|
|
|
@@ -497,7 +498,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
497
498
|
|
|
498
499
|
`--attach=path1,path2` (comma-separated local file paths) is available on `comment` and `create` only. Images render as an inline thumbnail on Jira and Linear; GitHub has no attachment upload API, so `--attach` is unsupported there.
|
|
499
500
|
|
|
500
|
-
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
|
|
501
|
+
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time. `ticketlens issue-types` (below) is the proactive counterpart — pre-fetches and caches the same data ahead of time, so a `create` right after it is a pure cache hit.
|
|
501
502
|
|
|
502
503
|
All six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
|
|
503
504
|
|
|
@@ -526,6 +527,19 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
|
|
|
526
527
|
|
|
527
528
|
---
|
|
528
529
|
|
|
530
|
+
### Pre-fetch Issue Types
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
ticketlens issue-types # Pre-fetch/cache the active profile's real projects + Jira issue types
|
|
534
|
+
ticketlens issue-types --profile=acme # Target a specific profile
|
|
535
|
+
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
536
|
+
ticketlens issue-types --format=json # JSON output for scripting
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Fetches a profile's real creatable projects and, for Jira, each project's valid issue types — the proactive counterpart to `create`'s reactive failure-message enrichment (above): a ready lookup instead of only learning them from a failed create's error. Jira only — Linear has no per-project issue-type concept and GitHub has neither, so both report a clear "not available" instead of an empty result. Free, no license gate. Shares its cache with `create`'s enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json`, 24h TTL), so a `create` failure right after this command is a pure cache hit, no extra network round-trip.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
529
543
|
### Doctor
|
|
530
544
|
|
|
531
545
|
```bash
|
|
@@ -818,6 +832,12 @@ ticketlens stats --profile=acme # Metrics for a specific profile
|
|
|
818
832
|
ticketlens stats --days=14 # Extend lookback window (Pro, max 30)
|
|
819
833
|
ticketlens stats --format=json # JSON output for scripting
|
|
820
834
|
|
|
835
|
+
# ── Issue Types ───────────────────────────────────────────────────────────────
|
|
836
|
+
ticketlens issue-types # Pre-fetch/cache the active profile's projects + Jira issue types
|
|
837
|
+
ticketlens issue-types --profile=acme # Target a specific profile
|
|
838
|
+
ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
|
|
839
|
+
ticketlens issue-types --format=json # JSON output for scripting
|
|
840
|
+
|
|
821
841
|
# ── Doctor ────────────────────────────────────────────────────────────────────
|
|
822
842
|
ticketlens doctor # Diagnose profile/license/connectivity/cache/MCP-registration/queue problems
|
|
823
843
|
ticketlens doctor --fix # Attempt safe automatic fixes
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -27,12 +27,13 @@ import {
|
|
|
27
27
|
printInitHelp, printSwitchHelp, printConfigHelp,
|
|
28
28
|
printReviewHelp, printStandupHelp, printUpdateSkillHelp,
|
|
29
29
|
printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
|
|
30
|
-
printCollisionsHelp, printStatsHelp, printDoctorHelp,
|
|
30
|
+
printCollisionsHelp, printStatsHelp, printIssueTypesHelp, printDoctorHelp,
|
|
31
31
|
printCloudKeysHelp,
|
|
32
32
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
33
33
|
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
34
34
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
35
35
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
36
|
+
import { runIssueTypes } from '../skills/jtb/scripts/lib/run-issue-types.mjs';
|
|
36
37
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
37
38
|
import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
38
39
|
import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
|
|
@@ -159,6 +160,15 @@ switch (command) {
|
|
|
159
160
|
break;
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
case 'issue-types': {
|
|
164
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printIssueTypesHelp(); break; }
|
|
165
|
+
runIssueTypes(cmdArgs).catch(err => {
|
|
166
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
case 'doctor': {
|
|
163
173
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDoctorHelp(); break; }
|
|
164
174
|
runDoctor(cmdArgs).then(({ ok }) => {
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.40.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -42,6 +42,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
42
42
|
/jtb stats # personal response-time metrics from local history
|
|
43
43
|
/jtb stats --days=14 # extend lookback window (Pro, max 30)
|
|
44
44
|
/jtb stats --format=json # JSON output for scripting
|
|
45
|
+
/jtb issue-types # pre-fetch/cache a profile's valid Jira issue types
|
|
46
|
+
/jtb issue-types --refresh # force a live fetch, bypassing the cache
|
|
45
47
|
/jtb collisions # show branch collisions with teammates (Team)
|
|
46
48
|
/jtb collisions --json # machine-readable output
|
|
47
49
|
/jtb review # code-review context brief from current branch
|
|
@@ -66,7 +68,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
66
68
|
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
67
69
|
```
|
|
68
70
|
|
|
69
|
-
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
|
|
71
|
+
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own. If this harness has TicketLens's MCP server configured, `note delete`'s equivalent is the `recall_delete` tool (`id`/`ticket`/`confirm`) — it has no interactive prompt at all (no real terminal exists under MCP to prompt against), so `confirm: true` is the only way it ever executes; the same rule applies — only pass it when the user's message is the confirmation for that specific note.
|
|
70
72
|
|
|
71
73
|
## Prerequisites
|
|
72
74
|
|
|
@@ -148,6 +150,25 @@ If this harness has TicketLens's MCP server configured (a tool named `stats` —
|
|
|
148
150
|
|
|
149
151
|
---
|
|
150
152
|
|
|
153
|
+
### Issue-types subcommand
|
|
154
|
+
|
|
155
|
+
If the first argument is `issue-types`:
|
|
156
|
+
|
|
157
|
+
Run:
|
|
158
|
+
```bash
|
|
159
|
+
ticketlens issue-types $EXTRA_ARGS
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --refresh --format=json`).
|
|
163
|
+
|
|
164
|
+
Pre-fetches and caches a profile's real creatable projects and their valid Jira issue types, ahead of a `ticketlens create` attempt — a ready lookup instead of only learning them from a failed create's error message. Jira only; Linear (no per-project issue-type concept) and GitHub (neither) report a clear "not available" instead of an empty result. Free, no license gate. `--profile=NAME` (target a specific tracker profile); `--refresh` (force a live fetch even if a complete cache already exists); `--format=plain` (default, human-readable table) or `--format=json` (scripting). Shares its cache with `ticketlens create`'s own failure-message enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json`, 24h TTL) — a create failure right after this command is a pure cache hit.
|
|
165
|
+
|
|
166
|
+
Display the script's stdout directly. No plan mode. Stop here.
|
|
167
|
+
|
|
168
|
+
If this harness has TicketLens's MCP server configured (a tool named `issue_types` — often shown as `mcp__ticketlens__issue_types` — visible in your tool list), prefer it over the bash form: same Jira-only scope, same shared cache, no shell command to construct. It accepts `profile`/`refresh`/`format`.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
151
172
|
### History subcommand
|
|
152
173
|
|
|
153
174
|
If the first argument is `history`:
|
|
@@ -370,6 +391,8 @@ When it does apply, run up to 3 rounds:
|
|
|
370
391
|
ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
|
|
371
392
|
```
|
|
372
393
|
`--expect-mtime` is what keeps this safe: if the file changed since step 1 (the user hand-edited it while you were drafting), the patch silently no-ops and prints "not found or already changed" — the user's own edit always wins, never gets clobbered by a stale background draft.
|
|
394
|
+
|
|
395
|
+
If this harness has TicketLens's MCP server configured (a tool named `recall_update` — often shown as `mcp__ticketlens__recall_update` — visible in your tool list), prefer it over the bash form: same license gate, same structural/secret-scan checks, same optimistic-concurrency behavior via `expectMtime` — just no shell command or stdin piping to construct. It accepts `id`/`body`/`ticket`/`expectMtime`.
|
|
373
396
|
5. Repeat from step 1 (re-capture mtime/body fresh each round) up to 3 total rounds. If no round ever produces a fully-passing draft, patch in whichever round scored highest across all attempts, and let the "not found or already changed" message stand if that patch itself loses a late race — don't retry past round 3.
|
|
374
397
|
|
|
375
398
|
This never calls any external API or bills any tokens beyond the session you already have open — the generator and validator are subagents inside your own Claude Code session, not a TicketLens server call.
|
|
@@ -506,6 +529,9 @@ The same tier-gated check also runs as its own command — `ticketlens complianc
|
|
|
506
529
|
|
|
507
530
|
If this harness has TicketLens's MCP server configured (a tool named `compliance` — often shown as `mcp__ticketlens__compliance` — visible in your tool list), prefer it over the bash form: same tier gate (Free: 3 checks/month, Pro: unlimited), same report — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`, matching the standalone command's arguments above.
|
|
508
531
|
|
|
532
|
+
### Ledger export
|
|
533
|
+
`ticketlens ledger [--format=json|csv]` exports the same local, signed compliance ledger these checks write to — entirely local, no network call. `[Pro]`. If this harness has TicketLens's MCP server configured (a tool named `ledger` — often shown as `mcp__ticketlens__ledger` — visible in your tool list), prefer it over the bash form: same tier gate, same export, just no shell command to construct or stdout to parse. It accepts `format` (`json`/`csv`, defaults to `json`).
|
|
534
|
+
|
|
509
535
|
---
|
|
510
536
|
|
|
511
537
|
## Advanced Options
|
|
@@ -488,10 +488,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
|
|
491
|
-
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, and
|
|
492
|
-
// paths all use this now (each has an MCP tool). `
|
|
493
|
-
//
|
|
494
|
-
// gets one.
|
|
491
|
+
// Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, standup, and
|
|
492
|
+
// ledger paths all use this now (each has an MCP tool). `install-hooks` is CLI-only
|
|
493
|
+
// and never gets one.
|
|
495
494
|
const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
|
|
496
495
|
// Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
|
|
497
496
|
// so an injected print/printErr survives the retry instead of silently reverting to
|
|
@@ -610,7 +609,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
610
609
|
const { isLicensed: isLic, showUpgradePrompt: showUpgrade } = await import('./lib/license.mjs');
|
|
611
610
|
const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
|
|
612
611
|
if (!isLic('pro', resolvedConfigDir)) {
|
|
613
|
-
showUpgrade('pro', 'ledger', { stream:
|
|
612
|
+
showUpgrade('pro', 'ledger', { stream: errStream });
|
|
614
613
|
process.exitCode = 1;
|
|
615
614
|
return;
|
|
616
615
|
}
|
|
@@ -621,7 +620,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
621
620
|
printFn(result + '\n');
|
|
622
621
|
} else {
|
|
623
622
|
printFn(JSON.stringify(result, null, 2) + '\n');
|
|
624
|
-
|
|
623
|
+
printErrFn(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
|
|
625
624
|
}
|
|
626
625
|
return;
|
|
627
626
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Centralised here to avoid triplicating the regex and warning logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export const DEFAULT_API_BASE = '
|
|
6
|
+
export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
|
|
7
7
|
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
@@ -118,6 +118,10 @@ export function parseCommand(args) {
|
|
|
118
118
|
return { command: 'stats', args: args.slice(1) };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
if (first === 'issue-types') {
|
|
122
|
+
return { command: 'issue-types', args: args.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
if (first === 'doctor') {
|
|
122
126
|
return { command: 'doctor', args: args.slice(1) };
|
|
123
127
|
}
|
|
@@ -62,6 +62,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
62
62
|
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
63
63
|
` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
|
|
64
64
|
` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...] [--attach=...]')} Create a new ticket ${s.dim('[Pro]')}`,
|
|
65
|
+
` ${s.brand('ticketlens')} issue-types ${s.dim('[--profile=NAME] [--refresh]')} Pre-fetch valid Jira issue types ${s.dim('[Jira only]')}`,
|
|
65
66
|
'',
|
|
66
67
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
67
68
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -646,11 +647,14 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
646
647
|
'',
|
|
647
648
|
` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
|
|
648
649
|
` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('stats')}, ${s.cyan('history')},`,
|
|
649
|
-
` ${s.cyan('collisions')}, ${s.cyan('doctor')}, ${s.cyan('
|
|
650
|
-
` ${s.cyan('
|
|
651
|
-
`
|
|
652
|
-
`
|
|
653
|
-
` ${s.cyan('
|
|
650
|
+
` ${s.cyan('collisions')}, ${s.cyan('ledger')}, ${s.cyan('doctor')}, ${s.cyan('issue_types')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
|
|
651
|
+
` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
|
|
652
|
+
` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
|
|
653
|
+
` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
|
|
654
|
+
` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
|
|
655
|
+
` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('issue-types')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
|
|
656
|
+
` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
|
|
657
|
+
` ${s.cyan('update')}/${s.cyan('create')} above.`,
|
|
654
658
|
` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
|
|
655
659
|
` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
|
|
656
660
|
` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
|
|
@@ -658,14 +662,22 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
658
662
|
` requires Pro as a plain license check, not a draw on that same counter;`,
|
|
659
663
|
` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
|
|
660
664
|
` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
|
|
661
|
-
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license
|
|
662
|
-
`
|
|
665
|
+
` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license;`,
|
|
666
|
+
` ${s.cyan('issue_types')} is Free and Jira-only — Linear/GitHub profiles get a clear`,
|
|
667
|
+
` "not available" instead of an empty result — every other tool needs Pro.`,
|
|
668
|
+
` ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
663
669
|
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
|
|
664
670
|
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
665
671
|
` duplicate — different semantics than Jira/Linear's relationship-only add;`,
|
|
666
672
|
` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
667
673
|
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
668
674
|
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
675
|
+
` ${s.cyan('ledger')} exports the local, signed compliance audit trail — entirely local, no`,
|
|
676
|
+
` network call. ${s.cyan('recall_update')} overwrites an existing Recall note's body — internal`,
|
|
677
|
+
` plumbing for the note quality loop, not typically called directly.`,
|
|
678
|
+
` ${s.cyan('recall_delete')} is destructive and local-vault-only — requires \`confirm: true\`,`,
|
|
679
|
+
` alongside \`id\`, to actually execute; there is no interactive y/N prompt under`,
|
|
680
|
+
` MCP, so omitting it always fails rather than silently blocking.`,
|
|
669
681
|
` Long-running — exits when the client closes stdin.`,
|
|
670
682
|
'',
|
|
671
683
|
` ${s.dim('If a tool call rejects a parameter you expect right after upgrading ticketlens,')}`,
|
|
@@ -1305,6 +1317,38 @@ export function printStatsHelp({ stream = process.stdout } = {}) {
|
|
|
1305
1317
|
stream.write(lines.join('\n') + '\n');
|
|
1306
1318
|
}
|
|
1307
1319
|
|
|
1320
|
+
export function printIssueTypesHelp({ stream = process.stdout } = {}) {
|
|
1321
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
1322
|
+
const lines = [
|
|
1323
|
+
'',
|
|
1324
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--refresh] [--format=plain|json]')}`,
|
|
1325
|
+
'',
|
|
1326
|
+
` Pre-fetch and cache a profile's real creatable projects and their valid`,
|
|
1327
|
+
` Jira issue types, ahead of a ${s.brand('ticketlens create')} attempt — a ready lookup`,
|
|
1328
|
+
` instead of only learning them from a failed create's error message.`,
|
|
1329
|
+
` Jira only — Linear has no per-project issue-type concept and GitHub has`,
|
|
1330
|
+
` neither, so both report a clear "not available" instead of an empty result.`,
|
|
1331
|
+
` Shares its cache with ${s.brand('ticketlens create')}'s own failure-message enrichment`,
|
|
1332
|
+
` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}, 24h TTL).`,
|
|
1333
|
+
'',
|
|
1334
|
+
` ${s.bold('OPTIONS')}`,
|
|
1335
|
+
'',
|
|
1336
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile`,
|
|
1337
|
+
` ${s.brand('--refresh')} Force a live fetch even if a complete cache exists`,
|
|
1338
|
+
` ${s.brand('--format')}=${s.dim('plain')} Human-readable table ${s.dim('(default)')}`,
|
|
1339
|
+
` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
|
|
1340
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1341
|
+
'',
|
|
1342
|
+
` ${s.bold('EXAMPLES')}`,
|
|
1343
|
+
'',
|
|
1344
|
+
` ${s.dim('$')} ticketlens issue-types`,
|
|
1345
|
+
` ${s.dim('$')} ticketlens issue-types --profile=myteam --refresh`,
|
|
1346
|
+
` ${s.dim('$')} ticketlens issue-types --format=json | jq .`,
|
|
1347
|
+
'',
|
|
1348
|
+
];
|
|
1349
|
+
stream.write(lines.join('\n') + '\n');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1308
1352
|
export function printStandupHelp({ stream = process.stdout } = {}) {
|
|
1309
1353
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
1310
1354
|
const lines = [
|
|
@@ -22,12 +22,13 @@
|
|
|
22
22
|
import readline from 'node:readline';
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runDoctor } from './doctor-command.mjs';
|
|
25
|
-
import { runNoteAdd } from './note-command.mjs';
|
|
25
|
+
import { runNoteAdd, runNotePatch, runNoteDelete } from './note-command.mjs';
|
|
26
26
|
import { runRecall } from './recall-command.mjs';
|
|
27
27
|
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
28
28
|
import { run as runFetchTicket } from '../fetch-ticket.mjs';
|
|
29
29
|
import { run as runTriage } from '../fetch-my-tickets.mjs';
|
|
30
30
|
import { runStats } from './run-stats.mjs';
|
|
31
|
+
import { runIssueTypes } from './run-issue-types.mjs';
|
|
31
32
|
import { runHistory } from './run-history.mjs';
|
|
32
33
|
import { runCollisions } from './run-collisions.mjs';
|
|
33
34
|
import { TOOLS } from './mcp-tool-schemas.mjs';
|
|
@@ -239,6 +240,25 @@ async function callPr(args, deps) {
|
|
|
239
240
|
return callFetchTicketRun(buildPrArgs, args, deps, 'pr failed');
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
/**
|
|
244
|
+
* `ledger` is a subcommand of the same fetch-ticket.mjs `run()` that
|
|
245
|
+
* `callFetch`/`callCompliance`/`callPr` already wrap — reuses `runFetchTicketFn`,
|
|
246
|
+
* no new dependency. Unlike those, it has no `ticket`/`profile` argument — the
|
|
247
|
+
* ledger is local and config-dir scoped, not per-ticket. Its two direct
|
|
248
|
+
* `process.stderr` writes (the license-gate upgrade prompt and the
|
|
249
|
+
* verify-signature note printed alongside a successful json export) were
|
|
250
|
+
* threaded through opts.printErr as part of adding this tool.
|
|
251
|
+
*/
|
|
252
|
+
function buildLedgerArgs({ format }) {
|
|
253
|
+
const args = ['ledger'];
|
|
254
|
+
if (format) args.push(`--format=${format}`);
|
|
255
|
+
return args;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function callLedger(args, deps) {
|
|
259
|
+
return callFetchTicketRun(buildLedgerArgs, args, deps, 'ledger export failed');
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
function buildDoctorArgs({ fix, profile }) {
|
|
243
263
|
const args = ['--format=json'];
|
|
244
264
|
if (fix === true) args.push('--fix');
|
|
@@ -289,6 +309,18 @@ async function callStats(args, { configDir, runStatsFn }) {
|
|
|
289
309
|
return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
|
|
290
310
|
}
|
|
291
311
|
|
|
312
|
+
function buildIssueTypesArgs({ profile, refresh, format }) {
|
|
313
|
+
const args = [];
|
|
314
|
+
if (profile) args.push(`--profile=${profile}`);
|
|
315
|
+
if (refresh === true) args.push('--refresh');
|
|
316
|
+
if (format) args.push(`--format=${format}`);
|
|
317
|
+
return args;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function callIssueTypes(args, { configDir, runIssueTypesFn }) {
|
|
321
|
+
return callPrintWarnRun(buildIssueTypesArgs, args, { configDir, runFn: runIssueTypesFn }, 'issue-types failed');
|
|
322
|
+
}
|
|
323
|
+
|
|
292
324
|
function buildHistoryArgs({ ticket }) {
|
|
293
325
|
return [ticket];
|
|
294
326
|
}
|
|
@@ -355,6 +387,68 @@ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
|
|
|
355
387
|
return written ? { content } : { isError: true, content };
|
|
356
388
|
}
|
|
357
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Builds runNotePatch's cmdArgs array — same single-opaque-element reasoning
|
|
392
|
+
* as buildNoteAddArgs above. `expectMtime` is optimistic-concurrency: a
|
|
393
|
+
* caller that fetched a note via recall_search and wants to refine it
|
|
394
|
+
* without racing a concurrent edit passes back the mtime it observed.
|
|
395
|
+
*/
|
|
396
|
+
function buildNotePatchArgs({ id, ticket, expectMtime }) {
|
|
397
|
+
const args = [`--id=${id}`];
|
|
398
|
+
if (ticket) args.push(`--ticket=${ticket}`);
|
|
399
|
+
if (expectMtime !== undefined) args.push(`--expect-mtime=${expectMtime}`);
|
|
400
|
+
return args;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function callRecallUpdate(args, { configDir, runNotePatchFn }) {
|
|
404
|
+
if (!args.id) {
|
|
405
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
|
|
406
|
+
}
|
|
407
|
+
if (!args.body) {
|
|
408
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
|
|
409
|
+
}
|
|
410
|
+
const capture = capturingStream();
|
|
411
|
+
const { patched } = await runNotePatchFn(buildNotePatchArgs(args), {
|
|
412
|
+
configDir,
|
|
413
|
+
stream: capture,
|
|
414
|
+
readStdin: async () => args.body,
|
|
415
|
+
});
|
|
416
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
417
|
+
return patched ? { content } : { isError: true, content };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Builds runNoteDelete's cmdArgs array — same single-opaque-element reasoning
|
|
422
|
+
* as buildNoteAddArgs above. Always passes `--yes`: runNoteDelete's own
|
|
423
|
+
* confirmDestructive gate refuses outright in non-interactive mode (no real
|
|
424
|
+
* TTY exists under the MCP transport to prompt against), so without it every
|
|
425
|
+
* call would fail. The caller's `confirm: true` is enforced in callRecallDelete
|
|
426
|
+
* below instead, before runNoteDeleteFn is ever reached — same nudge-and-audit
|
|
427
|
+
* -trail spirit as ticket_transition/ticket_link, but deliberately a different
|
|
428
|
+
* code path: those two defer the refusal to the wrapped CLI function, which
|
|
429
|
+
* here would surface as that generic non-interactive error instead of a
|
|
430
|
+
* dedicated one naming `confirm`.
|
|
431
|
+
*/
|
|
432
|
+
function buildNoteDeleteArgs({ id, ticket }) {
|
|
433
|
+
const args = [`--id=${id}`];
|
|
434
|
+
if (ticket) args.push(`--ticket=${ticket}`);
|
|
435
|
+
args.push('--yes');
|
|
436
|
+
return args;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function callRecallDelete(args, { configDir, runNoteDeleteFn }) {
|
|
440
|
+
if (!args.id) {
|
|
441
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
|
|
442
|
+
}
|
|
443
|
+
if (args.confirm !== true) {
|
|
444
|
+
return { isError: true, content: [{ type: 'text', text: 'Deletion requires confirm: true — this cannot be restored.' }] };
|
|
445
|
+
}
|
|
446
|
+
const capture = capturingStream();
|
|
447
|
+
const { deleted } = await runNoteDeleteFn(buildNoteDeleteArgs(args), { configDir, stream: capture });
|
|
448
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
449
|
+
return deleted ? { content } : { isError: true, content };
|
|
450
|
+
}
|
|
451
|
+
|
|
358
452
|
async function callRecallSearch(args, { configDir, runRecallFn }) {
|
|
359
453
|
const capture = capturingStream();
|
|
360
454
|
const { ok } = await runRecallFn([args.query ?? ''], {
|
|
@@ -532,11 +626,15 @@ async function handleToolsCall(params, deps) {
|
|
|
532
626
|
if (name === 'review') return callReview(args, deps);
|
|
533
627
|
if (name === 'standup') return callStandup(args, deps);
|
|
534
628
|
if (name === 'pr') return callPr(args, deps);
|
|
629
|
+
if (name === 'ledger') return callLedger(args, deps);
|
|
535
630
|
if (name === 'doctor') return callDoctor(args, deps);
|
|
536
631
|
if (name === 'stats') return callStats(args, deps);
|
|
632
|
+
if (name === 'issue_types') return callIssueTypes(args, deps);
|
|
537
633
|
if (name === 'history') return callHistory(args, deps);
|
|
538
634
|
if (name === 'collisions') return callCollisions(args, deps);
|
|
539
635
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
636
|
+
if (name === 'recall_update') return callRecallUpdate(args, deps);
|
|
637
|
+
if (name === 'recall_delete') return callRecallDelete(args, deps);
|
|
540
638
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
541
639
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
542
640
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
@@ -548,7 +646,7 @@ async function handleToolsCall(params, deps) {
|
|
|
548
646
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
549
647
|
}
|
|
550
648
|
|
|
551
|
-
async function handleMessage(raw,
|
|
649
|
+
async function handleMessage(raw, deps) {
|
|
552
650
|
let msg;
|
|
553
651
|
try {
|
|
554
652
|
msg = JSON.parse(raw);
|
|
@@ -578,7 +676,7 @@ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, ru
|
|
|
578
676
|
|
|
579
677
|
if (method === 'tools/call') {
|
|
580
678
|
try {
|
|
581
|
-
const result = await handleToolsCall(params,
|
|
679
|
+
const result = await handleToolsCall(params, deps);
|
|
582
680
|
return jsonRpcResult(id, result);
|
|
583
681
|
} catch (err) {
|
|
584
682
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -603,9 +701,12 @@ export function runMcpServer({
|
|
|
603
701
|
runTriageFn = runTriage,
|
|
604
702
|
runDoctorFn = runDoctor,
|
|
605
703
|
runStatsFn = runStats,
|
|
704
|
+
runIssueTypesFn = runIssueTypes,
|
|
606
705
|
runHistoryFn = runHistory,
|
|
607
706
|
runCollisionsFn = runCollisions,
|
|
608
707
|
runNoteAddFn = runNoteAdd,
|
|
708
|
+
runNotePatchFn = runNotePatch,
|
|
709
|
+
runNoteDeleteFn = runNoteDelete,
|
|
609
710
|
runRecallFn = runRecall,
|
|
610
711
|
runTicketCommentFn = runTicketComment,
|
|
611
712
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
@@ -624,6 +725,11 @@ export function runMcpServer({
|
|
|
624
725
|
stdin.on('error', () => {});
|
|
625
726
|
stdout.on('error', () => {});
|
|
626
727
|
|
|
728
|
+
// Assembled once and passed straight through handleMessage to handleToolsCall,
|
|
729
|
+
// which is the only place the individual functions are read — so a new tool
|
|
730
|
+
// needs its dependency named here and in the parameter list above, nowhere else.
|
|
731
|
+
const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runIssueTypesFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
|
|
732
|
+
|
|
627
733
|
const rl = readline.createInterface({ input: stdin, terminal: false });
|
|
628
734
|
let queue = Promise.resolve();
|
|
629
735
|
|
|
@@ -635,7 +741,7 @@ export function runMcpServer({
|
|
|
635
741
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
636
742
|
// server would hang on shutdown instead of exiting.
|
|
637
743
|
queue = queue.then(async () => {
|
|
638
|
-
const response = await handleMessage(line,
|
|
744
|
+
const response = await handleMessage(line, deps);
|
|
639
745
|
if (response) stdout.write(response);
|
|
640
746
|
}).catch(() => {});
|
|
641
747
|
});
|
|
@@ -88,6 +88,16 @@ export const TOOLS = [
|
|
|
88
88
|
required: ['ticket'],
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'ledger',
|
|
93
|
+
description: 'Export the local compliance ledger — a signed, tamper-evident record of every compliance check run (ticket key, commit SHA, author, timestamp, coverage %). Read-only, entirely local — no network call. Verifiable offline: the json format includes an HMAC-SHA256 signature over {records, exportedAt}, keyed at a local ledger-key file. Requires a TicketLens Pro license.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
format: { type: 'string', enum: ['json', 'csv'], description: 'Output shape: "json" (default) includes the HMAC signature; "csv" is a flat export with no signature.' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
91
101
|
{
|
|
92
102
|
name: 'stats',
|
|
93
103
|
description: 'Show response-time and triage-cadence metrics from local triage history — average/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. Free tier: 7-day lookback max; TicketLens Pro extends it to 30 days.',
|
|
@@ -100,6 +110,18 @@ export const TOOLS = [
|
|
|
100
110
|
},
|
|
101
111
|
},
|
|
102
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: 'issue_types',
|
|
115
|
+
description: 'Pre-fetch and cache a profile\'s real creatable projects and their valid Jira issue types, ahead of a `ticket_create` attempt — a ready lookup instead of only learning them from a failed create\'s error message. Jira only; Linear/GitHub report "not available" (Linear has no per-project issue-type concept, GitHub has neither). Shares its cache with ticket_create\'s own failure-message enrichment (24h TTL) — a create failure right after this call is a pure cache hit, no extra network round-trip.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
|
|
120
|
+
refresh: { type: 'boolean', description: 'Force a live fetch even if a complete cache already exists for this profile.' },
|
|
121
|
+
format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
103
125
|
{
|
|
104
126
|
name: 'history',
|
|
105
127
|
description: 'Show a ticket\'s urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call. Requires a TicketLens Pro license.',
|
|
@@ -147,6 +169,33 @@ export const TOOLS = [
|
|
|
147
169
|
required: ['title', 'body'],
|
|
148
170
|
},
|
|
149
171
|
},
|
|
172
|
+
{
|
|
173
|
+
name: 'recall_update',
|
|
174
|
+
description: 'Overwrite an existing Recall note\'s body with a better draft — internal plumbing for the jtb skill\'s note quality loop, not typically called directly. The new body gets the same structural and secret-scan checks a user-typed body gets. Local vault only. Requires a TicketLens Pro license.',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
id: { type: 'string', description: 'Note id to patch, as printed by recall_add or recall_search.' },
|
|
179
|
+
body: { type: 'string', description: 'The replacement note body.' },
|
|
180
|
+
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
181
|
+
expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
|
|
182
|
+
},
|
|
183
|
+
required: ['id', 'body'],
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'recall_delete',
|
|
188
|
+
description: 'Delete a Recall note from the local vault. Irreversible locally, and destructive — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal exists to prompt against), so omitting confirm always fails rather than silently blocking. Local vault only — if this note was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall). Requires a TicketLens Pro license.',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
id: { type: 'string', description: 'Note id to delete, as printed by recall_add or recall_search.' },
|
|
193
|
+
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
194
|
+
confirm: { type: 'boolean', description: 'Must be true to actually execute the deletion — a nudge and audit trail, not just a formality. Omitted or false always fails.' },
|
|
195
|
+
},
|
|
196
|
+
required: ['id'],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
150
199
|
{
|
|
151
200
|
name: 'recall_search',
|
|
152
201
|
description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
|
|
@@ -131,7 +131,11 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
const ticketKeys = ticketKey ? [ticketKey] : [];
|
|
134
|
-
|
|
134
|
+
// Captured once and threaded into writeNoteFn's now() override so the local
|
|
135
|
+
// vault file's `created` and the pushed payload's captured_at can never skew
|
|
136
|
+
// by the (short but real) gap between the local write and the push below.
|
|
137
|
+
const capturedAt = new Date();
|
|
138
|
+
const { id } = writeNoteFn({ title, ticketKeys, tags, author, body }, { configDir, now: () => capturedAt });
|
|
135
139
|
incrementDraftKeptFn(configDir);
|
|
136
140
|
const styled = !cmdArgs.includes('--plain') && stream.isTTY;
|
|
137
141
|
const s = createStyler({ forceColor: styled, noColor: !styled });
|
|
@@ -144,7 +148,7 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
144
148
|
// the backend wire contract, not the local vault's internal camelCase shape.
|
|
145
149
|
const profile = resolveProfileFn(ticketKey || null, { configDir, cwd: process.cwd() });
|
|
146
150
|
const recallTeamId = profile ? loadProfileRecallTeamIdFn(profile.name, configDir) : null;
|
|
147
|
-
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
|
|
151
|
+
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body, captured_at: capturedAt.toISOString() };
|
|
148
152
|
if (recallTeamId !== null) payload.group_id = recallTeamId;
|
|
149
153
|
const result = await pushNoteFn(payload, { cliToken, configDir, warn });
|
|
150
154
|
if (isRetryableFailureFn(result)) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG_DIR, timeAgo } from './config.mjs';
|
|
2
|
+
import { resolveConnection } from './profile-resolver.mjs';
|
|
3
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
4
|
+
import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
|
|
5
|
+
import { createStyler } from './ansi.mjs';
|
|
6
|
+
import { handleUnknownFlags } from './arg-validator.mjs';
|
|
7
|
+
import { printIssueTypesHelp } from './help.mjs';
|
|
8
|
+
import { formatTable } from './table-formatter.mjs';
|
|
9
|
+
|
|
10
|
+
// A cache hit must cover every project it lists — a cache seeded only by
|
|
11
|
+
// ticket-create-enrichment.mjs's reactive path (one project at a time, on a
|
|
12
|
+
// create failure) can have projects with no recorded issue types yet. Showing
|
|
13
|
+
// that as "the" answer would silently omit the rest of the profile's projects.
|
|
14
|
+
function isCacheComplete(cached) {
|
|
15
|
+
if (!cached || cached.projects.length === 0) return false;
|
|
16
|
+
return cached.projects.every(p => (cached.issueTypesByProject[p.key] || []).length > 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render({ print, format, projects, issueTypesByProject, fetchedAt, cached }) {
|
|
20
|
+
if (format === 'json') {
|
|
21
|
+
print(JSON.stringify({ projects, issueTypesByProject, fetchedAt, cached }, null, 2) + '\n');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const s = createStyler({ isTTY: process.stdout.isTTY });
|
|
26
|
+
|
|
27
|
+
if (projects.length === 0) {
|
|
28
|
+
print(`\n ${s.dim('No creatable projects found for this profile.')}\n\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rows = projects.map(p => {
|
|
33
|
+
const types = issueTypesByProject[p.key] || [];
|
|
34
|
+
return [p.key, types.length ? types.map(t => t.name).join(', ') : s.dim('(none)')];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
print('\n');
|
|
38
|
+
print(formatTable(['Project', 'Issue Types'], rows) + '\n');
|
|
39
|
+
print(`\n ${s.dim(cached
|
|
40
|
+
? `Cached ${timeAgo(fetchedAt)} — pass --refresh to force a live fetch.`
|
|
41
|
+
: 'Fetched live and cached for 24h.')}\n\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `ticketlens issue-types` — proactive counterpart to
|
|
46
|
+
* ticket-create-enrichment.mjs's reactive cache-refresh: fetches every
|
|
47
|
+
* creatable project and its Jira issue types ahead of a `ticketlens create`
|
|
48
|
+
* attempt instead of only learning them from a failed create's error.
|
|
49
|
+
* Writes through the same ticket-metadata-cache.mjs file/TTL that enrichment
|
|
50
|
+
* reads, so a create failure right after this command is a pure cache hit.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} args
|
|
53
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function runIssueTypes(args = [], opts = {}) {
|
|
56
|
+
const print = opts.print ?? ((s) => process.stdout.write(s));
|
|
57
|
+
const warn = opts.warn ?? ((s) => process.stderr.write(s));
|
|
58
|
+
const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
|
|
59
|
+
const resolveConnectionFn = opts.resolveConnectionFn ?? resolveConnection;
|
|
60
|
+
const resolveAdapterFn = opts.resolveAdapterFn ?? resolveAdapter;
|
|
61
|
+
const readMetadataCacheFn = opts.readMetadataCacheFn ?? readMetadataCache;
|
|
62
|
+
const writeMetadataCacheFn = opts.writeMetadataCacheFn ?? writeMetadataCache;
|
|
63
|
+
|
|
64
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
65
|
+
printIssueTypesHelp();
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validated = await handleUnknownFlags(
|
|
70
|
+
args,
|
|
71
|
+
['--help', '-h', '--profile=', '--refresh', '--format='],
|
|
72
|
+
{ hints: [] },
|
|
73
|
+
);
|
|
74
|
+
if (validated === null) { process.exitCode = 1; return { ok: false }; }
|
|
75
|
+
|
|
76
|
+
const profileArg = args.find(a => a.startsWith('--profile='));
|
|
77
|
+
const formatArg = args.find(a => a.startsWith('--format='));
|
|
78
|
+
const forceRefresh = args.includes('--refresh');
|
|
79
|
+
|
|
80
|
+
const format = formatArg ? formatArg.split('=')[1] : 'plain';
|
|
81
|
+
if (format !== 'plain' && format !== 'json') {
|
|
82
|
+
warn(`Error: --format must be plain or json, got: ${format}\n`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return { ok: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
|
|
88
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
89
|
+
const conn = resolveConnectionFn(null, {
|
|
90
|
+
configDir,
|
|
91
|
+
profileName: explicitProfile,
|
|
92
|
+
cwd,
|
|
93
|
+
onWarning: (msg) => warn(` ⚠ ${msg}\n`),
|
|
94
|
+
});
|
|
95
|
+
if (!conn.baseUrl) {
|
|
96
|
+
warn(' No connection configured. Run `ticketlens init` or pass --profile=NAME.\n');
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return { ok: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const adapter = resolveAdapterFn(conn);
|
|
102
|
+
if (adapter.type !== 'jira') {
|
|
103
|
+
warn(` Issue types are not available for this tracker (${adapter.type}) — only Jira exposes per-project issue types.\n`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const profileName = conn.profileName ?? 'default';
|
|
109
|
+
|
|
110
|
+
if (!forceRefresh) {
|
|
111
|
+
const cached = readMetadataCacheFn(profileName, configDir);
|
|
112
|
+
if (isCacheComplete(cached)) {
|
|
113
|
+
render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.fetchedAt, cached: true });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let projects, issueTypesByProject;
|
|
119
|
+
try {
|
|
120
|
+
projects = await adapter.listCreatableProjects();
|
|
121
|
+
issueTypesByProject = Object.create(null);
|
|
122
|
+
for (const p of projects) {
|
|
123
|
+
issueTypesByProject[p.key] = await adapter.listIssueTypes(p.key);
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
warn(` Could not fetch issue types: ${err.message}\n`);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return { ok: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
writeMetadataCacheFn(profileName, { projects, issueTypesByProject }, configDir);
|
|
132
|
+
render({ print, format, projects, issueTypesByProject, fetchedAt: new Date().toISOString(), cached: false });
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|