@skitterbyte/skitterspec-linear 7.0.0 → 7.0.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.1",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('node:fs')
|
|
18
18
|
const path = require('node:path')
|
|
19
|
+
const { findTaskBlocks, collapse } = require('./task-block.js')
|
|
19
20
|
|
|
20
21
|
// --- markdown / frontmatter parsing -----------------------------------------
|
|
21
22
|
|
|
@@ -165,10 +166,15 @@ function readPhaseFiles(snapshotDir) {
|
|
|
165
166
|
.map((file) => {
|
|
166
167
|
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
167
168
|
const { data, body } = parseFrontmatter(raw)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
// No /m on either: under /m, `$` matches end-of-LINE, so a non-greedy
|
|
170
|
+
// scan stops at the first newline and a hand-wrapped bullet or goal loses
|
|
171
|
+
// every continuation line. Tasks come from findTaskBlocks, which reads a
|
|
172
|
+
// wrapped bullet as one logical task.
|
|
173
|
+
// Collapsed, not just captured: the goal becomes a milestone description,
|
|
174
|
+
// and Linear may canonicalize a soft line break away on save. Collapsing
|
|
175
|
+
// both sides keeps a wrapped goal from diffing forever.
|
|
176
|
+
const goal = collapse((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
|
|
177
|
+
const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
|
|
172
178
|
return {
|
|
173
179
|
phase: file.replace(/\.md$/, ''),
|
|
174
180
|
file,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Task bullets in a phase file are hand-wrapped prose, not single lines:
|
|
4
|
+
//
|
|
5
|
+
// - [x] Add `DbProcessEventOutbox` to `prisma/schema.prisma`, modelled on
|
|
6
|
+
// `DbNotificationOutbox`: status, attempts, `nextAttemptAt`, …
|
|
7
|
+
//
|
|
8
|
+
// A Linear issue title is single-line, so the two representations differ by
|
|
9
|
+
// wrapping alone. This module is the one place that converts between them:
|
|
10
|
+
// `findTaskBlocks` reads wrapped bullets into logical tasks, `renderTaskBlock`
|
|
11
|
+
// writes a logical task back out re-wrapped in the file's own style.
|
|
12
|
+
//
|
|
13
|
+
// Everything here is line-index based so callers can splice whole blocks.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WIDTH = 80
|
|
16
|
+
|
|
17
|
+
// Start of a task bullet. The continuation lines that follow are any indented,
|
|
18
|
+
// non-empty lines that are not themselves a bullet or heading.
|
|
19
|
+
const TASK_START_RE = /^([ \t]*)-\s*\[([ xX])\]\s*(.*)$/
|
|
20
|
+
const CONTINUATION_RE = /^[ \t]+\S/
|
|
21
|
+
const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
|
|
22
|
+
|
|
23
|
+
// Collapse a wrapped bullet's lines into the single logical line the rest of the
|
|
24
|
+
// sync engine (and Linear) works in.
|
|
25
|
+
function collapse(text) {
|
|
26
|
+
return String(text).replace(/\s+/g, ' ').trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find every task bullet in `lines` as a logical block.
|
|
31
|
+
* @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
|
|
32
|
+
* `end` is exclusive. `text` is the collapsed single-line form, id included.
|
|
33
|
+
*/
|
|
34
|
+
function findTaskBlocks(lines) {
|
|
35
|
+
const blocks = []
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const m = TASK_START_RE.exec(lines[i])
|
|
38
|
+
if (!m) continue
|
|
39
|
+
const parts = [m[3]]
|
|
40
|
+
let j = i + 1
|
|
41
|
+
for (; j < lines.length; j++) {
|
|
42
|
+
const l = lines[j]
|
|
43
|
+
if (!l.trim()) break
|
|
44
|
+
if (!CONTINUATION_RE.test(l)) break
|
|
45
|
+
if (BLOCK_BREAK_RE.test(l)) break
|
|
46
|
+
parts.push(l.trim())
|
|
47
|
+
}
|
|
48
|
+
blocks.push({
|
|
49
|
+
start: i,
|
|
50
|
+
end: j,
|
|
51
|
+
indent: m[1],
|
|
52
|
+
mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
|
|
53
|
+
text: collapse(parts.join(' ')),
|
|
54
|
+
})
|
|
55
|
+
i = j - 1
|
|
56
|
+
}
|
|
57
|
+
return blocks
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render a logical task back into wrapped file lines, matching the surrounding
|
|
62
|
+
* style: `- [x] ` opener, continuations aligned under the text.
|
|
63
|
+
* @returns {string[]}
|
|
64
|
+
*/
|
|
65
|
+
function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
|
|
66
|
+
const opener = `${indent}- [${done ? 'x' : ' '}] `
|
|
67
|
+
const hang = ' '.repeat(opener.length)
|
|
68
|
+
const body = collapse(text) + (id ? ` (${id})` : '')
|
|
69
|
+
|
|
70
|
+
const out = []
|
|
71
|
+
let line = opener
|
|
72
|
+
let first = true
|
|
73
|
+
for (const word of body.split(' ')) {
|
|
74
|
+
if (!first && line.length + 1 + word.length > width) {
|
|
75
|
+
out.push(line)
|
|
76
|
+
line = hang + word
|
|
77
|
+
} else {
|
|
78
|
+
line += (first ? '' : ' ') + word
|
|
79
|
+
first = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.push(line)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
|
|
87
|
+
// different column. Falls back to the default when there's nothing to learn from.
|
|
88
|
+
function inferWidth(lines, fallback = DEFAULT_WIDTH) {
|
|
89
|
+
const widths = lines.filter((l) => l.trim()).map((l) => l.length)
|
|
90
|
+
if (!widths.length) return fallback
|
|
91
|
+
const max = Math.max(...widths)
|
|
92
|
+
return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { findTaskBlocks, renderTaskBlock, collapse, inferWidth, DEFAULT_WIDTH }
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require('node:fs')
|
|
20
20
|
const path = require('node:path')
|
|
21
|
+
const {
|
|
22
|
+
findTaskBlocks,
|
|
23
|
+
renderTaskBlock,
|
|
24
|
+
collapse,
|
|
25
|
+
inferWidth,
|
|
26
|
+
} = require('./task-block.js')
|
|
21
27
|
|
|
22
28
|
// Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
|
|
23
29
|
// key is dropped (caller shouldn't pass those).
|
|
@@ -222,33 +228,31 @@ function applyMilestonesPull(snapshotDir, items) {
|
|
|
222
228
|
|
|
223
229
|
// --- task-line denormalizer (keyed issue pull) -----------------------------
|
|
224
230
|
//
|
|
225
|
-
// Tasks live as checkbox
|
|
226
|
-
//
|
|
227
|
-
// new
|
|
231
|
+
// Tasks live as (hand-wrapped) checkbox bullets inside phase files. A pulled
|
|
232
|
+
// issue edit rewrites the matching bullet — whole block, re-wrapped — by its
|
|
233
|
+
// inline id; a Linear-only issue appends a new bullet; a created issue's id is
|
|
234
|
+
// stamped inline. Removals report-only. See task-block.js for the wrapping.
|
|
228
235
|
|
|
229
|
-
const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
|
|
230
236
|
const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
|
|
231
237
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// Update the task line carrying inline id `id` (text + checkbox), in place.
|
|
238
|
+
// Update the task carrying inline id `id` (text + checkbox), in place.
|
|
239
|
+
//
|
|
240
|
+
// Block-aware: a task bullet is hand-wrapped across several lines, so the whole
|
|
241
|
+
// block is replaced and the new text re-wrapped in the file's own style. Editing
|
|
242
|
+
// only the first line would strand its continuation lines as orphaned prose.
|
|
238
243
|
function updateTaskLine(snapshotDir, id, { text, done }) {
|
|
239
244
|
const want = String(id)
|
|
240
245
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
241
246
|
const p = path.join(snapshotDir, file)
|
|
242
247
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
248
|
+
const width = inferWidth(lines)
|
|
249
|
+
for (const b of findTaskBlocks(lines)) {
|
|
250
|
+
const idm = INLINE_ID_RE.exec(b.text)
|
|
251
|
+
if (!idm || idm[1] !== want) continue
|
|
252
|
+
const rendered = renderTaskBlock({ indent: b.indent, done, text, id: want }, width)
|
|
253
|
+
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
254
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
255
|
+
return true
|
|
252
256
|
}
|
|
253
257
|
}
|
|
254
258
|
return false
|
|
@@ -262,11 +266,10 @@ function addTaskLine(snapshotDir, item) {
|
|
|
262
266
|
if (!file) return null
|
|
263
267
|
const p = path.join(snapshotDir, file)
|
|
264
268
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
else lines.push(line)
|
|
269
|
+
const blocks = findTaskBlocks(lines)
|
|
270
|
+
const rendered = renderTaskBlock({ indent: '', ...item }, inferWidth(lines))
|
|
271
|
+
if (blocks.length) lines.splice(blocks[blocks.length - 1].end, 0, ...rendered)
|
|
272
|
+
else lines.push(...rendered)
|
|
270
273
|
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
271
274
|
return file
|
|
272
275
|
}
|
|
@@ -274,18 +277,21 @@ function addTaskLine(snapshotDir, item) {
|
|
|
274
277
|
// Stamp an inline id onto the (idless) task line whose text matches — used after
|
|
275
278
|
// the skill creates an issue for a new local task.
|
|
276
279
|
function stampIssueId(snapshotDir, text, id) {
|
|
277
|
-
const want =
|
|
280
|
+
const want = collapse(text)
|
|
278
281
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
279
282
|
const p = path.join(snapshotDir, file)
|
|
280
283
|
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
const width = inferWidth(lines)
|
|
285
|
+
for (const b of findTaskBlocks(lines)) {
|
|
286
|
+
if (INLINE_ID_RE.test(b.text)) continue
|
|
287
|
+
if (b.text !== want) continue
|
|
288
|
+
const rendered = renderTaskBlock(
|
|
289
|
+
{ indent: b.indent, done: b.mark === 'x', text: want, id },
|
|
290
|
+
width,
|
|
291
|
+
)
|
|
292
|
+
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
293
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
294
|
+
return file
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
return null
|