ticketlens 0.38.54 → 0.38.56

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
@@ -462,7 +462,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
462
462
 
463
463
  **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).
464
464
 
465
- **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; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `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; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `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).
465
+ **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; `compliance` additionally accepts `consensus: true` (Pro) to replace the local deterministic matcher with a multi-agent AI review, run server-side against a "consensus" role configured at Console > Admin > AI Roles (2+ providers from the team's shared pool), and always implies `--yes` under MCP since there's no TTY for the cost-confirmation prompt; `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; accepts an optional `project` to skip the full scan and return just that one project's types (3-day cache, vs. 7-day for the full scan); 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; its `attachments` array appends new files to whatever the note already has, same as `recall_add`'s, never replacing existing ones. `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).
466
466
 
467
467
  `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.
468
468
 
@@ -508,7 +508,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
508
508
 
509
509
  `--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.
510
510
 
511
- **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.
511
+ **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 3 days (a targeted, single-project lookup — same bar `issue-types --project=KEY` uses) 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.
512
512
 
513
513
  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.
514
514
 
@@ -540,13 +540,14 @@ A one-line summary footer is also appended automatically to `ticketlens triage`
540
540
  ### Pre-fetch Issue Types
541
541
 
542
542
  ```bash
543
- ticketlens issue-types # Pre-fetch/cache the active profile's real projects + Jira issue types
543
+ ticketlens issue-types # Pre-fetch/cache every project this connection sees + Jira issue types
544
544
  ticketlens issue-types --profile=acme # Target a specific profile
545
+ ticketlens issue-types --project=PROJ # Only this project — skips the full scan, 3-day cache
545
546
  ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
546
547
  ticketlens issue-types --format=json # JSON output for scripting
547
548
  ```
548
549
 
549
- 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.
550
+ 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. `--project=KEY` skips the full project-list scan and returns just that one project's types — a targeted, "on purpose" lookup, so it trusts its cache entry for 3 days instead of the full scan's 7. Shares its cache with `create`'s enrichment (`~/.ticketlens/cache/PROFILE/ticket-metadata.json`), so a `create` failure right after this command is a pure cache hit, no extra network round-trip.
550
551
 
551
552
  ---
552
553
 
@@ -844,8 +845,9 @@ ticketlens stats --days=14 # Extend lookback window (Pro, max
844
845
  ticketlens stats --format=json # JSON output for scripting
845
846
 
846
847
  # ── Issue Types ───────────────────────────────────────────────────────────────
847
- ticketlens issue-types # Pre-fetch/cache the active profile's projects + Jira issue types
848
+ ticketlens issue-types # Pre-fetch/cache every project this connection sees + Jira issue types
848
849
  ticketlens issue-types --profile=acme # Target a specific profile
850
+ ticketlens issue-types --project=PROJ # Only this project — skips the full scan, 3-day cache
849
851
  ticketlens issue-types --refresh # Force a live fetch, bypassing the cache
850
852
  ticketlens issue-types --format=json # JSON output for scripting
851
853
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.54",
3
+ "version": "0.38.56",
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.43.2 -->
1
+ <!-- jtb-skill-version: 0.43.3 -->
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,7 +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
45
+ /jtb issue-types # pre-fetch/cache every project's valid Jira issue types
46
+ /jtb issue-types --project=PROJ # only this project — skips the full scan, 3-day cache
46
47
  /jtb issue-types --refresh # force a live fetch, bypassing the cache
47
48
  /jtb collisions # show branch collisions with teammates (Team)
48
49
  /jtb collisions --json # machine-readable output
@@ -160,13 +161,13 @@ Run:
160
161
  ticketlens issue-types $EXTRA_ARGS
161
162
  ```
162
163
 
163
- Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --refresh --format=json`).
164
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme --project=PROJ --refresh --format=json`).
164
165
 
165
- 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.
166
+ 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); `--project=KEY` (only this project — skips the full project scan, 3-day cache TTL since a targeted lookup is "on purpose"; without it, every project this connection can see, 7-day TTL); `--refresh` (force a live fetch even if a fresh cache entry 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`) — a create failure right after this command is a pure cache hit.
166
167
 
167
168
  Display the script's stdout directly. No plan mode. Stop here.
168
169
 
