ticketlens 0.38.31 → 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 `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`, `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.31",
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.36.0 -->
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.
@@ -127,6 +127,63 @@ Display the script's stdout directly. No plan mode. Stop here.
127
127
 
128
128
  ---
129
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
+
130
187
  ### Fetch ticket workflow (default)
131
188
 
132
189
  ### Step 1: Validate environment
@@ -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
@@ -549,12 +552,12 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
549
552
  const { assemblePr } = await import('./lib/pr-assembler.mjs');
550
553
  const ticketKeyArg = args[1];
551
554
  if (!ticketKeyArg) {
552
- 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');
553
556
  process.exitCode = 1;
554
557
  return;
555
558
  }
556
559
  if (!TICKET_KEY_PATTERN.test(ticketKeyArg)) {
557
- 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`);
558
561
  process.exitCode = 1;
559
562
  return;
560
563
  }
@@ -568,27 +571,33 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
568
571
  configDir: resolvedConfigDir,
569
572
  profileName: profileNamePr,
570
573
  cwd: process.cwd(),
571
- onWarning: (w) => process.stderr.write(w + '\n'),
574
+ onWarning: (w) => printErrFn(w + '\n'),
572
575
  onProfileNotFound: () => {},
573
576
  });
574
577
 
575
578
  const hasAuthPr = connPr.pat || (connPr.email && connPr.apiToken);
576
579
  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');
580
+ printErrFn('Error: No Jira credentials found. Run \'ticketlens init\' or set JIRA_BASE_URL + JIRA_API_TOKEN.\n');
578
581
  process.exitCode = 1;
579
582
  return;
580
583
  }
581
584
 
582
585
  const adapterPr = resolveAdapter(connPr, { fetcher });
586
+ const complianceRunnerPr = opts.runComplianceCheck ?? runComplianceCheck;
583
587
 
584
588
  try {
585
589
  const md = await assemblePr(ticketKeyArg, {
586
590
  configDir: resolvedConfigDir,
587
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 }),
588
597
  });
589
598
  printFn(md + '\n');
590
599
  } catch (err) {
591
- process.stderr.write(`Error: ${err.message}\n`);
600
+ printErrFn(`Error: ${err.message}\n`);
592
601
  process.exitCode = 1;
593
602
  }
594
603
  return;
@@ -710,7 +719,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
710
719
  const execFn = opts.execFn ?? spawnSync;
711
720
  const cwd = process.cwd();
712
721
 
713
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
722
+ const sErr = createStyler({ isTTY });
714
723
 
715
724
  // Validate flags before any git work
716
725
  const reviewFlags = args.slice(1);
@@ -721,28 +730,28 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
721
730
  // Detect --profile-NAME typo (dash instead of =)
722
731
  const profileDashM = flag.match(/^--profile-(.+)$/);
723
732
  if (profileDashM) {
724
- 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`);
725
734
  process.exitCode = 1;
726
735
  return;
727
736
  }
728
737
  const baseDashM = flag.match(/^--base-(.+)$/);
729
738
  if (baseDashM) {
730
- 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`);
731
740
  process.exitCode = 1;
732
741
  return;
733
742
  }
734
743
  const branchDashM = flag.match(/^--branch-(.+)$/);
735
744
  if (branchDashM) {
736
- 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`);
737
746
  process.exitCode = 1;
738
747
  return;
739
748
  }
740
- 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`);
741
750
  process.exitCode = 1;
742
751
  return;
743
752
  }
744
753
 
745
- const spinner = makeSpinner(sErr);
754
+ const spinner = makeSpinner(sErr, errStream);
746
755
  const stderrNotes = [];
747
756
 
748
757
  // Resolve base branch: --base=BRANCH or auto-detect main/master/develop
@@ -756,7 +765,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
756
765
  const verifyR = execFn('git', ['rev-parse', '--verify', baseBranch], { encoding: 'utf8', cwd, timeout: 5_000 });
757
766
  if (verifyR.status !== 0) {
758
767
  spinner.done();
759
- 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`);
760
769
  process.exitCode = 1;
761
770
  return;
762
771
  }
@@ -794,10 +803,10 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
794
803
  const profiles = loadProfiles(resolvedConfigDir);
795
804
  if (!profiles?.profiles?.[profileNameR]) {
796
805
  spinner.done();
797
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
806
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameR}" not found.\n`);
798
807
  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`);
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`);
801
810
  process.exitCode = 1;
802
811
  return;
803
812
  }
@@ -839,8 +848,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
839
848
  }
840
849
 
841
850
  spinner.done();
842
- for (const note of stderrNotes) process.stderr.write(note + '\n');
843
- if (stderrNotes.length > 0) process.stderr.write('\n');
851
+ for (const note of stderrNotes) printErrFn(note + '\n');
852
+ if (stderrNotes.length > 0) printErrFn('\n');
844
853
 
845
854
  const isLic = opts.isLicensedFn ?? ((tier) => isLicensed(tier, resolvedConfigDir));
