@skitterbyte/skitterspec-linear 10.5.0 → 10.5.2

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.
@@ -403,6 +403,22 @@ issue exists, where it lives is Linear's business: move it between projects and
403
403
  A spec that **adopted** an existing issue (see below) skips the picker entirely —
404
404
  it was filed somewhere deliberately.
405
405
 
406
+ `skitterspec spec-sync doctor` reports what this setting is doing:
407
+
408
+ | `linear.projectId` | Row | Meaning |
409
+ |--------------------|-----|---------|
410
+ | unset | `missing` | specs file to the team, and the picker asks each push — a supported choice, and it never fails the run |
411
+ | set | `ok` | the id is there; add `--check-remote` to resolve it against Linear |
412
+ | set, resolves in the team | `ok` | the project exists and this team is one of its teams |
413
+ | set, resolves elsewhere | `broken` | specs would file out of the team — exits non-zero |
414
+ | set, resolves to nothing | `broken` | a deleted or mistyped id |
415
+
416
+ `doctor --mcp <file>` additionally compares this id — and the team and workspace —
417
+ against what the Linear **MCP server** reports, so the two transports cannot
418
+ quietly point at different places. A mismatch is `broken` and names both sides; it
419
+ is never resolved by rewriting this file, because which side is wrong is yours to
420
+ say.
421
+
406
422
  ### Known limits — one team per repo, no initiatives
407
423
 
408
424
  Two things this config deliberately cannot express today. Both are limits, not
@@ -47,6 +47,11 @@ anything. The interview offers real lists; it never prompts for a raw id.
47
47
  | `list_projects` | the projects, per candidate team — names + ids, minus archived/completed |
48
48
  | `list_issue_statuses` | the team's **issue workflow-state names**, exactly as spelled |
49
49
  | `list_issue_labels` | the label names available for intake routing |
50
+ | `get_workspace` | **which workspace this MCP server is connected to** — id + name |
51
+
52
+ `get_workspace` is not part of the interview: nothing is asked about it. It is
53
+ recorded so step 8 can prove the MCP server and the API key are pointed at the
54
+ same Linear, which nothing else establishes.
50
55
 
51
56
  Projects, labels and statuses are all **team-scoped** — fetch them for the team
52
57
  once step 3 has settled it, not for the whole workspace up front.
@@ -159,10 +164,22 @@ paraphrase it into "done".
159
164
 
160
165
  ## 8. Report and hand off
161
166
 
162
- **Finish by checking, not by describing.** Run:
167
+ **Finish by checking, not by describing.** First write down what the MCP server
168
+ says, from the reads you already made in step 2 — no extra round trip unless a
169
+ project was chosen and you have not read it yet (`get_project`):
170
+
171
+ ```json
172
+ { "workspace": {"id": "…", "name": "…"},
173
+ "team": {"id": "…", "key": "SKS"},
174
+ "project": {"id": "…", "name": "…"} }
175
+ ```
176
+
177
+ Every key is optional, and **omit what you could not fetch** rather than guessing
178
+ — an absent field is reported as unchecked, while a wrong one is reported as a
179
+ mismatch. Then run:
163
180
 
164
181
  ```
165
- skitterspec spec-sync doctor
182
+ skitterspec spec-sync doctor --mcp <factsfile>
166
183
  ```
167
184
 
168
185
  and relay its table. That is the difference between a summary of what setup
@@ -173,7 +190,17 @@ nothing to paraphrase.
173
190
 
174
191
  It exits non-zero only when a layer is **broken** — configured but wrong. A
175
192
  `missing` row is an opt-in nobody took, which is fine; report it, don't treat it
176
- as a failure.
193
+ as a failure. An empty `project` row is exactly that: specs file to the team and
194
+ the picker asks each push.
195
+
196
+ **A `broken` `mcp` row is the one to stop on.** It means the MCP server and the
197
+ config (or the API key) name different workspaces, teams or projects — so where a
198
+ spec lands depends on which transport ran. Relay both sides as the row prints
199
+ them and **stop**: do not rewrite the config to make them agree. Which one is
200
+ correct is the user's to say — the API key may be the wrong one just as easily as
201
+ the config, and picking a winner silently sends their specs somewhere they did
202
+ not choose. Ask which is right, then re-run this skill (or reconnect the MCP
203
+ server) to match it.
177
204
 
