@skitterbyte/skitterspec-linear 10.1.0 → 10.3.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.
@@ -4,28 +4,61 @@
4
4
  /**
5
5
  * The Linear-provider distribution's bin — a superset of the base CLI.
6
6
  *
7
- * `spec-sync …` is handled here (the provider engine seam); every other command
8
- * (`init`, `update`, `spec-env`, `--help`, …) delegates to the base CLI unchanged.
7
+ * The provider's own commands are routed from ONE table (`src/commands.js`),
8
+ * which also generates their `--help` section; every other command (`init`,
9
+ * `update`, `spec-env`, …) delegates to the base CLI unchanged.
10
+ *
11
+ * `--help` is the exception that has to be handled here rather than delegated:
12
+ * the base prints its own HELP const, which cannot know what a provider adds, so
13
+ * delegating made this distribution report that `spec-sync` did not exist.
9
14
  */
10
15
 
11
- const { run } = require('../src/cli.js')
12
- const { specSync } = require('../src/vendor/linear/cli-sync.js')
13
- const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
16
+ // This package's bin/, src/ and assets/ are COMPOSED by scripts/build-dist.js and
17
+ // gitignored, not committed so a checkout linked before a build (or after a
18
+ // `git clean`) has a working binary with nothing behind it. Say that, instead of
19
+ // letting `require` raise MODULE_NOT_FOUND on an internal path the caller has no
20
+ // way to interpret.
21
+ //
22
+ // Inline rather than shared: a helper would have to live in src/, which is
23
+ // exactly what may be missing. In the workspace packages src/ always exists, so
24
+ // this is inert there.
25
+ const { existsSync } = require('node:fs')
26
+ const { join } = require('node:path')
27
+ if (!existsSync(join(__dirname, '..', 'src'))) {
28
+ console.error(
29
+ 'skitterspec-linear: no build output — this package\'s bin/src/assets are composed, not committed.\n' +
30
+ ' run "npm run build" in the skitterspec repo, then try again.',
31
+ )
32
+ process.exit(1)
33
+ }
34
+
35
+ const { run, HELP } = require('../src/cli.js')
36
+ const {
37
+ PROVIDER_COMMANDS,
38
+ providerHelpSection,
39
+ } = require('../src/vendor/linear/commands.js')
14
40
 
