@skitterbyte/skitterspec-linear 10.2.0 → 10.4.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.
@@ -189,6 +189,59 @@ instead:
189
189
 
190
190
  ```bash
191
191
  export LINEAR_API_KEY=lin_api_… # from Linear → Settings → Security & access
192
+
193
+ Or store it once, for every repo on the machine, instead of exporting it in each:
194
+
195
+ ```
196
+ skitterspec spec-sync credentials set # prompts; input is hidden
197
+ skitterspec spec-sync credentials status # readiness only, never the value
198
+ skitterspec spec-sync credentials unset # remove this team's key
199
+ ```
200
+
201
+ That writes `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else
202
+ `~/.config/…`) at mode `600`, keyed by team id. Resolution order is **environment
203
+ variable first, then the store**, so CI is unaffected. A store readable by other
204
+ users is refused with the `chmod` to run rather than used.
205
+
206
+ `set` reads the key from a hidden prompt, or from a pipe with `--stdin`. There is
207
+ deliberately **no `--key <value>` flag**: a secret in the command line is visible
208
+ in shell history and to `ps`. For the same reason, run `set` yourself — never
209
+ paste a key into an assistant conversation, where it would enter the transcript.
210
+ `status` exists so an assistant can confirm readiness without ever seeing the
211
+ key.
212
+
213
+ ### Delegating to a password manager
214
+
215
+ Rather than storing the key at all, record a **command** that prints it. A
216
+ command is not a secret, so unlike `--key` it is safe as an argument:
217
+
218
+ ```
219
+ # 1Password CLI
220
+ skitterspec spec-sync credentials set --command 'op read op://Private/linear/token'
221
+
222
+ # pass
223
+ skitterspec spec-sync credentials set --command 'pass show linear/api-key'
224
+
225
+ # macOS Keychain (add once: security add-generic-password -s skitterspec -a linear -w)
226
+ skitterspec spec-sync credentials set --command 'security find-generic-password -w -s skitterspec -a linear'
227
+ ```
228
+
229
+ The command runs on each resolution; its stdout, trimmed, is the key. A non-zero
230
+ exit or empty output means "no key", which simply falls back to MCP — with the
231
+ reason shown by `credentials status`, so a broken command is never silently
232
+ inert. It has 60 seconds to complete, enough for a biometric or master-password
233
+ prompt. Recording a command replaces any stored key for that team, so the
234
+ command actually runs.
235
+
236
+ A command with a key written into it (`--command 'echo lin_api_…'`) is
237
+ **refused**: commands are displayed by `status` and stored in clear, so that is
238
+ strictly worse than storing the key. Use `credentials set` for a key.
239
+
240
+ > **`keyCommand` is honoured only from the user-level store — never from
241
+ > `specs/.core/linear.config.json`.** That file is committed and travels with the
242
+ > repo, so a command named there would run on the machine of anyone who cloned it
243
+ > and ran `spec-sync`. If one is found there it is ignored, and `credentials
244
+ > status` says so.
192
245
  ```
193
246
 
194
247
  ```json
@@ -166,7 +166,27 @@ Confirm the file written and what it says, then name the next step:
166
166
  - `/spec-status` — read-only drift report, the safe way to prove the link works.
167
167
  - `/spec-push` — send a spec up.
168
168
 
169
+ Then **check the API key, but never ask for it.** Run:
170
+
171
+ ```
172
+ skitterspec spec-sync credentials status
173
+ ```
174
+
175
+ It reports whether a key is set and where from, and never prints the value. If
176
+ it says `key: not set`, tell the user to run this **themselves, in their own
177
+ terminal**:
178
+
179
+ ```
180
+ skitterspec spec-sync credentials set
181
+ ```
182
+
183
+ **Do not ask the user to paste an API key into this conversation, and do not
184
+ offer to store one for them.** A key pasted into a chat enters the transcript,
185
+ is sent to the model, and may be logged — moving where a key is stored is
186
+ pointless if it travels through the conversation to get there. Your job is to
187
+ confirm the setup is ready and name the command; the value is theirs alone. The
188
+ key is optional: without one, pushes go over MCP.
189
+
169
190
  Mention what setup did **not** configure, so the defaults aren't mistaken for
