@skitterbyte/skitterspec-linear 8.0.1 → 8.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.
@@ -168,3 +168,9 @@ With a linked spec, confirm push end-to-end:
168
168
  (`Backlog / Planned / In Progress / Completed / Canceled`).
169
169
  - **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
170
170
  workspace. Remove and re-add the server to authenticate against another.
171
+ - **Bold around an inline code span renders oddly in Linear** — Linear moves the
172
+ closing `**` before an inline code span on save (`**no unresolved `` `X` ``**`
173
+ → `**no unresolved** `` `X` ``). This is a Linear rendering quirk in the mirror
174
+ only; it never touches your repo (one-way sync never reads content back), so
175
+ it's cosmetic. Avoid wrapping a whole phrase that ends in code in bold if the
176
+ mirror's rendering matters to you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "8.0.1",
3
+ "version": "8.0.2",
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",
Binary file
@@ -433,6 +433,19 @@ function remoteWorkflowState(project, config) {
433
433
  return name != null ? bucketForState(name, config) : null
434
434
  }
435
435
 
436
+ // Trim a trailing parenthetical whose opener has no matching close — so a cut
437
+ // title never ends on a dangling `(`/`[`.
438
+ function dropUnclosedBracket(t) {
439
+ for (const [open, close] of [
440
+ ['(', ')'],
441
+ ['[', ']'],
442
+ ]) {
443
+ const o = t.lastIndexOf(open)
444
+ if (o !== -1 && t.indexOf(close, o) === -1) t = t.slice(0, o)
445
+ }
446
+ return t
447
+ }
448
+
436
449
  // Derive a short Linear issue title from a task's full text: the first sentence,
437
450
  // falling back to the first `max` chars at a word boundary. A paragraph-length
438
451
  // task keeps its full text as the issue *description* (see the projection); this
@@ -457,8 +470,23 @@ function titleFromText(text, max = 100) {
457
470
  title = title.trim()
458
471
  if (title.length > max) {
459
472
  const cut = title.slice(0, max)
460
- const sp = cut.lastIndexOf(' ')
461
- title = (sp > 40 ? cut.slice(0, sp) : cut).trim()
473
+ // Prefer the last clause boundary before the limit — a terminator/separator
474
+ // to cut AFTER (`.`/`;`/`:`…) or a dash to cut BEFORE over a bare word break.
475
+ const minCut = Math.floor(max * 0.4)
476
+ let boundary = -1
477
+ let mm
478
+ const after = /[.!?;:](?=\s|$)/g
479
+ while ((mm = after.exec(cut)) !== null) boundary = Math.max(boundary, mm.index + 1)
480
+ const before = /\s[—–]/g
481
+ while ((mm = before.exec(cut)) !== null) boundary = Math.max(boundary, mm.index)
482
+ let t
483
+ if (boundary > minCut) {
484
+ t = cut.slice(0, boundary)
485
+ } else {
486
+ const sp = cut.lastIndexOf(' ')
487
+ t = sp > 40 ? cut.slice(0, sp) : cut
488
+ }
489
+ title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
462
490
  }
463
491
  return title
464
492
  }
@@ -36,6 +36,29 @@ function cleanLogicalLine(text) {
36
36
  const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
37
37
  // Structural blocks we never reflow.
38
38
  const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
39
+ // A line that is its own structural block — a blockquote (`>`) or a table/pipe
40
+ // row (`|`). A hard boundary a join must never reach across (else the markers get
41
+ // absorbed into the preceding paragraph as literal text); copied verbatim.
42
+ const STRUCT_LINE_RE = /^[ \t]*[>|]/
43
+ // A fenced-code delimiter.
44
+ const FENCE_RE = /^[ \t]*```/
45
+
46
+ // A structural fingerprint of a document: the count of block-marker lines that a
47
+ // safe reflow must leave untouched. Used as a self-check — if sanitising changes
48
+ // any of these, the reflow corrupted structure (a blockquote absorbed, a fence
49
+ // eaten, a bullet collapsed) and the write is refused. Cheap insurance against the
50
+ // next corruption class, not just the ones we special-case.
51
+ function structuralSignature(text) {
52
+ let struct = 0 // blockquote or pipe-row lines
53
+ let fence = 0
54
+ let bullets = 0
55
+ for (const l of String(text).split('\n')) {
56
+ if (STRUCT_LINE_RE.test(l)) struct++
57
+ if (FENCE_RE.test(l)) fence++
58
+ if (BULLET_RE.test(l)) bullets++
59
+ }
60
+ return `${struct}|${fence}|${bullets}`
61
+ }
39
62
  // A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
40
63
  // dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
41
64
  // inline `code|span` doesn't make us treat a whole list as an untouchable table.
@@ -99,21 +122,28 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
99
122
  while (i < lines.length) {
100
123
  const line = lines[i]
101
124
  // Fenced code — copy verbatim through the closing fence.
102
- if (/^[ \t]*```/.test(line)) {
125
+ if (FENCE_RE.test(line)) {
103
126
  out.push(line)
104
127
  i++
105
- while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
128
+ while (i < lines.length && !FENCE_RE.test(lines[i])) out.push(lines[i++])
106
129
  if (i < lines.length) out.push(lines[i++])
107
130
  continue
108
131
  }
132
+ // Blockquote / table row — copy the whole run verbatim. A hard boundary: a
133
+ // join must never reach across it, so it also terminates any preceding block.
134
+ if (STRUCT_LINE_RE.test(line)) {
135
+ while (i < lines.length && STRUCT_LINE_RE.test(lines[i])) out.push(lines[i++])
136
+ continue
137
+ }
109
138
  if (!line.trim()) {
110
139
  out.push(line)
111
140
  i++
112
141
  continue
113
142
  }
114
- // Gather a block of consecutive non-blank, non-fence lines.
143
+ // Gather a block of consecutive non-blank lines, stopping at any structural
144
+ // boundary (fence, blockquote, table row) so a reflow can't absorb one.
115
145
  let j = i
116
- while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
146
+ while (j < lines.length && lines[j].trim() && !FENCE_RE.test(lines[j]) && !STRUCT_LINE_RE.test(lines[j])) j++
117
147
  const block = lines.slice(i, j)
118
148
  i = j
119
149
 
@@ -137,7 +167,13 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
137
167
  }
138
168
  }
139
169
  const result = out.join('\n')
170
+ // Self-check: a safe reflow never changes the count of structural markers. If it
171
+ // did, we corrupted something (a blockquote, fence, or bullet) — refuse the write
172
+ // and hand back the original untouched, flagged for the caller to report.
173
+ if (result !== src && structuralSignature(result) !== structuralSignature(src)) {
174
+ return { text: src, changed: false, fixes: 0, refused: true }
175
+ }
140
176
  return { text: result, changed: result !== src, fixes }
141
177
  }
142
178
 
143
- module.exports = { sanitizeSpecMarkdown, hasStraddle }
179
+ module.exports = { sanitizeSpecMarkdown, hasStraddle, structuralSignature }