846
855
  const assembleFn = opts.assemblePrReviewFn ?? assemblePrReview;
@@ -867,7 +876,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
867
876
  const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
868
877
  const execFn = opts.execFn ?? spawnSync;
869
878
  const cwd = process.cwd();
870
- const sErr = createStyler({ isTTY: process.stderr.isTTY });
879
+ const sErr = createStyler({ isTTY });
871
880
 
872
881
  const standupFlags = args.slice(1);
873
882
 
@@ -878,29 +887,29 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
878
887
 
879
888
  const sinceDashM = flag.match(/^--since-(.+)$/);
880
889
  if (sinceDashM) {
881
- 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`);
882
891
  process.exitCode = 1;
883
892
  return;
884
893
  }
885
894
  const formatDashM = flag.match(/^--format-(.+)$/);
886
895
  if (formatDashM) {
887
- 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`);
888
897
  process.exitCode = 1;
889
898
  return;
890
899
  }
891
900
  const profileDashM = flag.match(/^--profile-(.+)$/);
892
901
  if (profileDashM) {
893
- 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`);
894
903
  process.exitCode = 1;
895
904
  return;
896
905
  }
897
906
  const formatValueM = flag.match(/^--format=(.+)$/);
898
907
  if (formatValueM) {
899
- 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`);
900
909
  process.exitCode = 1;
901
910
  return;
902
911
  }
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`);
912
+ printErrFn(`${sErr.red('✖')} Unknown flag: ${sErr.bold(flag)}\n Usage: ${sErr.cyan('ticketlens standup [--since=N] [--format=standup|pr] [--profile=NAME]')}\n`);
904
913
  process.exitCode = 1;
905
914
  return;
906
915
  }
@@ -921,16 +930,16 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
921
930
  if (profileNameS) {
922
931
  const profiles = loadProfiles(resolvedConfigDir);
923
932
  if (!profiles?.profiles?.[profileNameS]) {
924
- process.stderr.write(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
933
+ printErrFn(`${sErr.red('✖')} Profile "${profileNameS}" not found.\n`);
925
934
  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`);
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`);
928
937
  process.exitCode = 1;
929
938
  return;
930
939
  }
931
940
  }
932
941
 
933
- const spinner = makeSpinner(sErr);
942
+ const spinner = makeSpinner(sErr, errStream);
934
943
  const stderrNotes = [];
935
944
 
936
945
  spinner.update('Scanning git log…');
@@ -977,8 +986,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
977
986
  }
978
987
 
979
988
  spinner.done();
980
- for (const note of stderrNotes) process.stderr.write(note + '\n');
981
- if (stderrNotes.length > 0) process.stderr.write('\n');
989
+ for (const note of stderrNotes) printErrFn(note + '\n');
990
+ if (stderrNotes.length > 0) printErrFn('\n');
982
991
 
983
992
  const isPlain = standupFlags.includes('--plain');
984
993
  const assembleStandupFn = opts.assembleStandupFn ?? assembleStandup;
@@ -645,17 +645,21 @@ 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`,
650
- ` 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;`,
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;`,
659
663
  ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
660
664
  ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
661
665
  ` Long-running — exits when the client closes stdin.`,
@@ -78,6 +78,42 @@ const TOOLS = [
78
78
  required: ['ticket'],
79
79
  },
80
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
+ },
81
117
  {
82
118
  name: 'doctor',
83
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.',
@@ -249,8 +285,8 @@ function buildFetchArgs({ ticket, profile, depth }) {
249
285
 
250
286
  /**
251
287
  * Shared by every dispatch that goes through fetch-ticket.mjs's `run()`
252
- * (currently `fetch` and `compliance` — `compliance` is just another
253
- * subcommand of that same function). `run()` has no {ok} return value
288
+ * (currently `fetch`, `compliance`, `review`, `standup`, and `pr` — all but
289
+ * `fetch` are subcommands of that same function). `run()` has no {ok} return value
254
290
  * (unlike every other wrapped function) — failure is signaled by mutating
255
291
  * process.exitCode, which is unsafe to read in a long-lived server (one
256
292
  * failed call would poison the whole process's exit code forever). Success
@@ -371,6 +407,51 @@ async function callCompliance(args, deps) {
371
407
  return callFetchTicketRun(buildComplianceArgs, args, deps, 'compliance check failed');
372
408
  }
373
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
+
374
455
  function buildDoctorArgs({ fix, profile }) {
375
456
  const args = ['--format=json'];
376
457
  if (fix === true) args.push('--fix');
@@ -597,6 +678,9 @@ async function handleToolsCall(params, deps) {
597
678
  if (name === 'fetch') return callFetch(args, deps);
598
679
  if (name === 'triage') return callTriage(args, deps);
599
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);
600
684
  if (name === 'doctor') return callDoctor(args, deps);
601
685
  if (name === 'recall_add') return callRecallAdd(args, deps);
602
686
  if (name === 'recall_search') return callRecallSearch(args, deps);