@enrichlayer/el-linear 1.18.1 → 1.19.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
@@ -356,6 +356,27 @@ preferences; those stay in personal config. Example team file:
356
356
  Run `el-linear config show` to see the resolved config and confirm which team
357
357
  config path is active (`teamConfig` field in the output).
358
358
 
359
+ #### Migrating a personal-heavy config
360
+
361
+ Before the team-config split, members typically carried full local copies of
362
+ `members` / `teams` / `labels` / `statusDefaults` / `teamAliases` — and often
363
+ a deprecated `brand: { name, reject }` key. Once a team config is in place
364
+ those local copies just silently shadow the shared values (and silently
365
+ *diverge* when they drift). To clean it up safely:
366
+
367
+ ```bash
368
+ el-linear config migrate-from-personal # dry-run — prints the plan per file
369
+ el-linear config migrate-from-personal --apply # write the slimmed files (with .bak-<ts> backups)
370
+ ```
371
+
372
+ Targets the global personal config + every named profile's `config.json`. For
373
+ each top-level shadowable key, the slim drops it **only** when the local copy
374
+ is a strict subset of team (zero divergence, zero non-trivial personal-only
375
+ entries); otherwise the key is left untouched and the divergence is reported
376
+ in the plan so a human resolves it. The deprecated `brand` key is dropped
377
+ when content-identical to a team `terms[]` entry, or converted to a personal
378
+ `terms[]` entry when it differs (with a warning).
379
+
359
380
  ## Term enforcement (with brand-promotion examples)
360
381
 
361
382
  The `terms` rules let you keep a list of canonical names and the misspellings
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
5
- import { resolveActiveProfile } from "../config/paths.js";
5
+ import { planMigration, } from "../config/migrate-from-personal.js";
6
+ import { CONFIG_PATH, isSafeProfileName, PROFILES_DIR, resolveActiveProfile, } from "../config/paths.js";
6
7
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
8
  import { updateConfig } from "./init/shared.js";
