@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.
@@ -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();
@@ -1,6 +1,6 @@
1
1
  import { loadConfig } from "../config/config.js";
2
2
  import { resolveTeam } from "../config/resolver.js";
3
- import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
3
+ import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_FIELDS_MUTATION, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
4
4
  import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
5
5
  import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
@@ -229,6 +229,26 @@ function formatTeamsOutput(projectUpdate) {
229
229
  })),
230
230
  };
231
231
  }
232
+ function hasOption(options, key) {
233
+ return options[key] !== undefined;
234
+ }
235
+ function flattenProjectUpdate(projectUpdate) {
236
+ if (!projectUpdate.project) {
237
+ throw new Error("Failed to update project");
238
+ }
239
+ const updatedProject = projectUpdate.project;
240
+ return {
241
+ id: updatedProject.id,
242
+ name: updatedProject.name,
243
+ description: updatedProject.description ?? undefined,
244
+ content: updatedProject.content ?? undefined,
245
+ teams: updatedProject.teams.nodes.map((t) => ({
246
+ id: t.id,
247
+ key: t.key,
248
+ name: t.name,
249
+ })),
250
+ };
251
+ }
232
252
  async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
233
253
  const rootOpts = getRootOpts(command);
234
254
  const graphQLService = await createGraphQLService(rootOpts);
@@ -437,6 +457,31 @@ async function handleReadProject(projectNameOrId, _options, command) {
437
457
  teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
438
458
  });
439
459
  }
