linmux 0.1.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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/bin/run.js +4 -0
  4. package/dist/commands/comment/create.js +94 -0
  5. package/dist/commands/comment/delete.js +74 -0
  6. package/dist/commands/comment/list.js +84 -0
  7. package/dist/commands/comment/update.js +80 -0
  8. package/dist/commands/cycle/current.js +78 -0
  9. package/dist/commands/cycle/list.js +84 -0
  10. package/dist/commands/cycle/move.js +91 -0
  11. package/dist/commands/describe.js +65 -0
  12. package/dist/commands/graphql/index.js +92 -0
  13. package/dist/commands/install-skill.js +54 -0
  14. package/dist/commands/issue/archive.js +75 -0
  15. package/dist/commands/issue/create.js +115 -0
  16. package/dist/commands/issue/get.js +84 -0
  17. package/dist/commands/issue/list.js +93 -0
  18. package/dist/commands/issue/purge.js +81 -0
  19. package/dist/commands/issue/search.js +109 -0
  20. package/dist/commands/issue/transition.js +91 -0
  21. package/dist/commands/issue/trash.js +75 -0
  22. package/dist/commands/issue/update.js +126 -0
  23. package/dist/commands/label/create.js +91 -0
  24. package/dist/commands/label/list.js +76 -0
  25. package/dist/commands/list-tools.js +47 -0
  26. package/dist/commands/me.js +71 -0
  27. package/dist/commands/project/create.js +101 -0
  28. package/dist/commands/project/get.js +83 -0
  29. package/dist/commands/project/list.js +75 -0
  30. package/dist/commands/project/update-status.js +99 -0
  31. package/dist/commands/project/update.js +99 -0
  32. package/dist/commands/raw/batch.js +85 -0
  33. package/dist/commands/raw/index.js +72 -0
  34. package/dist/commands/schema.js +69 -0
  35. package/dist/commands/state/list.js +77 -0
  36. package/dist/commands/team/get.js +73 -0
  37. package/dist/commands/team/list.js +73 -0
  38. package/dist/commands/whoami.js +71 -0
  39. package/dist/commands/workspace/add.js +97 -0
  40. package/dist/commands/workspace/list.js +47 -0
  41. package/dist/commands/workspace/remove.js +63 -0
  42. package/dist/commands/workspace/replace-token.js +89 -0
  43. package/dist/commands/workspace/use.js +54 -0
  44. package/dist/core/client/factory.js +28 -0
  45. package/dist/core/client/index.js +2 -0
  46. package/dist/core/config/index.js +4 -0
  47. package/dist/core/config/paths.js +30 -0
  48. package/dist/core/config/schema.js +36 -0
  49. package/dist/core/config/store.js +149 -0
  50. package/dist/core/errors/error.js +142 -0
  51. package/dist/core/errors/exit-codes.js +70 -0
  52. package/dist/core/output/envelope.js +53 -0
  53. package/dist/core/output/format.js +42 -0
  54. package/dist/core/output/index.js +3 -0
  55. package/dist/core/pagination/flags.js +29 -0
  56. package/dist/core/pagination/index.js +2 -0
  57. package/dist/core/projection/presets.js +116 -0
  58. package/dist/core/projection/project.js +282 -0
  59. package/dist/core/redact/redact.js +45 -0
  60. package/dist/core/resolvers/cycle.js +60 -0
  61. package/dist/core/resolvers/index.js +7 -0
  62. package/dist/core/resolvers/label.js +54 -0
  63. package/dist/core/resolvers/project-status.js +42 -0
  64. package/dist/core/resolvers/project.js +43 -0
  65. package/dist/core/resolvers/state.js +46 -0
  66. package/dist/core/resolvers/team.js +50 -0
  67. package/dist/core/transport/fetch-interceptor.js +109 -0
  68. package/dist/core/transport/index.js +3 -0
  69. package/dist/core/transport/rate-limit.js +167 -0
  70. package/dist/core/workspace/resolver.js +70 -0
  71. package/dist/core/workspace/write-guard.js +43 -0
  72. package/dist/generated/graphql.js +89428 -0
  73. package/dist/generated/operations.js +3013 -0
  74. package/dist/lib/comment-create-runtime.js +96 -0
  75. package/dist/lib/comment-delete-runtime.js +46 -0
  76. package/dist/lib/comment-list-runtime.js +182 -0
  77. package/dist/lib/comment-update-runtime.js +93 -0
  78. package/dist/lib/cycle-current-runtime.js +90 -0
  79. package/dist/lib/cycle-list-runtime.js +151 -0
  80. package/dist/lib/cycle-move-runtime.js +142 -0
  81. package/dist/lib/describe-runtime.js +180 -0
  82. package/dist/lib/filter-heuristics.js +59 -0
  83. package/dist/lib/graphql-runtime.js +202 -0
  84. package/dist/lib/include-fragments.js +73 -0
  85. package/dist/lib/install-skill-runtime.js +228 -0
  86. package/dist/lib/introspection-registry.js +488 -0
  87. package/dist/lib/issue-archive-runtime.js +89 -0
  88. package/dist/lib/issue-create-runtime.js +175 -0
  89. package/dist/lib/issue-get-runtime.js +153 -0
  90. package/dist/lib/issue-list-runtime.js +164 -0
  91. package/dist/lib/issue-purge-runtime.js +89 -0
  92. package/dist/lib/issue-search-runtime.js +114 -0
  93. package/dist/lib/issue-transition-runtime.js +131 -0
  94. package/dist/lib/issue-trash-runtime.js +84 -0
  95. package/dist/lib/issue-update-runtime.js +164 -0
  96. package/dist/lib/label-create-runtime.js +113 -0
  97. package/dist/lib/label-list-runtime.js +97 -0
  98. package/dist/lib/levenshtein.js +42 -0
  99. package/dist/lib/list-tools-runtime.js +38 -0
  100. package/dist/lib/me-runtime.js +55 -0
  101. package/dist/lib/project-create-runtime.js +103 -0
  102. package/dist/lib/project-get-runtime.js +134 -0
  103. package/dist/lib/project-list-runtime.js +84 -0
  104. package/dist/lib/project-update-runtime.js +110 -0
  105. package/dist/lib/project-update-status-runtime.js +91 -0
  106. package/dist/lib/raw-batch-runtime.js +229 -0
  107. package/dist/lib/raw-runtime.js +171 -0
  108. package/dist/lib/schema-loader.js +41 -0
  109. package/dist/lib/schema-runtime.js +65 -0
  110. package/dist/lib/state-list-runtime.js +93 -0
  111. package/dist/lib/team-get-runtime.js +55 -0
  112. package/dist/lib/team-list-runtime.js +52 -0
  113. package/dist/lib/workspace-runtime.js +112 -0
  114. package/dist/operations/_registry.zod.js +5337 -0
  115. package/oclif.manifest.json +3631 -0
  116. package/package.json +99 -0
  117. package/schema.graphql +30772 -0
  118. package/skills/linmux/SKILL.md +186 -0
