ticketlens 0.38.31 → 0.38.33

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 `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).
456
+ **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `fetch`, `triage`, `compliance`, `review`, `standup`, `pr`, `stats`, `history`, `collisions`, `doctor`, `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `review` is Free for branch/files/ticket context, with its coverage/focus section requiring Pro as a plain license check — it does not draw from that same monthly counter; `stats` is Free with a 7-day lookback cap, Pro extends it to 30 days, same split as the CLI (`ticketlens stats --help`); `history` reads local triage history only (zero network) and requires Pro; `collisions` requires `ticketlens login` (Console access) plus a Team license; every other tool needs Pro. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
457
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
 
@@ -20,7 +20,7 @@ import { deleteProfile, loadProfiles, saveCredentialKey, resolveRecallStrictness
20
20
  import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
21
21
  import { runDoctor } from '../skills/jtb/scripts/lib/doctor-command.mjs';
22
22
  import {
23
- printHelp, printProfiles, printHistoryHelp,
23
+ printHelp, printProfiles,
24
24
  printLoginHelp, printLogoutHelp, printSyncHelp,
25
25
  printActivateHelp, printLicenseHelp, printDeleteHelp,
26
26
  printProfilesHelp, printScheduleHelp,
@@ -142,28 +142,11 @@ switch (command) {
142
142
  }
143
143
 
144
144
  case 'history': {
145
- if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printHistoryHelp(); break; }
146
- if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
147
- const ticketKey = cmdArgs[0];
148
- if (!ticketKey || ticketKey.startsWith('-')) {
149
- process.stderr.write('Usage: ticketlens history TICKET-KEY\n');
145
+ const { runHistory } = await import('../skills/jtb/scripts/lib/run-history.mjs');
146
+ runHistory(cmdArgs).catch(err => {
147
+ process.stderr.write(`Error: ${err.message}\n`);
150
148
  process.exitCode = 1;
151
- break;
152
- }
153
- const { queryTicketHistory } = await import('../skills/jtb/scripts/lib/triage-history.mjs');
154
- const entries = queryTicketHistory(ticketKey);
155
- if (entries.length === 0) {
156
- process.stdout.write(`No triage history found for ${ticketKey}.\n`);
157
- break;
158
- }
159
- const hs = createStyler({ isTTY: process.stdout.isTTY });
160
- process.stdout.write(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
161
- for (const e of entries) {
162
- const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
163
- const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
164
- process.stdout.write(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
165
- }
166
- process.stdout.write('\n');
149
+ });
167
150
  break;
168
151
  }
169
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.31",
3
+ "version": "0.38.33",
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.36.0 -->
1
+ <!-- jtb-skill-version: 0.38.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.
@@ -125,6 +125,103 @@ Requires a Team license and at least one teammate in the same group. Compares yo
125
125
 
126
126
  Display the script's stdout directly. No plan mode. Stop here.
127
127
 
128
+ If this harness has TicketLens's MCP server configured (a tool named `collisions` — often shown as `mcp__ticketlens__collisions` — visible in your tool list), prefer it over the bash form: same Team-license gate, same `ticketlens login` requirement, no shell command to construct. It accepts `json`/`plain`.
129
+
130
+ ---
131
+
132
+ ### Stats subcommand
133
+
134
+ If the first argument is `stats`:
135
+
136
+ Run:
137
+ ```bash
138
+ ticketlens stats $EXTRA_ARGS
139
+ ```
140
+
141
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--days=14 --format=json --profile=acme`).
142
+
143
+ Shows response-time and triage-cadence metrics from local triage history: avg/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. `--days=N` (default 7; Free silently caps at 7, Pro allows up to 30); `--format=plain` (default, human-readable table) or `--format=json` (scripting).
144
+
145
+ Display the script's stdout directly. No plan mode. Stop here.
146
+
147
+ If this harness has TicketLens's MCP server configured (a tool named `stats` — often shown as `mcp__ticketlens__stats` — visible in your tool list), prefer it over the bash form: same Free/Pro day-cap split, no shell command to construct. It accepts `profile`/`days`/`format`.
148
+
149
+ ---
150
+
151
+ ### History subcommand
152
+
153
+ If the first argument is `history`:
154
+
155
+ Run:
156
+ ```bash
157
+ ticketlens history TICKET-KEY
158
+ ```
159
+
160
+ Requires a ticket key as the first positional argument. Requires a Pro license.
161
+
162
+ Shows the ticket's urgency timeline from local triage history — every prior triage scan that surfaced it, with the urgency level and reason computed at that point in time. Read-only, entirely local — no network call, no other flags.
163
+
164
+ Display the script's stdout directly. No plan mode. Stop here.
165
+
166
+ If this harness has TicketLens's MCP server configured (a tool named `history` — often shown as `mcp__ticketlens__history` — visible in your tool list), prefer it over the bash form: same Pro gate, no shell command to construct. It accepts `ticket`.
167
+
168
+ ---
169
+
170
+ ### Standup subcommand
171
+
172
+ If the first argument is `standup`:
173
+
174
+ Run:
175
+ ```bash
176
+ ticketlens standup $EXTRA_ARGS
177
+ ```
178
+
179
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--since=48 --format=pr --profile=acme`).
180
+
181
+ 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.
182
+
183
+ Display the script's stdout directly. No plan mode. Stop here.
184
+
185
+ 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`.
186
+
187
+ ---
188
+
189
+ ### PR subcommand
190
+
191
+ If the first argument is `pr`:
192
+
193
+ Run:
194
+ ```bash
195
+ ticketlens pr PROJ-123 $EXTRA_ARGS
196
+ ```
197
+
198
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--profile=acme`). Requires a ticket key as the first positional argument.
199
+
200
+ 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.
201
+
202
+ Display the script's stdout directly. No plan mode. Stop here.
203
+
204
+ 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`.
205
+
206
+ ---
207
+
208
+ ### Review subcommand
209
+
210
+ If the first argument is `review`:
211
+
212
+ Run:
213
+ ```bash
214
+ ticketlens review $EXTRA_ARGS
215
+ ```
216
+
217
+ Where `$EXTRA_ARGS` are any flags passed (e.g. `--base=develop --profile=acme`). No required positional argument — operates on the current branch.
218
+
219
+ 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`.
220
+
221
+ Display the script's stdout directly. No plan mode. Stop here.
222
+
223
+ 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.
224
+
128
225
  ---
129
226
 
130
227
  ### Fetch ticket workflow (default)
@@ -99,6 +99,50 @@ export function hasRecentCapture(cwd, now = Date.now()) {
99
99
  return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
100
100
  }
101
101
 
102
+ /**
103
+ * Cross-session NAG marker (backlog #14) — same shape as lastCapturePath/
104
+ * readLastCaptureAt/writeLastCaptureAt/hasRecentCapture above, but records
105
+ * "we already blocked once for this directory" instead of "a note was
106
+ * added". Closes a gap those functions never covered: they bridge a
107
+ * session_id rollover only when a REAL capture landed, but a dismissed nag
108
+ * ("genuinely nothing qualified" — the hook's own suggested response) was
109
+ * never remembered anywhere. Since compaction/resume mints a brand-new
110
+ * session_id (see recall-nudge-stop.mjs's docstring), and that resets the
111
+ * per-session_id stopChecked gate, one long working session with several
112
+ * compaction cycles could get nagged repeatedly for the same still-ongoing
113
+ * work — a real, reported friction source (backlog #14), not hypothetical.
114
+ * Deliberately a separate marker file from lastCapture (not folded into
115
+ * it): a nag and a capture are different facts, and conflating them would
116
+ * make hasRecentCapture's "a real capture landed" guarantee ambiguous.
117
+ * Reuses CAPTURE_FRESHNESS_MS rather than a second magic number — the same
118
+ * 2-hour window is a reasonable proxy for "still the same working session"
119
+ * in both cases, and a distinct constant isn't justified by anything
120
+ * observed so far.
121
+ */
122
+ export function lastNagPath(cwd) {
123
+ const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
124
+ return path.join(privateTmpDir(), `lastnag-${hash}.json`);
125
+ }
126
+
127
+ export function readLastNagAt(cwd) {
128
+ try {
129
+ return JSON.parse(fs.readFileSync(lastNagPath(cwd), 'utf8')).lastNagAt ?? 0;
130
+ } catch {
131
+ return 0;
132
+ }
133
+ }
134
+
135
+ export function writeLastNagAt(cwd, timestamp) {
136
+ try {
137
+ fs.writeFileSync(lastNagPath(cwd), JSON.stringify({ lastNagAt: timestamp }));
138
+ } catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
139
+ }
140
+
141
+ export function hasRecentNag(cwd, now = Date.now()) {
142
+ const lastNagAt = readLastNagAt(cwd);
143
+ return lastNagAt > 0 && (now - lastNagAt) < CAPTURE_FRESHNESS_MS;
144
+ }
145
+
102
146
  /**
103
147
  * Reads the transcript (JSONL) and returns simple booleans about what
104
148
  * happened this session. Best-effort: any read/parse failure returns all
@@ -15,7 +15,12 @@
15
15
  * survive a compaction/resume event — that hands this hook a brand-new
16
16
  * session_id, a blank dedup state, AND a blank transcript file, so a real
17
17
  * earlier capture becomes invisible. The cross-session lastCapture marker
18
- * (keyed by cwd, not session_id) is what actually bridges that boundary.
18
+ * (keyed by cwd, not session_id) is what actually bridges that boundary
19
+ * for a genuine capture. The parallel lastNag marker (backlog #14) bridges
20
+ * the same boundary for a DISMISSED nag: without it, a session that already
21
+ * got its one nag and was told "genuinely nothing qualified" would nag
22
+ * again after the next compaction/resume rollover, since that dismissal
23
+ * was never recorded anywhere — only a real capture was.
19
24
  *
20
25
  * Which of the two cases above actually blocks is governed by the active
21
26
  * profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
@@ -30,7 +35,7 @@
30
35
  * profile, same as before (backlog #12, design spec §6).
31
36
  */
32
37
 
33
- import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
38
+ import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
34
39
  import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
35
40
 
36
41
  const input = readStdinJson();
@@ -61,8 +66,13 @@ if (hasRecentCapture(cwd)) {
61
66
  process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
62
67
  }
63
68
 
69
+ if (hasRecentNag(cwd)) {
70
+ process.exit(0); // already nagged recently in this same directory, just under a different session_id — a compaction/resume rollover, not a fresh session (backlog #14)
71
+ }
72
+
64
73
  state.stopChecked = true;
65
74
  writeState(sessionId, state);
75
+ writeLastNagAt(cwd, Date.now());
66
76
 
67
77
  if (sawRecallFlag) {
68
78
  process.stderr.write(
@@ -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
  }
@@ -485,10 +488,10 @@ 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));
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).
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.
492
495
  const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
