ticketlens 0.38.33 → 0.38.35

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`, `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).
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`, `ledger`, `doctor`, `recall_add`, `recall_update`, `recall_delete`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — every CLI action now has an MCP tool, so any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same license gate per tool, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. `fetch`, `doctor`, and `standup` are Free; `triage`'s base scan is Free with some options gated Pro/Team, same as the CLI (`ticketlens triage --help`); `compliance` and `pr` are Free, sharing a 3-checks/month cap on their requirements-coverage section, Pro unlimited; `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; `ledger` exports the local, signed compliance audit trail (zero network) and requires Pro; every other tool needs Pro. `recall_update` overwrites an existing Recall note's body — internal plumbing for the note quality loop, not typically called directly. `recall_delete` is destructive and local-vault-only — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal to prompt against), so omitting it always fails rather than silently blocking. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
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.33",
3
+ "version": "0.38.35",
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.38.0 -->
1
+ <!-- jtb-skill-version: 0.39.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.
@@ -66,7 +66,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
66
66
  /jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
67
67
  ```
68
68
 
69
- **Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
69
+ **Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own. If this harness has TicketLens's MCP server configured, `note delete`'s equivalent is the `recall_delete` tool (`id`/`ticket`/`confirm`) — it has no interactive prompt at all (no real terminal exists under MCP to prompt against), so `confirm: true` is the only way it ever executes; the same rule applies — only pass it when the user's message is the confirmation for that specific note.
70
70
 
71
71
  ## Prerequisites
72
72
 
@@ -370,6 +370,8 @@ When it does apply, run up to 3 rounds:
370
370
  ticketlens note patch --id="THE-ID-PRINTED-ABOVE" --ticket=TICKET-KEY --expect-mtime="THE-MTIME-FROM-STEP-1"
371
371
  ```
372
372
  `--expect-mtime` is what keeps this safe: if the file changed since step 1 (the user hand-edited it while you were drafting), the patch silently no-ops and prints "not found or already changed" — the user's own edit always wins, never gets clobbered by a stale background draft.
373
+
374
+ If this harness has TicketLens's MCP server configured (a tool named `recall_update` — often shown as `mcp__ticketlens__recall_update` — visible in your tool list), prefer it over the bash form: same license gate, same structural/secret-scan checks, same optimistic-concurrency behavior via `expectMtime` — just no shell command or stdin piping to construct. It accepts `id`/`body`/`ticket`/`expectMtime`.
373
375
  5. Repeat from step 1 (re-capture mtime/body fresh each round) up to 3 total rounds. If no round ever produces a fully-passing draft, patch in whichever round scored highest across all attempts, and let the "not found or already changed" message stand if that patch itself loses a late race — don't retry past round 3.
374
376
 
375
377
  This never calls any external API or bills any tokens beyond the session you already have open — the generator and validator are subagents inside your own Claude Code session, not a TicketLens server call.
