@enrichlayer/el-linear 1.25.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.
- package/README.md +33 -0
- package/claude-skills/linear-operations/SKILL.md +54 -1
- package/dist/commands/issues.js +84 -1
- package/dist/commands/search.js +14 -3
- package/dist/config/config.d.ts +12 -0
- package/dist/utils/duplicate-detection.d.ts +75 -0
- package/dist/utils/duplicate-detection.js +148 -0
- package/dist/utils/relation-candidate-prompt.d.ts +57 -0
- package/dist/utils/relation-candidate-prompt.js +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -766,6 +766,39 @@ existing markdown or Slack links, angle-bracket autolinks, and bare URLs, so
|
|
|
766
766
|
it's safe to pipe documents that already contain a mix of formatted links
|
|
767
767
|
and bare identifiers.
|
|
768
768
|
|
|
769
|
+
## Relation-candidate confirmation prompt
|
|
770
|
+
|
|
771
|
+
When `el-linear search` or `el-linear issues search` returns results that
|
|
772
|
+
carry issue identifiers (the "I just ran a duplicate/related check" shape),
|
|
773
|
+
the JSON envelope embeds a structured `_warnings` line:
|
|
774
|
+
|
|
775
|
+
```json
|
|
776
|
+
{
|
|
777
|
+
"data": [{ "identifier": "DEV-2134", "title": "…" }, /* … */],
|
|
778
|
+
"meta": { "count": 3, "query": "auth flicker" },
|
|
779
|
+
"_warnings": [
|
|
780
|
+
"relation_candidates: Found 3 candidate related issues (DEV-2134, FIN-77, ALL-672). To link them as related: reply with the IDs you want linked (e.g. \"link DEV-2134 and FIN-77\"). To skip linking: reply \"no links\". (Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)"
|
|
781
|
+
]
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Why it exists: Claude Code's auto-mode permission classifier blocks
|
|
786
|
+
`el-linear issues relate <source> --related-to "<ids>"` calls when the IDs
|
|
787
|
+
were inferred by the agent from its own search rather than typed by the
|
|
788
|
+
user — because each listed peer is a write target. The prompt nudges the
|
|
789
|
+
caller (typically an agent driving the `linear-operations` skill) to surface
|
|
790
|
+
the candidates verbatim and have the user name which IDs to link; any
|
|
791
|
+
subsequent `issues relate` call then carries user-specified IDs and the
|
|
792
|
+
guard passes naturally. **The fix is not to weaken the guard** — see
|
|
793
|
+
[DEV-4494](https://linear.app/verticalint/issue/DEV-4494/) for the original
|
|
794
|
+
incident (PYT-213 triage, 2026-06-04).
|
|
795
|
+
|
|
796
|
+
The `relation_candidates:` prefix is a stable token so a skill or harness
|
|
797
|
+
can grep for it without parsing free-form prose, matching the existing
|
|
798
|
+
`results_truncated:` convention. Non-issue rows (projects, documents,
|
|
799
|
+
initiatives) are ignored; the warning is suppressed when no result carries
|
|
800
|
+
an identifier.
|
|
801
|
+
|
|
769
802
|
## Use with Claude Code
|
|
770
803
|
|
|
771
804
|
el-linear ships a Claude Code skill at `claude-skills/linear-operations/SKILL.md`.
|
|
@@ -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
|
|
|
@@ -181,6 +197,43 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
181
197
|
el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
|
|
182
198
|
```
|
|
183
199
|
|
|
200
|
+
### Surfacing relation candidates — explicit user reply required ([DEV-4494](https://linear.app/verticalint/issue/DEV-4494/))
|
|
201
|
+
|
|
202
|
+
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
203
|
+
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
|
204
|
+
starting with `relation_candidates:` that enumerates the candidate IDs and
|
|
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.
|
|
214
|
+
|
|
215
|
+
Why this matters: Claude Code's auto-mode permission classifier blocks
|
|
216
|
+
`issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
|
|
217
|
+
(came from your own search) rather than *user-specified* (typed by the human),
|
|
218
|
+
because each listed peer is a write target. Routing the IDs through an
|
|
219
|
+
explicit human reply converts them from agent-inferred → user-specified;
|
|
220
|
+
the existing search step (above) stays intact; auto-mode's guard is not
|
|
221
|
+
weakened. The fix is the loop shape, not the guard.
|
|
222
|
+
|
|
223
|
+
Anti-patterns:
|
|
224
|
+
|
|
225
|
+
- **Calling `issues relate` directly off your own search output** — even if
|
|
226
|
+
the IDs are real and the candidates look obvious, this is the exact path
|
|
227
|
+
the auto-mode guard refuses.
|
|
228
|
+
- **Splitting one relate call into N single-ID calls** to "look smaller" —
|
|
229
|
+
same provenance problem, same block, just multiplied.
|
|
230
|
+
- **Asking the user a yes/no question** ("Should I link these?") instead of
|
|
231
|
+
having them name the IDs — yes answers stay agent-inferred, the reply
|
|
232
|
+
must carry the IDs to convert them to user-specified.
|
|
233
|
+
|
|
234
|
+
If `--include-closed` search returns no matches, no `relation_candidates:`
|
|
235
|
+
warning is emitted (nothing to confirm) and the flow proceeds normally.
|
|
236
|
+
|
|
184
237
|
### Viewing existing relations
|
|
185
238
|
|
|
186
239
|
```bash
|
package/dist/commands/issues.js
CHANGED
|
@@ -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";
|
|
@@ -14,6 +15,7 @@ import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
|
14
15
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
15
16
|
import { logger } from "../utils/logger.js";
|
|
16
17
|
import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } from "../utils/output.js";
|
|
18
|
+
import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
|
|
17
19
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
18
20
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
19
21
|
import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
@@ -269,6 +271,15 @@ async function handleSearchIssues(query, options, command) {
|
|
|
269
271
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
270
272
|
}
|
|
271
273
|
warnIfTruncated(result.length, limit);
|
|
274
|
+
// DEV-4494: surface the explicit "reply with the IDs to link" prompt
|
|
275
|
+
// whenever an issue search returns candidate identifiers. The
|
|
276
|
+
// `linear-operations` skill consumes this `_warnings` line and shows it
|
|
277
|
+
// to the user so any subsequent `issues relate` call is user-specified
|
|
278
|
+
// rather than agent-inferred (which auto-mode blocks).
|
|
279
|
+
const relationPrompt = buildRelationCandidatePrompt(result);
|
|
280
|
+
if (relationPrompt) {
|
|
281
|
+
outputWarning(relationPrompt);
|
|
282
|
+
}
|
|
272
283
|
outputIssues(result, options.format, options.fields, { query });
|
|
273
284
|
}
|
|
274
285
|
/**
|
|
@@ -441,6 +452,64 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
441
452
|
}
|
|
442
453
|
return description;
|
|
443
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
|
+
}
|
|
444
513
|
async function handleCreateIssue(title, options, command) {
|
|
445
514
|
const rootOpts = getRootOpts(command);
|
|
446
515
|
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
|
|
@@ -456,6 +525,19 @@ async function handleCreateIssue(title, options, command) {
|
|
|
456
525
|
noFooter,
|
|
457
526
|
}) ?? "";
|
|
458
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
|
+
}
|
|
459
541
|
// Wrap valid issue identifiers as markdown links before creating, so the description
|
|
460
542
|
// saved on Linear has clickable refs from the start. Self-reference can't apply here
|
|
461
543
|
// because the issue doesn't exist yet — pass undefined.
|
|
@@ -999,7 +1081,8 @@ export function setupIssuesCommands(program) {
|
|
|
999
1081
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1000
1082
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1001
1083
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1002
|
-
.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")
|
|
1003
1086
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1004
1087
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1005
1088
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
package/dist/commands/search.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolveTeam, resolveUserDisplayName } from "../config/resolver.js";
|
|
2
2
|
import { SEMANTIC_SEARCH_QUERY } from "../queries/search.js";
|
|
3
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
|
-
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
5
|
+
import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
|
|
5
6
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
7
|
import { parsePositiveInt } from "../utils/validators.js";
|
|
7
8
|
const TEMPLATES_QUERY = `
|
|
@@ -180,9 +181,19 @@ export function setupSearchCommands(program) {
|
|
|
180
181
|
const templates = templateResult.templates ?? [];
|
|
181
182
|
data = [...data, ...searchTemplates(templates, query)];
|
|
182
183
|
}
|
|
184
|
+
const finalData = data.slice(0, limit);
|
|
185
|
+
// DEV-4494: when results carry issue identifiers, nudge the
|
|
186
|
+
// caller to surface them to the user verbatim and have the
|
|
187
|
+
// user name which IDs to link via `issues relate`. Keeps
|
|
188
|
+
// agent-inferred IDs out of relate calls without weakening
|
|
189
|
+
// auto-mode's guard.
|
|
190
|
+
const relationPrompt = buildRelationCandidatePrompt(finalData);
|
|
191
|
+
if (relationPrompt) {
|
|
192
|
+
outputWarning(relationPrompt);
|
|
193
|
+
}
|
|
183
194
|
outputSuccess({
|
|
184
|
-
data:
|
|
185
|
-
meta: { count:
|
|
195
|
+
data: finalData,
|
|
196
|
+
meta: { count: finalData.length, query },
|
|
186
197
|
});
|
|
187
198
|
}));
|
|
188
199
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation-candidate confirmation prompt — DEV-4494.
|
|
3
|
+
*
|
|
4
|
+
* When `el-linear search` or `el-linear issues search` returns results that
|
|
5
|
+
* carry issue identifiers (the "I just ran a dup-check" shape), we emit a
|
|
6
|
+
* structured `_warnings` line that nudges the *caller* (typically a Claude
|
|
7
|
+
* agent driving `linear-operations`) to surface the IDs to the user verbatim
|
|
8
|
+
* and wait for an explicit reply naming which ones to link.
|
|
9
|
+
*
|
|
10
|
+
* Why the explicit reply matters
|
|
11
|
+
* ------------------------------
|
|
12
|
+
* Claude Code's auto-mode permission classifier blocks
|
|
13
|
+
* `el-linear issues relate <source> --related-to "<ids>"` when the IDs were
|
|
14
|
+
* inferred by the agent from its own search rather than typed by the user —
|
|
15
|
+
* because creating a relation writes onto *every* listed peer issue, and the
|
|
16
|
+
* IDs must be user-specified, not agent-inferred, to clear the guard.
|
|
17
|
+
*
|
|
18
|
+
* The fix isn't to weaken the guard. It's to tighten the loop: surface the
|
|
19
|
+
* candidates, ask the human to name which IDs to link, and only THEN call
|
|
20
|
+
* `issues relate` — at which point the IDs are user-specified by
|
|
21
|
+
* construction and the guard passes naturally.
|
|
22
|
+
*
|
|
23
|
+
* This module produces the warning. The actual UX is enforced by the
|
|
24
|
+
* `linear-operations` skill (it consumes the warning and shows it to the
|
|
25
|
+
* user) and by the existing auto-mode guard (it continues to block
|
|
26
|
+
* agent-inferred relate calls).
|
|
27
|
+
*
|
|
28
|
+
* Reference: https://linear.app/verticalint/issue/DEV-4494/
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Extract issue identifiers from a heterogeneous result array.
|
|
32
|
+
*
|
|
33
|
+
* Accepts the union of shapes used across the search commands:
|
|
34
|
+
* - `issues search` rows (`LinearIssue`) carry `identifier` at the top level
|
|
35
|
+
* - cross-resource `search` rows transform to `{ type: "issue", identifier }`
|
|
36
|
+
* for issue rows; non-issue rows (`project`, `document`, …) have no
|
|
37
|
+
* identifier and are skipped.
|
|
38
|
+
*
|
|
39
|
+
* Deduplicates and preserves insertion order so the prompt enumerates IDs
|
|
40
|
+
* in the same order they appear on screen.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Build the relation-candidate confirmation warning string, or `null` when
|
|
45
|
+
* the result set has no identifier-bearing rows (nothing to confirm).
|
|
46
|
+
*
|
|
47
|
+
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
48
|
+
*
|
|
49
|
+
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
50
|
+
* To link them as related: reply with the IDs you want linked
|
|
51
|
+
* (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
|
|
52
|
+
*
|
|
53
|
+
* The `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
54
|
+
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
55
|
+
* harness can grep for without parsing free-form prose.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildRelationCandidatePrompt(rows: unknown[]): string | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation-candidate confirmation prompt — DEV-4494.
|
|
3
|
+
*
|
|
4
|
+
* When `el-linear search` or `el-linear issues search` returns results that
|
|
5
|
+
* carry issue identifiers (the "I just ran a dup-check" shape), we emit a
|
|
6
|
+
* structured `_warnings` line that nudges the *caller* (typically a Claude
|
|
7
|
+
* agent driving `linear-operations`) to surface the IDs to the user verbatim
|
|
8
|
+
* and wait for an explicit reply naming which ones to link.
|
|
9
|
+
*
|
|
10
|
+
* Why the explicit reply matters
|
|
11
|
+
* ------------------------------
|
|
12
|
+
* Claude Code's auto-mode permission classifier blocks
|
|
13
|
+
* `el-linear issues relate <source> --related-to "<ids>"` when the IDs were
|
|
14
|
+
* inferred by the agent from its own search rather than typed by the user —
|
|
15
|
+
* because creating a relation writes onto *every* listed peer issue, and the
|
|
16
|
+
* IDs must be user-specified, not agent-inferred, to clear the guard.
|
|
17
|
+
*
|
|
18
|
+
* The fix isn't to weaken the guard. It's to tighten the loop: surface the
|
|
19
|
+
* candidates, ask the human to name which IDs to link, and only THEN call
|
|
20
|
+
* `issues relate` — at which point the IDs are user-specified by
|
|
21
|
+
* construction and the guard passes naturally.
|
|
22
|
+
*
|
|
23
|
+
* This module produces the warning. The actual UX is enforced by the
|
|
24
|
+
* `linear-operations` skill (it consumes the warning and shows it to the
|
|
25
|
+
* user) and by the existing auto-mode guard (it continues to block
|
|
26
|
+
* agent-inferred relate calls).
|
|
27
|
+
*
|
|
28
|
+
* Reference: https://linear.app/verticalint/issue/DEV-4494/
|
|
29
|
+
*/
|
|
30
|
+
/** Cap how many candidate IDs the prompt enumerates inline. */
|
|
31
|
+
const MAX_CANDIDATES_IN_PROMPT = 10;
|
|
32
|
+
/**
|
|
33
|
+
* Extract issue identifiers from a heterogeneous result array.
|
|
34
|
+
*
|
|
35
|
+
* Accepts the union of shapes used across the search commands:
|
|
36
|
+
* - `issues search` rows (`LinearIssue`) carry `identifier` at the top level
|
|
37
|
+
* - cross-resource `search` rows transform to `{ type: "issue", identifier }`
|
|
38
|
+
* for issue rows; non-issue rows (`project`, `document`, …) have no
|
|
39
|
+
* identifier and are skipped.
|
|
40
|
+
*
|
|
41
|
+
* Deduplicates and preserves insertion order so the prompt enumerates IDs
|
|
42
|
+
* in the same order they appear on screen.
|
|
43
|
+
*/
|
|
44
|
+
export function extractCandidateIdentifiers(rows) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (row === null || typeof row !== "object")
|
|
49
|
+
continue;
|
|
50
|
+
const r = row;
|
|
51
|
+
const id = typeof r.identifier === "string" ? r.identifier : undefined;
|
|
52
|
+
if (!id)
|
|
53
|
+
continue;
|
|
54
|
+
if (seen.has(id))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(id);
|
|
57
|
+
out.push(id);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the relation-candidate confirmation warning string, or `null` when
|
|
63
|
+
* the result set has no identifier-bearing rows (nothing to confirm).
|
|
64
|
+
*
|
|
65
|
+
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
66
|
+
*
|
|
67
|
+
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
68
|
+
* To link them as related: reply with the IDs you want linked
|
|
69
|
+
* (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
|
|
70
|
+
*
|
|
71
|
+
* The `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
72
|
+
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
73
|
+
* harness can grep for without parsing free-form prose.
|
|
74
|
+
*/
|
|
75
|
+
export function buildRelationCandidatePrompt(rows) {
|
|
76
|
+
const ids = extractCandidateIdentifiers(rows);
|
|
77
|
+
if (ids.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
const shown = ids.slice(0, MAX_CANDIDATES_IN_PROMPT);
|
|
80
|
+
const overflow = ids.length - shown.length;
|
|
81
|
+
const idList = overflow > 0
|
|
82
|
+
? `${shown.join(", ")}, … (+${overflow} more)`
|
|
83
|
+
: shown.join(", ");
|
|
84
|
+
// Build two concrete example IDs from the head of the list so the
|
|
85
|
+
// "reply with the IDs you want linked" example is realistic for the
|
|
86
|
+
// caller's actual search rather than a fixed placeholder. Single-result
|
|
87
|
+
// case still reads naturally ("link DEV-1").
|
|
88
|
+
const example = shown.length >= 2 ? `link ${shown[0]} and ${shown[1]}` : `link ${shown[0]}`;
|
|
89
|
+
const noun = ids.length === 1 ? "candidate related issue" : "candidate related issues";
|
|
90
|
+
return (`relation_candidates: Found ${ids.length} ${noun} (${idList}). ` +
|
|
91
|
+
`To link them as related: reply with the IDs you want linked ` +
|
|
92
|
+
`(e.g. "${example}"). To skip linking: reply "no links". ` +
|
|
93
|
+
`(Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)`);
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
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",
|