ticketlens 0.25.0 → 0.26.0

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
@@ -411,7 +411,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
411
411
 
412
412
  **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).
413
413
 
414
- **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, and `ticket_assign` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
414
+ **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, and `ticket_duplicates` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
415
415
 
416
416
  `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.
417
417
 
@@ -419,22 +419,25 @@ Every note is scanned before saving — anything shaped like a real secret (API
419
419
 
420
420
  ---
421
421
 
422
- ### Comment, Transition & Assign
422
+ ### Comment, Transition, Assign & Duplicates
423
423
 
424
424
  ```bash
425
425
  ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
426
426
  ticketlens transition PROJ-123 # List valid transitions (read-only)
427
427
  ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
428
428
  ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
429
+ ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
429
430
  ```
430
431
 
431
- Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign` MCP tools. Requires a Pro license.
432
+ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` MCP tools. Requires a Pro license.
432
433
 
433
434
  `ticketlens transition` with just a ticket key lists the tracker's current valid options without changing anything (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Add both `--target` and `--confirm` to execute — `--confirm` is a deliberate two-step gate: a behavioral nudge and forensic trail, not a hard security guarantee. Every write, once resolved, is re-validated against the tracker's current state immediately before executing — never a blind write against a stale option.
434
435
 
435
436
  `ticketlens assign` is self-assign only for now — `--to` must be `me`. Assigning to someone else needs a per-tracker user-lookup step this doesn't do yet, so it's deliberately out of scope until that's built.
436
437
 
437
- All three actions have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated.
438
+ `ticketlens duplicates` is read-only — it never links or changes anything, just lists likely matches in the same project. No tracker (Jira/GitHub/Linear) scores similarity server-side, so ranking happens locally from title/description word overlap; treat a match as a nudge to check manually, not a verdict. `--threshold=N` (0–1, default 0.35) controls how loose a match counts.
439
+
440
+ All three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
438
441
 
439
442
  ---
440
443
 
@@ -715,11 +718,13 @@ ticketlens mcp # Start the MCP stdio server (reca
715
718
  ticketlens mcp install # Register it into the current project's .mcp.json
716
719
  ticketlens mcp install --dry-run # Preview the registration without writing
717
720
 
718
- # ── Comment, Transition & Assign ─────────────────────────────────────────────
721
+ # ── Comment, Transition, Assign & Duplicates ────────────────────────────────────
719
722
  ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
720
723
  ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
721
724
  ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
722
725
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
726
+ ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
727
+ ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
723
728
 
724
729
  # ── Stats ──────────────────────────────────────────────────────────────────────
725
730
  ticketlens stats # Response-time metrics from local history
@@ -805,6 +810,7 @@ ticketlens mcp # Start the MCP stdio server (recall/ti
805
810
  ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
806
811
  ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
807
812
  ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
813
+ ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
808
814
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
809
815
  ```
810
816
 
@@ -28,7 +28,7 @@ import {
28
28
  printCollisionsHelp, printStatsHelp,
29
29
  printCloudKeysHelp,
30
30
  printNoteHelp, printRecallHelp, printMcpHelp,
31
- printCommentHelp, printTransitionHelp, printAssignHelp,
31
+ printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp,
32
32
  } from '../skills/jtb/scripts/lib/help.mjs';
33
33
  import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
34
34
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
@@ -778,6 +778,18 @@ switch (command) {
778
778
  break;
779
779
  }
780
780
 
781
+ case 'duplicates': {
782
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printDuplicatesHelp(); break; }
783
+ const { runTicketDuplicates } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
784
+ runTicketDuplicates(cmdArgs).then(({ ok }) => {
785
+ if (!ok) process.exitCode = 1;
786
+ }).catch(err => {
787
+ process.stderr.write(`Error: ${err.message}\n`);
788
+ process.exitCode = 1;
789
+ });
790
+ break;
791
+ }
792
+
781
793
  case 'help':
782
794
  default: {
783
795
  const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
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": {
@@ -17,7 +17,7 @@
17
17
  "skills/jtb/scripts/fetch-my-tickets.mjs"
18
18
  ],