@@ -506,6 +508,9 @@ The same tier-gated check also runs as its own command — `ticketlens complianc
506
508
 
507
509
  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.
508
510
 
511
+ ### Ledger export
512
+ `ticketlens ledger [--format=json|csv]` exports the same local, signed compliance ledger these checks write to — entirely local, no network call. `[Pro]`. If this harness has TicketLens's MCP server configured (a tool named `ledger` — often shown as `mcp__ticketlens__ledger` — visible in your tool list), prefer it over the bash form: same tier gate, same export, just no shell command to construct or stdout to parse. It accepts `format` (`json`/`csv`, defaults to `json`).
513
+
509
514
  ---
510
515
 
511
516
  ## Advanced Options
@@ -18,6 +18,19 @@ export const NOTE_ADD_RE = /\bticketlens\s+note\s+add\b|\/jtb\s+note\b/;
18
18
  // (mcp__<server-alias>__<tool-name>) — the server alias is whatever the user
19
19
  // named it in their own .mcp.json, so only the tool-name suffix is fixed.
20
20
  export const NOTE_ADD_MCP_RE = /^mcp__.+__recall_add$/;
21
+ // Matches jtb's fetch: the CLI's bare/default ticket-key form ("ticketlens
22
+ // PROJ-123" or its "tl" bin alias), the "get" alias form, and the /jtb skill
23
+ // wrapper. Deliberately excludes an explicit "fetch" subcommand — there is
24
+ // no such subcommand; parseCommand() (cli.mjs) only special-cases "get" as
25
+ // an alias, so "ticketlens fetch PROJ-123" falls through to the catch-all
26
+ // with "fetch" itself still in argv and errors as an invalid ticket key
27
+ // (live-verified during code review) — matching it here would silently
28
+ // swallow a broken invocation as if a real fetch had happened. Also
29
+ // deliberately does not match any other tracked subcommand (triage/
30
+ // compliance/etc) — those are lowercase words and can never satisfy the
31
+ // uppercase ticket-key class required immediately after the command name.
32
+ export const FETCH_RE = /\bticketlens\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\btl\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b|\/jtb\s+(?:get\s+)?[A-Z][A-Z0-9]{1,9}-\d+\b/;
33
+ export const FETCH_MCP_RE = /^mcp__.+__fetch$/;
21
34
 
22
35
  export function readStdinJson() {
23
36
  const raw = fs.readFileSync(0, 'utf8');
@@ -170,9 +183,19 @@ export function hasRecentNag(cwd, now = Date.now()) {
170
183
  * early, and picking one deterministic match keeps this function decoupled
171
184
  * from profiles.json (it stays a pure transcript reader; profile lookup
172
185
  * and its own fallback chain belong entirely to resolveProfile()).
186
+ *
187
+ * `sawFetch` (backlog #15) is deliberately narrower than `sawTicketKey`: it
188
+ * only goes true when jtb's fetch tool actually ran (CLI or MCP form, same
189
+ * dual-detection shape as sawNoteAdd below) — not merely when a ticket-key-
190
+ * shaped string appears anywhere in the transcript. `sawTicketKey` false-
191
+ * positived on any incidental match (a doc, a code comment, a test fixture
192
+ * name like BETA-42), which is fine for its own low-stakes use (picking
193
+ * which profile's settings apply) but was wrong as shouldNag()'s nag
194
+ * precondition — SKILL.md's own capture guidance is scoped to "whenever
195
+ * jtb's fetch was used," so the hook now checks the same thing it backstops.
173
196
  */
174
197
  export function scanTranscript(transcriptPath) {
175
- const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
198
+ const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, ticketKey: null };
176
199
  let lines;
177
200
  try {
178
201
  lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
@@ -217,6 +240,10 @@ export function scanTranscript(transcriptPath) {
217
240
  const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
218
241
  const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
219
242
  if (isCliNoteAdd || isMcpNoteAdd) result.sawNoteAdd = true;
243
+
244
+ const isCliFetch = block.name === 'Bash' && FETCH_RE.test(block.input?.command ?? '');
245
+ const isMcpFetch = FETCH_MCP_RE.test(block.name ?? '');
246
+ if (isCliFetch || isMcpFetch) result.sawFetch = true;
220
247
  }
221
248
  }
222
249
  }
@@ -235,9 +262,17 @@ export function scanTranscript(transcriptPath) {
235
262
  * compaction/session_id rollover, or nagging more than once per session).
236
263
  * Strict's actual effect on capture volume comes from SKILL.md's lowered
237
264
  * in-session capture bar, not from this function.
265
+ *
266
+ * Gated on `sawFetch`, not `sawTicketKey` (backlog #15) — including the
267
+ * `sawRecallFlag` broken-promise case, which is why the gate is a blanket
268
+ * `!sawFetch` check rather than per-branch: a flag can't legitimately fire
269
+ * outside real ticket work, and this keeps the whole function's trigger
270
+ * matching SKILL.md's own capture-guidance scope exactly ("unconditionally
271
+ * whenever jtb's fetch was used"), instead of firing on any incidental
272
+ * ticket-key-shaped string.
238
273
  */
239
- export function shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
240
- if (!sawTicketKey || sawNoteAdd) return false;
274
+ export function shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
275
+ if (!sawFetch || sawNoteAdd) return false;
241
276
  if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
242
- return true; // balanced and strict: ticket work with no note is enough
277
+ return true; // balanced and strict: a fetch with no note is enough
243
278
  }
@@ -3,12 +3,16 @@
3
3
  * Stop hook — end-of-session Recall check.
4
4
  *
5
5
  * Blocks (exit 2) at most ONCE per session — never traps the user in a
6
- * loop regardless of how Claude responds. Two cases force a check:
6
+ * loop regardless of how Claude responds. Both cases below require jtb's
7
+ * fetch to have actually run this session (backlog #15) — a ticket-key-
8
+ * shaped string appearing incidentally (a doc, a code comment, a test
9
+ * fixture) is not enough, matching SKILL.md's own capture-guidance scope.
10
+ * Given that:
7
11
  * 1. Claude flagged something (🔖 Recall-flag:) but never called note add
8
12
  * — a broken promise, the strongest signal something was missed.
9
13
  * 2. Ticket work happened all session with zero flags and zero notes
10
14
  * — the weaker "did anything ever get considered?" catch.
11
- * Anything else (no ticket work at all, or a note was already added) exits
15
+ * Anything else (no fetch this session, or a note was already added) exits
12
16
  * clean — this must never be the reason a session can't end.
13
17
  *
14
18
  * The per-session_id "asked once" state (readState/writeState) cannot
@@ -45,7 +49,7 @@ const cwd = input?.cwd ?? process.cwd();
45
49
 
46
50
  if (!sessionId || !transcriptPath) process.exit(0);
47
51
 
48
- const { sawTicketKey, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
52
+ const { sawFetch, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
49
53
 
50
54
  // Refreshed on every check, independent of the once-per-session gate below —
51
55
  // a capture that happens AFTER this session already nagged once must still
@@ -58,7 +62,7 @@ if (state.stopChecked) process.exit(0); // already asked once this session — r
58
62
  const profile = resolveProfile(ticketKey, { cwd });
59
63
  const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
60
64
 
61
- if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
65
+ if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
62
66
  process.exit(0); // nothing this strictness level requires a capture for
63
67
  }
64
68
 
@@ -488,10 +488,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
488
488
  }
489
489
 
490
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.
491
+ // Mirrors printFn above — the bare ticket-fetch, compliance, pr, review, standup, and
492
+ // ledger paths all use this now (each has an MCP tool). `install-hooks` is CLI-only
493
+ // and never gets one.
495
494
  const printErrFn = opts.printErr ?? ((chunk) => process.stderr.write(chunk));
496
495
  // Reused on every recursive self-call in the bare-fetch path (profile-prompt retries)
497
496
  // so an injected print/printErr survives the retry instead of silently reverting to
@@ -610,7 +609,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
610
609
  const { isLicensed: isLic, showUpgradePrompt: showUpgrade } = await import('./lib/license.mjs');
611
610
  const resolvedConfigDir = configDir ?? (await import('./lib/config.mjs')).DEFAULT_CONFIG_DIR;
612
611
  if (!isLic('pro', resolvedConfigDir)) {
613
- showUpgrade('pro', 'ledger', { stream: process.stderr });
612
+ showUpgrade('pro', 'ledger', { stream: errStream });
614
613
  process.exitCode = 1;
615
614
  return;
616
615
  }
@@ -621,7 +620,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
621
620
  printFn(result + '\n');
622
621
  } else {
623
622
  printFn(JSON.stringify(result, null, 2) + '\n');
624
- process.stderr.write(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
623
+ printErrFn(' Verify signature: HMAC-SHA256 over {records, exportedAt} with key at ledger-key\n');
625
624
  }
626
625
  return;
627
626
  }
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
6
+ export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -646,11 +646,14 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
646
646
  '',
647
647
  ` Start an MCP (Model Context Protocol) stdio server exposing CLI actions as`,
648
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`,
651
- ` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
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.`,
649
+ ` ${s.cyan('collisions')}, ${s.cyan('ledger')}, ${s.cyan('doctor')}, ${s.cyan('recall_add')}, ${s.cyan('recall_update')}, ${s.cyan('recall_delete')},`,
650
+ ` ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')}, ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')},`,
651
+ ` ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — every CLI action`,
652
+ ` now has an MCP tool, for any MCP-compatible AI harness, not just Claude Code.`,
653
+ ` Thin adapter over the same code as ${s.cyan('TICKET-KEY')}/${s.cyan('doctor')}/${s.cyan('triage')}/${s.cyan('compliance')}/${s.cyan('review')}/`,
654
+ ` ${s.cyan('standup')}/${s.cyan('pr')}/${s.cyan('stats')}/${s.cyan('history')}/${s.cyan('collisions')}/${s.cyan('ledger')}/${s.cyan('note add')}/${s.cyan('note patch')}/`,
655
+ ` ${s.cyan('note delete')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/`,
656
+ ` ${s.cyan('update')}/${s.cyan('create')} above.`,
654
657
  ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
