@enrichlayer/el-linear 1.45.1 → 1.46.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.
@@ -468,6 +468,23 @@ The label is plain config — set it to anything you want, or skip it entirely.
468
468
 
469
469
  ---
470
470
 
471
+ ## Label advisor defaults and consent labels (`bot`)
472
+
473
+ A workspace can configure a **label advisor** (`labelAdvisor.command` in personal config, or `EL_LINEAR_LABEL_ADVISOR`): a command `issues create` consults with the proposed issue. Whatever labels it returns are added and reported — a `labels added by advisor: … (<reason>)` warning on stderr and a `labelAdvisor` field in the JSON output. In Enrich Layer's Tools setup the advisor is `el-bot linear-rubric --advise`, the bot-suitability rubric: an issue it marks BOT gets `bot` **by default**, and an EXCLUDE issue is unchanged.
474
+
475
+ - **Opt out for one create** with `--no-label-advisor` (for example, work you intend to do yourself, or an issue whose consent you are not in a position to give).
476
+ - **Read the create output.** `labelAdvisor.added` lists what the advisor added; `labelAdvisor.consent` reports a consent label (`{labels, applied, repo, problem?}`). `applied: false` means the issue exists without that label, and `problem` says why.
477
+
478
+ **Consent labels need a receipt.** Where `validation.consentReceiptGate` is on (Enrich Layer's shared config turns it on), a consent label (`validation.consentLabels`, default `bot`) is only valid with exactly one `<!-- el-intake-decision:v1 {...} -->` receipt in the description that names the issue — bot-layer intake silently skips a `bot` issue without one. So:
479
+
480
+ - **Default route: let the advisor apply it.** When the advisor returns `bot` with its receipt fields, el-linear creates the issue and then, in one update, applies `bot` with a receipt naming the new issue and you (the acting Linear user) as `actor`.
481
+ - **Never pass `--labels bot` on create.** It is refused: the receipt must name an issue that does not exist yet.
482
+ - **Adding `bot` to an existing issue** needs the receipt in the same update, or the update is refused and the error names what is missing. Enrich Layer: run `el-bot linear-consent <ID> --automatic-implementation --reason "<why>"`, which writes the label and a valid receipt together. Elsewhere: `el-linear issues update <ID> --labels bot --description-file <body-ending-with-the-receipt>`.
483
+
484
+ The gate has no override flag and ignores `--skip-validation`: it protects consent, not field hygiene.
485
+
486
+ ---
487
+
471
488
  ## User @Mentions
472
489
 
473
490
  Reference team members by name in comments. el-linear resolves both explicit `@name` tokens and bare capitalized references to proper Linear mentions.
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { loadConfig } from "../config/config.js";
3
+ import { appendConsentReceipt, consentLabelsIn, evaluateConsentReceipt, formatConsentReceipt, formatCreateConsentRefusal, formatUpdateConsentRefusal, getConsentReceiptGateConfig, } from "../config/consent-receipt.js";
3
4
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
5
  import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
5
6
  import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
6
7
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
8
+ import { runLabelAdvisor, } from "../config/label-advisor.js";
7
9
  import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
8
10
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
9
11
  import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
@@ -811,6 +813,199 @@ async function enforceIntakeDecision(description, options) {
811
813
  });
812
814
  throw new Error(`Issue creation blocked: ${message}`);
813
815
  }
