ticketlens 0.38.29 → 0.38.31

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
@@ -453,7 +453,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
453
453
 
454
454
  **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
455
 
456
- **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `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 Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. 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).
456
+ **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `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` and `doctor` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` is Free with a 3-checks/month cap, Pro unlimited; 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
457
 
458
458
  `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
459
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.29",
3
+ "version": "0.38.31",
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.34.1 -->
1
+ <!-- jtb-skill-version: 0.36.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.
@@ -106,6 +106,8 @@ Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=
106
106
 
107
107
  **IMPORTANT:** Copy the script's stdout and display it directly as your response text (not inside a tool result). This ensures the markdown table renders visibly and URLs are clickable in the terminal. No VCS enrichment, no plan mode. Stop here.
108
108
 
109
+ If this harness has TicketLens's MCP server configured (a tool named `triage` — often shown as `mcp__ticketlens__triage` — visible in your tool list), prefer it over the bash form: same license gate per option (Free base scan, Pro `save`/`all`/`digest`, Team `assignee`/`sprint`/`export`/`project`/`label`/`priority`) — just no shell command to construct. It accepts `profile`/`stale`/`status`/`sort` plus those gated options; `--push`/`--share` aren't exposed yet — fall back to the bash form for those.
110
+
109
111
  ---
110
112
 
111
113
  ### Collisions subcommand
@@ -145,6 +147,8 @@ Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` a
145
147
 
146
148
  The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
147
149
 
150
+ If this harness has TicketLens's MCP server configured (a tool named `fetch` — often shown as `mcp__ticketlens__fetch` — visible in your tool list), prefer it over the bash form: same license gate (Free), same cache — just no shell command to construct or stdout to parse. It accepts `ticket`/`profile`/`depth`, matching `$TICKET_KEY`/`--profile`/`--depth` above; other CLI flags (`--summarize`, `--handoff`, `--no-cache`, etc.) aren't exposed yet — fall back to the bash form for those.
151
+
148
152
  ### Step 2b: Read attached files
149
153
 
150
154
  Check if the TicketBrief contains an `## Attachments` section. If it does, for each line containing a backtick-quoted absolute path, call the Read tool on that path based on file type:
@@ -400,6 +404,11 @@ Use this evaluation order:
400
404
  ### Privacy