655
658
  ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
656
659
  ` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
@@ -666,6 +669,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
666
669
  ` ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
667
670
  ` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
668
671
  ` tracker, and it fabricates a real item, the highest blast radius of this family.`,
672
+ ` ${s.cyan('ledger')} exports the local, signed compliance audit trail — entirely local, no`,
673
+ ` network call. ${s.cyan('recall_update')} overwrites an existing Recall note's body — internal`,
674
+ ` plumbing for the note quality loop, not typically called directly.`,
675
+ ` ${s.cyan('recall_delete')} is destructive and local-vault-only — requires \`confirm: true\`,`,
676
+ ` alongside \`id\`, to actually execute; there is no interactive y/N prompt under`,
677
+ ` MCP, so omitting it always fails rather than silently blocking.`,
669
678
  ` Long-running — exits when the client closes stdin.`,
670
679
  '',
671
680
  ` ${s.dim('If a tool call rejects a parameter you expect right after upgrading ticketlens,')}`,
@@ -22,7 +22,7 @@
22
22
  import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
24
  import { runDoctor } from './doctor-command.mjs';
25
- import { runNoteAdd } from './note-command.mjs';
25
+ import { runNoteAdd, runNotePatch, runNoteDelete } 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
28
  import { run as runFetchTicket } from '../fetch-ticket.mjs';
