@enrichlayer/el-linear 1.41.0 → 1.42.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.
@@ -9,7 +9,7 @@ import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvabl
9
9
  import { resolveDefaultStatus } from "../config/status-defaults.js";
10
10
  import { enforceTerms } from "../config/term-enforcer.js";
11
11
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
12
- import { DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
12
+ import { bypassesDuplicateHardBlock, DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
13
13
  import { createFileService } from "../utils/file-service.js";
14
14
  import { applyFooter } from "../utils/footer.js";
15
15
  import { emitGateEvent } from "../utils/gate-telemetry.js";
@@ -589,7 +589,12 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
589
589
  // The gate would hard-block. Record the decision so `el-telemetry gates`
590
590
  // can compute override-rate (DEV-4834): `overridden` when the user passed
591
591
  // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
592
- if (options.allowDuplicate) {
592
+ //
593
+ // The escape set lives in `bypassesDuplicateHardBlock` rather than inline so
594
+ // the remedy copy in `formatDuplicateBlock` is derived from the same source
595
+ // this decision reads (DEV-6205 — the message previously named a flag the
596
+ // gate never consulted).
597
+ if (bypassesDuplicateHardBlock(options)) {
593
598
  await emitGateEvent("el-linear", "issues create", {
594
599
  ...gateEvent,
595
600
  outcome: "overridden",
@@ -73,14 +73,21 @@ export async function readIssues(issueIds, options, command) {
73
73
  const rootOpts = getRootOpts(command);
74
74
  const { graphQLService, issuesService } = await createIssuesService(rootOpts);
75
75
  const fileService = await createFileService(rootOpts);
76
- const fieldName = typeof options.field === "string" ? options.field : null;
77
- const bodyOnly = options.body === true;
78
- const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
76
+ // `issues` owns these options for its no-subcommand shorthand, while
77
+ // `issues read` registers the same options on the child command. Commander
78
+ // stores a duplicated flag on the parent even when it appears after `read`,
79
+ // so the action callback's local options can be empty. Merge the command
80
+ // hierarchy here, with local values winning, so every read route shares the
81
+ // same deterministic option contract.
82
+ const readOptions = { ...command.optsWithGlobals(), ...options };
83
+ const fieldName = typeof readOptions.field === "string" ? readOptions.field : null;
84
+ const bodyOnly = readOptions.body === true;
85
+ const sectionsRaw = typeof readOptions.sections === "string" ? readOptions.sections : null;
79
86
  // DEV-4476: --with opt-in includes (currently `relations`). Throws on
80
87
  // unknown values via parseWithIncludes — fail fast in the CLI per the
81
88
  // deterministic-CLI doctrine.
82
- const includes = typeof options.with === "string"
83
- ? parseWithIncludes(options.with)
89
+ const includes = typeof readOptions.with === "string"
90
+ ? parseWithIncludes(readOptions.with)
84
91
  : { relations: false };
85
92
  if (fieldName && sectionsRaw) {
86
93
  throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
@@ -36,6 +36,31 @@ export interface ElLinearConfig {
36
36
  };
37
37
  teamAliases: Record<string, string>;
38
38
  teams: Record<string, string>;
39
+ /**
40
+ * Optional identity-resolver hook (DEV-5628). `resolver` is an argv array —
41
+ * el-linear appends the identifier as the final element and reads a Linear
42
+ * user UUID off stdout:
43
+ *
44
+ * "identity": { "resolver": ["el-identity", "resolve"] }
45
+ *
46
+ * Use it when your org has a people registry that knows things Linear does
47
+ * not (that `jd` is a person; that a GitLab handle and a Linear handle are one human).
48
+ *
49
+ * It is a COMMAND rather than a URL + credentials on purpose: el-linear is
50
+ * MIT and most installs are not ours, so it must not bake in anybody's auth
51
+ * scheme. The credential lives entirely inside whatever you point this at —
52
+ * Vault, Infisical, 1Password, a plain env var, SSO. Adding a backend is
53
+ * writing a different script, not patching this package.
54
+ *
55
+ * Entirely optional and fail-open: unconfigured, or on any failure,
56
+ * resolution falls through to Linear's own user lookup exactly as before.
57
+ * See `identity-resolver.ts` for the output contract.
58
+ */
59
+ identity?: {
60
+ resolver?: string[];
61
+ /** Milliseconds before the resolver is treated as a miss (default 8000). */
62
+ resolverTimeoutMs?: number;
63
+ };
39
64
  /**
40
65
  * Term-enforcement rules. Each rule has a canonical form and a list of
41
66
  * rejected forms; rejected forms in issue titles/descriptions are flagged
@@ -139,6 +139,21 @@ export function loadConfig() {
139
139
  : {};
140
140
  // teamConfigPath inside a team config file would be circular; strip it.
141
141
  delete teamRaw.teamConfigPath;
142
+ // `identity.resolver` names a BINARY el-linear will spawn. The team layer is
143
+ // data, not code: it arrives from a different repository via `git pull` (or,
144
+ // for an OSS user, from whatever repo they pointed `teamConfigPath` at), and
145
+ // nobody reviews a config file expecting it to hand them a subprocess. Letting
146
+ // it choose the executable would turn "clone this repo and run el-linear" into
147
+ // arbitrary code execution on every `--assignee` resolution.
148
+ //
149
+ // So the resolver is honored ONLY from the personal config (or the env var) —
150
+ // files the operator owns. Same reasoning as teamConfigPath above, which is
151
+ // stripped for the same "the team layer doesn't get to decide this" reason.
152
+ //
153
+ // An organization that wants to ship a resolver to its developers writes it
154
+ // into their personal config at setup time; that keeps the decision with the
155
+ // machine's owner instead of with whoever can land a commit upstream.
156
+ delete teamRaw.identity;
142
157
  // Merge order: defaults → team config → personal config.
143
158
  // Arrays (terms, defaultLabels, etc.) are concatenated so personal entries
144
159
  // extend team entries rather than replace them.
@@ -0,0 +1,40 @@
1
+ import type { ElLinearConfig } from "./config.js";
2
+ /** Test seam — the memo would otherwise leak between cases in one vitest process. */
3
+ export declare function clearResolverMemoForTests(): void;
4
+ /**
5
+ * The configured resolver argv, or `null` when the hook is off.
6
+ *
7
+ * Env wins over config so a single invocation can point at a different resolver
8
+ * (or disable one) without editing files: `EL_LINEAR_IDENTITY_RESOLVER=""` is an
9
+ * explicit off switch.
10
+ */
11
+ export declare function resolverCommand(config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): string[] | null;
12
+ /** True when an identity resolver is configured (env or config). */
13
+ export declare function isResolverConfigured(config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): boolean;
14
+ /**
15
+ * Pull a Linear user UUID out of whatever the resolver printed.
16
+ *
17
+ * Deliberately generous about the shape, because the point of this hook is that
18
+ * anyone can write the resolver — demanding one exact JSON envelope would make
19
+ * the "just shell out to your own script" promise a lie. Accepted:
20
+ *
21
+ * - a bare UUID: `3f2a…`
22
+ * - `{"linearId": "3f2a…"}` (the el-identity record shape)
23
+ * - `{"data": {"linearId": "…"}}` (an el-* CLI `{data, meta}` envelope)
24
+ * - `{"id": "3f2a…"}` (the obvious alternative spelling)
25
+ *
26
+ * Anything else — including a *non*-UUID string, which is the shape a confused
27
+ * resolver most plausibly emits — is a miss. We would rather fall through to
28
+ * Linear's own lookup than hand a bogus id to the API and produce an opaque
29
+ * "Argument Validation Error" (the DEV-4312 failure, in a new costume).
30
+ */
31
+ export declare function parseResolverOutput(stdout: string): string | null;
32
+ /**
33
+ * Run the configured resolver for `identifier`. Returns the Linear user UUID, or
34
+ * `null` on any miss/failure. Never throws.
35
+ *
36
+ * Synchronous (`spawnSync`) on purpose: resolution sits on the critical path of
37
+ * `--assignee` for a short-lived CLI, the callers are already `async` so nothing
38
+ * is starved, and it keeps the failure modes to exactly one place.
39
+ */
40
+ export declare function resolveViaCommand(identifier: string, config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): string | null;
@@ -0,0 +1,253 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Optional **identity resolver hook** (DEV-5628).
4
+ *
5
+ * Some organizations keep a people registry that knows things Linear does not:
6
+ * that `jd` is a person, that a GitLab handle and a Linear handle belong to the
7
+ * same human. el-linear can consult it — but it must never learn *how* to reach it.
8
+ *
9
+ * So the hook is a **command**, not an HTTP client:
10
+ *
11
+ * identity.resolver = ["el-identity", "resolve"]
12
+ *
13
+ * el-linear appends the identifier as the final argv element, runs the command,
14
+ * and reads a Linear user UUID off stdout. That is the entire contract.
15
+ *
16
+ * Why a command rather than a URL + credentials (which is what
17
+ * `registry-resolve.ts` does, and why that path stayed dormant): el-linear is
18
+ * MIT and published on npm. Most installs are not Enrich Layer. Baking in an
19
+ * auth scheme means baking in *somebody's* auth scheme — and the moment we did,
20
+ * the next organization would need Infisical, or 1Password, or a plain env var,
21
+ * or SSO. A command has no such problem: the credential lives entirely inside
22
+ * whatever the operator points this at. Adding a new secret backend is writing a
23
+ * different script, not patching this package.
24
+ *
25
+ * The command is trusted (the operator configured it) but the *identifier* is
26
+ * not, so it is passed as an argv element with `shell: false` — a name like
27
+ * `"; rm -rf /"` is an argument, never a command.
28
+ *
29
+ * Fail-open by design: unconfigured, a miss, a non-zero exit, a timeout, an
30
+ * unparseable answer, or a missing binary all return `null`, and the caller
31
+ * falls through to Linear's own user lookup (which resolves names and emails
32
+ * perfectly well — see `LinearService.resolveUserId`). This hook is an
33
+ * *enhancement*, so a broken resolver must degrade to plain el-linear rather
34
+ * than break every command. It never throws.
35
+ */
36
+ /** Env override — a whitespace-separated command, e.g. `el-identity resolve`. */
37
+ const RESOLVER_ENV = "EL_LINEAR_IDENTITY_RESOLVER";
38
+ /** A resolver that hasn't answered in this long is not going to. */
39
+ const DEFAULT_TIMEOUT_MS = 8000;
40
+ /**
41
+ * Per-process memo. A single command can resolve the same person several times —
42
+ * `--subscriber a,b,a`, or an assignee who is also a subscriber — and each miss
43
+ * costs a full subprocess round-trip (seconds, against a network-backed
44
+ * resolver). Nothing here is cached across processes: el-linear is short-lived,
45
+ * and a stale identity cache on disk is exactly the drift this hook exists to
46
+ * remove.
47
+ */
48
+ const memo = new Map();
49
+ /** Test seam — the memo would otherwise leak between cases in one vitest process. */
50
+ export function clearResolverMemoForTests() {
51
+ memo.clear();
52
+ }
53
+ /**
54
+ * Resolve the effective timeout.
55
+ *
56
+ * `?? DEFAULT` is not enough: Node treats `timeout <= 0` as *no timeout*, and `0`
57
+ * is the most natural thing to type when you mean "off". That would turn the
58
+ * documented fail-open contract into an unkillable hang inside a synchronous
59
+ * call — the one failure mode the surrounding try/catch cannot save you from.
60
+ */
61
+ function resolveTimeoutMs(config) {
62
+ const configured = config.identity?.resolverTimeoutMs;
63
+ return typeof configured === "number" && configured > 0
64
+ ? configured
65
+ : DEFAULT_TIMEOUT_MS;
66
+ }
67
+ /**
68
+ * The environment handed to the resolver.
69
+ *
70
+ * It inherits the ambient env on purpose — reaching its own secret backend
71
+ * (Vault, Infisical, 1Password, a plain env var) is the entire point, so
72
+ * scrubbing wholesale would defeat the design. But the resolver has no business
73
+ * with *Linear's* token: it resolves people, it never talks to Linear. Dropping
74
+ * it keeps the CLI's most sensitive secret out of the blast radius of a
75
+ * compromised — or merely over-logging — resolver.
76
+ */
77
+ function resolverEnv(env) {
78
+ const { LINEAR_API_TOKEN: _dropped, ...rest } = env;
79
+ return rest;
80
+ }
81
+ /**
82
+ * Debug-gated diagnosis. Every failure here is a silent `null` by design, which
83
+ * is right for the user but miserable for the operator whose resolver is broken:
84
+ * all they see is an unexplained pause. `EL_LINEAR_DEBUG=1` is this repo's
85
+ * existing convention for "tell me what actually happened", and stderr never
86
+ * corrupts the JSON on stdout.
87
+ */
88
+ function debugMiss(reason, env) {
89
+ if (!env.EL_LINEAR_DEBUG)
90
+ return;
91
+ process.stderr.write(`el-linear: identity resolver miss — ${reason}\n`);
92
+ }
93
+ /**
94
+ * The configured resolver argv, or `null` when the hook is off.
95
+ *
96
+ * Env wins over config so a single invocation can point at a different resolver
97
+ * (or disable one) without editing files: `EL_LINEAR_IDENTITY_RESOLVER=""` is an
98
+ * explicit off switch.
99
+ */
100
+ export function resolverCommand(config, env = process.env) {
101
+ const fromEnv = env[RESOLVER_ENV];
102
+ if (fromEnv !== undefined) {
103
+ const argv = fromEnv.trim().split(/\s+/).filter(Boolean);
104
+ return argv.length > 0 ? argv : null;
105
+ }
106
+ const configured = config.identity?.resolver;
107
+ if (!configured || configured.length === 0) {
108
+ return null;
109
+ }
110
+ return configured;
111
+ }
112
+ /** True when an identity resolver is configured (env or config). */
113
+ export function isResolverConfigured(config, env = process.env) {
114
+ return resolverCommand(config, env) !== null;
115
+ }
116
+ /**
117
+ * Pull a Linear user UUID out of whatever the resolver printed.
118
+ *
119
+ * Deliberately generous about the shape, because the point of this hook is that
120
+ * anyone can write the resolver — demanding one exact JSON envelope would make
121
+ * the "just shell out to your own script" promise a lie. Accepted:
122
+ *
123
+ * - a bare UUID: `3f2a…`
124
+ * - `{"linearId": "3f2a…"}` (the el-identity record shape)
125
+ * - `{"data": {"linearId": "…"}}` (an el-* CLI `{data, meta}` envelope)
126
+ * - `{"id": "3f2a…"}` (the obvious alternative spelling)
127
+ *
128
+ * Anything else — including a *non*-UUID string, which is the shape a confused
129
+ * resolver most plausibly emits — is a miss. We would rather fall through to
130
+ * Linear's own lookup than hand a bogus id to the API and produce an opaque
131
+ * "Argument Validation Error" (the DEV-4312 failure, in a new costume).
132
+ */
133
+ export function parseResolverOutput(stdout) {
134
+ const trimmed = stdout.trim();
135
+ if (!trimmed) {
136
+ return null;
137
+ }
138
+ if (UUID_RE.test(trimmed)) {
139
+ return trimmed;
140
+ }
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(trimmed);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ const candidate = pickLinearId(parsed);
149
+ return candidate && UUID_RE.test(candidate) ? candidate : null;
150
+ }
151
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
152
+ function pickLinearId(parsed) {
153
+ if (typeof parsed === "string") {
154
+ return parsed;
155
+ }
156
+ if (!parsed || typeof parsed !== "object") {
157
+ return null;
158
+ }
159
+ const obj = parsed;
160
+ for (const key of ["linearId", "id"]) {
161
+ const value = obj[key];
162
+ if (typeof value === "string") {
163
+ return value;
164
+ }
165
+ }
166
+ // One level of `{data: …}` unwrapping — the el-* CLI envelope.
167
+ if (obj.data && typeof obj.data === "object") {
168
+ return pickLinearId(obj.data);
169
+ }
170
+ return null;
171
+ }
172
+ /**
173
+ * Run the configured resolver for `identifier`. Returns the Linear user UUID, or
174
+ * `null` on any miss/failure. Never throws.
175
+ *
176
+ * Synchronous (`spawnSync`) on purpose: resolution sits on the critical path of
177
+ * `--assignee` for a short-lived CLI, the callers are already `async` so nothing
178
+ * is starved, and it keeps the failure modes to exactly one place.
179
+ */
180
+ export function resolveViaCommand(identifier, config, env = process.env) {
181
+ const argv = resolverCommand(config, env);
182
+ if (!argv) {
183
+ return null;
184
+ }
185
+ const [command, ...args] = argv;
186
+ if (!command) {
187
+ return null;
188
+ }
189
+ // A leading `-` is never a valid Linear identifier, but it IS a flag to the
190
+ // resolver's own option parser — `--assignee "--output=/tmp/x"` would be
191
+ // presented to it as one. There is no shell escape here (argv, not a shell),
192
+ // but el-linear is increasingly driven by agents over untrusted issue text, so
193
+ // close the class for free rather than trust every resolver to be careful.
194
+ if (identifier.startsWith("-")) {
195
+ debugMiss(`refusing flag-shaped identifier "${identifier}"`, env);
196
+ return null;
197
+ }
198
+ const cached = memo.get(identifier);
199
+ if (cached !== undefined) {
200
+ return cached;
201
+ }
202
+ const resolved = spawnResolver(command, args, identifier, config, env);
203
+ memo.set(identifier, resolved);
204
+ return resolved;
205
+ }
206
+ function spawnResolver(command, args, identifier, config, env) {
207
+ try {
208
+ const result = spawnSync(command, [...args, identifier], {
209
+ encoding: "utf8",
210
+ timeout: resolveTimeoutMs(config),
211
+ // `spawnSync` waits for the child to fully exit after its timeout. Its
212
+ // default kill signal is SIGTERM, which a broken resolver can trap or
213
+ // ignore forever — defeating this hook's fail-open timeout. The resolver
214
+ // is an isolated lookup helper, so force it down when its time budget is
215
+ // exhausted rather than letting it wedge the whole CLI.
216
+ killSignal: "SIGKILL",
217
+ // The identifier is untrusted input; never hand it to a shell. This also
218
+ // means a Windows npm shim (`el-identity.cmd`) will NOT be found — see
219
+ // the Windows note in docs/configuration.md. Turning `shell: true` on to
220
+ // fix that would hand the untrusted identifier to cmd.exe, which is a
221
+ // far worse trade.
222
+ shell: false,
223
+ // stdin closed: a resolver that decides to prompt must not hang the CLI.
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ env: resolverEnv(env),
226
+ });
227
+ // BOTH conditions are load-bearing; do not "simplify" this to one.
228
+ // - a plain timeout → error ETIMEDOUT, status null
229
+ // - a maxBuffer overflow → error ENOBUFS, status null
230
+ // - a child that leaves a grandchild holding the stdout pipe
231
+ // → error ETIMEDOUT, status **0**
232
+ // Checking only `status` would let that last case through as a success and
233
+ // parse whatever partial output happened to be buffered.
234
+ if (result.error) {
235
+ debugMiss(`${command}: ${result.error.message}`, env);
236
+ return null;
237
+ }
238
+ if (result.status !== 0) {
239
+ const stderr = (result.stderr ?? "").trim().split("\n")[0] ?? "";
240
+ debugMiss(`${command} exited ${result.status}${stderr ? `: ${stderr}` : ""}`, env);
241
+ return null;
242
+ }
243
+ const parsed = parseResolverOutput(result.stdout ?? "");
244
+ if (!parsed) {
245
+ debugMiss(`${command} printed no usable Linear UUID for "${identifier}"`, env);
246
+ }
247
+ return parsed;
248
+ }
249
+ catch (err) {
250
+ debugMiss(err instanceof Error ? err.message : String(err), env);
251
+ return null;
252
+ }
253
+ }
@@ -14,14 +14,29 @@ export declare function resolveMember(input: string): string;
14
14
  */
15
15
  export declare function resolveAssignee(input: string, rootOpts: Record<string, unknown>): Promise<string>;
16
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).
17
+ * Resolve a member identifier (alias / handle / name) to a UUID. The shared
18
+ * async resolution path behind `--assignee` and `--delegate` (DEV-4871 /
19
+ * DEV-4872 / DEV-5628).
21
20
  *
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.
21
+ * Order, most authoritative first:
22
+ *
23
+ * 1. **The identity-resolver hook** (`identity.resolver`, DEV-5628) — an
24
+ * operator-supplied command. This is the one to reach for: it can answer
25
+ * for aliases and cross-system handles Linear has never heard of, and it
26
+ * owns its own credentials, so el-linear carries no auth scheme.
27
+ * 2. **The HTTP registry** (`EL_IDENTITY_URL`, DEV-4871) — the older,
28
+ * env-gated path. Superseded by (1), which needs no URL or CF-Access
29
+ * credentials in the environment. Kept because it ships and someone may
30
+ * rely on it.
31
+ * 3. **The bundled config** (`resolveMember`) — a local lookup table.
32
+ *
33
+ * All three are optional. With none of them, `resolveMember` hands the raw input
34
+ * back and Linear's own user lookup resolves it (`LinearService.resolveUserId`
35
+ * matches email → displayName → name), which is why an install with no config at
36
+ * all still works.
37
+ *
38
+ * Fail-open throughout: a miss or a failure at any layer falls through to the
39
+ * next. Never throws.
25
40
  */
26
41
  export declare function resolveMemberWithRegistry(input: string): Promise<string>;
27
42
  /**
@@ -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 { resolveViaCommand } from "./identity-resolver.js";
5
6
  import { isRegistryConfigured, resolveViaRegistry, } from "./registry-resolve.js";
6
7
  /**
7
8
  * Resolve a team key/name/alias to its UUID.
@@ -102,16 +103,39 @@ export async function resolveAssignee(input, rootOpts) {
102
103
  return resolveMemberWithRegistry(input);
103
104
  }
104
105
  /**
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).
106
+ * Resolve a member identifier (alias / handle / name) to a UUID. The shared
107
+ * async resolution path behind `--assignee` and `--delegate` (DEV-4871 /
108
+ * DEV-4872 / DEV-5628).
109
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.
110
+ * Order, most authoritative first:
111
+ *
112
+ * 1. **The identity-resolver hook** (`identity.resolver`, DEV-5628) — an
113
+ * operator-supplied command. This is the one to reach for: it can answer
114
+ * for aliases and cross-system handles Linear has never heard of, and it
115
+ * owns its own credentials, so el-linear carries no auth scheme.
116
+ * 2. **The HTTP registry** (`EL_IDENTITY_URL`, DEV-4871) — the older,
117
+ * env-gated path. Superseded by (1), which needs no URL or CF-Access
118
+ * credentials in the environment. Kept because it ships and someone may
119
+ * rely on it.
120
+ * 3. **The bundled config** (`resolveMember`) — a local lookup table.
121
+ *
122
+ * All three are optional. With none of them, `resolveMember` hands the raw input
123
+ * back and Linear's own user lookup resolves it (`LinearService.resolveUserId`
124
+ * matches email → displayName → name), which is why an install with no config at
125
+ * all still works.
126
+ *
127
+ * Fail-open throughout: a miss or a failure at any layer falls through to the
128
+ * next. Never throws.
113
129
  */
114
130
  export async function resolveMemberWithRegistry(input) {
131
+ // UUIDs are already canonical — don't spend a subprocess on them.
132
+ if (isUuid(input)) {
133
+ return input;
134
+ }
135
+ const viaCommand = resolveViaCommand(input, loadConfig());
136
+ if (viaCommand) {
137
+ return viaCommand;
138
+ }
115
139
  if (isRegistryConfigured()) {
116
140
  const viaRegistry = await resolveViaRegistry(input);
117
141
  if (viaRegistry) {
@@ -70,6 +70,30 @@ export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
70
70
  * Overridable via `config.validation.duplicateHardBlockThreshold`.
71
71
  */
72
72
  export declare const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
73
+ /**
74
+ * The one CLI flag that lets a HARD-BLOCKED create proceed.
75
+ *
76
+ * Exported so the remedy copy in {@link formatDuplicateBlock} is BUILT from the
77
+ * same constant the gate honors, rather than restating it. DEV-6205 shipped a
78
+ * remedy naming `--parent <id>` alone — a flag the gate never reads — so the
79
+ * message promised an outcome the command refused. Deriving the copy from this
80
+ * constant makes that class of drift a compile-time concern instead of a
81
+ * proofreading one.
82
+ */
83
+ export declare const DUPLICATE_GATE_OVERRIDE_FLAG = "--allow-duplicate";
84
+ /**
85
+ * Does this parsed option set clear the duplicate gate's HARD block?
86
+ *
87
+ * The single source of truth for the escape set, consumed by the gate itself
88
+ * (`enforceNoDuplicateIssue`) and asserted against the rendered remedy copy by
89
+ * the composition test. Note `--skip-validation` also bypasses, but it returns
90
+ * long before scoring (it skips ALL field validation), so it is not part of the
91
+ * hard-block decision this predicate models and is deliberately not offered as
92
+ * a remedy.
93
+ */
94
+ export declare function bypassesDuplicateHardBlock(options: {
95
+ allowDuplicate?: unknown;
96
+ }): boolean;
73
97
  /** A scored duplicate candidate, ready to print in the block. */
74
98
  export interface DuplicateCandidate {
75
99
  identifier: string;
@@ -116,5 +140,30 @@ export declare function scoreDuplicateCandidates(title: string, candidates: Line
116
140
  * "advisory"` is printed as a warning when the score is below the hard-block
117
141
  * threshold: creation already proceeded, so the trailing hint differs (no
118
142
  * "re-run" — there's nothing to re-run).
143
+ *
144
+ * Three remedies, not two (DEV-6205). "Same work → comment" and "distinct →
145
+ * --allow-duplicate" leave out the most common real case: the new work is a
146
+ * piece of the match — neither a duplicate of it nor unrelated to it. Offering
147
+ * only the two extremes pushes the operator toward reusing the matched issue,
148
+ * which is actively harmful when that issue is a multi-phase parent: the branch
149
+ * then carries the parent's id, and merging it auto-closes work that isn't done.
150
+ * That is not hypothetical — it is what the omission cost on MAR-744, where a
151
+ * findings pack was filed against a six-criterion parent because `--parent` was
152
+ * never mentioned.
153
+ *
154
+ * The sub-issue remedy is per-tier, NOT shared, because the two tiers are at
155
+ * opposite sides of the create:
156
+ *
157
+ * - **block** — creation was refused, so the remedy is a re-run. `--parent`
158
+ * alone does NOT satisfy the gate ({@link bypassesDuplicateHardBlock} reads
159
+ * only `allowDuplicate`), so the copy is built from
160
+ * {@link DUPLICATE_GATE_OVERRIDE_FLAG} and names both flags. A message that
161
+ * names a command the gate then refuses is the DEV-6205 bug one level down;
162
+ * the composition test parses this copy through the real CLI and asserts it
163
+ * actually clears the gate.
164
+ * - **advisory** — creation ALREADY proceeded, so there is nothing to re-run
165
+ * and a re-run would file a second issue (which then scores 1.0 against its
166
+ * own twin and hard-blocks). The remedy is to attach the issue that now
167
+ * exists, via `issues update`.
119
168
  */
120
169
  export declare function formatDuplicateBlock(candidates: DuplicateCandidate[], mode?: "block" | "advisory"): string;
@@ -141,6 +141,30 @@ const BOILERPLATE_STOPWORDS = new Set([
141
141
  "create",
142
142
  "update",
143
143
  ]);
144
+ /**
145
+ * The one CLI flag that lets a HARD-BLOCKED create proceed.
146
+ *
147
+ * Exported so the remedy copy in {@link formatDuplicateBlock} is BUILT from the
148
+ * same constant the gate honors, rather than restating it. DEV-6205 shipped a
149
+ * remedy naming `--parent <id>` alone — a flag the gate never reads — so the
150
+ * message promised an outcome the command refused. Deriving the copy from this
151
+ * constant makes that class of drift a compile-time concern instead of a
152
+ * proofreading one.
153
+ */
154
+ export const DUPLICATE_GATE_OVERRIDE_FLAG = "--allow-duplicate";
155
+ /**
156
+ * Does this parsed option set clear the duplicate gate's HARD block?
157
+ *
158
+ * The single source of truth for the escape set, consumed by the gate itself
159
+ * (`enforceNoDuplicateIssue`) and asserted against the rendered remedy copy by
160
+ * the composition test. Note `--skip-validation` also bypasses, but it returns
161
+ * long before scoring (it skips ALL field validation), so it is not part of the
162
+ * hard-block decision this predicate models and is deliberately not offered as
163
+ * a remedy.
164
+ */
165
+ export function bypassesDuplicateHardBlock(options) {
166
+ return Boolean(options.allowDuplicate);
167
+ }
144
168
  /**
145
169
  * Tokenize a title into a set of salient lowercase keywords.
146
170
  *
@@ -219,19 +243,52 @@ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_
219
243
  * "advisory"` is printed as a warning when the score is below the hard-block
220
244
  * threshold: creation already proceeded, so the trailing hint differs (no
221
245
  * "re-run" — there's nothing to re-run).
246
+ *
247
+ * Three remedies, not two (DEV-6205). "Same work → comment" and "distinct →
248
+ * --allow-duplicate" leave out the most common real case: the new work is a
249
+ * piece of the match — neither a duplicate of it nor unrelated to it. Offering
250
+ * only the two extremes pushes the operator toward reusing the matched issue,
251
+ * which is actively harmful when that issue is a multi-phase parent: the branch
252
+ * then carries the parent's id, and merging it auto-closes work that isn't done.
253
+ * That is not hypothetical — it is what the omission cost on MAR-744, where a
254
+ * findings pack was filed against a six-criterion parent because `--parent` was
255
+ * never mentioned.
256
+ *
257
+ * The sub-issue remedy is per-tier, NOT shared, because the two tiers are at
258
+ * opposite sides of the create:
259
+ *
260
+ * - **block** — creation was refused, so the remedy is a re-run. `--parent`
261
+ * alone does NOT satisfy the gate ({@link bypassesDuplicateHardBlock} reads
262
+ * only `allowDuplicate`), so the copy is built from
263
+ * {@link DUPLICATE_GATE_OVERRIDE_FLAG} and names both flags. A message that
264
+ * names a command the gate then refuses is the DEV-6205 bug one level down;
265
+ * the composition test parses this copy through the real CLI and asserts it
266
+ * actually clears the gate.
267
+ * - **advisory** — creation ALREADY proceeded, so there is nothing to re-run
268
+ * and a re-run would file a second issue (which then scores 1.0 against its
269
+ * own twin and hard-blocks). The remedy is to attach the issue that now
270
+ * exists, via `issues update`.
222
271
  */
223
272
  export function formatDuplicateBlock(candidates, mode = "block") {
224
273
  const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
274
+ // Name the parent when there is exactly one candidate; with several there is
275
+ // no single right parent, and silently picking the top score would invite a
276
+ // wrong one.
277
+ const parentRef = candidates.length === 1 ? candidates[0].identifier : "<id>";
225
278
  const header = `Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
226
279
  "(by title-keyword overlap):\n" +
227
280
  `${lines.join("\n")}\n\n` +
228
281
  " If one of these is the same work, comment on it instead of creating a new issue.\n";
229
282
  if (mode === "advisory") {
230
283
  return (header +
284
+ " If this is a piece of one of them rather than a duplicate, attach it with " +
285
+ `\`issues update <new-id> --parent ${parentRef}\`.\n` +
231
286
  " This is advisory only (DEV-5590) — creation is proceeding. Pass " +
232
287
  "--allow-duplicate to silence this notice next time.");
233
288
  }
234
289
  return (header +
235
- " If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
290
+ " If this is a piece of one of them rather than a duplicate, re-run with " +
291
+ `--parent ${parentRef} ${DUPLICATE_GATE_OVERRIDE_FLAG} to file it as a sub-issue.\n` +
292
+ ` If this is genuinely distinct, re-run with ${DUPLICATE_GATE_OVERRIDE_FLAG} to proceed ` +
236
293
  "(and consider --related-to to link the related issue).");
237
294
  }
@@ -1,3 +1,5 @@
1
+ import { loadConfig } from "../config/config.js";
2
+ import { resolveViaCommand } from "../config/identity-resolver.js";
1
3
  import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
2
4
  import { resolveUserDisplayName } from "../config/resolver.js";
3
5
  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_SCOPED_FILTERED_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
@@ -1116,9 +1118,15 @@ export class GraphQLIssuesService {
1116
1118
  if (!assigneeId || isUuid(assigneeId)) {
1117
1119
  return assigneeId;
1118
1120
  }
1119
- // Opt-in registry resolution (DEV-4872): try the identity registry first,
1120
- // falling back to the Linear-API user lookup below on a miss. EL-only,
1121
- // env-gated, fail-open — a non-EL install never reaches the network here.
1121
+ // Opt-in identity resolution, falling back to the Linear-API user lookup
1122
+ // below on a miss. Both layers are optional and fail-open — an install
1123
+ // with neither configured never reaches the network here.
1124
+ // 1. The resolver-command hook (DEV-5628) — brings its own credentials.
1125
+ // 2. The older env-gated HTTP registry (DEV-4872).
1126
+ const viaCommand = resolveViaCommand(assigneeId, loadConfig());
1127
+ if (viaCommand) {
1128
+ return viaCommand;
1129
+ }
1122
1130
  if (isRegistryConfigured()) {
1123
1131
  const viaRegistry = await resolveViaRegistry(assigneeId);
1124
1132
  if (viaRegistry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.41.0",
3
+ "version": "1.42.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",