@skitterbyte/skitterspec-linear 8.0.3 → 8.0.5

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": "8.0.3",
3
+ "version": "8.0.5",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require('node:fs')
18
18
  const path = require('node:path')
19
- const { findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
19
+ const { fenceMask, findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
20
20
 
21
21
  // --- markdown / frontmatter parsing -----------------------------------------
22
22
 
@@ -57,6 +57,7 @@ function parseScalar(v) {
57
57
  // heading text → its content (until the next `## `). The H1 `# ` is the title.
58
58
  function parseSections(body) {
59
59
  const lines = body.split('\n')
60
+ const inFence = fenceMask(lines)
60
61
  let title = null
61
62
  const sections = {}
62
63
  let current = null
@@ -64,9 +65,12 @@ function parseSections(body) {
64
65
  const flush = () => {
65
66
  if (current !== null) sections[current] = buf.join('\n').trim()
66
67
  }
67
- for (const line of lines) {
68
- const h1 = /^#\s+(.*)$/.exec(line)
69
- const h2 = /^##\s+(.*)$/.exec(line)
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i]
70
+ // A `#`/`##` inside a fenced code block is example text, not a real heading —
71
+ // treat it as body content so it can't start a phantom section.
72
+ const h1 = inFence[i] ? null : /^#\s+(.*)$/.exec(line)
73
+ const h2 = inFence[i] ? null : /^##\s+(.*)$/.exec(line)
70
74
  if (h1 && title === null) {
71
75
  title = h1[1].trim()
72
76
  continue
@@ -19,6 +19,41 @@ const DEFAULT_WIDTH = 80
19
19
  const TASK_START_RE = /^([ \t]*)-\s*\[([ xX])\]\s*(.*)$/
20
20
  const CONTINUATION_RE = /^[ \t]+\S/
21
21
  const BLOCK_BREAK_RE = /^[ \t]*(?:[-*+]\s|\d+\.\s|#{1,6}\s|>|\||```)/
22
+ // A list-marker line (unordered or ordered). Distinguished from other block
23
+ // breaks because a wrapped continuation can legitimately begin with one.
24
+ const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
25
+ // A checkbox bullet — unambiguously a task, so it always starts its own block,
26
+ // at any indent. (A bare list marker is ambiguous; a checkbox never is.)
27
+ const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
28
+
29
+ // Mark every line that lies inside a fenced code block — the opening fence, its
30
+ // content, and the closing fence all count as `true`. Line scanners that hunt
31
+ // for markdown structure (task bullets, section headings) use this to ignore an
32
+ // EXAMPLE of that structure shown inside a fence: a spec that documents a
33
+ // checklist format in a ``` block must not have those example bullets harvested
34
+ // as real tasks (and pushed as real tracker issues). Supports ``` and ~~~ fences
35
+ // of any length ≥ 3; a closing fence is a bare marker (no info string) of the
36
+ // same character and at least the opening length.
37
+ function fenceMask(lines) {
38
+ const mask = new Array(lines.length).fill(false)
39
+ let open = null // the opening marker string (``` or ~~~ …), or null
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const trimmed = lines[i].trim()
42
+ if (open) {
43
+ mask[i] = true
44
+ if (/^(`{3,}|~{3,})$/.test(trimmed) && trimmed[0] === open[0] && trimmed.length >= open.length) {
45
+ open = null
46
+ }
47
+ continue
48
+ }
49
+ const m = /^[ \t]*(`{3,}|~{3,})/.exec(lines[i])
50
+ if (m) {
51
+ open = m[1]
52
+ mask[i] = true
53
+ }
54
+ }
55
+ return mask
56
+ }
22
57
 
23
58
  // Collapse a wrapped bullet's lines into the single logical line the rest of the
24
59
  // sync engine (and Linear) works in.
@@ -78,16 +113,35 @@ function spanMask(body) {
78
113
  */
79
114
  function findTaskBlocks(lines) {
80
115
  const blocks = []
116
+ const inFence = fenceMask(lines)
81
117
  for (let i = 0; i < lines.length; i++) {
118
+ if (inFence[i]) continue // an example bullet inside a ``` block is not a task
82
119
  const m = TASK_START_RE.exec(lines[i])
83
120
  if (!m) continue
84
121
  const parts = [m[3]]
122
+ // The hanging indent: where the bullet's text starts (marker width). A
123
+ // wrapped continuation aligns exactly AT this column.
124
+ const hang = lines[i].length - m[3].length
85
125
  let j = i + 1
86
126
  for (; j < lines.length; j++) {
87
127
  const l = lines[j]
88
128
  if (!l.trim()) break
129
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
89
130
  if (!CONTINUATION_RE.test(l)) break
90
- if (BLOCK_BREAK_RE.test(l)) break
131
+ if (BLOCK_BREAK_RE.test(l)) {
132
+ // Indent alone can't tell a nested child from a wrapped continuation —
133
+ // both sit at the hanging indent. The *marker* is the reliable signal:
134
+ // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
135
+ // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
136
+ // sits exactly at the hang; shallower or deeper it's a real sub/
137
+ // sibling list → break. (Keeping the at-hang continuation preserves
138
+ // the task's stamped id, so the next push updates instead of
139
+ // creating a duplicate issue.)
140
+ // - headings, quotes, tables and fences always break.
141
+ if (CHECKBOX_RE.test(l)) break
142
+ const indent = l.length - l.trimStart().length
143
+ if (!LIST_MARKER_RE.test(l) || indent !== hang) break
144
+ }
91
145
  parts.push(l.trim())
92
146
  }
93
147
  blocks.push({
@@ -177,6 +231,7 @@ function inferWidth(lines, fallback = DEFAULT_WIDTH) {
177
231
  }
178
232
 
179
233
  module.exports = {
234
+ fenceMask,
180
235
  findTaskBlocks,
181
236
  renderTaskBlock,
182
237
  wrapEmphasisAware,