@enrichlayer/el-linear 1.37.1 → 1.38.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
@@ -145,6 +145,16 @@ EL_LINEAR_PROFILE=forage el-linear teams list
145
145
  el-linear profile remove old-profile
146
146
  ```
147
147
 
148
+ Fix or clean up a single member's aliases/handles without hand-editing
149
+ `config.json` — `profile members` operates directly on the active profile's
150
+ on-disk config (no Linear API round-trip):
151
+
152
+ ```bash
153
+ el-linear profile members list # show configured members
154
+ el-linear profile members clear "linear" # remove a mistaken/stale entry
155
+ el-linear profile members set "Alice Anderson" --aliases ali,al,alex
156
+ ```
157
+
148
158
  Each profile lives at `~/.config/el-linear/profiles/<name>/` and owns:
149
159
 
150
160
  - `token` — its Linear API token (mode 0600)
@@ -267,8 +277,9 @@ el-linear can record each gate's fire/override decision to a local JSONL file so
267
277
  you can measure its **override-rate** and tell whether it's too aggressive. It is
268
278
  **off by default** and writes nothing unless you opt in (e.g.
269
279
  `export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
270
- `EL_TELEMETRY_DISABLED=1` forces it off. Full opt-in rules, the event schema, and
271
- a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
280
+ `EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
281
+ backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
282
+ and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
272
283
 
273
284
  ### Networking (IPv4 preference)
274
285
 
@@ -466,7 +477,7 @@ el-linear <command> --help # detailed help for one command
466
477
  | Search | `search <query>` (semantic, cross-resource) |
467
478
  | Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
468
479
  | Escape hatch | `graphql [query]` (with `--introspect`) |
469
- | Config | `config show`, `users list`, `teams list`, `templates list` |
480
+ | Config | `config show`, `users {list, read}`, `teams list`, `templates list` |
470
481
 
471
482
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
472
483
  top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
@@ -237,6 +237,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
237
237
  el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
238
238
  ```
239
239
 
240
+ ### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
241
+
242
+ **The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
243
+
244
+ - **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
245
+ - **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
246
+ - Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
247
+
248
+ The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
249
+
240
250
  ### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
241
251
 
242
252
  The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
@@ -248,38 +258,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
248
258
 
249
259
  When `el-linear issues search` (or the cross-resource `search`) returns rows
250
260
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
251
- starting with `relation_candidates:` that enumerates the candidate IDs and
252
- asks the user to reply with which ones to link, and states the skip phrase.
253
-
254
- That warning is the authoritative instruction — surface it to the user
255
- verbatim and follow it literally: **do not call `el-linear issues relate`
256
- until the user replies naming the IDs to link.** Only user-named IDs go into
257
- the `issues relate <source> --related-to "<ids>"` call — never pass an ID the
258
- user did not name, even one your own search obviously surfaced. The CLI emits
259
- the full procedure (every candidate ID, the example reply, the skip phrase) in
260
- that one line, so follow it rather than re-deriving or paraphrasing it away.
261
-
262
- Why this matters: Claude Code's auto-mode permission classifier blocks
263
- `issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
264
- (came from your own search) rather than *user-specified* (typed by the human),
265
- because each listed peer is a write target. Routing the IDs through an
266
- explicit human reply converts them from agent-inferred → user-specified;
267
- the existing search step (above) stays intact; auto-mode's guard is not
268
- weakened. The fix is the loop shape, not the guard.
269
-
270
- Anti-patterns:
271
-
272
- - **Calling `issues relate` directly off your own search output** — even if
273
- the IDs are real and the candidates look obvious, this is the exact path
274
- the auto-mode guard refuses.
275
- - **Splitting one relate call into N single-ID calls** to "look smaller" —
276
- same provenance problem, same block, just multiplied.
277
- - **Asking the user a yes/no question** ("Should I link these?") instead of
278
- having them name the IDs — yes answers stay agent-inferred, the reply
279
- must carry the IDs to convert them to user-specified.
261
+ starting with `relation_candidates:` enumerating the candidate IDs. Treat it
262
+ as a **convenience list of link candidates, not a stop sign** — under the
263
+ proactive default above, relate the ones that are genuinely related/blocking
264
+ without waiting to be told. Prefer create-time `--related-to` when the search
265
+ ran as part of filing a new issue (create-time relations typically pass the
266
+ classifier); otherwise call `issues relate` directly.
267
+
268
+ **Residual auto-mode constraint (the only reason to pause).** Claude Code's
269
+ auto-mode permission classifier *may* block a standalone `issues relate
270
+ --related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
271
+ your own search) rather than *user-specified*, because each peer is a write
272
+ target. A standing user instruction to cross-link generously is itself
273
+ authorization — so proceed by default. But if a specific `relate` call is
274
+ **actually blocked** by the classifier, that block is your cue: surface the
275
+ candidate IDs to the user for a one-word confirm, then re-run with the
276
+ user-named IDs (which pass). This is the **exception path**, not the default —
277
+ do not pre-emptively withhold links the classifier would have allowed. Two
278
+ ways to keep it frictionless: file relations at create time (`--related-to`),
279
+ or the operator adds a permission rule / runs non-auto-mode so post-hoc
280
+ `relate` never trips.
280
281
 
