@enrichlayer/el-linear 1.27.0 → 1.29.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
@@ -1,4 +1,4 @@
1
- import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
1
+ import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
2
2
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
@@ -45,7 +45,7 @@ async function resolveTargetIssues(options, rootOpts) {
45
45
  ? await resolveAssignee(filters.assignee, rootOpts)
46
46
  : undefined,
47
47
  delegateId: filters.delegate
48
- ? resolveMember(filters.delegate)
48
+ ? await resolveMemberWithRegistry(filters.delegate)
49
49
  : undefined,
50
50
  project: filters.project
51
51
  ? { kind: "id", id: filters.project }
@@ -2,13 +2,14 @@ import { execFileSync } from "node:child_process";
2
2
  import { loadConfig } from "../config/config.js";
3
3
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
4
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
- import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
5
+ import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
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
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";
@@ -209,7 +210,7 @@ async function handleListIssues(options, command) {
209
210
  ? await resolveAssignee(options.assignee, rootOpts)
210
211
  : undefined,
211
212
  delegateId: options.delegate
212
- ? resolveMember(options.delegate)
213
+ ? await resolveMemberWithRegistry(options.delegate)
213
214
  : undefined,
214
215
  project: resolveProjectFlag(options.project),
215
216
  labelNames: options.labels ? splitList(options.labels) : undefined,
@@ -255,7 +256,7 @@ async function handleSearchIssues(query, options, command) {
255
256
  ? await resolveAssignee(options.assignee, rootOpts)
256
257
  : undefined,
257
258
  delegateId: options.delegate
258
- ? resolveMember(options.delegate)
259
+ ? await resolveMemberWithRegistry(options.delegate)
259
260
  : undefined,
260
261
  project: resolveProjectFlag(options.project),
261
262
  status: explicitStatus,
@@ -383,7 +384,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
383
384
  ? await resolveAssignee(effectiveAssignee, rootOpts)
384
385
  : undefined;
385
386
  const delegateId = options.delegate
386
- ? resolveMember(options.delegate)
387
+ ? await resolveMemberWithRegistry(options.delegate)
387
388
  : undefined;
388
389
  let labelIds = [];
389
390
  if (options.labels) {
@@ -406,7 +407,10 @@ async function resolveCreateInputs(title, options, rootOpts) {
406
407
  });
407
408
  let subscriberIds;
408
409
  if (options.subscriber) {
409
- subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
410
+ // --subscriber resolves through the opt-in registry too (DEV-4880),
411
+ // matching --assignee / --delegate. Each entry falls back to config on a
412
+ // miss; resolveMemberWithRegistry is fail-open and dormant when unconfigured.
413
+ subscriberIds = await Promise.all(splitList(options.subscriber).map((s) => resolveMemberWithRegistry(s)));
410
414
  }
411
415
  const priority = effectivePriorityInput
412
416
  ? validatePriority(effectivePriorityInput)
@@ -458,13 +462,18 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
458
462
  * title-overlap, and throws — listing the matches — when one is at/above the
459
463
  * configured similarity threshold.
460
464
  *
461
- * Bypassed by `--allow-duplicate`, `--skip-validation`, and
465
+ * Bypassed by `--skip-validation` and
462
466
  * `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.
467
+ * `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
468
+ * would-fire, records an `overridden` telemetry event (DEV-4834) before
469
+ * proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
470
+ * fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
471
+ * nothing — it's not a gate-specific override, so counting it would dilute the
472
+ * signal. The search itself is best-effort: a network/API failure warns and
473
+ * proceeds rather than blocking legitimate issue creation on infra trouble.
465
474
  */
466
475
  async function enforceNoDuplicateIssue(title, options, issuesService) {
467
- if (options.allowDuplicate || options.skipValidation) {
476
+ if (options.skipValidation) {
468
477
  return;
469
478
  }
470
479
  const validation = loadConfig().validation;
@@ -498,7 +507,13 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
498
507
  });
499
508
  }
500
509
  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.`);
510
+ // The "pass --allow-duplicate" hint only makes sense when the caller
511
+ // hasn't already passed it (under --allow-duplicate we ran the search
512
+ // only to detect a would-fire for telemetry, and we proceed regardless).
513
+ const silenceHint = options.allowDuplicate
514
+ ? ""
515
+ : " Pass --allow-duplicate to silence.";
516
+ outputWarning(`Duplicate-detection search failed (${err instanceof Error ? err.message : String(err)}); proceeding without the dup check.${silenceHint}`);
502
517
  return;
503
518
  }
504
519
  if (!Array.isArray(candidates) || candidates.length === 0) {
@@ -508,6 +523,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
508
523
  if (matches.length === 0) {
509
524
  return;
510
525
  }
526
+ // The gate would fire. Record the decision so `el-telemetry gates` can
527
+ // compute override-rate (DEV-4834): `overridden` when the user passed
528
+ // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
529
+ const gateEvent = {
530
+ gate: "issues-create-dup",
531
+ topScore: matches[0].score,
532
+ candidateCount: matches.length,
533
+ };
534
+ if (options.allowDuplicate) {
535
+ await emitGateEvent("el-linear", "issues create", {
536
+ ...gateEvent,
537
+ outcome: "overridden",
538
+ });
539
+ return;
540
+ }
541
+ await emitGateEvent("el-linear", "issues create", {
542
+ ...gateEvent,
543
+ outcome: "blocked",
544
+ });
511
545
  throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
512
546
  }
513
547
  async function handleCreateIssue(title, options, command) {
@@ -812,7 +846,7 @@ async function handleUpdateIssue(issueId, options, command) {
812
846
  const delegateId = options.clearDelegate
813
847
  ? null
814
848
  : options.delegate
815
- ? resolveMember(options.delegate)
849
+ ? await resolveMemberWithRegistry(options.delegate)
816
850
  : undefined;
817
851
  const updateArgs = buildUpdateArgs(issueId, options, assigneeId, delegateId);
818
852
  const result = await withProjectResolverEnrichment(() => issuesService.updateIssue(updateArgs, options.labelBy || "adding"), {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Optional, opt-in resolution against the company-wide identity registry
3
+ * (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
4
+ * `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
5
+ * leaves this dormant (no network, no config, nothing written), and the package
6
+ * never takes a dependency on EL-internal infrastructure. It mirrors the
7
+ * env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
8
+ * is kept self-contained so the OSS package carries no private dependency.
9
+ *
10
+ * The registry is an *enhancement* for EL users: callers try it first, then fall
11
+ * back to the bundled config (`resolveMember`). It must never throw or break a
12
+ * command because the registry is unreachable.
13
+ */
14
+ /** True only when the registry is explicitly configured (opt-in). */
15
+ export declare function isRegistryConfigured(env?: NodeJS.ProcessEnv): boolean;
16
+ /**
17
+ * Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
18
+ * UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
19
+ * registry is not configured, when nothing matches, or on **any** failure
20
+ * (unreachable / timeout / CF-Access challenge / malformed response) — the
21
+ * caller falls back to the config-based resolver. Never throws.
22
+ */
23
+ export declare function resolveViaRegistry(identifier: string, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<string | null>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Optional, opt-in resolution against the company-wide identity registry
3
+ * (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
4
+ * `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
5
+ * leaves this dormant (no network, no config, nothing written), and the package
6
+ * never takes a dependency on EL-internal infrastructure. It mirrors the
7
+ * env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
8
+ * is kept self-contained so the OSS package carries no private dependency.
9
+ *
10
+ * The registry is an *enhancement* for EL users: callers try it first, then fall
11
+ * back to the bundled config (`resolveMember`). It must never throw or break a
12
+ * command because the registry is unreachable.
13
+ */
14
+ const URL_ENV = "EL_IDENTITY_URL";
15
+ const CF_ID_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_ID";
16
+ const CF_SECRET_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_SECRET";
17
+ const TIMEOUT_MS = 8000;
18
+ /** True only when the registry is explicitly configured (opt-in). */
19
+ export function isRegistryConfigured(env = process.env) {
20
+ return Boolean(env[URL_ENV]?.trim());
21
+ }
22
+ /**
23
+ * Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
24
+ * UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
25
+ * registry is not configured, when nothing matches, or on **any** failure
26
+ * (unreachable / timeout / CF-Access challenge / malformed response) — the
27
+ * caller falls back to the config-based resolver. Never throws.
28
+ */
29
+ export async function resolveViaRegistry(identifier, env = process.env, fetchImpl = fetch) {
30
+ const base = env[URL_ENV]?.trim();
31
+ if (!base) {
32
+ return null;
33
+ }
34
+ const headers = { Accept: "application/json" };
35
+ const cfId = env[CF_ID_ENV];
36
+ const cfSecret = env[CF_SECRET_ENV];
37
+ if (cfId && cfSecret) {
38
+ headers["CF-Access-Client-Id"] = cfId;
39
+ headers["CF-Access-Client-Secret"] = cfSecret;
40
+ }
41
+ const url = `${base.replace(/\/+$/, "")}/api/people/resolve?identifier=${encodeURIComponent(identifier)}`;
42
+ try {
43
+ const res = await fetchImpl(url, {
44
+ headers,
45
+ // A 3xx is a Cloudflare Access SSO bounce, not a result — `redirect:
46
+ // "manual"` surfaces it as a non-ok status we treat as a miss.
47
+ redirect: "manual",
48
+ signal: AbortSignal.timeout(TIMEOUT_MS),
49
+ });
50
+ if (!res.ok) {
51
+ return null;
52
+ }
53
+ const record = (await res.json());
54
+ return record?.linearId ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
@@ -13,6 +13,17 @@ export declare function resolveMember(input: string): string;
13
13
  * Falls back to resolveMember for all other inputs.
14
14
  */
15
15
  export declare function resolveAssignee(input: string, rootOpts: Record<string, unknown>): Promise<string>;
16
+ /**
17
+ * Resolve a member identifier (alias / handle / name) to a UUID, consulting the
18
+ * opt-in identity registry first when configured, then the bundled config
19
+ * (`resolveMember`). The shared async resolution path behind `--assignee` and
20
+ * `--delegate` (DEV-4871 / DEV-4872).
21
+ *
22
+ * EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
23
+ * a miss, or on any failure it falls back to config, so a non-EL install and an
24
+ * unreachable registry both behave exactly as before. Never throws.
25
+ */
26
+ export declare function resolveMemberWithRegistry(input: string): Promise<string>;
16
27
  /**
17
28
  * Resolve a user's display name from their UUID via config fullNames map.
18
29
  * Returns the full name if found, otherwise returns the original name.
@@ -2,6 +2,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
2
2
  import { outputWarning } from "../utils/output.js";
3
3
  import { isUuid, isUuidPrefix } from "../utils/uuid.js";
4
4
  import { loadConfig } from "./config.js";
5
+ import { isRegistryConfigured, resolveViaRegistry, } from "./registry-resolve.js";
5
6
  /**
6
7
  * Resolve a team key/name/alias to its UUID.
7
8
  * Case-insensitive: "fe" → FE UUID, "frontend" → FE UUID via alias.
@@ -98,6 +99,25 @@ export async function resolveAssignee(input, rootOpts) {
98
99
  }
99
100
  return result.viewer.id;
100
101
  }
102
+ return resolveMemberWithRegistry(input);
103
+ }
104
+ /**
105
+ * Resolve a member identifier (alias / handle / name) to a UUID, consulting the
106
+ * opt-in identity registry first when configured, then the bundled config
107
+ * (`resolveMember`). The shared async resolution path behind `--assignee` and
108
+ * `--delegate` (DEV-4871 / DEV-4872).
109
+ *
110
+ * EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
111
+ * a miss, or on any failure it falls back to config, so a non-EL install and an
112
+ * unreachable registry both behave exactly as before. Never throws.
113
+ */
114
+ export async function resolveMemberWithRegistry(input) {
115
+ if (isRegistryConfigured()) {
116
+ const viaRegistry = await resolveViaRegistry(input);
117
+ if (viaRegistry) {
118
+ return viaRegistry;
119
+ }
120
+ }
101
121
  return resolveMember(input);
102
122
  }
103
123
  /**
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
1
2
  import { resolveUserDisplayName } from "../config/resolver.js";
2
3
  import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
4
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
@@ -1048,6 +1049,15 @@ export class GraphQLIssuesService {
1048
1049
  if (!assigneeId || isUuid(assigneeId)) {
1049
1050
  return assigneeId;
1050
1051
  }
1052
+ // Opt-in registry resolution (DEV-4872): try the identity registry first,
1053
+ // falling back to the Linear-API user lookup below on a miss. EL-only,
1054
+ // env-gated, fail-open — a non-EL install never reaches the network here.
1055
+ if (isRegistryConfigured()) {
1056
+ const viaRegistry = await resolveViaRegistry(assigneeId);
1057
+ if (viaRegistry) {
1058
+ return viaRegistry;
1059
+ }
1060
+ }
1051
1061
  // A plain name (no `@`) resolves via the user lookup — same as
1052
1062
  // `resolveDelegateId`. Without this, a non-config full name like
1053
1063
  // "Yury Tsukerman" fell through unchanged and was sent to the
@@ -1068,6 +1078,14 @@ export class GraphQLIssuesService {
1068
1078
  if (!delegateId || isUuid(delegateId)) {
1069
1079
  return delegateId;
1070
1080
  }
1081
+ // Opt-in registry resolution (DEV-4872): registry-first, Linear-API
1082
+ // fallback. EL-only, env-gated, fail-open.
1083
+ if (isRegistryConfigured()) {
1084
+ const viaRegistry = await resolveViaRegistry(delegateId);
1085
+ if (viaRegistry) {
1086
+ return viaRegistry;
1087
+ }
1088
+ }
1071
1089
  if (!delegateId.includes("@")) {
1072
1090
  return this.linearService.resolveUserId(delegateId);
1073
1091
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.27.0",
3
+ "version": "1.29.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",