ticketlens 0.38.35 → 0.38.37

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
@@ -7,7 +7,7 @@
7
7
  <img src="https://img.shields.io/npm/dm/ticketlens?style=flat-square&color=06b6d4&label=downloads" />
8
8
  <img src="https://github.com/ralphmoran/ticket-lens/actions/workflows/test.yml/badge.svg?style=flat-square" />
9
9
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
10
- <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square" />
10
+ <img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen?style=flat-square" />
11
11
  </div>
12
12
 
13
13
  </div>
@@ -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)
@@ -99,7 +100,7 @@ npx ticketlens CNV1-2
99
100
 
100
101
  Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
101
102
 
102
- **Prerequisites:** Node.js >=20
103
+ **Prerequisites:** Node.js >=22.6
103
104
 
104
105
  ---
105
106
 
@@ -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`, `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`); `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
+ **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.35",
3
+ "version": "0.38.37",
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": {
@@ -40,6 +40,6 @@
40
40
  },
41
41
  "homepage": "https://github.com/ralphmoran/ticket-lens",
42
42
  "engines": {
43
- "node": ">=20.0.0"
43
+ "node": ">=22.6.0"
44
44
  }
45
45
  }
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.39.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
@@ -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`:
@@ -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 = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
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,12 +647,12 @@ 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('ledger')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
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')},`,
650
651
  ` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
651
652
  ` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
652
653
  ` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
653
654
  ` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
654
- ` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
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')}/`,
655
656
  ` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
656
657
  ` ${s.cyan('update')}/${s.cyan('create')} above.`,
657
658
  ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
@@ -661,8 +662,10 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
661
662
  ` requires Pro as a plain license check, not a draw on that same counter;`,
662
663
  ` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
663
664
  ` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
664
- ` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license — every`,
665
- ` 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`,
666
669
  ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
667
670
  ` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
668
671
  ` duplicate — different semantics than Jira/Linear's relationship-only add;`,
@@ -1314,6 +1317,38 @@ export function printStatsHelp({ stream = process.stdout } = {}) {
1314
1317
  stream.write(lines.join('\n') + '\n');
1315
1318
  }
