@enrichlayer/el-linear 1.27.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 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
@@ -9,6 +9,7 @@ import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../qu
9
9
  import { DEFAULT_DUPLICATE_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
10
10
  import { createFileService } from "../utils/file-service.js";
11
11
  import { applyFooter } from "../utils/footer.js";
12
+ import { emitGateEvent } from "../utils/gate-telemetry.js";
12
13
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
13
14
  import { createGraphQLService, } from "../utils/graphql-service.js";
14
15
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
@@ -458,13 +459,18 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
458
459
  * title-overlap, and throws — listing the matches — when one is at/above the
459
460
  * configured similarity threshold.
460
461
  *
461
- * Bypassed by `--allow-duplicate`, `--skip-validation`, and
462
+ * Bypassed by `--skip-validation` and
462
463
  * `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.
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.
465
471
  */
466
472
  async function enforceNoDuplicateIssue(title, options, issuesService) {
467
- if (options.allowDuplicate || options.skipValidation) {
473
+ if (options.skipValidation) {
468
474
  return;
469
475
  }
470
476
  const validation = loadConfig().validation;
@@ -498,7 +504,13 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
498
504
  });
499
505
  }
500
506
  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.`);
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}`);
502
514
  return;
503
515
  }
504
516
  if (!Array.isArray(candidates) || candidates.length === 0) {
@@ -508,6 +520,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
508
520
  if (matches.length === 0) {
509
521
  return;
510
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
+ });
511
542
  throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
512
543
  }
513
544
  async function handleCreateIssue(title, options, command) {
@@ -45,9 +45,10 @@ export interface DuplicateCandidate {
45
45
  * Tokenize a title into a set of salient lowercase keywords.
46
46
  *
47
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.
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.
51
52
  *
52
53
  * Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
53
54
  * script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
@@ -68,13 +68,49 @@ const STOPWORDS = new Set([
68
68
  "with",
69
69
  "without",
70
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
+ ]);
71
106
  /**
72
107
  * Tokenize a title into a set of salient lowercase keywords.
73
108
  *
74
109
  * 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.
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.
78
114
  *
79
115
  * Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
80
116
  * script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
@@ -85,7 +121,10 @@ export function tokenizeTitle(title) {
85
121
  const tokens = title
86
122
  .toLowerCase()
87
123
  .split(/[^a-z0-9]+/)
88
- .filter((t) => t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t));
124
+ .filter((t) => t.length >= 2 &&
125
+ !STOPWORDS.has(t) &&
126
+ !BOILERPLATE_STOPWORDS.has(t) &&
127
+ !/^\d+$/.test(t));
89
128
  return new Set(tokens);
90
129
  }
91
130
  /**
@@ -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.27.0",
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",