@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/MIGRATION.md +156 -0
- package/README.md +34 -4
- package/assets/core/SETUP.md +23 -6
- package/assets/core/linear.config.json.example +1 -1
- package/assets/core/linear.config.md +25 -6
- package/assets/skills/spec/SKILL.md +14 -12
- package/assets/skills/spec-push/SKILL.md +63 -25
- package/assets/skills/spec-review/SKILL.md +13 -0
- package/bin/skitterspec-linear.js +5 -1
- package/package.json +3 -2
- package/src/cli.js +6 -2
- package/src/init.js +45 -11
- package/src/lines-diff.js +114 -0
- package/src/vendor/linear/cli-sync.js +216 -13
- package/src/vendor/linear/config.js +20 -1
- package/src/vendor/linear/mcp.js +2 -1
- package/src/vendor/sync-core/index.js +7 -2
- package/src/vendor/sync-core/src/legacy.js +90 -0
- package/src/vendor/sync-core/src/normalize.js +188 -7
- package/src/vendor/sync-core/src/push.js +7 -0
- package/src/vendor/sync-core/src/task-block.js +87 -33
- package/src/vendor/sync-core/src/write.js +1 -0
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 —
|
|
136
|
-
// customized — on disk but differs
|
|
137
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
}
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -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
|
|
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
|
}
|