493
496
  // Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
494
497
  // so an injected print/printErr survives the retry instead of silently reverting to
@@ -508,7 +511,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
508
511
  const errStream = { write: printErrFn, isTTY };
509
512
 
510
513
  if (args.includes('--help') || args.includes('-h')) {
511
- printFetchHelp();
514
+ // stdout's own isTTY, not the `isTTY` above (that one is documented as
515
+ // reflecting process.stderr.isTTY for the stderr-bound helpers below).
516
+ printFetchHelp({ stream: { write: printFn, isTTY: process.stdout.isTTY } });
512
517
  return;
513
518
  }
514
519
 
@@ -549,12 +554,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
549
554
  const { assemblePr } = await import('./lib/pr-assembler.mjs');
550
555
  const ticketKeyArg = args[1];
551
556
  if (!ticketKeyArg) {
552
- process.stderr.write('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
557
+ printErrFn('Error: "pr" requires a ticket key. Usage: ticketlens pr PROJ-123\n');
553
558
  process.exitCode = 1;
554
559
  return;
555
560
  }
556
561
  if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
557
- process.stderr.write(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
562
+ printErrFn(`Error: "${ticketKeyArg}" is not a valid ticket key. Expected format: PROJ-123\n`);
558
563
  process.exitCode = 1;
559
564
  return;
560
565
  }
@@ -568,27 +573,33 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
568
573
  configDir: resolvedConfigDir,
569
574
  profileName: profileNamePr,
570
575
  cwd: process.cwd(),
571
- onWarning: (w) => process.stderr.write(w + '\n'),
576
+ onWarning: (w) => printErrFn(w + '\n'),
572
577
  onProfileNotFound: () => {},
573
578
  });
