@enrichlayer/el-linear 1.37.1 → 1.37.2

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)
@@ -466,7 +476,7 @@ el-linear <command> --help # detailed help for one command
466
476
  | Search | `search <query>` (semantic, cross-resource) |
467
477
  | Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
468
478
  | Escape hatch | `graphql [query]` (with `--introspect`) |
469
- | Config | `config show`, `users list`, `teams list`, `templates list` |
479
+ | Config | `config show`, `users {list, read}`, `teams list`, `templates list` |
470
480
 
471
481
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
472
482
  top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
@@ -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
@@ -7,7 +7,7 @@ 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";
@@ -477,18 +477,28 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
477
477
  /**
478
478
  * DEV-4823: create-time duplicate-detection gate. Searches the title's
479
479
  * 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.
480
+ * title-overlap, and — for a candidate at/above the hard-block threshold —
481
+ * throws, listing the matches.
482
+ *
483
+ * DEV-5590: two-tier since the single-threshold gate measured a 52.2%
484
+ * override rate (score alone didn't separate genuine duplicates from
485
+ * legitimate distinct issues — see {@link DEFAULT_HARD_BLOCK_THRESHOLD} for
486
+ * the data). A candidate at/above `duplicateThreshold` (detection floor) but
487
+ * below `duplicateHardBlockThreshold` (hard-block floor) is ADVISORY: printed
488
+ * as a warning, creation proceeds, and the fire is recorded as `advisory` —
489
+ * excluded from the override-rate denominator (only `blocked`/`overridden`
490
+ * count). At/above the hard-block floor, behavior is unchanged from DEV-4823.
482
491
  *
483
492
  * Bypassed by `--skip-validation` and
484
493
  * `config.validation.duplicateDetection: false` / `validation.enabled: false`.
485
494
  * `--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.
495
+ * would-hard-block, records an `overridden` telemetry event (DEV-4834) before
496
+ * proceeding, so `el-telemetry gates` can measure the override-rate. (A
497
+ * hard-blocked fire records `blocked`.) `--skip-validation` is a blanket
498
+ * bypass and emits nothing — it's not a gate-specific override, so counting
499
+ * it would dilute the signal. The search itself is best-effort: a network/API
500
+ * failure warns and proceeds rather than blocking legitimate issue creation
501
+ * on infra trouble.
492
502
  */
