@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.
@@ -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
+ }
@@ -77,6 +77,16 @@ export interface UpdateProjectResponse {
77
77
  project: ProjectBaseNode | null;
78
78
  };
79
79
  }
80
+ interface ProjectUpdateFieldsNode extends ProjectBaseNode {
81
+ description: string | null;
82
+ content: string | null;
83
+ }
84
+ export interface UpdateProjectFieldsResponse {
85
+ projectUpdate: {
86
+ success: boolean;
87
+ project: ProjectUpdateFieldsNode | null;
88
+ };
89
+ }
80
90
  interface ProjectArchiveEntity {
81
91
  id: string;
82
92
  }
@@ -14,5 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
14
14
  export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
15
15
  export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
16
16
  export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
17
+ export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
17
18
  export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
18
19
  export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
@@ -129,6 +129,26 @@ export const UPDATE_PROJECT_MUTATION = `
129
129
  }
130
130
  }
131
131
  `;
132
+ export const UPDATE_PROJECT_FIELDS_MUTATION = `
133
+ mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {
134
+ projectUpdate(id: $id, input: $input) {
135
+ success
136
+ project {
137
+ id
138
+ name
139
+ description
140
+ content
141
+ teams {
142
+ nodes {
143
+ id
144
+ key
145
+ name
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ `;
132
152
  export const ARCHIVE_PROJECT_MUTATION = `
133
153
  mutation ArchiveProject($id: String!) {
134
154
  projectArchive(id: $id) {
@@ -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,
@@ -1,3 +1,23 @@
1
+ /**
2
+ * Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
3
+ *
4
+ * The `issues create` duplicate-detection gate (DEV-4823) records each decision
5
+ * it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
6
+ * command, or any JSONL consumer) can compute the gate's override-rate
7
+ * (overridden / total) and tell whether the threshold is noisy.
8
+ *
9
+ * **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
10
+ * must never write files a user didn't ask for. Emission is therefore OFF by
11
+ * default and turns on only when telemetry is actually configured — see
12
+ * {@link decideGateLedger}. The ledger is a plain local JSONL file
13
+ * (`gate-events.jsonl`); there is no server or database. el-linear can't import
14
+ * `el-telemetry` (separate package), so it writes by **path-contract** — the
15
+ * same approach `el-hook` uses. The path mirrors `el-telemetry`'s
16
+ * `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
17
+ * `docs/telemetry.md`.
18
+ */
19
+ export declare const GATE_LEDGER_MAX_BYTES: number;
20
+ export declare const GATE_LEDGER_BACKUP_SUFFIX = ".old";
1
21
  /** Resolve where the ledger lives — for a *reader* locating the file (mirrors
2
22
  * el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
3
23
  * the opt-in policy, so never write through it — `emitGateEvent` goes through
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { appendFile, mkdir } from "node:fs/promises";
2
+ import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  /**
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
20
20
  * `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
21
21
  * `docs/telemetry.md`.
22
22
  */
23
+ export const GATE_LEDGER_MAX_BYTES = 2 * 1024 * 1024;
24
+ export const GATE_LEDGER_BACKUP_SUFFIX = ".old";
23
25
  /** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
24
26
  function defaultTelemetryDir() {
25
27
  return join(homedir(), ".cache", "el-telemetry");
@@ -67,6 +69,28 @@ function gateLedgerIfEnabled() {
67
69
  defaultDirExists: existsSync(defaultDir),
68
70
  });
69
71
  }
72
+ async function rotateGateLedgerIfOverLimit(path) {
73
+ let size = 0;
74
+ try {
75
+ const s = await stat(path);
76
+ if (!s.isFile())
77
+ return;
78
+ size = s.size;
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (size <= GATE_LEDGER_MAX_BYTES)
84
+ return;
85
+ try {
86
+ const backupPath = `${path}${GATE_LEDGER_BACKUP_SUFFIX}`;
87
+ await rm(backupPath, { force: true });
88
+ await rename(path, backupPath);
89
+ }
90
+ catch {
91
+ // best-effort — telemetry never blocks issue creation
92
+ }
93
+ }
70
94
  /**
71
95
  * Best-effort append of a gate event to the local ledger — but only when
72
96
  * telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
@@ -80,6 +104,7 @@ export async function emitGateEvent(name, subcommand, event) {
80
104
  }
81
105
  try {
82
106
  await mkdir(dirname(path), { recursive: true });
107
+ await rotateGateLedgerIfOverLimit(path);
83
108
  const record = {
84
109
  ts: new Date().toISOString(),
85
110
  kind: "gate",
@@ -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
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Normalize shell-literal newline escapes in inline CLI text fields.
3
+ *
4
+ * File inputs are intentionally excluded by call site: a file body is already
5
+ * explicit authored text and may intentionally contain backslash sequences.
6
+ */
7
+ export declare function normalizeInlineTextInput(value: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalize shell-literal newline escapes in inline CLI text fields.
3
+ *
4
+ * File inputs are intentionally excluded by call site: a file body is already
5
+ * explicit authored text and may intentionally contain backslash sequences.
6
+ */
7
+ export function normalizeInlineTextInput(value) {
8
+ return value
9
+ .replace(/\\r\\n/g, "\n")
10
+ .replace(/\\n/g, "\n")
11
+ .replace(/\\r/g, "\n");
12
+ }
@@ -41,16 +41,23 @@
41
41
  */
42
42
  export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
43
43
  /**
44
- * Build the relation-candidate confirmation warning string, or `null` when
45
- * the result set has no identifier-bearing rows (nothing to confirm).
44
+ * Build the relation-candidate warning string, or `null` when the result set
45
+ * has no identifier-bearing rows (nothing to surface).
46
46
  *
47
47
  * Shape (single line, structured-prose so a skill can match on the prefix):
48
48
  *
49
49
  * relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
50
- * To link them as related: reply with the IDs you want linked
51
- * (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
50
+ * Link the relevant ones now — at create time with --related-to, or
51
+ * `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
52
+ * agent-inferred relate, reply with the IDs you want linked
53
+ * (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
52
54
  *
53
- * The `relation_candidates:` prefix matches the existing `results_truncated:`
55
+ * DEV-5853: the primary framing is **proactive** — this is a convenience list
56
+ * of link candidates, not a stop sign. Relate the relevant ones directly
57
+ * rather than waiting to be told; the reply flow is the *fallback* for when
58
+ * the auto-mode classifier actually blocks an agent-inferred `issues relate`
59
+ * (create-time `--related-to` typically passes, so prefer it). The
60
+ * `relation_candidates:` prefix matches the existing `results_truncated:`
54
61
  * convention in `outputWarning` callers — a stable token a skill / agent
55
62
  * harness can grep for without parsing free-form prose.
56
63
  */