@skitterbyte/skitterspec-linear 10.1.0 → 10.2.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.
@@ -8,6 +8,25 @@
8
8
  * (`init`, `update`, `spec-env`, `--help`, …) delegates to the base CLI unchanged.
9
9
  */
10
10
 
11
+ // This package's bin/, src/ and assets/ are COMPOSED by scripts/build-dist.js and
12
+ // gitignored, not committed — so a checkout linked before a build (or after a
13
+ // `git clean`) has a working binary with nothing behind it. Say that, instead of
14
+ // letting `require` raise MODULE_NOT_FOUND on an internal path the caller has no
15
+ // way to interpret.
16
+ //
17
+ // Inline rather than shared: a helper would have to live in src/, which is
18
+ // exactly what may be missing. In the workspace packages src/ always exists, so
19
+ // this is inert there.
20
+ const { existsSync } = require('node:fs')
21
+ const { join } = require('node:path')
22
+ if (!existsSync(join(__dirname, '..', 'src'))) {
23
+ console.error(
24
+ 'skitterspec-linear: no build output — this package\'s bin/src/assets are composed, not committed.\n' +
25
+ ' run "npm run build" in the skitterspec repo, then try again.',
26
+ )
27
+ process.exit(1)
28
+ }
29
+
11
30
  const { run } = require('../src/cli.js')
12
31
  const { specSync } = require('../src/vendor/linear/cli-sync.js')
13
32
  const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.1.0",
3
+ "version": "10.2.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
@@ -190,7 +190,7 @@ function specEnvUp(dir, config, specArg) {
190
190
  process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
191
191
  return
192
192
  }
193
- const spec = resolveSpec(specArg, dir, config)
193
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
194
194
 
195
195
  // Live-safe: if this spec is already live on the primary checkout (its branch was
196
196
  // branch-switched in by `live take`), a `git worktree add` would fail — the branch
@@ -357,7 +357,7 @@ function specEnvDown(dir, config, specArg, flags) {
357
357
  process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
358
358
  return
359
359
  }
360
- const spec = resolveSpec(specArg, dir, config)
360
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
361
361
 
362
362
  // A worktree-only spec never held a slot but its worktree still needs removing,
363
363
  // so "nothing to do" means neither a slot nor a worktree exists.
@@ -459,6 +459,31 @@ function liveWorktreePaths(dir) {
459
459
  return paths
460
460
  }
461
461
 
462
+ // Resolve a spec argument the ONE way every spec-env subcommand resolves it:
463
+ // against the primary checkout first, then the spec's own worktree, then every
464
+ // other checkout git knows about. An in-progress spec is git-mv'd into
465
+ // specs/in-progress/ **on its own branch**, so it exists only in its worktree —
466
+ // a primary-checkout-only lookup fails for exactly the specs these commands
467
+ // serve. The first fallback is the worktree path the config derives for this
468
+ // spec (cheap, no git); the rest come from `git worktree list`, so a worktree
469
+ // provisioned under an older `worktree.root` still resolves. Identity and
470
+ // coordinate tokens always expand against `dir` (the primary checkout), so the
471
+ // answer is identical whether the command was run from main or a worktree.
472
+ function resolveSpecWithWorktree(dir, config, specArg) {
473
+ const { slug } = splitPrefix(path.basename(specArg))
474
+ const { repo, repoSlug } = repoInfo(dir)
475
+ const wtTokens = { repo, repoSlug, slug }
476
+ const worktreeGuess = path.resolve(
477
+ dir,
478
+ expandTokens(config.worktree.root, wtTokens),
479
+ expandTokens(config.worktree.folderPattern, wtTokens),
480
+ )
481
+ const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
482
+ (p) => p !== dir,
483
+ )
484
+ return resolveSpec(specArg, dir, config, { searchDirs })
485
+ }
486
+
462
487
  // Every spec folder name found under specs/* across the given checkout roots.
463
488
  // An in-progress spec lives on its *worktree branch*, not the primary checkout,
464
489
  // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
@@ -771,7 +796,7 @@ function specEnvResolve(dir, config, specArg) {
771
796
  process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
772
797
  return
773
798
  }
774
- const r = resolveSpec(specArg, dir, config)
799
+ const r = resolveSpecWithWorktree(dir, config, specArg)
775
800
  process.stdout.write(
776
801
  `spec: ${r.folder} (${r.bucket})\n` +
777
802
  `type/slug: ${r.type} / ${r.slug}\n` +
@@ -792,7 +817,7 @@ async function specEnvDev(dir, config, positional) {
792
817
  process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
793
818
  return
794
819
  }
795
- const spec = resolveSpec(specArg, dir, config)
820
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
796
821
  if (!config.dev.length) {
797
822
  process.stdout.write(
798
823
  'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
@@ -888,7 +913,7 @@ async function specEnvConnect(dir, config, specArg) {
888
913
  return
889
914
  }
890
915
 
891
- const spec = resolveSpec(target, dir, config)
916
+ const spec = resolveSpecWithWorktree(dir, config, target)
892
917
  const registry = readRegistry(dir, config)
893
918
  if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
894
919
  process.stdout.write(
@@ -984,20 +1009,6 @@ async function specEnvLive(dir, config, positional) {
984
1009
  }
985
1010
  }
986
1011
 
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
1012
  // Take the running instance: rebase the spec's branch onto base, free it from its
1002
1013
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1003
1014
  async function specEnvLiveTake(dir, config, specArg) {
@@ -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
+ }