ticketlens 0.38.30 → 0.38.32

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`, `review`, `standup`, `pr`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; 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.30",
3
+ "version": "0.38.32",
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.37.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
@@ -125,6 +127,63 @@ Display the script's stdout directly. No plan mode. Stop here.
125
127
 
126
128
  ---
127
129
 
130
+ ### Standup subcommand
131
+
132
+ If the first argument is `standup`:
133
+
134
+ Run:
135
+ ```bash
136
+ ticketlens standup $EXTRA_ARGS
137
+ ```
138
+
139
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--since=48 --format=pr --profile=acme`).
140
+
141
+ Summarizes recent git commits grouped by linked ticket key. `--since=N` (hours, default 24) or a git-compatible date expression (`--since="3 days ago"`); `--format=standup` (default) renders a per-ticket standup update, `--format=pr` renders the same grouped commits as PR-body-style markdown. Read-only, fully free tier — no license gate on any option.
142
+
143
+ Display the script's stdout directly. No plan mode. Stop here.
144
+
145
+ If this harness has TicketLens's MCP server configured (a tool named `standup` — often shown as `mcp__ticketlens__standup` — visible in your tool list), prefer it over the bash form: same output, no license gate, no shell command to construct. It accepts `since`/`format`/`profile`.
146
+
147
+ ---
148
+
149
+ ### PR subcommand
150
+
151
+ If the first argument is `pr`:
152
+
153
+ Run:
154
+ ```bash
155
+ ticketlens pr PROJ-123 $EXTRA_ARGS
156
+ ```
157
+
158
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme`). Requires a ticket key as the first positional argument.
159
+
160
+ Assembles a ready-to-paste PR description: what changed (from commits linked to the ticket), linked tickets, and — if the ticket has acceptance criteria — a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as `--compliance`/`ticketlens compliance` — running `pr` on a ticket with acceptance criteria counts against that shared monthly limit; Pro removes the cap.
161
+
162
+ Display the script's stdout directly. No plan mode. Stop here.
163
+
164
+ If this harness has TicketLens's MCP server configured (a tool named `pr` — often shown as `mcp__ticketlens__pr` — visible in your tool list), prefer it over the bash form: same output, same shared compliance-counter caveat, no shell command to construct. It accepts `ticket`/`profile`.
165
+
166
+ ---
167
+
168
+ ### Review subcommand
169
+
170
+ If the first argument is `review`:
171
+
172
+ Run:
173
+ ```bash
174
+ ticketlens review $EXTRA_ARGS
175
+ ```
176
+
177
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--base=develop --profile=acme`). No required positional argument — operates on the current branch.
178
+
179
+ Assembles PR review context from the current git branch: changed files, linked-ticket summaries from commits/branch name, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. `--base=BRANCH` (or its alias `--branch=BRANCH`) sets the diff base, auto-detecting `main`/`master`/`develop` when omitted. Read-only — never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; Pro adds the requirements-coverage and review-focus sections — same split as `--compliance`.
180
+
181
+ Display the script's stdout directly. No plan mode. Stop here.
182
+
183
+ If this harness has TicketLens's MCP server configured (a tool named `review` — often shown as `mcp__ticketlens__review` — visible in your tool list), prefer it over the bash form: same tier gate (Free: branch/files/tickets, Pro: +coverage/focus), no shell command to construct. It accepts `base`/`branch`/`profile` — `base` and `branch` are aliases; if both are given, `base` wins.
184
+
185
+ ---
186
+
128
187
  ### Fetch ticket workflow (default)
129
188
 
130
189
  ### Step 1: Validate environment
