@skitterbyte/skitterspec-linear 10.3.0 → 10.5.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.
- package/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +80 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +35 -4
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +167 -0
- package/package.json +1 -1
- package/src/init.js +20 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +677 -2
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +179 -0
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
- 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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user-level credentials store — where a Linear API key lives when it is
|
|
5
|
+
* not in the environment.
|
|
6
|
+
*
|
|
7
|
+
* The repo's `specs/.core/linear.config.json` is COMMITTED and deliberately
|
|
8
|
+
* holds only the NAME of an env var, never a key. That is what makes it safe to
|
|
9
|
+
* share, so a key can never go there. This store is the alternative: one file
|
|
10
|
+
* per machine, outside every repo, at
|
|
11
|
+
* `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else `~/.config/…`), keyed by
|
|
12
|
+
* Linear team id so one file serves every checkout.
|
|
13
|
+
*
|
|
14
|
+
* Reads never throw. `resolveApiKey` already treats "no key" as a NORMAL state
|
|
15
|
+
* meaning "fall back to MCP" rather than an error, so every failure here returns
|
|
16
|
+
* a structured reason the caller can report or ignore — a missing store is not a
|
|
17
|
+
* problem, it is the default.
|
|
18
|
+
*
|
|
19
|
+
* Nothing in this module ever returns a key inside an error, and callers must
|
|
20
|
+
* keep it out of logs, plans, snapshots and stamped frontmatter.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { spawnSync } = require('node:child_process')
|
|
24
|
+
const fs = require('node:fs')
|
|
25
|
+
const os = require('node:os')
|
|
26
|
+
const path = require('node:path')
|
|
27
|
+
|
|
28
|
+
const DIR_NAME = 'skitterspec'
|
|
29
|
+
const FILE_NAME = 'credentials.json'
|
|
30
|
+
|
|
31
|
+
// Modes wider than owner-only. Checked ssh-style: a store other users can read
|
|
32
|
+
// is refused rather than read, because silently using it would hide the leak,
|
|
33
|
+
// and silently chmod'ing someone's file is not ours to do.
|
|
34
|
+
const GROUP_OR_WORLD = 0o077
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Absolute path of the store. Honours `$XDG_CONFIG_HOME`, else `~/.config`.
|
|
38
|
+
* `env` and `homedir` are injected so tests never touch a real home directory.
|
|
39
|
+
*/
|
|
40
|
+
function storePath(env = process.env, homedir = os.homedir) {
|
|
41
|
+
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()
|
|
42
|
+
const base = xdg || path.join(homedir(), '.config')
|
|
43
|
+
return path.join(base, DIR_NAME, FILE_NAME)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the store.
|
|
48
|
+
*
|
|
49
|
+
* @returns {object} `{ ok: true, store, path }` — parsed and owner-only;
|
|
50
|
+
* `{ ok: false, reason, code, path }` otherwise. `code` is one of:
|
|
51
|
+
* `absent` (no file — the normal default, not a problem), `permissions`
|
|
52
|
+
* (group/world readable), `unreadable`, `malformed`.
|
|
53
|
+
*/
|
|
54
|
+
function readStore(file, { stat = fs.statSync, read = fs.readFileSync } = {}) {
|
|
55
|
+
let info
|
|
56
|
+
try {
|
|
57
|
+
info = stat(file)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === 'ENOENT') {
|
|
60
|
+
return { ok: false, code: 'absent', path: file, reason: `no credentials store at ${file}` }
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (info.mode & GROUP_OR_WORLD) {
|
|
66
|
+
const mode = (info.mode & 0o777).toString(8)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
code: 'permissions',
|
|
70
|
+
path: file,
|
|
71
|
+
reason:
|
|
72
|
+
`credentials store ${file} is mode ${mode} — readable by other users.\n` +
|
|
73
|
+
` Run: chmod 600 ${file}`,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let raw
|
|
78
|
+
try {
|
|
79
|
+
raw = read(file, 'utf-8')
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { ok: false, code: 'unreadable', path: file, reason: `cannot read ${file}: ${err.message}` }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let store
|
|
85
|
+
try {
|
|
86
|
+
store = JSON.parse(raw)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, code: 'malformed', path: file, reason: `invalid JSON in ${file}: ${err.message}` }
|
|
89
|
+
}
|
|
90
|
+
if (!store || typeof store !== 'object' || Array.isArray(store)) {
|
|
91
|
+
return { ok: false, code: 'malformed', path: file, reason: `invalid credentials store in ${file}` }
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, store, path: file }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The key recorded for one team, or null. Never throws, and never reports the
|
|
98
|
+
* value it did or didn't find.
|
|
99
|
+
*/
|
|
100
|
+
function keyForTeam(store, teamId) {
|
|
101
|
+
if (!teamId) return null
|
|
102
|
+
const teams = store && store.teams
|
|
103
|
+
const entry = teams && typeof teams === 'object' ? teams[teamId] : null
|
|
104
|
+
if (!entry || typeof entry !== 'object') return null
|
|
105
|
+
const key = entry.key
|
|
106
|
+
return typeof key === 'string' && key.trim() ? key.trim() : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Last 4 characters, for reporting that a key exists without revealing it.
|
|
110
|
+
function fingerprint(key) {
|
|
111
|
+
if (typeof key !== 'string' || !key) return null
|
|
112
|
+
return `…${key.slice(-4)}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record a key for one team, creating the store at `0600` and its directory at
|
|
118
|
+
* `0700`. Other teams' entries are preserved.
|
|
119
|
+
*
|
|
120
|
+
* Refuses rather than writing when the existing store is unreadable or
|
|
121
|
+
* over-permissive — the same guard as reading, because silently rewriting a
|
|
122
|
+
* world-readable file would leave the leak in place.
|
|
123
|
+
*
|
|
124
|
+
* Returns `{ ok: true, path, created }` or `{ ok: false, reason }`. The key is
|
|
125
|
+
* never echoed back in either.
|
|
126
|
+
*/
|
|
127
|
+
function writeKey(file, teamId, key, deps = {}) {
|
|
128
|
+
const mkdir = deps.mkdir || fs.mkdirSync
|
|
129
|
+
const write = deps.write || fs.writeFileSync
|
|
130
|
+
const chmod = deps.chmod || fs.chmodSync
|
|
131
|
+
const exists = deps.exists || fs.existsSync
|
|
132
|
+
|
|
133
|
+
if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
|
|
134
|
+
if (typeof key !== 'string' || !key.trim()) return { ok: false, reason: 'empty key — nothing stored' }
|
|
135
|
+
|
|
136
|
+
const created = !exists(file)
|
|
137
|
+
let store = { version: 1, teams: {} }
|
|
138
|
+
if (!created) {
|
|
139
|
+
const current = readStore(file, deps)
|
|
140
|
+
if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
|
|
141
|
+
store = current.store
|
|
142
|
+
if (!store.teams || typeof store.teams !== 'object') store.teams = {}
|
|
143
|
+
if (!store.version) store.version = 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
store.teams[teamId] = { ...(store.teams[teamId] || {}), key: key.trim() }
|
|
147
|
+
|
|
148
|
+
mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
149
|
+
write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
150
|
+
// `mode` on writeFileSync only applies when the file is CREATED, so an
|
|
151
|
+
// existing file keeps its mode — narrow it explicitly.
|
|
152
|
+
chmod(file, 0o600)
|
|
153
|
+
return { ok: true, path: file, created }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Remove one team's entry, leaving every other team intact. A store or entry
|
|
158
|
+
* that isn't there is a clean no-op, not an error.
|
|
159
|
+
*/
|
|
160
|
+
function removeKey(file, teamId, deps = {}) {
|
|
161
|
+
const write = deps.write || fs.writeFileSync
|
|
162
|
+
const current = readStore(file, deps)
|
|
163
|
+
if (!current.ok) {
|
|
164
|
+
if (current.code === 'absent') return { ok: true, path: file, removed: false }
|
|
165
|
+
return { ok: false, reason: current.reason, code: current.code }
|
|
166
|
+
}
|
|
167
|
+
const teams = current.store.teams
|
|
168
|
+
if (!teams || !teams[teamId]) return { ok: true, path: file, removed: false }
|
|
169
|
+
delete teams[teamId]
|
|
170
|
+
write(file, JSON.stringify(current.store, null, 2) + '\n', { mode: 0o600 })
|
|
171
|
+
return { ok: true, path: file, removed: true }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve one team's key from the store: a stored `key` first, else running its
|
|
176
|
+
* `keyCommand` and taking stdout.
|
|
177
|
+
*
|
|
178
|
+
* `keyCommand` is honoured ONLY from this user-level store — never from the
|
|
179
|
+
* repo's committed `linear.config.json`. That file travels with the repo, so a
|
|
180
|
+
* command named there would let a cloned repo run arbitrary code on the machine
|
|
181
|
+
* of anyone who ran `spec-sync`. The repo config keeps naming only an env var.
|
|
182
|
+
*
|
|
183
|
+
* A command that fails, times out or prints nothing resolves to no key, which is
|
|
184
|
+
* the ordinary "fall back to MCP" state — but its stderr is carried back so a
|
|
185
|
+
* broken command is diagnosable rather than mysteriously inert. Its **stdout is
|
|
186
|
+
* never** put in an error: that is the key.
|
|
187
|
+
*
|
|
188
|
+
* @returns {object} `{ key, source }` with source `'store'` | `'command'`, or
|
|
189
|
+
* `{ key: null, source: null, reason }`.
|
|
190
|
+
*/
|
|
191
|
+
function resolveTeamKey(store, teamId, deps = {}) {
|
|
192
|
+
const direct = keyForTeam(store, teamId)
|
|
193
|
+
if (direct) return { key: direct, source: 'store' }
|
|
194
|
+
|
|
195
|
+
const entry = store && store.teams && typeof store.teams === 'object' ? store.teams[teamId] : null
|
|
196
|
+
const command = entry && typeof entry.keyCommand === 'string' ? entry.keyCommand.trim() : ''
|
|
197
|
+
if (!command) return { key: null, source: null }
|
|
198
|
+
|
|
199
|
+
const run = deps.run || defaultRunCommand
|
|
200
|
+
const result = run(command, deps.timeoutMs || COMMAND_TIMEOUT_MS)
|
|
201
|
+
if (!result.ok) {
|
|
202
|
+
return { key: null, source: null, command, reason: `keyCommand failed: ${result.error}` }
|
|
203
|
+
}
|
|
204
|
+
const key = (result.stdout || '').trim()
|
|
205
|
+
if (!key) {
|
|
206
|
+
return { key: null, source: null, command, reason: 'keyCommand produced no output' }
|
|
207
|
+
}
|
|
208
|
+
return { key, source: 'command', command }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 60s: a password manager may prompt for biometric or a master password, and a
|
|
212
|
+
// hang here would look like the CLI itself wedging.
|
|
213
|
+
const COMMAND_TIMEOUT_MS = 60_000
|
|
214
|
+
|
|
215
|
+
// Linear personal API keys are `lin_api_` + a long token.
|
|
216
|
+
const LITERAL_KEY_RE = /lin_api_[A-Za-z0-9]{8,}/
|
|
217
|
+
|
|
218
|
+
function defaultRunCommand(command, timeout) {
|
|
219
|
+
const r = spawnSync('sh', ['-c', command], { encoding: 'utf-8', timeout })
|
|
220
|
+
if (r.error) return { ok: false, error: r.error.message }
|
|
221
|
+
if (r.status !== 0) {
|
|
222
|
+
// stderr only — stdout is the secret, and must not reach an error message.
|
|
223
|
+
const detail = (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}`
|
|
224
|
+
return { ok: false, error: detail }
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, stdout: r.stdout }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Record a command for one team instead of a key. Unlike a key, a command is not
|
|
231
|
+
* a secret, so this one IS safe to pass as an argument.
|
|
232
|
+
*/
|
|
233
|
+
function writeKeyCommand(file, teamId, command, deps = {}) {
|
|
234
|
+
const mkdir = deps.mkdir || fs.mkdirSync
|
|
235
|
+
const write = deps.write || fs.writeFileSync
|
|
236
|
+
const chmod = deps.chmod || fs.chmodSync
|
|
237
|
+
const exists = deps.exists || fs.existsSync
|
|
238
|
+
|
|
239
|
+
if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
|
|
240
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
241
|
+
return { ok: false, reason: 'empty command — nothing stored' }
|
|
242
|
+
}
|
|
243
|
+
// Refusing `--key` makes `--command "echo lin_api_…"` the obvious workaround,
|
|
244
|
+
// and it is worse: a command is NOT treated as a secret — `status` prints it
|
|
245
|
+
// back, and it sits in the store in clear. Catch the shortcut at the door.
|
|
246
|
+
if (LITERAL_KEY_RE.test(command)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason:
|
|
250
|
+
'that command has a Linear key written into it.\n' +
|
|
251
|
+
' A command is displayed by `credentials status` and is not treated as a\n' +
|
|
252
|
+
' secret — embedding a key there exposes it. To store a key, run\n' +
|
|
253
|
+
' `credentials set` with no arguments and paste at the hidden prompt.',
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const created = !exists(file)
|
|
258
|
+
let store = { version: 1, teams: {} }
|
|
259
|
+
if (!created) {
|
|
260
|
+
const current = readStore(file, deps)
|
|
261
|
+
if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
|
|
262
|
+
store = current.store
|
|
263
|
+
if (!store.teams || typeof store.teams !== 'object') store.teams = {}
|
|
264
|
+
if (!store.version) store.version = 1
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A command REPLACES a stored key for that team — keeping both would mean the
|
|
268
|
+
// key silently wins and the command never runs.
|
|
269
|
+
store.teams[teamId] = { keyCommand: command.trim() }
|
|
270
|
+
|
|
271
|
+
mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
272
|
+
write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
273
|
+
chmod(file, 0o600)
|
|
274
|
+
return { ok: true, path: file, created }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The store's permission bits as an octal string, or null when absent. */
|
|
278
|
+
function storeMode(file, deps = {}) {
|
|
279
|
+
const stat = deps.stat || fs.statSync
|
|
280
|
+
try {
|
|
281
|
+
return (stat(file).mode & 0o777).toString(8)
|
|
282
|
+
} catch {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
storePath,
|
|
289
|
+
readStore,
|
|
290
|
+
keyForTeam,
|
|
291
|
+
fingerprint,
|
|
292
|
+
writeKey,
|
|
293
|
+
writeKeyCommand,
|
|
294
|
+
resolveTeamKey,
|
|
295
|
+
removeKey,
|
|
296
|
+
storeMode,
|
|
297
|
+
DIR_NAME,
|
|
298
|
+
FILE_NAME,
|
|
299
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `spec-sync doctor` — one readiness report across every layer of a setup.
|
|
5
|
+
*
|
|
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.
|
|
12
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const STATES = ['ok', 'missing', 'broken', 'skipped']
|
|
33
|
+
|
|
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 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
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}}
|
|
51
|
+
*/
|
|
52
|
+
function runChecks(state = {}) {
|
|
53
|
+
const checks = [
|
|
54
|
+
scaffoldCheck(state.scaffold),
|
|
55
|
+
isolationCheck(state.isolation),
|
|
56
|
+
trackerCheck(state.tracker),
|
|
57
|
+
keyCheck(state.key, state.tracker),
|
|
58
|
+
remoteCheck(state.remote),
|
|
59
|
+
]
|
|
60
|
+
// `missing` is a declined opt-in, so it must not fail the run. Only a
|
|
61
|
+
// configured-but-wrong layer does.
|
|
62
|
+
return { ok: !checks.some((c) => c.state === 'broken'), checks }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scaffoldCheck(s = {}) {
|
|
66
|
+
if (!s.specsDir) {
|
|
67
|
+
return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
|
|
68
|
+
}
|
|
69
|
+
// A LIFECYCLE BUCKET IS NOT CHECKED, deliberately. git does not track empty
|
|
70
|
+
// directories, so `specs/in-progress/` disappears whenever no spec is in
|
|
71
|
+
// progress and returns the moment one starts — every lifecycle skill runs
|
|
72
|
+
// `mkdir -p` before it moves a spec. Checking for it reported a healthy repo
|
|
73
|
+
// as broken, and exited 1 under any skill branching on the code.
|
|
74
|
+
//
|
|
75
|
+
// `.core` is the signal that survives: `init` always writes the config
|
|
76
|
+
// templates and the manifest into it, so it is never an empty directory.
|
|
77
|
+
if (!s.core) {
|
|
78
|
+
return row(
|
|
79
|
+
'scaffold',
|
|
80
|
+
'scaffold',
|
|
81
|
+
'broken',
|
|
82
|
+
'specs/ exists but specs/.core/ is missing — a half-installed scaffold',
|
|
83
|
+
'skitterspec init --resync',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (!s.skills) {
|
|
87
|
+
return row('scaffold', 'scaffold', 'broken', 'specs/ exists but no skills are installed', 'skitterspec init --resync')
|
|
88
|
+
}
|
|
89
|
+
return row('scaffold', 'scaffold', 'ok', `specs/ + ${s.skills} skills installed`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Isolation and tracker have NO false-positive mode, and no test is added for
|
|
93
|
+
// one: each only says `broken` on positive evidence — a file that is present and
|
|
94
|
+
// does not parse, or a config that parses and holds no teamId. Absence is
|
|
95
|
+
// reported as `missing`, an opt-in not taken, which never fails the run.
|
|
96
|
+
function isolationCheck(s = {}) {
|
|
97
|
+
if (!s.present) {
|
|
98
|
+
return row('isolation', 'isolation', 'missing', 'not enabled — every spec builds in place', 'skitterspec init --isolation')
|
|
99
|
+
}
|
|
100
|
+
if (!s.parsed) {
|
|
101
|
+
return row('isolation', 'isolation', 'broken', s.error || 'env.config.json does not parse', 'fix specs/.core/env.config.json')
|
|
102
|
+
}
|
|
103
|
+
return row('isolation', 'isolation', 'ok', 'env.config.json — worktree per spec')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function trackerCheck(s = {}) {
|
|
107
|
+
if (!s.present) {
|
|
108
|
+
return row('tracker', 'tracker', 'missing', 'no linear.config.json — sync is opt-in', '/spec-linear-setup')
|
|
109
|
+
}
|
|
110
|
+
if (!s.parsed) {
|
|
111
|
+
return row('tracker', 'tracker', 'broken', s.error || 'linear.config.json does not parse', '/spec-linear-setup')
|
|
112
|
+
}
|
|
113
|
+
if (!s.teamId) {
|
|
114
|
+
// Configured but unusable: every Linear call needs the team id.
|
|
115
|
+
return row('tracker', 'tracker', 'broken', 'linear.config.json has no linear.teamId', '/spec-linear-setup')
|
|
116
|
+
}
|
|
117
|
+
const team = s.teamKey ? `${s.teamId} (${s.teamKey})` : s.teamId
|
|
118
|
+
return row('tracker', 'tracker', 'ok', `linear.config.json — team ${team}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// BLIND SPOT: `s.ok` collapses three sources — the env var, the store, and a
|
|
122
|
+
// `keyCommand` the store runs. An absent env var is not an absent key, and the
|
|
123
|
+
// caller resolves all three before this sees it. `s.error` carries WHY when one
|
|
124
|
+
// of them failed; passing it through is what keeps a broken keyCommand from
|
|
125
|
+
// being reported as a key the user never set.
|
|
126
|
+
function keyCheck(s = {}, tracker = {}) {
|
|
127
|
+
// Without a tracker there is nothing for a key to authenticate, so asking for
|
|
128
|
+
// one would be noise.
|
|
129
|
+
if (!tracker.present) return row('key', 'key', 'skipped', 'no tracker configured')
|
|
130
|
+
if (!s.ok) {
|
|
131
|
+
return row(
|
|
132
|
+
'key',
|
|
133
|
+
'key',
|
|
134
|
+
'missing',
|
|
135
|
+
s.error || `no key for ${tracker.teamKey || tracker.teamId || 'this team'}`,
|
|
136
|
+
'skitterspec spec-sync credentials set',
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
// Masked fingerprint and source only — never the value.
|
|
140
|
+
return row('key', 'key', 'ok', `${s.fingerprint || 'set'} from ${s.source || 'unknown'}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function remoteCheck(s = {}) {
|
|
144
|
+
if (!s.checked) {
|
|
145
|
+
return row('remote', 'remote', 'skipped', 'pass --check-remote to verify against Linear')
|
|
146
|
+
}
|
|
147
|
+
// Asked for, but the check did not run — either there was nothing to ask WITH
|
|
148
|
+
// (no key, no team id; the row that owns that already reported it), or the
|
|
149
|
+
// request never got an answer (unreachable, rate-limited). Neither says this
|
|
150
|
+
// project is misconfigured, and `broken` here exits 1 for every skill
|
|
151
|
+
// branching on the code. The caller decides which failures land here.
|
|
152
|
+
if (s.skipped) {
|
|
153
|
+
return row('remote', 'remote', 'skipped', s.reason || 'nothing to check against')
|
|
154
|
+
}
|
|
155
|
+
if (!s.ok) {
|
|
156
|
+
// Well-formed config is not working config: the id may not resolve, or the
|
|
157
|
+
// key may be revoked. Either way this is configured-but-wrong.
|
|
158
|
+
//
|
|
159
|
+
// `reason` is composed by the caller from a CLASSIFIED failure, never from a
|
|
160
|
+
// raw API message — an error body can echo the request back, and this is the
|
|
161
|
+
// command a skill prints.
|
|
162
|
+
return row('remote', 'remote', 'broken', s.reason || 'Linear did not accept the request', s.fix || 'skitterspec spec-sync credentials set')
|
|
163
|
+
}
|
|
164
|
+
if (s.teamKey && s.recordedKey && s.teamKey !== s.recordedKey) {
|
|
165
|
+
// The team resolved and the key worked — but it is not the team this repo
|
|
166
|
+
// thinks it files into. That is a rename, and every stamped identifier in
|
|
167
|
+
// the repo is now stale.
|
|
168
|
+
return row(
|
|
169
|
+
'remote',
|
|
170
|
+
'remote',
|
|
171
|
+
'broken',
|
|
172
|
+
`team resolves as ${s.teamKey}, but the config records ${s.recordedKey} — the team was renamed`,
|
|
173
|
+
'skitterspec spec-sync retarget',
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return row('remote', 'remote', 'ok', `team ${s.teamKey} resolves, key accepted`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
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
|
}
|
|
@@ -71,7 +71,9 @@ function snapshotOf(projection) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Diff the local projection against the last-pushed snapshot.
|
|
73
73
|
* @returns {{ issue?: object, subIssues: {create,update} }}
|
|
74
|
-
* create items carry a `ref` (local handle) and no id; update items carry
|
|
74
|
+
* create items carry a `ref` (local handle) and no id; update items carry both
|
|
75
|
+
* — the `ref` because the read-back check matches sub-issues to phases BY ref,
|
|
76
|
+
* and an update with only an id makes every one of them look unmatched.
|
|
75
77
|
* `plan.issue` (when present) is the spec issue's description + state; the push
|
|
76
78
|
* skill applies `config.linear.projectId` grouping on top of it.
|
|
77
79
|
*/
|
|
@@ -85,7 +87,7 @@ function planChanges(projection, snapshot) {
|
|
|
85
87
|
if (s.id == null) {
|
|
86
88
|
subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
|
|
87
89
|
} else if (snapS[String(s.id)] !== subIssueHash(s)) {
|
|
88
|
-
subIssues.update.push({ id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
90
|
+
subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -237,7 +237,12 @@ function parsePhaseIndex(phasesSection) {
|
|
|
237
237
|
if (!/^\d+$/.test(n)) continue // skip header + separator rows
|
|
238
238
|
const name = cells[2]
|
|
239
239
|
const emoji = (cells[3].match(/[⬜🔄✅]/u) || [])[0]
|
|
240
|
-
|
|
240
|
+
// `stated` separates "the row says not-started" from "the row said nothing
|
|
241
|
+
// we recognise". A cell holding the word `Done`, an em dash, or a legacy
|
|
242
|
+
// spec's freeform text expresses no status in this vocabulary, and reading
|
|
243
|
+
// its absence as `not-started` let lintPhases quote the overview as saying
|
|
244
|
+
// something it never said.
|
|
245
|
+
rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started', stated: Boolean(emoji) })
|
|
241
246
|
}
|
|
242
247
|
return rows
|
|
243
248
|
}
|
|
@@ -419,6 +424,10 @@ function readPhaseFiles(snapshotDir) {
|
|
|
419
424
|
*/
|
|
420
425
|
function lintPhases(snapshotDir, config) {
|
|
421
426
|
const phases = readPhaseFiles(snapshotDir)
|
|
427
|
+
// BLIND SPOT: `readPhaseFiles` only sees `NN-*.md`, so a legacy bare
|
|
428
|
+
// `<name>.md` spec yields no phases at all. Silence is the right answer —
|
|
429
|
+
// there is no phase file to carry an emoji — but it is silence from having
|
|
430
|
+
// looked nowhere, not from having looked and found everything in order.
|
|
422
431
|
if (!phases.length) return []
|
|
423
432
|
|
|
424
433
|
// The overview may be absent (a legacy bare `<name>.md` spec) — that is not
|
|
@@ -459,8 +468,14 @@ function lintPhases(snapshotDir, config) {
|
|
|
459
468
|
|
|
460
469
|
// Match the index row by phase title, falling back to position — a renamed
|
|
461
470
|
// phase shouldn't silently drop the check.
|
|
471
|
+
//
|
|
472
|
+
// BLIND SPOT: the index is only evidence where it used the emoji vocabulary.
|
|
473
|
+
// A row whose Status cell holds prose (or nothing) parses as `not-started`,
|
|
474
|
+
// which is a default, not a statement — cross-checking against it accused a
|
|
475
|
+
// healthy spec of a disagreement with a value nobody wrote. `stated` is the
|
|
476
|
+
// positive signal: compare only against a status the row actually expressed.
|
|
462
477
|
const row = indexRows.find((r) => r.name === phase.name) || indexRows[i]
|
|
463
|
-
if (row && row.status !== heading) {
|
|
478
|
+
if (row && row.stated && row.status !== heading) {
|
|
464
479
|
warnings.push({
|
|
465
480
|
file: phase.file,
|
|
466
481
|
code: 'status-disagreement',
|