401
405
  `--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
402
406
 
407
+ ### Standalone command
408
+ The same tier-gated check also runs as its own command — `ticketlens compliance PROJ-123` — independent of a full ticket fetch. This is what `ticketlens install-hooks` wires into a pre-push git hook (`ticketlens compliance "$KEY" || exit 1`, gated on a configurable coverage threshold). It shares the same `FREE_LIMIT`/Pro gate and the same compliance ledger as the `--compliance` flag above.
409
+
410
+ 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.
411
+
403
412
  ---
404
413
 
405
414
  ## Advanced Options
@@ -118,9 +118,17 @@ export function hasRecentCapture(cwd, now = Date.now()) {
118
118
  * tool — for note-add. Missing the MCP path here was a real bug: it made
119
119
  * the Stop hook nudge even after a note was genuinely captured via MCP,
120
120
  * since only the Bash/CLI form was ever recognized.
121
+ *
122
+ * `ticketKey` carries the FIRST matched ticket key's literal text (or
123
+ * `null`), so callers can resolve a profile by ticket-key prefix the same
124
+ * way `resolveConnection()` does — see recall-nudge-stop.mjs. First match,
125
+ * not last: the primary ticket a session is about is normally established
126
+ * early, and picking one deterministic match keeps this function decoupled
127
+ * from profiles.json (it stays a pure transcript reader; profile lookup
128
+ * and its own fallback chain belong entirely to resolveProfile()).
121
129
  */
122
130
  export function scanTranscript(transcriptPath) {
123
- const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false };
131
+ const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
124
132
  let lines;
125
133
  try {
126
134
  lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
@@ -136,10 +144,22 @@ export function scanTranscript(transcriptPath) {
136
144
  continue;
137
145
  }
138
146
 
139
- // Ticket-key detection stays broad (whole entry, any role) — it's only
140
- // the weaker "did ticket work happen at all" signal, and a rare false
141
- // positive here just means an extra harmless once-per-session check.
142
- if (TICKET_KEY_RE.test(JSON.stringify(entry))) result.sawTicketKey = true;
147
+ // Ticket-key detection stays broad (whole entry, any role) — for
148
+ // sawTicketKey alone, it's only the weaker "did ticket work happen at
149
+ // all" signal, so a rare false positive just means an extra harmless
150
+ // once-per-session check. The captured ticketKey text carries a bit
151
+ // more weight (it also selects which profile's recallStrictness
152
+ // applies, see recall-nudge-stop.mjs), but the ceiling is still just
153
+ // "the wrong local settings value governs one Stop-hook decision" — no
154
+ // credentials or ticket data are read using this key, so a false
155
+ // positive here stays low-consequence, not narrowed further for now.
156
+ if (!result.ticketKey) {
157
+ const match = TICKET_KEY_RE.exec(JSON.stringify(entry));
158
+ if (match) {
159
+ result.sawTicketKey = true;
160
+ result.ticketKey = match[0];
161
+ }
162
+ }
143
163
 
144
164
  if (entry.type !== 'assistant') continue;
145
165
  const blocks = entry.message?.content;
@@ -21,14 +21,13 @@
21
21
  * profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
22
22
  * comment for the calibration and why strict doesn't widen this further.
23
23
  *
24
- * Known limitation: resolveProfile(null, { cwd }) below has no ticket key to
25
- * match against, so it falls through to cwd/projectPaths then the default
26
- * profile — unlike the brief-injection lever (resolveConnection(ticketKey, ...)
27
- * in profile-resolver.mjs), which resolves by ticket-key prefix when one is
28
- * available. For a multi-profile user these two levers can genuinely resolve
29
- * different profiles in the same session, so the recallStrictness setting
30
- * doesn't reliably reweight both together outside the single-profile case.
31
- * Not fixed here — see design spec §6 for the follow-up.
24
+ * resolveProfile() below is given scanTranscript()'s matched ticket key (not
25
+ * null), so it resolves by ticket-key prefix the same way the brief-injection
26
+ * lever (resolveConnection(ticketKey, ...) in profile-resolver.mjs) does —
27
+ * both levers now agree on which profile's recallStrictness applies for a
28
+ * multi-profile user. When no ticket key was seen, or it matches no profile's
29
+ * ticketPrefixes, this falls through to cwd/projectPaths then the default
30
+ * profile, same as before (backlog #12, design spec §6).
32
31
  */
33
32
 
34
33
  import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
@@ -41,7 +40,7 @@ const cwd = input?.cwd ?? process.cwd();
41
40
 
42
41
  if (!sessionId || !transcriptPath) process.exit(0);
43
42
 
44
- const { sawTicketKey, sawRecallFlag, sawNoteAdd } = scanTranscript(transcriptPath);
43
+ const { sawTicketKey, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
45
44
 
46
45
  // Refreshed on every check, independent of the once-per-session gate below —
47
46
  // a capture that happens AFTER this session already nagged once must still
@@ -51,7 +50,7 @@ if (sawNoteAdd) writeLastCaptureAt(cwd, Date.now());
51
50
  const state = readState(sessionId);
52
51
  if (state.stopChecked) process.exit(0); // already asked once this session — respect the answer
53
52
 
54
- const profile = resolveProfile(null, { cwd });
53
+ const profile = resolveProfile(ticketKey, { cwd });
55
54
  const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
56
55
 
57
56
  if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
@@ -57,6 +57,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
57
57
  env = envOrOpts;
58
58
  }
59
59
 
60
+ // Injectable stderr-equivalent — defaults to the real stream so every existing
61
+ // CLI caller (which never sets opts.stream) is byte-identical to before. Lets
62
+ // a long-lived host (the MCP server) capture banners/errors instead of losing
63
+ // them to a real process.stderr it never reads.
64
+ const stream = opts.stream ?? process.stderr;
65
+
60
66
  // Strip leading 'triage' subcommand if present (when called via CLI router)
61
67
  if (args[0] === 'triage') args = args.slice(1);
62
68
 
@@ -99,7 +105,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
99
105
  const saveArg = args.find(a => a.startsWith('--save='))?.split('=').slice(1).join('=') ?? null;
100
106
 
101
107
  if (exportArg && exportArg !== 'csv' && exportArg !== 'json') {
102
- process.stderr.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
108
+ stream.write(`Error: --export must be csv or json, got: ${exportArg}\n`);
103
109
  process.exitCode = 1;
104
110
  return;
105
111
  }
@@ -120,14 +126,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
120
126
  // --save=FILE: Pro gate + validate path is not a directory
121
127
  if (saveArg) {
122
128
  if (!licensedFn('pro', configDir)) {
123
- upgradeFn('pro', '--save');
129
+ upgradeFn('pro', '--save', { stream });
124
130
  process.exitCode = 1;
125
131
  return;
126
132
  }
127
133
  const resolvedSave = resolvePath(saveArg);
128
134
  try {
129
135
  if (statSync(resolvedSave).isDirectory()) {
130
- process.stderr.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
136
+ stream.write(`Error: --save path must be a file, not a directory: ${resolvedSave}\n`);
131
137
  process.exitCode = 1;
132
138
  return;
133
139
  }
@@ -138,7 +144,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
138
144
  if (projectArg || labelArg || priorityArg) {
139
145
  if (!licensedFn('team', configDir)) {
140
146
  const flag = projectArg ? '--project' : labelArg ? '--label' : '--priority';
141
- upgradeFn('team', flag);
147
+ upgradeFn('team', flag, { stream });
142
148
  process.exitCode = 1;
143
149
  return;
144
150
  }
@@ -147,14 +153,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
147
153
  // --all: triage all configured profiles in parallel with live status block
148
154
  if (allFlag) {
149
155
  if (!licensedFn('pro', configDir)) {
150
- upgradeFn('pro', '--all');
156
+ upgradeFn('pro', '--all', { stream });
151
157
  process.exitCode = 1;
152
158
  return;
153
159
  }
154
160
  const profilesConfig = loadProfiles(configDir);
155
161
  const profileNames = profilesConfig?.profiles ? Object.keys(profilesConfig.profiles) : [];
156
162
  if (profileNames.length === 0) {
157
- process.stderr.write('Error: No profiles configured. Run `ticketlens init` first.\n');
163
+ stream.write('Error: No profiles configured. Run `ticketlens init` first.\n');
158
164
  process.exitCode = 1;
159
165
  return;
160
166
  }
@@ -234,14 +240,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
234
240
 
235
241
  // Team-tier gate: --assignee and --sprint require a Team license
236
242
  if ((assigneeArg || sprintArg) && !licensedFn('team', configDir)) {
237
- upgradeFn('team', assigneeArg ? '--assignee' : '--sprint');
243
+ upgradeFn('team', assigneeArg ? '--assignee' : '--sprint', { stream });
238
244
  process.exitCode = 1;
239
245
  return;
240
246
  }
241
247
 
242
248
  // Team-tier gate: --export requires a Team license
243
249
  if (exportArg && !licensedFn('team', configDir)) {
244
- upgradeFn('team', '--export');
250
+ upgradeFn('team', '--export', { stream });
245
251
  process.exitCode = 1;
246
252
  return;
247
253
  }
@@ -256,14 +262,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
256
262
  configDir,
257
263
  profileName,
258
264
  cwd,
259
- onWarning: (w) => process.stderr.write(w + '\n'),
265
+ onWarning: (w) => stream.write(w + '\n'),
260
266
  onProfileNotFound: (info) => { profileError = info; },
261
267
  });
262
268
 
263
269
  const hasAuth = conn.pat || (conn.email && conn.apiToken);
264
270
  if (!conn.baseUrl || !hasAuth) {
265
271
  if (profileError) {
266
- const picked = await promptProfileSelect(profileError);
272
+ const picked = await promptProfileSelect(profileError, { stream });
267
273
  if (picked) {
268
274
  // Re-run with the selected profile
269
275
  const newArgs = args.filter(a => !a.startsWith('--profile='));
@@ -275,7 +281,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
275
281
  const msg = noProfiles
276
282
  ? 'Error: Could not determine Jira profile.\nRun `ticketlens init` to set up your connection.'
277
283
  : 'Error: Could not determine Jira profile. Use --profile=NAME or add projectPaths to ~/.ticketlens/profiles.json';
278
- process.stderr.write(msg + '\n');
284
+ stream.write(msg + '\n');
279
285
  }
280
286
  process.exitCode = 1;
281
287
  return;
@@ -304,7 +310,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
304
310
  const priorityClause = priorityArg ? ` AND priority = "${escapeJql(priorityArg.split('=')[1])}"` : '';
305
311
  const jql = `${assigneeClause} AND status IN (${statusList})${sprintClause}${projectClause}${labelClause}${priorityClause} ORDER BY updated DESC`;
306
312
 
307
- const session = createSession(conn);
313
+ const session = createSession(conn, { stream });
308
314
  session.spin(`Connecting to ${session.label}…`);
309
315
 
310
316
  // Fire both requests concurrently — they are independent of each other
@@ -329,7 +335,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
329
335
  }
330
336
 
331
337
  session.connected();
332
- process.stderr.write('\n');
338
+ stream.write('\n');
333
339
 
334
340
  const scanSpinner = createSpinner('Scanning tickets…');
335
341
  scanSpinner.start();
@@ -341,7 +347,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
341
347
  scanSpinner.stop();
342
348
  if (err.status === 400 && err.detail && /does not exist for the field 'status'/.test(err.detail)) {
343
349
  const s = session.styler;
344
- const out = process.stderr;
350
+ const out = stream;
345
351
  out.write(`\n ${s.yellow('○')} Status mismatch — checking Jira...\n`);
346
352
  try {
347
353
  const available = await adapter.fetchStatuses();
@@ -424,7 +430,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
424
430
  : currentUser;
425
431
 
426
432
  if (assigneeName) {
427
- process.stderr.write(`Viewing ${assigneeName}'s tickets\n\n`);
433
+ stream.write(`Viewing ${assigneeName}'s tickets\n\n`);
428
434
  }