170
- decisions: phase mapping (`mapping.phases` — sub-issue per phase by default),
171
- field ownership, and the API key (`LINEAR_API_KEY`, which makes pushes take the
172
- fast path and never lives in the config). Point at `linear.config.md` for those.
191
+ decisions: phase mapping (`mapping.phases` — sub-issue per phase by default) and
192
+ field ownership. Point at `linear.config.md` for those.
@@ -0,0 +1,177 @@
1
+ ---
2
+ name: spec-sync
3
+ description: Run the repo-wide spec-sync operations against Linear — what's linked, the workspace's states and projects, read-back verification, manual stamping, and bulk `apply --all <bucket>`. Run it bare for the repo-wide overview. Wraps the `spec-sync` CLI, which is a local devDependency and never on PATH, so this skill always states the full invocation. Defers to /spec-push and /spec-status for per-spec work. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-sync", "run spec-sync", "what's linked to Linear", "which states does the workspace have", or "mirror the whole backlog".
4
+ ---
5
+
6
+ # /spec-sync — the repo-wide Linear sync operations
7
+
8
+ `/spec-push` and `/spec-status` cover **one spec**. This skill covers everything
9
+ **repo-wide**, and is the answer to a bare "run spec-sync" — which is not itself
10
+ a command.
11
+
12
+ **Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
13
+ tell the user how to enable Linear sync and stop.
14
+
15
+ ## The invocation — always state it
16
+
17
+ `spec-sync` is a subcommand of a binary that is a **local devDependency**, so it
18
+ is never on `PATH`. Typing `spec-sync` gives `command not found`, which reads as
19
+ a broken install rather than a wrong invocation. Always run it — and always show
20
+ the user you ran it — in full:
21
+
22
+ ```
23
+ pnpm exec skitterspec-linear spec-sync <subcommand> [args]
24
+ ```
25
+
26
+ In a project that installs the Linear superset the binary is
27
+ `skitterspec-linear` (it also answers to `skitterspec`). Never print a bare
28
+ `spec-sync …` as if the user could type it.
29
+
30
+ ## 1. Route the ask
31
+
32
+ | The user asks | Run |
33
+ |---------------|-----|
34
+ | bare `/spec-sync`, "what's linked?", "how much is mirrored?" | `linked` |
35
+ | "what states / projects does the workspace have?" | `states`, `projects` |
36
+ | "did the mirror survive the push?" | `verify <spec> --stored <file>` |
37
+ | "link this spec to KEY-1 by hand" | `stamp <spec> --issue KEY-1` |
38
+ | "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
39
+ | "is the team key stale?", "did Linear get renamed?" | `doctor` |
40
+ | push one spec, or "what would push?" | **defer** — see below |
41
+
42
+ **With no argument, run `linked`.** It is the repo-wide overview, it is
43
+ read-only, and it is what a bare "run spec-sync" means.
44
+
45
+ **When the ask is genuinely ambiguous between a read and a write — ask.**
46
+ "Sync the backlog" could mean `linked` (show me) or `apply --all backlog`
47
+ (write dozens of issues). Never resolve that guess in favour of the write.
48
+
49
+ ## 2. Defer, don't duplicate
50
+
51
+ Two front doors to one write path is worse than none:
52
+
53
+ - **Push one spec** → `/spec-push`. It owns the transport choice, the
54
+ workspace-state gate, the plan, the apply, the stamping and the snapshot.
55
+ - **Per-spec drift** ("is this spec in sync?", "what would push?") →
56
+ `/spec-status`.
57
+
58
+ Say which skill you're handing to and stop; don't reimplement either.
59
+
60
+ ## 3. The read-only subcommands
61
+
62
+ ```
63
+ pnpm exec skitterspec-linear spec-sync linked [--json]
64
+ pnpm exec skitterspec-linear spec-sync states [--via api|mcp] [--json]
65
+ pnpm exec skitterspec-linear spec-sync projects [--via api|mcp] [--json]
66
+ ```
67
+
68
+ - **`linked`** is offline: which specs carry a `linear_identifier`, their bucket,
69
+ and an `N/M linked` total. Relay it, and when specs in `in-progress` have no
70
+ issue, say so — those are the ones a push would mint.
71
+ - **`states` / `projects`** reach the workspace. `states` also reports the
72
+ **transport** (`api` when a key is set, else `mcp`), which is the honest way to
73
+ answer "how is this talking to Linear?".
74
+
75
+ ## 4. `verify` — check what the tracker actually stored
76
+
77
+ ```
78
+ pnpm exec skitterspec-linear spec-sync verify <spec> --stored <file>
79
+ ```
80
+
81
+ `--stored` is **what Linear currently holds**, read back over MCP:
82
+
83
+ ```json
84
+ { "issue": "…", "subIssues": { "01-engine": "…" } }
85
+ ```
86
+
87
+ **It is not a `linear-base/*.base.json` snapshot.** That file holds content
88
+ *hashes* keyed by identifier; comparing one against a description reports
89
+ enormous losses on a perfectly intact mirror. The engine refuses a snapshot
90
+ outright — if you see that refusal, you passed the wrong file, not found a bug.
91
+
92
+ Warns, never fails. The repo is the source of truth; a mangled mirror is fixed
93
+ by pushing again.
94
+
95
+ ## 5. `stamp` — the manual escape hatch
96
+
97
+ ```
98
+ pnpm exec skitterspec-linear spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]
99
+ ```
100
+
101
+ Writes the ids into the spec's frontmatter without touching Linear. For adopting
102
+ an issue created by hand, or repairing a stamp — not the normal path, which is
103
+ `/spec-push`. A wrong id here makes the next push see an **unlinked** spec and
104
+ mint a duplicate, so read the values back to the user before writing.
105
+
106
+ ## 6. `apply --all <bucket>` — bulk, and the one with real blast radius
107
+
108
+ ```
109
+ pnpm exec skitterspec-linear spec-sync apply --all <bucket> [--via api|mcp] [--json]
110
+ ```
111
+
112
+ **Confirm before running, and state creates and updates separately.** They are
113
+ not the same risk:
114
+
115
+ - an **update** refreshes a mirror that already exists;
116
+ - a **create** mints new issues and sub-issues in someone's shared tracker.
117
+
118
+ A repo adopting Linear reports "N to create" for every unlinked spec, so an
119
+ unconsidered `--all` can mint dozens of sub-issues. Get the counts first — run
120
+ `linked` to see what is unlinked, or `spec-sync push <spec>` per spec for exact
121
+ numbers — then show the user something like:
122
+
123
+ ```
124
+ apply --all backlog would:
125
+ create 12 issue(s) + 34 sub-issue(s) ← new objects in Linear
126
+ update 3 issue(s) + 5 sub-issue(s) ← refresh existing mirrors
127
+ ```
128
+
129
+ Only run it on an explicit yes. It is resumable: every id is stamped the moment
130
+ its object exists, so an interrupted run continues rather than duplicating.
131
+
132
+ `--all` refuses over MCP by design — bulk goes through the API path.
133
+
134
+ ## 7. `doctor` — identifier drift after a team rename
135
+
136
+ ```
137
+ pnpm exec skitterspec-linear spec-sync doctor [--json]
138
+ ```
139
+
140
+ Renaming a Linear team changes every issue's identifier prefix and **nothing in
141
+ the repo moves**: the stamps, the config `teamKey`, and the snapshot filenames
142
+ and their sub-issue keys all keep the old prefix. `doctor` reports that. It is
143
+ read-only and needs the **API transport** — it reads one issue per drifted ref,
144
+ which over MCP would be a model round-trip each.
145
+
146
+ It reports three things, deliberately separately:
147
+
148
+ - **drift** — what `--write` would repair: stamps, snapshot filenames and keys,
149
+ the config key.
150
+ - **mentions** — stale refs in spec *prose* (`(REU-61)` beside a task). Reported,
151
+ **never rewritten** — say so, so nobody reads a repair as total.
152
+ - **missing** — refs that resolve to no issue under the new key. A different
153
+ problem; repair leaves them alone.
154
+
155
+ A `missing` count that looks alarmingly high is worth a second look before you
156
+ relay it as fact — the first hand-run of this check reported 146 of 198 refs as
157
+ non-existent when every one was healthy and merely archived.
158
+
159
+ **`--write` repairs it — confirm first, and state the counts.** It rewrites the
160
+ config key, the frontmatter stamps, the snapshot filenames and the identifier
161
+ keys inside them, all together. It refuses on a dirty git tree, because the
162
+ repair is one large diff and has to be reviewable (and `git checkout -- .`-able)
163
+ on its own — so commit or stash before offering it.
164
+
165
+ It exits non-zero when it left anything behind: a ref that resolves to no issue
166
+ is **not** rewritten, because repair fixes what is provably repairable rather
167
+ than inventing a target. Relay that as an unfinished repair, not a success.
168
+
169
+ After a repair, `spec-sync status` should still read `up to date` — the snapshot
170
+ hashes are content-derived, so only their keys move. If it does not, say so
171
+ rather than pushing over it.
172
+
173
+ ## 8. Report
174
+
175
+ Relay the engine's output. Name the subcommand you ran, in full, so the user can
176
+ re-run it themselves. For anything that wrote, say what changed in Linear and
177
+ that the repo is unchanged — it is the source of truth either way.
@@ -4,8 +4,13 @@
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
16
  // This package's bin/, src/ and assets/ are COMPOSED by scripts/build-dist.js and
