@skitterbyte/skitterspec-linear 3.1.0 → 4.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/README.md +15 -8
- package/assets/core/SETUP.md +175 -0
- package/assets/core/linear.config.json.example +2 -5
- package/assets/core/linear.config.md +44 -6
- package/assets/skills/spec-bug/SKILL.md +55 -8
- package/assets/skills/spec-go/SKILL.md +6 -0
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +28 -4
- package/src/vendor/linear/mcp.js +38 -12
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +144 -17
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +51 -10
- package/src/vendor/sync-core/src/write.js +247 -0
|
@@ -79,8 +79,255 @@ function writeFrontmatter(snapshotDir, config, patch) {
|
|
|
79
79
|
return Object.keys(clean)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// --- phase-file denormalizer (keyed milestone pull) ------------------------
|
|
83
|
+
//
|
|
84
|
+
// Writes pulled milestone edits back into the *body* — the phase files — which
|
|
85
|
+
// the frontmatter writer above never touches. An edit updates the matching phase
|
|
86
|
+
// file (by its linear_milestone_id) in place, leaving everything else
|
|
87
|
+
// byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
|
|
88
|
+
// never applied here (report-only, Decision 7).
|
|
89
|
+
|
|
90
|
+
// Phase files in a snapshot dir (01-*.md …), execution order.
|
|
91
|
+
function listPhaseFiles(snapshotDir) {
|
|
92
|
+
try {
|
|
93
|
+
return fs
|
|
94
|
+
.readdirSync(snapshotDir)
|
|
95
|
+
.filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
|
|
96
|
+
.sort()
|
|
97
|
+
} catch {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The linear_milestone_id recorded in a phase file's frontmatter, or null.
|
|
103
|
+
function phaseMilestoneId(raw) {
|
|
104
|
+
const { fmLines } = splitFrontmatter(raw)
|
|
105
|
+
for (const line of fmLines) {
|
|
106
|
+
const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
|
|
107
|
+
if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Find the phase file linked to a milestone id, or null.
|
|
113
|
+
function findPhaseFileByMilestoneId(snapshotDir, id) {
|
|
114
|
+
const want = String(id)
|
|
115
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
116
|
+
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
117
|
+
if (phaseMilestoneId(raw) === want) return file
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find the phase file whose h1 title matches `name` (used to link a freshly
|
|
123
|
+
// created milestone back to the phase it came from, before it has an id).
|
|
124
|
+
function findPhaseFileByTitle(snapshotDir, name) {
|
|
125
|
+
const want = String(name).trim()
|
|
126
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
127
|
+
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
128
|
+
const h1 = /^#\s+(.*)$/m.exec(splitFrontmatter(raw).body)
|
|
129
|
+
if (!h1) continue
|
|
130
|
+
const title = h1[1]
|
|
131
|
+
.replace(/\s*[⬜🔄✅]\s*$/u, '')
|
|
132
|
+
.replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
|
|
133
|
+
.trim()
|
|
134
|
+
if (title === want) return file
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update a phase file's title (h1, preserving the "Phase N — " prefix + status
|
|
140
|
+
// emoji) and its `**Goal:**` line, leaving everything else untouched.
|
|
141
|
+
function writeMilestoneFields(snapshotDir, file, { name, goal }) {
|
|
142
|
+
const p = path.join(snapshotDir, file)
|
|
143
|
+
let raw = fs.readFileSync(p, 'utf-8')
|
|
144
|
+
if (name != null) {
|
|
145
|
+
raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
|
|
146
|
+
const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
|
|
147
|
+
return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
|
|
151
|
+
raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
|
|
152
|
+
}
|
|
153
|
+
fs.writeFileSync(p, raw, 'utf-8')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add/update linear_milestone_id in a phase file's frontmatter (in place).
|
|
157
|
+
function stampMilestoneId(snapshotDir, file, id) {
|
|
158
|
+
const p = path.join(snapshotDir, file)
|
|
159
|
+
const raw = fs.readFileSync(p, 'utf-8')
|
|
160
|
+
const { fmLines, body, had } = splitFrontmatter(raw)
|
|
161
|
+
const patched = patchFrontmatterLines(fmLines, { linear_milestone_id: String(id) })
|
|
162
|
+
const fm = `---\n${patched.join('\n')}\n---\n`
|
|
163
|
+
fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const slugify = (name) =>
|
|
167
|
+
String(name || 'phase')
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
170
|
+
.replace(/^-+|-+$/g, '')
|
|
171
|
+
.slice(0, 40) || 'phase'
|
|
172
|
+
|
|
173
|
+
// Next phase number (max existing + 1).
|
|
174
|
+
function nextPhaseNumber(snapshotDir) {
|
|
175
|
+
const nums = listPhaseFiles(snapshotDir)
|
|
176
|
+
.map((f) => parseInt(f.slice(0, 2), 10))
|
|
177
|
+
.filter(Number.isFinite)
|
|
178
|
+
return nums.length ? Math.max(...nums) + 1 : 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Create a new phase file for a Linear-only milestone. Returns the filename.
|
|
182
|
+
function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
|
|
183
|
+
const n = nextPhaseNumber(snapshotDir)
|
|
184
|
+
const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
|
|
185
|
+
const content =
|
|
186
|
+
`---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
|
|
187
|
+
`# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
|
|
188
|
+
`> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
|
|
189
|
+
`**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
|
|
190
|
+
fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
|
|
191
|
+
return file
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Apply a pull's keyed milestone item outcomes to the phase files.
|
|
196
|
+
* @param items classifyItems output for the milestones field.
|
|
197
|
+
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
198
|
+
*/
|
|
199
|
+
function applyMilestonesPull(snapshotDir, items) {
|
|
200
|
+
const applied = []
|
|
201
|
+
const created = []
|
|
202
|
+
const reported = []
|
|
203
|
+
for (const it of items || []) {
|
|
204
|
+
if (it.report) {
|
|
205
|
+
reported.push(it.id)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (!it.pullable || !it.remote) continue
|
|
209
|
+
if (it.status === 'added') {
|
|
210
|
+
const file = createPhaseFileForMilestone(snapshotDir, it.remote)
|
|
211
|
+
created.push({ id: it.id, file })
|
|
212
|
+
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
213
|
+
const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
|
|
214
|
+
if (file) {
|
|
215
|
+
writeMilestoneFields(snapshotDir, file, it.remote)
|
|
216
|
+
applied.push(it.id)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { applied, created, reported }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- task-line denormalizer (keyed issue pull) -----------------------------
|
|
224
|
+
//
|
|
225
|
+
// Tasks live as checkbox lines inside phase files. A pulled issue edit rewrites
|
|
226
|
+
// the matching line (by its inline id) in place; a Linear-only issue appends a
|
|
227
|
+
// new task line; a created issue's id is stamped inline. Removals report-only.
|
|
228
|
+
|
|
229
|
+
const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
|
|
230
|
+
const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
|
|
231
|
+
|
|
232
|
+
// Render a task line from an item.
|
|
233
|
+
function taskLine(indent, { id, text, done }) {
|
|
234
|
+
return `${indent}- [${done ? 'x' : ' '}] ${text}${id ? ` (${id})` : ''}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Update the task line carrying inline id `id` (text + checkbox), in place.
|
|
238
|
+
function updateTaskLine(snapshotDir, id, { text, done }) {
|
|
239
|
+
const want = String(id)
|
|
240
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
241
|
+
const p = path.join(snapshotDir, file)
|
|
242
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
244
|
+
const m = TASK_RE.exec(lines[i])
|
|
245
|
+
if (!m) continue
|
|
246
|
+
const idm = INLINE_ID_RE.exec(m[3])
|
|
247
|
+
if (idm && idm[1] === want) {
|
|
248
|
+
lines[i] = taskLine(m[1], { id: want, text, done })
|
|
249
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Append a task line for a Linear-only issue after the last existing task line
|
|
258
|
+
// (falls back to end of the last phase file). Returns the file it landed in.
|
|
259
|
+
function addTaskLine(snapshotDir, item) {
|
|
260
|
+
const files = listPhaseFiles(snapshotDir)
|
|
261
|
+
const file = files[files.length - 1]
|
|
262
|
+
if (!file) return null
|
|
263
|
+
const p = path.join(snapshotDir, file)
|
|
264
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
265
|
+
let lastTask = -1
|
|
266
|
+
for (let i = 0; i < lines.length; i++) if (TASK_RE.test(lines[i])) lastTask = i
|
|
267
|
+
const line = taskLine('', item)
|
|
268
|
+
if (lastTask >= 0) lines.splice(lastTask + 1, 0, line)
|
|
269
|
+
else lines.push(line)
|
|
270
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
271
|
+
return file
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Stamp an inline id onto the (idless) task line whose text matches — used after
|
|
275
|
+
// the skill creates an issue for a new local task.
|
|
276
|
+
function stampIssueId(snapshotDir, text, id) {
|
|
277
|
+
const want = String(text).trim()
|
|
278
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
279
|
+
const p = path.join(snapshotDir, file)
|
|
280
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
281
|
+
for (let i = 0; i < lines.length; i++) {
|
|
282
|
+
const m = TASK_RE.exec(lines[i])
|
|
283
|
+
if (!m || INLINE_ID_RE.test(m[3])) continue
|
|
284
|
+
if (m[3].trim() === want) {
|
|
285
|
+
lines[i] = `${m[1]}- [${m[2].toLowerCase() === 'x' ? 'x' : ' '}] ${want} (${id})`
|
|
286
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
287
|
+
return file
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Apply a pull's keyed task item outcomes to the phase files' task lines.
|
|
296
|
+
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
297
|
+
*/
|
|
298
|
+
function applyTasksPull(snapshotDir, items) {
|
|
299
|
+
const applied = []
|
|
300
|
+
const created = []
|
|
301
|
+
const reported = []
|
|
302
|
+
for (const it of items || []) {
|
|
303
|
+
if (it.report) {
|
|
304
|
+
reported.push(it.id)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (!it.pullable || !it.remote) continue
|
|
308
|
+
if (it.status === 'added') {
|
|
309
|
+
const file = addTaskLine(snapshotDir, it.remote)
|
|
310
|
+
if (file) created.push({ id: it.id, file })
|
|
311
|
+
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
312
|
+
if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { applied, created, reported }
|
|
316
|
+
}
|
|
317
|
+
|
|
82
318
|
module.exports = {
|
|
83
319
|
writeFrontmatter,
|
|
84
320
|
splitFrontmatter,
|
|
85
321
|
serialize,
|
|
322
|
+
listPhaseFiles,
|
|
323
|
+
findPhaseFileByMilestoneId,
|
|
324
|
+
findPhaseFileByTitle,
|
|
325
|
+
writeMilestoneFields,
|
|
326
|
+
stampMilestoneId,
|
|
327
|
+
createPhaseFileForMilestone,
|
|
328
|
+
applyMilestonesPull,
|
|
329
|
+
updateTaskLine,
|
|
330
|
+
addTaskLine,
|
|
331
|
+
stampIssueId,
|
|
332
|
+
applyTasksPull,
|
|
86
333
|
}
|