429
435
 
430
436
  const scored = tickets.map(t => scoreAttention(t, effectiveUser, { staleDays, customRules: conn.attentionRules, staleRule: conn.staleRule ?? null }));
@@ -445,7 +451,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
445
451
  // --digest: POST scored results to the digest backend endpoint
446
452
  if (digestFlag) {
447
453
  if (!licensedFn('pro', configDir)) {
448
- upgradeFn('pro', '--digest');
454
+ upgradeFn('pro', '--digest', { stream });
449
455
  process.exitCode = 1;
450
456
  return;
451
457
  }
@@ -472,9 +472,9 @@ const RETRY_OPTIONS = [
472
472
  ];
473
473
 
474
474
  export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
475
- // Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print })
475
+ // Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print, printErr })
476
476
  let env, opts;
477
- if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
477
+ if (envOrOpts && typeof envOrOpts === 'object' && !Array.isArray(envOrOpts) && ('env' in envOrOpts || 'fetcher' in envOrOpts || 'print' in envOrOpts || 'printErr' in envOrOpts || 'detectVcs' in envOrOpts || 'getDiff' in envOrOpts)) {
478
478
  opts = envOrOpts;
479
479
  env = opts.env ?? process.env;
480
480
  fetcher = opts.fetcher ?? globalThis.fetch;
@@ -485,6 +485,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
485
485
  }
486
486
 
487
487
  const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
488
+ // Mirrors printFn above — only the bare ticket-fetch path (from `const ticketKey =
489
+ // args.find(...)` onward) uses this; pr/ledger/compliance/review/standup/install-hooks
490
+ // above still write directly to the real process.stderr (out of scope for this change,
491
+ // each gets the same treatment when its own MCP tool is built).
492
+ const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
493
+ // Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
494
+ // so an injected print/printErr survives the retry instead of silently reverting to
495
+ // the real stdout/stderr.
496
+ const selfOpts = { env, fetcher, configDir, print: printFn, printErr: printErrFn };
497
+ // Passed to every stream-shaped helper (createSession, the profile-prompt pickers,
498
+ // handleUnknownFlags) in the bare-fetch path below. isTTY MUST reflect the real
499
+ // process.stderr.isTTY, not a hardcoded false: these helpers use it to decide
500
+ // whether to render an animated/interactive UI at all, for real CLI users too, not
501
+ // just MCP calls. Their own separate `!process.stdin.setRawMode` gate — always true
502
+ // under the MCP server, since its stdin is the JSON-RPC channel, never a TTY — is
503
+ // what actually keeps the interactive branch from firing under MCP; isTTY here only
504
+ // controls real-terminal UX and must stay accurate for that to keep working. Injectable
505
+ // via opts.isTTY so a test can assert deterministic behavior without mutating the real,
506
+ // process-global process.stderr.isTTY (unsafe to flip mid-suite — see fetch-ticket.test.mjs).
507
+ const isTTY = opts.isTTY ?? process.stderr.isTTY;
508
+ const errStream = { write: printErrFn, isTTY };
488
509
 
489
510
  if (args.includes('--help') || args.includes('-h')) {
490
511
  printFetchHelp();
@@ -597,12 +618,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
597
618
  if (args[0] === 'compliance') {
598
619
  const ticketKeyArg = args[1];
599
620
  if (!ticketKeyArg) {
600
- process.stderr.write('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
621
+ printErrFn('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
601
622
  process.exitCode = 1;
602
623
  return;
603
624
  }
604
625
  if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
605
- process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
626
+ printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
606
627
  process.exitCode = 1;
607
628
  return;
608
629
  }
@@ -626,13 +647,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
626
647
  configDir: resolvedConfigDir,
627
648
  profileName: profileNameC,
628
649
  cwd: process.cwd(),
629
- onWarning: (w) => process.stderr.write(w + '\n'),
650
+ onWarning: (w) => printErrFn(w + '\n'),
630
651
  onProfileNotFound: () => {},
631
652
  });
632
653
 
633
654
  const hasAuthC = connC.pat || (connC.email && connC.apiToken);
634
655
  if (!connC.baseUrl || !hasAuthC) {
635
- process.stderr.write('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
656
+ printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
636
657
  process.exitCode = 1;
637
658
  return;
638
659
  }
@@ -643,7 +664,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
643
664
  try {
644
665
  ticketC = await adapterC.fetchTicket(ticketKeyArg, { depth: 0 });
645
666
  } catch (err) {
646
- process.stderr.write(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
667
+ printErrFn(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
647
668
  process.exitCode = 1;
648
669
  return;
649
670
  }
@@ -658,6 +679,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
658
679
  description: ticketC.description,
659
680
  ticketKey: ticketKeyArg,
660
681
  configDir: resolvedConfigDir,
682
+ stream: errStream,
661
683
  });
662
684
 
663
685
  if (complianceResult === null) {
@@ -969,12 +991,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
969
991
 
970
992
  const ticketKey = args.find(a => !a.startsWith('--'));
971
993
  if (!ticketKey) {
972
- printFetchHelp({ stream: process.stderr });
994
+ printFetchHelp({ stream: errStream });
973
995
  process.exitCode = 1;
974
996
  return;
975
997
  }
976
998
  if (!TICKET_KEY_PATTERN.test(ticketKey)) {
977
- process.stderr.write(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
999
+ printErrFn(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
978
1000
  process.exitCode = 1;
979
1001
  return;
980
1002
  }
@@ -982,7 +1004,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
982
1004
  // Normalize --project= alias once at entry so all recursive calls only see --profile=
983
1005
  const projectArg = args.find(a => a.startsWith('--project='));
984
1006
  if (projectArg) {
985
- process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
1007
+ printErrFn(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
986
1008
  args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
987
1009
  }
988
1010
 
@@ -992,7 +1014,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
992
1014
  const validatedArgs = await handleUnknownFlags(
993
1015
  args,
994
1016
  ['--help', '-h', '--plain', '--styled', '--no-attachments', '--no-cache', '--profile=', '--depth=', '--check', '--summarize', '--cloud', '--compliance', '--budget=', '--handoff', '--provider=', '--template='],
995
- { hints: ['--stale=', '--status=', '--static'] } // triage-only flags — shown as hints, not applied
1017
+ { hints: ['--stale=', '--status=', '--static'], stream: errStream } // triage-only flags — shown as hints, not applied
996
1018
  );
997
1019
  if (validatedArgs === null) { process.exitCode = 1; return; }
998
1020
  args = validatedArgs;
@@ -1006,7 +1028,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1006
1028
  const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
1007
1029
  templateSections = tpl.sections;
1008
1030
  } catch (err) {
1009
- process.stderr.write(`Error: ${err.message}\n`);
1031
+ printErrFn(`Error: ${err.message}\n`);
1010
1032
  process.exitCode = 1;
1011
1033
  return;
1012
1034
  }
@@ -1021,10 +1043,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1021
1043
  .filter(([, p]) => p.ticketPrefixes?.includes(prefix))
1022
1044
  .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
1023
1045
  if (multiMatches.length > 1) {
1024
- const picked = await promptMultipleMatches(ticketKey, multiMatches);
1046
+ const promptMultipleMatchesFn = opts.promptMultipleMatchesFn ?? promptMultipleMatches;
1047
+ const picked = await promptMultipleMatchesFn(ticketKey, multiMatches, { stream: errStream });
1025
1048
  if (!picked) { process.exitCode = 1; return; }
1026
1049
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1027
- return run(withProfile, env, fetcher, configDir);
1050
+ return run(withProfile, selfOpts);
1028
1051
  }
1029
1052
  }
1030
1053
 
@@ -1034,18 +1057,19 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1034
1057
  configDir,
1035
1058
  profileName,
1036
1059
  cwd: process.cwd(),
1037
- onWarning: (w) => process.stderr.write(w + '\n'),
1060
+ onWarning: (w) => printErrFn(w + '\n'),
1038
1061
  onProfileNotFound: (info) => { profileError = info; },
1039
1062
  });
1040
1063
 
1041
1064
  const hasAuth = conn.pat || (conn.email && conn.apiToken);
1042
1065
  if (!conn.baseUrl || !hasAuth) {
1043
1066
  if (profileError) {
1044
- const picked = await promptProfileSelect(profileError);
1067
+ const promptProfileSelectFn = opts.promptProfileSelectFn ?? promptProfileSelect;
1068
+ const picked = await promptProfileSelectFn(profileError, { stream: errStream });
1045
1069
  if (picked) {
1046
1070
  const newArgs = args.filter(a => !a.startsWith('--profile='));
1047
1071
  newArgs.push(`--profile=${picked}`);
1048
- return run(newArgs, env, fetcher, configDir);
1072
+ return run(newArgs, selfOpts);
1049
1073
  }
1050
1074
  } else {
1051
1075
  const missing = [];
@@ -1056,7 +1080,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1056
1080
  : `Missing config in profile "${conn.profileName}": ${missing.join(', ')}`;
1057
1081
  const noProfiles = !loadProfiles(configDir)?.profiles;
1058
1082
  const initHint = noProfiles ? '\nRun `ticketlens init` to set up your connection.' : '';
1059
- process.stderr.write(`Error: ${hint}${initHint}\n`);
1083
+ printErrFn(`Error: ${hint}${initHint}\n`);
1060
1084
  }
1061
1085
  process.exitCode = 1;
1062
1086
  return;
@@ -1072,10 +1096,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1072
1096
  const allProfiles = Object.entries(config?.profiles ?? {})
1073
1097
  .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
1074
1098
  if (allProfiles.length > 1) {
1075
- const picked = await promptProfileMismatch(ticketKey, conn.profileName, allProfiles);
1099
+ const promptProfileMismatchFn = opts.promptProfileMismatchFn ?? promptProfileMismatch;
1100
+ const picked = await promptProfileMismatchFn(ticketKey, conn.profileName, allProfiles, { stream: errStream });
1076
1101
  if (picked && picked !== conn.profileName) {
1077
1102
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1078
- return run(withProfile, env, fetcher, configDir);
1103
+ return run(withProfile, selfOpts);
1079
1104
  }
1080
1105
  }
1081
1106
  }
@@ -1113,7 +1138,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1113
1138
  if (cached) {
1114
1139
  const s = createStyler({ isTTY: process.stderr.isTTY });
1115
1140
  const age = briefCacheAge(cached.fetchedAt);
1116
- process.stderr.write(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
1141
+ printErrFn(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
1117
1142
 
1118
1143
  const allText = [cached.ticket.description, ...cached.ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
1119
1144
  const codeRefs = extractCodeReferences(allText);
@@ -1177,7 +1202,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1177
1202
  }
1178
1203
  // ──────────────────────────────────────────────────────────────────────────
1179
1204
 
1180
- const session = createSession(conn);
1205
+ const session = createSession(conn, { stream: errStream });
1181
1206
 
1182
1207
  // Load all profiles once for use in the switch-profile retry option.
1183
1208
  const allProfiles = Object.entries(loadProfiles(configDir)?.profiles ?? {})
@@ -1212,23 +1237,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1212
1237
  }
1213
1238
 
1214
1239
  if (RETRY_OPTIONS[retryIndex].value === 'switch') {
1215
- const picked = await promptSwitchProfile(conn.profileName, allProfiles);
1240
+ const promptSwitchProfileFn = opts.promptSwitchProfileFn ?? promptSwitchProfile;
1241
+ const picked = await promptSwitchProfileFn(conn.profileName, allProfiles, { stream: errStream });
1216
1242
  if (picked && picked !== conn.profileName) {
1217
1243
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1218
- return run(withProfile, env, fetcher, configDir);
1244
+ return run(withProfile, selfOpts);
1219
1245
  }
1220
1246
  // Cancelled switch — exit
1221
1247
  process.exitCode = 1;
1222
1248
  return;
1223
1249
  }
1224
1250
 
1225
- // 'retry' — loop with updated spinner message
1251
+ // 'retry' — loop with updated spinner message. This whole retry-prompt
1252
+ // branch only runs when `!process.stderr.isTTY || !process.stdin.setRawMode`
1253
+ // is false — i.e. never under the MCP server (stdin is always piped there) —
1254
+ // so its remaining process.stderr.write calls are intentionally left
1255
+ // unconverted, dead code under MCP by construction, not an oversight.
1226
1256
  isRetry = true;
1227
1257
  process.stderr.write('\n');
1228
1258
  }
1229
1259
  }
1230
1260
  session.connected();
1231
- process.stderr.write('\n');
1261
+ printErrFn('\n');
1232
1262
 
1233
1263
  // ── Confluence page fetching ───────────────────────────────────────────────
1234
1264
  // Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
@@ -1276,7 +1306,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1276
1306
  requirements: extractRequirements(desc),
1277
1307
  };
1278
1308
  const result = dtm.detectDrift(current, prior);
1279
- if (result.drifted) process.stderr.write(dtm.formatDriftWarning(ticketKey, result.changes));
1309
+ if (result.drifted) printErrFn(dtm.formatDriftWarning(ticketKey, result.changes));
1280
1310
  }
1281
1311
  dtm.writeSnapshot(ticketKey, ticket, { profile: profileName, configDir: resolvedConfigDir, branch });
1282
1312
  }