281
282
  If `--include-closed` search returns no matches, no `relation_candidates:`
282
- warning is emitted (nothing to confirm) and the flow proceeds normally.
283
+ warning is emitted and the flow proceeds normally.
283
284
 
284
285
  ### Viewing existing relations
285
286
 
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
4
4
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
5
5
  import { applyFooter } from "../utils/footer.js";
6
6
  import { createGraphQLService, } from "../utils/graphql-service.js";
7
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
7
8
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
8
9
  import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
9
10
  import { createLinearService, } from "../utils/linear-service.js";
@@ -79,7 +80,7 @@ function readBody(options) {
79
80
  return readFileSync(options.bodyFile, "utf-8");
80
81
  }
81
82
  if (options.body) {
82
- return options.body;
83
+ return normalizeInlineTextInput(options.body);
83
84
  }
84
85
  throw new Error("Either --body or --body-file is required");
85
86
  }
@@ -17,6 +17,17 @@ export interface User {
17
17
  displayName: string;
18
18
  }
19
19
  export declare function fetchAllUsers(token: string): Promise<User[]>;
20
+ /**
21
+ * Detect the synthetic Linear-internal "system actor" surfaced by the Users
22
+ * API alongside real people — DEV-5612. Its email is a
23
+ * `<uuid>@linear.linear.app` address (the documented pattern for Linear's own
24
+ * automation account); it isn't a person and should never be offered as an
25
+ * assignable alias target. A user setting up the alias wizard for the first
26
+ * time mistook it for themselves (`linear <linear-…@linear.linear.app>`
27
+ * presented as an ordinary numbered member) and typed their own nicknames as
28
+ * its aliases.
29
+ */
30
+ export declare function isLinearSystemActor(user: User): boolean;
20
31
  /**
21
32
  * Find groups of users sharing the same display name. The current alias
22
33
  * config keys aliases by display name, so colliding users would silently
@@ -25,11 +36,29 @@ export declare function fetchAllUsers(token: string): Promise<User[]>;
25
36
  * schema to UUID-keyed maps.
26
37
  */
27
38
  export declare function findDisplayNameCollisions(users: User[]): Map<string, User[]>;
39
+ /** The alias/handle-mutation slice of {@link AliasUpdate}, without the user
40
+ * identity fields — shared by the UUID-keyed wizard merge and the by-name
41
+ * direct-edit path (DEV-5612), which has no UUID to record. */
42
+ export type MemberAliasMutation = Pick<AliasUpdate, "mode" | "aliases" | "github" | "gitlab">;
28
43
  /**
29
44
  * Merge new alias entries into existing config without dropping unrelated
30
45
  * fields. Pure function — no I/O. Exported for testing.
31
46
  */
32
47
  export declare function mergeAliasesIntoConfig(existing: WizardConfig, updates: Map<string, AliasUpdate>): WizardConfig;