460
+ async function handleUpdateProject(projectNameOrId, options, command) {
461
+ const input = {};
462
+ if (hasOption(options, "name")) {
463
+ input.name = options.name;
464
+ }
465
+ if (hasOption(options, "description")) {
466
+ input.description = options.description;
467
+ }
468
+ if (hasOption(options, "content")) {
469
+ input.content = options.content;
470
+ }
471
+ if (Object.keys(input).length === 0) {
472
+ throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
473
+ }
474
+ const rootOpts = getRootOpts(command);
475
+ const graphQLService = await createGraphQLService(rootOpts);
476
+ const linearService = await createLinearService(rootOpts);
477
+ const projectId = await linearService.resolveProjectId(projectNameOrId);
478
+ const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
479
+ const projectUpdate = updateResult.projectUpdate;
480
+ if (!projectUpdate.success) {
481
+ throw new Error(`Failed to update project "${projectNameOrId}"`);
482
+ }
483
+ outputSuccess(flattenProjectUpdate(projectUpdate));
484
+ }
440
485
  export function setupProjectsCommands(program) {
441
486
  const projects = program
442
487
  .command("projects")
@@ -463,6 +508,13 @@ export function setupProjectsCommands(program) {
463
508
  .command("read <project>")
464
509
  .description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
465
510
  .action(handleAsyncCommand(handleReadProject));
511
+ projects
512
+ .command("update <project>")
513
+ .description("Update project name, short description, or markdown content")
514
+ .option("--name <name>", "project name")
515
+ .option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
516
+ .option("--content <markdown>", "full markdown body (shown in project panel)")
517
+ .action(handleAsyncCommand(handleUpdateProject));
466
518
  projects
467
519
  .command("list")
468
520
  .description("List 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
@@ -77,6 +77,16 @@ export interface UpdateProjectResponse {
77
77
  project: ProjectBaseNode | null;
78
78
  };
79
79
  }
80
+ interface ProjectUpdateFieldsNode extends ProjectBaseNode {
81
+ description: string | null;
82
+ content: string | null;
83
+ }
84
+ export interface UpdateProjectFieldsResponse {
85
+ projectUpdate: {
86
+ success: boolean;
87
+ project: ProjectUpdateFieldsNode | null;
88
+ };
89
+ }
80
90
  interface ProjectArchiveEntity {
81
91
  id: string;
82
92
  }
@@ -14,5 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
14
14
  export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
15
15
  export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
16
16
  export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
17
+ export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
17
18
  export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
18
19
  export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
@@ -129,6 +129,26 @@ export const UPDATE_PROJECT_MUTATION = `
129
129
  }
130
130
  }
131
131
  `;
132
+ export const UPDATE_PROJECT_FIELDS_MUTATION = `
133
+ mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {
134
+ projectUpdate(id: $id, input: $input) {
135
+ success
136
+ project {
137
+ id
138
+ name
139
+ description
140
+ content
141
+ teams {
142
+ nodes {
143
+ id
144
+ key
145
+ name
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ `;
132
152
  export const ARCHIVE_PROJECT_MUTATION = `
133
153
  mutation ArchiveProject($id: String!) {
134
154
  projectArchive(id: $id) {
@@ -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
  }
@@ -1,3 +1,23 @@
1
+ /**
2
+ * Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
3
+ *
4
+ * The `issues create` duplicate-detection gate (DEV-4823) records each decision
5
+ * it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
6
+ * command, or any JSONL consumer) can compute the gate's override-rate
7
+ * (overridden / total) and tell whether the threshold is noisy.
8
+ *
9
+ * **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
10
+ * must never write files a user didn't ask for. Emission is therefore OFF by
11
+ * default and turns on only when telemetry is actually configured — see
12
+ * {@link decideGateLedger}. The ledger is a plain local JSONL file
13
+ * (`gate-events.jsonl`); there is no server or database. el-linear can't import
14
+ * `el-telemetry` (separate package), so it writes by **path-contract** — the
15
+ * same approach `el-hook` uses. The path mirrors `el-telemetry`'s
16
+ * `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
17
+ * `docs/telemetry.md`.
18
+ */
19
+ export declare const GATE_LEDGER_MAX_BYTES: number;
20
+ export declare const GATE_LEDGER_BACKUP_SUFFIX = ".old";
1
21
  /** Resolve where the ledger lives — for a *reader* locating the file (mirrors
2
22
  * el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
3
23
  * the opt-in policy, so never write through it — `emitGateEvent` goes through
@@ -29,9 +49,15 @@ export interface GateEvent {
29
49
  * `blocked` — the gate stopped creation. `overridden` — a gate-specific
30
50
  * override flag let a would-block proceed. `fail-open` — the gate could not
31
51
  * evaluate (infra/service error) and let creation proceed rather than block
32
- * on trouble; tracked so degradation is measurable (DEV-5378).
52
+ * on trouble; tracked so degradation is measurable (DEV-5378). `advisory` —
53
+ * the gate fired below its hard-block threshold: printed, but never forced
54
+ * a stop (DEV-5590). Like `fail-open`, the tools-repo reader
55
+ * (`el-telemetry gates`) only recognizes `blocked`/`overridden` for the
56
+ * override-rate denominator — an unrecognized outcome is skipped rather
57
+ * than counted, so `advisory` fires are visible in the raw ledger but
58
+ * intentionally excluded from the metric.
33
59
  */
34
- outcome: "blocked" | "overridden" | "fail-open";
60
+ outcome: "blocked" | "overridden" | "fail-open" | "advisory";
35
61
  /** Highest candidate similarity that triggered the gate (0–1). */
36
62
  topScore?: number;
37
63
  /** How many candidates crossed the threshold. */
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { appendFile, mkdir } from "node:fs/promises";
2
+ import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  /**
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
20
20
  * `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
21
21
  * `docs/telemetry.md`.
22
22
  */
23
+ export const GATE_LEDGER_MAX_BYTES = 2 * 1024 * 1024;
24
+ export const GATE_LEDGER_BACKUP_SUFFIX = ".old";
23
25
  /** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
24
26
  function defaultTelemetryDir() {
25
27
  return join(homedir(), ".cache", "el-telemetry");
@@ -67,6 +69,28 @@ function gateLedgerIfEnabled() {
67
69
  defaultDirExists: existsSync(defaultDir),
68
70
  });
69
71
  }
72
+ async function rotateGateLedgerIfOverLimit(path) {
73
+ let size = 0;
74
+ try {
75
+ const s = await stat(path);
76
+ if (!s.isFile())
77
+ return;
78
+ size = s.size;
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (size <= GATE_LEDGER_MAX_BYTES)
84
+ return;
85
+ try {
86
+ const backupPath = `${path}${GATE_LEDGER_BACKUP_SUFFIX}`;
87
+ await rm(backupPath, { force: true });
88
+ await rename(path, backupPath);
89
+ }
90
+ catch {
91
+ // best-effort — telemetry never blocks issue creation
92
+ }
93
+ }
70
94
  /**
71
95
  * Best-effort append of a gate event to the local ledger — but only when
72
96
  * telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
@@ -80,6 +104,7 @@ export async function emitGateEvent(name, subcommand, event) {
80
104
  }
81
105
  try {
82
106
  await mkdir(dirname(path), { recursive: true });
107
+ await rotateGateLedgerIfOverLimit(path);
83
108
  const record = {
84
109
  ts: new Date().toISOString(),
85
110
  kind: "gate",
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Normalize shell-literal newline escapes in inline CLI text fields.
3
+ *
4
+ * File inputs are intentionally excluded by call site: a file body is already
5
+ * explicit authored text and may intentionally contain backslash sequences.
6
+ */
7
+ export declare function normalizeInlineTextInput(value: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalize shell-literal newline escapes in inline CLI text fields.
3
+ *
4
+ * File inputs are intentionally excluded by call site: a file body is already
5
+ * explicit authored text and may intentionally contain backslash sequences.
6
+ */
7
+ export function normalizeInlineTextInput(value) {
8
+ return value
9
+ .replace(/\\r\\n/g, "\n")
10
+ .replace(/\\n/g, "\n")
11
+ .replace(/\\r/g, "\n");
12
+ }
@@ -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) {
@@ -41,16 +41,23 @@
41
41
  */
42
42
  export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
43
43
  /**
44
- * Build the relation-candidate confirmation warning string, or `null` when
45
- * the result set has no identifier-bearing rows (nothing to confirm).
44
+ * Build the relation-candidate warning string, or `null` when the result set
45
+ * has no identifier-bearing rows (nothing to surface).
46
46
  *
47
47
  * Shape (single line, structured-prose so a skill can match on the prefix):
48
48
  *
49
49
  * relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
50
- * To link them as related: reply with the IDs you want linked
51
- * (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
50
+ * Link the relevant ones now — at create time with --related-to, or
51
+ * `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
52
+ * agent-inferred relate, reply with the IDs you want linked
53
+ * (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
52
54
  *
53
- * The `relation_candidates:` prefix matches the existing `results_truncated:`
55
+ * DEV-5853: the primary framing is **proactive** — this is a convenience list
56
+ * of link candidates, not a stop sign. Relate the relevant ones directly
57
+ * rather than waiting to be told; the reply flow is the *fallback* for when
58
+ * the auto-mode classifier actually blocks an agent-inferred `issues relate`
59
+ * (create-time `--related-to` typically passes, so prefer it). The
60
+ * `relation_candidates:` prefix matches the existing `results_truncated:`
54
61
  * convention in `outputWarning` callers — a stable token a skill / agent
55
62
  * harness can grep for without parsing free-form prose.
56
63
  */