@@ -27,24 +32,33 @@ if (!existsSync(join(__dirname, '..', 'src'))) {
27
32
  process.exit(1)
28
33
  }
29
34
 
30
- const { run } = require('../src/cli.js')
31
- const { specSync } = require('../src/vendor/linear/cli-sync.js')
32
- const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
35
+ const { run, HELP } = require('../src/cli.js')
36
+ const {
37
+ PROVIDER_COMMANDS,
38
+ providerHelpSection,
39
+ } = require('../src/vendor/linear/commands.js')
33
40
 
34
41
  async function main(argv) {
35
42
  const [cmd, ...rest] = argv
36
- if (cmd === 'spec-sync') {
37
- // Propagate the exit code, like spec-sanitise below. Dropping it made
38
- // `status --workspace-states` (a bad state name) and `stamp` (a refused
39
- // write) both look successful to any caller checking $?, which is exactly
40
- // what the /spec-push skill does before it applies a plan.
41
- 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()}`)
42
49
  return
43
50
  }
44
- if (cmd === 'spec-sanitise') {
45
- 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)
46
59
  return
47
60
  }
61
+
48
62
  await run(argv)
49
63
  }
50
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.2.0",
3
+ "version": "10.4.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:
@@ -1384,8 +1407,8 @@ async function run(argv) {
1384
1407
  await cleanupReleaseTooling(dir, opts)
1385
1408
  break
1386
1409
  default:
1387
- throw new Error(`unknown command: ${cmd} (try --help)`)
1410
+ throw new Error(unknownCommand(cmd))
1388
1411
  }
1389
1412
  }
1390
1413
 
1391
- module.exports = { run, parse }
1414
+ module.exports = { run, parse, HELP, unknownCommand }
package/src/init.js CHANGED
@@ -562,11 +562,21 @@ function printReport(dir, mode, { diff = false } = {}) {
562
562
  // ships none. Discovering it from what was actually installed keeps this file
563
563
  // tracker-free — it never has to know which tracker (if any) is in the box.
564
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
- : ''
565
+ const provider = setupSkill ? /^spec-(.+)-setup$/.exec(setupSkill)[1] : null
566
+ // …and the same derivation gives the provider's config filename, so this can
567
+ // report tracker sync the way it reports isolation above from what is
568
+ // actually on disk. It used to say "opt-in: run /…-setup" even on a repo that
569
+ // had already configured it, telling you to set up what was already set up.
570
+ const trackerOn =
571
+ provider && fs.existsSync(path.join(dir, 'specs', '.core', `${provider}.config.json`))
572
+ const trackerNote = !setupSkill
573
+ ? ''
574
+ : trackerOn
575
+ ? `Tracker sync is ON: ${provider} — the repo stays the source of truth;` +
576
+ ' /spec-push mirrors a spec up and /spec-status reports drift.\n'
577
+ : `Tracker sync is opt-in: run /${setupSkill} to configure it` +
578
+ ' (it discovers your workspace and writes the config), or see' +
579
+ ' specs/.core/SETUP.md.\n'
570
580
  process.stdout.write(
571
581
  '\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
572
582
  ' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
@@ -29,27 +29,69 @@ const ENDPOINT = 'https://api.linear.app/graphql'
29
29
  const MAX_RETRIES = 5
30
30
  const MAX_BACKOFF_MS = 60_000
31
31
 
32
+ const { storePath, readStore, resolveTeamKey } = require('./credentials.js')
33
+
32
34
  /**
33
- * Resolve the personal API key from the environment.
35
+ * Resolve the personal API key, environment first.
36
+ *
37
+ * Order — first hit wins:
38
+ * 1. `process.env[auth.keyEnv]` (default LINEAR_API_KEY) — CI and every
39
+ * existing setup, so this path is unchanged.
40
+ * 2. the user-level credentials store, keyed by `linear.teamId`.
34
41
  *
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.
42
+ * Returns `{ ok: true, key, envVar, source }` where `source` is `'env'` or
43
+ * `'store'`, or `{ ok: false, envVar, error }` when nothing is set a value the
44
+ * caller branches on rather than an exception, because "no key" is a normal
45
+ * state that means "use MCP", not a failure. A store that exists but is
46
+ * unreadable or world-readable is reported in `error`; it never silently
47
+ * degrades to "no key", or the leak would be invisible.
38
48
  *
39
49
  * The key is never part of the returned error, and callers must keep it out of
40
50
  * logs, plans, snapshots and stamped frontmatter.
41
51
  */
42
- function resolveApiKey(config, env = process.env) {
52
+ function resolveApiKey(config, env = process.env, deps = {}) {
43
53
  const envVar = (config && config.auth && config.auth.keyEnv) || DEFAULT_KEY_ENV
44
- const key = env[envVar]
45
- if (typeof key !== 'string' || !key.trim()) {
54
+ const fromEnv = env[envVar]
55
+ if (typeof fromEnv === 'string' && fromEnv.trim()) {
56
+ return { ok: true, key: fromEnv.trim(), envVar, source: 'env' }
57
+ }
58
+
59
+ // The store is keyed by team, so with no teamId there is nothing to look up —
60
+ // skip the read entirely. That also keeps callers with no Linear config (and
61
+ // every unit test using the defaults) off the real filesystem.
62
+ const teamId = config && config.linear && config.linear.teamId
63
+ if (!teamId) {
46
64
  return {
47
65
  ok: false,
48
66
  envVar,
49
67
  error: `no Linear API key — set ${envVar}, or apply the plan over MCP with --via mcp`,
50
68
  }
51
69
  }
52
- return { ok: true, key: key.trim(), envVar }
70
+
71
+ const file = (deps.storePath || storePath)(env)
72
+ const result = (deps.readStore || readStore)(file)
73
+ let commandReason = ''
74
+ if (result.ok) {
75
+ const found = (deps.resolveTeamKey || resolveTeamKey)(result.store, teamId, deps)
76
+ if (found.key) {
77
+ return { ok: true, key: found.key, envVar, source: found.source, command: found.command, path: file }
78
+ }
79
+ // A keyCommand that exists but did not produce a key is a real problem —
80
+ // surfacing it is the difference between "fix your command" and silence.
81
+ if (found.reason) commandReason = `\n ${found.reason}`
82
+ }
83
+
84
+ // A store that is present but unusable is a distinct, reportable problem —
85
+ // don't let a permissions refusal read as "you never set a key".
86
+ const detail = (result.ok || result.code === 'absent' ? '' : `\n ${result.reason}`) + commandReason
87
+ return {
88
+ ok: false,
89
+ envVar,
90
+ path: file,
91
+ error:
92
+ `no Linear API key — set ${envVar}, run \`skitterspec spec-sync credentials set\` ` +
93
+ `to store one in ${file}, or apply the plan over MCP with --via mcp${detail}`,
94
+ }
53
95
  }
54
96
 
55
97
  // Fields we read back on every write. `identifier` and `url` are what the skill
@@ -192,6 +234,18 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
192
234
  if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
193
235
  return (data && data.projects && data.projects.nodes) || []
194
236
  },
237
+ // The team's CURRENT key, which is what `doctor` compares stamped
238
+ // identifiers against. Read from Linear rather than `config.linear.teamKey`
239
+ // on purpose: the config key is itself one of the things that goes stale
240
+ // when a team is renamed, so trusting it would make drift invisible. The
241
+ // teamId is stable across a rename; the key is not.
242
+ //
243
+ // API-only, like `listIssueStates` — see the operation-contract test: the
244
+ // API adapter may add ops, it may only never be missing one.
245
+ async readTeam(teamId) {
246
+ const data = await call(`query($id: String!) { team(id: $id) { id key name } }`, { id: teamId })
247
+ return (data && data.team) || null
248
+ },
195
249
  // The workspace's issue workflow states, in the shape `--workspace-states`
196
250
  // already accepts, so the existing state check is reused rather than forked.
197
251
  async listIssueStates(teamId) {