493
503
  async function enforceNoDuplicateIssue(title, options, issuesService) {
494
504
  if (options.skipValidation) {
@@ -509,6 +519,9 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
509
519
  const threshold = typeof validation?.duplicateThreshold === "number"
510
520
  ? validation.duplicateThreshold
511
521
  : DEFAULT_DUPLICATE_THRESHOLD;
522
+ const hardBlockThreshold = typeof validation?.duplicateHardBlockThreshold === "number"
523
+ ? validation.duplicateHardBlockThreshold
524
+ : DEFAULT_HARD_BLOCK_THRESHOLD;
512
525
  let candidates;
513
526
  try {
514
527
  candidates = await issuesService.searchIssues({
@@ -541,14 +554,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
541
554
  if (matches.length === 0) {
542
555
  return;
543
556
  }
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
557
  const gateEvent = {
548
558
  gate: "issues-create-dup",
549
559
  topScore: matches[0].score,
550
560
  candidateCount: matches.length,
551
561
  };
562
+ // DEV-5590: below the hard-block floor, warn and proceed — never forces a
563
+ // stop, so there's nothing to "override". Recorded as `advisory` (visible
564
+ // in the raw ledger, excluded from the override-rate metric).
565
+ if (matches[0].score < hardBlockThreshold) {
566
+ await emitGateEvent("el-linear", "issues create", {
567
+ ...gateEvent,
568
+ outcome: "advisory",
569
+ });
570
+ outputWarning(formatDuplicateBlock(matches, "advisory"));
571
+ return;
572
+ }
573
+ // The gate would hard-block. Record the decision so `el-telemetry gates`
574
+ // can compute override-rate (DEV-4834): `overridden` when the user passed
575
+ // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
552
576
  if (options.allowDuplicate) {
553
577
  await emitGateEvent("el-linear", "issues create", {
554
578
  ...gateEvent,
@@ -1268,7 +1292,7 @@ export function setupIssuesCommands(program) {
1268
1292
  .option("--checkout", "create and checkout a git branch named after the issue")
1269
1293
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1270
1294
  .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")
1295
+ .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
1296
  .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1273
1297
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1274
1298
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
@@ -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;
@@ -0,0 +1,132 @@
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 { outputSuccess } from "../../utils/output.js";
23
+ import { applyMemberAliasUpdate } from "../init/aliases.js";
24
+ import { parseCsvList, readConfig, updateConfig, } from "../init/shared.js";
25
+ /**
26
+ * Reconstruct a per-member view from the on-disk `members.aliases` /
27
+ * `members.handles.{github,gitlab}` maps (each `{ key: displayName }`).
28
+ * `WizardConfig`'s maps are `Record<string, string | undefined>` (DeepPartial
29
+ * over an index signature) — an `undefined` value can't happen in practice
30
+ * (JSON never round-trips one), but we guard it defensively rather than cast.
31
+ * Pure — exported for testing.
32
+ */
33
+ export function listMembers(config) {
34
+ const aliases = config.members?.aliases ?? {};
35
+ const github = config.members?.handles?.github ?? {};
36
+ const gitlab = config.members?.handles?.gitlab ?? {};
37
+ const byName = new Map();
38
+ const ensure = (name) => {
39
+ let entry = byName.get(name);
40
+ if (!entry) {
41
+ entry = { displayName: name, aliases: [] };
42
+ byName.set(name, entry);
43
+ }
44
+ return entry;
45
+ };
46
+ for (const [alias, name] of Object.entries(aliases)) {
47
+ if (name === undefined)
48
+ continue;
49
+ ensure(name).aliases.push(alias);
50
+ }
51
+ for (const [handle, name] of Object.entries(github)) {
52
+ if (name === undefined)
53
+ continue;
54
+ ensure(name).github = handle;
55
+ }
56
+ for (const [handle, name] of Object.entries(gitlab)) {
57
+ if (name === undefined)
58
+ continue;
59
+ ensure(name).gitlab = handle;
60
+ }
61
+ return [...byName.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
62
+ }
63
+ /**
64
+ * Translate a `--github`/`--gitlab` flag value into a HandleAction:
65
+ * - flag absent → keep (leave any existing handle alone)
66
+ * - flag present, empty → clear
67
+ * - flag present, value → set
68
+ */
69
+ function handleActionFromFlag(raw) {
70
+ if (raw === undefined)
71
+ return { kind: "keep" };
72
+ const trimmed = raw.trim();
73
+ return trimmed ? { kind: "set", value: trimmed } : { kind: "clear" };
74
+ }
75
+ /** `el-linear profile members list` — exported for direct testing. */
76
+ export async function runMembersList() {
77
+ const config = await readConfig();
78
+ return listMembers(config);
79
+ }
80
+ /** `el-linear profile members clear <name>` — exported for direct testing. */
81
+ export async function runMembersClear(name) {
82
+ await updateConfig((current) => applyMemberAliasUpdate(current, name, {
83
+ mode: "clear",
84
+ aliases: [],
85
+ github: { kind: "clear" },
86
+ gitlab: { kind: "clear" },
87
+ }));
88
+ }
89
+ /** `el-linear profile members set <name>` — exported for direct testing. */
90
+ export async function runMembersSet(name, opts) {
91
+ if (opts.aliases === undefined &&
92
+ opts.github === undefined &&
93
+ opts.gitlab === undefined) {
94
+ throw new Error("Pass at least one of --aliases, --github, --gitlab.");
95
+ }
96
+ await updateConfig((current) => applyMemberAliasUpdate(current, name, {
97
+ mode: opts.aliases !== undefined ? "edit" : "keep",
98
+ aliases: opts.aliases !== undefined ? parseCsvList(opts.aliases) : [],
99
+ github: handleActionFromFlag(opts.github),
100
+ gitlab: handleActionFromFlag(opts.gitlab),
101
+ }));
102
+ }
103
+ export function registerMembersCommands(profile) {
104
+ const members = profile
105
+ .command("members")
106
+ .description("Directly edit or clear a member's aliases/handles without hand-editing config.json (DEV-5612).");
107
+ members.action(() => members.help());
108
+ members
109
+ .command("list")
110
+ .description("List members with configured aliases/GitHub/GitLab handles.")
111
+ .action(async () => {
112
+ const data = await runMembersList();
113
+ outputSuccess({ data, meta: { count: data.length } });
114
+ });
115
+ members
116
+ .command("clear <name>")
117
+ .description("Remove all aliases + GitHub/GitLab handles for <name> (exact display name, see `members list`).")
118
+ .action(async (name) => {
119
+ await runMembersClear(name);
120
+ outputSuccess({ data: { cleared: name } });
121
+ });
122
+ members
123
+ .command("set <name>")
124
+ .description("Replace aliases/handles for <name> (exact display name). Omit a flag to leave that field unchanged; pass an empty value to clear just that field.")
125
+ .option("--aliases <csv>", "comma-separated aliases; replaces the existing set for this member")
126
+ .option("--github <handle>", "GitHub handle; pass an empty string to clear")
127
+ .option("--gitlab <handle>", "GitLab handle; pass an empty string to clear")
128
+ .action(async (name, opts) => {
129
+ await runMembersSet(name, opts);
130
+ outputSuccess({ data: { updated: name } });
131
+ });
132
+ }
@@ -26,6 +26,7 @@ import { confirm } from "@inquirer/prompts";
26
26
  import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, readActiveProfileMarker, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
27
27
  import { outputSuccess, outputWarning } from "../utils/output.js";
28
28
  import { runFullWizard } from "./init/index.js";
29
+ import { registerMembersCommands } from "./profile/members.js";
29
30
  import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
30
31
  export function setupProfileCommands(program) {
31
32
  const profile = program
@@ -79,6 +80,9 @@ export function setupProfileCommands(program) {
79
80
  // self-contained and unit-testable without dragging in the full
80
81
  // profile-management surface.
81
82
  registerMigrateLegacy(profile);
83
+ // `el-linear profile members {list,clear,set}` — DEV-5612: direct,
84
+ // non-interactive alias/handle edits without hand-editing config.json.
85
+ registerMembersCommands(profile);
82
86
  }
83
87
  export async function runProfileList() {
84
88
  const active = resolveActiveProfile();
@@ -5,6 +5,15 @@ import { parsePositiveInt } from "../utils/validators.js";
5
5
  export function setupUsersCommands(program) {
6
6
  const users = program.command("users").description("User operations");
7
7
  users.action(() => users.help());
8
+ users
9
+ .command("read <id>")
10
+ .description("Look up a single user by UUID, email, or name (resolves ambiguity the same way as other --assignee-style lookups).")
11
+ .action(handleAsyncCommand(async (id, _options, command) => {
12
+ const rootOpts = getRootOpts(command);
13
+ const service = await createLinearService(rootOpts);
14
+ const result = await service.getUser(id);
15
+ outputSuccess({ data: result });
16
+ }));
8
17
  users
9
18
  .command("list")
10
19
  .description("List all users")
@@ -69,6 +69,17 @@ export interface ElLinearConfig {
69
69
  * `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
70
70
  */
71
71
  duplicateThreshold?: number;
72
+ /**
73
+ * Jaccard title-similarity threshold (0-1) above which a duplicate
74
+ * candidate HARD blocks creation (throws, requires `--allow-duplicate`).
75
+ * A candidate scoring at/above `duplicateThreshold` but below this is
76
+ * advisory only — printed, creation proceeds. Defaults to
77
+ * `DEFAULT_HARD_BLOCK_THRESHOLD` (0.6). See DEV-5590 for why the gate
78
+ * is two-tier: single-threshold override-rate telemetry showed score
79
+ * alone doesn't separate genuine duplicates from legitimate distinct
80
+ * issues in the 0.35-0.6 range.
81
+ */
82
+ duplicateHardBlockThreshold?: number;
72
83
  /**
73
84
  * OPT-IN SOP-label parent gate (DEV-5378). When `true`, `issues create`
74
85
  * requires an issue carrying an SOP-type label (see `sopLabels`) to point
@@ -32,6 +32,44 @@ import type { LinearIssue } from "../types/linear.js";
32
32
  * Overridable via `config.validation.duplicateThreshold`.
33
33
  */
34
34
  export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
35
+ /**
36
+ * Threshold above which a candidate is a HARD block (creation refuses without
37
+ * `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
38
+ * a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
39
+ *
40
+ * Why a second threshold instead of just retuning the first: `el-telemetry
41
+ * gates` measured a 52.2% override rate on the single-threshold (0.35) gate
42
+ * over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
43
+ * fire, no title text is ever recorded — el-linear collects nothing by
44
+ * default) falsifies the obvious fix of "just raise the threshold": mean/
45
+ * median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
46
+ * fires (0.421 / 0.40) are statistically indistinguishable, and simulating
47
+ * every cutoff from 0.35 to 0.56 against the real corpus held the override
48
+ * rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
49
+ * not separate genuine duplicates from legitimate distinct issues in this
50
+ * workspace's real usage; no single threshold in the observed range is
51
+ * better than a coin flip.
52
+ *
53
+ * Given that, blocking hard on a weak signal is worse than not blocking at
54
+ * all: over half the stops were wrong. 0.6 sits above the entire analyzed
55
+ * range (only 1/144 historical fires scored this high) and is reserved for
56
+ * near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
57
+ * combined salient tokens are shared, which is a qualitatively different
58
+ * (and much rarer) signal than the "shares a few topical words" fires that
59
+ * dominate the false-positive population. This preserves a real backstop for
60
+ * the obvious copy-paste case while no longer forcing a stop-and-override
61
+ * ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
62
+ * about as often as we're right.
63
+ *
64
+ * Caveat for future tuning: this corpus has no title text, so it cannot
65
+ * validate a *tokenization* fix (further stopwording per DEV-4830) — only a
66
+ * threshold-shape fix. A real precision improvement (distinguishing WHICH
67
+ * 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
68
+ * not collected. Revisit if `el-telemetry gates` after this ships still shows
69
+ * an unhealthy override rate on the >=0.6 hard-block tier specifically.
70
+ * Overridable via `config.validation.duplicateHardBlockThreshold`.
71
+ */
72
+ export declare const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
35
73
  /** A scored duplicate candidate, ready to print in the block. */
36
74
  export interface DuplicateCandidate {
37
75
  identifier: string;
@@ -71,6 +109,12 @@ export declare function scoreDuplicateCandidates(title: string, candidates: Line
71
109
  /**
72
110
  * Render the human/agent-facing block listing duplicate candidates, matching
73
111
  * the shape of the validation "Suggestions:" blocks (id · title · state ·
74
- * assignee). Used as the body of the thrown error when the gate fires.
112
+ * assignee).
113
+ *
114
+ * `mode: "block"` (default) is the body of the thrown error when the gate
115
+ * hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
116
+ * "advisory"` is printed as a warning when the score is below the hard-block
117
+ * threshold: creation already proceeded, so the trailing hint differs (no
118
+ * "re-run" — there's nothing to re-run).
75
119
  */
76
- export declare function formatDuplicateBlock(candidates: DuplicateCandidate[]): string;
120
+ export declare function formatDuplicateBlock(candidates: DuplicateCandidate[], mode?: "block" | "advisory"): string;
@@ -31,6 +31,44 @@
31
31
  * Overridable via `config.validation.duplicateThreshold`.
32
32
  */
33
33
  export const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
34
+ /**
35
+ * Threshold above which a candidate is a HARD block (creation refuses without
36
+ * `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
37
+ * a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
38
+ *
39
+ * Why a second threshold instead of just retuning the first: `el-telemetry
40
+ * gates` measured a 52.2% override rate on the single-threshold (0.35) gate
41
+ * over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
42
+ * fire, no title text is ever recorded — el-linear collects nothing by
43
+ * default) falsifies the obvious fix of "just raise the threshold": mean/
44
+ * median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
45
+ * fires (0.421 / 0.40) are statistically indistinguishable, and simulating
46
+ * every cutoff from 0.35 to 0.56 against the real corpus held the override
47
+ * rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
48
+ * not separate genuine duplicates from legitimate distinct issues in this
49
+ * workspace's real usage; no single threshold in the observed range is
50
+ * better than a coin flip.
51
+ *
52
+ * Given that, blocking hard on a weak signal is worse than not blocking at
53
+ * all: over half the stops were wrong. 0.6 sits above the entire analyzed
54
+ * range (only 1/144 historical fires scored this high) and is reserved for
55
+ * near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
56
+ * combined salient tokens are shared, which is a qualitatively different
57
+ * (and much rarer) signal than the "shares a few topical words" fires that
58
+ * dominate the false-positive population. This preserves a real backstop for
59
+ * the obvious copy-paste case while no longer forcing a stop-and-override
60
+ * ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
61
+ * about as often as we're right.
62
+ *
63
+ * Caveat for future tuning: this corpus has no title text, so it cannot
64
+ * validate a *tokenization* fix (further stopwording per DEV-4830) — only a
65
+ * threshold-shape fix. A real precision improvement (distinguishing WHICH
66
+ * 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
67
+ * not collected. Revisit if `el-telemetry gates` after this ships still shows
68
+ * an unhealthy override rate on the >=0.6 hard-block tier specifically.
69
+ * Overridable via `config.validation.duplicateHardBlockThreshold`.
70
+ */
71
+ export const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
34
72
  /**
35
73
  * Function words and issue-boilerplate tokens that carry no topical signal.
36
74
  * Dropping them keeps the Jaccard score driven by the distinctive nouns
@@ -174,14 +212,26 @@ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_
174
212
  /**
175
213
  * Render the human/agent-facing block listing duplicate candidates, matching
176
214
  * the shape of the validation "Suggestions:" blocks (id · title · state ·
177
- * assignee). Used as the body of the thrown error when the gate fires.
215
+ * assignee).
216
+ *
217
+ * `mode: "block"` (default) is the body of the thrown error when the gate
218
+ * hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
219
+ * "advisory"` is printed as a warning when the score is below the hard-block
220
+ * threshold: creation already proceeded, so the trailing hint differs (no
221
+ * "re-run" — there's nothing to re-run).
178
222
  */
179
- export function formatDuplicateBlock(candidates) {
223
+ export function formatDuplicateBlock(candidates, mode = "block") {
180
224
  const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
181
- return (`Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
225
+ const header = `Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
182
226
  "(by title-keyword overlap):\n" +
183
227
  `${lines.join("\n")}\n\n` +
184
- " If one of these is the same work, comment on it instead of creating a new issue.\n" +
228
+ " If one of these is the same work, comment on it instead of creating a new issue.\n";
229
+ if (mode === "advisory") {
230
+ return (header +
231
+ " This is advisory only (DEV-5590) — creation is proceeding. Pass " +
232
+ "--allow-duplicate to silence this notice next time.");
233
+ }
234
+ return (header +
185
235
  " If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
186
236
  "(and consider --related-to to link the related issue).");
187
237
  }
@@ -29,9 +29,15 @@ export interface GateEvent {
29
29
  * `blocked` — the gate stopped creation. `overridden` — a gate-specific
30
30
  * override flag let a would-block proceed. `fail-open` — the gate could not
31
31
  * evaluate (infra/service error) and let creation proceed rather than block
32
- * on trouble; tracked so degradation is measurable (DEV-5378).
32
+ * on trouble; tracked so degradation is measurable (DEV-5378). `advisory` —
33
+ * the gate fired below its hard-block threshold: printed, but never forced
34
+ * a stop (DEV-5590). Like `fail-open`, the tools-repo reader
35
+ * (`el-telemetry gates`) only recognizes `blocked`/`overridden` for the
36
+ * override-rate denominator — an unrecognized outcome is skipped rather
37
+ * than counted, so `advisory` fires are visible in the raw ledger but
38
+ * intentionally excluded from the metric.
33
39
  */
34
- outcome: "blocked" | "overridden" | "fail-open";
40
+ outcome: "blocked" | "overridden" | "fail-open" | "advisory";
35
41
  /** Highest candidate similarity that triggered the gate (0–1). */
36
42
  topScore?: number;
37
43
  /** How many candidates crossed the threshold. */
@@ -14,6 +14,14 @@ export declare class LinearService {
14
14
  resolveIssueId(issueId: string): Promise<string>;
15
15
  getTeams(limit?: number): Promise<LinearTeam[]>;
16
16
  resolveUserId(nameOrEmailOrId: string): Promise<string>;
17
+ /**
18
+ * Single-user lookup by UUID, email, or name (DEV-5612) — the natural
19
+ * complement to `getUsers`/`users list`, so identifying one actor (e.g. an
20
+ * unrecognized member surfaced by the alias wizard) doesn't require
21
+ * dumping the whole workspace. Resolution/ambiguity semantics match every
22
+ * other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
23
+ */
24
+ getUser(nameOrEmailOrId: string): Promise<LinearUser>;
17
25
  getUsers(activeOnly?: boolean, limit?: number, nameFilter?: string): Promise<LinearUser[]>;
18
26
  getProjects(limit?: number, options?: {
19
27
  nameFilter?: string;
@@ -99,6 +99,24 @@ export class LinearService {
99
99
  }
100
100
  throw notFoundError("User", nameOrEmailOrId);
101
101
  }
102
+ /**
103
+ * Single-user lookup by UUID, email, or name (DEV-5612) — the natural
104
+ * complement to `getUsers`/`users list`, so identifying one actor (e.g. an
105
+ * unrecognized member surfaced by the alias wizard) doesn't require
106
+ * dumping the whole workspace. Resolution/ambiguity semantics match every
107
+ * other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
108
+ */
109
+ async getUser(nameOrEmailOrId) {
110
+ const id = await this.resolveUserId(nameOrEmailOrId);
111
+ const user = await this.client.user(id);
112
+ return {
113
+ id: user.id,
114
+ name: user.name,
115
+ displayName: user.displayName,
116
+ email: user.email,
117
+ active: user.active,
118
+ };
119
+ }
102
120
  async getUsers(activeOnly, limit = 100, nameFilter) {
103
121
  const filter = {};
104
122
  if (activeOnly) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.37.1",
3
+ "version": "1.37.2",
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",