@enrichlayer/el-linear 1.9.0 → 1.15.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.
Files changed (135) hide show
  1. package/README.md +139 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/attachments.js +2 -1
  20. package/dist/commands/batch.js +18 -21
  21. package/dist/commands/comments.js +22 -33
  22. package/dist/commands/config.js +178 -5
  23. package/dist/commands/cycles.js +2 -1
  24. package/dist/commands/documents.js +2 -1
  25. package/dist/commands/graphql.js +4 -6
  26. package/dist/commands/init/aliases.js +1 -1
  27. package/dist/commands/init/defaults.d.ts +2 -1
  28. package/dist/commands/init/index.js +45 -35
  29. package/dist/commands/init/oauth.d.ts +4 -1
  30. package/dist/commands/init/oauth.js +22 -4
  31. package/dist/commands/init/shared.d.ts +24 -2
  32. package/dist/commands/init/shared.js +35 -4
  33. package/dist/commands/init/token.d.ts +3 -3
  34. package/dist/commands/init/token.js +5 -24
  35. package/dist/commands/init/workspace.d.ts +2 -1
  36. package/dist/commands/init/workspace.js +1 -1
  37. package/dist/commands/introspect.d.ts +27 -0
  38. package/dist/commands/introspect.js +178 -0
  39. package/dist/commands/issue-id.js +1 -3
  40. package/dist/commands/issues/branch.js +9 -1
  41. package/dist/commands/issues/description.js +2 -6
  42. package/dist/commands/issues/link-references.d.ts +21 -0
  43. package/dist/commands/issues/link-references.js +171 -0
  44. package/dist/commands/issues/relations.d.ts +44 -0
  45. package/dist/commands/issues/relations.js +132 -0
  46. package/dist/commands/issues.js +269 -309
  47. package/dist/commands/labels.js +15 -24
  48. package/dist/commands/profile.js +1 -0
  49. package/dist/commands/project-milestones.js +13 -20
  50. package/dist/commands/projects.d.ts +2 -0
  51. package/dist/commands/projects.js +157 -44
  52. package/dist/commands/read-shortcut.d.ts +1 -1
  53. package/dist/commands/read-shortcut.js +28 -8
  54. package/dist/commands/refs.js +75 -8
  55. package/dist/commands/releases.js +26 -30
  56. package/dist/commands/search.js +49 -33
  57. package/dist/commands/teams.js +2 -1
  58. package/dist/commands/templates.js +9 -14
  59. package/dist/commands/users.js +5 -2
  60. package/dist/config/config.d.ts +99 -1
  61. package/dist/config/config.js +264 -52
  62. package/dist/config/error-enrichment.d.ts +62 -0
  63. package/dist/config/error-enrichment.js +417 -0
  64. package/dist/config/issue-validation.d.ts +37 -0
  65. package/dist/config/issue-validation.js +63 -1
  66. package/dist/config/paths.d.ts +2 -8
  67. package/dist/config/paths.js +4 -2
  68. package/dist/config/resolver.d.ts +8 -1
  69. package/dist/config/resolver.js +11 -5
  70. package/dist/main.js +13 -1
  71. package/dist/queries/attachments-types.d.ts +30 -0
  72. package/dist/queries/attachments-types.js +5 -0
  73. package/dist/queries/comments-types.d.ts +55 -0
  74. package/dist/queries/comments-types.js +5 -0
  75. package/dist/queries/common.d.ts +2 -2
  76. package/dist/queries/common.js +8 -0
  77. package/dist/queries/documents-types.d.ts +62 -0
  78. package/dist/queries/documents-types.js +9 -0
  79. package/dist/queries/introspect-types.d.ts +58 -0
  80. package/dist/queries/introspect-types.js +10 -0
  81. package/dist/queries/issues-types.d.ts +481 -0
  82. package/dist/queries/issues-types.js +23 -0
  83. package/dist/queries/issues.d.ts +51 -10
  84. package/dist/queries/issues.js +147 -5
  85. package/dist/queries/labels-types.d.ts +65 -0
  86. package/dist/queries/labels-types.js +5 -0
  87. package/dist/queries/project-milestones-types.d.ts +92 -0
  88. package/dist/queries/project-milestones-types.js +10 -0
  89. package/dist/queries/project-milestones.d.ts +1 -1
  90. package/dist/queries/projects-types.d.ts +76 -0
  91. package/dist/queries/projects-types.js +5 -0
  92. package/dist/queries/projects.d.ts +2 -0
  93. package/dist/queries/projects.js +22 -0
  94. package/dist/queries/releases-types.d.ts +85 -0
  95. package/dist/queries/releases-types.js +5 -0
  96. package/dist/queries/search-types.d.ts +102 -0
  97. package/dist/queries/search-types.js +6 -0
  98. package/dist/queries/templates-types.d.ts +62 -0
  99. package/dist/queries/templates-types.js +9 -0
  100. package/dist/types/linear.d.ts +21 -3
  101. package/dist/utils/auto-link-references.d.ts +3 -3
  102. package/dist/utils/auto-link-references.js +30 -34
  103. package/dist/utils/extract-field.d.ts +19 -0
  104. package/dist/utils/extract-field.js +99 -0
  105. package/dist/utils/file-service.d.ts +6 -13
  106. package/dist/utils/file-service.js +0 -2
  107. package/dist/utils/formatters/summary.js +6 -1
  108. package/dist/utils/graphql-attachments-service.js +6 -9
  109. package/dist/utils/graphql-documents-service.js +19 -25
  110. package/dist/utils/graphql-issues-service.d.ts +112 -46
  111. package/dist/utils/graphql-issues-service.js +398 -206
  112. package/dist/utils/graphql-service.d.ts +10 -12
  113. package/dist/utils/graphql-service.js +0 -3
  114. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  115. package/dist/utils/issue-reference-extractor.js +5 -3
  116. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  117. package/dist/utils/issues-service-bootstrap.js +27 -0
  118. package/dist/utils/linear-service.d.ts +21 -14
  119. package/dist/utils/linear-service.js +73 -11
  120. package/dist/utils/markdown-prosemirror.js +12 -12
  121. package/dist/utils/mention-resolver.js +1 -1
  122. package/dist/utils/output.d.ts +82 -2
  123. package/dist/utils/output.js +76 -11
  124. package/dist/utils/project-slug.d.ts +21 -0
  125. package/dist/utils/project-slug.js +45 -0
  126. package/dist/utils/protected-ranges.d.ts +14 -0
  127. package/dist/utils/protected-ranges.js +88 -2
  128. package/dist/utils/sanitize-for-log.d.ts +24 -0
  129. package/dist/utils/sanitize-for-log.js +38 -0
  130. package/dist/utils/table-formatter.js +24 -0
  131. package/dist/utils/validators.d.ts +7 -2
  132. package/dist/utils/validators.js +6 -0
  133. package/dist/utils/workspace-url.d.ts +5 -1
  134. package/dist/utils/workspace-url.js +53 -7
  135. package/package.json +2 -2
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Validation error enrichment.
3
+ *
4
+ * When `validateIssueCreation` flags a missing required field AND `--team`
5
+ * is set, this module fetches concrete team-scoped suggestions (active
6
+ * projects, active members, valid labels) and appends a "Suggestions:" block
7
+ * to each relevant error message.
8
+ *
9
+ * Goal: reduce the agent's loop time. A single error should carry enough
10
+ * concrete options that the agent can rebuild a complete retry command
11
+ * without follow-up `projects list` / `users list` / `labels list` calls.
12
+ *
13
+ * Latency budget: enrichment only runs on the validation-fail path. The
14
+ * success path is unchanged. Fetches run in parallel and only for the fields
15
+ * that are actually missing.
16
+ */
17
+ import { isUuid } from "../utils/uuid.js";
18
+ import { loadConfig } from "./config.js";
19
+ import { getCanonicalTypeLabels, inferTypeFromTitle, } from "./issue-validation.js";
20
+ import { resolveTeam } from "./resolver.js";
21
+ /**
22
+ * Raw GraphQL is used here instead of `LinearService` / `client.team(id)`
23
+ * because we need three connections (projects, members, labels) in a
24
+ * single round-trip, gated on which fields the validator actually flagged
25
+ * as missing. Going through the SDK would mean three sequential
26
+ * `.projects()` / `.members()` / `.labels()` calls (the SDK's
27
+ * connection-resolver promises don't batch), which doubles the
28
+ * worst-case latency of an already-on-the-error-path enrichment.
29
+ *
30
+ * Per CLAUDE.md "prefer @linear/sdk over raw GraphQL" rule: this is the
31
+ * "batching with @include directives" exception. Revisit if `@linear/sdk`
32
+ * ever exposes a multi-connection batch helper.
33
+ */
34
+ const TEAM_SUGGESTIONS_QUERY = `
35
+ query TeamSuggestions(
36
+ $teamId: String!
37
+ $includeProjects: Boolean!
38
+ $includeMembers: Boolean!
39
+ $includeLabels: Boolean!
40
+ ) {
41
+ team(id: $teamId) {
42
+ id
43
+ key
44
+ name
45
+ projects(
46
+ filter: { state: { in: ["started", "backlog", "planned"] } }
47
+ orderBy: updatedAt
48
+ first: 5
49
+ ) @include(if: $includeProjects) {
50
+ nodes { id name state }
51
+ }
52
+ members(
53
+ filter: { active: { eq: true } }
54
+ first: 8
55
+ ) @include(if: $includeMembers) {
56
+ nodes { id name displayName email active }
57
+ }
58
+ labels(first: 50) @include(if: $includeLabels) {
59
+ nodes { id name isGroup }
60
+ }
61
+ }
62
+ }
63
+ `;
64
+ /**
65
+ * What kind of "missing field" each error represents, derived from the
66
+ * error message prefix. Returns null for errors we don't enrich.
67
+ */
68
+ function classifyError(error) {
69
+ if (error.startsWith("Missing --project")) {
70
+ return "project";
71
+ }
72
+ if (error.startsWith("Missing --assignee")) {
73
+ return "assignee";
74
+ }
75
+ if (error.startsWith("Missing --labels")) {
76
+ return "labels";
77
+ }
78
+ if (error.startsWith("Missing type label")) {
79
+ return "type-label";
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Resolve the team input to a Linear UUID.
85
+ * Tries the synchronous config-based resolver first (no API call). Falls back
86
+ * to LinearService.resolveTeamId for inputs not in config.
87
+ */
88
+ async function resolveTeamUuid(team, linearService) {
89
+ const resolved = resolveTeam(team);
90
+ if (isUuid(resolved)) {
91
+ return resolved;
92
+ }
93
+ try {
94
+ return await linearService.resolveTeamId(team);
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Best shell-safe token to identify this user on the command line.
102
+ *
103
+ * Preference order:
104
+ * 1. Config alias (always single-token, e.g. "dima")
105
+ * 2. Email (stable, no spaces, no shell metacharacters)
106
+ * 3. POSIX-quoted displayName (last resort — single-quoted via
107
+ * `shellQuote` so a name like `Kamal M`, `O'Brien`, or one
108
+ * containing `$`/backtick/`\\` doesn't get split or expanded by
109
+ * the shell on retry).
110
+ *
111
+ * The retryability is the point: the suggestion must paste back into the
112
+ * same `el-linear issues create ... --assignee <token>` without further
113
+ * escaping by the caller. The displayName branch uses the same POSIX
114
+ * single-quote helper as the rest of the retry command so the escaping
115
+ * contract is uniform — no asymmetric "quote with double quotes here,
116
+ * single quotes there" trap for future maintainers.
117
+ */
118
+ function bestAssigneeToken(name, displayName, email) {
119
+ const config = loadConfig();
120
+ for (const [alias, configName] of Object.entries(config.members.aliases)) {
121
+ if (configName.toLowerCase() === name.toLowerCase()) {
122
+ return alias;
123
+ }
124
+ }
125
+ if (email && email.trim().length > 0) {
126
+ return email;
127
+ }
128
+ const label = displayName || name;
129
+ if (/\s/.test(label) || /['"`$\\]/.test(label)) {
130
+ return shellQuote(label);
131
+ }
132
+ return label;
133
+ }
134
+ function formatProjectSuggestions(projects) {
135
+ if (projects.length === 0) {
136
+ return " Suggestions: no active projects found on this team.";
137
+ }
138
+ const lines = projects
139
+ .filter((p) => Boolean(p.name))
140
+ .map((p) => ` --project "${p.name}"`);
141
+ return ` Suggestions (top active projects on this team):\n${lines.join("\n")}`;
142
+ }
143
+ function formatAssigneeSuggestions(members) {
144
+ if (members.length === 0) {
145
+ return " Suggestions: no active members found on this team.";
146
+ }
147
+ const lines = members
148
+ .filter((m) => Boolean(m.name))
149
+ .map((m) => ` --assignee ${bestAssigneeToken(m.name, m.displayName, m.email)}`);
150
+ return ` Suggestions (active team members):\n${lines.join("\n")}`;
151
+ }
152
+ function formatLabelSuggestions(ctx) {
153
+ const { labels, typeLabels, inferred } = ctx;
154
+ // Domain labels = team labels that are NOT type labels and NOT groups.
155
+ const typeSet = new Set(typeLabels.map((t) => t.toLowerCase()));
156
+ const domainLabels = labels
157
+ .filter((l) => !l.isGroup && l.name)
158
+ .filter((l) => !typeSet.has(l.name.toLowerCase()))
159
+ .slice(0, 5)
160
+ .map((l) => l.name);
161
+ // Order type labels: inferred type first if it matches one of typeLabels.
162
+ let orderedTypes = [...typeLabels];
163
+ if (inferred && typeLabels.includes(inferred.type)) {
164
+ orderedTypes = [
165
+ inferred.type,
166
+ ...typeLabels.filter((t) => t !== inferred.type),
167
+ ];
168
+ }
169
+ const typeLines = orderedTypes
170
+ .slice(0, 5)
171
+ .map((t) => inferred && t === inferred.type
172
+ ? ` ${t} # inferred from title verb "${inferred.verb}"`
173
+ : ` ${t}`);
174
+ const out = [];
175
+ out.push(" Suggestions:");
176
+ out.push(" Valid type labels (pick one):");
177
+ out.push(...typeLines.map((l) => ` ${l}`));
178
+ if (domainLabels.length > 0) {
179
+ out.push(` Common domain labels on this team: ${domainLabels.join(", ")}`);
180
+ }
181
+ return out.join("\n");
182
+ }
183
+ /**
184
+ * Enrich validation errors with team-scoped suggestions.
185
+ *
186
+ * Mutates `result.errors` in place — each enrichable error gets a
187
+ * "Suggestions:" block appended. Verb→type inference is added as a prefixed
188
+ * "Inferred from title:" line on the `--labels` missing error.
189
+ *
190
+ * No-op when `--team` is not set or when there are no errors to enrich.
191
+ * Safe to call on the failure path only — never invoked when validation
192
+ * passes, so the success path's latency is unchanged.
193
+ *
194
+ * Failures (network, unknown team) are swallowed: enrichment is a best-effort
195
+ * UX hint, not part of the validation contract.
196
+ */
197
+ export async function enrichValidationErrors(result, options, services) {
198
+ if (!options.team || result.errors.length === 0) {
199
+ return;
200
+ }
201
+ // Figure out which suggestion types we actually need.
202
+ const classifications = result.errors.map(classifyError);
203
+ const needProjects = classifications.includes("project");
204
+ const needMembers = classifications.includes("assignee");
205
+ const needLabels = classifications.includes("labels") ||
206
+ classifications.includes("type-label");
207
+ if (!(needProjects || needMembers || needLabels)) {
208
+ return;
209
+ }
210
+ // Resolve team to UUID. Bail if we can't — no enrichment possible.
211
+ const teamId = await resolveTeamUuid(options.team, services.linearService);
212
+ if (!teamId) {
213
+ return;
214
+ }
215
+ // Single batched query, only fetching the connections we need.
216
+ let teamData;
217
+ try {
218
+ const res = await services.graphQLService.rawRequest(TEAM_SUGGESTIONS_QUERY, {
219
+ teamId,
220
+ includeProjects: needProjects,
221
+ includeMembers: needMembers,
222
+ includeLabels: needLabels,
223
+ });
224
+ teamData = res.team;
225
+ }
226
+ catch {
227
+ return; // best-effort
228
+ }
229
+ if (!teamData) {
230
+ return;
231
+ }
232
+ const projects = teamData.projects?.nodes ?? [];
233
+ const members = teamData.members?.nodes ?? [];
234
+ const labels = teamData.labels?.nodes ?? [];
235
+ const typeLabels = getCanonicalTypeLabels();
236
+ const inferred = options.title ? inferTypeFromTitle(options.title) : null;
237
+ for (let i = 0; i < result.errors.length; i++) {
238
+ result.errors[i] = decorateError(result.errors[i], classifications[i], {
239
+ projects,
240
+ members,
241
+ labels,
242
+ typeLabels,
243
+ inferred,
244
+ });
245
+ }
246
+ // Append a single copy-pasteable retry command after the last enriched
247
+ // error, so the agent has one concrete line to run instead of having to
248
+ // stitch together fragments from each error's suggestion block.
249
+ const retry = formatRetryCommand({
250
+ title: options.title,
251
+ team: options.team,
252
+ classifications,
253
+ projects,
254
+ members,
255
+ labels,
256
+ typeLabels,
257
+ inferred,
258
+ });
259
+ if (retry) {
260
+ const lastIdx = result.errors.length - 1;
261
+ result.errors[lastIdx] = `${result.errors[lastIdx]}\n\n${retry}`;
262
+ }
263
+ }
264
+ /**
265
+ * Pattern for the `Project "X" not found` error shape thrown by
266
+ * `LinearService.resolveProjectId` (both name and slug-id paths) and the
267
+ * batched issues-service project resolver. Matches the message body and
268
+ * captures the user's original input so the synthesized enrichment error
269
+ * can reference it.
270
+ *
271
+ * Start-anchored to avoid a false-positive when the substring appears
272
+ * inside an unrelated error. Greedy `.+` with `\.?` tail handles project
273
+ * names that themselves contain quotes — the regex backtracks from the
274
+ * final `" not found.` to find the longest valid identifier (`notFoundError`
275
+ * always ends with a period, so the tail is a stable terminator).
276
+ */
277
+ const PROJECT_NOT_FOUND_PATTERN = /^Project "(.+)" not found\.?/;
278
+ /**
279
+ * Enrich a project resolver failure ({@link PROJECT_NOT_FOUND_PATTERN})
280
+ * with the same team-scoped suggestions that {@link enrichValidationErrors}
281
+ * appends to `Missing --project` validation errors. Returns the original
282
+ * message unchanged if the error doesn't match or `--team` is unknown.
283
+ *
284
+ * The validation path covers "user forgot `--project`"; this path covers
285
+ * "user pasted a URL/slug/name that didn't resolve" — same recovery hints
286
+ * apply in both cases. Synthesizes a `Missing --project: …` error string
287
+ * so the existing classifier and suggestion code can be reused without
288
+ * special-casing the resolver shape.
289
+ *
290
+ * Best-effort like `enrichValidationErrors`: any failure (network, unknown
291
+ * team, GraphQL error) returns the original message unchanged. The caller
292
+ * is expected to rethrow with the returned message.
293
+ */
294
+ export async function enrichProjectResolverError(originalMessage, options, services) {
295
+ const match = originalMessage.match(PROJECT_NOT_FOUND_PATTERN);
296
+ if (!match || !options.team) {
297
+ return originalMessage;
298
+ }
299
+ const projectInput = match[1];
300
+ const synthetic = {
301
+ errors: [
302
+ `Missing --project: "${projectInput}" did not resolve to any project on team ${options.team}.`,
303
+ ],
304
+ warnings: [],
305
+ normalizedLabels: null,
306
+ };
307
+ try {
308
+ await enrichValidationErrors(synthetic, options, services);
309
+ }
310
+ catch {
311
+ return originalMessage;
312
+ }
313
+ // If enrichment didn't actually append a suggestions block, fall back
314
+ // to the original — better a clean error than a stuttering one with
315
+ // just the synthetic preamble and no concrete options.
316
+ if (synthetic.errors.length === 0 ||
317
+ !synthetic.errors[0].includes("Suggestions")) {
318
+ return originalMessage;
319
+ }
320
+ return synthetic.errors[0];
321
+ }
322
+ /**
323
+ * Build one copy-pasteable `el-linear issues create ...` command using the
324
+ * top-ranked suggestion for each missing field. Returns null when there's
325
+ * nothing useful to retry with (no team, or every suggestion source was empty).
326
+ *
327
+ * All values are shell-quoted with single quotes (POSIX), so titles, project
328
+ * names, and display names with spaces or special characters paste safely.
329
+ */
330
+ function formatRetryCommand(ctx) {
331
+ if (!ctx.team) {
332
+ return null;
333
+ }
334
+ const parts = ["el-linear issues create"];
335
+ parts.push(shellQuote(ctx.title ?? "<title>"));
336
+ parts.push(`--team ${shellQuote(ctx.team)}`);
337
+ const missing = new Set(ctx.classifications.filter((c) => c !== null));
338
+ if (missing.has("project")) {
339
+ const top = ctx.projects.find((p) => p.name);
340
+ if (!top?.name) {
341
+ return null;
342
+ }
343
+ parts.push(`--project ${shellQuote(top.name)}`);
344
+ }
345
+ if (missing.has("assignee")) {
346
+ const top = ctx.members.find((m) => m.name);
347
+ if (!top?.name) {
348
+ return null;
349
+ }
350
+ // Token is already shell-safe (alias, email, or pre-quoted display name).
351
+ parts.push(`--assignee ${bestAssigneeToken(top.name, top.displayName, top.email)}`);
352
+ }
353
+ if (missing.has("labels") || missing.has("type-label")) {
354
+ const typeLabel = ctx.inferred && ctx.typeLabels.includes(ctx.inferred.type)
355
+ ? ctx.inferred.type
356
+ : ctx.typeLabels[0];
357
+ if (!typeLabel) {
358
+ return null;
359
+ }
360
+ const domain = pickDomainHint(ctx.labels, ctx.typeLabels);
361
+ const labelArg = domain === "<domain>" ? typeLabel : `${typeLabel},${domain}`;
362
+ parts.push(`--labels ${shellQuote(labelArg)}`);
363
+ }
364
+ parts.push("--description '<describe the change and the motivation>'");
365
+ return ` Retry with:\n ${parts.join(" ")}`;
366
+ }
367
+ /** POSIX single-quote escape: 'foo' → 'foo', it's → 'it'\''s'. */
368
+ function shellQuote(value) {
369
+ return `'${value.replace(/'/g, `'\\''`)}'`;
370
+ }
371
+ /**
372
+ * Apply suggestion blocks to a single error message based on its classification.
373
+ * Extracted from enrichValidationErrors to keep that orchestrator small.
374
+ */
375
+ function decorateError(message, kind, ctx) {
376
+ if (kind === "project") {
377
+ return `${message}\n${formatProjectSuggestions(ctx.projects)}`;
378
+ }
379
+ if (kind === "assignee") {
380
+ return `${message}\n${formatAssigneeSuggestions(ctx.members)}`;
381
+ }
382
+ if (kind === "labels" || kind === "type-label") {
383
+ // Both branches benefit from the verb→type inference hint plus
384
+ // the canonical-types + domain-labels suggestion block. Sharing
385
+ // the decorator keeps them in lockstep: a user who passed a
386
+ // non-type label (type-label error) sees the same "title verb
387
+ // suggests type X" guidance as one who passed no labels at all.
388
+ return decorateLabelsError(message, ctx);
389
+ }
390
+ return message;
391
+ }
392
+ /**
393
+ * Decorate a "Missing --labels" error with a verb-inference hint (when
394
+ * applicable) plus the standard label suggestions block.
395
+ *
396
+ * Inference is a hint only — the agent must still pass --labels explicitly.
397
+ * We do not auto-apply labels.
398
+ */
399
+ function decorateLabelsError(message, ctx) {
400
+ let body = message;
401
+ if (ctx.inferred && ctx.typeLabels.includes(ctx.inferred.type)) {
402
+ const domainHint = pickDomainHint(ctx.labels, ctx.typeLabels);
403
+ const hint = `Inferred from title: type label "${ctx.inferred.type}" (title starts with "${ctx.inferred.verb}") — suggested: --labels "${ctx.inferred.type},${domainHint}"`;
404
+ body = `${hint}\n${body}`;
405
+ }
406
+ return `${body}\n${formatLabelSuggestions(ctx)}`;
407
+ }
408
+ /** First non-type, non-team-key label as a placeholder, or "<domain>". */
409
+ function pickDomainHint(labels, typeLabels) {
410
+ const typeSet = new Set(typeLabels.map((t) => t.toLowerCase()));
411
+ const teamLikeKeys = new Set(["dev", "fe", "be"]);
412
+ const candidate = labels
413
+ .filter((l) => !l.isGroup && l.name)
414
+ .map((l) => l.name)
415
+ .find((n) => !(typeSet.has(n.toLowerCase()) || teamLikeKeys.has(n.toLowerCase())));
416
+ return candidate ?? "<domain>";
417
+ }
@@ -7,6 +7,14 @@
7
7
  * Gated behind `validation.enabled` in config so it can be rolled out per-user
8
8
  * before becoming the default for the team.
9
9
  */
10
+ /**
11
+ * Recommended leading verbs for each type label.
12
+ * Title verb and type label should express the same intent.
13
+ *
14
+ * Exported so error-enrichment can reuse the same mapping when inferring a
15
+ * type label from a title's first word.
16
+ */
17
+ export declare const TYPE_VERB_MAP: Record<string, string[]>;
10
18
  export interface ValidationResult {
11
19
  errors: string[];
12
20
  warnings: string[];
@@ -20,6 +28,35 @@ export interface ValidationInput {
20
28
  assignee: string | undefined;
21
29
  project: string | undefined;
22
30
  }
31
+ /**
32
+ * Public accessor for the canonical type labels.
33
+ * Used by error-enrichment when suggesting labels.
34
+ */
35
+ export declare function getCanonicalTypeLabels(): string[];
36
+ /**
37
+ * Find the canonical type label for a title's first word, if it matches a
38
+ * known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
39
+ * or null if no match.
40
+ *
41
+ * Checks multi-word verbs first (e.g. "Set up"), then single-word verbs.
42
+ *
43
+ * Why this lives next to `checkTitleVerbAlignment` but does NOT share its
44
+ * matching loop: the two functions answer different questions despite
45
+ * walking the same map.
46
+ * - `checkTitleVerbAlignment` runs only when a type label is already
47
+ * supplied; it warns when the verb belongs to a *different* type set
48
+ * than the one the user picked.
49
+ * - `inferTypeFromTitle` runs only when no type label is supplied; it
50
+ * wants the *positive* match — "this verb belongs to this type" —
51
+ * so it can suggest a default in the enrichment block.
52
+ * Sharing TYPE_VERB_MAP keeps them in sync; sharing the matcher would
53
+ * conflate "warn about mismatch" with "suggest a default" and produce
54
+ * subtle bugs (e.g. inferring a type the alignment check just rejected).
55
+ */
56
+ export declare function inferTypeFromTitle(title: string): {
57
+ verb: string;
58
+ type: string;
59
+ } | null;
23
60
  /**
24
61
  * Normalize a label name: resolve known aliases and fix casing.
25
62
  * Returns the canonical form if an alias exists, otherwise the original.
@@ -14,8 +14,11 @@ const DEFAULT_TYPE_LABELS = ["bug", "feature", "refactor", "chore", "spike"];
14
14
  /**
15
15
  * Recommended leading verbs for each type label.
16
16
  * Title verb and type label should express the same intent.
17
+ *
18
+ * Exported so error-enrichment can reuse the same mapping when inferring a
19
+ * type label from a title's first word.
17
20
  */
18
- const TYPE_VERB_MAP = {
21
+ export const TYPE_VERB_MAP = {
19
22
  bug: ["Fix", "Resolve", "Patch", "Handle", "Address", "Correct"],
20
23
  feature: [
21
24
  "Add",
@@ -93,6 +96,65 @@ function getValidationConfig() {
93
96
  typeLabels: validation?.typeLabels ?? DEFAULT_TYPE_LABELS,
94
97
  };
95
98
  }
99
+ /**
100
+ * Public accessor for the canonical type labels.
101
+ * Used by error-enrichment when suggesting labels.
102
+ */
103
+ export function getCanonicalTypeLabels() {
104
+ return getValidationConfig().typeLabels;
105
+ }
106
+ /**
107
+ * Find the canonical type label for a title's first word, if it matches a
108
+ * known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
109
+ * or null if no match.
110
+ *
111
+ * Checks multi-word verbs first (e.g. "Set up"), then single-word verbs.
112
+ *
113
+ * Why this lives next to `checkTitleVerbAlignment` but does NOT share its
114
+ * matching loop: the two functions answer different questions despite
115
+ * walking the same map.
116
+ * - `checkTitleVerbAlignment` runs only when a type label is already
117
+ * supplied; it warns when the verb belongs to a *different* type set
118
+ * than the one the user picked.
119
+ * - `inferTypeFromTitle` runs only when no type label is supplied; it
120
+ * wants the *positive* match — "this verb belongs to this type" —
121
+ * so it can suggest a default in the enrichment block.
122
+ * Sharing TYPE_VERB_MAP keeps them in sync; sharing the matcher would
123
+ * conflate "warn about mismatch" with "suggest a default" and produce
124
+ * subtle bugs (e.g. inferring a type the alignment check just rejected).
125
+ */
126
+ export function inferTypeFromTitle(title) {
127
+ const lowered = title.toLowerCase();
128
+ // Multi-word verbs first ("Set up")
129
+ for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
130
+ for (const verb of verbs) {
131
+ if (!verb.includes(" ")) {
132
+ continue;
133
+ }
134
+ const verbLower = verb.toLowerCase();
135
+ if (lowered.startsWith(`${verbLower} `) || lowered === verbLower) {
136
+ return { verb, type };
137
+ }
138
+ }
139
+ }
140
+ // Single-word verb
141
+ const firstWord = title.split(/\s/)[0];
142
+ if (!firstWord) {
143
+ return null;
144
+ }
145
+ const firstLower = firstWord.toLowerCase();
146
+ for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
147
+ for (const verb of verbs) {
148
+ if (verb.includes(" ")) {
149
+ continue;
150
+ }
151
+ if (verb.toLowerCase() === firstLower) {
152
+ return { verb, type };
153
+ }
154
+ }
155
+ }
156
+ return null;
157
+ }
96
158
  /**
97
159
  * Normalize a label name: resolve known aliases and fix casing.
98
160
  * Returns the canonical form if an alias exists, otherwise the original.
@@ -5,17 +5,10 @@
5
5
  */
6
6
  export declare const CONFIG_DIR: string;
7
7
  export declare const CONFIG_PATH: string;
8
+ export declare const LOCAL_CONFIG_PATH: string;
8
9
  export declare const TOKEN_PATH: string;
9
10
  export declare const TEAM_OAUTH_CONFIG_PATH: string;
10
11
  export declare const ALIASES_PROGRESS_PATH: string;
11
- /**
12
- * Legacy fallback paths kept for backward compatibility. The CLI was briefly
13
- * published as `@enrichlayer/linctl` (binary `linctl`); reverted to el-linear
14
- * because of an npm collision with `dorkitude/linctl`. Reads check the new
15
- * path first and fall back to these. Writes always go to the new path.
16
- */
17
- export declare const LEGACY_LINCTL_CONFIG_DIR: string;
18
- export declare const LEGACY_LINCTL_CONFIG_PATH: string;
19
12
  export declare const LEGACY_LINCTL_TOKEN_PATH: string;
20
13
  /** Even older fallback from before the `~/.config/...` move (one release). */
21
14
  export declare const LEGACY_TOKEN_PATH: string;
@@ -25,6 +18,7 @@ export interface ProfilePaths {
25
18
  /** Profile name; null when using the legacy single-file layout. */
26
19
  name: string | null;
27
20
  configPath: string;
21
+ localConfigPath: string;
28
22
  tokenPath: string;
29
23
  }
30
24
  export interface ProfileFsOps {
@@ -8,6 +8,7 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
  export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
10
10
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
11
+ export const LOCAL_CONFIG_PATH = path.join(CONFIG_DIR, "local.json");
11
12
  export const TOKEN_PATH = path.join(CONFIG_DIR, "token");
12
13
  export const TEAM_OAUTH_CONFIG_PATH = path.join(CONFIG_DIR, "team-oauth.json");
13
14
  export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progress");
@@ -17,8 +18,7 @@ export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progre
17
18
  * because of an npm collision with `dorkitude/linctl`. Reads check the new
18
19
  * path first and fall back to these. Writes always go to the new path.
19
20
  */
20
- export const LEGACY_LINCTL_CONFIG_DIR = path.join(os.homedir(), ".config", "linctl");
21
- export const LEGACY_LINCTL_CONFIG_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "config.json");
21
+ const LEGACY_LINCTL_CONFIG_DIR = path.join(os.homedir(), ".config", "linctl");
22
22
  export const LEGACY_LINCTL_TOKEN_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "token");
23
23
  /** Even older fallback from before the `~/.config/...` move (one release). */
24
24
  export const LEGACY_TOKEN_PATH = path.join(os.homedir(), ".linear_api_token");
@@ -113,6 +113,7 @@ export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS)
113
113
  return {
114
114
  name: null,
115
115
  configPath: CONFIG_PATH,
116
+ localConfigPath: LOCAL_CONFIG_PATH,
116
117
  tokenPath: TOKEN_PATH,
117
118
  };
118
119
  }
@@ -122,6 +123,7 @@ export function profilePaths(name) {
122
123
  return {
123
124
  name,
124
125
  configPath: path.join(dir, "config.json"),
126
+ localConfigPath: path.join(dir, "local.json"),
125
127
  tokenPath: path.join(dir, "token"),
126
128
  };
127
129
  }
@@ -20,6 +20,13 @@ export declare function resolveAssignee(input: string, rootOpts: Record<string,
20
20
  export declare function resolveUserDisplayName(id: string, name: string): string;
21
21
  /**
22
22
  * Resolve labels for a team, returning UUIDs for known labels.
23
- * Unknown labels are returned as-is for API resolution.
23
+ *
24
+ * Labels that don't map to a config UUID are returned as their canonical
25
+ * (alias-expanded) name — e.g. `docs` becomes `documentation` — so the
26
+ * API-side resolver matches the right label rather than auto-creating one
27
+ * under the abbreviation. Pass no `teamKey` to defer team-scoped labels to
28
+ * API resolution: a team-scoped config UUID is only valid for that team,
29
+ * so resolving it here is wrong whenever the team is decided later (e.g.
30
+ * the create flow auto-switches the team to match the project).
24
31
  */
25
32
  export declare function resolveLabels(names: string[], teamKey?: string): string[];
@@ -93,11 +93,10 @@ export async function resolveAssignee(input, rootOpts) {
93
93
  if (input.toLowerCase() === "me") {
94
94
  const graphQLService = await createGraphQLService(rootOpts);
95
95
  const result = await graphQLService.rawRequest("{ viewer { id } }");
96
- const viewer = result.viewer;
97
- if (!viewer?.id) {
96
+ if (!result.viewer?.id) {
98
97
  throw new Error('Could not resolve "me" — viewer query returned no user. Check your API token.');
99
98
  }
100
- return viewer.id;
99
+ return result.viewer.id;
101
100
  }
102
101
  return resolveMember(input);
103
102
  }
@@ -173,11 +172,18 @@ function resolveLabel(name, teamKey) {
173
172
  }
174
173
  /**
175
174
  * Resolve labels for a team, returning UUIDs for known labels.
176
- * Unknown labels are returned as-is for API resolution.
175
+ *
176
+ * Labels that don't map to a config UUID are returned as their canonical
177
+ * (alias-expanded) name — e.g. `docs` becomes `documentation` — so the
178
+ * API-side resolver matches the right label rather than auto-creating one
179
+ * under the abbreviation. Pass no `teamKey` to defer team-scoped labels to
180
+ * API resolution: a team-scoped config UUID is only valid for that team,
181
+ * so resolving it here is wrong whenever the team is decided later (e.g.
182
+ * the create flow auto-switches the team to match the project).
177
183
  */
178
184
  export function resolveLabels(names, teamKey) {
179
185
  return names.map((name) => {
180
186
  const resolved = resolveLabel(name, teamKey);
181
- return resolved || name;
187
+ return resolved || LABEL_ALIASES[name.toLowerCase()] || name;
182
188
  });
183
189
  }