@enrichlayer/el-linear 1.38.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -271,8 +271,11 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
271
271
 
272
272
  ### Gate telemetry (optional)
273
273
 
274
- `issues create` has a duplicate-detection gate (on by default) and an opt-in
275
- [SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate).
274
+ `issues create` has a duplicate-detection gate (on by default), an opt-in
275
+ [SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate),
276
+ and an opt-in
277
+ [goal-completion gate](./docs/configuration.md#goal-completion-gate-validationgoalcompletiongate)
278
+ (requires a falsifiable "Done when" / acceptance-criteria section).
276
279
  el-linear can record each gate's fire/override decision to a local JSONL file so
277
280
  you can measure its **override-rate** and tell whether it's too aggressive. It is
278
281
  **off by default** and writes nothing unless you opt in (e.g.
@@ -121,6 +121,20 @@ el-linear comments list DEV-123 --format json 2>&1 | python3 -c "import json,sys
121
121
  `#comment-<hash>`. `comments list --format summary` includes each comment id
122
122
  so you can copy it straight into `comments read`.
123
123
 
124
+ ### Attachment reads and downloads
125
+
126
+ List attachments first, then use the attachment ID or exact title. Text files
127
+ can be streamed directly; binary files require an explicit download path.
128
+
129
+ ```bash
130
+ el-linear attachments list DEV-123 --format summary
131
+ el-linear attachments read DEV-123 <attachment-id>
132
+ el-linear attachments download DEV-123 <attachment-id> --output /tmp/report.pdf
133
+ ```
134
+
135
+ Do not reconstruct authenticated `curl` commands from attachment URLs. The
136
+ attachment commands use the active Linear profile and reject binary stdout.
137
+
124
138
  ### Terse write confirmations: `-q, --quiet`
125
139
 
126
140
  `issues create|update` and `comments create|update` accept `-q, --quiet`, which prints a single machine-stable confirmation line instead of the full JSON envelope — no need to `grep` the result for the identifier / state / url:
@@ -1,9 +1,28 @@
1
+ import { basename } from "node:path";
1
2
  import { createFileService } from "../utils/file-service.js";
2
3
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
3
4
  import { createLinearService } from "../utils/linear-service.js";
4
5
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
6
  import { getRootOpts } from "../utils/root-opts.js";
6
7
  import { parsePositiveInt } from "../utils/validators.js";
8
+ function selectAttachment(attachments, selector) {
9
+ const idMatch = attachments.find((attachment) => attachment.id === selector);
10
+ if (idMatch)
11
+ return idMatch;
12
+ const matches = attachments.filter((attachment) => attachment.title === selector || attachment.url === selector);
13
+ if (matches.length === 1)
14
+ return matches[0];
15
+ if (matches.length > 1) {
16
+ throw new Error(`Multiple attachments are titled "${selector}"; select one by attachment ID.`);
17
+ }
18
+ throw new Error(`Attachment "${selector}" was not found. Run attachments list <issueId> to see attachment IDs and titles.`);
19
+ }
20
+ async function resolveAttachment(issueId, selector, rootOpts) {
21
+ const linearService = await createLinearService(rootOpts);
22
+ const resolvedIssueId = await linearService.resolveIssueId(issueId);
23
+ const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
24
+ return selectAttachment(await attachmentsService.listAttachments(resolvedIssueId), selector);
25
+ }
7
26
  export function setupAttachmentsCommands(program) {
8
27
  const attachments = program
9
28
  .command("attachments")
@@ -45,6 +64,47 @@ export function setupAttachmentsCommands(program) {
45
64
  const data = allAttachments.slice(0, limit);
46
65
  outputSuccess({ data, meta: { count: data.length } });
47
66
  }));
67
+ attachments
68
+ .command("read <issueId> <attachment>")
69
+ .description("Write a text attachment to stdout by ID, title, or URL.")
70
+ .action(handleAsyncCommand(async (issueId, selector, _options, command) => {
71
+ const rootOpts = getRootOpts(command);
72
+ const attachment = await resolveAttachment(issueId, selector, rootOpts);
73
+ const fileService = await createFileService(rootOpts);
74
+ const result = await fileService.readTextFile(attachment.url);
75
+ if (!result.success)
76
+ throw new Error(result.error);
77
+ process.stdout.write(result.content);
78
+ if (!result.content.endsWith("\n"))
79
+ process.stdout.write("\n");
80
+ }));
81
+ attachments
82
+ .command("download <issueId> <attachment>")
83
+ .description("Download an attachment by ID, title, or URL.")
84
+ .option("--output <path>", "output file path")
85
+ .option("--overwrite", "overwrite existing file", false)
86
+ .action(handleAsyncCommand(async (issueId, selector, options, command) => {
87
+ const rootOpts = getRootOpts(command);
88
+ const attachment = await resolveAttachment(issueId, selector, rootOpts);
89
+ const fileService = await createFileService(rootOpts);
90
+ const titleBasename = attachment.title
91
+ ? basename(attachment.title)
92
+ : undefined;
93
+ const defaultOutput = titleBasename && ![".", ".."].includes(titleBasename)
94
+ ? titleBasename
95
+ : undefined;
96
+ const result = await fileService.downloadFile(attachment.url, {
97
+ output: options.output ?? defaultOutput,
98
+ overwrite: options.overwrite,
99
+ });
100
+ if (!result.success)
101
+ throw new Error(result.error);
102
+ outputSuccess({
103
+ success: true,
104
+ filePath: result.filePath,
105
+ message: `Attachment downloaded to ${result.filePath}`,
106
+ });
107
+ }));
48
108
  attachments
49
109
  .command("delete <attachmentId>")
50
110
  .description("Delete an attachment.")
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { loadConfig } from "../config/config.js";
3
3
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
+ import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
4
5
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
6
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
6
7
  import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
@@ -340,7 +341,12 @@ async function withProjectResolverEnrichment(fn, options, services) {
340
341
  throw err;
341
342
  }
342
343
  }