@@ -239,6 +239,25 @@ async function callPr(args, deps) {
239
239
  return callFetchTicketRun(buildPrArgs, args, deps, 'pr failed');
240
240
  }
241
241
 
242
+ /**
243
+ * `ledger` is a subcommand of the same fetch-ticket.mjs `run()` that
244
+ * `callFetch`/`callCompliance`/`callPr` already wrap — reuses `runFetchTicketFn`,
245
+ * no new dependency. Unlike those, it has no `ticket`/`profile` argument — the
246
+ * ledger is local and config-dir scoped, not per-ticket. Its two direct
247
+ * `process.stderr` writes (the license-gate upgrade prompt and the
248
+ * verify-signature note printed alongside a successful json export) were
249
+ * threaded through opts.printErr as part of adding this tool.
250
+ */
251
+ function buildLedgerArgs({ format }) {
252
+ const args = ['ledger'];
253
+ if (format) args.push(`--format=${format}`);
254
+ return args;
255
+ }
256
+
257
+ async function callLedger(args, deps) {
258
+ return callFetchTicketRun(buildLedgerArgs, args, deps, 'ledger export failed');
259
+ }
260
+
242
261
  function buildDoctorArgs({ fix, profile }) {
243
262
  const args = ['--format=json'];
244
263
  if (fix === true) args.push('--fix');
@@ -355,6 +374,68 @@ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
355
374
  return written ? { content } : { isError: true, content };
356
375
  }
357
376
 