@@ -145,6 +204,8 @@ Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` a
145
204
 
146
205
  The script outputs a structured markdown TicketBrief to stdout. If it fails (exit code 1), show the stderr message to the user.
147
206
 
207
+ 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.
208
+
148
209
  ### Step 2b: Read attached files
149
210
 
150
211
  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 +461,11 @@ Use this evaluation order:
400
461
  ### Privacy
401
462
  `--compliance` never sends data anywhere. The diff stays local. All analysis is performed by Claude Code within your session context.
402
463
 
464
+ ### Standalone command
465
+ 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.
466
+
467
+ 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.
468
+
403
469
  ---
404
470
 
405
471
  ## Advanced Options
@@ -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
  }
@@ -336,17 +336,20 @@ async function countRecallInjection(recallNotes, configDir, opts) {
336
336
  }
337
337
  }
338
338
 
339
- function makeSpinner(s) {
340
- // setInterval won't fire while spawnSync blocks the event loop, so we draw
341
- // synchronously on update() and only use setInterval during async fetch phases.
342
- if (!process.stderr.isTTY) return { update: () => {}, startAnim: () => {}, done: () => {} };
339
+ // setInterval won't fire while spawnSync blocks the event loop, so we draw
340
+ // synchronously on update() and only use setInterval during async fetch phases.
341
+ // Both callers pass run()'s errStream, so the spinner shares the same isTTY and
342
+ // write target as everything else the command emits: the real process.stderr for
343
+ // a CLI user, the captured printErr under MCP (where the draws must not leak).
344
+ function makeSpinner(s, { isTTY, write }) {
345
+ if (!isTTY) return { update: () => {}, startAnim: () => {}, done: () => {} };
343
346
  const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
344
347
  let fi = 0, msg = '', lastW = 0, timerId = null, finished = false;
345
348
 
346
349
  const draw = () => {
347
350
  const raw = ` ${s.brand(frames[fi % frames.length])} ${s.dim(msg)}`;
348
351
  const vis = raw.replace(/\x1b\[[0-9;]*m/g, '');
349
- process.stderr.write(`\r${raw}${' '.repeat(Math.max(0, lastW - vis.length))}`);
352
+ write(`\r${raw}${' '.repeat(Math.max(0, lastW - vis.length))}`);
350
353
  lastW = vis.length;
351
354
  };
352
355
 
@@ -360,7 +363,7 @@ function makeSpinner(s) {
360
363
  if (finished) return;
361
364
  finished = true;
362
365
  if (timerId) { clearInterval(timerId); timerId = null; }
363
- process.stderr.write('\r\x1b[2K');
366
+ write('\r\x1b[2K');
364
367
  },
365
368
  };
366
369
  }
@@ -472,9 +475,9 @@ const RETRY_OPTIONS = [
472
475
  ];
473
476
 
474
477
  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 })
478
+ // Support opts-object injection: run(args, { env, fetcher, configDir, detectVcs, getDiff, print, printErr })
476
479
  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)) {
480
+ 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
481
  opts = envOrOpts;
479
482
  env = opts.env ?? process.env;
480
483
  fetcher = opts.fetcher ?? globalThis.fetch;
@@ -485,6 +488,27 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
485
488
  }
486
489
 
487
490
  const printFn = opts.print ?? ((chunk) => process.stdout.write(chunk));
491
+ // Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, and standup
492
+ // paths all use this now (each has an MCP tool). `ledger` above still writes directly
493
+ // to the real process.stderr (no MCP tool yet); `install-hooks` is CLI-only and never
494
+ // gets one.
495
+ const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
496
+ // Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
497
+ // so an injected print/printErr survives the retry instead of silently reverting to
498
+ // the real stdout/stderr.
499
+ const selfOpts = { env, fetcher, configDir, print: printFn, printErr: printErrFn };
500
+ // Passed to every stream-shaped helper (createSession, the profile-prompt pickers,
501
+ // handleUnknownFlags) in the bare-fetch path below. isTTY MUST reflect the real
502
+ // process.stderr.isTTY, not a hardcoded false: these helpers use it to decide
503
+ // whether to render an animated/interactive UI at all, for real CLI users too, not
504
+ // just MCP calls. Their own separate `!process.stdin.setRawMode` gate — always true
505
+ // under the MCP server, since its stdin is the JSON-RPC channel, never a TTY — is
506
+ // what actually keeps the interactive branch from firing under MCP; isTTY here only
507
+ // controls real-terminal UX and must stay accurate for that to keep working. Injectable
508
+ // via opts.isTTY so a test can assert deterministic behavior without mutating the real,
509
+ // process-global process.stderr.isTTY (unsafe to flip mid-suite — see fetch-ticket.test.mjs).
510
+ const isTTY = opts.isTTY ?? process.stderr.isTTY;
511
+ const errStream = { write: printErrFn, isTTY };
488
512
 
489
513
  if (args.includes('--help') || args.includes('-h')) {
490
514
  printFetchHelp();
@@ -528,12 +552,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
528
552
  const { assemblePr } = await import('./lib/pr-assembler.mjs');
529
553
  const ticketKeyArg = args[1];
530
554
  if (!ticketKeyArg) {
531
- process.stderr.write('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
555
+ printErrFn('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
532
556
  process.exitCode = 1;
533
557
  return;
534
558
  }
535
559
  if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
536
- process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
560
+ printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
537
561
  process.exitCode = 1;
538
562
  return;
539
563
  }
@@ -547,27 +571,33 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
547
571
  configDir: resolvedConfigDir,
548
572
  profileName: profileNamePr,
549
573
  cwd: process.cwd(),
550
- onWarning: (w) => process.stderr.write(w + '\n'),
574
+ onWarning: (w) => printErrFn(w + '\n'),
551
575
  onProfileNotFound: () => {},
552
576
  });
553
577
 
554
578
  const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
555
579
  if (!connPr.baseUrl || !hasAuthPr) {
556
- process.stderr.write('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
580
+ printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
557
581
  process.exitCode = 1;
558
582
  return;
559
583
  }
560
584
 
561
585
  const adapterPr = resolveAdapter(connPr, { fetcher });
586
+ const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
562
587
 
563
588
  try {
564
589
  const md = await assemblePr(ticketKeyArg, {
565
590
  configDir: resolvedConfigDir,
566
591
  fetchTicketFn: (key, fOpts = {}) => adapterPr.fetchTicket(key, fOpts),
592
+ // assemblePr's own runComplianceCheckFn default has no stream override
593
+ // (compliance-checker.mjs's `stream = process.stderr`), so the Pro-gate
594
+ // upgrade prompt would leak to real stderr unless threaded here — same
595
+ // treatment as the compliance dispatch block above (`stream: errStream`).
596
+ runComplianceCheckFn: (o) => complianceRunnerPr({ ...o, stream: errStream }),
567
597
  });
568
598
  printFn(md + '\n');
569
599
  } catch (err) {
570
- process.stderr.write(`Error: ${err.message}\n`);
600
+ printErrFn(`Error: ${err.message}\n`);
571
601
  process.exitCode = 1;
572
602
  }
573
603
  return;
@@ -597,12 +627,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
597
627
  if (args[0] === 'compliance') {
598
628
  const ticketKeyArg = args[1];
599
629
  if (!ticketKeyArg) {
600
- process.stderr.write('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
630
+ printErrFn('Error: "compliance" requires a ticket key. Usage: ticketlens compliance PROJ-123\n');
601
631
  process.exitCode = 1;
602
632
  return;
603
633
  }
604
634
  if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
605
- process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
635
+ printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
606
636
  process.exitCode = 1;
607
637
  return;
608
638
  }
@@ -626,13 +656,13 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
626
656
  configDir: resolvedConfigDir,
627
657
  profileName: profileNameC,
628
658
  cwd: process.cwd(),
629
- onWarning: (w) => process.stderr.write(w + '\n'),
659
+ onWarning: (w) => printErrFn(w + '\n'),
630
660
  onProfileNotFound: () => {},
631
661
  });
632
662
 
633
663
  const hasAuthC = connC.pat || (connC.email && connC.apiToken);
634
664
  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');
665
+ printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
636
666
  process.exitCode = 1;
637
667
  return;
638
668
  }
@@ -643,7 +673,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
643
673
  try {
644
674
  ticketC = await adapterC.fetchTicket(ticketKeyArg, { depth: 0 });
645
675
  } catch (err) {
646
- process.stderr.write(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
676
+ printErrFn(`Error fetching ${ticketKeyArg}: ${err.message}\n`);
647
677
  process.exitCode = 1;
648
678
  return;
649
679
  }
@@ -658,6 +688,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
658
688
  description: ticketC.description,
659
689
  ticketKey: ticketKeyArg,
660
690
  configDir: resolvedConfigDir,
691
+ stream: errStream,
661
692
  });
662
693
 
663
694
  if (complianceResult === null) {
@@ -688,7 +719,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
688
719
  const execFn = opts.execFn ?? spawnSync;
689
720
  const cwd = process.cwd();
690
721
 
691
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
722
+ const sErr = createStyler({ isTTY });
692
723
 
693
724
  // Validate flags before any git work
694
725
  const reviewFlags = args.slice(1);
@@ -699,28 +730,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
699
730
  // Detect --profile-NAME typo (dash instead of =)
700
731
  const profileDashM = flag.match(/^--profile-(.+)$/);
701
732
  if (profileDashM) {
702
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
733
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
703
734
  process.exitCode = 1;
704
735
  return;
705
736
  }
706
737
  const baseDashM = flag.match(/^--base-(.+)$/);
707
738
  if (baseDashM) {
708
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
739
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
709
740
  process.exitCode = 1;
710
741
  return;
711
742
  }
712
743
  const branchDashM = flag.match(/^--branch-(.+)$/);
713
744
  if (branchDashM) {
714
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
745
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
715
746
  process.exitCode = 1;
716
747
  return;
717
748
  }
718
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
749
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
719
750
  process.exitCode = 1;
720
751
  return;
721
752
  }
722
753
 
723
- const spinner = makeSpinner(sErr);
754
+ const spinner = makeSpinner(sErr, errStream);
724
755
  const stderrNotes = [];
725
756
 
726
757
  // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
@@ -734,7 +765,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
734
765
  const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
735
766
  if (verifyR.status !== 0) {
736
767
  spinner.done();
737
- process.stderr.write(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
768
+ printErrFn(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
738
769
  process.exitCode = 1;
739
770
  return;
740
771
  }
@@ -772,10 +803,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
772
803
  const profiles = loadProfiles(resolvedConfigDir);
773
804
  if (!profiles?.profiles?.[profileNameR]) {
774
805
  spinner.done();
775
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
806
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
776
807
  const names = Object.keys(profiles?.profiles ?? {});
777
- if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
778
- else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
808
+ if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
809
+ else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
779
810
  process.exitCode = 1;
780
811
  return;
781
812
  }
@@ -817,8 +848,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
817
848
  }
818
849
 
819
850
  spinner.done();
820
- for (const note of stderrNotes) process.stderr.write(note + '\n');
821
- if (stderrNotes.length > 0) process.stderr.write('\n');
851
+ for (const note of stderrNotes) printErrFn(note + '\n');
852
+ if (stderrNotes.length > 0) printErrFn('\n');
822
853
 
823
854
  const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
824
855
  const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
@@ -845,7 +876,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
845
876
  const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
846
877
  const execFn = opts.execFn ?? spawnSync;
847
878
  const cwd = process.cwd();
848
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
879
+ const sErr = createStyler({ isTTY });
849
880
 
850
881
  const standupFlags = args.slice(1);
851
882
 
@@ -856,29 +887,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
856
887
 
857
888
  const sinceDashM = flag.match(/^--since-(.+)$/);
858
889
  if (sinceDashM) {
859
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
890
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
860
891
  process.exitCode = 1;
861
892
  return;
862
893
  }
863
894
  const formatDashM = flag.match(/^--format-(.+)$/);
864
895
  if (formatDashM) {
865
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
896
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
866
897
  process.exitCode = 1;
867
898
  return;
868
899
  }
869
900
  const profileDashM = flag.match(/^--profile-(.+)$/);
870
901
  if (profileDashM) {
871
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
902
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
872
903
  process.exitCode = 1;
873
904
  return;
874
905
  }
875
906
  const formatValueM = flag.match(/^--format=(.+)$/);
876
907
  if (formatValueM) {
877
- process.stderr.write(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
908
+ printErrFn(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
878
909
  process.exitCode = 1;
879
910
  return;
880
911
  }
881
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
912
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
882
913
  process.exitCode = 1;
883
914
  return;
884
915
  }
@@ -899,16 +930,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
899
930
  if (profileNameS) {
900
931
  const profiles = loadProfiles(resolvedConfigDir);
901
932
  if (!profiles?.profiles?.[profileNameS]) {
902
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
933
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
903
934
  const names = Object.keys(profiles?.profiles ?? {});
904
- if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
905
- else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
935
+ if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
936
+ else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
906
937
  process.exitCode = 1;
907
938
  return;
908
939
  }
909
940
  }
910
941
 
911
- const spinner = makeSpinner(sErr);
942
+ const spinner = makeSpinner(sErr, errStream);
912
943
  const stderrNotes = [];
913
944
 
914
945
  spinner.update('Scanning git log…');
@@ -955,8 +986,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
955
986
  }
956
987
 
957
988
  spinner.done();
958
- for (const note of stderrNotes) process.stderr.write(note + '\n');
959
- if (stderrNotes.length > 0) process.stderr.write('\n');
989
+ for (const note of stderrNotes) printErrFn(note + '\n');
990
+ if (stderrNotes.length > 0) printErrFn('\n');
960
991
 
961
992
  const isPlain = standupFlags.includes('--plain');
962
993
  const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
@@ -969,12 +1000,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
969
1000
 
970
1001
  const ticketKey = args.find(a => !a.startsWith('--'));
971
1002
  if (!ticketKey) {
972
- printFetchHelp({ stream: process.stderr });
1003
+ printFetchHelp({ stream: errStream });
973
1004
  process.exitCode = 1;
974
1005
  return;
975
1006
  }
976
1007
  if (!TICKET_KEY_PATTERN.test(ticketKey)) {
977
- process.stderr.write(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
1008
+ printErrFn(`Error: "${ticketKey}" is not a valid ticket key. Expected format: PROJ-123\n`);
978
1009
  process.exitCode = 1;
979
1010
  return;
980
1011
  }
@@ -982,7 +1013,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
982
1013
  // Normalize --project= alias once at entry so all recursive calls only see --profile=
983
1014
  const projectArg = args.find(a => a.startsWith('--project='));
984
1015
  if (projectArg) {
985
- process.stderr.write(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
1016
+ printErrFn(`Hint: --project recognized as alias for --profile=${projectArg.split('=')[1]}\n\n`);
986
1017
  args = args.map(a => a.startsWith('--project=') ? `--profile=${a.split('=')[1]}` : a);
987
1018
  }
988
1019
 
@@ -992,7 +1023,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
992
1023
  const validatedArgs = await handleUnknownFlags(
993
1024
  args,
994
1025
  ['--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
1026
+ { hints: ['--stale=', '--status=', '--static'], stream: errStream } // triage-only flags — shown as hints, not applied
996
1027
  );
997
1028
  if (validatedArgs === null) { process.exitCode = 1; return; }
998
1029
  args = validatedArgs;
@@ -1006,7 +1037,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1006
1037
  const tpl = await resolveTemplate(templateSlug, { token: readCliToken(configDir), fetcher });
1007
1038
  templateSections = tpl.sections;
1008
1039
  } catch (err) {
1009
- process.stderr.write(`Error: ${err.message}\n`);
1040
+ printErrFn(`Error: ${err.message}\n`);
1010
1041
  process.exitCode = 1;
1011
1042
  return;
1012
1043
  }
@@ -1021,10 +1052,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1021
1052
  .filter(([, p]) => p.ticketPrefixes?.includes(prefix))
1022
1053
  .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
1023
1054
  if (multiMatches.length > 1) {
1024
- const picked = await promptMultipleMatches(ticketKey, multiMatches);
1055
+ const promptMultipleMatchesFn = opts.promptMultipleMatchesFn ?? promptMultipleMatches;
1056
+ const picked = await promptMultipleMatchesFn(ticketKey, multiMatches, { stream: errStream });
1025
1057
  if (!picked) { process.exitCode = 1; return; }
1026
1058
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1027
- return run(withProfile, env, fetcher, configDir);
1059
+ return run(withProfile, selfOpts);
1028
1060
  }
1029
1061
  }
1030
1062
 
@@ -1034,18 +1066,19 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1034
1066
  configDir,
1035
1067
  profileName,
1036
1068
  cwd: process.cwd(),
1037
- onWarning: (w) => process.stderr.write(w + '\n'),
1069
+ onWarning: (w) => printErrFn(w + '\n'),
1038
1070
  onProfileNotFound: (info) => { profileError = info; },
1039
1071
  });
1040
1072
 
1041
1073
  const hasAuth = conn.pat || (conn.email && conn.apiToken);
1042
1074
  if (!conn.baseUrl || !hasAuth) {
1043
1075
  if (profileError) {
1044
- const picked = await promptProfileSelect(profileError);
1076
+ const promptProfileSelectFn = opts.promptProfileSelectFn ?? promptProfileSelect;
1077
+ const picked = await promptProfileSelectFn(profileError, { stream: errStream });
1045
1078
  if (picked) {
1046
1079
  const newArgs = args.filter(a => !a.startsWith('--profile='));
1047
1080
  newArgs.push(`--profile=${picked}`);
1048
- return run(newArgs, env, fetcher, configDir);
1081
+ return run(newArgs, selfOpts);
1049
1082
  }
1050
1083
  } else {
1051
1084
  const missing = [];
@@ -1056,7 +1089,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1056
1089
  : `Missing config in profile "${conn.profileName}": ${missing.join(', ')}`;
1057
1090
  const noProfiles = !loadProfiles(configDir)?.profiles;
1058
1091
  const initHint = noProfiles ? '\nRun `ticketlens init` to set up your connection.' : '';
1059
- process.stderr.write(`Error: ${hint}${initHint}\n`);
1092
+ printErrFn(`Error: ${hint}${initHint}\n`);
1060
1093
  }
1061
1094
  process.exitCode = 1;
1062
1095
  return;
@@ -1072,10 +1105,11 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1072
1105
  const allProfiles = Object.entries(config?.profiles ?? {})
1073
1106
  .map(([name, p]) => ({ name, baseUrl: p.baseUrl || null }));
1074
1107
  if (allProfiles.length > 1) {
1075
- const picked = await promptProfileMismatch(ticketKey, conn.profileName, allProfiles);
1108
+ const promptProfileMismatchFn = opts.promptProfileMismatchFn ?? promptProfileMismatch;
1109
+ const picked = await promptProfileMismatchFn(ticketKey, conn.profileName, allProfiles, { stream: errStream });
1076
1110
  if (picked && picked !== conn.profileName) {
1077
1111
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1078
- return run(withProfile, env, fetcher, configDir);
1112
+ return run(withProfile, selfOpts);
1079
1113
  }
1080
1114
  }
1081
1115
  }
@@ -1113,7 +1147,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1113
1147
  if (cached) {
1114
1148
  const s = createStyler({ isTTY: process.stderr.isTTY });
1115
1149
  const age = briefCacheAge(cached.fetchedAt);
1116
- process.stderr.write(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
1150
+ printErrFn(` ${s.dim('○')} ${s.dim(`${ticketKey} · from cache (${age}) · --no-cache to refresh`)}\n\n`);
1117
1151
 
1118
1152
  const allText = [cached.ticket.description, ...cached.ticket.comments.map(c => c.body)].filter(Boolean).join('\n');
1119
1153
  const codeRefs = extractCodeReferences(allText);
@@ -1177,7 +1211,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1177
1211
  }
1178
1212
  // ──────────────────────────────────────────────────────────────────────────
1179
1213
 
1180
- const session = createSession(conn);
1214
+ const session = createSession(conn, { stream: errStream });
1181
1215
 
1182
1216
  // Load all profiles once for use in the switch-profile retry option.
1183
1217
  const allProfiles = Object.entries(loadProfiles(configDir)?.profiles ?? {})
@@ -1212,23 +1246,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1212
1246
  }
1213
1247
 
1214
1248
  if (RETRY_OPTIONS[retryIndex].value === 'switch') {
1215
- const picked = await promptSwitchProfile(conn.profileName, allProfiles);
1249
+ const promptSwitchProfileFn = opts.promptSwitchProfileFn ?? promptSwitchProfile;
1250
+ const picked = await promptSwitchProfileFn(conn.profileName, allProfiles, { stream: errStream });
1216
1251
  if (picked && picked !== conn.profileName) {
1217
1252
  const withProfile = [...args.filter(a => !a.startsWith('--profile=')), `--profile=${picked}`];
1218
- return run(withProfile, env, fetcher, configDir);
1253
+ return run(withProfile, selfOpts);
1219
1254
  }
1220
1255
  // Cancelled switch — exit
1221
1256
  process.exitCode = 1;
1222
1257
  return;
1223
1258
  }
1224
1259
 
1225
- // 'retry' — loop with updated spinner message
1260
+ // 'retry' — loop with updated spinner message. This whole retry-prompt
1261
+ // branch only runs when `!process.stderr.isTTY || !process.stdin.setRawMode`
1262
+ // is false — i.e. never under the MCP server (stdin is always piped there) —
1263
+ // so its remaining process.stderr.write calls are intentionally left
1264
+ // unconverted, dead code under MCP by construction, not an oversight.
1226
1265
  isRetry = true;
1227
1266
  process.stderr.write('\n');
1228
1267
  }
1229
1268
  }
1230
1269
  session.connected();
1231
- process.stderr.write('\n');
1270
+ printErrFn('\n');
1232
1271
 
1233
1272
  // ── Confluence page fetching ───────────────────────────────────────────────
1234
1273
  // Fetch pages referenced via Jira Remote Links (Confluence-only, non-blocking).
@@ -1276,7 +1315,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1276
1315
  requirements: extractRequirements(desc),
1277
1316
  };
1278
1317
  const result = dtm.detectDrift(current, prior);
1279
- if (result.drifted) process.stderr.write(dtm.formatDriftWarning(ticketKey, result.changes));
1318
+ if (result.drifted) printErrFn(dtm.formatDriftWarning(ticketKey, result.changes));
1280
1319
  }
1281
1320
  dtm.writeSnapshot(ticketKey, ticket, { profile: profileName, configDir: resolvedConfigDir, branch });
1282
1321
  }
@@ -1287,14 +1326,14 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1287
1326
  const downloadable = (ticket.attachments ?? []).filter(a => a.content);
1288
1327
  if (downloadable.length > 0) {
1289
1328
  const noun = downloadable.length === 1 ? 'attachment' : 'attachments';
1290
- process.stderr.write(`Downloading ${downloadable.length} ${noun}…\n`);
1329
+ printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
1291
1330
  // attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
1292
1331
  const jiraEnv = buildJiraEnv(conn);
1293
1332
  ticket.localAttachments = await downloadAttachments(ticket, {
1294
1333
  env: jiraEnv,
1295
1334
  fetcher,
1296
1335
  noCache: args.includes('--no-cache'),
1297
- onProgress: (msg) => process.stderr.write(msg + '\n'),
1336
+ onProgress: (msg) => printErrFn(msg + '\n'),
1298
1337
  allowPrivateIp: conn.allowPrivateIp,
1299
1338
  });
1300
1339
  const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
@@ -1302,8 +1341,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1302
1341
  const parts = [];
1303
1342
  if (downloaded > 0) parts.push(`${downloaded} downloaded`);
1304
1343
  if (cached > 0) parts.push(`${cached} cached`);
1305
- if (parts.length > 0) process.stderr.write(` ✓ ${parts.join(', ')}\n`);
1306
- process.stderr.write('\n');
1344
+ if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
1345
+ printErrFn('\n');
1307
1346
  }
1308
1347
  }
1309
1348
 
@@ -1369,7 +1408,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
1369
1408
  // Contextual upsell: after a deep traversal with a substantial graph, nudge toward --summarize
1370
1409
  if (depth > 1 && !args.includes('--summarize') && (ticket.linked?.length ?? 0) >= 2) {
1371
1410
  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`);