816
+ /**
817
+ * DEV-10372: consent-label receipt gate on create. A consent label (default
818
+ * `bot`) needs a receipt naming the issue, which cannot exist before the issue
819
+ * does, so an explicit consent label on create is refused with the two-step
820
+ * route. Dormant unless `validation.consentReceiptGate` is true; not bypassed
821
+ * by `--skip-validation`.
822
+ */
823
+ function enforceCreateConsentLabels(options) {
824
+ const gate = getConsentReceiptGateConfig();
825
+ if (!gate.enabled || !options.labels) {
826
+ return;
827
+ }
828
+ const requested = splitList(options.labels).map((label) => labelNameForId(label) ?? label);
829
+ const consent = consentLabelsIn(requested, gate.labels);
830
+ if (consent.length > 0) {
831
+ throw new Error(`Issue creation blocked: ${formatCreateConsentRefusal(consent)}`);
832
+ }
833
+ }
834
+ /**
835
+ * DEV-10372: consult the optional label advisor and return the labels to add.
836
+ * Fail-closed on labels: an advisor failure warns and adds nothing. Labels the
837
+ * author already passed are not re-added. When the consent-receipt gate is on,
838
+ * an advisor-proposed consent label is dropped with a warning — the advisor
839
+ * cannot supply the receipt, and a create cannot carry one.
840
+ */
841
+ function adviseLabels(title, options, context) {
842
+ if (options.labelAdvisor === false) {
843
+ return null;
844
+ }
845
+ const current = options.labels ? splitList(options.labels) : [];
846
+ const result = runLabelAdvisor({
847
+ team: context.teamInput || null,
848
+ project: typeof options.project === "string" ? options.project : null,
849
+ title: title ?? null,
850
+ description: context.description ?? null,
851
+ labels: current,
852
+ state: context.status ?? null,
853
+ }, loadConfig());
854
+ if (result === null) {
855
+ return null;
856
+ }
857
+ if (!result.ok) {
858
+ outputWarning(`label advisor failed; creating without advisor labels: ${result.error}`);
859
+ return null;
860
+ }
861
+ const present = new Set(current.map((label) => label.toLowerCase()));
862
+ let added = result.labels.filter((label) => !present.has(label.toLowerCase()));
863
+ const gate = getConsentReceiptGateConfig();
864
+ const consent = consentLabelsIn(added, gate.labels);
865
+ let deferred;
866
+ if (consent.length > 0 && (gate.enabled || result.receipt)) {
867
+ const held = new Set(consent.map((label) => label.toLowerCase()));
868
+ added = added.filter((label) => !held.has(label.toLowerCase()));
869
+ if (result.receipt) {
870
+ // DEV-10455: the advisor supplied the receipt policy fields, so the
871
+ // consent label is applied right after create, in one update with a
872
+ // receipt naming the new issue (see applyAdvisorConsent).
873
+ deferred = { labels: consent, receipt: result.receipt };
874
+ }
875
+ else {
876
+ outputWarning(`label advisor proposed ${consent.join(", ")}, not applied: a consent label needs an el-intake-decision:v1 receipt naming the issue. Apply it after creation with \`el-linear issues update <ID> --labels ${consent.join(",")}\` and the receipt in the description.`);
877
+ }
878
+ }
879
+ if (added.length === 0 && !deferred) {
880
+ return null;
881
+ }
882
+ if (added.length > 0) {
883
+ outputWarning(`labels added by advisor: ${added.join(", ")}${result.reason ? ` (${result.reason})` : ""}`);
884
+ }
885
+ return {
886
+ added,
887
+ reason: result.reason,
888
+ ...(deferred ? { consent: deferred } : {}),
889
+ };
890
+ }
891
+ /**
892
+ * DEV-10455: apply advisor-proposed consent labels after create, in ONE
893
+ * update that also appends an `el-intake-decision:v1` receipt naming the new
894
+ * issue. The advisor supplied the policy fields (`repo`, `reason`); el-linear
895
+ * adds only the identifier and the acting Linear user, read from the API.
896
+ *
897
+ * Fail-closed: any problem (an app viewer, a description that already holds a
898
+ * receipt marker, a failed update, a stored receipt that does not read back
899
+ * as valid) warns and leaves the issue without the consent label — or, when
900
+ * the write landed but the read-back fails, says so loudly. Never throws: the
901
+ * issue already exists.
902
+ *
903
+ * Raw GraphQL for the read: it batches `viewer` (including the `app` flag the
904
+ * SDK's typed viewer wrapper does not expose) with the issue in one round
905
+ * trip.
906
+ */
907
+ async function applyAdvisorConsent(created, consent, services) {
908
+ const labels = consent.labels.join(", ");
909
+ const skip = (problem) => {
910
+ outputWarning(`label advisor proposed ${labels}, not applied: ${problem}. Apply it with \`el-linear issues update ${created.identifier} --labels ${consent.labels.join(",")}\` and a receipt in the description.`);
911
+ return { applied: false, problem };
912
+ };
913
+ try {
914
+ const current = await services.graphQLService.rawRequest("query($id: String!) { viewer { name email app } issue(id: $id) { identifier description } }", { id: created.id });
915
+ if (!current.issue) {
916
+ return skip(`issue ${created.identifier} could not be read back`);
917
+ }
918
+ const viewer = current.viewer;
919
+ if (!viewer || viewer.app === true) {
920
+ return skip("consent must be attributable to a person, and the authenticated Linear viewer is not one");
921
+ }
922
+ const actor = (viewer.name?.trim() || viewer.email?.trim() || "").slice(0, 160);
923
+ if (!actor) {
924
+ return skip("the authenticated Linear viewer has no name or email");
925
+ }
926
+ const existing = current.issue.description ?? "";
927
+ const description = appendConsentReceipt(existing, formatConsentReceipt({
928
+ repo: consent.receipt.repo,
929
+ reason: consent.receipt.reason,
930
+ actor,
931
+ issue: current.issue.identifier,
932
+ }));
933
+ const planned = evaluateConsentReceipt(description, current.issue.identifier);
934
+ if (!planned.ok) {
935
+ return skip(planned.problem);
936
+ }
937
+ const updated = await services.issuesService.updateIssue({ id: created.id, description, labelIds: consent.labels }, "adding");
938
+ const stored = evaluateConsentReceipt(updated.description ?? "", current.issue.identifier);
939
+ if (!stored.ok) {
940
+ outputWarning(`label advisor applied ${labels} to ${created.identifier}, but the stored receipt does not read back as valid (${stored.problem}); fix the receipt or remove the label.`);
941
+ return { applied: true, problem: stored.problem, issue: updated };
942
+ }
943
+ outputWarning(`labels added by advisor: ${labels} with an el-intake-decision:v1 receipt for ${current.issue.identifier} (repo ${consent.receipt.repo}, actor ${actor})`);
944
+ return { applied: true, issue: updated };
945
+ }
946
+ catch (error) {
947
+ return skip(error instanceof Error ? error.message : String(error));
948
+ }
949
+ }
950
+ /**
951
+ * DEV-10372: consent-label receipt gate on update. When the update newly
952
+ * applies a consent label, the resulting description must carry exactly one
953
+ * valid automatic-implementation receipt for this issue; otherwise refuse and
954
+ * name what is missing. Labels already on the issue are not re-checked.
955
+ */
956
+ async function enforceUpdateConsentLabels(issueId, options, graphQLService, linearService) {
957
+ const gate = getConsentReceiptGateConfig();
958
+ if (!gate.enabled || !options.labels) {
959
+ return;
960
+ }
961
+ const requested = splitList(options.labels).map((label) => labelNameForId(label) ?? label);
962
+ const consent = consentLabelsIn(requested, gate.labels);
963
+ if (consent.length === 0) {
964
+ return;
965
+ }
966
+ const resolved = await linearService.resolveIssueId(issueId);
967
+ const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { identifier description labels { nodes { name } } } }", { id: resolved });
968
+ if (!current.issue) {
969
+ throw new Error(`Issue "${issueId}" not found`);
970
+ }
971
+ const existing = new Set(current.issue.labels.nodes.map((label) => label.name.toLowerCase()));
972
+ const newlyApplied = consent.filter((label) => !existing.has(label.toLowerCase()));
973
+ if (newlyApplied.length === 0) {
974
+ return;
975
+ }
976
+ const description = typeof options.description === "string"
977
+ ? options.description
978
+ : (current.issue.description ?? "");
979
+ const evaluation = evaluateConsentReceipt(description, current.issue.identifier);
980
+ if (!evaluation.ok) {
981
+ throw new Error(`Issue update blocked: ${formatUpdateConsentRefusal(newlyApplied, current.issue.identifier, evaluation.problem)}`);
982
+ }
983
+ }
984
+ /**
985
+ * The configured team key for a resolved team UUID, so the label advisor sees
986
+ * the canonical key (`DEV`) rather than whatever alias the author typed.
987
+ */
988
+ function teamKeyForId(teamId) {
989
+ if (!teamId)
990
+ return undefined;
991
+ for (const [key, id] of Object.entries(loadConfig().teams ?? {})) {
992
+ if (id === teamId)
993
+ return key;
994
+ }
995
+ return undefined;
996
+ }
997
+ /** Reverse-map a configured label UUID to its name, so a UUID cannot bypass the gate. */
998
+ function labelNameForId(value) {
999
+ const labels = loadConfig().labels;
1000
+ const maps = [labels?.workspace ?? {}, ...Object.values(labels?.teams ?? {})];
1001
+ for (const map of maps) {
1002
+ for (const [name, id] of Object.entries(map)) {
1003
+ if (id === value)
1004
+ return name;
1005
+ }
1006
+ }
1007
+ return undefined;
1008
+ }
814
1009
  async function handleCreateIssue(title, options, command) {
815
1010
  const rootOpts = getRootOpts(command);
816
1011
  // DEV-7277: non-blocking nudge when this write is landing via the
@@ -833,7 +1028,25 @@ async function handleCreateIssue(title, options, command) {
833
1028
  // intentionally contains backslash sequences.
834
1029
  const rawDescription = resolveDescription(options);
835
1030
  await enforceIntakeDecision(rawDescription ?? "", options);
1031
+ enforceCreateConsentLabels(options);
836
1032
  const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
1033
+ // DEV-10372: optional label advisor. Runs after validation/normalization so
1034
+ // it sees the canonical labels, and before every gate that reads labels.
1035
+ const labelAdvice = adviseLabels(title, options, {
1036
+ teamInput: teamKeyForId(teamId) ?? teamInput,
1037
+ description: rawDescription,
1038
+ status,
1039
+ });
1040
+ if (labelAdvice) {
1041
+ for (const id of resolveLabels(labelAdvice.added)) {
1042
+ if (!labelIds.includes(id))
1043
+ labelIds.push(id);
1044
+ }
1045
+ options.labels = [
1046
+ ...(options.labels ? splitList(options.labels) : []),
1047
+ ...labelAdvice.added,
1048
+ ].join(",");
1049
+ }
837
1050
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
838
1051
  const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
839
1052
  // Append messageFooter (config or --footer flag) so auto-link picks up any
@@ -912,6 +1125,33 @@ async function handleCreateIssue(title, options, command) {
912
1125
  ? { templateId: options.fromTemplate }
913
1126
  : {}),
914
1127
  }), { team: options.team ?? loadConfig().defaultTeam, title }, { graphQLService, linearService });
