@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.
@@ -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 `id`.
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
- rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started' })
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',