@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.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
- const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
169
- const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
170
- t.replace(/^-\s*/, '').trim(),
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 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.
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
- // 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
+ // 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
- 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
- }
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
- 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)
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 = String(text).trim()
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
- 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
- }
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