574
579
 
575
580
  const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
576
581
  if (!connPr.baseUrl || !hasAuthPr) {
577
- process.stderr.write('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
582
+ printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
578
583
  process.exitCode = 1;
579
584
  return;
580
585
  }
581
586
 
582
587
  const adapterPr = resolveAdapter(connPr, { fetcher });
588
+ const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
583
589
 
584
590
  try {
585
591
  const md = await assemblePr(ticketKeyArg, {
586
592
  configDir: resolvedConfigDir,
587
593
  fetchTicketFn: (key, fOpts = {}) => adapterPr.fetchTicket(key, fOpts),
594
+ // assemblePr's own runComplianceCheckFn default has no stream override
595
+ // (compliance-checker.mjs's `stream = process.stderr`), so the Pro-gate
596
+ // upgrade prompt would leak to real stderr unless threaded here — same
597
+ // treatment as the compliance dispatch block above (`stream: errStream`).
598
+ runComplianceCheckFn: (o) => complianceRunnerPr({ ...o, stream: errStream }),
588
599
  });
589
600
  printFn(md + '\n');
590
601
  } catch (err) {
591
- process.stderr.write(`Error: ${err.message}\n`);
602
+ printErrFn(`Error: ${err.message}\n`);
592
603
  process.exitCode = 1;
593
604
  }
594
605
  return;
@@ -710,7 +721,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
710
721
  const execFn = opts.execFn ?? spawnSync;
711
722
  const cwd = process.cwd();
712
723
 
713
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
724
+ const sErr = createStyler({ isTTY });
714
725
 
715
726
  // Validate flags before any git work
716
727
  const reviewFlags = args.slice(1);
@@ -721,28 +732,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
721
732
  // Detect --profile-NAME typo (dash instead of =)
722
733
  const profileDashM = flag.match(/^--profile-(.+)$/);
723
734
  if (profileDashM) {
724
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
735
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
725
736
  process.exitCode = 1;
726
737
  return;
727
738
  }
728
739
  const baseDashM = flag.match(/^--base-(.+)$/);
729
740
  if (baseDashM) {
730
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
741
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--base=${baseDashM[1]}`)}?\n`);
731
742
  process.exitCode = 1;
732
743
  return;
733
744
  }