1316
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
+
1317
1352
  export function printStandupHelp({ stream = process.stdout } = {}) {
1318
1353
  const s = createStyler({ isTTY: stream.isTTY });
1319
1354
  const lines = [
@@ -28,6 +28,7 @@ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTick
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';
@@ -308,6 +309,18 @@ async function callStats(args, { configDir, runStatsFn }) {
308
309
  return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
309
310
  }
310
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
+
311
324
  function buildHistoryArgs({ ticket }) {
312
325
  return [ticket];
313
326
  }
@@ -616,6 +629,7 @@ async function handleToolsCall(params, deps) {
616
629
  if (name === 'ledger') return callLedger(args, deps);
617
630
  if (name === 'doctor') return callDoctor(args, deps);
618
631
  if (name === 'stats') return callStats(args, deps);
632
+ if (name === 'issue_types') return callIssueTypes(args, deps);
619
633
  if (name === 'history') return callHistory(args, deps);
620
634
  if (name === 'collisions') return callCollisions(args, deps);
621
635
  if (name === 'recall_add') return callRecallAdd(args, deps);
@@ -687,6 +701,7 @@ export function runMcpServer({
687
701
  runTriageFn = runTriage,
688
702
  runDoctorFn = runDoctor,
689
703
  runStatsFn = runStats,
704
+ runIssueTypesFn = runIssueTypes,
690
705
  runHistoryFn = runHistory,
691
706
  runCollisionsFn = runCollisions,
692
707
  runNoteAddFn = runNoteAdd,
@@ -713,7 +728,7 @@ export function runMcpServer({
713
728
  // Assembled once and passed straight through handleMessage to handleToolsCall,
714
729
  // which is the only place the individual functions are read — so a new tool
715
730
  // needs its dependency named here and in the parameter list above, nowhere else.
716
- const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
731
+ const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runIssueTypesFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
717
732
 
718
733
  const rl = readline.createInterface({ input: stdin, terminal: false });
719
734
  let queue = Promise.resolve();
@@ -110,6 +110,18 @@ export const TOOLS = [
110
110
  },
111
111
  },
112
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
+ },
113
125
  {
114
126
  name: 'history',
115
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.',
@@ -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
+ }
@@ -32,6 +32,30 @@ const MAX_JOINED_CHUNKS = 4;
32
32
  const MAX_COMPOUND_SEGMENT_LENGTH = 15;
33
33
  const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
34
34
 
35
+ // A candidate containing an unstripped '(', ')', '[', or ']' reads as code
36
+ // syntax (an array/list literal element, or a function-call argument — e.g.
37
+ // "['compliance', '--help']" or "matches(x)") rather than a secret fragment.
38
+ // Consulted ONLY from looksLikeCodeSyntax below, which downgrades a matching
39
+ // high-entropy candidate to a warning — deliberately NOT wired into
40
+ // isLabelWord (backlog #14 residual, code review caught this on the first
41
+ // pass): making a bracket-bearing token a hard isLabelWord stop would end a
42
+ // joinedChunkRuns run there unconditionally, the same way GIT_REFERENCE_WORD_RE/
43
+ // isHyphenatedWordCompound/looksLikeFilenameReference already do — but unlike
44
+ // those, a bracket character is trivial for an attacker to insert anywhere
45
+ // ("a(b"), and doing so would fully and SILENTLY stop a genuine fragmented
46
+ // secret split around it from ever being reassembled for the entropy check
47
+ // (confirmed live: a real 36-char secret split into two 18-char halves around
48
+ // a bare "a(b" separator went from rejected:true to a fully silent
49
+ // rejected:false/warnings:[] once isLabelWord treated brackets as a stop).
50
+ // The downgrade-only design below avoids that: the join still happens (a
51
+ // bracket-bearing token is ordinary, never a label word), so any joined
52
+ // candidate spanning real secret content still trips the entropy check —
53
+ // and since that joined candidate necessarily still contains the bracket
54
+ // character too, looksLikeCodeSyntax downgrades it to a WARNING rather than
55
+ // silently exempting it, unlike the fully-silent gap the hard-wall version
56
+ // would have reopened.
57
+ const CODE_SYNTAX_RE = /[()[\]]/;
58
+
35
59
  // U+200B (ZERO WIDTH SPACE) is added explicitly: despite the name, it does
36
60
  // NOT carry the Unicode White_Space property (General_Category=Cf, not Zs),
37
61
  // so it's excluded from JS's native \s (ECMA-262 WhiteSpace production) —
@@ -147,6 +171,29 @@ function looksLikeFilenameReference(strippedToken) {
147
171
  return FILENAME_REFERENCE_RE.test(strippedToken);
148
172
  }
149
173
 
174
+ /**
175
+ * True for a candidate — a raw token OR a joinedChunkRuns result — that
176
+ * itself contains an unstripped '(', ')', '[', or ']': code syntax (a
177
+ * function-call argument or array/list-literal element) rather than a secret
178
+ * fragment. Deliberately NOT wired into isLabelWord/joinedChunkRuns (see
179
+ * CODE_SYNTAX_RE's own comment for why that hard-wall approach reopened a
180
+ * silent reassembly bypass) — instead this only downgrades a candidate that
181
+ * ALREADY tripped looksRandom, same never-exempt treatment as
182
+ * looksLikeCodeFilename below. Covers both backlog #14 residual shapes: a
183
+ * token already 20+ chars on its own with no join needed (e.g.
184
+ * "matches(['compliance'," — whitespace-split with no space after '(' or
185
+ * '[', so it's one raw token from the very first split), and a joined run
186
+ * that only crosses the entropy threshold once several bracket-literal
187
+ * tokens glue together (e.g. "['compliance','--help','-h','debug']") — the
188
+ * bracket character survives into the joined string either way, so this
189
+ * still catches it. Fully exempting this shape would let a 20+ char secret
190
+ * dodge rejection just by wrapping it in a fake "f(" / "[" — see the
191
+ * security regression test alongside looksLikeCodeFilename's.
192
+ */
193
+ function looksLikeCodeSyntax(rawToken) {
194
+ return CODE_SYNTAX_RE.test(stripEdgePunctuation(rawToken));
195
+ }
196
+
150
197
  function shannonEntropy(token) {
151
198
  const counts = new Map();
152
199
  for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
@@ -396,12 +443,15 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
396
443
  // Downgrading to a warning — never silently dropping the signal — matches
397
444
  // how an email address is already handled below.
398
445
  const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
399
- if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
446
+ if (randomCandidates.some(token => !looksLikeCodeFilename(token) && !looksLikeCodeSyntax(token))) {
400
447
  reasons.push('Contains a long, random-looking string that could be a secret.');
401
448
  }
402
449
  if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
403
450
  warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
404
451
  }
452
+ if (randomCandidates.some(token => looksLikeCodeSyntax(token))) {
453
+ warnings.push('Contains a code-syntax-shaped token (brackets or parentheses) that also reads as high-entropy — double-check it is not a credential.');
454
+ }
405
455
 
406
456
  if (EMAIL_RE.test(combined)) {
407
457
  warnings.push('Contains an email address.');