@enrichlayer/el-linear 1.37.0 → 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.
@@ -23,9 +23,10 @@
23
23
  import { promises as fsp } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { confirm } from "@inquirer/prompts";
26
- import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
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();
@@ -120,8 +124,27 @@ export async function runProfileUse(name) {
120
124
  if (!isSafeName(trimmed)) {
121
125
  throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
122
126
  }
127
+ // DEV-5610: `active-profile` is a single, machine-global marker — every
128
+ // el-linear invocation anywhere on this machine that doesn't set
129
+ // --profile/$EL_LINEAR_PROFILE resolves through it. Warn loudly on an
130
+ // actual switch so the blast radius is visible instead of silently
131
+ // redirecting some other concurrent session/tool to a different
132
+ // workspace. `--profile`/$EL_LINEAR_PROFILE remain the correct,
133
+ // non-mutating choice for automation and concurrent sessions.
134
+ //
135
+ // Read the raw marker (readActiveProfileMarker), not
136
+ // resolveActiveProfile().name — the latter resolves through the
137
+ // --profile/$EL_LINEAR_PROFILE precedence layers too, so a concurrent
138
+ // override in place while `profile use` runs would make it report the
139
+ // wrong "previous" value (a false-positive warning when the marker
140
+ // didn't actually change, or a false-negative when it did) — cycle-1
141
+ // review finding on PR #229.
142
+ const previous = readActiveProfileMarker();
123
143
  await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
124
144
  await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
145
+ if (previous !== trimmed) {
146
+ outputWarning(`Switched the GLOBAL active profile to "${trimmed}" (${ACTIVE_PROFILE_FILE}). This affects every other el-linear invocation on this machine that doesn't set --profile/$EL_LINEAR_PROFILE explicitly — concurrent sessions/tools relying on a different profile will silently start hitting the wrong workspace.`);
147
+ }
125
148
  }
