@enrichlayer/el-linear 1.40.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -275,7 +275,11 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
275
275
  [SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate),
276
276
  and an opt-in
277
277
  [goal-completion gate](./docs/configuration.md#goal-completion-gate-validationgoalcompletiongate)
278
- (requires a falsifiable "Done when" / acceptance-criteria section).
278
+ (requires a falsifiable "Done when" / acceptance-criteria section), plus an
279
+ opt-in
280
+ [intake-decision gate](./docs/configuration.md#intake-decision-gate-validationintakedecisiongate)
281
+ that requires need, value, ownership, and placement to be decided before issue
282
+ creation.
279
283
  el-linear can record each gate's fire/override decision to a local JSONL file so
280
284
  you can measure its **override-rate** and tell whether it's too aggressive. It is
281
285
  **off by default** and writes nothing unless you opt in (e.g.
@@ -2,6 +2,7 @@ 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
4
  import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
5
+ import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
5
6
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
6
7
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
7
8
  import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
@@ -762,6 +763,43 @@ async function enforceGoalCompletion(description, options) {
762
763
  });
763
764
  throw new Error(`Issue creation blocked: ${message}`);
764
765
  }
766
+ /**
767
+ * DEV-6163: require the intake judgment before any create-time resolution or
768
+ * mutation. Unlike general field validation, this gate is not silently
769
+ * bypassed by `--skip-validation`; only its narrow, recorded override applies.
770
+ */
771
+ async function enforceIntakeDecision(description, options) {
772
+ const { mode, headers } = getIntakeDecisionGateConfig();
773
+ if (mode === "off") {
774
+ return;
775
+ }
776
+ const evaluation = evaluateIntakeDecision(description, headers);
777
+ if (evaluation.ok) {
778
+ return;
779
+ }
780
+ const gateEvent = { gate: "issues-create-intake-decision" };
781
+ if (options.allowMissingIntakeDecision) {
782
+ await emitGateEvent("el-linear", "issues create", {
783
+ ...gateEvent,
784
+ outcome: "overridden",
785
+ });
786
+ return;
787
+ }
788
+ const message = formatIntakeDecisionBlock({ evaluation, headers });
789
+ if (mode === "warn") {
790
+ await emitGateEvent("el-linear", "issues create", {
791
+ ...gateEvent,
792
+ outcome: "advisory",
793
+ });
794
+ outputWarning(message);
795
+ return;
796
+ }
797
+ await emitGateEvent("el-linear", "issues create", {
798
+ ...gateEvent,
799
+ outcome: "blocked",
800
+ });
801
+ throw new Error(`Issue creation blocked: ${message}`);
802
+ }
765
803
  async function handleCreateIssue(title, options, command) {
766
804
  const rootOpts = getRootOpts(command);
767
805
  // DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
@@ -779,6 +817,7 @@ async function handleCreateIssue(title, options, command) {
779
817
  // normalization) — re-resolving an inline copy would corrupt a file that
780
818
  // intentionally contains backslash sequences.
781
819
  const rawDescription = resolveDescription(options);
820
+ await enforceIntakeDecision(rawDescription ?? "", options);
782
821
  const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
783
822
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
784
823
  const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
@@ -1401,10 +1440,11 @@ export function setupIssuesCommands(program) {
1401
1440
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
1402
1441
  .option("--checkout", "create and checkout a git branch named after the issue")
1403
1442
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1404
- .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate)")
1443
+ .option("--skip-validation", "skip general validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate); the intake-decision gate still requires its narrow override")
1405
1444
  .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)")
1406
1445
  .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1407
1446
  .option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
1447
+ .option("--allow-missing-intake-decision", "create without a complete intake decision when an accountable human approved the exception (recorded as a gate override)")
1408
1448
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1409
1449
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1410
1450
  .option("--no-footer", "skip the configured messageFooter for this issue")
@@ -115,6 +115,18 @@ export interface ElLinearConfig {
115
115
  * "Acceptance criteria", "Success criteria"]`.
116
116
  */
117
117
  goalSectionHeaders?: string[];
118
+ /**
119
+ * OPT-IN explicit intake gate (DEV-6163). When `"warn"` or `"block"`,
120
+ * `issues create` requires an ordered `Intake decision` section recording
121
+ * why the work is needed, why it is worth doing, the existing/duplicate
122
+ * work checked, its canonical owner, concrete placement, and a `PROCEED`
123
+ * decision. Defaults to off for the
124
+ * open-source package. A narrow, recorded override is available through
125
+ * `--allow-missing-intake-decision`.
126
+ */
127
+ intakeDecisionGate?: false | "warn" | "block";
128
+ /** Headers accepted for the intake section. Defaults to `["Intake decision"]`. */
129
+ intakeSectionHeaders?: string[];
118
130
  };
119
131
  /**
120
132
  * Optional override for the Linear workspace URL key (the part after
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Explicit issue-intake validation — DEV-6163.
3
+ *
4
+ * This gate does not try to decide whether work is worthwhile or where it
5
+ * belongs. It requires the author to record those judgments, in order, before
6
+ * `issues create` can mutate Linear. The deterministic part is completeness:
7
+ * needed, worth doing, existing/duplicate work checked, canonical owner,
8
+ * concrete placement, then a proceed decision.
9
+ *
10
+ * OPT-IN by design. el-linear is open source, so the gate is dormant unless a
11
+ * workspace config sets `validation.intakeDecisionGate` to `"warn"` or
12
+ * `"block"`.
13
+ */
14
+ export declare const DEFAULT_INTAKE_SECTION_HEADERS: string[];
15
+ export type IntakeDecisionGateMode = "off" | "warn" | "block";
16
+ export interface IntakeDecisionGateConfig {
17
+ mode: IntakeDecisionGateMode;
18
+ headers: string[];
19
+ }
20
+ export declare function getIntakeDecisionGateConfig(): IntakeDecisionGateConfig;
21
+ export type IntakeDecisionEvaluation = {
22
+ ok: true;
23
+ header: string;
24
+ } | {
25
+ ok: false;
26
+ reason: "no-section";
27
+ } | {
28
+ ok: false;
29
+ reason: "missing-field";
30
+ field: string;
31
+ } | {
32
+ ok: false;
33
+ reason: "duplicate-field";
34
+ field: string;
35
+ } | {
36
+ ok: false;
37
+ reason: "out-of-order";
38
+ field: string;
39
+ } | {
40
+ ok: false;
41
+ reason: "invalid-field";
42
+ field: string;
43
+ } | {
44
+ ok: false;
45
+ reason: "not-proceeding";
46
+ decision: string;
47
+ };
48
+ export declare function evaluateIntakeDecision(description: string, headers?: string[]): IntakeDecisionEvaluation;
49
+ export declare function formatIntakeDecisionBlock(opts: {
50
+ evaluation: Exclude<IntakeDecisionEvaluation, {
51
+ ok: true;
52
+ }>;
53
+ headers: string[];
54
+ }): string;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Explicit issue-intake validation — DEV-6163.
3
+ *
4
+ * This gate does not try to decide whether work is worthwhile or where it
5
+ * belongs. It requires the author to record those judgments, in order, before
6
+ * `issues create` can mutate Linear. The deterministic part is completeness:
7
+ * needed, worth doing, existing/duplicate work checked, canonical owner,
8
+ * concrete placement, then a proceed decision.
9
+ *
10
+ * OPT-IN by design. el-linear is open source, so the gate is dormant unless a
11
+ * workspace config sets `validation.intakeDecisionGate` to `"warn"` or
12
+ * `"block"`.
13
+ */
14
+ import { extractField, stripFencedCodeBlocks } from "../utils/extract-field.js";
15
+ import { loadConfig } from "./config.js";
16
+ export const DEFAULT_INTAKE_SECTION_HEADERS = ["Intake decision"];
17
+ export function getIntakeDecisionGateConfig() {
18
+ const validation = loadConfig().validation;
19
+ const raw = validation?.intakeDecisionGate;
20
+ const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
21
+ ? raw
22
+ : "off";
23
+ const headers = validation?.intakeSectionHeaders &&
24
+ validation.intakeSectionHeaders.length > 0
25
+ ? validation.intakeSectionHeaders
26
+ : DEFAULT_INTAKE_SECTION_HEADERS;
27
+ return { mode, headers };
28
+ }
29
+ const FIELD_DEFINITIONS = [
30
+ { key: "needed", label: "Needed" },
31
+ { key: "worth", label: "Worth doing" },
32
+ { key: "existing", label: "Existing work" },
33
+ { key: "owner", label: "Owner" },
34
+ { key: "placement", label: "Placement" },
35
+ { key: "decision", label: "Decision" },
36
+ ];
37
+ const FIELD_LINE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:\*\*)?(Needed|Worth doing|Existing work|Owner|Placement|Decision)(?:\*\*)?\s*:\s*(.*?)\s*$/gim;
38
+ const PLACEHOLDER = /^(?:tbd|todo|unknown|n\/?a|none|unsure|not decided|-)\.?$/i;
39
+ const NON_SPECIFIC = /^(?:yes|no)$/i;
40
+ const AFFIRMATIVE_WITH_REASON = /^yes\s*(?:[-—:;,]|because)\s*(\S.{2,})$/i;
41
+ function normalizedKey(label) {
42
+ switch (label.toLowerCase()) {
43
+ case "needed":
44
+ return "needed";
45
+ case "worth doing":
46
+ return "worth";
47
+ case "existing work":
48
+ return "existing";
49
+ case "owner":
50
+ return "owner";
51
+ case "placement":
52
+ return "placement";
53
+ default:
54
+ return "decision";
55
+ }
56
+ }
57
+ export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SECTION_HEADERS) {
58
+ let section = null;
59
+ let matchedHeader = "";
60
+ for (const header of headers) {
61
+ section = extractField(description, header);
62
+ if (section !== null) {
63
+ matchedHeader = header;
64
+ break;
65
+ }
66
+ }
67
+ if (section === null) {
68
+ return { ok: false, reason: "no-section" };
69
+ }
70
+ // A template/example fence is not an operative decision record. Remove all
71
+ // fenced examples before matching so copied guidance cannot satisfy intake.
72
+ const operativeSection = stripFencedCodeBlocks(section);
73
+ const values = new Map();
74
+ for (const match of operativeSection.matchAll(FIELD_LINE)) {
75
+ const key = normalizedKey(match[1] ?? "");
76
+ if (values.has(key)) {
77
+ return {
78
+ ok: false,
79
+ reason: "duplicate-field",
80
+ field: FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key,
81
+ };
82
+ }
83
+ values.set(key, { value: match[2]?.trim() ?? "", index: match.index });
84
+ }
85
+ let previousIndex = -1;
86
+ for (const definition of FIELD_DEFINITIONS) {
87
+ const entry = values.get(definition.key);
88
+ if (!entry) {
89
+ return {
90
+ ok: false,
91
+ reason: "missing-field",
92
+ field: definition.label,
93
+ };
94
+ }
95
+ if (entry.index < previousIndex) {
96
+ return {
97
+ ok: false,
98
+ reason: "out-of-order",
99
+ field: definition.label,
100
+ };
101
+ }
102
+ previousIndex = entry.index;
103
+ }
104
+ for (const key of ["needed", "worth"]) {
105
+ const value = values.get(key)?.value ?? "";
106
+ const match = value.match(AFFIRMATIVE_WITH_REASON);
107
+ const reason = match?.[1]?.trim() ?? "";
108
+ if (!match || PLACEHOLDER.test(reason)) {
109
+ return {
110
+ ok: false,
111
+ reason: "invalid-field",
112
+ field: key === "needed" ? "Needed" : "Worth doing",
113
+ };
114
+ }
115
+ }
116
+ for (const key of ["existing", "owner", "placement"]) {
117
+ const value = values.get(key)?.value ?? "";
118
+ if (value.length < 3 ||
119
+ PLACEHOLDER.test(value) ||
120
+ NON_SPECIFIC.test(value)) {
121
+ return {
122
+ ok: false,
123
+ reason: "invalid-field",
124
+ field: key === "existing"
125
+ ? "Existing work"
126
+ : key === "owner"
127
+ ? "Owner"
128
+ : "Placement",
129
+ };
130
+ }
131
+ }
132
+ const decision = values.get("decision")?.value ?? "";
133
+ if (!/^proceed$/i.test(decision)) {
134
+ return { ok: false, reason: "not-proceeding", decision };
135
+ }
136
+ return { ok: true, header: matchedHeader };
137
+ }
138
+ export function formatIntakeDecisionBlock(opts) {
139
+ const { evaluation } = opts;
140
+ let reason;
141
+ switch (evaluation.reason) {
142
+ case "no-section":
143
+ reason = `Issue description has no intake section (looked for: ${opts.headers.join(", ")}).`;
144
+ break;
145
+ case "missing-field":
146
+ reason = `The intake decision is missing the "${evaluation.field}" field.`;
147
+ break;
148
+ case "duplicate-field":
149
+ reason = `The intake decision repeats the "${evaluation.field}" field; record one unambiguous value.`;
150
+ break;
151
+ case "out-of-order":
152
+ reason = `The intake fields are out of order at "${evaluation.field}".`;
153
+ break;
154
+ case "invalid-field":
155
+ reason = `The intake field "${evaluation.field}" is empty, a placeholder, or lacks an explicit yes-and-reason judgment.`;
156
+ break;
157
+ case "not-proceeding":
158
+ reason = `The intake decision is "${evaluation.decision || "empty"}", not PROCEED.`;
159
+ }
160
+ return `${reason}\n\nRecord the decision before creating the issue, in this exact order:\n\n## ${opts.headers[0]}\n- Needed: Yes — <why this is needed>\n- Worth doing: Yes — <why the value exceeds the cost>\n- Existing work: <duplicate/search result and evidence>\n- Owner: <canonical owner or source of truth>\n- Placement: <team/project/repository/document path>\n- Decision: PROCEED\n\nIf an accountable human has approved an exceptional create, re-run with --allow-missing-intake-decision; that override is recorded.`;
161
+ }
@@ -16,6 +16,8 @@
16
16
  * Returns `null` when the field isn't found, so callers can distinguish
17
17
  * "missing" from "empty body".
18
18
  */
19
+ /** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
20
+ export declare function stripFencedCodeBlocks(body: string): string;
19
21
  /**
20
22
  * Multi-section variant of `extractField`. Extracts each requested section
21
23
  * by name in one call and returns a `{section -> text|null}` map preserving
@@ -28,7 +28,50 @@ const BOLD_PSEUDO_HEADER_RE = /^\*\*([^*\n]+?)(?::\s*)?\*\*\s*$/;
28
28
  // permitted as fence indent per the CommonMark spec). We toggle an inFence
29
29
  // flag while scanning so section headers inside code samples don't
30
30
  // terminate the real section.
31
- const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
31
+ const FENCE_DELIMITER_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
32
+ function fenceDelimiter(line) {
33
+ const match = line.match(FENCE_DELIMITER_RE);
34
+ const run = match?.[1] ?? "";
35
+ if (!run || new Set(run).size !== 1)
36
+ return null;
37
+ return { run, rest: match?.[2] ?? "" };
38
+ }
39
+ function openFence(line) {
40
+ const delimiter = fenceDelimiter(line);
41
+ if (!delimiter)
42
+ return null;
43
+ const marker = delimiter.run[0];
44
+ // CommonMark forbids backticks in the info string of a backtick fence.
45
+ if (marker === "`" && delimiter.rest.includes("`"))
46
+ return null;
47
+ return { marker, length: delimiter.run.length };
48
+ }
49
+ function closesFence(line, state) {
50
+ const delimiter = fenceDelimiter(line);
51
+ return (delimiter !== null &&
52
+ delimiter.run[0] === state.marker &&
53
+ delimiter.run.length >= state.length &&
54
+ delimiter.rest.trim().length === 0);
55
+ }
56
+ /** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
57
+ export function stripFencedCodeBlocks(body) {
58
+ const out = [];
59
+ let fence = null;
60
+ for (const line of body.split("\n")) {
61
+ if (fence) {
62
+ if (closesFence(line, fence))
63
+ fence = null;
64
+ continue;
65
+ }
66
+ const opened = openFence(line);
67
+ if (opened) {
68
+ fence = opened;
69
+ continue;
70
+ }
71
+ out.push(line);
72
+ }
73
+ return out.join("\n");
74
+ }
32
75
  function normalize(s) {
33
76
  return s.toLowerCase().trim().replace(/\s+/g, " ");
34
77
  }
@@ -86,15 +129,19 @@ export function extractField(body, fieldName) {
86
129
  return null;
87
130
  const target = normalize(fieldName);
88
131
  const lines = body.split("\n");
89
- let inFence = false;
132
+ let fence = null;
90
133
  let startIdx = -1;
91
134
  for (let i = 0; i < lines.length; i++) {
92
- if (FENCE_RE.test(lines[i])) {
93
- inFence = !inFence;
135
+ if (fence) {
136
+ if (closesFence(lines[i], fence))
137
+ fence = null;
94
138
  continue;
95
139
  }
96
- if (inFence)
140
+ const opened = openFence(lines[i]);
141
+ if (opened) {
142
+ fence = opened;
97
143
  continue;
144
+ }
98
145
  const match = matchHeader(lines[i]);
99
146
  if (!match)
100
147
  continue;
@@ -106,14 +153,21 @@ export function extractField(body, fieldName) {
106
153
  if (startIdx === -1)
107
154
  return null;
108
155
  const out = [];
109
- let sectionInFence = false;
156
+ let sectionFence = null;
110
157
  for (let i = startIdx; i < lines.length; i++) {
111
- if (FENCE_RE.test(lines[i])) {
112
- sectionInFence = !sectionInFence;
158
+ if (sectionFence) {
159
+ if (closesFence(lines[i], sectionFence))
160
+ sectionFence = null;
161
+ out.push(lines[i]);
162
+ continue;
163
+ }
164
+ const opened = openFence(lines[i]);
165
+ if (opened) {
166
+ sectionFence = opened;
113
167
  out.push(lines[i]);
114
168
  continue;
115
169
  }
116
- if (!sectionInFence && matchHeader(lines[i]) !== null)
170
+ if (matchHeader(lines[i]) !== null)
117
171
  break;
118
172
  out.push(lines[i]);
119
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",