377
+ /**
378
+ * Builds runNotePatch's cmdArgs array — same single-opaque-element reasoning
379
+ * as buildNoteAddArgs above. `expectMtime` is optimistic-concurrency: a
380
+ * caller that fetched a note via recall_search and wants to refine it
381
+ * without racing a concurrent edit passes back the mtime it observed.
382
+ */
383
+ function buildNotePatchArgs({ id, ticket, expectMtime }) {
384
+ const args = [`--id=${id}`];
385
+ if (ticket) args.push(`--ticket=${ticket}`);
386
+ if (expectMtime !== undefined) args.push(`--expect-mtime=${expectMtime}`);
387
+ return args;
388
+ }
389
+
390
+ async function callRecallUpdate(args, { configDir, runNotePatchFn }) {
391
+ if (!args.id) {
392
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
393
+ }
394
+ if (!args.body) {
395
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
396
+ }
397
+ const capture = capturingStream();
398
+ const { patched } = await runNotePatchFn(buildNotePatchArgs(args), {
399
+ configDir,
400
+ stream: capture,
401
+ readStdin: async () => args.body,
402
+ });
403
+ const content = [{ type: 'text', text: capture.text }];
404
+ return patched ? { content } : { isError: true, content };
405
+ }
406
+
407
+ /**
408
+ * Builds runNoteDelete's cmdArgs array — same single-opaque-element reasoning
409
+ * as buildNoteAddArgs above. Always passes `--yes`: runNoteDelete's own
410
+ * confirmDestructive gate refuses outright in non-interactive mode (no real
411
+ * TTY exists under the MCP transport to prompt against), so without it every
412
+ * call would fail. The caller's `confirm: true` is enforced in callRecallDelete
413
+ * below instead, before runNoteDeleteFn is ever reached — same nudge-and-audit
414
+ * -trail spirit as ticket_transition/ticket_link, but deliberately a different
415
+ * code path: those two defer the refusal to the wrapped CLI function, which
416
+ * here would surface as that generic non-interactive error instead of a
417
+ * dedicated one naming `confirm`.
418
+ */
419
+ function buildNoteDeleteArgs({ id, ticket }) {
420
+ const args = [`--id=${id}`];
421
+ if (ticket) args.push(`--ticket=${ticket}`);
422
+ args.push('--yes');
423
+ return args;
424
+ }
425
+
426
+ async function callRecallDelete(args, { configDir, runNoteDeleteFn }) {
427
+ if (!args.id) {
428
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: id' }] };
429
+ }
430
+ if (args.confirm !== true) {
431
+ return { isError: true, content: [{ type: 'text', text: 'Deletion requires confirm: true — this cannot be restored.' }] };
432
+ }
433
+ const capture = capturingStream();
434
+ const { deleted } = await runNoteDeleteFn(buildNoteDeleteArgs(args), { configDir, stream: capture });
435
+ const content = [{ type: 'text', text: capture.text }];
436
+ return deleted ? { content } : { isError: true, content };
437
+ }
438
+
358
439
  async function callRecallSearch(args, { configDir, runRecallFn }) {
359
440
  const capture = capturingStream();
360
441
  const { ok } = await runRecallFn([args.query ?? ''], {
@@ -532,11 +613,14 @@ async function handleToolsCall(params, deps) {
532
613
  if (name === 'review') return callReview(args, deps);
533
614
  if (name === 'standup') return callStandup(args, deps);
534
615
  if (name === 'pr') return callPr(args, deps);
616
+ if (name === 'ledger') return callLedger(args, deps);
535
617
  if (name === 'doctor') return callDoctor(args, deps);
536
618
  if (name === 'stats') return callStats(args, deps);
537
619
  if (name === 'history') return callHistory(args, deps);
538
620
  if (name === 'collisions') return callCollisions(args, deps);
539
621
  if (name === 'recall_add') return callRecallAdd(args, deps);
622
+ if (name === 'recall_update') return callRecallUpdate(args, deps);
623
+ if (name === 'recall_delete') return callRecallDelete(args, deps);
540
624
  if (name === 'recall_search') return callRecallSearch(args, deps);
541
625
  if (name === 'ticket_comment') return callTicketComment(args, deps);
542
626
  if (name === 'ticket_transition') return callTicketTransition(args, deps);
@@ -548,7 +632,7 @@ async function handleToolsCall(params, deps) {
548
632
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
549
633
  }
550
634
 
551
- async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
635
+ async function handleMessage(raw, deps) {
552
636
  let msg;
553
637
  try {
554
638
  msg = JSON.parse(raw);
@@ -578,7 +662,7 @@ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, ru
578
662
 
579
663
  if (method === 'tools/call') {
580
664
  try {
581
- const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
665
+ const result = await handleToolsCall(params, deps);
582
666
  return jsonRpcResult(id, result);
583
667
  } catch (err) {
584
668
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -606,6 +690,8 @@ export function runMcpServer({
606
690
  runHistoryFn = runHistory,
607
691
  runCollisionsFn = runCollisions,
608
692
  runNoteAddFn = runNoteAdd,
693
+ runNotePatchFn = runNotePatch,
694
+ runNoteDeleteFn = runNoteDelete,
609
695
  runRecallFn = runRecall,
610
696
  runTicketCommentFn = runTicketComment,
611
697
  runTicketTransitionListFn = runTicketTransitionList,
@@ -624,6 +710,11 @@ export function runMcpServer({
624
710
  stdin.on('error', () => {});
625
711
  stdout.on('error', () => {});
626
712
 
713
+ // Assembled once and passed straight through handleMessage to handleToolsCall,
714
+ // which is the only place the individual functions are read — so a new tool
715
+ // needs its dependency named here and in the parameter list above, nowhere else.
716
+ const deps = { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runNotePatchFn, runNoteDeleteFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn };
717
+
627
718
  const rl = readline.createInterface({ input: stdin, terminal: false });
628
719
  let queue = Promise.resolve();
629
720
 
@@ -635,7 +726,7 @@ export function runMcpServer({
635
726
  // never resolving (a dropped rejection isn't a resolution) — the
636
727
  // server would hang on shutdown instead of exiting.
637
728
  queue = queue.then(async () => {
638
- const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
729
+ const response = await handleMessage(line, deps);
639
730
  if (response) stdout.write(response);
640
731
  }).catch(() => {});
641
732
  });
@@ -88,6 +88,16 @@ export const TOOLS = [
88
88
  required: ['ticket'],
89
89
  },
90
90
  },
91
+ {
92
+ name: 'ledger',
93
+ description: 'Export the local compliance ledger — a signed, tamper-evident record of every compliance check run (ticket key, commit SHA, author, timestamp, coverage %). Read-only, entirely local — no network call. Verifiable offline: the json format includes an HMAC-SHA256 signature over {records, exportedAt}, keyed at a local ledger-key file. Requires a TicketLens Pro license.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ format: { type: 'string', enum: ['json', 'csv'], description: 'Output shape: "json" (default) includes the HMAC signature; "csv" is a flat export with no signature.' },
98
+ },
99
+ },
100
+ },
91
101
  {
92
102
  name: 'stats',
93
103
  description: 'Show response-time and triage-cadence metrics from local triage history — average/median response time, clear rate, triage run count, current urgency breakdown. Read-only, entirely local — no network call. Free tier: 7-day lookback max; TicketLens Pro extends it to 30 days.',
@@ -147,6 +157,33 @@ export const TOOLS = [
147
157
  required: ['title', 'body'],
148
158
  },
149
159
  },
160
+ {
161
+ name: 'recall_update',
162
+ description: 'Overwrite an existing Recall note\'s body with a better draft — internal plumbing for the jtb skill\'s note quality loop, not typically called directly. The new body gets the same structural and secret-scan checks a user-typed body gets. Local vault only. Requires a TicketLens Pro license.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ id: { type: 'string', description: 'Note id to patch, as printed by recall_add or recall_search.' },
167
+ body: { type: 'string', description: 'The replacement note body.' },
168
+ ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
169
+ expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
170
+ },
171
+ required: ['id', 'body'],
172
+ },
173
+ },
174
+ {
175
+ name: 'recall_delete',
176
+ description: 'Delete a Recall note from the local vault. Irreversible locally, and destructive — requires `confirm: true` alongside `id` to actually execute; there is no interactive y/N prompt under MCP (no real terminal exists to prompt against), so omitting confirm always fails rather than silently blocking. Local vault only — if this note 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). Requires a TicketLens Pro license.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ id: { type: 'string', description: 'Note id to delete, as printed by recall_add or recall_search.' },
181
+ ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
182
+ confirm: { type: 'boolean', description: 'Must be true to actually execute the deletion — a nudge and audit trail, not just a formality. Omitted or false always fails.' },
183
+ },
184
+ required: ['id'],
185
+ },
186
+ },
150
187
  {
151
188
  name: 'recall_search',
152
189
  description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',