@enrichlayer/el-linear 1.34.1 → 1.36.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
@@ -249,13 +249,14 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
249
249
 
250
250
  ### Gate telemetry (optional)
251
251
 
252
- `issues create` has a duplicate-detection gate. el-linear can record each
253
- fire/override decision to a local JSONL file so you can measure the gate's
254
- **override-rate** and tell whether it's too aggressive. It is **off by default**
255
- and writes nothing unless you opt in (e.g. `export EL_TELEMETRY_DIR=<path>`);
256
- there is no server or database, and `EL_TELEMETRY_DISABLED=1` forces it off.
257
- Full opt-in rules, the event schema, and a `jq` reader are in
258
- [docs/telemetry.md](./docs/telemetry.md).
252
+ `issues create` has a duplicate-detection gate (on by default) and an opt-in
253
+ [SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate).
254
+ el-linear can record each gate's fire/override decision to a local JSONL file so
255
+ you can measure its **override-rate** and tell whether it's too aggressive. It is
256
+ **off by default** and writes nothing unless you opt in (e.g.
257
+ `export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
258
+ `EL_TELEMETRY_DISABLED=1` forces it off. Full opt-in rules, the event schema, and
259
+ a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
259
260
 
260
261
  ### Networking (IPv4 preference)
261
262
 
@@ -445,6 +446,7 @@ el-linear <command> --help # detailed help for one command
445
446
  | Comments | `comments {list, read, create, update, delete}` |
446
447
  | Labels | `labels {list, create, retire, restore}` |
447
448
  | Projects | `projects {list, add-team, remove-team}` |
449
+ | Project updates | `project-updates {create, list, read}` (post a status update to a project's Updates feed) |
448
450
  | Cycles | `cycles {list, read}` |
449
451
  | Documents | `documents {list, read, create, update, delete}` |
450
452
  | Releases | `releases {list, read, create, pipelines}` |
@@ -578,7 +580,7 @@ el-linear projects list --format summary --fields name,state,progress,lead,teams
578
580
  # ...
579
581
  ```
580
582
 
581
- 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.
583
+ 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, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
582
584
 
583
585
  ### Windowed metadata (`WindowedMeta`)
584
586
 
@@ -200,6 +200,21 @@ and outreach tracked in one place.
200
200
  > `validation.duplicateDetection: false` (field validation still runs);
201
201
  > `validation.enabled: false` turns off all validation.
202
202
 
203
+ > **SOP-label parent gate ([DEV-5378](https://linear.app/verticalint/issue/DEV-5378/), opt-in).** When enabled, `el-linear issues create` requires an
204
+ > issue carrying an **SOP-type label** (any name in `validation.sopLabels`,
205
+ > default `["SOP"]`, case-insensitive) to point at a **parent SOP** — `--parent`
206
+ > or `--related-to` must resolve to another SOP-labeled issue — and **blocks
207
+ > (exit non-zero)** otherwise, naming the rule. An SOP with no parent SOP is
208
+ > unfindable by SOP tooling and breaks the catalog topology. It is **off by
209
+ > default** (`validation.sopLabelParentGate: true` turns it on; the Enrich Layer
210
+ > shared team config flips it on) because "SOP" is a workspace-specific
211
+ > taxonomy, not something an open-source install should assume. Escape hatch:
212
+ > `--allow-unparented-sop` (narrow, for an intentionally top-level SOP);
213
+ > `--skip-validation` also bypasses it but skips all field validation. A typo'd
214
+ > or nonexistent parent reference **blocks** (naming the ref) so a mistake can't
215
+ > orphan an SOP; a transport/service error **fails open** with a warning (and a
216
+ > `fail-open` gate event); a resolvable non-SOP parent hard-blocks.
217
+
203
218
  ```bash
204
219
  # --include-closed is required so previously-completed duplicates surface.
205
220
  # `issues search` defaults to open states (DEV-4478); the duplicate check
@@ -22,11 +22,12 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
22
22
  // elsewhere in the workspace. Any addition here is the single point of
23
23
  // truth that all skills rely on.
24
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
25
+ // Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; DEV-5342
26
+ // adds the feat short-form alias; all mirror tools-repo DEV-4417/DEV-5334):
27
+ // feature | feat | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
28
28
  // bug | spike — sanctioned Linear type labels
29
29
  // codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
30
+ // feat — short-form alias for feature (DEV-5342)
30
31
  // New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
31
32
  // type labels (bug, spike) get first-class issue detection so commit guards,
32
33
  // MR descriptions, and session handoff don't go dark on a branch a human
@@ -34,7 +35,7 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
34
35
  //
35
36
  // Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
36
37
  // 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;
38
+ const BRANCH_RE = /^(?:feature|feat|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
38
39
  export function parseBranchName(branch) {
39
40
  const m = branch.match(BRANCH_RE);
40
41
  if (!m) {
@@ -3,6 +3,7 @@ import { loadConfig } from "../config/config.js";
3
3
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
4
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
5
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
6
+ import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
6
7
  import { resolveDefaultStatus } from "../config/status-defaults.js";
7
8
  import { enforceTerms } from "../config/term-enforcer.js";
8
9
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
@@ -544,6 +545,109 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
544
545
  });
545
546
  throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
546
547
  }
548
+ /**
549
+ * DEV-5378: create-time SOP-label parent gate. When the issue being created
550
+ * carries an SOP-type label, require that `--parent` or `--related-to` resolves
551
+ * to another SOP-labeled issue, and throw otherwise — the deterministic form of
552
+ * the kaizen skill's Stage-4 rule (parent ALL-1028). An SOP with no parent SOP
553
+ * is unfindable by `el-sop landscape` and breaks the catalog topology.
554
+ *
555
+ * OPT-IN: dormant unless `validation.sopLabelParentGate: true` (see
556
+ * {@link getSopLabelGateConfig}) — el-linear is MIT/open-source and "SOP" is an
557
+ * Enrich-Layer-specific taxonomy. Bypassed by `--skip-validation` (blanket) and
558
+ * the narrow `--allow-unparented-sop` (which records an `overridden` gate event,
559
+ * mirroring `--allow-duplicate`). The parent-label fetch is best-effort: when a
560
+ * referenced issue can't be resolved (network / not-found) the gate fails open
561
+ * with a warning rather than blocking legitimate creation on infra trouble — a
562
+ * genuinely resolvable non-SOP parent still hard-blocks.
563
+ */
564
+ async function enforceSopLabelParent(labels, options, issuesService) {
565
+ if (options.skipValidation) {
566
+ return;
567
+ }
568
+ const { enabled, sopLabels } = getSopLabelGateConfig();
569
+ if (!enabled) {
570
+ return;
571
+ }
572
+ if (!hasSopLabel(labels, sopLabels)) {
573
+ return;
574
+ }
575
+ const parentRefs = [
576
+ ...(typeof options.parentTicket === "string" && options.parentTicket
577
+ ? [options.parentTicket]
578
+ : []),
579
+ ...(options.relatedTo ? splitList(options.relatedTo) : []),
580
+ ];
581
+ // Record the decision so `el-telemetry gates` can compute override-rate,
582
+ // mirroring the dup gate (DEV-4834): `overridden` when the caller passed
583
+ // --allow-unparented-sop and we proceed, `blocked` when we stop creation.
584
+ const decide = async (reason, unresolvableRefs) => {
585
+ const gateEvent = { gate: "issues-create-sop-parent" };
586
+ if (options.allowUnparentedSop) {
587
+ await emitGateEvent("el-linear", "issues create", {
588
+ ...gateEvent,
589
+ outcome: "overridden",
590
+ });
591
+ return;
592
+ }
593
+ await emitGateEvent("el-linear", "issues create", {
594
+ ...gateEvent,
595
+ outcome: "blocked",
596
+ });
597
+ throw new Error(`Issue creation blocked: ${formatSopParentBlock({
598
+ sopLabels,
599
+ reason,
600
+ parentRefs,
601
+ unresolvableRefs,
602
+ })}`);
603
+ };
604
+ // No parent/related at all → deterministic block (no fetch needed).
605
+ if (parentRefs.length === 0) {
606
+ await decide("no-parent");
607
+ return;
608
+ }
609
+ // A referenced issue that resolves AND carries an SOP label satisfies the
610
+ // gate. Fetch per-ref (not batched) so one unresolvable ref doesn't sink a
611
+ // sibling that would have passed. Classify each failure: a clean
612
+ // not-found/malformed ref is an unresolvable reference (it contributes to a
613
+ // block — a typo must not slip through), a transport/service error is infra
614
+ // trouble (fail open so it can't block legitimate creation).
615
+ const unresolvableRefs = [];
616
+ let transportError = false;
617
+ for (const ref of parentRefs) {
618
+ try {
619
+ const issue = await issuesService.getIssueById(ref);
620
+ if (hasSopLabel(issue.labels.map((l) => l.name), sopLabels)) {
621
+ return; // valid SOP parent — pass
622
+ }
623
+ }
624
+ catch (err) {
625
+ if (isUnresolvableReferenceError(err)) {
626
+ unresolvableRefs.push(ref);
627
+ }
628
+ else {
629
+ transportError = true;
630
+ }
631
+ }
632
+ }
633
+ // No SOP parent was confirmed. A transport failure may have hidden the real
634
+ // SOP parent → fail open (best-effort, like the dup gate on a search
635
+ // failure) and record it so the degradation is measurable (DEV-5378).
636
+ if (transportError) {
637
+ await emitGateEvent("el-linear", "issues create", {
638
+ gate: "issues-create-sop-parent",
639
+ outcome: "fail-open",
640
+ });
641
+ outputWarning(`SOP-parent check could not verify a parent SOP for ${parentRefs.join(", ")} due to a service error; proceeding without blocking.${options.allowUnparentedSop
642
+ ? ""
643
+ : " Pass --allow-unparented-sop to silence."}`);
644
+ return;
645
+ }
646
+ // Every reference either resolved to a non-SOP issue or cleanly failed to
647
+ // resolve (typo / nonexistent) — none is a valid SOP parent → block, naming
648
+ // any unresolvable refs so a typo reads differently from a real non-SOP parent.
649
+ await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
650
+ }
547
651
  async function handleCreateIssue(title, options, command) {
548
652
  const rootOpts = getRootOpts(command);
549
653
  const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
@@ -572,6 +676,15 @@ async function handleCreateIssue(title, options, command) {
572
676
  if (title) {
573
677
  await enforceNoDuplicateIssue(title, options, issuesService);
574
678
  }
679
+ // DEV-5378: deterministic SOP-label parent gate. Opt-in (dormant unless
680
+ // validation.sopLabelParentGate). Runs on the post-normalization labels
681
+ // (resolveCreateInputs rewrote options.labels to the canonical set) and,
682
+ // when the issue is SOP-labeled, blocks unless --parent/--related-to points
683
+ // at another SOP-labeled issue. Independent of title, so it runs even on the
684
+ // --from-template path — though a template that supplies labels server-side
685
+ // leaves options.labels undefined here, in which case the gate sees no SOP
686
+ // label and no-ops (same client-side-visibility gap as the dup gate).
687
+ await enforceSopLabelParent(options.labels ? splitList(options.labels) : [], options, issuesService);
575
688
  // Wrap valid issue identifiers as markdown links before creating, so the description
576
689
  // saved on Linear has clickable refs from the start. Self-reference can't apply here
577
690
  // because the issue doesn't exist yet — pass undefined.
@@ -1137,8 +1250,9 @@ export function setupIssuesCommands(program) {
1137
1250
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
1138
1251
  .option("--checkout", "create and checkout a git branch named after the issue")
1139
1252
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1140
- .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection)")
1253
+ .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
1141
1254
  .option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
1255
+ .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1142
1256
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1143
1257
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1144
1258
  .option("--no-footer", "skip the configured messageFooter for this issue")
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupProjectUpdatesCommands(program: Command): void;
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CREATE_PROJECT_UPDATE_MUTATION, GET_PROJECT_UPDATE_BY_ID_QUERY, LIST_PROJECT_UPDATES_QUERY, } from "../queries/project-updates.js";
3
+ import { notFoundError } from "../utils/error-messages.js";
4
+ import { createGraphQLService } from "../utils/graphql-service.js";
5
+ import { createLinearService } from "../utils/linear-service.js";
6
+ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
8
+ import { parsePositiveInt } from "../utils/validators.js";
9
+ const VALID_HEALTH = [
10
+ "onTrack",
11
+ "atRisk",
12
+ "offTrack",
13
+ ];
14
+ /**
15
+ * Resolve the update body from `--body` / `--body-file` (mutually exclusive,
16
+ * exactly one required) — mirrors the comments-command contract so file-sourced
17
+ * bodies sidestep shell-quoting traps for markdown/tables/backticks.
18
+ */
19
+ function resolveBody(options) {
20
+ if (options.body && options.bodyFile) {
21
+ throw new Error("--body and --body-file are mutually exclusive — pass one or the other");
22
+ }
23
+ if (options.bodyFile) {
24
+ return readFileSync(options.bodyFile, "utf-8");
25
+ }
26
+ if (options.body) {
27
+ return options.body;
28
+ }
29
+ throw new Error("Either --body or --body-file is required");
30
+ }
31
+ /**
32
+ * Validate an optional `--health` value against Linear's
33
+ * `ProjectUpdateHealthType` enum. Returns undefined when the flag is omitted
34
+ * (Linear then defaults the health), throws on an unknown value.
35
+ */
36
+ function resolveHealth(value) {
37
+ if (value === undefined) {
38
+ return undefined;
39
+ }
40
+ if (!VALID_HEALTH.includes(value)) {
41
+ throw new Error(`Invalid --health "${value}" — expected one of: ${VALID_HEALTH.join(", ")}`);
42
+ }
43
+ return value;
44
+ }
45
+ async function handleCreateProjectUpdate(options, command) {
46
+ // Validate local input first so bad --body/--health fails with zero network.
47
+ const body = resolveBody(options);
48
+ const health = resolveHealth(options.health);
49
+ const rootOpts = getRootOpts(command);
50
+ const graphQLService = await createGraphQLService(rootOpts);
51
+ const linearService = await createLinearService(rootOpts);
52
+ const projectId = await linearService.resolveProjectId(options.project);
53
+ const input = { projectId, body };
54
+ if (health !== undefined) {
55
+ input.health = health;
56
+ }
57
+ if (options.diffHidden) {
58
+ input.isDiffHidden = true;
59
+ }
60
+ const result = await graphQLService.rawRequest(CREATE_PROJECT_UPDATE_MUTATION, { input });
61
+ if (!result.projectUpdateCreate.success ||
62
+ !result.projectUpdateCreate.projectUpdate) {
63
+ throw new Error(`Failed to create project update on project "${options.project}"`);
64
+ }
65
+ outputSuccess(result.projectUpdateCreate.projectUpdate);
66
+ }
67
+ async function handleListProjectUpdates(options, command) {
68
+ const rootOpts = getRootOpts(command);
69
+ const graphQLService = await createGraphQLService(rootOpts);
70
+ const linearService = await createLinearService(rootOpts);
71
+ const projectId = await linearService.resolveProjectId(options.project);
72
+ const result = await graphQLService.rawRequest(LIST_PROJECT_UPDATES_QUERY, {
73
+ projectId,
74
+ first: parsePositiveInt(options.limit, "--limit"),
75
+ });
76
+ if (!result.project) {
77
+ throw notFoundError("Project", options.project);
78
+ }
79
+ const nodes = result.project.projectUpdates.nodes;
80
+ outputSuccess({ data: nodes, meta: { count: nodes.length } });
81
+ }
82
+ async function handleReadProjectUpdate(updateId, _options, command) {
83
+ const rootOpts = getRootOpts(command);
84
+ const graphQLService = await createGraphQLService(rootOpts);
85
+ const result = await graphQLService.rawRequest(GET_PROJECT_UPDATE_BY_ID_QUERY, { id: updateId });
86
+ if (!result.projectUpdate) {
87
+ throw notFoundError("Project update", updateId);
88
+ }
89
+ outputSuccess(result.projectUpdate);
90
+ }
91
+ export function setupProjectUpdatesCommands(program) {
92
+ const projectUpdates = program
93
+ .command("project-updates")
94
+ .description("Project update (status post) operations");
95
+ projectUpdates.action(() => projectUpdates.help());
96
+ projectUpdates
97
+ .command("create")
98
+ .description("Post a status update to a project (appears in the project's Updates feed).")
99
+ .requiredOption("--project <project>", "project name or ID")
100
+ .option("--body <body>", "update body markdown (inline)")
101
+ .option("--body-file <path>", "read update body from file")
102
+ .option("--health <health>", "project health: onTrack | atRisk | offTrack (omit to leave unset)")
103
+ .option("--diff-hidden", "hide the progress diff on the update")
104
+ .option("-q, --quiet", "print one confirmation line (health url) instead of the full JSON")
105
+ .action(handleAsyncCommand(handleCreateProjectUpdate));
106
+ projectUpdates
107
+ .command("list")
108
+ .description("List status updates posted to a project (newest first).")
109
+ .requiredOption("--project <project>", "project name or ID")
110
+ .option("-l, --limit <number>", "limit results", "50")
111
+ .action(handleAsyncCommand(handleListProjectUpdates));
112
+ projectUpdates
113
+ .command("read <updateId>")
114
+ .description("Get a single project update by its ID.")
115
+ .action(handleAsyncCommand(handleReadProjectUpdate));
116
+ }
@@ -67,6 +67,21 @@ export interface ElLinearConfig {
67
67
  * `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
68
68
  */
69
69
  duplicateThreshold?: number;
70
+ /**
71
+ * OPT-IN SOP-label parent gate (DEV-5378). When `true`, `issues create`
72
+ * requires an issue carrying an SOP-type label (see `sopLabels`) to point
73
+ * at a parent SOP via `--parent` or `--related-to`, blocking otherwise.
74
+ * Defaults to `false` (dormant) — el-linear is MIT/open-source and "SOP"
75
+ * is an Enrich-Layer-specific taxonomy, so a fresh install stays silent
76
+ * until a workspace opts in (the EL shared team config flips it on).
77
+ * Bypass a single create with `--allow-unparented-sop`.
78
+ */
79
+ sopLabelParentGate?: boolean;
80
+ /**
81
+ * Label names (matched case-insensitively) that mark an issue as an SOP
82
+ * for the `sopLabelParentGate` check. Defaults to `["SOP"]`.
83
+ */
84
+ sopLabels?: string[];
70
85
  };
71
86
  /**
72
87
  * Optional override for the Linear workspace URL key (the part after
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SOP-label parent validation — DEV-5378.
3
+ *
4
+ * The el-linear side of the SOP-system enforcement (parent ALL-1028). When an
5
+ * issue carries an SOP-type label, it must point at a parent SOP so the
6
+ * `el-sop landscape` catalog topology stays connected — an SOP with no parent
7
+ * SOP is unfindable and breaks the tree. This turns the kaizen skill's Stage-4
8
+ * "always give an SOP issue a parent SOP" prose rule into a deterministic
9
+ * create-time gate, mirroring the DEV-4823 duplicate-detection gate.
10
+ *
11
+ * OPT-IN by design. el-linear is MIT and published on npm; most installs are
12
+ * not Enrich Layer and have no SOP taxonomy. The gate is dormant unless
13
+ * `validation.sopLabelParentGate: true` is set (the EL workspace flips it on in
14
+ * its shared team config). This is the opposite of the duplicate-detection gate,
15
+ * which defaults on — but the dup check is workspace-agnostic, whereas "SOP" is
16
+ * an Enrich-Layer-specific label taxonomy that a fresh OSS install must not be
17
+ * surprised by.
18
+ */
19
+ /**
20
+ * Default label names that mark an issue as an SOP. Overridable via
21
+ * `config.validation.sopLabels`. Matched case-insensitively against the
22
+ * issue's labels.
23
+ */
24
+ export declare const DEFAULT_SOP_LABELS: string[];
25
+ export interface SopLabelGateConfig {
26
+ /**
27
+ * Whether the gate is active. OPT-IN: only true when validation is not
28
+ * turned off AND `sopLabelParentGate` is explicitly `true`.
29
+ */
30
+ enabled: boolean;
31
+ /** The SOP label names in effect (config override or {@link DEFAULT_SOP_LABELS}). */
32
+ sopLabels: string[];
33
+ }
34
+ /**
35
+ * Resolve the SOP-label-parent-gate config from the merged el-linear config.
36
+ *
37
+ * The gate is dormant by default. It activates only when validation isn't
38
+ * disabled (`validation.enabled !== false`) AND the operator has explicitly set
39
+ * `validation.sopLabelParentGate: true`. An absent or empty `sopLabels` falls
40
+ * back to {@link DEFAULT_SOP_LABELS}.
41
+ */
42
+ export declare function getSopLabelGateConfig(): SopLabelGateConfig;
43
+ /**
44
+ * Case-insensitive membership test: does `labels` contain any of the configured
45
+ * SOP label names? Returns false for an empty `labels` set.
46
+ */
47
+ export declare function hasSopLabel(labels: string[], sopLabels: string[]): boolean;
48
+ /**
49
+ * Distinguish a reference that cleanly does NOT resolve to a real issue (a
50
+ * typo'd or nonexistent `--parent` / `--related-to`) from a transport/service
51
+ * failure (network, GraphQL 5xx, timeout).
52
+ *
53
+ * `getIssueById` throws a plain `Error` for both — this codebase has no error
54
+ * `code` or subclass to switch on — so we key on the two message families it
55
+ * produces for an *unresolvable reference*:
56
+ * - `notFoundError` → `… not found.` (well-formed ref, no such issue)
57
+ * - `parseIssueIdentifier` → `Invalid issue identifier format: …` /
58
+ * `Invalid issue number in identifier: …` (malformed ref)
59
+ * Everything else — notably `GraphQL request failed: …` / `GraphQL query
60
+ * failed` from `graphql-service` — is treated as transport and fails open.
61
+ *
62
+ * This is a correctness distinction, not just telemetry: an unresolvable
63
+ * reference must BLOCK. Otherwise a typo'd `--related-to` on an SOP issue fails
64
+ * open, the issue is created, and the *follow-up* `createRelations` throws —
65
+ * leaving an orphan SOP on the board (the exact gap DEV-5378 cycle-1 caught). A
66
+ * transport error must FAIL OPEN so infra trouble can't block legitimate work.
67
+ */
68
+ export declare function isUnresolvableReferenceError(err: unknown): boolean;
69
+ /**
70
+ * Render the human/agent-facing block thrown when an SOP-labeled issue has no
71
+ * SOP parent. `reason` distinguishes "no parent at all" from "one or more
72
+ * references present but none resolves to an SOP-labeled issue", so the message
73
+ * points at the exact fix. When some references couldn't be resolved at all,
74
+ * `unresolvableRefs` names them (a typo'd ref reads differently from a real but
75
+ * non-SOP parent). Names the rule and the `--allow-unparented-sop` escape hatch.
76
+ */
77
+ export declare function formatSopParentBlock(opts: {
78
+ sopLabels: string[];
79
+ reason: "no-parent" | "no-sop-parent";
80
+ parentRefs: string[];
81
+ unresolvableRefs?: string[];
82
+ }): string;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * SOP-label parent validation — DEV-5378.
3
+ *
4
+ * The el-linear side of the SOP-system enforcement (parent ALL-1028). When an
5
+ * issue carries an SOP-type label, it must point at a parent SOP so the
6
+ * `el-sop landscape` catalog topology stays connected — an SOP with no parent
7
+ * SOP is unfindable and breaks the tree. This turns the kaizen skill's Stage-4
8
+ * "always give an SOP issue a parent SOP" prose rule into a deterministic
9
+ * create-time gate, mirroring the DEV-4823 duplicate-detection gate.
10
+ *
11
+ * OPT-IN by design. el-linear is MIT and published on npm; most installs are
12
+ * not Enrich Layer and have no SOP taxonomy. The gate is dormant unless
13
+ * `validation.sopLabelParentGate: true` is set (the EL workspace flips it on in
14
+ * its shared team config). This is the opposite of the duplicate-detection gate,
15
+ * which defaults on — but the dup check is workspace-agnostic, whereas "SOP" is
16
+ * an Enrich-Layer-specific label taxonomy that a fresh OSS install must not be
17
+ * surprised by.
18
+ */
19
+ import { loadConfig } from "./config.js";
20
+ /**
21
+ * Default label names that mark an issue as an SOP. Overridable via
22
+ * `config.validation.sopLabels`. Matched case-insensitively against the
23
+ * issue's labels.
24
+ */
25
+ export const DEFAULT_SOP_LABELS = ["SOP"];
26
+ /**
27
+ * Resolve the SOP-label-parent-gate config from the merged el-linear config.
28
+ *
29
+ * The gate is dormant by default. It activates only when validation isn't
30
+ * disabled (`validation.enabled !== false`) AND the operator has explicitly set
31
+ * `validation.sopLabelParentGate: true`. An absent or empty `sopLabels` falls
32
+ * back to {@link DEFAULT_SOP_LABELS}.
33
+ */
34
+ export function getSopLabelGateConfig() {
35
+ const validation = loadConfig().validation;
36
+ const enabled = validation?.enabled !== false && validation?.sopLabelParentGate === true;
37
+ const sopLabels = validation?.sopLabels && validation.sopLabels.length > 0
38
+ ? validation.sopLabels
39
+ : DEFAULT_SOP_LABELS;
40
+ return { enabled, sopLabels };
41
+ }
42
+ /**
43
+ * Case-insensitive membership test: does `labels` contain any of the configured
44
+ * SOP label names? Returns false for an empty `labels` set.
45
+ */
46
+ export function hasSopLabel(labels, sopLabels) {
47
+ const wanted = new Set(sopLabels.map((l) => l.toLowerCase()));
48
+ return labels.some((l) => wanted.has(l.toLowerCase()));
49
+ }
50
+ /**
51
+ * Distinguish a reference that cleanly does NOT resolve to a real issue (a
52
+ * typo'd or nonexistent `--parent` / `--related-to`) from a transport/service
53
+ * failure (network, GraphQL 5xx, timeout).
54
+ *
55
+ * `getIssueById` throws a plain `Error` for both — this codebase has no error
56
+ * `code` or subclass to switch on — so we key on the two message families it
57
+ * produces for an *unresolvable reference*:
58
+ * - `notFoundError` → `… not found.` (well-formed ref, no such issue)
59
+ * - `parseIssueIdentifier` → `Invalid issue identifier format: …` /
60
+ * `Invalid issue number in identifier: …` (malformed ref)
61
+ * Everything else — notably `GraphQL request failed: …` / `GraphQL query
62
+ * failed` from `graphql-service` — is treated as transport and fails open.
63
+ *
64
+ * This is a correctness distinction, not just telemetry: an unresolvable
65
+ * reference must BLOCK. Otherwise a typo'd `--related-to` on an SOP issue fails
66
+ * open, the issue is created, and the *follow-up* `createRelations` throws —
67
+ * leaving an orphan SOP on the board (the exact gap DEV-5378 cycle-1 caught). A
68
+ * transport error must FAIL OPEN so infra trouble can't block legitimate work.
69
+ */
70
+ export function isUnresolvableReferenceError(err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return (message.includes("not found.") ||
73
+ message.includes("Invalid issue identifier format") ||
74
+ message.includes("Invalid issue number in identifier"));
75
+ }
76
+ /**
77
+ * Render the human/agent-facing block thrown when an SOP-labeled issue has no
78
+ * SOP parent. `reason` distinguishes "no parent at all" from "one or more
79
+ * references present but none resolves to an SOP-labeled issue", so the message
80
+ * points at the exact fix. When some references couldn't be resolved at all,
81
+ * `unresolvableRefs` names them (a typo'd ref reads differently from a real but
82
+ * non-SOP parent). Names the rule and the `--allow-unparented-sop` escape hatch.
83
+ */
84
+ export function formatSopParentBlock(opts) {
85
+ const sopList = opts.sopLabels.join(", ");
86
+ const head = `SOP-labeled issue must point at a parent SOP (SOP label(s): ${sopList}).\n` +
87
+ " An SOP with no parent SOP is unfindable by `el-sop landscape` and breaks the catalog topology.\n";
88
+ let detail;
89
+ if (opts.reason === "no-parent") {
90
+ detail =
91
+ " This issue has no --parent or --related-to. Add one that points at an SOP-labeled issue.\n";
92
+ }
93
+ else {
94
+ detail = ` No referenced issue resolves to an SOP-labeled issue (referenced: ${opts.parentRefs.join(", ")}).\n`;
95
+ if (opts.unresolvableRefs && opts.unresolvableRefs.length > 0) {
96
+ detail += ` Could not resolve: ${opts.unresolvableRefs.join(", ")} — check the identifier exists.\n`;
97
+ }
98
+ detail +=
99
+ " Point --parent or --related-to at an existing SOP-labeled issue.\n";
100
+ }
101
+ const hatch = " If this SOP is intentionally top-level, re-run with --allow-unparented-sop.";
102
+ return head + detail + hatch;
103
+ }
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { setupIssuesCommands } from "./commands/issues.js";
20
20
  import { setupLabelsCommands } from "./commands/labels.js";
21
21
  import { setupProfileCommands } from "./commands/profile.js";
22
22
  import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
23
+ import { setupProjectUpdatesCommands } from "./commands/project-updates.js";
23
24
  import { setupProjectsCommands } from "./commands/projects.js";
24
25
  import { setupReadShortcut } from "./commands/read-shortcut.js";
25
26
  import { setupRefsCommands } from "./commands/refs.js";
@@ -65,9 +66,13 @@ program
65
66
  .option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
66
67
  .option("--json", "output as JSON (default, accepted for compatibility)")
67
68
  .option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
68
- .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
69
+ .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly. " +
70
+ "Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
71
+ "they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
69
72
  .option("--jq <filter>", "apply a jq filter to the JSON output")
70
- .option("--fields <fields>", "filter output to specific fields (comma-separated)")
73
+ .option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
74
+ "emitted as null plus a `fields_unresolved:` warning — in the JSON " +
75
+ "envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
71
76
  .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
72
77
  program.hook("preAction", (_thisCommand, actionCommand) => {
73
78
  const rootOpts = actionCommand.optsWithGlobals();
@@ -143,6 +148,7 @@ setupReleasesCommands(program);
143
148
  setupProjectsCommands(program);
144
149
  setupCyclesCommands(program);
145
150
  setupProjectMilestonesCommands(program);
151
+ setupProjectUpdatesCommands(program);
146
152
  setupEmbedsCommands(program);
147
153
  setupTeamsCommands(program);
148
154
  setupTemplatesCommands(program);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Typed response shapes for the queries in `./project-updates.ts`.
3
+ * See `./issues-types.ts` for the rationale (ALL-937).
4
+ *
5
+ * All three queries (create / list / read) select the same
6
+ * `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
7
+ */
8
+ export type ProjectUpdateHealth = "onTrack" | "atRisk" | "offTrack";
9
+ interface ProjectUpdateProjectRef {
10
+ id: string;
11
+ name: string;
12
+ }
13
+ interface ProjectUpdateUserRef {
14
+ id: string;
15
+ name: string;
16
+ displayName: string | null;
17
+ }
18
+ /**
19
+ * Mirrors `PROJECT_UPDATE_FRAGMENT` — the full selection set shared by the
20
+ * create mutation, the project-scoped list, and the by-id read.
21
+ */
22
+ export interface ProjectUpdateNode {
23
+ id: string;
24
+ body: string | null;
25
+ health: ProjectUpdateHealth | null;
26
+ url: string | null;
27
+ slugId: string | null;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ editedAt: string | null;
31
+ user: ProjectUpdateUserRef | null;
32
+ project: ProjectUpdateProjectRef | null;
33
+ }
34
+ export interface CreateProjectUpdateResponse {
35
+ projectUpdateCreate: {
36
+ success: boolean;
37
+ projectUpdate: ProjectUpdateNode | null;
38
+ };
39
+ }
40
+ export interface ListProjectUpdatesResponse {
41
+ project: {
42
+ id: string;
43
+ name: string;
44
+ projectUpdates: {
45
+ nodes: ProjectUpdateNode[];
46
+ };
47
+ } | null;
48
+ }
49
+ export interface GetProjectUpdateByIdResponse {
50
+ projectUpdate: ProjectUpdateNode | null;
51
+ }
52
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Typed response shapes for the queries in `./project-updates.ts`.
3
+ * See `./issues-types.ts` for the rationale (ALL-937).
4
+ *
5
+ * All three queries (create / list / read) select the same
6
+ * `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
7
+ */
8
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const CREATE_PROJECT_UPDATE_MUTATION = "\n mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {\n projectUpdateCreate(input: $input) {\n success\n projectUpdate {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n";
2
+ export declare const LIST_PROJECT_UPDATES_QUERY = "\n query ListProjectUpdates($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectUpdates(first: $first, orderBy: createdAt) {\n nodes {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n }\n";
3
+ export declare const GET_PROJECT_UPDATE_BY_ID_QUERY = "\n query GetProjectUpdate($id: String!) {\n projectUpdate(id: $id) {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n";
@@ -0,0 +1,49 @@
1
+ const PROJECT_UPDATE_FRAGMENT = `
2
+ id
3
+ body
4
+ health
5
+ url
6
+ slugId
7
+ createdAt
8
+ updatedAt
9
+ editedAt
10
+ user {
11
+ id
12
+ name
13
+ displayName
14
+ }
15
+ project {
16
+ id
17
+ name
18
+ }
19
+ `;
20
+ export const CREATE_PROJECT_UPDATE_MUTATION = `
21
+ mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
22
+ projectUpdateCreate(input: $input) {
23
+ success
24
+ projectUpdate {
25
+ ${PROJECT_UPDATE_FRAGMENT}
26
+ }
27
+ }
28
+ }
29
+ `;
30
+ export const LIST_PROJECT_UPDATES_QUERY = `
31
+ query ListProjectUpdates($projectId: String!, $first: Int!) {
32
+ project(id: $projectId) {
33
+ id
34
+ name
35
+ projectUpdates(first: $first, orderBy: createdAt) {
36
+ nodes {
37
+ ${PROJECT_UPDATE_FRAGMENT}
38
+ }
39
+ }
40
+ }
41
+ }
42
+ `;
43
+ export const GET_PROJECT_UPDATE_BY_ID_QUERY = `
44
+ query GetProjectUpdate($id: String!) {
45
+ projectUpdate(id: $id) {
46
+ ${PROJECT_UPDATE_FRAGMENT}
47
+ }
48
+ }
49
+ `;
@@ -30,6 +30,8 @@ export declare function formatCycleSummary(cycle: Record<string, unknown>): stri
30
30
  export declare function formatCycleList(cycles: unknown[]): string;
31
31
  export declare function formatMilestoneSummary(milestone: Record<string, unknown>): string;
32
32
  export declare function formatMilestoneList(milestones: unknown[]): string;
33
+ export declare function formatProjectUpdateSummary(update: Record<string, unknown>): string;
34
+ export declare function formatProjectUpdateList(updates: unknown[]): string;
33
35
  export declare function formatTeamList(teams: unknown[]): string;
34
36
  export declare function formatLabelList(labels: unknown[]): string;
35
37
  /**
@@ -68,7 +70,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
68
70
  * the full payload. Lists fall back to a simple bulleted list.
69
71
  */
70
72
  export declare function formatGenericSummary(value: unknown): string;
71
- export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
73
+ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "project-update" | "project-update-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
72
74
  /**
73
75
  * Heuristic — used by the central `outputSuccess` path which doesn't know
74
76
  * which command produced the payload. Looks at the shape of the data to
@@ -655,6 +655,35 @@ export function formatMilestoneList(milestones) {
655
655
  { header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
656
656
  ], { emptyText: "(no milestones)", itemNoun: "milestone" });
657
657
  }
658
+ // ── project updates ────────────────────────────────────────────
659
+ export function formatProjectUpdateSummary(update) {
660
+ const headerLine = `project-update ${s(update.id)}`;
661
+ const fields = [
662
+ { label: "Health", value: s(update.health) },
663
+ { label: "Project", value: getName(update.project) },
664
+ { label: "Author", value: getName(update.user) },
665
+ { label: "Created", value: s(update.createdAt) },
666
+ { label: "URL", value: s(update.url) },
667
+ ];
668
+ const header = renderHeader(fields);
669
+ const body = clipDescription(update.body);
670
+ const parts = [headerLine, header];
671
+ if (body)
672
+ parts.push("", body);
673
+ return parts.filter((p) => p !== "").join("\n");
674
+ }
675
+ export function formatProjectUpdateList(updates) {
676
+ return renderTable(updates.map((raw) => asObj(raw) ?? {}), [
677
+ { header: "HEALTH", minWidth: 6, extract: (u) => s(u.health) },
678
+ { header: "AUTHOR", minWidth: 6, extract: (u) => getName(u.user) },
679
+ {
680
+ header: "CREATED",
681
+ minWidth: 7,
682
+ extract: (u) => s(u.createdAt).slice(0, 10),
683
+ },
684
+ { header: "URL", minWidth: 3, maxWidth: 60, extract: (u) => s(u.url) },
685
+ ], { emptyText: "(no project updates)", itemNoun: "project update" });
686
+ }
658
687
  // ── teams ──────────────────────────────────────────────────────
659
688
  export function formatTeamList(teams) {
660
689
  return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
@@ -1036,6 +1065,10 @@ export function inferKindFromPayload(value) {
1036
1065
  "progress" in obj &&
1037
1066
  ("state" in obj || "lead" in obj || "teams" in obj))
1038
1067
  return "project";
1068
+ // Project updates carry body + createdAt + user like comments; `health` is
1069
+ // the distinguishing field, so this must precede the comment check.
1070
+ if ("body" in obj && "health" in obj)
1071
+ return "project-update";
1039
1072
  if ("body" in obj && "createdAt" in obj && "user" in obj)
1040
1073
  return "comment";
1041
1074
  if (("number" in obj || "isActive" in obj) &&
@@ -1079,6 +1112,10 @@ function inferListKind(items) {
1079
1112
  }
1080
1113
  return "issue-list";
1081
1114
  }
1115
+ // Project-update rows carry body + createdAt like comments; `health` is the
1116
+ // distinguishing field, so this must precede the comment-list check.
1117
+ if ("body" in sample && "health" in sample)
1118
+ return "project-update-list";
1082
1119
  if ("body" in sample && "createdAt" in sample)
1083
1120
  return "comment-list";
1084
1121
  if ("progress" in sample && "name" in sample) {
@@ -1168,6 +1205,10 @@ export function dispatch(kind, payload, fields) {
1168
1205
  return formatMilestoneSummary((obj ?? {}));
1169
1206
  case "milestone-list":
1170
1207
  return formatMilestoneList(list ?? []);
1208
+ case "project-update":
1209
+ return formatProjectUpdateSummary((obj ?? {}));
1210
+ case "project-update-list":
1211
+ return formatProjectUpdateList(list ?? []);
1171
1212
  case "team-list":
1172
1213
  return formatTeamList(list ?? []);
1173
1214
  case "label-list":
@@ -1230,6 +1271,11 @@ export function formatLine(payload) {
1230
1271
  if (kind === "comment" && obj) {
1231
1272
  return `comment ${s(obj.id)}`;
1232
1273
  }
1274
+ if (kind === "project-update" && obj) {
1275
+ // create doesn't carry an identifier/title — health + url are the
1276
+ // stable handles a caller needs (matches the summary header style).
1277
+ return `${s(obj.health)} ${s(obj.url)}`;
1278
+ }
1233
1279
  if (kind === "relation-list") {
1234
1280
  return formatRelationLine(payload);
1235
1281
  }
@@ -25,7 +25,13 @@ export declare function decideGateLedger(opts: {
25
25
  export interface GateEvent {
26
26
  /** Stable gate id, e.g. `issues-create-dup`. */
27
27
  gate: string;
28
- outcome: "blocked" | "overridden";
28
+ /**
29
+ * `blocked` — the gate stopped creation. `overridden` — a gate-specific
30
+ * override flag let a would-block proceed. `fail-open` — the gate could not
31
+ * evaluate (infra/service error) and let creation proceed rather than block
32
+ * on trouble; tracked so degradation is measurable (DEV-5378).
33
+ */
34
+ outcome: "blocked" | "overridden" | "fail-open";
29
35
  /** Highest candidate similarity that triggered the gate (0–1). */
30
36
  topScore?: number;
31
37
  /** How many candidates crossed the threshold. */
@@ -78,6 +78,12 @@ function filterFields(obj, fields, unresolved) {
78
78
  const perItem = new Set();
79
79
  const projected = filterFields(item, fields, perItem);
80
80
  for (const field of fields) {
81
+ // A primitive item short-circuits: filterFields returns it
82
+ // unchanged and records NOTHING in perItem, so every field counts
83
+ // as resolved here — a mixed array holding one primitive suppresses
84
+ // the unresolved warning for all fields. Acceptable: a primitive
85
+ // item can't meaningfully be projected, and heterogeneous lists
86
+ // legitimately have per-item gaps.
81
87
  if (!perItem.has(field)) {
82
88
  resolvedSomewhere.add(field);
83
89
  }
@@ -208,20 +214,19 @@ export function outputSuccess(data) {
208
214
  }
209
215
  else if (output !== null && typeof output === "object") {
210
216
  const obj = output;
211
- if (Array.isArray(obj.data)) {
212
- output = {
213
- ...obj,
214
- data: filterFields(obj.data, fieldsFilter, unresolved),
215
- };
216
- }
217
- else if (obj.data !== null &&
217
+ if (obj.data !== null &&
218
218
  obj.data !== undefined &&
219
219
  typeof obj.data === "object") {
220
- // Envelope with an OBJECT data payload (e.g. el-git context):
221
- // project inside `data`, same as the array branch. Before
222
- // DEV-5323 this fell through to root filtering, so
223
- // `--fields branch,issueId` on an envelope returned `{}`.
224
- // Paths are relative to `data` for every envelope shape.
220
+ // Envelope with a `data` payload — project inside `data`,
221
+ // whether it's an array (list envelope) or an object (e.g.
222
+ // el-git context). Arrays ARE objects, so this single
223
+ // `typeof === "object"` check subsumes the former separate
224
+ // `Array.isArray(obj.data)` arm (DEV-5339 collapsed the two
225
+ // byte-identical branches); `filterFields` dispatches on
226
+ // array-vs-object internally. Paths are relative to `data` for
227
+ // every envelope shape. Before DEV-5323 the object case fell
228
+ // through to root filtering, so `--fields branch,issueId` on an
229
+ // envelope returned `{}`.
225
230
  output = {
226
231
  ...obj,
227
232
  data: filterFields(obj.data, fieldsFilter, unresolved),
@@ -261,11 +266,31 @@ export function outputSuccess(data) {
261
266
  // fields_unprojectable surfaced from a prior dispatch) and embed
262
267
  // them as `_warnings` on the envelope.
263
268
  const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
264
- if (warnings.length > 0 &&
265
- output !== null &&
266
- typeof output === "object" &&
267
- !Array.isArray(output)) {
268
- output = { ...output, _warnings: warnings };
269
+ if (warnings.length > 0) {
270
+ if (output !== null &&
271
+ typeof output === "object" &&
272
+ !Array.isArray(output)) {
273
+ output = { ...output, _warnings: warnings };
274
+ }
275
+ else {
276
+ // Bare-array (or primitive / null) output has no envelope object to
277
+ // carry `_warnings`. This is the DEV-5339 fix: previously the buffer
278
+ // was drained above but only re-embedded for object output, so a
279
+ // warning was silently dropped on a top-level array payload AND on
280
+ // `--raw` (which unwraps { data: [...] } to a bare array *before*
281
+ // this point) — losing exactly the DEV-5323 `fields_unresolved:`
282
+ // fail-visible signal on the `--raw` form our own CLAUDE.md
283
+ // recommends to agents. Route each warning to STDERR prefixed
284
+ // `_warnings: ` so the signal always reaches the consumer while
285
+ // stdout stays a pure JSON array (safe to pipe to `jq`). Mirrors the
286
+ // summary path's `_warnings:` line convention — that path uses
287
+ // logger.info because its stdout is already human text; here stdout
288
+ // must remain machine-parseable JSON, so we use logger.error
289
+ // (stderr).
290
+ for (const w of warnings) {
291
+ logger.error(`_warnings: ${w}`);
292
+ }
293
+ }
269
294
  }
270
295
  if (jqFilter) {
271
296
  const json = JSON.stringify(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.34.1",
3
+ "version": "1.36.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",