343
- async function resolveCreateInputs(title, options, rootOpts) {
344
+ async function resolveCreateInputs(title, options, rootOpts,
345
+ // Pre-resolved description, threaded in so this validation path does NOT
346
+ // call resolveDescription() itself — on the `--description-file -` (stdin)
347
+ // path a second read drains the pipe to "" (DEV-5920 cycle-2). Resolve
348
+ // happens exactly once in handleCreateIssue and the value flows here.
349
+ resolvedDescription) {
344
350
  const config = loadConfig();
345
351
  enforceTerms([title, options.description], { strict: options.strict });
346
352
  // Effective assignee: explicit --assignee wins; --no-assignee (commander
@@ -369,10 +375,9 @@ async function resolveCreateInputs(title, options, rootOpts) {
369
375
  const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
370
376
  if (!options.skipValidation && !hasFromTemplate) {
371
377
  const rawLabels = options.labels ? splitList(options.labels) : null;
372
- const description = resolveDescription(options);
373
378
  const validationResult = validateIssueCreation({
374
379
  labels: rawLabels,
375
- description: description || undefined,
380
+ description: resolvedDescription || undefined,
376
381
  title,
377
382
  assignee: effectiveAssignee,
378
383
  project: options.project,
@@ -699,11 +704,84 @@ async function enforceSopLabelParent(labels, options, issuesService) {
699
704
  // any unresolvable refs so a typo reads differently from a real non-SOP parent.
700
705
  await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
701
706
  }
707
+ /**
708
+ * DEV-5920: create-time goal-completion gate. Checks the description for a
709
+ * "Done when" (or equivalent) section containing at least one falsifiable
710
+ * criterion — the deterministic form of the concrete-goals rule (RFC-0027
711
+ * discussion): a goal a later session can't mechanically verify has no
712
+ * terminal state to converge on.
713
+ *
714
+ * OPT-IN and two-mode: dormant unless `validation.goalCompletionGate` is
715
+ * `"warn"` (stderr warning, creation proceeds — recorded as `advisory`) or
716
+ * `"block"` (throws — recorded as `blocked`), mirroring the DEV-5378 SOP gate
717
+ * on config plumbing and the DEV-5590 advisory tier on outcomes. Bypassed by
718
+ * `--skip-validation` (blanket, emits nothing) and the narrow
719
+ * `--allow-vague-goal` (which records an `overridden` gate event on a
720
+ * would-fire, mirroring `--allow-duplicate`). Purely local — no network, so it
721
+ * runs before the service-backed gates and has no fail-open branch.
722
+ */
723
+ async function enforceGoalCompletion(description, options) {
724
+ if (options.skipValidation) {
725
+ return;
726
+ }
727
+ const { mode, headers } = getGoalCompletionGateConfig();
728
+ if (mode === "off") {
729
+ return;
730
+ }
731
+ const evaluation = evaluateGoalCompletion(description, headers);
732
+ if (evaluation.ok) {
733
+ return;
734
+ }
735
+ // Record the decision so `el-telemetry gates` can compute override-rate,
736
+ // mirroring the dup gate (DEV-4834): `overridden` when the caller passed
737
+ // --allow-vague-goal, `advisory` in warn mode, `blocked` when we stop.
738
+ const gateEvent = { gate: "issues-create-goal-completion" };
739
+ if (options.allowVagueGoal) {
740
+ await emitGateEvent("el-linear", "issues create", {
741
+ ...gateEvent,
742
+ outcome: "overridden",
743
+ });
744
+ return;
745
+ }
746
+ const message = formatGoalCompletionBlock({
747
+ reason: evaluation.reason,
748
+ headers,
749
+ sectionHeader: evaluation.reason === "vague-section" ? evaluation.header : undefined,
750
+ });
751
+ if (mode === "warn") {
752
+ await emitGateEvent("el-linear", "issues create", {
753
+ ...gateEvent,
754
+ outcome: "advisory",
755
+ });
756
+ outputWarning(message);
757
+ return;
758
+ }
759
+ await emitGateEvent("el-linear", "issues create", {
760
+ ...gateEvent,
761
+ outcome: "blocked",
762
+ });
763
+ throw new Error(`Issue creation blocked: ${message}`);
764
+ }
702
765
  async function handleCreateIssue(title, options, command) {
703
766
  const rootOpts = getRootOpts(command);
704
- const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
767
+ // DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
768
+ // else. On create, resolveDescription would otherwise run three times —
769
+ // field validation (inside resolveCreateInputs), the body build, and the
770
+ // goal-completion gate. With `--description-file -` each call does
771
+ // fs.readFileSync(fd 0), and a stdin pipe only yields data on the FIRST
772
+ // read — later reads drain to "". That silently dropped a piped body and
773
+ // made the gate see an empty description (spurious block/warn). Resolving
774
+ // once here and threading the value through fixes all three reads. This
775
+ // single resolve also enforces the --template / --description-file
776
+ // mutual-exclusivity guard (it lives in resolveDescription). Kept as a lone
777
+ // resolve rather than round-tripping through options.description because
778
+ // resolveDescription returns file bodies verbatim (no inline-escape
779
+ // normalization) — re-resolving an inline copy would corrupt a file that
780
+ // intentionally contains backslash sequences.
781
+ const rawDescription = resolveDescription(options);
782
+ const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
705
783
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
706
- const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
784
+ const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
707
785
  // Append messageFooter (config or --footer flag) so auto-link picks up any
708
786
  // issue refs in the footer too. --no-footer skips both flag and config.
709
787
  // Commander parses --no-footer as `options.footer === false`.
@@ -713,6 +791,21 @@ async function handleCreateIssue(title, options, command) {
713
791
  footer: explicitFooter,
714
792
  noFooter,
715
793
  }) ?? "";
794
+ // DEV-5920: goal-completion gate. Opt-in (dormant unless
795
+ // validation.goalCompletionGate is "warn"/"block"). Runs on the raw resolved
796
+ // description — not the composed one — so an attachment link or a configured
797
+ // messageFooter (which routinely carries digits/URLs) can't satisfy the
798
+ // falsifiability check on the author's behalf. Purely local, so it runs
799
+ // before the service-backed gates. Skipped on the --from-template path when
800
+ // no local description override is present: the description is instantiated
801
+ // server-side from the template, invisible to a client-side check (same gap
802
+ // as the dup gate on a template-resolved title).
803
+ {
804
+ const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
805
+ if (!hasFromTemplate || rawDescription) {
806
+ await enforceGoalCompletion(rawDescription ?? "", options);
807
+ }
808
+ }
716
809
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
717
810
  // DEV-4823: deterministic duplicate-detection gate. Runs before the create
718
811
  // POST, searches the title's salient keywords (including closed issues),
@@ -1308,9 +1401,10 @@ export function setupIssuesCommands(program) {
1308
1401
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
1309
1402
  .option("--checkout", "create and checkout a git branch named after the issue")
1310
1403
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1311
- .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
1404
+ .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate)")
1312
1405
  .option("--allow-duplicate", "silence the duplicate-detection hard block and create even if a near-identical issue already exists (DEV-5590: only matters at/above the hard-block threshold — below it the gate is advisory-only and never blocks)")
1313
1406
  .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1407
+ .option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
1314
1408
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1315
1409
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1316
1410
  .option("--no-footer", "skip the configured messageFooter for this issue")
@@ -95,6 +95,26 @@ export interface ElLinearConfig {
95
95
  * for the `sopLabelParentGate` check. Defaults to `["SOP"]`.
96
96
  */
97
97
  sopLabels?: string[];
98
+ /**
99
+ * OPT-IN goal-completion gate (DEV-5920). When `"warn"` or `"block"`,
100
+ * `issues create` checks the description for a goal-completion section
101
+ * ("Done when" / "Acceptance criteria" / … — see `goalSectionHeaders`)
102
+ * containing at least one falsifiable criterion (a command, a threshold
103
+ * number, an artifact path, an exit-code assertion, or a "verifiable
104
+ * via X" phrase). `"warn"` prints a stderr warning; `"block"` stops
105
+ * creation. Defaults to off (absent/`false`) — el-linear is
106
+ * MIT/open-source and a fresh install must not be surprised by a new
107
+ * refusal (the EL shared team config opts in). Bypass a single create
108
+ * with `--allow-vague-goal`.
109
+ */
110
+ goalCompletionGate?: false | "warn" | "block";
111
+ /**
112
+ * Section header names (matched case-insensitively, `##`/`###`/bold
113
+ * pseudo-header forms) accepted as the goal-completion section for the
114
+ * `goalCompletionGate` check. Defaults to `["Done when", "Done-when",
115
+ * "Acceptance criteria", "Success criteria"]`.
116
+ */
117
+ goalSectionHeaders?: string[];
98
118
  };
99
119
  /**
100
120
  * Optional override for the Linear workspace URL key (the part after
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Goal-completion ("Done when") validation — DEV-5920.
3
+ *
4
+ * A create-time gate that checks the issue description for a goal-completion
5
+ * section ("Done when", "Acceptance criteria", "Success criteria", …) that
6
+ * contains at least one FALSIFIABLE criterion — something a later session can
7
+ * mechanically verify (a command with an expected result, a threshold number,
8
+ * a named artifact path, an exit-code/status assertion, or an explicit
9
+ * "verifiable via X" phrase). A section made only of bare quality adjectives
10
+ * ("improved", "better", "cleaner", "faster") gives the implementing agent no
11
+ * terminal state to converge on, which is the concrete-goals failure mode this
12
+ * gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
13
+ * gate and the DEV-5378 SOP-label parent gate.
14
+ *
15
+ * OPT-IN by design. el-linear is MIT and published on npm; most installs are
16
+ * not Enrich Layer and must not be surprised by a new refusal. The gate is
17
+ * dormant unless `validation.goalCompletionGate` is set to `"warn"` or
18
+ * `"block"` (the EL workspace flips it on in its shared team config). Section
19
+ * matching reuses the `extractField` header semantics (`##`/`###` ATX headers
20
+ * and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
21
+ * so the gate accepts exactly what `issues read --field "Done when"` can later
22
+ * extract.
23
+ */
24
+ /**
25
+ * Default section headers accepted as the goal-completion section, matched
26
+ * with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
27
+ * forms, trailing colons stripped). Overridable via
28
+ * `config.validation.goalSectionHeaders`.
29
+ */
30
+ export declare const DEFAULT_GOAL_SECTION_HEADERS: string[];
31
+ /** Gate mode: dormant, stderr warning, or hard block. */
32
+ export type GoalCompletionGateMode = "off" | "warn" | "block";
33
+ export interface GoalCompletionGateConfig {
34
+ /**
35
+ * The mode in effect. OPT-IN: `"off"` unless validation is not turned off
36
+ * AND `goalCompletionGate` is explicitly `"warn"` or `"block"`.
37
+ */
38
+ mode: GoalCompletionGateMode;
39
+ /** The section headers in effect (config override or {@link DEFAULT_GOAL_SECTION_HEADERS}). */
40
+ headers: string[];
41
+ }
42
+ /**
43
+ * Resolve the goal-completion-gate config from the merged el-linear config.
44
+ *
45
+ * The gate is dormant by default. It activates only when validation isn't
46
+ * disabled (`validation.enabled !== false`) AND the operator has explicitly
47
+ * set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
48
+ * value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
49
+ * must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
50
+ * falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
51
+ */
52
+ export declare function getGoalCompletionGateConfig(): GoalCompletionGateConfig;
53
+ /**
54
+ * Does the section text contain at least one falsifiable criterion?
55
+ * See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
56
+ * section trivially fails — a bare header is not a criterion.
57
+ */
58
+ export declare function hasFalsifiableCriterion(sectionText: string): boolean;
59
+ export type GoalCompletionEvaluation = {
60
+ ok: true;
61
+ header: string;
62
+ } | {
63
+ ok: false;
64
+ reason: "no-section";
65
+ } | {
66
+ ok: false;
67
+ reason: "vague-section";
68
+ header: string;
69
+ };
70
+ /**
71
+ * Evaluate a description against the goal-completion rule. Headers are tried
72
+ * in **configured list order**, not document order — the first header in
73
+ * `headers` that is present in the description decides the outcome, even if a
74
+ * later-listed header appears earlier in the body. This is intentional: the
75
+ * list order encodes the operator's preferred canonical header, so the block
76
+ * message names the header they'd rather authors use. A present-but-vague
77
+ * section is reported as `vague-section` with the header that matched, so the
78
+ * error can point at the exact section rather than a generic "missing".
79
+ */
80
+ export declare function evaluateGoalCompletion(description: string, headers?: string[]): GoalCompletionEvaluation;
81
+ /**
82
+ * Render the human/agent-facing block emitted when the gate fires. `reason`
83
+ * distinguishes "no goal-completion section at all" from "section present but
84
+ * nothing falsifiable in it", so the message points at the exact fix. Names
85
+ * the rule and the `--allow-vague-goal` escape hatch.
86
+ */
87
+ export declare function formatGoalCompletionBlock(opts: {
88
+ reason: "no-section" | "vague-section";
89
+ headers: string[];
90
+ /** The header that matched, when reason is `vague-section`. */
91
+ sectionHeader?: string;
92
+ }): string;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Goal-completion ("Done when") validation — DEV-5920.
3
+ *
4
+ * A create-time gate that checks the issue description for a goal-completion
5
+ * section ("Done when", "Acceptance criteria", "Success criteria", …) that
6
+ * contains at least one FALSIFIABLE criterion — something a later session can
7
+ * mechanically verify (a command with an expected result, a threshold number,
8
+ * a named artifact path, an exit-code/status assertion, or an explicit
9
+ * "verifiable via X" phrase). A section made only of bare quality adjectives
10
+ * ("improved", "better", "cleaner", "faster") gives the implementing agent no
11
+ * terminal state to converge on, which is the concrete-goals failure mode this
12
+ * gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
13
+ * gate and the DEV-5378 SOP-label parent gate.
14
+ *
15
+ * OPT-IN by design. el-linear is MIT and published on npm; most installs are
16
+ * not Enrich Layer and must not be surprised by a new refusal. The gate is
17
+ * dormant unless `validation.goalCompletionGate` is set to `"warn"` or
18
+ * `"block"` (the EL workspace flips it on in its shared team config). Section
19
+ * matching reuses the `extractField` header semantics (`##`/`###` ATX headers
20
+ * and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
21
+ * so the gate accepts exactly what `issues read --field "Done when"` can later
22
+ * extract.
23
+ */
24
+ import { extractField } from "../utils/extract-field.js";
25
+ import { loadConfig } from "./config.js";
26
+ /**
27
+ * Default section headers accepted as the goal-completion section, matched
28
+ * with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
29
+ * forms, trailing colons stripped). Overridable via
30
+ * `config.validation.goalSectionHeaders`.
31
+ */
32
+ export const DEFAULT_GOAL_SECTION_HEADERS = [
33
+ "Done when",
34
+ "Done-when",
35
+ "Acceptance criteria",
36
+ "Success criteria",
37
+ ];
38
+ /**
39
+ * Resolve the goal-completion-gate config from the merged el-linear config.
40
+ *
41
+ * The gate is dormant by default. It activates only when validation isn't
42
+ * disabled (`validation.enabled !== false`) AND the operator has explicitly
43
+ * set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
44
+ * value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
45
+ * must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
46
+ * falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
47
+ */
48
+ export function getGoalCompletionGateConfig() {
49
+ const validation = loadConfig().validation;
50
+ const raw = validation?.goalCompletionGate;
51
+ const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
52
+ ? raw
53
+ : "off";
54
+ const headers = validation?.goalSectionHeaders && validation.goalSectionHeaders.length > 0
55
+ ? validation.goalSectionHeaders
56
+ : DEFAULT_GOAL_SECTION_HEADERS;
57
+ return { mode, headers };
58
+ }
59
+ /**
60
+ * Falsifiability proxies — any single match makes the section pass. Each is a
61
+ * cheap textual stand-in for "a later session can mechanically check this":
62
+ *
63
+ * - **command** — inline code or a fenced block (`` `pnpm test` `` and its
64
+ * expected output live in code spans by Markdown convention).
65
+ * - **number** — a digit anywhere in the section: thresholds ("under 200ms",
66
+ * "95%"), counts ("all 12 tests"), issue/artifact ids. Deliberately
67
+ * permissive — the failure mode this gate targets is a section with NO
68
+ * number/command/artifact at all, not a weak number.
69
+ * - **artifact path** — a bare filename with a code-adjacent extension
70
+ * (`foo.ts`, `report.json` — this also matches the file segment inside a
71
+ * longer path like `src/utils/foo.ts`), or an ANCHORED slash-path (leading
72
+ * `./`, `../`, `/`, or `~/`) for extension-less dirs like `./scripts/run`.
73
+ * The anchor requirement is deliberate: an un-anchored two-segment
74
+ * `a/b` pattern over-fires on English prose ("and/or", "read/write",
75
+ * "client/server"), so a real relative artifact path must either carry a
76
+ * file extension or start with a path anchor to count.
77
+ * - **exit/status assertion** — "exits non-zero", "exit code 0", "tests
78
+ * pass", "CI green", "returns nonzero".
79
+ * - **verifiable-via phrase** — "verifiable via/by/with/through X".
80
+ */
81
+ const FALSIFIABLE_PROXY_RES = [
82
+ // Inline code span or a fenced code block opener.
83
+ /`[^`\n]+`/,
84
+ /^ {0,3}(?:`{3,}|~{3,})/m,
85
+ // Threshold number / percentage / count.
86
+ /\d/,
87
+ // Artifact path — a filename with a code-adjacent extension (also fires on
88
+ // the file segment of a longer path), OR an anchored slash-path for
89
+ // extension-less dirs. The anchor (`./` `../` `/` `~/`) is required so a
90
+ // bare `a/b` doesn't over-fire on prose like "and/or" / "read/write".
91
+ /\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|ya?ml|sh|css|html|txt|csv|toml|sql|py|go|rs|lock)\b/i,
92
+ /(?:^|[\s("'[])(?:\.{1,2}\/|~\/|\/)[\w.-]+(?:\/[\w.-]+)*/m,
93
+ // Exit-code / status assertion.
94
+ /\bexit(?:s|ed)?\s+(?:code\s+|status\s+)?(?:non-?zero|zero|\d+)\b/i,
95
+ /\bexit\s+(?:code|status)\b/i,
96
+ /\breturns?\s+non-?zero\b/i,
97
+ /\b(?:test|tests|suite|ci|pipeline|lint|typecheck|build|check|checks)\s+(?:is\s+|are\s+|stays?\s+|go(?:es)?\s+)?(?:pass(?:es|ing)?|green|fail(?:s|ing)?|red)\b/i,
98
+ // Explicit "verifiable via X" escape phrase.
99
+ /\bverifi(?:able|ed)\s+(?:via|by|with|through)\b/i,
100
+ ];
101
+ /**
102
+ * Does the section text contain at least one falsifiable criterion?
103
+ * See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
104
+ * section trivially fails — a bare header is not a criterion.
105
+ */
106
+ export function hasFalsifiableCriterion(sectionText) {
107
+ if (!sectionText || sectionText.trim().length === 0) {
108
+ return false;
109
+ }
110
+ return FALSIFIABLE_PROXY_RES.some((re) => re.test(sectionText));
111
+ }
112
+ /**
113
+ * Evaluate a description against the goal-completion rule. Headers are tried
114
+ * in **configured list order**, not document order — the first header in
115
+ * `headers` that is present in the description decides the outcome, even if a
116
+ * later-listed header appears earlier in the body. This is intentional: the
117
+ * list order encodes the operator's preferred canonical header, so the block
118
+ * message names the header they'd rather authors use. A present-but-vague
119
+ * section is reported as `vague-section` with the header that matched, so the
120
+ * error can point at the exact section rather than a generic "missing".
121
+ */
122
+ export function evaluateGoalCompletion(description, headers = DEFAULT_GOAL_SECTION_HEADERS) {
123
+ for (const header of headers) {
124
+ const section = extractField(description, header);
125
+ if (section !== null) {
126
+ return hasFalsifiableCriterion(section)
127
+ ? { ok: true, header }
128
+ : { ok: false, reason: "vague-section", header };
129
+ }
130
+ }
131
+ return { ok: false, reason: "no-section" };
132
+ }
133
+ /**
134
+ * Render the human/agent-facing block emitted when the gate fires. `reason`
135
+ * distinguishes "no goal-completion section at all" from "section present but
136
+ * nothing falsifiable in it", so the message points at the exact fix. Names
137
+ * the rule and the `--allow-vague-goal` escape hatch.
138
+ */
139
+ export function formatGoalCompletionBlock(opts) {
140
+ const headerList = opts.headers.join(", ");
141
+ let head;
142
+ if (opts.reason === "no-section") {
143
+ head =
144
+ `Issue description has no goal-completion section (looked for: ${headerList}).\n` +
145
+ ` Add a "${opts.headers[0]}" section stating how completion will be verified.\n`;
146
+ }
147
+ else {
148
+ head =
149
+ `The "${opts.sectionHeader}" section contains no falsifiable criterion.\n` +
150
+ ' Bare quality adjectives ("improved", "better", "cleaner", "faster") give the\n' +
151
+ " implementing session no terminal state to converge on.\n";
152
+ }
153
+ const criteria = " At least one criterion must be mechanically checkable: a command with its\n" +
154
+ " expected exit/output, a threshold number or percentage, a named artifact path,\n" +
155
+ ' an exit-code/status assertion, or a "verifiable via X" phrase.\n';
156
+ const hatch = " If the goal is intentionally open-ended, re-run with --allow-vague-goal.";
157
+ return head + criteria + hatch;
158
+ }
@@ -223,6 +223,15 @@ export type FileDownloadResult = {
223
223
  error: string;
224
224
  statusCode?: number;
225
225
  };
226
+ export type FileReadResult = {
227
+ success: true;
228
+ content: string;
229
+ contentType: string;
230
+ } | {
231
+ success: false;
232
+ error: string;
233
+ statusCode?: number;
234
+ };
226
235
  export type FileUploadResult = {
227
236
  success: true;
228
237
  assetUrl: string;
@@ -1,5 +1,5 @@
1
1
  import type { LinearCredential } from "../auth/linear-credential.js";
2
- import type { FileDownloadResult, FileUploadResult } from "../types/linear.js";
2
+ import type { FileDownloadResult, FileReadResult, FileUploadResult } from "../types/linear.js";
3
3
  /**
4
4
  * Constructor arg for `FileService`. Re-exported alias of the shared
5
5
  * `LinearCredential` union (`{ apiKey } | { oauthToken }`). See
@@ -10,6 +10,8 @@ export type FileServiceAuth = LinearCredential;
10
10
  export declare class FileService {
11
11
  private readonly authHeader;
12
12
  constructor(auth: FileServiceAuth);
13
+ private fetchUpload;
14
+ readTextFile(url: string): Promise<FileReadResult>;
13
15
  downloadFile(url: string, options?: {
14
16
  output?: string;
15
17
  overwrite?: boolean;
@@ -47,6 +47,58 @@ export class FileService {
47
47
  constructor(auth) {
48
48
  this.authHeader = buildAuthHeader(auth);
49
49
  }
50
+ async fetchUpload(url) {
51
+ const urlObj = new URL(url);
52
+ const headers = {};
53
+ if (!urlObj.searchParams.has("signature")) {
54
+ headers.Authorization = this.authHeader;
55
+ }
56
+ return fetch(url, { method: "GET", headers });
57
+ }
58
+ async readTextFile(url) {
59
+ if (!isLinearUploadUrl(url)) {
60
+ return {
61
+ success: false,
62
+ error: "URL must be from uploads.linear.app domain",
63
+ };
64
+ }
65
+ try {
66
+ const response = await this.fetchUpload(url);
67
+ if (!response.ok) {
68
+ return {
69
+ success: false,
70
+ error: `HTTP ${response.status}: ${response.statusText}`,
71
+ statusCode: response.status,
72
+ };
73
+ }
74
+ const contentType = (response.headers.get("content-type") ?? "")
75
+ .split(";", 1)[0]
76
+ .toLowerCase();
77
+ const isText = contentType.startsWith("text/") ||
78
+ [
79
+ "application/json",
80
+ "application/xml",
81
+ "application/javascript",
82
+ ].includes(contentType);
83
+ if (!isText) {
84
+ return {
85
+ success: false,
86
+ error: `Attachment is not text (${contentType || "unknown content type"}); use attachments download instead.`,
87
+ };
88
+ }
89
+ return {
90
+ success: true,
91
+ content: await response.text(),
92
+ contentType,
93
+ };
94
+ }
95
+ catch (error) {
96
+ return {
97
+ success: false,
98
+ error: error instanceof Error ? error.message : String(error),
99
+ };
100
+ }
101
+ }
50
102
  async downloadFile(url, options = {}) {
51
103
  if (!isLinearUploadUrl(url)) {
52
104
  return {
@@ -68,13 +120,7 @@ export class FileService {
68
120
  }
69
121
  }
70
122
  try {
71
- const urlObj = new URL(url);
72
- const isSignedUrl = urlObj.searchParams.has("signature");
73
- const headers = {};
74
- if (!isSignedUrl) {
75
- headers.Authorization = this.authHeader;
76
- }
77
- const response = await fetch(url, { method: "GET", headers });
123
+ const response = await this.fetchUpload(url);
78
124
  if (!response.ok) {
79
125
  return {
80
126
  success: false,
@@ -209,6 +209,7 @@ export declare class GraphQLIssuesService {
209
209
  startIssue(issueId: string): Promise<StartIssueResult>;
210
210
  claimIssue(issueId: string): Promise<ClaimIssueResult>;
211
211
  updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
212
+ private updateIssueImpl;
212
213
  archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
213
214
  deleteIssue(issueId: string, options?: {
214
215
  permanentlyDelete?: boolean;
@@ -216,6 +217,7 @@ export declare class GraphQLIssuesService {
216
217
  private extractMilestoneNodes;
217
218
  private executeUpdateMutation;
218
219
  createIssue(args: CreateIssueArgs): Promise<LinearIssue>;
220
+ private createIssueImpl;
219
221
  /**
220
222
  * Folds the `@include`-gated `projectsByName` / `projectsById` aliases
221
223
  * from a create batch response into the single `projects` field the
@@ -28,6 +28,31 @@ function extractSummaryText(summary) {
28
28
  walk(summary.content);
29
29
  return parts.length > 0 ? parts.join("") : undefined;
30
30
  }
31
+ /**
32
+ * FE-926: Linear's description-content store can end up in a corrupted state
33
+ * for a specific issue — observed on FE-921, where a description saved
34
+ * cleanly at creation was empty ~1 minute later with no issue-history trace,
35
+ * and every subsequent write attempt raised this same raw GraphQL error
36
+ * against a *different* DocumentContent id each time. It can surface from
37
+ * any call that touches the issue's description, including a plain
38
+ * batch-resolve read (not just the create/update mutation itself), so
39
+ * `updateIssue`/`createIssue` wrap their whole body rather than a single
40
+ * call site. Retrying does not self-heal — the raw backend message is
41
+ * replaced with the known workaround.
42
+ */
43
+ function rethrowWithDocumentContentHint(error, issueId) {
44
+ const msg = error instanceof Error ? error.message : String(error);
45
+ if (msg.includes("Conflict on insert of DocumentContent")) {
46
+ const detail = issueId
47
+ ? `for ${issueId}. This does not self-heal by retrying — each attempt fails against a new conflicting id. Workaround: create a replacement issue with the same content, relate it back with --duplicate-of ${issueId}, and cancel the original.`
48
+ : "for this issue. This does not self-heal by retrying — each attempt fails against a new conflicting id. If it recurs on the same issue after creation, recreate it under a fresh id rather than repairing via update.";
49
+ throw new Error(`Linear reports its description store is in conflict ${detail}`);
50
+ }
51
+ if (error instanceof Error) {
52
+ throw error;
53
+ }
54
+ throw new Error(msg);
55
+ }
31
56
  export class GraphQLIssuesService {
32
57
  graphQLService;
33
58
  linearService;
@@ -262,6 +287,14 @@ export class GraphQLIssuesService {
262
287
  };
263
288
  }
264
289
  async updateIssue(args, labelMode = "overwriting") {
290
+ try {
291
+ return await this.updateIssueImpl(args, labelMode);
292
+ }
293
+ catch (error) {
294
+ rethrowWithDocumentContentHint(error, args.id);
295
+ }
296
+ }
297
+ async updateIssueImpl(args, labelMode = "overwriting") {
265
298
  // Normalize URL/slug-id --project inputs to UUIDs before the batch
266
299
  // resolver runs; see comment on `withNormalizedProjectId`.
267
300
  const normalizedArgs = await this.withNormalizedProjectId(args);
@@ -368,6 +401,14 @@ export class GraphQLIssuesService {
368
401
  return this.transformIssueData(issueUpdate.issue);
369
402
  }
370
403
  async createIssue(args) {
404
+ try {
405
+ return await this.createIssueImpl(args);
406
+ }
407
+ catch (error) {
408
+ rethrowWithDocumentContentHint(error);
409
+ }
410
+ }
411
+ async createIssueImpl(args) {
371
412
  // Pre-resolve URL/slug-id forms of --project to a UUID so the batch
372
413
  // resolver below (which uses a `name eqIgnoreCase` filter) can skip
373
414
  // the project lookup entirely. Plain name inputs flow through unchanged
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.38.0",
3
+ "version": "1.38.1",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",