@@ -0,0 +1,42 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { withRateLimitRetry } from "../transport/rate-limit.js";
3
+ import { UUID_RE } from "../../lib/filter-heuristics.js";
4
+ //#region src/core/resolvers/project-status.ts
5
+ const cache = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Resolve a project-status name (or UUID) to a `ProjectStatus.id` for the
8
+ * active workspace. Throws `PROJECT_NOT_FOUND` (re-used taxonomy) on miss.
9
+ */
10
+ async function resolveProjectStatusId(client, workspaceName, nameOrId, retryOpts) {
11
+ if (UUID_RE.test(nameOrId)) return nameOrId;
12
+ let map = cache.get(workspaceName);
13
+ if (!map) {
14
+ map = (async () => {
15
+ const conn = await withRateLimitRetry(() => client.projectStatuses({ first: 50 }), retryOpts);
16
+ const m = /* @__PURE__ */ new Map();
17
+ for (const s of conn.nodes) m.set(s.name.toLowerCase(), s.id);
18
+ return m;
19
+ })();
20
+ cache.set(workspaceName, map);
21
+ }
22
+ let m;
23
+ try {
24
+ m = await map;
25
+ } catch (e) {
26
+ cache.delete(workspaceName);
27
+ throw e;
28
+ }
29
+ const id = m.get(nameOrId.toLowerCase());
30
+ if (!id) throw new LinearAgentError({
31
+ code: "PROJECT_NOT_FOUND",
32
+ message: `project status not found: ${nameOrId}`,
33
+ details: {
34
+ workspace: workspaceName,
35
+ requested: nameOrId,
36
+ available: [...m.keys()].sort()
37
+ }
38
+ });
39
+ return id;
40
+ }
41
+ //#endregion
42
+ export { resolveProjectStatusId };
@@ -0,0 +1,43 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { withRateLimitRetry } from "../transport/rate-limit.js";
3
+ import { UUID_RE } from "../../lib/filter-heuristics.js";
4
+ //#region src/core/resolvers/project.ts
5
+ const cache = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Resolve a project name (or UUID) to a project UUID for the active
8
+ * workspace. Throws `PROJECT_NOT_FOUND` on miss with the cached project name
9
+ * list so the agent can self-correct.
10
+ */
11
+ async function resolveProjectId(client, workspaceName, nameOrId, retryOpts) {
12
+ if (UUID_RE.test(nameOrId)) return nameOrId;
13
+ let map = cache.get(workspaceName);
14
+ if (!map) {
15
+ map = (async () => {
16
+ const conn = await withRateLimitRetry(() => client.projects({ first: 250 }), retryOpts);
17
+ const m = /* @__PURE__ */ new Map();
18
+ for (const p of conn.nodes) m.set(p.name.toLowerCase(), p.id);
19
+ return m;
20
+ })();
21
+ cache.set(workspaceName, map);
22
+ }
23
+ let m;
24
+ try {
25
+ m = await map;
26
+ } catch (e) {
27
+ cache.delete(workspaceName);
28
+ throw e;
29
+ }
30
+ const id = m.get(nameOrId.toLowerCase());
31
+ if (!id) throw new LinearAgentError({
32
+ code: "PROJECT_NOT_FOUND",
33
+ message: `project not found: ${nameOrId}`,
34
+ details: {
35
+ workspace: workspaceName,
36
+ requested: nameOrId,
37
+ available: [...m.keys()].sort()
38
+ }
39
+ });
40
+ return id;
41
+ }
42
+ //#endregion
43
+ export { resolveProjectId };
@@ -0,0 +1,46 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { withRateLimitRetry } from "../transport/rate-limit.js";
3
+ import { UUID_RE } from "../../lib/filter-heuristics.js";
4
+ //#region src/core/resolvers/state.ts
5
+ const cache = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Resolve a state-name (or UUID) to a workflow-state UUID, scoped to one
8
+ * `${workspace}:${teamId}` pair.
9
+ */
10
+ async function resolveStateNameToId(client, workspaceName, teamId, nameOrId, retryOpts) {
11
+ if (UUID_RE.test(nameOrId)) return nameOrId;
12
+ const key = `${workspaceName}:${teamId}`;
13
+ let map = cache.get(key);
14
+ if (!map) {
15
+ map = (async () => {
16
+ const conn = await withRateLimitRetry(() => client.workflowStates({
17
+ filter: { team: { id: { eq: teamId } } },
18
+ first: 50
19
+ }), retryOpts);
20
+ const m = /* @__PURE__ */ new Map();
21
+ for (const s of conn.nodes) m.set(s.name.toLowerCase(), s.id);
22
+ return m;
23
+ })();
24
+ cache.set(key, map);
25
+ }
26
+ let m;
27
+ try {
28
+ m = await map;
29
+ } catch (e) {
30
+ cache.delete(key);
31
+ throw e;
32
+ }
33
+ const id = m.get(nameOrId.toLowerCase());
34
+ if (!id) throw new LinearAgentError({
35
+ code: "WORKFLOW_STATE_NOT_FOUND",
36
+ message: `workflow state not found: ${nameOrId}`,
37
+ details: {
38
+ teamId,
39
+ requested: nameOrId,
40
+ available: [...m.keys()].sort()
41
+ }
42
+ });
43
+ return id;
44
+ }
45
+ //#endregion
46
+ export { resolveStateNameToId };
@@ -0,0 +1,50 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { withRateLimitRetry } from "../transport/rate-limit.js";
3
+ import { TEAM_KEY_RE, UUID_RE } from "../../lib/filter-heuristics.js";
4
+ //#region src/core/resolvers/team.ts
5
+ const cache = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Resolve a team key, name, or UUID to a team UUID for the active workspace.
8
+ */
9
+ async function resolveTeamId(client, workspaceName, keyOrIdOrName, retryOpts) {
10
+ if (UUID_RE.test(keyOrIdOrName)) return keyOrIdOrName;
11
+ let entry = cache.get(workspaceName);
12
+ if (!entry) {
13
+ entry = (async () => {
14
+ const conn = await withRateLimitRetry(() => client.teams({ first: 250 }), retryOpts);
15
+ const byKey = /* @__PURE__ */ new Map();
16
+ const byName = /* @__PURE__ */ new Map();
17
+ for (const t of conn.nodes) {
18
+ byKey.set(t.key.toLowerCase(), t.id);
19
+ byName.set(t.name.toLowerCase(), t.id);
20
+ }
21
+ return {
22
+ byKey,
23
+ byName
24
+ };
25
+ })();
26
+ cache.set(workspaceName, entry);
27
+ }
28
+ let resolved;
29
+ try {
30
+ resolved = await entry;
31
+ } catch (e) {
32
+ cache.delete(workspaceName);
33
+ throw e;
34
+ }
35
+ const lower = keyOrIdOrName.toLowerCase();
36
+ const id = TEAM_KEY_RE.test(keyOrIdOrName) ? resolved.byKey.get(lower) ?? resolved.byName.get(lower) : resolved.byName.get(lower) ?? resolved.byKey.get(lower);
37
+ if (!id) throw new LinearAgentError({
38
+ code: "TEAM_NOT_FOUND",
39
+ message: `team not found: ${keyOrIdOrName}`,
40
+ details: {
41
+ workspace: workspaceName,
42
+ requested: keyOrIdOrName,
43
+ availableKeys: [...resolved.byKey.keys()].sort(),
44
+ availableNames: [...resolved.byName.keys()].sort()
45
+ }
46
+ });
47
+ return id;
48
+ }
49
+ //#endregion
50
+ export { resolveTeamId };
@@ -0,0 +1,109 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/core/transport/fetch-interceptor.ts
3
+ /**
4
+ * Fetch interceptor for capturing Linear's complexity-meter response headers
5
+ * (Phase 2 PLAN 02-01, RAT-02).
6
+ *
7
+ * Linear surfaces rate-limit pressure on every successful response via:
8
+ * - `x-complexity` (cost of this query)
9
+ * - `x-ratelimit-complexity-remaining` (remaining budget in window)
10
+ *
11
+ * The SDK does not expose these headers via its public API, so the only
12
+ * lever to surface them in our envelope is patching `globalThis.fetch` for
13
+ * the lifetime of the SDK call. To stay safe under concurrent invocations
14
+ * (parallel workspace add/replace-token, parallel agents), the patched
15
+ * fetch's "last response" state lives in an `AsyncLocalStorage` context —
16
+ * each `withFetchInterception(fn)` call gets its own ALS frame.
17
+ *
18
+ * Snapshot-drift safety (see RESEARCH § Pitfall 8):
19
+ * `getLastComplexity()` returns `undefined` whenever no patched fetch has
20
+ * observed a response — typically because tests mock at the SDK class
21
+ * boundary (vi.mock('@linear/sdk')) and never go through `globalThis.fetch`.
22
+ * Runtimes spread `meta.complexity` only when the value is present:
23
+ * `...(getLastComplexity() && { complexity: getLastComplexity() })`
24
+ * This keeps Phase 1 snapshots byte-identical: the spread is a no-op in
25
+ * mocked tests, so `meta.complexity` is absent from the serialized envelope.
26
+ *
27
+ * Restoration invariant: the original `globalThis.fetch` is restored in a
28
+ * `finally` block. Even if `fn` throws, the global is restored to the
29
+ * captured `original` reference (Test 17). The patch is also nested-safe:
30
+ * each `withFetchInterception` saves the current fetch (which may itself be
31
+ * a previous patch) and restores it on exit.
32
+ */
33
+ const als = new AsyncLocalStorage();
34
+ /**
35
+ * Read the most-recent complexity headers captured inside the current
36
+ * `withFetchInterception(fn)` ALS frame. Returns `undefined` when the
37
+ * interceptor was never engaged (e.g. SDK-mocked unit tests) or no fetch
38
+ * response carried the headers.
39
+ */
40
+ function getLastComplexity() {
41
+ return als.getStore()?.last;
42
+ }
43
+ /**
44
+ * Marker we attach to our patched fetch so nested `withFetchInterception`
45
+ * calls can recognise an existing patch and reuse the *real* fetch
46
+ * underneath rather than chaining writes into the wrong ALS frame.
47
+ *
48
+ * Without this, two parallel `withFetchInterception(fnA), withFetchInterception(fnB)`
49
+ * would chain patches: B's patched fetch → A's patched fetch → real fetch.
50
+ * A's closure-captured `ctx` would receive writes from B's responses,
51
+ * breaking ALS isolation. The marker lets B's patch detect that the current
52
+ * `globalThis.fetch` is already a patch and skip past it to whatever it
53
+ * wraps — so each patch sits between the **real** fetch and exactly the
54
+ * `fn` call tree it was created for.
55
+ */
56
+ const PATCH_MARKER = Symbol.for("linmux.fetch-interceptor.patch");
57
+ const RAW_FETCH_KEY = Symbol.for("linmux.fetch-interceptor.rawFetch");
58
+ function unwrapToRealFetch(f) {
59
+ const cursor = f;
60
+ if (cursor[PATCH_MARKER] === true && cursor[RAW_FETCH_KEY] !== void 0) return cursor[RAW_FETCH_KEY];
61
+ return f;
62
+ }
63
+ /**
64
+ * Run `fn` with `globalThis.fetch` patched so that every response within
65
+ * `fn`'s call tree updates the ALS-scoped "last complexity" record for
66
+ * THIS frame only. The original fetch is restored unconditionally (success
67
+ * or throw).
68
+ *
69
+ * Concurrent invocations are isolated via `AsyncLocalStorage`. The patch
70
+ * itself reads `als.getStore()` on each call — so even if two parallel
71
+ * frames share the same global patch via interleaved scheduling, each
72
+ * fetch's complexity write lands in the ALS frame whose `fn` is currently
73
+ * on the stack (not the closure-captured ctx of whichever patch happens to
74
+ * be the "outermost" wrapper).
75
+ */
76
+ async function withFetchInterception(fn) {
77
+ return als.run({}, async () => {
78
+ const previous = globalThis.fetch;
79
+ const realFetch = unwrapToRealFetch(previous);
80
+ const patched = async (...args) => {
81
+ const res = await realFetch(...args);
82
+ const cost = parseHeaderInt(res.headers.get("x-complexity"));
83
+ const remaining = parseHeaderInt(res.headers.get("x-ratelimit-complexity-remaining"));
84
+ if (cost !== void 0 && remaining !== void 0) {
85
+ const store = als.getStore();
86
+ if (store) store.last = {
87
+ cost,
88
+ remaining
89
+ };
90
+ }
91
+ return res;
92
+ };
93
+ patched[PATCH_MARKER] = true;
94
+ patched[RAW_FETCH_KEY] = realFetch;
95
+ globalThis.fetch = patched;
96
+ try {
97
+ return await fn();
98
+ } finally {
99
+ globalThis.fetch = previous;
100
+ }
101
+ });
102
+ }
103
+ function parseHeaderInt(value) {
104
+ if (value === null) return void 0;
105
+ const n = Number(value);
106
+ return Number.isFinite(n) ? n : void 0;
107
+ }
108
+ //#endregion
109
+ export { getLastComplexity, withFetchInterception };
@@ -0,0 +1,3 @@
1
+ import "./fetch-interceptor.js";
2
+ import "./rate-limit.js";
3
+ export {};
@@ -0,0 +1,167 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ import { AuthenticationLinearError, InvalidInputLinearError, LinearError, NetworkLinearError, RatelimitedLinearError } from "@linear/sdk";
3
+ //#region src/core/transport/rate-limit.ts
4
+ /**
5
+ * Rate-limit-aware transport wrapper (Phase 2 PLAN 02-01, RAT-01 / RAT-03).
6
+ *
7
+ * Every Phase 2 SDK call routes through `withRateLimitRetry`. The wrapper
8
+ * 1. retries on `RatelimitedLinearError` and `NetworkLinearError` with
9
+ * exponential backoff + full jitter (RAT-01 default policy);
10
+ * 2. classifies all other thrown SDK errors into `LinearAgentError`
11
+ * instances via `classifySdkError` (RAT-03) so the kernel envelope
12
+ * shape stays canonical.
13
+ *
14
+ * The classifier discriminates on `@linear/sdk` typed error classes
15
+ * (`RatelimitedLinearError`, `AuthenticationLinearError`, `NetworkLinearError`,
16
+ * `InvalidInputLinearError`, `LinearError`) — NOT regex on `err.message` and
17
+ * NOT `errors[].extensions.code`. Phase 1's two regex-based message-substring
18
+ * classifiers (formerly in issue-list-runtime + workspace-runtime) are
19
+ * retired in Plan 02-01 Task 3; this module is the canonical replacement.
20
+ *
21
+ * Default backoff policy (RAT-01):
22
+ * - Rate-limit: base 250ms doubling (250 → 500 → 1000) with full jitter,
23
+ * OR `err.retryAfter * 1000` capped at `4 × base`. 3 attempts.
24
+ * - Network: base 100ms doubling (100 → 200 → 400) with full jitter.
25
+ * 3 attempts.
26
+ * - Auth/Validation/Other: NO retry — surfaced immediately.
27
+ *
28
+ * Test seams: callers may inject `opts.sleep` and `opts.random` to make
29
+ * timing deterministic in unit tests. The `retryOptsOverride?: RetryOpts`
30
+ * field on per-runtime input interfaces (issue-list-runtime, etc.) is the
31
+ * test-only entry point — production call sites pass nothing.
32
+ */
33
+ const DEFAULTS = {
34
+ maxAttempts: 3,
35
+ rateLimitBaseMs: 250,
36
+ networkBaseMs: 100,
37
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
38
+ random: () => Math.random()
39
+ };
40
+ const RATELIMIT_DEFAULT_RETRY_MS = 3e4;
41
+ function resolveOpts(opts) {
42
+ const resolved = {
43
+ maxAttempts: (opts?.maxAttempts ?? DEFAULTS.maxAttempts) + Math.max(0, opts?.extraAttempts ?? 0),
44
+ rateLimitBaseMs: opts?.rateLimitBaseMs ?? DEFAULTS.rateLimitBaseMs,
45
+ networkBaseMs: opts?.networkBaseMs ?? DEFAULTS.networkBaseMs,
46
+ sleep: opts?.sleep ?? DEFAULTS.sleep,
47
+ random: opts?.random ?? DEFAULTS.random
48
+ };
49
+ if (opts?.onRetry !== void 0) resolved.onRetry = opts.onRetry;
50
+ return resolved;
51
+ }
52
+ /**
53
+ * Wrap a thunk that performs an SDK call so it transparently retries on
54
+ * rate-limit and network errors and classifies any thrown error into a
55
+ * `LinearAgentError` before re-throwing.
56
+ *
57
+ * Successful inner calls return their value unchanged.
58
+ *
59
+ * Errors thrown by the inner call are funneled through `classifySdkError`
60
+ * after the retry loop exhausts (or immediately for non-retryable cases).
61
+ * If the inner call throws a `LinearAgentError` directly, it passes through
62
+ * unchanged (idempotent — no double-wrapping).
63
+ */
64
+ async function withRateLimitRetry(call, opts) {
65
+ const o = resolveOpts(opts);
66
+ if (o.maxAttempts < 1) throw LinearAgentError.usage(`withRateLimitRetry: maxAttempts must be >= 1 (got ${o.maxAttempts})`);
67
+ let attempt = 0;
68
+ let lastErr;
69
+ while (attempt < o.maxAttempts) try {
70
+ return await call();
71
+ } catch (err) {
72
+ lastErr = err;
73
+ if (err instanceof RatelimitedLinearError) {
74
+ if (attempt === o.maxAttempts - 1) break;
75
+ const base = o.rateLimitBaseMs * 2 ** attempt;
76
+ const hint = err.retryAfter !== void 0 ? err.retryAfter * 1e3 : void 0;
77
+ const sleepMs = hint !== void 0 ? Math.max(Math.min(hint, base * 4), base) : base + o.random() * base;
78
+ o.onRetry?.({
79
+ attempt: attempt + 1,
80
+ total: o.maxAttempts,
81
+ code: "RATELIMITED",
82
+ backoffMs: Math.round(sleepMs)
83
+ });
84
+ await o.sleep(sleepMs);
85
+ attempt++;
86
+ continue;
87
+ }
88
+ if (err instanceof NetworkLinearError) {
89
+ if (attempt === o.maxAttempts - 1) break;
90
+ const base = o.networkBaseMs * 2 ** attempt;
91
+ const sleepMs = base + o.random() * base;
92
+ o.onRetry?.({
93
+ attempt: attempt + 1,
94
+ total: o.maxAttempts,
95
+ code: "NETWORK_ERROR",
96
+ backoffMs: Math.round(sleepMs)
97
+ });
98
+ await o.sleep(sleepMs);
99
+ attempt++;
100
+ continue;
101
+ }
102
+ break;
103
+ }
104
+ const classified = classifySdkError(lastErr);
105
+ if (lastErr instanceof LinearAgentError && classified === lastErr) throw classified;
106
+ if (!(opts?.extraAttempts !== void 0 && opts.extraAttempts > 0 || opts?.onRetry !== void 0)) throw classified;
107
+ throw withAttempts(classified, attempt + 1);
108
+ }
109
+ /**
110
+ * Return a `LinearAgentError` byte-identical to `err` except for an added
111
+ * `details.attempts: count` key. Used to tag final-exhaustion attempt
112
+ * counts without mutating `LinearAgentError`'s readonly `details`.
113
+ */
114
+ function withAttempts(err, count) {
115
+ const mergedDetails = {
116
+ ...err.details ?? {},
117
+ attempts: count
118
+ };
119
+ const init = {
120
+ code: err.code,
121
+ message: err.message,
122
+ transient: err.transient,
123
+ details: mergedDetails
124
+ };
125
+ if (err.retryAfterMs !== void 0) init.retryAfterMs = err.retryAfterMs;
126
+ return new LinearAgentError(init);
127
+ }
128
+ /**
129
+ * Classify any thrown SDK error into the canonical `LinearAgentError`
130
+ * taxonomy. Idempotent: a `LinearAgentError` argument passes through
131
+ * unchanged.
132
+ *
133
+ * Discrimination uses `instanceof` on `@linear/sdk` typed error classes —
134
+ * NOT message regex, NOT `errors[].extensions.code`. The SDK already does
135
+ * that work for us in `parseLinearError`.
136
+ */
137
+ function classifySdkError(err) {
138
+ if (err instanceof LinearAgentError) return err;
139
+ if (err instanceof RatelimitedLinearError) {
140
+ const retryAfterMs = err.retryAfter !== void 0 ? err.retryAfter * 1e3 : RATELIMIT_DEFAULT_RETRY_MS;
141
+ const details = {};
142
+ if (err.complexityRemaining !== void 0) details.complexityRemaining = err.complexityRemaining;
143
+ if (err.complexityLimit !== void 0) details.complexityLimit = err.complexityLimit;
144
+ if (err.complexityResetAt !== void 0) details.complexityResetAt = err.complexityResetAt;
145
+ return LinearAgentError.rateLimited(retryAfterMs, Object.keys(details).length > 0 ? details : void 0);
146
+ }
147
+ if (err instanceof NetworkLinearError) return LinearAgentError.network("network error during Linear API call");
148
+ if (err instanceof AuthenticationLinearError) return LinearAgentError.auth.invalid("token rejected by Linear");
149
+ if (err instanceof InvalidInputLinearError) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ return LinearAgentError.validation.failed("Linear rejected the request payload", { cause: msg });
152
+ }
153
+ if (err instanceof LinearError) {
154
+ const msg = err instanceof Error ? err.message : String(err);
155
+ return LinearAgentError.linear.apiError({
156
+ message: "Linear API call failed",
157
+ details: { cause: msg }
158
+ });
159
+ }
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ return LinearAgentError.linear.apiError({
162
+ message: "Linear API call failed",
163
+ details: { cause: msg }
164
+ });
165
+ }
166
+ //#endregion
167
+ export { classifySdkError, withRateLimitRetry };
@@ -0,0 +1,70 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ //#region src/core/workspace/resolver.ts
3
+ /**
4
+ * Pure resolver that translates `(flags, env, config)` into a single
5
+ * `ResolvedWorkspace`. Implements the 6-step precedence chain from
6
+ * `01-CONTEXT.md § Workspace Resolution Precedence`:
7
+ *
8
+ * 1. `flags.workspace` (--workspace flag)
9
+ * 2. `env.LINEAR_WORKSPACE` (LINEAR_WORKSPACE env var)
10
+ * 3. `config.active` (active default in config)
11
+ * 4. single-workspace short-circuit (only one entry in config.workspaces)
12
+ * 5. `env.LINEAR_API_KEY` (env-key bypass — no config consulted)
13
+ * 6. throw `WORKSPACE_NOT_RESOLVED` (no input matched)
14
+ *
15
+ * Steps 1–3 also throw `WORKSPACE_NOT_FOUND` when the requested name is not
16
+ * registered. This is critical for tenancy isolation (PITFALLS § Pitfall 2):
17
+ * `--workspace ghost` MUST fail loudly rather than silently fall through to
18
+ * the active default.
19
+ *
20
+ * Purity guarantees:
21
+ * - No `process.env` reads (caller passes `env`)
22
+ * - No filesystem I/O (caller passes `config`)
23
+ * - No mutation of any input field (read-only walk)
24
+ * - Same input -> deeply-equal output, every time
25
+ */
26
+ function resolveWorkspace(input) {
27
+ const { flags, env, config } = input;
28
+ if (flags.workspace) return loadOrThrow(config, flags.workspace, "flag");
29
+ if (env.LINEAR_WORKSPACE) return loadOrThrow(config, env.LINEAR_WORKSPACE, "env");
30
+ if (config.active) return loadOrThrow(config, config.active, "active");
31
+ const names = Object.keys(config.workspaces);
32
+ if (names.length === 1) {
33
+ const sole = names[0];
34
+ if (sole !== void 0) return loadOrThrow(config, sole, "single");
35
+ }
36
+ if (env.LINEAR_API_KEY) return {
37
+ name: null,
38
+ token: env.LINEAR_API_KEY,
39
+ organizationId: null,
40
+ source: "api-key-env"
41
+ };
42
+ throw new LinearAgentError({
43
+ code: "WORKSPACE_NOT_RESOLVED",
44
+ message: "no workspace selected: pass --workspace <name>, set LINEAR_WORKSPACE, or run `linmux workspace use <name>`",
45
+ details: {
46
+ configuredWorkspaces: names,
47
+ remediation: names.length === 0 ? "run `linmux workspace add <name> --token <api-key>` to register a workspace" : "run `linmux workspace use <name>` to set an active default, or pass --workspace <name>"
48
+ }
49
+ });
50
+ }
51
+ function loadOrThrow(config, name, source) {
52
+ const entry = config.workspaces[name];
53
+ if (!entry) throw new LinearAgentError({
54
+ code: "WORKSPACE_NOT_FOUND",
55
+ message: `workspace not found: ${name}`,
56
+ details: {
57
+ requested: name,
58
+ configured: Object.keys(config.workspaces),
59
+ source
60
+ }
61
+ });
62
+ return {
63
+ name: entry.name,
64
+ token: entry.token,
65
+ organizationId: entry.organizationId,
66
+ source
67
+ };
68
+ }
69
+ //#endregion
70
+ export { resolveWorkspace };
@@ -0,0 +1,43 @@
1
+ import { LinearAgentError } from "../errors/error.js";
2
+ //#region src/core/workspace/write-guard.ts
3
+ /**
4
+ * WSP-06 enforcement for write commands (mutations).
5
+ *
6
+ * A write command requires an *explicit* workspace selector to prevent
7
+ * cross-workspace data leakage (PITFALLS § Pitfall 2 — the #1 tenancy risk).
8
+ * Of the five `WorkspaceSource` values, three are considered explicit:
9
+ *
10
+ * - `flag` — caller passed `--workspace <name>` for THIS invocation
11
+ * - `env` — caller set `LINEAR_WORKSPACE` in the env for THIS invocation
12
+ * - `api-key-env` — `LINEAR_API_KEY` is the selector itself; there is no
13
+ * ambiguity about which workspace the token targets, and
14
+ * setting it is an explicit per-invocation act
15
+ *
16
+ * The other two are NOT explicit:
17
+ *
18
+ * - `active` — silently inherits the user's persisted active default
19
+ * - `single` — auto-picks the only registered workspace (still implicit)
20
+ *
21
+ * For `active` and `single`, this guard throws `WORKSPACE_REQUIRED_FOR_WRITE`
22
+ * BEFORE any SDK call is made — unless the caller passes
23
+ * `allowActiveOptIn=true` (mapped from the per-invocation
24
+ * `--allow-active-workspace-write` flag).
25
+ *
26
+ * The opt-in is per-invocation only. There is no persisted config flag that
27
+ * relaxes this rule globally.
28
+ */
29
+ function requireExplicitWorkspaceForWrite(resolved, allowActiveOptIn) {
30
+ if (allowActiveOptIn) return;
31
+ if (resolved.source === "flag" || resolved.source === "env" || resolved.source === "api-key-env") return;
32
+ throw new LinearAgentError({
33
+ code: "WORKSPACE_REQUIRED_FOR_WRITE",
34
+ message: "write commands require an explicit workspace selector: pass --workspace <name>, set LINEAR_WORKSPACE, or pass --allow-active-workspace-write to opt in to using the active default for this invocation",
35
+ details: {
36
+ resolvedWorkspace: resolved.name,
37
+ resolvedFrom: resolved.source,
38
+ remediation: "pass --workspace <name>, set LINEAR_WORKSPACE=<name>, or pass --allow-active-workspace-write"
39
+ }
40
+ });
41
+ }
42
+ //#endregion
43
+ export { requireExplicitWorkspaceForWrite };