ticketlens 0.38.32 → 0.38.34

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`, `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).
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.32",
3
+ "version": "0.38.34",
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.37.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,46 @@ 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
+
128
168
  ---
129
169
 
130
170
  ### Standup subcommand
@@ -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');
@@ -99,6 +112,50 @@ export function hasRecentCapture(cwd, now = Date.now()) {
99
112
  return lastCaptureAt > 0 && (now - lastCaptureAt) < CAPTURE_FRESHNESS_MS;
100
113
  }
101
114
 
115
+ /**
116
+ * Cross-session NAG marker (backlog #14) — same shape as lastCapturePath/
117
+ * readLastCaptureAt/writeLastCaptureAt/hasRecentCapture above, but records
118
+ * "we already blocked once for this directory" instead of "a note was
119
+ * added". Closes a gap those functions never covered: they bridge a
120
+ * session_id rollover only when a REAL capture landed, but a dismissed nag
121
+ * ("genuinely nothing qualified" — the hook's own suggested response) was
122
+ * never remembered anywhere. Since compaction/resume mints a brand-new
123
+ * session_id (see recall-nudge-stop.mjs's docstring), and that resets the
124
+ * per-session_id stopChecked gate, one long working session with several
125
+ * compaction cycles could get nagged repeatedly for the same still-ongoing
126
+ * work — a real, reported friction source (backlog #14), not hypothetical.
127
+ * Deliberately a separate marker file from lastCapture (not folded into
128
+ * it): a nag and a capture are different facts, and conflating them would
129
+ * make hasRecentCapture's "a real capture landed" guarantee ambiguous.
130
+ * Reuses CAPTURE_FRESHNESS_MS rather than a second magic number — the same
131
+ * 2-hour window is a reasonable proxy for "still the same working session"
132
+ * in both cases, and a distinct constant isn't justified by anything
133
+ * observed so far.
134
+ */
135
+ export function lastNagPath(cwd) {
136
+ const hash = crypto.createHash('sha256').update(cwd || 'unknown').digest('hex').slice(0, 16);
137
+ return path.join(privateTmpDir(), `lastnag-${hash}.json`);
138
+ }
139
+
140
+ export function readLastNagAt(cwd) {
141
+ try {
142
+ return JSON.parse(fs.readFileSync(lastNagPath(cwd), 'utf8')).lastNagAt ?? 0;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
147
+
148
+ export function writeLastNagAt(cwd, timestamp) {
149
+ try {
150
+ fs.writeFileSync(lastNagPath(cwd), JSON.stringify({ lastNagAt: timestamp }));
151
+ } catch { /* best-effort — losing this marker only costs one extra nag next rollover */ }
152
+ }
153
+
154
+ export function hasRecentNag(cwd, now = Date.now()) {
155
+ const lastNagAt = readLastNagAt(cwd);
156
+ return lastNagAt > 0 && (now - lastNagAt) < CAPTURE_FRESHNESS_MS;
157
+ }
158
+
102
159
  /**
103
160
  * Reads the transcript (JSONL) and returns simple booleans about what
104
161
  * happened this session. Best-effort: any read/parse failure returns all
@@ -126,9 +183,19 @@ export function hasRecentCapture(cwd, now = Date.now()) {
126
183
  * early, and picking one deterministic match keeps this function decoupled
127
184
  * from profiles.json (it stays a pure transcript reader; profile lookup
128
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.
129
196
  */
130
197
  export function scanTranscript(transcriptPath) {
131
- const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, ticketKey: null };
198
+ const result = { sawTicketKey: false, sawRecallFlag: false, sawNoteAdd: false, sawFetch: false, ticketKey: null };
132
199
  let lines;
133
200
  try {
134
201
  lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
@@ -173,6 +240,10 @@ export function scanTranscript(transcriptPath) {
173
240
  const isCliNoteAdd = block.name === 'Bash' && NOTE_ADD_RE.test(block.input?.command ?? '');
174
241
  const isMcpNoteAdd = NOTE_ADD_MCP_RE.test(block.name ?? '');
175
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;
176
247
  }
177
248
  }
178
249
  }
@@ -191,9 +262,17 @@ export function scanTranscript(transcriptPath) {
191
262
  * compaction/session_id rollover, or nagging more than once per session).
192
263
  * Strict's actual effect on capture volume comes from SKILL.md's lowered
193
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.
194
273
  */
195
- export function shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
196
- if (!sawTicketKey || sawNoteAdd) return false;
274
+ export function shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness = 'balanced' }) {
275
+ if (!sawFetch || sawNoteAdd) return false;
197
276
  if (recallStrictness === 'loose') return sawRecallFlag; // only the broken-promise case
198
- 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
199
278
  }
@@ -3,19 +3,28 @@
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
15
19
  * survive a compaction/resume event — that hands this hook a brand-new
16
20
  * session_id, a blank dedup state, AND a blank transcript file, so a real
17
21
  * earlier capture becomes invisible. The cross-session lastCapture marker
