@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.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +43 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +48 -10
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +25 -35
- package/package.json +1 -1
- package/src/init.js +5 -0
- package/src/vendor/linear/api.js +31 -1
- package/src/vendor/linear/cli-sync.js +476 -111
- package/src/vendor/linear/doctor.js +244 -296
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/normalize.js +17 -2
- package/src/vendor/sync-core/src/retarget.js +274 -0
- package/src/vendor/sync-core/src/verify.js +5 -0
|
@@ -1,341 +1,289 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* `spec-sync doctor` — one readiness report across every layer of a setup.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
*
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
}
|