48
+ /**
49
+ * Apply a single member alias/handle mutation directly by display name — the
50
+ * non-interactive counterpart to the `init aliases` walk (DEV-5612). Used by
51
+ * `el-linear profile members clear <name>` / `members set <name>` so a
52
+ * mistaken or unwanted entry (e.g. the Linear system actor, or a stale
53
+ * alias) can be fixed without hand-editing config.json or re-walking every
54
+ * user interactively.
55
+ *
56
+ * Unlike {@link mergeAliasesIntoConfig} (keyed by Linear user UUID, populated
57
+ * by the `init aliases` walk against the live Users API), this operates
58
+ * purely on the on-disk config by display name — the `uuids`/`fullNames`
59
+ * maps are left untouched since there's no fresh UUID to record here.
60
+ */
61
+ export declare function applyMemberAliasUpdate(existing: WizardConfig, displayName: string, update: MemberAliasMutation): WizardConfig;
33
62
  /**
34
63
  * What to do with a single platform handle (github/gitlab/...) when applying
35
64
  * an AliasUpdate. Discriminated union; no sentinel strings.
@@ -46,6 +46,19 @@ export async function fetchAllUsers(token) {
46
46
  }
47
47
  return out;
48
48
  }
49
+ /**
50
+ * Detect the synthetic Linear-internal "system actor" surfaced by the Users
51
+ * API alongside real people — DEV-5612. Its email is a
52
+ * `<uuid>@linear.linear.app` address (the documented pattern for Linear's own
53
+ * automation account); it isn't a person and should never be offered as an
54
+ * assignable alias target. A user setting up the alias wizard for the first
55
+ * time mistook it for themselves (`linear <linear-…@linear.linear.app>`
56
+ * presented as an ordinary numbered member) and typed their own nicknames as
57
+ * its aliases.
58
+ */
59
+ export function isLinearSystemActor(user) {
60
+ return Boolean(user.email && /@linear\.linear\.app$/i.test(user.email));
61
+ }
49
62
  /**
50
63
  * Find groups of users sharing the same display name. The current alias
51
64
  * config keys aliases by display name, so colliding users would silently
@@ -82,6 +95,45 @@ function applyHandleAction(handlesMap, fullName, action) {
82
95
  handlesMap[action.value.trim()] = fullName;
83
96
  }
84
97
  }
98
+ /**
99
+ * Apply one member's alias/handle mutation into `next` (mutated in place).
100
+ * Ensures the `members.aliases`/`members.handles.{github,gitlab}` sub-trees
101
+ * exist first. Pure with respect to `fullName`/`update` — the only side
102
+ * effect is on `next`.
103
+ */
104
+ function applyAliasMutation(next, fullName, update) {
105
+ next.members = next.members ?? {};
106
+ next.members.aliases = next.members.aliases ?? {};
107
+ next.members.handles = next.members.handles ?? {};
108
+ next.members.handles.github = next.members.handles.github ?? {};
109
+ next.members.handles.gitlab = next.members.handles.gitlab ?? {};
110
+ // Aliases: replace this user's entries based on the update mode.
111
+ // We track aliases as { aliasKey: fullName }, so removing means deleting
112
+ // any keys whose value is this fullName.
113
+ const currentAliasKeys = Object.entries(next.members.aliases)
114
+ .filter(([_, v]) => v === fullName)
115
+ .map(([k]) => k);
116
+ if (update.mode === "clear") {
117
+ for (const k of currentAliasKeys)
118
+ delete next.members.aliases[k];
119
+ }
120
+ else if (update.mode === "edit") {
121
+ for (const k of currentAliasKeys)
122
+ delete next.members.aliases[k];
123
+ for (const a of update.aliases)
124
+ next.members.aliases[a] = fullName;
125
+ }
126
+ else if (update.mode === "append") {
127
+ for (const a of update.aliases)
128
+ next.members.aliases[a] = fullName;
129
+ }
130
+ // "keep" → no changes.
131
+ // GitHub / GitLab handles use the same edit/append/clear semantics,
132
+ // driven by the discriminated HandleAction per platform.
133
+ for (const platform of HANDLE_PLATFORMS) {
134
+ applyHandleAction(next.members.handles[platform], fullName, update[platform]);
135
+ }
136
+ }
85
137
  /**
86
138
  * Merge new alias entries into existing config without dropping unrelated
87
139
  * fields. Pure function — no I/O. Exported for testing.
@@ -89,45 +141,34 @@ function applyHandleAction(handlesMap, fullName, action) {
89
141
  export function mergeAliasesIntoConfig(existing, updates) {
90
142
  const next = JSON.parse(JSON.stringify(existing));
91
143
  next.members = next.members ?? {};
92
- next.members.aliases = next.members.aliases ?? {};
93
144
  next.members.fullNames = next.members.fullNames ?? {};
94
- next.members.handles = next.members.handles ?? {};
95
145
  next.members.uuids = next.members.uuids ?? {};
96
- next.members.handles.github = next.members.handles.github ?? {};
97
- next.members.handles.gitlab = next.members.handles.gitlab ?? {};
98
146
  for (const [userId, update] of updates) {
99
147
  const fullName = update.displayName;
100
148
  next.members.uuids[fullName] = userId;
101
149
  next.members.fullNames[userId] = fullName;
102
- // Aliases: replace this user's entries based on the update mode.
103
- // We track aliases as { aliasKey: fullName }, so removing means deleting
104
- // any keys whose value is this fullName.
105
- const currentAliasKeys = Object.entries(next.members.aliases)
106
- .filter(([_, v]) => v === fullName)
107
- .map(([k]) => k);
108
- if (update.mode === "clear") {
109
- for (const k of currentAliasKeys)
110
- delete next.members.aliases[k];
111
- }
112
- else if (update.mode === "edit") {
113
- for (const k of currentAliasKeys)
114
- delete next.members.aliases[k];
115
- for (const a of update.aliases)
116
- next.members.aliases[a] = fullName;
117
- }
118
- else if (update.mode === "append") {
119
- for (const a of update.aliases)
120
- next.members.aliases[a] = fullName;
121
- }
122
- // "keep" → no changes.
123
- // GitHub / GitLab handles use the same edit/append/clear semantics,
124
- // driven by the discriminated HandleAction per platform.
125
- for (const platform of HANDLE_PLATFORMS) {
126
- applyHandleAction(next.members.handles[platform], fullName, update[platform]);
127
- }
150
+ applyAliasMutation(next, fullName, update);
128
151
  }
129
152
  return next;
130
153
  }
154
+ /**
155
+ * Apply a single member alias/handle mutation directly by display name — the
156
+ * non-interactive counterpart to the `init aliases` walk (DEV-5612). Used by
157
+ * `el-linear profile members clear <name>` / `members set <name>` so a
158
+ * mistaken or unwanted entry (e.g. the Linear system actor, or a stale
159
+ * alias) can be fixed without hand-editing config.json or re-walking every
160
+ * user interactively.
161
+ *
162
+ * Unlike {@link mergeAliasesIntoConfig} (keyed by Linear user UUID, populated
163
+ * by the `init aliases` walk against the live Users API), this operates
164
+ * purely on the on-disk config by display name — the `uuids`/`fullNames`
165
+ * maps are left untouched since there's no fresh UUID to record here.
166
+ */
167
+ export function applyMemberAliasUpdate(existing, displayName, update) {
168
+ const next = JSON.parse(JSON.stringify(existing));
169
+ applyAliasMutation(next, displayName, update);
170
+ return next;
171
+ }
131
172
  /** Set of supported platforms for member handle keys. */
