@enrichlayer/el-linear 1.22.0 → 1.24.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
@@ -533,6 +533,50 @@ global `summary` value works on every read/list command.
533
533
  `--raw` together with `--format summary` to render a list envelope as a
534
534
  bare item-list rather than an envelope.
535
535
 
536
+ ### Windowed metadata (`WindowedMeta`)
537
+
538
+ When a command returns less than its complete result set — because it
539
+ windowed by time, paginated, filtered, or hit a `--limit` — it should make
540
+ that visible in the envelope's `meta` rather than leaving the consumer to
541
+ guess. The shared output package (`@enrichlayer/el-linear/output`) exports a
542
+ canonical `WindowedMeta` type for exactly these fields, so every CLI built on
543
+ it uses one set of names instead of ad-hoc `_window` / `_total` / `truncated`
544
+ keys:
545
+
546
+ | Field | Populate when… |
547
+ | ---------------- | --------------------------------------------------------------------- |
548
+ | `_window` | a time/scope window was applied — `"30d"`, `"since 2026-06-01"`. |
549
+ | `_limit_applied` | a cap is in effect — the caller's value, or the default when omitted. |
550
+ | `_query` | a search / filter expression produced `data`. |
551
+ | `_total` | the total matching rows *before* windowing / limiting / filtering. |
552
+ | `_fetched` | rows in *this* response (equals `meta.count` for list envelopes). |
553
+ | `truncated` | `_fetched` hit `_limit_applied` and more rows exist beyond this page. |
554
+ | `availability` | `{status: "complete" \| "partial" \| "degraded", detail?}` — emit `degraded` when a sub-source failed, never an empty result that reads as "no hits". |
555
+
556
+ All fields are optional; a command populates only the ones that apply. The
557
+ `meta` object still admits CLI-specific counters (`_total_hits`,
558
+ `_source_users_total`, …) alongside these, but prefer the generic field where
559
+ one fits so cross-CLI tooling and skills can read a single shape. Skill output
560
+ templates that show counts MUST consume `_total` / `truncated` from `meta`
561
+ rather than counting returned rows.
562
+
563
+ This convention comes from the output-transparency audit's **"Standard
564
+ Convention"** section (`docs/output-transparency-audit-report.md` in the
565
+ `vertical-int/tools` repo, DEV-3810); `WindowedMeta` is the shared type that
566
+ audit recommends promoting into the envelope (DEV-4668).
567
+
568
+ ```ts
569
+ import type { WindowedMeta } from "@enrichlayer/el-linear/output";
570
+
571
+ // A list command echoing what it windowed and whether it clipped:
572
+ outputList(rows, {
573
+ _window: "30d",
574
+ _limit_applied: 100,
575
+ _total: 247,
576
+ truncated: rows.length === 100,
577
+ } satisfies WindowedMeta);
578
+ ```
579
+
536
580
  ### Extract a single description section: `--field`
537
581
 
538
582
  `issues read --field <name>` extracts one named section from an issue's
@@ -1,6 +1,6 @@
1
1
  import { loadConfig } from "../config/config.js";
2
2
  import { resolveTeam } from "../config/resolver.js";
3
- import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
3
+ import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
4
4
  import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
5
5
  import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
@@ -413,6 +413,30 @@ async function handleDeleteProject(projectNameOrId, _options, command) {
413
413
  lastSyncId: payload.lastSyncId,
414
414
  });
415
415
  }