126
149
  export async function runProfileAdd(name) {
127
150
  const trimmed = name.trim();
@@ -6,7 +6,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
9
- import { getRootOpts } from "../utils/root-opts.js";
9
+ import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
10
10
  import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
11
11
  import { isUuid } from "../utils/uuid.js";
12
12
  import { parsePositiveInt, splitList } from "../utils/validators.js";
@@ -516,7 +516,11 @@ export function setupProjectsCommands(program) {
516
516
  // ahead of completed/canceled in every format, so the active
517
517
  // set always fits within `--limit`. DEV-4175.
518
518
  const sorted = sortActiveFirst(result);
519
- const format = options.format;
519
+ // Resolved via effectiveOption because commander 15 hands the
520
+ // CLI token to the root program's same-named --format/--fields
521
+ // registration, leaving the subcommand's option at its default
522
+ // (DEV-5376).
523
+ const format = effectiveOption(command, "format") ?? "json";
520
524
  const isTabular = format === "table" ||
521
525
  format === "md" ||
522
526
  format === "markdown" ||
@@ -530,9 +534,8 @@ export function setupProjectsCommands(program) {
530
534
  warnProjectsTruncated(sorted.length, limit, format);
531
535
  }
532
536
  if (isTabular) {
533
- const fieldList = options.fields
534
- ? splitList(options.fields)
535
- : undefined;
537
+ const fields = effectiveOption(command, "fields");
538
+ const fieldList = fields ? splitList(fields) : undefined;
536
539
  formatProjectsOutput(sorted, format, fieldList);
537
540
  if (format === "table") {
538
541
  logger.info(`\n${sorted.length} projects`);
@@ -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
@@ -51,3 +51,12 @@ export declare function getSessionProfileOverride(): string | null;
51
51
  export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv, fsImpl?: ProfileFsOps): ProfilePaths;
52
52
  /** Build profile-relative paths for a named profile. Pure. */
53
53
  export declare function profilePaths(name: string): ProfilePaths;
54
+ /**
55
+ * Read the raw on-disk marker content, bypassing the `--profile`/
56
+ * `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
57
+ * Exported (DEV-5610) so `profile use` can detect whether *the file itself*
58
+ * is about to change, independent of any per-invocation override that might
59
+ * otherwise make `resolveActiveProfile().name` report a different "previous"
60
+ * value than what's actually on disk.
61
+ */
62
+ export declare function readActiveProfileMarker(fsImpl?: ProfileFsOps): string | null;
@@ -127,7 +127,15 @@ export function profilePaths(name) {
127
127
  tokenPath: path.join(dir, "token"),
128
128
  };
129
129
  }
130
- function readActiveProfileMarker(fsImpl) {
130
+ /**
131
+ * Read the raw on-disk marker content, bypassing the `--profile`/
132
+ * `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
133
+ * Exported (DEV-5610) so `profile use` can detect whether *the file itself*
134
+ * is about to change, independent of any per-invocation override that might
135
+ * otherwise make `resolveActiveProfile().name` report a different "previous"
136
+ * value than what's actually on disk.
137
+ */
138
+ export function readActiveProfileMarker(fsImpl = DEFAULT_FS_OPS) {
131
139
  if (!fsImpl.existsSync(ACTIVE_PROFILE_FILE))
132
140
  return null;
133
141
  try {
package/dist/main.js CHANGED
@@ -70,7 +70,9 @@ program
70
70
  "Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
71
71
  "they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
72
72
  .option("--jq <filter>", "apply a jq filter to the JSON output")
73
- .option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
73
+ .option("--fields <fields>", "filter output to specific fields (comma-separated). Dot-paths resolve nested " +
74
+ "values; the column aliases status (state.name) and updated (updatedAt) " +
75
+ "resolve too. Unresolved fields are " +
74
76
  "emitted as null plus a `fields_unresolved:` warning — in the JSON " +
75
77
  "envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
76
78
  .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
@@ -153,6 +153,17 @@ export interface GetIssuesResponse {
153
153
  nodes: IssueNode[];
154
154
  };
155
155
  }
156
+ /**
157
+ * Response shape for `TEAM_SCOPED_FILTERED_ISSUES_QUERY` (DEV-5578).
158
+ * `team` is null when the resolved team UUID does not exist.
159
+ */
160
+ export interface TeamScopedFilteredIssuesResponse {
161
+ team: {
162
+ issues: {
163
+ nodes: IssueNode[];
164
+ };
165
+ } | null;
166
+ }
156
167
  /** Response shape for `SEARCH_ISSUES_QUERY` (full-text). */
157
168
  export interface SearchIssuesResponse {
158
169
  searchIssues: {
@@ -1,6 +1,25 @@
1
1
  export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
2
2
  export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
3
3
  export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
4
+ /**
5
+ * Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
6
+ *
7
+ * When a `--team` filter is present, the team boundary is applied
8
+ * *structurally* — the `issues` connection is rooted at the `Team` node
9
+ * (`team(id: $teamId).issues(...)`) rather than passed as a top-level
10
+ * `issues(filter: { team: { id: { eq } } })` relation filter.
11
+ *
12
+ * The top-level relation-filter form is unreliable at scale: Linear's API
13
+ * silently leaks issues from *other* teams once `$first` grows past a small
14
+ * page (~20), so `issues list --team DEV --limit 100` returned issues from
15
+ * EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
16
+ * `projects list --team` (`ProjectFilter` had no working `teams` relation, so
17
+ * project scoping moved to `Team.projects`). Rooting at the team node keeps
18
+ * the team boundary server-side and exact. The remaining `$filter`
19
+ * (state / labels / assignee / priority / project) is applied on top of the
20
+ * already-team-scoped connection.
21
+ */
22
+ export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
4
23
  /**
5
24
  * Batch-resolves a search's team/project/assignee/delegate filter inputs.
6
25
  *
@@ -41,6 +41,45 @@ export const FILTERED_SEARCH_ISSUES_QUERY = `
41
41
  }
42
42
  }
43
43
  `;
44
+ /**
45
+ * Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
46
+ *
47
+ * When a `--team` filter is present, the team boundary is applied
48
+ * *structurally* — the `issues` connection is rooted at the `Team` node
49
+ * (`team(id: $teamId).issues(...)`) rather than passed as a top-level
50
+ * `issues(filter: { team: { id: { eq } } })` relation filter.
51
+ *
52
+ * The top-level relation-filter form is unreliable at scale: Linear's API
53
+ * silently leaks issues from *other* teams once `$first` grows past a small
54
+ * page (~20), so `issues list --team DEV --limit 100` returned issues from
55
+ * EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
56
+ * `projects list --team` (`ProjectFilter` had no working `teams` relation, so
57
+ * project scoping moved to `Team.projects`). Rooting at the team node keeps
58
+ * the team boundary server-side and exact. The remaining `$filter`
59
+ * (state / labels / assignee / priority / project) is applied on top of the
60
+ * already-team-scoped connection.
61
+ */
62
+ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
63
+ query TeamScopedFilteredIssues(
64
+ $teamId: String!
65
+ $first: Int!
66
+ $filter: IssueFilter
67
+ $orderBy: PaginationOrderBy
68
+ ) {
69
+ team(id: $teamId) {
70
+ issues(
71
+ first: $first
72
+ filter: $filter
73
+ orderBy: $orderBy
74
+ includeArchived: false
75
+ ) {
76
+ nodes {
77
+ ${COMPLETE_ISSUE_FRAGMENT}
78
+ }
79
+ }
80
+ }
81
+ }
82
+ `;
44
83
  /**
45
84
  * Batch-resolves a search's team/project/assignee/delegate filter inputs.
46
85
  *
@@ -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. */
@@ -1,6 +1,6 @@
1
1
  import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
2
2
  import { resolveUserDisplayName } from "../config/resolver.js";
3
- import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
+ 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";
4
4
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
5
5
  import { TERMINAL_STATE_TYPES } from "../types/linear.js";
6
6
  import { toISOStringOrNow, toISOStringOrUndefined } from "./date-format.js";
@@ -556,8 +556,14 @@ export class GraphQLIssuesService {
556
556
  priority: args.priority,
557
557
  });
558
558
  }
559
+ // DEV-5578: the team boundary is NOT put in the IssueFilter. Linear's
560
+ // top-level `issues(filter: { team: { id: { eq } } })` relation filter
561
+ // leaks other teams' issues once `first` grows past a small page (~20),
562
+ // so `--team` was silently contaminated on paginated (`--limit > ~20`)
563
+ // results. When a team is present we scope structurally through the
564
+ // `Team.issues` connection below (same class of fix as DEV-5325's
565
+ // `Team.projects`); the rest of the filter rides on top of it.
559
566
  const filter = this.buildSearchFilter({
560
- teamId: finalTeamId,
561
567
  assigneeId: finalAssigneeId,
562
568
  delegateId: finalDelegateId,
563
569
  project: projectFilter,
@@ -566,10 +572,25 @@ export class GraphQLIssuesService {
566
572
  labelNames: args.labelNames,
567
573
  priority: args.priority,
568
574
  });
575
+ const filterArg = Object.keys(filter).length > 0 ? filter : undefined;
576
+ const orderBy = args.orderBy ?? "updatedAt";
577
+ if (finalTeamId) {
578
+ const teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
579
+ teamId: finalTeamId,
580
+ first: limit,
581
+ filter: filterArg,
582
+ orderBy,
583
+ });
584
+ const nodes = teamScoped.team?.issues?.nodes;
585
+ if (!nodes?.length) {
586
+ return [];
587
+ }
588
+ return nodes.map((issue) => this.transformIssueData(issue));
589
+ }
569
590
  const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
570
591
  first: limit,
571
- filter: Object.keys(filter).length > 0 ? filter : undefined,
572
- orderBy: args.orderBy ?? "updatedAt",
592
+ filter: filterArg,
593
+ orderBy,
573
594
  });
574
595
  const filteredIssues = searchResult.issues;
575
596
  if (!filteredIssues?.nodes) {
@@ -999,11 +1020,12 @@ export class GraphQLIssuesService {
999
1020
  }
1000
1021
  return filtered;
1001
1022
  }
1023
+ // DEV-5578: `teamId` is intentionally NOT a member — the team boundary is
1024
+ // applied structurally via `TEAM_SCOPED_FILTERED_ISSUES_QUERY`, never as a
1025
+ // top-level `team` relation filter (which Linear leaks past ~20 rows). Do
1026
+ // not reintroduce a `filter.team = …` branch here.
1002
1027
  buildSearchFilter(filters) {
1003
1028
  const filter = {};
1004
- if (filters.teamId) {
1005
- filter.team = { id: { eq: filters.teamId } };
1006
- }
1007
1029
  if (filters.assigneeId) {
1008
1030
  filter.assignee = { id: { eq: filters.assigneeId } };
1009
1031
  }
@@ -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) {
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { resolveActiveProfile } from "../config/paths.js";
2
3
  import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
3
4
  import { logger } from "./logger.js";
4
5
  import { sanitizeForLog } from "./sanitize-for-log.js";
@@ -57,6 +58,19 @@ function getNestedPath(obj, path) {
57
58
  }
58
59
  return cur;
59
60
  }
61
+ /**
62
+ * Aliases for the table/csv column names that don't exist as literal keys
63
+ * on the JSON payload (DEV-5376). Tried only after the literal key and the
64
+ * dot-path both miss, and only kept when the alias target actually
65
+ * resolves — so a resource that genuinely lacks `state`/`updatedAt` still
66
+ * gets the explicit-null + `fields_unresolved` treatment. The requested
67
+ * name stays the output key (`{"status": "Todo"}`), matching the
68
+ * "consumers read exactly what they asked for" contract.
69
+ */
70
+ const FIELD_ALIASES = {
71
+ status: "state.name",
72
+ updated: "updatedAt",
73
+ };
60
74
  /**
61
75
  * Project `obj` down to the requested fields.
62
76
  *
@@ -64,6 +78,8 @@ function getNestedPath(obj, path) {
64
78
  * - Dot-separated paths (DEV-5323) resolve nested values; the requested path
65
79
  * string becomes a flat output key (`{"pipeline.status": "success"}`), so
66
80
  * consumers read exactly what they asked for.
81
+ * - Documented column aliases resolve when the literal key is absent
82
+ * (`status` → `state.name`, `updated` → `updatedAt`; DEV-5376).
67
83
  * - A field that resolves nowhere is emitted as an explicit `null` AND
68
84
  * reported via `unresolved` — never silently omitted. Silent omission made
69
85
  * a typo'd field indistinguishable from an empty value (DEV-5323).
@@ -114,6 +130,12 @@ function filterFields(obj, fields, unresolved) {
114
130
  result[field] = nested;
115
131
  continue;
116
132
  }
133
+ const alias = FIELD_ALIASES[field];
134
+ const aliased = alias !== undefined ? getNestedPath(source, alias) : undefined;
135
+ if (aliased !== undefined) {
136
+ result[field] = aliased;
137
+ continue;
138
+ }
117
139
  result[field] = null;
118
140
  unresolved?.add(field);
119
141
  }
@@ -354,7 +376,25 @@ function outputError(error) {
354
376
  // `lin_oauth_…` / `Bearer <payload>` in error text can't leak a token
355
377
  // into stdout, shell history, or CI logs. The wizard already sanitizes
356
378
  // its own log paths; this is the central error path on every command.
357
- const payload = JSON.stringify({ error: sanitizeForLog(error.message) }, null, 2);
379
+ //
380
+ // `activeProfile` is included so a "not found" error is distinguishable
381
+ // from "wrong workspace" without manually inspecting
382
+ // ~/.config/el-linear/active-profile (DEV-5610) — the active profile can
383
+ // change between commands (an explicit `profile use`, a different
384
+ // $EL_LINEAR_PROFILE, or another process on the machine switching the
385
+ // shared marker file), and a bare "X not found" reads as data loss or an
386
+ // API outage when the real cause is often just the wrong workspace.
387
+ // Guarded: resolveActiveProfile() throws on an invalid $EL_LINEAR_PROFILE
388
+ // value (a real, separate user error) — outputError is the last-resort
389
+ // error path, so this addition must not itself throw uncaught.
390
+ let activeProfile = "<unknown>";
391
+ try {
392
+ activeProfile = resolveActiveProfile().name ?? "<legacy default>";
393
+ }
394
+ catch {
395
+ // leave activeProfile as "<unknown>"
396
+ }
397
+ const payload = JSON.stringify({ error: sanitizeForLog(error.message), activeProfile }, null, 2);
358
398
  // Write to stdout (same channel as success) so machine callers always
359
399
  // receive exactly one parseable JSON object regardless of stream capture.
360
400
  logger.info(payload);
@@ -9,3 +9,16 @@ import type { Command, OptionValues } from "commander";
9
9
  * assertions live here so call sites can stay clean and typed.
10
10
  */
11
11
  export declare function getRootOpts(command: Command): OptionValues;
12
+ /**
13
+ * Resolve an option that is registered on BOTH the root program and a
14
+ * subcommand (`--format` / `--fields` on `issues list|search`,
15
+ * `projects list`).
16
+ *
17
+ * Commander 15 hands a same-named option to the OUTERMOST registration
18
+ * even when it appears after the subcommand: `issues list --format table`
19
+ * sets the program's global `--format` and leaves the subcommand's local
20
+ * option at its default, so handlers reading `options.format` silently
21
+ * saw `"json"` (DEV-5376). Precedence here: local CLI-set value, then the
22
+ * nearest ancestor's CLI-set value, then the local default.
23
+ */
24
+ export declare function effectiveOption(command: Command, key: string): string | undefined;