@enrichlayer/el-linear 1.23.0 → 1.25.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
@@ -528,11 +528,92 @@ continue to accept their per-command formats too: `table`, `md`,
528
528
  `markdown`, `csv` — those go to the per-command rendering path. The
529
529
  global `summary` value works on every read/list command.
530
530
 
531
- `--format summary` does not compose with `--jq` (jq is JSON-only) or
532
- `--fields` (fields filter the JSON shape, not the rendered text). Use
531
+ `--format summary` does not compose with `--jq` (jq is JSON-only). Use
533
532
  `--raw` together with `--format summary` to render a list envelope as a
534
533
  bare item-list rather than an envelope.
535
534
 
535
+ #### `--fields` on `--format summary` — project additional columns
536
+
537
+ `--fields` on a summary render is a **projection request**, not a
538
+ JSON-shape filter (DEV-4750). For list outputs it sets the column set;
539
+ for single-resource outputs it filters the labelled field block beneath
540
+ the headline. Order is preserved: `--fields project,identifier,title`
541
+ renders `PROJECT ID TITLE`.
542
+
543
+ Currently wired through:
544
+
545
+ | Resource | Defaults | Extras you can request |
546
+ |----------|----------|------------------------|
547
+ | `issues list` | `id, title, state, assignee` | `project, cycle, milestone, labels, url, priority, estimate, createdAt, updatedAt, team` |
548
+ | `projects list` | `name, state, progress, lead` | `teams, target, url, updatedAt` |
549
+ | `issues read` (single) | `state, assignee, project, cycle, milestone, labels, url` | `priority, estimate, created, updated` |
550
+ | `projects read` (single) | `state, lead, teams, target, progress, url` | (filter only — no extras) |
551
+
552
+ `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`.
553
+
554
+ ```bash
555
+ # issues list with project column (the gap that motivated DEV-4750):
556
+ el-linear issues list --status "In Progress" --format summary \
557
+ --fields identifier,title,status,assignee,project
558
+ # ID TITLE STATE ASSIGNEE PROJECT
559
+ # -----------------------------------------------------------------------------
560
+ # DEV-1 Migrate auth middleware In Progress Alice Auth Refactor
561
+ # ...
562
+
563
+ # projects list with teams column:
564
+ el-linear projects list --format summary --fields name,state,progress,lead,teams
565
+ # NAME STATE PROGRESS LEAD TEAMS
566
+ # ---------------------------------------------------------
567
+ # Auth Refactor started 65% Alice DEV, FE
568
+ # ...
569
+ ```
570
+
571
+ Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
572
+
573
+ ### Windowed metadata (`WindowedMeta`)
574
+
575
+ When a command returns less than its complete result set — because it
576
+ windowed by time, paginated, filtered, or hit a `--limit` — it should make
577
+ that visible in the envelope's `meta` rather than leaving the consumer to
578
+ guess. The shared output package (`@enrichlayer/el-linear/output`) exports a
579
+ canonical `WindowedMeta` type for exactly these fields, so every CLI built on
580
+ it uses one set of names instead of ad-hoc `_window` / `_total` / `truncated`
581
+ keys:
582
+
583
+ | Field | Populate when… |
584
+ | ---------------- | --------------------------------------------------------------------- |
585
+ | `_window` | a time/scope window was applied — `"30d"`, `"since 2026-06-01"`. |
586
+ | `_limit_applied` | a cap is in effect — the caller's value, or the default when omitted. |
587
+ | `_query` | a search / filter expression produced `data`. |
588
+ | `_total` | the total matching rows *before* windowing / limiting / filtering. |
589
+ | `_fetched` | rows in *this* response (equals `meta.count` for list envelopes). |
590
+ | `truncated` | `_fetched` hit `_limit_applied` and more rows exist beyond this page. |
591
+ | `availability` | `{status: "complete" \| "partial" \| "degraded", detail?}` — emit `degraded` when a sub-source failed, never an empty result that reads as "no hits". |
592
+
593
+ All fields are optional; a command populates only the ones that apply. The
594
+ `meta` object still admits CLI-specific counters (`_total_hits`,
595
+ `_source_users_total`, …) alongside these, but prefer the generic field where
596
+ one fits so cross-CLI tooling and skills can read a single shape. Skill output
597
+ templates that show counts MUST consume `_total` / `truncated` from `meta`
598
+ rather than counting returned rows.
599
+
600
+ This convention comes from the output-transparency audit's **"Standard
601
+ Convention"** section (`docs/output-transparency-audit-report.md` in the
602
+ `vertical-int/tools` repo, DEV-3810); `WindowedMeta` is the shared type that
603
+ audit recommends promoting into the envelope (DEV-4668).
604
+
605
+ ```ts
606
+ import type { WindowedMeta } from "@enrichlayer/el-linear/output";
607
+
608
+ // A list command echoing what it windowed and whether it clipped:
609
+ outputList(rows, {
610
+ _window: "30d",
611
+ _limit_applied: 100,
612
+ _total: 247,
613
+ truncated: rows.length === 100,
614
+ } satisfies WindowedMeta);
615
+ ```
616
+
536
617
  ### Extract a single description section: `--field`
537
618
 
538
619
  `issues read --field <name>` extracts one named section from an issue's
@@ -21,7 +21,20 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
21
21
  // Branch naming conventions — kept in sync with branch-name validators
22
22
  // elsewhere in the workspace. Any addition here is the single point of