416
+ /**
417
+ * Read a single project (DEV-4610). Resolves the identifier through the same
418
+ * `resolveProjectId` path the `--project` flags use (UUID / slug / URL / name),
419
+ * fetches the full project, and emits it. `teams.nodes` is flattened to a plain
420
+ * array so the `--format summary` project renderer (and the JSON shape) match
421
+ * `projects list` — the formatter expects a flat team list, not a `{ nodes }`
422
+ * connection. Replaces the raw-`graphql` workaround for grabbing a project's
423
+ * url / content / lead.
424
+ */
425
+ async function handleReadProject(projectNameOrId, _options, command) {
426
+ const rootOpts = getRootOpts(command);
427
+ const graphQLService = await createGraphQLService(rootOpts);
428
+ const linearService = await createLinearService(rootOpts);
429
+ const projectId = await linearService.resolveProjectId(projectNameOrId);
430
+ const result = await graphQLService.rawRequest(PROJECT_READ_QUERY, { id: projectId });
431
+ if (!result.project) {
432
+ throw new Error(`Project "${projectNameOrId}" not found`);
433
+ }
434
+ const { teams, ...rest } = result.project;
435
+ outputSuccess({
436
+ ...rest,
437
+ teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
438
+ });
439
+ }
416
440
  export function setupProjectsCommands(program) {
417
441
  const projects = program
418
442
  .command("projects")
@@ -435,6 +459,10 @@ export function setupProjectsCommands(program) {
435
459
  .command("delete <project>")
436
460
  .description("Delete (trash) a project (resolves names)")
437
461
  .action(handleAsyncCommand(handleDeleteProject));
462
+ projects
463
+ .command("read <project>")
464
+ .description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
465
+ .action(handleAsyncCommand(handleReadProject));
438
466
  projects
439
467
  .command("list")
440
468
  .description("List projects")
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";
@@ -34,6 +34,24 @@ interface ProjectWithIssuesNode extends ProjectBaseNode {
34
34
  export interface ProjectByIdResponse {
35
35
  project: ProjectBaseNode | null;
36
36
  }
37
+ /** Full project read (`PROJECT_READ_QUERY`) — base fields + summary/JSON fields. */
38
+ interface ProjectReadNode extends ProjectBaseNode {
39
+ state: string;
40
+ progress: number;
41
+ url: string;
42
+ startDate: string | null;
43
+ targetDate: string | null;
44
+ description: string | null;
45
+ content: string | null;
46
+ lead: {
47
+ id: string;
48
+ name: string;
49
+ displayName: string;
50
+ } | null;
51
+ }
52
+ export interface ProjectReadResponse {
53
+ project: ProjectReadNode | null;
54
+ }
37
55
  export interface GetProjectResponse {
38
56
  projects: {
39
57
  nodes: ProjectBaseNode[];
@@ -1,5 +1,14 @@
1
1
  export declare const TEAM_LOOKUP_QUERY = "\n query TeamLookup($key: String) {\n teams(filter: { or: [{ key: { eq: $key } }, { name: { eq: $key } }] }, first: 1) {\n nodes { id key name }\n }\n }\n";
2
2
  export declare const PROJECT_BY_ID_QUERY = "\n query ProjectById($id: String!) {\n project(id: $id) {\n id\n name\n teams {\n nodes { id key name }\n }\n }\n }\n";
3
+ /**
4
+ * Full single-project read for `el-linear projects read` (DEV-4610). Carries
5
+ * everything the `--format summary` project renderer shows (name, state, lead,
6
+ * teams, target, progress, url) plus the long-form `description`/`content` the
7
+ * JSON envelope exposes — so callers no longer fall back to a raw `graphql`
8
+ * query for a project URL or its content. The `progress` + `teams` fields also
9
+ * make the payload self-describing to the summary kind-inference.
10
+ */
11
+ export declare const PROJECT_READ_QUERY = "\n query ProjectRead($id: String!) {\n project(id: $id) {\n id\n name\n state\n progress\n url\n startDate\n targetDate\n description\n content\n lead { id name displayName }\n teams {\n nodes { id key name }\n }\n }\n }\n";
3
12
  export declare const GET_PROJECT_QUERY = "\n query GetProject($name: String!) {\n projects(filter: { name: { eqIgnoreCase: $name } }, first: 1) {\n nodes {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
4
13
  export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIssues($projectId: String!, $teamId: String!) {\n project(id: $projectId) {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n issues(filter: { team: { id: { eq: $teamId } } }, first: 50) {\n nodes {\n id\n identifier\n title\n }\n }\n }\n }\n";
5
14
  export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
@@ -16,6 +16,33 @@ export const PROJECT_BY_ID_QUERY = `
16
16
  }
17
17
  }
18
18
  `;
19
+ /**
20
+ * Full single-project read for `el-linear projects read` (DEV-4610). Carries
21
+ * everything the `--format summary` project renderer shows (name, state, lead,
22
+ * teams, target, progress, url) plus the long-form `description`/`content` the
23
+ * JSON envelope exposes — so callers no longer fall back to a raw `graphql`
24
+ * query for a project URL or its content. The `progress` + `teams` fields also
25
+ * make the payload self-describing to the summary kind-inference.
26
+ */
27
+ export const PROJECT_READ_QUERY = `
28
+ query ProjectRead($id: String!) {
29
+ project(id: $id) {
30
+ id
31
+ name
32
+ state
33
+ progress
34
+ url
35
+ startDate
36
+ targetDate
37
+ description
38
+ content
39
+ lead { id name displayName }
40
+ teams {
41
+ nodes { id key name }
42
+ }
43
+ }
44
+ }
45
+ `;
19
46
  export const GET_PROJECT_QUERY = `
20
47
  query GetProject($name: String!) {
21
48
  projects(filter: { name: { eqIgnoreCase: $name } }, first: 1) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.22.0",
3
+ "version": "1.24.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",