@@ -1287,14 +1317,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1287
1317
  const downloadable = (ticket.attachments ?? []).filter(a => a.content);
1288
1318
  if (downloadable.length > 0) {
1289
1319
  const noun = downloadable.length === 1 ? 'attachment' : 'attachments';
1290
- process.stderr.write(`Downloading ${downloadable.length} ${noun}…\n`);
1320
+ printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
1291
1321
  // attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
1292
1322
  const jiraEnv = buildJiraEnv(conn);
1293
1323
  ticket.localAttachments = await downloadAttachments(ticket, {
1294
1324
  env: jiraEnv,
1295
1325
  fetcher,
1296
1326
  noCache: args.includes('--no-cache'),
1297
- onProgress: (msg) => process.stderr.write(msg + '\n'),
1327
+ onProgress: (msg) => printErrFn(msg + '\n'),
1298
1328
  allowPrivateIp: conn.allowPrivateIp,
1299
1329
  });
1300
1330
  const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
@@ -1302,8 +1332,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1302
1332
  const parts = [];
1303
1333
  if (downloaded > 0) parts.push(`${downloaded} downloaded`);
1304
1334
  if (cached > 0) parts.push(`${cached} cached`);
1305
- if (parts.length > 0) process.stderr.write(` ✓ ${parts.join(', ')}\n`);
1306
- process.stderr.write('\n');
1335
+ if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
1336
+ printErrFn('\n');
1307
1337
  }