169
- 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`.
170
+ 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`/`project`/`refresh`/`format`.
170
171
 
171
172
  ---
172
173
 
@@ -33,6 +33,7 @@ import { buildCaptureExcerpt, privateTmpDir } from './recall-nudge-lib.mjs';
33
33
  import { autoCapture } from '../scripts/lib/summarizer.mjs';
34
34
  import { runNoteAdd } from '../scripts/lib/note-command.mjs';
35
35
  import { DEFAULT_CONFIG_DIR } from '../scripts/lib/config.mjs';
36
+ import { apiBase } from '../scripts/lib/api-utils.mjs';
36
37
 
37
38
  /** Buffers stream.write() calls instead of touching a real stream — same shape mcp-server.mjs's own capturingStream() uses, this process has no real stdout/stderr worth writing to. */
38
39
  function capturingStream() {
@@ -40,9 +41,12 @@ function capturingStream() {
40
41
  return { write(s) { parts.push(s); return true; }, get text() { return parts.join(''); } };
41
42
  }
42
43
 
44
+ // backlog #33: every outcome logs its resolved backend URL — "Unauthorized"/
45
+ // "not logged in" reports had no way to attribute which backend was hit
46
+ // (ngrok tunnel rotation and local-dev fallback both silently swap it).
43
47
  function logLine(message) {
44
48
  try {
45
- const line = `${new Date().toISOString()} ${message}\n`;
49
+ const line = `${new Date().toISOString()} ${message} [api=${apiBase()}]\n`;
46
50
  appendFileSync(join(privateTmpDir(), 'auto-capture.log'), line);
47
51
  } catch { /* best-effort — losing a debug log line is not worth failing over */ }
48
52
  }
@@ -85,7 +89,7 @@ export async function runAutoCapture({
85
89
  try {
86
90
  result = await autoCaptureFn({ excerpt, ticketKey, cliToken });
87
91
  } catch (err) {
88
- logLine(`error: ${err.message}`);
92
+ logLine(`error: ${err.message}${err.status ? ` (status=${err.status})` : ''}`);
89
93
  return { outcome: 'error', error: err.message };
90
94
  }
91
95
 
@@ -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,
@@ -62,7 +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
+ ` ${s.brand('ticketlens')} issue-types ${s.dim('[--profile=NAME] [--project=KEY] [--refresh]')} Pre-fetch valid Jira issue types ${s.dim('[Jira only]')}`,
66
66
  '',
67
67
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
68
68
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -1335,7 +1335,7 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
1335
1335
  const s = createStyler({ isTTY: stream.isTTY });
1336
1336
  const lines = [
1337
1337
  '',
1338
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--refresh] [--format=plain|json]')}`,
1338
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('issue-types')} ${s.dim('[--profile=NAME] [--project=KEY] [--refresh] [--format=plain|json]')}`,
1339
1339
  '',
1340
1340
  ` Pre-fetch and cache a profile's real creatable projects and their valid`,
1341
1341
  ` Jira issue types, ahead of a ${s.brand('ticketlens create')} attempt — a ready lookup`,
@@ -1343,12 +1343,14 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
1343
1343
  ` Jira only — Linear has no per-project issue-type concept and GitHub has`,
1344
1344
  ` neither, so both report a clear "not available" instead of an empty result.`,
1345
1345
  ` Shares its cache with ${s.brand('ticketlens create')}'s own failure-message enrichment`,
1346
- ` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}, 24h TTL).`,
1346
+ ` (${s.dim('~/.ticketlens/cache/PROFILE/ticket-metadata.json')}).`,
1347
1347
  '',
1348
1348
  ` ${s.bold('OPTIONS')}`,
1349
1349
  '',
1350
1350
  ` ${s.brand('--profile')}=${s.dim('NAME')} Use a specific tracker profile`,
1351
- ` ${s.brand('--refresh')} Force a live fetch even if a complete cache exists`,
1351
+ ` ${s.brand('--project')}=${s.dim('KEY')} Only this project — skips the full scan, 3-day cache TTL`,
1352
+ ` ${s.dim('(no --project: every project this connection sees, 7-day TTL)')}`,
1353
+ ` ${s.brand('--refresh')} Force a live fetch even if a fresh cache entry exists`,
1352
1354
  ` ${s.brand('--format')}=${s.dim('plain')} Human-readable table ${s.dim('(default)')}`,
1353
1355
  ` ${s.brand('--format')}=${s.dim('json')} JSON output for scripting/piping`,
1354
1356
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
@@ -1357,6 +1359,7 @@ export function printIssueTypesHelp({ stream = process.stdout } = {}) {
1357
1359
  '',
1358
1360
  ` ${s.dim('$')} ticketlens issue-types`,
1359
1361
  ` ${s.dim('$')} ticketlens issue-types --profile=myteam --refresh`,
1362
+ ` ${s.dim('$')} ticketlens issue-types --project=PROJ`,
1360
1363
  ` ${s.dim('$')} ticketlens issue-types --format=json | jq .`,
1361
1364
  '',
1362
1365
  ];
@@ -312,9 +312,10 @@ async function callStats(args, { configDir, runStatsFn }) {
312
312
  return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
313
313
  }
314
314
 
315
- function buildIssueTypesArgs({ profile, refresh, format }) {
315
+ function buildIssueTypesArgs({ profile, project, refresh, format }) {
316
316
  const args = [];
317
317
  if (profile) args.push(`--profile=${profile}`);
318
+ if (project) args.push(`--project=${project}`);
318
319
  if (refresh === true) args.push('--refresh');
319
320
  if (format) args.push(`--format=${format}`);
320
321
  return args;
@@ -113,12 +113,13 @@ export const TOOLS = [
113
113
  },
114
114
  {
115
115
  name: 'issue_types',
116
- 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
+ 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 — a create failure right after this call is a pure cache hit, no extra network round-trip. Without `project`, lists every project this connection can see (7-day cache); with `project`, skips the full scan and returns just that one project\'s issue types (3-day cache, since a targeted lookup is "on purpose" and expects fresher data).',
117
117
  inputSchema: {
118
118
  type: 'object',
119
119
  properties: {
120
120
  profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
121
- refresh: { type: 'boolean', description: 'Force a live fetch even if a complete cache already exists for this profile.' },
121
+ project: { type: 'string', description: 'Only fetch this project\'s issue types instead of scanning every project the connection can see. Skips the full project-list call; uses a shorter 3-day cache TTL.' },
122
+ refresh: { type: 'boolean', description: 'Force a live fetch even if a fresh cache entry already exists.' },
122
123
  format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
123
124
  },
124
125
  },
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_CONFIG_DIR, timeAgo } from './config.mjs';
2
2
  import { resolveConnection } from './profile-resolver.mjs';
3
3
  import { resolveAdapter } from './resolve-adapter.mjs';
4
- import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
4
+ import { readMetadataCache, writeMetadataCache, METADATA_TTL_MS, SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectIssueTypes } from './ticket-metadata-cache.mjs';
5
5
  import { createStyler } from './ansi.mjs';
6
6
  import { handleUnknownFlags } from './arg-validator.mjs';
7
7
  import { printIssueTypesHelp } from './help.mjs';
@@ -11,12 +11,23 @@ import { formatTable } from './table-formatter.mjs';
11
11
  // ticket-create-enrichment.mjs's reactive path (one project at a time, on a
12
12
  // create failure) can have projects with no recorded issue types yet. Showing
13
13
  // that as "the" answer would silently omit the rest of the profile's projects.
14
+ // Recency is checked per-project (not just the whole file's own GC marker) —
15
+ // a project's entry can be older than the file itself if only OTHER projects
16
+ // were refreshed since (e.g. by an intervening --project=KEY fetch). A cache
17
+ // written before issueTypesFetchedAt/projectsFetchedAt existed has neither
18
+ // field, so isFresh(undefined, ...) correctly treats it as stale — one live
19
+ // full scan repopulates both, then reuse resumes as normal.
14
20
  function isCacheComplete(cached) {
15
21
  if (!cached || cached.projects.length === 0) return false;
16
- return cached.projects.every(p => (cached.issueTypesByProject[p.key] || []).length > 0);
22
+ if (!isFresh(cached.projectsFetchedAt, METADATA_TTL_MS)) return false;
23
+ return cached.projects.every(p => {
24
+ const types = cached.issueTypesByProject[p.key];
25
+ if (!types || types.length === 0) return false;
26
+ return isFresh(cached.issueTypesFetchedAt?.[p.key], METADATA_TTL_MS);
27
+ });
17
28
  }
18
29
 
19
- function render({ print, format, projects, issueTypesByProject, fetchedAt, cached }) {
30
+ function render({ print, format, projects, issueTypesByProject, fetchedAt, cached, ttlLabel }) {
20
31
  if (format === 'json') {
21
32
  print(JSON.stringify({ projects, issueTypesByProject, fetchedAt, cached }, null, 2) + '\n');
22
33
  return;
@@ -38,7 +49,61 @@ function render({ print, format, projects, issueTypesByProject, fetchedAt, cache
38
49
  print(formatTable(['Project', 'Issue Types'], rows) + '\n');
39
50
  print(`\n ${s.dim(cached
40
51
  ? `Cached ${timeAgo(fetchedAt)} — pass --refresh to force a live fetch.`
41
- : 'Fetched live and cached for 24h.')}\n\n`);
52
+ : `Fetched live and cached for ${ttlLabel}.`)}\n\n`);
53
+ }
54
+
55
+ /**
56
+ * A targeted `--project=KEY` lookup — "on purpose," so it trusts a shorter
57
+ * SINGLE_PROJECT_TTL_MS (3d) than a full scan's 7d, and merge-writes just
58
+ * this one project into the shared cache instead of replacing it (same
59
+ * read-merge-write pattern ticket-create-enrichment.mjs already uses for
60
+ * its own single-project reactive path). No display name is available
61
+ * without the full project-list scan, so `name` is honestly null rather
62
+ * than guessed.
63
+ */
64
+ async function runSingleProject({ print, warn, format, projectKey, forceRefresh, adapter, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
65
+ const cached = !forceRefresh ? readMetadataCacheFn(profileName, configDir) : null;
66
+ const cachedTypes = cached?.issueTypesByProject?.[projectKey];
67
+
68
+ if (cachedTypes?.length && isFresh(cached.issueTypesFetchedAt?.[projectKey], SINGLE_PROJECT_TTL_MS)) {
69
+ render({
70
+ print, format,
71
+ projects: [{ key: projectKey, name: null }],
72
+ issueTypesByProject: { [projectKey]: cachedTypes },
73
+ fetchedAt: cached.issueTypesFetchedAt[projectKey],
74
+ cached: true,
75
+ });
76
+ return { ok: true };
77
+ }
78
+
79
+ let types;
80
+ try {
81
+ types = await adapter.listIssueTypes(projectKey);
82
+ } catch (err) {
83
+ warn(` Could not fetch issue types: ${err.message}\n`);
84
+ process.exitCode = 1;
85
+ return { ok: false };
86
+ }
87
+
88
+ const now = new Date().toISOString();
89
+ const { issueTypesByProject, issueTypesFetchedAt } = mergeProjectIssueTypes(cached, projectKey, types, now);
90
+
91
+ writeMetadataCacheFn(profileName, {
92
+ projects: cached?.projects ?? [],
93
+ issueTypesByProject,
94
+ issueTypesFetchedAt,
95
+ projectsFetchedAt: cached?.projectsFetchedAt ?? null,
96
+ }, configDir);
97
+
98
+ render({
99
+ print, format,
100
+ projects: [{ key: projectKey, name: null }],
101
+ issueTypesByProject: { [projectKey]: types },
102
+ fetchedAt: now,
103
+ cached: false,
104
+ ttlLabel: '3 days',
105
+ });
106
+ return { ok: true };
42
107
  }
43
108
 
44
109
  /**
@@ -48,6 +113,7 @@ function render({ print, format, projects, issueTypesByProject, fetchedAt, cache
48
113
  * attempt instead of only learning them from a failed create's error.
49
114
  * Writes through the same ticket-metadata-cache.mjs file/TTL that enrichment
50
115
  * reads, so a create failure right after this command is a pure cache hit.
116
+ * `--project=KEY` narrows to a single project — see runSingleProject above.
51
117
  *
52
118
  * @param {string[]} args
53
119
  * @returns {Promise<{ ok: boolean }>}
@@ -68,13 +134,14 @@ export async function runIssueTypes(args = [], opts = {}) {
68
134
 
69
135
  const validated = await handleUnknownFlags(
70
136
  args,
71
- ['--help', '-h', '--profile=', '--refresh', '--format='],
137
+ ['--help', '-h', '--profile=', '--refresh', '--format=', '--project='],
72
138
  { hints: [] },
73
139
  );
74
140
  if (validated === null) { process.exitCode = 1; return { ok: false }; }
75
141
 
76
142
  const profileArg = args.find(a => a.startsWith('--profile='));
77
143
  const formatArg = args.find(a => a.startsWith('--format='));
144
+ const projectArg = args.find(a => a.startsWith('--project='));
78
145
  const forceRefresh = args.includes('--refresh');
79
146
 
80
147
  const format = formatArg ? formatArg.split('=')[1] : 'plain';
@@ -84,6 +151,13 @@ export async function runIssueTypes(args = [], opts = {}) {
84
151
  return { ok: false };
85
152
  }
86
153
 
154
+ const projectKey = projectArg ? projectArg.split('=')[1] : undefined;
155
+ if (projectArg && !projectKey) {
156
+ warn('Error: --project requires a value, e.g. --project=PROJ\n');
157
+ process.exitCode = 1;
158
+ return { ok: false };
159
+ }
160
+
87
161
  const explicitProfile = profileArg ? profileArg.split('=')[1] : undefined;
88
162
  const cwd = opts.cwd ?? process.cwd();
89
163
  const conn = resolveConnectionFn(null, {
@@ -107,10 +181,14 @@ export async function runIssueTypes(args = [], opts = {}) {
107
181
 
108
182
  const profileName = conn.profileName ?? 'default';
109
183
 
184
+ if (projectKey) {
185
+ return runSingleProject({ print, warn, format, projectKey, forceRefresh, adapter, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
186
+ }
187
+
110
188
  if (!forceRefresh) {
111
189
  const cached = readMetadataCacheFn(profileName, configDir);
112
190
  if (isCacheComplete(cached)) {
113
- render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.fetchedAt, cached: true });
191
+ render({ print, format, projects: cached.projects, issueTypesByProject: cached.issueTypesByProject, fetchedAt: cached.projectsFetchedAt, cached: true });
114
192
  return { ok: true };
115
193
  }
116
194
  }
@@ -128,7 +206,11 @@ export async function runIssueTypes(args = [], opts = {}) {
128
206
  return { ok: false };
129
207
  }
130
208
 
131
- writeMetadataCacheFn(profileName, { projects, issueTypesByProject }, configDir);
132
- render({ print, format, projects, issueTypesByProject, fetchedAt: new Date().toISOString(), cached: false });
209
+ const now = new Date().toISOString();
210
+ const issueTypesFetchedAt = Object.create(null);
211
+ for (const p of projects) issueTypesFetchedAt[p.key] = now;
212
+
213
+ writeMetadataCacheFn(profileName, { projects, issueTypesByProject, issueTypesFetchedAt, projectsFetchedAt: now }, configDir);
214
+ render({ print, format, projects, issueTypesByProject, fetchedAt: now, cached: false, ttlLabel: '7 days' });
133
215
  return { ok: true };
134
216
  }
@@ -6,6 +6,8 @@
6
6
  * of ticket-command.mjs's routing logic.
7
7
  */
8
8
 
9
+ import { SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectIssueTypes } from './ticket-metadata-cache.mjs';
10
+
9
11
  /**
10
12
  * Detects whether a create failure is shaped like a project/issuetype
11
13
  * mismatch — the only case cache-refresh enrichment applies to. Jira
@@ -45,22 +47,34 @@ export async function enrichCreateFailure(err, { adapter, project, profileName,
45
47
  // hit as fully sufficient silently drops the other half of a later,
46
48
  // differently-shaped error's enrichment (caught via live-instance
47
49
  // testing, not by unit tests alone).
50
+ //
51
+ // This is a targeted, single-project fetch — "on purpose," same pattern
52
+ // as `issue-types --project=KEY` — so issue-types presence alone isn't
53
+ // enough: it must also be fresh within SINGLE_PROJECT_TTL_MS (3 days),
54
+ // not the longer full-scan bar.
55
+ const hasFreshIssueTypes = cached?.issueTypesByProject?.[project]?.length
56
+ && isFresh(cached?.issueTypesFetchedAt?.[project], SINGLE_PROJECT_TTL_MS);
48
57
  const needsProjects = shape.project && !cached?.projects?.length;
49
- const needsIssueTypes = shape.type && adapter.type === 'jira' && project && !cached?.issueTypesByProject?.[project]?.length;
58
+ const needsIssueTypes = shape.type && adapter.type === 'jira' && project && !hasFreshIssueTypes;
50
59
 
51
60
  if (needsProjects || needsIssueTypes) {
52
61
  try {
53
62
  const projects = needsProjects ? await adapter.listCreatableProjects() : (cached?.projects ?? []);
54
- // Object.create(null), not {} — `project` is an unvalidated CLI value
55
- // reaching this key position. On a plain {}, assigning to a key like
56
- // "__proto__" redirects into the object's own prototype slot instead
57
- // of creating a real entry, silently losing this project's cache
58
- // write. A null-prototype target has no such accessor to intercept.
59
- const issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
63
+ // Object.create(null), not {} — preserves any existing entries when
64
+ // this pass only needed `projects`, not a new issue-type fetch. See
65
+ // mergeProjectIssueTypes' own doc for why a null-prototype target
66
+ // matters once `project` (an unvalidated CLI value) reaches a key.
67
+ let issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
68
+ let issueTypesFetchedAt = Object.assign(Object.create(null), cached?.issueTypesFetchedAt ?? {});
60
69
  if (needsIssueTypes) {
61
- issueTypesByProject[project] = await adapter.listIssueTypes(project);
70
+ ({ issueTypesByProject, issueTypesFetchedAt } = mergeProjectIssueTypes(cached, project, await adapter.listIssueTypes(project)));
62
71
  }
63
- cached = { projects, issueTypesByProject };
72
+ cached = {
73
+ projects,
74
+ issueTypesByProject,
75
+ issueTypesFetchedAt,
76
+ projectsFetchedAt: needsProjects ? new Date().toISOString() : (cached?.projectsFetchedAt ?? null),
77
+ };
64
78
  writeMetadataCacheFn(profileName, cached, configDir);
65
79
  } catch {
66
80
  return '';
@@ -1,24 +1,66 @@
1
1
  /**
2
- * Project/issue-type metadata cache for `ticketlens create` — stores what
3
- * this profile has actually confirmed it can create against (real project
4
- * keys, real Jira issue types per project), refreshed only when a create
5
- * attempt fails with a project/issuetype-shaped error. Never populated on
6
- * the success path: that data only has value for enriching a failure
7
- * message, so fetching it on every create call "just in case" would waste
8
- * a network round-trip for the common case where the caller already got
9
- * project/type right.
2
+ * Project/issue-type metadata cache for `ticketlens create` and
3
+ * `ticketlens issue-types` — stores what this profile has actually
4
+ * confirmed it can create against (real project keys, real Jira issue
5
+ * types per project). Two access patterns share this file with two
6
+ * different freshness bars: a full scan (`issue-types` with no
7
+ * `--project`) trusts data for METADATA_TTL_MS (7 days); a targeted
8
+ * single-project lookup (`issue-types --project=KEY`, or the reactive
9
+ * create-failure enrichment path) is "on purpose" and trusts data for
10
+ * the shorter SINGLE_PROJECT_TTL_MS (3 days) instead — tracked via its
11
+ * own per-project `issueTypesFetchedAt[KEY]` timestamp, independent of
12
+ * the whole-file `fetchedAt`/`projectsFetchedAt` markers a full scan uses.
13
+ * Callers own the freshness decision (this module just persists whatever
14
+ * timestamps they pass); `fetchedAt` alone still gates this file's own
15
+ * read-side garbage collection (deleted once older than the ttlMs param).
10
16
  *
11
17
  * Path: ~/.ticketlens/cache/PROFILE/ticket-metadata.json
12
- * Format: { fetchedAt, projects: [{key, name}], issueTypesByProject: {KEY: [{id, name}]} }
13
- * TTL: 24 hours (bypassed by an always-forced refresh from the caller
14
- * after a project/issuetype error — see ticket-command.mjs)
18
+ * Format: {
19
+ * fetchedAt, // bumped on every write — GC liveness marker only
20
+ * projectsFetchedAt, // set only by a full project-list scan
21
+ * projects: [{key, name}],
22
+ * issueTypesByProject: {KEY: [{id, name}]},
23
+ * issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
24
+ * }
15
25
  */
16
26
 
17
27
  import fs from 'node:fs';
18
28
  import path from 'node:path';
19
29
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
30
 
21
- export const METADATA_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
31
+ export const METADATA_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — full project-list scan
32
+ export const SINGLE_PROJECT_TTL_MS = 3 * 24 * 60 * 60 * 1000; // 3 days — targeted, "on purpose" lookup
33
+
34
+ /**
35
+ * Shared freshness check for a single stored timestamp — used by both
36
+ * `issue-types --project=KEY` and the reactive create-failure enrichment
37
+ * path, so the two "on purpose, single project" access patterns can't
38
+ * silently drift onto different freshness logic over time.
39
+ */
40
+ export function isFresh(isoTimestamp, ttlMs) {
41
+ if (!isoTimestamp) return false;
42
+ const age = Date.now() - new Date(isoTimestamp).getTime();
43
+ return !isNaN(age) && age <= ttlMs;
44
+ }
45
+
46
+ /**
47
+ * Merges one project's issue types into an existing (possibly null) cached
48
+ * map, returning a new { issueTypesByProject, issueTypesFetchedAt } pair —
49
+ * shared by both single-project write paths (`issue-types --project=KEY`
50
+ * and the reactive create-failure enrichment) so the null-prototype defense
51
+ * below can't independently drift or regress between them.
52
+ *
53
+ * Object.create(null), not {} — projectKey is an unvalidated CLI value
54
+ * reaching a key position; a plain {} lets "--project=__proto__" redirect
55
+ * into the object's own prototype slot instead of creating a real entry.
56
+ */
57
+ export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = new Date().toISOString()) {
58
+ const issueTypesByProject = Object.assign(Object.create(null), cached?.issueTypesByProject ?? {});
59
+ issueTypesByProject[projectKey] = types;
60
+ const issueTypesFetchedAt = Object.assign(Object.create(null), cached?.issueTypesFetchedAt ?? {});
61
+ issueTypesFetchedAt[projectKey] = fetchedAt;
62
+ return { issueTypesByProject, issueTypesFetchedAt };
63
+ }
22
64
 
23
65
  /**
24
66
  * Returns the absolute path to the ticket-metadata cache file for a profile.
@@ -41,8 +83,8 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
41
83
  *
42
84
  * @param {string|null} profileName
43
85
  * @param {string} [configDir]
44
- * @param {number} [ttlMs] - override TTL in ms; defaults to METADATA_TTL_MS (24h)
45
- * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, fetchedAt: string } | null}
86
+ * @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
87
+ * @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
46
88
  */
47
89
  export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
48
90
  const filePath = metadataCachePath(profileName, configDir);
@@ -64,6 +106,8 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
64
106
  return {
65
107
  projects: data.projects ?? [],
66
108
  issueTypesByProject: data.issueTypesByProject ?? {},
109
+ issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
110
+ projectsFetchedAt: data.projectsFetchedAt ?? null,
67
111
  fetchedAt: data.fetchedAt,
68
112
  };
69
113
  }
@@ -71,13 +115,21 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
71
115
  /**
72
116
  * Writes project/issue-type metadata to the cache. Non-fatal — a write
73
117
  * failure must never break the caller (an enrichment attempt after an
74
- * already-failed create).
118
+ * already-failed create). Callers own merge-before-write for partial
119
+ * updates (e.g. a single-project fetch merging into an existing
120
+ * multi-project cache) — this function persists exactly what it's given.
75
121
  */
76
- export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {} } = {}, configDir = DEFAULT_CONFIG_DIR) {
122
+ export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
77
123
  const filePath = metadataCachePath(profileName, configDir);
78
124
  try {
79
125
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
80
- fs.writeFileSync(filePath, JSON.stringify({ fetchedAt: new Date().toISOString(), projects, issueTypesByProject }));
126
+ fs.writeFileSync(filePath, JSON.stringify({
127
+ fetchedAt: new Date().toISOString(),
128
+ projectsFetchedAt,
129
+ projects,
130
+ issueTypesByProject,
131
+ issueTypesFetchedAt,
132
+ }));
81
133
  } catch {
82
134
  // Non-fatal
83
135
  }