@enrichlayer/el-linear 1.37.2 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -277,8 +277,9 @@ el-linear can record each gate's fire/override decision to a local JSONL file so
277
277
  you can measure its **override-rate** and tell whether it's too aggressive. It is
278
278
  **off by default** and writes nothing unless you opt in (e.g.
279
279
  `export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
280
- `EL_TELEMETRY_DISABLED=1` forces it off. Full opt-in rules, the event schema, and
281
- a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
280
+ `EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
281
+ backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
282
+ and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
282
283
 
283
284
  ### Networking (IPv4 preference)
284
285
 
@@ -237,6 +237,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
237
237
  el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
238
238
  ```
239
239
 
240
+ ### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
241
+
242
+ **The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
243
+
244
+ - **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
245
+ - **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
246
+ - Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
247
+
248
+ The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
249
+
240
250
  ### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
241
251
 
242
252
  The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
@@ -248,38 +258,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
248
258
 
249
259
  When `el-linear issues search` (or the cross-resource `search`) returns rows
250
260
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
251
- starting with `relation_candidates:` that enumerates the candidate IDs and
252
- asks the user to reply with which ones to link, and states the skip phrase.
253
-
254
- That warning is the authoritative instruction — surface it to the user
255
- verbatim and follow it literally: **do not call `el-linear issues relate`
256
- until the user replies naming the IDs to link.** Only user-named IDs go into
257
- the `issues relate <source> --related-to "<ids>"` call — never pass an ID the
258
- user did not name, even one your own search obviously surfaced. The CLI emits
259
- the full procedure (every candidate ID, the example reply, the skip phrase) in
260
- that one line, so follow it rather than re-deriving or paraphrasing it away.
261
-
262
- Why this matters: Claude Code's auto-mode permission classifier blocks
263
- `issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
264
- (came from your own search) rather than *user-specified* (typed by the human),
265
- because each listed peer is a write target. Routing the IDs through an
266
- explicit human reply converts them from agent-inferred → user-specified;
267
- the existing search step (above) stays intact; auto-mode's guard is not
268
- weakened. The fix is the loop shape, not the guard.
269
-
270
- Anti-patterns:
271
-
272
- - **Calling `issues relate` directly off your own search output** — even if
273
- the IDs are real and the candidates look obvious, this is the exact path
274
- the auto-mode guard refuses.
275
- - **Splitting one relate call into N single-ID calls** to "look smaller" —
276
- same provenance problem, same block, just multiplied.
277
- - **Asking the user a yes/no question** ("Should I link these?") instead of
278
- having them name the IDs — yes answers stay agent-inferred, the reply
279
- must carry the IDs to convert them to user-specified.
261
+ starting with `relation_candidates:` enumerating the candidate IDs. Treat it
262
+ as a **convenience list of link candidates, not a stop sign** — under the
263
+ proactive default above, relate the ones that are genuinely related/blocking
264
+ without waiting to be told. Prefer create-time `--related-to` when the search
265
+ ran as part of filing a new issue (create-time relations typically pass the
266
+ classifier); otherwise call `issues relate` directly.
267
+
268
+ **Residual auto-mode constraint (the only reason to pause).** Claude Code's
269
+ auto-mode permission classifier *may* block a standalone `issues relate
270
+ --related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
271
+ your own search) rather than *user-specified*, because each peer is a write
272
+ target. A standing user instruction to cross-link generously is itself
273
+ authorization — so proceed by default. But if a specific `relate` call is
274
+ **actually blocked** by the classifier, that block is your cue: surface the
275
+ candidate IDs to the user for a one-word confirm, then re-run with the
276
+ user-named IDs (which pass). This is the **exception path**, not the default —
277
+ do not pre-emptively withhold links the classifier would have allowed. Two
278
+ ways to keep it frictionless: file relations at create time (`--related-to`),
279
+ or the operator adds a permission rule / runs non-auto-mode so post-hoc
280
+ `relate` never trips.
280
281
 
281
282
  If `--include-closed` search returns no matches, no `relation_candidates:`
282
- warning is emitted (nothing to confirm) and the flow proceeds normally.
283
+ warning is emitted and the flow proceeds normally.
283
284
 
284
285
  ### Viewing existing relations
285
286
 
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
4
4
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
5
5
  import { applyFooter } from "../utils/footer.js";
6
6
  import { createGraphQLService, } from "../utils/graphql-service.js";
7
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
7
8
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
8
9
  import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
9
10
  import { createLinearService, } from "../utils/linear-service.js";
@@ -79,7 +80,7 @@ function readBody(options) {
79
80
  return readFileSync(options.bodyFile, "utf-8");
80
81
  }
81
82
  if (options.body) {
82
- return options.body;
83
+ return normalizeInlineTextInput(options.body);
83
84
  }
84
85
  throw new Error("Either --body or --body-file is required");
85
86
  }
@@ -20,6 +20,7 @@ import fs from "node:fs";
20
20
  import { loadConfig } from "../../config/config.js";
21
21
  import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
22
22
  import { autoLinkReferences, } from "../../utils/auto-link-references.js";
23
+ import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
23
24
  import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
24
25
  import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
25
26
  import { validateReferences } from "../../utils/validate-references.js";
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
59
60
  return readDescriptionFile(options.descriptionFile);
60
61
  }
61
62
  if (hasInline) {
62
- return options.description;
63
+ return normalizeInlineTextInput(options.description);
63
64
  }
64
65
  if (hasTemplate) {
65
66
  const templates = loadConfig().descriptionTemplates ?? {};
@@ -13,6 +13,7 @@ import { applyFooter } from "../utils/footer.js";
13
13
  import { emitGateEvent } from "../utils/gate-telemetry.js";
14
14
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
15
  import { createGraphQLService, } from "../utils/graphql-service.js";
16
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
16
17
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
17
18
  import { createLinearService, } from "../utils/linear-service.js";
18
19
  import { logger } from "../utils/logger.js";
@@ -205,7 +206,8 @@ async function handleListIssues(options, command) {
205
206
  // user passes --include-closed OR explicit --status. Explicit status
206
207
  // wins because the user already named the workflow states they want.
207
208
  const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
208
- const hasOtherFilters = options.team ||
209
+ const hasOtherFilters = options.search ||
210
+ options.team ||
209
211
  options.labels ||
210
212
  options.status ||
211
213
  options.assignee ||
@@ -223,6 +225,7 @@ async function handleListIssues(options, command) {
223
225
  // (DEV-4478 cycle-1.)
224
226
  if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
225
227
  const searchArgs = {
228
+ query: options.search,
226
229
  teamId: options.team ? resolveTeam(options.team) : undefined,
227
230
  assigneeId: options.assignee
228
231
  ? await resolveAssignee(options.assignee, rootOpts)
@@ -244,8 +247,15 @@ async function handleListIssues(options, command) {
244
247
  if (excludeTerminalStates) {
245
248
  outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
246
249
  }
250
+ if (options.search) {
251
+ const relationPrompt = buildRelationCandidatePrompt(result);
252
+ if (relationPrompt) {
253
+ outputWarning(relationPrompt);
254
+ }
255
+ }
247
256
  warnIfTruncated(result.length, limit);
248
257
  outputIssues(result, command, {
258
+ query: options.search,
249
259
  team: options.team,
250
260
  });
251
261
  }
@@ -947,6 +957,12 @@ async function handleUpdateIssue(issueId, options, command) {
947
957
  if (options.descriptionFile) {
948
958
  options.description = readDescriptionFile(options.descriptionFile);
949
959
  }
960
+ else if (typeof options.description === "string") {
961
+ options.description = normalizeInlineTextInput(options.description);
962
+ }
963
+ if (typeof options.appendDescription === "string") {
964
+ options.appendDescription = normalizeInlineTextInput(options.appendDescription);
965
+ }
950
966
  validateUpdateOptions(options);
951
967
  const rootOpts = getRootOpts(command);
952
968
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
@@ -1228,6 +1244,7 @@ export function setupIssuesCommands(program) {
1228
1244
  .command("list")
1229
1245
  .description("List issues.")
1230
1246
  .option("-l, --limit <number>", "limit results", "25")
1247
+ .option("--search <query>", "full-text search term; composes with list filters")
1231
1248
  .option("--team <team>", "filter by team key (EL: resolves names)")
1232
1249
  .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
1233
1250
  .option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
9
9
  import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
10
10
  async function handleCreateLabel(name, options, command) {
11
11
  const rootOpts = getRootOpts(command);
12
- const teamId = resolveTeam(options.team);
13
12
  const graphQLService = await createGraphQLService(rootOpts);
13
+ const linearService = await createLinearService(rootOpts);
14
+ const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
14
15
  const input = { name, teamId };
15
16
  if (options.color) {
16
17
  input.color = validateHexColor(options.color);
@@ -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")
@@ -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) {
@@ -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
@@ -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
+ }
@@ -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
  */
@@ -59,16 +59,23 @@ export function extractCandidateIdentifiers(rows) {
59
59
  return out;
60
60
  }
61
61
  /**
62
- * Build the relation-candidate confirmation warning string, or `null` when
63
- * the result set has no identifier-bearing rows (nothing to confirm).
62
+ * Build the relation-candidate warning string, or `null` when the result set
63
+ * has no identifier-bearing rows (nothing to surface).
64
64
  *
65
65
  * Shape (single line, structured-prose so a skill can match on the prefix):
66
66
  *
67
67
  * relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
68
- * To link them as related: reply with the IDs you want linked
69
- * (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
68
+ * Link the relevant ones now — at create time with --related-to, or
69
+ * `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
70
+ * agent-inferred relate, reply with the IDs you want linked
71
+ * (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
70
72
  *
71
- * The `relation_candidates:` prefix matches the existing `results_truncated:`
73
+ * DEV-5853: the primary framing is **proactive** — this is a convenience list
74
+ * of link candidates, not a stop sign. Relate the relevant ones directly
75
+ * rather than waiting to be told; the reply flow is the *fallback* for when
76
+ * the auto-mode classifier actually blocks an agent-inferred `issues relate`
77
+ * (create-time `--related-to` typically passes, so prefer it). The
78
+ * `relation_candidates:` prefix matches the existing `results_truncated:`
72
79
  * convention in `outputWarning` callers — a stable token a skill / agent
73
80
  * harness can grep for without parsing free-form prose.
74
81
  */
@@ -82,13 +89,20 @@ export function buildRelationCandidatePrompt(rows) {
82
89
  ? `${shown.join(", ")}, … (+${overflow} more)`
83
90
  : shown.join(", ");
84
91
  // Build two concrete example IDs from the head of the list so the
85
- // "reply with the IDs you want linked" example is realistic for the
86
- // caller's actual search rather than a fixed placeholder. Single-result
92
+ // fallback "reply with the IDs you want linked" example is realistic for
93
+ // the caller's actual search rather than a fixed placeholder. Single-result
87
94
  // case still reads naturally ("link DEV-1").
88
95
  const example = shown.length >= 2 ? `link ${shown[0]} and ${shown[1]}` : `link ${shown[0]}`;
89
96
  const noun = ids.length === 1 ? "candidate related issue" : "candidate related issues";
97
+ // DEV-5853: proactive framing first (relate the relevant ones directly),
98
+ // then the reply-flow fallback for when auto-mode blocks an agent-inferred
99
+ // relate. The `relate the relevant ones` / `reply with the IDs you want
100
+ // linked` / `"no links"` / DEV-4494 tokens are all preserved so downstream
101
+ // skill/agent matchers stay stable.
90
102
  return (`relation_candidates: Found ${ids.length} ${noun} (${idList}). ` +
91
- `To link them as related: reply with the IDs you want linked ` +
92
- `(e.g. "${example}"). To skip linking: reply "no links". ` +
103
+ `Link the relevant ones now — at create time with --related-to, or ` +
104
+ `\`issues relate <id> --related-to "<ids>"\`. ` +
105
+ `If auto-mode blocks an agent-inferred relate, reply with the IDs you want linked ` +
106
+ `(e.g. "${example}"), or "no links" to skip. ` +
93
107
  `(Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)`);
94
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.37.2",
3
+ "version": "1.38.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",
@@ -68,8 +68,9 @@
68
68
  "@sentry/node": "^10.50.0"
69
69
  },
70
70
  "scripts": {
71
- "build": "tsc && chmod +x dist/main.js",
71
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/main.js",
72
72
  "clean": "rm -rf dist/",
73
+ "typecheck": "tsc --noEmit",
73
74
  "lint": "biome check .",
74
75
  "lint:fix": "biome check --fix .",
75
76
  "start": "tsx src/main.ts",