8
9
  /**
@@ -185,4 +186,166 @@ export function setupConfigCommands(program) {
185
186
  fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
186
187
  outputSuccess({ data: updated });
187
188
  }));
189
+ // ── config migrate-from-personal ──────────────────────────────────
190
+ //
191
+ // Slim personal/profile configs that duplicate keys the team config now
192
+ // provides (DEV-4458). The companion pure logic + tests live in
193
+ // `src/config/migrate-from-personal.ts`. This command just discovers
194
+ // the target files, reads the active team config, calls planMigration
195
+ // for each, and on --apply backs up + atomically rewrites them.
196
+ config
197
+ .command("migrate-from-personal")
198
+ .description("Slim personal/profile configs against the active team config — drops duplicated members/teams/labels/statusDefaults/teamAliases entries, and migrates the deprecated 'brand' key to a 'terms[]' entry. Dry-run by default; pass --apply to write.")
199
+ .option("--apply", "Write the slimmed files (with timestamped .bak-<ts> backups + atomic replace). Default is dry-run — only prints the plan.")
200
+ .option("--file <path>", "Scope to one file. Default: ~/.config/el-linear/config.json plus every <profiles>/<name>/config.json with a safe name.")
201
+ .addHelpText("after", `\nThe slim only drops a shadowable top-level key when the personal copy is a strict subset of team (zero divergence and zero non-trivial personal-only entries). Anything that would silently shadow a team value, or carry a personal-only entry team doesn't have, is left untouched and reported in the plan so a human can decide.\n\nThe active team config is never a target — passing --file <team-path> is rejected, and a profile config that happens to coincide with the team path is skipped silently with a warning. The first --apply may re-format JSON to 2-space indent + trailing newline; this is harmless but the .bak-<ts> backup preserves the original byte-for-byte.\n\nUses the currently-active team config for every target. If you have profiles pointing at different team configs, run --file <path> per profile with EL_LINEAR_TEAM_CONFIG set.\n\nExamples:\n el-linear config migrate-from-personal\n el-linear config migrate-from-personal --apply\n el-linear config migrate-from-personal --file ~/.config/el-linear/profiles/foo/config.json --apply`)
202
+ .action(handleAsyncCommand(async (opts) => {
203
+ const teamPath = getActiveTeamConfigPath();
204
+ if (!teamPath) {
205
+ throw new Error("No active team config found. Set one with `el-linear config team set-path <path>` (or EL_LINEAR_TEAM_CONFIG) before running migrate-from-personal — without a team layer there's nothing to dedupe against.");
206
+ }
207
+ let team;
208
+ try {
209
+ team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
210
+ }
211
+ catch (err) {
212
+ throw new Error(`Failed to read team config at ${teamPath}: ${err.message}`);
213
+ }
214
+ const apply = opts.apply ?? false;
215
+ const resolvedTeamPath = path.resolve(teamPath);
216
+ // Self-diff guard: refuse to migrate the team config against itself
217
+ // (cycle-1 nit). Without this, every shadowable key in team would
218
+ // classify as "drop" (a key is trivially a subset of itself) and
219
+ // --apply would gut the team config.
220
+ if (opts.file &&
221
+ path.resolve(expandHome(opts.file)) === resolvedTeamPath) {
222
+ throw new Error(`--file resolves to the active team config (${resolvedTeamPath}). Refusing to migrate the team config against itself.`);
223
+ }
224
+ const targetsBeforeFilter = opts.file
225
+ ? [path.resolve(expandHome(opts.file))]
226
+ : enumerateMigrationTargets();
227
+ const targets = [];
228
+ for (const t of targetsBeforeFilter) {
229
+ if (path.resolve(t) === resolvedTeamPath) {
230
+ outputWarning(`Skipping ${t} — it resolves to the active team config; migrating it against itself would erase team-shared keys.`);
231
+ continue;
232
+ }
233
+ targets.push(t);
234
+ }
235
+ const results = targets.map((file) => migrateOneFile(file, team, apply));
236
+ // Forward per-file plan warnings to the standard _warnings envelope
237
+ // (cycle-1 nit) so scripters reading the stable warning channel see
238
+ // them without having to inspect each target.
239
+ for (const r of results) {
240
+ for (const w of r.warnings)
241
+ outputWarning(`${r.file}: ${w}`);
242
+ }
243
+ // --file <single>: an unreadable / unparseable target is a hard
244
+ // failure (cycle-1 nit). Default enumeration stays soft so one bad
245
+ // profile doesn't crash the run for the rest.
246
+ if (opts.file && results.length === 1 && results[0].error) {
247
+ throw new Error(results[0].error);
248
+ }
249
+ outputSuccess({
250
+ data: {
251
+ teamConfigPath: teamPath,
252
+ applied: apply,
253
+ targetCount: results.length,
254
+ targets: results,
255
+ },
256
+ });
257
+ }));
258
+ }
259
+ /**
260
+ * Enumerate every personal/profile config file the migration tool should
261
+ * consider by default: the global personal config, plus each profile dir
262
+ * whose name passes `isSafeProfileName` and whose `config.json` exists.
263
+ */
264
+ function enumerateMigrationTargets() {
265
+ const out = [];
266
+ if (fs.existsSync(CONFIG_PATH))
267
+ out.push(CONFIG_PATH);
268
+ if (fs.existsSync(PROFILES_DIR)) {
269
+ const entries = fs.readdirSync(PROFILES_DIR);
270
+ for (const name of entries.sort()) {
271
+ if (!isSafeProfileName(name))
272
+ continue;
273
+ const p = path.join(PROFILES_DIR, name, "config.json");
274
+ if (fs.existsSync(p))
275
+ out.push(p);
276
+ }
277
+ }
278
+ return out;
279
+ }
280
+ function migrateOneFile(file, team, apply) {
281
+ let rawText;
282
+ let personal;
283
+ try {
284
+ rawText = fs.readFileSync(file, "utf8");
285
+ personal = JSON.parse(rawText);
286
+ }
287
+ catch (err) {
288
+ return {
289
+ file,
290
+ changed: false,
291
+ before: { sizeBytes: 0, keys: [] },
292
+ after: { sizeBytes: 0, keys: [] },
293
+ keys: [],
294
+ brand: { status: "absent" },
295
+ warnings: [],
296
+ error: `Could not read/parse: ${err.message}`,
297
+ };
298
+ }
299
+ const { slimmed, plan } = planMigration(personal, team);
300
+ const slimmedText = `${JSON.stringify(slimmed, null, 2)}\n`;
301
+ const beforeBytes = Buffer.byteLength(rawText, "utf8");
302
+ const afterBytes = Buffer.byteLength(slimmedText, "utf8");
303
+ const changed = slimmedText !== rawText;
304
+ const result = {
305
+ file,
306
+ changed,
307
+ before: { sizeBytes: beforeBytes, keys: Object.keys(personal) },
308
+ after: { sizeBytes: afterBytes, keys: Object.keys(slimmed) },
309
+ keys: plan.keys,
310
+ brand: plan.brand,
311
+ warnings: plan.warnings,
312
+ };
313
+ if (apply && changed) {
314
+ // Backup names include milliseconds (sub-second collisions on rapid
315
+ // re-apply would otherwise overwrite the original backup) AND copy
316
+ // with COPYFILE_EXCL so a same-millisecond collision throws instead
317
+ // of clobbering. Same for the temp file via `wx` flag.
318
+ const { backup, tmp } = acquireBackupAndTempPaths(file);
319
+ fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
320
+ fs.writeFileSync(tmp, slimmedText, { encoding: "utf8", flag: "wx" });
321
+ fs.renameSync(tmp, file);
322
+ result.backup = backup;
323
+ }
324
+ return result;
325
+ }
326
+ function backupTimestamp() {
327
+ const d = new Date();
328
+ const pad = (n) => n.toString().padStart(2, "0");
329
+ const pad3 = (n) => n.toString().padStart(3, "0");
330
+ return (`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
331
+ `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +
332
+ `-${pad3(d.getMilliseconds())}`);
333
+ }
334
+ /**
335
+ * Pick a `.bak-<ts>` and `.tmp-<ts>` pair that don't collide with anything
336
+ * on disk. Millisecond resolution makes a collision extraordinarily rare,
337
+ * but a tight retry loop closes the remaining gap (and the `COPYFILE_EXCL`
338
+ * / `wx` flags at the call site fail loudly if the gap is ever crossed).
339
+ */
340
+ function acquireBackupAndTempPaths(file) {
341
+ for (let attempt = 0; attempt < 100; attempt++) {
342
+ const ts = backupTimestamp();
343
+ const suffix = attempt === 0 ? ts : `${ts}-${attempt}`;
344
+ const backup = `${file}.bak-${suffix}`;
345
+ const tmp = `${file}.tmp-${suffix}`;
346
+ if (!fs.existsSync(backup) && !fs.existsSync(tmp)) {
347
+ return { backup, tmp };
348
+ }
349
+ }
350
+ throw new Error(`Could not pick a unique backup name for ${file} after 100 attempts; clean up old .bak-/.tmp- siblings and retry.`);
188
351
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Plan + apply the personal-to-team-backed config slim.
3
+ *
4
+ * After the team-config split (DEV-4172 / ALL-964) members typically still
5
+ * carry full duplicates of the now-shared keys in their personal/profile
6
+ * configs. The duplicates are silent shadows when identical and silent
7
+ * divergences when they drift. This module computes a per-file plan:
8
+ *
9
+ * - **Top-level shadowable keys** (`members`, `teams`, `labels`,
10
+ * `statusDefaults`, `teamAliases`) are *dropped* only when the personal
11
+ * copy is a strict subset of team (zero divergence and zero non-trivial
12
+ * personal-only entries). Otherwise the key is left untouched and the
13
+ * diff is reported, so a human resolves it.
14
+ * - The deprecated `brand: { name, reject }` key is dropped when content-
15
+ * identical to an existing team `terms[]` entry; otherwise it is
16
+ * *converted* into a personal `terms[]` entry (no shadowing of team).
17
+ * - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
18
+ * `teamConfigPath`, anything not in the shadowable set or `brand`) are
19
+ * never touched.
20
+ *
21
+ * Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
22
+ */
23
+ /** Top-level keys typically owned by the team config layer. */
24
+ export declare const TEAM_SHADOWABLE_KEYS: readonly ["members", "teams", "labels", "statusDefaults", "teamAliases"];
25
+ export type TeamShadowableKey = (typeof TEAM_SHADOWABLE_KEYS)[number];
26
+ export type JsonValue = string | number | boolean | null | {
27
+ [k: string]: JsonValue;
28
+ } | JsonValue[];
29
+ export type JsonObject = {
30
+ [k: string]: JsonValue;
31
+ };
32
+ /** Per-key decision in the plan. */
33
+ export interface KeyAction {
34
+ key: string;
35
+ action: "drop" | "keep-divergent" | "keep-additions" | "absent";
36
+ /** Leaves whose personal value matched team exactly. */
37
+ matchCount: number;
38
+ /** Leaves whose personal value differs from team (would be a silent shadow). */
39
+ divergentCount: number;
40
+ /** Non-trivial personal-only leaves (would be lost if dropped). */
41
+ additionCount: number;
42
+ /** First few divergent / addition paths, for human-readable context. */
43
+ sampleDivergent?: string[];
44
+ sampleAdditions?: string[];
45
+ }
46
+ /** What we did with the deprecated `brand` key. */
47
+ export interface BrandAction {
48
+ status: "absent" | "drop-duplicate" | "convert-to-term" /** Couldn't convert safely (e.g. existing personal `terms` is malformed). */ | "keep-malformed";
49
+ /** When status === "convert-to-term", the new entry appended to terms[]. */
50
+ convertedTo?: {
51
+ canonical: string;
52
+ reject: string[];
53
+ };
54
+ reason?: string;
55
+ }
56
+ export interface MigrationPlan {
57
+ keys: KeyAction[];
58
+ brand: BrandAction;
59
+ warnings: string[];
60
+ }
61
+ export interface MigrationResult {
62
+ slimmed: JsonObject;
63
+ plan: MigrationPlan;
64
+ }
65
+ /** A canonical JSON-friendly deep equality. */
66
+ export declare function deepEqual(a: JsonValue, b: JsonValue): boolean;
67
+ /**
68
+ * Compute the slimmed config + the plan that explains it. Pure.
69
+ *
70
+ * @param personal The personal/profile config object as parsed from disk.
71
+ * @param team The active team config object as parsed from disk. Pass
72
+ * `{}` if no team config is configured — every shadowable
73
+ * key becomes either `keep-additions` or `keep-divergent`,
74
+ * so nothing gets dropped (correct: without team there is
75
+ * nothing to fall back to).
76
+ */
77
+ export declare function planMigration(personal: JsonObject, team: JsonObject): MigrationResult;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Plan + apply the personal-to-team-backed config slim.
3
+ *
4
+ * After the team-config split (DEV-4172 / ALL-964) members typically still
5
+ * carry full duplicates of the now-shared keys in their personal/profile
6
+ * configs. The duplicates are silent shadows when identical and silent
7
+ * divergences when they drift. This module computes a per-file plan:
8
+ *
9
+ * - **Top-level shadowable keys** (`members`, `teams`, `labels`,
10
+ * `statusDefaults`, `teamAliases`) are *dropped* only when the personal
11
+ * copy is a strict subset of team (zero divergence and zero non-trivial
12
+ * personal-only entries). Otherwise the key is left untouched and the
13
+ * diff is reported, so a human resolves it.
14
+ * - The deprecated `brand: { name, reject }` key is dropped when content-
15
+ * identical to an existing team `terms[]` entry; otherwise it is
16
+ * *converted* into a personal `terms[]` entry (no shadowing of team).
17
+ * - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
18
+ * `teamConfigPath`, anything not in the shadowable set or `brand`) are
19
+ * never touched.
20
+ *
21
+ * Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
22
+ */
23
+ /** Top-level keys typically owned by the team config layer. */
24
+ export const TEAM_SHADOWABLE_KEYS = [
25
+ "members",
26
+ "teams",
27
+ "labels",
28
+ "statusDefaults",
29
+ "teamAliases",
30
+ ];
31
+ /** A canonical JSON-friendly deep equality. */
32
+ export function deepEqual(a, b) {
33
+ if (a === b)
34
+ return true;
35
+ if (a === null || b === null)
36
+ return false;
37
+ if (typeof a !== typeof b)
38
+ return false;
39
+ if (Array.isArray(a) || Array.isArray(b)) {
40
+ if (!Array.isArray(a) || !Array.isArray(b))
41
+ return false;
42
+ if (a.length !== b.length)
43
+ return false;
44
+ return a.every((v, i) => deepEqual(v, b[i]));
45
+ }
46
+ if (typeof a === "object") {
47
+ const ak = Object.keys(a).sort();
48
+ const bk = Object.keys(b).sort();
49
+ if (ak.length !== bk.length)
50
+ return false;
51
+ if (ak.some((k, i) => k !== bk[i]))
52
+ return false;
53
+ return ak.every((k) => deepEqual(a[k], b[k]));
54
+ }
55
+ return false;
56
+ }
57
+ function isTriviallyEmpty(v) {
58
+ if (v === undefined || v === null)
59
+ return true;
60
+ if (Array.isArray(v))
61
+ return v.length === 0;
62
+ if (typeof v === "object")
63
+ return Object.keys(v).length === 0;
64
+ return false;
65
+ }
66
+ function isPlainObject(v) {
67
+ return v !== null && typeof v === "object" && !Array.isArray(v);
68
+ }
69
+ /**
70
+ * Walk personal and classify each LEAF against team at the same path:
71
+ * match — present in team with the same value
72
+ * divergent — present in team with a different value (would silently shadow)
73
+ * addition — absent from team and non-trivially non-empty (would be lost)
74
+ *
75
+ * Trivially-empty containers (e.g. `members.handles.github = {}`) are not
76
+ * counted as additions — dropping them loses no information.
77
+ */
78
+ function classifyDeep(personal, team, path, out) {
79
+ if (personal === undefined)
80
+ return;
81
+ // Both objects → recurse into keys.
82
+ if (isPlainObject(personal) && isPlainObject(team)) {
83
+ for (const k of Object.keys(personal)) {
84
+ classifyDeep(personal[k], team[k], path ? `${path}.${k}` : k, out);
85
+ }
86
+ return;
87
+ }
88
+ // Personal is a leaf (primitive / array / object without a matching team obj).
89
+ if (team === undefined) {
90
+ if (!isTriviallyEmpty(personal))
91
+ out.additions.push(path);
92
+ return;
93
+ }
94
+ if (deepEqual(personal, team)) {
95
+ out.matches += 1;
96
+ }
97
+ else {
98
+ out.divergent.push(path);
99
+ }
100
+ }
101
+ function planTopLevelKey(key, personal, team) {
102
+ if (personal === undefined) {
103
+ return {
104
+ key,
105
+ action: "absent",
106
+ matchCount: 0,
107
+ divergentCount: 0,
108
+ additionCount: 0,
109
+ };
110
+ }
111
+ const acc = { matches: 0, divergent: [], additions: [] };
112
+ classifyDeep(personal, team, key, acc);
113
+ const action = acc.divergent.length === 0 && acc.additions.length === 0
114
+ ? "drop"
115
+ : acc.divergent.length > 0
116
+ ? "keep-divergent"
117
+ : "keep-additions";
118
+ const ka = {
119
+ key,
120
+ action,
121
+ matchCount: acc.matches,
122
+ divergentCount: acc.divergent.length,
123
+ additionCount: acc.additions.length,
124
+ };
125
+ if (acc.divergent.length > 0)
126
+ ka.sampleDivergent = acc.divergent.slice(0, 5);
127
+ if (acc.additions.length > 0)
128
+ ka.sampleAdditions = acc.additions.slice(0, 5);
129
+ return ka;
130
+ }
131
+ function readBrand(personal) {
132
+ const raw = personal.brand;
133
+ if (!isPlainObject(raw))
134
+ return null;
135
+ const name = raw.name;
136
+ const reject = raw.reject;
137
+ if (typeof name !== "string")
138
+ return null;
139
+ if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
140
+ return null;
141
+ }
142
+ return { name, reject: reject };
143
+ }
144
+ function readTeamTerms(team) {
145
+ const t = team.terms;
146
+ if (!Array.isArray(t))
147
+ return [];
148
+ const out = [];
149
+ for (const e of t) {
150
+ if (!isPlainObject(e))
151
+ continue;
152
+ const canonical = e.canonical;
153
+ const reject = e.reject;
154
+ if (typeof canonical !== "string")
155
+ continue;
156
+ if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
157
+ continue;
158
+ }
159
+ out.push({ canonical, reject: reject });
160
+ }
161
+ return out;
162
+ }
163
+ function brandMatchesTerm(brand, term) {
164
+ return brand.name === term.canonical && deepEqual(brand.reject, term.reject);
165
+ }
166
+ /**
167
+ * Compute the slimmed config + the plan that explains it. Pure.
168
+ *
169
+ * @param personal The personal/profile config object as parsed from disk.
170
+ * @param team The active team config object as parsed from disk. Pass
171
+ * `{}` if no team config is configured — every shadowable
172
+ * key becomes either `keep-additions` or `keep-divergent`,
173
+ * so nothing gets dropped (correct: without team there is
174
+ * nothing to fall back to).
175
+ */
176
+ export function planMigration(personal, team) {
177
+ const warnings = [];
178
+ const slimmed = { ...personal };
179
+ const keyActions = TEAM_SHADOWABLE_KEYS.map((key) => planTopLevelKey(key, personal[key], team[key]));
180
+ for (const ka of keyActions) {
181
+ if (ka.action === "drop")
182
+ delete slimmed[ka.key];
183
+ }
184
+ // Brand → terms.
185
+ let brand;
186
+ const brandShape = readBrand(personal);
187
+ if (brandShape === null) {
188
+ brand = { status: "absent" };
189
+ }
190
+ else {
191
+ const teamTerms = readTeamTerms(team);
192
+ const dup = teamTerms.find((t) => brandMatchesTerm(brandShape, t));
193
+ if (dup !== undefined) {
194
+ delete slimmed.brand;
195
+ brand = {
196
+ status: "drop-duplicate",
197
+ reason: `brand is content-identical to team terms entry "${dup.canonical}"`,
198
+ };
199
+ }
200
+ else if (personal.terms !== undefined && !Array.isArray(personal.terms)) {
201
+ // Refuse to convert: an existing personal `terms` field is not an
202
+ // array (likely a hand-edit gone wrong). The strict-subset gate
203
+ // elsewhere never silently destroys info — neither should this
204
+ // path. Leave brand AND terms as-is; surface as a warning so a
205
+ // human can resolve.
206
+ brand = {
207
+ status: "keep-malformed",
208
+ reason: "existing personal 'terms' field is not an array; leaving 'brand' and 'terms' untouched for human review",
209
+ };
210
+ warnings.push(`Cannot convert deprecated 'brand': existing 'terms' field is not an array (got ${typeof personal.terms}). Left 'brand' and 'terms' as-is — fix 'terms' to a proper array of { canonical, reject } entries and re-run.`);
211
+ }
212
+ else {
213
+ // Convert: append a personal terms entry, drop brand.
214
+ delete slimmed.brand;
215
+ const existing = Array.isArray(slimmed.terms)
216
+ ? slimmed.terms
217
+ : [];
218
+ const converted = {
219
+ canonical: brandShape.name,
220
+ reject: brandShape.reject,
221
+ };
222
+ slimmed.terms = [...existing, { ...converted }];
223
+ brand = {
224
+ status: "convert-to-term",
225
+ convertedTo: converted,
226
+ reason: teamTerms.length === 0
227
+ ? "team config has no terms; converted brand to a personal terms entry"
228
+ : "brand does not match any team terms entry; converted to a personal terms entry",
229
+ };
230
+ if (teamTerms.length > 0) {
231
+ warnings.push(`brand on this file diverges from team terms — preserved as a personal terms entry. Review whether the team config should adopt it.`);
232
+ }
233
+ }
234
+ }
235
+ return { slimmed, plan: { keys: keyActions, brand, warnings } };
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.18.1",
3
+ "version": "1.19.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",