@skitterbyte/skitterspec-linear 9.1.0 → 10.0.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/src/init.js CHANGED
@@ -53,7 +53,7 @@ const CORE_FILES = listCoreTemplates()
53
53
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
54
54
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
55
55
 
56
- const report = { created: [], updated: [], skipped: [], removed: [], customized: [], warnings: [] }
56
+ const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
57
57
 
58
58
  function resetReport() {
59
59
  for (const k of Object.keys(report)) report[k].length = 0
@@ -82,6 +82,8 @@ function ensureDir(p) {
82
82
  // old version we own" (safe to update) from "a file the user edited" (keep). It
83
83
  // lists only managed FILES (skills, rules, .core templates) — never user content.
84
84
 
85
+ const { linesDiff } = require('./lines-diff.js')
86
+
85
87
  const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
86
88
  const MANIFEST_VERSION = 1
87
89
 
@@ -132,13 +134,24 @@ function writeManifest(dir, files) {
132
134
 
133
135
  // Classify a managed file against the manifest baseline.
134
136
  // missing — not on disk
135
- // pristine — on disk and matches the hash we recorded (ours to update)
136
- // customized — on disk but differs (or unknown) — a user edit; keep it
137
- function managedState(dir, relPath, manifest) {
137
+ // pristine — ours to update: it matches the package asset, or the hash we recorded
138
+ // customized — on disk but differs from both — a user edit; keep it
139
+ //
140
+ // `bundled` (the current package asset) is optional but decisive: a file whose
141
+ // CONTENT equals what we ship is not customized, whatever the manifest says.
142
+ // Without that check a stale hash pinned the file out of updates permanently —
143
+ // anything that changed it out-of-band (an errant tool, a partial restore, a
144
+ // manifest lost and re-seeded at the wrong version) froze it for good, silently.
145
+ // Comparing content first makes the tool self-healing after any restore.
146
+ // `pruneRetiredManaged` passes no `bundled` on purpose: the package no longer
147
+ // ships that file, so there is nothing to compare it against.
148
+ function managedState(dir, relPath, manifest, bundled) {
138
149
  const abs = path.join(dir, relPath)
139
150
  if (!fs.existsSync(abs)) return 'missing'
151
+ const onDisk = fs.readFileSync(abs, 'utf8')
152
+ if (bundled !== undefined && onDisk === bundled) return 'pristine'
140
153
  const known = manifest.files[relPath]
141
- return known && sha1(fs.readFileSync(abs, 'utf8')) === known ? 'pristine' : 'customized'
154
+ return known && sha1(onDisk) === known ? 'pristine' : 'customized'
142
155
  }
143
156
 
144
157
  // Reconcile and persist the manifest after an install/resync run: keep prior
@@ -405,7 +418,7 @@ function isExistingSetup(dir) {
405
418
  // update; customized (edited) → keep + report, unless `force`.
406
419
  function resyncManagedFile(dir, target, manifest, force) {
407
420
  const { relPath, abs, bundled } = target
408
- const state = managedState(dir, relPath, manifest)
421
+ const state = managedState(dir, relPath, manifest, bundled)
409
422
  const write = (bucket) => {
410
423
  ensureDir(path.dirname(abs))
411
424
  fs.writeFileSync(abs, bundled)
@@ -416,17 +429,25 @@ function resyncManagedFile(dir, target, manifest, force) {
416
429
  if (state === 'customized') {
417
430
  if (force) return write('updated')
418
431
  writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
419
- return report.customized.push(relPath)
432
+ // Carry the change the user just DECLINED. A bare filename tells them a
433
+ // decision was made on their behalf but not what it was, which leaves
434
+ // "clobber and re-apply my edits by hand" as the only safe way to upgrade.
435
+ const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
436
+ return report.customized.push({ relPath, added, removed, hunks })
420
437
  }
421
438
  // pristine — update only if the bundled content actually changed
422
439
  if (fs.readFileSync(abs, 'utf8') === bundled) {
440
+ // The file is ours and current, but the manifest disagreed — record the
441
+ // repair rather than healing in silence: a file that quietly starts
442
+ // updating again is as opaque as one that quietly stopped.
443
+ if (manifest.files[relPath] !== sha1(bundled)) report.healed.push(relPath)
423
444
  writtenHashes[relPath] = sha1(bundled)
424
445
  return report.skipped.push(relPath)
425
446
  }
426
447
  write('updated')
427
448
  }
428
449
 
429
- function resync(dir, { force = false, claudeMd = true } = {}) {
450
+ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
430
451
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
431
452
  resetReport()
432
453
  const manifest = readManifest(dir)
@@ -436,7 +457,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
436
457
  pruneRetiredManaged(dir, manifest)
437
458
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
438
459
  flushManifest(dir)
439
- printReport(dir, 'resync')
460
+ printReport(dir, 'resync', { diff })
440
461
  }
441
462
 
442
463
  // The never-touch set: START AGAIN may only delete a known managed file, and may
@@ -502,7 +523,7 @@ function reset(dir, { claudeMd = true } = {}) {
502
523
  printReport(dir, 'reset')
503
524
  }
504
525
 
505
- function printReport(dir, mode) {
526
+ function printReport(dir, mode, { diff = false } = {}) {
506
527
  const line = (label, items) => {
507
528
  if (!items.length) return
508
529
  process.stdout.write(`\n${label}:\n`)
@@ -512,12 +533,25 @@ function printReport(dir, mode) {
512
533
  line('created', report.created)
513
534
  line('updated', report.updated)
514
535
  line('removed', report.removed)
515
- line('customized (kept)', report.customized)
536
+ line(
537
+ 'customized (kept)',
538
+ report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
539
+ )
540
+ line('manifest repaired', report.healed)
516
541
  line('unchanged', report.skipped)
517
542
  if (report.warnings.length) {
518
543
  process.stdout.write('\nwarnings:\n')
519
544
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
520
545
  }
546
+ if (diff) {
547
+ for (const c of report.customized) {
548
+ if (!c.hunks.length) continue
549
+ process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
550
+ for (const h of c.hunks) process.stdout.write(`${h}\n`)
551
+ }
552
+ } else if (report.customized.length) {
553
+ process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
554
+ }
521
555
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
522
556
  const isolationNote = isolationOn
523
557
  ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * A minimal line diff — just enough for `update` to say what it skipped.
5
+ *
6
+ * `update` reports a file it kept as `customized (kept)` and nothing else, so
7
+ * there is no way to learn WHICH upstream changes you declined without diffing
8
+ * against `node_modules` by hand. That is how a real behavioural change (the
9
+ * lifecycle skills learning to commit their own edits) went unnoticed through an
10
+ * upgrade in the field.
11
+ *
12
+ * Zero dependencies on purpose: this package ships with none, and `diff(1)` is
13
+ * not a portable guarantee. An LCS over lines is a few dozen lines of code and
14
+ * the inputs are markdown files of a few hundred lines.
15
+ */
16
+
17
+ // Longest-common-subsequence walk over two line arrays, as a flat op list.
18
+ // `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
19
+ function diffOps(a, b) {
20
+ const n = a.length
21
+ const m = b.length
22
+ // dp[i][j] = LCS length of a[i..] and b[j..], flattened.
23
+ const dp = new Int32Array((n + 1) * (m + 1))
24
+ const at = (i, j) => i * (m + 1) + j
25
+ for (let i = n - 1; i >= 0; i--) {
26
+ for (let j = m - 1; j >= 0; j--) {
27
+ dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
28
+ }
29
+ }
30
+
31
+ const ops = []
32
+ let i = 0
33
+ let j = 0
34
+ while (i < n && j < m) {
35
+ if (a[i] === b[j]) {
36
+ ops.push({ t: ' ', line: a[i], a: i, b: j })
37
+ i++
38
+ j++
39
+ } else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
40
+ ops.push({ t: '-', line: a[i], a: i, b: j })
41
+ i++
42
+ } else {
43
+ ops.push({ t: '+', line: b[j], a: i, b: j })
44
+ j++
45
+ }
46
+ }
47
+ while (i < n) {
48
+ ops.push({ t: '-', line: a[i], a: i, b: j })
49
+ i++
50
+ }
51
+ while (j < m) {
52
+ ops.push({ t: '+', line: b[j], a: i, b: j })
53
+ j++
54
+ }
55
+ return ops
56
+ }
57
+
58
+ // Group the ops into unified-diff hunks, each carrying `context` unchanged lines
59
+ // either side of a run of changes. Runs closer together than 2×context merge, as
60
+ // `diff -u` does, so a cluster of edits reads as one hunk.
61
+ function toHunks(ops, context) {
62
+ const changed = ops.map((o) => o.t !== ' ')
63
+ const hunks = []
64
+ let k = 0
65
+ while (k < ops.length) {
66
+ if (!changed[k]) {
67
+ k++
68
+ continue
69
+ }
70
+ let start = Math.max(0, k - context)
71
+ let end = k
72
+ // Extend while the next change is near enough to keep in the same hunk.
73
+ for (let p = k; p < ops.length; p++) {
74
+ if (changed[p]) end = p
75
+ else if (p - end > context * 2) break
76
+ }
77
+ end = Math.min(ops.length - 1, end + context)
78
+
79
+ const body = ops.slice(start, end + 1)
80
+ const aStart = body[0].a + 1
81
+ const bStart = body[0].b + 1
82
+ const aLen = body.filter((o) => o.t !== '+').length
83
+ const bLen = body.filter((o) => o.t !== '-').length
84
+ hunks.push(
85
+ [`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
86
+ )
87
+ k = end + 1
88
+ }
89
+ return hunks
90
+ }
91
+
92
+ /**
93
+ * Diff `a` (what is on disk) against `b` (what the package ships).
94
+ *
95
+ * `added`/`removed` count the lines an update WOULD add and remove — the summary
96
+ * printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
97
+ *
98
+ * @param {string|string[]} a
99
+ * @param {string|string[]} b
100
+ * @param {{context?:number}} [opts]
101
+ * @returns {{added:number, removed:number, hunks:string[]}}
102
+ */
103
+ function linesDiff(a, b, { context = 3 } = {}) {
104
+ const A = Array.isArray(a) ? a : String(a).split('\n')
105
+ const B = Array.isArray(b) ? b : String(b).split('\n')
106
+ const ops = diffOps(A, B)
107
+ return {
108
+ added: ops.filter((o) => o.t === '+').length,
109
+ removed: ops.filter((o) => o.t === '-').length,
110
+ hunks: toHunks(ops, context),
111
+ }
112
+ }
113
+
114
+ module.exports = { linesDiff }
@@ -10,12 +10,14 @@
10
10
  *
11
11
  * spec-sync normalize <spec> print the local projection (JSON)
12
12
  * spec-sync push <spec> print the create/update PLAN the skill applies
13
+ * (requires --workspace-states; see stateCheckFailure)
14
+ * spec-sync stamp <spec> write returned ids back into the spec files
13
15
  * spec-sync record <spec> write the last-pushed snapshot (after apply)
14
16
  * spec-sync status <spec> read-only drift report (never writes)
15
17
  * spec-sync linked list every spec's linear_identifier (offline)
16
18
  *
17
- * The `/spec-push` skill: `push` → apply the plan over MCP → stamp returned ids
18
- * into the repo → `record`. There is no pull — Linear is not read for content.
19
+ * The `/spec-push` skill: `push` → apply the plan over MCP → `stamp` the returned
20
+ * ids into the repo → `record`. There is no pull — Linear is not read for content.
19
21
  */
20
22
 
21
23
  const fs = require('node:fs')
@@ -34,6 +36,11 @@ const {
34
36
  isEmptyPlan,
35
37
  remoteWorkflowState,
36
38
  validateStates,
39
+ stateSuggestions,
40
+ lintPhases,
41
+ writeFrontmatter,
42
+ stampSubIssueId,
43
+ listPhaseFiles,
37
44
  } = require('../sync-core')
38
45
 
39
46
  const { loadLinearConfig } = require('./config.js')
@@ -131,6 +138,24 @@ function specSyncLinked(dir, config, flags, out) {
131
138
  out.write(lines.join('\n') + '\n')
132
139
  }
133
140
 
141
+ // Phase-status warnings for a spec, one formatted line each.
142
+ //
143
+ // A spec states each phase's status three times — the phase file's h1 emoji, its
144
+ // `> **Status:**` line, and the overview phase-index row — and only the h1 is
145
+ // read. Get it wrong and the phase projects as `backlog`, pushes cleanly, and is
146
+ // recorded as intended: invisible. So every subcommand that reads a projection
147
+ // reports these, and none of them treats one as fatal — a legacy spec must still
148
+ // push. See sync-core `lintPhases`.
149
+ function warningLines(snapshotDir, config) {
150
+ return lintPhases(snapshotDir, config).map((w) => ` warning ${w.file}: ${w.message}`)
151
+ }
152
+
153
+ // Emit warnings on stderr, keeping stdout pure for a machine-readable payload.
154
+ function warnToErr(snapshotDir, config, err) {
155
+ const lines = warningLines(snapshotDir, config)
156
+ if (lines.length) err.write(lines.join('\n') + '\n')
157
+ }
158
+
134
159
  function resolveOrExit(specArg, dir, out) {
135
160
  if (!specArg) return null
136
161
  const snapshotDir = resolveSnapshotDir(specArg, dir)
@@ -142,26 +167,35 @@ function resolveOrExit(specArg, dir, out) {
142
167
  }
143
168
 
144
169
  // `spec-sync normalize <spec>` — print the local projection as JSON.
145
- function specSyncNormalize(dir, config, specArg, out) {
170
+ function specSyncNormalize(dir, config, specArg, out, err) {
146
171
  const snapshotDir = resolveOrExit(specArg, dir, out)
147
172
  if (!snapshotDir) return
173
+ // stdout is the projection and nothing else — callers pipe it into jq.
174
+ warnToErr(snapshotDir, config, err)
148
175
  out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
149
176
  }
150
177
 
151
178
  // `spec-sync push <spec> [--json]` — print the create/update PLAN diffed against
152
179
  // the last-pushed snapshot. Machine-readable by default; the /spec-push skill
153
180
  // applies it over MCP then calls `record`.
154
- function specSyncPush(dir, config, specArg, flags, out) {
181
+ function specSyncPush(dir, config, specArg, flags, out, err) {
155
182
  const snapshotDir = resolveOrExit(specArg, dir, out)
156
- if (!snapshotDir) return
183
+ if (!snapshotDir) return 1
184
+ const failure = stateCheckFailure(config, flags)
185
+ if (failure) {
186
+ out.write(failure.join('\n') + '\n')
187
+ return 1
188
+ }
157
189
  const identifier = specIdentifier(snapshotDir, config)
158
190
  const r = push({ dir, snapshotDir, identifier, config })
159
191
  if (flags.json || !out.isTTY) {
192
+ warnToErr(snapshotDir, config, err)
160
193
  out.write(JSON.stringify(r.plan, null, 2) + '\n')
161
- return
194
+ return 0
162
195
  }
163
196
  const p = r.plan
164
- const lines = [`spec-sync push: ${identifier}`]
197
+ const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
198
+ if (p.legacy) lines.push(...legacyLines(p.legacy))
165
199
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
166
200
  else {
167
201
  if (p.issue) lines.push(' issue: description/state')
@@ -170,6 +204,167 @@ function specSyncPush(dir, config, specArg, flags, out) {
170
204
  lines.push(' (run with --json for the full plan the skill applies)')
171
205
  }
172
206
  out.write(lines.join('\n') + '\n')
207
+ return 0
208
+ }
209
+
210
+ // The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
211
+ // ordinary — an all-creates plan for a spec that reads as unlinked — and
212
+ // applying it mints a second mirror and abandons the first.
213
+ function legacyLines(legacy) {
214
+ const found = legacy.keys.length ? legacy.keys.join(', ') : 'a pre-9.0 last-pushed snapshot'
215
+ const where = legacy.files.length ? ` in ${legacy.files.join(', ')}` : ''
216
+ const out = [
217
+ ' !! PRE-9.0 MIRROR — do not apply this plan as-is',
218
+ ` found ${found}${where}`,
219
+ ]
220
+ if (legacy.orphans) {
221
+ const o = legacy.orphans
222
+ out.push(
223
+ ` applying it would orphan ${o.total} live object(s): ` +
224
+ `${o.projects} project(s), ${o.milestones} milestone(s), ${o.issues} task issue(s)`,
225
+ )
226
+ }
227
+ out.push(' migrate first — see MIGRATION.md ("v8 → v9")')
228
+ return out
229
+ }
230
+
231
+ /**
232
+ * Validate the configured `states` names against the workspace, for a command
233
+ * that REFUSES without them.
234
+ *
235
+ * The engine is offline — `mcp.js` is the skill's adapter, not ours — so the
236
+ * names have to be fetched over MCP and handed in via `--workspace-states`. That
237
+ * handoff used to be advisory: `/spec-push` told the agent to run it against
238
+ * `status`, and skipping it sent a state name Linear **silently ignores** (the
239
+ * description lands, the issue never moves, nothing errors). Requiring the file
240
+ * turns the one check that catches it from a convention into a precondition.
241
+ *
242
+ * Returns null when the caller may proceed, or the lines to print before exiting
243
+ * non-zero.
244
+ */
245
+ function stateCheckFailure(config, flags) {
246
+ if (flags.skipStateCheck) return null
247
+ if (!flags.workspaceStates) {
248
+ return [
249
+ 'spec-sync push: refusing — the configured issue states have not been validated',
250
+ ' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
251
+ ' workflow-state names, which /spec-push fetches over MCP), or',
252
+ ' --skip-state-check to push anyway.',
253
+ ' Linear silently ignores an unknown issue state: the push would look',
254
+ ' clean and the issue would never move.',
255
+ ]
256
+ }
257
+ if (!fs.existsSync(flags.workspaceStates)) {
258
+ return [`spec-sync push: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
259
+ }
260
+ let names
261
+ try {
262
+ names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
263
+ } catch (error) {
264
+ return [`spec-sync push: refusing — --workspace-states is not valid JSON: ${error.message}`]
265
+ }
266
+ const list = Array.isArray(names) ? names : []
267
+ const missing = validateStates(config, list)
268
+ if (missing.length) {
269
+ // Say what IS available, and what to use instead. "Done is not a state" sends
270
+ // you to the Linear UI to go and look; naming the replacement does not.
271
+ const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
272
+ for (const { bucket, configured, suggestion } of stateSuggestions(config, list)) {
273
+ lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
274
+ if (suggestion) lines.push(` use "${suggestion}" instead`)
275
+ }
276
+ lines.push(
277
+ '',
278
+ ` available: ${list.join(', ') || '(the workspace reported none)'}`,
279
+ ' Fix specs/.core/linear.config.json → states. Linear silently ignores an',
280
+ ' unknown issue state, so this would have pushed clean and moved nothing.',
281
+ )
282
+ return lines
283
+ }
284
+ return null
285
+ }
286
+
287
+ // A tracker id as it appears in a spec: `SKI-42`. Deliberately strict — the
288
+ // whole point of `stamp` is that a mistyped id is caught here rather than
289
+ // re-minting a duplicate issue on the next push.
290
+ const ID_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
291
+
292
+ // Resolve a `--sub` ref to a phase file in the spec folder. Accepts the ref as
293
+ // the plan emits it (`01-outbox`) or with its extension (`01-outbox.md`).
294
+ function resolvePhaseFile(snapshotDir, ref) {
295
+ const want = String(ref).replace(/\.md$/, '')
296
+ return listPhaseFiles(snapshotDir).find((f) => f.replace(/\.md$/, '') === want) || null
297
+ }
298
+
299
+ /**
300
+ * `spec-sync stamp <spec> --issue KEY-N [--url URL] --sub <ref>=KEY-M …`
301
+ *
302
+ * Write the ids a push just returned back into the spec: `linear_identifier` /
303
+ * `linear_url` onto the overview, `linear_issue_id` onto each phase file. This
304
+ * was prose in `/spec-push` telling the agent to hand-edit N files — the step
305
+ * most likely to go wrong at scale, because one mistyped id makes the next push
306
+ * treat the phase as unlinked and mint a duplicate issue.
307
+ *
308
+ * Validates EVERYTHING before writing ANYTHING: a bad ref or id fails the whole
309
+ * command with nothing touched. A half-stamped spec is worse than an unstamped
310
+ * one — it looks linked while pointing at the wrong object.
311
+ *
312
+ * `record` stays a separate call: this writes the repo, that writes the snapshot,
313
+ * and the skill sequences them.
314
+ */
315
+ function specSyncStamp(dir, config, specArg, flags, out) {
316
+ const snapshotDir = resolveOrExit(specArg, dir, out)
317
+ if (!snapshotDir) return 1
318
+
319
+ const problems = []
320
+ if (flags.issue != null && !ID_RE.test(flags.issue)) {
321
+ problems.push(`--issue ${flags.issue} is not an id like SKI-42`)
322
+ }
323
+ if (flags.url != null && !/^https?:\/\//.test(flags.url)) {
324
+ problems.push(`--url ${flags.url} is not an http(s) URL`)
325
+ }
326
+
327
+ const subs = []
328
+ for (const raw of flags.subs) {
329
+ const eq = String(raw).indexOf('=')
330
+ if (eq === -1) {
331
+ problems.push(`--sub ${raw} is not <ref>=<id> (e.g. --sub 01-outbox=SKI-43)`)
332
+ continue
333
+ }
334
+ const ref = raw.slice(0, eq)
335
+ const id = raw.slice(eq + 1)
336
+ const file = resolvePhaseFile(snapshotDir, ref)
337
+ if (!file) problems.push(`--sub ${ref}: no phase file in ${path.relative(dir, snapshotDir)}`)
338
+ if (!ID_RE.test(id)) problems.push(`--sub ${ref}=${id}: not an id like SKI-42`)
339
+ if (file && ID_RE.test(id)) subs.push({ ref, id, file })
340
+ }
341
+
342
+ if (!problems.length && flags.issue == null && !subs.length) {
343
+ problems.push('nothing to stamp — pass --issue and/or --sub <ref>=<id>')
344
+ }
345
+
346
+ if (problems.length) {
347
+ // Every problem at once: fixing them one round-trip at a time is the same
348
+ // slow hand-editing this command replaces.
349
+ out.write(['spec-sync stamp: refusing to write — nothing was changed', ...problems.map((p) => ` ${p}`)].join('\n') + '\n')
350
+ return 1
351
+ }
352
+
353
+ const lines = [`spec-sync stamp: ${path.relative(dir, snapshotDir)}`]
354
+ if (flags.issue != null || flags.url != null) {
355
+ const written = writeFrontmatter(snapshotDir, config, {
356
+ linear_identifier: flags.issue,
357
+ linear_url: flags.url,
358
+ })
359
+ lines.push(` overview: ${written.join(', ')}`)
360
+ }
361
+ for (const s of subs) {
362
+ stampSubIssueId(snapshotDir, s.file, s.id)
363
+ lines.push(` ${s.file}: linear_issue_id = ${s.id}`)
364
+ }
365
+ lines.push(' next: skitterspec spec-sync record <spec>')
366
+ out.write(lines.join('\n') + '\n')
367
+ return 0
173
368
  }
174
369
 
175
370
  // `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
@@ -191,7 +386,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
191
386
  const snapshotDir = resolveOrExit(specArg, dir, out)
192
387
  if (!snapshotDir) return
193
388
  const identifier = specIdentifier(snapshotDir, config)
194
- const lines = [`spec-sync status: ${identifier}`]
389
+ const lines = [`spec-sync status: ${identifier}`, ...warningLines(snapshotDir, config)]
195
390
 
196
391
  if (flags.workspaceStates && fs.existsSync(flags.workspaceStates)) {
197
392
  const names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
@@ -234,15 +429,20 @@ function specSyncStatus(dir, config, specArg, flags, out) {
234
429
 
235
430
  async function specSync(rest, io = {}) {
236
431
  const out = io.out || process.stdout
432
+ const err = io.err || process.stderr
237
433
  const [sub, ...args] = rest
238
434
  let dir = io.cwd || process.cwd()
239
435
  const positional = []
240
- const flags = { json: false, remote: null, workspaceStates: null }
436
+ const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [] }
241
437
  for (let i = 0; i < args.length; i++) {
242
438
  if (args[i] === '--dir') dir = path.resolve(args[++i])
243
439
  else if (args[i] === '--json') flags.json = true
244
440
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
245
441
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
442
+ else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
443
+ else if (args[i] === '--issue') flags.issue = args[++i]
444
+ else if (args[i] === '--url') flags.url = args[++i]
445
+ else if (args[i] === '--sub') flags.subs.push(args[++i])
246
446
  else positional.push(args[i])
247
447
  }
248
448
  dir = path.resolve(dir)
@@ -258,11 +458,12 @@ async function specSync(rest, io = {}) {
258
458
 
259
459
  switch (sub) {
260
460
  case 'normalize':
261
- specSyncNormalize(dir, config, positional[0], out)
461
+ specSyncNormalize(dir, config, positional[0], out, err)
262
462
  return 0
263
463
  case 'push':
264
- specSyncPush(dir, config, positional[0], flags, out)
265
- return 0
464
+ return specSyncPush(dir, config, positional[0], flags, out, err) || 0
465
+ case 'stamp':
466
+ return specSyncStamp(dir, config, positional[0], flags, out)
266
467
  case 'record':
267
468
  specSyncRecord(dir, config, positional[0], out)
268
469
  return 0
@@ -272,7 +473,9 @@ async function specSync(rest, io = {}) {
272
473
  specSyncLinked(dir, config, flags, out)
273
474
  return 0
274
475
  default:
275
- out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
476
+ out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
477
+ ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
478
+ ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
276
479
  ' skitterspec spec-sync linked [--json]\n')
277
480
  return 0
278
481
  }
@@ -37,6 +37,13 @@ const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
37
37
 
38
38
  const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
39
39
 
40
+ // How a phase's task list is projected into its sub-issue description.
41
+ // checklist — mirror the tasks as a read-only markdown checklist (default)
42
+ // none — sub-issue description is the phase's `**Goal:**` line alone
43
+ // Tasks are never read back either way; the repo stays the source of truth and a
44
+ // box ticked in the tracker is overwritten by the next push.
45
+ const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
46
+
40
47
  const DEFAULT_CONFIG = Object.freeze({
41
48
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
42
49
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -52,7 +59,9 @@ const DEFAULT_CONFIG = Object.freeze({
52
59
  intake: Object.freeze({ label: '', bugLabels: Object.freeze([]) }),
53
60
  // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
54
61
  // synced (they live only in the repo phase files).
55
- mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'none' }),
62
+ // A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
63
+ // phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
64
+ mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'checklist' }),
56
65
  // Linear ISSUE workflow-state names — the spec issue's state (from the folder
57
66
  // bucket) and each sub-issue's state (from the phase emoji) both map through
58
67
  // this one table. They must match the workspace's issue states exactly;
@@ -183,6 +192,15 @@ function mergeConfig(base, parsed) {
183
192
  assign(base.mapping, parsed.mapping, 'specFolder', 'string')
184
193
  assign(base.mapping, parsed.mapping, 'phases', 'string')
185
194
  assign(base.mapping, parsed.mapping, 'tasks', 'string')
195
+ // Loud on a typo, like fieldOwnership above. Quietly falling back would make
196
+ // a misspelt value look like a deliberate `none` — the same silent
197
+ // degradation the phase-status lint exists to stamp out.
198
+ if (!TASK_MAPPINGS.includes(base.mapping.tasks)) {
199
+ throw new Error(
200
+ `Invalid ${CONFIG_FILE}: mapping.tasks = ${JSON.stringify(base.mapping.tasks)} ` +
201
+ `(expected one of ${TASK_MAPPINGS.join('|')})`,
202
+ )
203
+ }
186
204
  }
187
205
 
188
206
  if (isObject(parsed.states)) {
@@ -247,4 +265,5 @@ module.exports = {
247
265
  DEFAULT_CONFIG,
248
266
  CONFIG_FILE,
249
267
  OWNERSHIP,
268
+ TASK_MAPPINGS,
250
269
  }
@@ -4,7 +4,8 @@
4
4
  * The Linear MCP boundary — the one place that knows concrete Linear tool names.
5
5
  *
6
6
  * A spec is a Linear **issue** and each phase a **sub-issue** (a child issue
7
- * with a `parentId`); tasks are not synced. `discoverLinear(tools)` resolves the
7
+ * with a `parentId`); a phase's tasks are mirrored into its sub-issue
8
+ * description, never created as issues. `discoverLinear(tools)` resolves the
8
9
  * issue operations the sync needs (read / create / update an issue, optionally
9
10
  * list a parent's children) against the *connected* server's advertised tool
10
11
  * list at runtime, rather than hardcoding names that drift. If Linear isn't
@@ -10,15 +10,17 @@
10
10
  * over its API. No remote content is read or merged.
11
11
  */
12
12
 
13
- const { normalizeLocal, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
13
+ const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
14
14
  const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
15
15
  const { readBase, writeBase } = require('./src/base.js')
16
16
  const { push, recordPush, projectionOf } = require('./src/push.js')
17
- const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
17
+ const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
18
18
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
+ const { detectLegacyMirror } = require('./src/legacy.js')
19
20
 
20
21
  module.exports = {
21
22
  normalizeLocal,
23
+ lintPhases,
22
24
  readSnapshot,
23
25
  parseFrontmatter,
24
26
  projectionOf,
@@ -30,6 +32,7 @@ module.exports = {
30
32
  remoteWorkflowState,
31
33
  titleFromText,
32
34
  validateStates,
35
+ stateSuggestions,
33
36
  hashField,
34
37
  stableStringify,
35
38
  readBase,
@@ -38,5 +41,7 @@ module.exports = {
38
41
  stampSubIssueId,
39
42
  stampIssueId,
40
43
  findPhaseFileByTitle,
44
+ listPhaseFiles,
41
45
  sanitizeSpecMarkdown,
46
+ detectLegacyMirror,
42
47
  }