1128
+ // DEV-10455: advisor-proposed consent label + receipt, applied now that
1129
+ // the issue (and its identifier) exists.
1130
+ let issue = result;
1131
+ let labelAdvisorOutput;
1132
+ if (labelAdvice) {
1133
+ labelAdvisorOutput = {
1134
+ added: [...labelAdvice.added],
1135
+ reason: labelAdvice.reason,
1136
+ };
1137
+ if (labelAdvice.consent) {
1138
+ const outcome = await applyAdvisorConsent(result, labelAdvice.consent, {
1139
+ graphQLService,
1140
+ issuesService,
1141
+ });
1142
+ if (outcome.issue)
1143
+ issue = { ...result, ...outcome.issue };
1144
+ if (outcome.applied) {
1145
+ labelAdvisorOutput.added.push(...labelAdvice.consent.labels);
1146
+ }
1147
+ labelAdvisorOutput.consent = {
1148
+ labels: labelAdvice.consent.labels,
1149
+ applied: outcome.applied,
1150
+ repo: labelAdvice.consent.receipt.repo,
1151
+ ...(outcome.problem ? { problem: outcome.problem } : {}),
1152
+ };
1153
+ }
1154
+ }
915
1155
  const relations = await createRelations(result.id, options, graphQLService, linearService);
916
1156
  // Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
917
1157
  // ("blocked by", "duplicates", etc.) sees `keyword DEV-100` instead of `keyword [DEV-100](url)`.
@@ -966,7 +1206,8 @@ async function handleCreateIssue(title, options, command) {
966
1206
  }
967
1207
  }
968
1208
  const output = {
969
- ...result,
1209
+ ...issue,
1210
+ ...(labelAdvisorOutput ? { labelAdvisor: labelAdvisorOutput } : {}),
970
1211
  ...(branch ? { branch } : {}),
971
1212
  ...(claim ? { claim } : {}),
972
1213
  ...(relations.length > 0 ? { relations } : {}),
@@ -1153,6 +1394,7 @@ async function handleUpdateIssue(issueId, options, command) {
1153
1394
  strict: options.strict,
1154
1395
  });
1155
1396
  }
1397
+ await enforceUpdateConsentLabels(issueId, options, graphQLService, linearService);
1156
1398
  // Save the original (pre-wrap) description so we can pass it to maybeAutoLink later.
1157
1399
  // The wrapped form breaks prose-keyword inference because the inserted `[` defeats the
1158
1400
  // trailing-whitespace anchor in patterns like /\bblocked by\s*$/.
