@enrichlayer/el-linear 1.36.0 → 1.37.1

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
@@ -160,6 +160,18 @@ The active profile is selected by, in priority:
160
160
  The legacy fallback means **existing single-profile users see no
161
161
  behavior change** — profiles are purely opt-in.
162
162
 
163
+ **Automation and concurrent sessions: prefer `--profile`/`$EL_LINEAR_PROFILE`
164
+ over `profile use`.** `~/.config/el-linear/active-profile` is a single,
165
+ machine-global marker file — `profile use <name>` mutates it for *every*
166
+ el-linear invocation on the machine, including other terminals, scripts, and
167
+ agent sessions that don't set an explicit override. Two processes wanting
168
+ different workspaces at the same time will silently clobber each other. The
169
+ flag and env var are per-invocation and never touch the marker file, so they
170
+ compose safely with any number of concurrent sessions each pinned to their
171
+ own workspace. Reserve `profile use` for a human's own interactive default
172
+ switch; `profile use`/`profile add` print a loud warning when they change the
173
+ global marker so the blast radius is visible instead of silent.
174
+
163
175
  ## Migrating from v1.0–1.3
164
176
 
165
177
  Versions 1.0–1.3 stored everything in the single-file layout
@@ -556,9 +568,9 @@ Currently wired through:
556
568
 
557
569
  | Resource | Defaults | Extras you can request |
558
570
  |----------|----------|------------------------|
559
- | `issues list` | `id, title, state, assignee` | `project, cycle, milestone, labels, url, priority, estimate, createdAt, updatedAt, team` |
571
+ | `issues list` | `id, title, state, assignee` | `project, cycle, milestone, labels, url, priority, estimate, createdAt, updatedAt, completedAt, team` |
560
572
  | `projects list` | `name, state, progress, lead` | `teams, target, url, updatedAt` |
561
- | `issues read` (single) | `state, assignee, project, cycle, milestone, labels, url` | `priority, estimate, created, updated` |
573
+ | `issues read` (single) | `state, assignee, project, cycle, milestone, labels, url` | `priority, estimate, created, updated, completed` |
562
574
  | `projects read` (single) | `state, lead, teams, target, progress, url` | (filter only — no extras) |
563
575
 
564
576
  `identifier`, `id`, `title` and `name` are headline-only on single-resource summaries and are filtered out of the labelled block (they remain on the title line above). `status` is accepted as a synonym for `state`, `owner` for `assignee`, `targetDate` for `target`.
@@ -143,8 +143,8 @@ For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` fo
143
143
 
144
144
  `--format summary` is implemented for:
145
145
 
146
- - **Single resources:** `issues read`, `comments read`, `projects read`, `cycles read`, `project-milestones read`, `documents read`, `templates read`, releases (`graphql` query results), `users read`
147
- - **Lists:** `issues list`, `issues search`, `projects list`, `comments list`, `cycles list`, `project-milestones list`, `labels list`, `teams list`, `users list`, `documents list`, `templates list`, `attachments list`, `releases list`, and the cross-resource `search` command
146
+ - **Single resources:** `issues read`, `comments read`, `projects read`, `cycles read`, `project-milestones read`, `project-updates read`, `documents read`, `templates read`, releases (`graphql` query results), `users read`
147
+ - **Lists:** `issues list`, `issues search`, `projects list`, `comments list`, `cycles list`, `project-milestones list`, `project-updates list`, `labels list`, `teams list`, `users list`, `documents list`, `templates list`, `attachments list`, `releases list`, and the cross-resource `search` command
148
148
 
149
149
  Commands without a dedicated formatter (e.g. `config show`, custom `graphql` queries) fall back to a generic key/value rendering of their JSON payload.
150
150
 
@@ -241,11 +241,10 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
241
241
 
242
242
  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**.
243
243
 
244
- - `<cli> <subcommand> --help` (the flag may be a global option absent from the subcommand's help — check `<cli> --help` too).
245
- - `el-catalog commands --search "<intent>"` / `el-catalog clis --search` (the snapshot likely already lists it).
246
- - For a hook/guard, grep the source — e.g. `cli/el-hook/src/checks`.
244
+ - **`el-catalog commands --search "<intent>"`** / `el-catalog clis --search` — the **authoritative** check for a command/subcommand; it deterministically indexes every `<cli> <subcommand>`. Confirm with `<cli> <subcommand> --help` (and `<cli> --help` — a flag may be a global option absent from the subcommand's help).
245
+ - For a hook/guard, grep the source (e.g. `cli/el-hook/src/checks`) — but **empty grep output is inconclusive, not proof of absence**. A mis-quoted glob or bad flag makes grep exit silently with zero matches (`grep --include=*.ts …` errors under zsh, returning nothing for a symbol that exists). Never read a raw grep's silence as "doesn't exist" for the command check — that is exactly what `el-catalog` is for.
247
246
 
248
- Skipping it cost two needless branch+MR cycles in one session: a "feature" issue to add a hook guard that already existed and was firing, and one to add `--jq`/`--fields` flags that already worked — both premises only caught at implementation.
247
+ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields` flags filed as "features" though both already existed and worked, and a near-duplicate `el-git` subcommand built after a raw grep silently misfired (the `el-catalog` search would have found the existing one) — every premise caught only at implementation.
249
248
 
250
249
  When `el-linear issues search` (or the cross-resource `search`) returns rows
251
250
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
@@ -532,6 +531,25 @@ el-linear projects add-team "Project Name" ENG 2>&1
532
531
 