734
745
  const branchDashM = flag.match(/^--branch-(.+)$/);
735
746
  if (branchDashM) {
736
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
747
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--branch=${branchDashM[1]}`)}?\n`);
737
748
  process.exitCode = 1;
738
749
  return;
739
750
  }
740
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
751
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens review [--base=BRANCH] [--branch=BRANCH] [--profile=NAME]')}\n`);
741
752
  process.exitCode = 1;
742
753
  return;
743
754
  }
744
755
 
745
- const spinner = makeSpinner(sErr);
756
+ const spinner = makeSpinner(sErr, errStream);
746
757
  const stderrNotes = [];
747
758
 
748
759
  // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
@@ -756,7 +767,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
756
767
  const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
757
768
  if (verifyR.status !== 0) {
758
769
  spinner.done();
759
- process.stderr.write(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
770
+ printErrFn(`${sErr.red('✖')} Branch "${baseBranch}" not found in this repository.\n`);
760
771
  process.exitCode = 1;
761
772
  return;
762
773
  }
@@ -794,10 +805,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
794
805
  const profiles = loadProfiles(resolvedConfigDir);
795
806
  if (!profiles?.profiles?.[profileNameR]) {
796
807
  spinner.done();
797
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
808
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
798
809
  const names = Object.keys(profiles?.profiles ?? {});
799
- if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
800
- else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
810
+ if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
811
+ else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
801
812
  process.exitCode = 1;
802
813
  return;
803
814
  }
@@ -839,8 +850,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
839
850
  }
840
851
 
841
852
  spinner.done();
842
- for (const note of stderrNotes) process.stderr.write(note + '\n');
843
- if (stderrNotes.length > 0) process.stderr.write('\n');
853
+ for (const note of stderrNotes) printErrFn(note + '\n');
854
+ if (stderrNotes.length > 0) printErrFn('\n');
844
855
 
845
856
  const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
846
857
  const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
@@ -867,7 +878,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
867
878
  const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
868
879
  const execFn = opts.execFn ?? spawnSync;
869
880
  const cwd = process.cwd();
870
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
881
+ const sErr = createStyler({ isTTY });
871
882
 
872
883
  const standupFlags = args.slice(1);
873
884
 
@@ -878,29 +889,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
878
889
 