23
23
  // truth that all skills rely on.
24
- const BRANCH_RE = /^(?:feature|fix|chore|refactor|dev)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
24
+ //
25
+ // Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; both
26
+ // mirror tools-repo DEV-4417):
27
+ // feature | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
28
+ // bug | spike — sanctioned Linear type labels
29
+ // codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
30
+ // New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
31
+ // type labels (bug, spike) get first-class issue detection so commit guards,
32
+ // MR descriptions, and session handoff don't go dark on a branch a human
33
+ // wouldn't even notice was prefixed differently.
34
+ //
35
+ // Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
36
+ // vertical-int/tools repo. If you change one, change the other.
37
+ const BRANCH_RE = /^(?:feature|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
25
38
  export function parseBranchName(branch) {
26
39
  const m = branch.match(BRANCH_RE);
27
40
  if (!m) {
package/dist/output.d.ts CHANGED
@@ -79,4 +79,4 @@
79
79
  * redactor of its own; coupling token redaction to the output layer
80
80
  * would make this API surface stickier than it needs to be.
81
81
  */
82
- export { type CliListEnvelope, getOutputFormat, handleAsyncCommand, type ListExtraMeta, type ListMeta, outputList, outputSingle, outputSuccess, outputWarning, resetWarnings, setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, warnIfTruncated, } from "./utils/output.js";
82
+ export { type CliListEnvelope, getOutputFormat, handleAsyncCommand, type ListExtraMeta, type ListMeta, outputList, outputSingle, outputSuccess, outputWarning, resetWarnings, setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, type WindowedMeta, warnIfTruncated, } from "./utils/output.js";
@@ -14,10 +14,16 @@
14
14
  * pretty-print. Field ordering and labels should not change across
15
15
  * patch releases without a CHANGELOG entry.
16
16
  */
17
- export declare function formatIssueSummary(issue: Record<string, unknown>): string;
18
- export declare function formatIssueList(issues: unknown[]): string;
19
- export declare function formatProjectSummary(project: Record<string, unknown>): string;
20
- export declare function formatProjectList(projects: unknown[]): string;
17
+ /**
18
+ * Drain the formatter-level `_warnings` buffer. Returns the collected
19
+ * warnings and resets the queue. Called by `outputSuccess` after every
20
+ * summary render to surface unprojectable-field hints to scripts.
21
+ */
22
+ export declare function drainSummaryFieldWarnings(): string[];
23
+ export declare function formatIssueSummary(issue: Record<string, unknown>, fields?: string[]): string;
24
+ export declare function formatIssueList(issues: unknown[], fields?: string[]): string;
25
+ export declare function formatProjectSummary(project: Record<string, unknown>, fields?: string[]): string;
26
+ export declare function formatProjectList(projects: unknown[], fields?: string[]): string;
21
27
  export declare function formatCommentSummary(comment: Record<string, unknown>): string;
22
28
  export declare function formatCommentList(comments: unknown[]): string;
23
29
  export declare function formatCycleSummary(cycle: Record<string, unknown>): string;
@@ -55,11 +61,19 @@ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" |
55
61
  */
56
62
  export declare function inferKindFromPayload(value: unknown): ResourceKind;
57
63
  /**
58
- * Central dispatch: given a resource kind and payload, return the
59
- * formatted string. The payload for list kinds may be either the raw
60
- * array or a `{ data: [...] }` envelope — both are handled.
64
+ * Central dispatch: given a resource kind, payload, and optional `--fields`
65
+ * projection list, return the formatted string. The payload for list kinds
66
+ * may be either the raw array or a `{ data: [...] }` envelope — both are
67
+ * handled.
68
+ *
69
+ * `fields` is forwarded to formatters that wire `--fields` projection
70
+ * (issues, projects). For other kinds it's an opt-out path: we emit a
71
+ * `fields_unprojectable` warning and render defaults. The eventual
72
+ * direction is to wire projection through every list formatter; until
73
+ * then the warning gives consumers a deterministic signal instead of
74
+ * silently ignoring their flag.
61
75
  */
62
- export declare function dispatch(kind: ResourceKind, payload: unknown): string;
76
+ export declare function dispatch(kind: ResourceKind, payload: unknown, fields?: string[]): string;
63
77
  /**
64
78
  * One-line confirmation render for the `--quiet` write path.
65
79
  *
@@ -116,6 +116,109 @@ function joinLabels(v) {
116
116
  .filter((x) => x && x !== "—")
117
117
  .join(", ");
118
118
  }
119
+ /**
120
+ * Select the ordered column set for a `--fields` request. Returns
121
+ * `{ columns, unprojectable }` so the caller can render and warn.
122
+ *
123
+ * Resolution order per requested name (case-insensitive):
124
+ *
125
+ * 1. Synonym lookup (`status` → `state`, `id` → `identifier`).
126
+ * 2. Match against a `defaults` column header.
127
+ * 3. Match against an `extras` entry.
128
+ * 4. Otherwise → `unprojectable`.
129
+ *
130
+ * The returned columns preserve the order the user requested. This is the
131
+ * contract: `--fields project,identifier,title` renders `PROJECT ID TITLE`.
132
+ */
133
+ function selectColumns(projection, requested) {
134
+ const columns = [];
135
+ const unprojectable = [];
136
+ const synonyms = projection.synonyms ?? {};
137
+ const extras = projection.extras ?? {};
138
+ // Build a lowercased default-header lookup once.
139
+ const defaultByHeader = new Map();
140
+ for (const col of projection.defaults) {
141
+ defaultByHeader.set(col.header.toLowerCase(), col);
142
+ }
143
+ for (const raw of requested) {
144
+ const name = raw.trim().toLowerCase();
145
+ if (!name)
146
+ continue;
147
+ const canonical = synonyms[name] ?? name;
148
+ const fromDefaults = defaultByHeader.get(canonical);
149
+ if (fromDefaults) {
150
+ columns.push(fromDefaults);
151
+ continue;
152
+ }
153
+ const fromExtras = extras[canonical];
154
+ if (fromExtras) {
155
+ columns.push(fromExtras);
156
+ continue;
157
+ }
158
+ unprojectable.push(raw);
159
+ }
160
+ return { columns, unprojectable };
161
+ }
162
+ /**
163
+ * Filter the single-resource HeaderField list by user-requested field
164
+ * names (DEV-4750). Matches each requested name against the field's
165
+ * lowercased label and an optional synonym map. Unknown names are
166
+ * reported in `unprojectable[]`.
167
+ *
168
+ * Order of the returned fields preserves the user's request order. The
169
+ * implicit headline (identifier line for issues, name for projects) is
170
+ * the caller's responsibility — `--fields` only filters the labelled
171
+ * key/value block beneath it.
172
+ */
173
+ function selectHeaderFields(defaults, requested, synonyms = {}) {
174
+ const byLabel = new Map();
175
+ for (const f of defaults) {
176
+ byLabel.set(f.label.toLowerCase(), f);
177
+ }
178
+ const fields = [];
179
+ const unprojectable = [];
180
+ for (const raw of requested) {
181
+ const name = raw.trim().toLowerCase();
182
+ if (!name)
183
+ continue;
184
+ const canonical = synonyms[name] ?? name;
185
+ const match = byLabel.get(canonical);
186
+ if (match) {
187
+ fields.push(match);
188
+ }
189
+ else {
190
+ unprojectable.push(raw);
191
+ }
192
+ }
193
+ return { fields, unprojectable };
194
+ }
195
+ /**
196
+ * Module-level sink for unprojectable `--fields` names surfaced from the
197
+ * summary formatters. The output dispatcher (`outputSuccess` in
198
+ * `utils/output.ts`) drains this between renders and forwards them to
199
+ * `outputWarning` so they ride out on the next JSON envelope's
200
+ * `_warnings`. Kept here rather than in output.ts so the formatter stays
201
+ * the single source of truth on what is and isn't projectable.
202
+ *
203
+ * Internal — consumers should call `drainSummaryFieldWarnings()`.
204
+ */
205
+ const summaryFieldWarnings = [];
206
+ function recordUnprojectable(resource, names) {
207
+ if (names.length === 0)
208
+ return;
209
+ summaryFieldWarnings.push(`fields_unprojectable: --format summary on ${resource} does not project ${names.join(", ")}; ` +
210
+ `use --format json --fields ${names.join(",")} or omit the field(s).`);
211
+ }
212
+ /**
213
+ * Drain the formatter-level `_warnings` buffer. Returns the collected
214
+ * warnings and resets the queue. Called by `outputSuccess` after every
215
+ * summary render to surface unprojectable-field hints to scripts.
216
+ */
217
+ export function drainSummaryFieldWarnings() {
218
+ const out = [...summaryFieldWarnings];
219
+ summaryFieldWarnings.length = 0;
220
+ return out;
221
+ }
119
222
  /**
120
223
  * Render a list of rows as a fixed-width text table with a header,
121
224
  * separator, body, and "<N> <noun>s" footer.
@@ -164,62 +267,219 @@ function renderHeader(fields) {
164
267
  .join("\n");
165
268
  }
166
269
  // ── issues ─────────────────────────────────────────────────────
167
- export function formatIssueSummary(issue) {
168
- const identifier = s(issue.identifier);
169
- const title = s(issue.title);
170
- const headerLine = `${identifier} ${title}`;
171
- const fields = [
270
+ /**
271
+ * Map a user-requested field name → the canonical lookup key used by
272
+ * `selectColumns` for the issues *list* formatter. Canonical keys are
273
+ * the lowercased column header (`id`, `title`, `state`, `assignee`)
274
+ * for defaults; extras are looked up by their key in
275
+ * `ISSUE_LIST_EXTRAS`.
276
+ *
277
+ * `identifier` (the JSON key) → `id` (the column header). `status` and
278
+ * `owner` are natural-language aliases for `state` and `assignee`.
279
+ *
280
+ * `prioritylabel` / `projectmilestone` are the raw JSON-field spellings a
281
+ * script author might reach for; they map to the `priority` / `milestone`
282
+ * column keys so the list formatter accepts the same names the
283
+ * single-resource formatter does (`ISSUE_SUMMARY_SYNONYMS`) — keeping the
284
+ * two surfaces in sync.
285
+ */
286
+ const ISSUE_LIST_SYNONYMS = {
287
+ identifier: "id",
288
+ status: "state",
289
+ owner: "assignee",
290
+ prioritylabel: "priority",
291
+ projectmilestone: "milestone",
292
+ };
293
+ /**
294
+ * Map a user-requested field name → the canonical lowercased label used
295
+ * by the single-resource issue formatter. Single-resource labels are
296
+ * full words (`Created`, `Updated`), so `createdat` / `updatedat`
297
+ * (JSON-field spellings) need mapping. `status` / `owner` carry over.
298
+ */
299
+ const ISSUE_SUMMARY_SYNONYMS = {
300
+ status: "state",
301
+ owner: "assignee",
302
+ createdat: "created",
303
+ updatedat: "updated",
304
+ prioritylabel: "priority",
305
+ projectmilestone: "milestone",
306
+ };
307
+ function buildIssueHeaderFields(issue) {
308
+ return [
172
309
  { label: "State", value: getName(issue.state) },
173
310
  { label: "Assignee", value: getName(issue.assignee) },
174
311
  { label: "Project", value: getName(issue.project) },
175
312
  { label: "Cycle", value: getName(issue.cycle) },
176
313
  { label: "Milestone", value: getName(issue.projectMilestone) },
177
314
  { label: "Labels", value: joinLabels(issue.labels) },
315
+ { label: "Priority", value: s(issue.priorityLabel ?? issue.priority) },
316
+ { label: "Estimate", value: s(issue.estimate) },
317
+ { label: "Created", value: s(issue.createdAt) },
318
+ { label: "Updated", value: s(issue.updatedAt) },
178
319
  { label: "URL", value: s(issue.url) },
179
320
  ];
180
- const header = renderHeader(fields);
321
+ }
322
+ export function formatIssueSummary(issue, fields) {
323
+ const identifier = s(issue.identifier);
324
+ const title = s(issue.title);
325
+ const headerLine = `${identifier} ${title}`;
326
+ const allFields = buildIssueHeaderFields(issue);
327
+ let headerFields;
328
+ if (fields && fields.length > 0) {
329
+ // `identifier` / `title` make up the implicit headline above —
330
+ // strip them from the user's request so a `--fields title,state`
331
+ // doesn't try to render "Title:" inside the labelled block.
332
+ const filtered = fields.filter((f) => {
333
+ const name = f.trim().toLowerCase();
334
+ return name !== "identifier" && name !== "id" && name !== "title";
335
+ });
336
+ const selected = selectHeaderFields(allFields, filtered, ISSUE_SUMMARY_SYNONYMS);
337
+ recordUnprojectable("issue", selected.unprojectable);
338
+ headerFields = selected.fields;
339
+ }
340
+ else {
341
+ // Default issue summary historically omits priority / estimate / dates —
342
+ // keep that surface stable unless the caller asks for those columns.
343
+ const defaultLabels = new Set([
344
+ "state",
345
+ "assignee",
346
+ "project",
347
+ "cycle",
348
+ "milestone",
349
+ "labels",
350
+ "url",
351
+ ]);
352
+ headerFields = allFields.filter((f) => defaultLabels.has(f.label.toLowerCase()));
353
+ }
354
+ const header = renderHeader(headerFields);
181
355
  const body = clipDescription(issue.description);
182
356
  const parts = [headerLine, header];
183
357
  if (body)
184
358
  parts.push("", body);
185
359
  return parts.filter((p) => p !== "").join("\n");
186
360
  }
187
- export function formatIssueList(issues) {
188
- return renderTable(issues.map((raw) => asObj(raw) ?? {}), [
189
- { header: "ID", minWidth: 2, extract: (i) => s(i.identifier) },
190
- {
191
- header: "TITLE",
192
- minWidth: 5,
193
- maxWidth: TITLE_TRUNC,
194
- extract: (i) => s(i.title),
195
- },
196
- { header: "STATE", minWidth: 5, extract: (i) => getName(i.state) },
197
- {
198
- header: "ASSIGNEE",
199
- minWidth: 8,
200
- extract: (i) => getName(i.assignee),
361
+ const ISSUE_LIST_DEFAULTS = [
362
+ { header: "ID", minWidth: 2, extract: (i) => s(i.identifier) },
363
+ {
364
+ header: "TITLE",
365
+ minWidth: 5,
366
+ maxWidth: TITLE_TRUNC,
367
+ extract: (i) => s(i.title),
368
+ },
369
+ { header: "STATE", minWidth: 5, extract: (i) => getName(i.state) },
370
+ {
371
+ header: "ASSIGNEE",
372
+ minWidth: 8,
373
+ extract: (i) => getName(i.assignee),
374
+ },
375
+ ];
376
+ const ISSUE_LIST_EXTRAS = {
377
+ project: {
378
+ header: "PROJECT",
379
+ minWidth: 7,
380
+ maxWidth: 40,
381
+ extract: (i) => getName(i.project),
382
+ },
383
+ cycle: {
384
+ header: "CYCLE",
385
+ minWidth: 5,
386
+ maxWidth: 20,
387
+ extract: (i) => getName(i.cycle),
388
+ },
389
+ milestone: {
390
+ header: "MILESTONE",
391
+ minWidth: 9,
392
+ maxWidth: 30,
393
+ extract: (i) => getName(i.projectMilestone),
394
+ },
395
+ labels: {
396
+ header: "LABELS",
397
+ minWidth: 6,
398
+ maxWidth: 40,
399
+ extract: (i) => joinLabels(i.labels),
400
+ },
401
+ url: {
402
+ header: "URL",
403
+ minWidth: 3,
404
+ maxWidth: 60,
405
+ extract: (i) => s(i.url),
406
+ },
407
+ priority: {
408
+ header: "PRIORITY",
409
+ minWidth: 8,
410
+ extract: (i) => s(i.priorityLabel ?? i.priority),
411
+ },
412
+ estimate: {
413
+ header: "ESTIMATE",
414
+ minWidth: 8,
415
+ extract: (i) => s(i.estimate),
416
+ },
417
+ createdat: {
418
+ header: "CREATED",
419
+ minWidth: 7,
420
+ extract: (i) => s(i.createdAt).slice(0, 10),
421
+ },
422
+ updatedat: {
423
+ header: "UPDATED",
424
+ minWidth: 7,
425
+ extract: (i) => s(i.updatedAt).slice(0, 10),
426
+ },
427
+ team: {
428
+ header: "TEAM",
429
+ minWidth: 4,
430
+ extract: (i) => {
431
+ // issues carry team as either {key, name} or a bare key string.
432
+ const team = i.team;
433
+ const o = asObj(team);
434
+ if (o)
435
+ return s(o.key ?? o.name);
436
+ return s(team);
201
437
  },
202
- ], { emptyText: "(no issues)", itemNoun: "issue" });
438
+ },
439
+ };
440
+ export function formatIssueList(issues, fields) {
441
+ const rows = issues.map((raw) => asObj(raw) ?? {});
442
+ let columns = ISSUE_LIST_DEFAULTS;
443
+ if (fields && fields.length > 0) {
444
+ const selected = selectColumns({
445
+ defaults: ISSUE_LIST_DEFAULTS,
446
+ extras: ISSUE_LIST_EXTRAS,
447
+ synonyms: ISSUE_LIST_SYNONYMS,
448
+ }, fields);
449
+ recordUnprojectable("issues list", selected.unprojectable);
450
+ // If every requested name was unprojectable, fall back to defaults
451
+ // so the user still sees something useful with the warning attached.
452
+ columns =
453
+ selected.columns.length > 0 ? selected.columns : ISSUE_LIST_DEFAULTS;
454
+ }
455
+ return renderTable(rows, columns, {
456
+ emptyText: "(no issues)",
457
+ itemNoun: "issue",
458
+ });
203
459
  }
204
460
  // ── projects ───────────────────────────────────────────────────
205
- export function formatProjectSummary(project) {
461
+ const PROJECT_SYNONYMS = {
462
+ targetdate: "target",
463
+ };
464
+ function joinTeamKeys(value) {
465
+ if (!Array.isArray(value) || value.length === 0)
466
+ return "";
467
+ return value
468
+ .map((team) => {
469
+ const o = asObj(team);
470
+ return o ? s(o.key ?? o.name) : s(team);
471
+ })
472
+ .filter((x) => x && x !== "—")
473
+ .join(", ");
474
+ }
475
+ export function formatProjectSummary(project, fields) {
206
476
  const name = s(project.name);
207
477
  const headerLine = name;
208
- const teams = (() => {
209
- const t = project.teams;
210
- if (!Array.isArray(t) || t.length === 0)
211
- return "";
212
- return t
213
- .map((team) => {
214
- const o = asObj(team);
215
- return o ? s(o.key ?? o.name) : s(team);
216
- })
217
- .join(", ");
218
- })();
478
+ const teams = joinTeamKeys(project.teams);
219
479
  const progress = typeof project.progress === "number"
220
480
  ? `${Math.round(project.progress * 100)}%`
221
481
  : "";
222
- const fields = [
482
+ const allFields = [
223
483
  { label: "State", value: s(project.state) },
224
484
  { label: "Lead", value: getName(project.lead) },
225
485
  { label: "Teams", value: teams },
@@ -227,7 +487,20 @@ export function formatProjectSummary(project) {
227
487
  { label: "Progress", value: progress },
228
488
  { label: "URL", value: s(project.url) },
229
489
  ];
230
- const header = renderHeader(fields);
490
+ let headerFields;
491
+ if (fields && fields.length > 0) {
492
+ // `name` is the implicit headline above; drop it from the request
493
+ // so a user passing `--fields name,teams` doesn't render "Name:" in
494
+ // the labelled block beneath.
495
+ const filtered = fields.filter((f) => f.trim().toLowerCase() !== "name");
496
+ const selected = selectHeaderFields(allFields, filtered, PROJECT_SYNONYMS);
497
+ recordUnprojectable("project", selected.unprojectable);
498
+ headerFields = selected.fields;
499
+ }
500
+ else {
501
+ headerFields = allFields;
502
+ }
503
+ const header = renderHeader(headerFields);
231
504
  const body = clipDescription(project.description);
232
505
  const parts = [headerLine, header];
233
506
  if (body)
@@ -237,18 +510,58 @@ export function formatProjectSummary(project) {
237
510
  function pct(value) {
238
511
  return typeof value === "number" ? `${Math.round(value * 100)}%` : "—";
239
512
  }
240
- export function formatProjectList(projects) {
241
- return renderTable(projects.map((raw) => asObj(raw) ?? {}), [
242
- {
243
- header: "NAME",
244
- minWidth: 4,
245
- maxWidth: TITLE_TRUNC,
246
- extract: (p) => s(p.name),
247
- },
248
- { header: "STATE", minWidth: 5, extract: (p) => s(p.state) },
249
- { header: "PROGRESS", minWidth: 8, extract: (p) => pct(p.progress) },
250
- { header: "LEAD", minWidth: 4, extract: (p) => getName(p.lead) },
251
- ], { emptyText: "(no projects)", itemNoun: "project" });
513
+ const PROJECT_LIST_DEFAULTS = [
514
+ {
515
+ header: "NAME",
516
+ minWidth: 4,
517
+ maxWidth: TITLE_TRUNC,
518
+ extract: (p) => s(p.name),
519
+ },
520
+ { header: "STATE", minWidth: 5, extract: (p) => s(p.state) },
521
+ { header: "PROGRESS", minWidth: 8, extract: (p) => pct(p.progress) },
522
+ { header: "LEAD", minWidth: 4, extract: (p) => getName(p.lead) },
523
+ ];
524
+ const PROJECT_LIST_EXTRAS = {
525
+ teams: {
526
+ header: "TEAMS",
527
+ minWidth: 5,
528
+ maxWidth: 30,
529
+ extract: (p) => joinTeamKeys(p.teams),
530
+ },
531
+ target: {
532
+ header: "TARGET",
533
+ minWidth: 6,
534
+ extract: (p) => s(p.targetDate),
535
+ },
536
+ url: {
537
+ header: "URL",
538
+ minWidth: 3,
539
+ maxWidth: 60,
540
+ extract: (p) => s(p.url),
541
+ },
542
+ updatedat: {
543
+ header: "UPDATED",
544
+ minWidth: 7,
545
+ extract: (p) => s(p.updatedAt).slice(0, 10),
546
+ },
547
+ };
548
+ export function formatProjectList(projects, fields) {
549
+ const rows = projects.map((raw) => asObj(raw) ?? {});
550
+ let columns = PROJECT_LIST_DEFAULTS;
551
+ if (fields && fields.length > 0) {
552
+ const selected = selectColumns({
553
+ defaults: PROJECT_LIST_DEFAULTS,
554
+ extras: PROJECT_LIST_EXTRAS,
555
+ synonyms: PROJECT_SYNONYMS,
556
+ }, fields);
557
+ recordUnprojectable("projects list", selected.unprojectable);
558
+ columns =
559
+ selected.columns.length > 0 ? selected.columns : PROJECT_LIST_DEFAULTS;
560
+ }
561
+ return renderTable(rows, columns, {
562
+ emptyText: "(no projects)",
563
+ itemNoun: "project",
564
+ });
252
565
  }
253
566
  // ── comments ───────────────────────────────────────────────────
254
567
  export function formatCommentSummary(comment) {
@@ -694,11 +1007,30 @@ function inferListKind(items) {
694
1007
  return "generic";
695
1008
  }
696
1009
  /**
697
- * Central dispatch: given a resource kind and payload, return the
698
- * formatted string. The payload for list kinds may be either the raw
699
- * array or a `{ data: [...] }` envelope — both are handled.
1010
+ * Resources where `--fields` projection is wired through to the formatter
1011
+ * (DEV-4750). For any other kind, passing `--fields` records a
1012
+ * deterministic unprojectable warning and renders the default summary.
700
1013
  */
701
- export function dispatch(kind, payload) {
1014
+ const FIELDS_PROJECTED = new Set([
1015
+ "issue",
1016
+ "issue-list",
1017
+ "project",
1018
+ "project-list",
1019
+ ]);
1020
+ /**
1021
+ * Central dispatch: given a resource kind, payload, and optional `--fields`
1022
+ * projection list, return the formatted string. The payload for list kinds
1023
+ * may be either the raw array or a `{ data: [...] }` envelope — both are
1024
+ * handled.
1025
+ *
1026
+ * `fields` is forwarded to formatters that wire `--fields` projection
1027
+ * (issues, projects). For other kinds it's an opt-out path: we emit a
1028
+ * `fields_unprojectable` warning and render defaults. The eventual
1029
+ * direction is to wire projection through every list formatter; until
1030
+ * then the warning gives consumers a deterministic signal instead of
1031
+ * silently ignoring their flag.
1032
+ */
1033
+ export function dispatch(kind, payload, fields) {
702
1034
  const obj = asObj(payload);
703
1035
  const list = (() => {
704
1036
  if (Array.isArray(payload))
@@ -707,15 +1039,19 @@ export function dispatch(kind, payload) {
707
1039
  return obj.data;
708
1040
  return null;
709
1041
  })();
1042
+ const hasFields = Boolean(fields && fields.length > 0);
1043
+ if (hasFields && !FIELDS_PROJECTED.has(kind)) {
1044
+ recordUnprojectable(kind === "generic" ? "this resource" : kind, fields);
1045
+ }
710
1046
  switch (kind) {
711
1047
  case "issue":
712
- return formatIssueSummary((obj ?? {}));
1048
+ return formatIssueSummary((obj ?? {}), fields);
713
1049
  case "issue-list":
714
- return formatIssueList(list ?? []);
1050
+ return formatIssueList(list ?? [], fields);
715
1051
  case "project":
716
- return formatProjectSummary((obj ?? {}));
1052
+ return formatProjectSummary((obj ?? {}), fields);
717
1053
  case "project-list":
718
- return formatProjectList(list ?? []);
1054
+ return formatProjectList(list ?? [], fields);
719
1055
  case "comment":
720
1056
  return formatCommentSummary((obj ?? {}));
721
1057
  case "comment-list":
@@ -11,6 +11,65 @@ export declare function setFieldsFilter(fields: string[] | null): void;
11
11
  export declare function setOutputFormat(format: OutputFormat): void;
12
12
  /** @internal Test seam — consumers should not depend on the format state. */
13
13
  export declare function getOutputFormat(): OutputFormat;
14
+ /**
15
+ * Standard windowing / pagination / truncation metadata for any command
16
+ * that does not return its complete result set in one response.
17
+ *
18
+ * This is the canonical `WindowedMeta` type referenced by the
19
+ * output-transparency audit (DEV-3810 → DEV-4668). It promotes the
20
+ * `el-user usage` reference convention into the shared envelope so every
21
+ * consuming CLI uses the same field names instead of inventing ad-hoc
22
+ * `_window` / `_total` / `truncated` keys. The motivating principle:
23
+ * **every piece of data between a database and a decision-maker (human or
24
+ * LLM) should make its scope, limits, and assumptions visible in the
25
+ * output** — a consumer should never have to read source to interpret
26
+ * data correctly.
27
+ *
28
+ * All fields are optional: a command populates only the ones that apply.
29
+ * Because `ListMeta` / `ListExtraMeta` still carry an open
30
+ * `Record<string, unknown>` index, a CLI may also add its own
31
+ * domain-specific counters (`_total_hits`, `_indices_queried`,
32
+ * `_source_users_total`, …) alongside these — but where a generic field
33
+ * fits, prefer it so cross-CLI tooling and skills can read one shape.
34
+ *
35
+ * When to populate each field:
36
+ * - `_window` — the time/scope window applied, e.g. `"30d"`, `"12 months"`,
37
+ * `"since 2026-06-01"`.
38
+ * - `_limit_applied` — the cap actually in effect (the value the caller
39
+ * passed, or the command's default when they passed nothing).
40
+ * - `_query` — the search / filter expression applied to produce `data`.
41
+ * - `_total` — total matching rows *before* windowing / limiting /
42
+ * filtering. Lets a consumer report "showing N of `_total`".
43
+ * - `_fetched` — how many rows are in *this* response (distinct from
44
+ * `_total`). For list envelopes this equals `meta.count`.
45
+ * - `truncated` — `true` when `_fetched` hit `_limit_applied` and more
46
+ * rows exist beyond this page. Skills MUST consume this rather than
47
+ * counting returned rows to decide whether output is complete.
48
+ * - `availability` — per-response (or per-source) completeness signal.
49
+ * Emit `{status: "degraded", detail}` when a sub-source failed (e.g. a
50
+ * Slack timeout in an aggregator) rather than collapsing to an empty
51
+ * result indistinguishable from "no hits".
52
+ */
53
+ export interface WindowedMeta {
54
+ /** Time/scope window applied, e.g. `"30d"`, `"since 2026-06-01"`. */
55
+ _window?: string;
56
+ /** The cap actually in effect (caller's value, or the default). */
57
+ _limit_applied?: number;
58
+ /** The search / filter expression applied to produce `data`. */
59
+ _query?: string;
60
+ /** Total matching rows before windowing / limiting / filtering. */
61
+ _total?: number;
62
+ /** Rows in this response (equals `meta.count` for list envelopes). */
63
+ _fetched?: number;
64
+ /** `true` when `_fetched` hit `_limit_applied` — more rows exist. */
65
+ truncated?: boolean;
66
+ /** Per-response completeness signal; mirrors the `el-user` convention. */
67
+ availability?: {
68
+ status: "complete" | "partial" | "degraded";
69
+ /** Human-readable reason, e.g. `"result reached row cap 100"`. */
70
+ detail?: string;
71
+ };
72
+ }
14
73
  /**
15
74
  * Resource-specific extra metadata that may be added to a list response
16
75
  * alongside the canonical `count` (e.g. `query` on search, `team` on
@@ -18,12 +77,16 @@ export declare function getOutputFormat(): OutputFormat;
18
77
  * accidentally pass a string `count` and have it silently overridden;
19
78
  * the only way to set `count` is via `data.length` inside `outputList`.
20
79
  *
80
+ * Intersected with {@link WindowedMeta} so the standard windowing fields
81
+ * (`_total`, `truncated`, `_window`, …) are typed when present, while the
82
+ * open `Record<string, unknown>` index still admits CLI-specific keys.
83
+ *
21
84
  * Implementation note: `count?: never` together with `Record<string, unknown>`
22
85
  * lets TypeScript accept any other key while disallowing the literal
23
86
  * `count` key. The `never`-typed property is impossible to assign, which
24
87
  * is what we want for the "no count here" contract.
25
88
  */
26
- export type ListExtraMeta = Record<string, unknown> & {
89
+ export type ListExtraMeta = Record<string, unknown> & WindowedMeta & {
27
90
  count?: never;
28
91
  };
29
92
  /**
@@ -34,6 +97,10 @@ export type ListExtraMeta = Record<string, unknown> & {
34
97
  * read it both for emptiness checks (`.meta.count == 0`) and for the
35
98
  * actual magnitude (e.g. logging "found N issues"). A boolean isEmpty
36
99
  * would lose the magnitude signal, so `count: number` stays.
100
+ *
101
+ * The open `Record<string, unknown>` index admits the {@link WindowedMeta}
102
+ * fields a windowed list echoes (`_total`, `truncated`, `_window`, …);
103
+ * those are typed at the write site via {@link ListExtraMeta}.
37
104
  */
38
105
  export interface ListMeta extends Record<string, unknown> {
39
106
  count: number;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { dispatch as dispatchSummary, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
2
+ import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
3
3
  import { logger } from "./logger.js";
4
4
  import { sanitizeForLog } from "./sanitize-for-log.js";
5
5
  const warningBuffer = [];
@@ -56,9 +56,14 @@ function filterFields(obj, fields) {
56
56
  * the heuristic would fall through to "generic", silently breaking
57
57
  * the issue-list table. Caching the pre-filter shape keeps the
58
58
  * formatter accurate regardless of how the user pared the JSON.
59
+ *
60
+ * `fields` (when set) is forwarded to the formatter as a projection
61
+ * request (DEV-4750). For summary the JSON-shape `filterFields` is a
62
+ * no-op — the formatter needs the full object to extract nested values
63
+ * like `project.name` or `teams[].key`.
59
64
  */
60
- function emitSummary(payload, kind) {
61
- logger.info(dispatchSummary(kind, payload));
65
+ function emitSummary(payload, kind, fields) {
66
+ logger.info(dispatchSummary(kind, payload, fields ?? undefined));
62
67
  }
63
68
  /**
64
69
  * Typed wrapper around `outputSuccess` for list responses — builds the
@@ -95,17 +100,7 @@ export function outputSingle(data) {
95
100
  outputSuccess(data);
96
101
  }
97
102
  export function outputSuccess(data) {
98
- const warnings = drainWarnings();
99
- let output;
100
- if (warnings.length > 0 &&
101
- data !== null &&
102
- typeof data === "object" &&
103
- !Array.isArray(data)) {
104
- output = { ...data, _warnings: warnings };
105
- }
106
- else {
107
- output = data;
108
- }
103
+ let output = data;
109
104
  // Capture the kind from the original envelope shape BEFORE --raw /
110
105
  // --fields stripping. Otherwise filtering away signature fields (e.g.
111
106
  // `title` on an issue) breaks shape inference and the summary
@@ -115,9 +110,13 @@ export function outputSuccess(data) {
115
110
  // precedence on the write path and independent of --raw / --fields / --jq
116
111
  // (those reshape the payload the flag exists to avoid) — so we emit from
117
112
  // the full pre-filter object, otherwise `--fields identifier` would strip
118
- // the state/url formatLine needs and break shape inference.
113
+ // the state/url formatLine needs and break shape inference. Drain any
114
+ // buffered warnings to preserve pre-DEV-4750 behavior (they were
115
+ // silently dropped on the quiet path; the contract is "one line only").
119
116
  if (quietMode) {
120
117
  logger.info(formatLine(output));
118
+ drainWarnings();
119
+ drainSummaryFieldWarnings();
121
120
  return;
122
121
  }
123
122
  // --raw: unwrap { data: [...] } to just the array
@@ -130,8 +129,12 @@ export function outputSuccess(data) {
130
129
  output = obj.data;
131
130
  }
132
131
  }
133
- // --fields: filter object keys (applies to array items or flat objects)
134
- if (fieldsFilter) {
132
+ // --fields on summary format is a *projection request* — the formatter
133
+ // consumes it directly to extend/replace its column set (DEV-4750). The
134
+ // JSON-shape `filterFields` step would otherwise strip nested values
135
+ // (project.name, teams[].key) the formatter needs to render. Skip the
136
+ // JSON filter when summary is active and let the formatter do the work.
137
+ if (fieldsFilter && outputFormat !== "summary") {
135
138
  if (Array.isArray(output)) {
136
139
  output = filterFields(output, fieldsFilter);
137
140
  }
@@ -145,13 +148,35 @@ export function outputSuccess(data) {
145
148
  }
146
149
  }
147
150
  }
148
- // summary format takes the post-raw / post-fields value and renders it
149
- // as a human-readable block. We bypass the jq path because jq is a
150
- // JSON-shape filter — it doesn't compose with text output.
151
+ // summary format takes the post-raw value and renders it as a
152
+ // human-readable block. We bypass the jq path because jq is a
153
+ // JSON-shape filter — it doesn't compose with text output. The
154
+ // formatter records any unprojectable `--fields` names; we drain
155
+ // them after dispatch and append them as `_warnings: …` lines so
156
+ // the signal is visible without breaking the text contract.
151
157
  if (outputFormat === "summary") {
152
- emitSummary(output, inferredKind);
158
+ emitSummary(output, inferredKind, fieldsFilter);
159
+ const summaryWarnings = [
160
+ ...drainWarnings(),
161
+ ...drainSummaryFieldWarnings(),
162
+ ];
163
+ if (summaryWarnings.length > 0) {
164
+ for (const w of summaryWarnings) {
165
+ logger.info(`_warnings: ${w}`);
166
+ }
167
+ }
153
168
  return;
154
169
  }
170
+ // JSON path: drain buffered warnings (including any
171
+ // fields_unprojectable surfaced from a prior dispatch) and embed
172
+ // them as `_warnings` on the envelope.
173
+ const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
174
+ if (warnings.length > 0 &&
175
+ output !== null &&
176
+ typeof output === "object" &&
177
+ !Array.isArray(output)) {
178
+ output = { ...output, _warnings: warnings };
179
+ }
155
180
  if (jqFilter) {
156
181
  const json = JSON.stringify(output);
157
182
  // Normalize common shell-escape artifacts (zsh history expansion)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.23.0",
3
+ "version": "1.25.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",