@@ -1483,6 +1725,7 @@ export function setupIssuesCommands(program) {
1483
1725
  .option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
1484
1726
  .option("--allow-missing-intake-decision", "create without a complete intake decision when an accountable human approved the exception (recorded as a gate override)")
1485
1727
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1728
+ .option("--no-label-advisor", "skip the configured label advisor (config.labelAdvisor / EL_LINEAR_LABEL_ADVISOR) for this issue")
1486
1729
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1487
1730
  .option("--no-footer", "skip the configured messageFooter for this issue")
1488
1731
  .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
@@ -61,6 +61,23 @@ export interface ElLinearConfig {
61
61
  /** Milliseconds before the resolver is treated as a miss (default 8000). */
62
62
  resolverTimeoutMs?: number;
63
63
  };
64
+ /**
65
+ * Optional label-advisor hook (DEV-10372). `command` is an argv array that
66
+ * `issues create` runs with the proposed issue as JSON on stdin
67
+ * (`{team, project, title, description, labels, state}`); any labels it
68
+ * prints are added to the issue and reported. `--no-label-advisor` skips it
69
+ * for one create. Fail-closed on labels: any advisor failure warns and
70
+ * creates the issue without extra labels. See `label-advisor.ts`.
71
+ *
72
+ * Like `identity.resolver`, this names a binary el-linear spawns, so it is
73
+ * honored only from personal config or `EL_LINEAR_LABEL_ADVISOR` — never
74
+ * from the shared team layer.
75
+ */
76
+ labelAdvisor?: {
77
+ command?: string[];
78
+ /** Milliseconds before the advisor is treated as failed (default 5000). */
79
+ timeoutMs?: number;
80
+ };
64
81
  /**
65
82
  * Term-enforcement rules. Each rule has a canonical form and a list of
66
83
  * rejected forms; rejected forms in issue titles/descriptions are flagged
@@ -152,6 +169,19 @@ export interface ElLinearConfig {
152
169
  intakeDecisionGate?: false | "warn" | "block";
153
170
  /** Headers accepted for the intake section. Defaults to `["Intake decision"]`. */
154
171
  intakeSectionHeaders?: string[];
172
+ /**
173
+ * OPT-IN consent-label receipt gate (DEV-10372). When `true`, applying a
174
+ * consent label (see `consentLabels`) is refused unless the issue
175
+ * description carries exactly one valid `el-intake-decision:v1`
176
+ * automatic-implementation receipt for that issue. `issues create`
177
+ * always refuses an explicit consent label (the receipt must name an
178
+ * issue that does not exist yet) and drops an advisor-proposed one with
179
+ * a warning. Independent of `enabled` and `--skip-validation`. See
180
+ * `consent-receipt.ts`.
181
+ */
182
+ consentReceiptGate?: boolean;
183
+ /** Labels treated as consent for the receipt gate. Defaults to `["bot"]`. */
184
+ consentLabels?: string[];
155
185
  };