1411
+ printErrFn(` ${s.dim('○')} ${s.dim('Tip: large briefs compress further — `--summarize` condenses this to a single AI digest ($8/mo)')}\n`);
1373
1412
  }
1374
1413
  }
1375
1414
 
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -642,18 +642,24 @@ 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]')}`,
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')},`,
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
- ` 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`,
653
- ` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
654
- ` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
655
- ` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
656
- ` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
645
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
646
+ '',
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('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')},`,
649
+ ` ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')},`,
650
+ ` ${s.cyan('ticket_create')} — for any MCP-compatible AI harness, not just Claude Code. Thin`,
651
+ ` adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('note add')}/`,
652
+ ` ${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.`,
653
+ ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
654
+ ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
655
+ ` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
656
+ ` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
657
+ ` requires Pro as a plain license check, not a draw on that same counter; every`,
658
+ ` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
659
+ ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
660
+ ` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
661
+ ` duplicate — different semantics than Jira/Linear's relationship-only add;`,
662
+ ` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
657
663
  ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
658
664
  ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
659
665
  ` Long-running — exits when the client closes stdin.`,
@@ -663,9 +669,8 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
663
669
  ` ${s.dim('restart or reconnect the MCP client to pick up the new schema. The running')}`,
664
670
  ` ${s.dim('server is always current; only the client-side copy goes stale.')}`,
665
671
  '',
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.')}`,
672
+ ` ${s.dim('Starting the server itself is ungated, same as `mcp install`; each tool still')}`,
673
+ ` ${s.dim('enforces its own license check at call time.')}`,
669
674
  '',