1308
1338
  }
1309
1339
 
@@ -1369,7 +1399,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1369
1399
  // Contextual upsell: after a deep traversal with a substantial graph, nudge toward --summarize
1370
1400
  if (depth > 1 && !args.includes('--summarize') && (ticket.linked?.length ?? 0) >= 2) {
1371
1401
  const s = createStyler({ isTTY: process.stderr.isTTY });
1372
- process.stderr.write(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
1402
+ printErrFn(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
1373
1403
  }
1374
1404
  }
1375
1405
 
@@ -642,14 +642,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
642
642
  const s = createStyler({ isTTY: stream.isTTY });
643
643
  const lines = [
644
644
  '',
645
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
645
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
646
646
  '',
647
- ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
648
- ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
647
+ ` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
648
+ ` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
649
649
  ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
650
650
  ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
651
- ` code as ${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:`,
652
- ` same Pro gate, same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is`,
651
+ ` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${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')}`,
652
+ ` above. ${s.cyan('fetch')} and ${s.cyan('doctor')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
653
+ ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} is Free with a 3-checks/month cap,`,
654
+ ` Pro unlimited; every other tool needs Pro. ${s.cyan('ticket_transition')} is`,
653
655
  ` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
654
656
  ` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
655
657
  ` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
@@ -663,9 +665,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
663
665
  ` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
664
666
  ` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
665
667
  '',
666
- ` ${s.dim('The [Pro] badge above describes the tools this server exposes — starting the')}`,
667
- ` ${s.dim('server itself is ungated, same as `mcp install`; each tool still enforces its')}`,
668
- ` ${s.dim('own license check at call time.')}`,
668
+ ` ${s.dim('Starting the server itself is ungated, same as `mcp install`; each tool still')}`,
669
+ ` ${s.dim('enforces its own license check at call time.')}`,
669
670
  '',
670
671
  ` ${s.bold('OPTIONS')}`,
671
672
  '',
@@ -25,10 +25,59 @@ import { runDoctor } from './doctor-command.mjs';
25
25
  import { runNoteAdd } 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
+ import { run as runFetchTicket } from '../fetch-ticket.mjs';
29
+ import { run as runTriage } from '../fetch-my-tickets.mjs';
28
30
 
29
31
  const PROTOCOL_VERSION = '2025-11-25';
30
32
 
31
33
  const TOOLS = [
34
+ {
35
+ name: 'fetch',
36
+ description: 'Fetch a ticket\'s full context brief (Jira/GitHub/Linear) — description, comments, linked tickets, code references, attachments. The core read action; free tier. Not a discovery tool — requires a known ticket key.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
41
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
42
+ depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
43
+ },
44
+ required: ['ticket'],
45
+ },
46
+ },
47
+ {
48
+ name: 'triage',
49
+ description: 'Scan assigned tickets and surface what needs attention — replies owed, aging tickets, stale-status tickets. The base scan is free tier; some options require a TicketLens Pro or Team license.',
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties: {
53
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
54
+ stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
55
+ status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
56
+ sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
57
+ save: { type: 'string', description: 'Write the plain-text summary to this local file path instead of (in addition to) returning it. Requires a TicketLens Pro license.' },
58
+ all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
59
+ digest: { type: 'boolean', description: 'Deliver the scored results to the digest backend instead of returning them as text — on success, no summary is returned, only a delivery confirmation. Requires a TicketLens Pro license.' },
60
+ assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
61
+ sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
62
+ export: { type: 'string', enum: ['csv', 'json'], description: 'Write results to a file in this format instead of returning the summary text, returning the written file path instead. Requires a TicketLens Team license.' },
63
+ project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
64
+ label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
65
+ priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
66
+ },
67
+ },
68
+ },
69
+ {
70
+ name: 'compliance',
71
+ description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
76
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
77
+ },
78
+ required: ['ticket'],
79
+ },
80
+ },
32
81
  {
33
82
  name: 'doctor',
34
83
  description: 'Diagnose common TicketLens problems: profile configuration, license freshness, tracker connectivity, attachment cache health, MCP registration, and the Recall sync queue. Always returns structured JSON. Free tier, fully unrestricted — including fix.',
@@ -180,6 +229,148 @@ function capturingStream() {
180
229
  };
181
230
  }
182
231
 
232
+ /**
233
+ * Deliberately v1-minimal — only the three flags with zero cost/AI-provider
234
+ * implications (see the 49b scoping decision). If `--summarize`/`--handoff`/
235
+ * `--budget=`/`--compliance`/`--template=` are ever added here, note that
236
+ * their error/progress output inside fetch-ticket.mjs's bare-fetch path
237
+ * (applySummarize/applyHandoff/budgetPruner.pruneBrief/showUpgradePrompt)
238
+ * still writes to the real process.stderr, not the injected printErr —
239
+ * unlike every path reachable through this function today. Thread printErr
240
+ * through those call sites first, or their failures will silently collapse
241
+ * to callFetch's generic 'fetch failed' instead of the real reason.
242
+ */
243
+ function buildFetchArgs({ ticket, profile, depth }) {
244
+ const args = [ticket];
245
+ if (profile) args.push(`--profile=${profile}`);
246
+ if (depth !== undefined) args.push(`--depth=${depth}`);
247
+ return args;
248
+ }
249
+
250
+ /**
251
+ * Shared by every dispatch that goes through fetch-ticket.mjs's `run()`
252
+ * (currently `fetch` and `compliance` — `compliance` is just another
253
+ * subcommand of that same function). `run()` has no {ok} return value
254
+ * (unlike every other wrapped function) — failure is signaled by mutating
255
+ * process.exitCode, which is unsafe to read in a long-lived server (one
256
+ * failed call would poison the whole process's exit code forever). Success
257
+ * is instead determined by whether `print` ever received real content —
258
+ * every success path (cache hit, fresh fetch, handoff, a printed report
259
+ * regardless of pass/fail) calls it exactly once; every failure path
260
+ * returns before reaching it. errCapture may contain informational
261
+ * chatter (cache notice, download progress) even on success — only read
262
+ * on the failure branch, where it carries the actual error message.
263
+ */
264
+ async function callFetchTicketRun(buildArgsFn, args, { configDir, runFetchTicketFn }, fallbackErrorText) {
265
+ const capture = capturingStream();
266
+ const errCapture = capturingStream();
267
+ await runFetchTicketFn(buildArgsFn(args), {
268
+ configDir,
269
+ env: process.env,
270
+ fetcher: globalThis.fetch,
271
+ print: capture.write,
272
+ printErr: errCapture.write,
273
+ });
274
+ if (capture.text) {
275
+ return { content: [{ type: 'text', text: capture.text }] };
276
+ }
277
+ return { isError: true, content: [{ type: 'text', text: errCapture.text || fallbackErrorText }] };
278
+ }
279
+
280
+ async function callFetch(args, deps) {
281
+ if (!args.ticket) {
282
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
283
+ }
284
+ return callFetchTicketRun(buildFetchArgs, args, deps, 'fetch failed');
285
+ }
286
+
287
+ /**
288
+ * Deliberately excludes --push/--share — those sync/share a snapshot as a
289
+ * human-collaboration side effect (Console notification queue, shareable
290
+ * link), not "what needs attention" read value. See the 49b scoping memory.
291
+ */
292
+ function buildTriageArgs({ profile, stale, status, sort, save, all, digest, assignee, sprint, export: exportFormat, project, label, priority }) {
293
+ const args = ['--plain'];
294
+ if (profile) args.push(`--profile=${profile}`);
295
+ if (stale !== undefined) args.push(`--stale=${stale}`);
296
+ if (Array.isArray(status) && status.length > 0) args.push(`--status=${status.join(',')}`);
297
+ if (sort) args.push(`--sort=${sort}`);
298
+ if (save) args.push(`--save=${save}`);
299
+ if (all === true) args.push('--all');
300
+ if (digest === true) args.push('--digest');
301
+ if (assignee) args.push(`--assignee=${assignee}`);
302
+ if (sprint) args.push(`--sprint=${sprint}`);
303
+ if (exportFormat) args.push(`--export=${exportFormat}`);
304
+ if (project) args.push(`--project=${project}`);
305
+ if (Array.isArray(label) && label.length > 0) args.push(`--label=${label.join(',')}`);
306
+ if (priority) args.push(`--priority=${priority}`);
307
+ return args;
308
+ }
309
+
310
+ /**
311
+ * runTriage has the same no-{ok}-return architecture as runFetchTicket —
312
+ * success is "did print receive the summary," failure is whatever landed in
313
+ * the injected stream. One deliberate exception: `--digest` delivers to the
314
+ * backend and prints NOTHING to `print` on success (locked by
315
+ * fetch-my-tickets.test.mjs's own "stdout should be empty" test) — an empty
316
+ * capture there means delivery succeeded, not that it failed. Only a gate
317
+ * rejection (Pro license, captured in `stream`) or a thrown delivery error
318
+ * (caught below) signal an actual digest failure.
319
+ */
320
+ async function callTriage(args, { configDir, runTriageFn }) {
321
+ const capture = capturingStream();
322
+ const errCapture = capturingStream();
323
+ try {
324
+ await runTriageFn(buildTriageArgs(args), {
325
+ configDir,
326
+ env: process.env,
327
+ fetcher: globalThis.fetch,
328
+ print: capture.write,
329
+ stream: errCapture,
330
+ });
331
+ } catch (err) {
332
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
333
+ }
334
+ if (args.digest === true) {
335
+ if (errCapture.text) {
336
+ return { isError: true, content: [{ type: 'text', text: errCapture.text }] };
337
+ }
338
+ return { content: [{ type: 'text', text: 'Digest delivered.' }] };
339
+ }
340
+ if (capture.text) {
341
+ return { content: [{ type: 'text', text: capture.text }] };
342
+ }
343
+ return { isError: true, content: [{ type: 'text', text: errCapture.text || 'triage failed' }] };
344
+ }
345
+
346
+ /**
347
+ * `compliance` is a subcommand of the same fetch-ticket.mjs `run()` that
348
+ * `callFetch` already wraps — reuses `runFetchTicketFn`, no new dependency
349
+ * or import. See fetch-ticket.mjs's `compliance` dispatch block (thread
350
+ * printErr through it before this tool existed — see the fetch tool's own
351
+ * shipping notes for why that treatment was deferred per-tool).
352
+ */
353
+ function buildComplianceArgs({ ticket, profile }) {
354
+ const args = ['compliance', ticket];
355
+ if (profile) args.push(`--profile=${profile}`);
356
+ return args;
357
+ }
358
+
359
+ /**
360
+ * Uses the shared callFetchTicketRun — a below-threshold result is still a
361
+ * successful check: `run()` prints the report (via `printFn`) before
362
+ * evaluating the threshold, so a failing coverage percentage is real,
363
+ * useful report content, not a tool failure. Only the license/usage-gate
364
+ * case (`runComplianceCheck` returns null) skips the report print entirely
365
+ * — that's the one path that surfaces as `isError`.
366
+ */
367
+ async function callCompliance(args, deps) {
368
+ if (!args.ticket) {
369
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
370
+ }
371
+ return callFetchTicketRun(buildComplianceArgs, args, deps, 'compliance check failed');
372
+ }
373
+
183
374
  function buildDoctorArgs({ fix, profile }) {
184
375
  const args = ['--format=json'];
185
376
  if (fix === true) args.push('--fix');
@@ -403,6 +594,9 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
403
594
 
404
595
  async function handleToolsCall(params, deps) {
405
596
  const { name, arguments: args = {} } = params ?? {};
597
+ if (name === 'fetch') return callFetch(args, deps);
598
+ if (name === 'triage') return callTriage(args, deps);
599
+ if (name === 'compliance') return callCompliance(args, deps);
406
600
  if (name === 'doctor') return callDoctor(args, deps);
407
601
  if (name === 'recall_add') return callRecallAdd(args, deps);
408
602
  if (name === 'recall_search') return callRecallSearch(args, deps);
@@ -416,7 +610,7 @@ async function handleToolsCall(params, deps) {
416
610
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
417
611
  }
418
612
 
419
- async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
613
+ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
420
614
  let msg;
421
615
  try {
422
616
  msg = JSON.parse(raw);
@@ -446,7 +640,7 @@ async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRec
446
640
 
447
641
  if (method === 'tools/call') {
448
642
  try {
449
- const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
643
+ const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
450
644
  return jsonRpcResult(id, result);
451
645
  } catch (err) {
452
646
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -467,6 +661,8 @@ export function runMcpServer({
467
661
  configDir = DEFAULT_CONFIG_DIR,
468
662
  stdin = process.stdin,
469
663
  stdout = process.stdout,
664
+ runFetchTicketFn = runFetchTicket,
665
+ runTriageFn = runTriage,
470
666
  runDoctorFn = runDoctor,
471
667
  runNoteAddFn = runNoteAdd,
472
668
  runRecallFn = runRecall,
@@ -498,7 +694,7 @@ export function runMcpServer({
498
694
  // never resolving (a dropped rejection isn't a resolution) — the
499
695
  // server would hang on shutdown instead of exiting.
500
696
  queue = queue.then(async () => {
501
- const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
697
+ const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
502
698
  if (response) stdout.write(response);
503
699
  }).catch(() => {});
504
700
  });