178
205
  Then name the next step:
179
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.5.0",
3
+ "version": "10.5.2",
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",
@@ -246,6 +246,36 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
246
246
  const data = await call(`query($id: String!) { team(id: $id) { id key name } }`, { id: teamId })
247
247
  return (data && data.team) || null
248
248
  },
249
+ // The WORKSPACE this key belongs to. The one fact that says whether the API
250
+ // transport and the MCP transport are pointed at the same Linear at all —
251
+ // every other id could match by coincidence across two workspaces, but an
252
+ // organization id is the workspace.
253
+ //
254
+ // API-only, like `readTeam`.
255
+ async readOrganization() {
256
+ const data = await call('query { organization { id name urlKey } }')
257
+ return (data && data.organization) || null
258
+ },
259
+ // One project by id, with the teams it belongs to — enough to answer both
260
+ // halves of the `project` doctor row: does this id resolve at all, and is it
261
+ // a project of the team this repo files into?
262
+ //
263
+ // `teams`, not `team`: a Linear project can span several teams, so belonging
264
+ // is a membership test. Treating it as a single field would report a healthy
265
+ // shared project as foreign.
266
+ //
267
+ // API-only, like `readTeam` — the adapter may add ops, it may only never be
268
+ // missing one (see the operation-contract test).
269
+ async readProject(projectId) {
270
+ const data = await call(
271
+ `query($id: String!) { project(id: $id) { id name teams { nodes { id key } } } }`,
272
+ { id: projectId },
273
+ )
274
+ const project = (data && data.project) || null
275
+ if (!project) return null
276
+ const teams = (project.teams && project.teams.nodes) || []
277
+ return { id: project.id, name: project.name, teams }
278
+ },
249
279
  // The workspace's issue workflow states, in the shape `--workspace-states`
250
280
  // already accepts, so the existing state check is reused rather than forked.
