@enrichlayer/el-linear 1.37.2 → 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,14 +271,18 @@ 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.
279
282
  `export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
280
- `EL_TELEMETRY_DISABLED=1` forces it off. Full opt-in rules, the event schema, and
281
- a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
283
+ `EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
284
+ backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
285
+ and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
282
286
 
283
287
  ### Networking (IPv4 preference)
284
288
 
@@ -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:
@@ -237,6 +251,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
237
251
  el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
238
252
  ```
239
253
 
254
+ ### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
255
+
256
+ **The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
257
+
258
+ - **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
259
+ - **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
260
+ - Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
261
+
262
+ The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
263
+
240
264
  ### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
241
265
 
242
266
  The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
@@ -248,38 +272,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
248
272
 
249
273
  When `el-linear issues search` (or the cross-resource `search`) returns rows
250
274
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
251
- starting with `relation_candidates:` that enumerates the candidate IDs and
252
- asks the user to reply with which ones to link, and states the skip phrase.
253
-
254
- That warning is the authoritative instruction — surface it to the user
255
- verbatim and follow it literally: **do not call `el-linear issues relate`
256
- until the user replies naming the IDs to link.** Only user-named IDs go into
257
- the `issues relate <source> --related-to "<ids>"` call — never pass an ID the
258
- user did not name, even one your own search obviously surfaced. The CLI emits
259
- the full procedure (every candidate ID, the example reply, the skip phrase) in
260
- that one line, so follow it rather than re-deriving or paraphrasing it away.
261
-
262
- Why this matters: Claude Code's auto-mode permission classifier blocks
263
- `issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
264
- (came from your own search) rather than *user-specified* (typed by the human),
265
- because each listed peer is a write target. Routing the IDs through an
266
- explicit human reply converts them from agent-inferred → user-specified;
267
- the existing search step (above) stays intact; auto-mode's guard is not
268
- weakened. The fix is the loop shape, not the guard.
269
-
270
- Anti-patterns:
271
-
272
- - **Calling `issues relate` directly off your own search output** — even if
273
- the IDs are real and the candidates look obvious, this is the exact path
274
- the auto-mode guard refuses.
275
- - **Splitting one relate call into N single-ID calls** to "look smaller" —
276
- same provenance problem, same block, just multiplied.
277
- - **Asking the user a yes/no question** ("Should I link these?") instead of
278
- having them name the IDs — yes answers stay agent-inferred, the reply
279
- must carry the IDs to convert them to user-specified.
275
+ starting with `relation_candidates:` enumerating the candidate IDs. Treat it
276
+ as a **convenience list of link candidates, not a stop sign** — under the
277
+ proactive default above, relate the ones that are genuinely related/blocking
278
+ without waiting to be told. Prefer create-time `--related-to` when the search
279
+ ran as part of filing a new issue (create-time relations typically pass the
280
+ classifier); otherwise call `issues relate` directly.
281
+
282
+ **Residual auto-mode constraint (the only reason to pause).** Claude Code's
283
+ auto-mode permission classifier *may* block a standalone `issues relate
284
+ --related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
285
+ your own search) rather than *user-specified*, because each peer is a write
286
+ target. A standing user instruction to cross-link generously is itself
287
+ authorization — so proceed by default. But if a specific `relate` call is
288
+ **actually blocked** by the classifier, that block is your cue: surface the
289
+ candidate IDs to the user for a one-word confirm, then re-run with the
290
+ user-named IDs (which pass). This is the **exception path**, not the default —
291
+ do not pre-emptively withhold links the classifier would have allowed. Two
292
+ ways to keep it frictionless: file relations at create time (`--related-to`),
293
+ or the operator adds a permission rule / runs non-auto-mode so post-hoc
294
+ `relate` never trips.
280
295
 
281
296
  If `--include-closed` search returns no matches, no `relation_candidates:`
282
- warning is emitted (nothing to confirm) and the flow proceeds normally.
297
+ warning is emitted and the flow proceeds normally.
283
298
 
284
299
  ### Viewing existing relations
285
300
 
@@ -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.")
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
4
4
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
5
5
  import { applyFooter } from "../utils/footer.js";
6
6
  import { createGraphQLService, } from "../utils/graphql-service.js";
7
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
7
8
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
8
9
  import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
9
10
  import { createLinearService, } from "../utils/linear-service.js";
@@ -79,7 +80,7 @@ function readBody(options) {
79
80
  return readFileSync(options.bodyFile, "utf-8");
80
81
  }
81
82
  if (options.body) {
82
- return options.body;
83
+ return normalizeInlineTextInput(options.body);
83
84
  }
84
85
  throw new Error("Either --body or --body-file is required");
85
86
  }
@@ -20,6 +20,7 @@ import fs from "node:fs";
20
20
  import { loadConfig } from "../../config/config.js";
21
21
  import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
22
22
  import { autoLinkReferences, } from "../../utils/auto-link-references.js";
23
+ import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
23
24
  import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
24
25
  import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
25
26
  import { validateReferences } from "../../utils/validate-references.js";
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
59
60
  return readDescriptionFile(options.descriptionFile);
60
61
  }
61
62
  if (hasInline) {
62
- return options.description;
63
+ return normalizeInlineTextInput(options.description);
63
64
  }
64
65
  if (hasTemplate) {
65
66
  const templates = loadConfig().descriptionTemplates ?? {};
@@ -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";
@@ -13,6 +14,7 @@ import { applyFooter } from "../utils/footer.js";
13
14
  import { emitGateEvent } from "../utils/gate-telemetry.js";
14
15
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
16
  import { createGraphQLService, } from "../utils/graphql-service.js";
17
+ import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
16
18
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
17
19
  import { createLinearService, } from "../utils/linear-service.js";
18
20
  import { logger } from "../utils/logger.js";
@@ -205,7 +207,8 @@ async function handleListIssues(options, command) {
205
207
  // user passes --include-closed OR explicit --status. Explicit status
206
208
  // wins because the user already named the workflow states they want.
207
209
  const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
208
- const hasOtherFilters = options.team ||
210
+ const hasOtherFilters = options.search ||
211
+ options.team ||
209
212
  options.labels ||
210
213
  options.status ||
211
214
  options.assignee ||
@@ -223,6 +226,7 @@ async function handleListIssues(options, command) {
223
226
  // (DEV-4478 cycle-1.)
224
227
  if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
225
228
  const searchArgs = {
229
+ query: options.search,
226
230
  teamId: options.team ? resolveTeam(options.team) : undefined,
227
231
  assigneeId: options.assignee
228
232
  ? await resolveAssignee(options.assignee, rootOpts)
@@ -244,8 +248,15 @@ async function handleListIssues(options, command) {
244
248
  if (excludeTerminalStates) {
245
249
  outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
246
250
  }
251
+ if (options.search) {
252
+ const relationPrompt = buildRelationCandidatePrompt(result);
253
+ if (relationPrompt) {
254
+ outputWarning(relationPrompt);
255
+ }
256
+ }
247
257
  warnIfTruncated(result.length, limit);
248
258
  outputIssues(result, command, {
259
+ query: options.search,
249
260
  team: options.team,
250
261
  });
251
262
  }
@@ -330,7 +341,12 @@ async function withProjectResolverEnrichment(fn, options, services) {
330
341
  throw err;
331
342
  }
332
343
  }
333
- 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) {
334
350
  const config = loadConfig();
335
351
  enforceTerms([title, options.description], { strict: options.strict });
336
352
  // Effective assignee: explicit --assignee wins; --no-assignee (commander
@@ -359,10 +375,9 @@ async function resolveCreateInputs(title, options, rootOpts) {
359
375
  const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
360
376
  if (!options.skipValidation && !hasFromTemplate) {
361
377
  const rawLabels = options.labels ? splitList(options.labels) : null;
362
- const description = resolveDescription(options);
363
378
  const validationResult = validateIssueCreation({
364
379
  labels: rawLabels,
365
- description: description || undefined,
380
+ description: resolvedDescription || undefined,
366
381
  title,
367
382
  assignee: effectiveAssignee,
368
383
  project: options.project,
@@ -689,11 +704,84 @@ async function enforceSopLabelParent(labels, options, issuesService) {
689
704
  // any unresolvable refs so a typo reads differently from a real non-SOP parent.
690
705
  await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
691
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
+ }
692
765
  async function handleCreateIssue(title, options, command) {
693
766
  const rootOpts = getRootOpts(command);
694
- 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);
695
783
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
696
- const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
784
+ const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
697
785
  // Append messageFooter (config or --footer flag) so auto-link picks up any
698
786
  // issue refs in the footer too. --no-footer skips both flag and config.
699
787
  // Commander parses --no-footer as `options.footer === false`.
@@ -703,6 +791,21 @@ async function handleCreateIssue(title, options, command) {
703
791
  footer: explicitFooter,
704
792
  noFooter,
705
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
+ }
706
809
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
707
810
  // DEV-4823: deterministic duplicate-detection gate. Runs before the create
708
811
  // POST, searches the title's salient keywords (including closed issues),
@@ -947,6 +1050,12 @@ async function handleUpdateIssue(issueId, options, command) {
947
1050
  if (options.descriptionFile) {
948
1051
  options.description = readDescriptionFile(options.descriptionFile);
949
1052
  }
1053
+ else if (typeof options.description === "string") {
1054
+ options.description = normalizeInlineTextInput(options.description);
1055
+ }
1056
+ if (typeof options.appendDescription === "string") {
1057
+ options.appendDescription = normalizeInlineTextInput(options.appendDescription);
1058
+ }
950
1059
  validateUpdateOptions(options);
951
1060
  const rootOpts = getRootOpts(command);
952
1061
  const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
@@ -1228,6 +1337,7 @@ export function setupIssuesCommands(program) {
1228
1337
  .command("list")
1229
1338
  .description("List issues.")
1230
1339
  .option("-l, --limit <number>", "limit results", "25")
1340
+ .option("--search <query>", "full-text search term; composes with list filters")
1231
1341
  .option("--team <team>", "filter by team key (EL: resolves names)")
1232
1342
  .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
1233
1343
  .option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
@@ -1291,9 +1401,10 @@ export function setupIssuesCommands(program) {
1291
1401
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
1292
1402
  .option("--checkout", "create and checkout a git branch named after the issue")
1293
1403
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1294
- .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)")
1295
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)")
1296
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')
1297
1408
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1298
1409
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1299
1410
  .option("--no-footer", "skip the configured messageFooter for this issue")
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
9
9
  import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
10
10
  async function handleCreateLabel(name, options, command) {
11
11
  const rootOpts = getRootOpts(command);
12
- const teamId = resolveTeam(options.team);
13
12
  const graphQLService = await createGraphQLService(rootOpts);
13
+ const linearService = await createLinearService(rootOpts);
14
+ const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
14
15
  const input = { name, teamId };
15
16
  if (options.color) {
16
17
  input.color = validateHexColor(options.color);
@@ -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, PROJECT_READ_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_FIELDS_MUTATION, 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";
@@ -229,6 +229,26 @@ function formatTeamsOutput(projectUpdate) {
229
229
  })),
230
230
  };
231
231
  }
232
+ function hasOption(options, key) {
233
+ return options[key] !== undefined;
234
+ }
235
+ function flattenProjectUpdate(projectUpdate) {
236
+ if (!projectUpdate.project) {
237
+ throw new Error("Failed to update project");
238
+ }
239
+ const updatedProject = projectUpdate.project;
240
+ return {
241
+ id: updatedProject.id,
242
+ name: updatedProject.name,
243
+ description: updatedProject.description ?? undefined,
244
+ content: updatedProject.content ?? undefined,
245
+ teams: updatedProject.teams.nodes.map((t) => ({
246
+ id: t.id,
247
+ key: t.key,
248
+ name: t.name,
249
+ })),
250
+ };
251
+ }
232
252
  async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
233
253
  const rootOpts = getRootOpts(command);
234
254
  const graphQLService = await createGraphQLService(rootOpts);
@@ -437,6 +457,31 @@ async function handleReadProject(projectNameOrId, _options, command) {
437
457
  teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
438
458
  });
439
459
  }
460
+ async function handleUpdateProject(projectNameOrId, options, command) {
461
+ const input = {};
462
+ if (hasOption(options, "name")) {
463
+ input.name = options.name;
464
+ }
465
+ if (hasOption(options, "description")) {
466
+ input.description = options.description;
467
+ }
468
+ if (hasOption(options, "content")) {
469
+ input.content = options.content;
470
+ }
471
+ if (Object.keys(input).length === 0) {
472
+ throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
473
+ }
474
+ const rootOpts = getRootOpts(command);
475
+ const graphQLService = await createGraphQLService(rootOpts);
476
+ const linearService = await createLinearService(rootOpts);
477
+ const projectId = await linearService.resolveProjectId(projectNameOrId);
478
+ const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
479
+ const projectUpdate = updateResult.projectUpdate;
480
+ if (!projectUpdate.success) {
481
+ throw new Error(`Failed to update project "${projectNameOrId}"`);
482
+ }
483
+ outputSuccess(flattenProjectUpdate(projectUpdate));
484
+ }
440
485
  export function setupProjectsCommands(program) {
441
486
  const projects = program
442
487
  .command("projects")
@@ -463,6 +508,13 @@ export function setupProjectsCommands(program) {
463
508
  .command("read <project>")
464
509
  .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
510
  .action(handleAsyncCommand(handleReadProject));
511
+ projects
512
+ .command("update <project>")
513
+ .description("Update project name, short description, or markdown content")
514
+ .option("--name <name>", "project name")
515
+ .option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
516
+ .option("--content <markdown>", "full markdown body (shown in project panel)")
517
+ .action(handleAsyncCommand(handleUpdateProject));
466
518
  projects
467
519
  .command("list")
468
520
  .description("List projects")
@@ -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