670
675
  ` ${s.bold('OPTIONS')}`,
671
676
  '',
@@ -25,10 +25,95 @@ 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
+ },
81
+ {
82
+ name: 'review',
83
+ description: 'Assemble PR review context from the current git branch — changed files, linked-ticket summaries, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. Read-only; never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; TicketLens Pro adds the requirements-coverage and review-focus sections — same split as the `compliance` tool.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ base: { type: 'string', description: 'Base branch to diff against. Auto-detects main/master/develop when omitted. Alias of `branch` — if both are given, `base` wins.' },
88
+ branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
89
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
90
+ },
91
+ },
92
+ },
93
+ {
94
+ name: 'standup',
95
+ description: 'Summarize recent git commits grouped by linked ticket — a standup update or, with format:"pr", PR-body-style formatting. Read-only, fully free tier — no license gate on any option.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ since: { type: 'string', description: 'How far back to scan — an integer number of hours ("24") or a git-compatible date expression ("3 days ago"). Defaults to 24 hours.' },
100
+ format: { type: 'string', enum: ['standup', 'pr'], description: 'Output shape: "standup" (default) groups commits under a per-ticket standup update; "pr" renders the same grouped commits as PR-body-style markdown.' },
101
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
102
+ },
103
+ },
104
+ },
105
+ {
106
+ name: 'pr',
107
+ description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
112
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
113
+ },
114
+ required: ['ticket'],
115
+ },
116
+ },
32
117
  {
33
118
  name: 'doctor',
34
119
  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 +265,193 @@ function capturingStream() {
180
265
  };
181
266
  }