533
532
  **Never use raw `projectUpdate` with `teamIds`** — it replaces the entire team list. Always use `projects add-team` / `remove-team`.
534
533
 
534
+ ### Project Updates (status posts) — mind the naming collision
535
+
536
+ Linear has two unrelated things spelled almost the same:
537
+
538
+ - `projectUpdate(id, input)` — the mutation that **edits a project** (the one warned about just above). Surfaced by the `projects` command.
539
+ - `ProjectUpdate` — a **status post** in a project's Updates feed (progress + a health color). Surfaced by the dedicated `project-updates` command:
540
+
541
+ ```bash
542
+ # Post a status update to a project (appears in the Updates feed)
543
+ el-linear project-updates create --project "Auth Refactor" \
544
+ --body "Shipped the session-store migration; rollout at 60%." \
545
+ --health onTrack # onTrack | atRisk | offTrack — omit to leave unset
546
+
547
+ el-linear project-updates list --project "Auth Refactor"
548
+ el-linear project-updates read <updateId>
549
+ ```
550
+
551
+ `--body` / `--body-file` are mutually exclusive (one required); `--body-file` avoids shell-quoting for markdown/tables. `--health` is validated against the enum before the API call. `--diff-hidden` hides the auto-generated progress diff on the update. `-q/--quiet` prints `<health> <url>`. A status update is not the same as a project **document** (`documents create --project`) — use a document for durable reference content, a project update for point-in-time progress.
552
+
535
553
  ### Discovery Before Creation
536
554
 
537
555
  Always check if a project exists before creating: `el-linear projects list`.
@@ -33,6 +33,8 @@ function sourceLabel(source) {
33
33
  return "teamConfigPath in personal config";
34
34
  case "marker":
35
35
  return "auto-discovered via ~/.config/el-tools-root";
36
+ case "disabled":
37
+ return 'disabled for this profile via teamConfigPath: "" in personal config';
36
38
  case null:
37
39
  return null;
38
40
  }
@@ -18,7 +18,7 @@ import { createLinearService, } from "../utils/linear-service.js";
18
18
  import { logger } from "../utils/logger.js";
19
19
  import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } from "../utils/output.js";
20
20
  import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
21
- import { getRootOpts } from "../utils/root-opts.js";
21
+ import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
22
22
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
23
23
  import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
24
24
  import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssue, gitCheckoutBranch, setBranchLinearIssue, toBranchName, } from "./issues/branch.js";
@@ -121,7 +121,17 @@ function buildUpdateArgs(issueId, options, assigneeId, delegateId) {
121
121
  dueDate: options.dueDate || (options.clearDueDate ? null : undefined),
122
122
  };
123
123
  }
