@enrichlayer/el-linear 1.26.0 → 1.28.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 +10 -0
- package/claude-skills/linear-operations/SKILL.md +26 -12
- package/dist/commands/issues.js +105 -1
- package/dist/config/config.d.ts +12 -0
- package/dist/utils/duplicate-detection.d.ts +76 -0
- package/dist/utils/duplicate-detection.js +187 -0
- package/dist/utils/gate-telemetry.d.ts +40 -0
- package/dist/utils/gate-telemetry.js +100 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -247,6 +247,16 @@ A full reference with every key documented lives in [config.example.json](./conf
|
|
|
247
247
|
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
|
|
248
248
|
itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
249
249
|
|
|
250
|
+
### Gate telemetry (optional)
|
|
251
|
+
|
|
252
|
+
`issues create` has a duplicate-detection gate. el-linear can record each
|
|
253
|
+
fire/override decision to a local JSONL file so you can measure the gate's
|
|
254
|
+
**override-rate** and tell whether it's too aggressive. It is **off by default**
|
|
255
|
+
and writes nothing unless you opt in (e.g. `export EL_TELEMETRY_DIR=<path>`);
|
|
256
|
+
there is no server or database, and `EL_TELEMETRY_DISABLED=1` forces it off.
|
|
257
|
+
Full opt-in rules, the event schema, and a `jq` reader are in
|
|
258
|
+
[docs/telemetry.md](./docs/telemetry.md).
|
|
259
|
+
|
|
250
260
|
### Networking (IPv4 preference)
|
|
251
261
|
|
|
252
262
|
el-linear talks only to `api.linear.app` (Cloudflare, dual-stack). On a network
|
|
@@ -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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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*
|
package/dist/commands/issues.js
CHANGED
|
@@ -6,8 +6,10 @@ 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";
|
|
12
|
+
import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
11
13
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
12
14
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
13
15
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
@@ -451,6 +453,94 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
451
453
|
}
|
|
452
454
|
return description;
|
|
453
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* DEV-4823: create-time duplicate-detection gate. Searches the title's
|
|
458
|
+
* salient keywords (including closed issues), scores candidates by Jaccard
|
|
459
|
+
* title-overlap, and throws — listing the matches — when one is at/above the
|
|
460
|
+
* configured similarity threshold.
|
|
461
|
+
*
|
|
462
|
+
* Bypassed by `--skip-validation` and
|
|
463
|
+
* `config.validation.duplicateDetection: false` / `validation.enabled: false`.
|
|
464
|
+
* `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
|
|
465
|
+
* would-fire, records an `overridden` telemetry event (DEV-4834) before
|
|
466
|
+
* proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
|
|
467
|
+
* fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
|
|
468
|
+
* nothing — it's not a gate-specific override, so counting it would dilute the
|
|
469
|
+
* signal. The search itself is best-effort: a network/API failure warns and
|
|
470
|
+
* proceeds rather than blocking legitimate issue creation on infra trouble.
|
|
471
|
+
*/
|
|
472
|
+
async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
473
|
+
if (options.skipValidation) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const validation = loadConfig().validation;
|
|
477
|
+
// Master switch (validation.enabled) defaults on; the dup-specific toggle
|
|
478
|
+
// defaults on too, so the gate is active out of the box.
|
|
479
|
+
if (validation?.enabled === false ||
|
|
480
|
+
validation?.duplicateDetection === false) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const keywords = [...tokenizeTitle(title)];
|
|
484
|
+
if (keywords.length === 0) {
|
|
485
|
+
// Nothing distinctive to search on — can't meaningfully detect a dupe.
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const threshold = typeof validation?.duplicateThreshold === "number"
|
|
489
|
+
? validation.duplicateThreshold
|
|
490
|
+
: DEFAULT_DUPLICATE_THRESHOLD;
|
|
491
|
+
let candidates;
|
|
492
|
+
try {
|
|
493
|
+
candidates = await issuesService.searchIssues({
|
|
494
|
+
query: keywords.join(" "),
|
|
495
|
+
// Include Done/Canceled — a closed-out duplicate is still a duplicate
|
|
496
|
+
// (the motivating DEV-4816 was already Canceled).
|
|
497
|
+
excludeTerminalStates: false,
|
|
498
|
+
// Linear ranks the full-text matches; cap the window at 50 so a real
|
|
499
|
+
// dupe on a high-traffic keyword set is unlikely to rank out of view,
|
|
500
|
+
// while keeping the single search cheap. The gate is a best-effort
|
|
501
|
+
// backstop to the manual dup check, not the sole guard — recall need
|
|
502
|
+
// not be exhaustive.
|
|
503
|
+
limit: 50,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
// The "pass --allow-duplicate" hint only makes sense when the caller
|
|
508
|
+
// hasn't already passed it (under --allow-duplicate we ran the search
|
|
509
|
+
// only to detect a would-fire for telemetry, and we proceed regardless).
|
|
510
|
+
const silenceHint = options.allowDuplicate
|
|
511
|
+
? ""
|
|
512
|
+
: " Pass --allow-duplicate to silence.";
|
|
513
|
+
outputWarning(`Duplicate-detection search failed (${err instanceof Error ? err.message : String(err)}); proceeding without the dup check.${silenceHint}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const matches = scoreDuplicateCandidates(title, candidates, threshold);
|
|
520
|
+
if (matches.length === 0) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// The gate would fire. Record the decision so `el-telemetry gates` can
|
|
524
|
+
// compute override-rate (DEV-4834): `overridden` when the user passed
|
|
525
|
+
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
526
|
+
const gateEvent = {
|
|
527
|
+
gate: "issues-create-dup",
|
|
528
|
+
topScore: matches[0].score,
|
|
529
|
+
candidateCount: matches.length,
|
|
530
|
+
};
|
|
531
|
+
if (options.allowDuplicate) {
|
|
532
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
533
|
+
...gateEvent,
|
|
534
|
+
outcome: "overridden",
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
539
|
+
...gateEvent,
|
|
540
|
+
outcome: "blocked",
|
|
541
|
+
});
|
|
542
|
+
throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
|
|
543
|
+
}
|
|
454
544
|
async function handleCreateIssue(title, options, command) {
|
|
455
545
|
const rootOpts = getRootOpts(command);
|
|
456
546
|
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
|
|
@@ -466,6 +556,19 @@ async function handleCreateIssue(title, options, command) {
|
|
|
466
556
|
noFooter,
|
|
467
557
|
}) ?? "";
|
|
468
558
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
559
|
+
// DEV-4823: deterministic duplicate-detection gate. Runs before the create
|
|
560
|
+
// POST, searches the title's salient keywords (including closed issues),
|
|
561
|
+
// and throws — listing candidates — when a high-similarity issue already
|
|
562
|
+
// exists. Bypassed by --allow-duplicate and --skip-validation (it's a
|
|
563
|
+
// validation-class check). Reuses the issuesService just created.
|
|
564
|
+
//
|
|
565
|
+
// Gap: with --from-template and no title override, `title` is undefined
|
|
566
|
+
// (Linear copies the template's title server-side), so the gate can't run
|
|
567
|
+
// — the template-resolved title isn't known client-side. Template-
|
|
568
|
+
// instantiated issues are rarer; the manual dup check still applies there.
|
|
569
|
+
if (title) {
|
|
570
|
+
await enforceNoDuplicateIssue(title, options, issuesService);
|
|
571
|
+
}
|
|
469
572
|
// Wrap valid issue identifiers as markdown links before creating, so the description
|
|
470
573
|
// saved on Linear has clickable refs from the start. Self-reference can't apply here
|
|
471
574
|
// because the issue doesn't exist yet — pass undefined.
|
|
@@ -1009,7 +1112,8 @@ export function setupIssuesCommands(program) {
|
|
|
1009
1112
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1010
1113
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1011
1114
|
.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)")
|
|
1115
|
+
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection)")
|
|
1116
|
+
.option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
|
|
1013
1117
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1014
1118
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1015
1119
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
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,76 @@
|
|
|
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 English stopwords, tool-name /
|
|
49
|
+
* CLI-scaffolding boilerplate (DEV-4830), pure numbers (`52 files` → `files`),
|
|
50
|
+
* and single-character tokens. Returns a Set so downstream set algebra is
|
|
51
|
+
* direct.
|
|
52
|
+
*
|
|
53
|
+
* Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
|
|
54
|
+
* script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
|
|
55
|
+
* open (no candidates, never a false positive) and the manual dup check
|
|
56
|
+
* carries it. Acceptable given the workspace's title language.
|
|
57
|
+
*/
|
|
58
|
+
export declare function tokenizeTitle(title: string): Set<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Jaccard similarity of two token sets: |intersection| / |union|.
|
|
61
|
+
* Returns 0 when either set is empty (no signal to compare).
|
|
62
|
+
*/
|
|
63
|
+
export declare function jaccardSimilarity(a: Set<string>, b: Set<string>): number;
|
|
64
|
+
/**
|
|
65
|
+
* Score candidate issues against a proposed title and return those at or above
|
|
66
|
+
* `threshold`, sorted by descending similarity (highest first). Candidates
|
|
67
|
+
* with an unparseable/empty title are skipped. `score` is rounded to 2 dp for
|
|
68
|
+
* stable display and tests.
|
|
69
|
+
*/
|
|
70
|
+
export declare function scoreDuplicateCandidates(title: string, candidates: LinearIssue[], threshold?: number): DuplicateCandidate[];
|
|
71
|
+
/**
|
|
72
|
+
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
73
|
+
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
74
|
+
* assignee). Used as the body of the thrown error when the gate fires.
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatDuplicateBlock(candidates: DuplicateCandidate[]): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
* Tool-name and CLI-scaffolding boilerplate — DEV-4830. These tokens appear in
|
|
73
|
+
* a large fraction of this workspace's titles ("Add --X flag to el-linear
|
|
74
|
+
* issues create", "… el-git pipeline watch", …) regardless of topic, so they
|
|
75
|
+
* inflate Jaccard between genuinely-distinct issues that merely touch the same
|
|
76
|
+
* command surface. A retrospective precision sweep over 288 DEV titles showed
|
|
77
|
+
* the "Add --X flag to el-linear issues create" family scoring 0.45–0.60 (all
|
|
78
|
+
* false positives) purely on shared boilerplate; dropping these tokens pushes
|
|
79
|
+
* that family to 0.17–0.25 while every genuine duplicate stayed ≥ 0.35 (the
|
|
80
|
+
* motivating DEV-4816↔DEV-4818 pair holds at 0.40). Total fires 29 → 19.
|
|
81
|
+
*
|
|
82
|
+
* Note the el-tool prefixes split on `-` first, so `el-linear` arrives here as
|
|
83
|
+
* `el` + `linear`; both fragments are listed. Topical words that happen to be
|
|
84
|
+
* tool *suffixes* (`research`, `telemetry`, `audit`, …) are deliberately NOT
|
|
85
|
+
* listed — they carry real signal in non-tool issues.
|
|
86
|
+
*/
|
|
87
|
+
const BOILERPLATE_STOPWORDS = new Set([
|
|
88
|
+
"el",
|
|
89
|
+
"cli",
|
|
90
|
+
"command",
|
|
91
|
+
"commands",
|
|
92
|
+
"subcommand",
|
|
93
|
+
"flag",
|
|
94
|
+
"flags",
|
|
95
|
+
"option",
|
|
96
|
+
"options",
|
|
97
|
+
"arg",
|
|
98
|
+
"args",
|
|
99
|
+
"linear",
|
|
100
|
+
"git",
|
|
101
|
+
"issue",
|
|
102
|
+
"issues",
|
|
103
|
+
"create",
|
|
104
|
+
"update",
|
|
105
|
+
]);
|
|
106
|
+
/**
|
|
107
|
+
* Tokenize a title into a set of salient lowercase keywords.
|
|
108
|
+
*
|
|
109
|
+
* Splits on any run of non-alphanumeric characters (so `scripts/*.mjs` →
|
|
110
|
+
* `scripts`, `mjs`), lowercases, then drops English stopwords, tool-name /
|
|
111
|
+
* CLI-scaffolding boilerplate (DEV-4830), pure numbers (`52 files` → `files`),
|
|
112
|
+
* and single-character tokens. Returns a Set so downstream set algebra is
|
|
113
|
+
* direct.
|
|
114
|
+
*
|
|
115
|
+
* Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
|
|
116
|
+
* script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
|
|
117
|
+
* open (no candidates, never a false positive) and the manual dup check
|
|
118
|
+
* carries it. Acceptable given the workspace's title language.
|
|
119
|
+
*/
|
|
120
|
+
export function tokenizeTitle(title) {
|
|
121
|
+
const tokens = title
|
|
122
|
+
.toLowerCase()
|
|
123
|
+
.split(/[^a-z0-9]+/)
|
|
124
|
+
.filter((t) => t.length >= 2 &&
|
|
125
|
+
!STOPWORDS.has(t) &&
|
|
126
|
+
!BOILERPLATE_STOPWORDS.has(t) &&
|
|
127
|
+
!/^\d+$/.test(t));
|
|
128
|
+
return new Set(tokens);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Jaccard similarity of two token sets: |intersection| / |union|.
|
|
132
|
+
* Returns 0 when either set is empty (no signal to compare).
|
|
133
|
+
*/
|
|
134
|
+
export function jaccardSimilarity(a, b) {
|
|
135
|
+
if (a.size === 0 || b.size === 0) {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
let intersection = 0;
|
|
139
|
+
for (const token of a) {
|
|
140
|
+
if (b.has(token)) {
|
|
141
|
+
intersection++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const union = a.size + b.size - intersection;
|
|
145
|
+
return union === 0 ? 0 : intersection / union;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Score candidate issues against a proposed title and return those at or above
|
|
149
|
+
* `threshold`, sorted by descending similarity (highest first). Candidates
|
|
150
|
+
* with an unparseable/empty title are skipped. `score` is rounded to 2 dp for
|
|
151
|
+
* stable display and tests.
|
|
152
|
+
*/
|
|
153
|
+
export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_DUPLICATE_THRESHOLD) {
|
|
154
|
+
const titleTokens = tokenizeTitle(title);
|
|
155
|
+
if (titleTokens.size === 0) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
const scored = [];
|
|
159
|
+
for (const issue of candidates) {
|
|
160
|
+
const score = jaccardSimilarity(titleTokens, tokenizeTitle(issue.title));
|
|
161
|
+
if (score >= threshold) {
|
|
162
|
+
scored.push({
|
|
163
|
+
identifier: issue.identifier,
|
|
164
|
+
title: issue.title,
|
|
165
|
+
state: issue.state?.name ?? "—",
|
|
166
|
+
assignee: issue.assignee?.name ?? "—",
|
|
167
|
+
score: Math.round(score * 100) / 100,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
scored.sort((a, b) => b.score - a.score);
|
|
172
|
+
return scored;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
176
|
+
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
177
|
+
* assignee). Used as the body of the thrown error when the gate fires.
|
|
178
|
+
*/
|
|
179
|
+
export function formatDuplicateBlock(candidates) {
|
|
180
|
+
const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
|
|
181
|
+
return (`Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
|
|
182
|
+
"(by title-keyword overlap):\n" +
|
|
183
|
+
`${lines.join("\n")}\n\n` +
|
|
184
|
+
" If one of these is the same work, comment on it instead of creating a new issue.\n" +
|
|
185
|
+
" If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
|
|
186
|
+
"(and consider --related-to to link the related issue).");
|
|
187
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
2
|
+
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
3
|
+
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
4
|
+
* {@link decideGateLedger}, which may veto writing entirely. */
|
|
5
|
+
export declare function gateEventsPath(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Decide whether gate telemetry is enabled and, if so, the ledger path —
|
|
8
|
+
* returning `null` (no-op) otherwise. Pure (no env / fs reads) so the opt-in
|
|
9
|
+
* policy is exhaustively testable.
|
|
10
|
+
*
|
|
11
|
+
* Policy (open-source-safe):
|
|
12
|
+
* - `disabled` (`EL_TELEMETRY_DISABLED`) → off. Hard opt-out, wins over all.
|
|
13
|
+
* - `explicitDir` (`EL_TELEMETRY_DIR` set) → on. An explicit destination is an
|
|
14
|
+
* explicit opt-in; the dir is created on demand.
|
|
15
|
+
* - otherwise → on **only if the default dir already exists**, i.e. the user
|
|
16
|
+
* already runs the EL telemetry tooling that created it. A fresh open-source
|
|
17
|
+
* install has no such dir, so nothing is ever written for them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function decideGateLedger(opts: {
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
explicitDir?: string;
|
|
22
|
+
defaultDir: string;
|
|
23
|
+
defaultDirExists: boolean;
|
|
24
|
+
}): string | null;
|
|
25
|
+
export interface GateEvent {
|
|
26
|
+
/** Stable gate id, e.g. `issues-create-dup`. */
|
|
27
|
+
gate: string;
|
|
28
|
+
outcome: "blocked" | "overridden";
|
|
29
|
+
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
30
|
+
topScore?: number;
|
|
31
|
+
/** How many candidates crossed the threshold. */
|
|
32
|
+
candidateCount?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort append of a gate event to the local ledger — but only when
|
|
36
|
+
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
37
|
+
* no-op, so an open-source install with no telemetry writes nothing. **Never
|
|
38
|
+
* throws** — telemetry must not block issue creation.
|
|
39
|
+
*/
|
|
40
|
+
export declare function emitGateEvent(name: string, subcommand: string, event: GateEvent): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
|
|
7
|
+
*
|
|
8
|
+
* The `issues create` duplicate-detection gate (DEV-4823) records each decision
|
|
9
|
+
* it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
|
|
10
|
+
* command, or any JSONL consumer) can compute the gate's override-rate
|
|
11
|
+
* (overridden / total) and tell whether the threshold is noisy.
|
|
12
|
+
*
|
|
13
|
+
* **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
|
|
14
|
+
* must never write files a user didn't ask for. Emission is therefore OFF by
|
|
15
|
+
* default and turns on only when telemetry is actually configured — see
|
|
16
|
+
* {@link decideGateLedger}. The ledger is a plain local JSONL file
|
|
17
|
+
* (`gate-events.jsonl`); there is no server or database. el-linear can't import
|
|
18
|
+
* `el-telemetry` (separate package), so it writes by **path-contract** — the
|
|
19
|
+
* same approach `el-hook` uses. The path mirrors `el-telemetry`'s
|
|
20
|
+
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
21
|
+
* `docs/telemetry.md`.
|
|
22
|
+
*/
|
|
23
|
+
/** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
|
|
24
|
+
function defaultTelemetryDir() {
|
|
25
|
+
return join(homedir(), ".cache", "el-telemetry");
|
|
26
|
+
}
|
|
27
|
+
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
28
|
+
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
29
|
+
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
30
|
+
* {@link decideGateLedger}, which may veto writing entirely. */
|
|
31
|
+
export function gateEventsPath() {
|
|
32
|
+
const dir = process.env.EL_TELEMETRY_DIR || defaultTelemetryDir();
|
|
33
|
+
return join(dir, "gate-events.jsonl");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether gate telemetry is enabled and, if so, the ledger path —
|
|
37
|
+
* returning `null` (no-op) otherwise. Pure (no env / fs reads) so the opt-in
|
|
38
|
+
* policy is exhaustively testable.
|
|
39
|
+
*
|
|
40
|
+
* Policy (open-source-safe):
|
|
41
|
+
* - `disabled` (`EL_TELEMETRY_DISABLED`) → off. Hard opt-out, wins over all.
|
|
42
|
+
* - `explicitDir` (`EL_TELEMETRY_DIR` set) → on. An explicit destination is an
|
|
43
|
+
* explicit opt-in; the dir is created on demand.
|
|
44
|
+
* - otherwise → on **only if the default dir already exists**, i.e. the user
|
|
45
|
+
* already runs the EL telemetry tooling that created it. A fresh open-source
|
|
46
|
+
* install has no such dir, so nothing is ever written for them.
|
|
47
|
+
*/
|
|
48
|
+
export function decideGateLedger(opts) {
|
|
49
|
+
if (opts.disabled) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (opts.explicitDir) {
|
|
53
|
+
return join(opts.explicitDir, "gate-events.jsonl");
|
|
54
|
+
}
|
|
55
|
+
if (!opts.defaultDirExists) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return join(opts.defaultDir, "gate-events.jsonl");
|
|
59
|
+
}
|
|
60
|
+
/** Wire {@link decideGateLedger} to the real environment + filesystem. */
|
|
61
|
+
function gateLedgerIfEnabled() {
|
|
62
|
+
const defaultDir = defaultTelemetryDir();
|
|
63
|
+
return decideGateLedger({
|
|
64
|
+
disabled: Boolean(process.env.EL_TELEMETRY_DISABLED),
|
|
65
|
+
explicitDir: process.env.EL_TELEMETRY_DIR,
|
|
66
|
+
defaultDir,
|
|
67
|
+
defaultDirExists: existsSync(defaultDir),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort append of a gate event to the local ledger — but only when
|
|
72
|
+
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
73
|
+
* no-op, so an open-source install with no telemetry writes nothing. **Never
|
|
74
|
+
* throws** — telemetry must not block issue creation.
|
|
75
|
+
*/
|
|
76
|
+
export async function emitGateEvent(name, subcommand, event) {
|
|
77
|
+
const path = gateLedgerIfEnabled();
|
|
78
|
+
if (!path) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(dirname(path), { recursive: true });
|
|
83
|
+
const record = {
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
kind: "gate",
|
|
86
|
+
name,
|
|
87
|
+
subcommand,
|
|
88
|
+
metadata: {
|
|
89
|
+
gate: event.gate,
|
|
90
|
+
outcome: event.outcome,
|
|
91
|
+
top_score: event.topScore,
|
|
92
|
+
candidate_count: event.candidateCount,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// best-effort — telemetry never blocks issue creation
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.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",
|