251
281
  async listIssueStates(teamId) {
@@ -604,6 +604,14 @@ function verifyLines(snapshotDir, config, stored, identifier) {
604
604
  */
605
605
  async function specSyncDoctor(dir, flags, out) {
606
606
  const state = gatherState(dir, flags)
607
+ if (flags.mcp) {
608
+ const read = readMcpFacts(flags.mcp)
609
+ if (read.error) {
610
+ out.write(`spec-sync doctor: cannot read --mcp ${flags.mcp}: ${read.error}\n`)
611
+ return 1
612
+ }
613
+ state.mcp = read.facts
614
+ }
607
615
  if (flags.remoteCheck) state.remote = await checkRemote(state, flags)
608
616
 
609
617
  const report = runChecks(state)
@@ -675,7 +683,74 @@ async function checkRemote(state, flags) {
675
683
  fix: '/spec-linear-setup',
676
684
  }
677
685
  }
678
- return { checked: true, ok: true, teamKey: team.key, recordedKey: state.tracker.teamKey }
686
+ // The team resolved, so the key works. Only now is it worth spending further
687
+ // calls — on the workspace this key belongs to (so the MCP row has an API side
688
+ // to compare against, and only when there is an MCP side to compare with), and
689
+ // on the project, when one is configured.
690
+ let organization = null
691
+ if (state.mcp && state.mcp.workspace && typeof adapter.readOrganization === 'function') {
692
+ try {
693
+ organization = await adapter.readOrganization()
694
+ } catch {
695
+ // Unexamined, exactly like the project below: the team read already proved
696
+ // the key works, so a failure here is not evidence about the config.
697
+ organization = null
698
+ }
699
+ }
700
+
701
+
702
+ let project = null
703
+ // `readProject` is API-only. An adapter without it cannot answer the question,
704
+ // and a `TypeError` caught below would be dressed up as Linear refusing the
705
+ // request — accusing the user's config of a gap that is ours. Unexamined.
706
+ if (state.project && state.project.configured && typeof adapter.readProject === 'function') {
707
+ try {
708
+ const found = await adapter.readProject(state.project.configured)
709
+ project = found
710
+ ? {
711
+ resolved: true,
712
+ name: found.name,
713
+ // Membership, not equality: a Linear project can span teams.
714
+ belongsToTeam: (found.teams || []).some((t) => t && t.id === state.tracker.teamId),
715
+ }
716
+ : { resolved: false }
717
+ } catch (error) {
718
+ // Same rule as the team read: NEVER ANSWERED is not ANSWERED NO. A
719
+ // transport failure leaves the project unexamined rather than accused.
720
+ const failure = classifyRemoteFailure(error)
721
+ project = failure.reached === false ? null : { resolved: false, reason: failure.reason }
722
+ }
723
+ }
724
+
725
+ return { checked: true, ok: true, teamKey: team.key, teamId: team.id, organization, recordedKey: state.tracker.teamKey, project }
726
+ }
727
+
728
+ /**
729
+ * The MCP server's view of where this repo files, as the calling skill read it.
730
+ *
731
+ * The engine never speaks MCP — the same split `--workspace-states` and
732
+ * `verify --stored` use — so a skill fetches `get_workspace` / `get_team` /
733
+ * `get_project` and writes them here:
734
+ *
735
+ * { "workspace": {"id","name"}, "team": {"id","key"}, "project": {"id","name"} }
736
+ *
737
+ * Every field is optional. A field the skill could not fetch is ABSENT, and
738
+ * absent means unchecked — never mismatched. That distinction is the whole
739
+ * safety of the row: it is a comparison, so it can only speak about the pairs it
740
+ * actually has both halves of.
741
+ */
742
+ function readMcpFacts(file) {
743
+ let parsed
744
+ try {
745
+ parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
746
+ } catch (error) {
747
+ return { error: error.message }
748
+ }
749
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
750
+ return { error: 'expected an object with workspace / team / project keys' }
751
+ }
752
+ const pick = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
753
+ return { facts: { workspace: pick(parsed.workspace), team: pick(parsed.team), project: pick(parsed.project) } }
679
754
  }
680
755
 
681
756
  // Map a thrown API error onto our own short reason. Matched on the shapes