132
173
  export const HANDLE_PLATFORMS = ["github", "gitlab"];
133
174
  function currentAliasesFor(config, fullName) {
@@ -162,11 +203,24 @@ export async function runAliasesStep(token, existing, options = {}) {
162
203
  console.log(" Skipped — run `el-linear init aliases` to add or edit aliases later.");
163
204
  return new Map();
164
205
  }
165
- const allUsers = await fetchAllUsers(token);
166
- if (allUsers.length === 0) {
206
+ const fetchedUsers = await fetchAllUsers(token);
207
+ if (fetchedUsers.length === 0) {
167
208
  console.log(" No active users visible to this token.");
168
209
  return new Map();
169
210
  }
211
+ // Skip the Linear system actor(s) — not real people, never alias targets
212
+ // (DEV-5612). Filtered before collision detection so it can't spuriously
213
+ // collide with (or mask) a real display-name collision.
214
+ const systemActors = fetchedUsers.filter(isLinearSystemActor);
215
+ if (systemActors.length > 0) {
216
+ console.log(` Skipping ${systemActors.length} Linear system actor(s) — not real people, never alias targets: ` +
217
+ `${systemActors.map((u) => u.email ?? u.id).join(", ")}`);
218
+ }
219
+ const allUsers = fetchedUsers.filter((u) => !isLinearSystemActor(u));
220
+ if (allUsers.length === 0) {
221
+ console.log(" No human users remain after filtering system actors.");
222
+ return new Map();
223
+ }
170
224
  // Skip users whose display name collides with another active user. The
171
225
  // current schema keys aliases by display name, so writing aliases for a
172
226
  // colliding user would corrupt the other user's entries. A schema migration
@@ -20,6 +20,7 @@ import fs from "node:fs";
20
20
  import { loadConfig } from "../../config/config.js";
21
21
  import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
22
22
  import { autoLinkReferences, } from "../../utils/auto-link-references.js";
23
+ import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
23
24
  import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
24
25
  import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
25
26
  import { validateReferences } from "../../utils/validate-references.js";
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
59
60
  return readDescriptionFile(options.descriptionFile);
60
61
  }
61
62
  if (hasInline) {
62
- return options.description;
63
+ return normalizeInlineTextInput(options.description);
63
64
  }
64
65
  if (hasTemplate) {
65
66
  const templates = loadConfig().descriptionTemplates ?? {};
@@ -7,12 +7,13 @@ import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvabl
7
7
  import { resolveDefaultStatus } from "../config/status-defaults.js";
8
8
  import { enforceTerms } from "../config/term-enforcer.js";
9
9
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
10
- import { DEFAULT_DUPLICATE_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
10
+ import { DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
11
11
  import { createFileService } from "../utils/file-service.js";
12
12
  import { applyFooter } from "../utils/footer.js";
13
13
  import { emitGateEvent } from "../utils/gate-telemetry.js";
14
14
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
15
  import { createGraphQLService, } from "../utils/graphql-service.js";
16
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
16
17
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
17
18
  import { createLinearService, } from "../utils/linear-service.js";
18
19
  import { logger } from "../utils/logger.js";
@@ -205,7 +206,8 @@ async function handleListIssues(options, command) {
205
206
  // user passes --include-closed OR explicit --status. Explicit status
206
207
  // wins because the user already named the workflow states they want.
207
208
  const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
208
- const hasOtherFilters = options.team ||
209
+ const hasOtherFilters = options.search ||
210
+ options.team ||
209
211
  options.labels ||
210
212
  options.status ||
211
213
  options.assignee ||
@@ -223,6 +225,7 @@ async function handleListIssues(options, command) {
223
225
  // (DEV-4478 cycle-1.)
224
226
  if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
225
227
  const searchArgs = {
228
+ query: options.search,
226
229
  teamId: options.team ? resolveTeam(options.team) : undefined,
227
230
  assigneeId: options.assignee
228
231
  ? await resolveAssignee(options.assignee, rootOpts)
@@ -244,8 +247,15 @@ async function handleListIssues(options, command) {
244
247
  if (excludeTerminalStates) {
245
248
  outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
246
249
  }
250
+ if (options.search) {
251
+ const relationPrompt = buildRelationCandidatePrompt(result);
252
+ if (relationPrompt) {
253
+ outputWarning(relationPrompt);
254
+ }
255
+ }
247
256
  warnIfTruncated(result.length, limit);
248
257
  outputIssues(result, command, {
258
+ query: options.search,
249
259
  team: options.team,
250
260
  });
251
261
  }
@@ -477,18 +487,28 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
477
487
  /**
478
488
  * DEV-4823: create-time duplicate-detection gate. Searches the title's
479
489
  * salient keywords (including closed issues), scores candidates by Jaccard
480
- * title-overlap, and throws — listing the matches — when one is at/above the
481
- * configured similarity threshold.
490
+ * title-overlap, and — for a candidate at/above the hard-block threshold —
491
+ * throws, listing the matches.
492
+ *
493
+ * DEV-5590: two-tier since the single-threshold gate measured a 52.2%
494
+ * override rate (score alone didn't separate genuine duplicates from
495
+ * legitimate distinct issues — see {@link DEFAULT_HARD_BLOCK_THRESHOLD} for
496
+ * the data). A candidate at/above `duplicateThreshold` (detection floor) but
497
+ * below `duplicateHardBlockThreshold` (hard-block floor) is ADVISORY: printed
498
+ * as a warning, creation proceeds, and the fire is recorded as `advisory` —
499
+ * excluded from the override-rate denominator (only `blocked`/`overridden`
500
+ * count). At/above the hard-block floor, behavior is unchanged from DEV-4823.
482
501
  *
483
502
  * Bypassed by `--skip-validation` and
484
503
  * `config.validation.duplicateDetection: false` / `validation.enabled: false`.
485
504
  * `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
486
- * would-fire, records an `overridden` telemetry event (DEV-4834) before
487
- * proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
488
- * fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
489
- * nothing — it's not a gate-specific override, so counting it would dilute the
490
- * signal. The search itself is best-effort: a network/API failure warns and
491
- * proceeds rather than blocking legitimate issue creation on infra trouble.
505
+ * would-hard-block, records an `overridden` telemetry event (DEV-4834) before
506
+ * proceeding, so `el-telemetry gates` can measure the override-rate. (A
507
+ * hard-blocked fire records `blocked`.) `--skip-validation` is a blanket
508
+ * bypass and emits nothing — it's not a gate-specific override, so counting
509
+ * it would dilute the signal. The search itself is best-effort: a network/API
510
+ * failure warns and proceeds rather than blocking legitimate issue creation
511
+ * on infra trouble.
492
512
  */
493
513
  async function enforceNoDuplicateIssue(title, options, issuesService) {
494
514
  if (options.skipValidation) {
@@ -509,6 +529,9 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
509
529
  const threshold = typeof validation?.duplicateThreshold === "number"
510
530
  ? validation.duplicateThreshold
511
531
  : DEFAULT_DUPLICATE_THRESHOLD;
532
+ const hardBlockThreshold = typeof validation?.duplicateHardBlockThreshold === "number"
533
+ ? validation.duplicateHardBlockThreshold
534
+ : DEFAULT_HARD_BLOCK_THRESHOLD;
512
535
  let candidates;
513
536
  try {
514
537
  candidates = await issuesService.searchIssues({
@@ -541,14 +564,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
541
564
  if (matches.length === 0) {
542
565
  return;
543
566
  }
544
- // The gate would fire. Record the decision so `el-telemetry gates` can
545
- // compute override-rate (DEV-4834): `overridden` when the user passed
546
- // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
547
567
  const gateEvent = {
548
568
  gate: "issues-create-dup",
549
569
  topScore: matches[0].score,
550
570
  candidateCount: matches.length,
551
571
  };
572
+ // DEV-5590: below the hard-block floor, warn and proceed — never forces a
573
+ // stop, so there's nothing to "override". Recorded as `advisory` (visible
574
+ // in the raw ledger, excluded from the override-rate metric).
575
+ if (matches[0].score < hardBlockThreshold) {
576
+ await emitGateEvent("el-linear", "issues create", {
577
+ ...gateEvent,
578
+ outcome: "advisory",
579
+ });
580
+ outputWarning(formatDuplicateBlock(matches, "advisory"));
581
+ return;
582
+ }
583
+ // The gate would hard-block. Record the decision so `el-telemetry gates`
584
+ // can compute override-rate (DEV-4834): `overridden` when the user passed
585
+ // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
552
586
  if (options.allowDuplicate) {
553
587
  await emitGateEvent("el-linear", "issues create", {
554
588
  ...gateEvent,
@@ -923,6 +957,12 @@ async function handleUpdateIssue(issueId, options, command) {
923
957
  if (options.descriptionFile) {
924
958
  options.description = readDescriptionFile(options.descriptionFile);
925
959
  }
960
+ else if (typeof options.description === "string") {
961
+ options.description = normalizeInlineTextInput(options.description);
962
+ }
963
+ if (typeof options.appendDescription === "string") {
964
+ options.appendDescription = normalizeInlineTextInput(options.appendDescription);
965
+ }
926
966
  validateUpdateOptions(options);
927
967
  const rootOpts = getRootOpts(command);
928
968
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
@@ -1204,6 +1244,7 @@ export function setupIssuesCommands(program) {
1204
1244
  .command("list")
1205
1245
  .description("List issues.")
1206
1246
  .option("-l, --limit <number>", "limit results", "25")
1247
+ .option("--search <query>", "full-text search term; composes with list filters")
1207
1248
  .option("--team <team>", "filter by team key (EL: resolves names)")
1208
1249
  .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
1209
1250
  .option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
@@ -1268,7 +1309,7 @@ export function setupIssuesCommands(program) {
1268
1309
  .option("--checkout", "create and checkout a git branch named after the issue")
1269
1310
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1270
1311
  .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
1271
- .option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
1312
+ .option("--allow-duplicate", "silence the duplicate-detection hard block and create even if a near-identical issue already exists (DEV-5590: only matters at/above the hard-block threshold — below it the gate is advisory-only and never blocks)")
1272
1313
  .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1273
1314
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1274
1315
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
9
9
  import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
10
10
  async function handleCreateLabel(name, options, command) {
11
11
  const rootOpts = getRootOpts(command);
12
- const teamId = resolveTeam(options.team);
13
12
  const graphQLService = await createGraphQLService(rootOpts);
13
+ const linearService = await createLinearService(rootOpts);
14
+ const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
14
15
  const input = { name, teamId };
15
16
  if (options.color) {
16
17
  input.color = validateHexColor(options.color);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `el-linear profile members` — direct, non-interactive alias/handle edits
3
+ * on the active profile's on-disk config — DEV-5612.
4
+ *
5
+ * `init aliases` (the interactive wizard walk) is the right tool for a
6
+ * first-time setup or a bulk pass over many users, but fixing ONE entry
7
+ * (e.g. clearing a mistaken alias, or the Linear system actor a user
8
+ * accidentally aliased before DEV-5612's wizard-side skip existed) meant
9
+ * either re-walking every user interactively or hand-editing
10
+ * `~/.config/el-linear/config.json`. These commands are the direct path:
11
+ *
12
+ * el-linear profile members list — show configured members
13
+ * el-linear profile members clear <name> — remove all aliases/handles for <name>
14
+ * el-linear profile members set <name> [options] — replace aliases/handles for <name>
15
+ *
16
+ * `<name>` is the exact Linear display name as it appears in
17
+ * `members.aliases`/`members.handles.*` values (see `profile members list`).
18
+ * These commands operate purely on the local config file — no Linear API
19
+ * call, no UUID resolution — so they also work to clean up a stale/mistaken
20
+ * entry for a user no longer in the workspace.
21
+ */
22
+ import type { Command } from "commander";
23
+ import { type WizardConfig } from "../init/shared.js";
24
+ export interface MemberListEntry {
25
+ displayName: string;
26
+ aliases: string[];
27
+ github?: string;
28
+ gitlab?: string;
29
+ }
30
+ /**
31
+ * Reconstruct a per-member view from the on-disk `members.aliases` /
32
+ * `members.handles.{github,gitlab}` maps (each `{ key: displayName }`).
33
+ * `WizardConfig`'s maps are `Record<string, string | undefined>` (DeepPartial
34
+ * over an index signature) — an `undefined` value can't happen in practice
35
+ * (JSON never round-trips one), but we guard it defensively rather than cast.
36
+ * Pure — exported for testing.
37
+ */
38
+ export declare function listMembers(config: WizardConfig): MemberListEntry[];
39
+ /** `el-linear profile members list` — exported for direct testing. */
40
+ export declare function runMembersList(): Promise<MemberListEntry[]>;
41
+ /** `el-linear profile members clear <name>` — exported for direct testing. */
42
+ export declare function runMembersClear(name: string): Promise<void>;
43
+ export interface MembersSetOptions {
44
+ aliases?: string;
45
+ github?: string;
46
+ gitlab?: string;
47
+ }
48
+ /** `el-linear profile members set <name>` — exported for direct testing. */
49
+ export declare function runMembersSet(name: string, opts: MembersSetOptions): Promise<void>;
50
+ export declare function registerMembersCommands(profile: Command): void;