879
890
  const sinceDashM = flag.match(/^--since-(.+)$/);
880
891
  if (sinceDashM) {
881
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
892
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--since=${sinceDashM[1]}`)}?\n`);
882
893
  process.exitCode = 1;
883
894
  return;
884
895
  }
885
896
  const formatDashM = flag.match(/^--format-(.+)$/);
886
897
  if (formatDashM) {
887
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
898
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--format=${formatDashM[1]}`)}?\n`);
888
899
  process.exitCode = 1;
889
900
  return;
890
901
  }
891
902
  const profileDashM = flag.match(/^--profile-(.+)$/);
892
903
  if (profileDashM) {
893
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
904
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Did you mean ${sErr.cyan(`--profile=${profileDashM[1]}`)}?\n`);
894
905
  process.exitCode = 1;
895
906
  return;
896
907
  }
897
908
  const formatValueM = flag.match(/^--format=(.+)$/);
898
909
  if (formatValueM) {
899
- process.stderr.write(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
910
+ printErrFn(`${sErr.red('✖')} Invalid --format value: "${formatValueM[1]}". Expected: standup or pr\n`);
900
911
  process.exitCode = 1;
901
912
  return;
902
913
  }
903
- process.stderr.write(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
914
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
904
915
  process.exitCode = 1;
905
916
  return;
906
917
  }
@@ -921,16 +932,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
921
932
  if (profileNameS) {
922
933
  const profiles = loadProfiles(resolvedConfigDir);
923
934
  if (!profiles?.profiles?.[profileNameS]) {
924
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
935
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
925
936
  const names = Object.keys(profiles?.profiles ?? {});
926
- if (names.length > 0) process.stderr.write(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
927
- else process.stderr.write(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
937
+ if (names.length > 0) printErrFn(` ${sErr.dim('Available:')} ${names.join(', ')}\n`);
938
+ else printErrFn(` Run ${sErr.cyan('ticketlens init')} to configure a profile.\n`);
928
939
  process.exitCode = 1;
929
940
  return;
930
941
  }
931
942
  }
932
943
 
933
- const spinner = makeSpinner(sErr);
944
+ const spinner = makeSpinner(sErr, errStream);
934
945
  const stderrNotes = [];
935
946
 
936
947
  spinner.update('Scanning git log…');
@@ -977,8 +988,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
977
988
  }
978
989
 
979
990
  spinner.done();
980
- for (const note of stderrNotes) process.stderr.write(note + '\n');
981
- if (stderrNotes.length > 0) process.stderr.write('\n');
991
+ for (const note of stderrNotes) printErrFn(note + '\n');
992
+ if (stderrNotes.length > 0) printErrFn('\n');
982
993
 
983
994
  const isPlain = standupFlags.includes('--plain');
984
995
  const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
@@ -645,17 +645,25 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
645
645
  ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')}`,
646
646
  '',
647
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
- ` ${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`,
648
+ ` native tools — ${s.cyan('fetch')}, ${s.cyan('triage')}, ${s.cyan('compliance')}, ${s.cyan('review')}, ${s.cyan('standup')}, ${s.cyan('pr')}, ${s.cyan('stats')}, ${s.cyan('history')},`,
649
+ ` ${s.cyan('collisions')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')},`,
650
+ ` ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
650
651
  ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
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`,
655
- ` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
656
- ` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
657
- ` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
658
- ` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
652
+ ` code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/`,
653
+ ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above.`,
654
+ ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
655
+ ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
656
+ ` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
657
+ ` ${s.cyan('review')} is Free for branch/files/ticket context — its coverage/focus section`,
658
+ ` requires Pro as a plain license check, not a draw on that same counter;`,
659
+ ` ${s.cyan('stats')} is Free with a 7-day lookback, Pro extends it to 30 days, same split`,
660
+ ` as ${s.cyan('ticketlens stats --help')}; ${s.cyan('history')} is entirely local and requires Pro;`,
661
+ ` ${s.cyan('collisions')} requires ${s.cyan('ticketlens login')} (Console access) plus a Team license — every`,
662
+ ` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
663
+ ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
664
+ ` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
665
+ ` duplicate — different semantics than Jira/Linear's relationship-only add;`,
666
+ ` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
659
667
  ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
660
668
  ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
661
669
  ` Long-running — exits when the client closes stdin.`,