19
19
  "scripts": {
20
- "test": "node --test skills/jtb/scripts/test/*.test.mjs",
20
+ "test": "node --test 'skills/jtb/scripts/test/**/*.test.mjs'",
21
21
  "postinstall": "node scripts/postinstall.mjs",
22
22
  "prepublishOnly": "node scripts/preflight.mjs",
23
23
  "publish:beta": "node scripts/publish.mjs --tag=beta",
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.25.0 -->
1
+ <!-- jtb-skill-version: 0.26.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.
@@ -264,26 +264,29 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
264
264
 
265
265
  ---
266
266
 
267
- ## Comment, Transition & Assign — write back to the tracker (Pro)
267
+ ## Comment, Transition, Assign & Duplicates — write back to the tracker (Pro)
268
268
 
269
- Unlike Recall (a local note about a ticket), these write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch when the user has actually asked for the ticket to be commented on, moved, or assigned — never as a routine end-of-session action the way Recall capture is.
269
+ Unlike Recall (a local note about a ticket), comment/transition/assign write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch a write when the user has actually asked for the ticket to be commented on, moved, or assigned — never as a routine end-of-session action the way Recall capture is. `duplicates` is read-only and safe to run more freely — it never mutates anything.
270
270
 
271
271
  ```bash
272
272
  ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
273
273
  ticketlens transition PROD-1234 # list valid transitions — read-only
274
274
  ticketlens transition PROD-1234 --target="Done" --confirm # execute
275
275
  ticketlens assign PROD-1234 --to=me # assign to yourself
276
+ ticketlens duplicates PROD-1234 # find likely duplicates — read-only
276
277
  ```
277
278
 
278
279
  `transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
279
280
 
280
281
  `assign` is self-assign only — `--to` must be `me`. There is no way to assign to anyone else yet; don't attempt a workaround (e.g. via `comment`) if the user asks for that — tell them it isn't supported.
281
282
 
282
- All three actions have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is.
283
+ `duplicates` lists likely-duplicate tickets in the same project, ranked by local title/description overlap — no tracker scores similarity server-side, so treat a match as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a match.
283
284
 
284
- **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
285
+ The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
285
286
 
286
- Requires a Pro license — on Free, all three no-op with an upgrade hint on stderr.
287
+ **Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
288
+
289
+ Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
287
290
 
288
291
  ---
289
292
 
@@ -10,6 +10,7 @@ import { assembleTriageSummary } from './lib/brief-assembler.mjs';
10
10
  import { styleTriageSummary } from './lib/styled-assembler.mjs';
11
11
  import { resolveConnection, loadProfiles, saveProfile } from './lib/profile-resolver.mjs';
12
12
  import { resolveAdapter } from './lib/resolve-adapter.mjs';
13
+ import { escapeJql } from './lib/jira-client.mjs';
13
14
  import { incrementTriageRun, readAndResetActivity } from './lib/activity-counter.mjs';
14
15
  import { DEFAULT_CONFIG_DIR } from './lib/config.mjs';
15
16
  import { writeFileSync, mkdirSync, statSync } from 'node:fs';
@@ -42,10 +43,6 @@ async function defaultDigestDeliverer(payload, { cliToken } = {}) {
42
43
  return true;
43
44
  }
44
45
 
45
- function escapeJql(s) {
46
- return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
47
- }
48
-
49
46
  export async function run(args, envOrOpts = process.env, fetcher = globalThis.fetch, configDir = undefined) {
50
47
  // Support both legacy positional form run(args, env, fetcher, configDir)
51
48
  // and new opts-object form run(args, { env, fetcher, configDir, exporter, isLicensed, showUpgradePrompt, print })
@@ -1,3 +1,5 @@
1
+ import { tokenize } from '../duplicate-scorer.mjs';
2
+
1
3
  /**
2
4
  * Parses owner and repo from a GitHub profile baseUrl.
3
5
  * Expected format: https://github.com/OWNER/REPO
@@ -208,5 +210,35 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
208
210
  if (!res.ok) await throwGitHubWriteError(res, 'assigning', key);
209
211
  return { assignee: me.displayName ?? me.login };
210
212
  },
213
+
214
+ /**
215
+ * Candidate search for duplicate-ticket detection via GitHub's search
216
+ * endpoint (distinct from the issues-list endpoint `searchTickets` uses,
217
+ * which hardcodes "assigned to me" and ignores its query argument).
218
+ * Excludes the source issue and returns unranked candidates —
219
+ * duplicate-scorer.mjs does the actual similarity ranking.
220
+ *
221
+ * Never interpolates raw ticket text into `q` — GitHub parses the
222
+ * decoded query with its own qualifier grammar (`repo:`, `org:`, `is:`,
223
+ * etc, space-delimited), and multiple `repo:`/`org:` qualifiers are
224
+ * OR'd together. A crafted title/description containing e.g.
225
+ * `repo:otherorg/private-repo` would widen the search to a repo outside
226
+ * this profile's scope — a confused-deputy query-injection, not just a
227
+ * cosmetic bug. tokenize() strips all non-letter/digit characters
228
+ * (including `:`), so no qualifier syntax survives into `q`, and its
229
+ * cap keeps the query well under GitHub's search length limit.
230
+ */
231
+ async findCandidates(text, sourceKey, opts = {}) {
232
+ const sourceNumber = parseInt(sourceKey.split('-').pop(), 10);
233
+ const terms = tokenize(text).slice(0, 8).join(' ');
234
+ const q = `repo:${owner}/${repo} is:issue ${terms}`;
235
+ const url = `${GITHUB_API}/search/issues?q=${encodeURIComponent(q)}`;
236
+ const res = await fetcher(url, { headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) });
237
+ if (!res.ok) await throwGitHubWriteError(res, 'searching', sourceKey);
238
+ const raw = await res.json();
239
+ return raw.items
240
+ .filter(item => item.number !== sourceNumber)
241
+ .map(item => normalizeGitHubIssue(item, [], keyPrefix));
242
+ },
211
243
  };
212
244
  }
@@ -1,4 +1,4 @@
1
- import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue } from '../jira-client.mjs';
1
+ import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql } from '../jira-client.mjs';
2
2
  import { buildJiraEnv } from '../config.mjs';
3
3
 
4
4
  /**
@@ -7,6 +7,8 @@ import { buildJiraEnv } from '../config.mjs';
7
7
  * trusts a caller-supplied id without confirming it's still a real,
8
8
  * currently-valid option for this exact issue right now.
9
9
  */
10
+ const SEARCH_TEXT_CHAR_LIMIT = 300;
11
+
10
12
  function resolveTransitionTarget(options, target) {
11
13
  const t = String(target).toLowerCase();
12
14
  return options.find(o => o.id === String(target) || o.name.toLowerCase() === t || (o.to ?? '').toLowerCase() === t);
@@ -64,5 +66,22 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
64
66
  await assignIssue(key, { [field]: value }, { ...base, ...opts });
65
67
  return { assignee: me.displayName ?? value };
66
68
  },
69
+
70
+ /**
71
+ * Candidate search for duplicate-ticket detection. Scoped to the same
72
+ * project as `sourceKey` (derived from its own prefix) and excludes it
73
+ * from results. Jira has no server-side similarity scoring — this only
74
+ * narrows the candidate pool; ranking happens in duplicate-scorer.mjs.
75
+ */
76
+ async findCandidates(text, sourceKey, opts = {}) {
77
+ const hyphenIndex = sourceKey.lastIndexOf('-');
78
+ if (hyphenIndex < 1) {
79
+ throw new Error(`Cannot derive a project key from "${sourceKey}" — expected PROJECT-123.`);
80
+ }
81
+ const project = sourceKey.slice(0, hyphenIndex);
82
+ const searchText = text.slice(0, SEARCH_TEXT_CHAR_LIMIT);
83
+ const jql = `project = "${escapeJql(project)}" AND key != "${escapeJql(sourceKey)}" AND text ~ "${escapeJql(searchText)}" ORDER BY updated DESC`;
84
+ return searchTickets(jql, { ...base, ...opts });
85
+ },
67
86
  };
68
87
  }
@@ -1,3 +1,5 @@
1
+ import { tokenize } from '../duplicate-scorer.mjs';
2
+
1
3
  const LINEAR_API = 'https://api.linear.app/graphql';
2
4
 
3
5
  const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
@@ -257,5 +259,41 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
257
259
  }
258
260
  return { assignee: me.displayName ?? me.id };
259
261
  },
262
+
263
+ /**
264
+ * Candidate search for duplicate-ticket detection. Linear's `contains`/
265
+ * `containsIgnoreCase` filters are literal substring matches, not
266
+ * full-text search (confirmed — Linear has no built-in similarity
267
+ * matching) — passing the whole source text as one substring filter
268
+ * would almost never match anything. Instead ORs across the
269
+ * significant tokens, ANDed with a team scope derived from the source
270
+ * key's prefix. Ranking happens in duplicate-scorer.mjs.
271
+ */
272
+ async findCandidates(text, sourceKey, opts = {}) {
273
+ const terms = tokenize(text).slice(0, 5);
274
+ if (terms.length === 0) return [];
275
+ const hyphenIndex = sourceKey.lastIndexOf('-');
276
+ if (hyphenIndex < 1) {
277
+ throw new Error(`Cannot derive a team key from "${sourceKey}" — expected TEAM-123.`);
278
+ }
279
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
280
+ const prefix = sourceKey.slice(0, hyphenIndex);
281
+ const filter = {
282
+ team: { key: { eq: prefix } },
283
+ or: terms.map(term => ({ title: { containsIgnoreCase: term } })),
284
+ };
285
+ const data = await gql(
286
+ `query ($filter: IssueFilter) {
287
+ issues(filter: $filter, first: 50) {
288
+ nodes { ${ISSUE_FIELDS} }
289
+ }
290
+ }`,
291
+ { filter },
292
+ { token, fetcher, signal },
293
+ );
294
+ return (data.issues?.nodes ?? [])
295
+ .filter(node => node.identifier !== sourceKey)
296
+ .map(normalizeLinearIssue);
297
+ },
260
298
  };
261
299
  }
@@ -142,6 +142,10 @@ export function parseCommand(args) {
142
142
  return { command: 'assign', args: args.slice(1) };
143
143
  }
144
144
 
145
+ if (first === 'duplicates') {
146
+ return { command: 'duplicates', args: args.slice(1) };
147
+ }
148
+
145
149
  // Anything that looks like a ticket key or any non-flag arg → fetch
146
150
  return { command: 'fetch', args };
147
151
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Zero-dependency duplicate-ticket scoring. No tracker (Jira/GitHub/Linear)
3
+ * offers server-side similarity scoring — only exact/substring text filters —
4
+ * so candidate ranking happens here via Jaccard set overlap on tokenized text.
5
+ */
6
+
7
+ const STOPWORDS = new Set([
8
+ 'the', 'a', 'an', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'is', 'are',
9
+ 'this', 'that', 'with', 'as', 'at', 'by', 'from', 'it', 'be', 'was', 'were',
10
+ 'has', 'have', 'had', 'not', 'but', 'if', 'will', 'can', 'do', 'does',
11
+ ]);
12
+
13
+ const TITLE_WEIGHT = 2;
14
+ const DESCRIPTION_WEIGHT = 1;
15
+ const DESCRIPTION_CHAR_LIMIT = 500;
16
+
17
+ /**
18
+ * Lowercases, strips punctuation, drops stopwords and sub-2-char tokens,
19
+ * and de-duplicates. Returns a plain array (order not significant — callers
20
+ * treat it as a set).
21
+ */
22
+ export function tokenize(text) {
23
+ if (!text) return [];
24
+ const words = text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
25
+ const seen = new Set();
26
+ for (const w of words) {
27
+ if (w.length >= 2 && !STOPWORDS.has(w)) seen.add(w);
28
+ }
29
+ return [...seen];
30
+ }
31
+
32
+ /**
33
+ * Intersection-over-union. Two empty sets are defined as 0 similarity, not 1 —
34
+ * an empty-vs-empty match is a lack of signal, not a confirmed duplicate.
35
+ */
36
+ export function jaccardSimilarity(tokensA, tokensB) {
37
+ const setA = new Set(tokensA);
38
+ const setB = new Set(tokensB);
39
+ if (setA.size === 0 && setB.size === 0) return 0;
40
+ let intersection = 0;
41
+ for (const t of setA) {
42
+ if (setB.has(t)) intersection += 1;
43
+ }
44
+ const union = setA.size + setB.size - intersection;
45
+ return union === 0 ? 0 : intersection / union;
46
+ }
47
+
48
+ function weightedScore(source, candidate) {
49
+ const titleScore = jaccardSimilarity(tokenize(source.summary), tokenize(candidate.summary));
50
+ const descScore = jaccardSimilarity(
51
+ tokenize((source.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
52
+ tokenize((candidate.description ?? '').slice(0, DESCRIPTION_CHAR_LIMIT)),
53
+ );
54
+ return (titleScore * TITLE_WEIGHT + descScore * DESCRIPTION_WEIGHT) / (TITLE_WEIGHT + DESCRIPTION_WEIGHT);
55
+ }
56
+
57
+ /**
58
+ * Ranks candidates against the source ticket, excludes the source itself
59
+ * (defense-in-depth — callers already exclude it at the query level) and
60
+ * anything below `threshold`, sorted highest score first, capped at `limit`.
61
+ */
62
+ export function scoreCandidates(source, candidates, { threshold = 0.35, limit = 5 } = {}) {
63
+ return candidates
64
+ .filter(c => c.key !== source.key)
65
+ .map(c => ({ key: c.key, summary: c.summary, score: weightedScore(source, c) }))
66
+ .filter(c => c.score >= threshold)
67
+ .sort((a, b) => b.score - a.score)
68
+ .slice(0, limit);
69
+ }
@@ -57,6 +57,7 @@ export function printHelp({ stream = process.stdout } = {}) {
57
57
  ` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
58
58
  ` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
59
59
  ` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
60
+ ` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
60
61
  '',
61
62
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
62
63
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -597,11 +598,12 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
597
598
  '',
598
599
  ` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
599
600
  ` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
600
- ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible AI harness, not just`,
601
- ` Claude Code. Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/`,
602
- ` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate, same local vault/tracker writes, same`,
603
- ` team sync. ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`;`,
604
- ` ${s.cyan('ticket_assign')} is currently self-assign only.`,
601
+ ` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')} — for any MCP-compatible AI`,
602
+ ` harness, not just Claude Code. Thin adapter over the same code as`,
603
+ ` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')} above: same Pro gate, same`,
604
+ ` local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is destructive when`,
605
+ ` called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently self-assign only;`,
606
+ ` ${s.cyan('ticket_duplicates')} is read-only.`,
605
607
  ` Long-running — exits when the client closes stdin.`,
606
608
  '',
607
609
  ` ${s.bold('OPTIONS')}`,
@@ -702,6 +704,32 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
702
704
  stream.write(lines.join('\n') + '\n');
703
705
  }
