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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.34",
3
+ "version": "0.38.36",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.38.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 standup
492
- // paths all use this now (each has an MCP tool). `ledger` above still writes directly
493
- // to the real process.stderr (no MCP tool yet); `install-hooks` is CLI-only and never
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: process.stderr });
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
- process.stderr.write(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
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 = 'https://api.ticketlens.app';
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('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')},`,
650
- ` ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
651
- ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
652
- ` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/`,
653
- ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above.`,
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 — every`,
662
- ` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
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, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
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, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
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, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
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
- const { id } = writeNoteFn({ title, ticketKeys, tags, author, body }, { configDir });
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
+ }