124
- function outputIssues(issues, format, fields, meta) {
124
+ /**
125
+ * `--format` / `--fields` are registered on both the root program and the
126
+ * list/search subcommands, and commander 15 assigns the CLI token to the
127
+ * root registration — so the values must be resolved via
128
+ * {@link effectiveOption} rather than read off the subcommand's own
129
+ * options (DEV-5376: `--format table --fields …` silently fell through to
130
+ * the JSON envelope because `options.format` stayed at its default).
131
+ */
132
+ function outputIssues(issues, command, meta) {
133
+ const format = effectiveOption(command, "format");
134
+ const fields = effectiveOption(command, "fields");
125
135
  const fieldList = fields ? splitList(fields) : undefined;
126
136
  if (format === "table") {
127
137
  logger.info(formatTable(issues, fieldList));
@@ -134,6 +144,13 @@ function outputIssues(issues, format, fields, meta) {
134
144
  logger.info(formatCsv(issues, fieldList));
135
145
  }
136
146
  else {
147
+ // JSON path: the `--fields` projection is applied by the global
148
+ // `fieldsFilter` inside `outputSuccess` (set in main.ts's preAction),
149
+ // NOT by `fieldList` here — `fieldList` only feeds the tabular
150
+ // formatters above. Keeping the projection in one place is what
151
+ // prevents double-application; a future refactor that routes
152
+ // table/csv/md through `outputSuccess` would need to drop the global
153
+ // filter or it would project twice.
137
154
  outputSuccess({ data: issues, meta: { count: issues.length, ...meta } });
138
155
  }
139
156
  }
@@ -228,14 +245,14 @@ async function handleListIssues(options, command) {
228
245
  outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
229
246
  }
230
247
  warnIfTruncated(result.length, limit);
231
- outputIssues(result, options.format, options.fields, {
248
+ outputIssues(result, command, {
232
249
  team: options.team,
233
250
  });
234
251
  }
235
252
  else {
236
253
  const result = sortIssues(await issuesService.getIssues(limit), options.sort);
237
254
  warnIfTruncated(result.length, limit);
238
- outputIssues(result, options.format, options.fields, {});
255
+ outputIssues(result, command, {});
239
256
  }
240
257
  }
241
258
  async function handleSearchIssues(query, options, command) {
@@ -282,7 +299,7 @@ async function handleSearchIssues(query, options, command) {
282
299
  if (relationPrompt) {
283
300
  outputWarning(relationPrompt);
284
301
  }
285
- outputIssues(result, options.format, options.fields, { query });
302
+ outputIssues(result, command, { query });
286
303
  }
287
304
  /**
288
305
  * Run an `issuesService.createIssue` / `.updateIssue` call and, if it
@@ -23,7 +23,7 @@
23
23
  import { promises as fsp } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { confirm } from "@inquirer/prompts";
26
- import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
26
+ import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, readActiveProfileMarker, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
27
27
  import { outputSuccess, outputWarning } from "../utils/output.js";
28
28
  import { runFullWizard } from "./init/index.js";
29
29
  import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
@@ -120,8 +120,27 @@ export async function runProfileUse(name) {
120
120
  if (!isSafeName(trimmed)) {
121
121
  throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
122
122
  }
123
+ // DEV-5610: `active-profile` is a single, machine-global marker — every
124
+ // el-linear invocation anywhere on this machine that doesn't set
125
+ // --profile/$EL_LINEAR_PROFILE resolves through it. Warn loudly on an
126
+ // actual switch so the blast radius is visible instead of silently
127
+ // redirecting some other concurrent session/tool to a different
128
+ // workspace. `--profile`/$EL_LINEAR_PROFILE remain the correct,
129
+ // non-mutating choice for automation and concurrent sessions.
130
+ //
131
+ // Read the raw marker (readActiveProfileMarker), not
132
+ // resolveActiveProfile().name — the latter resolves through the
133
+ // --profile/$EL_LINEAR_PROFILE precedence layers too, so a concurrent
134
+ // override in place while `profile use` runs would make it report the
135
+ // wrong "previous" value (a false-positive warning when the marker
136
+ // didn't actually change, or a false-negative when it did) — cycle-1
137
+ // review finding on PR #229.
138
+ const previous = readActiveProfileMarker();
123
139
  await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
124
140
  await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
141
+ if (previous !== trimmed) {
142
+ outputWarning(`Switched the GLOBAL active profile to "${trimmed}" (${ACTIVE_PROFILE_FILE}). This affects every other el-linear invocation on this machine that doesn't set --profile/$EL_LINEAR_PROFILE explicitly — concurrent sessions/tools relying on a different profile will silently start hitting the wrong workspace.`);
143
+ }
125
144
  }
126
145
  export async function runProfileAdd(name) {
127
146
  const trimmed = name.trim();
@@ -6,7 +6,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
9
- import { getRootOpts } from "../utils/root-opts.js";
9
+ import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
10
10
  import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
11
11
  import { isUuid } from "../utils/uuid.js";
12
12
  import { parsePositiveInt, splitList } from "../utils/validators.js";
@@ -516,7 +516,11 @@ export function setupProjectsCommands(program) {
516
516
  // ahead of completed/canceled in every format, so the active
517
517
  // set always fits within `--limit`. DEV-4175.
518
518
  const sorted = sortActiveFirst(result);
519
- const format = options.format;
519
+ // Resolved via effectiveOption because commander 15 hands the
520
+ // CLI token to the root program's same-named --format/--fields
521
+ // registration, leaving the subcommand's option at its default
522
+ // (DEV-5376).
523
+ const format = effectiveOption(command, "format") ?? "json";
520
524
  const isTabular = format === "table" ||
521
525
  format === "md" ||
522
526
  format === "markdown" ||
@@ -530,9 +534,8 @@ export function setupProjectsCommands(program) {
530
534
  warnProjectsTruncated(sorted.length, limit, format);
531
535
  }
532
536
  if (isTabular) {
533
- const fieldList = options.fields
534
- ? splitList(options.fields)
535
- : undefined;
537
+ const fields = effectiveOption(command, "fields");
538
+ const fieldList = fields ? splitList(fields) : undefined;
536
539
  formatProjectsOutput(sorted, format, fieldList);
537
540
  if (format === "table") {
538
541
  logger.info(`\n${sorted.length} projects`);
@@ -7,9 +7,11 @@ import type { TermRule } from "./term-enforcer.js";
7
7
  * - `env`: `EL_LINEAR_TEAM_CONFIG` is set in the environment (highest).
8
8
  * - `personal`: `teamConfigPath` field in personal `config.json`.
9
9
  * - `marker`: auto-discovered via `~/.config/el-tools-root` (DEV-4258).
10
+ * - `disabled`: explicitly opted out via `teamConfigPath: ""` in personal
11
+ * config — the layer is off even when the marker would otherwise apply.
10
12
  * - `null`: no team layer active.
11
13
  */
12
- export type TeamConfigSource = "env" | "personal" | "marker" | null;
14
+ export type TeamConfigSource = "env" | "personal" | "marker" | "disabled" | null;
13
15
  /**
14
16
  * Canonical shape of `~/.config/el-linear/config.json`. This is the single source
15
17
  * of truth for the on-disk config — the wizard reads/writes the same shape
@@ -142,6 +144,15 @@ export interface ElLinearConfig {
142
144
  * maps, and term rules across a team by checking the file into a shared
143
145
  * repository. Override at runtime with the `EL_LINEAR_TEAM_CONFIG` env var
144
146
  * (env var takes precedence over this field).
147
+ *
148
+ * Set to the empty string (`""`) to explicitly DISABLE the team layer for
149
+ * this profile, including `~/.config/el-tools-root` marker auto-discovery.
150
+ * This is the multi-account escape hatch: a marker-discovered team config
151
+ * carries workspace-specific member/label UUIDs, and applying it to a
152
+ * profile pointed at a different Linear workspace makes `--assignee <name>`
153
+ * resolve to a foreign UUID that the API rejects with an opaque
154
+ * "Entity not found in validateAccess" error. Profiles for secondary
155
+ * workspaces should set `"teamConfigPath": ""`.
145
156
  */
146
157
  teamConfigPath?: string;
147
158
  }
@@ -112,6 +112,10 @@ export function loadConfig() {
112
112
  // The marker is the opt-in consent signal — only developers who ran
113
113
  // `link-project.sh` get the auto-discovery; OSS users hit the silent
114
114
  // no-op path. DEV-4258 / ALL-964.
115
+ //
116
+ // An EMPTY string is a deliberate per-profile opt-out (see the
117
+ // `teamConfigPath` JSDoc): it is defined, so marker discovery is
118
+ // skipped, and falsy, so no team layer is loaded below.
115
119
  personalRaw = readRawPersonalConfig(active);
116
120
  teamConfigPath = personalRaw.teamConfigPath;
117
121
  if (teamConfigPath === undefined) {
@@ -181,7 +185,14 @@ export function getActiveTeamConfigInfo() {
181
185
  }
182
186
  const personalRaw = readRawPersonalConfig(resolveActiveProfile());
183
187
  const personalPath = personalRaw.teamConfigPath;
184
- if (personalPath !== undefined && personalPath !== "") {
188
+ if (personalPath === "") {
189
+ // Explicit per-profile opt-out — must be attributed as such, and must
190
+ // NOT fall through to the marker: loadConfig skips marker discovery
191
+ // for a defined-but-empty path, so reporting "marker" here would
192
+ // contradict what actually loads.
193
+ return { path: undefined, source: "disabled" };
194
+ }
195
+ if (personalPath !== undefined) {
185
196
  return { path: personalPath, source: "personal" };
186
197
  }
187
198
  const markerPath = discoverTeamConfigViaMarker();
@@ -51,3 +51,12 @@ export declare function getSessionProfileOverride(): string | null;
51
51
  export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv, fsImpl?: ProfileFsOps): ProfilePaths;
52
52
  /** Build profile-relative paths for a named profile. Pure. */
53
53
  export declare function profilePaths(name: string): ProfilePaths;
54
+ /**
55
+ * Read the raw on-disk marker content, bypassing the `--profile`/
56
+ * `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
57
+ * Exported (DEV-5610) so `profile use` can detect whether *the file itself*
58
+ * is about to change, independent of any per-invocation override that might
59
+ * otherwise make `resolveActiveProfile().name` report a different "previous"
60
+ * value than what's actually on disk.
61
+ */
62
+ export declare function readActiveProfileMarker(fsImpl?: ProfileFsOps): string | null;
@@ -127,7 +127,15 @@ export function profilePaths(name) {
127
127
  tokenPath: path.join(dir, "token"),
128
128
  };
129
129
  }
130
- function readActiveProfileMarker(fsImpl) {
130
+ /**
131
+ * Read the raw on-disk marker content, bypassing the `--profile`/
132
+ * `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
133
+ * Exported (DEV-5610) so `profile use` can detect whether *the file itself*
134
+ * is about to change, independent of any per-invocation override that might
135
+ * otherwise make `resolveActiveProfile().name` report a different "previous"
136
+ * value than what's actually on disk.
137
+ */
138
+ export function readActiveProfileMarker(fsImpl = DEFAULT_FS_OPS) {
131
139
  if (!fsImpl.existsSync(ACTIVE_PROFILE_FILE))
132
140
  return null;
133
141
  try {
package/dist/main.js CHANGED
@@ -70,7 +70,9 @@ program
70
70
  "Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
71
71
  "they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
72
72
  .option("--jq <filter>", "apply a jq filter to the JSON output")
73
- .option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
73
+ .option("--fields <fields>", "filter output to specific fields (comma-separated). Dot-paths resolve nested " +
74
+ "values; the column aliases status (state.name) and updated (updatedAt) " +
75
+ "resolve too. Unresolved fields are " +
74
76
  "emitted as null plus a `fields_unresolved:` warning — in the JSON " +
75
77
  "envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
76
78
  .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
@@ -1,2 +1,2 @@
1
- export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
2
- export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
1
+ export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
2
+ export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
@@ -11,6 +11,7 @@ const ISSUE_CORE_FIELDS = `
11
11
  url
12
12
  createdAt
13
13
  updatedAt
14
+ completedAt
14
15
  `;
15
16
  const ISSUE_STATE_FRAGMENT = `
16
17
  state {
@@ -39,6 +39,8 @@ export interface IssueTreeNode {
39
39
  id: string;
40
40
  name: string;
41
41
  } | null;
42
+ /** ISO timestamp the node reached a terminal state; null while open. */
43
+ completedAt?: string | null;
42
44
  children?: {
43
45
  nodes: IssueTreeNode[];
44
46
  };
@@ -23,6 +23,10 @@ export const DEFAULT_TREE_DEPTH = 3;
23
23
  // Field set per tree node — kept minimal so the depth-N query stays small.
24
24
  // `state.type` is required client-side for `--no-include-closed` pruning;
25
25
  // `state.name` for the [Done]/[Canceled] suffix on terminal nodes.
26
+ // `completedAt` is the moment a node reached a terminal state (null while
27
+ // open) — consumers that reason about how long a subtree has been finished
28
+ // (e.g. el-sop health's completed-children grace period, DEV-5454) need the
29
+ // per-child timestamp, which no other field carries.
26
30
  // `priority` was intentionally dropped after cycle-1: the ASCII formatter
27
31
  // doesn't render it and including it inflates the query string by ~N nodes.
28
32
  const TREE_NODE_FIELDS = `
@@ -31,6 +35,7 @@ const TREE_NODE_FIELDS = `
31
35
  title
32
36
  state { id name type }
33
37
  assignee { id name }
38
+ completedAt
34
39
  `;
35
40
  /**
36
41
  * Recursive children fragment generator. `depth=0` emits nothing (no
@@ -106,6 +106,7 @@ export interface IssueNode {
106
106
  url: string;
107
107
  createdAt: string;
108
108
  updatedAt: string;
109
+ completedAt: string | null;
109
110
  state: IdNameRef | null;
110
111
  assignee: AssigneeNode | null;
111
112
  delegate: AssigneeNode | null;
@@ -152,6 +153,17 @@ export interface GetIssuesResponse {
152
153
  nodes: IssueNode[];
153
154
  };
154
155
  }
156
+ /**
157
+ * Response shape for `TEAM_SCOPED_FILTERED_ISSUES_QUERY` (DEV-5578).
158
+ * `team` is null when the resolved team UUID does not exist.
159
+ */
160
+ export interface TeamScopedFilteredIssuesResponse {
161
+ team: {
162
+ issues: {
163
+ nodes: IssueNode[];
164
+ };
165
+ } | null;
166
+ }
155
167
  /** Response shape for `SEARCH_ISSUES_QUERY` (full-text). */
156
168
  export interface SearchIssuesResponse {
157
169
  searchIssues: {
@@ -1,6 +1,25 @@
1
- export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
2
- export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
3
- export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
1
+ export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
2
+ export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
3
+ export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
4
+ /**
5
+ * Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
6
+ *
7
+ * When a `--team` filter is present, the team boundary is applied
8
+ * *structurally* — the `issues` connection is rooted at the `Team` node
9
+ * (`team(id: $teamId).issues(...)`) rather than passed as a top-level
10
+ * `issues(filter: { team: { id: { eq } } })` relation filter.
11
+ *
12
+ * The top-level relation-filter form is unreliable at scale: Linear's API
13
+ * silently leaks issues from *other* teams once `$first` grows past a small
14
+ * page (~20), so `issues list --team DEV --limit 100` returned issues from
15
+ * EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
16
+ * `projects list --team` (`ProjectFilter` had no working `teams` relation, so
17
+ * project scoping moved to `Team.projects`). Rooting at the team node keeps
18
+ * the team boundary server-side and exact. The remaining `$filter`
19
+ * (state / labels / assignee / priority / project) is applied on top of the
20
+ * already-team-scoped connection.
21
+ */
22
+ export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
4
23
  /**
5
24
  * Batch-resolves a search's team/project/assignee/delegate filter inputs.
6
25
  *
@@ -11,8 +30,8 @@ export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIss
11
30
  * name-resolution arm is needed here.)
12
31
  */
13
32
  export declare const BATCH_RESOLVE_FOR_SEARCH_QUERY = "\n query BatchResolveForSearch(\n $teamKey: String\n $teamName: String\n $projectName: String\n $hasProjectName: Boolean = false\n $assigneeEmail: String\n $delegateEmail: String\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 1\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n }\n }\n\n users(filter: { email: { eq: $assigneeEmail } }, first: 1) {\n nodes {\n id\n name\n email\n }\n }\n\n delegates: users(filter: { email: { eq: $delegateEmail } }, first: 1) {\n nodes {\n id\n name\n email\n }\n }\n }\n";
14
- export declare const GET_ISSUE_BY_ID_QUERY = "\n query GetIssue($id: String!) {\n issue(id: $id) {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n";
15
- export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n issues(\n filter: {\n team: { key: { eq: $teamKey } }\n number: { eq: $number }\n }\n first: 1\n ) {\n nodes {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n }\n";
33
+ export declare const GET_ISSUE_BY_ID_QUERY = "\n query GetIssue($id: String!) {\n issue(id: $id) {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n";
34
+ export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n issues(\n filter: {\n team: { key: { eq: $teamKey } }\n number: { eq: $number }\n }\n first: 1\n ) {\n nodes {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n }\n";
16
35
  /**
17
36
  * Batch issue fetch. Used by `issues read <id...>` (DEV-4477) to collapse
18
37
  * N parallel single-issue queries into one round-trip. The `$filter` is
@@ -34,7 +53,7 @@ export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdenti
34
53
  * CLAUDE.md ("Performance-critical loops where the SDK's per-edge
35
54
  * resolver promises produce N+1 round-trips") explicitly authorizes this.
36
55
  */
37
- export declare const BATCH_GET_ISSUES_QUERY = "\n query BatchGetIssues($filter: IssueFilter!, $first: Int!) {\n issues(filter: $filter, first: $first) {\n nodes {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n }\n";
56
+ export declare const BATCH_GET_ISSUES_QUERY = "\n query BatchGetIssues($filter: IssueFilter!, $first: Int!) {\n issues(filter: $filter, first: $first) {\n nodes {\n \n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n\n }\n }\n }\n";
38
57
  /**
39
58
  * Batch-resolves an update's project/milestone/issue inputs.
40
59
  *
@@ -45,8 +64,8 @@ export declare const BATCH_GET_ISSUES_QUERY = "\n query BatchGetIssues($filter:
45
64
  * `--project-milestone` name resolution; a name uses `projectsByName`.
46
65
  */
47
66
  export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n issues(\n filter: {\n and: [\n { team: { key: { eq: $teamKey } } }\n { number: { eq: $issueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n team {\n id\n key\n }\n labels {\n nodes {\n id\n name\n }\n }\n project {\n id\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n }\n }\n";
48
- export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
49
- export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
67
+ export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
68
+ export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
50
69
  export declare const ARCHIVE_ISSUE_MUTATION = "\n mutation ArchiveIssue($id: String!) {\n issueArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
51
70
  export declare const DELETE_ISSUE_MUTATION = "\n mutation DeleteIssue($id: String!, $permanentlyDelete: Boolean) {\n issueDelete(id: $id, permanentlyDelete: $permanentlyDelete) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
52
71
  /**
@@ -41,6 +41,45 @@ export const FILTERED_SEARCH_ISSUES_QUERY = `
41
41
  }
42
42
  }
43
43
  `;
44
+ /**
45
+ * Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
46
+ *
47
+ * When a `--team` filter is present, the team boundary is applied
48
+ * *structurally* — the `issues` connection is rooted at the `Team` node
49
+ * (`team(id: $teamId).issues(...)`) rather than passed as a top-level
50
+ * `issues(filter: { team: { id: { eq } } })` relation filter.
51
+ *
52
+ * The top-level relation-filter form is unreliable at scale: Linear's API
53
+ * silently leaks issues from *other* teams once `$first` grows past a small
54
+ * page (~20), so `issues list --team DEV --limit 100` returned issues from
55
+ * EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
56
+ * `projects list --team` (`ProjectFilter` had no working `teams` relation, so
57
+ * project scoping moved to `Team.projects`). Rooting at the team node keeps
58
+ * the team boundary server-side and exact. The remaining `$filter`
59
+ * (state / labels / assignee / priority / project) is applied on top of the
60
+ * already-team-scoped connection.
61
+ */
62
+ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
63
+ query TeamScopedFilteredIssues(
64
+ $teamId: String!
65
+ $first: Int!
66
+ $filter: IssueFilter
67
+ $orderBy: PaginationOrderBy
68
+ ) {
69
+ team(id: $teamId) {
70
+ issues(
71
+ first: $first
72
+ filter: $filter
73
+ orderBy: $orderBy
74
+ includeArchived: false
75
+ ) {
76
+ nodes {
77
+ ${COMPLETE_ISSUE_FRAGMENT}
78
+ }
79
+ }
80
+ }
81
+ }
82
+ `;
44
83
  /**
45
84
  * Batch-resolves a search's team/project/assignee/delegate filter inputs.
46
85
  *
@@ -1,5 +1,5 @@
1
1
  export declare const LIST_PROJECT_MILESTONES_QUERY = "\n query ListProjectMilestones($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectMilestones(first: $first) {\n nodes {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n }\n }\n }\n }\n";
2
- export declare const GET_PROJECT_MILESTONE_BY_ID_QUERY = "\n query GetProjectMilestone($id: String!, $issuesFirst: Int) {\n projectMilestone(id: $id) {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n issues(first: $issuesFirst) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
2
+ export declare const GET_PROJECT_MILESTONE_BY_ID_QUERY = "\n query GetProjectMilestone($id: String!, $issuesFirst: Int) {\n projectMilestone(id: $id) {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n issues(first: $issuesFirst) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
3
3
  export declare const FIND_PROJECT_MILESTONE_BY_NAME_SCOPED = "\n query FindProjectMilestoneScoped($name: String!, $projectId: String!) {\n project(id: $projectId) {\n projectMilestones(filter: { name: { eq: $name } }, first: 10) {\n nodes {\n id\n name\n targetDate\n sortOrder\n project {\n id\n name\n }\n }\n }\n }\n }\n";
4
4
  export declare const FIND_PROJECT_MILESTONE_BY_NAME_GLOBAL = "\n query FindProjectMilestoneGlobal($name: String!) {\n projectMilestones(filter: { name: { eq: $name } }, first: 10) {\n nodes {\n id\n name\n targetDate\n sortOrder\n project {\n id\n name\n }\n }\n }\n }\n";
5
5
  export declare const CREATE_PROJECT_MILESTONE_MUTATION = "\n mutation CreateProjectMilestone($projectId: String!, $name: String!, $description: String, $targetDate: TimelessDate) {\n projectMilestoneCreate(input: {\n projectId: $projectId\n name: $name\n description: $description\n targetDate: $targetDate\n }) {\n success\n projectMilestone {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n }\n }\n }\n";
@@ -114,6 +114,7 @@ export interface LinearIssue {
114
114
  assignee?: UserRef;
115
115
  branchName?: string;
116
116
  comments?: LinearComment[];
117
+ completedAt?: string;
117
118
  createdAt: string;
118
119
  cycle?: CycleRef;
119
120
  delegate?: UserRef;
@@ -301,6 +301,7 @@ const ISSUE_SUMMARY_SYNONYMS = {
301
301
  owner: "assignee",
302
302
  createdat: "created",
303
303
  updatedat: "updated",
304
+ completedat: "completed",
304
305
  prioritylabel: "priority",
305
306
  projectmilestone: "milestone",
306
307
  };
@@ -316,6 +317,7 @@ function buildIssueHeaderFields(issue) {
316
317
  { label: "Estimate", value: s(issue.estimate) },
317
318
  { label: "Created", value: s(issue.createdAt) },
318
319
  { label: "Updated", value: s(issue.updatedAt) },
320
+ { label: "Completed", value: s(issue.completedAt) },
319
321
  { label: "URL", value: s(issue.url) },
320
322
  ];
321
323
  }
@@ -424,6 +426,11 @@ const ISSUE_LIST_EXTRAS = {
424
426
  minWidth: 7,
425
427
  extract: (i) => s(i.updatedAt).slice(0, 10),
426
428
  },
429
+ completedat: {
430
+ header: "COMPLETED",
431
+ minWidth: 9,
432
+ extract: (i) => s(i.completedAt).slice(0, 10),
433
+ },
427
434
  team: {
428
435
  header: "TEAM",
429
436
  minWidth: 4,
@@ -1,9 +1,9 @@
1
1
  import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
2
2
  import { resolveUserDisplayName } from "../config/resolver.js";
3
- import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
+ import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_SCOPED_FILTERED_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
4
4
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
5
5
  import { TERMINAL_STATE_TYPES } from "../types/linear.js";
6
- import { toISOStringOrNow } from "./date-format.js";
6
+ import { toISOStringOrNow, toISOStringOrUndefined } from "./date-format.js";
7
7
  import { extractEmbeds } from "./embed-parser.js";
8
8
  import { multipleMatchesError, notFoundError } from "./error-messages.js";
9
9
  import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-parser.js";
@@ -556,8 +556,14 @@ export class GraphQLIssuesService {
556
556
  priority: args.priority,
557
557
  });
558
558
  }
559
+ // DEV-5578: the team boundary is NOT put in the IssueFilter. Linear's
560
+ // top-level `issues(filter: { team: { id: { eq } } })` relation filter
561
+ // leaks other teams' issues once `first` grows past a small page (~20),
562
+ // so `--team` was silently contaminated on paginated (`--limit > ~20`)
563
+ // results. When a team is present we scope structurally through the
564
+ // `Team.issues` connection below (same class of fix as DEV-5325's
565
+ // `Team.projects`); the rest of the filter rides on top of it.
559
566
  const filter = this.buildSearchFilter({
560
- teamId: finalTeamId,
561
567
  assigneeId: finalAssigneeId,
562
568
  delegateId: finalDelegateId,
563
569
  project: projectFilter,
@@ -566,10 +572,25 @@ export class GraphQLIssuesService {
566
572
  labelNames: args.labelNames,
567
573
  priority: args.priority,
568
574
  });
575
+ const filterArg = Object.keys(filter).length > 0 ? filter : undefined;
576
+ const orderBy = args.orderBy ?? "updatedAt";
577
+ if (finalTeamId) {
578
+ const teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
579
+ teamId: finalTeamId,
580
+ first: limit,
581
+ filter: filterArg,
582
+ orderBy,
583
+ });
584
+ const nodes = teamScoped.team?.issues?.nodes;
585
+ if (!nodes?.length) {
586
+ return [];
587
+ }
588
+ return nodes.map((issue) => this.transformIssueData(issue));
589
+ }
569
590
  const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
570
591
  first: limit,
571
- filter: Object.keys(filter).length > 0 ? filter : undefined,
572
- orderBy: args.orderBy ?? "updatedAt",
592
+ filter: filterArg,
593
+ orderBy,
573
594
  });
574
595
  const filteredIssues = searchResult.issues;
575
596
  if (!filteredIssues?.nodes) {
@@ -999,11 +1020,12 @@ export class GraphQLIssuesService {
999
1020
  }
1000
1021
  return filtered;
1001
1022
  }
1023
+ // DEV-5578: `teamId` is intentionally NOT a member — the team boundary is
1024
+ // applied structurally via `TEAM_SCOPED_FILTERED_ISSUES_QUERY`, never as a
1025
+ // top-level `team` relation filter (which Linear leaks past ~20 rows). Do
1026
+ // not reintroduce a `filter.team = …` branch here.
1002
1027
  buildSearchFilter(filters) {
1003
1028
  const filter = {};
1004
- if (filters.teamId) {
1005
- filter.team = { id: { eq: filters.teamId } };
1006
- }
1007
1029
  if (filters.assigneeId) {
1008
1030
  filter.assignee = { id: { eq: filters.assigneeId } };
1009
1031
  }
@@ -1270,6 +1292,9 @@ export class GraphQLIssuesService {
1270
1292
  : [],
1271
1293
  createdAt: toISOStringOrNow(issue.createdAt),
1272
1294
  updatedAt: toISOStringOrNow(issue.updatedAt),
1295
+ // Nullable, not `-OrNow`: an open issue has no completion, so it must
1296
+ // stay `undefined` rather than be fabricated as the current time.
1297
+ completedAt: toISOStringOrUndefined(issue.completedAt),
1273
1298
  };
1274
1299
  }
1275
1300
  transformIssueCoreFields(issue) {
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { resolveActiveProfile } from "../config/paths.js";
2
3
  import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
3
4
  import { logger } from "./logger.js";
4
5
  import { sanitizeForLog } from "./sanitize-for-log.js";
@@ -57,6 +58,19 @@ function getNestedPath(obj, path) {
57
58
  }
58
59
  return cur;
59
60
  }
61
+ /**
62
+ * Aliases for the table/csv column names that don't exist as literal keys
63
+ * on the JSON payload (DEV-5376). Tried only after the literal key and the
64
+ * dot-path both miss, and only kept when the alias target actually
65
+ * resolves — so a resource that genuinely lacks `state`/`updatedAt` still
66
+ * gets the explicit-null + `fields_unresolved` treatment. The requested
67
+ * name stays the output key (`{"status": "Todo"}`), matching the
68
+ * "consumers read exactly what they asked for" contract.
69
+ */
70
+ const FIELD_ALIASES = {
71
+ status: "state.name",
72
+ updated: "updatedAt",
73
+ };
60
74
  /**
61
75
  * Project `obj` down to the requested fields.
62
76
  *
@@ -64,6 +78,8 @@ function getNestedPath(obj, path) {
64
78
  * - Dot-separated paths (DEV-5323) resolve nested values; the requested path
65
79
  * string becomes a flat output key (`{"pipeline.status": "success"}`), so
66
80
  * consumers read exactly what they asked for.
81
+ * - Documented column aliases resolve when the literal key is absent
82
+ * (`status` → `state.name`, `updated` → `updatedAt`; DEV-5376).
67
83
  * - A field that resolves nowhere is emitted as an explicit `null` AND
68
84
  * reported via `unresolved` — never silently omitted. Silent omission made
69
85
  * a typo'd field indistinguishable from an empty value (DEV-5323).
@@ -114,6 +130,12 @@ function filterFields(obj, fields, unresolved) {
114
130
  result[field] = nested;
115
131
  continue;
116
132
  }
133
+ const alias = FIELD_ALIASES[field];
134
+ const aliased = alias !== undefined ? getNestedPath(source, alias) : undefined;
135
+ if (aliased !== undefined) {
136
+ result[field] = aliased;
137
+ continue;
138
+ }
117
139
  result[field] = null;
118
140
  unresolved?.add(field);
119
141
  }
@@ -354,7 +376,25 @@ function outputError(error) {
354
376
  // `lin_oauth_…` / `Bearer <payload>` in error text can't leak a token
355
377
  // into stdout, shell history, or CI logs. The wizard already sanitizes
356
378
  // its own log paths; this is the central error path on every command.
357
- const payload = JSON.stringify({ error: sanitizeForLog(error.message) }, null, 2);
379
+ //
380
+ // `activeProfile` is included so a "not found" error is distinguishable
381
+ // from "wrong workspace" without manually inspecting
382
+ // ~/.config/el-linear/active-profile (DEV-5610) — the active profile can
383
+ // change between commands (an explicit `profile use`, a different
384
+ // $EL_LINEAR_PROFILE, or another process on the machine switching the
385
+ // shared marker file), and a bare "X not found" reads as data loss or an
386
+ // API outage when the real cause is often just the wrong workspace.
387
+ // Guarded: resolveActiveProfile() throws on an invalid $EL_LINEAR_PROFILE
388
+ // value (a real, separate user error) — outputError is the last-resort
389
+ // error path, so this addition must not itself throw uncaught.
390
+ let activeProfile = "<unknown>";
391
+ try {
392
+ activeProfile = resolveActiveProfile().name ?? "<legacy default>";
393
+ }
394
+ catch {
395
+ // leave activeProfile as "<unknown>"
396
+ }
397
+ const payload = JSON.stringify({ error: sanitizeForLog(error.message), activeProfile }, null, 2);
358
398
  // Write to stdout (same channel as success) so machine callers always
359
399
  // receive exactly one parseable JSON object regardless of stream capture.
360
400
  logger.info(payload);
@@ -9,3 +9,16 @@ import type { Command, OptionValues } from "commander";
9
9
  * assertions live here so call sites can stay clean and typed.
10
10
  */
11
11
  export declare function getRootOpts(command: Command): OptionValues;
12
+ /**
13
+ * Resolve an option that is registered on BOTH the root program and a
14
+ * subcommand (`--format` / `--fields` on `issues list|search`,
15
+ * `projects list`).
16
+ *
17
+ * Commander 15 hands a same-named option to the OUTERMOST registration
18
+ * even when it appears after the subcommand: `issues list --format table`
19
+ * sets the program's global `--format` and leaves the subcommand's local
20
+ * option at its default, so handlers reading `options.format` silently
21
+ * saw `"json"` (DEV-5376). Precedence here: local CLI-set value, then the
22
+ * nearest ancestor's CLI-set value, then the local default.
23
+ */
24
+ export declare function effectiveOption(command: Command, key: string): string | undefined;
@@ -11,3 +11,26 @@ export function getRootOpts(command) {
11
11
  // biome-ignore lint/style/noNonNullAssertion: command.parent / .parent.parent are guaranteed by commander inside subcommand actions; see docstring
12
12
  return (command.parent?.parent ?? command.parent).opts();
13
13
  }
14
+ /**
15
+ * Resolve an option that is registered on BOTH the root program and a
16
+ * subcommand (`--format` / `--fields` on `issues list|search`,
17
+ * `projects list`).
18
+ *
19
+ * Commander 15 hands a same-named option to the OUTERMOST registration
20
+ * even when it appears after the subcommand: `issues list --format table`
21
+ * sets the program's global `--format` and leaves the subcommand's local
22
+ * option at its default, so handlers reading `options.format` silently
23
+ * saw `"json"` (DEV-5376). Precedence here: local CLI-set value, then the
24
+ * nearest ancestor's CLI-set value, then the local default.
25
+ */
26
+ export function effectiveOption(command, key) {
27
+ if (command.getOptionValueSource(key) === "cli") {
28
+ return command.getOptionValue(key);
29
+ }
30
+ for (let c = command.parent; c; c = c.parent) {
31
+ if (c.getOptionValueSource(key) === "cli") {
32
+ return c.getOptionValue(key);
33
+ }
34
+ }
35
+ return command.getOptionValue(key);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
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",