@skitterbyte/skitterspec-linear 8.0.1 → 8.0.3

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.3",
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",
@@ -15,7 +15,8 @@
15
15
  "author": "Reuben Greaves",
16
16
  "type": "commonjs",
17
17
  "bin": {
18
- "skitterspec-linear": "bin/skitterspec-linear.js"
18
+ "skitterspec-linear": "bin/skitterspec-linear.js",
19
+ "skitterspec": "bin/skitterspec-linear.js"
19
20
  },
20
21
  "files": [
21
22
  "bin",
Binary file
@@ -126,11 +126,13 @@ function joinOpenSpans(text) {
126
126
  const c = text[i]
127
127
  if (c === '\n') {
128
128
  if (!code && (bold || italic || link)) {
129
- // Skip the continuation's indentation, then join. A word wrapped at a
130
- // hyphen (`state-entry-with-`⏎`assignment`) is one compound join TIGHT
131
- // (no space) so we don't corrupt it; otherwise a single space.
129
+ // Skip the continuation's indentation AND any blockquote marker (`>`)
130
+ // a span wrapped across two `> …` lines is one quoted run, so the join
131
+ // must drop the continuation `>` rather than swallow it into the text.
132
+ // Then join: a word wrapped at a hyphen (`state-entry-with-`⏎`assignment`)
133
+ // is one compound — join TIGHT (no space); otherwise a single space.
132
134
  let j = i + 1
133
- while (j < text.length && (text[j] === ' ' || text[j] === '\t')) j++
135
+ while (j < text.length && (text[j] === ' ' || text[j] === '\t' || text[j] === '>')) j++
134
136
  const softHyphen = text[i - 1] === '-' && /\w/.test(text[i - 2] || '') && /\w/.test(text[j] || '')
135
137
  if (!softHyphen) out.push(' ')
136
138
  i = j - 1
@@ -433,6 +435,19 @@ function remoteWorkflowState(project, config) {
433
435
  return name != null ? bucketForState(name, config) : null
434
436
  }
435
437
 
438
+ // Trim a trailing parenthetical whose opener has no matching close — so a cut
439
+ // title never ends on a dangling `(`/`[`.
440
+ function dropUnclosedBracket(t) {
441
+ for (const [open, close] of [
442
+ ['(', ')'],
443
+ ['[', ']'],
444
+ ]) {
445
+ const o = t.lastIndexOf(open)
446
+ if (o !== -1 && t.indexOf(close, o) === -1) t = t.slice(0, o)
447
+ }
448
+ return t
449
+ }
450
+
436
451
  // Derive a short Linear issue title from a task's full text: the first sentence,
437
452
  // falling back to the first `max` chars at a word boundary. A paragraph-length
438
453
  // task keeps its full text as the issue *description* (see the projection); this
@@ -457,8 +472,23 @@ function titleFromText(text, max = 100) {
457
472
  title = title.trim()
458
473
  if (title.length > max) {
459
474
  const cut = title.slice(0, max)
460
- const sp = cut.lastIndexOf(' ')
461
- title = (sp > 40 ? cut.slice(0, sp) : cut).trim()
475
+ // Prefer the last clause boundary before the limit — a terminator/separator
476
+ // to cut AFTER (`.`/`;`/`:`…) or a dash to cut BEFORE over a bare word break.
477
+ const minCut = Math.floor(max * 0.4)
478
+ let boundary = -1
479
+ let mm
480
+ const after = /[.!?;:](?=\s|$)/g
481
+ while ((mm = after.exec(cut)) !== null) boundary = Math.max(boundary, mm.index + 1)
482
+ const before = /\s[—–]/g
483
+ while ((mm = before.exec(cut)) !== null) boundary = Math.max(boundary, mm.index)
484
+ let t
485
+ if (boundary > minCut) {
486
+ t = cut.slice(0, boundary)
487
+ } else {
488
+ const sp = cut.lastIndexOf(' ')
489
+ t = sp > 40 ? cut.slice(0, sp) : cut
490
+ }
491
+ title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
462
492
  }
463
493
  return title
464
494
  }
@@ -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 }