156
186
  /**
157
187
  * Optional override for the Linear workspace URL key (the part after
@@ -154,6 +154,9 @@ export function loadConfig() {
154
154
  // into their personal config at setup time; that keeps the decision with the
155
155
  // machine's owner instead of with whoever can land a commit upstream.
156
156
  delete teamRaw.identity;
157
+ // Same reasoning for the label advisor (DEV-10372): it is a binary el-linear
158
+ // spawns on every `issues create`, so only the operator's own files choose it.
159
+ delete teamRaw.labelAdvisor;
157
160
  // Merge order: defaults → team config → personal config.
158
161
  // Arrays (terms, defaultLabels, etc.) are concatenated so personal entries
159
162
  // extend team entries rather than replace them.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Consent-label receipt gate (DEV-10372).
3
+ *
4
+ * Some workspaces treat a label as **consent** for unattended work: an
5
+ * automation picks up any issue carrying it. Consent there is not the label
6
+ * alone — the automation also requires exactly one machine-readable intake
7
+ * receipt in the issue description:
8
+ *
9
+ * <!-- el-intake-decision:v1 {"policyVersion":"intake-policy/v1", …} -->
10
+ *
11
+ * An issue that carries the label without a valid receipt is silently skipped
12
+ * by that automation, which is the failure this gate removes: el-linear refuses
13
+ * to apply a consent label unless the resulting description carries a receipt
14
+ * for that issue, and names what is missing.
15
+ *
16
+ * Why el-linear refuses rather than writes the receipt: the receipt names the
17
+ * target repository, and whether a (team, project) maps to an admissible
18
+ * repository — and whether that repository is review-only — is policy owned
19
+ * by the automation, not by a generic Linear CLI. Writing a receipt with a
20
+ * guessed repository would manufacture consent the policy never granted. The
21
+ * receipt must also name the issue identifier, which does not exist before
22
+ * `issues create`, so a create can never carry a valid receipt: the consent
23
+ * label is applied with `issues update` once the receipt is written.
24
+ *
25
+ * The receipt shape mirrors the canonical `intake-policy/v1` wire format
26
+ * (`validateIntakeDecisionReceipt` in the automation's shared contracts). This
27
+ * is a strict structural check; the automation's own evaluator remains the
28
+ * admission authority and additionally checks the repository mapping.
29
+ *
30
+ * OPT-IN: dormant unless `validation.consentReceiptGate` is `true`. It is
31
+ * deliberately independent of `validation.enabled` and `--skip-validation`:
32
+ * it guards consent integrity, not field hygiene.
33
+ */
34
+ export declare const DEFAULT_CONSENT_LABELS: string[];
35
+ export declare const INTAKE_POLICY_VERSION = "intake-policy/v1";
36
+ export interface ConsentReceiptGateConfig {
37
+ enabled: boolean;
38
+ labels: string[];
39
+ }
40
+ export declare function getConsentReceiptGateConfig(): ConsentReceiptGateConfig;
41
+ /** Consent labels present in `names`, matched case-insensitively. */
42
+ export declare function consentLabelsIn(names: readonly string[], consentLabels: readonly string[]): string[];
43
+ export type ConsentReceiptEvaluation = {
44
+ ok: true;
45
+ } | {
46
+ ok: false;
47
+ problem: string;
48
+ };
49
+ /**
50
+ * Does `description` carry exactly one valid automatic-implementation receipt
51
+ * for `identifier`? An `issue-triage` receipt must name this issue; a receipt
52
+ * copied from another issue is not consent for this one.
53
+ */
54
+ export declare function evaluateConsentReceipt(description: string, identifier: string): ConsentReceiptEvaluation;
55
+ /** Refusal for `issues create` carrying a consent label. */
56
+ export declare function formatCreateConsentRefusal(labels: readonly string[]): string;
57
+ /** Refusal for `issues update` applying a consent label without a receipt. */
58
+ export declare function formatUpdateConsentRefusal(labels: readonly string[], identifier: string, problem: string): string;
59
+ export interface ConsentReceiptFields {
60
+ /** Repository the organization's intake policy maps the issue to. */
61
+ repo: string;
62
+ /** Why that policy consents to unattended work. */
63
+ reason: string;
64
+ /** The acting Linear user, as named by the API. */
65
+ actor: string;
66
+ /** The issue identifier the receipt is for. */
67
+ issue: string;
68
+ }
69
+ /**
70
+ * The canonical `issue-triage` receipt marker for `fields` (DEV-10455). The
71
+ * caller supplies every policy field; this only serializes them in the
72
+ * `intake-policy/v1` shape that `evaluateConsentReceipt` accepts.
73
+ */
74
+ export declare function formatConsentReceipt(fields: ConsentReceiptFields): string;
75
+ /** `description` with `receipt` appended as its final paragraph. */
76
+ export declare function appendConsentReceipt(description: string, receipt: string): string;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Consent-label receipt gate (DEV-10372).
3
+ *
4
+ * Some workspaces treat a label as **consent** for unattended work: an
5
+ * automation picks up any issue carrying it. Consent there is not the label
6
+ * alone — the automation also requires exactly one machine-readable intake
7
+ * receipt in the issue description:
8
+ *
9
+ * <!-- el-intake-decision:v1 {"policyVersion":"intake-policy/v1", …} -->
10
+ *
11
+ * An issue that carries the label without a valid receipt is silently skipped
12
+ * by that automation, which is the failure this gate removes: el-linear refuses
13
+ * to apply a consent label unless the resulting description carries a receipt
14
+ * for that issue, and names what is missing.
15
+ *
16
+ * Why el-linear refuses rather than writes the receipt: the receipt names the
17
+ * target repository, and whether a (team, project) maps to an admissible
18
+ * repository — and whether that repository is review-only — is policy owned
19
+ * by the automation, not by a generic Linear CLI. Writing a receipt with a
20
+ * guessed repository would manufacture consent the policy never granted. The
21
+ * receipt must also name the issue identifier, which does not exist before
22
+ * `issues create`, so a create can never carry a valid receipt: the consent
23
+ * label is applied with `issues update` once the receipt is written.
24
+ *
25
+ * The receipt shape mirrors the canonical `intake-policy/v1` wire format
26
+ * (`validateIntakeDecisionReceipt` in the automation's shared contracts). This
27
+ * is a strict structural check; the automation's own evaluator remains the
28
+ * admission authority and additionally checks the repository mapping.
29
+ *
30
+ * OPT-IN: dormant unless `validation.consentReceiptGate` is `true`. It is
31
+ * deliberately independent of `validation.enabled` and `--skip-validation`:
32
+ * it guards consent integrity, not field hygiene.
33
+ */
34
+ import { loadConfig } from "./config.js";
35
+ export const DEFAULT_CONSENT_LABELS = ["bot"];
36
+ export const INTAKE_POLICY_VERSION = "intake-policy/v1";
37
+ const RECEIPT_RE = /<!--\s*el-intake-decision:v1\s+([^\r\n]+?)\s*-->/g;
38
+ const LINEAR_ISSUE_RE = /^[A-Z][A-Z0-9]*-\d+$/;
39
+ const SENTRY_SOURCE_ISSUE_RE = /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$/;
40
+ /** True when `text` has no ASCII control character (U+0000–U+001F, U+007F). */
41
+ function isSafeText(text) {
42
+ for (let i = 0; i < text.length; i++) {
43
+ const code = text.charCodeAt(i);
44
+ if (code < 0x20 || code === 0x7f)
45
+ return false;
46
+ }
47
+ return text.length > 0;
48
+ }
49
+ /**
50
+ * Linear stores descriptions as normalized markdown and backslash-escapes
51
+ * characters such as `[` and `]`, so a receipt read back from an issue can
52
+ * contain `\[`. No JSON escape starts with those characters, so dropping a
53
+ * backslash before a non-JSON-escape character recovers the written receipt
54
+ * and leaves `\"` and `\\` intact (mirrors the canonical parser).
55
+ */
56
+ const JSON_ESCAPE_CHARACTERS = new Set([
57
+ '"',
58
+ "\\",
59
+ "/",
60
+ "b",
61
+ "f",
62
+ "n",
63
+ "r",
64
+ "t",
65
+ "u",
66
+ ]);
67
+ export function getConsentReceiptGateConfig() {
68
+ const validation = loadConfig().validation;
69
+ const labels = validation?.consentLabels && validation.consentLabels.length > 0
70
+ ? validation.consentLabels
71
+ : DEFAULT_CONSENT_LABELS;
72
+ return { enabled: validation?.consentReceiptGate === true, labels };
73
+ }
74
+ /** Consent labels present in `names`, matched case-insensitively. */
75
+ export function consentLabelsIn(names, consentLabels) {
76
+ const wanted = new Set(consentLabels.map((label) => label.toLowerCase()));
77
+ return names.filter((name) => wanted.has(name.trim().toLowerCase()));
78
+ }
79
+ function isRecord(value) {
80
+ return value !== null && typeof value === "object" && !Array.isArray(value);
81
+ }
82
+ function parseReceiptJson(raw) {
83
+ return JSON.parse(raw.replace(/\\(.)/g, (sequence, character) => JSON_ESCAPE_CHARACTERS.has(character) ? sequence : character));
84
+ }
85
+ /** First structural problem with a parsed receipt, or `null` when valid. */
86
+ function receiptError(value) {
87
+ if (!isRecord(value))
88
+ return "receipt is not a JSON object";
89
+ if (value.policyVersion !== INTAKE_POLICY_VERSION) {
90
+ return "unsupported policy version";
91
+ }
92
+ if (value.decision !== "automatic-implementation") {
93
+ return `receipt decision is ${JSON.stringify(value.decision)}, not "automatic-implementation"`;
94
+ }
95
+ const capabilities = value.capabilities;
96
+ if (!Array.isArray(capabilities) || capabilities.length === 0) {
97
+ return "capabilities must be a non-empty array";
98
+ }
99
+ const allowed = new Set(["manual-review", "automatic-implementation"]);
100
+ if (capabilities.some((capability) => typeof capability !== "string" || !allowed.has(capability)) ||
101
+ new Set(capabilities).size !== capabilities.length) {
102
+ return "capabilities must be unique known values";
103
+ }
104
+ if (!capabilities.includes("manual-review") ||
105
+ !capabilities.includes("automatic-implementation")) {
106
+ return "capabilities must include manual-review and automatic-implementation";
107
+ }
108
+ if (typeof value.reason !== "string" ||
109
+ value.reason.trim().length === 0 ||
110
+ value.reason.length > 500) {
111
+ return "reason must be a non-empty string of at most 500 characters";
112
+ }
113
+ const provenance = value.provenance;
114
+ if (!isRecord(provenance))
115
+ return "provenance must be an object";
116
+ if (provenance.source !== "issue-triage" &&
117
+ provenance.source !== "sentry-registry") {
118
+ return "provenance.source is not recognized";
119
+ }
120
+ if (typeof provenance.actor !== "string" ||
121
+ provenance.actor.trim().length === 0 ||
122
+ provenance.actor.length > 160) {
123
+ return "provenance.actor must be a non-empty string of at most 160 characters";
124
+ }
125
+ if (typeof provenance.issue !== "string" ||
126
+ provenance.issue.length > 200 ||
127
+ !isSafeText(provenance.issue) ||
128
+ (provenance.source === "issue-triage" &&
129
+ !LINEAR_ISSUE_RE.test(provenance.issue)) ||
130
+ (provenance.source === "sentry-registry" &&
131
+ !SENTRY_SOURCE_ISSUE_RE.test(provenance.issue))) {
132
+ return "provenance.issue has an invalid shape";
133
+ }
134
+ if (typeof provenance.repo !== "string" ||
135
+ provenance.repo.trim().length === 0 ||
136
+ provenance.repo.length > 200 ||
137
+ !isSafeText(provenance.repo)) {
138
+ return "provenance.repo must be a non-empty safe string of at most 200 characters";
139
+ }
140
+ return null;
141
+ }
142
+ /**
143
+ * Does `description` carry exactly one valid automatic-implementation receipt
144
+ * for `identifier`? An `issue-triage` receipt must name this issue; a receipt
145
+ * copied from another issue is not consent for this one.
146
+ */
147
+ export function evaluateConsentReceipt(description, identifier) {
148
+ const matches = [...description.matchAll(RECEIPT_RE)];
149
+ if (matches.length === 0) {
150
+ return {
151
+ ok: false,
152
+ problem: "the description has no el-intake-decision:v1 receipt",
153
+ };
154
+ }
155
+ if (matches.length > 1) {
156
+ return {
157
+ ok: false,
158
+ problem: "the description has more than one el-intake-decision:v1 receipt",
159
+ };
160
+ }
161
+ let parsed;
162
+ try {
163
+ parsed = parseReceiptJson(matches[0]?.[1] ?? "");
164
+ }
165
+ catch {
166
+ return { ok: false, problem: "the receipt is not valid JSON" };
167
+ }
168
+ const error = receiptError(parsed);
169
+ if (error) {
170
+ return { ok: false, problem: `the receipt is invalid: ${error}` };
171
+ }
172
+ const provenance = parsed
173
+ .provenance;
174
+ if (provenance.source === "issue-triage" &&
175
+ provenance.issue.toUpperCase() !== identifier.toUpperCase()) {
176
+ return {
177
+ ok: false,
178
+ problem: `the receipt names ${provenance.issue}, not ${identifier}`,
179
+ };
180
+ }
181
+ return { ok: true };
182
+ }
183
+ function receiptTemplate(identifier) {
184
+ return `<!-- el-intake-decision:v1 {"policyVersion":"${INTAKE_POLICY_VERSION}","decision":"automatic-implementation","capabilities":["manual-review","automatic-implementation"],"reason":"<why unattended work is consented>","provenance":{"source":"issue-triage","actor":"<your name>","issue":"${identifier}","repo":"<namespace/repo>"}} -->`;
185
+ }
186
+ /** Refusal for `issues create` carrying a consent label. */
187
+ export function formatCreateConsentRefusal(labels) {
188
+ const names = labels.map((label) => `"${label}"`).join(", ");
189
+ return `Label ${names} is consent for unattended work and requires an el-intake-decision:v1 receipt naming the issue, which cannot exist before the issue does. Create the issue without ${names}, then apply it with the receipt in one update:\n\n el-linear issues update <ID> --labels ${labels.join(",")} --description-file <body-with-receipt>\n\nwhere the body ends with:\n\n${receiptTemplate("<ID>")}`;
190
+ }
191
+ /** Refusal for `issues update` applying a consent label without a receipt. */
192
+ export function formatUpdateConsentRefusal(labels, identifier, problem) {
193
+ const names = labels.map((label) => `"${label}"`).join(", ");
194
+ return `Label ${names} is consent for unattended work and requires an el-intake-decision:v1 receipt for ${identifier}, but ${problem}. Include the receipt in the same update (--description / --description-file / --append-description), e.g.:\n\n${receiptTemplate(identifier)}`;
195
+ }
196
+ /**
197
+ * The canonical `issue-triage` receipt marker for `fields` (DEV-10455). The
198
+ * caller supplies every policy field; this only serializes them in the
199
+ * `intake-policy/v1` shape that `evaluateConsentReceipt` accepts.
200
+ */
201
+ export function formatConsentReceipt(fields) {
202
+ return `<!-- el-intake-decision:v1 ${JSON.stringify({
203
+ policyVersion: INTAKE_POLICY_VERSION,
204
+ decision: "automatic-implementation",
205
+ capabilities: ["manual-review", "automatic-implementation"],
206
+ reason: fields.reason,
207
+ provenance: {
208
+ source: "issue-triage",
209
+ actor: fields.actor,
210
+ issue: fields.issue,
211
+ repo: fields.repo,
212
+ },
213
+ })} -->`;
214
+ }
215
+ /** `description` with `receipt` appended as its final paragraph. */
216
+ export function appendConsentReceipt(description, receipt) {
217
+ const body = description.trimEnd();
218
+ return body ? `${body}\n\n${receipt}` : receipt;
219
+ }
@@ -0,0 +1,53 @@
1
+ import type { ElLinearConfig } from "./config.js";
2
+ export interface LabelAdvisorInput {
3
+ team: string | null;
4
+ project: string | null;
5
+ title: string | null;
6
+ description: string | null;
7
+ labels: string[];
8
+ state: string | null;
9
+ }
10
+ /**
11
+ * Receipt policy fields an advisor may return with a consent label
12
+ * (DEV-10455). el-linear never guesses these: `repo` is the repository the
13
+ * organization's intake policy maps this issue to, and `reason` is why that
14
+ * policy consents to unattended work. el-linear adds only what it knows
15
+ * itself (the new issue's identifier and the acting Linear user) and writes
16
+ * the receipt after the issue exists. See `consent-receipt.ts`.
17
+ */
18
+ export interface LabelAdvisorReceipt {
19
+ repo: string;
20
+ reason: string;
21
+ }
22
+ export type LabelAdvisorResult = {
23
+ ok: true;
24
+ labels: string[];
25
+ reason: string | null;
26
+ receipt: LabelAdvisorReceipt | null;
27
+ } | {
28
+ ok: false;
29
+ error: string;
30
+ };
31
+ /**
32
+ * The configured advisor argv, or `null` when the hook is off. Env wins over
33
+ * config so one invocation can point elsewhere or disable it without editing
34
+ * files.
35
+ */
36
+ export declare function labelAdvisorCommand(config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): string[] | null;
37
+ /**
38
+ * Parse what the advisor printed. Returns an error string for anything that is
39
+ * not exactly a well-formed answer — a malformed answer is a failure, and a
40
+ * failure adds no labels. Partial acceptance (keep the valid elements, drop the
41
+ * rest) is deliberately not offered: an advisor emitting garbage is broken, and
42
+ * trusting half of its output is how a wrong label slips in.
43
+ */
44
+ export declare function parseLabelAdvisorOutput(stdout: string): LabelAdvisorResult;
45
+ /**
46
+ * Run the configured advisor. Returns `null` when no advisor is configured,
47
+ * otherwise the parsed advice or a failure. Never throws.
48
+ *
49
+ * Synchronous for the same reason as the identity resolver: it sits on the
50
+ * critical path of a short-lived CLI, and a single failure site is easier to
51
+ * reason about than a promise race.
52
+ */
53
+ export declare function runLabelAdvisor(input: LabelAdvisorInput, config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): LabelAdvisorResult | null;
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Optional **label advisor hook** (DEV-10372).
4
+ *
5
+ * An organization may have a rubric that knows which labels a new issue should
6
+ * carry — for example "this Tools issue is small and well specified, so it
7
+ * belongs in the unattended-automation lane". el-linear can consult it on
8
+ * `issues create`, but it must never learn *what* the rubric is or how to run
9
+ * it. So the hook is a **command**, modelled on the identity resolver
10
+ * (DEV-5628, `identity-resolver.ts`):
11
+ *
12
+ * "labelAdvisor": { "command": ["my-rubric", "--advise"] }
13
+ *
14
+ * Contract:
15
+ *
16
+ * - stdin: one JSON object describing the proposed issue —
17
+ * `{team, project, title, description, labels, state}`.
18
+ * - stdout: JSON. Either a bare array of label names (`["bot"]`) or an
19
+ * object `{"labels": ["bot"], "reason": "…"}`; one level of `{data: …}`
20
+ * envelope is unwrapped (the el-* CLI shape). `{"labels": []}` means
21
+ * "nothing to add". With a consent label (see `consent-receipt.ts`) the
22
+ * object may also carry `"receipt": {"repo": "…", "reason": "…"}`; then
23
+ * el-linear applies the consent label after create together with an
24
+ * `el-intake-decision:v1` receipt naming the new issue.
25
+ * - exit 0 on success.
26
+ *
27
+ * **Fail-closed on labels.** A label may carry meaning — in some workspaces a
28
+ * label is consent for unattended work — so a broken advisor must never add
29
+ * one. Non-zero exit, timeout, missing binary, unparseable or malformed output
30
+ * all return a failure; the caller warns and creates the issue with exactly
31
+ * the labels the author asked for. The hook never throws.
32
+ *
33
+ * The issue text is untrusted input (agents create issues from arbitrary
34
+ * prose), so it travels on stdin, never in argv, and the command runs with
35
+ * `shell: false`.
36
+ */
37
+ /** Env override — a whitespace-separated command; `""` is an explicit OFF. */
38
+ const ADVISOR_ENV = "EL_LINEAR_LABEL_ADVISOR";
39
+ /** An advisor that hasn't answered in this long is not going to. */
40
+ const DEFAULT_TIMEOUT_MS = 5000;
41
+ /** Advice for more labels than this is a confused advisor, not a rubric. */
42
+ const MAX_LABELS = 10;
43
+ const MAX_LABEL_LENGTH = 80;
44
+ const MAX_REASON_LENGTH = 300;
45
+ const MAX_RECEIPT_REPO_LENGTH = 200;
46
+ const MAX_RECEIPT_REASON_LENGTH = 500;
47
+ /** True when `text` has no ASCII control character (U+0000–U+001F, U+007F). */
48
+ function isSafeText(text) {
49
+ for (let i = 0; i < text.length; i++) {
50
+ const code = text.charCodeAt(i);
51
+ if (code < 0x20 || code === 0x7f)
52
+ return false;
53
+ }
54
+ return text.length > 0;
55
+ }
56
+ /**
57
+ * The configured advisor argv, or `null` when the hook is off. Env wins over
58
+ * config so one invocation can point elsewhere or disable it without editing
59
+ * files.
60
+ */
61
+ export function labelAdvisorCommand(config, env = process.env) {
62
+ const fromEnv = env[ADVISOR_ENV];
63
+ if (fromEnv !== undefined) {
64
+ const argv = fromEnv.trim().split(/\s+/).filter(Boolean);
65
+ return argv.length > 0 ? argv : null;
66
+ }
67
+ const configured = config.labelAdvisor?.command;
68
+ if (!Array.isArray(configured) || configured.length === 0) {
69
+ return null;
70
+ }
71
+ return configured;
72
+ }
73
+ /**
74
+ * Resolve the effective timeout. Node treats `timeout <= 0` as *no timeout*,
75
+ * which would turn a hung advisor into a hung CLI — so non-positive values
76
+ * fall back to the default, exactly like the identity resolver.
77
+ */
78
+ function resolveTimeoutMs(config) {
79
+ const configured = config.labelAdvisor?.timeoutMs;
80
+ return typeof configured === "number" && configured > 0
81
+ ? configured
82
+ : DEFAULT_TIMEOUT_MS;
83
+ }
84
+ /** The advisor classifies issue text; it never needs Linear's token. */
85
+ function advisorEnv(env) {
86
+ const { LINEAR_API_TOKEN: _dropped, ...rest } = env;
87
+ return rest;
88
+ }
89
+ function isRecord(value) {
90
+ return value !== null && typeof value === "object" && !Array.isArray(value);
91
+ }
92
+ /**
93
+ * Parse what the advisor printed. Returns an error string for anything that is
94
+ * not exactly a well-formed answer — a malformed answer is a failure, and a
95
+ * failure adds no labels. Partial acceptance (keep the valid elements, drop the
96
+ * rest) is deliberately not offered: an advisor emitting garbage is broken, and
97
+ * trusting half of its output is how a wrong label slips in.
98
+ */
99
+ export function parseLabelAdvisorOutput(stdout) {
100
+ const trimmed = stdout.trim();
101
+ if (!trimmed) {
102
+ return { ok: false, error: "advisor printed nothing (expected JSON)" };
103
+ }
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(trimmed);
107
+ }
108
+ catch {
109
+ return { ok: false, error: "advisor output is not valid JSON" };
110
+ }
111
+ if (isRecord(parsed) && isRecord(parsed.data)) {
112
+ parsed = parsed.data;
113
+ }
114
+ let rawLabels;
115
+ let rawReason = null;
116
+ let rawReceipt = null;
117
+ if (Array.isArray(parsed)) {
118
+ rawLabels = parsed;
119
+ }
120
+ else if (isRecord(parsed)) {
121
+ rawLabels = parsed.labels;
122
+ rawReason = parsed.reason ?? null;
123
+ rawReceipt = parsed.receipt ?? null;
124
+ }
125
+ else {
126
+ return {
127
+ ok: false,
128
+ error: "advisor output must be a label array or an object",
129
+ };
130
+ }
131
+ if (!Array.isArray(rawLabels)) {
132
+ return { ok: false, error: 'advisor output has no "labels" array' };
133
+ }
134
+ if (rawLabels.length > MAX_LABELS) {
135
+ return {
136
+ ok: false,
137
+ error: `advisor returned ${rawLabels.length} labels (limit ${MAX_LABELS})`,
138
+ };
139
+ }
140
+ const labels = [];
141
+ for (const label of rawLabels) {
142
+ if (typeof label !== "string" ||
143
+ label.trim().length === 0 ||
144
+ label.length > MAX_LABEL_LENGTH ||
145
+ !isSafeText(label) ||
146
+ label.includes(",")) {
147
+ return {
148
+ ok: false,
149
+ error: "advisor returned a label that is not a plain label name",
150
+ };
151
+ }
152
+ const name = label.trim();
153
+ if (!labels.some((seen) => seen.toLowerCase() === name.toLowerCase())) {
154
+ labels.push(name);
155
+ }
156
+ }
157
+ if (rawReason !== null && typeof rawReason !== "string") {
158
+ return { ok: false, error: "advisor reason must be a string" };
159
+ }
160
+ const reason = typeof rawReason === "string" && rawReason.trim()
161
+ ? rawReason.replace(/\s+/g, " ").trim().slice(0, MAX_REASON_LENGTH)
162
+ : null;
163
+ const receipt = parseReceiptFields(rawReceipt);
164
+ if (typeof receipt === "string") {
165
+ return { ok: false, error: receipt };
166
+ }
167
+ return { ok: true, labels, reason, receipt };
168
+ }
169
+ /**
170
+ * Validate the optional `receipt` object. A malformed one fails the whole
171
+ * answer (returned as an error string): a receipt is consent, so half of one
172
+ * is never used.
173
+ */
174
+ function parseReceiptFields(raw) {
175
+ if (raw === null) {
176
+ return null;
177
+ }
178
+ if (!isRecord(raw)) {
179
+ return "advisor receipt must be an object";
180
+ }
181
+ const { repo, reason } = raw;
182
+ if (typeof repo !== "string" ||
183
+ repo.trim().length === 0 ||
184
+ repo.length > MAX_RECEIPT_REPO_LENGTH ||
185
+ !isSafeText(repo)) {
186
+ return "advisor receipt.repo must be a non-empty plain string";
187
+ }
188
+ if (typeof reason !== "string" ||
189
+ reason.trim().length === 0 ||
190
+ reason.length > MAX_RECEIPT_REASON_LENGTH ||
191
+ !isSafeText(reason)) {
192
+ return `advisor receipt.reason must be a non-empty plain string of at most ${MAX_RECEIPT_REASON_LENGTH} characters`;
193
+ }
194
+ return { repo: repo.trim(), reason: reason.trim() };
195
+ }
196
+ /**
197
+ * Run the configured advisor. Returns `null` when no advisor is configured,
198
+ * otherwise the parsed advice or a failure. Never throws.
199
+ *
200
+ * Synchronous for the same reason as the identity resolver: it sits on the
201
+ * critical path of a short-lived CLI, and a single failure site is easier to
202
+ * reason about than a promise race.
203
+ */
204
+ export function runLabelAdvisor(input, config, env = process.env) {
205
+ const argv = labelAdvisorCommand(config, env);
206
+ if (!argv) {
207
+ return null;
208
+ }
209
+ const [command, ...args] = argv;
210
+ if (!command) {
211
+ return null;
212
+ }
213
+ try {
214
+ const result = spawnSync(command, args, {
215
+ encoding: "utf8",
216
+ input: JSON.stringify(input),
217
+ timeout: resolveTimeoutMs(config),
218
+ // A trapped SIGTERM would let a broken advisor wedge the CLI past
219
+ // its time budget; see the identical note in identity-resolver.ts.
220
+ killSignal: "SIGKILL",
221
+ shell: false,
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ env: advisorEnv(env),
224
+ });
225
+ // Check `error` before `status`: a timeout that leaves a grandchild
226
+ // holding the pipe reports ETIMEDOUT with status 0 (identity-resolver.ts).
227
+ if (result.error) {
228
+ return { ok: false, error: `${command}: ${result.error.message}` };
229
+ }
230
+ if (result.status !== 0) {
231
+ const stderr = (result.stderr ?? "").trim().split("\n")[0] ?? "";
232
+ return {
233
+ ok: false,
234
+ error: `${command} exited ${result.status ?? `on ${result.signal}`}${stderr ? `: ${stderr}` : ""}`,
235
+ };
236
+ }
237
+ return parseLabelAdvisorOutput(result.stdout ?? "");
238
+ }
239
+ catch (err) {
240
+ return {
241
+ ok: false,
242
+ error: err instanceof Error ? err.message : String(err),
243
+ };
244
+ }
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.45.1",
3
+ "version": "1.46.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",