@skitterbyte/skitterspec-linear 10.4.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.
@@ -1,341 +1,289 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Identifier driftthe offline half of `spec-sync doctor`.
4
+ * `spec-sync doctor`one readiness report across every layer of a setup.
5
5
  *
6
- * When a Linear team is renamed (`REU` `ERQ`), nothing in the repo moves: every
7
- * stamped `linear_identifier` / `linear_issue_id` / `linear_url`, the config's
8
- * `teamKey`, and every `linear-base/<ID>.base.json` filename keeps the old
9
- * prefix. Nothing detected it and nothing repaired it; the first occurrence was
10
- * fixed by hand across 221 refs in 54 files.
6
+ * Setting skitterspec up spans four layers the `specs/` scaffold and skills,
7
+ * per-spec isolation, the tracker config, and the API key — and each was checked
8
+ * by a different command, or by none. `init` reports the scaffold and isolation;
9
+ * `credentials status` reports the key; the tracker config had no readiness check
10
+ * at all, only commands that write it. So "is this project set up?" had no single
11
+ * answer, and a skill needing to know its own prerequisites had nothing to call.
11
12
  *
12
- * This module only SCANS it reads the repo and reports what disagrees with a
13
- * team key it is handed. It performs no network calls and writes nothing, so the
14
- * detection logic is testable without an adapter. Deciding what the current key
15
- * IS (a Linear read) and repairing (phase 3) live in `cli-sync.js`.
13
+ * This module is the PURE half. It takes the project's state as an argument
14
+ * gathered by the caller — and returns rows. No `fs`, no network, no output, so
15
+ * every branch is exercised from a literal rather than a scaffolded temp project.
16
+ *
17
+ * Two distinctions carry the design:
18
+ *
19
+ * 1. **`missing` is not `broken`.** `missing` is an opt-in not taken, which is
20
+ * fine; `broken` is configured-but-wrong, which is not. `ok` is false only for
21
+ * `broken`, so declining isolation or a tracker never reads as a failure. The
22
+ * existing commands blur exactly this.
23
+ * 2. **Every non-`ok` row names the command that fixes it**, so the output is
24
+ * actionable without reading docs — the shape `credentials status` already
25
+ * uses.
26
+ *
27
+ * It never prints a secret: the key row carries a masked fingerprint and its
28
+ * source, never the value. This is the command a skill runs, so that has to hold
29
+ * by construction rather than by convention.
16
30
  */
17
31
 
18
- const fs = require('node:fs')
19
- const path = require('node:path')
20
- const { execFileSync } = require('node:child_process')
21
-
22
- const { BUCKETS } = require('../../env/resolve.js')
23
- const { parseFrontmatter } = require('../sync-core')
24
-
25
- // A Linear issue identifier: an uppercase team key, a dash, a number. The key is
26
- // captured so drift is a prefix comparison rather than a guess.
27
- const IDENTIFIER_RE = /^([A-Z][A-Z0-9]*)-(\d+)$/
28
- // The same, embedded in a URL path (`…/issue/REU-151/slug`).
29
- const URL_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]*)-(\d+)\b/g
30
-
31
- const STAMP_FIELDS = ['linear_identifier', 'linear_issue_id']
32
-
33
- // Every `.md` under each spec folder — overview and phase files alike, plus the
34
- // legacy bare `<name>.md` shape. Anything stamped lives in one of these.
35
- function specMarkdownFiles(dir) {
36
- const files = []
37
- for (const bucket of BUCKETS) {
38
- const root = path.join(dir, 'specs', bucket)
39
- let entries
40
- try {
41
- entries = fs.readdirSync(root, { withFileTypes: true })
42
- } catch {
43
- continue
44
- }
45
- for (const entry of entries) {
46
- const p = path.join(root, entry.name)
47
- if (entry.isDirectory()) {
48
- for (const f of fs.readdirSync(p)) if (f.endsWith('.md')) files.push(path.join(p, f))
49
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
50
- files.push(p)
51
- }
52
- }
53
- }
54
- return files.sort()
55
- }
32
+ const STATES = ['ok', 'missing', 'broken', 'skipped']
56
33
 