18
- * (keyed by cwd, not session_id) is what actually bridges that boundary.
22
+ * (keyed by cwd, not session_id) is what actually bridges that boundary
23
+ * for a genuine capture. The parallel lastNag marker (backlog #14) bridges
24
+ * the same boundary for a DISMISSED nag: without it, a session that already
25
+ * got its one nag and was told "genuinely nothing qualified" would nag
26
+ * again after the next compaction/resume rollover, since that dismissal
27
+ * was never recorded anywhere — only a real capture was.
19
28
  *
20
29
  * Which of the two cases above actually blocks is governed by the active
21
30
  * profile's recallStrictness — see recall-nudge-lib.mjs's shouldNag() doc
@@ -30,7 +39,7 @@
30
39
  * profile, same as before (backlog #12, design spec §6).
31
40
  */
32
41
 
33
- import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, shouldNag } from './recall-nudge-lib.mjs';
42
+ import { readStdinJson, readState, writeState, scanTranscript, hasRecentCapture, writeLastCaptureAt, hasRecentNag, writeLastNagAt, shouldNag } from './recall-nudge-lib.mjs';
34
43
  import { resolveProfile, normalizeRecallStrictness } from '../scripts/lib/profile-resolver.mjs';
35
44
 
36
45
  const input = readStdinJson();
@@ -40,7 +49,7 @@ const cwd = input?.cwd ?? process.cwd();
40
49
 
41
50
  if (!sessionId || !transcriptPath) process.exit(0);
42
51
 
43
- const { sawTicketKey, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
52
+ const { sawFetch, sawRecallFlag, sawNoteAdd, ticketKey } = scanTranscript(transcriptPath);
44
53
 
45
54
  // Refreshed on every check, independent of the once-per-session gate below —
46
55
  // a capture that happens AFTER this session already nagged once must still
@@ -53,7 +62,7 @@ if (state.stopChecked) process.exit(0); // already asked once this session — r
53
62
  const profile = resolveProfile(ticketKey, { cwd });
54
63
  const recallStrictness = normalizeRecallStrictness(profile?.recallStrictness);
55
64
 
56
- if (!shouldNag({ sawTicketKey, sawRecallFlag, sawNoteAdd, recallStrictness })) {
65
+ if (!shouldNag({ sawFetch, sawRecallFlag, sawNoteAdd, recallStrictness })) {
57
66
  process.exit(0); // nothing this strictness level requires a capture for
58
67
  }
59
68
 
@@ -61,8 +70,13 @@ if (hasRecentCapture(cwd)) {
61
70
  process.exit(0); // a real capture landed recently in this same directory, just under a different session_id
62
71
  }
63
72
 
73
+ if (hasRecentNag(cwd)) {
74
+ 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)
75
+ }
76
+
64
77
  state.stopChecked = true;
65
78
  writeState(sessionId, state);
79
+ writeLastNagAt(cwd, Date.now());
66
80
 
67
81
  if (sawRecallFlag) {
68
82
  process.stderr.write(
@@ -511,7 +511,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
511
511
  const errStream = { write: printErrFn, isTTY };
512
512
 
513
513
  if (args.includes('--help') || args.includes('-h')) {
514
- 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 } });
515
517
  return;
516
518
  }
517
519
 
@@ -645,16 +645,20 @@ 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('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.`,
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.`,
653
654
  ` ${s.cyan('fetch')}, ${s.cyan('doctor')}, and ${s.cyan('standup')} are Free; ${s.cyan('triage')} is Free with some Pro/Team-gated`,
654
655
  ` options (see ${s.cyan('ticketlens triage --help')}); ${s.cyan('compliance')} and ${s.cyan('pr')} are Free, sharing a`,
655
656
  ` 3-checks/month cap on their requirements-coverage section, Pro unlimited;`,
656
657
  ` ${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
+ ` 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`,
658
662
  ` other tool needs Pro. ${s.cyan('ticket_transition')} is destructive when called with`,
659
663
  ` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
660
664
  ` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
@@ -27,226 +27,13 @@ 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';
29
29
  import { run as runTriage } from '../fetch-my-tickets.mjs';
30
+ import { runStats } from './run-stats.mjs';
31
+ import { runHistory } from './run-history.mjs';
32
+ import { runCollisions } from './run-collisions.mjs';
33
+ import { TOOLS } from './mcp-tool-schemas.mjs';
30
34
 
31
35
  const PROTOCOL_VERSION = '2025-11-25';
32
36
 
33
- const TOOLS = [
34
- {
35
- name: 'fetch',
36
- description: 'Fetch a ticket\'s full context brief (Jira/GitHub/Linear) — description, comments, linked tickets, code references, attachments. The core read action; free tier. Not a discovery tool — requires a known ticket key.',
37
- inputSchema: {
38
- type: 'object',
39
- properties: {
40
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
41
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
42
- depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
43
- },
44
- required: ['ticket'],
45
- },
46
- },
47
- {
48
- name: 'triage',
49
- description: 'Scan assigned tickets and surface what needs attention — replies owed, aging tickets, stale-status tickets. The base scan is free tier; some options require a TicketLens Pro or Team license.',
50
- inputSchema: {
51
- type: 'object',
52
- properties: {
53
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
54
- stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
55
- status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
56
- sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
57
- save: { type: 'string', description: 'Write the plain-text summary to this local file path instead of (in addition to) returning it. Requires a TicketLens Pro license.' },
58
- all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
59
- digest: { type: 'boolean', description: 'Deliver the scored results to the digest backend instead of returning them as text — on success, no summary is returned, only a delivery confirmation. Requires a TicketLens Pro license.' },
60
- assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
61
- sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
62
- export: { type: 'string', enum: ['csv', 'json'], description: 'Write results to a file in this format instead of returning the summary text, returning the written file path instead. Requires a TicketLens Team license.' },
63
- project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
64
- label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
65
- priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
66
- },
67
- },
68
- },
69
- {
70
- name: 'compliance',
71
- description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit.',
72
- inputSchema: {
73
- type: 'object',
74
- properties: {
75
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
76
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
77
- },
78
- required: ['ticket'],
79
- },
80
- },
81
- {
82
- name: 'review',
83
- description: 'Assemble PR review context from the current git branch — changed files, linked-ticket summaries, and (Pro) a requirements-coverage / review-focus section extracted from the diff against acceptance criteria. Read-only; never modifies the tracker or the repo. Free tier gets branch info, changed files, and ticket context; TicketLens Pro adds the requirements-coverage and review-focus sections — same split as the `compliance` tool.',
84
- inputSchema: {
85
- type: 'object',
86
- properties: {
87
- base: { type: 'string', description: 'Base branch to diff against. Auto-detects main/master/develop when omitted. Alias of `branch` — if both are given, `base` wins.' },
88
- branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
89
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
90
- },
91
- },
92
- },
93
- {
94
- name: 'standup',
95
- description: 'Summarize recent git commits grouped by linked ticket — a standup update or, with format:"pr", PR-body-style formatting. Read-only, fully free tier — no license gate on any option.',
96
- inputSchema: {
97
- type: 'object',
98
- properties: {
99
- since: { type: 'string', description: 'How far back to scan — an integer number of hours ("24") or a git-compatible date expression ("3 days ago"). Defaults to 24 hours.' },
100
- format: { type: 'string', enum: ['standup', 'pr'], description: 'Output shape: "standup" (default) groups commits under a per-ticket standup update; "pr" renders the same grouped commits as PR-body-style markdown.' },
101
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
102
- },
103
- },
104
- },
105
- {
106
- name: 'pr',
107
- description: 'Assemble a ready-to-paste PR description for a ticket — what changed (from linked commits), linked tickets, and (if the ticket has acceptance criteria) a requirements-coverage section. Read-only. The requirements-coverage section reuses the same Free-tier 3-checks/month counter as the `compliance` tool — calling `pr` on a ticket with acceptance criteria counts against that shared monthly limit; TicketLens Pro removes the cap.',
108
- inputSchema: {
109
- type: 'object',
110
- properties: {
111
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
112
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
113
- },
114
- required: ['ticket'],
115
- },
116
- },
117
- {
118
- name: 'doctor',
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.',
120
- inputSchema: {
121
- type: 'object',
122
- properties: {
123
- fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
124
- profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
125
- },
126
- },
127
- },
128
- {
129
- name: 'recall_add',
130
- description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
131
- inputSchema: {
132
- type: 'object',
133
- properties: {
134
- title: { type: 'string', description: 'Short one-line title.' },
135
- ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
136
- tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
137
- body: { type: 'string', description: 'The note body — one or more paragraphs.' },
138
- },
139
- required: ['title', 'body'],
140
- },
141
- },
142
- {
143
- name: 'recall_search',
144
- description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
145
- inputSchema: {
146
- type: 'object',
147
- properties: {
148
- query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
149
- },
150
- required: ['query'],
151
- },
152
- },
153
- {
154
- name: 'ticket_comment',
155
- description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
156
- inputSchema: {
157
- type: 'object',
158
- properties: {
159
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
160
- body: { type: 'string', description: 'Comment body.' },
161
- attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach — images render as a real inline thumbnail in the posted comment on both Jira (Cloud and Server/Data Center) and Linear. Not supported on GitHub — no PAT-compatible upload API exists there.' },
162
- },
163
- required: ['ticket', 'body'],
164
- },
165
- },
166
- {
167
- name: 'ticket_transition',
168
- description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
169
- inputSchema: {
170
- type: 'object',
171
- properties: {
172
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
173
- target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
174
- confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
175
- },
176
- required: ['ticket'],
177
- },
178
- },
179
- {
180
- name: 'ticket_assign',
181
- description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
182
- inputSchema: {
183
- type: 'object',
184
- properties: {
185
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
186
- to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
187
- },
188
- required: ['ticket', 'to'],
189
- },
190
- },
191
- {
192
- name: 'ticket_duplicates',
193
- description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. That scorer can miss real duplicates as easily as it over-matches, so an empty result means none were found, not a guarantee that none exist. Requires a TicketLens Pro license.',
194
- inputSchema: {
195
- type: 'object',
196
- properties: {
197
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
198
- threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
199
- },
200
- required: ['ticket'],
201
- },
202
- },
203
- {
204
- name: 'ticket_link',
205
- description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
206
- inputSchema: {
207
- type: 'object',
208
- properties: {
209
- ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
210
- target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
211
- type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
212
- confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
213
- },
214
- required: ['ticket', 'target'],
215
- },
216
- },
217
- {
218
- name: 'ticket_update',
219
- description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
220
- inputSchema: {
221
- type: 'object',
222
- properties: {
223
- ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
224
- title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
225
- description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
226
- addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
227
- removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
228
- priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
229
- },
230
- required: ['ticket'],
231
- },
232
- },
233
- {
234
- name: 'ticket_create',
235
- description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
236
- inputSchema: {
237
- type: 'object',
238
- properties: {
239
- project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
240
- type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
241
- summary: { type: 'string', description: 'Ticket title/summary.' },
242
- description: { type: 'string', description: 'Ticket description. Omit for none.' },
243
- attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
244
- profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile. Use this when `project` belongs to a profile other than the one auto-resolved from the current working directory.' },
245
- },
246
- required: ['summary'],
247
- },
248
- },
249
- ];
250
37
 
251
38
  function jsonRpcResult(id, result) {
252
39
  return JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n';
@@ -471,6 +258,70 @@ async function callDoctor(args, { configDir, runDoctorFn }) {
471
258
  return { content: [{ type: 'text', text: capture.text }] };
472
259
  }
473
260
 
261
+ /**
262
+ * Shared by `stats` and `history` — both wrap a run*Fn taking {configDir,
263
+ * print, warn} where a real report always reaches `print` on success and
264
+ * every failure (validation error, license gate) reaches `warn`. Same
265
+ * "did print receive text" success heuristic as callTriage, factored out
266
+ * here the way callFetchTicketRun already factors it out for the
267
+ * fetch/compliance/review/standup/pr family. NOT used by `collisions` —
268
+ * see callCollisions for why that one needs the returned {ok} instead.
269
+ */
270
+ async function callPrintWarnRun(buildArgsFn, args, { configDir, runFn }, fallbackErrorText) {
271
+ const capture = capturingStream();
272
+ const errCapture = capturingStream();
273
+ await runFn(buildArgsFn(args), { configDir, print: capture.write, warn: errCapture.write });
274
+ if (capture.text) {
275
+ return { content: [{ type: 'text', text: capture.text }] };
276
+ }
277
+ return { isError: true, content: [{ type: 'text', text: errCapture.text || fallbackErrorText }] };
278
+ }
279
+
280
+ function buildStatsArgs({ profile, days, format }) {
281
+ const args = [];
282
+ if (profile) args.push(`--profile=${profile}`);
283
+ if (days !== undefined) args.push(`--days=${days}`);
284
+ if (format) args.push(`--format=${format}`);
285
+ return args;
286
+ }
287
+
288
+ async function callStats(args, { configDir, runStatsFn }) {
289
+ return callPrintWarnRun(buildStatsArgs, args, { configDir, runFn: runStatsFn }, 'stats failed');
290
+ }
291
+
292
+ function buildHistoryArgs({ ticket }) {
293
+ return [ticket];
294
+ }
295
+
296
+ async function callHistory(args, { configDir, runHistoryFn }) {
297
+ if (!args.ticket) {
298
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
299
+ }
300
+ return callPrintWarnRun(buildHistoryArgs, args, { configDir, runFn: runHistoryFn }, 'history failed');
301
+ }
302
+
303
+ function buildCollisionsArgs({ json, plain }) {
304
+ const args = [];
305
+ if (json === true) args.push('--json');
306
+ if (plain === true) args.push('--plain');
307
+ return args;
308
+ }
309
+
310
+ /**
311
+ * Unlike every other read tool here, runCollisions writes its own failure
312
+ * messages (no-token, 401, 403, network error) through `print`, not `warn` —
313
+ * so the "did print receive text" heuristic used by callStats/callTriage
314
+ * would misreport every one of those as a success. Uses the returned {ok}
315
+ * instead, same as callTicketCreate.
316
+ */
317
+ async function callCollisions(args, { configDir, runCollisionsFn }) {
318
+ const capture = capturingStream();
319
+ const errCapture = capturingStream();
320
+ const { ok } = await runCollisionsFn(buildCollisionsArgs(args), { configDir, print: capture.write, warn: errCapture.write });
321
+ const content = [{ type: 'text', text: capture.text }];
322
+ return ok ? { content } : { isError: true, content };
323
+ }
324
+
474
325
  /**
475
326
  * Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
476
327
  * discrete array element — runNoteAdd's parseFlag matches per-element via
@@ -682,6 +533,9 @@ async function handleToolsCall(params, deps) {
682
533
  if (name === 'standup') return callStandup(args, deps);
683
534
  if (name === 'pr') return callPr(args, deps);
684
535
  if (name === 'doctor') return callDoctor(args, deps);
536
+ if (name === 'stats') return callStats(args, deps);
537
+ if (name === 'history') return callHistory(args, deps);
538
+ if (name === 'collisions') return callCollisions(args, deps);
685
539
  if (name === 'recall_add') return callRecallAdd(args, deps);
686
540
  if (name === 'recall_search') return callRecallSearch(args, deps);
687
541
  if (name === 'ticket_comment') return callTicketComment(args, deps);
@@ -694,7 +548,7 @@ async function handleToolsCall(params, deps) {
694
548
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
695
549
  }
696
550
 
697
- async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
551
+ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
698
552
  let msg;
699
553
  try {
700
554
  msg = JSON.parse(raw);
@@ -724,7 +578,7 @@ async function handleMessage(raw, { configDir, runFetchTicketFn, runTriageFn, ru
724
578
 
725
579
  if (method === 'tools/call') {
726
580
  try {
727
- const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
581
+ const result = await handleToolsCall(params, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
728
582
  return jsonRpcResult(id, result);
729
583
  } catch (err) {
730
584
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -748,6 +602,9 @@ export function runMcpServer({
748
602
  runFetchTicketFn = runFetchTicket,
749
603
  runTriageFn = runTriage,
750
604
  runDoctorFn = runDoctor,
605
+ runStatsFn = runStats,
606
+ runHistoryFn = runHistory,
607
+ runCollisionsFn = runCollisions,
751
608
  runNoteAddFn = runNoteAdd,
752
609
  runRecallFn = runRecall,
753
610
  runTicketCommentFn = runTicketComment,
@@ -778,7 +635,7 @@ export function runMcpServer({
778
635
  // never resolving (a dropped rejection isn't a resolution) — the
779
636
  // server would hang on shutdown instead of exiting.
780
637
  queue = queue.then(async () => {
781
- const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
638
+ const response = await handleMessage(line, { configDir, runFetchTicketFn, runTriageFn, runDoctorFn, runStatsFn, runHistoryFn, runCollisionsFn, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
782
639
  if (response) stdout.write(response);
783
640
  }).catch(() => {});
784
641
  });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * JSON-RPC `tools/list` schema definitions for every MCP tool `mcp-server.mjs`
3
+ * exposes. Pure data — no logic, no imports — extracted out of mcp-server.mjs
4
+ * to keep that file under the project's 800-line cap. Order here is the order
5
+ * clients see in `tools/list`.
6
+ */
7
+ export const TOOLS = [
8
+ {
9
+ name: 'fetch',
10
+ description: 'Fetch a ticket\'s full context brief (Jira/GitHub/Linear) — description, comments, linked tickets, code references, attachments. The core read action; free tier. Not a discovery tool — requires a known ticket key.',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
15
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
16
+ depth: { type: 'number', description: 'How many hops of linked tickets to traverse. Defaults to 1 (direct links only). 0 disables traversal.' },
17
+ },
18
+ required: ['ticket'],
19
+ },
20
+ },
21
+ {
22
+ name: 'triage',
23
+ description: 'Scan assigned tickets and surface what needs attention — replies owed, aging tickets, stale-status tickets. The base scan is free tier; some options require a TicketLens Pro or Team license.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
28
+ stale: { type: 'number', description: 'Days before an untouched ticket counts as aging. Defaults to 5.' },
29
+ status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include, overriding the profile default / built-in defaults (In Progress, Code Review, QA).' },
30
+ sort: { type: 'string', description: 'Sort order for results, overriding the profile default.' },
31
+ save: { type: 'string', description: 'Write the plain-text summary to this local file path instead of (in addition to) returning it. Requires a TicketLens Pro license.' },
32
+ all: { type: 'boolean', description: 'Triage every configured profile, not just the resolved one. Requires a TicketLens Pro license.' },
33
+ digest: { type: 'boolean', description: 'Deliver the scored results to the digest backend instead of returning them as text — on success, no summary is returned, only a delivery confirmation. Requires a TicketLens Pro license.' },
34
+ assignee: { type: 'string', description: 'View another user\'s tickets instead of your own. Requires a TicketLens Team license.' },
35
+ sprint: { type: 'string', description: 'Scope to a named sprint. Requires a TicketLens Team license.' },
36
+ export: { type: 'string', enum: ['csv', 'json'], description: 'Write results to a file in this format instead of returning the summary text, returning the written file path instead. Requires a TicketLens Team license.' },
37
+ project: { type: 'string', description: 'Scope to a project/team key. Requires a TicketLens Team license.' },
38
+ label: { type: 'array', items: { type: 'string' }, description: 'Scope to one or more labels. Requires a TicketLens Team license.' },
39
+ priority: { type: 'string', description: 'Scope to a priority name, e.g. "High". Requires a TicketLens Team license.' },
40
+ },
41
+ },
42
+ },
43
+ {
44
+ name: 'compliance',
45
+ description: 'Check a ticket\'s acceptance-criteria coverage against the current git diff — extracts requirements from the ticket description, matches them against code changes, and reports a coverage percentage plus what\'s missing. Read-only; the same check `ticketlens install-hooks` runs automatically. Free tier: 3 checks per month; TicketLens Pro removes the limit.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
50
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
51
+ },
52
+ required: ['ticket'],
53
+ },
54
+ },
55
+ {
56
+ name: 'review',
57
+ 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.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ 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.' },
62
+ branch: { type: 'string', description: 'Alias for `base` — same effect, provided for parity with the CLI\'s `--branch=` flag.' },
63
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
64
+ },
65
+ },
66
+ },
67
+ {
68
+ name: 'standup',
69
+ 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.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ 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.' },
74
+ 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.' },
75
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
76
+ },
77
+ },
78
+ },
79
+ {
80
+ name: 'pr',
81
+ 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.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
86
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
87
+ },
88
+ required: ['ticket'],
89
+ },
90
+ },
91
+ {
92
+ name: 'stats',
93
+ 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.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile.' },
98
+ days: { type: 'number', description: 'Lookback window in days. Defaults to 7. Free tier is silently capped at 7; TicketLens Pro allows up to 30.' },
99
+ format: { type: 'string', enum: ['plain', 'json'], description: 'Output shape: "plain" (default) is a human-readable table; "json" is structured for scripting.' },
100
+ },
101
+ },
102
+ },
103
+ {
104
+ name: 'history',
105
+ description: 'Show a 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. Requires a TicketLens Pro license.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
110
+ },
111
+ required: ['ticket'],
112
+ },
113
+ },
114
+ {
115
+ name: 'collisions',
116
+ description: 'Show branches where your changed files overlap with a teammate\'s — compares your current branch against teammates\' recent branches (within 7 days) pushed via `ticketlens triage --push`. Requires `ticketlens login` (Console access) and a TicketLens Team license.',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ json: { type: 'boolean', description: 'Return a raw JSON array of collision objects instead of a formatted report.' },
121
+ plain: { type: 'boolean', description: 'Plain text output with no ANSI colour.' },
122
+ },
123
+ },
124
+ },
125
+ {
126
+ name: 'doctor',
127
+ 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.',
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: {
131
+ fix: { type: 'boolean', description: 'Attempt safe, non-destructive repairs for failing checks.' },
132
+ profile: { type: 'string', description: 'Scope profile/connectivity/cache checks to one profile.' },
133
+ },
134
+ },
135
+ },
136
+ {
137
+ name: 'recall_add',
138
+ description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ title: { type: 'string', description: 'Short one-line title.' },
143
+ ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
144
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
145
+ body: { type: 'string', description: 'The note body — one or more paragraphs.' },
146
+ },
147
+ required: ['title', 'body'],
148
+ },
149
+ },
150
+ {
151
+ name: 'recall_search',
152
+ description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
157
+ },
158
+ required: ['query'],
159
+ },
160
+ },
161
+ {
162
+ name: 'ticket_comment',
163
+ description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
168
+ body: { type: 'string', description: 'Comment body.' },
169
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach — images render as a real inline thumbnail in the posted comment on both Jira (Cloud and Server/Data Center) and Linear. Not supported on GitHub — no PAT-compatible upload API exists there.' },
170
+ },
171
+ required: ['ticket', 'body'],
172
+ },
173
+ },
174
+ {
175
+ name: 'ticket_transition',
176
+ description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
181
+ target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
182
+ confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
183
+ },
184
+ required: ['ticket'],
185
+ },
186
+ },
187
+ {
188
+ name: 'ticket_assign',
189
+ description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
190
+ inputSchema: {
191
+ type: 'object',
192
+ properties: {
193
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
194
+ to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
195
+ },
196
+ required: ['ticket', 'to'],
197
+ },
198
+ },
199
+ {
200
+ name: 'ticket_duplicates',
201
+ description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything. On Jira, any ticket already linked as a "Duplicate" is always included first (a confirmed relationship, not a guess); everything else comes from a local, approximate title/description overlap score, since no tracker scores similarity server-side. That scorer can miss real duplicates as easily as it over-matches, so an empty result means none were found, not a guarantee that none exist. Requires a TicketLens Pro license.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
206
+ threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
207
+ },
208
+ required: ['ticket'],
209
+ },
210
+ },
211
+ {
212
+ name: 'ticket_link',
213
+ description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
218
+ target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
219
+ type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
220
+ confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
221
+ },
222
+ required: ['ticket', 'target'],
223
+ },
224
+ },
225
+ {
226
+ name: 'ticket_update',
227
+ description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. At least one field is required. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {
231
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
232
+ title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
233
+ description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
234
+ addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
235
+ removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
236
+ priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
237
+ },
238
+ required: ['ticket'],
239
+ },
240
+ },
241
+ {
242
+ name: 'ticket_create',
243
+ description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
248
+ type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
249
+ summary: { type: 'string', description: 'Ticket title/summary.' },
250
+ description: { type: 'string', description: 'Ticket description. Omit for none.' },
251
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
252
+ profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile. Use this when `project` belongs to a profile other than the one auto-resolved from the current working directory.' },
253
+ },
254
+ required: ['summary'],
255
+ },
256
+ },
257
+ ];
@@ -0,0 +1,50 @@
1
+ import { queryTicketHistory, DEFAULT_CONFIG_DIR } from './triage-history.mjs';
2
+ import { isLicensed as defaultIsLicensed, showUpgradePrompt } from './license.mjs';
3
+ import { createStyler } from './ansi.mjs';
4
+ import { printHistoryHelp } from './help.mjs';
5
+
6
+ /**
7
+ * Prints a ticket's local triage-history timeline. Extracted from
8
+ * bin/ticketlens.mjs's inline 'history' case so the MCP `history` tool has
9
+ * a CLI-layer function to wrap, same as every other MCP tool (see
10
+ * mcp-server.mjs's file header). Read-only, entirely local — no network call.
11
+ */
12
+ export async function runHistory(args = [], opts = {}) {
13
+ const print = opts.print ?? ((s) => process.stdout.write(s));
14
+ const warn = opts.warn ?? ((s) => process.stderr.write(s));
15
+ const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR;
16
+ const isLic = opts.isLicensed ?? defaultIsLicensed;
17
+ const queryFn = opts.queryTicketHistoryFn ?? queryTicketHistory;
18
+
19
+ if (args.includes('--help') || args.includes('-h')) {
20
+ printHistoryHelp({ stream: { write: print, isTTY: process.stdout.isTTY } });
21
+ return;
22
+ }
23
+
24
+ if (!isLic('pro', configDir)) {
25
+ showUpgradePrompt('pro', 'ticketlens history', { stream: { write: warn, isTTY: process.stderr.isTTY } });
26
+ return;
27
+ }
28
+
29
+ const ticketKey = args[0];
30
+ if (!ticketKey || ticketKey.startsWith('-')) {
31
+ warn('Usage: ticketlens history TICKET-KEY\n');
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ const entries = queryFn(ticketKey, { configDir });
37
+ if (entries.length === 0) {
38
+ print(`No triage history found for ${ticketKey}.\n`);
39
+ return;
40
+ }
41
+
42
+ const hs = createStyler({ isTTY: process.stdout.isTTY });
43
+ print(`\nHistory for ${hs.bold(ticketKey)} (${entries.length} entries)\n\n`);
44
+ for (const e of entries) {
45
+ const bounce = e.bounced ? hs.yellow(' ⟳ bounced') : '';
46
+ const urg = e.urgency === 'needs-response' ? hs.red(e.urgency) : e.urgency === 'aging' ? hs.yellow(e.urgency) : hs.green(e.urgency);
47
+ print(` ${hs.dim(e.date)} [${e.profile}] ${urg}${bounce} ${hs.dim(e.reason)}\n`);
48
+ }
49
+ print('\n');
50
+ }
@@ -73,6 +73,11 @@ const HARD_REJECT_PATTERNS = [
73
73
 
74
74
  const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
75
75
 
76
+ // Shared between CODE_FILENAME_RE below and FILENAME_REFERENCE_RE further
77
+ // down, the same way WHITESPACE_CLASS is shared across the whitespace
78
+ // regexes above — one definition so the two can't silently drift apart.
79
+ const CODE_EXTENSION_ALTERNATION = 'php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh';
80
+
76
81
  // A letters-only token ending in a recognized source-file extension reads as
77
82
  // high-entropy the same way a real secret does — a class name doubling as its
78
83
  // filename is the common case, but a deliberately-renamed or accidentally
@@ -86,17 +91,60 @@ const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
86
91
  // not a shortcut inside looksRandom itself. A bare identifier with no
87
92
  // extension (a method name, not a filename) isn't covered by this at all —
88
93
  // that shape is indistinguishable from a base64 secret fragment either way.
89
- const CODE_FILENAME_RE = /^[A-Za-z]+\.(php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh)$/i;
94
+ //
95
+ // Stem allows hyphens (this project's own file-naming convention —
96
+ // "recall-nudge-stop.mjs", "secret-scanner.mjs") captured as group 1, so
97
+ // looksLikeCodeFilename can judge a kebab-case stem the same way
98
+ // isHyphenatedWordCompound already judges one elsewhere in this file (see
99
+ // its call below) — a real multi-word filename reads as short, plausible
100
+ // segments, not one long random run.
101
+ const CODE_FILENAME_RE = new RegExp(`^([A-Za-z][A-Za-z-]*)\\.(${CODE_EXTENSION_ALTERNATION})$`, 'i');
102
+
103
+ // A hyphenated-stem filename (this project's own file-naming convention —
104
+ // "secret-scanner.mjs", "note-command.mjs") optionally followed by a
105
+ // directly-attached possessive apostrophe-s ("note-command.mjs's") stops a
106
+ // joinedChunkRuns run the same way any other label word does (see
107
+ // isLabelWord below). Deliberately a SEPARATE regex from CODE_FILENAME_RE
108
+ // above rather than a loosened version of it: CODE_FILENAME_RE feeds
109
+ // looksLikeCodeFilename's downgrade-a-reject-to-a-warning path and is
110
+ // guarded by hasInternalCaseSwitch specifically so a random single-case
111
+ // string plus a fake extension can't be silently waved through (see its
112
+ // docstring) — loosening that guard is out of scope here and unnecessary:
113
+ // this regex only ever stops a *run*, it never exempts a token from the
114
+ // standalone entropy check (every token stays in the flat `tokens` array
115
+ // in scanForSecrets regardless of isLabelWord), so it cannot itself bypass
116
+ // detection the way a change to CODE_FILENAME_RE could.
117
+ //
118
+ // Known accepted gap, same class as isLabelWord's ordinary-word and
119
+ // hyphenated-compound allowances below: a deliberate attacker could append
120
+ // a fake ".mjs" (optionally + "'s") to the first half of a fragmented
121
+ // secret specifically to stop the join here. Not a new exposure — this
122
+ // file already accepts that an ordinary word or short hyphenated compound
123
+ // can be used the same way (see isLabelWord's "Known accepted gap"
124
+ // comment). HARD_REJECT_PATTERNS are unaffected either way: that pass uses
125
+ // stopAtLabelWords:false and also checks the raw combined/despacedCombined
126
+ // text regardless of any token's label-word status.
127
+ const FILENAME_REFERENCE_RE = new RegExp(`^[A-Za-z][A-Za-z-]*\\.(${CODE_EXTENSION_ALTERNATION})('s)?$`, 'i');
90
128
 
91
129
  function looksLikeCodeFilename(rawToken) {
92
130
  const stripped = stripEdgePunctuation(rawToken);
93
131
  const match = stripped.match(CODE_FILENAME_RE);
94
- // Requiring an internal case switch in the stem (the same signal used to
95
- // detect base64 content elsewhere in this file) means a genuinely random
96
- // single-case letter run plus a fake extension gets no special treatment
97
- // at all — only tokens that already look like a real PascalCase/camelCase
98
- // identifier reach the softer warning path below.
99
- return match !== null && hasInternalCaseSwitch(match[0]);
132
+ if (match === null) return false;
133
+ // Two independent ways a filename-shaped token reads as "structured, not
134
+ // random" rather than a disguised secret: an internal case switch (the
135
+ // same signal used to detect base64 content elsewhere in this file) is
136
+ // the PascalCase/camelCase signal; isHyphenatedWordCompound (already
137
+ // defined above, already reused this way for the join-stopping check) is
138
+ // the kebab-case signal — this project's own actual file-naming
139
+ // convention. A genuinely random single-case, non-hyphenated letter run
140
+ // plus a fake extension satisfies neither and gets no special treatment
141
+ // at all — only tokens that already look like a real filename (either
142
+ // convention) reach the softer warning path below.
143
+ return hasInternalCaseSwitch(match[0]) || isHyphenatedWordCompound(match[1]);
144
+ }
145
+
146
+ function looksLikeFilenameReference(strippedToken) {
147
+ return FILENAME_REFERENCE_RE.test(strippedToken);
100
148
  }
101
149
 
102
150
  function shannonEntropy(token) {
@@ -190,13 +238,25 @@ function isHyphenatedWordCompound(token) {
190
238
  /**
191
239
  * True for a token that stops a joined-chunk run: either a recognized git/
192
240
  * checksum label word ("commit", "sha256", "md5sum", ...), a hyphenated
193
- * compound word (see isHyphenatedWordCompound), or an ordinary English word
194
- * (letters only, optionally with an internal possessive/contraction
195
- * apostrophe — "relay's", "doesn't" — but no base64-style case switching).
196
- * Anything else — a fragment containing a digit or other symbol, or an
197
- * all-letter chunk that still reads as random content — stays eligible to
198
- * join, so a secret split by whitespace can still be reassembled for the
199
- * entropy check.
241
+ * compound word (see isHyphenatedWordCompound), a filename reference (see
242
+ * FILENAME_REFERENCE_RE — "note-command.mjs", "note-command.mjs's"), or an
243
+ * ordinary English word (letters only, optionally with an internal
244
+ * possessive/contraction apostrophe — "relay's", "doesn't" — but no
245
+ * base64-style case switching). Anything else — a fragment containing a
246
+ * digit or other symbol, or an all-letter chunk that still reads as random
247
+ * content — stays eligible to join, so a secret split by whitespace can
248
+ * still be reassembled for the entropy check.
249
+ *
250
+ * Filename references (backlog #13) matter for the same reason the
251
+ * apostrophe allowance below does: "note-command.mjs's runNoteAdd" is
252
+ * ordinary engineering prose (a filename possessive next to an identifier),
253
+ * but before this allowance neither token qualified as a label word — the
254
+ * filename fails the ordinary-word branch (hyphen and period aren't
255
+ * letters) and the identifier fails it too (camelCase has an internal case
256
+ * switch, by design) — so they glued into one candidate that tripped the
257
+ * entropy threshold. See FILENAME_REFERENCE_RE's own comment for why this
258
+ * is scoped separately from CODE_FILENAME_RE/looksLikeCodeFilename and
259
+ * cannot reopen that function's CRITICAL-bypass guard.
200
260
  *
201
261
  * The apostrophe allowance matters because without it, an ordinary possessive
202
262
  * next to another non-label token (e.g. a hyphenated compound: "relay's
@@ -235,6 +295,7 @@ function isLabelWord(token) {
235
295
  const stripped = stripEdgePunctuation(token);
236
296
  if (GIT_REFERENCE_WORD_RE.test(stripped)) return true;
237
297
  if (isHyphenatedWordCompound(stripped)) return true;
298
+ if (looksLikeFilenameReference(stripped)) return true;
238
299
  return /^[A-Za-z]+(?:'[A-Za-z]+)*$/.test(stripped) && !hasInternalCaseSwitch(stripped);
239
300
  }
240
301