@skitterbyte/skitterspec-linear 7.0.0 → 7.0.2

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.
@@ -190,3 +190,7 @@ queried directly.)
190
190
  - Never delete historical notes.
191
191
  - The spec file is the single source of truth for implementation progress.
192
192
  - Move specs between buckets with `git mv` to preserve history.
193
+ - Never let inline emphasis or a link cross a hard line break — keep a whole
194
+ `**bold**`, `*italic*`, or `[text](url)` on one line (let it overflow the wrap
195
+ column rather than splitting it). Many round-tripping editors mangle a
196
+ `**`/`*`/link span that straddles a newline, so clean source avoids the churn.
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { run } = require('../src/cli.js')
12
12
  const { specSync } = require('../src/vendor/linear/cli-sync.js')
13
+ const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
13
14
 
14
15
  async function main(argv) {
15
16
  const [cmd, ...rest] = argv
@@ -17,6 +18,10 @@ async function main(argv) {
17
18
  await specSync(rest)
18
19
  return
19
20
  }
21
+ if (cmd === 'spec-sanitise') {
22
+ process.exitCode = await specSanitise(rest)
23
+ return
24
+ }
20
25
  await run(argv)
21
26
  }
22
27
 
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.2",
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",
@@ -0,0 +1,101 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `spec-sanitise` — a one-time maintenance pass over spec markdown.
5
+ *
6
+ * Rewrites files so no inline emphasis (`**bold**`, `*italic*`) or link text
7
+ * straddles a hard line break, and repairs any Linear-mangled `****` artifacts
8
+ * already committed. Hand-wrapped specs acquire straddles over time; Linear
9
+ * corrupts them on save, so this brings the corpus "inline" to round-trip cleanly.
10
+ *
11
+ * Dry-run by default (reports the files it WOULD change); pass `--write` to apply.
12
+ * Minimal-diff and idempotent — only paragraphs/items that actually straddle are
13
+ * reflowed (see sync-core `sanitizeSpecMarkdown`).
14
+ *
15
+ * skitterspec-linear spec-sanitise [paths...] [--write] [--width N]
16
+ *
17
+ * Defaults to scanning `specs/`. Exit code 1 in dry-run when changes are pending
18
+ * (so it's CI-friendly), 0 once everything is clean.
19
+ */
20
+
21
+ const fs = require('node:fs')
22
+ const path = require('node:path')
23
+ const { sanitizeSpecMarkdown } = require('../sync-core')
24
+
25
+ // Recursively collect *.md files under a path (a file path is returned as-is).
26
+ function collectMarkdown(target) {
27
+ const out = []
28
+ const walk = (p) => {
29
+ let st
30
+ try {
31
+ st = fs.statSync(p)
32
+ } catch {
33
+ return
34
+ }
35
+ if (st.isDirectory()) {
36
+ if (/(^|\/)(node_modules|\.git)$/.test(p)) return
37
+ for (const name of fs.readdirSync(p).sort()) walk(path.join(p, name))
38
+ } else if (st.isFile() && p.endsWith('.md')) {
39
+ out.push(p)
40
+ }
41
+ }
42
+ walk(target)
43
+ return out
44
+ }
45
+
46
+ function parseArgs(argv) {
47
+ const paths = []
48
+ let write = false
49
+ let width = null
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i]
52
+ if (a === '--write') write = true
53
+ else if (a === '--width') width = parseInt(argv[++i], 10)
54
+ else if (a === '--help' || a === '-h') return { help: true }
55
+ else paths.push(a)
56
+ }
57
+ if (!paths.length) paths.push('specs')
58
+ return { paths, write, width: Number.isFinite(width) ? width : null }
59
+ }
60
+
61
+ async function specSanitise(argv, { cwd = process.cwd(), out = process.stdout } = {}) {
62
+ const opts = parseArgs(argv)
63
+ if (opts.help) {
64
+ out.write('Usage: skitterspec-linear spec-sanitise [paths...] [--write] [--width N]\n')
65
+ return 0
66
+ }
67
+
68
+ const files = new Set()
69
+ for (const p of opts.paths) for (const f of collectMarkdown(path.resolve(cwd, p))) files.add(f)
70
+
71
+ let changedFiles = 0
72
+ let totalFixes = 0
73
+ for (const file of [...files].sort()) {
74
+ const src = fs.readFileSync(file, 'utf-8')
75
+ const res = sanitizeSpecMarkdown(src, opts.width ? { width: opts.width } : {})
76
+ if (!res.changed) continue
77
+ changedFiles++
78
+ totalFixes += res.fixes
79
+ const rel = path.relative(cwd, file)
80
+ if (opts.write) {
81
+ fs.writeFileSync(file, res.text, 'utf-8')
82
+ out.write(`fixed ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
83
+ } else {
84
+ out.write(`would fix ${rel} (${res.fixes} span${res.fixes === 1 ? '' : 's'})\n`)
85
+ }
86
+ }
87
+
88
+ if (!changedFiles) {
89
+ out.write(`spec-sanitise: clean — no straddling spans in ${files.size} file(s).\n`)
90
+ return 0
91
+ }
92
+ const verb = opts.write ? 'Fixed' : 'Would fix'
93
+ out.write(`\n${verb} ${totalFixes} span(s) across ${changedFiles} file(s).\n`)
94
+ if (!opts.write) {
95
+ out.write('Re-run with --write to apply. Review the diff before committing.\n')
96
+ return 1
97
+ }
98
+ return 0
99
+ }
100
+
101
+ module.exports = { specSanitise, collectMarkdown, parseArgs }
@@ -16,6 +16,7 @@ const { pull } = require('./src/pull.js')
16
16
  const { push } = require('./src/push.js')
17
17
  const { writeFrontmatter } = require('./src/write.js')
18
18
  const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
19
+ const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
20
 
20
21
  module.exports = {
21
22
  normalizeLocal,
@@ -32,4 +33,5 @@ module.exports = {
32
33
  writeFrontmatter,
33
34
  frontmatterPatchFor,
34
35
  localWorkflowState,
36
+ sanitizeSpecMarkdown,
35
37
  }
@@ -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
 
@@ -82,21 +83,123 @@ function parseSections(body) {
82
83
  return { title, sections }
83
84
  }
84
85
 
86
+ // Linear mangles inline emphasis whose markers straddle a hard line break: it
87
+ // terminates the run at end-of-line and restarts it at the next, so `**a\nb**`
88
+ // comes back as `**a****\n****b**`, `*a\nb*` as `*a**\n**b*`, and a link splits
89
+ // into two. Canonicalise BOTH representations — the clean straddle we author and
90
+ // the mangled form Linear returns — to the same single-line span, so an
91
+ // already-mangled remote stops reading as a spurious `remote-only` diff and the
92
+ // payload we push carries no straddle for Linear to mangle again. Idempotent: a
93
+ // joined span has no interior newline, so nothing re-fires. Repo files are never
94
+ // rewritten — this only shapes the normalized projection the compare/push see.
95
+ function joinEmphasisAcrossBreaks(text) {
96
+ let s = String(text)
97
+ // (1) Repair Linear's mangle artifacts first — an emphasis run terminated at
98
+ // end-of-line and restarted at the next. These are very specific asterisk runs
99
+ // flanking a break, so a targeted regex is safe:
100
+ // bold: `**X****\n****Y**` → the `****\n****` empty-bold artifact → a space.
101
+ s = s.replace(/\*{4}[ \t]*\n[ \t]*\*{4}/g, ' ')
102
+ // italic: `*X**\n**Y*` — the `**\n**` artifact sits INSIDE a single-`*` span;
103
+ // gate on the enclosing single `*` so a genuine pair of adjacent bolds at a
104
+ // line boundary (`a**\n**b`) is left alone.
105
+ s = s.replace(/(^|[^*\n])\*([^*\n]+)\*\*[ \t]*\n[ \t]*\*\*([^*\n]+)\*(?![*])/g, '$1*$2 $3*')
106
+ // link split across a break onto the same url → one link.
107
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)[ \t]*\n[ \t]*\[([^\]]+)\]\(\2\)/g, '[$1 $3]($2)')
108
+ // (2) Join the clean straddle we author — a newline that falls while an
109
+ // emphasis/link span is OPEN. A scanner (not a regex) so an opening `**` is
110
+ // never mis-paired with an unrelated closing `**` on the next line, and markers
111
+ // inside a `code span` are ignored (Linear only rejoins those, harmlessly).
112
+ return joinOpenSpans(s)
113
+ }
114
+
115
+ // Walk the text tracking whether we're inside a `**bold**`, `*italic*`, `[link
116
+ // text]`/`(url)`, or `` `code` `` span. A newline encountered while a
117
+ // non-code span is open joins the two lines with a single space (dropping the
118
+ // next line's indentation); every other newline is preserved.
119
+ function joinOpenSpans(text) {
120
+ const out = []
121
+ let bold = false
122
+ let italic = false
123
+ let code = false
124
+ let link = 0 // 0 none · 1 in link text · 2 in url
125
+ for (let i = 0; i < text.length; i++) {
126
+ const c = text[i]
127
+ if (c === '\n') {
128
+ if (!code && (bold || italic || link)) {
129
+ out.push(' ')
130
+ while (i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\t')) i++
131
+ } else {
132
+ out.push('\n')
133
+ }
134
+ continue
135
+ }
136
+ if (c === '`') {
137
+ code = !code
138
+ out.push(c)
139
+ continue
140
+ }
141
+ if (code) {
142
+ out.push(c)
143
+ continue
144
+ }
145
+ if (c === '*' && text[i + 1] === '*') {
146
+ bold = !bold
147
+ out.push('**')
148
+ i++
149
+ continue
150
+ }
151
+ if (c === '*') {
152
+ // Basic flanking so a stray `*` (e.g. `2 * 3`) doesn't open a phantom span:
153
+ // an opener needs a non-space to its right, a closer a non-space to its left.
154
+ const ok = italic ? !/\s/.test(text[i - 1] || '') : !/\s/.test(text[i + 1] || '')
155
+ if (ok) italic = !italic
156
+ out.push(c)
157
+ continue
158
+ }
159
+ if (c === '[' && link === 0) {
160
+ link = 1
161
+ out.push(c)
162
+ continue
163
+ }
164
+ if (c === ']' && link === 1) {
165
+ if (text[i + 1] === '(') {
166
+ link = 2
167
+ out.push('](')
168
+ i++
169
+ } else {
170
+ link = 0
171
+ out.push(c)
172
+ }
173
+ continue
174
+ }
175
+ if (c === ')' && link === 2) {
176
+ link = 0
177
+ out.push(c)
178
+ continue
179
+ }
180
+ out.push(c)
181
+ }
182
+ return out.join('')
183
+ }
184
+
85
185
  // Canonicalise markdown so semantically-equal content hashes equal across the
86
186
  // boundary. Linear reserializes markdown on save (authored `-` bullets come back
87
- // as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
88
- // clean push→pull would report `description` as perpetually changed. Applied to
89
- // the description on BOTH sides. Conservative: only unifies list markers and
90
- // whitespace the transforms actually observed from Linear.
187
+ // as `*`, trailing whitespace trimmed, blank runs collapsed, emphasis spanning a
188
+ // line break mangled), so without this a clean push→pull would report
189
+ // `description` as perpetually changed. Applied to the description on BOTH sides.
190
+ // Conservative: only unifies list markers, whitespace, and emphasis-across-a-break
191
+ // — the transforms actually observed from Linear.
91
192
  function canonicalizeMarkdown(text) {
92
193
  if (text == null) return text
93
- return String(text)
194
+ const marked = String(text)
94
195
  .replace(/\r\n/g, '\n')
95
196
  .split('\n')
96
197
  // Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
97
- // after the marker so bold/emphasis (`**Goal:**`) is untouched.
198
+ // after the marker so bold/emphasis (`**Goal:**`) is untouched. Done BEFORE
199
+ // the emphasis join so a `*` list bullet can't spoof an italic delimiter.
98
200
  .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
99
201
  .join('\n')
202
+ return joinEmphasisAcrossBreaks(marked)
100
203
  .replace(/\n{3,}/g, '\n\n')
101
204
  .trim()
102
205
  }
@@ -165,10 +268,15 @@ function readPhaseFiles(snapshotDir) {
165
268
  .map((file) => {
166
269
  const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
167
270
  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
- )
271
+ // No /m on either: under /m, `$` matches end-of-LINE, so a non-greedy
272
+ // scan stops at the first newline and a hand-wrapped bullet or goal loses
273
+ // every continuation line. Tasks come from findTaskBlocks, which reads a
274
+ // wrapped bullet as one logical task.
275
+ // Collapsed, not just captured: the goal becomes a milestone description,
276
+ // and Linear may canonicalize a soft line break away on save. Collapsing
277
+ // both sides keeps a wrapped goal from diffing forever.
278
+ const goal = collapse((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
279
+ const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
172
280
  return {
173
281
  phase: file.replace(/\.md$/, ''),
174
282
  file,
@@ -372,5 +480,6 @@ module.exports = {
372
480
  parseTaskLine,
373
481
  canonicalRemoteStatus,
374
482
  canonicalizeMarkdown,
483
+ joinEmphasisAcrossBreaks,
375
484
  bucketForState,
376
485
  }
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * One-time sanitiser: rewrite spec markdown so no inline emphasis or link span
5
+ * straddles a hard line break, and repair any Linear-mangled `****` artifacts
6
+ * already committed. This brings hand-wrapped spec files "inline" so they round-
7
+ * trip through Linear cleanly (Linear mangles a straddling `**`/`*`/link on save).
8
+ *
9
+ * Deliberately minimal-diff: only a block that actually contains a straddle or a
10
+ * mangle is reflowed — every other paragraph, table, code fence, and heading is
11
+ * left byte-for-byte untouched. A reflowed block is re-wrapped emphasis-aware to
12
+ * the file's own inferred width, so spans stay whole. Idempotent: a second run is
13
+ * a no-op.
14
+ *
15
+ * Pure string→string (`sanitizeSpecMarkdown`); the CLI wraps it with fs walking.
16
+ */
17
+
18
+ const {
19
+ wrapEmphasisAware,
20
+ collapse,
21
+ inferWidth,
22
+ DEFAULT_WIDTH,
23
+ } = require('./task-block.js')
24
+ const { joinEmphasisAcrossBreaks } = require('./normalize.js')
25
+
26
+ // A run of non-blank lines contains an emphasis straddle / mangle iff joining
27
+ // spans across breaks changes it.
28
+ function hasStraddle(text) {
29
+ return joinEmphasisAcrossBreaks(text) !== text
30
+ }
31
+
32
+ // Turn a multi-line region into its clean single logical line: repair mangles and
33
+ // join straddles first (needs the newlines), then collapse whitespace.
34
+ function cleanLogicalLine(text) {
35
+ return collapse(joinEmphasisAcrossBreaks(text))
36
+ }
37
+
38
+ // A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
39
+ // checkbox), then the text.
40
+ const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
41
+ // Structural blocks we never reflow.
42
+ const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
43
+ // A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
44
+ // dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
45
+ // inline `code|span` doesn't make us treat a whole list as an untouchable table.
46
+ const TABLE_SEP_RE = /^[\s|:-]*\|[\s|:-]*-[\s|:-]*$|^[\s|:-]*-[\s|:-]*\|[\s|:-]*$/
47
+
48
+ // Split a block (consecutive non-blank lines) into items when it's a list; each
49
+ // item is its bullet line plus more-indented continuation lines.
50
+ function splitListItems(block) {
51
+ const items = []
52
+ let cur = null
53
+ for (const line of block) {
54
+ if (BULLET_RE.test(line)) {
55
+ cur = [line]
56
+ items.push(cur)
57
+ } else if (cur) {
58
+ cur.push(line) // continuation of the current bullet
59
+ } else {
60
+ return null // leading non-bullet line — not a clean list block
61
+ }
62
+ }
63
+ return items
64
+ }
65
+
66
+ // Reflow one bullet item (raw lines) to width, emphasis-aware. Returns the item's
67
+ // lines unchanged when it carries no straddle.
68
+ function sanitizeItem(itemLines, width) {
69
+ const raw = itemLines.join('\n')
70
+ if (!hasStraddle(raw)) return { lines: itemLines, fixed: false }
71
+ const m = BULLET_RE.exec(itemLines[0])
72
+ const indent = m[1]
73
+ const marker = m[2]
74
+ const firstPrefix = indent + marker
75
+ const hang = ' '.repeat(firstPrefix.length)
76
+ // Body = the item's text (first line after the marker + continuations).
77
+ const bodyRaw = [m[3], ...itemLines.slice(1)].join('\n')
78
+ const body = cleanLogicalLine(bodyRaw)
79
+ return { lines: wrapEmphasisAware(body, { firstPrefix, hang, width }), fixed: true }
80
+ }
81
+
82
+ // Reflow a plain prose paragraph (raw lines) to width, preserving its leading
83
+ // indent. Unchanged when it carries no straddle.
84
+ function sanitizeProse(block, width) {
85
+ const raw = block.join('\n')
86
+ if (!hasStraddle(raw)) return { lines: block, fixed: false }
87
+ const indent = (/^(\s*)/.exec(block[0]) || ['', ''])[1]
88
+ const body = cleanLogicalLine(raw)
89
+ return { lines: wrapEmphasisAware(body, { firstPrefix: indent, hang: indent, width }), fixed: true }
90
+ }
91
+
92
+ /**
93
+ * Sanitise one markdown document.
94
+ * @returns {{ text:string, changed:boolean, fixes:number }} fixes = blocks/items reflowed.
95
+ */
96
+ function sanitizeSpecMarkdown(text, { width } = {}) {
97
+ const src = String(text)
98
+ const lines = src.split('\n')
99
+ const w = width || inferWidth(lines) || DEFAULT_WIDTH
100
+ const out = []
101
+ let i = 0
102
+ let fixes = 0
103
+ while (i < lines.length) {
104
+ const line = lines[i]
105
+ // Fenced code — copy verbatim through the closing fence.
106
+ if (/^[ \t]*```/.test(line)) {
107
+ out.push(line)
108
+ i++
109
+ while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
110
+ if (i < lines.length) out.push(lines[i++])
111
+ continue
112
+ }
113
+ if (!line.trim()) {
114
+ out.push(line)
115
+ i++
116
+ continue
117
+ }
118
+ // Gather a block of consecutive non-blank, non-fence lines.
119
+ let j = i
120
+ while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
121
+ const block = lines.slice(i, j)
122
+ i = j
123
+
124
+ // Never reflow structural blocks (headings, tables, quotes, rules, HTML).
125
+ if (PASSTHROUGH_RE.test(block[0]) || block.some((l) => TABLE_SEP_RE.test(l))) {
126
+ out.push(...block)
127
+ continue
128
+ }
129
+
130
+ const items = BULLET_RE.test(block[0]) ? splitListItems(block) : null
131
+ if (items) {
132
+ for (const it of items) {
133
+ const r = sanitizeItem(it, w)
134
+ out.push(...r.lines)
135
+ if (r.fixed) fixes++
136
+ }
137
+ } else {
138
+ const r = sanitizeProse(block, w)
139
+ out.push(...r.lines)
140
+ if (r.fixed) fixes++
141
+ }
142
+ }
143
+ const result = out.join('\n')
144
+ return { text: result, changed: result !== src, fixes }
145
+ }
146
+
147
+ module.exports = { sanitizeSpecMarkdown, hasStraddle }
@@ -0,0 +1,176 @@
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
+ // Mark every character of `body` that lies inside an inline emphasis or link
30
+ // span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
31
+ // one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
32
+ // spans are exempt (Linear only rejoins them, harmlessly) but their contents are
33
+ // neutralised first so a `*` inside code can't spoof an italic marker.
34
+ function spanMask(body) {
35
+ const mask = new Array(body.length).fill(false)
36
+ // Neutralise code-span contents to same-length filler (indices stay aligned).
37
+ const chars = body.split('')
38
+ let m
39
+ const codeRe = /`[^`]*`/g
40
+ while ((m = codeRe.exec(body)) !== null) {
41
+ for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
42
+ }
43
+ let masked = chars.join('')
44
+ const cover = (re) => {
45
+ re.lastIndex = 0
46
+ let mm
47
+ while ((mm = re.exec(masked)) !== null) {
48
+ for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
49
+ if (mm[0].length === 0) re.lastIndex++
50
+ }
51
+ }
52
+ cover(/\*\*.+?\*\*/g) // bold
53
+ cover(/\[[^\]]*\]\([^)]*\)/g) // link
54
+ // Neutralise the bold/link spans already found, so their asterisks aren't
55
+ // reused when scanning for single-`*` italics.
56
+ const m2 = masked.split('')
57
+ for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
58
+ masked = m2.join('')
59
+ cover(/\*[^*]+?\*/g) // italic
60
+ return mask
61
+ }
62
+
63
+ /**
64
+ * Find every task bullet in `lines` as a logical block.
65
+ * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
66
+ * `end` is exclusive. `text` is the collapsed single-line form, id included.
67
+ */
68
+ function findTaskBlocks(lines) {
69
+ const blocks = []
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const m = TASK_START_RE.exec(lines[i])
72
+ if (!m) continue
73
+ const parts = [m[3]]
74
+ let j = i + 1
75
+ for (; j < lines.length; j++) {
76
+ const l = lines[j]
77
+ if (!l.trim()) break
78
+ if (!CONTINUATION_RE.test(l)) break
79
+ if (BLOCK_BREAK_RE.test(l)) break
80
+ parts.push(l.trim())
81
+ }
82
+ blocks.push({
83
+ start: i,
84
+ end: j,
85
+ indent: m[1],
86
+ mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
87
+ text: collapse(parts.join(' ')),
88
+ })
89
+ i = j - 1
90
+ }
91
+ return blocks
92
+ }
93
+
94
+ /**
95
+ * Render a logical task back into wrapped file lines, matching the surrounding
96
+ * style: `- [x] ` opener, continuations aligned under the text.
97
+ * @returns {string[]}
98
+ */
99
+ // Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
100
+ // only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
101
+ // emphasis run never straddles a line (Linear mangles one that does). The first
102
+ // line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
103
+ // single span overflows rather than being split.
104
+ function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
105
+ const mask = spanMask(body)
106
+ const words = body.split(' ')
107
+ // The index in `body` of the space that precedes each word.
108
+ const spaceBefore = []
109
+ let pos = 0
110
+ for (let k = 0; k < words.length; k++) {
111
+ if (k > 0) {
112
+ spaceBefore[k] = pos
113
+ pos += 1
114
+ }
115
+ pos += words[k].length
116
+ }
117
+
118
+ const out = []
119
+ let line = firstPrefix
120
+ let first = true
121
+ for (let k = 0; k < words.length; k++) {
122
+ const word = words[k]
123
+ // Break only at a safe gap; a gap inside a span overflows instead of
124
+ // splitting it (so an over-width single span stays whole on one line).
125
+ const canBreak = k > 0 && !mask[spaceBefore[k]]
126
+ if (!first && canBreak && line.length + 1 + word.length > width) {
127
+ out.push(line)
128
+ line = hang + word
129
+ } else {
130
+ line += (first ? '' : ' ') + word
131
+ first = false
132
+ }
133
+ }
134
+ out.push(line)
135
+ return out
136
+ }
137
+
138
+ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
139
+ const opener = `${indent}- [${done ? 'x' : ' '}] `
140
+ const hang = ' '.repeat(opener.length)
141
+ const body = collapse(text) + (id ? ` (${id})` : '')
142
+ return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
143
+ }
144
+
145
+ // Infer the wrap width a file already uses, so a rewrite doesn't reflow it to a
146
+ // different column. Only *prose* lines count — a single wide table row or a long
147
+ // line of fenced code would otherwise pull the whole file's prose to a wider
148
+ // column than the author wrapped at. Falls back to the default when there's
149
+ // nothing prose-like to learn from.
150
+ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
151
+ let inFence = false
152
+ const widths = []
153
+ for (const l of lines) {
154
+ if (/^[ \t]*```/.test(l)) {
155
+ inFence = !inFence
156
+ continue
157
+ }
158
+ if (inFence) continue
159
+ if (!l.trim()) continue
160
+ if (l.includes('|')) continue // table row
161
+ widths.push(l.length)
162
+ }
163
+ if (!widths.length) return fallback
164
+ const max = Math.max(...widths)
165
+ return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
166
+ }
167
+
168
+ module.exports = {
169
+ findTaskBlocks,
170
+ renderTaskBlock,
171
+ wrapEmphasisAware,
172
+ spanMask,
173
+ collapse,
174
+ inferWidth,
175
+ DEFAULT_WIDTH,
176
+ }
@@ -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