57
- // Retarget one identifier onto `currentKey`, preserving the number. Returns null
58
- // when it is already current or is not an identifier at all.
59
- function retarget(value, currentKey) {
60
- const m = IDENTIFIER_RE.exec(String(value).trim())
61
- if (!m || m[1] === currentKey) return null
62
- return `${currentKey}-${m[2]}`
34
+ // A row is a check. `fix` is the exact command to run, or null when there is
35
+ // nothing to fix.
36
+ const row = (id, label, state, detail, fix = null) => {
37
+ if (!STATES.includes(state)) throw new Error(`doctor: unknown check state "${state}" for ${id}`)
38
+ return { id, label, state, detail, fix }
63
39
  }
64
40
 
65
41
  /**
66
- * Scan the repo for identifiers that disagree with `currentKey`.
67
- *
68
- * @returns {{
69
- * stamps: Array<{file, field, from, to}>,
70
- * urls: Array<{file, from, to}>,
71
- * snapshots: Array<{from, to}>,
72
- * snapshotKeys: Array<{file, from, to}>,
73
- * mentions: Array<{file, from, to}>, // prose refs — reported, never repaired
74
- * config: {from, to}|null,
75
- * refs: Array<{from, to}>,
76
- * }} `refs` is the DISTINCT set of drifted identifiers — what the caller checks
77
- * against Linear, so 221 stamps of 198 identifiers cost 198 reads, not 221.
42
+ * @param {object} state gathered by the caller:
43
+ * {
44
+ * scaffold: { specsDir: bool, buckets: string[], skills: number },
45
+ * isolation: { present: bool, parsed: bool, error?: string },
46
+ * tracker: { present: bool, parsed: bool, teamId?: string, teamKey?: string, error?: string },
47
+ * key: { ok: bool, source?: string, fingerprint?: string, error?: string },
48
+ * remote: { checked: bool, ok?: bool, teamKey?: string, error?: string },
49
+ * }
50
+ * @returns {{ok: boolean, checks: Array}}
78
51
  */
79
- function scanDrift(dir, config, currentKey) {
80
- const stamps = []
81
- const urls = []
82
- const seen = new Map()
83
- const note = (from, to) => seen.set(from, to)
84
-
85
- for (const file of specMarkdownFiles(dir)) {
86
- let raw
87
- try {
88
- raw = fs.readFileSync(file, 'utf-8')
89
- } catch {
90
- continue
91
- }
92
- const { data } = parseFrontmatter(raw)
93
- for (const field of STAMP_FIELDS) {
94
- if (!data[field]) continue
95
- const to = retarget(data[field], currentKey)
96
- if (!to) continue
97
- stamps.push({ file: path.relative(dir, file), field, from: String(data[field]).trim(), to })
98
- note(String(data[field]).trim(), to)
99
- }
100
- if (data.linear_url) {
101
- const url = String(data.linear_url)
102
- let changed = url
103
- for (const m of url.matchAll(URL_IDENTIFIER_RE)) {
104
- const to = retarget(m[0], currentKey)
105
- if (!to) continue
106
- changed = changed.split(m[0]).join(to)
107
- note(m[0], to)
108
- }
109
- if (changed !== url) urls.push({ file: path.relative(dir, file), from: url, to: changed })
110
- }
111
- }
52
+ function runChecks(state = {}) {
53
+ const checks = [
54
+ scaffoldCheck(state.scaffold),
55
+ isolationCheck(state.isolation),
56
+ trackerCheck(state.tracker),
57
+ projectCheck(state.project, state.tracker, state.remote),
58
+ keyCheck(state.key, state.tracker),
59
+ remoteCheck(state.remote),
60
+ mcpCheck(state.mcp, state.tracker, state.project, state.remote),
61
+ ]
62
+ // `missing` is a declined opt-in, so it must not fail the run. Only a
63
+ // configured-but-wrong layer does.
64
+ return { ok: !checks.some((c) => c.state === 'broken'), checks }
65
+ }
112
66
 
113
- const snapshots = []
114
- const snapshotKeys = []
115
- const baseDir = path.resolve(dir, config.sync.baseDir)
116
- let snapshotNames = []
117
- try {
118
- snapshotNames = fs.readdirSync(baseDir).filter((f) => f.endsWith('.base.json'))
119
- } catch {
120
- /* no snapshots yet — nothing to retarget */
67
+ function scaffoldCheck(s = {}) {
68
+ if (!s.specsDir) {
69
+ return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
121
70
  }
122
- for (const name of snapshotNames.sort()) {
123
- const to = retarget(name.slice(0, -'.base.json'.length), currentKey)
124
- if (to) {
125
- snapshots.push({ from: name, to: `${to}.base.json` })
126
- note(name.slice(0, -'.base.json'.length), to)
127
- }
128
- // A snapshot's `subIssues` map is KEYED BY IDENTIFIER, so a rename strands
129
- // every key inside the file as well as the filename. Missing these made the
130
- // first scan report 59 refs where the repo really carries ~198: the bulk of
131
- // a linked repo's identifiers live in here, not in frontmatter. The hashes
132
- // are content-derived and stay valid — only their keys move.
133
- let body
134
- try {
135
- body = JSON.parse(fs.readFileSync(path.join(baseDir, name), 'utf-8'))
136
- } catch {
137
- continue
138
- }
139
- for (const ident of Object.keys((body && body.subIssues) || {})) {
140
- const keyTo = retarget(ident, currentKey)
141
- if (!keyTo) continue
142
- snapshotKeys.push({ file: path.join(config.sync.baseDir, name), from: ident, to: keyTo })
143
- note(ident, keyTo)
144
- }
145
- }
146
-
147
- // Prose MENTIONS of a stale identifier — `(REU-61)` beside a task, "the REU-196
148
- // spec claimed …". These are human-written references, not functional stamps,
149
- // and repair deliberately leaves them alone: rewriting narrative text is a
150
- // different risk class, and an identifier-shaped token in prose need not be a
151
- // Linear ref at all. They are counted anyway so the report cannot imply a
152
- // `--write` left the repo fully retargeted when ~145 mentions still say REU.
71
+ // A LIFECYCLE BUCKET IS NOT CHECKED, deliberately. git does not track empty
72
+ // directories, so `specs/in-progress/` disappears whenever no spec is in
73
+ // progress and returns the moment one starts — every lifecycle skill runs
74
+ // `mkdir -p` before it moves a spec. Checking for it reported a healthy repo
75
+ // as broken, and exited 1 under any skill branching on the code.
153
76
  //
154
- // Only prefixes that actually appear in the repo's STAMPS are counted, so an
155
- // unrelated `ABC-123` in prose is never mistaken for a drifted ref.
156
- const staleKeys = new Set([...seen.keys()].map((k) => k.split('-')[0]))
157
- const mentions = []
158
- if (staleKeys.size) {
159
- for (const file of specMarkdownFiles(dir)) {
160
- let raw
161
- try {
162
- raw = fs.readFileSync(file, 'utf-8')
163
- } catch {
164
- continue
165
- }
166
- const { body } = parseFrontmatter(raw)
167
- for (const m of String(body).matchAll(URL_IDENTIFIER_RE)) {
168
- if (!staleKeys.has(m[1]) || m[1] === currentKey) continue
169
- mentions.push({ file: path.relative(dir, file), from: m[0], to: `${currentKey}-${m[2]}` })
170
- }
171
- }
77
+ // `.core` is the signal that survives: `init` always writes the config
78
+ // templates and the manifest into it, so it is never an empty directory.
79
+ if (!s.core) {
80
+ return row(
81
+ 'scaffold',
82
+ 'scaffold',
83
+ 'broken',
84
+ 'specs/ exists but specs/.core/ is missing — a half-installed scaffold',
85
+ 'skitterspec init --resync',
86
+ )
172
87
  }
173
-
174
- const configured = (config.linear && config.linear.teamKey) || ''
175
- const configDrift = configured && configured !== currentKey ? { from: configured, to: currentKey } : null
176
-
177
- return {
178
- stamps,
179
- urls,
180
- snapshots,
181
- snapshotKeys,
182
- mentions,
183
- config: configDrift,
184
- refs: [...seen.entries()].map(([from, to]) => ({ from, to })).sort((a, b) => a.from.localeCompare(b.from)),
88
+ if (!s.skills) {
89
+ return row('scaffold', 'scaffold', 'broken', 'specs/ exists but no skills are installed', 'skitterspec init --resync')
185
90
  }
91
+ return row('scaffold', 'scaffold', 'ok', `specs/ + ${s.skills} skills installed`)
186
92
  }
187
93
 
188
- // True when a scan found nothing to repair.
189
- function isClean(drift) {
190
- return (
191
- !drift.stamps.length &&
192
- !drift.urls.length &&
193
- !drift.snapshots.length &&
194
- !drift.snapshotKeys.length &&
195
- !drift.config
196
- )
94
+ // Isolation and tracker have NO false-positive mode, and no test is added for
95
+ // one: each only says `broken` on positive evidence — a file that is present and
96
+ // does not parse, or a config that parses and holds no teamId. Absence is
97
+ // reported as `missing`, an opt-in not taken, which never fails the run.
98
+ function isolationCheck(s = {}) {
99
+ if (!s.present) {
100
+ return row('isolation', 'isolation', 'missing', 'not enabled — every spec builds in place', 'skitterspec init --isolation')
101
+ }
102
+ if (!s.parsed) {
103
+ return row('isolation', 'isolation', 'broken', s.error || 'env.config.json does not parse', 'fix specs/.core/env.config.json')
104
+ }
105
+ return row('isolation', 'isolation', 'ok', 'env.config.json — worktree per spec')
197
106
  }
198
107
 
199
- // How many distinct files a repair would touch.
200
- function fileCount(drift) {
201
- return new Set([...drift.stamps.map((s) => s.file), ...drift.urls.map((u) => u.file)]).size
108
+ function trackerCheck(s = {}) {
109
+ if (!s.present) {
110
+ return row('tracker', 'tracker', 'missing', 'no linear.config.json sync is opt-in', '/spec-linear-setup')
111
+ }
112
+ if (!s.parsed) {
113
+ return row('tracker', 'tracker', 'broken', s.error || 'linear.config.json does not parse', '/spec-linear-setup')
114
+ }
115
+ if (!s.teamId) {
116
+ // Configured but unusable: every Linear call needs the team id.
117
+ return row('tracker', 'tracker', 'broken', 'linear.config.json has no linear.teamId', '/spec-linear-setup')
118
+ }
119
+ const team = s.teamKey ? `${s.teamId} (${s.teamKey})` : s.teamId
120
+ return row('tracker', 'tracker', 'ok', `linear.config.json — team ${team}`)
202
121
  }
203
122
 
204
- // --- repair ------------------------------------------------------------------
123
+ // BLIND SPOT: `s.ok` collapses three sources — the env var, the store, and a
124
+ // `keyCommand` the store runs. An absent env var is not an absent key, and the
125
+ // caller resolves all three before this sees it. `s.error` carries WHY when one
126
+ // of them failed; passing it through is what keeps a broken keyCommand from
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
+ }
205
147
 
206
- // `git status --porcelain` over `dir`: [] when clean, the offending lines when
207
- // dirty, null when this is not a git repo at all.
208
- function dirtyPaths(dir) {
209
- let out
210
- try {
211
- out = execFileSync('git', ['-C', dir, 'status', '--porcelain'], {
212
- stdio: ['ignore', 'pipe', 'ignore'],
213
- })
214
- .toString()
215
- .trim()
216
- } catch {
217
- return null
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
+ )
218
162
  }
219
- return out ? out.split('\n') : []
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}`)
220
173
  }
221
174
 
222
- // Rewrite identifier tokens INSIDE a file's frontmatter block only.
223
- //
224
- // Scoped to the frontmatter on purpose: the same token appears in spec prose,
225
- // which repair deliberately leaves alone. A blind whole-file replace would
226
- // rewrite narrative text as a side effect of fixing a stamp.
227
- function rewriteFrontmatter(raw, replacements) {
228
- const m = /^(---\n[\s\S]*?\n---)(\n[\s\S]*)?$/.exec(raw)
229
- if (!m) return raw
230
- let head = m[1]
231
- for (const [from, to] of replacements) head = head.split(from).join(to)
232
- return head + (m[2] || '')
175
+ function keyCheck(s = {}, tracker = {}) {
176
+ // Without a tracker there is nothing for a key to authenticate, so asking for
177
+ // one would be noise.
178
+ if (!tracker.present) return row('key', 'key', 'skipped', 'no tracker configured')
179
+ if (!s.ok) {
180
+ return row(
181
+ 'key',
182
+ 'key',
183
+ 'missing',
184
+ s.error || `no key for ${tracker.teamKey || tracker.teamId || 'this team'}`,
185
+ 'skitterspec spec-sync credentials set',
186
+ )
187
+ }
188
+ // Masked fingerprint and source only — never the value.
189
+ return row('key', 'key', 'ok', `${s.fingerprint || 'set'} from ${s.source || 'unknown'}`)
233
190
  }
234
191
 
235
- // Move a file, preferring `git mv` so history survives. Falls back to a plain
236
- // rename when the file is untracked (git mv refuses those) or git is absent.
237
- function moveFile(dir, from, to) {
238
- try {
239
- execFileSync('git', ['-C', dir, 'mv', from, to], { stdio: ['ignore', 'ignore', 'ignore'] })
240
- return 'git mv'
241
- } catch {
242
- fs.renameSync(path.join(dir, from), path.join(dir, to))
243
- return 'rename'
192
+ function remoteCheck(s = {}) {
193
+ if (!s.checked) {
194
+ return row('remote', 'remote', 'skipped', 'pass --check-remote to verify against Linear')
195
+ }
196
+ // Asked for, but the check did not run either there was nothing to ask WITH
197
+ // (no key, no team id; the row that owns that already reported it), or the
198
+ // request never got an answer (unreachable, rate-limited). Neither says this
199
+ // project is misconfigured, and `broken` here exits 1 for every skill
200
+ // branching on the code. The caller decides which failures land here.
201
+ if (s.skipped) {
202
+ return row('remote', 'remote', 'skipped', s.reason || 'nothing to check against')
203
+ }
204
+ if (!s.ok) {
205
+ // Well-formed config is not working config: the id may not resolve, or the
206
+ // key may be revoked. Either way this is configured-but-wrong.
207
+ //
208
+ // `reason` is composed by the caller from a CLASSIFIED failure, never from a
209
+ // raw API message — an error body can echo the request back, and this is the
210
+ // command a skill prints.
211
+ return row('remote', 'remote', 'broken', s.reason || 'Linear did not accept the request', s.fix || 'skitterspec spec-sync credentials set')
212
+ }
213
+ if (s.teamKey && s.recordedKey && s.teamKey !== s.recordedKey) {
214
+ // The team resolved and the key worked — but it is not the team this repo
215
+ // thinks it files into. That is a rename, and every stamped identifier in
216
+ // the repo is now stale.
217
+ return row(
218
+ 'remote',
219
+ 'remote',
220
+ 'broken',
221
+ `team resolves as ${s.teamKey}, but the config records ${s.recordedKey} — the team was renamed`,
222
+ 'skitterspec spec-sync retarget',
223
+ )
244
224
  }
225
+ return row('remote', 'remote', 'ok', `team ${s.teamKey} resolves, key accepted`)
245
226
  }
246
227
 
247
228
  /**
248
- * Apply a scan's repairs. Everything moves together config, stamps, snapshot
249
- * filenames and the identifier keys inside them — because a half-repaired repo
250
- * is harder to reason about than an un-repaired one.
229
+ * Do the two transports point at the same place?
251
230
  *
252
- * `skip` is the set of `from` identifiers that resolve to NO issue under the new
253
- * key. Those are left exactly as they are: repair fixes what is provably
254
- * repairable, and reports the rest rather than inventing a target.
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.
255
235
  *
256
- * Prose mentions are never touched see `scanDrift`.
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.
257
247
  */
258
- function repairDrift(dir, config, drift, { skip = new Set() } = {}) {
259
- const keep = (r) => !skip.has(r.from)
260
- const changed = { files: [], snapshots: [], config: false, skipped: 0 }
261
-
262
- // 1. Frontmatter stamps and urls, one pass per file.
263
- const byFile = new Map()
264
- for (const r of [...drift.stamps, ...drift.urls]) {
265
- if (!keep(r)) {
266
- changed.skipped++
267
- continue
268
- }
269
- if (!byFile.has(r.file)) byFile.set(r.file, [])
270
- byFile.get(r.file).push([r.from, r.to])
271
- }
272
- for (const [rel, replacements] of byFile) {
273
- const abs = path.join(dir, rel)
274
- const raw = fs.readFileSync(abs, 'utf-8')
275
- const next = rewriteFrontmatter(raw, replacements)
276
- if (next !== raw) {
277
- fs.writeFileSync(abs, next, 'utf-8')
278
- changed.files.push(rel)
279
- }
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')
280
251
  }
281
252
 
282
- // 2. Snapshot sub-issue keys, rewritten BEFORE the filename moves so the path
283
- // being read is still the one the scan recorded.
284
- const keysByFile = new Map()
285
- for (const k of drift.snapshotKeys) {
286
- if (!keep(k)) {
287
- changed.skipped++
288
- continue
289
- }
290
- if (!keysByFile.has(k.file)) keysByFile.set(k.file, [])
291
- keysByFile.get(k.file).push(k)
292
- }
293
- for (const [rel, keys] of keysByFile) {
294
- const abs = path.join(dir, rel)
295
- const body = JSON.parse(fs.readFileSync(abs, 'utf-8'))
296
- const subIssues = {}
297
- // The hashes are CONTENT-derived, so they survive a rename untouched — only
298
- // the keys move. Rebuilt rather than mutated so key order stays stable.
299
- for (const [ident, hash] of Object.entries(body.subIssues || {})) {
300
- const hit = keys.find((k) => k.from === ident)
301
- subIssues[hit ? hit.to : ident] = hash
302
- }
303
- fs.writeFileSync(abs, JSON.stringify({ ...body, subIssues }, null, 2) + '\n', 'utf-8')
304
- }
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
+ ]
305
259
 
306
- // 3. Snapshot filenames.
307
- const baseRel = config.sync.baseDir
308
- for (const snap of drift.snapshots) {
309
- const ident = snap.from.slice(0, -'.base.json'.length)
310
- if (skip.has(ident)) {
311
- changed.skipped++
312
- continue
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
+ )
313
275
  }
314
- const how = moveFile(dir, path.join(baseRel, snap.from), path.join(baseRel, snap.to))
315
- changed.snapshots.push({ ...snap, how })
316
276
  }
317
277
 
318
- // 4. The config key, last: it is the thing that makes the next scan read
319
- // clean, so it should not flip before the files it describes have moved.
320
- if (drift.config) {
321
- const file = path.join(dir, 'specs', '.core', 'linear.config.json')
322
- const raw = fs.readFileSync(file, 'utf-8')
323
- // Textual, not parse-and-restringify: the config is hand-edited and carries
324
- // comments and ordering a JSON round-trip would silently discard.
325
- fs.writeFileSync(file, raw.replace(/("teamKey"\s*:\s*")([^"]*)(")/, `$1${drift.config.to}$3`), 'utf-8')
326
- changed.config = true
278
+ if (!checked.length) {
279
+ return row('mcp', 'mcp', 'skipped', 'the --mcp file names nothing that can be compared yet')
327
280
  }
328
-
329
- return changed
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`)
330
283
  }
331
284
 
332
- module.exports = {
333
- scanDrift,
334
- isClean,
335
- fileCount,
336
- retarget,
337
- specMarkdownFiles,
338
- dirtyPaths,
339
- repairDrift,
340
- rewriteFrontmatter,
341
- }
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
+
289
+ module.exports = { runChecks, STATES }
@@ -22,6 +22,7 @@ const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
22
22
  const { detectLegacyMirror } = require('./src/legacy.js')
23
23
  const { compareStored } = require('./src/verify.js')
24
24
  const { flattenNestedTables } = require('./src/tables.js')
25
+ const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
25
26
 
26
27
  module.exports = {
27
28
  normalizeLocal,
@@ -51,4 +52,9 @@ module.exports = {
51
52
  detectLegacyMirror,
52
53
  compareStored,
53
54
  flattenNestedTables,
55
+ planRetarget,
56
+ applyRetarget,
57
+ deriveRecordedKey,
58
+ isEmptyRetarget,
59
+ dirtyPaths,
54
60
  }