@@ -708,7 +783,7 @@ function classifyRemoteFailure(error) {
708
783
  // Read the project's real state for `runChecks`. Never throws: every probe that
709
784
  // can fail reports the failure as data.
710
785
  function gatherState(dir, flags) {
711
- const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, remote: { checked: false } }
786
+ const state = { scaffold: {}, isolation: {}, tracker: {}, key: {}, project: {}, remote: { checked: false } }
712
787
 
713
788
  const specs = path.join(dir, 'specs')
714
789
  state.scaffold.specsDir = fs.existsSync(specs)
@@ -739,6 +814,10 @@ function gatherState(dir, flags) {
739
814
  state.tracker.parsed = true
740
815
  state.tracker.teamId = (config.linear && config.linear.teamId) || ''
741
816
  state.tracker.teamKey = (config.linear && config.linear.teamKey) || ''
817
+ // Offline this is all that can be known: whether a string is there. That a
818
+ // well-formed id names a LIVE project is only answerable with
819
+ // --check-remote, which is why the row says what it checked.
820
+ state.project.configured = (config.linear && config.linear.projectId) || ''
742
821
  } catch (error) {
743
822
  state.tracker.parsed = false
744
823
  state.tracker.error = error.message
@@ -1706,7 +1785,19 @@ async function credentialsSet(file, teamId, label, flags, out) {
1706
1785
 
1707
1786
  let key
1708
1787
  if (flags.stdin) {
1709
- key = (await readAllStdin(flags.input || process.stdin)).trim()
1788
+ const piped = flags.input || process.stdin
1789
+ // A TTY on stdin is positive evidence that nothing was piped: `--stdin` then
1790
+ // waits for an EOF a terminal never sends, printing nothing while it does.
1791
+ // Refuse and name the two working forms rather than blocking forever.
1792
+ if (piped.isTTY) {
1793
+ out.write(
1794
+ 'spec-sync credentials: --stdin expects a pipe, but stdin is a terminal.\n' +
1795
+ ' Run it without --stdin to be prompted (input is hidden), or pipe the key:\n' +
1796
+ ' <command that prints the key> | skitterspec spec-sync credentials set --stdin\n',
1797
+ )
1798
+ return 1
1799
+ }
1800
+ key = (await readAllStdin(piped)).trim()
1710
1801
  if (!key) {
1711
1802
  out.write('spec-sync credentials: nothing on stdin — no key stored.\n')
1712
1803
  return 1
@@ -1778,19 +1869,33 @@ function readAllStdin(input) {
1778
1869
  }
1779
1870
 
1780
1871
  // Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
1781
- // hook — silencing it is what keeps the key off the screen (and out of a
1872
+ // hook — filtering it is what keeps the key off the screen (and out of a
1782
1873
  // screen-shared terminal or a recorded session).
1874
+ //
1875
+ // READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
1876
+ // the interface loses it: readline clears from the cursor to the end of the
1877
+ // screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
1878
+ // it appeared and the user was left staring at a blank line while the process
1879
+ // waited for a key — indistinguishable from a hang.
1880
+ //
1881
+ // The hook is assigned BEFORE `question`, so the very first redraw goes through
1882
+ // it. readline hands it `prompt + what has been typed`; re-writing only the
1883
+ // prompt is what keeps the key hidden while the prompt survives every redraw.
1783
1884
  function promptHidden(question, input, out) {
1784
1885
  const readline = require('node:readline')
1785
1886
  return new Promise((resolve) => {
1786
- const rl = readline.createInterface({ input, output: process.stdout, terminal: true })
1787
- out.write(question)
1788
- rl.question('', (answer) => {
1887
+ // `out`, never `process.stdout`: they are the same stream in production, and
1888
+ // hardcoding one half meant readline cleared a screen the prompt had not
1889
+ // been written to under test — the split that hid this bug from the suite.
1890
+ const rl = readline.createInterface({ input, output: out, terminal: true })
1891
+ rl._writeToOutput = (s) => {
1892
+ if (s.includes(question)) out.write(question)
1893
+ }
1894
+ rl.question(question, (answer) => {
1789
1895
  out.write('\n')
1790
1896
  rl.close()
1791
1897
  resolve(answer)
1792
1898
  })
1793
- rl._writeToOutput = () => {}
1794
1899
  })
1795
1900
  }
1796
1901
 
@@ -1805,12 +1910,13 @@ async function specSync(rest, io = {}) {
1805
1910
  // after the loop.
1806
1911
  const unknownFlags = []
1807
1912
  const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [], stored: null, plan: null, via: null, project: null, all: null,
1808
- force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1913
+ mcp: null, force: false, yes: false, remoteCheck: false, teamId: '', teamKey: '', projectId: '', intakeLabel: '', bugLabels: [], hotfixLabels: [], stateNames: {}, statesFile: null }
1809
1914
  for (let i = 0; i < args.length; i++) {
1810
1915
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1811
1916
  else if (args[i] === '--json') flags.json = true
1812
1917
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
1813
1918
  else if (args[i] === '--stored') flags.stored = path.resolve(args[++i])
1919
+ else if (args[i] === '--mcp') flags.mcp = path.resolve(args[++i])
1814
1920
  else if (args[i] === '--plan') flags.plan = path.resolve(args[++i])
1815
1921
  else if (args[i] === '--via') flags.via = args[++i]
1816
1922
  else if (args[i] === '--all') flags.all = args[++i]
@@ -1880,6 +1986,10 @@ async function specSync(rest, io = {}) {
1880
1986
  flags.env = io.env || process.env
1881
1987
  if (io.adapter) flags.adapter = io.adapter
1882
1988
  if (io.fetch) flags.fetch = io.fetch
1989
+ // The stdin seam. `credentials set` branches on whether stdin is a TTY, and
1990
+ // with no way to inject one the suite could only ever exercise the non-TTY
1991
+ // half — which is how a prompt that erased itself reached a release.
1992
+ if (io.input) flags.input = io.input
1883
1993
 
1884
1994
  // Dispatched ahead of the load on purpose: this is the command you run when
1885
1995
  // there is no config, and `--force` must be able to replace one that is
@@ -1938,7 +2048,7 @@ async function specSync(rest, io = {}) {
1938
2048
  ' skitterspec spec-sync verify <spec> --stored <file>\n' +
1939
2049
  ' skitterspec spec-sync linked [--json]\n' +
1940
2050
  ' skitterspec spec-sync retarget [--yes]\n' +
1941
- ' skitterspec spec-sync doctor [--check-remote] [--json]\n' +
2051
+ ' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
1942
2052
  ' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
1943
2053
  ' [--intake-label L] [--bug-labels a,b] [--hotfix-labels a,b]\n' +
1944
2054
  ' [--state <bucket>=<name> …] [--states <file>] [--force] [--json]\n')
@@ -1946,4 +2056,4 @@ async function specSync(rest, io = {}) {
1946
2056
  }
1947
2057
  }
1948
2058
 
1949
- module.exports = { specSync, listSpecs }
2059
+ module.exports = { specSync, listSpecs, promptHidden }
@@ -54,8 +54,10 @@ function runChecks(state = {}) {
54
54
  scaffoldCheck(state.scaffold),
55
55
  isolationCheck(state.isolation),
56
56
  trackerCheck(state.tracker),
57
+ projectCheck(state.project, state.tracker, state.remote),
57
58
  keyCheck(state.key, state.tracker),
58
59
  remoteCheck(state.remote),
60
+ mcpCheck(state.mcp, state.tracker, state.project, state.remote),
59
61
  ]
60
62
  // `missing` is a declined opt-in, so it must not fail the run. Only a
61
63
  // configured-but-wrong layer does.
@@ -123,6 +125,53 @@ function trackerCheck(s = {}) {
123
125
  // caller resolves all three before this sees it. `s.error` carries WHY when one
124
126
  // of them failed; passing it through is what keeps a broken keyCommand from
125
127
  // being reported as a key the user never set.
128
+ // Where specs get filed. `projectId` is the picker's DEFAULT, not a mandate
129
+ // (`config.js`), so an unset one is a declined opt-in and NEVER fails the run —
130
+ // filing to the team and choosing a project each push is a supported way to work.
131
+ //
132
+ // BLIND SPOT: offline this can only see that a string is present. A well-formed
133
+ // id naming a deleted project, or one belonging to another team, reads `ok`
134
+ // until `--check-remote` resolves it — so the detail says which of the two was
135
+ // actually established rather than implying the stronger one.
136
+ function projectCheck(s = {}, tracker = {}, remote = {}) {
137
+ if (!tracker.present) return row('project', 'project', 'skipped', 'no tracker configured')
138
+ if (!s.configured) {
139
+ return row(
140
+ 'project',
141
+ 'project',
142
+ 'missing',
143
+ 'no linear.projectId — specs file to the team, and the picker asks each push',
144
+ '/spec-linear-setup',
145
+ )
146
+ }
147
+
148
+ const found = remote && remote.project
149
+ // Configured but unexamined — either --check-remote was not passed, or it was
150
+ // and Linear never answered. Both are "we did not look", not "it is wrong".
151
+ if (!found) {
152
+ return row('project', 'project', 'ok', `${s.configured} — configured, not checked against Linear`)
153
+ }
154
+ if (!found.resolved) {
155
+ return row(
156
+ 'project',
157
+ 'project',
158
+ 'broken',
159
+ found.reason || `linear.projectId ${s.configured} does not resolve in this workspace`,
160
+ '/spec-linear-setup',
161
+ )
162
+ }
163
+ if (!found.belongsToTeam) {
164
+ return row(
165
+ 'project',
166
+ 'project',
167
+ 'broken',
168
+ `"${found.name}" is not a project of team ${tracker.teamKey || tracker.teamId} — specs would file out of the team`,
169
+ '/spec-linear-setup',
170
+ )
171
+ }
172
+ return row('project', 'project', 'ok', `"${found.name}" (${s.configured}) in team ${tracker.teamKey || tracker.teamId}`)
173
+ }
174
+
126
175
  function keyCheck(s = {}, tracker = {}) {
127
176
  // Without a tracker there is nothing for a key to authenticate, so asking for
128
177
  // one would be noise.
@@ -176,4 +225,65 @@ function remoteCheck(s = {}) {
176
225
  return row('remote', 'remote', 'ok', `team ${s.teamKey} resolves, key accepted`)
177
226
  }
178
227
 
228
+ /**
229
+ * Do the two transports point at the same place?
230
+ *
231
+ * A repo reaches Linear over the API or over MCP, chosen per invocation, and
232
+ * they are configured independently: the API key belongs to whatever workspace
233
+ * issued it, the MCP server to whatever workspace it was connected to. Nothing
234
+ * made them agree, so the destination could depend on which transport ran.
235
+ *
236
+ * `s` is what a skill read over MCP (see `readMcpFacts`). Three sources are
237
+ * compared — the repo's config, the API key's workspace, and the MCP server's —
238
+ * and a disagreement is `broken` because writes would land in the wrong place.
239
+ *
240
+ * IDS, NEVER NAMES: a renamed workspace, team or project keeps its id, and
241
+ * `retarget` exists precisely because a team KEY is not identity.
242
+ *
243
+ * BLIND SPOT: the file is a snapshot the skill took, so `ok` means the sources
244
+ * agreed WHEN IT WAS FETCHED. And a field the skill could not fetch is absent —
245
+ * absence is unchecked, so it never produces `broken`. The row can only speak
246
+ * about pairs it holds both halves of, which is why it names them.
247
+ */
248
+ function mcpCheck(s, tracker = {}, project = {}, remote = {}) {
249
+ if (!s) {
250
+ return row('mcp', 'mcp', 'skipped', 'pass --mcp <file> to check the MCP server points at the same place')
251
+ }
252
+
253
+ const apiOrg = remote && remote.organization
254
+ const pairs = [
255
+ ['workspace', s.workspace && s.workspace.id, apiOrg && apiOrg.id, s.workspace && s.workspace.name, apiOrg && apiOrg.name, "the API key's workspace"],
256
+ ['team', s.team && s.team.id, tracker.teamId, s.team && s.team.key, tracker.teamKey, 'the config'],
257
+ ['project', s.project && s.project.id, project && project.configured, s.project && s.project.name, null, 'the config'],
258
+ ]
259
+
260
+ const checked = []
261
+ for (const [what, mcpId, otherId, mcpName, otherName, whose] of pairs) {
262
+ // Both halves, or nothing to compare. An absent id is a question nobody
263
+ // asked, not an answer of "no".
264
+ if (!mcpId || !otherId) continue
265
+ checked.push(what)
266
+ if (mcpId !== otherId) {
267
+ return row(
268
+ 'mcp',
269
+ 'mcp',
270
+ 'broken',
271
+ `${what} mismatch — the MCP server says ${describe(mcpName, mcpId)}, ${whose} says ` +
272
+ `${describe(otherName, otherId)}; writes land wherever the transport does`,
273
+ '/spec-linear-setup',
274
+ )
275
+ }
276
+ }
277
+
278
+ if (!checked.length) {
279
+ return row('mcp', 'mcp', 'skipped', 'the --mcp file names nothing that can be compared yet')
280
+ }
281
+ const where = (s.workspace && s.workspace.name) || (s.team && s.team.key) || 'the same place'
282
+ return row('mcp', 'mcp', 'ok', `${where} — ${checked.join(', ')} agree across both transports`)
283
+ }
284
+
285
+ // `Name (id)` when a name is known, the bare id otherwise — the id is what was
286
+ // compared, so it is always shown.
287
+ const describe = (name, id) => (name ? `"${name}" (${id})` : String(id))
288
+
179
289
  module.exports = { runChecks, STATES }