704
706
 
707
+ export function printDuplicatesHelp({ stream = process.stdout } = {}) {
708
+ const s = createStyler({ isTTY: stream.isTTY });
709
+ const lines = [
710
+ '',
711
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('duplicates')} ${s.dim('TICKET-KEY [--threshold=0.35]')} ${s.dim('[Pro]')}`,
712
+ '',
713
+ ` Find likely duplicate tickets in the same project (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
714
+ ` Read-only — lists possible matches, never links or changes anything.`,
715
+ ` No tracker scores similarity server-side, so ranking happens locally`,
716
+ ` from title/description overlap. Not exact — treat it as a nudge to check.`,
717
+ '',
718
+ ` ${s.bold('OPTIONS')}`,
719
+ '',
720
+ ` ${s.brand('--threshold')}=${s.dim('N')} Minimum match score 0–1 to report ${s.dim('(default 0.35)')}`,
721
+ ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
722
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
723
+ '',
724
+ ` ${s.bold('EXAMPLES')}`,
725
+ '',
726
+ ` ${s.dim('$')} ticketlens duplicates PROD-123`,
727
+ ` ${s.dim('$')} ticketlens duplicates PROD-123 --threshold=0.5`,
728
+ '',
729
+ ];
730
+ stream.write(lines.join('\n') + '\n');
731
+ }
732
+
705
733
  export function printSwitchHelp({ stream = process.stdout } = {}) {
706
734
  const s = createStyler({ isTTY: stream.isTTY });
707
735
  const lines = [
@@ -13,6 +13,14 @@ function toText(value) {
13
13
  return adfToText(value);
14
14
  }
15
15
 
16
+ /**
17
+ * Escapes a value for safe interpolation into a double-quoted JQL string
18
+ * literal. Single source of truth — do not duplicate in callers.
19
+ */
20
+ export function escapeJql(s) {
21
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
22
+ }
23
+
16
24
  /**
17
25
  * Extracts the active sprint name from customfield_10020.
18
26
  * Cloud v3: array of sprint objects — prefers active, falls back to last.
@@ -23,7 +23,7 @@ import readline from 'node:readline';
23
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
24
24
  import { runNoteAdd } from './note-command.mjs';
25
25
  import { runRecall } from './recall-command.mjs';
26
- import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign } from './ticket-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates } from './ticket-command.mjs';
27
27
 
28
28
  const PROTOCOL_VERSION = '2025-11-25';
29
29
 
@@ -90,6 +90,18 @@ const TOOLS = [
90
90
  required: ['ticket', 'to'],
91
91
  },
92
92
  },
93
+ {
94
+ name: 'ticket_duplicates',
95
+ description: 'Find likely duplicate tickets in the same project (Jira/GitHub/Linear). Read-only — never links or changes anything; no tracker scores similarity server-side, so ranking is local and approximate. Requires a TicketLens Pro license.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
100
+ threshold: { type: 'number', description: 'Minimum match score 0-1 to report. Defaults to 0.35.' },
101
+ },
102
+ required: ['ticket'],
103
+ },
104
+ },
93
105
  ];
94
106
 
95
107
  function jsonRpcResult(id, result) {
@@ -209,6 +221,18 @@ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
209
221
  return ok ? { content } : { isError: true, content };
210
222
  }
211
223
 
224
+ async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn }) {
225
+ if (!args.ticket) {
226
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
227
+ }
228
+ const cmdArgs = [args.ticket];
229
+ if (args.threshold !== undefined) cmdArgs.push(`--threshold=${args.threshold}`);
230
+ const capture = capturingStream();
231
+ const { ok } = await runTicketDuplicatesFn(cmdArgs, { configDir, stream: capture });
232
+ const content = [{ type: 'text', text: capture.text }];
233
+ return ok ? { content } : { isError: true, content };
234
+ }
235
+
212
236
  async function handleToolsCall(params, deps) {
213
237
  const { name, arguments: args = {} } = params ?? {};
214
238
  if (name === 'recall_add') return callRecallAdd(args, deps);
@@ -216,10 +240,11 @@ async function handleToolsCall(params, deps) {
216
240
  if (name === 'ticket_comment') return callTicketComment(args, deps);
217
241
  if (name === 'ticket_transition') return callTicketTransition(args, deps);
218
242
  if (name === 'ticket_assign') return callTicketAssign(args, deps);
243
+ if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
219
244
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
220
245
  }
221
246
 
222
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
247
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn }) {
223
248
  let msg;
224
249
  try {
225
250
  msg = JSON.parse(raw);
@@ -249,7 +274,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
249
274
 
250
275
  if (method === 'tools/call') {
251
276
  try {
252
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
277
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
253
278
  return jsonRpcResult(id, result);
254
279
  } catch (err) {
255
280
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -276,6 +301,7 @@ export function runMcpServer({
276
301
  runTicketTransitionListFn = runTicketTransitionList,
277
302
  runTicketTransitionFn = runTicketTransition,
278
303
  runTicketAssignFn = runTicketAssign,
304
+ runTicketDuplicatesFn = runTicketDuplicates,
279
305
  } = {}) {
280
306
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
281
307
  // process — an unhandled 'error' event on either stream would otherwise
@@ -295,7 +321,7 @@ export function runMcpServer({
295
321
  // never resolving (a dropped rejection isn't a resolution) — the
296
322
  // server would hang on shutdown instead of exiting.
297
323
  queue = queue.then(async () => {
298
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
324
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
299
325
  if (response) stdout.write(response);
300
326
  }).catch(() => {});
301
327
  });
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
19
19
  * Instantiates the correct tracker adapter for a resolved connection.
20
20
  * @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
21
21
  * @param {{ fetcher?: Function }} [opts]
22
- * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function }}
22
+ * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function, findCandidates: Function }}
23
23
  */
24
24
  export function resolveAdapter(conn, opts = {}) {
25
25
  const type = detectTrackerType(conn?.baseUrl);
@@ -17,6 +17,7 @@ import { resolveAdapter } from './resolve-adapter.mjs';
17
17
  import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
18
18
  import { logAction } from './ticket-action-log.mjs';
19
19
  import { TICKET_KEY_PATTERN } from './cli.mjs';
20
+ import { scoreCandidates } from './duplicate-scorer.mjs';
20
21
 
21
22
  function parseFlag(cmdArgs, name) {
22
23
  return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
@@ -63,6 +64,30 @@ function formatWriteFailure(ticketKey, err) {
63
64
  }
64
65
  }
65
66
 
67
+ /**
68
+ * Read-path counterpart to formatWriteFailure — reuses the same
69
+ * classification (rate-limit/timeout/server-error metadata is real and
70
+ * worth keeping, not specific to writes) but with read-appropriate wording,
71
+ * since "duplicates" never writes anything.
72
+ */
73
+ function formatDuplicatesFailure(ticketKey, err) {
74
+ const classification = classifyWriteFailure(err);
75
+ switch (classification.kind) {
76
+ case 'rate-limited': {
77
+ const wait = classification.detail.retryAfterSeconds ?? null;
78
+ return wait
79
+ ? ` Rate limited by the tracker — retry checking ${ticketKey} after ~${wait}s.\n`
80
+ : ` Rate limited by the tracker — try checking ${ticketKey} again later.\n`;
81
+ }
82
+ case 'network-or-timeout':
83
+ return ` Network error or timeout checking ${ticketKey} for duplicates. Try again.\n`;
84
+ case 'server-error':
85
+ return ` Tracker returned a server error (${classification.status}) checking ${ticketKey} for duplicates. Try again later.\n`;
86
+ default:
87
+ return ` Error checking ${ticketKey} for duplicates: ${err.message}\n`;
88
+ }
89
+ }
90
+
66
91
  function requireLicense(isLicensedFn, configDir, commandName, stream) {
67
92
  if (isLicensedFn('pro', configDir)) return true;
68
93
  showUpgradePrompt('pro', commandName, { stream });
@@ -288,3 +313,58 @@ export async function runTicketAssign(cmdArgs, {
288
313
  return { ok: false };
289
314
  }
290
315
  }
316
+
317
+ /**
318
+ * Read-only — no cooldown, no action-log entry. Nothing is mutated, so
319
+ * there's nothing to debounce or audit, unlike comment/transition/assign.
320
+ *
321
+ * @param {string[]} cmdArgs - [ticketKey, ...flags], e.g. ["PROJ-1", '--threshold=0.4']
322
+ * @returns {Promise<{ ok: boolean, results?: Array<{key: string, summary: string, score: number}> }>}
323
+ */
324
+ export async function runTicketDuplicates(cmdArgs, {
325
+ configDir = DEFAULT_CONFIG_DIR,
326
+ stream = process.stderr,
327
+ isLicensedFn = isLicensed,
328
+ resolveConnectionFn = resolveConnection,
329
+ resolveAdapterFn = resolveAdapter,
330
+ } = {}) {
331
+ const usage = 'Usage: ticketlens duplicates TICKET-KEY [--threshold=0.35]\n';
332
+ if (!requireLicense(isLicensedFn, configDir, 'ticketlens duplicates', stream)) return { ok: false };
333
+
334
+ const ticketKey = requireTicketKey(cmdArgs, usage, stream);
335
+ if (!ticketKey) return { ok: false };
336
+
337
+ const thresholdArg = parseFlag(cmdArgs, 'threshold');
338
+ let threshold;
339
+ if (thresholdArg !== undefined) {
340
+ threshold = Number(thresholdArg);
341
+ if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
342
+ stream.write(` --threshold must be a number between 0 and 1 (got "${thresholdArg}").\n`);
343
+ return { ok: false };
344
+ }
345
+ }
346
+
347
+ const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
348
+ if (!adapter) return { ok: false };
349
+
350
+ try {
351
+ const source = await adapter.fetchTicket(ticketKey);
352
+ const searchText = [source.summary, source.description].filter(Boolean).join(' ');
353
+ const candidates = await adapter.findCandidates(searchText, ticketKey);
354
+ const scoreOpts = threshold !== undefined ? { threshold } : {};
355
+ const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
356
+
357
+ if (results.length === 0) {
358
+ stream.write(` No likely duplicates found for ${ticketKey}.\n`);
359
+ return { ok: true, results: [] };
360
+ }
361
+ stream.write(` Possible duplicates of ${ticketKey}:\n`);
362
+ for (const r of results) {
363
+ stream.write(` ${r.key} (${Math.round(r.score * 100)}% match) — ${r.summary}\n`);
364
+ }
365
+ return { ok: true, results };
366
+ } catch (err) {
367
+ stream.write(formatDuplicatesFailure(ticketKey, err));
368
+ return { ok: false };
369
+ }
370
+ }