15
41
  async function main(argv) {
16
42
  const [cmd, ...rest] = argv
17
- if (cmd === 'spec-sync') {
18
- // Propagate the exit code, like spec-sanitise below. Dropping it made
19
- // `status --workspace-states` (a bad state name) and `stamp` (a refused
20
- // write) both look successful to any caller checking $?, which is exactly
21
- // what the /spec-push skill does before it applies a plan.
22
- process.exitCode = await specSync(rest)
43
+
44
+ // Base help + what this distribution adds. Matched on the COMMAND SLOT only,
45
+ // never the whole argv: `spec-sanitise --help` must reach that command's own
46
+ // help, not be swallowed by the top-level one.
47
+ if (!cmd || cmd === '--help' || cmd === '-h') {
48
+ process.stdout.write(`${HELP}\n${providerHelpSection()}`)
23
49
  return
24
50
  }
25
- if (cmd === 'spec-sanitise') {
26
- process.exitCode = await specSanitise(rest)
51
+
52
+ const provider = PROVIDER_COMMANDS[cmd]
53
+ if (provider) {
54
+ // Propagate the exit code. Dropping it made `spec-sync status
55
+ // --workspace-states` (a bad state name) and `stamp` (a refused write) both
56
+ // look successful to any caller checking $?, which is exactly what the
57
+ // /spec-push skill does before it applies a plan.
58
+ process.exitCode = await provider.run(rest)
27
59
  return
28
60
  }
61
+
29
62
  await run(argv)
30
63
  }
31
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.1.0",
3
+ "version": "10.3.0",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -48,6 +48,29 @@ const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
48
48
 
49
49
  const pkg = require('../package.json')
50
50
 
51
+ // Commands this (tracker-free) base does NOT ship, and the distribution that
52
+ // does. Without this the base says only "unknown command: spec-sync", which a
53
+ // user correctly reads as "no such feature" — nothing anywhere named the
54
+ // distribution that has it, so they were stranded. Naming Linear here is a
55
+ // diagnostic string, not provider machinery: `init.js` already knows
56
+ // `linear.config.json` and `linear-base/` by name in order to protect them.
57
+ const PROVIDER_COMMANDS = {
58
+ 'spec-sync': '@skitterbyte/skitterspec-linear',
59
+ 'spec-sanitise': '@skitterbyte/skitterspec-linear',
60
+ }
61
+
62
+ function unknownCommand(cmd) {
63
+ const dist = PROVIDER_COMMANDS[cmd]
64
+ if (dist) {
65
+ return (
66
+ `unknown command: ${cmd} — this is the base distribution, which does not ` +
67
+ `ship it.\n ${cmd} comes from ${dist} (a superset of this package): ` +
68
+ `install that instead.`
69
+ )
70
+ }
71
+ return `unknown command: ${cmd} (try --help)`
72
+ }
73
+
51
74
  const HELP = `skitterspec — spec-driven-development for Claude Code
52
75
 
53
76
  Usage:
@@ -190,7 +213,7 @@ function specEnvUp(dir, config, specArg) {
190
213
  process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
191
214
  return
192
215
  }
193
- const spec = resolveSpec(specArg, dir, config)
216
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
194
217
 
195
218
  // Live-safe: if this spec is already live on the primary checkout (its branch was
196
219
  // branch-switched in by `live take`), a `git worktree add` would fail — the branch
@@ -357,7 +380,7 @@ function specEnvDown(dir, config, specArg, flags) {
357
380
  process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
358
381
  return
359
382
  }
360
- const spec = resolveSpec(specArg, dir, config)
383
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
361
384
 
362
385
  // A worktree-only spec never held a slot but its worktree still needs removing,
363
386
  // so "nothing to do" means neither a slot nor a worktree exists.
@@ -459,6 +482,31 @@ function liveWorktreePaths(dir) {
459
482
  return paths
460
483
  }
461
484
 
485
+ // Resolve a spec argument the ONE way every spec-env subcommand resolves it:
486
+ // against the primary checkout first, then the spec's own worktree, then every
487
+ // other checkout git knows about. An in-progress spec is git-mv'd into
488
+ // specs/in-progress/ **on its own branch**, so it exists only in its worktree —
489
+ // a primary-checkout-only lookup fails for exactly the specs these commands
490
+ // serve. The first fallback is the worktree path the config derives for this
491
+ // spec (cheap, no git); the rest come from `git worktree list`, so a worktree
492
+ // provisioned under an older `worktree.root` still resolves. Identity and
493
+ // coordinate tokens always expand against `dir` (the primary checkout), so the
494
+ // answer is identical whether the command was run from main or a worktree.
495
+ function resolveSpecWithWorktree(dir, config, specArg) {
496
+ const { slug } = splitPrefix(path.basename(specArg))
497
+ const { repo, repoSlug } = repoInfo(dir)
498
+ const wtTokens = { repo, repoSlug, slug }
499
+ const worktreeGuess = path.resolve(
500
+ dir,
501
+ expandTokens(config.worktree.root, wtTokens),
502
+ expandTokens(config.worktree.folderPattern, wtTokens),
503
+ )
504
+ const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
505
+ (p) => p !== dir,
506
+ )
507
+ return resolveSpec(specArg, dir, config, { searchDirs })
508
+ }
509
+
462
510
  // Every spec folder name found under specs/* across the given checkout roots.
463
511
  // An in-progress spec lives on its *worktree branch*, not the primary checkout,
464
512
  // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
@@ -771,7 +819,7 @@ function specEnvResolve(dir, config, specArg) {
771
819
  process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
772
820
  return
773
821
  }
774
- const r = resolveSpec(specArg, dir, config)
822
+ const r = resolveSpecWithWorktree(dir, config, specArg)
775
823
  process.stdout.write(
776
824
  `spec: ${r.folder} (${r.bucket})\n` +
777
825
  `type/slug: ${r.type} / ${r.slug}\n` +
@@ -792,7 +840,7 @@ async function specEnvDev(dir, config, positional) {
792
840
  process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
793
841
  return
794
842
  }
795
- const spec = resolveSpec(specArg, dir, config)
843
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
796
844
  if (!config.dev.length) {
797
845
  process.stdout.write(
798
846
  'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
@@ -888,7 +936,7 @@ async function specEnvConnect(dir, config, specArg) {
888
936
  return
889
937
  }
890
938
 
891
- const spec = resolveSpec(target, dir, config)
939
+ const spec = resolveSpecWithWorktree(dir, config, target)
892
940
  const registry = readRegistry(dir, config)
893
941
  if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
894
942
  process.stdout.write(
@@ -984,20 +1032,6 @@ async function specEnvLive(dir, config, positional) {
984
1032
  }
985
1033
  }
986
1034
 
987
- // Resolve a spec, offering its worktree as a fallback search dir — a spec authored
988
- // on its own branch may not exist in the primary checkout's specs/**.
989
- function resolveSpecWithWorktree(dir, config, specArg) {
990
- const { slug } = splitPrefix(path.basename(specArg))
991
- const { repo, repoSlug } = repoInfo(dir)
992
- const wtTokens = { repo, repoSlug, slug }
993
- const worktreeGuess = path.resolve(
994
- dir,
995
- expandTokens(config.worktree.root, wtTokens),
996
- expandTokens(config.worktree.folderPattern, wtTokens),
997
- )
998
- return resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
999
- }
1000
-
1001
1035
  // Take the running instance: rebase the spec's branch onto base, free it from its
1002
1036
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1003
1037
  async function specEnvLiveTake(dir, config, specArg) {
@@ -1373,8 +1407,8 @@ async function run(argv) {
1373
1407
  await cleanupReleaseTooling(dir, opts)
1374
1408
  break
1375
1409
  default:
1376
- throw new Error(`unknown command: ${cmd} (try --help)`)
1410
+ throw new Error(unknownCommand(cmd))
1377
1411
  }
1378
1412
  }
1379
1413
 
1380
- module.exports = { run, parse }
1414
+ module.exports = { run, parse, HELP, unknownCommand }
@@ -217,9 +217,14 @@ function assertPrimaryOnMain(config, git) {
217
217
  * (the primary checkout), so a worktree-only spec resolves to the right base.
218
218
  */
219
219
  function resolveSpec(specArg, dir, config, opts = {}) {
220
- const found = findSpecFolder(specArg, dir, opts.searchDirs || [])
220
+ const searchDirs = opts.searchDirs || []
221
+ const found = findSpecFolder(specArg, dir, searchDirs)
221
222
  if (!found) {
222
- throw new Error(`spec not found under specs/**: ${specArg}`)
223
+ // Name the roots we looked under: the usual cause is a spec that only exists
224
+ // on its own branch, and the message should say where we didn't find it.
225
+ throw new Error(
226
+ `spec not found under specs/**: ${specArg} (searched: ${[dir, ...searchDirs].join(', ')})`,
227
+ )
223
228
  }
224
229
 
225
230
  const { type, slug } = splitPrefix(found.folder)
@@ -33,7 +33,10 @@ function planDown(spec, config, flags, ctx) {
33
33
  // A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
34
34
  // base — but once its head is captured by a tag (the deploy tag from
35
35
  // `hotfix land`), the commits are recoverable and the branch is safe to drop.
36
- // Treat "reachable from a tag" as landed, alongside merged.
36
+ // Treat "reachable from a tag" as landed, alongside merged. Read twice below:
37
+ // it decides whether the unpushed guard blocks, and whether the branch delete
38
+ // can use `-D` — the same question ("are these commits recoverable?"), so the
39
+ // two must never answer it differently.
37
40
  const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
38
41
 
39
42
  // --- guards (overridable with --force) ---
@@ -88,15 +91,26 @@ function planDown(spec, config, flags, ctx) {
88
91
  )
89
92
 
90
93
  // --- delete the branch ---
91
- // Runs after the worktree remove frees the branch. Normally `-d` (safe: refuses
92
- // an unmerged branch, never -D). A tag-landed hotfix branch is intentionally NOT
93
- // an ancestor of base, so `-d` would refuse it — but its commits are captured by
94
- // the deploy tag, so `-D` is safe *only* in that case. Everything else stays `-d`;
95
- // on a forced teardown of a genuinely unmerged branch it fails loudly and the
96
- // skill relays it rather than -D-ing.
94
+ // Runs after the worktree remove frees the branch. `-D` exactly when we have
95
+ // PROVEN the commits survive the delete, `-d` otherwise.
96
+ //
97
+ // `-d` looks like the safe default and mostly is, but its refusal answers a
98
+ // different question from ours: it also declines a branch that is ahead of its
99
+ // upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
100
+ // even though it is merged to HEAD`. That fires on the ordinary spec flow —
101
+ // `/spec-go` pushes the branch when it provisions, and the phase commits after
102
+ // it are landed locally rather than pushed — so teardown meets a branch whose
103
+ // every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
104
+ // of base) already establishes what we actually care about, and establishes it
105
+ // more strongly than `-d` checks.
106
+ //
107
+ // `reachableFromTag` is the same argument for a hotfix: never an ancestor of
108
+ // base, but its head is captured by the deploy tag.
109
+ //
110
+ // Everything else keeps `-d`, so a forced teardown of a genuinely unlanded
111
+ // branch fails loudly and the skill relays it rather than -D-ing.
97
112
  if (spec.branch) {
98
- const tagLanded = Boolean(worktreeState.reachableFromTag && !worktreeState.merged)
99
- commands.push(`git branch ${tagLanded ? '-D' : '-d'} ${spec.branch}`)
113
+ commands.push(`git branch ${landed ? '-D' : '-d'} ${spec.branch}`)
100
114
  }
101
115
 
102
116
  return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
package/src/init.js CHANGED
@@ -558,12 +558,22 @@ function printReport(dir, mode, { diff = false } = {}) {
558
558
  ' at /spec-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
559
559
  : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
560
560
  ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
561
+ // A provider superset ships its own `spec-<provider>-setup` skill; the base
562
+ // ships none. Discovering it from what was actually installed keeps this file
563
+ // tracker-free — it never has to know which tracker (if any) is in the box.
564
+ const setupSkill = SKILLS.find((s) => /^spec-.+-setup$/.test(s))
565
+ const trackerNote = setupSkill
566
+ ? `Tracker sync is opt-in: run /${setupSkill} to configure it` +
567
+ ' (it discovers your workspace and writes the config), or see' +
568
+ ' specs/.core/SETUP.md.\n'
569
+ : ''
561
570
  process.stdout.write(
562
571
  '\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
563
572
  ' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
564
573
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
565
574
  " project's stack, then run /spec.\n" +
566
- isolationNote,
575
+ isolationNote +
576
+ trackerNote,
567
577
  )
568
578
  }
569
579
 
@@ -0,0 +1,246 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The Linear GraphQL boundary — the one place that knows concrete Linear API
5
+ * shapes, exactly as `mcp.js` is the one place that knows concrete tool names.
6
+ *
7
+ * Both fulfil the SAME typed operation contract (`makeAdapter`'s return), so
8
+ * `spec-sync apply` is written once against an adapter and never learns which
9
+ * transport it got. That is the whole point: the MCP path stays supported, and
10
+ * the API path exists because routing every issue description through the model
11
+ * as generated tokens makes push throughput a function of decode speed rather
12
+ * than of Linear's API.
13
+ *
14
+ * Auth: a personal API key goes in `Authorization` **raw, with no `Bearer`
15
+ * prefix** — verified against Linear's own docs (https://linear.app/developers/graphql),
16
+ * which is the opposite of the OAuth access-token convention. Sending
17
+ * `Bearer lin_api_…` fails authentication.
18
+ *
19
+ * `fetch` is injected so tests exercise every operation offline and
20
+ * deterministically; production passes the global.
21
+ */
22
+
23
+ const { DEFAULT_KEY_ENV } = require('./config.js')
24
+
25
+ const ENDPOINT = 'https://api.linear.app/graphql'
26
+
27
+ // Rate-limit handling. Enough retries to ride out a burst without turning a
28
+ // genuinely stuck run into an unbounded one.
29
+ const MAX_RETRIES = 5
30
+ const MAX_BACKOFF_MS = 60_000
31
+
32
+ /**
33
+ * Resolve the personal API key from the environment.
34
+ *
35
+ * Returns `{ ok: true, key, envVar }`, or `{ ok: false, envVar, error }` when
36
+ * unset — a value the caller branches on rather than an exception, because "no
37
+ * key" is a normal state that means "use MCP", not a failure.
38
+ *
39
+ * The key is never part of the returned error, and callers must keep it out of
40
+ * logs, plans, snapshots and stamped frontmatter.
41
+ */
42
+ function resolveApiKey(config, env = process.env) {
43
+ const envVar = (config && config.auth && config.auth.keyEnv) || DEFAULT_KEY_ENV
44
+ const key = env[envVar]
45
+ if (typeof key !== 'string' || !key.trim()) {
46
+ return {
47
+ ok: false,
48
+ envVar,
49
+ error: `no Linear API key — set ${envVar}, or apply the plan over MCP with --via mcp`,
50
+ }
51
+ }
52
+ return { ok: true, key: key.trim(), envVar }
53
+ }
54
+
55
+ // Fields we read back on every write. `identifier` and `url` are what the skill
56
+ // stamps into the spec; `description` is what `spec-sync verify` compares.
57
+ const ISSUE_FIELDS = 'id identifier url title description state { id name }'
58
+
59
+ /**
60
+ * A GraphQL caller bound to one key. Throws a clear Error on transport failure,
61
+ * on an HTTP error, and on a GraphQL `errors` payload — an `apply` that half
62
+ * succeeds must be loud, never silent.
63
+ */
64
+ function makeClient({ apiKey, fetch: fetchImpl, endpoint = ENDPOINT, sleep, maxRetries = MAX_RETRIES }) {
65
+ const doFetch = fetchImpl || globalThis.fetch
66
+ if (typeof doFetch !== 'function') {
67
+ throw new Error('no fetch available — Node 18+ is required for the API transport')
68
+ }
69
+ // Injected so the retry path is testable without real delays.
70
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
71
+
72
+ return async function call(query, variables) {
73
+ for (let attempt = 0; ; attempt++) {
74
+ let res
75
+ try {
76
+ res = await doFetch(endpoint, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ // Raw, no `Bearer` — see the module comment.
81
+ Authorization: apiKey,
82
+ },
83
+ body: JSON.stringify({ query, variables }),
84
+ })
85
+ } catch (cause) {
86
+ throw new Error(`Linear API unreachable: ${cause && cause.message ? cause.message : cause}`)
87
+ }
88
+ if (res.status === 401 || res.status === 403) {
89
+ throw new Error(
90
+ `Linear rejected the API key (HTTP ${res.status}) — check the value of your key environment variable`,
91
+ )
92
+ }
93
+ // Rate limited. A bulk backfill is exactly the workload that hits this, and
94
+ // failing the run would strand a half-mirrored bucket — so wait and retry
95
+ // rather than surfacing it. Linear says how long to wait; honour that over
96
+ // guessing, and fall back to exponential backoff when it doesn't.
97
+ if (res.status === 429 && attempt < maxRetries) {
98
+ await wait(retryDelay(res, attempt))
99
+ continue
100
+ }
101
+ if (res.status === 429) {
102
+ throw new Error(`Linear rate-limited this request and did not recover after ${maxRetries} retries`)
103
+ }
104
+ if (!res.ok) throw new Error(`Linear API returned HTTP ${res.status}`)
105
+ const body = await res.json()
106
+ if (body && Array.isArray(body.errors) && body.errors.length) {
107
+ throw new Error(`Linear API error: ${body.errors.map((e) => e.message).join('; ')}`)
108
+ }
109
+ return body && body.data
110
+ }
111
+ }
112
+ }
113
+
114
+ // How long to wait before retrying a 429: the server's `Retry-After` (seconds)
115
+ // when it sends one, else exponential backoff from 1s.
116
+ function retryDelay(res, attempt) {
117
+ const header = res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null
118
+ const seconds = header != null ? Number(header) : NaN
119
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, MAX_BACKOFF_MS)
120
+ return Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS)
121
+ }
122
+
123
+ /**
124
+ * Wrap a GraphQL client into the typed ops the engine uses — the same set
125
+ * `mcp.js`'s `makeAdapter` returns, so the two are interchangeable.
126
+ */
127
+ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries } = {}) {
128
+ const call = makeClient({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries })
129
+
130
+ // Issue mutations return the created/updated issue under a payload wrapper.
131
+ const unwrap = (data, field) => (data && data[field] && data[field].issue) || null
132
+
133
+ return {
134
+ async readIssue(id) {
135
+ const data = await call(`query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id })
136
+ return (data && data.issue) || null
137
+ },
138
+ async createIssue(issue) {
139
+ const data = await call(
140
+ `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`,
141
+ { input: issue },
142
+ )
143
+ return unwrap(data, 'issueCreate')
144
+ },
145
+ async updateIssue(id, updates) {
146
+ const data = await call(
147
+ `mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`,
148
+ { id, input: updates },
149
+ )
150
+ return unwrap(data, 'issueUpdate')
151
+ },
152
+ // A phase sub-issue is an ordinary issue carrying `parentId`. Same mutation,
153
+ // so there is no separate sub-issue surface to keep in step.
154
+ async createSubIssue(parentId, subIssue) {
155
+ return this.createIssue({ ...subIssue, parentId })
156
+ },
157
+ async updateSubIssue(id, updates) {
158
+ return this.updateIssue(id, updates)
159
+ },
160
+ async listSubIssues(parentId) {
161
+ const data = await call(
162
+ `query($id: String!) { issue(id: $id) { children { nodes { ${ISSUE_FIELDS} } } } }`,
163
+ { id: parentId },
164
+ )
165
+ return (data && data.issue && data.issue.children && data.issue.children.nodes) || []
166
+ },
167
+ // The intake inbox. A free-text term uses Linear's `searchIssues` query; a
168
+ // label/team filter alone uses `issues`. Two queries rather than one because
169
+ // Linear exposes text search as its own root field, not an `IssueFilter` key.
170
+ async searchIssues({ label, query, teamId } = {}) {
171
+ const filter = {}
172
+ if (label) filter.labels = { name: { eq: label } }
173
+ if (teamId) filter.team = { id: { eq: teamId } }
174
+ if (query) {
175
+ const data = await call(
176
+ `query($term: String!, $filter: IssueFilter) {
177
+ searchIssues(term: $term, filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`,
178
+ { term: query, filter },
179
+ )
180
+ return (data && data.searchIssues && data.searchIssues.nodes) || []
181
+ }
182
+ const data = await call(
183
+ `query($filter: IssueFilter) { issues(filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`,
184
+ { filter },
185
+ )
186
+ return (data && data.issues && data.issues.nodes) || []
187
+ },
188
+ async listProjects(teamId) {
189
+ const data = teamId
190
+ ? await call(`query($id: String!) { team(id: $id) { projects { nodes { id name } } } }`, { id: teamId })
191
+ : await call('query { projects { nodes { id name } } }')
192
+ if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
193
+ return (data && data.projects && data.projects.nodes) || []
194
+ },
195
+ // The workspace's issue workflow states, in the shape `--workspace-states`
196
+ // already accepts, so the existing state check is reused rather than forked.
197
+ async listIssueStates(teamId) {
198
+ const data = teamId
199
+ ? await call(`query($id: String!) { team(id: $id) { states { nodes { id name type } } } }`, { id: teamId })
200
+ : await call('query { workflowStates { nodes { id name type } } }')
201
+ if (data && data.team) return (data.team.states && data.team.states.nodes) || []
202
+ return (data && data.workflowStates && data.workflowStates.nodes) || []
203
+ },
204
+ }
205
+ }
206
+
207
+ /**
208
+ * The workspace's state NAMES, for `validateStates` — the same array
209
+ * `--workspace-states` carries on the MCP path. On the API path nobody has to
210
+ * fetch it by hand.
211
+ */
212
+ async function fetchWorkspaceStates(adapter, teamId) {
213
+ const states = await adapter.listIssueStates(teamId)
214
+ return states.map((s) => s && s.name).filter((n) => typeof n === 'string' && n)
215
+ }
216
+
217
+ /**
218
+ * Map a local lifecycle bucket to a Linear state ID.
219
+ *
220
+ * MCP accepted a state NAME; the GraphQL API needs an id, so this is the one
221
+ * extra hop the API transport adds. Resolved against the states actually fetched
222
+ * from the workspace, so a `config.states` value the workspace lacks fails here
223
+ * with the same vocabulary the state check already uses.
224
+ */
225
+ function stateIdFor(bucket, config, states) {
226
+ const name = config && config.states && config.states[bucket]
227
+ if (!name) return null
228
+ const hit = states.find((s) => s && s.name === name)
229
+ if (!hit) {
230
+ throw new Error(
231
+ `no Linear state named ${JSON.stringify(name)} for bucket ${JSON.stringify(bucket)} — ` +
232
+ `workspace has: ${states.map((s) => s.name).join(', ')}`,
233
+ )
234
+ }
235
+ return hit.id
236
+ }
237
+
238
+ module.exports = {
239
+ ENDPOINT,
240
+ MAX_RETRIES,
241
+ resolveApiKey,
242
+ makeClient,
243
+ makeApiAdapter,
244
+ fetchWorkspaceStates,
245
+ stateIdFor,
246
+ }