@enrichlayer/el-linear 1.26.0 → 1.27.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.
@@ -160,11 +160,27 @@ and outreach tracked in one place.
160
160
 
161
161
  **Search before creating. No exceptions.**
162
162
 
163
+ > **Now enforced at the CLI ([DEV-4823](https://linear.app/verticalint/issue/DEV-4823/)).** `el-linear issues create` runs a deterministic
164
+ > duplicate-detection gate *before* the create POST: it tokenizes the title,
165
+ > searches the salient keywords (including closed issues), scores candidates
166
+ > by Jaccard title-overlap, and **blocks (exit non-zero) — listing the
167
+ > matches (id · title · state · assignee)** — when one crosses the similarity
168
+ > threshold (default `0.35`, set `validation.duplicateThreshold` to tune). This
169
+ > is the deterministic backstop for the manual check below — don't skip the
170
+ > manual review just because the gate exists (it catches title-keyword dupes,
171
+ > not semantic ones with different wording). To proceed past a flagged dupe,
172
+ > use `--allow-duplicate` (the narrow, correct flag for this gate);
173
+ > `--skip-validation` also bypasses it but skips all field validation too, so
174
+ > prefer `--allow-duplicate`. Disable just the gate with
175
+ > `validation.duplicateDetection: false` (field validation still runs);
176
+ > `validation.enabled: false` turns off all validation.
177
+
163
178
  ```bash
164
179
  # --include-closed is required so previously-completed duplicates surface.
165
180
  # `issues search` defaults to open states (DEV-4478); the duplicate check
166
181
  # intentionally widens to Done/Canceled because a closed-out duplicate is
167
- # still a duplicate.
182
+ # still a duplicate. (`issues create` runs this same widened search itself
183
+ # as the DEV-4823 gate — this manual step is for the semantic/judgment pass.)
168
184
  el-linear issues search "keywords from proposed title" --include-closed 2>&1
169
185
  ```
170
186
 
@@ -186,17 +202,15 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
186
202
  When `el-linear issues search` (or the cross-resource `search`) returns rows
187
203
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
188
204
  starting with `relation_candidates:` that enumerates the candidate IDs and
189
- asks the user to reply with which ones to link. Treat it as a hard step,
190
- not a hint:
191
-
192
- 1. **Surface the IDs to the user verbatim.** Show the `relation_candidates:`
193
- line (or paraphrase it preserving every ID + the example reply). Do **not**
194
- skip ahead to `issues relate`.
195
- 2. **Wait for an explicit reply naming the IDs to link** (e.g. `link DEV-2134
196
- and ALL-672`) or a clear skip (`no links`).
197
- 3. **Only the user-named IDs** go into the next `el-linear issues relate
198
- <source> --related-to "<ids>"` call. Never pass IDs the user did not name,
199
- even if your earlier search obviously surfaced them.
205
+ asks the user to reply with which ones to link, and states the skip phrase.
206
+
207
+ That warning is the authoritative instruction — surface it to the user
208
+ verbatim and follow it literally: **do not call `el-linear issues relate`
209
+ until the user replies naming the IDs to link.** Only user-named IDs go into
210
+ the `issues relate <source> --related-to "<ids>"` call — never pass an ID the
211
+ user did not name, even one your own search obviously surfaced. The CLI emits
212
+ the full procedure (every candidate ID, the example reply, the skip phrase) in
213
+ that one line, so follow it rather than re-deriving or paraphrasing it away.
200
214
 
201
215
  Why this matters: Claude Code's auto-mode permission classifier blocks
202
216
  `issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
@@ -6,6 +6,7 @@ import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../
6
6
  import { resolveDefaultStatus } from "../config/status-defaults.js";
7
7
  import { enforceTerms } from "../config/term-enforcer.js";
8
8
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
9
+ import { DEFAULT_DUPLICATE_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
9
10
  import { createFileService } from "../utils/file-service.js";
10
11
  import { applyFooter } from "../utils/footer.js";
11
12
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
@@ -451,6 +452,64 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
451
452
  }
452
453
  return description;
453
454
  }
455
+ /**
456
+ * DEV-4823: create-time duplicate-detection gate. Searches the title's
457
+ * salient keywords (including closed issues), scores candidates by Jaccard
458
+ * title-overlap, and throws — listing the matches — when one is at/above the
459
+ * configured similarity threshold.
460
+ *
461
+ * Bypassed by `--allow-duplicate`, `--skip-validation`, and
462
+ * `config.validation.duplicateDetection: false` / `validation.enabled: false`.
463
+ * The search itself is best-effort: a network/API failure warns and proceeds
464
+ * rather than blocking legitimate issue creation on infra trouble.
465
+ */
466
+ async function enforceNoDuplicateIssue(title, options, issuesService) {
467
+ if (options.allowDuplicate || options.skipValidation) {
468
+ return;
469
+ }
470
+ const validation = loadConfig().validation;
471
+ // Master switch (validation.enabled) defaults on; the dup-specific toggle
472
+ // defaults on too, so the gate is active out of the box.
473
+ if (validation?.enabled === false ||
474
+ validation?.duplicateDetection === false) {
475
+ return;
476
+ }
477
+ const keywords = [...tokenizeTitle(title)];
478
+ if (keywords.length === 0) {
479
+ // Nothing distinctive to search on — can't meaningfully detect a dupe.
480
+ return;
481
+ }
482
+ const threshold = typeof validation?.duplicateThreshold === "number"
483
+ ? validation.duplicateThreshold
484
+ : DEFAULT_DUPLICATE_THRESHOLD;
485
+ let candidates;
486
+ try {
487
+ candidates = await issuesService.searchIssues({
488
+ query: keywords.join(" "),
489
+ // Include Done/Canceled — a closed-out duplicate is still a duplicate
490
+ // (the motivating DEV-4816 was already Canceled).
491
+ excludeTerminalStates: false,
492
+ // Linear ranks the full-text matches; cap the window at 50 so a real
493
+ // dupe on a high-traffic keyword set is unlikely to rank out of view,
494
+ // while keeping the single search cheap. The gate is a best-effort
495
+ // backstop to the manual dup check, not the sole guard — recall need
496
+ // not be exhaustive.
497
+ limit: 50,
498
+ });
499
+ }
500
+ catch (err) {
501
+ outputWarning(`Duplicate-detection search failed (${err instanceof Error ? err.message : String(err)}); proceeding without the dup check. Pass --allow-duplicate to silence.`);
502
+ return;
503
+ }
504
+ if (!Array.isArray(candidates) || candidates.length === 0) {
505
+ return;
506
+ }
507
+ const matches = scoreDuplicateCandidates(title, candidates, threshold);
508
+ if (matches.length === 0) {
509
+ return;
510
+ }
511
+ throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
512
+ }
454
513
  async function handleCreateIssue(title, options, command) {
455
514
  const rootOpts = getRootOpts(command);
456
515
  const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
@@ -466,6 +525,19 @@ async function handleCreateIssue(title, options, command) {
466
525
  noFooter,
467
526
  }) ?? "";
468
527
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
528
+ // DEV-4823: deterministic duplicate-detection gate. Runs before the create
529
+ // POST, searches the title's salient keywords (including closed issues),
530
+ // and throws — listing candidates — when a high-similarity issue already
531
+ // exists. Bypassed by --allow-duplicate and --skip-validation (it's a
532
+ // validation-class check). Reuses the issuesService just created.
533
+ //
534
+ // Gap: with --from-template and no title override, `title` is undefined
535
+ // (Linear copies the template's title server-side), so the gate can't run
536
+ // — the template-resolved title isn't known client-side. Template-
537
+ // instantiated issues are rarer; the manual dup check still applies there.
538
+ if (title) {
539
+ await enforceNoDuplicateIssue(title, options, issuesService);
540
+ }
469
541
  // Wrap valid issue identifiers as markdown links before creating, so the description
470
542
  // saved on Linear has clickable refs from the start. Self-reference can't apply here
471
543
  // because the issue doesn't exist yet — pass undefined.
@@ -1009,7 +1081,8 @@ export function setupIssuesCommands(program) {
1009
1081
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
1010
1082
  .option("--checkout", "create and checkout a git branch named after the issue")
1011
1083
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1012
- .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
1084
+ .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection)")
1085
+ .option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
1013
1086
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1014
1087
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1015
1088
  .option("--no-footer", "skip the configured messageFooter for this issue")
@@ -55,6 +55,18 @@ export interface ElLinearConfig {
55
55
  * built-in overrides (DEV-4084).
56
56
  */
57
57
  teamTypeLabels?: Record<string, string[]>;
58
+ /**
59
+ * Toggle the create-time duplicate-detection gate (DEV-4823). Defaults
60
+ * to `true` when validation is enabled. Set `false` to keep field
61
+ * validation (labels/description/…) while turning off the dup search.
62
+ */
63
+ duplicateDetection?: boolean;
64
+ /**
65
+ * Jaccard title-similarity threshold (0–1) above which a pre-existing
66
+ * issue is treated as a duplicate and blocks creation. Defaults to
67
+ * `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
68
+ */
69
+ duplicateThreshold?: number;
58
70
  };
59
71
  /**
60
72
  * Optional override for the Linear workspace URL key (the part after
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Duplicate-issue detection — DEV-4823.
3
+ *
4
+ * Turns the MANDATORY-but-skippable duplicate check from the
5
+ * `linear-operations` skill into a deterministic create-time gate, mirroring
6
+ * the one `projects create` already has (DEV-3604). Before the create POST,
7
+ * `issues create` searches the title's salient keywords and refuses (listing
8
+ * the candidates) when a high-similarity open/recently-closed issue already
9
+ * exists.
10
+ *
11
+ * The skill prose is skippable; this isn't. On 2026-06-19 a single session
12
+ * filed two duplicates of in-flight work (DEV-4816 duped DEV-4818, opposite
13
+ * decided approaches) because it claim-checked the *new* issue's branch
14
+ * instead of searching for a pre-existing issue on the same topic. The
15
+ * claim-check (`scripts/issue-claimed.mjs`, DEV-4666) only catches collisions
16
+ * on the new issue's own id/branch — not a topically-identical issue with a
17
+ * different number. This gate closes that gap.
18
+ *
19
+ * Scoring reuses the Jaccard keyword-overlap heuristic from the
20
+ * `agent-efficiency-auditor` (DEV-4155): tokenize both titles, drop
21
+ * stopwords/numbers, and score by |intersection| / |union| of the token sets.
22
+ */
23
+ import type { LinearIssue } from "../types/linear.js";
24
+ /**
25
+ * Default similarity threshold above which a candidate is treated as a
26
+ * duplicate. Tuned against real workspace data so the motivating
27
+ * DEV-4816 ↔ DEV-4818 pair (Jaccard 0.40 with the tokenization below) and the
28
+ * genuine sibling DEV-3604 (0.44) fire, while merely same-domain issues do
29
+ * not: two unrelated "Migrate …" titles sharing only the verb sit at ~0.14,
30
+ * and a different-problem tooling issue sharing only boilerplate tokens
31
+ * (`el-linear`/`issues`/`create`) measured 0.31 — both below 0.35.
32
+ * Overridable via `config.validation.duplicateThreshold`.
33
+ */
34
+ export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
35
+ /** A scored duplicate candidate, ready to print in the block. */
36
+ export interface DuplicateCandidate {
37
+ identifier: string;
38
+ title: string;
39
+ state: string;
40
+ assignee: string;
41
+ /** Jaccard similarity in [0, 1], rounded to 2 dp for display. */
42
+ score: number;
43
+ }
44
+ /**
45
+ * Tokenize a title into a set of salient lowercase keywords.
46
+ *
47
+ * Splits on any run of non-alphanumeric characters (so `scripts/*.mjs` →
48
+ * `scripts`, `mjs`), lowercases, then drops stopwords, pure numbers
49
+ * (`52 files` → `files`), and single-character tokens. Returns a Set so
50
+ * downstream set algebra is direct.
51
+ *
52
+ * Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
53
+ * script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
54
+ * open (no candidates, never a false positive) and the manual dup check
55
+ * carries it. Acceptable given the workspace's title language.
56
+ */
57
+ export declare function tokenizeTitle(title: string): Set<string>;
58
+ /**
59
+ * Jaccard similarity of two token sets: |intersection| / |union|.
60
+ * Returns 0 when either set is empty (no signal to compare).
61
+ */
62
+ export declare function jaccardSimilarity(a: Set<string>, b: Set<string>): number;
63
+ /**
64
+ * Score candidate issues against a proposed title and return those at or above
65
+ * `threshold`, sorted by descending similarity (highest first). Candidates
66
+ * with an unparseable/empty title are skipped. `score` is rounded to 2 dp for
67
+ * stable display and tests.
68
+ */
69
+ export declare function scoreDuplicateCandidates(title: string, candidates: LinearIssue[], threshold?: number): DuplicateCandidate[];
70
+ /**
71
+ * Render the human/agent-facing block listing duplicate candidates, matching
72
+ * the shape of the validation "Suggestions:" blocks (id · title · state ·
73
+ * assignee). Used as the body of the thrown error when the gate fires.
74
+ */
75
+ export declare function formatDuplicateBlock(candidates: DuplicateCandidate[]): string;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Duplicate-issue detection — DEV-4823.
3
+ *
4
+ * Turns the MANDATORY-but-skippable duplicate check from the
5
+ * `linear-operations` skill into a deterministic create-time gate, mirroring
6
+ * the one `projects create` already has (DEV-3604). Before the create POST,
7
+ * `issues create` searches the title's salient keywords and refuses (listing
8
+ * the candidates) when a high-similarity open/recently-closed issue already
9
+ * exists.
10
+ *
11
+ * The skill prose is skippable; this isn't. On 2026-06-19 a single session
12
+ * filed two duplicates of in-flight work (DEV-4816 duped DEV-4818, opposite
13
+ * decided approaches) because it claim-checked the *new* issue's branch
14
+ * instead of searching for a pre-existing issue on the same topic. The
15
+ * claim-check (`scripts/issue-claimed.mjs`, DEV-4666) only catches collisions
16
+ * on the new issue's own id/branch — not a topically-identical issue with a
17
+ * different number. This gate closes that gap.
18
+ *
19
+ * Scoring reuses the Jaccard keyword-overlap heuristic from the
20
+ * `agent-efficiency-auditor` (DEV-4155): tokenize both titles, drop
21
+ * stopwords/numbers, and score by |intersection| / |union| of the token sets.
22
+ */
23
+ /**
24
+ * Default similarity threshold above which a candidate is treated as a
25
+ * duplicate. Tuned against real workspace data so the motivating
26
+ * DEV-4816 ↔ DEV-4818 pair (Jaccard 0.40 with the tokenization below) and the
27
+ * genuine sibling DEV-3604 (0.44) fire, while merely same-domain issues do
28
+ * not: two unrelated "Migrate …" titles sharing only the verb sit at ~0.14,
29
+ * and a different-problem tooling issue sharing only boilerplate tokens
30
+ * (`el-linear`/`issues`/`create`) measured 0.31 — both below 0.35.
31
+ * Overridable via `config.validation.duplicateThreshold`.
32
+ */
33
+ export const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
34
+ /**
35
+ * Function words and issue-boilerplate tokens that carry no topical signal.
36
+ * Dropping them keeps the Jaccard score driven by the distinctive nouns
37
+ * (`scripts`, `mjs`, `typescript`) rather than by glue words every title
38
+ * shares. Type-indicating verbs (`add`, `fix`, `migrate`, …) are deliberately
39
+ * NOT stopworded: `migrate` is a genuine topical signal in the motivating
40
+ * dupe pair, and two unrelated "Add X" issues already score low because their
41
+ * *other* tokens differ — keeping the verb inflates the score by at most one
42
+ * shared token, not enough to false-positive.
43
+ */
44
+ const STOPWORDS = new Set([
45
+ "a",
46
+ "an",
47
+ "and",
48
+ "as",
49
+ "at",
50
+ "but",
51
+ "by",
52
+ "for",
53
+ "from",
54
+ "in",
55
+ "into",
56
+ "of",
57
+ "off",
58
+ "on",
59
+ "or",
60
+ "out",
61
+ "over",
62
+ "per",
63
+ "the",
64
+ "then",
65
+ "to",
66
+ "via",
67
+ "vs",
68
+ "with",
69
+ "without",
70
+ ]);
71
+ /**
72
+ * Tokenize a title into a set of salient lowercase keywords.
73
+ *
74
+ * Splits on any run of non-alphanumeric characters (so `scripts/*.mjs` →
75
+ * `scripts`, `mjs`), lowercases, then drops stopwords, pure numbers
76
+ * (`52 files` → `files`), and single-character tokens. Returns a Set so
77
+ * downstream set algebra is direct.
78
+ *
79
+ * Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
80
+ * script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
81
+ * open (no candidates, never a false positive) and the manual dup check
82
+ * carries it. Acceptable given the workspace's title language.
83
+ */
84
+ export function tokenizeTitle(title) {
85
+ const tokens = title
86
+ .toLowerCase()
87
+ .split(/[^a-z0-9]+/)
88
+ .filter((t) => t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t));
89
+ return new Set(tokens);
90
+ }
91
+ /**
92
+ * Jaccard similarity of two token sets: |intersection| / |union|.
93
+ * Returns 0 when either set is empty (no signal to compare).
94
+ */
95
+ export function jaccardSimilarity(a, b) {
96
+ if (a.size === 0 || b.size === 0) {
97
+ return 0;
98
+ }
99
+ let intersection = 0;
100
+ for (const token of a) {
101
+ if (b.has(token)) {
102
+ intersection++;
103
+ }
104
+ }
105
+ const union = a.size + b.size - intersection;
106
+ return union === 0 ? 0 : intersection / union;
107
+ }
108
+ /**
109
+ * Score candidate issues against a proposed title and return those at or above
110
+ * `threshold`, sorted by descending similarity (highest first). Candidates
111
+ * with an unparseable/empty title are skipped. `score` is rounded to 2 dp for
112
+ * stable display and tests.
113
+ */
114
+ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_DUPLICATE_THRESHOLD) {
115
+ const titleTokens = tokenizeTitle(title);
116
+ if (titleTokens.size === 0) {
117
+ return [];
118
+ }
119
+ const scored = [];
120
+ for (const issue of candidates) {
121
+ const score = jaccardSimilarity(titleTokens, tokenizeTitle(issue.title));
122
+ if (score >= threshold) {
123
+ scored.push({
124
+ identifier: issue.identifier,
125
+ title: issue.title,
126
+ state: issue.state?.name ?? "—",
127
+ assignee: issue.assignee?.name ?? "—",
128
+ score: Math.round(score * 100) / 100,
129
+ });
130
+ }
131
+ }
132
+ scored.sort((a, b) => b.score - a.score);
133
+ return scored;
134
+ }
135
+ /**
136
+ * Render the human/agent-facing block listing duplicate candidates, matching
137
+ * the shape of the validation "Suggestions:" blocks (id · title · state ·
138
+ * assignee). Used as the body of the thrown error when the gate fires.
139
+ */
140
+ export function formatDuplicateBlock(candidates) {
141
+ const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
142
+ return (`Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
143
+ "(by title-keyword overlap):\n" +
144
+ `${lines.join("\n")}\n\n` +
145
+ " If one of these is the same work, comment on it instead of creating a new issue.\n" +
146
+ " If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
147
+ "(and consider --related-to to link the related issue).");
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",