182
267
 
268
+ /**
269
+ * Deliberately v1-minimal — only the three flags with zero cost/AI-provider
270
+ * implications (see the 49b scoping decision). If `--summarize`/`--handoff`/
271
+ * `--budget=`/`--compliance`/`--template=` are ever added here, note that
272
+ * their error/progress output inside fetch-ticket.mjs's bare-fetch path
273
+ * (applySummarize/applyHandoff/budgetPruner.pruneBrief/showUpgradePrompt)
274
+ * still writes to the real process.stderr, not the injected printErr —
275
+ * unlike every path reachable through this function today. Thread printErr
276
+ * through those call sites first, or their failures will silently collapse
277
+ * to callFetch's generic 'fetch failed' instead of the real reason.
278
+ */
279
+ function buildFetchArgs({ ticket, profile, depth }) {
280
+ const args = [ticket];
281
+ if (profile) args.push(`--profile=${profile}`);
282
+ if (depth !== undefined) args.push(`--depth=${depth}`);
283
+ return args;
284
+ }
285
+
286
+ /**
287
+ * Shared by every dispatch that goes through fetch-ticket.mjs's `run()`
288
+ * (currently `fetch`, `compliance`, `review`, `standup`, and `pr` — all but
289
+ * `fetch` are subcommands of that same function). `run()` has no {ok} return value
290
+ * (unlike every other wrapped function) — failure is signaled by mutating
291
+ * process.exitCode, which is unsafe to read in a long-lived server (one
292
+ * failed call would poison the whole process's exit code forever). Success
293
+ * is instead determined by whether `print` ever received real content —
294
+ * every success path (cache hit, fresh fetch, handoff, a printed report
295
+ * regardless of pass/fail) calls it exactly once; every failure path
296
+ * returns before reaching it. errCapture may contain informational
297
+ * chatter (cache notice, download progress) even on success — only read
298
+ * on the failure branch, where it carries the actual error message.
299
+ */
300
+ async function callFetchTicketRun(buildArgsFn, args, { configDir, runFetchTicketFn }, fallbackErrorText) {
301
+ const capture = capturingStream();
302
+ const errCapture = capturingStream();
303
+ await runFetchTicketFn(buildArgsFn(args), {
304
+ configDir,
305
+ env: process.env,
306
+ fetcher: globalThis.fetch,
307
+ print: capture.write,
308
+ printErr: errCapture.write,
309
+ });
310
+ if (capture.text) {
311
+ return { content: [{ type: 'text', text: capture.text }] };
312
+ }
313
+ return { isError: true, content: [{ type: 'text', text: errCapture.text || fallbackErrorText }] };
314
+ }
315
+
316
+ async function callFetch(args, deps) {
317
+ if (!args.ticket) {
318
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
319
+ }
320
+ return callFetchTicketRun(buildFetchArgs, args, deps, 'fetch failed');
321
+ }
322
+
323
+ /**
324
+ * Deliberately excludes --push/--share — those sync/share a snapshot as a
325
+ * human-collaboration side effect (Console notification queue, shareable
326
+ * link), not "what needs attention" read value. See the 49b scoping memory.
327
+ */
328
+ function buildTriageArgs({ profile, stale, status, sort, save, all, digest, assignee, sprint, export: exportFormat, project, label, priority }) {
329
+ const args = ['--plain'];
330
+ if (profile) args.push(`--profile=${profile}`);
331
+ if (stale !== undefined) args.push(`--stale=${stale}`);
332
+ if (Array.isArray(status) && status.length > 0) args.push(`--status=${status.join(',')}`);
333
+ if (sort) args.push(`--sort=${sort}`);
334
+ if (save) args.push(`--save=${save}`);
335
+ if (all === true) args.push('--all');
336
+ if (digest === true) args.push('--digest');
337
+ if (assignee) args.push(`--assignee=${assignee}`);
338
+ if (sprint) args.push(`--sprint=${sprint}`);
339
+ if (exportFormat) args.push(`--export=${exportFormat}`);
340
+ if (project) args.push(`--project=${project}`);
341
+ if (Array.isArray(label) && label.length > 0) args.push(`--label=${label.join(',')}`);
342
+ if (priority) args.push(`--priority=${priority}`);
343
+ return args;
344
+ }
345
+
346
+ /**
347
+ * runTriage has the same no-{ok}-return architecture as runFetchTicket —
348
+ * success is "did print receive the summary," failure is whatever landed in
349
+ * the injected stream. One deliberate exception: `--digest` delivers to the
350
+ * backend and prints NOTHING to `print` on success (locked by
351
+ * fetch-my-tickets.test.mjs's own "stdout should be empty" test) — an empty
352
+ * capture there means delivery succeeded, not that it failed. Only a gate
353
+ * rejection (Pro license, captured in `stream`) or a thrown delivery error
354
+ * (caught below) signal an actual digest failure.
355
+ */
356
+ async function callTriage(args, { configDir, runTriageFn }) {
357
+ const capture = capturingStream();
358
+ const errCapture = capturingStream();
359
+ try {
360
+ await runTriageFn(buildTriageArgs(args), {
361
+ configDir,
362
+ env: process.env,
363
+ fetcher: globalThis.fetch,
364
+ print: capture.write,
365
+ stream: errCapture,
366
+ });
367
+ } catch (err) {
368
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
369
+ }
370
+ if (args.digest === true) {
371
+ if (errCapture.text) {
372
+ return { isError: true, content: [{ type: 'text', text: errCapture.text }] };
373
+ }
374
+ return { content: [{ type: 'text', text: 'Digest delivered.' }] };
375
+ }
376
+ if (capture.text) {
377
+ return { content: [{ type: 'text', text: capture.text }] };
378
+ }
379
+ return { isError: true, content: [{ type: 'text', text: errCapture.text || 'triage failed' }] };
380
+ }
381
+
382
+ /**
383
+ * `compliance` is a subcommand of the same fetch-ticket.mjs `run()` that
384
+ * `callFetch` already wraps — reuses `runFetchTicketFn`, no new dependency
385
+ * or import. See fetch-ticket.mjs's `compliance` dispatch block (thread
386
+ * printErr through it before this tool existed — see the fetch tool's own
387
+ * shipping notes for why that treatment was deferred per-tool).
388
+ */
389
+ function buildComplianceArgs({ ticket, profile }) {
390
+ const args = ['compliance', ticket];
391
+ if (profile) args.push(`--profile=${profile}`);
392
+ return args;
393
+ }
394
+
395
+ /**
396
+ * Uses the shared callFetchTicketRun — a below-threshold result is still a
397
+ * successful check: `run()` prints the report (via `printFn`) before
398
+ * evaluating the threshold, so a failing coverage percentage is real,
399
+ * useful report content, not a tool failure. Only the license/usage-gate
400
+ * case (`runComplianceCheck` returns null) skips the report print entirely
401
+ * — that's the one path that surfaces as `isError`.
402
+ */
403
+ async function callCompliance(args, deps) {
404
+ if (!args.ticket) {
405
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
406
+ }
407
+ return callFetchTicketRun(buildComplianceArgs, args, deps, 'compliance check failed');
408
+ }
409
+
410
+ /**
411
+ * `review`/`standup`/`pr` are all subcommands of the same fetch-ticket.mjs
412
+ * `run()` that `callFetch`/`callCompliance` already wrap — reuse
413
+ * `runFetchTicketFn`, no new dependency. Each subcommand's error/progress
414
+ * paths (including makeSpinner's writes) were threaded through opts.printErr
415
+ * as part of adding these tools — see fetch-ticket.mjs's `review`/`standup`/
416
+ * `pr` dispatch blocks and makeSpinner's injectable {isTTY, write}.
417
+ */
418
+ function buildReviewArgs({ base, branch, profile }) {
419
+ const args = ['review'];
420
+ if (base) args.push(`--base=${base}`);
421
+ else if (branch) args.push(`--branch=${branch}`);
422
+ if (profile) args.push(`--profile=${profile}`);
423
+ return args;
424
+ }
425
+
426
+ async function callReview(args, deps) {
427
+ return callFetchTicketRun(buildReviewArgs, args, deps, 'review failed');
428
+ }
429
+
430
+ function buildStandupArgs({ since, format, profile }) {
431
+ const args = ['standup'];
432
+ if (since !== undefined) args.push(`--since=${since}`);
433
+ if (format) args.push(`--format=${format}`);
434
+ if (profile) args.push(`--profile=${profile}`);
435
+ return args;
436
+ }
437
+
438
+ async function callStandup(args, deps) {
439
+ return callFetchTicketRun(buildStandupArgs, args, deps, 'standup failed');
440
+ }
441
+
442
+ function buildPrArgs({ ticket, profile }) {
443
+ const args = ['pr', ticket];
444
+ if (profile) args.push(`--profile=${profile}`);
445
+ return args;
446
+ }
447
+
448
+ async function callPr(args, deps) {
449
+ if (!args.ticket) {
450
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
451
+ }
452
+ return callFetchTicketRun(buildPrArgs, args, deps, 'pr failed');
453
+ }
454
+
183
455
  function buildDoctorArgs({ fix, profile }) {
184
456
  const args = ['--format=json'];
185
457
  if (fix === true) args.push('--fix');
@@ -403,6 +675,12 @@ async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
403
675
 
404
676
  async function handleToolsCall(params, deps) {
405
677
  const { name, arguments: args = {} } = params ?? {};
678
+ if (name === 'fetch') return callFetch(args, deps);
679
+ if (name === 'triage') return callTriage(args, deps);
680
+ if (name === 'compliance') return callCompliance(args, deps);
681
+ if (name === 'review') return callReview(args, deps);
682
+ if (name === 'standup') return callStandup(args, deps);
683
+ if (name === 'pr') return callPr(args, deps);
406
684
  if (name === 'doctor') return callDoctor(args, deps);
407
685
  if (name === 'recall_add') return callRecallAdd(args, deps);
408
686
  if (name === 'recall_search') return callRecallSearch(args, deps);
@@ -416,7 +694,7 @@ async function handleToolsCall(params, deps) {
416
694
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
417
695
  }
418
696
 
419
- async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
697
+ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
420
698
  let msg;
421
699
  try {
422
700
  msg = JSON.parse(raw);
@@ -446,7 +724,7 @@ async function handleMessage(raw, { configDir, runDoctorFn, runNoteAddFn, runRec
446
724
 
447
725
  if (method === 'tools/call') {
448
726
  try {
449
- const result = await handleToolsCall(params, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
727
+ const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
450
728
  return jsonRpcResult(id, result);
451
729
  } catch (err) {
452
730
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -467,6 +745,8 @@ export function runMcpServer({
467
745
  configDir = DEFAULT_CONFIG_DIR,
468
746
  stdin = process.stdin,
469
747
  stdout = process.stdout,
748
+ runFetchTicketFn = runFetchTicket,
749
+ runTriageFn = runTriage,
470
750
  runDoctorFn = runDoctor,
471
751
  runNoteAddFn = runNoteAdd,
472
752
  runRecallFn = runRecall,
@@ -498,7 +778,7 @@ export function runMcpServer({
498
778
  // never resolving (a dropped rejection isn't a resolution) — the
499
779
  // server would hang on shutdown instead of exiting.
500
780
  queue = queue.then(async () => {
501
- const response = await handleMessage(line, { configDir, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
781
+ const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
502
782
  if (response) stdout.write(response);
503
783
  }).catch(() => {});
504
784
  });